// The revision diff and change summary (lib/report/revisions.ts), over two // versions of a small fact-check. // // Run with: node_modules/.bin/tsx --test lib/report/revisions.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import type { Report } from "./schema"; import { claimChanges, reportChangeSummary, revisionCommitMessage, revisionSummaryOf, wordDiff, type DiffSegment, } from "./revisions"; const side = (d: DiffSegment[], op: "old" | "new") => d.filter((s) => s.op === "eq" || s.op === (op === "old" ? "del" : "ins")).map((s) => s.text).join(""); function v1(): Report { return { format: "archilyzer-report", version: 1, id: "demo", kind: "factcheck", title: "Checking a demo article", subtitle: "Three claims", published: "2026-03-01", citations: { c01: { kind: "video", channel: "demo-channel", id: "abc", start: 10, end: 20, quote: "The bridge opened in spring." }, c02: { kind: "video", channel: "demo-channel", id: "abc", start: 30, end: 40, quote: "I was there." }, c03: { kind: "page", url: "https://example.org/a", quote: "A page." }, }, sections: [ { id: "s1", title: "The bridge", claims: [ { id: "k1", title: "When it opened", text: "The bridge opened in 2018.", verdict: "PARTLY", findings: "He says [spring](cite:c01).", citations: ["c01"] }, { id: "k2", text: "He was not there.", verdict: "CONTRADICTED", findings: "He [was](cite:c02).", citations: ["c02"] }, { id: "k3", text: "A third claim.", verdict: "UNTESTABLE", citations: ["c03"] }, ], }, ], }; } function v2(): Report { const r = v1(); r.title = "Checking the demo article"; r.series = "Demo checks"; delete r.subtitle; const claims = r.sections[0].claims!; claims[0] = { ...claims[0], text: "The bridge opened in 2019.", verdict: "CORROBORATED" }; claims[1] = { ...claims[1], findings: "He says he [was there](cite:c02)." }; claims.splice(2, 1); claims.push({ id: "k4", title: "The mail", text: "The mail came late.", verdict: "NOT_FOUND", citations: ["c04"] }); r.citations!.c02 = { ...r.citations!.c02, quote: "I was there for it." }; delete r.citations!.c03; r.citations!.c04 = { kind: "page", url: "https://example.org/b", quote: "Another page." }; return r; } test("wordDiff: equal text is one run; a replaced word reads old then new; each side reassembles", () => { assert.deepEqual(wordDiff("same words", "same words"), [{ op: "eq", text: "same words" }]); const d = wordDiff("The bridge opened in 2018.", "The bridge opened in 2019."); assert.deepEqual(d, [ { op: "eq", text: "The bridge opened in " }, { op: "del", text: "2018." }, { op: "ins", text: "2019." }, ]); const a = "one two three four five six"; const b = "one 2 three four six seven"; const d2 = wordDiff(a, b); assert.equal(side(d2, "old"), a); assert.equal(side(d2, "new"), b); assert.deepEqual(wordDiff("", "new text"), [{ op: "ins", text: "new text" }]); assert.deepEqual(wordDiff("old text", ""), [{ op: "del", text: "old text" }]); }); test("claimChanges: edited fields with word diffs, added and removed claims", () => { const changes = claimChanges(v1(), v2()); assert.deepEqual( changes.map((c) => [c.id, c.status, c.fields.map((f) => f.field)]), [ ["k1", "edited", ["text", "verdict"]], ["k2", "edited", ["findings"]], ["k4", "added", ["title", "text", "verdict"]], ["k3", "removed", ["text", "verdict"]], ], ); const verdict = changes[0].fields.find((f) => f.field === "verdict")!; assert.deepEqual(verdict.diff, [ { op: "del", text: "PARTLY" }, { op: "ins", text: "CORROBORATED" }, ]); assert.equal(changes[0].section, "The bridge"); assert.ok(changes[2].fields.every((f) => f.diff.every((s) => s.op === "ins"))); assert.ok(changes[3].fields.every((f) => f.diff.every((s) => s.op === "del"))); assert.deepEqual(claimChanges(v1(), v1()), []); }); test("reportChangeSummary: every kind of change, in a fixed order", () => { assert.deepEqual(reportChangeSummary(v1(), v2()), [ "Title changed: “Checking a demo article” → “Checking the demo article”", "Series added: “Demo checks”", "Subtitle removed (was “Three claims”)", "Claim added: k4 “The mail”", "Claim removed: k3 “A third claim.”", "Verdict changed: k1 PARTLY → CORROBORATED", "Claims edited: k1 (text), k2 (findings)", "Citation added: c04", "Citation removed: c03", "Quote edited: c02", ]); }); test("reportChangeSummary: the first revision counts; an edit outside the list says so", () => { assert.deepEqual(reportChangeSummary(null, v1()), ["First revision: 1 section, 3 claims, 3 citations."]); const moved = v1(); (moved.citations!.c01 as { start: number }).start = 11; assert.deepEqual(reportChangeSummary(v1(), moved), ["Other edits: no title, claim, verdict, citation or quote changed"]); const summary = v1(); summary.summary = "A new summary."; assert.deepEqual(reportChangeSummary(v1(), summary), ["Summary edited"]); }); test("reportChangeSummary: long lists are cut, long labels shortened", () => { const many = v1(); for (let i = 0; i < 15; i++) { many.sections[0].claims!.push({ id: `n${i}`, text: `${"x".repeat(100)} ${i}` }); } const [line] = reportChangeSummary(v1(), many); assert.match(line, /^15 claims added: n0 “x{79}…”, /); assert.match(line, / and 3 more$/); }); test("the commit message carries the summary, and gives it back", () => { const lines = reportChangeSummary(v1(), v2()); const msg = revisionCommitMessage(2, lines); assert.match(msg, /^Revision 2\n\n- Title changed/); assert.deepEqual(revisionSummaryOf(msg), lines); });