The container runs on UTC and the school does not: at 00:30 in Erfurt the process clock still says yesterday, so anything deriving "today" from a Date would prepare the wrong school day twice a night. core/dates.ts holds calendar dates as plain YYYY-MM-DD strings, which is what a school day is — today in Europe/Berlin, whole-day arithmetic anchored at noon UTC so no daylight-saving change can shift a date, the German weekday and day formats, and the compact form the timetable API takes. isCalendarDate round-trips rather than only matching a shape: Date.parse turns 2026-02-30 into March 2nd instead of refusing it, so a shape check alone would let a caller read a different day than it asked for. germanDate moves here from mcp/prompts.ts, its only previous home, so the timezone is stated in one place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
6.5 KiB
TypeScript
156 lines
6.5 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, examPrompt, 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');
|
|
});
|
|
});
|
|
|