4.2: the ADF property
This commit is contained in:
@@ -67,7 +67,8 @@ deliberate re-pin, exceptions re-derived by hand beside it. Atlassian's ADF JSON
|
|||||||
vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev
|
vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev
|
||||||
dependency — CommonJS-only, some fifty packages with React among them, and a release most days for
|
dependency — CommonJS-only, some fifty packages with React among them, and a release most days for
|
||||||
Renovate to automerge — re-pinned by hand when a payload or a report shows the need.
|
Renovate to automerge — re-pinned by hand when a payload or a report shows the need.
|
||||||
`devDependencies`: few, each earning its keep; they never reach a consumer.
|
`devDependencies`: few, each earning its keep; they never reach a consumer. `fast-check` earns its
|
||||||
|
place shrinking a failing generated document to the nodes that break it.
|
||||||
|
|
||||||
## 6. The package contract
|
## 6. The package contract
|
||||||
|
|
||||||
@@ -225,6 +226,10 @@ compared against `undefined` — have a half no valid document reaches.
|
|||||||
The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from
|
The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from
|
||||||
live Atlassian APIs; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
|
live Atlassian APIs; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
|
||||||
|
|
||||||
|
Beside the corpus, properties run over documents generated from the node tables, on a fixed seed in
|
||||||
|
the gate; `PROPERTY_RUNS=<runs>` raises the runs and randomizes the seed for local digging, and a
|
||||||
|
counterexample found becomes a round-trip fixture.
|
||||||
|
|
||||||
`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they
|
`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they
|
||||||
copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
|
copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
|
||||||
named before its first em dash, with the attributes following `Attributes: ` — a parenthesized
|
named before its first em dash, with the attributes following `Attributes: ` — a parenthesized
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
in_image "$deno_image" deno test --allow-read --no-check src/
|
in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/
|
||||||
in_image "$bun_image" bun test src/
|
in_image "$bun_image" bun test src/
|
||||||
|
|
||||||
in_image "$node_image" npm run build
|
in_image "$node_image" npm run build
|
||||||
|
|||||||
Generated
+41
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
"fast-check": "4.10.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -366,6 +367,46 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "4.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
|
||||||
|
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "8.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||||
|
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
"fast-check": "4.10.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import fc from 'fast-check'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { env } from 'node:process'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||||
|
import type { Arbitrary } from 'fast-check'
|
||||||
|
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
||||||
|
import type { JsonValue } from './json-value.ts'
|
||||||
|
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||||
|
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
||||||
|
import { blockDirectives } from './adf/block-directives.ts'
|
||||||
|
import { inlineDirectives } from './adf/inline-directives.ts'
|
||||||
|
import { isJsonValue } from './json-value.ts'
|
||||||
|
import { markAttributes } from './adf/mark-attributes.ts'
|
||||||
|
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||||
|
import { toEditorNormal } from './adf/editor-normal.ts'
|
||||||
|
|
||||||
|
type Positions = { block: AdfNode; inline: AdfNode }
|
||||||
|
|
||||||
|
const deepRunsVariable = 'PROPERTY_RUNS'
|
||||||
|
const gateRuns = 3000
|
||||||
|
const gateSeed = 20260914
|
||||||
|
|
||||||
|
const depthIdentifier = fc.createDepthIdentifier()
|
||||||
|
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
||||||
|
const markdownCharacters = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é 🎉')
|
||||||
|
const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)])
|
||||||
|
|
||||||
|
function textOf(minLength: number): Arbitrary<string> {
|
||||||
|
return fc.oneof(
|
||||||
|
{ arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownCharacters }), weight: 4 },
|
||||||
|
{ arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonValue = fc.jsonValue({ maxDepth: 2 }).filter(isJsonValue)
|
||||||
|
const text = textOf(1)
|
||||||
|
const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type))
|
||||||
|
|
||||||
|
const valueByKind: Readonly<Record<AttributeKind, Arbitrary<JsonValue>>> = {
|
||||||
|
boolean: fc.boolean(),
|
||||||
|
json: jsonValue,
|
||||||
|
number: fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }),
|
||||||
|
string: textOf(0),
|
||||||
|
}
|
||||||
|
|
||||||
|
function attributes(vocabulary: AttributeVocabulary): Arbitrary<AdfAttributes> {
|
||||||
|
const model = Object.fromEntries(
|
||||||
|
Object.entries(vocabulary).map(([key, kind]) => [key, fc.oneof({ arbitrary: fc.constant(undefined), weight: 2 }, { arbitrary: valueByKind[kind], weight: 1 })]),
|
||||||
|
)
|
||||||
|
return fc.record(model).map(heldAttributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>): AdfAttributes {
|
||||||
|
const attrs: AdfAttributes = {}
|
||||||
|
for (const [key, value] of Object.entries(held)) if (value !== undefined) attrs[key] = value
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
||||||
|
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
|
||||||
|
return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mark: Arbitrary<AdfMark> = fc.oneof(
|
||||||
|
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
|
||||||
|
{ arbitrary: fc.record({ attrs: fc.dictionary(text, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
|
||||||
|
)
|
||||||
|
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
||||||
|
|
||||||
|
const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' }))
|
||||||
|
|
||||||
|
const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) =>
|
||||||
|
fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const positions = fc.letrec<Positions>((tie) => {
|
||||||
|
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
|
||||||
|
const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 })
|
||||||
|
const contentByModel = {
|
||||||
|
block: blockContent,
|
||||||
|
code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }),
|
||||||
|
inline: inlineContent,
|
||||||
|
none: fc.constant<AdfNode[]>([]),
|
||||||
|
}
|
||||||
|
const blockMarks = fc.oneof({ arbitrary: fc.constant<AdfMark[]>([]), weight: 4 }, { arbitrary: marks, weight: 1 })
|
||||||
|
const blockNodes = Object.entries(blockDirectives).map(([type, directive]) => {
|
||||||
|
const argument = blockArgument(type)
|
||||||
|
const vocabulary: AttributeVocabulary = argument === undefined ? directive.attributes : { ...directive.attributes, [argument]: 'string' }
|
||||||
|
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[directive.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type }))
|
||||||
|
return { leaf: directive.contentModel === 'code' || directive.contentModel === 'none', node }
|
||||||
|
})
|
||||||
|
const unknownNode = fc
|
||||||
|
.record({ attrs: fc.dictionary(text, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
|
||||||
|
.map((held): AdfNode => held)
|
||||||
|
const leafBlocks = blockNodes.filter((entry) => entry.leaf).map((entry) => entry.node)
|
||||||
|
const containerBlocks = blockNodes.filter((entry) => !entry.leaf).map((entry) => entry.node)
|
||||||
|
const misplacedWeight = 7
|
||||||
|
const paragraph = inlineContent.map((content): AdfNode => ({ content, type: 'paragraph' }))
|
||||||
|
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
|
||||||
|
const listItems = fc.array(
|
||||||
|
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })),
|
||||||
|
{ depthIdentifier, maxLength: 3, minLength: 1 },
|
||||||
|
)
|
||||||
|
const commonMarkShapes = [
|
||||||
|
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
|
||||||
|
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
|
||||||
|
fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })),
|
||||||
|
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 }) })
|
||||||
|
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
block: fc.oneof(
|
||||||
|
{ depthIdentifier, depthSize: 'small', maxDepth: 4 },
|
||||||
|
{ arbitrary: fc.oneof(...leafBlocks), weight: leafBlocks.length * 2 },
|
||||||
|
{ arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 },
|
||||||
|
{ arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight },
|
||||||
|
{ arbitrary: fc.oneof(...commonMarkShapes), weight: blockNodes.length * 2 + misplacedWeight },
|
||||||
|
),
|
||||||
|
inline: fc.oneof(
|
||||||
|
{ depthIdentifier, depthSize: 'small', maxDepth: 4 },
|
||||||
|
{ arbitrary: textNode, weight: 12 },
|
||||||
|
{ arbitrary: fc.oneof(...inlineNodes), weight: 7 },
|
||||||
|
{ arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 }))
|
||||||
|
|
||||||
|
function runParameters(): { numRuns: number; seed?: number } {
|
||||||
|
const deepRuns = env[deepRunsVariable]
|
||||||
|
if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed }
|
||||||
|
const numRuns = Number(deepRuns)
|
||||||
|
assert.ok(Number.isSafeInteger(numRuns) && numRuns > 0, `${deepRunsVariable} holds a whole number of runs: found ${deepRuns}`)
|
||||||
|
return { numRuns }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a generated document refuses to emit, or its markdown reads back to it', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(adfDocument, (document) => {
|
||||||
|
const emitted = adfToMarkdown(document)
|
||||||
|
if (!emitted.ok) return
|
||||||
|
const read = markdownToAdf(emitted.value)
|
||||||
|
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||||
|
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`)
|
||||||
|
}),
|
||||||
|
runParameters(),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -107,18 +107,6 @@ function roundTripFixtures(): { name: string; path: string }[] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test('no two round-trip documents share one markdown spelling', () => {
|
|
||||||
const spellings = new Map<string, string>()
|
|
||||||
for (const fixture of roundTripFixtures()) {
|
|
||||||
const parsed: unknown = JSON.parse(readFileSync(fixture.path, 'utf8'))
|
|
||||||
assert.ok(isAdfDocument(parsed), `${fixture.name} is not an ADF document`)
|
|
||||||
const result = adfToMarkdown(parsed)
|
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
|
||||||
assert.equal(spellings.get(result.value), undefined, `${fixture.name} and ${spellings.get(result.value)} share one markdown spelling`)
|
|
||||||
spellings.set(result.value, fixture.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('no round-trip fixture repeats the document another holds', () => {
|
test('no round-trip fixture repeats the document another holds', () => {
|
||||||
const documents = new Map<string, string>()
|
const documents = new Map<string, string>()
|
||||||
for (const fixture of roundTripFixtures()) {
|
for (const fixture of roundTripFixtures()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user