From 873af0794d7acab0d333751342863ea442cf4441 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 22:17:35 +0200 Subject: [PATCH] test: fuzz round-trip leg over seeded random documents tests/fuzzDoc.ts generates documents from a seeded PRNG (marks, lists, tables, indents, XML-hostile text); fuzz-roundtrip.test.ts runs 50 seeds through buildOdt -> importOdt and diffs the normalized JSON, naming the failing seed. normalize/firstDiff moved out of roundtrip.test.ts into tests/normalize.ts for reuse. The generator documents what is lossy by design: default-equal marks in headings, indents and start in nested lists. --- CLAUDE.md | 1 + tests/fuzz-roundtrip.test.ts | 33 ++++++++++ tests/fuzzDoc.ts | 121 +++++++++++++++++++++++++++++++++++ tests/normalize.ts | 60 +++++++++++++++++ tests/roundtrip.test.ts | 59 +---------------- 5 files changed, 216 insertions(+), 58 deletions(-) create mode 100644 tests/fuzz-roundtrip.test.ts create mode 100644 tests/fuzzDoc.ts create mode 100644 tests/normalize.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8428e60..b97dff5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom v `lo-roundtrip.test.ts` re-saves through LibreOffice and **self-skips** without `soffice`, so `npm test`/CI stay green; `corpus.test.ts` round-trips the committed `tests/corpus/` documents (authored by `render-parity/make-fixtures.mjs`, never by our own exporter); +`fuzz-roundtrip.test.ts` round-trips seeded random documents (`fuzzDoc.ts` generator); `tests/unit/` holds fast helper tests. All test tooling stays a `devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`. diff --git a/tests/fuzz-roundtrip.test.ts b/tests/fuzz-roundtrip.test.ts new file mode 100644 index 0000000..16a9ee9 --- /dev/null +++ b/tests/fuzz-roundtrip.test.ts @@ -0,0 +1,33 @@ +// Property leg: seeded random documents (fuzzDoc.ts) -> buildOdt -> importOdt must +// come back unchanged (normalized). A failure names the seed, so it reproduces. +import { describe, it, expect } from 'vitest'; +import { buildOdt } from '../src/lib/export/odt'; +import { importOdt } from '../src/lib/import/odt'; +import { normalize, firstDiff } from './normalize'; +import { genDoc, mulberry32 } from './fuzzDoc'; + +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + +// A paragraph whose runs share one font legitimately comes back with that font also +// on its attrs (the empty-line-height feature hoists it); ignore it on both sides. +function stripFontHoist(node: any): any { + if (node.content?.length && node.attrs) { + const { fontSize, fontFamily, ...rest } = node.attrs; + node.attrs = Object.keys(rest).length ? rest : undefined; + if (!node.attrs) delete node.attrs; + } + for (const c of node.content ?? []) stripFontHoist(c); + return node; +} + +describe('fuzz round-trip: editor → buildOdt → importOdt', () => { + it('50 seeded random documents come back identical', async () => { + for (let seed = 1; seed <= 50; seed++) { + const doc = genDoc(mulberry32(seed)); + const res = importOdt(await buildOdt(doc, margins, 'portrait')); + expect.soft(res.warnings, `seed ${seed}: warnings`).toEqual([]); + const diff = firstDiff(stripFontHoist(normalize(doc)), stripFontHoist(normalize(res.content))); + expect.soft(diff, `seed ${seed} (re-run genDoc(mulberry32(${seed})) to reproduce)`).toBeNull(); + } + }, 120_000); +}); diff --git a/tests/fuzzDoc.ts b/tests/fuzzDoc.ts new file mode 100644 index 0000000..415d031 --- /dev/null +++ b/tests/fuzzDoc.ts @@ -0,0 +1,121 @@ +// Seeded random-document generator for the fuzz round-trip leg (fuzz-roundtrip.test.ts). +// ponytail: conservative node pool (no spans, notes, boxes); widen when it holds. +type N = any; +type Rng = () => number; + +export function mulberry32(seed: number): Rng { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const pick = (r: Rng, arr: readonly T[]): T => arr[Math.floor(r() * arr.length)]; +const int = (r: Rng, min: number, max: number) => min + Math.floor(r() * (max - min + 1)); +const maybe = (r: Rng, p: number) => r() < p; + +// XML-hostile characters, umlauts, an astral-plane glyph and a tab on purpose. +const WORDS = ['Lorem', 'ipsum', 'Größe', 'fünf', 'a&b', '', '"quote"', "it's", + '€42', '🙂ok', 'x\ty', 'dolor', 'sit'] as const; + +// A heading run must not carry marks equal to the heading defaults (bold, italic on +// levels 4/6, Arial, the level's size) — both importers suppress default-equal values, +// so those are lossy by design. Headings get the marks with no heading default. +function marks(r: Rng, heading = false): N[] | undefined { + const out: N[] = []; + if (!heading && maybe(r, 0.25)) out.push({ type: 'bold' }); + if (!heading && maybe(r, 0.2)) out.push({ type: 'italic' }); + if (maybe(r, 0.1)) out.push({ type: 'underline' }); + if (maybe(r, 0.1)) out.push({ type: 'strike' }); + if (maybe(r, 0.1)) out.push({ type: 'highlight', attrs: { color: pick(r, ['#FFFF00', '#00FF00']) } }); + if (maybe(r, 0.2)) { + const attrs: N = {}; + if (maybe(r, 0.5)) attrs.color = pick(r, ['#C00000', '#0070C0']); + if (!heading && maybe(r, 0.4)) attrs.fontFamily = 'Arial'; + if (!heading && maybe(r, 0.4)) attrs.fontSize = pick(r, ['10pt', '14pt', '18pt']); + if (Object.keys(attrs).length) out.push({ type: 'textStyle', attrs }); + } + return out.length ? out : undefined; +} + +function runs(r: Rng, heading = false): N[] { + const n = int(r, 1, 4); + return Array.from({ length: n }, (_, i) => { + const text = Array.from({ length: int(r, 1, 5) }, () => pick(r, WORDS)).join(' ') + + (i < n - 1 ? ' ' : ''); + const m = marks(r, heading); + return { type: 'text', text, ...(m ? { marks: m } : {}) }; + }); +} + +// indents=false inside list items: a list paragraph's indent lives in the list style +// and does not round-trip as direct formatting (import/odt.ts skips it there). +function paraAttrs(r: Rng, indents: boolean): N | null { + const attrs: N = {}; + if (maybe(r, 0.3)) attrs.textAlign = pick(r, ['left', 'center', 'right', 'justify']); + if (maybe(r, 0.2)) attrs.lineHeight = pick(r, ['1.5', '2']); + if (maybe(r, 0.15)) attrs.spaceBefore = pick(r, [6, 12]); + if (maybe(r, 0.15)) attrs.spaceAfter = pick(r, [12, 18]); + if (indents && maybe(r, 0.15)) attrs.indent = pick(r, [1.25, 2.5]); + if (indents && maybe(r, 0.1)) attrs.indentFirst = pick(r, [0.75, -0.75]); + if (indents && maybe(r, 0.1)) attrs.indentRight = 1.5; + return Object.keys(attrs).length ? attrs : null; +} + +function paragraph(r: Rng, indents = true): N { + if (maybe(r, 0.08)) return { type: 'paragraph' }; // empty line + const attrs = paraAttrs(r, indents); + const content = runs(r); + const body: N[] = []; + for (const run of content) { + body.push(run); + if (maybe(r, 0.1)) body.push({ type: 'hardBreak' }); + } + return { type: 'paragraph', ...(attrs ? { attrs } : {}), content: body }; +} + +function list(r: Rng, kind: 'bulletList' | 'orderedList', depth: number): N { + const items = Array.from({ length: int(r, 1, 3) }, () => { + const kids: N[] = [paragraph(r, false)]; + if (depth < 2 && maybe(r, 0.25)) kids.push(list(r, pick(r, ['bulletList', 'orderedList']), depth + 1)); + return { type: 'listItem', content: kids }; + }); + // start only at the top level: applyListStartValues writes text:start-value for + // top-level lists only, a nested list's start does not round-trip. + const attrs: N = kind === 'orderedList' && depth === 0 && maybe(r, 0.2) ? { start: 3 } : null; + return { type: kind, ...(attrs ? { attrs } : {}), content: items }; +} + +function table(r: Rng): N { + const cols = int(r, 1, 3); + const rows = Array.from({ length: int(r, 1, 3) }, () => ({ + type: 'tableRow', + content: Array.from({ length: cols }, () => ({ + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [paragraph(r)], + })), + })); + return { type: 'table', content: rows }; +} + +export function genDoc(r: Rng): N { + const blocks: N[] = []; + let prev = ''; + for (let i = int(r, 1, 6); i > 0; i--) { + const roll = r(); + let block: N; + if (roll < 0.45) block = paragraph(r); + else if (roll < 0.55) block = { type: 'heading', attrs: { level: int(r, 1, 6) }, content: runs(r, true) }; + else if (roll < 0.75) block = list(r, pick(r, ['bulletList', 'orderedList']), 0); + else block = table(r); + // adjacent same-type lists merge on import; keep them apart so identity holds + if (block.type.endsWith('List') && block.type === prev) blocks.push({ type: 'paragraph' }); + blocks.push(block); + prev = block.type; + } + return { type: 'doc', content: blocks }; +} diff --git a/tests/normalize.ts b/tests/normalize.ts new file mode 100644 index 0000000..e6ef7d2 --- /dev/null +++ b/tests/normalize.ts @@ -0,0 +1,60 @@ +// Shared round-trip comparison: reduce editor JSON to a canonical shape (defaults +// dropped, marks sorted, adjacent identical runs merged) and report the first diff. +type N = any; + +const ORDERED_DEFAULTS: Record = { + textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null, + listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1, + type: null, level: undefined, rotation: 0, wrap: 'inline', + shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, +}; + +export function normalize(node: N): N { + const out: N = { type: node.type }; + if (node.text != null) out.text = node.text; + if (node.marks?.length) { + out.marks = node.marks + .map((m: N) => { + const mm: N = { type: m.type }; + const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}).filter(([, v]) => v != null)); + if (Object.keys(attrs).length) mm.attrs = attrs; + return mm; + }) + .sort((a: N, b: N) => a.type.localeCompare(b.type)); + } + const attrs: N = {}; + for (const [k, v] of Object.entries(node.attrs ?? {})) { + if (v == null) continue; + if (k in ORDERED_DEFAULTS && ORDERED_DEFAULTS[k] === v) continue; + if (k === 'colwidth') { attrs.colwidth = 'CW'; continue; } // ratios compared separately + attrs[k] = v; + } + if (node.attrs?.level != null) attrs.level = node.attrs.level; + if (Object.keys(attrs).length) out.attrs = attrs; + if (node.content?.length) { + // merge adjacent identical text nodes so run-splitting differences don't matter + const kids: N[] = []; + for (const c of node.content.map(normalize)) { + const prev = kids[kids.length - 1]; + if (prev && prev.type === 'text' && c.type === 'text' && + JSON.stringify(prev.marks ?? null) === JSON.stringify(c.marks ?? null)) { + prev.text += c.text; + } else kids.push(c); + } + out.content = kids; + } + return out; +} + +export function firstDiff(a: N, b: N, path = '$'): string | null { + if (typeof a !== typeof b) return `${path}: type ${typeof a} vs ${typeof b}`; + if (typeof a !== 'object' || a === null || b === null) { + return Object.is(a, b) ? null : `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`; + } + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of keys) { + const d = firstDiff(a[k], b[k], `${path}.${k}`); + if (d) return d; + } + return null; +} diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index da7c392..bfa4185 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -7,6 +7,7 @@ import { getSchema } from '@tiptap/core'; import { Node as PMNode } from '@tiptap/pm/model'; import { buildOdt, MAX_HEADING_LEVEL } from '../src/lib/export/odt'; import { importOdt } from '../src/lib/import/odt'; +import { normalize, firstDiff } from './normalize'; import { hfExtensions } from '../src/lib/editor/extensions/headerFooter'; import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow'; import { builtinStyleSheet } from '../src/lib/styles/styleSheet'; @@ -183,64 +184,6 @@ const fixture: N = { ], }; -// ---------- normalization for comparison ---------- -const ORDERED_DEFAULTS: Record = { - textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null, - listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1, - type: null, level: undefined, rotation: 0, wrap: 'inline', - shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, -}; - -function normalize(node: N): N { - const out: N = { type: node.type }; - if (node.text != null) out.text = node.text; - if (node.marks?.length) { - out.marks = node.marks - .map((m: N) => { - const mm: N = { type: m.type }; - const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}).filter(([, v]) => v != null)); - if (Object.keys(attrs).length) mm.attrs = attrs; - return mm; - }) - .sort((a: N, b: N) => a.type.localeCompare(b.type)); - } - const attrs: N = {}; - for (const [k, v] of Object.entries(node.attrs ?? {})) { - if (v == null) continue; - if (k in ORDERED_DEFAULTS && ORDERED_DEFAULTS[k] === v) continue; - if (k === 'colwidth') { attrs.colwidth = 'CW'; continue; } // ratios compared separately - attrs[k] = v; - } - if (node.attrs?.level != null) attrs.level = node.attrs.level; - if (Object.keys(attrs).length) out.attrs = attrs; - if (node.content?.length) { - // merge adjacent identical text nodes so run-splitting differences don't matter - const kids: N[] = []; - for (const c of node.content.map(normalize)) { - const prev = kids[kids.length - 1]; - if (prev && prev.type === 'text' && c.type === 'text' && - JSON.stringify(prev.marks ?? null) === JSON.stringify(c.marks ?? null)) { - prev.text += c.text; - } else kids.push(c); - } - out.content = kids; - } - return out; -} - -function firstDiff(a: N, b: N, path = '$'): string | null { - if (typeof a !== typeof b) return `${path}: type ${typeof a} vs ${typeof b}`; - if (typeof a !== 'object' || a === null || b === null) { - return Object.is(a, b) ? null : `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`; - } - const keys = new Set([...Object.keys(a), ...Object.keys(b)]); - for (const k of keys) { - const d = firstDiff(a[k], b[k], `${path}.${k}`); - if (d) return d; - } - return null; -} - function collectColwidths(node: N, acc: number[][] = []): number[][] { if (Array.isArray(node.attrs?.colwidth)) acc.push(node.attrs.colwidth); for (const c of node.content ?? []) collectColwidths(c, acc);