Skip to content
markpaper

src/format/decimal.test.ts

v0.3.0 · 4.4 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  HlFormatError,
  ceilDivDec,
  cmp,
  decimalPlaces,
  magnitude,
  mul,
  parseDec,
  roundRatio,
  roundToExp,
  significantDigits,
  toPlain,
} from './decimal.js';

describe('parseDec / toPlain', () => {
  it('parses plain, signed, exponent and dot-only forms exactly', () => {
    expect(toPlain(parseDec('83.2050'))).toBe('83.205');
    expect(toPlain(parseDec(-1.25))).toBe('-1.25');
    expect(toPlain(parseDec('.5'))).toBe('0.5');
    expect(toPlain(parseDec('5.'))).toBe('5');
    expect(toPlain(parseDec('1e-7'))).toBe('0.0000001');
    expect(toPlain(parseDec('1.5E+21'))).toBe('1500000000000000000000');
    expect(toPlain(parseDec(1e-7))).toBe('0.0000001');
    expect(toPlain(parseDec('-0.000'))).toBe('0');
    expect(toPlain(parseDec(-0))).toBe('0');
    expect(toPlain(parseDec(' 42 '))).toBe('42');
  });

  it('reads a JS number via its shortest round-trip decimal', () => {
    expect(toPlain(parseDec(0.1 + 0.2))).toBe('0.30000000000000004');
    expect(toPlain(parseDec(0.29))).toBe('0.29');
  });

  it('rejects non-finite, malformed and absurd exponents', () => {
    for (const bad of [NaN, Infinity, -Infinity, 'abc', '', '1.2.3', '0x10', '1e', '--1', '1e999']) {
      expect(() => parseDec(bad as number | string)).toThrow(HlFormatError);
    }
    expect(() => parseDec(undefined as unknown as string)).toThrow(HlFormatError);
  });
});

describe('arithmetic helpers', () => {
  it('multiplies and compares exactly', () => {
    expect(toPlain(mul(parseDec(0.1), parseDec(3)))).toBe('0.3');
    expect(cmp(parseDec('10'), parseDec('10.000'))).toBe(0);
    expect(cmp(parseDec('9.999999999999998'), parseDec(10))).toBe(-1);
  });

  it('computes magnitude, decimals and significant digits from digits', () => {
    expect(magnitude(parseDec('0.05'))).toBe(-2);
    expect(magnitude(parseDec('99.999'))).toBe(1);
    expect(magnitude(parseDec('100'))).toBe(2);
    expect(decimalPlaces(parseDec('1.2300'))).toBe(2);
    expect(significantDigits(parseDec('79000'))).toBe(2);
    expect(significantDigits(parseDec('0.00012340'))).toBe(4);
    expect(significantDigits(parseDec('0'))).toBe(0);
    expect(() => magnitude(parseDec(0))).toThrow();
  });

  it('ceilDivDec is exact', () => {
    // 10 / (3000 * 0.001) = 3.33.. -> 4
    expect(ceilDivDec(parseDec(10), parseDec('3'))).toBe(4n);
    expect(ceilDivDec(parseDec(10), parseDec('2.5'))).toBe(4n);
    expect(ceilDivDec(parseDec(0), parseDec('2.5'))).toBe(0n);
  });
});

describe('roundRatio epsilon', () => {
  it('floors 28.999999999999996 lots to 29 (0.29 * 100 float artifact)', () => {
    const d = parseDec(0.29 * 100); // 28.999999999999996
    expect(Math.floor(0.29 * 100)).toBe(28);
    expect(toPlain(roundToExp(d, 0, 'floor'))).toBe('29');
  });

  it('ceils 29.000000000000004 lots to 29, not 30', () => {
    expect(toPlain(roundToExp(parseDec('29.000000000000004'), 0, 'ceil'))).toBe('29');
  });

  it('does not swallow real sub-lot differences', () => {
    expect(toPlain(roundToExp(parseDec('28.99999'), 0, 'floor'))).toBe('28');
    expect(toPlain(roundToExp(parseDec('29.00001'), 0, 'ceil'))).toBe('30');
  });

  it('widens to a relative epsilon for very large counts', () => {
    // 5e-9 below a lot boundary: the absolute 1e-9 epsilon alone would floor to ...78
    expect(toPlain(roundToExp(parseDec('12345678.999999995'), 0, 'floor'))).toBe('12345679');
  });

  it('caps the widened epsilon so floor never exceeds and ceil never undercuts by a lot', () => {
    // At 1e15 lots an uncapped relative epsilon equals a whole lot.
    expect(toPlain(roundToExp(parseDec('1000000000000000.5'), 0, 'floor'))).toBe('1000000000000000');
    expect(toPlain(roundToExp(parseDec('1000000000000000.5'), 0, 'ceil'))).toBe('1000000000000001');
    expect(toPlain(roundToExp(parseDec('10000000.5'), -8, 'floor'))).toBe('10000000.5');
    expect(toPlain(roundToExp(parseDec('10000000.000000015'), -8, 'floor'))).toBe('10000000.00000001');
    expect(toPlain(roundToExp(parseDec('10000000.000000005'), -8, 'ceil'))).toBe('10000000.00000001');
  });

  it('rounds half up and validates arguments', () => {
    expect(roundRatio(5n, 2n, 'round')).toBe(3n);
    expect(roundRatio(4n, 2n, 'round')).toBe(2n);
    expect(roundRatio(0n, 7n, 'ceil')).toBe(0n);
    expect(() => roundRatio(-1n, 1n, 'floor')).toThrow(RangeError);
    expect(() => roundRatio(1n, 0n, 'floor')).toThrow(RangeError);
    expect(() => roundToExp(parseDec(-1), 0, 'floor')).toThrow(RangeError);
  });
});
All files