jevstrudel.git / website / src / jev / JevTakes.test.mjs
1// The song view's takes and form map, rendered from the Worker's real
2// answers (worker/src/listening.ts against testD1): what they show, and a
3// section name as text, never markup.
4import { createElement } from 'react';
5import { renderToString } from 'react-dom/server';
6import { describe, expect, it } from 'vitest';
7import { testD1 } from '../../../worker/test/d1';
8import { listening } from '../../../worker/src/listening';
9import { d1Listening } from '../../../worker/src/listening-store';
10import { FormMap, Takes } from './JevTakes.jsx';
11
12const hash = 'a'.repeat(64);
13const segment = (section, status = 'answered', ms = 90) => ({ status, ms, values: { section: { choice: section } } });
14const performance = (sections, fallbackAt = -1) => ({
15  song: 'jev/dial-up',
16  codeHash: hash,
17  model: 'jev-1.13.0',
18  every: 4,
19  ended: 'finished',
20  form: { jev: 0, question: 'section' },
21  jevs: [{ segments: sections.map((s, i) => segment(s, i === fallbackAt ? 'fallback' : 'answered')) }],
22});
23const env = { LISTEN_LIMIT: { limit: async () => ({ success: true }) }, RECORD_LIMIT: { limit: async () => ({ success: true }) } };
24const get = async (store, path) => (await listening(new Request(`http://x${path}`), env, store)).json();
25
26async function answers() {
27  const store = d1Listening(testD1());
28  await store.record('a'.repeat(22), '2026-09-26', performance(['intro', 'verse', 'drop', 'outro']));
29  await store.record('b'.repeat(22), '2026-09-27', performance(['intro', 'drop', 'drop', 'outro'], 2));
30  await store.record('c'.repeat(22), '2026-09-27', performance(['intro', 'x_script_alert', 'outro']));
31  return {
32    takes: await get(store, '/jev/performances?song=jev/dial-up'),
33    moves: await get(store, '/jev/performances/moves?song=jev/dial-up'),
34  };
35}
36
37const song = {
38  id: 'jev/dial-up',
39  code: `walk(section, { sections: { intro: { at: 0, next: [] }, verse: { at: 4, next: [] }, drop: { at: 8, next: [] }, outro: { at: 12, next: [] } } })`,
40};
41const text = (html) => html.replaceAll('<!-- -->', '').replace(/<[^>]*>/g, ' ').replace(/&#x27;/g, "'").replace(/\s+/g, ' ');
42
43describe("a song's takes", () => {
44  it('lists each take newest first, with its path, its fallbacks and a replay link', async () => {
45    const { takes } = await answers();
46    const html = renderToString(createElement(Takes, { song, first: takes }));
47    expect(text(html)).toMatch(/Jev's takes · 3/);
48    expect(text(html)).toMatch(/intro → drop → drop → outro/);
49    expect(text(html)).toMatch(/1 unanswered/);
50    expect(html).toContain(`href="/songs/jev/dial-up/?performance=${'b'.repeat(22)}"`);
51    expect(html.indexOf('c'.repeat(22)) < html.indexOf('a'.repeat(22)), 'newest first').toBe(true);
52  });
53});
54
55describe('the form map', () => {
56  it('lays the sections out as written, with the ways Jev took and how often', async () => {
57    const { takes, moves } = await answers();
58    const html = renderToString(createElement(FormMap, { song, moves, takes: takes.takes }));
59    const t = text(html);
60    expect(t).toMatch(/over 3 takes/);
61    expect(t.indexOf('intro played') < t.indexOf('verse played')).toBe(true);
62    expect(t.indexOf('verse played') < t.indexOf('drop played')).toBe(true);
63    // intro went to verse once (as written), drop once, and to the unknown section once
64    expect(t).toMatch(/intro played 3× · answered in 90 ms \(p90 90\) → (drop|verse|x_script_alert) 33%/);
65    expect(t).toMatch(/verse 33% \(as written\)/);
66    expect(t).toMatch(/drop played 3× · Jev unreachable 1 of 3/);
67    expect(html).toContain('<svg');
68  });
69
70  it('shows a section name as text', () => {
71    const name = 'a<b'; // not a section name the Worker accepts; rendered anyway as text
72    const moves = { song: 'jev/dial-up', performances: 1, of: 1, sections: [{ section: name, plays: 1, asked: 1, fallbacks: 0, ms: null, next: [] }] };
73    const html = renderToString(createElement(FormMap, { song, moves, takes: [] }));
74    expect(html).not.toContain('a<b');
75    expect(html).toContain('a&lt;b');
76  });
77});
78
79describe("a listener song's takes", () => {
80  it('replay in the sandbox, say who played them and when, and map the form from the code it was written as', async () => {
81    const db = testD1();
82    const player = 'U'.repeat(22);
83    await db.prepare('INSERT INTO users (id, display_name, created_at) VALUES (?, ?, 1)').bind(player, 'Link').run();
84    const store = d1Listening(db);
85    const id = `listener:${'L'.repeat(22)}`;
86    const at = Date.parse('2026-09-27T14:05:31Z');
87    await store.record('a'.repeat(22), '2026-09-27', { ...performance(['intro', 'drop', 'outro']), song: id }, { at, player });
88    await store.record('b'.repeat(22), '2026-09-27', { ...performance(['intro', 'outro']), song: id }, { at: at + 1, player: null });
89    const takes = await get(store, `/jev/performances?song=${encodeURIComponent(id)}`);
90    const moves = await get(store, `/jev/performances/moves?song=${encodeURIComponent(id)}`);
91    // a listener song carries its code as foreignCode, never code
92    const listenerSong = { id, foreignCode: song.code };
93    const html = renderToString(createElement(Takes, { song: listenerSong, first: takes }));
94    const t = text(html);
95    expect(html).toContain(`href="/?listener=${'L'.repeat(22)}&amp;performance=${'a'.repeat(22)}"`);
96    expect(t).toMatch(/2026-09-27 14:05 UTC · Link · finished/);
97    expect(t).toMatch(/a listener · finished/);
98    const map = text(renderToString(createElement(FormMap, { song: listenerSong, moves, takes: takes.takes })));
99    expect(map.indexOf('intro played') < map.indexOf('drop played')).toBe(true);
100    expect(map).toMatch(/\(as written\)/);
101  });
102});