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>
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
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:00–08:45 · MEI',
|
||
'',
|
||
'Erörterung: These, Argument, Fazit.',
|
||
'',
|
||
'### Aufbau',
|
||
'',
|
||
'- Gegenargument nicht vergessen',
|
||
'',
|
||
'## 2. LF07 — 08:50–09: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:00–08: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:00–08: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:00–08:45',
|
||
'',
|
||
'Normalisierung: erste, zweite und dritte Normalform.',
|
||
'',
|
||
'## 2. Deutsch — 08:50–09: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:00–08: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');
|
||
});
|
||
});
|