test: the parity harness aligns two documents instead of indexing lines

Comparison stopped at the first divergence on a page, so one displaced line
reported every line behind it and a slipped page hid what followed: a
468-page reference reported 4 differences while hiding 465 pages. The line
streams are aligned instead — nearest resync within 60 lines, contiguous
divergences merged, neighbouring lines off by the same amount collapsed to
a count — and it reports 5766. Reference PDFs are cached by file hash
(corpus 9:00 cold against 5:41 cached), each run prints its counts against
the last, and --quick skips what measured over 50 pages.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:11 +02:00
1 parent 7203d716af
commit f26de200fe
4 files changed
+312 -113

No files matched your search

+39 -9
View File
@@ -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
+139
View File
@@ -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');
+78 -104
View File
@@ -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 <page><flow><block><line><word>, 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();
+56
View File
@@ -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 });
});
});