Files
Schulcloud-MCP/test/untis-history.test.ts
MechaCat02 af4464decb Read the user's own lesson notes, and the class register behind them
Schulcloud says what was uploaded and WebUntis says what was scheduled.
Neither says what was *taught* — which point the teacher laboured, which
example landed, what "will definitely come up". That lives in two places
this server could not reach: the notes the user takes in the lesson, and
WebUntis' class register.

Notes are a directory of Markdown files (NOTES_DIR), not a table. They
have to be writable from a phone in a classroom, readable when Postgres
is down, and outlive this project, and files are the only shape that is
all three — so the files are the truth and the index is a view of them,
the same split as file_texts and the mirror. list_notes and get_note read
disk, so they answer before the first crawl; search, what_changed and all
three German prompts read them alongside the Schulcloud material.

add_note writes one, and is the only thing in this server that writes
anything. That is not a hole in the read-only invariant but a different
store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin
pair that stops a hostile Schulcloud filename escaping the mirror, so a
note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and
WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY
refuses writes outright.

Appending targets the *lesson*, not the title: "halt das auch noch fest"
mid-lesson carries a new title, and deriving the path from it would start
a second note every time, which is the one thing append exists to prevent.

Notes.app has no export — its bodies are compressed protobuf and the
iCloud copy is encrypted — so scripting the app is not the clumsy route
to the notes but the only one. scripts/export-apple-notes.js reads them
through AppleScript into one JSON object per line, and `schulcloud note
import` converts the HTML to Markdown, takes the Notes folder as the
subject and the *creation* date as the lesson's date. Attachments cannot
come across; a note that was a photo of the board imports as a line
saying so, because importing it empty would hide the loss.

The class register needed one API property to become cheap:
getLessonTopic2017 answers per *series*, not per period, so a term is
reconstructed by asking about the latest period of each lesson series and
merging back by id — a few dozen calls for a school year rather than one
per lesson. untis_lesson_topics now takes a subject as well as a period
id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind
of its own, so "what did we actually do before the test" is searchable.

Sharing the snapshot rather than duplicating it caught one thing on the
way: the search tool's live path had to learn notes too, or fresh=true
would have quietly disagreed with the index.

305 tests; 88/89 smoke against the local instance, the one failure being
the H5P service that instance does not run. The live smoke could not be
retaken: that session has lapsed and needs a fresh jwt cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:46:26 +02:00

