jevstrudel.git / website / src / jev / tabTools.test.mjs

The tab's end of the tools both MCPs share (tabTools.mjs), with stand-ins for the page's modules: what each answers from the panel's data, that Jev is asked through the page's own askJev in banks when there are more than 255 sounds, that settings outside the allowlist never reach the store, and that songs play the ways their cards do.

6import { describe, expect, it, vi } from 'vitest';
7import { htmlText, referenceData, referenceFunctions } from './reference.mjs';
8import { categoryOf, inCategory, soundList, useOf, variantsOf } from './sounds.mjs';
9import { banks, MAX_OPTIONS, pack, pickSound } from './soundPick.mjs';
10import { runTabTool, TAB_TOOLS } from './tabTools.mjs';
12const SOUNDS = {
13  bd: { data: { type: 'sample', prebake: true, samples: ['bd/0.wav', 'bd/1.wav'] } },
14  RolandTR808_bd: { data: { type: 'sample', prebake: true, tag: 'drum-machines', samples: ['a.wav', 'b.wav', 'c.wav'] } },
15  sawtooth: { data: { type: 'synth', prebake: true } },
16  gm_piano: { data: { type: 'soundfont', prebake: true, fonts: [1, 2] } },
17  wt_digital: { data: { type: 'wavetable', prebake: true, tables: { a: 1, b: 2 } } },
18  jeff: { data: { type: 'sample', prebake: false, samples: ['/jev-samples/jeff/jeff.mp3'] } },
19  swoop: { data: { type: 'sample', prebake: false, samples: ['blob:http://localhost/1', 'blob:http://localhost/2'] } },
20  _hidden: { data: { type: 'synth', prebake: true } },
21};
22
23function editor() {
24  return { code: '', repl: { state: {}, scheduler: { started: false, now: () => 0 } } };
25}
26function deps(over = {}) {
27  let settings = { theme: 'strudelTheme', fontSize: 18, prebakeScript: 'secret code', audioEngineTarget: 'webaudio' };
28  const d = {
29    sounds: () => SOUNDS,
30    askJev: vi.fn(async (questions) => {
31      const [key, q] = Object.entries(questions)[0];
32      const [first] = Object.keys(q.criteria);
33      return { [key]: { choice: first, probabilities: { [first]: 0.7 }, confidence: 0.7 } };
34    }),
35    settings: { get: () => settings, setKey: vi.fn((k, v) => (settings = { ...settings, [k]: v })) },
36    themes: () => ['strudelTheme', 'dracula'],
37    consoleLog: () => [{ message: '<a href="x">loaded</a> &lt;b&gt;', type: 'success', count: 2 }, { message: 'plain', type: '' }],
38    readSongs: () => [
39      { id: 'jev/dial-up', title: 'Dial-Up', code: 's("bd")' },
40      { id: 'jev/no-code', title: 'Listed Only' },
41    ],
42    playSong: vi.fn(async () => {}),
43    audioRunning: () => true,
44    loadListener: vi.fn(async (id) => ({ id: `listener:${id}`, title: 'Theirs', foreignCode: 's("hh")' })),
45    showListener: vi.fn(async () => {}),
46    foreign: () => ({ song: { id: 'x' } }),
47    playForeign: vi.fn(async () => ({ ok: true })),
48    pickByMood: vi.fn(async (songs) => ({ song: songs[0], p: 0.4, confidence: 0.5 })),
49    booth: () => ({ every: 4, segments: 8, last: null, decisions: [{}, {}], form: null }),
50    cycle: () => 5,
51    press: vi.fn(() => ({ section: '2', counted: true })),
52    ...over,
53  };
54  return d;
55}
56
57describe('the sounds tab as data (sounds.mjs)', () => {
58  it("puts each sound under the sub-tabs the tab shows it under", () => {
59    const names = (c) => soundList(SOUNDS, { category: c }).map(([n]) => n);
60    expect(names('samples')).toEqual(['bd', 'jeff', 'swoop']);
61    expect(names('drum-machines')).toEqual(['RolandTR808_bd']);
62    expect(names('synths')).toEqual(['gm_piano', 'sawtooth']);
63    expect(names('wavetables')).toEqual(['wt_digital']);
64    expect(names('user')).toEqual(['jeff', 'swoop']);
65    expect(names('imported')).toEqual(['swoop']);
66    expect(names(undefined)).toHaveLength(7);
67    // the settings value the sounds tab keeps for drum machines
68    expect(inCategory(SOUNDS.RolandTR808_bd.data, 'drums')).toBe(true);
69    expect(soundList(SOUNDS, { filter: 'BD' }).map(([n]) => n)).toEqual(['bd', 'RolandTR808_bd']);
70  });
71
72  it('counts variants and says how to play each', () => {
73    expect(variantsOf(SOUNDS.bd.data)).toBe(2);
74    expect(variantsOf(SOUNDS.wt_digital.data)).toBe(2);
75    expect(variantsOf(SOUNDS.gm_piano.data)).toBe(2);
76    expect(useOf('RolandTR808_bd', SOUNDS.RolandTR808_bd.data)).toBe('s("bd").bank("RolandTR808")');
77    expect(useOf('gm_piano', SOUNDS.gm_piano.data)).toBe('note("c3 e3 g3").s("gm_piano")');
78    expect(useOf('bd', SOUNDS.bd.data)).toBe('s("bd")');
79    expect(categoryOf(SOUNDS.swoop.data)).toBe('imported');
80  });
81});
82
83describe('Jev picking a sound (soundPick.mjs)', () => {
84  const many = (n) => Array.from({ length: n }, (_, i) => ({ name: `s${String(i).padStart(4, '0')}`, category: 'samples', type: 'sample', variants: 1 }));
85
86  it('splits more than 255 into near-equal banks of at most 255', () => {
87    expect(banks(many(255))).toHaveLength(1);
88    const split = banks(many(1000));
89    expect(split.map((b) => b.length)).toEqual([250, 250, 250, 250]);
90    expect(banks(many(511)).map((b) => b.length)).toEqual([171, 170, 170]);
91    expect(split.flat()).toHaveLength(1000);
92  });
93
94  it('packs questions into requests the relay takes', () => {
95    const questions = Object.fromEntries(banks(many(2000)).map((b, i) => [`bank${i}`, { type: 'choice', instructions: 'x', criteria: Object.fromEntries(b.map((s) => [s.name, 'y'.repeat(60)])) }]));
96    const requests = pack(questions);
97    expect(requests.length).toBeGreaterThan(1);
98    for (const r of requests) expect(new TextEncoder().encode(JSON.stringify(r)).length).toBeLessThanOrEqual(48 * 1024 + 100);
99    expect(requests.flatMap(Object.keys)).toEqual(Object.keys(questions));
100  });
101
102  it('asks once among 255 or fewer, with every option a sound', async () => {
103    const ask = vi.fn(async (q) => ({ sound: { choice: 's0003', probabilities: { s0003: 0.6, s0001: 0.3, s0002: 0.1 }, confidence: 0.6 } }));
104    const r = await pickSound(many(10), 'a crunchy snare', { ask });
105    expect(ask).toHaveBeenCalledTimes(1);
106    const [questions, state] = ask.mock.calls[0];
107    expect(state).toEqual({ want: 'a crunchy snare' });
108    expect(Object.keys(questions.sound.criteria)).toHaveLength(10);
109    expect(r).toMatchObject({ candidates: 10, banks: 0, calls: 1, scope: 'all', confidence: 0.6 });
110    expect(r.picks.map((p) => [p.name, p.p])).toEqual([['s0003', 0.6], ['s0001', 0.3], ['s0002', 0.1]]);
111  });
112
113  it("asks in banks past 255, then among each bank's likeliest, and says the odds are the finalists'", async () => {
114    const ask = vi.fn(async (questions) =>
115      Object.fromEntries(
116        Object.entries(questions).map(([k, q]) => {
117          const opts = Object.keys(q.criteria);
118          expect(opts.length).toBeLessThanOrEqual(MAX_OPTIONS);
119          return [k, { choice: opts.at(-1), probabilities: Object.fromEntries(opts.map((o, i) => [o, (i + 1) / 1e5])) }];
120        }),
121      ),
122    );
123    const r = await pickSound(many(1000), 'wind', { ask });
124    const finalQ = ask.mock.calls.at(-1)[0].sound;
125    // four banks, three finalists each
126    expect(Object.keys(finalQ.criteria)).toHaveLength(12);
127    expect(r).toMatchObject({ candidates: 1000, banks: 4, scope: 'finalists', calls: ask.mock.calls.length });
128    expect(r.calls).toBeGreaterThanOrEqual(2);
129  });
130
131  it("refuses an answer that is not a sound, and too many sounds to ask about", async () => {
132    await expect(pickSound(many(3), 'x', { ask: async () => ({ sound: { choice: 'toString', probabilities: {} } }) })).rejects.toThrow(/not one of the sounds/);
133    await expect(pickSound(many(16 * 255 + 1), 'x', { ask: vi.fn() })).rejects.toThrow(/narrow/);
134    await expect(pickSound([], 'x', { ask: vi.fn() })).rejects.toThrow(/no sounds match/);
135    await expect(pickSound(many(3), '  ', { ask: vi.fn() })).rejects.toThrow(/say what/);
136  });
137});
138
139describe('the reference (reference.mjs)', () => {
140  const docs = [
141    { name: 'lpf', description: '<p>Low <strong>pass</strong> &amp; more.</p>\n<p>Second.</p>', tags: ['filter', 'superdough'], synonyms: ['cutoff', 'lp'], params: [{ name: 'f', type: { names: ['number', 'Pattern'] }, description: '<p>hz</p>' }], examples: ['s("bd").lpf(300)'] },
142    { name: 'cutoff', description: '<p>dup</p>' },
143    { name: '_internal', description: 'x' },
144    { name: 'nodesc' },
145    { name: 'dirtonly', description: 'x', tags: ['superdirt'] },
146    { name: 'a', description: 'first', tags: [7] },
147  ];
148  it('lists what the reference tab lists, each once', () => {
149    const fns = referenceFunctions(docs);
150    expect(fns.map((f) => f.name)).toEqual(['a', 'lpf']);
151    expect(fns[1].synonyms).toEqual(['cutoff', 'lp']);
152    expect(fns[0].tags).toEqual([]);
153  });
154  it('serves them as text', () => {
155    expect(referenceData(docs)[1]).toEqual({
156      name: 'lpf',
157      synonyms: ['cutoff', 'lp'],
158      tags: ['filter', 'superdough'],
159      description: 'Low pass & more.\n\nSecond.',
160      params: [{ name: 'f', type: 'number | Pattern', description: 'hz' }],
161      examples: ['s("bd").lpf(300)'],
162    });
163    expect(htmlText('<a href="x">a</a>&lt;b&gt;')).toBe('a<b>');
164  });
165});
166
167describe('the tab tools (tabTools.mjs)', () => {
168  it('answers only its own commands', async () => {
169    expect(await runTabTool(editor(), { type: 'play', code: 'x' }, deps())).toBeUndefined();
170    expect(TAB_TOOLS.has('list-sounds')).toBe(true);
171    expect(await runTabTool(null, { type: 'get-settings' }, deps())).toEqual({ error: 'the editor is not ready' });
172  });
173
174  it('lists a page of sounds, with the total', async () => {
175    const r = await runTabTool(editor(), { type: 'list-sounds', category: 'samples', offset: 1, limit: 1 }, deps());
176    expect(r).toEqual({ sounds: { total: 3, items: [{ name: 'jeff', category: 'samples', type: 'sample', variants: 1, use: 's("jeff")' }] } });
177    await expect(runTabTool(editor(), { type: 'list-sounds', category: 'nope' }, deps())).rejects.toThrow(/category/);
178  });
179
180  it("asks Jev through the page's own askJev", async () => {
181    const d = deps();
182    const r = await runTabTool(editor(), { type: 'ask-sound', description: 'a kick', category: 'drum-machines' }, d);
183    expect(d.askJev).toHaveBeenCalledTimes(0); // one sound: nothing to ask
184    expect(r.pick.picks[0].name).toBe('RolandTR808_bd');
185    const r2 = await runTabTool(editor(), { type: 'ask-sound', description: 'a kick' }, d);
186    expect(d.askJev).toHaveBeenCalledTimes(1);
187    expect(r2.pick).toMatchObject({ candidates: 7, calls: 1, scope: 'all' });
188    expect(await runTabTool(editor(), { type: 'ask-sound', description: '' }, d)).toEqual({ error: 'description is what sound you want' });
189  });
190
191  it("reads only the allowed settings, and changes only those", async () => {
192    const d = deps();
193    expect(await runTabTool(editor(), { type: 'get-settings' }, d)).toEqual({
194      settings: { theme: 'strudelTheme', fontSize: 18 },
195      themes: ['strudelTheme', 'dracula'],
196    });
197    expect(await runTabTool(editor(), { type: 'set-settings', changes: { prebakeScript: 'fetch()' } }, d)).toMatchObject({ error: expect.stringMatching(/not a setting/) });
198    expect(await runTabTool(editor(), { type: 'set-settings', changes: { theme: 'nope' } }, d)).toMatchObject({ error: expect.stringMatching(/no theme nope/) });
199    expect(d.settings.setKey).not.toHaveBeenCalled();
200    const r = await runTabTool(editor(), { type: 'set-settings', changes: { theme: 'dracula', isZen: true } }, d);
201    expect(d.settings.setKey.mock.calls).toEqual([['theme', 'dracula'], ['isZen', true]]);
202    expect(r.settings).toMatchObject({ theme: 'dracula', isZen: true });
203    expect(r.settings).not.toHaveProperty('prebakeScript');
204  });
205
206  it("reads the console tab's lines as text", async () => {
207    expect(await runTabTool(editor(), { type: 'get-console' }, deps())).toEqual({
208      logs: [
209        { at: 0, message: 'loaded <b>', kind: 'success', count: 2 },
210        { at: 0, message: 'plain', kind: '', count: 1 },
211      ],
212    });
213  });
214
215  it("plays a site song in the page from the page's own build, as its card does", async () => {
216    const d = deps();
217    const ed = editor();
218    expect(await runTabTool(ed, { type: 'play-song', id: 'jev/dial-up' }, d)).toEqual({ song: { id: 'jev/dial-up', title: 'Dial-Up', where: 'page' } });
219    expect(d.playSong).toHaveBeenCalledWith(ed, expect.objectContaining({ id: 'jev/dial-up', code: 's("bd")' }));
220    // not in this build, or only listed: refused, nothing played
221    expect(await runTabTool(ed, { type: 'play-song', id: 'jev/no-code' }, d)).toEqual({ error: 'this tab has no song jev/no-code' });
222    expect(await runTabTool(ed, { type: 'play-song', id: 's("bd")' }, d)).toMatchObject({ error: expect.stringMatching(/id is/) });
223    expect(await runTabTool(ed, { type: 'play-song', id: 'jev/dial-up' }, deps({ audioRunning: () => false }))).toMatchObject({ error: expect.stringMatching(/click the page/) });
224    ed.repl.state.evalError = new Error('boom');
225    expect(await runTabTool(ed, { type: 'play-song', id: 'jev/dial-up' }, d)).toEqual({ error: 'boom' });
226    expect(d.playSong).toHaveBeenCalledTimes(2);
227  });
228
229  it("plays a listener's song in the sandbox, never in the page", async () => {
230    const d = deps();
231    const id = 'A'.repeat(22);
232    expect(await runTabTool(editor(), { type: 'play-song', id: `listener:${id}` }, d)).toEqual({ song: { id: `listener:${id}`, title: 'Theirs', where: 'sandbox' } });
233    expect(d.loadListener).toHaveBeenCalledWith(id);
234    expect(d.showListener).toHaveBeenCalled();
235    expect(d.playForeign).toHaveBeenCalledWith('s("hh")');
236    expect(d.playSong).not.toHaveBeenCalled();
237    const failing = deps({ playForeign: async () => ({ error: 'the sandbox did not start' }) });
238    expect(await runTabTool(editor(), { type: 'play-song', id: `listener:${id}` }, failing)).toEqual({ error: 'the sandbox did not start' });
239  });
240
241  it("asks the mood picker, playing its pick only when told", async () => {
242    const d = deps();
243    expect(await runTabTool(editor(), { type: 'ask-song', mood: 'sleepy' }, d)).toEqual({ song: { id: 'jev/dial-up', title: 'Dial-Up', p: 0.4, confidence: 0.5 } });
244    expect(d.playSong).not.toHaveBeenCalled();
245    expect((await runTabTool(editor(), { type: 'ask-song', mood: 'sleepy', play: true }, d)).song.where).toBe('page');
246    expect(d.playSong).toHaveBeenCalledTimes(1);
247    expect(await runTabTool(editor(), { type: 'ask-song', mood: ' ' }, d)).toEqual({ error: 'mood is how the listener feels' });
248  });
249
250  it('presses 🔥/😴 for the section playing, as the booth does', async () => {
251    const d = deps();
252    const ed = editor();
253    expect(await runTabTool(ed, { type: 'react', kind: 'fire' }, d)).toEqual({ reaction: { section: '2', kind: 'fire', counted: true } });
254    // cycle 5 at every 4 is the second section
255    expect(d.press).toHaveBeenCalledWith(expect.anything(), 1, 'fire', ed);
256    expect(await runTabTool(ed, { type: 'react', kind: 'fire' }, deps({ cycle: () => null }))).toMatchObject({ error: expect.stringMatching(/no section/) });
257    expect(await runTabTool(ed, { type: 'react', kind: 'meh' }, d)).toEqual({ error: 'kind is fire or sleep' });
258  });
259});