Spaces:
Sleeping
Sleeping
File size: 5,140 Bytes
3852523 5918e23 ee86043 5918e23 3852523 5918e23 3852523 5918e23 ee86043 5918e23 3852523 5918e23 3852523 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | 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);
});
});
|