diff --git a/tests/render-parity/README.md b/tests/render-parity/README.md index a91b536..8039cdd 100644 --- a/tests/render-parity/README.md +++ b/tests/render-parity/README.md @@ -9,32 +9,62 @@ actually looks like. npm run test:parity:fixtures # (re)generate the baseline .docx corpus npm run test:parity # whole corpus npm run test:parity -- path/to/file.docx --json report.json +npm run test:parity -- --quick # skip what last measured over 50 pages ``` Exit code 1 when any document differs. `--json` also dumps both sides' lines (`y`, `x`, `x2` in mm, per page) — that dump is what you diagnose from. +Every run prints each document's issue count against the previous run's and updates +`node_modules/.cache/render-parity/baseline.json`, so an A/B measurement reads +`218 (was 220, −2)` instead of being arithmetic over two reports. A run of one file +leaves the other files' recorded counts alone. `--no-baseline` neither reads nor +writes it — use it when measuring a deliberately broken tree. + +**The LibreOffice side is cached** in the same directory, keyed by the file's hash and +the export arguments: the reference never depends on our code, so only the first run of +a document pays for it — the whole corpus measured **9:00 cold against 5:41 cached**, +and one fixture 15s against 10s. `--no-cache` after installing or removing a font, +which does change what LibreOffice renders. + ## How it compares | | reference | editor | |---|---|---| | render | `soffice --convert-to pdf` | Playwright Chromium, real app, real file input | +| read | `pdftotext -bbox-layout` (word boxes, pt) | `Range.getClientRects()` (word boxes, px) | The PDF is exported with `IsSkipEmptyPages=false`: LibreOffice drops its own auto-inserted blank pages by default — a chapter forced onto a right page — and the reference then has fewer sheets than the document LibreOffice lays out (the 458-page guide 456 against 468, one fixture 28 against 32). -| read | `pdftotext -bbox-layout` (word boxes, pt) | `Range.getClientRects()` (word boxes, px) | -Both sides are normalized to mm from the top-left of each page, grouped into lines, -and compared as: page count → line count → line text → line position. Comparison is -whitespace-insensitive, because the spell-check decorations split text nodes -mid-word and the two engines needn't agree on word boundaries — only on what sits -on a line. +Both sides are normalized to mm from the top-left of each page and grouped into lines +(`compare.mjs`, the pure half of the harness — `tests/unit/parity-compare.test.ts` +covers it without a browser). Comparison is whitespace-insensitive, because the +spell-check decorations split text nodes mid-word and the two engines needn't agree on +word boundaries — only on what sits on a line; the invisible joiners a note anchor +leaves behind are ignored for the same reason. -Reported differences are `pageCount`, `lineCount`, `lineBreak` (same line, different -words) and `position` (same words, off by more than `POS_TOL_MM`). Comparison of a -page stops at the first line-level divergence, since everything after it is noise. +The two documents are then **aligned as a whole**, not compared line index against line +index: after a divergence the harness searches up to `SYNC_WINDOW` lines on both sides +for the nearest pair of skips that makes two consecutive lines match again. One dropped +line therefore costs one report, not every line after it, and a page-level slip shows up +as one `pageShift` naming the line it starts at instead of a wall of noise. + +Where no resync is in reach it steps both sides by one and keeps comparing. A contents +block whose numbers are all off by one has no two consecutive matching rows anywhere in +it, and declaring the documents parted there hid 465 of the 458-page guide's pages +behind a single report. Contiguous divergent lines are merged into one report carrying +how many lines each side spent, so the region still reads as one difference. + +Reported differences are `pageCount`, `pageShift` (from here on the editor runs ±n pages +off), `lineCount`, `lineBreak` (same place, different words) and `position` / `lineEnd` +(same words, off by more than `POS_TOL_MM`). Consecutive lines off by the *same* amount +are one report with a `×n` count, since that is one cause — a strut, a spacing, a band +height — and not n of them. Counts recorded before 2026-09-03 came from the index-wise +comparison, which stopped at the first divergence per page: they are not comparable to +what a run prints now. ## Prerequisites diff --git a/tests/render-parity/compare.mjs b/tests/render-parity/compare.mjs new file mode 100644 index 0000000..0dbe5af --- /dev/null +++ b/tests/render-parity/compare.mjs @@ -0,0 +1,139 @@ +// Pure comparison half of the harness: two rendered documents in, a list of +// differences out. Split from run.mjs so it can be tested without a browser. +const LINE_TOL_MM = 1.2; // words within this vertical span are one line +const POS_TOL_MM = 1.0; // reported as a position difference beyond this +const SYNC_WINDOW = 60; // lines searched for a resync after a divergence + +// Both sides are reduced to the same shape before diffing: words sorted into +// lines by vertical band, then left to right. +export function toLines(words) { + const sorted = [...words].sort((a, b) => a.y - b.y || a.x - b.x); + const lines = []; + for (const w of sorted) { + const last = lines[lines.length - 1]; + // Tops within a hair, or boxes that overlap by half the shorter one: a chapter + // number set far larger than its title shares the title's baseline but starts a + // long way above it, and banding by top alone reads that as two lines. + const over = last ? Math.min(w.y + (w.h ?? 0), last.y2) - Math.max(w.y, last.y) : 0; + if (last && (Math.abs(w.y - last.y) <= LINE_TOL_MM || over >= 0.5 * Math.min(w.h ?? 0, last.y2 - last.y))) { + last.words.push(w); + last.y = Math.min(last.y, w.y); + last.y2 = Math.max(last.y2, w.y + (w.h ?? 0)); + } else lines.push({ y: w.y, y2: w.y + (w.h ?? 0), words: [w] }); + } + return lines.map((l) => lineOf(l.y, l.words.sort((a, b) => a.x - b.x))); +} + +const round = (n) => Math.round(n * 10) / 10; + +const lineOf = (y, words) => ({ + y: round(y), x: round(words[0].x), x2: round(Math.max(...words.map((w) => w.x + w.w))), + text: words.map((w) => w.text).join(' '), words, +}); + +// The editor draws list markers with CSS ::marker, which is not a text node and cannot +// be measured; LibreOffice's PDF has them as words. Drop a leading reference word only +// when that makes the two lines identical, so real content can never be skipped. +function withoutMarker(line) { + return line.words.length > 1 ? lineOf(line.y, line.words.slice(1)) : null; +} + +// One stream per side, in reading order: the two are aligned against each other as a +// whole, so a line the editor drops costs one report instead of every line after it. +const stream = (doc) => doc.pages.flatMap((p, i) => + toLines(p.words).map((l, k) => ({ ...l, page: i + 1, line: k + 1, n: norm(l.text) }))); + +// The reference line as it has to be read to match `b` — with its list marker dropped +// where that is what makes the two identical — or null when they are different lines. +function match(a, b) { + if (a.n === b.n) return a; // normalized once per line, in `stream` + const t = withoutMarker(a); + return t && norm(t.text) === b.n ? { ...a, ...t, n: b.n } : null; +} + +// The nearest pair of skips that puts the two streams back on the same line. Two +// consecutive matches are required: a page number or a lone word matches by accident +// often enough to resync on the wrong line and report the whole document as broken. +function resync(r, e, i, j) { + for (let d = 1; d <= 2 * SYNC_WINDOW; d++) { + for (let a = Math.max(0, d - SYNC_WINDOW); a <= Math.min(d, SYNC_WINDOW); a++) { + const b = d - a; + if (i + a >= r.length || j + b >= e.length || !match(r[i + a], e[j + b])) continue; + const next = i + a + 1 >= r.length || j + b + 1 >= e.length || match(r[i + a + 1], e[j + b + 1]); + if (next) return [a, b]; + } + } + return null; +} + +// One cause, one report: neighbouring lines off by the same amount on the same page are +// one strut or spacing or band height, and a stretch of lines that never match — a +// contents block whose numbers are all off by one — is one region, not one report a row. +function collapse(issues) { + const out = []; + for (const i of issues) { + const last = out[out.length - 1]; + const geo = i.kind === 'position' || i.kind === 'lineEnd'; + if (last && last.kind === i.kind && geo + && last.page === i.page && last.dxMm === i.dxMm && last.dyMm === i.dyMm) last.lines++; + // Contiguous in the reference stream, not merely one report after the other: + // divergences with clean text between them are separate causes. + else if (last && last.kind === i.kind && !geo && last.at + last.refLines === i.at) { + last.refLines += i.refLines; last.edLines += i.edLines; + } else out.push({ ...i, ...(geo ? { lines: 1 } : {}) }); + } + for (const i of out) delete i.at; + return out; +} + +export function compare(ref, ed) { + const issues = []; + if (ref.pages.length !== ed.pages.length) { + issues.push({ kind: 'pageCount', ref: ref.pages.length, editor: ed.pages.length }); + } + const r = stream(ref), e = stream(ed); + let i = 0, j = 0, shift = 0; + while (i < r.length && j < e.length) { + const a = match(r[i], e[j]); + if (!a) { + // No resync in reach: report this line and step both sides, rather than declaring + // the documents parted — a contents block of numbers all off by one has no two + // consecutive matching rows, and giving up there hides everything behind it. + const [da, db] = resync(r, e, i, j) ?? [1, 1]; + issues.push({ + kind: da && db ? 'lineBreak' : 'lineCount', page: r[i].page, line: r[i].line, + ref: r[i].text, editor: e[j].text, refLines: da, edLines: db, at: i, + }); + i += da; j += db; + continue; + } + const b = e[j]; + if (b.page - a.page !== shift) { + shift = b.page - a.page; + issues.push({ kind: 'pageShift', page: a.page, edPage: b.page, by: shift, text: a.text.slice(0, 48) }); + } + const dx = round(b.x - a.x), dy = round(b.y - a.y); + const loc = { page: a.page, line: a.line, ...(shift ? { edPage: b.page } : {}) }; + if (Math.abs(dx) > POS_TOL_MM || Math.abs(dy) > POS_TOL_MM) { + issues.push({ kind: 'position', ...loc, text: a.text.slice(0, 48), dxMm: dx, dyMm: dy }); + } + // The line's end catches what its start cannot: tab stops, justification and + // any per-word drift that leaves the first word in place. + const dEnd = round(b.x2 - a.x2); + if (Math.abs(dEnd - dx) > POS_TOL_MM) { + issues.push({ kind: 'lineEnd', ...loc, text: a.text.slice(0, 48), dxMm: dEnd }); + } + i++; j++; + } + if (i < r.length || j < e.length) { + issues.push({ kind: 'lineCount', page: (r[i] ?? e[j]).page, line: (r[i] ?? e[j]).line, at: i, + ref: r[i]?.text ?? null, editor: e[j]?.text ?? null, refLines: r.length - i, edLines: e.length - j }); + } + return collapse(issues); +} + +// Whitespace-insensitive, and blind to the invisible joiners a note anchor leaves +// behind: the engines needn't agree on word boundaries, only on what sits on a line. A +// tab or index leader collapses to one token too — both end the fill at the same stop. +const norm = (s) => s.replace(/[\s\u00a0\u00ad\u200b\u2060\ufeff]+/g, '').replace(/([.\u00b7_-])\1{2,}/g, '\u2026'); + diff --git a/tests/render-parity/run.mjs b/tests/render-parity/run.mjs index 68c5446..1143dc5 100644 --- a/tests/render-parity/run.mjs +++ b/tests/render-parity/run.mjs @@ -3,11 +3,13 @@ // Usage and prerequisites — including the mandatory font setup — are in README.md. import { execFileSync } from 'node:child_process'; import { spawn } from 'node:child_process'; -import { readFileSync, writeFileSync, mkdtempSync, readdirSync, rmSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync, existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join, basename, extname, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright-core'; +import { compare, toLines } from './compare.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '../..'); @@ -16,25 +18,37 @@ const PORT = +(process.env.PARITY_PORT ?? 5199); // reuse a dev server already const PAGE_GAP = 20; // pageBreaks.ts const PT_MM = 25.4 / 72; const PX_MM = 25.4 / 96; -const LINE_TOL_MM = 1.2; // words within this vertical span are one line -const POS_TOL_MM = 1.0; // reported as a position difference beyond this +const QUICK_PAGES = 50; // --quick skips what last measured longer than this +const CACHE = process.env.PARITY_CACHE ?? join(ROOT, 'node_modules/.cache/render-parity'); +// Keep the blank pages LibreOffice inserts itself (a chapter forced onto a right page): +// its PDF export drops them by default and the reference then loses a sheet the +// document really has. +const LO_ARGS = ['--convert-to', + 'pdf:writer_pdf_Export:{"IsSkipEmptyPages":{"type":"boolean","value":"false"}}']; // ---------------------------------------------------------------- reference -function loRender(file, work) { - execFileSync('soffice', [ - '--headless', '--norestore', `-env:UserInstallation=file://${work}/loprofile`, - // Keep the blank pages LibreOffice inserts itself (a chapter forced onto a right - // page): its PDF export drops them by default and the reference then loses a sheet - // the document really has. - '--convert-to', 'pdf:writer_pdf_Export:{"IsSkipEmptyPages":{"type":"boolean","value":"false"}}', - '--outdir', work, file, - ], { stdio: 'pipe', timeout: 120_000 }); - const pdf = join(work, basename(file, extname(file)) + '.pdf'); - if (!existsSync(pdf)) throw new Error(`LibreOffice produced no PDF for ${file}`); - const xml = join(work, 'ref.xml'); - execFileSync('pdftotext', ['-bbox-layout', pdf, xml], { stdio: 'pipe' }); - return { pages: parseBbox(readFileSync(xml, 'utf8')), pdf }; +// The reference depends on the file and the export arguments, never on our code, so a +// re-run after an editor change reuses it: the corpus measured 9:00 cold, 5:41 cached. +// `--no-cache` after installing or removing a font, which does change what LO renders. +function loRender(file, work, cache) { + const key = createHash('sha1').update(LO_ARGS.join('\0')).update(readFileSync(file)).digest('hex').slice(0, 16); + const pdf = join(CACHE, key + '.pdf'); + const xml = join(CACHE, key + '.xml'); + const hit = cache && existsSync(xml); + if (!hit) { + execFileSync('soffice', [ + '--headless', '--norestore', `-env:UserInstallation=file://${work}/loprofile`, + ...LO_ARGS, '--outdir', work, file, + ], { stdio: 'pipe', timeout: 120_000 }); + const out = join(work, basename(file, extname(file)) + '.pdf'); + if (!existsSync(out)) throw new Error(`LibreOffice produced no PDF for ${file}`); + execFileSync('pdftotext', ['-bbox-layout', out, join(work, 'ref.xml')], { stdio: 'pipe' }); + mkdirSync(CACHE, { recursive: true }); + copyFileSync(out, pdf); + copyFileSync(join(work, 'ref.xml'), xml); + } + return { pages: parseBbox(readFileSync(xml, 'utf8')), pdf, cached: hit }; } // pdftotext -bbox-layout emits , coords in pt, @@ -192,87 +206,6 @@ function extractLayout() { }; } -// ---------------------------------------------------------------- compare - -// Both sides are reduced to the same shape before diffing: words sorted into -// lines by vertical band, then left to right. -function toLines(words) { - const sorted = [...words].sort((a, b) => a.y - b.y || a.x - b.x); - const lines = []; - for (const w of sorted) { - const last = lines[lines.length - 1]; - // Tops within a hair, or boxes that overlap by half the shorter one: a chapter - // number set far larger than its title shares the title's baseline but starts a - // long way above it, and banding by top alone reads that as two lines. - const over = last ? Math.min(w.y + (w.h ?? 0), last.y2) - Math.max(w.y, last.y) : 0; - if (last && (Math.abs(w.y - last.y) <= LINE_TOL_MM || over >= 0.5 * Math.min(w.h ?? 0, last.y2 - last.y))) { - last.words.push(w); - last.y = Math.min(last.y, w.y); - last.y2 = Math.max(last.y2, w.y + (w.h ?? 0)); - } else lines.push({ y: w.y, y2: w.y + (w.h ?? 0), words: [w] }); - } - return lines.map((l) => lineOf(l.y, l.words.sort((a, b) => a.x - b.x))); -} - -const round = (n) => Math.round(n * 10) / 10; - -const lineOf = (y, words) => ({ - y: round(y), x: round(words[0].x), x2: round(Math.max(...words.map((w) => w.x + w.w))), - text: words.map((w) => w.text).join(' '), words, -}); - -// The editor draws list markers with CSS ::marker, which is not a text node and cannot -// be measured; LibreOffice's PDF has them as words. Drop a leading reference word only -// when that makes the two lines identical, so real content can never be skipped. -function withoutMarker(line) { - return line.words.length > 1 ? lineOf(line.y, line.words.slice(1)) : null; -} - -function compare(ref, ed) { - const issues = []; - if (ref.pages.length !== ed.pages.length) { - issues.push({ kind: 'pageCount', ref: ref.pages.length, editor: ed.pages.length }); - } - const n = Math.min(ref.pages.length, ed.pages.length); - for (let p = 0; p < n; p++) { - const r = toLines(ref.pages[p].words); - const e = toLines(ed.pages[p].words); - const rows = Math.max(r.length, e.length); - for (let i = 0; i < rows; i++) { - let a = r[i]; - const b = e[i]; - if (!a || !b) { - issues.push({ kind: 'lineCount', page: p + 1, line: i + 1, ref: a?.text ?? null, editor: b?.text ?? null }); - break; // once the lines slip, every following row is noise - } - if (norm(a.text) !== norm(b.text)) { - const trimmed = withoutMarker(a); - if (!trimmed || norm(trimmed.text) !== norm(b.text)) { - issues.push({ kind: 'lineBreak', page: p + 1, line: i + 1, ref: a.text, editor: b.text }); - break; - } - a = trimmed; - } - const dx = round(b.x - a.x), dy = round(b.y - a.y); - if (Math.abs(dx) > POS_TOL_MM || Math.abs(dy) > POS_TOL_MM) { - issues.push({ kind: 'position', page: p + 1, line: i + 1, text: a.text.slice(0, 48), dxMm: dx, dyMm: dy }); - } - // The line's end catches what its start cannot: tab stops, justification and - // any per-word drift that leaves the first word in place. - const dEnd = round(b.x2 - a.x2); - if (Math.abs(dEnd - dx) > POS_TOL_MM) { - issues.push({ kind: 'lineEnd', page: p + 1, line: i + 1, text: a.text.slice(0, 48), dxMm: dEnd }); - } - } - } - return issues; -} - -// Whitespace-insensitive: spell decorations split text nodes mid-word and the engines -// needn't agree on word boundaries, only on what sits on a line. A tab or index leader -// collapses to one token too — both engines end the fill at the same stop. -const norm = (s) => s.replace(/[\s\u00a0\u00ad]+/g, '').replace(/([.\u00b7_-])\1{2,}/g, '\u2026'); - // ------------------------------------------------------------------- driver async function devServer() { @@ -303,10 +236,24 @@ function corpus(args) { return out.sort(); } +// The last run's issue count per file, so a re-run prints what a change moved instead +// of leaving the arithmetic to whoever reads two reports side by side. +const BASELINE = join(CACHE, 'baseline.json'); +const readBaseline = () => { + try { return JSON.parse(readFileSync(BASELINE, 'utf8')); } catch { return {}; } +}; + const args = process.argv.slice(2); const keep = args.includes('--keep'); +const cache = !args.includes('--no-cache'); const jsonAt = args.includes('--json') ? args[args.indexOf('--json') + 1] : null; -const files = corpus(args.filter((a) => !a.startsWith('--') && a !== jsonAt)); +const baseline = args.includes('--no-baseline') ? null : readBaseline(); +let files = corpus(args.filter((a) => !a.startsWith('--') && a !== jsonAt)); +if (args.includes('--quick')) { + const long = files.filter((f) => (baseline?.[basename(f)]?.refPages ?? 0) > QUICK_PAGES); + files = files.filter((f) => !long.includes(f)); + if (long.length) console.log(`--quick: skipping ${long.length} document(s) over ${QUICK_PAGES} pages`); +} if (!files.length) { console.error('no .docx/.odt files found'); process.exit(2); } const server = await devServer(); @@ -317,7 +264,7 @@ const report = []; for (const file of files) { const name = basename(file); try { - const ref = loRender(file, work); + const ref = loRender(file, work, cache); const ed = await editorRender(browser, file); const issues = compare(ref, ed); report.push({ @@ -328,7 +275,9 @@ for (const file of files) { margins: ed.margins, } : {}), }); - console.log(`\n${issues.length ? '✗' : '✓'} ${name} (LO ${ref.pages.length}p / editor ${ed.pages.length}p)`); + const was = baseline?.[name]?.issues; + console.log(`\n${issues.length ? '✗' : '✓'} ${name} (LO ${ref.pages.length}p / editor ${ed.pages.length}p)` + + ` ${issues.length} ${delta(issues.length, was)}${ref.cached ? ' [cached]' : ''}`); for (const i of issues.slice(0, 12)) console.log(' ', fmt(i)); if (issues.length > 12) console.log(` … ${issues.length - 12} more`); } catch (err) { @@ -337,11 +286,36 @@ for (const file of files) { } } +// Only what this run measured: a single-fixture run says what that fixture moved and +// leaves the other files' recorded counts alone. +const done = report.filter((r) => !r.error); +const total = done.reduce((n, r) => n + r.issues.length, 0); +const seen = done.filter((r) => baseline?.[r.file]); +const before = seen.reduce((n, r) => n + baseline[r.file].issues, 0); +console.log(`\ntotal ${total} across ${done.length} document(s)` + + (seen.length === done.length ? ` ${delta(total, before)}` + : seen.length ? ` (${done.length - seen.length} without a baseline)` : '')); + +if (baseline) { + for (const r of done) baseline[r.file] = { issues: r.issues.length, refPages: r.refPages }; + mkdirSync(CACHE, { recursive: true }); + writeFileSync(BASELINE, JSON.stringify(baseline, null, 2)); +} + +function delta(now, was) { + if (was == null) return '(new)'; + return now === was ? '(unchanged)' : `(was ${was}, ${now > was ? '+' : '\u2212'}${Math.abs(now - was)})`; +} + function fmt(i) { + const at = `p${i.page}${i.edPage ? `\u2192ed p${i.edPage}` : ''} l${i.line}`; + const n = i.lines > 1 ? ` \u00d7${i.lines}` : ''; if (i.kind === 'pageCount') return `pages: LO ${i.ref}, editor ${i.editor}`; - if (i.kind === 'position') return `p${i.page} l${i.line} off by dx ${i.dxMm}mm dy ${i.dyMm}mm — "${i.text}"`; - if (i.kind === 'lineEnd') return `p${i.page} l${i.line} ends ${i.dxMm}mm off — "${i.text}"`; - return `p${i.page} l${i.line} ${i.kind}\n LO: ${i.ref}\n ed: ${i.editor}`; + if (i.kind === 'pageShift') return `p${i.page}: the editor runs ${i.by > 0 ? '+' : ''}${i.by} page(s) from here — "${i.text}"`; + if (i.kind === 'position') return `${at}${n} off by dx ${i.dxMm}mm dy ${i.dyMm}mm — "${i.text}"`; + if (i.kind === 'lineEnd') return `${at}${n} ends ${i.dxMm}mm off — "${i.text}"`; + return `p${i.page} l${i.line} ${i.kind} (LO ${i.refLines} / ed ${i.edLines} line(s))` + + `\n LO: ${i.ref}\n ed: ${i.editor}`; } await browser.close(); diff --git a/tests/unit/parity-compare.test.ts b/tests/unit/parity-compare.test.ts new file mode 100644 index 0000000..0fc06b3 --- /dev/null +++ b/tests/unit/parity-compare.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +// @ts-expect-error — the parity harness is plain .mjs, deliberately untyped +import { compare } from '../render-parity/compare.mjs'; + +// The comparison aligns the two documents as a whole, so one dropped or displaced +// line costs one report instead of every line after it. +const doc = (pages: [string, number][][]) => ({ + pages: pages.map((lines) => ({ + words: lines.map(([text, y]) => ({ text, x: 20, y, w: 50, h: 4 })), + width: 210, height: 297, + })), +}); + +describe('render-parity comparison', () => { + it('reports a line the editor drops once, then resyncs', () => { + const ref = doc([[['A', 10], ['B', 20], ['C', 30], ['D', 40]]]); + const ed = doc([[['A', 10], ['C', 30], ['D', 40]]]); + const issues = compare(ref, ed); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ kind: 'lineCount', refLines: 1, edLines: 0, ref: 'B' }); + }); + + it('reports a page the content slipped onto once, not once per line', () => { + const ref = doc([[['A', 10], ['B', 30]], [['C', 10], ['D', 30]]]); + const ed = doc([[['A', 10], ['B', 30]], [], [['C', 10], ['D', 30]]]); + const issues = compare(ref, ed); + const shifts = issues.filter((i: { kind: string }) => i.kind === 'pageShift'); + expect(shifts).toHaveLength(1); + expect(shifts[0]).toMatchObject({ by: 1, page: 2, edPage: 3, text: 'C' }); + expect(issues.some((i: { kind: string }) => i.kind.startsWith('line'))).toBe(false); + }); + + it('keeps going where no resync is in reach, and folds the region into one report', () => { + const ref = doc([[['A', 10], ['X1', 20], ['X2', 30]]]); + const ed = doc([[['A', 10], ['Y1', 20], ['Y2', 30]]]); + const issues = compare(ref, ed); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ kind: 'lineBreak', refLines: 2, edLines: 2, ref: 'X1' }); + }); + + // Two matching lines, not one: a resync is only trusted where two consecutive lines + // match again, so a single agreeing line between two divergences is part of the region. + it('leaves two divergences with matching text between them apart', () => { + const ref = doc([[['A', 10], ['X', 20], ['B', 30], ['C', 40], ['Y', 50], ['D', 60]]]); + const ed = doc([[['A', 10], ['P', 20], ['B', 30], ['C', 40], ['Q', 50], ['D', 60]]]); + expect(compare(ref, ed)).toHaveLength(2); + }); + + it('folds a run of lines off by the same amount into one report', () => { + const ref = doc([[['A', 10], ['B', 20], ['C', 30]]]); + const ed = doc([[['A', 12], ['B', 22], ['C', 32]]]); + const issues = compare(ref, ed); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ kind: 'position', dyMm: 2, lines: 3 }); + }); +});