1// The history's SQL, run against the real schema (worker/test/d1.ts) with 2// 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'; 11 12const song = 'jev/dial-up'; 13const hash = 'a'.repeat(64); 14const answered = (choice, extra = {}) => ({ status: 'answered', values: { section: { choice, ...extra } } }); 15 16// 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}); 34 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});