jevstrudel.git / website / src / jev / tabTools.test.mjs
1// The tab's end of the tools both MCPs share (tabTools.mjs), with stand-ins
2// for the page's modules: what each answers from the panel's data, that Jev
3// is asked through the page's own askJev in banks when there are more than
4// 255 sounds, that settings outside the allowlist never reach the store,
5// 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';
11
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});