jevstrudel.git / tools / jev-history / history.test.mjs

The history's SQL, run against the real schema (worker/test/d1.ts) with performances stored the way the Worker stores them.

3import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4import { tmpdir } from 'node:os';
5import { join } from 'node:path';
6import { createHash } from 'node:crypto';
7import { describe, expect, it } from 'vitest';
8import { testD1 } from '../../worker/test/d1';
9import { d1Listening } from '../../worker/src/listening-store';
10import { currentHash, format, historyQuery, results, songId, summaryQuery } from './history.mjs';
12const song = 'jev/dial-up';
13const hash = 'a'.repeat(64);
14const answered = (choice, extra = {}) => ({ status: 'answered', values: { section: { choice, ...extra } } });

A performance walking path, with a parts jev choosing into each section.

17const performance = (path, { codeHash = hash, into = [] } = {}) => ({
18  song,
19  codeHash,
20  model: 'jev-1.13.0',
21  every: 8,
22  ended: 'finished',
23  form: { jev: 0, question: 'section' },
24  jevs: [
25    { segments: path.map((s, i) => (i === 0 ? answered(s, { forced: true, fallback: true }) : typeof s === 'string' ? answered(s) : s)) },
26    {
27      segments: path.map((_, i) => ({
28        status: 'answered',
29        values: { into: into[i] ? { choice: into[i] } : { choice: 'cut', fallback: true }, drums: { score: 1 } },
30      })),
31    },
32  ],
33});
35async function db() {
36  const d1 = testD1();
37  const store = d1Listening(d1);
38  let n = 0;
39  const id = () => String(n++).padStart(22, 'x');
40  await store.record(id(), '2026-09-25', performance(['intro', 'drop2d', 'outro'], { into: [null, 'fill', 'cut'] }));
41  await store.record(id(), '2026-09-25', performance(['intro', 'drop2d', 'verse', 'outro'], { into: [null, 'fill'] }));
42  await store.record(
43    id(),
44    '2026-09-25',
45    performance(['intro', 'drop2d', { status: 'fallback', values: { section: { choice: 'outro', fallback: true } } }], {
46      codeHash: 'b'.repeat(64),
47    }),
48  );
49  await store.record(id(), '2026-09-25', { ...performance(['intro', 'verse']), song: 'jev/system-one' });
50  return d1;
51}
52const all = async (d1, sql) => (await d1.prepare(sql).all()).results;
53
54describe('jev-history', () => {
55  it('counts what Jev picks next at each section, and each other choice in its section', async () => {
56    const d1 = await db();
57    const rows = await all(d1, historyQuery(song));
58    const [summary] = await all(d1, summaryQuery(song, hash));
59    expect(summary).toEqual({ performances: 3, finished: 3, versions: 2, current: 2 });
60    const text = format(song, summary, rows);
61    expect(text).toContain('jev/dial-up: 3 performances (3 finished, 0 stopped), 2 versions of its code (2 of the current one)');
62    // "how often does Jev pick the outro at drop2d": 1 of 2 of its own picks, plus a fallback
63    expect(text).toMatch(/drop2d → outro 50% \(1\) · verse 50% \(1\)\s+\+ 1 fallback$/m);
64    expect(text).toMatch(/intro\s+→ drop2d 100% \(3\)/);
65    expect(text).toMatch(/verse\s+→ outro 100% \(1\)/);
66    // into is counted in the section it plays in
67    expect(text).toContain("into: what Jev picks, in each section (Jev's own picks)");
68    expect(text).toMatch(/drop2d\s+fill 100% \(2\)/);
69    // the other song is not counted
70    expect(text).not.toMatch(/system-one/);
71  });
72
73  it('counts a forced section as forced, not as a fallback', async () => {
74    const d1 = testD1();
75    const forced = performance(['intro', 'drop2d', 'outro']);
76    forced.jevs[0].segments[2] = answered('outro', { forced: true, fallback: true });
77    await d1Listening(d1).record('f'.repeat(22), '2026-09-25', forced);
78    const text = format(song, (await all(d1, summaryQuery(song, hash)))[0], await all(d1, historyQuery(song)));
79    expect(text).toMatch(/drop2d → \(no picks of its own\)\s+\+ 1 forced$/m);
80  });
81
82  it('--current keeps only the current code', async () => {
83    const d1 = await db();
84    const text = format(song, (await all(d1, summaryQuery(song, hash)))[0], await all(d1, historyQuery(song, { onlyHash: hash })));
85    expect(text).toMatch(/drop2d → outro 50% \(1\) · verse 50% \(1\)$/m);
86  });
87
88  it('refuses anything that could reach the SQL as more than a literal', () => {
89    expect(() => historyQuery("jev/x' OR 1=1 --")).toThrow(/refusing/);
90    expect(() => summaryQuery(song, "a' --")).toThrow(/refusing/);
91  });
92
93  it('finds a song by its folder name, and hashes its code as the page plays it', () => {
94    const root = mkdtempSync(join(tmpdir(), 'songs-'));
95    mkdirSync(join(root, 'jev', 'dial-up'), { recursive: true });
96    writeFileSync(join(root, 'jev', 'dial-up', 'SPEC.md'), '---\ntitle: DIAL UP\n---\n');
97    writeFileSync(join(root, 'jev', 'dial-up', 'song.js'), 'note("c")\n');
98    expect(songId('dial-up', root)).toBe('jev/dial-up');
99    expect(songId('jev/dial-up', root)).toBe('jev/dial-up');
100    expect(() => songId('nope', root)).toThrow(/no song/);
101    expect(currentHash(join(root, 'jev', 'dial-up'))).toBe(
102      createHash('sha256').update('// @title DIAL UP\nnote("c")\n').digest('hex'),
103    );
104  });
105
106  it("reads wrangler's --json output", () => {
107    expect(results('banner\n[{"results":[{"n":1}],"success":true,"meta":{}}]')).toEqual([{ n: 1 }]);
108    expect(() => results('[{"success":false,"error":"no such table"}]')).toThrow(/failed/);
109  });
110
111  it('says so when nothing is recorded', () => {
112    expect(format(song, { performances: 0 }, [])).toBe('jev/dial-up: 0 performances');
113  });
114});