diff --git a/tests/fuzz-roundtrip.test.ts b/tests/fuzz-roundtrip.test.ts index 164db4b..72e16f6 100644 --- a/tests/fuzz-roundtrip.test.ts +++ b/tests/fuzz-roundtrip.test.ts @@ -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); }); diff --git a/tests/fuzzDoc.ts b/tests/fuzzDoc.ts index 415d031..1c2b520 100644 --- a/tests/fuzzDoc.ts +++ b/tests/fuzzDoc.ts @@ -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 }; } diff --git a/tests/normalize.ts b/tests/normalize.ts index e6ef7d2..b487d5d 100644 --- a/tests/normalize.ts +++ b/tests/normalize.ts @@ -9,14 +9,34 @@ const ORDERED_DEFAULTS: Record = { shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, }; +// Mark-attr defaults dropped like ORDERED_DEFAULTS is for node attrs. +const MARK_DEFAULTS: Record = { + 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(); + (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; }) diff --git a/tests/unit/odt-page-break-export.test.ts b/tests/unit/odt-page-break-export.test.ts index 060613c..6034824 100644 --- a/tests/unit/odt-page-break-export.test.ts +++ b/tests/unit/odt-page-break-export.test.ts @@ -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*]*>\s*two`); });