| 1 | import { describe, it } from "@effect/vitest" |
| 2 | import { |
| 3 | assertFalse, |
| 4 | assertNone, |
| 5 | assertSome, |
| 6 | assertTrue, |
| 7 | deepStrictEqual, |
| 8 | strictEqual, |
| 9 | throws |
| 10 | } from "@effect/vitest/utils" |
| 11 | import { ByteSize, Equal, Hash, Option } from "effect" |
| 12 | import * as fc from "fast-check" |
| 13 | |
| 14 | const arb = fc.bigInt({ min: 0n, max: 10n ** 40n }).map(ByteSize.bytes) |
| 15 | |
| 16 | describe("ByteSize", () => { |
| 17 | it("decodes strict inputs and validates numeric values at runtime", () => { |
| 18 | deepStrictEqual(ByteSize.fromInputUnsafe("4MB"), ByteSize.megabytes(4)) |
| 19 | assertSome(ByteSize.fromInput("100kB"), ByteSize.kilobytes(100)) |
| 20 | assertSome(ByteSize.fromInput("2 mebibytes"), ByteSize.mebibytes(2)) |
| 21 | assertSome(ByteSize.fromInput("0B"), ByteSize.zero) |
| 22 | for (const input of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, -1n]) { |
| 23 | assertNone(ByteSize.fromInput(input)) |
| 24 | throws(() => ByteSize.fromInputUnsafe(input)) |
| 25 | } |
| 26 | }) |
| 27 | |
| 28 | it("constructs exact decimal and binary units", () => { |
| 29 | const decimal = [ |
| 30 | ByteSize.kilobytes, |
| 31 | ByteSize.megabytes, |
| 32 | ByteSize.gigabytes, |
| 33 | ByteSize.terabytes, |
| 34 | ByteSize.petabytes, |
| 35 | ByteSize.exabytes, |
| 36 | ByteSize.zettabytes, |
| 37 | ByteSize.yottabytes, |
| 38 | ByteSize.ronnabytes, |
| 39 | ByteSize.quettabytes |
| 40 | ] |
| 41 | const binary = [ |
| 42 | ByteSize.kibibytes, |
| 43 | ByteSize.mebibytes, |
| 44 | ByteSize.gibibytes, |
| 45 | ByteSize.tebibytes, |
| 46 | ByteSize.pebibytes, |
| 47 | ByteSize.exbibytes, |
| 48 | ByteSize.zebibytes, |
| 49 | ByteSize.yobibytes |
| 50 | ] |
| 51 | decimal.forEach((constructor, index) => { |
| 52 | strictEqual(ByteSize.toBigInt(constructor(1n)), 1_000n ** BigInt(index + 1)) |
| 53 | }) |
| 54 | binary.forEach((constructor, index) => { |
| 55 | strictEqual(ByteSize.toBigInt(constructor(1n)), 1_024n ** BigInt(index + 1)) |
| 56 | }) |
| 57 | }) |
| 58 | |
| 59 | it("validates primitive and unit constructor inputs", () => { |
| 60 | deepStrictEqual(ByteSize.bytes(-0), ByteSize.zero) |
| 61 | deepStrictEqual(ByteSize.bytes(0n), ByteSize.zero) |
| 62 | for (const value of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, NaN, Infinity, -Infinity]) { |
| 63 | throws(() => ByteSize.bytes(value)) |
| 64 | } |
| 65 | throws(() => ByteSize.bytes(-1n)) |
| 66 | deepStrictEqual(ByteSize.kilobytes(1.5), ByteSize.bytes(1500)) |
| 67 | deepStrictEqual(ByteSize.kibibytes(0.5), ByteSize.bytes(512)) |
| 68 | throws(() => ByteSize.kibibytes(0.1)) |
| 69 | }) |
| 70 | |
| 71 | it("parses strict SI and IEC strings exactly", () => { |
| 72 | const cases: ReadonlyArray<readonly [string, bigint]> = [ |
| 73 | ["0 B", 0n], |
| 74 | ["1 byte", 1n], |
| 75 | ["1500 bytes", 1500n], |
| 76 | ["1.5 kB", 1500n], |
| 77 | ["1.5KiB", 1536n], |
| 78 | ["0.001 kB", 1n], |
| 79 | ["2 megabytes", 2_000_000n], |
| 80 | ["2 mebibytes", 2_097_152n], |
| 81 | [" 9007199254740993 B ", 9_007_199_254_740_993n] |
| 82 | ] |
| 83 | for (const [input, expected] of cases) { |
| 84 | deepStrictEqual(ByteSize.fromStringUnsafe(input), ByteSize.bytes(expected)) |
| 85 | assertSome(ByteSize.fromString(input), ByteSize.bytes(expected)) |
| 86 | } |
| 87 | }) |
| 88 | |
| 89 | it("rejects ambiguous, negative, and fractional-byte strings", () => { |
| 90 | const invalid = [ |
| 91 | "", |
| 92 | "1", |
| 93 | "+1 B", |
| 94 | "-1 B", |
| 95 | "1e3 B", |
| 96 | "1_000 B", |
| 97 | "0.5 B", |
| 98 | "0.1 KiB", |
| 99 | "1 KB", |
| 100 | "1 mb", |
| 101 | "1 Mb", |
| 102 | "1 K", |
| 103 | "1 b", |
| 104 | "1 MiB trailing" |
| 105 | ] |
| 106 | for (const input of invalid) { |
| 107 | assertNone(ByteSize.fromString(input)) |
| 108 | throws(() => ByteSize.fromStringUnsafe(input)) |
| 109 | } |
| 110 | }) |
| 111 | |
| 112 | it("uses a branded bigint representation", () => { |
| 113 | const value = ByteSize.bytes(9_007_199_254_740_993n) |
| 114 | strictEqual(typeof value, "bigint") |
| 115 | strictEqual(String(value), "9007199254740993") |
| 116 | assertTrue(Equal.equals(value, ByteSize.bytes(9_007_199_254_740_993n))) |
| 117 | strictEqual(Hash.hash(value), Hash.hash(9_007_199_254_740_993n)) |
| 118 | assertTrue(ByteSize.isByteSize(value)) |
| 119 | assertTrue(ByteSize.isByteSize(0n)) |
| 120 | assertFalse(ByteSize.isByteSize(-1n)) |
| 121 | }) |
| 122 | |
| 123 | it("converts to numbers only within the safe-integer range", () => { |
| 124 | assertSome(ByteSize.toNumber(ByteSize.bytes(BigInt(Number.MAX_SAFE_INTEGER))), Number.MAX_SAFE_INTEGER) |
| 125 | assertNone(ByteSize.toNumber(ByteSize.bytes(BigInt(Number.MAX_SAFE_INTEGER) + 1n))) |
| 126 | throws(() => ByteSize.toNumberUnsafe(ByteSize.bytes(BigInt(Number.MAX_SAFE_INTEGER) + 1n))) |
| 127 | strictEqual(ByteSize.toUnit(ByteSize.kibibytes(3), "KiB"), 3) |
| 128 | }) |
| 129 | |
| 130 | it("supports ordering and checked arithmetic", () => { |
| 131 | const one = ByteSize.bytes(1) |
| 132 | const two = ByteSize.bytes(2) |
| 133 | const three = ByteSize.bytes(3) |
| 134 | strictEqual(ByteSize.Order(one, two), -1) |
| 135 | strictEqual(ByteSize.Order(two, two), 0) |
| 136 | strictEqual(ByteSize.Order(two, one), 1) |
| 137 | deepStrictEqual(ByteSize.sum(one, two), three) |
| 138 | assertSome(ByteSize.subtract(three, one), two) |
| 139 | assertNone(ByteSize.subtract(one, two)) |
| 140 | throws(() => ByteSize.subtractUnsafe(one, two)) |
| 141 | assertSome(ByteSize.times(three, 2), ByteSize.bytes(6)) |
| 142 | assertNone(ByteSize.times(three, -1)) |
| 143 | assertNone(ByteSize.times(three, 0.5)) |
| 144 | assertSome(ByteSize.divide(ByteSize.bytes(7), 2), three) |
| 145 | assertNone(ByteSize.divide(three, 0)) |
| 146 | assertNone(ByteSize.divide(three, 0.5)) |
| 147 | deepStrictEqual(ByteSize.ReducerSum.combineAll([one, two]), three) |
| 148 | deepStrictEqual(ByteSize.ReducerSum.combineAll([]), ByteSize.zero) |
| 149 | deepStrictEqual(ByteSize.CombinerMin.combine(one, two), one) |
| 150 | deepStrictEqual(ByteSize.CombinerMax.combine(one, two), two) |
| 151 | }) |
| 152 | |
| 153 | it("formats without losing the represented value", () => { |
| 154 | strictEqual(ByteSize.format(ByteSize.bytes(0)), "0 B") |
| 155 | strictEqual(ByteSize.format(ByteSize.bytes(1536)), "1.5 KiB") |
| 156 | strictEqual(ByteSize.format(ByteSize.bytes(1536), { system: "decimal" }), "1.54 kB") |
| 157 | strictEqual(ByteSize.format(ByteSize.bytes(1500), { unit: "kB", precision: 3, trailingZeros: true }), "1.500 kB") |
| 158 | strictEqual(ByteSize.format(ByteSize.mebibytes(1)), "1 MiB") |
| 159 | strictEqual(ByteSize.format(ByteSize.subtractUnsafe(ByteSize.mebibytes(1), ByteSize.bytes(1))), "1 MiB") |
| 160 | strictEqual(ByteSize.format(ByteSize.bytes(10n ** 35n), { system: "decimal" }), "100000 QB") |
| 161 | throws(() => ByteSize.format(ByteSize.zero, { precision: 21 })) |
| 162 | }) |
| 163 | |
| 164 | it("roundtrips canonical strings", () => { |
| 165 | fc.assert(fc.property(arb, (value) => { |
| 166 | deepStrictEqual(ByteSize.fromStringUnsafe(`${value} B`), value) |
| 167 | })) |
| 168 | }) |
| 169 | |
| 170 | it("preserves arithmetic invariants", () => { |
| 171 | fc.assert(fc.property(arb, arb, (a, b) => { |
| 172 | if (ByteSize.isGreaterThanOrEqualTo(a, b)) { |
| 173 | deepStrictEqual(ByteSize.sum(ByteSize.subtractUnsafe(a, b), b), a) |
| 174 | } else { |
| 175 | assertNone(ByteSize.subtract(a, b)) |
| 176 | } |
| 177 | })) |
| 178 | fc.assert(fc.property(arb, fc.bigInt({ min: 1n, max: 1_000n }), (value, divisor) => { |
| 179 | const quotient = Option.getOrThrow(ByteSize.divide(value, divisor)) |
| 180 | const product = Option.getOrThrow(ByteSize.times(quotient, divisor)) |
| 181 | assertTrue(ByteSize.isLessThanOrEqualTo(product, value)) |
| 182 | assertTrue(ByteSize.isLessThan(value, ByteSize.sum(product, ByteSize.bytes(divisor)))) |
| 183 | })) |
| 184 | }) |
| 185 | |
| 186 | it("is total for arbitrary strings", () => { |
| 187 | fc.assert(fc.property(fc.string(), (input) => { |
| 188 | ByteSize.fromString(input) |
| 189 | })) |
| 190 | }) |
| 191 | }) |