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 & { 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, seen?: { periods: number[] }): UntisClient { return { async timetable(from: string, to: string): Promise { 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 { 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, ); }); });