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> <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});