Files
Schulcloud-MCP/test/prompts.test.ts
MechaCat02 eaf9c7aa38 Add a German prompt that prepares a school day
Tagesvorbereitung is where the two systems meet. It attaches the day's
timetable and then asks for the rest: match each lesson to its Schulcloud
course, read what is new there, look at what the previous lesson covered,
check both lists of due work, and read the notes on the periods, where the
announced tests are. Written for the morning before school, so it asks for
something short.

The argument takes heute, morgen, übermorgen or a date in either notation,
because evening preparation is the normal case and a German keyboard writes
21.09.2026. Registered only with WebUntis configured: a Tagesvorbereitung
that has to ask which lessons exist is not one.

readTimetable is shared with untis_timetable, the way readCourse is shared
with get_course, so an attached day reads exactly like a fetched one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:43:24 +02:00

191 lines
7.8 KiB
TypeScript

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