From 898f61c92ad91cb68bd5df55b7cb450a2a4feec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hask=C5=8D?= Date: Tue, 13 Oct 2026 09:35:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(file-size):=20=E2=9C=A8=20Add=20FileSize,?= =?UTF-8?q?=20FileSizeUnit=20and=20PositiveInteger=20value=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 25 +-- docs/.vitepress/config.mts | 9 + docs/index.md | 3 +- docs/reference/file-size-unit.md | 63 +++++++ docs/reference/file-size.md | 84 +++++++++ docs/reference/index.md | 6 + docs/reference/positive-integer.md | 62 +++++++ src/value-objects/PositiveInteger.ts | 16 ++ src/value-objects/file-size/FileSize.ts | 64 +++++++ src/value-objects/file-size/FileSizeUnit.ts | 77 +++++++++ src/value-objects/file-size/index.ts | 2 + src/value-objects/index.ts | 2 + tests/value-objects/FileSize.spec.ts | 159 ++++++++++++++++++ tests/value-objects/FileSizeUnit.spec.ts | 85 ++++++++++ tests/value-objects/PositiveInteger.spec.ts | 84 +++++++++ tests/value-objects/SubclassFactories.spec.ts | 13 ++ 16 files changed, 741 insertions(+), 13 deletions(-) create mode 100644 docs/reference/file-size-unit.md create mode 100644 docs/reference/file-size.md create mode 100644 docs/reference/positive-integer.md create mode 100644 src/value-objects/PositiveInteger.ts create mode 100644 src/value-objects/file-size/FileSize.ts create mode 100644 src/value-objects/file-size/FileSizeUnit.ts create mode 100644 src/value-objects/file-size/index.ts create mode 100644 tests/value-objects/FileSize.spec.ts create mode 100644 tests/value-objects/FileSizeUnit.spec.ts create mode 100644 tests/value-objects/PositiveInteger.spec.ts diff --git a/README.md b/README.md index 205e7ad..c3a4138 100644 --- a/README.md +++ b/README.md @@ -122,18 +122,19 @@ This is the main idea behind the library: use the provided Value Objects where t ## Available Value Objects -| Category | Classes | What they give you | -| ----------- | ------------------------------------------------------------- | ----------------------------------------------------------------- | -| Base | `ValueObject`, `NullObject`, `Enum` | Immutability, equality by value, safe absence | -| Strings | `StringValueObject`, `Password`, `Email`, `Color` | Length, format and emptiness rules | -| Numbers | `NumberValueObject`, `Integer`, `PositiveNumber` | Range and integer guarantees | -| Money | `Money`, `Currency` | Exact arithmetic in minor units, no floating point errors | -| Identifiers | `ShortId`, `UUID` | Generated and validated identifiers | -| Date & time | `Timestamp`, `CalendarDay`, `Hour`, `Duration`, `MonthOfYear` | Arithmetic and comparison without hand-rolled `Date` code | -| Coordinates | `Latitude`, `Longitude`, `Coordinates` | Range-checked geographic points | -| Hashes | `MD5Hash`, `SHA256Hash`, `SHA512Hash` | Validated digests | -| Media | `Media` | Typed media references | -| Collections | `UniqueObjectArray` | Immutable collection without duplicates, by Value Object equality | +| Category | Classes | What they give you | +| ----------- | ------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Base | `ValueObject`, `NullObject`, `Enum` | Immutability, equality by value, safe absence | +| Strings | `StringValueObject`, `Password`, `Email`, `Color` | Length, format and emptiness rules | +| Numbers | `NumberValueObject`, `Integer`, `PositiveInteger`, `PositiveNumber` | Range and integer guarantees | +| Money | `Money`, `Currency` | Exact arithmetic in minor units, no floating point errors | +| File size | `FileSize`, `FileSizeUnit` | Bytes with SI and IEC units, parsing and unit conversion | +| Identifiers | `ShortId`, `UUID` | Generated and validated identifiers | +| Date & time | `Timestamp`, `CalendarDay`, `Hour`, `Duration`, `MonthOfYear` | Arithmetic and comparison without hand-rolled `Date` code | +| Coordinates | `Latitude`, `Longitude`, `Coordinates` | Range-checked geographic points | +| Hashes | `MD5Hash`, `SHA256Hash`, `SHA512Hash` | Validated digests | +| Media | `Media` | Typed media references | +| Collections | `UniqueObjectArray` | Immutable collection without duplicates, by Value Object equality | Every class has its own page in the [API reference](https://haskou.github.io/value-objects/reference/). diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 992a37a..fcb4747 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -57,6 +57,7 @@ const referenceSidebar = [ items: [ { text: 'NumberValueObject', link: '/reference/number-value-object' }, { text: 'Integer', link: '/reference/integer' }, + { text: 'PositiveInteger', link: '/reference/positive-integer' }, { text: 'PositiveNumber', link: '/reference/positive-number' }, ], }, @@ -76,6 +77,14 @@ const referenceSidebar = [ { text: 'Currency', link: '/reference/currency' }, ], }, + { + text: 'File size', + collapsed: true, + items: [ + { text: 'FileSize', link: '/reference/file-size' }, + { text: 'FileSizeUnit', link: '/reference/file-size-unit' }, + ], + }, { text: 'Time', collapsed: true, diff --git a/docs/index.md b/docs/index.md index 151d18a..408527a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,9 +35,10 @@ yarn add @haskou/value-objects | Section | Contents | | --- | --- | | [Base objects](/reference/value-object) | `ValueObject`, `NullObject`, `Enum` | -| [Strings and numbers](/reference/string-value-object) | `StringValueObject`, `Email`, `Color`, `Password`, `NumberValueObject`, `Integer`, `PositiveNumber` | +| [Strings and numbers](/reference/string-value-object) | `StringValueObject`, `Email`, `Color`, `Password`, `NumberValueObject`, `Integer`, `PositiveInteger`, `PositiveNumber` | | [Identifiers](/reference/uuid) | `UUID`, `ShortId` | | [Money](/reference/money) | `Money`, `Currency` | +| [File size](/reference/file-size) | `FileSize`, `FileSizeUnit` | | [Time](/reference/timestamp) | `Timestamp`, `TimestampInterval`, `CalendarDay`, `Duration`, `Hour`, `Day`, `Year`, `Month`, `MonthOfYear`, `DayOfWeek` | | [Coordinates](/reference/coordinates) | `Latitude`, `Longitude`, `Coordinates` | | [Hashes](/reference/hash) | `Hash`, `MD5Hash`, `SHA256Hash`, `SHA512Hash` | diff --git a/docs/reference/file-size-unit.md b/docs/reference/file-size-unit.md new file mode 100644 index 0000000..ef0dab3 --- /dev/null +++ b/docs/reference/file-size-unit.md @@ -0,0 +1,63 @@ +--- +title: FileSizeUnit +description: Enum value object for file size units, SI and IEC. +--- + +# `FileSizeUnit` + +Enum value object for file size units, SI and IEC. + +## Import + +```typescript +import { FileSizeUnit } from '@haskou/value-objects'; +``` + +## Signature + +```typescript +class FileSizeUnit extends Enum +``` + +## Constructor + +```typescript +constructor(value: string) +``` + +## Values + +| Standard | Base | Units | +| --- | --- | --- | +| Bytes | 1 | `BYTE` (`B`) | +| SI | 1000 | `KILOBYTE` (`kB`), `MEGABYTE` (`MB`), `GIGABYTE` (`GB`), `TERABYTE` (`TB`), `PETABYTE` (`PB`) | +| IEC | 1024 | `KIBIBYTE` (`KiB`), `MEBIBYTE` (`MiB`), `GIBIBYTE` (`GiB`), `TEBIBYTE` (`TiB`), `PEBIBYTE` (`PiB`) | + +## Members + +| Member | Description | +| --- | --- | +| `static SI` | Bytes plus the SI units, from smallest to largest. | +| `static IEC` | Bytes plus the IEC units, from smallest to largest. | +| `getBytes()` | Returns the bytes in one unit as `PositiveInteger`. | +| `getValues()` | Returns the accepted symbols. | + +## Throws + +- `ValueNotInEnumError` when the symbol is not one of the values above. Symbols are case-sensitive. + +## Example + +```typescript +import { FileSizeUnit } from '@haskou/value-objects'; + +FileSizeUnit.MEBIBYTE.getBytes().valueOf(); // 1048576 +new FileSizeUnit('MB').isEqual(FileSizeUnit.MEGABYTE); // true +new FileSizeUnit('KB'); // throws ValueNotInEnumError +``` + +## Related + +- [`FileSize`](/reference/file-size) +- [`Enum`](/reference/enum) +- [Reference overview](/reference/) diff --git a/docs/reference/file-size.md b/docs/reference/file-size.md new file mode 100644 index 0000000..1b77c4f --- /dev/null +++ b/docs/reference/file-size.md @@ -0,0 +1,84 @@ +--- +title: FileSize +description: File size value object stored in bytes. +--- + +# `FileSize` + +File size value object stored in bytes. + +## Import + +```typescript +import { FileSize, FileSizeUnit } from '@haskou/value-objects'; +``` + +## Signature + +```typescript +class FileSize extends PositiveInteger +``` + +## Constructor + +```typescript +constructor(bytes: number | NumberValueObject) +``` + +## Validation + +Accepts integers greater than or equal to `0`. + +## Methods + +| Method | Description | +| --- | --- | +| `static fromBytes(bytes)` | Creates a size from bytes. | +| `static from(value, unit)` | Creates a size from a value in a `FileSizeUnit`, rounded to the nearest byte. | +| `static parse(text)` | Parses text such as `1.5 MiB` or `2MB`. | +| `toBytes()` | Returns the bytes as `PositiveInteger`. | +| `to(unit)` | Returns the size in `unit` as `NumberValueObject`, without rounding. | +| `toHumanReadable(units)` | Returns `{ value, unit }` using the largest unit of `units` that fits. | +| `add(other)` | Returns the sum as `FileSize`. | +| `subtract(other)` | Returns the difference as `FileSize`. | + +Comparison and the rest of the arithmetic are inherited from `NumberValueObject`. + +## Throws + +This class can throw: + +- `InvalidPositiveNumberError` +- `InvalidIntegerError` +- `InvalidNumberError` +- `InvalidFormatError` from `parse` when the text is not ` ` +- `ValueNotInEnumError` from `parse` when the unit is unknown + +## Example + +```typescript +import { FileSize, FileSizeUnit } from '@haskou/value-objects'; + +const size = FileSize.from(1.5, FileSizeUnit.MEBIBYTE); + +size.valueOf(); // 1572864 +size.to(FileSizeUnit.KIBIBYTE).valueOf(); // 1536 + +const { value, unit } = size.toHumanReadable(FileSizeUnit.IEC); +`${value} ${unit}`; // '1.5 MiB' + +FileSize.parse('2 MB').valueOf(); // 2000000 +``` + +## Notes + +- `toHumanReadable` does not format or round. Format `value` where you present it, for example with `Intl.NumberFormat`. +- SI units use base 1000 (`kB`, `MB`) and IEC units use base 1024 (`KiB`, `MiB`). `KB` is not a valid unit. +- `multiply` and `divide` do not round: a non-integer result throws `InvalidIntegerError`. +- Factories use the calling class, so subclasses get their own type back. + +## Related + +- [`FileSizeUnit`](/reference/file-size-unit) +- [`PositiveInteger`](/reference/positive-integer) +- [Reference overview](/reference/) diff --git a/docs/reference/index.md b/docs/reference/index.md index d706565..61fb87a 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -20,6 +20,7 @@ Each exported class has its own page. - [`Password`](/reference/password) - [`NumberValueObject`](/reference/number-value-object) - [`Integer`](/reference/integer) +- [`PositiveInteger`](/reference/positive-integer) - [`PositiveNumber`](/reference/positive-number) - [`Email`](/reference/email) - [`Color`](/reference/color) @@ -42,6 +43,11 @@ Each exported class has its own page. - [`Money`](/reference/money) - [`Currency`](/reference/currency) +## File size + +- [`FileSize`](/reference/file-size) +- [`FileSizeUnit`](/reference/file-size-unit) + ## Time - [`CalendarDay`](/reference/calendar-day) diff --git a/docs/reference/positive-integer.md b/docs/reference/positive-integer.md new file mode 100644 index 0000000..6735c1e --- /dev/null +++ b/docs/reference/positive-integer.md @@ -0,0 +1,62 @@ +--- +title: PositiveInteger +description: Integer value object constrained to non-negative values. +--- + +# `PositiveInteger` + +Integer value object constrained to non-negative values. + +## Import + +```typescript +import { PositiveInteger } from '@haskou/value-objects'; +``` + +## Signature + +```typescript +class PositiveInteger extends Integer +``` + +## Constructor + +```typescript +constructor(value: number | NumberValueObject) +``` + +## Validation + +Accepts integers greater than or equal to `0`. + +## Throws + +This class can throw: + +- `InvalidPositiveNumberError` +- `InvalidIntegerError` +- `InvalidNumberError` + +## Example + +```typescript +import { PositiveInteger } from '@haskou/value-objects'; + +const zero = new PositiveInteger(0); +const items = new PositiveInteger(3); + +items.subtract(1).valueOf(); // 2 +items.subtract(4); // throws InvalidPositiveNumberError +items.divide(2); // throws InvalidIntegerError +``` + +## Notes + +- Like `PositiveNumber`, it accepts `0`. +- Arithmetic is inherited from `NumberValueObject` and goes through the constructor, so a result that is negative or not an integer throws. + +## Related + +- [`Integer`](/reference/integer) +- [`PositiveNumber`](/reference/positive-number) +- [Reference overview](/reference/) diff --git a/src/value-objects/PositiveInteger.ts b/src/value-objects/PositiveInteger.ts new file mode 100644 index 0000000..c23c37d --- /dev/null +++ b/src/value-objects/PositiveInteger.ts @@ -0,0 +1,16 @@ +import { InvalidPositiveNumberError } from '../errors/InvalidPositiveNumberError'; +import { assert } from '../patterns/Assert'; +import { Integer } from './Integer'; +import { NullObject } from './NullObject'; +import { NumberValueObject } from './NumberValueObject'; + +export class PositiveInteger extends Integer { + constructor(value: number | NumberValueObject) { + super(value?.valueOf()); + + if (NullObject.isNullObject(this)) { + return this; + } + assert(this.value >= 0, new InvalidPositiveNumberError()); + } +} diff --git a/src/value-objects/file-size/FileSize.ts b/src/value-objects/file-size/FileSize.ts new file mode 100644 index 0000000..17f1247 --- /dev/null +++ b/src/value-objects/file-size/FileSize.ts @@ -0,0 +1,64 @@ +import { InvalidFormatError } from '../../errors/InvalidFormatError'; +import { assert } from '../../patterns/Assert'; +import { NumberValueObject } from '../NumberValueObject'; +import { PositiveInteger } from '../PositiveInteger'; +import { FileSizeUnit, FileSizeUnits } from './FileSizeUnit'; + +export interface HumanReadableFileSize { + value: NumberValueObject; + unit: FileSizeUnit; +} + +export class FileSize extends PositiveInteger { + private static readonly FORMAT = /^(\d+(?:\.\d+)?)\s*(\S+)$/; + + public static fromBytes( + this: new (bytes: number | NumberValueObject) => T, + bytes: number | NumberValueObject, + ): T { + return new this(bytes); + } + + public static from( + this: new (bytes: number | NumberValueObject) => T, + value: number | NumberValueObject, + unit: FileSizeUnit, + ): T { + return new this(Math.round(value.valueOf() * unit.getBytes().valueOf())); + } + + public static parse( + this: new (bytes: number | NumberValueObject) => T, + text: string, + ): T { + const match = FileSize.FORMAT.exec(text.trim()); + + assert(match, new InvalidFormatError(text)); + + const [, value, symbol] = match; + const unit = new FileSizeUnit(symbol); + + return new this(Math.round(Number(value) * unit.getBytes().valueOf())); + } + + public toBytes(): PositiveInteger { + return new PositiveInteger(this.value); + } + + public to(unit: FileSizeUnit): NumberValueObject { + return new NumberValueObject(this.value / unit.getBytes().valueOf()); + } + + public toHumanReadable(units: FileSizeUnits): HumanReadableFileSize { + const [smallest, ...rest] = [...units].sort((a, b) => + a.getBytes().isLessThan(b.getBytes()) ? -1 : 1, + ); + const unit = rest.reduce( + (fitting, candidate) => + this.isGreaterOrEqualThan(candidate.getBytes()) ? candidate : fitting, + smallest, + ); + + return { unit, value: this.to(unit) }; + } +} diff --git a/src/value-objects/file-size/FileSizeUnit.ts b/src/value-objects/file-size/FileSizeUnit.ts new file mode 100644 index 0000000..6f7003f --- /dev/null +++ b/src/value-objects/file-size/FileSizeUnit.ts @@ -0,0 +1,77 @@ +import { Enum } from '../Enum'; +import { PositiveInteger } from '../PositiveInteger'; + +export enum EFileSizeUnit { + BYTE = 'B', + KILOBYTE = 'kB', + MEGABYTE = 'MB', + GIGABYTE = 'GB', + TERABYTE = 'TB', + PETABYTE = 'PB', + KIBIBYTE = 'KiB', + MEBIBYTE = 'MiB', + GIBIBYTE = 'GiB', + TEBIBYTE = 'TiB', + PEBIBYTE = 'PiB', +} + +const SI_BASE = 1000; +const IEC_BASE = 1024; + +export const fileSizeUnitToBytesMap: Record = { + [EFileSizeUnit.BYTE]: 1, + [EFileSizeUnit.GIBIBYTE]: IEC_BASE ** 3, + [EFileSizeUnit.GIGABYTE]: SI_BASE ** 3, + [EFileSizeUnit.KIBIBYTE]: IEC_BASE ** 1, + [EFileSizeUnit.KILOBYTE]: SI_BASE ** 1, + [EFileSizeUnit.MEBIBYTE]: IEC_BASE ** 2, + [EFileSizeUnit.MEGABYTE]: SI_BASE ** 2, + [EFileSizeUnit.PEBIBYTE]: IEC_BASE ** 5, + [EFileSizeUnit.PETABYTE]: SI_BASE ** 5, + [EFileSizeUnit.TEBIBYTE]: IEC_BASE ** 4, + [EFileSizeUnit.TERABYTE]: SI_BASE ** 4, +}; + +export type FileSizeUnits = readonly [FileSizeUnit, ...FileSizeUnit[]]; + +export class FileSizeUnit extends Enum { + public static readonly BYTE = new FileSizeUnit(EFileSizeUnit.BYTE); + public static readonly KILOBYTE = new FileSizeUnit(EFileSizeUnit.KILOBYTE); + public static readonly MEGABYTE = new FileSizeUnit(EFileSizeUnit.MEGABYTE); + public static readonly GIGABYTE = new FileSizeUnit(EFileSizeUnit.GIGABYTE); + public static readonly TERABYTE = new FileSizeUnit(EFileSizeUnit.TERABYTE); + public static readonly PETABYTE = new FileSizeUnit(EFileSizeUnit.PETABYTE); + public static readonly KIBIBYTE = new FileSizeUnit(EFileSizeUnit.KIBIBYTE); + public static readonly MEBIBYTE = new FileSizeUnit(EFileSizeUnit.MEBIBYTE); + public static readonly GIBIBYTE = new FileSizeUnit(EFileSizeUnit.GIBIBYTE); + public static readonly TEBIBYTE = new FileSizeUnit(EFileSizeUnit.TEBIBYTE); + public static readonly PEBIBYTE = new FileSizeUnit(EFileSizeUnit.PEBIBYTE); + + public static readonly SI: FileSizeUnits = [ + FileSizeUnit.BYTE, + FileSizeUnit.KILOBYTE, + FileSizeUnit.MEGABYTE, + FileSizeUnit.GIGABYTE, + FileSizeUnit.TERABYTE, + FileSizeUnit.PETABYTE, + ]; + + public static readonly IEC: FileSizeUnits = [ + FileSizeUnit.BYTE, + FileSizeUnit.KIBIBYTE, + FileSizeUnit.MEBIBYTE, + FileSizeUnit.GIBIBYTE, + FileSizeUnit.TEBIBYTE, + FileSizeUnit.PEBIBYTE, + ]; + + public getValues(): string[] { + return Object.values(EFileSizeUnit); + } + + public getBytes(): PositiveInteger { + return new PositiveInteger( + fileSizeUnitToBytesMap[this.value as EFileSizeUnit], + ); + } +} diff --git a/src/value-objects/file-size/index.ts b/src/value-objects/file-size/index.ts new file mode 100644 index 0000000..d4f14a1 --- /dev/null +++ b/src/value-objects/file-size/index.ts @@ -0,0 +1,2 @@ +export * from './FileSize'; +export * from './FileSizeUnit'; diff --git a/src/value-objects/index.ts b/src/value-objects/index.ts index c5c2069..297c5cd 100644 --- a/src/value-objects/index.ts +++ b/src/value-objects/index.ts @@ -1,4 +1,5 @@ export * from './coordinates'; +export * from './file-size'; export * from './hashes'; export * from './ids'; export * from './media'; @@ -11,6 +12,7 @@ export * from './Integer'; export * from './NumberValueObject'; export * from './Password'; export * from './PositiveNumber'; +export * from './PositiveInteger'; export * from './StringValueObject'; export * from './NullObject'; export * from './UniqueObjectArray'; diff --git a/tests/value-objects/FileSize.spec.ts b/tests/value-objects/FileSize.spec.ts new file mode 100644 index 0000000..52563a7 --- /dev/null +++ b/tests/value-objects/FileSize.spec.ts @@ -0,0 +1,159 @@ +import { + FileSize, + FileSizeUnit, + InvalidFormatError, + InvalidIntegerError, + InvalidPositiveNumberError, + NullObject, + PositiveInteger, + ValueNotInEnumError, +} from '../../src'; + +describe('FileSize', () => { + it('should be created from bytes', () => { + expect(new FileSize(1024).valueOf()).toBe(1024); + expect(FileSize.fromBytes(0).valueOf()).toBe(0); + }); + + it('should reject negative and fractional bytes', () => { + expect(() => new FileSize(-1)).toThrow(InvalidPositiveNumberError); + expect(() => new FileSize(0.5)).toThrow(InvalidIntegerError); + }); + + it('should convert from SI and IEC units', () => { + expect(FileSize.from(1.5, FileSizeUnit.KILOBYTE).valueOf()).toBe(1500); + expect(FileSize.from(1.5, FileSizeUnit.KIBIBYTE).valueOf()).toBe(1536); + expect(FileSize.from(2, FileSizeUnit.GIBIBYTE).valueOf()).toBe(2 ** 31); + }); + + it('should round to the nearest byte when converting from a unit', () => { + expect(FileSize.from(0.4, FileSizeUnit.BYTE).valueOf()).toBe(0); + expect(FileSize.from(0.6, FileSizeUnit.BYTE).valueOf()).toBe(1); + }); + + it('should convert to a unit without rounding', () => { + expect(new FileSize(1536).to(FileSizeUnit.KIBIBYTE).valueOf()).toBe(1.5); + expect(new FileSize(1500).to(FileSizeUnit.KILOBYTE).valueOf()).toBe(1.5); + }); + + describe('parse', () => { + it('should parse value and unit with or without space', () => { + expect(FileSize.parse('1.5 MiB').valueOf()).toBe(1572864); + expect(FileSize.parse('2MB').valueOf()).toBe(2_000_000); + expect(FileSize.parse(' 10 B ').valueOf()).toBe(10); + }); + + it('should throw InvalidFormatError for malformed text', () => { + expect(() => FileSize.parse('MiB')).toThrow(InvalidFormatError); + expect(() => FileSize.parse('-1 MB')).toThrow(InvalidFormatError); + expect(() => FileSize.parse('')).toThrow(InvalidFormatError); + }); + + it('should throw ValueNotInEnumError for an unknown unit', () => { + expect(() => FileSize.parse('1 XB')).toThrow(ValueNotInEnumError); + expect(() => FileSize.parse('1 KB')).toThrow(ValueNotInEnumError); + }); + }); + + describe('arithmetic', () => { + it('should add and subtract returning FileSize', () => { + const sum = new FileSize(1000).add(new FileSize(24)); + const diff = new FileSize(1000).subtract(new FileSize(400)); + + expect(sum).toBeInstanceOf(FileSize); + expect(sum.valueOf()).toBe(1024); + expect(diff).toBeInstanceOf(FileSize); + expect(diff.valueOf()).toBe(600); + }); + + it('should throw when subtracting below zero', () => { + expect(() => new FileSize(1).subtract(new FileSize(2))).toThrow( + InvalidPositiveNumberError, + ); + }); + }); + + describe('toHumanReadable', () => { + it('should pick the largest fitting IEC unit', () => { + const result = new FileSize(1536).toHumanReadable(FileSizeUnit.IEC); + + expect(result.unit.isEqual(FileSizeUnit.KIBIBYTE)).toBe(true); + expect(result.value.valueOf()).toBe(1.5); + }); + + it('should pick the largest fitting SI unit', () => { + const result = new FileSize(2_500_000).toHumanReadable(FileSizeUnit.SI); + + expect(result.unit.isEqual(FileSizeUnit.MEGABYTE)).toBe(true); + expect(result.value.valueOf()).toBe(2.5); + }); + + it('should use the unit exactly at its boundary', () => { + const result = new FileSize(1024).toHumanReadable(FileSizeUnit.IEC); + + expect(result.unit.isEqual(FileSizeUnit.KIBIBYTE)).toBe(true); + expect(result.value.valueOf()).toBe(1); + }); + + it('should fall back to the smallest unit below it', () => { + const result = new FileSize(1023).toHumanReadable(FileSizeUnit.IEC); + + expect(result.unit.isEqual(FileSizeUnit.BYTE)).toBe(true); + expect(result.value.valueOf()).toBe(1023); + expect( + new FileSize(0) + .toHumanReadable(FileSizeUnit.SI) + .unit.isEqual(FileSizeUnit.BYTE), + ).toBe(true); + }); + + it('should not depend on the order of the given units', () => { + const result = new FileSize(5_000_000).toHumanReadable([ + FileSizeUnit.MEGABYTE, + FileSizeUnit.BYTE, + FileSizeUnit.KILOBYTE, + ]); + + expect(result.unit.isEqual(FileSizeUnit.MEGABYTE)).toBe(true); + }); + }); + + it('should return a NullObject when a Nullish is received', () => { + expect( + NullObject.isNullObject(new FileSize(null as unknown as number)), + ).toBe(true); + expect( + NullObject.isNullObject(new FileSize(undefined as unknown as number)), + ).toBe(true); + }); + + it('should be equal only to a FileSize with the same bytes', () => { + expect( + FileSize.from(1, FileSizeUnit.KIBIBYTE).isEqual(new FileSize(1024)), + ).toBe(true); + expect(new FileSize(1024).isEqual(new PositiveInteger(1024))).toBe(false); + }); + + it('should not mutate when operating', () => { + const original = new FileSize(100); + + original.add(new FileSize(1)); + + expect(original.valueOf()).toBe(100); + }); + + it('should expose its bytes as a PositiveInteger', () => { + const bytes = new FileSize(42).toBytes(); + + expect(bytes).toBeInstanceOf(PositiveInteger); + expect(bytes.valueOf()).toBe(42); + }); + + it('should convert every unit back to the same byte count', () => { + const unit = FileSizeUnit.PEBIBYTE; + const size = FileSize.from(1, unit); + + expect(size.valueOf()).toBe(2 ** 50); + expect(size.to(unit).valueOf()).toBe(1); + }); +}); diff --git a/tests/value-objects/FileSizeUnit.spec.ts b/tests/value-objects/FileSizeUnit.spec.ts new file mode 100644 index 0000000..1d403a0 --- /dev/null +++ b/tests/value-objects/FileSizeUnit.spec.ts @@ -0,0 +1,85 @@ +import { + Enum, + FileSizeUnit, + NullObject, + PositiveInteger, + ValueNotInEnumError, +} from '../../src'; + +describe('FileSizeUnit', () => { + it('should be an Enum', () => { + expect(FileSizeUnit.BYTE).toBeInstanceOf(Enum); + }); + + it('should reject symbols outside the enum', () => { + expect(() => new FileSizeUnit('bits')).toThrow(ValueNotInEnumError); + expect(() => new FileSizeUnit('KB')).toThrow(ValueNotInEnumError); + expect(() => new FileSizeUnit('mib')).toThrow(ValueNotInEnumError); + }); + + it('should return a NullObject when a Nullish is received', () => { + expect( + NullObject.isNullObject(new FileSizeUnit(null as unknown as string)), + ).toBeTrue(); + }); + + it('should be equal when built from the same symbol', () => { + expect(new FileSizeUnit('MiB').isEqual(FileSizeUnit.MEBIBYTE)).toBeTrue(); + expect(FileSizeUnit.MEGABYTE.isEqual(FileSizeUnit.MEBIBYTE)).toBeFalse(); + }); + + describe('getBytes', () => { + it.each([ + [FileSizeUnit.BYTE, 1], + [FileSizeUnit.KILOBYTE, 1000], + [FileSizeUnit.MEGABYTE, 1000 ** 2], + [FileSizeUnit.GIGABYTE, 1000 ** 3], + [FileSizeUnit.TERABYTE, 1000 ** 4], + [FileSizeUnit.PETABYTE, 1000 ** 5], + [FileSizeUnit.KIBIBYTE, 1024], + [FileSizeUnit.MEBIBYTE, 1024 ** 2], + [FileSizeUnit.GIBIBYTE, 1024 ** 3], + [FileSizeUnit.TEBIBYTE, 1024 ** 4], + [FileSizeUnit.PEBIBYTE, 1024 ** 5], + ])('should return the bytes of %s', (unit, bytes) => { + const result = unit.getBytes(); + + expect(result).toBeInstanceOf(PositiveInteger); + expect(result.valueOf()).toBe(bytes); + }); + }); + + describe('unit sets', () => { + it('should list SI units from smallest to largest', () => { + expect(FileSizeUnit.SI.map((unit) => unit.valueOf())).toEqual([ + 'B', + 'kB', + 'MB', + 'GB', + 'TB', + 'PB', + ]); + }); + + it('should list IEC units from smallest to largest', () => { + expect(FileSizeUnit.IEC.map((unit) => unit.valueOf())).toEqual([ + 'B', + 'KiB', + 'MiB', + 'GiB', + 'TiB', + 'PiB', + ]); + }); + + it('should cover every enum value across both sets', () => { + const symbols = new Set( + [...FileSizeUnit.SI, ...FileSizeUnit.IEC].map((unit) => unit.valueOf()), + ); + + expect([...symbols].sort()).toEqual( + [...FileSizeUnit.BYTE.getValues()].sort(), + ); + }); + }); +}); diff --git a/tests/value-objects/PositiveInteger.spec.ts b/tests/value-objects/PositiveInteger.spec.ts new file mode 100644 index 0000000..703756e --- /dev/null +++ b/tests/value-objects/PositiveInteger.spec.ts @@ -0,0 +1,84 @@ +import { + Integer, + InvalidIntegerError, + InvalidNumberError, + InvalidPositiveNumberError, + NullObject, + PositiveInteger, +} from '../../src'; + +describe('PositiveInteger', () => { + describe('constructor', () => { + it('should return a NullObject when a Nullish is received', () => { + expect( + NullObject.isNullObject( + new PositiveInteger(undefined as unknown as number), + ), + ).toBeTrue(); + expect( + NullObject.isNullObject(new PositiveInteger(null as unknown as number)), + ).toBeTrue(); + }); + + it('should accept 0 and positive integers', () => { + expect(new PositiveInteger(0).valueOf()).toBe(0); + expect(new PositiveInteger(7).valueOf()).toBe(7); + }); + + it('should reject negative integers', () => { + expect(() => new PositiveInteger(-1)).toThrow(InvalidPositiveNumberError); + expect(() => new PositiveInteger(-1)).toThrow( + 'Value must be greater or equal than 0', + ); + }); + + it('should reject non-integer values', () => { + expect(() => new PositiveInteger(1.5)).toThrow(InvalidIntegerError); + expect(() => new PositiveInteger(-0.5)).toThrow(InvalidIntegerError); + }); + + it('should reject NaN and infinite values', () => { + expect(() => new PositiveInteger(NaN)).toThrow(InvalidNumberError); + expect(() => new PositiveInteger(Infinity)).toThrow(InvalidNumberError); + }); + + it('should accept another PositiveInteger', () => { + const original = new PositiveInteger(42); + const copy = new PositiveInteger(original); + + expect(copy.valueOf()).toBe(42); + expect(copy.isEqual(original)).toBeTrue(); + }); + }); + + describe('inheritance', () => { + it('should inherit from Integer', () => { + expect(new PositiveInteger(1)).toBeInstanceOf(Integer); + }); + + it('should not be equal to an Integer with the same value', () => { + expect(new PositiveInteger(1).isEqual(new Integer(1))).toBeFalse(); + }); + }); + + describe('arithmetic', () => { + it('should keep returning PositiveInteger', () => { + const result = new PositiveInteger(6).subtract(2).multiply(2); + + expect(result).toBeInstanceOf(PositiveInteger); + expect(result.valueOf()).toBe(8); + }); + + it('should throw when the result is negative', () => { + expect(() => new PositiveInteger(1).subtract(2)).toThrow( + InvalidPositiveNumberError, + ); + }); + + it('should throw when the result is not an integer', () => { + expect(() => new PositiveInteger(3).divide(2)).toThrow( + InvalidIntegerError, + ); + }); + }); +}); diff --git a/tests/value-objects/SubclassFactories.spec.ts b/tests/value-objects/SubclassFactories.spec.ts index 2a07597..25c5bbb 100644 --- a/tests/value-objects/SubclassFactories.spec.ts +++ b/tests/value-objects/SubclassFactories.spec.ts @@ -1,8 +1,10 @@ +import { FileSize, FileSizeUnit } from '../../src'; import { ShortId } from '../../src/value-objects/ids/ShortId'; import { UUID } from '../../src/value-objects/ids/UUID'; import { Duration } from '../../src/value-objects/time/Duration'; import { Timestamp } from '../../src/value-objects/time/Timestamp'; +class MyFileSize extends FileSize {} class MyUUID extends UUID {} class MyShortId extends ShortId {} class MyTimestamp extends Timestamp {} @@ -37,6 +39,17 @@ describe('Static factories on subclasses', () => { expect(MyDuration.fromMilliseconds(1)).toBeInstanceOf(MyDuration); }); + it('should return the subclass from FileSize factories', () => { + expect(MyFileSize.fromBytes(1)).toBeInstanceOf(MyFileSize); + expect(MyFileSize.from(1, FileSizeUnit.KIBIBYTE)).toBeInstanceOf( + MyFileSize, + ); + expect(MyFileSize.parse('1 MB')).toBeInstanceOf(MyFileSize); + expect( + MyFileSize.fromBytes(10).add(MyFileSize.fromBytes(5)), + ).toBeInstanceOf(MyFileSize); + }); + it('should keep returning the base class when called on it', () => { expect(UUID.generate().constructor).toBe(UUID); expect(Duration.fromDays(1).constructor).toBe(Duration);