Repository Public
1import { describe, expect, it } from 'vitest';2import { D, DEC_RE, MAX_DIGITS, type Dec } from '../src/index.ts';3import { d, prng, randDec, randDecStr, randInt, s } from './helpers.ts';45const N = 400;67describe('decimal: parsing', () => {8 it('accepts canonical decimals and normalises the output', () => {9 const cases: [string, string][] = [10 ['0', '0'], ['-0', '0'], ['-0.000', '0'], ['0.0', '0'], ['1', '1'], ['-1', '-1'], ['1.50', '1.5'], ['1.05', '1.05'], ['100', '100'],11 ['0.001', '0.001'], ['-0.001', '-0.001'], ['123456789.123456789', '123456789.123456789'], ['10.0100', '10.01'], ['1000000.000000', '1000000'],12 ];13 for (const [input, out] of cases) expect(s(D.from(input)), input).toBe(out);14 });1516 it('rejects everything that is not a canonical decimal string', () => {17 const bad = ['', ' ', '+1', '1e5', '1E5', '1e-5', 'NaN', 'Infinity', '-Infinity', '.5', '1.', '01', '00', '-01', ' 1', '1 ', '--1', '1,5', '1.2.3', '0x10', '1_000', '-', '.', '-.5', '1n', '1', '1\n'];18 for (const x of bad) expect(() => D.from(x), JSON.stringify(x)).toThrow(RangeError);19 });2021 it('enforces the digit limit', () => {22 const ok = '9'.repeat(MAX_DIGITS);23 expect(s(D.from(ok))).toBe(ok);24 expect(s(D.from(`${'9'.repeat(30)}.${'9'.repeat(MAX_DIGITS - 30)}`))).toBe(`${'9'.repeat(30)}.${'9'.repeat(MAX_DIGITS - 30)}`);25 expect(() => D.from('9'.repeat(MAX_DIGITS + 1))).toThrow(RangeError);26 expect(() => D.from(`1.${'0'.repeat(MAX_DIGITS)}`)).toThrow(RangeError);27 expect(() => D.from('1'.repeat(5000))).toThrow(RangeError);28 });2930 it('refuses numbers and other types at run time', () => {31 for (const x of [1, 1.5, NaN, null, undefined, true, {}, { m: 1, s: 0 }, { m: 1n, s: -1 }, { m: 1n, s: 1.5 }]) {32 expect(() => D.from(x as unknown as string)).toThrow(TypeError);33 }34 });3536 it('builds from bigint and from a (possibly unnormalised) Dec', () => {37 expect(s(D.from(42n))).toBe('42');38 expect(s(D.from(-7n))).toBe('-7');39 expect(s(D.from({ m: 1500n, s: 3 }))).toBe('1.5');40 expect(D.from({ m: 1500n, s: 3 })).toEqual({ m: 15n, s: 1 });41 expect(s({ m: 0n, s: 5 })).toBe('0');42 expect(s({ m: -5n, s: 3 })).toBe('-0.005');43 });4445 it('isValid mirrors from()', () => {46 expect(D.isValid('1.5')).toBe(true);47 expect(D.isValid('1e5')).toBe(false);48 expect(D.isValid(1.5)).toBe(false);49 expect(D.isValid('9'.repeat(MAX_DIGITS + 1))).toBe(false);50 });51});5253describe('decimal: arithmetic examples', () => {54 it('adds, subtracts and multiplies exactly', () => {55 expect(s(D.add(d('0.1'), d('0.2')))).toBe('0.3');56 expect(s(D.sub(d('0.3'), d('0.1')))).toBe('0.2');57 expect(s(D.mul(d('0.1'), d('0.2')))).toBe('0.02');58 expect(s(D.mul(d('-1.5'), d('4')))).toBe('-6');59 expect(s(D.sub(d('1'), d('1.000000000000000001')))).toBe('-0.000000000000000001');60 expect(s(D.mul(d('123456789012345678901234567890'), d('0.000000000000000000000000000001')))).toBe('0.12345678901234567890123456789');61 });6263 it('never prints -0', () => {64 expect(s(D.neg(d('0')))).toBe('0');65 expect(s(D.mul(d('-1'), d('0')))).toBe('0');66 expect(s(D.sub(d('-1.5'), d('-1.5')))).toBe('0');67 expect(s(D.div(d('-1'), d('3'), 0, 'down'))).toBe('0');68 expect(s(D.quantize(d('-0.4'), d('1'), 'down'))).toBe('0');69 });7071 it('divides with an explicit scale and rounding mode', () => {72 expect(s(D.div(d('1'), d('3'), 4, 'down'))).toBe('0.3333');73 expect(s(D.div(d('1'), d('3'), 4, 'up'))).toBe('0.3334');74 expect(s(D.div(d('1'), d('3'), 4, 'half_even'))).toBe('0.3333');75 expect(s(D.div(d('2'), d('3'), 4, 'half_even'))).toBe('0.6667');76 expect(s(D.div(d('-1'), d('3'), 4, 'down'))).toBe('-0.3333'); // towards zero77 expect(s(D.div(d('-1'), d('3'), 4, 'up'))).toBe('-0.3334'); // away from zero78 expect(s(D.div(d('1'), d('-3'), 4, 'up'))).toBe('-0.3334');79 expect(s(D.div(d('-1'), d('-3'), 4, 'up'))).toBe('0.3334');80 expect(s(D.div(d('10'), d('4'), 0, 'down'))).toBe('2');81 expect(s(D.div(d('10'), d('4'), 0, 'up'))).toBe('3');82 expect(s(D.div(d('6'), d('3'), 0, 'up'))).toBe('2'); // exact: no rounding in any mode83 expect(s(D.div(d('0.006'), d('0.3'), 2, 'up'))).toBe('0.02');84 expect(s(D.div(d('0'), d('7'), 5, 'up'))).toBe('0');85 });8687 it('half_even breaks ties to the even digit', () => {88 expect(s(D.div(d('0.125'), d('1'), 2, 'half_even'))).toBe('0.12');89 expect(s(D.div(d('0.135'), d('1'), 2, 'half_even'))).toBe('0.14');90 expect(s(D.div(d('0.375'), d('1'), 2, 'half_even'))).toBe('0.38');91 expect(s(D.div(d('-0.125'), d('1'), 2, 'half_even'))).toBe('-0.12');92 expect(s(D.div(d('-0.135'), d('1'), 2, 'half_even'))).toBe('-0.14');93 expect(s(D.div(d('5'), d('2'), 0, 'half_even'))).toBe('2');94 expect(s(D.div(d('7'), d('2'), 0, 'half_even'))).toBe('4');95 expect(s(D.div(d('0.1251'), d('1'), 2, 'half_even'))).toBe('0.13');96 });9798 it('refuses division by zero and bad scales or modes', () => {99 expect(() => D.div(d('1'), d('0'), 2, 'down')).toThrow(/division by zero/);100 expect(() => D.div(d('1'), d('3'), -1, 'down')).toThrow(RangeError);101 expect(() => D.div(d('1'), d('3'), 1.5, 'down')).toThrow(RangeError);102 expect(() => D.div(d('1'), d('3'), 1000, 'down')).toThrow(RangeError);103 expect(() => D.div(d('1'), d('3'), 2, 'nearest' as 'down')).toThrow(RangeError);104 });105106 it('quantizes to a tick or lot', () => {107 expect(s(D.quantize(d('1.2345'), d('0.01'), 'down'))).toBe('1.23');108 expect(s(D.quantize(d('1.2345'), d('0.01'), 'up'))).toBe('1.24');109 expect(s(D.quantize(d('-1.2345'), d('0.01'), 'down'))).toBe('-1.23');110 expect(s(D.quantize(d('-1.2345'), d('0.01'), 'up'))).toBe('-1.24');111 expect(s(D.quantize(d('1.3'), d('0.5'), 'down'))).toBe('1');112 expect(s(D.quantize(d('1.3'), d('0.5'), 'up'))).toBe('1.5');113 expect(s(D.quantize(d('60123.7'), d('5'), 'down'))).toBe('60120');114 expect(s(D.quantize(d('60123.7'), d('5'), 'up'))).toBe('60125');115 expect(s(D.quantize(d('1.5'), d('0.5'), 'up'))).toBe('1.5');116 expect(s(D.quantize(d('0.00012'), d('0.001'), 'down'))).toBe('0');117 expect(() => D.quantize(d('1'), d('0'), 'down')).toThrow(RangeError);118 expect(() => D.quantize(d('1'), d('-0.1'), 'down')).toThrow(RangeError);119 expect(() => D.quantize(d('1'), d('0.1'), 'half_even' as 'down')).toThrow(RangeError);120 });121122 it('compares, negates, takes absolute values', () => {123 expect(D.cmp(d('1.10'), d('1.1'))).toBe(0);124 expect(D.cmp(d('-2'), d('1'))).toBe(-1);125 expect(D.cmp(d('0.0000001'), d('0'))).toBe(1);126 expect(s(D.abs(d('-3.2')))).toBe('3.2');127 expect(s(D.neg(d('3.2')))).toBe('-3.2');128 expect(D.isZero(d('0.000'))).toBe(true);129 expect(D.isZero(d('0.0001'))).toBe(false);130 expect(s(D.min(d('1'), d('2')))).toBe('1');131 expect(s(D.max(d('1'), d('2')))).toBe('2');132 expect(D.isMultiple(d('0.3'), d('0.1'))).toBe(true);133 expect(D.isMultiple(d('0.35'), d('0.1'))).toBe(false);134 expect(D.toNumber(d('1.25'))).toBe(1.25);135 });136});137138describe('decimal: properties (deterministic PRNG)', () => {139 const eq = (a: Dec, b: Dec): void => { expect(s(a)).toBe(s(b)); };140141 it('add and mul are commutative and associative; mul distributes over add', () => {142 const r = prng(1);143 for (let i = 0; i < N; i++) {144 const a = randDec(r, { signed: true }), b = randDec(r, { signed: true }), c = randDec(r, { signed: true });145 eq(D.add(a, b), D.add(b, a));146 eq(D.mul(a, b), D.mul(b, a));147 eq(D.add(D.add(a, b), c), D.add(a, D.add(b, c)));148 eq(D.mul(D.mul(a, b), c), D.mul(a, D.mul(b, c)));149 eq(D.mul(a, D.add(b, c)), D.add(D.mul(a, b), D.mul(a, c)));150 }151 });152153 it('a − a = 0, a + 0 = a, a · 1 = a, a + (−a) = 0', () => {154 const r = prng(2);155 for (let i = 0; i < N; i++) {156 const a = randDec(r, { signed: true });157 expect(s(D.sub(a, a))).toBe('0');158 eq(D.add(a, D.zero), a);159 eq(D.mul(a, D.one), a);160 expect(D.isZero(D.add(a, D.neg(a)))).toBe(true);161 }162 });163164 it('(a · b) / b gives a back, and a / b · b is within one unit of the last place of a', () => {165 const r = prng(3);166 const ulp = d('0.000000000001'); // 12 decimals167 for (let i = 0; i < N; i++) {168 const a = randDec(r, { signed: true }), b = randDec(r, { signed: true, positive: true });169 // a has at most 9 decimals, so it is representable at scale 20: the division must be exact in every mode170 for (const mode of ['down', 'up', 'half_even'] as const) eq(D.div(D.mul(a, b), b, 20, mode), a);171 const down = D.div(a, b, 12, 'down'), up = D.div(a, b, 12, 'up'), he = D.div(a, b, 12, 'half_even');172 // |down| ≤ |a/b| ≤ |up|, at most one unit apart, and half_even is one of the two173 expect(D.lte(D.abs(D.mul(down, b)), D.abs(a))).toBe(true);174 expect(D.gte(D.abs(D.mul(up, b)), D.abs(a))).toBe(true);175 expect(D.lte(D.abs(D.sub(up, down)), ulp)).toBe(true);176 expect(D.eq(he, down) || D.eq(he, up)).toBe(true);177 // the error of each result, multiplied back, is below |b| · ulp178 for (const q of [down, up, he]) expect(D.lt(D.abs(D.sub(D.mul(q, b), a)), D.mul(D.abs(b), ulp))).toBe(true);179 }180 });181182 it('quantize(a, step) is a multiple of step, within one step of a, on the right side', () => {183 const r = prng(4);184 for (let i = 0; i < N; i++) {185 const a = randDec(r, { signed: true }), step = randDec(r, { intDigits: 3, fracDigits: 6, positive: true });186 const down = D.quantize(a, step, 'down'), up = D.quantize(a, step, 'up');187 for (const q of [down, up]) {188 expect(D.isMultiple(q, step)).toBe(true);189 expect(D.lt(D.abs(D.sub(q, a)), step)).toBe(true);190 }191 expect(D.lte(D.abs(down), D.abs(a))).toBe(true);192 expect(D.gte(D.abs(up), D.abs(a))).toBe(true);193 if (D.isMultiple(a, step)) { eq(down, a); eq(up, a); } else eq(D.abs(D.sub(up, down)), step);194 eq(D.quantize(down, step, 'up'), down); // idempotent195 }196 });197198 it('comparison is consistent with subtraction and is a total order', () => {199 const r = prng(5);200 for (let i = 0; i < N; i++) {201 const a = randDec(r, { signed: true, intDigits: 3, fracDigits: 3 }), b = randDec(r, { signed: true, intDigits: 3, fracDigits: 3 }), c = randDec(r, { signed: true, intDigits: 3, fracDigits: 3 });202 expect(D.cmp(a, b)).toBe(D.sign(D.sub(a, b)));203 expect(D.cmp(a, b)).toBe(-D.cmp(b, a) || 0);204 if (D.lte(a, b) && D.lte(b, c)) expect(D.lte(a, c)).toBe(true);205 expect(D.cmp(D.add(a, c), D.add(b, c))).toBe(D.cmp(a, b)); // translation invariant206 }207 });208209 it('round-trips: str(from(s)) is the normalised s, from(str(x)) = x, and the output is canonical', () => {210 const r = prng(6);211 for (let i = 0; i < N; i++) {212 const text = randDecStr(r, { signed: true, intDigits: 20, fracDigits: 20 });213 const x = D.from(text);214 const out = D.str(x);215 expect(out).toMatch(DEC_RE);216 expect(out).not.toMatch(/^-0(\.0*)?$/);217 if (out.includes('.')) expect(out.endsWith('0')).toBe(false);218 eq(D.from(out), x);219 expect(D.str(D.from(out))).toBe(out);220 // same value as the input: compare through an independent bigint scaling221 const [i0 = '', f0 = ''] = text.replace('-', '').split('.');222 const big = BigInt(i0 + f0.padEnd(20, '0')) * (text.startsWith('-') ? -1n : 1n);223 eq(D.mul(x, D.from(10n ** 20n)), D.from(big));224 }225 });226227 it('a random unnormalised Dec prints like its normalised form', () => {228 const r = prng(7);229 for (let i = 0; i < N; i++) {230 const m = BigInt(randInt(r, -999999, 999999)), sc = randInt(r, 0, 8), pad = randInt(r, 0, 5);231 expect(D.str({ m: m * 10n ** BigInt(pad), s: sc + pad })).toBe(D.str({ m, s: sc }));232 }233 });234});235