161 lines
7.0 KiB
TypeScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { chunkRange, collectLessonLog, hasContent, lessonLogText } from '../src/core/untis-history.ts';
import type { UntisClient, UntisLesson, UntisTimetable, UntisTopic } from '../src/core/untis.ts';
/**
* The class register, read backwards. The client is a stand-in: what is under
* test is which periods are asked about and how the two halves are merged, not
* the JSON-RPC layer, which test/untis.test.ts already covers.
*/
function lesson(overrides: Partial<UntisLesson> & { periodId: number; lessonId: number; date: string }): UntisLesson {
return {
start: '08:00',
end: '08:45',
statuses: ['REGULAR'],
cancelled: false,
changed: false,
subjects: [{ name: 'DE', longName: 'Deutsch' }],
teachers: [{ name: 'MEI', longName: 'Meier' }],
rooms: [],
classes: [],
replaced: { subjects: [], teachers: [], rooms: [] },
notes: {},
homework: [],
online: false,
...overrides,
};
}
function client(lessons: UntisLesson[], topics: Record<number, UntisTopic[]>, seen?: { periods: number[] }): UntisClient {
return {
async timetable(from: string, to: string): Promise<UntisTimetable> {
const days = [...new Set(lessons.map((entry) => entry.date))].filter((date) => date >= from && date <= to);
return { from, to, days: days.map((date) => ({ date, lessons: lessons.filter((l) => l.date === date), holidays: [] })) };
},
async lessonTopics(periodId: number): Promise<UntisTopic[]> {
seen?.periods.push(periodId);
const found = topics[periodId];
if (!found) throw new Error(`period ${periodId} not found`);
return found;
},
} as unknown as UntisClient;
}
describe('chunkRange', () => {
it('is one window for a short range', () => {
assert.deepEqual(chunkRange('2026-09-01', '2026-09-30'), [['2026-09-01', '2026-09-30']]);
});
it('splits a school year into windows the timetable call accepts', () => {
const chunks = chunkRange('2026-01-01', '2026-12-31');
assert.ok(chunks.length > 1);
assert.equal(chunks[0]![0], '2026-01-01');
assert.equal(chunks.at(-1)![1], '2026-12-31');
// No gaps and no overlaps: every day belongs to exactly one window.
for (let i = 1; i < chunks.length; i++) {
const previousEnd = new Date(`${chunks[i - 1]![1]}T12:00:00Z`).getTime();
const start = new Date(`${chunks[i]![0]}T12:00:00Z`).getTime();
assert.equal(start - previousEnd, 86_400_000);
}
});
});
describe('collectLessonLog', () => {
it('asks each series once, about its latest period', async () => {
// getLessonTopic2017 answers with the lessons *before* the period given,
// so the newest period of a series reaches all of its history and one
// call per series covers a term.
const seen = { periods: [] as number[] };
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01' }),
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' }),
lesson({ periodId: 3, lessonId: 200, date: '2026-09-09' }),
];
const topics = {
2: [{ text: 'Erörterung', periodId: 1, date: '2026-09-01', start: '08:00', end: '08:45' }],
3: [{ text: 'Netze', periodId: 3, date: '2026-09-09', start: '08:00', end: '08:45' }],
};
await collectLessonLog(client(lessons, topics, seen), { from: '2026-09-01', to: '2026-09-30' });
assert.deepEqual(seen.periods.sort(), [2, 3]);
});
it('merges a topic onto the period it belongs to', async () => {
const lessons = [lesson({ periodId: 1, lessonId: 100, date: '2026-09-01' }), lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' })];
const topics = { 2: [{ text: 'Erörterung', periodId: 1, date: '2026-09-01', start: '08:00', end: '08:45' }] };
const log = await collectLessonLog(client(lessons, topics), { from: '2026-09-01', to: '2026-09-30' });
assert.deepEqual(log.entries.map((entry) => [entry.periodId, entry.topic]), [[1, 'Erörterung']]);
});
it('keeps a lesson that has only a teacher note, and drops the empty ones', async () => {
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'LK am 20.09.' } }),
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' }),
];
const log = await collectLessonLog(client(lessons, { 2: [] }), { from: '2026-09-01', to: '2026-09-30' });
assert.deepEqual(log.entries.map((entry) => entry.periodId), [1]);
assert.equal(log.periodsSeen, 2);
});
it('skips cancelled periods, which taught nothing', async () => {
const lessons = [lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', cancelled: true, notes: { info: 'Entfall' } })];
const log = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30' });
assert.equal(log.periodsSeen, 0);
assert.deepEqual(log.entries, []);
});
it('matches a subject on either its code or its long name', async () => {
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'x' } }),
lesson({ periodId: 2, lessonId: 200, date: '2026-09-01', subjects: [{ name: 'LF07', longName: 'Lernfeld 7' }], notes: { info: 'y' } }),
];
const byCode = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30', subject: 'lf07' });
assert.deepEqual(byCode.entries.map((entry) => entry.periodId), [2]);
const byName = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30', subject: 'deutsch' });
assert.deepEqual(byName.entries.map((entry) => entry.periodId), [1]);
});
it('records a refused series rather than losing the whole term', async () => {
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'bleibt' } }),
lesson({ periodId: 2, lessonId: 200, date: '2026-09-02', notes: { info: 'auch' } }),
];
// Period 2's series throws; period 1's does not.
const log = await collectLessonLog(client(lessons, { 1: [] }), { from: '2026-09-01', to: '2026-09-30' });
assert.equal(log.failures.length, 1);
assert.equal(log.entries.length, 2);
});
it('is newest first', async () => {
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'a' } }),
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08', notes: { info: 'b' } }),
];
const log = await collectLessonLog(client(lessons, { 2: [] }), { from: '2026-09-01', to: '2026-09-30' });
assert.deepEqual(log.entries.map((entry) => entry.date), ['2026-09-08', '2026-09-01']);
});
});
describe('lessonLogText', () => {
it('carries the topic, the announcement and the homework into one body', () => {
const body = lessonLogText({
periodId: 1, lessonId: 100, date: '2026-09-01', start: '08:00', end: '08:45',
teachers: ['Meier'], topic: 'Erörterung', notes: { info: 'LK am 20.09.' },
homework: [{ text: 'S. 42', due: '2026-09-08' }],
});
assert.match(body, /Erörterung/);
assert.match(body, /LK am 20\.09\./);
assert.match(body, /S\. 42/);
});
});
describe('hasContent', () => {
it('is false for a lesson that recorded nothing', () => {
assert.equal(
hasContent({ periodId: 1, lessonId: 1, date: '2026-09-01', start: '08:00', end: '08:45', teachers: [], notes: {}, homework: [] }),
false,
);
});
});