From 7c15b02258db863be9e58c02e3f8c1b39a4b1e03 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 29 Sep 2026 11:32:45 +0200 Subject: [PATCH] 35b - tests for the writer spelling the plain flavour and escaping its markers --- src/conformance/adf-property.test.ts | 21 +++++++++++-- src/conformance/property-harness.ts | 7 ++++- src/markdown/emit/plain-reduction.test.ts | 30 ++++++++++++------- .../parse/plain-markdown-to-adf.test.ts | 16 +++++++--- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 25c4a3a..16de9d7 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -2,9 +2,10 @@ import assert from 'node:assert/strict' import fc from 'fast-check' import test from 'node:test' +import type { AdfNode } from '../adf/document.ts' import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts' import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts' -import { adfToPlainMarkdown } from '../markdown/emit/plain-reduction.ts' +import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts' import { directivePrefix } from '../markdown/directive-syntax.ts' import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { toEditorNormal } from '../adf/editor-normal.ts' @@ -19,6 +20,20 @@ function readsNoDirective(markdown: string): boolean { return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value) } +// Each block's text, an expand's title and an image's alt and url, in document order: what the plain pair keeps. +function shownText(nodes: readonly AdfNode[]): string[] { + const shown: string[] = [] + for (const node of nodes) { + const attrs = node.attrs ?? {} + if ((node.type === 'expand' || node.type === 'nestedExpand') && typeof attrs['title'] === 'string') shown.push(attrs['title']) + if (node.type === 'media') shown.push(`${JSON.stringify(attrs['alt'] ?? '')} ${JSON.stringify(attrs['url'])}`) + const content = node.content ?? [] + if (content.some((child) => child.type === 'text' || child.type === 'hardBreak')) shown.push(content.map((child) => child.text ?? '\n').join('')) + else for (const text of shownText(content)) shown.push(text) + } + return shown.filter((text) => text !== '') +} + test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => { fc.assert( fc.property(adfDocument, (document) => { @@ -32,7 +47,7 @@ test('a generated document refuses to emit, or its markdown reads back to it', { ) }) -test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to itself', { timeout: propertyTimeout }, () => { +test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => { fc.assert( fc.property(adfDocument, (document) => { const written = adfToPlainMarkdown(document) @@ -40,6 +55,8 @@ test('a generated document writes plain markdown refusing only what the guard re assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`) const read = plainMarkdownToAdf(written.value) assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`) + const reduced = reduceToPlain(document) + assert.deepEqual(shownText(read.value.content ?? []), reduced.ok ? shownText(reduced.value.content ?? []) : reduced, `reading ${JSON.stringify(written.value)}`) assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`) }), propertyRuns(gateRuns), diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 832e74a..72bf66e 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -23,7 +23,7 @@ export const propertyTimeout = 600000 const depthIdentifier = fc.createDepthIdentifier() const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } const flatCommonMarkShapeWeight = 4 -export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`) +export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`) const nestingCommonMarkShapeWeight = 21 const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)]) @@ -80,6 +80,7 @@ function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): const mark: Arbitrary = fc.oneof( { arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 }, + { arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 }, { arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, ) const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) @@ -135,8 +136,12 @@ const positions = fc.letrec((tie) => { paragraph, fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), ] + const task = (type: string, content: Arbitrary) => + fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type })) + const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 }) const nestingCommonMarkShapes = [ blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), + fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })), listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), fc .record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 618c980..c12c343 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -2,9 +2,8 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' -import { adfToMarkdown } from './adf-to-markdown.ts' +import { adfToPlainMarkdown, reduceToPlain } from './plain-reduction.ts' import { largestNesting } from '../../nesting.ts' -import { reduceToPlain } from './plain-reduction.ts' const code: AdfMark = { type: 'code' } const em: AdfMark = { type: 'em' } @@ -19,11 +18,8 @@ function plain(...content: AdfNode[]): string { } function plainDocument(input: AdfDocument): string { - const reduced = reduceToPlain(input) - if (!reduced.ok) return `${reduced.error.code} at /${reduced.error.path.join('/')}` - const markdown = adfToMarkdown(reduced.value) - if (!markdown.ok) return `emit ${markdown.error.code}: ${markdown.error.message}` - return markdown.value + const markdown = adfToPlainMarkdown(input) + return markdown.ok ? markdown.value : `${markdown.error.code} at /${markdown.error.path.join('/')}` } function text(value: string, ...marks: AdfMark[]): AdfNode { @@ -119,6 +115,7 @@ test('spells an expand and a nested expand as a folded callout titled by the mar ) assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n') assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n') + assert.equal(plain(node('expand', { title: '**x** [y](z) ==w==' }, said('b'))), '> [!NOTE]- \\*\\*x\\*\\* \\[y](z) ==w==\n>\n> b\n') }) test('spells a task list as a bullet list whose items lead with their state', () => { @@ -142,6 +139,18 @@ test('spells a highlight as a == pair around the run, whatever its colour', () = assert.equal(plain(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n') assert.equal(plain(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n') assert.equal(plain(paragraph(text('a', highlight('#fff'), code))), '==`a`==\n') + assert.equal(plain(paragraph(text('=', highlight('#fff')), text(' '), text('a==b', highlight('#fff')))), '==\\=== ==a==b==\n') + assert.equal(plain(paragraph(text('x'), text('y', highlight('#fff')), text(' z'))), 'xy z\n') +}) + +test('escapes text that would read back as a flavour marker, and only there', () => { + assert.equal(plain(said('==x== a == b a==b ===')), '\\==x\\== a == b a==b \\=\\==\n') + assert.equal(plain({ content: [said('[!NOTE] x'), said('[!TIP]')], type: 'blockquote' }), '> \\[!NOTE] x\n>\n> [!TIP]\n') + assert.equal(plain({ content: [said('[!NOTE]x')], type: 'blockquote' }, said('[!NOTE]')), '> [!NOTE]x\n\n[!NOTE]\n') + assert.equal(plain(bulletList(item(said('[x] a')), item(said('[ ]')))), '- \\[x] a\n- [ ]\n') + assert.equal(plain(bulletList(item(said('[x] a')), item(said('b'))), node('orderedList', { order: 1 }, item(said('[x] c')))), '- [x] a\n- b\n\n1. [x] c\n') + const task = (state: string, value: string): AdfNode => node('taskItem', { state }, text(value)) + assert.equal(plain(node('taskList', {}, task('DONE', '[x] a'), task('TODO', '==b=='))), '- [x] [x] a\n- [ ] \\==b\\==\n') }) test('unwraps the containers plain markdown has no spelling for to their body blocks in order', () => { @@ -311,7 +320,8 @@ test('drops an empty paragraph and merges adjacent lists of one type', () => { assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n') }) -test('returns a document the lossless emitter spells without a directive', () => { - const reduced = reduceToPlain(document(node('panel', { panelType: 'info' }, said('x')))) - assert.deepEqual(reduced.ok ? reduced.value : undefined, document({ content: [said('[!NOTE]'), said('x')], type: 'blockquote' })) +test('keeps the nodes the plain flavour spells and degrades only what it cannot', () => { + const tasks = node('taskList', {}, node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state: 'DONE' }, text('t'))) + const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks)) + assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), node('taskList', {}, node('taskItem', { state: 'DONE' }, text('t'))))) }) diff --git a/src/markdown/parse/plain-markdown-to-adf.test.ts b/src/markdown/parse/plain-markdown-to-adf.test.ts index f378da4..68f73a2 100644 --- a/src/markdown/parse/plain-markdown-to-adf.test.ts +++ b/src/markdown/parse/plain-markdown-to-adf.test.ts @@ -3,9 +3,9 @@ import test from 'node:test' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import { adfToMarkdown } from '../emit/adf-to-markdown.ts' +import { adfToPlainMarkdown } from '../emit/plain-reduction.ts' import { largestNesting } from '../../nesting.ts' import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts' -import { reduceToPlain } from '../emit/plain-reduction.ts' import { toEditorNormal } from '../../adf/editor-normal.ts' const code: AdfMark = { type: 'code' } @@ -26,10 +26,8 @@ function document(...content: AdfNode[]): AdfDocument { return { content, type: 'doc', version: 1 } } -// ADF the reduction wrote, spelled and read back. function roundTripped(...content: AdfNode[]): readonly AdfNode[] | string { - const reduced = reduceToPlain(document(...content)) - const markdown = reduced.ok ? adfToMarkdown(reduced.value) : reduced + const markdown = adfToPlainMarkdown(document(...content)) return markdown.ok ? read(markdown.value) : markdown.error.code } @@ -190,6 +188,16 @@ test('reads what the reduction wrote back to the node it reduced, less the attri assert.deepEqual(roundTripped(tasks), [plainTasks]) const colour: AdfMark = { attrs: { color: '#c6edfb' }, type: 'backgroundColor' } assert.deepEqual(roundTripped(paragraph(text('a '), text('hi', colour, strong), text(' b'))), [paragraph(text('a '), text('hi', highlight, strong), text(' b'))]) + assert.deepEqual(roundTripped(paragraph(text('=', colour), text(' '), text('a==b', colour))), [paragraph(text('=', highlight), text(' '), text('a==b', highlight))]) + assert.deepEqual(roundTripped(paragraph(text('x'), text('y', colour))), [said('xy')]) + assert.deepEqual(roundTripped(node('expand', { title: '**x** [y](z)' }, said('b'))), [node('expand', { title: '**x** [y](z)' }, said('b'))]) +}) + +test('reads text the writer kept from reading as a marker back as text', () => { + const quote = bare('blockquote', said('[!NOTE] x')) + const list = bare('bulletList', bare('listItem', said('[x] a')), bare('listItem', said('[ ] b'))) + assert.deepEqual(roundTripped(said('==x== a==b'), quote, list), [said('==x== a==b'), quote, list]) + assert.deepEqual(roundTripped(bare('taskList', task('DONE', text('[x] ==a==')))), [bare('taskList', task('DONE', text('[x] ==a==')))]) }) test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => {