test: widen the fuzz generator to effects, links, notes and cell props

New in the pool: underline/strike line styles, super/subscript, caps,
textPosition, links, page breaks, footnotes/endnotes, cell shading and
vertical alignment. normalize() canonicalizes importer-generated note ids and
drops the link mark's default plain:false; FUZZ_SEEDS widens the sweep beyond
the default 50. This is the suite that surfaced the last four export/import
fixes; all 300 sweep seeds now round-trip clean on both legs.
This commit is contained in:
Steffen Becker committed 2026-08-16 07:50:22 +02:00
1 parent 1f1176283a
commit a7fe334900
4 files changed
+80 -21

No files matched your search

+7 -6
View File
@@ -9,6 +9,7 @@ import { normalize, firstDiff } from './normalize';
import { genDoc, mulberry32 } from './fuzzDoc';
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const SEEDS = Number(process.env.FUZZ_SEEDS ?? 50); // FUZZ_SEEDS=500 for a wide sweep
// 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.
@@ -23,15 +24,15 @@ function stripFontHoist(node: any): any {
}
describe('fuzz round-trip: editor → buildOdt → importOdt', () => {
it('50 seeded random documents come back identical', async () => {
for (let seed = 1; seed <= 50; seed++) {
it(`${SEEDS} seeded random documents come back identical`, async () => {
for (let seed = 1; seed <= SEEDS; 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);
}, 600_000);
// ODT leaves default column widths implicit where DOCX always writes w:tcW; ignore
// them in the cross-format comparison (each format's own leg still covers widths).
@@ -46,8 +47,8 @@ describe('fuzz round-trip: editor → buildOdt → importOdt', () => {
// Cross-format: the same document through both pipelines must import identically —
// catches one exporter silently losing what the other keeps.
it('50 seeded documents agree between the ODT and DOCX round-trips', async () => {
for (let seed = 1; seed <= 50; seed++) {
it(`${SEEDS} seeded documents agree between the ODT and DOCX round-trips`, async () => {
for (let seed = 1; seed <= SEEDS; seed++) {
const doc = genDoc(mulberry32(seed));
const viaOdt = importOdt(await buildOdt(doc, margins, 'portrait')).content;
const viaDocx = importDocx(await buildDocx(doc, margins, 'portrait')).content;
@@ -55,5 +56,5 @@ describe('fuzz round-trip: editor → buildOdt → importOdt', () => {
stripColwidth(stripFontHoist(normalize(viaDocx))));
expect.soft(diff, `seed ${seed}: ODT vs DOCX import (genDoc(mulberry32(${seed})))`).toBeNull();
}
}, 120_000);
}, 600_000);
});
+51 -13
View File
@@ -1,5 +1,5 @@
// 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.
// ponytail: no boxes/images/formulas yet; widen when the current pool holds.
type N = any;
type Rng = () => number;
@@ -28,14 +28,32 @@ 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' });
// A link excludes color/underline effects: the DOCX importer tells an editor link from
// a styled foreign one by exactly that styling (link.ts `plain`), so it can't survive.
if (maybe(r, 0.08)) {
out.push({ type: 'link', attrs: { href: 'https://example.com/x?a=1&b=2' } });
return out;
}
if (maybe(r, 0.1)) {
const attrs: N = {};
if (maybe(r, 0.3)) attrs.lineStyle = pick(r, ['dotted', 'double', 'dashed']);
if (maybe(r, 0.2)) attrs.lineColor = '#FF0000';
out.push({ type: 'underline', ...(Object.keys(attrs).length ? { attrs } : {}) });
}
if (maybe(r, 0.1)) {
out.push(maybe(r, 0.25) ? { type: 'strike', attrs: { lineStyle: 'double' } } : { type: 'strike' });
}
const shifted = maybe(r, 0.08);
if (shifted) out.push({ type: pick(r, ['superscript', 'subscript']) });
if (maybe(r, 0.1)) out.push({ type: 'highlight', attrs: { color: pick(r, ['#FFFF00', '#00FF00']) } });
if (maybe(r, 0.2)) {
if (maybe(r, 0.25)) {
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 (maybe(r, 0.2)) attrs.caps = pick(r, ['uppercase', 'smallCaps']);
// sub/superscript and a free raise share ODF's one text-position attribute
if (!shifted && maybe(r, 0.15)) attrs.textPosition = pick(r, [3, -2]);
if (Object.keys(attrs).length) out.push({ type: 'textStyle', attrs });
}
return out.length ? out : undefined;
@@ -53,7 +71,8 @@ function runs(r: Rng, heading = false): N[] {
// 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 {
// top=false in cells/lists: the page-break sentinel rides top-level blocks only.
function paraAttrs(r: Rng, indents: boolean, top: 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']);
@@ -62,12 +81,13 @@ function paraAttrs(r: Rng, indents: boolean): N | null {
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;
if (top && maybe(r, 0.05)) attrs.breakBefore = 'page';
return Object.keys(attrs).length ? attrs : null;
}
function paragraph(r: Rng, indents = true): N {
function paragraph(r: Rng, indents = true, top = false): N {
if (maybe(r, 0.08)) return { type: 'paragraph' }; // empty line
const attrs = paraAttrs(r, indents);
const attrs = paraAttrs(r, indents, top);
const content = runs(r);
const body: N[] = [];
for (const run of content) {
@@ -93,22 +113,39 @@ 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)],
})),
content: Array.from({ length: cols }, () => {
const attrs: N = { colspan: 1, rowspan: 1, colwidth: null };
// not #F2F2F2: that exact shade is HEADER_SHADE, whose bold is presentational
if (maybe(r, 0.15)) attrs.backgroundColor = pick(r, ['#FFFF00', '#DDEEFF']);
if (maybe(r, 0.1)) attrs.verticalAlign = pick(r, ['middle', 'bottom']);
return { type: 'tableCell', attrs, content: [paragraph(r)] };
}),
}));
return { type: 'table', content: rows };
}
// Roman labels for endnotes match the importer's default numbering.
const ROMAN = ['i', 'ii', 'iii', 'iv', 'v'] as const;
export function genDoc(r: Rng): N {
const blocks: N[] = [];
const notes: N[] = [];
let prev = '';
const noteRef = (kind: 'footnote' | 'endnote'): N => {
const n = notes.filter(x => x.attrs.kind === kind).length + 1;
const text = kind === 'footnote' ? String(n) : ROMAN[n - 1];
const id = `${kind[0]}${n}`;
notes.push({ type: 'note', attrs: { id, kind, label: null, text },
content: [{ type: 'text', text: `Note body ${id}` }] });
return { type: 'noteRef', attrs: { id, kind, text } };
};
for (let i = int(r, 1, 6); i > 0; i--) {
const roll = r();
let block: N;
if (roll < 0.45) block = paragraph(r);
if (roll < 0.45) {
block = paragraph(r, true, true);
if (block.content && maybe(r, 0.15)) block.content.push(noteRef(pick(r, ['footnote', 'endnote'])));
}
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);
@@ -117,5 +154,6 @@ export function genDoc(r: Rng): N {
blocks.push(block);
prev = block.type;
}
if (notes.length) blocks.push({ type: 'noteSection', content: notes });
return { type: 'doc', content: blocks };
}
+21 -1
View File
@@ -9,14 +9,34 @@ const ORDERED_DEFAULTS: Record<string, unknown> = {
shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1,
};
// Mark-attr defaults dropped like ORDERED_DEFAULTS is for node attrs.
const MARK_DEFAULTS: Record<string, unknown> = {
plain: false, // link.ts default; the DOCX importer writes it out explicitly
};
// Note ids are importer-generated (ftn1/footnote1/…); remap them to their order of
// appearance so only the ref↔note pairing is compared, not the naming scheme.
function canonNoteIds(doc: N): void {
const map = new Map<string, string>();
(function walk(n: N) {
if ((n.type === 'noteRef' || n.type === 'note') && n.attrs?.id != null) {
if (!map.has(n.attrs.id)) map.set(n.attrs.id, `n${map.size + 1}`);
n.attrs = { ...n.attrs, id: map.get(n.attrs.id) };
}
for (const c of n.content ?? []) walk(c);
})(doc);
}
export function normalize(node: N): N {
if (node.type === 'doc') canonNoteIds(node);
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));
const attrs = Object.fromEntries(Object.entries(m.attrs ?? {})
.filter(([k, v]) => v != null && MARK_DEFAULTS[k] !== v));
if (Object.keys(attrs).length) mm.attrs = attrs;
return mm;
})
+1 -1
View File
@@ -17,7 +17,7 @@ describe('ODF page break export', () => {
it('mints the break style even with no automatic styles section', async () => {
const bytes = await buildOdt(doc as never, undefined, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
const name = /style:name="(PB\d+)"[^>]*>\s*<style:paragraph-properties fo:break-before="page"/.exec(content)?.[1];
const name = /style:name="(PBK\d+)"[^>]*>\s*<style:paragraph-properties fo:break-before="page"/.exec(content)?.[1];
expect(name).toBeTruthy();
expect(content).toContain(`<text:p text:style-name="${name}">two`);
});