1import { describe, expect, it } from 'vitest'; 2import { 3 AGREE_KEY, 4 agreement, 5 bradleyTerry, 6 jevPrefers, 7 nextPair, 8 readVotes, 9 sendVote, 10 voteResults, 11 VOTES, 12} from './agree.mjs'; 13 14const song = (id, p) => ({ id, art: p === undefined ? null : { p } }); 15const songs = [song('a', 0.6), song('b', 0.5), song('c', 0.5), song('d')]; 16 17describe('agree', () => { 18 it('pairs only scored songs, least-voted first', () => { 19 const votes = [ 20 { a: 'a', b: 'b', pick: 'a' }, 21 { a: 'c', b: 'a', pick: 'a' }, 22 ]; 23 const pair = nextPair(songs, votes, () => 0); 24 expect(pair.map((s) => s.id).sort()).toEqual(['b', 'c']); 25 }); 26 27 it('needs two scored songs', () => { 28 expect(nextPair([song('a', 0.5), song('d')], [])).toBeNull(); 29 }); 30 31 it('knows when Jev has no preference', () => { 32 expect(jevPrefers(songs[0], songs[1])).toBe('a'); 33 expect(jevPrefers(songs[1], songs[2])).toBeNull(); 34 expect(jevPrefers(songs[0], songs[3])).toBeNull(); 35 }); 36 37 it('counts agreement against the current scores, leaving out ties', () => { 38 const votes = [ 39 { a: 'a', b: 'b', pick: 'a' }, 40 { a: 'b', b: 'a', pick: 'b' }, 41 { a: 'b', b: 'c', pick: 'b' }, 42 { a: 'a', b: 'gone', pick: 'a' }, 43 ]; 44 expect(agreement(songs, votes)).toEqual({ agreed: 1, counted: 2, share: 0.5, votes: 4 }); 45 }); 46 47 it('reads back only well-formed votes', () => { 48 const store = (v) => ({ getItem: (k) => (k === AGREE_KEY ? v : null) }); 49 expect(readVotes(store('not json'))).toEqual([]); 50 expect(readVotes(store('{}'))).toEqual([]); 51 expect( 52 readVotes( 53 store( 54 JSON.stringify([ 55 { a: 'a', b: 'b', pick: 'x' }, 56 { a: 'a', b: 'b', pick: 'b' }, 57 ]), 58 ), 59 ), 60 ).toEqual([{ a: 'a', b: 'b', pick: 'b' }]); 61 expect( 62 readVotes({ 63 getItem() { 64 throw new Error('blocked'); 65 }, 66 }), 67 ).toEqual([]); 68 }); 69}); 70 71describe('sendVote', () => { 72 it('posts the pair and the pick, and nothing else', () => { 73 const calls = []; 74 sendVote({ a: 'x', b: 'y', pick: 'y', extra: 1 }, (url, init) => { 75 calls.push([url, init]); 76 return Promise.resolve(); 77 }); 78 expect(calls).toHaveLength(1); 79 expect(calls[0][0]).toBe(VOTES); 80 expect(calls[0][1]).toMatchObject({ method: 'POST', keepalive: true }); 81 expect(JSON.parse(calls[0][1].body)).toEqual({ a: 'x', b: 'y', pick: 'y' }); 82 }); 83 84 it('never throws, whatever the network does', async () => { 85 expect(() => sendVote({ a: 'x', b: 'y', pick: 'x' }, () => Promise.reject(new Error('offline')))).not.toThrow(); 86 expect(() => 87 sendVote({ a: 'x', b: 'y', pick: 'x' }, () => { 88 throw new Error('no fetch'); 89 }), 90 ).not.toThrow(); 91 expect(() => sendVote({ a: 'x', b: 'y', pick: 'x' }, null)).not.toThrow(); 92 await new Promise((r) => setTimeout(r, 0)); 93 }); 94}); 95 96describe("bradleyTerry: listeners' ranking from pairwise votes", () => { 97 it('ranks by who beats whom, not by how many votes a song got', () => { 98 // x beats y 8-2, y beats z 8-2: x above y above z, though y has the most votes 99 const fit = bradleyTerry( 100 ['x', 'y', 'z'], 101 [ 102 { a: 'x', b: 'y', pick: 'x', n: 8 }, 103 { a: 'x', b: 'y', pick: 'y', n: 2 }, 104 { a: 'y', b: 'z', pick: 'y', n: 8 }, 105 { a: 'z', b: 'y', pick: 'z', n: 2 }, 106 ], 107 ); 108 const [x, y, z] = ['x', 'y', 'z'].map((id) => fit.get(id)); 109 expect(x.strength).toBeGreaterThan(y.strength); 110 expect(y.strength).toBeGreaterThan(z.strength); 111 expect([x.games, y.games, z.games]).toEqual([10, 20, 10]); 112 expect([x.wins, y.wins, z.wins]).toEqual([8, 10, 2]); 113 }); 114 115 it('on one pair, s_x / s_y is the odds (the closed form), pulled toward even by the prior', () => { 116 const votes = [ 117 { a: 'x', b: 'y', pick: 'x', n: 3 }, 118 { a: 'x', b: 'y', pick: 'y', n: 1 }, 119 ]; 120 const bare = bradleyTerry(['x', 'y'], votes, { prior: 1e-6 }); 121 expect(bare.get('x').strength / bare.get('y').strength).toBeCloseTo(3, 3); 122 const pulled = bradleyTerry(['x', 'y'], votes); 123 const odds = pulled.get('x').strength / pulled.get('y').strength; 124 expect(odds).toBeGreaterThan(1); 125 expect(odds).toBeLessThan(3); 126 }); 127 128 it('keeps an unbeaten song finite, and an unvoted one at the average', () => { 129 const fit = bradleyTerry(['x', 'y', 'q'], [{ a: 'x', b: 'y', pick: 'x', n: 5 }]); 130 expect(Number.isFinite(fit.get('x').strength)).toBe(true); 131 expect(fit.get('q')).toEqual({ strength: 1, wins: 0, games: 0 }); 132 }); 133 134 it('leaves out votes on songs it was not asked to rank, and malformed ones', () => { 135 const fit = bradleyTerry( 136 ['x', 'y'], 137 [ 138 { a: 'x', b: 'gone', pick: 'x', n: 9 }, 139 { a: 'x', b: 'y', pick: 'z', n: 9 }, 140 { a: 'x', b: 'y', pick: 'x', n: 0 }, 141 ], 142 ); 143 expect(fit.get('x').games).toBe(0); 144 }); 145}); 146 147describe('voteResults: what the results view shows', () => { 148 const pool = [ 149 { id: 'hi', title: 'HIGH', art: { p: 0.8 } }, 150 { id: 'mid', title: 'MID', art: { p: 0.6 } }, 151 { id: 'lo', title: 'LOW', art: { p: 0.4 } }, 152 { id: 'none', title: 'UNSCORED', art: null }, 153 ]; 154 const counts = [ 155 { a: 'hi', b: 'lo', pick: 'lo', n: 6 }, // listeners outvote Jev by 4 156 { a: 'lo', b: 'hi', pick: 'hi', n: 2 }, 157 { a: 'hi', b: 'mid', pick: 'hi', n: 3 }, // agree 158 { a: 'mid', b: 'lo', pick: 'lo', n: 2 }, // listeners outvote Jev by 1 159 { a: 'mid', b: 'lo', pick: 'mid', n: 1 }, 160 { a: 'none', b: 'hi', pick: 'none', n: 5 }, // unscored: left out 161 ]; 162 const r = voteResults(pool, counts); 163 164 it("ranks the scored songs by listeners, beside Jev's rank by art", () => { 165 expect(r.songs.map((s) => s.id)).toEqual(['lo', 'hi', 'mid']); 166 expect(r.songs.map((s) => [s.listenerRank, s.jevRank])).toEqual([ 167 [1, 3], 168 [2, 1], 169 [3, 2], 170 ]); 171 expect(r.songs[0]).toMatchObject({ title: 'LOW', art: 0.4, wins: 8, games: 11 }); 172 expect(r.songs[0].beatsAverage).toBeGreaterThan(0.5); 173 }); 174 175 it('lists the pairs listeners won against Jev, widest margin first, with counts', () => { 176 expect(r.disagreements).toEqual([ 177 { jev: 'hi', listeners: 'lo', against: 6, withJev: 2, votes: 8 }, 178 { jev: 'mid', listeners: 'lo', against: 2, withJev: 1, votes: 3 }, 179 ]); 180 }); 181 182 it('agrees with the agreement report overall', () => { 183 expect(r.overall).toMatchObject({ agreed: 6, counted: 14, votes: 19, unknown: 5, ties: 0 }); 184 }); 185});