import { test } from "node:test"; import assert from "node:assert/strict"; import { CITATION_KINDS, citationSchema, MAX_CITATION_SPAN_SECONDS, type Citation, } from "./schema"; import { citationProblems, isPartialDate, jsonPath, parseCitationSet, relativePathProblem, validateCitationSet, type Problem, } from "./validate"; import { momentKey, momentKeyOf, momentOf, momentPath, parseMomentKey, parseMomentPath, isSafeIdSegment, isSafeChannelSegment, } from "./moments"; import { citationAnchor, citeHref, extractCiteRefs, numberCitations } from "./inline"; const video = (over: Partial> = {}): Citation => ({ kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "the words as said", ...over, }); const paths = (ps: Problem[]) => ps.map((p) => p.path); const SET = { format: "archilyzer-citations", version: 1, sources: { s0: { kind: "article", title: "A demo article", url: "https://example.test/article", archives: [{ label: "archived", url: "https://archive.example.test/a", context: "as linked here" }], saved: "sources/s0/page.html", }, }, citations: { c01: { kind: "video", channel: "demo-channel", id: "-abc123", start: 1.5, end: 9.25, quote: "a span", pad: { before: 5, after: 5 } }, c02: { kind: "audio", channel: "demo-podcast", id: "ep42", start: 60, end: 75, quote: "an episode", speaker: "A guest" }, p01: { kind: "post", channel: "demo-channel", id: "1234567890", quote: "a post", thread: true }, a01: { kind: "source", source: "s0", quote: "the sentence", image: "stills/a01.png" }, w01: { kind: "page", url: "https://example.test/page", title: "A page", archiveUrl: "https://archive.example.test/p", quote: "on the page" }, }, }; // ─── schema ─── test("a citation set with every kind parses with no problems", () => { const r = parseCitationSet(SET); assert.ok(r.ok); assert.deepEqual(r.problems, []); }); test("every kind is a member of the union, and the union is closed", () => { assert.deepEqual( CITATION_KINDS.map((s) => s.shape.kind.value), ["video", "audio", "post", "source", "page"], ); assert.equal(citationSchema.safeParse({ kind: "tweet", quote: "x" }).success, false); }); test("shape problems: unknown keys, wrong types, a missing quote, the wrong format or version", () => { const bad = { ...SET, version: 2, citations: { c01: { ...SET.citations.c01, colour: "red" }, c02: { ...SET.citations.c02, start: "60" }, p01: { kind: "post", channel: "demo-channel", id: "1" }, }, }; const r = parseCitationSet(bad); assert.equal(r.ok, false); const ps = paths(r.problems); assert.ok(ps.includes("version"), ps.join()); assert.ok(ps.includes("citations.c01"), ps.join()); // the unknown key assert.ok(ps.includes("citations.c02.start"), ps.join()); assert.ok(ps.includes("citations.p01.quote"), ps.join()); assert.equal(parseCitationSet({ ...SET, format: "other" }).ok, false); }); // ─── value rules ─── test("span rules: end after start, pad at least 0, at most the cap with the pad", () => { assert.deepEqual(citationProblems(video(), ["c"]), []); assert.deepEqual(paths(citationProblems(video({ end: 10 }), ["c"])), ["c.end"]); assert.deepEqual(paths(citationProblems(video({ end: 5 }), ["c"])), ["c.end"]); assert.deepEqual(paths(citationProblems(video({ start: -1 }), ["c"])), ["c.start"]); assert.deepEqual(paths(citationProblems(video({ pad: { before: -1 } }), ["c"])), ["c.pad.before"]); // At the cap exactly is fine; past it, counting the pad, is not. assert.deepEqual(citationProblems(video({ start: 0, end: MAX_CITATION_SPAN_SECONDS }), ["c"]), []); assert.deepEqual(citationProblems(video({ start: 0, end: 110, pad: { before: 5, after: 5 } }), ["c"]), []); const over = citationProblems(video({ start: 0, end: 110, pad: { before: 5, after: 6 } }), ["c"]); assert.deepEqual(paths(over), ["c"]); assert.match(over[0].message, /121 s \(at most 120 s\)/); // A span that vanishes at the key's precision. assert.deepEqual(paths(citationProblems(video({ start: 1.001, end: 1.004 }), ["c"])), ["c.end"]); }); test("channel and id must be safe path segments; an id may start with -", () => { assert.deepEqual(citationProblems(video({ id: "-dQw4w9WgXcQ" }), ["c"]), []); assert.deepEqual(citationProblems(video({ id: "_x" }), ["c"]), []); for (const id of ["..", ".", "a/b", "a\\b", "", "a b", "a?b"]) { assert.deepEqual(paths(citationProblems(video({ id }), ["c"])), ["c.id"], id); } for (const channel of ["-lead", "..", "a/b", ""]) { assert.deepEqual(paths(citationProblems(video({ channel }), ["c"])), ["c.channel"], channel); } }); test("common fields: a blank quote, a two-line label, a bad date", () => { const ps = citationProblems(video({ quote: " ", label: "a\nb", date: "2024-02-30", speaker: "" }), ["c"]); assert.deepEqual(paths(ps).sort(), ["c.date", "c.label", "c.quote", "c.speaker"]); }); test("verification is computed: values that no check writes are flagged", () => { const ok = video({ verification: { quoteScore: 0.93, quoteCheckedAt: "2026-10-01T12:00:00Z", voiceChecked: true, method: "cue-window" } }); assert.deepEqual(citationProblems(ok, ["c"]), []); const ps = citationProblems(video({ verification: { quoteScore: 1.4, quoteCheckedAt: "yesterday" } }), ["c"]); assert.deepEqual(paths(ps).sort(), ["c.verification.quoteCheckedAt", "c.verification.quoteScore"]); assert.deepEqual(paths(citationProblems(video({ verification: { quoteScore: 0.5 } }), ["c"])), ["c.verification"]); const post: Citation = { kind: "post", channel: "demo-channel", id: "1", quote: "q", verification: { voiceChecked: true } }; assert.deepEqual(paths(citationProblems(post, ["p"])), ["p.verification.voiceChecked"]); }); test("source and page citations: the source must exist, paths may not escape, URLs are http(s)", () => { const set = structuredClone(SET) as typeof SET & { citations: Record }; set.citations.a02 = { kind: "source", source: "s9", quote: "q", image: "../outside.png" }; set.citations.w02 = { kind: "page", url: "javascript:alert(1)", quote: "q", archiveUrl: "ftp://x" }; (set.sources.s0 as { saved: string }).saved = "/abs/page.html"; const ps = paths(validateCitationSet(set)); assert.deepEqual(ps.sort(), [ "citations.a02.image", "citations.a02.source", "citations.w02.archiveUrl", "citations.w02.url", "sources.s0.saved", ]); }); test("ids: reference ids only, and no id names both a source and a citation", () => { const set = structuredClone(SET) as unknown as { citations: Record; sources: Record }; set.citations["bad id"] = { ...SET.citations.p01 }; set.citations.s0 = { ...SET.citations.p01 }; const ps = validateCitationSet(set); assert.deepEqual(paths(ps).sort(), ['citations["bad id"]', "sources.s0"]); }); test("relative paths: relative, forward slashes, inside the directory", () => { assert.equal(relativePathProblem("stills/a01.png"), null); for (const p of ["/etc/x", "C:/x", "a\\b", "../x", "a/../../x", "a//b", "./a", "https://x/y", "", "a/"]) { assert.notEqual(relativePathProblem(p), null, p); } }); test("dates: partial dates and zoned date-times, real calendar days only", () => { for (const d of ["2024", "2024-02", "2024-02-29", "2026-10-01T12:00Z", "2026-10-01T12:00:00.5+02:00"]) { assert.ok(isPartialDate(d), d); } for (const d of ["2023-02-29", "2024-13", "24-01-01", "2026-10-01T12:00", "October 2026"]) { assert.ok(!isPartialDate(d), d); } }); test("jsonPath: plain keys dotted, other keys quoted, indexes bracketed", () => { assert.equal(jsonPath(["sections", 0, "claims", 2, "findings"]), "sections[0].claims[2].findings"); assert.equal(jsonPath(["citations", "c.1", "end"]), 'citations["c.1"].end'); assert.equal(jsonPath([]), ""); }); // ─── moments ─── test("moment keys: video and audio by span, post by id, none for source and page", () => { const set = parseCitationSet(SET); assert.ok(set.ok); const c = set.value.citations; assert.equal(momentKeyOf(c.c01), "demo-channel/-abc123/1.50-9.25"); assert.equal(momentKeyOf(c.c02), "demo-podcast/ep42/60.00-75.00"); assert.equal(momentKeyOf(c.p01), "demo-channel/1234567890"); assert.equal(momentKeyOf(c.a01), null); assert.equal(momentKeyOf(c.w01), null); assert.equal(momentPath(momentOf(c.c01)!), "/m/demo-channel/-abc123/1.50-9.25/"); assert.equal(momentPath("demo-channel/1234567890"), "/m/demo-channel/1234567890/"); }); test("moment keys round-trip, an id starting with - included; the pad is not part of the key", () => { for (const key of ["demo-channel/-abc123/0.00-12.34", "demo-channel/abc_123/3600.10-3610.00", "demo-channel/-1"]) { const m = parseMomentKey(key); assert.ok(m, key); assert.equal(momentKey(m!), key); assert.deepEqual(parseMomentPath(momentPath(m!)), m); assert.deepEqual(parseMomentPath(`/m/${key}`), m); } assert.equal(momentKeyOf(video({ pad: { before: 3 } })), momentKeyOf(video())); // Rounded to the key's precision, and the moment carries the rounded numbers. const m = momentOf(video({ start: 1.234, end: 5.678 })); assert.deepEqual(m, { kind: "span", channel: "demo-channel", id: "abc123", start: 1.23, end: 5.68 }); }); test("moment keys: only the canonical spelling parses; unsafe segments never key", () => { for (const key of [ "demo-channel/abc/1.5-2.00", "demo-channel/abc/01.00-2.00", "demo-channel/abc/2.00-1.00", "demo-channel/abc/1.00-1.00", "demo-channel/abc/-1.00-2.00", "demo-channel/../1.00-2.00", "demo-channel", "demo-channel/abc/1.00-2.00/x", "-x/abc", ]) { assert.equal(parseMomentKey(key), null, key); } assert.equal(parseMomentPath("/reports/x/"), null); assert.throws(() => momentKey({ kind: "post", channel: "demo-channel", id: "a/b" }), /safe path segment/); assert.equal(momentKeyOf(video({ id: ".." })), null); assert.ok(isSafeIdSegment("-abc") && !isSafeChannelSegment("-abc")); }); // ─── inline citations ─── test("extractCiteRefs: every cite link in order, with labels and offsets; other links ignored", () => { const md = "He said so [here](cite:c01) and [again](cite:c02), see [the site](https://example.test) and [x]( cite:c01 )."; const refs = extractCiteRefs(md); assert.deepEqual(refs.map((r) => [r.id, r.label]), [["c01", "here"], ["c02", "again"], ["c01", "x"]]); assert.equal(md.slice(refs[0].offset, refs[0].offset + 6), "[here]"); assert.deepEqual(extractCiteRefs(undefined), []); assert.deepEqual(extractCiteRefs("[empty](cite:)").map((r) => r.id), [""]); }); test("extractCiteRefs: a label may hold an editorial [insertion], never a lone ]", () => { const md = "She said [“the plan is not what [the mayor] signed”](cite:p01), then [x ] y](cite:p02) and [a [b] [c] d](cite:p03)."; const refs = extractCiteRefs(md); assert.deepEqual(refs.map((r) => [r.id, r.label]), [ ["p01", "“the plan is not what [the mayor] signed”"], ["p03", "a [b] [c] d"], ]); assert.equal(md.slice(refs[0].offset, refs[0].offset + 2), "[“"); }); test("extractCiteRefs: a cite link in code is text about the syntax", () => { const md = [ "Write `[label](cite:id)` to cite, like [this](cite:c01).", "```md", "[in a fence](cite:c09)", "```", "~~~~", "[tilde fence](cite:c08)", "~~~~", "And ``[double](cite:c07)`` too, then [after](cite:c02).", ].join("\n"); assert.deepEqual(extractCiteRefs(md).map((r) => r.id), ["c01", "c02"]); }); test("numberCitations: by first appearance, a repeat keeps its number", () => { assert.deepEqual([...numberCitations(["c03", "c01", "c03", "c02", "c01"])], [["c03", 1], ["c01", 2], ["c02", 3]]); assert.equal(citeHref("c01"), "cite:c01"); assert.equal(citationAnchor("c01"), "c-c01"); });