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> <b>', 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> & 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><b>')).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});