test: unit and end-to-end suites
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27fafa0bcd
commit
9b00e21c44
49 files changed
+6694
-1
No files matched your search
@@ -0,0 +1,222 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseMarkdown, toMarkdown, mdToHtml, htmlToMd, toHtmlDocument } from '../src/lib/markdown.js';
|
||||
|
||||
const SAMPLE = `Plain paragraph with **bold**, *italic* and \`code\`.
|
||||
Second line of the same paragraph.
|
||||
|
||||
# Heading one
|
||||
|
||||
## Heading two
|
||||
|
||||
### Heading three
|
||||
|
||||
- first bullet
|
||||
- second bullet
|
||||
- [ ] open task
|
||||
- [x] done task
|
||||
|
||||
> A quote
|
||||
> over two lines
|
||||
|
||||
> [!note]
|
||||
> A callout with <kbd>Ctrl</kbd> <kbd>K</kbd>
|
||||
|
||||
---
|
||||
|
||||
\`\`\`ts
|
||||
const a = 1 < 2 && "x"
|
||||
console.log(a)
|
||||
\`\`\`
|
||||
|
||||
<details>
|
||||
<summary>More</summary>
|
||||
|
||||
Hidden text with **bold**.
|
||||
|
||||
</details>
|
||||
|
||||
$$
|
||||
x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
|
||||
$$
|
||||
|
||||
\`\`\`mermaid
|
||||
graph TD;
|
||||
A-->B;
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
test('markdown survives a round trip unchanged', () => {
|
||||
const blocks = parseMarkdown(SAMPLE);
|
||||
assert.equal(toMarkdown(blocks), SAMPLE);
|
||||
// and a second pass is stable
|
||||
assert.equal(toMarkdown(parseMarkdown(toMarkdown(blocks))), SAMPLE);
|
||||
});
|
||||
|
||||
test('block types are recognised', () => {
|
||||
const types = parseMarkdown(SAMPLE).map((b) => b.type);
|
||||
assert.deepEqual(types, ['p', 'h1', 'h2', 'h3', 'bullet', 'bullet', 'todo', 'todo', 'quote', 'callout', 'divider', 'code', 'toggle', 'math', 'mermaid']);
|
||||
const code = parseMarkdown(SAMPLE).find((b) => b.type === 'code');
|
||||
assert.equal(code.lang, 'ts');
|
||||
assert.equal(code.text, 'const a = 1 < 2 && "x"\nconsole.log(a)');
|
||||
});
|
||||
|
||||
|
||||
test('inline math converts both ways', () => {
|
||||
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
|
||||
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
|
||||
});
|
||||
|
||||
|
||||
test('inline math converts both ways', () => {
|
||||
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
|
||||
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
|
||||
});
|
||||
|
||||
|
||||
test('inline math converts both ways', () => {
|
||||
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
|
||||
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
|
||||
});
|
||||
|
||||
|
||||
test('inline math converts both ways', () => {
|
||||
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
|
||||
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
|
||||
});
|
||||
|
||||
test('inline formatting converts both ways and escapes HTML', () => {
|
||||
assert.equal(mdToHtml('a **b** *c* `<d>`'), 'a <b>b</b> <i>c</i> <code><d></code>');
|
||||
assert.equal(htmlToMd('a <b>b</b> <i>c</i> <code><d></code>'), 'a **b** *c* `<d>`');
|
||||
assert.equal(mdToHtml('5 < 6 & 7'), '5 < 6 & 7');
|
||||
assert.equal(htmlToMd('line<br>next word'), 'line\nnext word');
|
||||
assert.equal(htmlToMd('<span style="x">plain</span>'), 'plain');
|
||||
});
|
||||
|
||||
test('contenteditable output becomes clean markdown', () => {
|
||||
const blocks = [
|
||||
{ type: 'p', html: 'Hello <strong>world</strong>' },
|
||||
{ type: 'p', html: '' },
|
||||
{ type: 'todo', html: 'Ship it', checked: true },
|
||||
{ type: 'bullet', html: 'one' },
|
||||
{ type: 'h2', html: 'Next' }
|
||||
];
|
||||
assert.equal(toMarkdown(blocks), 'Hello **world**\n\n- [x] Ship it\n- one\n\n## Next\n');
|
||||
});
|
||||
|
||||
test('empty and unusual input', () => {
|
||||
assert.equal(parseMarkdown('').length, 1);
|
||||
assert.equal(toMarkdown(parseMarkdown('')), '');
|
||||
const b = parseMarkdown('#### deep heading\n* star bullet\n+ plus bullet');
|
||||
assert.deepEqual(b.map((x) => x.type), ['h3', 'bullet', 'bullet']);
|
||||
});
|
||||
|
||||
test('page links become chips and come back unchanged', () => {
|
||||
const md = 'See [[Roadmap]], [[Projects/Plan|the plan]] and `[[not a link]]`.';
|
||||
const html = mdToHtml(md);
|
||||
assert.match(html, /<span class="wl" contenteditable="false" data-ref="Roadmap">Roadmap<\/span>/);
|
||||
assert.match(html, /data-ref="Projects\/Plan">the plan<\/span>/);
|
||||
assert.match(html, /<code>\[\[not a link\]\]<\/code>/);
|
||||
assert.equal(htmlToMd(html), md);
|
||||
assert.equal(htmlToMd(mdToHtml('[[A_b_c]] & [[x<y]]')), '[[A_b_c]] & [[x<y]]');
|
||||
// a chip whose label was left alone keeps the short form
|
||||
assert.equal(htmlToMd('<span class="wl missing" contenteditable="false" data-ref="Plan">Plan</span> '), '[[Plan]]');
|
||||
assert.equal(htmlToMd('<span class="wl" contenteditable="false" data-ref="Plan">Plan</span> and more'), '[[Plan]] and more');
|
||||
});
|
||||
|
||||
test('images are their own block and round trip', () => {
|
||||
const md = '\n\n\n\n![[photo.webp]]\n\nText with  stays text.\n';
|
||||
const blocks = parseMarkdown(md);
|
||||
assert.deepEqual(blocks.map((b) => b.type), ['image', 'image', 'image', 'p']);
|
||||
assert.equal(blocks[1].src, 'my cat.jpg');
|
||||
assert.equal(blocks[1].alt, 'A cat');
|
||||
assert.equal(blocks[2].wiki, true);
|
||||
assert.equal(toMarkdown(blocks), md);
|
||||
});
|
||||
|
||||
test('numbered lists count per run and strikethrough round trips', () => {
|
||||
const md = '1. one\n2. two\n\nBreak\n\n1. again\n- bullet\n1. new run\n\n~~gone~~ and **kept**\n';
|
||||
const blocks = parseMarkdown(md);
|
||||
assert.deepEqual(blocks.map((b) => b.type), ['number', 'number', 'p', 'number', 'bullet', 'number', 'p']);
|
||||
assert.equal(toMarkdown(blocks), md);
|
||||
// numbers in the file are rewritten to count from 1
|
||||
assert.equal(toMarkdown(parseMarkdown('3. a\n7) b\n')), '1. a\n2. b\n');
|
||||
assert.equal(htmlToMd('<strike>a</strike> <del>b</del> <s>c</s>'), '~~a~~ ~~b~~ ~~c~~');
|
||||
assert.equal(mdToHtml('~~x~~'), '<s>x</s>');
|
||||
});
|
||||
|
||||
test('web links and bare URLs round trip; unsafe ones stay text', () => {
|
||||
const md = 'Read [the **docs**](https://example.com/a_b "Guide") or https://ink.app/x_(y). Mail [me](mailto:a@b.c), [spaced](<https://x.org/a b>).';
|
||||
const html = mdToHtml(md);
|
||||
assert.match(html, /<a class="xl" href="https:\/\/example\.com\/a_b" spellcheck="false" data-title="Guide">the <b>docs<\/b><\/a>/);
|
||||
assert.match(html, /href="https:\/\/ink\.app\/x_\(y\)" spellcheck="false" data-auto="1">https:\/\/ink\.app\/x_\(y\)<\/a>\./);
|
||||
assert.equal(htmlToMd(html), md);
|
||||
for (const keep of ['[x](javascript:alert(1))', '[rel](notes.md)', 'plain http:// text']) assert.equal(htmlToMd(mdToHtml(keep)), keep);
|
||||
assert.ok(!mdToHtml('[x](javascript:alert(1))').includes('<a'));
|
||||
// a link whose text was edited is written as [text](url)
|
||||
assert.equal(htmlToMd('<a class="xl" href="https://ink.app" data-auto="1">Ink</a>'), '[Ink](https://ink.app)');
|
||||
});
|
||||
|
||||
test('tables keep their source until edited, then line up', () => {
|
||||
const md = '|a|b|\n|:-|-:|\n|`x|y`|2|\n| p \\| q | [[Plan]] |\n';
|
||||
const [t] = parseMarkdown(md);
|
||||
assert.equal(t.type, 'table');
|
||||
assert.deepEqual(t.align, ['left', 'right']);
|
||||
assert.equal(t.rows.length, 3);
|
||||
assert.equal(t.rows[1][0], '<code>x|y</code>');
|
||||
assert.equal(t.rows[2][0], 'p | q');
|
||||
assert.equal(toMarkdown([t]), md);
|
||||
delete t.src;
|
||||
t.rows[1][1] = '<b>20</b>';
|
||||
assert.equal(toMarkdown([t]), '| a | b |\n| :----- | -------: |\n| `x\\|y` | **20** |\n| p \\| q | [[Plan]] |\n');
|
||||
assert.equal(parseMarkdown('a | b\nno delimiter row')[0].type, 'p');
|
||||
});
|
||||
|
||||
test('==highlight== round trips; == and === in normal text are left alone', () => {
|
||||
for (const md of ['A ==bright== idea', '==**bold** inside== and ==*it*==', 'if a == b then', 'x === y === z', '`==code==` stays']) {
|
||||
assert.equal(htmlToMd(mdToHtml(md)), md);
|
||||
}
|
||||
assert.equal(mdToHtml('A ==bright== idea'), 'A <mark>bright</mark> idea');
|
||||
assert.ok(!mdToHtml('if a == b == c').includes('<mark>'));
|
||||
assert.ok(!mdToHtml('`==code==`').includes('<mark>'));
|
||||
});
|
||||
|
||||
test('footnotes: references, inline notes and footnote texts round trip; export numbers them in reading order', () => {
|
||||
const md = 'A claim[^src] with an aside^[said in passing].\n\nAnother[^2] and again[^src].\n\n[^2]: Second note.\n[^src]: The source,\n on two lines.\n[^unused]: Nobody points here.\n';
|
||||
const blocks = parseMarkdown(md);
|
||||
assert.deepEqual(blocks.map((b) => b.type + (b.fid ? ':' + b.fid : '')), ['p', 'p', 'footnote:2', 'footnote:src', 'footnote:unused']);
|
||||
assert.match(blocks[0].html, /<sup class="fn" contenteditable="false" data-fn="src">/);
|
||||
assert.match(blocks[0].html, /data-inline="said in passing"/);
|
||||
assert.equal(blocks[3].html, 'The source,\non two lines.');
|
||||
assert.equal(toMarkdown(blocks), md);
|
||||
const html = toHtmlDocument('t', blocks);
|
||||
assert.deepEqual([...html.matchAll(/<sup class="fn"[^>]*>(?:<a href="#fn-(\w+)">)?(\d+)/g)].map((m) => m[2] + (m[1] ? ':' + m[1] : '')), ['1:src', '2', '3:2', '1:src']);
|
||||
assert.match(html, /<p class="footnote" id="fn-2"><sup>3<\/sup> Second note\.<\/p>/);
|
||||
assert.match(html, /<p class="footnote" id="fn-unused"><sup>unused<\/sup>/);
|
||||
// "[^1]:" mid-line isn't a footnote text, and ids can't hold spaces
|
||||
assert.deepEqual(parseMarkdown('Text [^1]: not a definition\n\n[^a b]: neither\n').map((b) => b.type), ['p', 'p']);
|
||||
});
|
||||
|
||||
test('columns: fenced :::: columns round-trip, with any blocks in a column', () => {
|
||||
const src = 'Before\n\n:::: columns\n::: column\nLeft **bold**\n\n- a\n- b\n:::\n::: column\n## Right\n\ntext\n:::\n::::\n\nAfter\n';
|
||||
const blocks = parseMarkdown(src);
|
||||
assert.deepEqual(blocks.map((b) => b.type + (b.col ? `[${b.col.i}]` : '')), ['p', 'p[0]', 'bullet[0]', 'bullet[0]', 'h2[1]', 'p[1]', 'p']);
|
||||
const g = blocks[1].col.g;
|
||||
assert.ok(blocks.slice(1, 6).every((b) => b.col.g === g), 'one group');
|
||||
assert.equal(toMarkdown(blocks), src);
|
||||
// three columns, one of them empty: it keeps its place
|
||||
const three = ':::: columns\n::: column\na\n:::\n::: column\n:::\n::: column\nc\n:::\n::::\n';
|
||||
const b3 = parseMarkdown(three);
|
||||
assert.deepEqual(b3.map((b) => b.col.i), [0, 1, 2]);
|
||||
// not closed: just text, nothing lost
|
||||
const open = ':::: columns\n::: column\nlost?\n:::\n';
|
||||
assert.ok(toMarkdown(parseMarkdown(open)).includes('lost?'));
|
||||
// columns inside a code block are code
|
||||
const code = '```\n:::: columns\n::: column\nx\n:::\n::::\n```\n';
|
||||
assert.deepEqual(parseMarkdown(code).map((b) => b.type), ['code']);
|
||||
// two groups in a row stay two groups
|
||||
const two = ':::: columns\n::: column\na\n:::\n::::\n\n:::: columns\n::: column\nb\n:::\n::::\n';
|
||||
const gs = new Set(parseMarkdown(two).map((b) => b.col.g));
|
||||
assert.equal(gs.size, 2);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user