Spaces:
Sleeping
Sleeping
assafvayner HF Staff
refactor(domain): move MAX_CENTS to money, deterministic shuffle in tests
ee86043 Download tests/unit/domain/split.test.ts from assafvayner/splitwise: direct link, hf CLI and curl.
- Browser
- Download file 5.14 kB
-
https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/tests/unit/domain/split.test.ts
- Command line
-
hf download hf://spaces/assafvayner/splitwise/tests/unit/domain/split.test.ts
-
curl -L -o split.test.ts https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/tests/unit/domain/split.test.ts
5.14 kB
| import { describe, expect, it } from 'vitest'; | |
| import { allocate, resolveShares, SplitError } from '../../../src/lib/domain/split'; | |
| const sum = (r: Record<string, number>) => Object.values(r).reduce((a, b) => a + b, 0); | |
| function mulberry32(seed: number) { | |
| return () => { | |
| seed = (seed + 0x6d2b79f5) | 0; | |
| let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); | |
| t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| }; | |
| } | |
| function shuffle<T>(arr: T[], rnd: () => number): T[] { | |
| const copy = [...arr]; | |
| for (let i = copy.length - 1; i > 0; i--) { | |
| const j = Math.floor(rnd() * (i + 1)); | |
| [copy[i], copy[j]] = [copy[j], copy[i]]; | |
| } | |
| return copy; | |
| } | |
| function fieldOf(fn: () => unknown): string { | |
| try { | |
| fn(); | |
| } catch (e) { | |
| if (e instanceof SplitError) return e.field; | |
| throw e; | |
| } | |
| throw new Error('expected to throw'); | |
| } | |
| describe('allocate', () => { | |
| it('splits evenly when divisible', () => { | |
| expect(allocate(3000, [{ id: 'a', weight: 1 }, { id: 'b', weight: 1 }, { id: 'c', weight: 1 }])).toEqual({ a: 1000, b: 1000, c: 1000 }); | |
| }); | |
| it('hands leftover cents to the largest remainders, ties by id', () => { | |
| expect(allocate(1000, [{ id: 'a', weight: 1 }, { id: 'b', weight: 1 }, { id: 'c', weight: 1 }])).toEqual({ a: 334, b: 333, c: 333 }); | |
| expect(allocate(1001, [{ id: 'b', weight: 1 }, { id: 'a', weight: 1 }, { id: 'c', weight: 1 }])).toEqual({ a: 334, b: 334, c: 333 }); | |
| }); | |
| it('respects weights', () => { | |
| expect(allocate(1000, [{ id: 'a', weight: 7500 }, { id: 'b', weight: 2500 }])).toEqual({ a: 750, b: 250 }); | |
| // 1/3, 2/3 of 100 -> 33.33 / 66.67 -> 33 / 67 | |
| expect(allocate(100, [{ id: 'a', weight: 1 }, { id: 'b', weight: 2 }])).toEqual({ a: 33, b: 67 }); | |
| }); | |
| it('gives leftover cents to the earliest id when the total is smaller than the participant count', () => { | |
| expect(allocate(1, [{ id: 'a', weight: 1 }, { id: 'b', weight: 1 }, { id: 'c', weight: 1 }])).toEqual({ a: 1, b: 0, c: 0 }); | |
| }); | |
| it('gives the whole total to a single participant', () => { | |
| expect(allocate(500, [{ id: 'a', weight: 1 }])).toEqual({ a: 500 }); | |
| }); | |
| it('supports a participant id of "__proto__"', () => { | |
| const r = allocate(100, [{ id: '__proto__', weight: 1 }, { id: 'b', weight: 1 }, { id: 'c', weight: 1 }]); | |
| expect(Object.keys(r)).toHaveLength(3); | |
| expect(sum(r)).toBe(100); | |
| }); | |
| it('rejects a non-positive total with field "amount"', () => { | |
| expect(() => allocate(0, [{ id: 'a', weight: 1 }])).toThrow(SplitError); | |
| expect(fieldOf(() => allocate(0, [{ id: 'a', weight: 1 }]))).toBe('amount'); | |
| }); | |
| it('rejects a zero weight with field "shares"', () => { | |
| expect(() => allocate(100, [{ id: 'a', weight: 0 }, { id: 'b', weight: 1 }])).toThrow(SplitError); | |
| expect(fieldOf(() => allocate(100, [{ id: 'a', weight: 0 }, { id: 'b', weight: 1 }]))).toBe('shares'); | |
| }); | |
| it('rejects amounts that overflow safe-integer arithmetic', () => { | |
| expect(() => | |
| allocate(6782420994015412, [ | |
| { id: 'u0', weight: 1589 }, | |
| { id: 'u1', weight: 7035 } | |
| ]) | |
| ).toThrow(SplitError); | |
| }); | |
| it('always sums to the total and is order-independent (property)', () => { | |
| const rnd = mulberry32(42); | |
| for (let i = 0; i < 500; i++) { | |
| const n = 1 + Math.floor(rnd() * 8); | |
| const total = 1 + Math.floor(rnd() * 1_000_000); | |
| const weights = Array.from({ length: n }, (_, k) => ({ id: `u${k}`, weight: 1 + Math.floor(rnd() * 100) })); | |
| const r = allocate(total, weights); | |
| expect(sum(r)).toBe(total); | |
| expect(Object.keys(r)).toHaveLength(n); | |
| expect(Object.values(r).every((v) => v >= 0)).toBe(true); | |
| const shuffled = shuffle(weights, rnd); | |
| expect(allocate(total, shuffled)).toEqual(r); | |
| } | |
| }); | |
| }); | |
| describe('resolveShares', () => { | |
| it('equal split', () => { | |
| expect(resolveShares(1000, { mode: 'equal', participants: ['b', 'a', 'c'] })).toEqual({ a: 334, b: 333, c: 333 }); | |
| }); | |
| it('percent split requires bp to sum to 10000', () => { | |
| expect(resolveShares(1000, { mode: 'percent', bp: { a: 2500, b: 7500 } })).toEqual({ a: 250, b: 750 }); | |
| expect(() => resolveShares(1000, { mode: 'percent', bp: { a: 2500, b: 7000 } })).toThrow(SplitError); | |
| }); | |
| it('exact split requires cents to sum to the total', () => { | |
| expect(resolveShares(1000, { mode: 'exact', cents: { a: 1, b: 999 } })).toEqual({ a: 1, b: 999 }); | |
| expect(() => resolveShares(1000, { mode: 'exact', cents: { a: 1, b: 998 } })).toThrow(SplitError); | |
| }); | |
| it('exact mode returns a copy of cents', () => { | |
| const cents = { a: 400, b: 600 }; | |
| const result = resolveShares(1000, { mode: 'exact', cents }); | |
| result.a = 999; | |
| expect(cents.a).toBe(400); | |
| }); | |
| it('rejects empty or duplicate participants', () => { | |
| expect(() => resolveShares(1000, { mode: 'equal', participants: [] })).toThrow(SplitError); | |
| expect(() => resolveShares(1000, { mode: 'equal', participants: ['a', 'a'] })).toThrow(SplitError); | |
| }); | |
| it('rejects non-positive weights', () => { | |
| expect(() => resolveShares(1000, { mode: 'percent', bp: { a: 0, b: 10000 } })).toThrow(SplitError); | |
| expect(() => resolveShares(1000, { mode: 'exact', cents: { a: 0, b: 1000 } })).toThrow(SplitError); | |
| }); | |
| }); | |