jevstrudel.git / worker / src / shared-mcp.test.ts
1// The tools both MCPs share (shared-mcp.ts), apart from either MCP: what
2// each checks before a tab is asked, what it asks the tab, how a tab's reply
3// is bounded before it is read, and the two that need no tab (the reference
4// and a vote). hosted-mcp.test.ts holds their scopes, mcp.test.ts the dev hub.
5import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6import { testD1 } from '../test/d1';
7import { SETTINGS } from './mcp-settings.mjs';
8import { forgetReference } from './reference';
9import { runShared, SHARED_TOOLS, type SharedContext } from './shared-mcp';
10import type { SharedCommand } from './tab-hub-core';
11import { forgetSongs } from './votes';
12
13const FNS = [
14  { name: 'lpf', synonyms: ['cutoff', 'ctf', 'lp'], tags: ['filter'], description: 'Applies the cutoff frequency of the low-pass filter. More text.', params: [{ name: 'frequency', type: 'number | Pattern', description: 'audible between 0 and 20000' }], examples: ['s("bd*4").lpf(800)'] },
15  { name: 'hpf', synonyms: ['hp'], tags: ['filter'], description: 'Applies the cutoff frequency of the high-pass filter.', params: [], examples: [] },
16  { name: 'fast', synonyms: [], tags: ['temporal'], description: 'Speeds up a pattern.', params: [{ name: 'factor', type: 'number', description: '' }], examples: ['s("bd sd").fast(2)'] },
17];
18
19let db: D1Database;
20let asked: SharedCommand[];
21let answer: (c: SharedCommand) => Record<string, unknown>;
22let voteKeys: string[];
23let voteLimited: boolean;
24
25const env = () =>
26  ({
27    DB: db,
28    SONGS_URL: 'http://songs.invalid/jev/songs.json',
29    VOTE_LIMIT: { limit: async ({ key }: { key: string }) => (voteKeys.push(key), { success: !voteLimited }) },
30  }) as never;
31const ctx = (over: Partial<SharedContext> = {}): SharedContext => ({
32  env: env(),
33  voter: 'user:u1',
34  hosted: true,
35  tab: async (c) => {
36    asked.push(c);
37    return answer(c);
38  },
39  ...over,
40});
41const run = (name: string, args: Record<string, unknown> = {}, over?: Partial<SharedContext>) => runShared(ctx(over), name, args);
42
43beforeEach(() => {
44  db = testD1();
45  asked = [];
46  voteKeys = [];
47  voteLimited = false;
48  answer = () => ({ ok: true });
49  forgetSongs();
50  forgetReference();
51  vi.spyOn(console, 'error').mockImplementation(() => {});
52  vi.stubGlobal('fetch', async (input: RequestInfo | URL) => {
53    const url = String(input instanceof Request ? input.url : input);
54    if (url === 'http://songs.invalid/jev/songs.json') return Response.json({ songs: ['jev/dial-up', 'jev/hey-listen', 'jev/no-score'] });
55    if (url === 'http://songs.invalid/jev/catalog.json') {
56      return Response.json({
57        songs: [
58          { id: 'jev/dial-up', title: 'Dial-Up', theme: 'jev', description: '', art: 0.8, seconds: 1 },
59          { id: 'jev/hey-listen', title: 'Hey Listen', theme: 'jev', description: '', art: 0.6, seconds: 1 },
60          { id: 'jev/no-score', title: 'No Score', theme: 'jev', description: '', art: null, seconds: 1 },
61        ],
62      });
63    }
64    if (url === 'http://songs.invalid/jev/reference.json') return Response.json({ functions: FNS });
65    throw new Error(`unexpected fetch ${url}`);
66  });
67});
68afterEach(() => {
69  vi.unstubAllGlobals();
70  vi.restoreAllMocks();
71});
72
73describe('the definitions', () => {
74  it('are small, and each names its scope', () => {
75    for (const t of SHARED_TOOLS) {
76      expect(t.description.length).toBeLessThanOrEqual(240);
77      expect([null, 'play', 'publish']).toContain(t.scope);
78    }
79    // what needs no tab needs no scope, and every tab tool needs play
80    expect(SHARED_TOOLS.filter((t) => t.scope === null).map((t) => t.name).sort()).toEqual(['api_reference', 'vote']);
81  });
82});
83
84describe('list_sounds', () => {
85  it('asks the tab for one page and says where the next begins', async () => {
86    answer = () => ({
87      sounds: { total: 3, items: [{ name: 'RolandTR808_bd', category: 'drum-machines', type: 'sample', variants: 3, use: 's("bd").bank("RolandTR808")' }] },
88    });
89    const text = await run('list_sounds', { category: 'drum-machines', filter: '808', limit: 1, cursor: '1' });
90    expect(asked).toEqual([{ type: 'list-sounds', category: 'drum-machines', filter: '808', offset: 1, limit: 1 }]);
91    expect(text).toBe('3 drum-machines sounds matching "808"; 2 to 2 (next: cursor "2"):\nRolandTR808_bd (drum-machines, 3 variants): s("bd").bank("RolandTR808")');
92  });
93
94  it('refuses a category, limit or cursor it does not know, before asking the tab', async () => {
95    await expect(run('list_sounds', { category: 'drums' })).rejects.toThrow(/category is one of/);
96    await expect(run('list_sounds', { limit: 0 })).rejects.toThrow(/limit is 1 to 200/);
97    await expect(run('list_sounds', { limit: 201 })).rejects.toThrow(/limit/);
98    await expect(run('list_sounds', { cursor: '-3' })).rejects.toThrow(/cursor/);
99    await expect(run('list_sounds', { filter: 'x'.repeat(65) })).rejects.toThrow(/filter/);
100    expect(asked).toEqual([]);
101  });
102
103  it("bounds what the tab sends: at most 200 sounds, each field cut", async () => {
104    answer = () => ({
105      sounds: { total: 1e9, items: Array.from({ length: 500 }, (_, i) => ({ name: `s${i}${'x'.repeat(300)}`, category: 'samples', type: 'sample', variants: -4, use: 'y'.repeat(900), extra: 'dropped' })) },
106    });
107    const lines = (await run('list_sounds')).split('\n');
108    expect(lines).toHaveLength(201);
109    expect(lines[1].length).toBeLessThan(128 + 300 + 40);
110    expect(lines[0]).toMatch(/^1000000 all sounds; 1 to 200/);
111  });
112
113  it("says, hosted, that imported sounds do not reach the sandbox", async () => {
114    answer = () => ({ sounds: { total: 1, items: [{ name: 'swoop', category: 'imported', type: 'sample', variants: 3, use: 's("swoop")' }] } });
115    expect(await run('list_sounds', { category: 'imported' })).toMatch(/sandbox your code plays in cannot/);
116    expect(await run('list_sounds', { category: 'imported' }, { hosted: false })).not.toMatch(/sandbox/);
117  });
118
119  it("passes on the tab's error", async () => {
120    answer = () => ({ error: 'the editor is not ready' });
121    await expect(run('list_sounds')).rejects.toThrow('the editor is not ready');
122  });
123});
124
125describe('ask_jev_sound', () => {
126  it('asks the tab, and says when the odds are among finalists only', async () => {
127    answer = () => ({
128      pick: {
129        picks: [
130          { name: 'gm_electric_guitar_muted', category: 'synths', type: 'soundfont', variants: 2, use: 'note("c3 e3 g3").s("gm_electric_guitar_muted")', p: 0.62 },
131          { name: 'gm_electric_guitar_clean', category: 'synths', type: 'soundfont', variants: 1, use: 'x', p: 0.2 },
132        ],
133        confidence: 0.6,
134        candidates: 1432,
135        banks: 6,
136        calls: 3,
137        scope: 'finalists',
138      },
139    });
140    const text = await run('ask_jev_sound', { description: 'a muted funk guitar', category: 'synths' });
141    expect(asked).toEqual([{ type: 'ask-sound', description: 'a muted funk guitar', category: 'synths', filter: undefined }]);
142    expect(text).toMatch(/^Jev picks gm_electric_guitar_muted \(synths soundfont, 2 variants\): note\("c3 e3 g3"\)\.s\("gm_electric_guitar_muted"\) \(62%\)/);
143    expect(text).toMatch(/1432 sounds in 6 banks, 3 Jev calls: the percentages are among the 6 banks' finalists/);
144  });
145
146  it('refuses an empty or long description before any Jev call', async () => {
147    await expect(run('ask_jev_sound', {})).rejects.toThrow(/description/);
148    await expect(run('ask_jev_sound', { description: 'x'.repeat(281) })).rejects.toThrow(/description/);
149    expect(asked).toEqual([]);
150  });
151
152  it('keeps a probability only when it is one', async () => {
153    answer = () => ({ pick: { picks: [{ name: 'bd', category: 'samples', type: 'sample', variants: 1, use: 's("bd")', p: 7 }], candidates: 2, calls: 1, scope: 'all' } });
154    expect(await run('ask_jev_sound', { description: 'a kick' })).toBe('Jev picks bd (samples): s("bd")\nAsked among 2 sounds, 1 Jev call.');
155  });
156});
157
158describe('api_reference', () => {
159  it('searches names first, then descriptions', async () => {
160    const text = await run('api_reference', { query: 'filter' });
161    expect(text.split('\n')[0]).toBe('2 of 3 functions match; api_reference with name for one whole:');
162    expect(text).toContain('lpf (cutoff, ctf, lp): Applies the cutoff frequency of the low-pass filter.');
163    expect(await run('api_reference', { query: 'fast' })).toMatch(/^1 of 3.*\nfast: Speeds up a pattern\.$/);
164    expect(await run('api_reference', { query: 'nothing-like-it' })).toMatch(/nothing in the reference matches/);
165  });
166
167  it('finds functions matching any of several words, a name before a mention', async () => {
168    // one query naming several functions finds them all ("jev noul choice score walk", 2026-09-27)
169    const text = await run('api_reference', { query: 'filter fast, nothing-like-it' });
170    expect(text.split('\n')).toEqual([
171      '3 of 3 functions match, best first; api_reference with name for one whole:',
172      'fast: Speeds up a pattern.',
173      'hpf (hp): Applies the cutoff frequency of the high-pass filter.',
174      'lpf (cutoff, ctf, lp): Applies the cutoff frequency of the low-pass filter.',
175    ]);
176    // a function matching more of the words comes first among equal weights
177    expect((await run('api_reference', { query: 'low-pass cutoff' })).split('\n')[1]).toMatch(/^lpf/);
178  });
179
180  it('gives one function whole, by name or synonym, and suggests near names', async () => {
181    const lpf = await run('api_reference', { name: 'cutoff' });
182    expect(lpf).toBe(
183      'lpf(frequency)\nsynonyms: cutoff, ctf, lp\ntags: filter\n\nApplies the cutoff frequency of the low-pass filter. More text.\n\nparameters:\n- frequency (number | Pattern): audible between 0 and 20000\n\nexamples:\ns("bd*4").lpf(800)',
184    );
185    await expect(run('api_reference', { name: 'pf' })).rejects.toThrow(/no function pf; did you mean lpf, hpf/);
186    await expect(run('api_reference', {})).rejects.toThrow(/query .* or name/);
187  });
188
189  it('needs no tab', async () => {
190    await run('api_reference', { query: 'fast' });
191    expect(asked).toEqual([]);
192  });
193});
194
195describe('settings', () => {
196  it('reads only the settings an MCP may see, whatever the tab sends', async () => {
197    answer = () => ({ settings: { theme: 'dracula', fontSize: '22', isZen: 'true', prebakeScript: 'fetch("/x")', audioEngineTarget: 'osc' }, themes: ['dracula', 7] });
198    expect(JSON.parse(await run('get_settings'))).toEqual({ settings: { theme: 'dracula', fontSize: 22, isZen: true }, themes: ['dracula'] });
199  });
200
201  it('never sends a change to a setting that runs or routes anything', async () => {
202    for (const changes of [
203      { prebakeScript: 'x' },
204      { audioEngineTarget: 'osc' },
205      { isSyncEnabled: true },
206      { patternAutoStart: true },
207      { userPatterns: '{}' },
208      { theme: 'dracula', latestCode: 's("x")' },
209      { fontSize: 99 },
210      { keybindings: 'nano' },
211      { isZen: 'yes' },
212      {},
213      'theme=dracula',
214    ]) {
215      await expect(run('set_settings', { changes })).rejects.toThrow();
216    }
217    expect(asked).toEqual([]);
218    expect(Object.keys(SETTINGS)).not.toContain('prebakeScript');
219  });
220
221  it('sends an allowed change and reports the result', async () => {
222    answer = () => ({ settings: { theme: 'dracula', fontSize: 22 } });
223    expect(await run('set_settings', { changes: { theme: 'dracula', fontSize: 22 } })).toBe('changed theme, fontSize; now: {"theme":"dracula","fontSize":22}');
224    expect(asked).toEqual([{ type: 'set-settings', changes: { theme: 'dracula', fontSize: 22 } }]);
225  });
226});
227
228describe('songs', () => {
229  it('plays a song by id, and says where', async () => {
230    answer = (c) => ({ song: { id: (c as { id: string }).id, title: 'Dial-Up', where: 'page' } });
231    expect(await run('play_song', { id: 'jev/dial-up' })).toBe('playing Dial-Up (jev/dial-up) in the page, from the top');
232    answer = () => ({ song: { id: 'listener:abc', title: 'Theirs', where: 'sandbox' } });
233    expect(await run('play_song', { id: 'listener:abc' })).toMatch(/in its sandbox/);
234    await expect(run('play_song', { id: 7 })).rejects.toThrow(/id is/);
235  });
236
237  it("asks the mood picker, and plays Jev's pick only when told", async () => {
238    answer = (c) => ({ song: { id: 'jev/dial-up', title: 'Dial-Up', p: 0.41, where: (c as { play: boolean }).play ? 'page' : null } });
239    expect(await run('ask_jev_song', { mood: 'sleepy' })).toBe('Jev picks Dial-Up (jev/dial-up) (41%)');
240    expect(await run('ask_jev_song', { mood: 'sleepy', play: true })).toBe('Jev picks Dial-Up (jev/dial-up) (41%); playing it in the page, from the top');
241    expect(asked).toEqual([
242      { type: 'ask-song', mood: 'sleepy', play: false },
243      { type: 'ask-song', mood: 'sleepy', play: true },
244    ]);
245    await expect(run('ask_jev_song', { mood: '' })).rejects.toThrow(/mood/);
246    await expect(run('ask_jev_song', { mood: 'x', play: 'yes' })).rejects.toThrow(/play/);
247  });
248
249  it('reacts to the section playing', async () => {
250    answer = () => ({ reaction: { section: 'drop', kind: 'fire', counted: true } });
251    expect(await run('react', { kind: 'fire' })).toBe("🔥 for section drop: Jev hears it before its next choice, and it counts in the song's tally");
252    await expect(run('react', { kind: 'love' })).rejects.toThrow(/fire or sleep/);
253  });
254});
255
256describe('vote', () => {
257  const count = () => db.prepare('SELECT a, b, pick, n FROM votes').all().then((r) => r.results);
258
259  it("counts through the votes' own rules, and says whether you agree with Jev", async () => {
260    expect(await run('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/dial-up' })).toBe(
261      'counted: jev/dial-up over jev/hey-listen; Jev scores jev/dial-up 80% and jev/hey-listen 60%, so you agree with Jev',
262    );
263    expect(await run('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/hey-listen' })).toMatch(/disagree/);
264    expect(await run('vote', { a: 'jev/dial-up', b: 'jev/no-score', pick: 'jev/no-score' })).toMatch(/no preference/);
265    expect(await count()).toHaveLength(3);
266    expect(voteKeys).toEqual(['user:u1', 'user:u1', 'user:u1']);
267    expect(asked).toEqual([]);
268  });
269
270  it('refuses what the page could not vote, and holds the rate per account', async () => {
271    await expect(run('vote', { a: 'jev/dial-up', b: 'jev/nope', pick: 'jev/dial-up' })).rejects.toThrow(/not a song/);
272    await expect(run('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/no-score' })).rejects.toThrow(/a or b/);
273    voteLimited = true;
274    await expect(run('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/dial-up' })).rejects.toThrow(/too many votes/);
275    expect(await count()).toEqual([]);
276  });
277});