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.
This commit is contained in:
Steffen Becker committed 2026-08-15 22:17:35 +02:00
1 parent 3c7b598169
commit 873af0794d
5 files changed
+216 -58

No files matched your search

+1
View File
@@ -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`.
+33
View File
@@ -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);
});
+121
View File
@@ -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 = <T,>(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', '<tag>', '"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 };
}
+60
View File
@@ -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<string, unknown> = {
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;
}
+1 -58
View File
@@ -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<string, unknown> = {
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);