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>
This commit is contained in:
100
test/apple-notes.test.ts
Normal file
100
test/apple-notes.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { convertAppleNote, htmlToMarkdown, parseExport } from '../src/cli/apple-notes.ts';
|
||||
|
||||
/**
|
||||
* The migration path from Notes.app. The HTML here is the shape Notes actually
|
||||
* emits — divs for lines, a repeated title, `<object>` for attachments — since
|
||||
* the converter's whole job is to survive that particular markup.
|
||||
*/
|
||||
|
||||
describe('htmlToMarkdown', () => {
|
||||
it('turns divs and breaks into lines', () => {
|
||||
assert.equal(htmlToMarkdown('<div>Erste Zeile</div><div>Zweite<br>Dritte</div>'), 'Erste Zeile\nZweite\nDritte');
|
||||
});
|
||||
|
||||
it('keeps headings, lists and emphasis', () => {
|
||||
const markdown = htmlToMarkdown('<h1>Thema</h1><ul><li>eins</li><li><b>zwei</b></li></ul>');
|
||||
assert.match(markdown, /^# Thema$/m);
|
||||
assert.match(markdown, /^- eins$/m);
|
||||
assert.match(markdown, /^- \*\*zwei\*\*$/m);
|
||||
});
|
||||
|
||||
it('keeps bullets together and separates what follows the list', () => {
|
||||
// Blank lines between bullets make a loose list; no blank line after one
|
||||
// makes the next paragraph a lazy continuation of the last bullet.
|
||||
const markdown = htmlToMarkdown('<ul><li>eins</li><li>zwei</li></ul><div>danach</div>');
|
||||
assert.equal(markdown, '- eins\n- zwei\n\ndanach');
|
||||
});
|
||||
|
||||
it('renders a checklist as a task list', () => {
|
||||
assert.match(htmlToMarkdown('<ul><li checked="checked">erledigt</li></ul>'), /- \[x\] erledigt/);
|
||||
});
|
||||
|
||||
it('keeps a link as a link', () => {
|
||||
assert.equal(htmlToMarkdown('<div><a href="https://example.org">Quelle</a></div>'), '[Quelle](https://example.org)');
|
||||
});
|
||||
|
||||
it('decodes entities', () => {
|
||||
assert.equal(htmlToMarkdown('<div>Erörterung & Analyse</div>'), 'Erörterung & Analyse');
|
||||
});
|
||||
|
||||
it('says an attachment was there rather than dropping it silently', () => {
|
||||
// A note that was one scan would otherwise import as empty, and nobody
|
||||
// would know the picture had been left behind.
|
||||
assert.match(htmlToMarkdown('<div>Tafelbild</div><object data="x"></object>'), /Anhang aus Apple Notes/);
|
||||
});
|
||||
|
||||
it('emits no empty emphasis markers', () => {
|
||||
assert.equal(htmlToMarkdown('<div><b> </b>Text</div>'), 'Text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertAppleNote', () => {
|
||||
const note = {
|
||||
id: 'x-coredata://1',
|
||||
name: 'Erörterung',
|
||||
body: '<div><b>Erörterung</b></div><div>These, Argument, Fazit</div>',
|
||||
folder: 'Schule/Deutsch',
|
||||
created: '2026-09-15T08:30:00',
|
||||
modified: '2026-09-20T19:00:00',
|
||||
};
|
||||
|
||||
it('dates the note when it was written, not when it was last touched', () => {
|
||||
// The creation date is the lesson; the modification date is whenever it
|
||||
// was last tidied, which is not a school day at all.
|
||||
assert.equal(convertAppleNote(note).date, '2026-09-15');
|
||||
});
|
||||
|
||||
it('takes the leaf of the Notes folder as the subject', () => {
|
||||
assert.equal(convertAppleNote(note).subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('ignores Notes\' own default folders', () => {
|
||||
assert.equal(convertAppleNote({ ...note, folder: 'Notizen' }).subject, undefined);
|
||||
});
|
||||
|
||||
it('lets an explicit subject win', () => {
|
||||
assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07');
|
||||
});
|
||||
|
||||
it('does not repeat the title as the first line of the body', () => {
|
||||
const converted = convertAppleNote(note);
|
||||
assert.equal(converted.title, 'Erörterung');
|
||||
assert.equal(converted.text, 'These, Argument, Fazit');
|
||||
});
|
||||
|
||||
it('marks where it came from', () => {
|
||||
assert.equal(convertAppleNote(note).source, 'apple-notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExport', () => {
|
||||
it('reads one note per line and ignores blank lines', () => {
|
||||
assert.equal(parseExport('{"id":"1","name":"A","body":"<div>a</div>"}\n\n{"id":"2","name":"B","body":""}\n').length, 2);
|
||||
});
|
||||
|
||||
it('names the line it could not read', () => {
|
||||
assert.throws(() => parseExport('{"id":"1"}\nnope\n'), /Line 2/);
|
||||
});
|
||||
});
|
||||
221
test/notes.test.ts
Normal file
221
test/notes.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
filterNotes,
|
||||
NoteNotFound,
|
||||
notePathFor,
|
||||
parseNote,
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
renderNote,
|
||||
splitFrontmatter,
|
||||
writeNote,
|
||||
} from '../src/core/notes.ts';
|
||||
|
||||
const STAMP = { modifiedAt: '2026-09-15T10:00:00.000Z', bytes: 100 };
|
||||
|
||||
async function root(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'schulcloud-notes-'));
|
||||
}
|
||||
|
||||
describe('splitFrontmatter', () => {
|
||||
it('reads a leading block and keeps the body', () => {
|
||||
const { front, body } = splitFrontmatter('---\ntitle: Erörterung\ndate: 2026-09-15\n---\n\nText hier.\n');
|
||||
assert.equal(front.title, 'Erörterung');
|
||||
assert.equal(front.date, '2026-09-15');
|
||||
assert.equal(body.trim(), 'Text hier.');
|
||||
});
|
||||
|
||||
it('leaves a note that merely starts with a rule alone', () => {
|
||||
// A horizontal rule with no closing fence must not eat the note.
|
||||
const { front, body } = splitFrontmatter('---\nkein Frontmatter, nur ein Strich\n');
|
||||
assert.deepEqual(front, {});
|
||||
assert.match(body, /kein Frontmatter/);
|
||||
});
|
||||
|
||||
it('accepts a note with no frontmatter at all', () => {
|
||||
const { front, body } = splitFrontmatter('# Titel\n\nText.');
|
||||
assert.deepEqual(front, {});
|
||||
assert.equal(body, '# Titel\n\nText.');
|
||||
});
|
||||
|
||||
it('takes a German date and an inline tag list', () => {
|
||||
const { front } = splitFrontmatter('---\ndate: 15.09.2026\ntags: [klausur, "aufsatz"]\nfach: Deutsch\n---\nx');
|
||||
assert.equal(front.date, '2026-09-15');
|
||||
assert.deepEqual(front.tags, ['klausur', 'aufsatz']);
|
||||
// "fach" is the German spelling of subject and has to mean the same thing.
|
||||
assert.equal(front.subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('keeps unknown keys rather than dropping them', () => {
|
||||
const { front } = splitFrontmatter('---\ntitle: T\nlehrer: Frau Meier\n---\nx');
|
||||
assert.equal(front.extra?.lehrer, 'Frau Meier');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNote', () => {
|
||||
it('falls back to the heading, then to the filename, for a title', () => {
|
||||
assert.equal(parseNote('a.md', '# Kryptografie\n\nText', STAMP).title, 'Kryptografie');
|
||||
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'nur Text', STAMP).title, 'Erörterung');
|
||||
});
|
||||
|
||||
it('takes the date from the filename when the frontmatter has none', () => {
|
||||
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'x', STAMP).date, '2026-09-15');
|
||||
});
|
||||
|
||||
it('never dates a note from its mtime', () => {
|
||||
// An import writes every file today; dating a year of lessons "today"
|
||||
// would make the whole store useless for revision.
|
||||
assert.equal(parseNote('lose Notiz.md', 'x', STAMP).date, undefined);
|
||||
});
|
||||
|
||||
it('takes the folder as the subject', () => {
|
||||
assert.equal(parseNote('LF07/2026-09-15 Netze.md', 'x', STAMP).subject, 'LF07');
|
||||
assert.equal(parseNote('lose.md', 'x', STAMP).subject, undefined);
|
||||
});
|
||||
|
||||
it('round-trips through renderNote', () => {
|
||||
const rendered = renderNote({ title: 'Erörterung', date: '2026-09-15', subject: 'Deutsch', tags: ['klausur'] }, 'Body');
|
||||
const note = parseNote('Deutsch/x.md', rendered, STAMP);
|
||||
assert.equal(note.title, 'Erörterung');
|
||||
assert.equal(note.date, '2026-09-15');
|
||||
assert.equal(note.subject, 'Deutsch');
|
||||
assert.deepEqual(note.tags, ['klausur']);
|
||||
assert.equal(note.text, 'Body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notePathFor', () => {
|
||||
it('is subject then date then title', () => {
|
||||
assert.equal(notePathFor({ date: '2026-09-15', subject: 'Deutsch', title: 'Erörterung' }), 'Deutsch/2026-09-15 Erörterung.md');
|
||||
});
|
||||
|
||||
it('reduces a hostile title to one component', () => {
|
||||
// The title comes from a tool call, so it is untrusted input that becomes
|
||||
// a filename — the same boundary the file mirror has.
|
||||
const path = notePathFor({ date: '2026-09-15', subject: '../../etc', title: '../../.ssh/authorized_keys' });
|
||||
assert.equal(path.split('/').length, 2);
|
||||
assert.ok(!path.includes('..'), path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readNotes', () => {
|
||||
it('is empty, not an error, for a directory that does not exist', async () => {
|
||||
assert.deepEqual(await readNotes(join(tmpdir(), 'schulcloud-notes-absent-xyz')), []);
|
||||
});
|
||||
|
||||
it('walks folders, skips dotfiles and non-notes, and sorts newest first', async () => {
|
||||
const dir = await root();
|
||||
await mkdir(join(dir, 'Deutsch'), { recursive: true });
|
||||
await mkdir(join(dir, '.obsidian'), { recursive: true });
|
||||
await writeFile(join(dir, 'Deutsch', '2026-09-15 Erörterung.md'), 'A');
|
||||
await writeFile(join(dir, 'Deutsch', '2026-09-22 Analyse.md'), 'B');
|
||||
await writeFile(join(dir, '.obsidian', 'workspace.md'), 'nope');
|
||||
await writeFile(join(dir, 'bild.png'), 'nope');
|
||||
|
||||
const notes = await readNotes(dir);
|
||||
assert.deepEqual(notes.map((note) => note.title), ['Analyse', 'Erörterung']);
|
||||
});
|
||||
|
||||
it('refuses to read its way out of the root', async () => {
|
||||
const dir = await root();
|
||||
await assert.rejects(() => readNoteAt(dir, '../../etc/passwd'), /traversal/);
|
||||
});
|
||||
|
||||
it('reports a missing note as missing', async () => {
|
||||
const dir = await root();
|
||||
await assert.rejects(() => readNoteAt(dir, 'Deutsch/nichts.md'), NoteNotFound);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeNote', () => {
|
||||
it('creates a note with frontmatter at the derived path', async () => {
|
||||
const dir = await root();
|
||||
const { note } = await writeNote(dir, { title: 'Erörterung', text: 'Aufbau: These, Argument, Fazit.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
|
||||
assert.equal(note.subject, 'Deutsch');
|
||||
assert.match(await readFile(join(dir, note.path), 'utf8'), /^---\ntitle: Erörterung\n/);
|
||||
});
|
||||
|
||||
it('appends to the same file when asked, so a lesson stays one note', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, {
|
||||
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15',
|
||||
path: 'Deutsch/2026-09-15 Erörterung.md', append: true,
|
||||
});
|
||||
assert.equal(appended, true);
|
||||
assert.match(note.text, /Erstens\./);
|
||||
assert.match(note.text, /Zweitens\./);
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
|
||||
it('appends by lesson, not by title — a second note in the same lesson has another name', async () => {
|
||||
// "halt das auch noch fest" mid-lesson carries a new title; deriving the
|
||||
// path from it would start a second note every time, which is the one
|
||||
// thing append exists to prevent.
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, {
|
||||
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15', append: true,
|
||||
});
|
||||
assert.equal(appended, true);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
|
||||
it('creates the note when append finds nothing to append to', async () => {
|
||||
const dir = await root();
|
||||
const { note, appended } = await writeNote(dir, { title: 'Erstes', text: 'x', subject: 'Deutsch', date: '2026-09-15', append: true });
|
||||
assert.equal(appended, false);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erstes.md');
|
||||
});
|
||||
|
||||
it('does not append across days or subjects', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Montag', text: 'a', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const otherDay = await writeNote(dir, { title: 'Dienstag', text: 'b', subject: 'Deutsch', date: '2026-09-16', append: true });
|
||||
const otherSubject = await writeNote(dir, { title: 'Netze', text: 'c', subject: 'LF07', date: '2026-09-15', append: true });
|
||||
assert.equal(otherDay.appended, false);
|
||||
assert.equal(otherSubject.appended, false);
|
||||
assert.equal((await readNotes(dir)).length, 3);
|
||||
});
|
||||
|
||||
it('never overwrites: a second note of the same name gets its own file', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Test', text: 'eins', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, { title: 'Test', text: 'zwei', subject: 'Deutsch', date: '2026-09-15' });
|
||||
assert.equal(appended, false);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Test 2.md');
|
||||
assert.equal((await readNotes(dir)).length, 2);
|
||||
});
|
||||
|
||||
it('cannot be steered out of the notes root by its title', async () => {
|
||||
const dir = await root();
|
||||
const { note } = await writeNote(dir, { title: '../../escape', text: 'x', subject: '..', date: '2026-09-15' });
|
||||
assert.ok(!note.path.includes('..'), note.path);
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterNotes', () => {
|
||||
const notes = [
|
||||
parseNote('Deutsch/2026-09-15 A.md', 'a', STAMP),
|
||||
parseNote('LF07/2026-09-22 B.md', 'b', STAMP),
|
||||
parseNote('lose.md', 'c', STAMP),
|
||||
];
|
||||
|
||||
it('matches a subject as a fragment', () => {
|
||||
assert.deepEqual(filterNotes(notes, { subject: 'deut' }).map((note) => note.title), ['A']);
|
||||
});
|
||||
|
||||
it('keeps undated notes inside a date window rather than hiding them', () => {
|
||||
// Excluding them would silently drop every note that arrived without a
|
||||
// date, which is most of an Apple Notes import.
|
||||
const titles = filterNotes(notes, { since: '2026-09-20' }).map((note) => note.title);
|
||||
assert.deepEqual(titles, ['B', 'lose']);
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,24 @@ function assertDisposable(url: string): void {
|
||||
function snapshot(
|
||||
courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[],
|
||||
rooms: { id: string; name: string; boardText?: string }[] = [],
|
||||
notes: { path: string; title: string; text: string; subject?: string; date?: string }[] = [],
|
||||
): Snapshot {
|
||||
return {
|
||||
crawledAt: new Date(),
|
||||
schoolId: 'school1',
|
||||
failures: [],
|
||||
submissions: [],
|
||||
lessonLog: [],
|
||||
notes: notes.map((n) => ({
|
||||
path: n.path,
|
||||
title: n.title,
|
||||
text: n.text,
|
||||
...(n.subject ? { subject: n.subject } : {}),
|
||||
...(n.date ? { date: n.date } : {}),
|
||||
tags: [],
|
||||
modifiedAt: '2026-09-15T10:00:00.000Z',
|
||||
bytes: n.text.length,
|
||||
})),
|
||||
rooms: rooms.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -152,6 +165,45 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, ()
|
||||
assert.ok(diff.changed.some((n) => n.nodeId === 'r1' && n.kind === 'room'), 'a renamed room is reported as changed');
|
||||
});
|
||||
|
||||
it('indexes the user\'s own notes beside the course material', async () => {
|
||||
// The point of the notes store: one search covers what the school
|
||||
// uploaded and what the user wrote down in the lesson.
|
||||
const before = await store.saveSnapshot(
|
||||
snapshot(
|
||||
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
|
||||
[],
|
||||
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Frau Meier betont den Schluss.' }],
|
||||
),
|
||||
'full',
|
||||
);
|
||||
const hits = await store.search('Erörterung', { limit: 5 });
|
||||
const note = hits.find((hit) => hit.kind === 'note');
|
||||
assert.ok(note, 'a note is searchable');
|
||||
assert.equal(note.nodeId, 'Deutsch/2026-09-15 Erörterung.md', 'the path is the id get_note takes');
|
||||
assert.equal(note.meta?.subject, 'Deutsch');
|
||||
|
||||
// And an edited note is a change, so what_changed reports it.
|
||||
const after = await store.saveSnapshot(
|
||||
snapshot(
|
||||
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
|
||||
[],
|
||||
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Gegenargument nicht vergessen.' }],
|
||||
),
|
||||
'full',
|
||||
);
|
||||
const diff = await store.diff(before, after);
|
||||
assert.ok(diff.changed.some((n) => n.kind === 'note'), 'an edited note is reported as changed');
|
||||
});
|
||||
|
||||
it('keeps notes through a per-course crawl, which never looks at them', async () => {
|
||||
// Notes belong to the account, not to a course, so a per-course refresh
|
||||
// must carry them forward rather than appear to delete them.
|
||||
const before = await store.latestCrawlId();
|
||||
const after = await store.saveSnapshot(snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }]), 'c1');
|
||||
const diff = await store.diff(before!, after);
|
||||
assert.ok(!diff.removed.some((n) => n.kind === 'note'), 'a per-course crawl must not delete the notes');
|
||||
});
|
||||
|
||||
it('carries other courses forward on a per-course crawl', async () => {
|
||||
await store.saveSnapshot(
|
||||
snapshot([
|
||||
|
||||
160
test/untis-history.test.ts
Normal file
160
test/untis-history.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user