splitwise / tests /unit /domain /split.test.ts
assafvayner's picture
assafvayner HF Staff
refactor(domain): move MAX_CENTS to money, deterministic shuffle in tests
ee86043
Raw History Blame Contribute Delete
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);
});
});