jevstrudel.git / website / src / jev / performance.test.mjs
1import { afterEach, describe, expect, it } from 'vitest';
2import { whyNotPerformance } from '../../../worker/src/listening';
3import { choice, createJev, score, setReplay, walk } from './jevCore.mjs';
4import { boothStore } from './boothStore.mjs';
5import { answerOf, armReplay, codeHash, performanceOf, performanceParam, performancePath } from './performance.mjs';
6import { loadTally, sectionOf, sendReaction, tallyFor } from './reactions.mjs';
7
8const settle = () => new Promise((r) => setTimeout(r, 0));
9async function play(pat, cycles) {
10  for (let c = 0; c < cycles; c++) {
11    pat.queryArc(c, c + 1);
12    await settle();
13  }
14}
15
16const sectionQ = choice('Pick the next section', { intro: 'i', verse: 'v', outro: 'o' });
17const sections = {
18  intro: { at: 0, next: ['verse'] },
19  verse: { at: 2, next: ['verse', 'outro'] },
20  outro: { at: 4, next: [] },
21};

a song of two jev()s played to its end against a stub Jev

24async function played() {
25  boothStore.beginEval();
26  const jev = createJev(async (state, q) => ({
27    ...(q.section ? { section: { choice: 'outro', confidence: 0.8, probabilities: { verse: 0.2, outro: 0.8 } } } : {}),
28    ...(q.drums ? { drums: { score: 1.25, confidence: 0.4, probabilities: { 0: 0.1, 1: 0.6, 2: 0.3, 'not a key': 1 } } } : {}),
29  }));
30  const { section } = jev({ questions: { section: sectionQ } }).every(2);
31  const form = walk(section, { sections });
32  const dj = jev({ questions: { drums: score('How much?', ['out', 'back', 'full']) } }).every(2, {
33    after: { section },
34    fallback: { drums: 2 },
35  });
36  boothStore.commitEval();
37  await play(form.arrange(dj.drums.score), 8);
38  return boothStore.getSnapshot().booth;
39}
41describe('performances', () => {
42  afterEach(() => setReplay(null));
43
44  it('performanceParam reads a well-formed id only', () => {
45    expect(performanceParam('?performance=abcdefghijklmnopqrstuv')).toBe('abcdefghijklmnopqrstuv');
46    expect(performanceParam('?session=probe&performance=abcdefghijklmnopqrstuv')).toBe('abcdefghijklmnopqrstuv');
47    expect(performanceParam('?performance=short')).toBeNull();
48    expect(performanceParam('?performance=../../../../etc/passwd..')).toBeNull();
49    expect(performanceParam('')).toBeNull();
50    expect(performancePath('jev/dial-up', 'x'.repeat(22))).toBe(`/songs/jev/dial-up/?performance=${'x'.repeat(22)}`);
51  });
52
53  it('answerOf keeps what the site keeps', () => {
54    expect(answerOf({ type: 'choice', choice: 'a', confidence: 0, probabilities: {}, fallback: true })).toEqual({
55      choice: 'a',
56      fallback: true,
57    });
58    expect(
59      answerOf({ type: 'choice', choice: 'b', confidence: 0.5, probabilities: { a: 0.5, b: 0.5, 'x y': 1 }, sampled: 0.7, top: 'a' }),
60    ).toEqual({ choice: 'b', confidence: 0.5, probabilities: { a: 0.5, b: 0.5 }, sampled: 0.7, top: 'a' });
61    expect(answerOf({ type: 'noul', noul: 0.3, absent: true, junk: 1 })).toEqual({ noul: 0.3, absent: true });
62  });
63
64  it('performanceOf records every jev per segment, in a shape the Worker accepts', async () => {
65    const booth = await played();
66    const hash = await codeHash('song');
67    expect(hash).toMatch(/^[0-9a-f]{64}$/);
68    const perf = performanceOf(booth, { song: 'jev/dial-up', codeHash: hash, ended: 'finished', upTo: booth.last });
69    expect(perf.form).toEqual({ jev: 0, question: 'section' });
70    expect(perf.jevs).toHaveLength(2);
71    expect(perf.jevs[0].segments.map((s) => s.values.section.choice)).toEqual(['intro', 'verse', 'outro']);
72    expect(perf.jevs[1].segments[1].values.drums).toMatchObject({ score: 1.25, confidence: 0.4 });
73    expect(whyNotPerformance(perf, new Set(['jev/dial-up']))).toBeNull();
74    // stopped early: only the segments that played
75    const stopped = performanceOf(booth, { song: 'jev/dial-up', codeHash: hash, ended: 'stopped', upTo: 1 });
76    expect(stopped.jevs.map((j) => j.segments.length)).toEqual([2, 2]);
77    expect(performanceOf(null, { song: 'jev/dial-up', codeHash: hash, ended: 'stopped', upTo: 1 })).toBeNull();
78  });
79
80  it('armReplay replays a performance of this song, and says when its code has changed', async () => {
81    const song = { id: 'jev/dial-up', code: 'song' };
82    const jevs = [{ segments: [{ status: 'answered', values: { move: { choice: 'b' } } }] }];
83    const serve = (body) => async (url) => {
84      expect(url).toBe(`/jev/performances/${'p'.repeat(22)}`);
85      return Response.json(body);
86    };
87    const same = await armReplay('p'.repeat(22), song, serve({ song: song.id, codeHash: await codeHash('song'), jevs }));
88    expect(same.changed).toBe(false);
89    const changed = await armReplay('p'.repeat(22), song, serve({ song: song.id, codeHash: 'f'.repeat(64), jevs }));
90    expect(changed.changed).toBe(true);
91    // a jev() declared now replays: it asks nothing
92    boothStore.beginEval();
93    let asked = 0;
94    const { move } = createJev(async () => {
95      asked++;
96      return {};
97    })({ questions: { move: choice('m', { a: 'a', b: 'b' }) } }).every(1, { segments: 1 });
98    boothStore.commitEval();
99    await boothStore.getSnapshot().booth.askOpening();
100    expect(move.choice.queryArc(0, 1).map((h) => h.value)).toEqual(['b']);
101    expect(asked).toBe(0);
102    await expect(armReplay('p'.repeat(22), song, serve({ song: 'jev/other', codeHash: 'f'.repeat(64), jevs }))).rejects.toThrow(
103      /of jev\/other/,
104    );
105    await expect(armReplay('p'.repeat(22), song, async () => new Response('', { status: 404 }))).rejects.toThrow(/no such/);
106  });
107});
108
109describe('reactions', () => {
110  it('sectionOf names a reaction by the form’s section, or the section’s number', async () => {
111    const booth = await played();
112    expect(sectionOf(booth, 1)).toBe('verse');
113    expect(sectionOf({ decisions: [] }, 2)).toBe('3');
114  });
115
116  it('sendReaction counts at once in the page and posts it; loadTally reads the site’s', async () => {
117    const posted = [];
118    sendReaction({ song: 'jev/x', section: 'drop', reaction: 'fire' }, async (url, init) => {
119      posted.push([url, JSON.parse(init.body)]);
120      return new Response(null, { status: 204 });
121    });
122    expect(posted).toEqual([['/jev/reactions', { song: 'jev/x', section: 'drop', reaction: 'fire' }]]);
123    expect(tallyFor('jev/x')).toEqual({ drop: { fire: 1, sleep: 0 } });
124    await loadTally('jev/y', async (url) => {
125      expect(url).toBe('/jev/reactions?song=jev%2Fy');
126      return Response.json({ song: 'jev/y', sections: [{ section: 'intro', fire: 2, sleep: 5 }] });
127    });
128    expect(tallyFor('jev/y')).toEqual({ intro: { fire: 2, sleep: 5 } });
129  });
130});