Files
Schulcloud-MCP/test/app-markdown.test.ts
MechaCat02 e129fd4b0a Keep a pasted note whole, and search the notes from the app
Two things found by using this on real notes.

**The paste.** Copying out of Apple Notes put most of the note on the
floor. WebKit wraps a copied selection in a single span carrying the
computed style of everything in it — `font-weight: 700` included — with
the real blocks nested inside. The serializer read that span as inline,
so every line collapsed into one paragraph and every word came out bold;
switching to the Markdown view then showed what little had survived,
which is what "most of the text was gone" was. And because the boldness
came from a foreign span's style rather than a tag, the bold button
could not remove it.

The rule now is that an element holding blocks is a block whatever its
tag, and that a container's style is not emphasis — only a span wrapping
a single run of text is. A paste this editor cannot read at all (some
engines withhold the clipboard from the event) is tidied afterwards
instead, but only if something actually arrived, so an empty paste still
costs nothing.

**The search.** A Suche tab over the user's own notes, reading the files
rather than the index: notes reach the index only on a full crawl, so a
lesson written this morning would not be findable this morning, which is
most of what anyone searches their own notes for. A result names the
lesson it matched in, not the day, for the same reason the index indexes
day notes per section. Tapping one opens that day in the editor.

Driven in Firefox against the real app with a proxied session: the
paste, six switches between the two views, bold and unbold on pasted
text, the search, and opening a result. 386 unit tests, 114/115 smoke.

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

