From 643932facbfdc88fbddf679989d401dcc40c6520 Mon Sep 17 00:00:00 2001 From: Kanav Jain Date: Thu, 1 Oct 2026 17:59:57 -0500 Subject: [PATCH] test(ui): add comprehensive component test suite for timeline, citations, disputes, and export --- bun.lock | 19 ++++++ package.json | 3 +- tests/citations.test.ts | 99 ++++++++++++++++++++++++++++ tests/disputes.test.ts | 143 ++++++++++++++++++++++++++++++++++++++++ tests/export.test.ts | 77 ++++++++++++++++++++++ tests/language.test.ts | 124 ++++++++++++++++++++++++++++++++++ tests/timeline.test.ts | 133 +++++++++++++++++++++++++++++++++++++ vitest.config.ts | 4 +- 8 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 tests/citations.test.ts create mode 100644 tests/disputes.test.ts create mode 100644 tests/export.test.ts create mode 100644 tests/language.test.ts create mode 100644 tests/timeline.test.ts 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", }, });