commit 4e9763e04c776c7841fe42abc2bbab08501a7a60
parent 2da702073bcdf8dd3e2add17ca5d05c52c16cde3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:34:42 -0400
common: converter tests — links, spans from cues, the markdown engine, /sweep, /ask, manifest both ways and the round trips
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 619 insertions(+), 1 deletion(-)
diff --git a/common/lib/report/convert.test.ts b/common/lib/report/convert.test.ts
@@ -0,0 +1,618 @@
+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<string, Cue[]> = {
+ "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<string, unknown>[];
+ 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<string, unknown>[]) =>
+ 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<string, unknown>[]), [
+ ...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<string, unknown>[]).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<string, unknown>[])[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",
+ });
+});
diff --git a/common/lib/report/convertShared.ts b/common/lib/report/convertShared.ts
@@ -392,7 +392,7 @@ async function rewriteBlock(md: string, previousText: string, resolve: CiteResol
}
out += md.slice(last);
withoutLinks.push(md.slice(last));
- const text = plainText(withoutLinks.join("")).replace(TRIM_SEP_RE, "");
+ const text = plainText(withoutLinks.join("")).replace(/\s+([.,;:!?])/g, "$1").replace(TRIM_SEP_RE, "");
return { md: out, text, ids: [...new Set(ids)], interleaved: between > 0 };
}