From d341da0a29a56ae2f97e28430aaa1f391f5cfcb3 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 9 Nov 2026 00:02:16 +0100 Subject: [PATCH] Pin the portable flavour's callout and task reading and writing with tests --- .../portable/adf-to-portable-markdown.test.ts | 28 ++++++++ .../portable/portable-markdown-to-adf.test.ts | 65 +++++++++++++++++++ todo.md | 21 +++--- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index cdf644a..7d137c9 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -491,3 +491,31 @@ test('writes a text node holding content the same in either order beside its nei assert.equal(portable(paragraph(holding, text('b'))), 'ab\n') assert.equal(portable(paragraph(text('b'), holding)), 'ba\n') }) + +test('writes a task list nested to the deepest level the reader carries, and refuses one more at the item holding it', () => { + const task = (): AdfNode => node('taskItem', { state: 'TODO' }, text('a')) + const nested = (levels: number): AdfNode => { + let list: AdfNode = { content: [task()], type: 'taskList' } + for (let level = 1; level < levels; level += 1) list = { content: [task(), list], type: 'taskList' } + return list + } + const written = portable(nested(largestNesting)) + assert.ok(portableMarkdownToAdf(written).ok) + assert.equal(written.split('\n').length, largestNesting + 1) + const refused = adfToPortableMarkdown(document(nested(largestNesting + 1))) + assert.deepEqual(refused.ok ? undefined : [refused.error.code, refused.error.path], ['unsupported-nesting-depth', ['content', 0, ...Array.from({ length: largestNesting }, () => ['content', 1]).flat(), 'content', 0]]) + const quoted = (levels: number): string => portable(node('panel', { panelType: 'info' }, nested(levels))) + assert.ok(portableMarkdownToAdf(quoted(largestNesting - 1)).ok) + assert.match(quoted(largestNesting), /^unsupported-nesting-depth at \//) +}) + +test('writes a task list nested in the task before it, and more than one beside each other in that task', () => { + const done = (value: string): AdfNode => node('taskItem', { state: 'DONE' }, text(value)) + const list = (...content: AdfNode[]): AdfNode => ({ content, type: 'taskList' }) + assert.equal(portable(list(done('Parent'), list(done('Child')), done('Next'))), '- [x] Parent\n - [x] Child\n- [x] Next\n') + assert.equal(portable(list(done('Parent'), list(done('A')), list(done('B')))), '- [x] Parent\n - [x] A\n * [x] B\n') +}) + +test('writes a task list opening with a list as that list in an item of its own', () => { + assert.equal(portable({ content: [{ content: [node('taskItem', { state: 'TODO' }, text('a'))], type: 'taskList' }], type: 'taskList' }), '- - [ ] a\n') +}) diff --git a/src/markdown/portable/portable-markdown-to-adf.test.ts b/src/markdown/portable/portable-markdown-to-adf.test.ts index 4d79614..901e830 100644 --- a/src/markdown/portable/portable-markdown-to-adf.test.ts +++ b/src/markdown/portable/portable-markdown-to-adf.test.ts @@ -218,6 +218,71 @@ test('refuses a task list nested one level past the 500 the parser carries, at i assert.equal(quoted.ok ? undefined : quoted.error.code, 'unsupported-nesting-depth') }) +test('keeps a backslash a title line ends in as text, a break only before a following line', () => { + assert.deepEqual(read('> [!NOTE]- Trail\\\n'), [node('expand', { title: 'Trail\\' }, paragraph())]) + assert.deepEqual(read('> [!NOTE]- a \\* b\n> body\n'), [node('expand', { title: 'a * b' }, said('body'))]) + assert.deepEqual(read('> [!NOTE]- x\\\\\n> body\n'), [node('expand', { title: 'x\\' }, said('body'))]) +}) + +test('keeps the target of a link in a title whichever marks sit beside the link', () => { + const titled = (markdown: string): unknown => { + const blocks = read(markdown) + return typeof blocks === 'string' ? blocks : blocks[0]?.attrs?.['title'] + } + assert.equal(titled('> [!faq]- ~~[x](http://y)~~ b\n'), 'x (http://y) b') + assert.equal(titled('> [!faq]- ==[x](http://y)== b\n'), 'x (http://y) b') + assert.equal(titled('> [!faq]- *[x](http://y)* and [**z**](http://w)\n'), 'x (http://y) and z (http://w)') + assert.equal(titled('> [!faq]- **[x](http://y)** and [*z*](http://w)\n'), 'x (http://y) and z (http://w)') + assert.equal(titled('> [!faq]- [x](http://y)~~z~~\n'), 'x (http://y)z') + assert.equal(titled('> [!faq]- ![a](http://u)\n'), 'a (http://u)') +}) + +test('refuses what a callout title or body holds that no node spells, at the path of the paragraph or the callout', () => { + const refused = (markdown: string): string => { + const parsed = portableMarkdownToAdf(markdown) + return parsed.ok ? 'read' : `${parsed.error.code} ${parsed.error.path.map((step) => '/' + step).join('')}` + } + assert.equal(refused('> [!faq]- a x\n'), 'unmappable-html /content/0') + assert.equal(refused('> [!NOTE]\n>\n> a x\n'), 'unmappable-html /content/0/content/0') + assert.equal(refused('> [!NOTE] first\n>\n> a x\n'), 'unmappable-html /content/0/content/1') + assert.equal(refused('> [!NOTE]-\n>\n> a x\n'), 'unmappable-html /content/0/content/0') + assert.equal(refused('> [!NOTE]- t\n>\n> > [!NOTE]-\n> >\n> > a x\n'), 'unmappable-html /content/0/content/0/content/0') + assert.equal(refused('p\n\n> [!NOTE]\n>\n> a x\n'), 'unmappable-html /content/1/content/0') +}) + +test('refuses a callout nested one level past the 500 the parser carries, a panel and an expand alike', () => { + const nested = (marker: string, levels: number): string => Array.from({ length: levels }, (_, level) => `${'> '.repeat(level + 1)}${marker}\n${'> '.repeat(level + 1)}\n`).join('') + for (const marker of ['[!NOTE]', '[!NOTE]-', '[!NOTE]- t']) { + assert.ok(portableMarkdownToAdf(nested(marker, largestNesting)).ok, marker) + const refused = portableMarkdownToAdf(nested(marker, largestNesting + 1)) + assert.equal(refused.ok ? undefined : refused.error.code, 'unsupported-nesting-depth', marker) + } +}) + +test('places a refusal in a bullet list by the list and the item holding it', () => { + const refused = (markdown: string): string => { + const parsed = portableMarkdownToAdf(markdown) + return parsed.ok ? 'read' : parsed.error.path.map((step) => '/' + step).join('') + } + assert.equal(refused('p\n\n- a\n\n- b x\n'), '/content/1/content/1/content/0') + assert.equal(refused('- [x] a\n- b x\n'), '/content/0/content/1/content/0') + assert.equal(refused('q\n\n- [x] a\n- [ ] b x\n'), '/content/1/content/1') +}) + +test('reads a task whose marker line holds only breaks and spaces by the lines after it', () => { + assert.deepEqual(read('- [ ] a\n b\n'), [bare('taskList', task('TODO', text('a b')))]) + assert.deepEqual(read('- [ ] \\\n \\\n x\n'), [bare('taskList', task('TODO', text('x')))]) + assert.deepEqual(read('- [x]\n\n Second.\n- [ ] Next\n'), [bare('taskList', task('DONE', text('Second.')), task('TODO', text('Next')))]) + assert.deepEqual(read('- [x]\n\n First.\n\n Second.\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')))]) + assert.deepEqual(read('- [x]\n- [ ]\n'), [bare('taskList', task('DONE'), task('TODO'))]) +}) + +test('reads a lone image after a task marker as its alt text linked', () => { + const link: AdfMark = { attrs: { href: 'http://u' }, type: 'link' } + assert.deepEqual(read('- [ ] ![alt](http://u)\n'), [bare('taskList', task('TODO', text('alt', link)))]) + assert.deepEqual(read('- [ ] ![alt](http://u) b\n'), [bare('taskList', task('TODO', text('alt', link), text(' b')))]) +}) + test('reads a task list beside a list of another bullet character as two lists', () => { const tasks = (state: string, value: string): AdfNode => bare('taskList', node('taskItem', { state }, text(value))) assert.deepEqual(read('- [x] a\n* b\n'), [tasks('DONE', 'a'), bare('bulletList', bare('listItem', bare('paragraph', text('b'))))]) diff --git a/todo.md b/todo.md index 5e6a330..3dcda69 100644 --- a/todo.md +++ b/todo.md @@ -6,18 +6,18 @@ `Bar = 0.1` -`Next ID = 175` +`Next ID = 176` ## Items | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| | 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 | -| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | | 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 | | 154 | 0.4.0 | | | **Pair highlights over the inline reader's pieces by their offsets before building nodes, so no placeholder text node stands in for a delimiter.** | 2 | 1 | 2 | 3 | 4 | code health by 4 | 17.4 | | 155 | 0.4.0 | | | **Have `closeBracket` return the link, image or literal bracket it builds, and apply it to the scan in one caller.** | 2 | 1 | 2 | 3 | 4 | code health by 4 | 17.4 | | 162 | 0.4.0 | | | **Pin each rule of the emphasis sweep with a hand-worked line, so every mutant that lets a pair fall where the one-run-per-pass reference keeps it fails a test.** | 2 | 1 | 1 | 2 | 4 | code health by 4 | 17.4 | +| 175 | 0.4.0 | | | **Delete the guards in `tryTaskList` that `reduceToPortable` makes unreachable, once a test shows `adfToPortableMarkdown` reaches none of them.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 | | 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | | 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 | | 159 | 0.4.0 | | | **Delete `taskBlocks`'s branch that writes the task marker as its own paragraph, once a test shows no block task item reaches it.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 10.5 | @@ -75,14 +75,6 @@ a paragraph opening with a spellable `!adf:link` (§Nothing recurses unbounded). `src/markdown/**` bans only `html/`; banning `emit/`, `parse/` and `portable/` for the root's own files, with its `ci.sh` probe, extends the decision. -### 128. Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks. - -Mutation testing left about 40 mutants alive in code moved verbatim into `src/markdown/portable/` -on 2026-10-22, so the gaps predate the move: `tryTaskList` and `taskBlocks` in `src/markdown/portable/block-spelling.ts`, and -`markerLine`, `quoteReader`, `titleText`, `taskBlocks` and `readTaskBlock` in -`src/markdown/portable/portable-markdown-to-adf.ts`. Each test takes its expected value from -`spec/flavour.md` or `docs/decisions.md`, never from the code. - ### 75. Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile. `blockCarry` and `hostileBlockCarry` (`src/conformance/markdown-property.test.ts`) write a fence @@ -119,6 +111,15 @@ random seed. Write one line per rule of that entry, its expected markdown worked never from the code, then run Stryker on the file. A mutant that only takes fewer runs back costs a pass and may survive. +### 175. Delete the guards in `tryTaskList` that `reduceToPortable` makes unreachable, once a test shows `adfToPortableMarkdown` reaches none of them. + +`reduceToPortable` runs first and turns a task list opening with a non-task into a bullet list. So +`tryTaskList` (`portable/block-spelling.ts`) never sees `previous === undefined`, and its +`'a task holds blocks no list item spells'` failure has no test. The `path` it passes to +`taskBlocks` and `placedBlock`, and its `Math.min` of `headroom`, change no output, since the +nesting refusal's path comes from `reduceToPortable`. Found by mutation testing for item 128, +2026-11-08. + ### 45. Replace `isAdfDocument` with a reader returning `Result`. Lands with item 77: the reader returns item 77's copy. Goal 1 has every call return a result; the