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);
	});
});