diff --git a/bun.lock b/bun.lock
index 655b884..02be11c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,7 @@
"name": "varia-ui",
"devDependencies": {
"@biomejs/biome": "^2.5.8",
+ "happy-dom": "^20.14.5",
"husky": "^9.1.7",
"lint-staged": "^17.0.5",
"typescript": "^5.7.0",
@@ -145,6 +146,12 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+ "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="],
+
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
+ "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
+
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
@@ -167,6 +174,8 @@
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
+
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
@@ -177,6 +186,8 @@
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
@@ -195,6 +206,8 @@
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
+ "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="],
+
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
@@ -257,14 +270,20 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
+ "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
+
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
diff --git a/package.json b/package.json
index 3ea6055..ec58303 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
- "description": "Standalone visualization UI for Refract \u2014 load JSONL, render timelines, diffs, citations",
+ "description": "Standalone visualization UI for Refract — load JSONL, render timelines, diffs, citations",
"license": "AGPL-3.0",
"repository": {
"type": "git",
@@ -26,6 +26,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.8",
+ "happy-dom": "^20.14.5",
"husky": "^9.1.7",
"lint-staged": "^17.0.5",
"typescript": "^5.7.0",
diff --git a/tests/citations.test.ts b/tests/citations.test.ts
new file mode 100644
index 0000000..af1a3a8
--- /dev/null
+++ b/tests/citations.test.ts
@@ -0,0 +1,99 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { CitationChurnChart } from "../src/citations/CitationChurnChart.js";
+import { SourceChangeTable } from "../src/citations/SourceChangeTable.js";
+import type { EvidenceEvent } from "../src/types.js";
+
+describe("CitationChurnChart", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no citation events exist", () => {
+ const chart = new CitationChurnChart(container);
+ chart.setData([]);
+
+ expect(container.textContent).toContain("No citation events found");
+ });
+
+ it("renders added/removed metrics and chart bars when citation events exist", () => {
+ const chart = new CitationChurnChart(container);
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "c1",
+ eventType: "citation_added",
+ fromRevisionId: 10,
+ toRevisionId: 11,
+ timestamp: "2026-02-01T00:00:00Z",
+ section: "References",
+ before: "",
+ after: "[Source A]",
+ deterministicFacts: [],
+ layer: "observed",
+ },
+ {
+ eventId: "c2",
+ eventType: "citation_removed",
+ fromRevisionId: 11,
+ toRevisionId: 12,
+ timestamp: "2026-02-02T00:00:00Z",
+ section: "References",
+ before: "[Source B]",
+ after: "",
+ deterministicFacts: [],
+ layer: "observed",
+ },
+ ];
+
+ chart.setData(events);
+ expect(container.textContent).toContain("Citation churn");
+ expect(container.textContent).toContain("Added");
+ expect(container.textContent).toContain("Removed");
+ expect(container.querySelectorAll(".bar-group").length).toBe(2);
+ expect(container.querySelectorAll(".bar").length).toBe(6);
+ });
+});
+
+describe("SourceChangeTable", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no source events exist", () => {
+ const table = new SourceChangeTable(container);
+ table.setData([]);
+
+ expect(container.textContent).toContain("No source/citation changes found");
+ });
+
+ it("renders table rows for citation modifications", () => {
+ const table = new SourceChangeTable(container);
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "s1",
+ eventType: "citation_replaced",
+ fromRevisionId: 30,
+ toRevisionId: 31,
+ timestamp: "2026-04-10T10:00:00Z",
+ section: "Outcomes",
+ before: "doi:10.1001/old",
+ after: "doi:10.1001/new",
+ deterministicFacts: [
+ {
+ fact: "Updated DOI reference",
+ },
+ ],
+ layer: "observed",
+ },
+ ];
+
+ table.setData(events);
+ const rows = container.querySelectorAll("tbody tr");
+ expect(rows.length).toBe(1);
+ expect(rows[0].textContent).toContain("Outcomes");
+ expect(rows[0].textContent).toContain("Updated DOI reference");
+ });
+});
diff --git a/tests/disputes.test.ts b/tests/disputes.test.ts
new file mode 100644
index 0000000..d14144d
--- /dev/null
+++ b/tests/disputes.test.ts
@@ -0,0 +1,143 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { EditVelocityChart } from "../src/disputes/EditVelocityChart.js";
+import { RevertClusterDisplay } from "../src/disputes/RevertClusterDisplay.js";
+import { TalkPageTimeline } from "../src/disputes/TalkPageTimeline.js";
+import type { EvidenceEvent } from "../src/types.js";
+
+describe("EditVelocityChart", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no events exist", () => {
+ const chart = new EditVelocityChart(container);
+ chart.setData([]);
+
+ expect(container.textContent).toContain("No events to chart");
+ });
+
+ it("groups events into daily velocity buckets and renders bars", () => {
+ const chart = new EditVelocityChart(container);
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "e1",
+ eventType: "claim_first_seen",
+ fromRevisionId: 1,
+ toRevisionId: 2,
+ timestamp: "2026-05-10T10:00:00Z",
+ section: "Lead",
+ before: "",
+ after: "First statement",
+ deterministicFacts: [],
+ layer: "observed",
+ },
+ {
+ eventId: "e2",
+ eventType: "revert_detected",
+ fromRevisionId: 2,
+ toRevisionId: 3,
+ timestamp: "2026-05-10T11:00:00Z",
+ section: "Lead",
+ before: "First statement",
+ after: "",
+ deterministicFacts: [],
+ layer: "observed",
+ },
+ ];
+
+ chart.setData(events);
+ expect(container.querySelectorAll(".bar-group").length).toBe(1);
+ expect(container.querySelectorAll(".bar").length).toBe(2);
+ });
+});
+
+describe("RevertClusterDisplay", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no revert events exist", () => {
+ const display = new RevertClusterDisplay(container);
+ display.setData([]);
+
+ expect(container.textContent).toContain("No revert events detected");
+ });
+
+ it("renders revert cluster cards with count badge", () => {
+ const display = new RevertClusterDisplay(container);
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "rev-1",
+ eventType: "revert_detected",
+ fromRevisionId: 100,
+ toRevisionId: 101,
+ timestamp: "2026-07-01T15:00:00Z",
+ section: "Safety",
+ before: "Claim with caveat",
+ after: "Claim without caveat",
+ deterministicFacts: [
+ {
+ fact: "Reverted 1 edit by user A",
+ },
+ ],
+ layer: "observed",
+ },
+ ];
+
+ display.setData(events);
+ const cards = container.querySelectorAll(".card");
+ expect(cards.length).toBe(1);
+ expect(cards[0].textContent).toContain("r101");
+ expect(cards[0].textContent).toContain("Safety");
+ expect(cards[0].textContent).toContain("Reverted 1 edit by user A");
+ });
+});
+
+describe("TalkPageTimeline", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no talk page events exist", () => {
+ const timeline = new TalkPageTimeline(container);
+ timeline.setData([]);
+
+ expect(container.textContent).toContain("No talk page events found");
+ });
+
+ it("renders timeline items for talk page discussions", () => {
+ const timeline = new TalkPageTimeline(container);
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "talk-1",
+ eventType: "talk_page_correlated",
+ fromRevisionId: 5,
+ toRevisionId: 6,
+ timestamp: "2026-08-01T12:00:00Z",
+ section: "Talk:Article",
+ before: "",
+ after: "Discussion started regarding trial validity",
+ deterministicFacts: [
+ {
+ fact: "Thread opened: 'Trial Validity Analysis'",
+ },
+ ],
+ layer: "observed",
+ },
+ ];
+
+ timeline.setData(events);
+ const items = container.querySelectorAll(".timeline-item");
+ expect(items.length).toBe(1);
+ expect(items[0].textContent).toContain("talk page correlated");
+ expect(items[0].textContent).toContain(
+ "Thread opened: 'Trial Validity Analysis'",
+ );
+ });
+});
diff --git a/tests/export.test.ts b/tests/export.test.ts
new file mode 100644
index 0000000..69f0607
--- /dev/null
+++ b/tests/export.test.ts
@@ -0,0 +1,77 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { SchemaViewer } from "../src/export/SchemaViewer.js";
+import { TimelineExport } from "../src/export/TimelineExport.js";
+import type { EvidenceEvent } from "../src/types.js";
+
+describe("TimelineExport", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ });
+
+ it("renders format selector and export button", () => {
+ new TimelineExport(container);
+
+ const select = container.querySelector("select");
+ expect(select).not.toBeNull();
+ expect(select?.children.length).toBe(3);
+
+ const button = container.querySelector(".export-btn");
+ expect(button).not.toBeNull();
+ expect(button?.textContent).toBe("Export");
+ });
+
+ it("displays message when exporting with empty events", () => {
+ new TimelineExport(container);
+ const button = container.querySelector(".export-btn") as HTMLButtonElement;
+ button.click();
+
+ const status = container.querySelector("#export-status");
+ expect(status?.textContent).toBe("No data to export.");
+ });
+});
+
+describe("SchemaViewer", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no events are provided", () => {
+ const viewer = new SchemaViewer(container);
+ viewer.setData([]);
+
+ expect(container.textContent).toContain("Upload data to inspect schema");
+ });
+
+ it("analyzes fields and types across events", () => {
+ const viewer = new SchemaViewer(container);
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "e1",
+ eventType: "claim_first_seen",
+ fromRevisionId: 10,
+ toRevisionId: 11,
+ timestamp: "2026-01-01T00:00:00Z",
+ section: "Lead",
+ before: "",
+ after: "Text",
+ deterministicFacts: [],
+ layer: "observed",
+ },
+ ];
+
+ viewer.setData(events as unknown as Record[]);
+
+ const keys = container.querySelectorAll(".schema-key");
+ expect(keys.length).toBeGreaterThan(0);
+ expect(container.textContent).toContain("1 events");
+ });
+});
diff --git a/tests/language.test.ts b/tests/language.test.ts
new file mode 100644
index 0000000..830b8b2
--- /dev/null
+++ b/tests/language.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { CertaintyTimeline } from "../src/language/CertaintyTimeline.js";
+import { WordingDiffCard } from "../src/language/WordingDiffCard.js";
+import type { EvidenceEvent } from "../src/types.js";
+
+describe("CertaintyTimeline", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no events with confidence exist", () => {
+ const timeline = new CertaintyTimeline(container);
+ timeline.setData([]);
+
+ expect(container.querySelector(".panel-title")?.textContent).toBe(
+ "Model confidence",
+ );
+ expect(container.textContent).toContain(
+ "No model interpretations with confidence scores found",
+ );
+ });
+
+ it("renders dots and chart when events have modelInterpretation confidence", () => {
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "ev-1",
+ eventType: "claim_strengthened",
+ fromRevisionId: 100,
+ toRevisionId: 101,
+ timestamp: "2026-01-01T00:00:00Z",
+ section: "Lead",
+ before: "alleged to work",
+ after: "proven to work",
+ deterministicFacts: [],
+ layer: "model_interpretation",
+ modelInterpretation: {
+ confidence: 0.85,
+ summary: "Stronger language",
+ },
+ },
+ {
+ eventId: "ev-2",
+ eventType: "claim_softened",
+ fromRevisionId: 101,
+ toRevisionId: 102,
+ timestamp: "2026-01-02T00:00:00Z",
+ section: "Lead",
+ before: "proven to work",
+ after: "suggested to work",
+ deterministicFacts: [],
+ layer: "model_interpretation",
+ modelInterpretation: {
+ confidence: 0.35,
+ summary: "Hedged language",
+ },
+ },
+ ];
+
+ const timeline = new CertaintyTimeline(container);
+ timeline.setData(events);
+
+ const chart = container.querySelector(".certainty-chart");
+ expect(chart).not.toBeNull();
+
+ const dots = container.querySelectorAll(".certainty-dot");
+ expect(dots.length).toBe(2);
+ expect(dots[0].className).toContain("high");
+ expect(dots[1].className).toContain("low");
+ });
+});
+
+describe("WordingDiffCard", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no wording events exist", () => {
+ const card = new WordingDiffCard(container);
+ card.setData([]);
+
+ expect(container.querySelector(".panel-title")?.textContent).toBe(
+ "Wording changes",
+ );
+ expect(container.textContent).toContain("No wording-related events found");
+ });
+
+ it("renders cards with before and after diffs for sentence modifications", () => {
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "w-1",
+ eventType: "sentence_modified",
+ fromRevisionId: 200,
+ toRevisionId: 201,
+ timestamp: "2026-03-01T12:00:00Z",
+ section: "Background",
+ before: "The protocol was disputed.",
+ after: "The protocol was established in 2024.",
+ deterministicFacts: [
+ {
+ fact: "Clarified historical timing",
+ },
+ ],
+ layer: "observed",
+ },
+ ];
+
+ const card = new WordingDiffCard(container);
+ card.setData(events);
+
+ const cards = container.querySelectorAll(".card");
+ expect(cards.length).toBe(1);
+
+ const diffs = container.querySelectorAll(".card-diff");
+ expect(diffs.length).toBe(2);
+ expect(diffs[0].textContent).toContain("− The protocol was disputed.");
+ expect(diffs[1].textContent).toContain(
+ "+ The protocol was established in 2024.",
+ );
+ });
+});
diff --git a/tests/timeline.test.ts b/tests/timeline.test.ts
new file mode 100644
index 0000000..fa926d5
--- /dev/null
+++ b/tests/timeline.test.ts
@@ -0,0 +1,133 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { DiffView } from "../src/timeline/DiffView.js";
+import { EventFilter } from "../src/timeline/EventFilter.js";
+import { TimelineView } from "../src/timeline/TimelineView.js";
+import type { EventType, EvidenceEvent } from "../src/types.js";
+
+describe("EventFilter", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("initializes with all types active and renders filter chips", () => {
+ const allTypes: EventType[] = [
+ "citation_added",
+ "claim_first_seen",
+ "revert_detected",
+ ];
+ const onChange = vi.fn();
+ const filter = new EventFilter({ container, allTypes, onChange });
+
+ expect(filter.getActiveTypes().size).toBe(3);
+ const chips = container.querySelectorAll(".filter-tag");
+ expect(chips.length).toBe(3);
+ });
+
+ it("toggles a filter type and triggers onChange callback", () => {
+ const allTypes: EventType[] = ["citation_added", "claim_first_seen"];
+ const onChange = vi.fn();
+ const filter = new EventFilter({ container, allTypes, onChange });
+
+ filter.toggleType("citation_added");
+ expect(filter.getActiveTypes().has("citation_added")).toBe(false);
+ expect(onChange).toHaveBeenCalledWith(filter.getActiveTypes());
+ });
+
+ it("clears and selects all types", () => {
+ const allTypes: EventType[] = ["citation_added", "claim_first_seen"];
+ const onChange = vi.fn();
+ const filter = new EventFilter({ container, allTypes, onChange });
+
+ filter.clearAll();
+ expect(filter.getActiveTypes().size).toBe(0);
+
+ filter.selectAll();
+ expect(filter.getActiveTypes().size).toBe(2);
+ });
+});
+
+describe("TimelineView", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty state when no events exist", () => {
+ const view = new TimelineView({ container });
+ view.setData([]);
+
+ expect(container.textContent).toContain(
+ "No events match the current filter.",
+ );
+ });
+
+ it("syncs selection and notifies onSelectEvent callback", () => {
+ const onSelect = vi.fn();
+ const view = new TimelineView({ container, onSelectEvent: onSelect });
+
+ const events: EvidenceEvent[] = [
+ {
+ eventId: "evt-01",
+ eventType: "claim_first_seen",
+ fromRevisionId: 10,
+ toRevisionId: 11,
+ timestamp: "2026-05-01T00:00:00Z",
+ section: "Lead",
+ before: "",
+ after: "Initial statement",
+ deterministicFacts: [],
+ layer: "observed",
+ },
+ ];
+
+ view.setData(events);
+ expect(onSelect).toHaveBeenCalledWith(events[0]);
+ expect(container.querySelectorAll(".timeline-item").length).toBe(1);
+ });
+});
+
+describe("DiffView", () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ });
+
+ it("renders empty prompt when no event is selected", () => {
+ const diff = new DiffView(container);
+ diff.clear();
+
+ expect(container.textContent).toContain("No event selected.");
+ });
+
+ it("renders word-level diff and metadata for a selected event", () => {
+ const diff = new DiffView(container);
+ const event: EvidenceEvent = {
+ eventId: "evt-diff",
+ eventType: "sentence_modified",
+ fromRevisionId: 50,
+ toRevisionId: 51,
+ timestamp: "2026-06-01T08:00:00Z",
+ section: "Clinical efficacy",
+ before: "Drug X was ineffective in trials.",
+ after: "Drug X showed efficacy in phase 3 trials.",
+ deterministicFacts: [
+ {
+ fact: "Trial outcome updated",
+ detail: "primary endpoint met",
+ },
+ ],
+ layer: "observed",
+ };
+
+ diff.showDiff(event);
+ expect(container.textContent).toContain("r50 → r51");
+ expect(container.textContent).toContain("Clinical efficacy");
+ expect(
+ container.querySelectorAll(".diff-word-added, .diff-word-removed").length,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 9915a5e..221f952 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,6 +1,8 @@
import { defineConfig } from "vitest/config";
+
export default defineConfig({
test: {
- include: ["tests/**/*.test.ts"],
+ globals: true,
+ environment: "happy-dom",
},
});