Files
Schulcloud-MCP/test/notes.test.ts
MechaCat02 48c6cf39d5 Lay the notes screen out like a notes app
The list of notes and the note being written are one screen now, two
panes: the notes on the left, the open one on the right, side by side
where there is room and one at a time on a phone, where the back button
returns to the list.

The search box moved into the top of that list, and its results *are*
the list — searching is a way of finding a note, not a separate place to
be, and a tab for it was a tab too many. Emptying the box brings the
whole list back. Opening a hit opens that day at the lesson that
matched, rather than at the top of a day with six of them.

A row has to say what the note holds, so the listing carries it: the
subjects a day covers, how many lessons, and the first line actually
written in it. One request for the whole list rather than one per note.

`plainText` is now one rule in one place for wherever a note is shown
rather than edited — the search snippet and the list row both went
through their own half-copy of it, and the row's copy rendered a table
as `| | |` and left `_Fazit_` wearing its markers. It strips one leading
marker, not each in turn, because `## 1. Deutsch` keeps its lesson
number and the list rule was eating it.

Driven in Firefox at both widths: the list, the search, opening a hit,
the jump to the lesson, and the phone's list-then-note. 390 unit tests,
116/117 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:22:47 +02:00

493 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
dayNotePath,
filterNotes,
NoteConflict,
NoteNotFound,
noteSections,
noteSubjects,
replaceNote,
subjectFromHeading,
notePathFor,
parseNote,
plainText,
readNoteAt,
readNotes,
renderNote,
searchNotes,
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('splitFrontmatter: block lists', () => {
it('reads tags written as indented "- item" lines, which is how editors write them', () => {
// Obsidian and most YAML front ends write a list this way; reading only
// the inline form silently dropped every tag such an editor had written.
const { front } = splitFrontmatter('---\ntitle: T\ntags:\n - klausur\n - aufsatz\n---\nx');
assert.deepEqual(front.tags, ['klausur', 'aufsatz']);
assert.equal(front.title, 'T');
});
it('stops the list at the next key', () => {
const { front } = splitFrontmatter('---\ntags:\n - eins\nsubject: Deutsch\n---\nx');
assert.deepEqual(front.tags, ['eins']);
assert.equal(front.subject, 'Deutsch');
});
});
describe('a note per school day', () => {
const day = parseNote(
'2026/2026-09-18.md',
[
'## 1. Deutsch — 08:0008:45 · MEI',
'',
'Erörterung: These, Argument, Fazit.',
'',
'### Aufbau',
'',
'- Gegenargument nicht vergessen',
'',
'## 2. LF07 — 08:5009:35 · Sb',
'',
'/24 = 254 nutzbare Adressen',
].join('\n'),
STAMP,
);
it('does not take the year folder for a subject', () => {
// "2026/" is a filing scheme, not a lesson.
assert.equal(day.subject, undefined);
});
it('splits into one section per lesson', () => {
assert.deepEqual(noteSections(day).map((section) => section.subject), ['Deutsch', 'LF07']);
});
it('keeps subheadings inside their lesson', () => {
const first = noteSections(day)[0]!;
assert.match(first.text, /### Aufbau/);
assert.doesNotMatch(first.text, /LF07/);
});
it('reports every subject the day covers', () => {
assert.deepEqual(noteSubjects(day), ['Deutsch', 'LF07']);
});
it('is found by a subject filter, which only its headings know', () => {
assert.equal(filterNotes([day], { subject: 'lf07' }).length, 1);
assert.equal(filterNotes([day], { subject: 'Mathe' }).length, 0);
});
it('does not split on a ## inside a fenced code block', () => {
const note = parseNote('2026/2026-09-18.md', '## Info\n\n```\n## nicht eine Stunde\n```\n', STAMP);
assert.equal(noteSections(note).length, 1);
});
it('has no sections when it is one piece of prose, as an imported note is', () => {
assert.deepEqual(noteSections(parseNote('Deutsch/2026-09-15 A.md', 'Nur Text.', STAMP)), []);
});
});
describe('subjectFromHeading', () => {
it('reads the subject out of every shape the page and a person write', () => {
for (const [heading, expected] of [
['1. Deutsch — 08:0008:45 · MEI · R 204', 'Deutsch'],
['2) LF07', 'LF07'],
['Deutsch', 'Deutsch'],
['08:00 Deutsch', 'Deutsch'],
['3. Mathe (Vertretung)', 'Mathe'],
] as const) {
assert.equal(subjectFromHeading(heading), expected, heading);
}
});
it('names no subject rather than a wrong one', () => {
for (const heading of ['1.', '08:0008:45', '—', '###']) {
assert.equal(subjectFromHeading(heading), undefined, heading);
}
});
});
describe('dayNotePath', () => {
it('files a day under its year', () => {
assert.equal(dayNotePath('2026-09-18'), '2026/2026-09-18.md');
});
});
describe('replaceNote', () => {
it('overwrites, which is what saving from an editor means', async () => {
const dir = await root();
await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'eins', date: '2026-09-18' });
const note = await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'zwei', date: '2026-09-18' });
assert.equal(note.text, 'zwei');
assert.equal((await readNotes(dir)).length, 1, 'saving twice is one note, not two');
});
it('refuses a save that would clobber a version the editor never saw', async () => {
// The notes folder is synced and open in more than one place; a phone must
// not silently win over a laptop.
const dir = await root();
const first = await replaceNote(dir, 'x.md', { title: 'X', text: 'vom Laptop' });
await assert.rejects(
() => replaceNote(dir, 'x.md', { title: 'X', text: 'vom Handy' }, { expectedModifiedAt: '2020-01-01T00:00:00.000Z' }),
NoteConflict,
);
assert.equal((await readNoteAt(dir, 'x.md')).text, 'vom Laptop', 'the refused save changed nothing');
assert.ok(first.modifiedAt);
});
it('accepts a save carrying the modification time it loaded', async () => {
const dir = await root();
const loaded = await replaceNote(dir, 'x.md', { title: 'X', text: 'eins' });
const saved = await replaceNote(dir, 'x.md', { title: 'X', text: 'zwei' }, { expectedModifiedAt: loaded.modifiedAt });
assert.equal(saved.text, 'zwei');
});
it('creates the note when there is none, with nothing to clash against', async () => {
const dir = await root();
const note = await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'neu' }, { expectedModifiedAt: '2020-01-01T00:00:00.000Z' });
assert.equal(note.text, 'neu');
});
it('cannot be steered out of the notes root', async () => {
const dir = await root();
await assert.rejects(() => replaceNote(dir, '../escape.md', { title: 'X', text: 'x' }), /traversal/);
});
});
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']);
});
});
describe('searchNotes', () => {
const day = parseNote(
'2026/2026-09-04.md',
[
'---',
'title: Freitag, 04.09.2026',
'date: 2026-09-04',
'---',
'',
'## 1. LF10 — 08:0008:45',
'',
'Normalisierung: erste, zweite und dritte Normalform.',
'',
'## 2. Deutsch — 08:5009:35',
'',
'Erörterung: These, Argument, Fazit.',
].join('\n'),
new Date(),
0,
);
const loose = parseNote(
'Deutsch/2026-09-15 Aufbau.md',
['---', 'title: Aufbau', 'subject: Deutsch', '---', '', 'Gegenargument nicht vergessen.'].join('\n'),
new Date(),
0,
);
it('answers with the lesson, not the day', () => {
const [hit] = searchNotes([day], 'Normalform');
assert.equal(hit?.heading, '1. LF10 — 08:0008:45');
assert.equal(hit?.subject, 'LF10');
assert.equal(hit?.date, '2026-09-04');
});
it('does not report a day because another of its lessons matched', () => {
// The whole reason a day note is searched per section: "Erörterung" is
// Deutsch, and reporting it as LF10 would be worse than not finding it.
const hits = searchNotes([day], 'Erörterung');
assert.equal(hits.length, 1);
assert.equal(hits[0]?.subject, 'Deutsch');
});
it('ignores case and accents', () => {
assert.equal(searchNotes([day], 'erorterung').length, 1);
assert.equal(searchNotes([day], 'ERÖRTERUNG').length, 1);
});
it('needs every word, in any order', () => {
assert.equal(searchNotes([day], 'normalform erste').length, 1);
assert.equal(searchNotes([day], 'normalform erörterung').length, 0);
});
it('searches the heading itself, so a subject finds its lessons', () => {
assert.equal(searchNotes([day], 'LF10').length, 1);
});
it('treats a note without lessons as one piece', () => {
const [hit] = searchNotes([loose], 'Gegenargument');
assert.equal(hit?.path, 'Deutsch/2026-09-15 Aufbau.md');
assert.equal(hit?.heading, undefined);
assert.equal(hit?.subject, 'Deutsch');
});
it('carries a snippet worth reading', () => {
const [hit] = searchNotes([day], 'Normalform');
assert.match(hit!.snippet, /erste, zweite und dritte Normalform/);
});
it('shows the snippet as prose, not as Markdown', () => {
const table = parseNote(
'2026/2026-09-05.md',
[
'---',
'title: Samstag',
'---',
'',
'## LF10',
'',
'| Normalform | Bedingung |',
'| --- | --- |',
'| 1NF | atomare Werte |',
].join('\n'),
new Date(),
0,
);
const [hit] = searchNotes([table], '1NF');
// The pipes and the `|---|` rule say nothing to someone reading a result.
assert.equal(hit?.snippet, '1NF · atomare Werte');
});
it('strips the markers from a bullet or a heading in the snippet', () => {
const [hit] = searchNotes([day], 'Argument');
assert.equal(hit?.snippet.includes('**'), false);
});
it('finds nothing for an empty query rather than everything', () => {
assert.deepEqual(searchNotes([day, loose], ' '), []);
});
it('stops at the limit', () => {
assert.equal(searchNotes([day], 'e', 1).length, 1);
});
});
describe('plainText', () => {
it('reads a table row as its cells', () => {
assert.equal(plainText('| 1NF | atomare Werte |'), '1NF · atomare Werte');
assert.equal(plainText('| --- | --- |'), '');
});
it('drops the markers but keeps the words', () => {
assert.equal(plainText('- **These**, Argument, _Fazit_'), 'These, Argument, Fazit');
assert.equal(plainText('## 1. Deutsch'), '1. Deutsch');
assert.equal(plainText('> Merksatz'), 'Merksatz');
assert.equal(plainText('- [x] erledigt'), 'erledigt');
});
it('leaves a word with an underscore in it alone', () => {
assert.equal(plainText('snake_case_name bleibt'), 'snake_case_name bleibt');
});
it('reads a link as its label and an escape as its character', () => {
assert.equal(plainText('[Arbeitsblatt](https://example.org/ab.pdf)'), 'Arbeitsblatt');
assert.equal(plainText('2 \\* 3'), '2 * 3');
});
});