import { describe, expect, it } from 'vitest'; import { allocate, resolveShares, SplitError } from '../../../src/lib/domain/split'; const sum = (r: Record) => 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(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); }); });