jevstrudel.git / website / src / jev / agree.test.mjs
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});