import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { germanDate } from '../src/core/dates.ts'; import { argumentText, dayPrompt, examPrompt, resolveDay, resolveTarget, summaryPrompt, type Target } from '../src/mcp/prompts.ts'; import { ProtocolError } from '../src/mcp/tools/result.ts'; const CANDIDATES: Target[] = [ { kind: 'course', id: '65a000000000000000000001', name: 'LF07 - FIA24A/B - Sb/Ha' }, { kind: 'course', id: '65a000000000000000000002', name: 'FIA24B - SK (Rh)' }, { kind: 'course', id: '65a000000000000000000003', name: 'FIA24A - SK (Rh)' }, { kind: 'course', id: '65a000000000000000000004', name: 'Deutsch' }, { kind: 'course', id: '65a000000000000000000005', name: 'Deutsch Förderkurs' }, { kind: 'room', id: '65a000000000000000000006', name: 'Prüfungsgruppe AP1' }, // Real naming, from one live account: the same codes, spaced differently. { kind: 'course', id: '65a000000000000000000007', name: 'FIA24B - IT- LF1 (Wm)' }, { kind: 'course', id: '65a000000000000000000008', name: 'FIA24B LF10 (VH)' }, { kind: 'course', id: '65a000000000000000000009', name: 'FIA24/FIP24 IT LF10 Ra/ZZ-Ri' }, { kind: 'course', id: '65a00000000000000000000a', name: 'FIA24B LF 11 Richter' }, ]; function refusal(query: string): ProtocolError { try { resolveTarget(query, CANDIDATES); } catch (error) { assert.ok(error instanceof ProtocolError, 'refusals are protocol errors'); assert.equal(error.code, ErrorCode.InvalidParams); return error; } assert.fail(`"${query}" should have been refused`); } describe('argumentText', () => { it('turns the underscores a joined value needs back into spaces', () => { assert.equal(argumentText('Erbrecht_und__Testament'), 'Erbrecht und Testament'); }); it('treats a dash, blanks and absence as not given', () => { assert.equal(argumentText('-'), undefined); assert.equal(argumentText(' _ '), undefined); assert.equal(argumentText(undefined), undefined); }); }); describe('resolveTarget', () => { it('takes an id as it is', () => { assert.equal(resolveTarget('65a000000000000000000003', CANDIDATES).name, 'FIA24A - SK (Rh)'); }); it('finds a course by a fragment, without the slashes of its name', () => { assert.equal(resolveTarget('lf07', CANDIDATES).id, '65a000000000000000000001'); }); it('reads words joined with underscores, as Claude Code has to send them', () => { assert.equal(resolveTarget('FIA24B_SK', CANDIDATES).id, '65a000000000000000000002'); }); it('prefers a name matched word for word over names that contain it', () => { // Otherwise "Deutsch" could never be chosen while "Deutsch Förderkurs" exists. assert.equal(resolveTarget('deutsch', CANDIDATES).id, '65a000000000000000000004'); }); it('prefers whole words, so LF1 is not ambiguous with LF10', () => { assert.equal(resolveTarget('LF1', CANDIDATES).id, '65a000000000000000000007'); }); it('still reports two courses that share the whole word', () => { assert.match(refusal('LF10').message, /passt auf 2 Einträge/); }); it('finds a code the teacher wrote with a space in it', () => { assert.equal(resolveTarget('LF11', CANDIDATES).id, '65a00000000000000000000a'); }); it('ignores umlauts, like search does', () => { assert.equal(resolveTarget('Forderkurs', CANDIDATES).id, '65a000000000000000000005'); }); it('includes rooms', () => { const room = resolveTarget('AP1', CANDIDATES); assert.equal(room.kind, 'room'); }); it('refuses an ambiguous fragment and lists the candidates with their ids', () => { const error = refusal('SK'); assert.match(error.message, /passt auf 2 Einträge/); assert.match(error.message, /FIA24A - SK \(Rh\) \(Kurs, ID 65a000000000000000000003\)/); assert.match(error.message, /FIA24B - SK \(Rh\)/); }); it('refuses a fragment that matches nothing and says what exists', () => { const error = refusal('Mathe_10b'); assert.match(error.message, /Kein Kurs und kein Raum passt zu „Mathe 10b“/); assert.match(error.message, /Prüfungsgruppe AP1 \(Raum\)/); }); it('refuses a query with nothing to match on', () => { assert.match(refusal(' - ').message, /Gib einen Kurs oder Raum an/); }); }); describe('summaryPrompt', () => { const course = CANDIDATES[1]!; const room = CANDIDATES[5]!; it('names the course, sends Claude to its file area and asks for German', () => { const text = summaryPrompt(course); assert.match(text, /Fasse den Kurs „FIA24B - SK \(Rh\)“ für mich zusammen/); assert.match(text, /fs_tree mit dem Pfad "\/courses\/65a000000000000000000002"/); assert.match(text, /\*\*Aufgaben:\*\*/); assert.match(text, /Antworte auf Deutsch\./); assert.doesNotMatch(text, /Konzentriere dich/); }); it('leaves out what a room does not have: topics, tasks, course files', () => { const text = summaryPrompt(room); assert.match(text, /Fasse den Raum „Prüfungsgruppe AP1“/); assert.doesNotMatch(text, /get_lesson|get_task|fs_tree|\*\*Aufgaben:\*\*/); }); it('numbers the steps without gaps when some are left out', () => { const text = summaryPrompt(room, 'Netzwerke'); assert.match(text, /1\. Lies die Boards des Raums mit get_board\.\n2\. Wenn es sehr viel Material gibt/); assert.match(text, /3\. Konzentriere dich auf: Netzwerke\./); }); }); describe('examPrompt', () => { const course = CANDIDATES[0]!; it('carries the topic, the date and today, and plans day by day', () => { const text = examPrompt(course, { topic: 'Subnetting', date: '02.10.2026', today: 'Dienstag, 15.09.2026' }); assert.match(text, /Prüfung im Kurs „LF07 - FIA24A\/B - Sb\/Ha“/); assert.match(text, /Thema der Prüfung: Subnetting/); assert.match(text, /Prüfungstermin: 02\.10\.2026 \(heute ist Dienstag, 15\.09\.2026\)\./); assert.match(text, /Mit search findest du das Thema/); assert.match(text, /\*\*Lernplan:\*\* Tag für Tag bis zur Prüfung/); assert.match(text, /list_submissions für diesen Kurs/); }); it('asks Claude to work out the scope when no topic is given', () => { const text = examPrompt(course, { today: 'Dienstag, 15.09.2026' }); assert.match(text, /Das Thema der Prüfung steht noch nicht fest/); assert.doesNotMatch(text, /Prüfungstermin|Mit search/); assert.match(text, /\*\*Lernplan:\*\* eine sinnvolle Reihenfolge der Themen/); }); }); describe('germanDate', () => { it('names the weekday and uses German time, not UTC', () => { // 23:30 UTC on the 14th is already the 15th in Germany. assert.equal(germanDate(new Date('2026-09-14T23:30:00Z')), 'Dienstag, 15.09.2026'); }); }); describe('resolveDay', () => { const today = '2026-09-17'; it('takes the words a person types in the evening', () => { assert.equal(resolveDay(undefined, today), today); assert.equal(resolveDay('heute', today), today); assert.equal(resolveDay('morgen', today), '2026-09-18'); assert.equal(resolveDay('Übermorgen', today), '2026-09-19'); assert.equal(resolveDay('uebermorgen', today), '2026-09-19'); }); it('takes a date in either notation, because a German keyboard writes one of them', () => { assert.equal(resolveDay('2026-09-21', today), '2026-09-21'); assert.equal(resolveDay('21.09.2026', today), '2026-09-21'); assert.equal(resolveDay('1.9.2026', today), '2026-09-01'); }); it('refuses anything else, in German', () => { assert.throws(() => resolveDay('irgendwann', today), /ist kein Tag/); assert.throws(() => resolveDay('2026-02-30', today), /ist kein Tag/); }); }); describe('dayPrompt', () => { it('names the day and keeps the two lists of due work apart', () => { const prompt = dayPrompt('2026-09-21'); assert.match(prompt, /Montag, 21\.09\.2026/); assert.match(prompt, /list_tasks/); assert.match(prompt, /untis_homework/); assert.match(prompt, /zwei getrennte Listen/); // The prompt has to survive a day without lessons, which is common here. assert.match(prompt, /kein Unterricht/); assert.match(prompt, /Antworte auf Deutsch/); }); });