import { test } from "node:test"; import assert from "node:assert/strict"; import { parseReport, validateReport } from "./validate"; import { reportCitationNumbers, reportCitationUses } from "./uses"; import { buildCitedIn } from "./citedIn"; import { VERDICTS, VERDICT_DEFAULTS, isVerdict, resolveVerdicts } from "./verdicts"; import type { Report } from "./schema"; import type { Problem } from "../citations/validate"; const paths = (ps: Problem[]) => ps.map((p) => p.path); function fixture(): Record & Report { return { format: "archilyzer-report", version: 1, id: "demo-report", kind: "factcheck", title: "A demo fact-check", subtitle: "What the article says, against the record", summary: "The article gets one thing [right](cite:c02) and one [wrong](cite:c01).", published: "2026-10-01", updated: "2026-10-04T09:30:00Z", subject: { source: "s0" }, verdicts: { PARTLY: { label: "Half true" } }, sources: { s0: { kind: "article", title: "A demo article", url: "https://example.test/article", archives: [{ label: "archived", url: "https://archive.example.test/a" }], saved: "sources/s0/page.html", }, }, citations: { c01: { kind: "video", channel: "demo-channel", id: "-abc123", start: 10, end: 20, quote: "q1", pad: { before: 5, after: 5 } }, c02: { kind: "video", channel: "demo-channel", id: "def456", start: 30, end: 42.5, quote: "q2" }, p01: { kind: "post", channel: "demo-channel", id: "1234567890", quote: "q3" }, a01: { kind: "source", source: "s0", quote: "the claim", image: "stills/a01.png" }, a02: { kind: "source", source: "s0", quote: "another claim" }, w01: { kind: "page", url: "https://example.test/page", quote: "q4" }, }, sections: [ { id: "ch1", title: "Chapter one", body: "Background, see [this](cite:w01).", claims: [ { id: "k1", text: "The first claim.", verdict: "CONTRADICTED", sourceQuote: { citation: "a01" }, findings: "The record says otherwise: [at 0:10](cite:c01), and [the post](cite:p01).", citations: ["c01", "p01"], }, { id: "k2", text: "The second claim.", verdict: "PARTLY", sourceQuote: { citation: "a02" }, findings: "Partly: [here](cite:c02) and [here again](cite:c01).", citations: ["c02"], }, ], }, { id: "ch2", title: "Chapter two", claims: [{ id: "k3", text: "Not yet ruled." }] }, ], }; } test("a sound fact-check parses with no problems", () => { const r = parseReport(fixture(), { id: "demo-report" }); assert.ok(r.ok); assert.deepEqual(r.problems, []); }); test("schema rejects: wrong format/version, unknown keys, an unknown verdict, a missing title", () => { const f = fixture() as Record; const bad = { ...f, format: "other", version: 2, extra: true, title: undefined, verdicts: { MAYBE: {} } }; const r = parseReport(bad); assert.equal(r.ok, false); const ps = paths(r.problems); for (const want of ["format", "version", "title", "verdicts.MAYBE"]) assert.ok(ps.includes(want), `${want} in ${ps}`); assert.ok(ps.includes(""), `the unknown key at the root in ${ps}`); const claim = structuredClone(fixture()); (claim.sections[0].claims![0] as Record).verdict = "MOSTLY"; assert.deepEqual(paths(validateReport(claim)), ["sections[0].claims[0].verdict"]); }); test("every reference must resolve: listed citations, source sentences, cite links, the subject", () => { const f = fixture(); f.subject = { source: "s9" }; const k1 = f.sections[0].claims![0]; k1.citations = ["c01", "c99", "c01"]; k1.sourceQuote = { citation: "c01" }; // not a source citation k1.findings = "See [gone](cite:c98) and [blank](cite:)."; f.summary = "Also [missing](cite:zz) and [“told [the mayor] so”](cite:zy)."; const ps = validateReport(f); assert.deepEqual(paths(ps).sort(), [ "sections[0].claims[0].citations[1]", "sections[0].claims[0].citations[2]", "sections[0].claims[0].findings", "sections[0].claims[0].findings", "sections[0].claims[0].sourceQuote.citation", "subject.source", "summary", "summary", ]); assert.ok(ps.some((p) => /\[gone\]\(cite:c98\) names no citation/.test(p.message))); assert.ok(ps.some((p) => /cite:zy\) names no citation/.test(p.message))); assert.ok(ps.some((p) => /lists "c01" twice/.test(p.message))); assert.ok(ps.some((p) => /names a video citation/.test(p.message))); }); test("citation problems surface under the report's paths", () => { const f = fixture(); f.citations!.c01 = { ...(f.citations!.c01 as object), end: 200 } as never; f.citations!.a01 = { ...(f.citations!.a01 as object), image: "../../etc/passwd" } as never; assert.deepEqual(paths(validateReport(f)).sort(), ["citations.a01.image", "citations.c01"]); }); test("ids: report id is a slug and matches its directory; section and claim ids unique together", () => { const f = fixture(); f.sections[1].id = "k1"; f.sections[1].claims![0].id = "bad id"; const ps = validateReport(f, { id: "other-report" }); assert.deepEqual(paths(ps).sort(), ["id", "sections[1].claims[0].id", "sections[1].id"]); assert.ok(ps.some((p) => /already the id of sections\[0\]\.claims\[0\]/.test(p.message))); assert.deepEqual(paths(validateReport({ ...fixture(), id: "Demo_Report" })), ["id"]); }); test("dates, verdict overrides, a sweep with a verdict", () => { const f = fixture(); f.published = "2026-10-05"; f.updated = "2026-10-04"; f.verdicts = { PARTLY: { label: "x".repeat(25), color: "orange" } }; const ps = validateReport(f); assert.deepEqual(paths(ps).sort(), ["updated", "verdicts.PARTLY.color", "verdicts.PARTLY.label"]); assert.deepEqual(paths(validateReport({ ...fixture(), published: "1 Oct 2026" })), ["published"]); assert.deepEqual(paths(validateReport({ ...fixture(), kind: "sweep" })), [ "sections[0].claims[0].verdict", "sections[0].claims[1].verdict", ]); }); test("a sweep with inline citations in its bodies and no claims is a report", () => { const sweep = { format: "archilyzer-report", version: 1, id: "demo-sweep", kind: "sweep", title: "A demo sweep", citations: { c01: { kind: "video", channel: "demo-channel", id: "abc123", start: 1, end: 2, quote: "q" } }, sections: [{ id: "s1", title: "Found", body: "It came up [once](cite:c01)." }], }; assert.deepEqual(validateReport(sweep), []); }); test("uses: reading order — summary, body, then each claim's source sentence, findings, list", () => { const r = parseReport(fixture()); assert.ok(r.ok); const uses = reportCitationUses(r.value).map((u) => `${u.field}:${u.citationId}`); assert.deepEqual(uses, [ "summary:c02", "summary:c01", "body:w01", "sourceQuote:a01", "findings:c01", "findings:p01", "citations:c01", "citations:p01", "sourceQuote:a02", "findings:c02", "findings:c01", "citations:c02", ]); assert.deepEqual([...reportCitationNumbers(r.value)], [ ["c02", 1], ["c01", 2], ["w01", 3], ["a01", 4], ["p01", 5], ["a02", 6], ]); }); test("numbers skip dangling references", () => { const f = fixture(); f.summary = "[x](cite:nope) then [y](cite:c01)"; const r = parseReport(f); assert.ok(r.ok); assert.equal(reportCitationNumbers(r.value).get("c01"), 1); assert.equal(reportCitationNumbers(r.value).has("nope"), false); }); test("cited-in: moment key → each place that cites it, across reports, deduplicated", () => { const a = parseReport(fixture()); const second = fixture(); second.id = "second-report"; second.summary = undefined; second.sections = [{ id: "only", title: "Only", body: "Again [here](cite:c01)." }]; const b = parseReport(second); assert.ok(a.ok && b.ok); const index = buildCitedIn([a.value, b.value]); assert.deepEqual(Object.keys(index), [ "demo-channel/def456/30.00-42.50", "demo-channel/-abc123/10.00-20.00", "demo-channel/1234567890", ]); assert.deepEqual(index["demo-channel/-abc123/10.00-20.00"], [ { reportId: "demo-report", sectionId: null, claimId: null, citationId: "c01" }, { reportId: "demo-report", sectionId: "ch1", claimId: "k1", citationId: "c01" }, { reportId: "demo-report", sectionId: "ch1", claimId: "k2", citationId: "c01" }, { reportId: "second-report", sectionId: "only", claimId: null, citationId: "c01" }, ]); assert.deepEqual(index["demo-channel/1234567890"], [ { reportId: "demo-report", sectionId: "ch1", claimId: "k1", citationId: "p01" }, ]); }); test("cited-in: two citations of one span in one place are two back-links; source and page citations have none", () => { const f = fixture(); f.citations!.c03 = { ...(f.citations!.c01 as object), quote: "the same span, quoted again" } as never; f.sections[0].claims![0].citations = ["c01", "c03"]; const r = parseReport(f); assert.ok(r.ok); const entries = buildCitedIn([r.value])["demo-channel/-abc123/10.00-20.00"]; assert.deepEqual( entries.filter((e) => e.claimId === "k1").map((e) => e.citationId), ["c01", "c03"], ); const keys = Object.keys(buildCitedIn([r.value])); assert.ok(!keys.some((k) => k.includes("example.test"))); }); test("the verdict vocabulary: five verdicts, a default for each, overrides laid over one key at a time", () => { assert.deepEqual([...VERDICTS], ["CORROBORATED", "PARTLY", "CONTRADICTED", "NOT_FOUND", "UNTESTABLE"]); assert.deepEqual(Object.keys(VERDICT_DEFAULTS), [...VERDICTS]); assert.ok(isVerdict("PARTLY") && !isVerdict("partly")); const v = resolveVerdicts({ PARTLY: { label: "Half true" }, MAYBE: { label: "x" } }); assert.deepEqual(v.PARTLY, { label: "Half true", color: VERDICT_DEFAULTS.PARTLY.color }); assert.deepEqual(Object.keys(v), [...VERDICTS]); }); test("a claim's flag: one line of at most 60 characters", () => { const withFlag = (flag: unknown) => { const f = structuredClone(fixture()); (f.sections[0].claims![0] as Record).flag = flag; return f; }; const ok = parseReport(withFlag("x".repeat(60))); assert.equal(ok.ok, true); assert.deepEqual(ok.problems, []); assert.deepEqual(validateReport(withFlag("No source given")), []); const long = parseReport(withFlag("x".repeat(61))); assert.equal(long.ok, false); assert.deepEqual(paths(long.problems), ["sections[0].claims[0].flag"]); assert.deepEqual(paths(validateReport(withFlag(" "))), ["sections[0].claims[0].flag"]); assert.deepEqual(paths(validateReport(withFlag("two\nlines"))), ["sections[0].claims[0].flag"]); }); test("a report's video: an mp4 and an image poster, relative to the report, and a one-line caption", () => { const withVideo = (video: unknown) => ({ ...structuredClone(fixture()), video }); assert.deepEqual(validateReport(withVideo({ src: "video.mp4" })), []); assert.deepEqual(validateReport(withVideo({ src: "media/cut.mp4", poster: "media/cut.jpg", caption: "The cut" })), []); assert.deepEqual(paths(validateReport(withVideo({ src: "video.webm" }))), ["video.src"]); assert.deepEqual(paths(validateReport(withVideo({ src: "../video.mp4" }))), ["video.src"]); assert.deepEqual(paths(validateReport(withVideo({ src: "https://x.test/v.mp4" }))), ["video.src"]); assert.deepEqual(paths(validateReport(withVideo({ src: "video.mp4", poster: "poster.gif" }))), ["video.poster"]); assert.deepEqual(paths(validateReport(withVideo({ src: "video.mp4", caption: "two\nlines" }))), ["video.caption"]); assert.equal(parseReport(withVideo({ src: "video.mp4", autoplay: true })).ok, false); }); test("slides: lines, points, a cite of its own section or claim, a point citing what the article cites", () => { const f = fixture(); f.slides = { title: "One line", closing: "Read it on the site.", points: ["One [right](cite:c02)", "One wrong"] }; f.sections[0].slide = { title: "The chapter", points: ["It cites [the page](cite:w01)"], cite: "c01", layout: "evidence" }; f.sections[0].claims![0].slide = { cite: "p01", layout: "quote", points: ["Said [here](cite:c01)"] }; f.sections[0].claims![1].slide = { hide: true }; assert.deepEqual(validateReport(f), []); const g = fixture(); g.slides = { title: "two\nlines", closing: " ", points: [] }; g.sections[0].slide = { title: "x".repeat(161), points: ["a", "b", "c", "d", "e", "f"], cite: "zzz", }; g.sections[0].claims![0].slide = { points: ["y".repeat(141), "[never cited](cite:unknown-or-uncited)", "[](cite:)"], // cited by the other claim, not this one cite: "c02", }; // A claim with no listed evidence, asked to show some. g.sections[1].claims![0].slide = { layout: "evidence" }; assert.deepEqual(paths(validateReport(g)), [ "slides.title", "slides.closing", "slides.points", "sections[0].claims[0].slide.points[0]", "sections[0].claims[0].slide.points[1]", "sections[0].claims[0].slide.points[2]", "sections[0].claims[0].slide.cite", "sections[0].slide.title", "sections[0].slide.points", "sections[0].slide.cite", "sections[1].claims[0].slide.layout", ]); // A point is measured as it reads: a citation counts as its label. const h = fixture(); h.sections[0].claims![0].slide = { points: [`${"z".repeat(130)} [label](cite:c01)`] }; assert.deepEqual(validateReport(h), []); }); test("a section or claim may not take the page's own anchors", () => { const f = fixture(); f.sections[1].id = "found"; f.sections[0].claims![1].id = "report-end"; assert.deepEqual(paths(validateReport(f)).sort(), ["sections[0].claims[1].id", "sections[1].id"]); }); test("slide fields are shape-checked: an unknown layout or key is refused", () => { const f = fixture(); (f.sections[0] as Record).slide = { layout: "carousel", extra: 1 }; assert.equal(parseReport(f).ok, false); });