import { test } from "node:test"; import assert from "node:assert/strict"; import type { Report } from "./schema"; import { REPORT_PAGE_ANCHOR_IDS } from "./slideRules"; import { buildReportSlides, firstParagraph, firstSentences, parseReaderPlace, readerPlaceUrl, reportPageAnchorIds, slideAnchorId, slideForAnchor, slideIndexForPlace, slideLint, slidePointText, type ClaimSlide, type SectionSlide, type SlideView, } from "./slides"; import { validateReport } from "./validate"; import { buildReportPageView, type ReportPageView } from "./views"; // One fact-check, every slide kind: a subject, a summary, two sections (one // with a body, one without), claims with and without citations and verdicts. function base(): Report { return { format: "archilyzer-report", version: 1, id: "demo", kind: "factcheck", series: "Demo Checks", title: "A demo fact-check", subtitle: "What the article says, against the record", summary: "The article gets one thing [right](cite:v1). It gets another wrong.\n\nA second paragraph that is never on a slide.", published: "2026-10-01", updated: "2026-10-04", subject: { source: "s0" }, sources: { s0: { kind: "article", title: "An article", author: "A. Writer", publisher: "Gazette", date: "2026-09-20", accent: "#8a6fb0" }, }, citations: { v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "one" }, w1: { kind: "page", url: "https://example.org/p", quote: "two" }, p1: { kind: "post", channel: "demo-social", id: "123", quote: "three" }, a1: { kind: "source", source: "s0", quote: "the claim, as printed" }, }, sections: [ { id: "ch1", title: "Chapter one", body: "The first sentence, citing [Mr. Example. Esq.](cite:w1). The second sentence! A third that is left off.", claims: [ { id: "k1", title: "“Opened it himself”", text: "He opened it himself.", verdict: "CONTRADICTED", gist: "It opened a year later.", sourceQuote: { citation: "a1" }, findings: "See [the stream](cite:v1).", citations: ["a1", "v1", "w1"], }, { id: "k2", text: "He went back every year.", verdict: "PARTLY", flag: "No source given" }, ], }, { id: "ch2", title: "Chapter two", claims: [{ id: "k3", text: "He denied it.", verdict: "CORROBORATED", citations: ["p1"] }] }, ], }; } function viewOf(report: Report): ReportPageView { assert.deepEqual(validateReport(report), [], "the test report must be sound"); return buildReportPageView(report, { record: (c) => ({ channel: c.channel, id: c.id }) }); } const keys = (slides: readonly SlideView[]) => slides.map((s) => s.key); const byKey = (slides: readonly SlideView[], key: string) => slides.find((s) => s.key === key) as Extract; test("the deck, derived: title, in brief, found, each section and claim, sources — numbered from 1", () => { const slides = buildReportSlides(viewOf(base())); assert.deepEqual(keys(slides), [ "title", "summary", "found", "section:ch1", "claim:k1", "claim:k2", "section:ch2", "claim:k3", "sources", ]); assert.deepEqual(slides.map((s) => s.n), [1, 2, 3, 4, 5, 6, 7, 8, 9]); }); // Every default, one row each: the slide, what it carries. const DEFAULTS: [string, (s: SlideView[]) => unknown, unknown][] = [ ["title: series, title, the subtitle as its line, dates, the subject", (s) => { const t = byKey<"title">(s, "title"); return [t.layout, t.series, t.title, t.line, t.dates, t.subject]; }, ["title", "Demo Checks", "A demo fact-check", "What the article says, against the record", ["2026-10-01", "updated 2026-10-04"], { title: "An article", meta: ["A. Writer", "Gazette", "2026-09-20"], accent: "#8a6fb0" }]], ["summary: the summary's first paragraph, as a statement, with the tally", (s) => { const t = byKey<"summary">(s, "summary"); return [t.layout, t.title, t.text, t.points, t.tally?.map((x) => `${x.verdict}:${x.count}`)]; }, ["statement", "In brief", "The article gets one thing [right](cite:v1). It gets another wrong.", undefined, ["CORROBORATED:1", "PARTLY:1", "CONTRADICTED:1"]]], ["found: the claims by verdict, contradicted first", (s) => { const t = byKey<"found">(s, "found"); return [t.layout, t.claimCount, t.groups.map((g) => `${g.verdict}:${g.claims.map((c) => c.title).join("|")}`)]; }, ["found", 3, ["CONTRADICTED:“Opened it himself”", "PARTLY:He went back every year.", "CORROBORATED:He denied it."]]], ["section: the body's first two sentences, a cite's label never split", (s) => { const t = byKey<"section">(s, "section:ch1"); return [t.layout, t.title, t.index, t.count, t.text, t.points, t.cite, t.claims.map((c) => c.id)]; }, ["points", "Chapter one", 1, 2, "The first sentence, citing [Mr. Example. Esq.](cite:w1). The second sentence!", undefined, undefined, ["k1", "k2"]]], ["section with no body: its claims, no text", (s) => { const t = byKey<"section">(s, "section:ch2"); return [t.layout, t.text, t.claims]; }, ["points", undefined, [{ id: "k3", title: "He denied it.", verdict: "CORROBORATED" }]]], ["claim: its title, its text under it, the first listed citation that is not its sentence", (s) => { const t = byKey<"claim">(s, "claim:k1"); return [t.layout, t.title, t.text, t.verdict, t.gist, t.cite, t.sourceQuote, t.section]; }, ["evidence", "“Opened it himself”", "He opened it himself.", "CONTRADICTED", "It opened a year later.", "v1", "a1", { id: "ch1", title: "Chapter one" }]], ["claim with no citation: a statement, its text the title", (s) => { const t = byKey<"claim">(s, "claim:k2"); return [t.layout, t.title, t.text, t.flag, t.cite]; }, ["statement", "He went back every year.", undefined, "No source given", undefined]], ["sources: the references and documents counted, no closing", (s) => { const t = byKey<"sources">(s, "sources"); return [t.layout, t.title, t.references, t.documents, t.closing]; }, ["sources", "Sources", 4, 1, undefined]], ]; for (const [name, pick, want] of DEFAULTS) { test(`default — ${name}`, () => { assert.deepEqual(pick(buildReportSlides(viewOf(base()))), want); }); } // Every override, one row each: the report as changed, what the slide carries. const OVERRIDES: [string, (r: Report) => void, (s: SlideView[]) => unknown, unknown][] = [ ["slides.title replaces the subtitle as the title slide's line", (r) => { r.slides = { title: "One article, checked" }; }, (s) => byKey<"title">(s, "title").line, "One article, checked"], ["slides.points make In brief points", (r) => { r.slides = { points: ["One [right](cite:v1)", "One wrong"] }; }, (s) => { const t = byKey<"summary">(s, "summary"); return [t.layout, t.points, t.text]; }, ["points", ["One [right](cite:v1)", "One wrong"], undefined]], ["slides.closing on the sources slide", (r) => { r.slides = { closing: "Read it all on the site." }; }, (s) => byKey<"sources">(s, "sources").closing, "Read it all on the site."], ["section slide: title, points (no derived text), cite and layout", (r) => { r.sections[0].slide = { title: "The bridge", points: ["It opened in 2019"], cite: "v1", layout: "evidence" }; }, (s) => { const t = byKey<"section">(s, "section:ch1"); return [t.title, t.points, t.text, t.cite, t.layout]; }, ["The bridge", ["It opened in 2019"], undefined, "v1", "evidence"]], ["claim slide: cite picks the evidence, layout quote", (r) => { r.sections[0].claims![0].slide = { cite: "w1", layout: "quote" }; }, (s) => { const t = byKey<"claim">(s, "claim:k1"); return [t.cite, t.layout]; }, ["w1", "quote"]], ["claim slide: points and a title; the text stays when the title is not it", (r) => { r.sections[0].claims![1].slide = { title: "Every year?", points: ["Once is on record"] }; }, (s) => { const t = byKey<"claim">(s, "claim:k2"); return [t.title, t.text, t.points, t.layout]; }, ["Every year?", "He went back every year.", ["Once is on record"], "statement"]], ["claim slide: layout points over the derived evidence", (r) => { r.sections[0].claims![0].slide = { layout: "points", points: ["It opened in 2019"] }; }, (s) => byKey<"claim">(s, "claim:k1").layout, "points"], ]; for (const [name, change, pick, want] of OVERRIDES) { test(`override — ${name}`, () => { const r = base(); change(r); assert.deepEqual(pick(buildReportSlides(viewOf(r))), want); }); } test("hide: a section's slide goes, its claims keep theirs; a claim's goes; slides.hide drops the deck", () => { const r = base(); r.sections[0].slide = { hide: true }; r.sections[1].claims![0].slide = { hide: true }; assert.deepEqual(keys(buildReportSlides(viewOf(r))), ["title", "summary", "found", "claim:k1", "claim:k2", "section:ch2", "sources"]); r.slides = { hide: true }; assert.deepEqual(buildReportSlides(viewOf(r)), []); }); test("a sweep: no tally, no found slide; with no summary, no In brief", () => { const r = base(); r.kind = "sweep"; for (const s of r.sections) for (const c of s.claims ?? []) delete c.verdict; let slides = buildReportSlides(viewOf(r)); assert.ok(!keys(slides).includes("found")); assert.equal(byKey<"summary">(slides, "summary").tally, undefined); delete r.summary; slides = buildReportSlides(viewOf(r)); assert.deepEqual(keys(slides).slice(0, 2), ["title", "section:ch1"]); }); test("an evidence layout with nothing to show falls back: a claim to statement, a section to points", () => { const view = viewOf(base()); // A view is read leniently in umtool: a layout the validator would refuse still builds. view.sections[0].slide = { layout: "quote" }; view.sections[0].claims[1].slide = { layout: "evidence" }; const slides = buildReportSlides(view); assert.equal(byKey<"section">(slides, "section:ch1").layout, "points"); assert.equal(byKey<"claim">(slides, "claim:k2").layout, "statement"); }); test("anchors: unique, each an id the article's page carries, in the page's order", () => { const view = viewOf(base()); const slides = buildReportSlides(view); const ids = slides.map((s) => s.anchorId); assert.equal(new Set(ids).size, ids.length); assert.equal(new Set(keys(slides)).size, slides.length); const page = reportPageAnchorIds(view); for (const s of slides) { assert.ok(page.includes(s.anchorId), `${s.key} → #${s.anchorId}`); assert.equal(slideAnchorId(s.anchor), s.anchorId); } const at = ids.map((id) => page.indexOf(id)); assert.deepEqual(at, [...at].sort((a, b) => a - b)); assert.deepEqual( [ids[0], ids[1], ids[2], ids[ids.length - 1]], [REPORT_PAGE_ANCHOR_IDS.head, REPORT_PAGE_ANCHOR_IDS.summary, REPORT_PAGE_ANCHOR_IDS.found, REPORT_PAGE_ANCHOR_IDS.end], ); }); test("slideForAnchor: the slide anchored there, else the last before it, else the first", () => { const r = base(); r.sections[0].claims![1].slide = { hide: true }; const view = viewOf(r); const slides = buildReportSlides(view); const order = reportPageAnchorIds(view); assert.equal(slideForAnchor(slides, "k1", order)?.key, "claim:k1"); // k2's slide is hidden: the slide before it in the article. assert.equal(slideForAnchor(slides, "k2", order)?.key, "claim:k1"); // `claims` (the tier heading) has no slide: the found slide is before it. assert.equal(slideForAnchor(slides, REPORT_PAGE_ANCHOR_IDS.claims, order)?.key, "found"); assert.equal(slideForAnchor(slides, null, order)?.key, "title"); assert.equal(slideForAnchor(slides, "nowhere", order)?.key, "title"); assert.equal(slideForAnchor([], "k1", order), undefined); }); test("text helpers: a point's visible text, the first paragraph, the first sentences", () => { assert.equal(slidePointText("He said so [on stream](cite:c01), twice."), "He said so on stream, twice."); assert.equal(firstParagraph("## A heading\n\n- one\n- two\n\nlater"), "A heading"); assert.equal(firstParagraph("> quoted\n> on two lines\n\nnext"), "quoted on two lines"); assert.equal(firstParagraph("\n\n"), undefined); assert.equal(firstSentences("One. Two? Three!", 2), "One. Two?"); assert.equal(firstSentences("Only one sentence", 2), "Only one sentence"); assert.equal(firstSentences("He said “no.” Then left. Later.", 2), "He said “no.” Then left."); }); test("lint: long derived text, too many or too long points, a long claim as a title", () => { const view = viewOf(base()); assert.deepEqual(slideLint(view), []); const long = "word ".repeat(60).trim(); view.sections[1].body = `${long}. ${long}.`; view.sections[0].slide = { points: ["a", "b", "c", "d", "e", "f", "x".repeat(150)] }; view.sections[0].claims[1].text = "y".repeat(200); const lint = slideLint(view).map((l) => `${l.key}: ${l.message.split(";")[0].split(":")[0]}`); assert.deepEqual(lint, [ "section:ch1: 7 points", "section:ch1: point 7 is 150 characters", "claim:k2: its slide's title is 200 characters (over 160)", "section:ch2: no slide points", ]); }); test("the reader's place in the URL: the view, the slide, the anchor; the default stays off", () => { assert.deepEqual(parseReaderPlace("", ""), { view: "article", slide: null, anchor: null }); assert.deepEqual(parseReaderPlace("?rv=slides", "#s-4"), { view: "slides", slide: 4, anchor: null }); assert.deepEqual(parseReaderPlace("?rv=slides", "#k1"), { view: "slides", slide: null, anchor: "k1" }); assert.deepEqual(parseReaderPlace("?rv=iso", "#ch2"), { view: "iso", slide: null, anchor: "ch2" }); assert.deepEqual(parseReaderPlace("?rv=other", "#claim-1"), { view: "article", slide: null, anchor: "claim-1" }); // An article's anchor that looks like a slide is still an anchor there. assert.deepEqual(parseReaderPlace("", "#s-4"), { view: "article", slide: null, anchor: "s-4" }); assert.equal(readerPlaceUrl("/reports/demo/", "", { view: "slides", slide: 3 }), "/reports/demo/?rv=slides#s-3"); assert.equal(readerPlaceUrl("/reports/demo/", "?rv=slides&x=1", { view: "article", anchor: "k1" }), "/reports/demo/?x=1#k1"); assert.equal(readerPlaceUrl("/r/", "?rv=slides", { view: "iso", anchor: "ch1" }), "/r/?rv=iso#ch1"); assert.equal(readerPlaceUrl("/r/", "", { view: "article" }), "/r/"); const round = parseReaderPlace(...(["?rv=slides", "#s-7"] as const)); assert.equal(readerPlaceUrl("/r/", "", round), "/r/?rv=slides#s-7"); }); test("slideIndexForPlace: the slide number clamped, else the anchor's slide", () => { const view = viewOf(base()); const slides = buildReportSlides(view); const order = reportPageAnchorIds(view); const at = (search: string, hash: string) => slideIndexForPlace(slides, parseReaderPlace(search, hash), order); assert.equal(at("?rv=slides", "#s-4"), 3); assert.equal(at("?rv=slides", "#s-99"), slides.length - 1); assert.equal(at("?rv=slides", "#s-0"), 0); assert.equal(at("?rv=slides", "#k2"), 5); assert.equal(at("?rv=slides", ""), 0); assert.equal(slideIndexForPlace([], parseReaderPlace("?rv=slides", "#s-2"), order), 0); }); test("a section slide's claims list keeps a hidden claim (it is still in the section)", () => { const r = base(); r.sections[0].claims![1].slide = { hide: true }; const s = byKey<"section">(buildReportSlides(viewOf(r)), "section:ch1") as SectionSlide; assert.deepEqual(s.claims.map((c) => c.id), ["k1", "k2"]); const c = byKey<"claim">(buildReportSlides(viewOf(r)), "claim:k1") as ClaimSlide; assert.equal(c.n, 5); });