import { test } from "node:test"; import assert from "node:assert/strict"; import { DEFAULT_SPAN_SECONDS, IdAllocator, parseCitationHref, spanAt, structureMarkdown, type Cue, type CuesOf, } from "./convertShared"; import { sweepToReport } from "./convertSweep"; import { askToReport, parseClock, readAskAnswer } from "./convertAsk"; import { manifestToReport, reportToManifest, STARTER_RENDER } from "./convertManifest"; import { validateReport } from "./validate"; import type { Report } from "./schema"; // ─── Fixtures: one channel of two records, one post channel ─── const CUES: Record = { "demo-channel/abc123": [ { start: 58.2, end: 60.1, text: "This is where it starts." }, { start: 60.1, end: 62.0, text: "and then the host says" }, { start: 62.4, end: 64.9, text: "we never said that" }, { start: 64.9, end: 67.3, text: "not once, not ever." }, { start: 67.3, end: 70.0, text: "Next topic." }, ], }; const cuesOf: CuesOf = async (channel, id) => CUES[`${channel}/${id}`] ?? null; const ORIGIN = "https://archive.example"; const moment = (channel: string, id: string, t: number, encode = true) => `${ORIGIN}/?v=${encode ? encodeURIComponent(`${channel}/${id}`) : `${channel}/${id}`}&t=${t}`; // ─── Links ─── test("parseCitationHref reads viewer moments, archive posts, moment pages and platform posts", () => { assert.deepEqual(parseCitationHref(moment("demo-channel", "abc123", 62)), { kind: "span", channel: "demo-channel", id: "abc123", seconds: 62, }); assert.deepEqual(parseCitationHref(moment("demo-channel", "-def456", 5, false)), { kind: "span", channel: "demo-channel", id: "-def456", seconds: 5, }); assert.deepEqual(parseCitationHref(`${ORIGIN}/?v=demo-social%2F1234567890&vm=post`), { kind: "post", channel: "demo-social", id: "1234567890", }); assert.deepEqual(parseCitationHref(`${ORIGIN}/m/demo-channel/abc123/12.50-20.00/`), { kind: "span", channel: "demo-channel", id: "abc123", seconds: 12.5, end: 20, }); assert.deepEqual(parseCitationHref("https://x.com/demo/status/987654321"), { kind: "post-original", platform: "x", id: "987654321", handle: "demo", }); assert.deepEqual(parseCitationHref("https://bsky.app/profile/demo.example/post/3abcxyz"), { kind: "post-original", platform: "bluesky", id: "3abcxyz", handle: "demo.example", }); assert.equal(parseCitationHref("https://www.youtube.com/watch?v=abc123&t=5s"), null); assert.equal(parseCitationHref("https://example.test/article"), null); assert.equal(parseCitationHref("cite:c01"), null); }); // ─── Spans ─── test("spanAt: no cues is the second plus the default span", async () => { assert.deepEqual(await spanAt("demo-channel", "nocues", 30, "anything", {}), { start: 30, end: 30 + DEFAULT_SPAN_SECONDS, from: "default", }); assert.deepEqual(await spanAt("demo-channel", "nocues", 30, "x", { spanSeconds: 4 }), { start: 30, end: 34, from: "default" }); }); test("spanAt: the cited cue, run on to the quote's length, widened to whole sentences", async () => { // t=62 cites the cue starting at 62.4; the quote is two cues long; the // sentence opens after "This is where it starts." and closes at "not ever.". const span = await spanAt("demo-channel", "abc123", 62, "we never said that, not once, not ever", { cuesOf }); assert.deepEqual(span, { start: 60.1, end: 67.3, from: "cues" }); }); test("spanAt: an end the link gives is kept", async () => { assert.deepEqual(await spanAt("demo-channel", "abc123", 12.5, "q", { cuesOf }, 20), { start: 12.5, end: 20, from: "link" }); }); test("IdAllocator keeps a sound free id, repairs and suffixes the rest", () => { const ids = new IdAllocator(); assert.equal(ids.claim("c01"), "c01"); assert.equal(ids.claim("c01"), "c01-2"); assert.equal(ids.claim("-x y"), "x-y"); assert.equal(ids.next("c"), "c02"); assert.equal(ids.next("c"), "c03"); }); // ─── The markdown engine ─── test("structureMarkdown: title, summary, sections, claims and bodies; code is not citing", async () => { const md = [ "# The title", "", "A summary [citing](x:1).", "", "## Section one", "", "- A claim [cite](x:2)", "- Not a claim.", "", "```", "[not](x:3)", "```", "", "### A sub-heading stays in the body", "", "## Section one", "", "Plain paragraph with `[code](x:4)`.", ].join("\n"); const seen: string[] = []; const doc = await structureMarkdown( md, async ({ href }) => { seen.push(href); return `c${href.slice(2)}`; }, { defaultSection: "Default" }, ); assert.deepEqual(seen, ["x:1", "x:2"]); assert.equal(doc.title, "The title"); assert.equal(doc.summary, "A summary [citing](cite:c1)."); assert.deepEqual( doc.sections.map((s) => s.id), ["section-one", "section-one-2"], ); assert.deepEqual(doc.sections[0].claims, [{ id: "k01", text: "A claim", citations: ["c2"] }]); assert.equal(doc.sections[0].body, "- Not a claim.\n\n```\n[not](x:3)\n```\n\n### A sub-heading stays in the body"); assert.equal(doc.sections[1].body, "Plain paragraph with `[code](x:4)`."); }); test("structureMarkdown: no section headings is one section of the default title", async () => { const doc = await structureMarkdown("One [a](x:1).\n\nTwo.", async () => "c1", { defaultSection: "Answer" }); assert.equal(doc.title, null); assert.equal(doc.summary, ""); assert.equal(doc.sections.length, 1); assert.equal(doc.sections[0].title, "Answer"); assert.equal(doc.sections[0].claims?.[0].text, "One."); assert.equal(doc.sections[0].body, "Two."); }); // ─── /sweep ─── const SWEEP_MD = [ "# Demo sweep", "", `What the sweep looked for; [the method](https://example.test/method).`, "", "## First topic", "", `- "We never said that." — [Demo episode @ 1:02](${moment("demo-channel", "abc123", 62)})`, "- Background with no citation.", "", `> "Another line here."`, "", `— [Demo episode @ 2:00](${moment("demo-channel", "abc123", 120, false)})`, "", "## Posts", "", `- The account posted it: "the post words" [post by demo, 2024-05-01](${ORIGIN}/?v=demo-social%2F1234567890&vm=post)`, `- On X: "a platform post" [post by demo](https://x.com/demo/status/987654321)`, `- "first" [A @ 0:05](${moment("demo-channel", "abc123", 5)}) and later "second" [B @ 0:30](${moment("demo-channel", "-def456", 30)}).`, ].join("\n"); test("sweepToReport: sections, claims, citations, quotes and a sound report", async () => { const { report, warnings, problems } = await sweepToReport(SWEEP_MD, { postChannelOf: async ({ id }) => (id === "987654321" ? "demo-social" : null), }); assert.deepEqual(problems, []); assert.deepEqual(warnings, []); assert.equal(report.id, "demo-sweep"); assert.equal(report.kind, "sweep"); assert.equal(report.title, "Demo sweep"); assert.equal(report.summary, "What the sweep looked for; [the method](https://example.test/method)."); assert.deepEqual( report.sections.map((s) => [s.id, s.title]), [ ["first-topic", "First topic"], ["posts", "Posts"], ], ); const [first, posts] = report.sections; assert.equal(first.body, "- Background with no citation."); assert.deepEqual(first.claims, [ { id: "k01", text: '"We never said that."', citations: ["c01"] }, { id: "k02", text: '"Another line here."', citations: ["c02"] }, ]); assert.deepEqual(report.citations?.c01, { kind: "video", channel: "demo-channel", id: "abc123", start: 62, end: 62 + DEFAULT_SPAN_SECONDS, quote: "We never said that.", label: "Demo episode", }); assert.equal(report.citations?.c02.quote, "Another line here."); assert.deepEqual(report.citations?.p01, { kind: "post", channel: "demo-social", id: "1234567890", quote: "the post words", label: "post by demo, 2024-05-01", date: "2024-05-01", }); assert.deepEqual(report.citations?.p02, { kind: "post", channel: "demo-social", id: "987654321", quote: "a platform post", label: "post by demo", }); // Two citations with words between them keep their places in findings. const both = posts.claims?.[2]; assert.deepEqual(both?.citations, ["c03", "c04"]); assert.equal(both?.findings, '"first" [A @ 0:05](cite:c03) and later "second" [B @ 0:30](cite:c04).'); assert.equal(report.citations?.c03.quote, "first"); assert.equal(report.citations?.c04.quote, "second"); assert.deepEqual(validateReport(report), []); }); test("sweepToReport: cues widen the spans; a platform post with no channel stays a plain link", async () => { const { report, warnings, problems } = await sweepToReport(SWEEP_MD, { cuesOf }); assert.deepEqual(problems, []); const c01 = report.citations?.c01; assert.ok(c01 && c01.kind === "video"); assert.equal(c01.start, 60.1); assert.equal(c01.end, 67.3); assert.ok(warnings.some((w) => w.startsWith("https://x.com/demo/status/987654321: no archive channel"))); assert.ok(warnings.some((w) => w.startsWith("demo-channel/-def456: no cues"))); assert.match(JSON.stringify(report.sections), /\[post by demo\]\(https:\/\/x\.com\/demo\/status\/987654321\)/); }); // ─── /ask ─── const ASK = { question: "What did the host say?", answer: "The host said it [1 @ 1:01], then posted about it [2].\n\nA stray marker [9].\n\n`[1]` in code.", sources: [ { key: "demo-channel/abc123", title: "Demo episode", snippets: [ { clock: "1:02", seconds: 62, text: "we never said that" }, { clock: "5:00", seconds: 300, text: "something else" }, ], }, { key: "demo-social/1234567890", title: "a post", isPost: true, uploadDate: "20240501", snippets: [{ clock: "", seconds: 0, text: "the post words" }] }, ], }; test("parseClock reads mm:ss and h:mm:ss", () => { assert.equal(parseClock("1:02"), 62); assert.equal(parseClock("1:00:05"), 3605); assert.equal(parseClock("x:10"), null); }); test("readAskAnswer takes an answer, a saved chat and an assistant message", () => { assert.deepEqual(readAskAnswer(ASK), ASK); const chat = { messages: [ { role: "user", content: "the question" }, { role: "assistant", content: "the answer [1]", sources: ASK.sources }, ], }; assert.deepEqual(readAskAnswer(chat), { answer: "the answer [1]", sources: ASK.sources, question: "the question" }); assert.deepEqual(readAskAnswer({ ...chat, report: "the report [1]", reportSources: ASK.sources }), { answer: "the report [1]", sources: ASK.sources, question: undefined, }); assert.equal(readAskAnswer({ role: "assistant", content: "x" })?.answer, "x"); assert.equal(readAskAnswer({ nothing: true }), null); assert.equal(readAskAnswer([]), null); }); test("askToReport: markers become citations snapped to excerpt lines; a stray marker is a warning", async () => { const { report, warnings, problems } = await askToReport(ASK, { cuesOf }); assert.deepEqual(problems, []); assert.equal(report.title, "What did the host say?"); assert.equal(report.id, "what-did-the-host-say"); assert.equal(report.subtitle, undefined); assert.equal(report.sections.length, 1); assert.equal(report.sections[0].title, "Answer"); const claim = report.sections[0].claims?.[0]; assert.deepEqual(claim?.citations, ["c01", "p01"]); assert.equal( claim?.findings, "The host said it [1 @ 1:01](cite:c01), then posted about it [2](cite:p01).", ); // 1:01 snaps to the excerpt line at 62; the cues widen it to the sentence. assert.deepEqual(report.citations?.c01, { kind: "video", channel: "demo-channel", id: "abc123", start: 60.1, end: 67.3, quote: "we never said that", label: "Demo episode", }); assert.deepEqual(report.citations?.p01, { kind: "post", channel: "demo-social", id: "1234567890", quote: "the post words", date: "2024-05-01", }); assert.equal(report.sections[0].body, "A stray marker [9].\n\n`[1]` in code."); assert.deepEqual(warnings, ["[9] names no source (the answer has 2)"]); }); // ─── report-to-video manifests ─── function demoManifest() { return { schemaVersion: 1, slug: "demo-factcheck", title: "A demo fact-check", subtitle: "What was said, against the record", provenance: { siteOrigin: ORIGIN, channelSlug: "demo-channel" }, render: { width: 1920 }, timeline: [ { type: "card", id: "t0", style: "title", heading: "A demo fact-check" }, { type: "card", id: "ch1", style: "chapter", heading: "Chapter one", sub: "The first part" }, { type: "image", id: "a01", src: "stills/a01.png", title: "A demo article", quote: "The article's claim.", date: "2026-01-02", citeUrl: "https://example.test/article", claim: { id: "k1", verdict: "CONTRADICTED" }, }, { type: "clip", id: "c01", video: "abc123", start: 60.1, end: 67.3, cite: 60, quote: "we never said that", claim: { id: "k1", verdict: "CONTRADICTED" } }, { type: "clip", id: "c02", channel: "demo-other", video: "-def456", start: 10, end: 20, cite: 10, quote: "a second record", claim: { id: "k1", verdict: "CONTRADICTED" } }, { type: "card", id: "ch2", style: "chapter", heading: "Chapter two" }, { type: "clip", id: "c03", video: "abc123", start: 100, end: 110, cite: 100, quote: "context, no claim" }, { type: "clip", id: "c04", video: "abc123", start: 200, end: 210, cite: 200, quote: "pinned by the ledger" }, { type: "clip", id: "x01", src: "media/episode.mp3", start: 1, end: 5 }, ], posts: [ { id: "pst1", platform: "x", date: "2026-01-03", text: "the post words", url: "https://x.com/i/status/1234567890", attachTo: "c01", siteChannel: "demo-social", }, ], ledger: [ { id: "L1", date: "2026-01-04", label: "a ledger label", quote: "the ledger's words", channel: "demo-channel", video: "abc123", cite: 200, entryId: "c04" }, { id: "L2", date: "2026-01-05", quote: "never clipped", channel: "demo-channel", video: "abc123", cite: 62 }, ], }; } test("manifestToReport: chapters, claims with verdicts, the source sentence, posts, the ledger", async () => { const { report, warnings, problems } = await manifestToReport(demoManifest(), { cuesOf }); assert.deepEqual(problems, []); assert.equal(report.id, "demo-factcheck"); assert.equal(report.kind, "factcheck"); assert.equal(report.subtitle, "What was said, against the record"); assert.deepEqual( report.sections.map((s) => [s.id, s.title, s.body]), [ ["ch1", "Chapter one", "The first part"], ["ch2", "Chapter two", "- “context, no claim” [abc123](cite:c03)"], ["ledger", "Not clipped", undefined], ], ); assert.deepEqual(report.sections[0].claims, [ { id: "k1", text: "The article's claim.", verdict: "CONTRADICTED", sourceQuote: { citation: "a01" }, citations: ["c01", "pst1", "c02"], }, ]); assert.deepEqual(report.sections[1].claims, [ { id: "L1", title: "a ledger label", text: "the ledger's words", citations: ["c04"] }, ]); assert.deepEqual(report.sections[2].claims, [{ id: "L2", text: "never clipped", citations: ["l-L2"] }]); assert.deepEqual(report.citations?.["l-L2"], { kind: "video", channel: "demo-channel", id: "abc123", start: 60.1, end: 67.3, quote: "never clipped", }); assert.deepEqual(report.citations?.c02, { kind: "video", channel: "demo-other", id: "-def456", start: 10, end: 20, quote: "a second record", }); assert.deepEqual(report.citations?.a01, { kind: "source", source: "src-a01", quote: "The article's claim.", image: "stills/a01.png", }); assert.deepEqual(report.sources?.["src-a01"], { kind: "other", title: "A demo article", url: "https://example.test/article", date: "2026-01-02", }); assert.deepEqual(report.citations?.pst1, { kind: "post", channel: "demo-social", id: "1234567890", quote: "the post words", date: "2026-01-03", }); assert.deepEqual(warnings, ["timeline[8] x01: a clip of its own media (src) cites no record — left out"]); }); test("reportToManifest: a starter cut — title card, a chapter per section, the claim's still and clips with its verdict, its posts", async () => { const { report } = await manifestToReport(demoManifest()); const result = await reportToManifest(report, { siteOrigin: ORIGIN, today: "2026-10-05" }); assert.ok(result.ok); const m = result.manifest; assert.equal(m.slug, "demo-factcheck"); assert.equal(m.generatedOn, "2026-10-05"); assert.deepEqual(m.render, STARTER_RENDER); assert.deepEqual(m.provenance, { siteOrigin: ORIGIN, channelSlug: "demo-channel", channel: "" }); const timeline = m.timeline as Record[]; assert.deepEqual( timeline.map((e) => `${e.type}:${e.id}`), ["card:title", "card:ch1", "image:a01", "clip:c01", "clip:c02", "card:ch2", "clip:c03", "clip:c04", "card:ledger", "clip:l-L2"], ); assert.deepEqual(timeline[3], { type: "clip", id: "c01", channel: "demo-channel", video: "abc123", start: 60.1, end: 67.3, cite: 60, quote: "we never said that", claim: { id: "k1", verdict: "CONTRADICTED" }, }); // A claim without a verdict is not stamped. assert.equal(timeline[7].claim, undefined); assert.deepEqual(m.posts, [ { id: "pst1", platform: "x", date: "2026-01-03", text: "the post words", url: "https://x.com/i/status/1234567890", attachTo: "c01", siteChannel: "demo-social", postId: "1234567890", }, ]); }); test("round trip: manifest → report → manifest keeps every clip, still, chapter, claim and post", async () => { const before = demoManifest(); const { report, problems } = await manifestToReport(before); assert.deepEqual(problems, []); const result = await reportToManifest(report); assert.ok(result.ok); const after = result.manifest; const project = (timeline: Record[]) => timeline .filter((e) => (e.type === "clip" && !e.src) || e.type === "image" || (e.type === "card" && e.style === "chapter")) .map((e) => e.type === "card" ? { type: "card", heading: e.heading } : e.type === "image" ? { type: "image", id: e.id, src: e.src, quote: e.quote, title: e.title, claim: e.claim } : { type: "clip", id: e.id, channel: e.channel ?? "demo-channel", video: e.video, start: e.start, end: e.end, quote: e.quote, claim: e.claim, }, ); assert.deepEqual(project(after.timeline as Record[]), [ ...project(before.timeline), // The ledger's claim that no clip pinned gains a clip of its own moment. { type: "card", heading: "Not clipped" }, { type: "clip", id: "l-L2", channel: "demo-channel", video: "abc123", start: 62, end: 72, quote: "never clipped", claim: undefined }, ]); const posts = (after.posts as Record[]).map(({ id, platform, date, text, url, attachTo, siteChannel }) => ({ id, platform, date, text, url, attachTo, siteChannel, })); assert.deepEqual(posts, before.posts); }); function demoReport(): Report { return { format: "archilyzer-report", version: 1, id: "demo-report", kind: "factcheck", title: "A demo report", sources: { s0: { kind: "article", title: "A demo article", url: "https://example.test/a", date: "2026-01-02" } }, citations: { a01: { kind: "source", source: "s0", quote: "The first claim.", image: "stills/a01.png" }, c01: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "q1", pad: { before: 5, after: 5 } }, c02: { kind: "audio", channel: "demo-podcast", id: "ep-042", start: 30.5, end: 42, quote: "q2", date: "2025-03-04" }, p01: { kind: "post", channel: "demo-social", id: "1234567890", quote: "the post words", date: "2026-01-03T10:00:00Z" }, w01: { kind: "page", url: "https://example.test/page", quote: "q4" }, }, sections: [ { id: "ch1", title: "Chapter one", body: "Context: [here](cite:c02).", claims: [ { id: "k1", text: "The first claim.", verdict: "PARTLY", sourceQuote: { citation: "a01" }, findings: "See [this](cite:c01) and [the page](cite:w01).", citations: ["c01", "p01"], }, ], }, ], }; } test("round trip: report → manifest → report keeps sections, claims, verdicts, spans and quotes", async () => { const before = demoReport(); const result = await reportToManifest(before); assert.ok(result.ok); assert.deepEqual(result.warnings, ["w01: a page citation has no manifest entry — left out"]); const { report: after, problems } = await manifestToReport(result.manifest); assert.deepEqual(problems, []); assert.equal(after.kind, "factcheck"); assert.deepEqual(after.sections[0].claims, [ { id: "k1", text: "The first claim.", verdict: "PARTLY", sourceQuote: { citation: "a01" }, citations: ["c01", "p01"] }, ]); assert.equal(after.sections[0].body, "- “q2” [ep-042](cite:c02)"); const c = after.citations ?? {}; // A pad is the site's, not the cut's: it does not make the trip. assert.deepEqual(c.c01, { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "q1" }); assert.deepEqual(c.c02, { kind: "audio", channel: "demo-podcast", id: "ep-042", start: 30.5, end: 42, quote: "q2", date: "2025-03-04" }); assert.deepEqual(c.p01, before.citations?.p01); assert.equal(c.a01.kind === "source" && c.a01.image, "stills/a01.png"); assert.equal(c.a01.quote, "The first claim."); }); test("reportToManifest refuses a report that does not validate", async () => { const bad = { ...demoReport(), citations: {} }; const result = await reportToManifest(bad); assert.equal(result.ok, false); assert.ok(!result.ok && result.problems.some((p) => p.path === "sections[0].claims[0].citations[0]")); }); test("reportToManifest: a post with no known platform or link is left out with a warning", async () => { const r = demoReport(); r.citations!.p01 = { kind: "post", channel: "demo-social", id: "3abcxyz", quote: "words", date: "2026-01-03" }; const result = await reportToManifest(r); assert.ok(result.ok); assert.equal(result.manifest.posts, undefined); assert.ok(result.warnings.some((w) => w.startsWith("p01: post demo-social/3abcxyz — its platform and link are unknown"))); const withRecord = await reportToManifest(r, { postOf: async () => ({ platform: "bluesky", url: "https://bsky.app/profile/demo.example/post/3abcxyz", author: "demo.example" }), }); assert.ok(withRecord.ok); assert.deepEqual((withRecord.manifest.posts as Record[])[0], { id: "p01", platform: "bluesky", handle: "demo.example", date: "2026-01-03", text: "words", url: "https://bsky.app/profile/demo.example/post/3abcxyz", attachTo: "c01", siteChannel: "demo-social", postId: "3abcxyz", }); });