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:
1 parent
1f1176283a
commit
a7fe334900
4 files changed
+80
-21
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user