286 lines
12 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 { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { markdownFromDom, markdownToHtml } from '../src/http/app/markdown.js';
import { parseHtml } from './mini-dom.ts';
/**
* The notes editor's Markdown bridge.
*
* The editor shows a note as formatted text and writes it back as Markdown, so
* every save runs the note through `markdownToHtml` and `markdownFromDom`. If
* that pair loses anything, it loses a lesson — these notes are the only record
* of what was actually said in the room, and there is no second copy to restore
* from. Hence the shape of almost every test here: put Markdown in, get the
* same Markdown back.
*/
/** Markdown → HTML → Markdown, the trip a note takes on every edit. */
function back(markdown: string): string {
return markdownFromDom(parseHtml(markdownToHtml(markdown)));
}
/**
* Asserts the trip is idempotent, and returns what it settles on.
*
* One pass may tidy — `*a*` becomes `_a_`, a ragged table lines up — and that
* is allowed, because the editor only rewrites a file the person has edited.
* A second pass changing anything is not: it would mean every save mangles the
* note a little further, which is how a term's notes rot into nothing.
*/
function settles(markdown: string): string {
const once = back(markdown);
assert.equal(back(once), once, 'the round trip is not stable');
return once;
}
/** Markdown that must survive the trip exactly as written. */
function unchanged(markdown: string): void {
assert.equal(settles(markdown), markdown);
}
test('headings keep their level', () => {
unchanged('## 1. Deutsch — 08:0008:45 · MEI · R 204');
unchanged('# Eins\n\n## Zwei\n\n### Drei\n\n#### Vier');
assert.match(markdownToHtml('## Deutsch'), /<h2>Deutsch<\/h2>/);
});
test('a paragraph keeps its line breaks without turning them into paragraphs', () => {
// How a note actually gets typed: shift-enter within a thought, enter
// between them.
unchanged('Erste Zeile\nzweite Zeile\n\nNeuer Absatz');
assert.equal(markdownToHtml('a\nb'), '<p>a<br>b</p>');
});
test('emphasis round-trips, and normalises to one spelling', () => {
unchanged('**fett** und _kursiv_ und ~~gestrichen~~');
assert.equal(settles('*kursiv*'), '_kursiv_');
assert.equal(settles('__fett__'), '**fett**');
assert.equal(markdownToHtml('**fett**'), '<p><strong>fett</strong></p>');
});
test('emphasis markers hug their text', () => {
// `** fett **` is four literal stars in every renderer there is.
const html = '<p>Merke:<strong> fett </strong>rest</p>';
assert.equal(markdownFromDom(parseHtml(html)), 'Merke: **fett** rest');
});
test('inline code keeps what is inside it literal', () => {
unchanged('Der Platzhalter `**nicht fett**` bleibt stehen.');
unchanged('`a | b`');
assert.equal(settles('``ein ` backtick``'), '``ein ` backtick``');
});
test('links keep their target, and a dangerous scheme is dropped', () => {
unchanged('[Arbeitsblatt](https://example.org/ab.pdf)');
// The label survives; only the target goes. A note is never worth less than
// its words, and nothing here should render a tappable `javascript:`.
assert.equal(back('[hier](javascript:alert)'), 'hier');
// A target with parentheses in it is a link, not a broken one.
unchanged('[Erörterung](https://de.wikipedia.org/wiki/Erörterung_(Textsorte))');
});
test('bullet lists nest', () => {
unchanged('- eins\n- zwei\n - zwei a\n - zwei b\n- drei');
});
test('numbered lists keep their numbering', () => {
unchanged('1. eins\n2. zwei\n3. drei');
// A list that starts elsewhere keeps its first number and renumbers the rest.
assert.equal(settles('3. drei\n4. vier'), '3. drei\n4. vier');
});
test('task lists keep their boxes', () => {
unchanged('- [ ] offen\n- [x] erledigt');
assert.match(markdownToHtml('- [x] fertig'), /<input type="checkbox" contenteditable="false" checked>/);
});
test('tables round-trip and line up', () => {
unchanged('| Präfix | Adressen |\n| --- | --- |\n| /24 | 254 |\n| /25 | 126 |');
// A ragged table is tidied once, then left alone.
assert.equal(settles('|a|b|\n|-|-|\n|1|2|'), '| a | b |\n| --- | --- |\n| 1 | 2 |');
});
test('a pipe inside a cell stays inside the cell', () => {
const md = '| Zeichen | Bedeutung |\n| --- | --- |\n| \\| | oder |';
assert.equal(settles(md), md);
});
test('blockquotes and rules survive', () => {
unchanged('> Merksatz des Lehrers\n> über zwei Zeilen');
unchanged('---');
});
test('fenced code keeps its language and its contents verbatim', () => {
unchanged('```bash\nip route add 10.0.0.0/8 via 10.1.1.1\n```');
// Indentation inside a fence is content, not structure.
unchanged('```\nif x:\n y = 1\n```');
});
test('underscores inside words are not emphasis', () => {
unchanged('snake_case_name bleibt ein Wort');
// `__` is bold in Markdown, though, and is normalised to the one spelling.
assert.equal(settles('__wirklich fett__'), '**wirklich fett**');
});
test('a note cannot smuggle HTML into the editor', () => {
const html = markdownToHtml('<script>alert(1)</script> & <b>nicht fett</b>');
assert.equal(html.includes('<script'), false);
assert.equal(html.includes('<b>'), false);
assert.match(html, /&lt;script&gt;/);
});
test('markup the editor does not model keeps its words', () => {
// What a paste from a web page leaves behind: the tags mean nothing here,
// the text means everything.
const html = '<p><span style="font-weight: 700">fett</span> <u>unterstrichen</u> <font color="red">rot</font></p>';
assert.equal(markdownFromDom(parseHtml(html)), '**fett** unterstrichen rot');
});
test('a browser\'s own line divs become paragraphs', () => {
// contenteditable produces these on every Enter, in every engine.
assert.equal(markdownFromDom(parseHtml('<div>eins</div><div>zwei</div>')), 'eins\n\nzwei');
assert.equal(markdownFromDom(parseHtml('<div><br></div>')), '');
});
test('text that looks like Markdown is escaped, and comes back as text', () => {
unchanged('2 \\* 3 \\* 4');
unchanged('\\- kein Listenpunkt');
unchanged('\\# keine Überschrift');
assert.equal(back('Gewicht \\_in kg\\_'), 'Gewicht \\_in kg\\_');
});
test('a whole day note survives unchanged', () => {
// The shape the app writes and the indexer reads back: one `##` per lesson,
// prose, a list, a subheading and a table underneath.
const note = [
'## 1. Deutsch — 08:0008:45 · MEI · R 204',
'',
'Dreischritt: These, Argument mit Beleg, Fazit.',
'',
'### Aufbau',
'',
'- Gegenargument nicht vergessen',
' - kam letztes Jahr in der Arbeit dran',
'- **Fazit** knapp halten',
'',
'## 2. LF07 — 08:5009:35 · Sb · R 108',
'',
'| Präfix | Nutzbare Adressen |',
'| --- | --- |',
'| /24 | 254 |',
'| /25 | 126 |',
'',
'> Kommt so in der Arbeit dran.',
].join('\n');
unchanged(note);
});
test('the lesson headings the indexer keys on come back verbatim', () => {
// `lessonHeading` writes these and `subjectFromHeading` reads the subject
// back out of them. An editor that rewrote the dash or the separator would
// file a day's notes under nothing.
for (const heading of [
'## 1. Deutsch — 08:0008:45 · MEI · R 204',
'## 3. LF07 — 10:3511:20 · Sb · R 108 (Vertretung)',
'## 5. Englisch — 12:1513:00',
]) {
unchanged(heading);
}
});
test('an empty note is empty, not a paragraph', () => {
assert.equal(markdownToHtml(''), '');
assert.equal(back(''), '');
assert.equal(back('\n\n \n'), '');
});
test('a list the browser nested as a sibling keeps its items', () => {
// What several engines produce when Tab indents a bullet: the nested list
// beside the items rather than inside one. Skipping it would drop
// everything under it without a trace.
const html = '<ul><li>eins</li><ul><li>eins a</li></ul><li>zwei</li></ul>';
assert.equal(markdownFromDom(parseHtml(html)), '- eins\n - eins a\n- zwei');
});
test('an item whose text the browser wrapped in a div is still one line', () => {
assert.equal(markdownFromDom(parseHtml('<ul><li><div>eins</div></li></ul>')), '- eins');
assert.equal(markdownFromDom(parseHtml('<ol><li><p>eins</p></li></ol>')), '1. eins');
});
test('what execCommand produces round-trips', () => {
// styleWithCSS is turned off, so bold and italic arrive as tags — but
// `<b>`/`<i>`, not `<strong>`/`<em>`.
assert.equal(markdownFromDom(parseHtml('<p><b>fett</b> und <i>kursiv</i></p>')), '**fett** und _kursiv_');
// A heading made by formatBlock, and the empty paragraph left behind.
assert.equal(markdownFromDom(parseHtml('<h2>Deutsch</h2><p><br></p>')), '## Deutsch');
});
test('a task list keeps its state through the DOM the editor builds', () => {
const html = '<ul><li class="task"><input type="checkbox" contenteditable="false">offen</li>' +
'<li class="task"><input type="checkbox" contenteditable="false" checked>fertig</li></ul>';
assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig');
});
test('the editor\'s trailing escape line never reaches the file', () => {
// A table at the end of a contenteditable element is a dead end, so the
// editor keeps an empty paragraph after it. It must serialize to nothing,
// or every note with a table would grow a blank line on each save.
const html = markdownToHtml('| a | b |\n| --- | --- |\n| 1 | 2 |') + '<p><br></p>';
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |');
});
test('an empty cell stays an empty cell', () => {
// The editor puts a <br> in blank cells so the caret can reach them.
unchanged('| a | b |\n| --- | --- |\n| | 2 |');
assert.match(markdownToHtml('| a |\n| --- |\n| |'), /<td><br><\/td>/);
});
test('a row the editor appended round-trips', () => {
const html = '<table><thead><tr><th>a</th><th>b</th></tr></thead>' +
'<tbody><tr><td>1</td><td>2</td></tr><tr><td><br></td><td><br></td></tr></tbody></table>';
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |');
});
test('a paste from Apple Notes keeps its structure and is not all bold', () => {
// What WebKit actually puts on the clipboard: one wrapper span carrying the
// *computed* style of everything copied — including `font-weight: 700` —
// with the real blocks nested inside it. Read naively that makes the whole
// note bold and flattens every line into one paragraph.
const html =
'<meta charset="UTF-8"><span style="color: rgb(0, 0, 0); font-family: Helvetica; ' +
'font-size: 16px; font-weight: 700; text-align: start; -webkit-text-stroke-width: 0px; ' +
'display: inline !important; float: none;">' +
'<div><b>Erörterung</b></div><div><br></div><div>These, Argument, Fazit</div>' +
'<ul><li>Gegenargument nicht vergessen</li><li>Fazit knapp halten</li></ul></span>';
assert.equal(
markdownFromDom(parseHtml(html)),
'**Erörterung**\n\nThese, Argument, Fazit\n\n- Gegenargument nicht vergessen\n- Fazit knapp halten',
);
});
test('a container\'s font never swallows the blocks inside it', () => {
// The general rule behind the case above: an element holding blocks is a
// container whatever its tag, and a container's style is not emphasis.
const html = '<span style="font-weight: bold"><h2>Deutsch</h2><p>Text</p></span>';
assert.equal(markdownFromDom(parseHtml(html)), '## Deutsch\n\nText');
});
test('a styled span around a single run is still emphasis', () => {
// The case the style check exists for, which must keep working.
assert.equal(markdownFromDom(parseHtml('<p><span style="font-weight: 700">fett</span> rest</p>')), '**fett** rest');
assert.equal(markdownFromDom(parseHtml('<p><span style="font-style: italic">kursiv</span></p>')), '_kursiv_');
});
test('no word is ever lost, whatever the markup', () => {
// The property that matters more than any particular shape: a note is
// allowed to lose its formatting, never its words.
const html =
'<div><span style="font-weight:700"><div>Erste Zeile</div>' +
'<blockquote><span><p>Zitat</p></span></blockquote>' +
'<table><tr><td><div>Zelle</div></td></tr></table></span></div>';
const markdown = markdownFromDom(parseHtml(html));
for (const word of ['Erste', 'Zeile', 'Zitat', 'Zelle']) {
assert.ok(markdown.includes(word), `lost "${word}" in: ${markdown}`);
}
});