/** * v0.59b — targeted deterministic tests for Report freshness UI and manual update. * * Tests at the seam-crossing boundary: * - Freshness comparison logic * - Storage layer crossing (saveInvestigation) * - Manual update flow (fetch + persistence) * - Duplicate prevention guard * * These are deterministic unit/seam tests — no component rendering, * no full mock chains. Mirrors the v059a test pattern. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; /* ═══════════════════ Shared in-memory mock for save/load/clear ═══════════════════ */ class MockStorageMap { constructor() { this._data = new Map(); } getItem(k) { return this._data.has(k) ? this._data.get(k) : null; } setItem(k, v){ this._data.set(k, v); } removeItem(k){ this._data.delete(k); } clear() { this._data.clear(); } } function installMockStorage() { Object.defineProperty(globalThis, "window", { value: globalThis.window || {}, writable: true, configurable: true, }); if (!globalThis.window.localStorage) { Object.defineProperty(globalThis.window, "localStorage", { value: new MockStorageMap(), writable: true, configurable: true, }); } } function uninstallMockStorage() { const desc = Object.getOwnPropertyDescriptor(globalThis, "window"); if (desc && !("localStorage" in globalThis.window)) return; delete globalThis.window.localStorage; delete globalThis.window.sessionStorage; } beforeEach(() => { installMockStorage(); }); afterEach(() => { uninstallMockStorage(); }); /* ═══════════════ Import persistence layer ═══════════════ */ let storageModule = null; async function getStorage() { if (!storageModule) { const m = await import("../lib/storage/investigation-storage.js"); storageModule = { load: m.loadInvestigation, save: m.saveInvestigation }; } return storageModule; } /* ═══════════════ Freshness comparison utility (mirrors ReportPage JSX) ═══════════════ */ /** Returns the freshness state from investigation + report data. */ function getFreshnessState(investigationRevision, generatedFromRevision) { if (investigationRevision === generatedFromRevision) { return "current"; } return "updateAvailable"; } /* ── Freshness display logic ───────────────────────────── */ describe("Freshness comparison logic", () => { it("matching revisions → 'Current'", async () => { expect(getFreshnessState(3, 3)).toBe("current"); }); it("differing revisions → 'Update available'", async () => { expect(getFreshnessState(5, 3)).toBe("updateAvailable"); }); it("zero-zero matching → 'Current'", async () => { expect(getFreshnessState(0, 0)).toBe("current"); }); it("large revision gap still detected", async () => { expect(getFreshnessState(10, 3)).toBe("updateAvailable"); }); }); /* ── 7. Zero overview calls on differing-revision render (seam crossing) ─ */ describe("Zero overview calls — differing revision render does not trigger persistence", () => { it("differing revisions retain report without mutating storage on render", async () => { const { load, save } = await getStorage(); // Precondition: existing Report at rev 3, investigation now at rev 5 save({ scenario: "Test", situationGraph: { centralStatement: "CS", nodes: [], edges: [] }, findings: [], investigationRevision: 5, investigationReport: { understanding: "R", generatedFromRevision: 3 }, focusedContributions: [], }); const loaded = load(); expect(loaded.investigationRevision).toBe(5); expect(loaded.investigationReport?.generatedFromRevision).toBe(3); // No save has been written during read-only render — freshness check is stateless }); }); /* ── 8. Manual update triggers fetch (seam: fetch boundary) ─────────── */ describe("Manual Report update seam — overview request", () => { it("activating Update report causes exactly one POST /api/cases/overview", async () => { const origFetch = global.fetch; let capturedBody = null; global.fetch = vi.fn(async (url, options) => { if (url === "/api/cases/overview") { capturedBody = JSON.parse(options.body); return { ok: true, json: async () => ({ success: true, understanding: "R2", plausibleInterpretations: "P.", hasPlausibleInterpretations: true, }), }; } return origFetch(url, options); }); const { load, save } = await getStorage(); // Precondition: Report at rev 3, investigation now at rev 5 save({ scenario: "Test", situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1" }], edges: [] }, findings: [{ id: "f-1", proposition: "P1" }], investigationRevision: 5, investigationReport: { understanding: "R", generatedFromRevision: 3 }, focusedContributions: [], }); // Simulate the update handler body (from ReportPage): const snap = load(); const rev = snap?.investigationRevision ?? 0; const situationGraph = snap?.situationGraph; const findings = snap?.findings ?? []; const res = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph, findings }), }); expect(global.fetch).toHaveBeenCalledTimes(1); expect(capturedBody.situationGraph).toBeDefined(); expect(res.ok).toBe(true); global.fetch = origFetch; }); }); /* ── 9. Successful update persists replacement with correct generatedFromRevision ─ */ describe("Successful update — persistence seam crossing", () => { it("replacement Report has generatedFromRevision = current investigationRevision", async () => { const { load, save } = await getStorage(); // Precondition: Report at rev 3, investigation at rev 5 save({ scenario: "Test", situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1" }], edges: [] }, findings: [], investigationRevision: 5, investigationReport: { understanding: "R", generatedFromRevision: 3 }, focusedContributions: [], }); const snap = load(); const rev = snap?.investigationRevision ?? 0; const newReportData = { understanding: "R2", plausibleInterpretations: "P.", hasPlausibleInterpretations: true, generatedFromRevision: rev }; save({ ...snap, investigationReport: newReportData }); const updated = load(); expect(updated.investigationRevision).toBe(5); expect(updated.investigationReport?.generatedFromRevision).toBe(5); }); }); /* ── 10. Successful update renders "Current" (freshness state) ─────── */ describe("Successful update — freshness returns to Current", () => { it("after replacement, revision equality yields 'Current'", async () => { const { load, save } = await getStorage(); save({ scenario: "Test", situationGraph: { centralStatement: "CS", nodes: [], edges: [] }, findings: [], investigationRevision: 5, investigationReport: { understanding: "R2", generatedFromRevision: 5 }, focusedContributions: [], }); const updated = load(); expect(getFreshnessState(updated.investigationRevision, updated.investigationReport?.generatedFromRevision)).toBe("current"); }); }); /* ── 11 & 12. Failed update retains existing Report ─────────────────── */ describe("Failed update — existing Report and revision state retained", () => { it("existing Report content preserved after failure", async () => { const origFetch = global.fetch; global.fetch = vi.fn().mockRejectedValue(new Error("network")); const { load, save } = await getStorage(); // Precondition: existing Report at rev 3 save({ scenario: "Test", situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1" }], edges: [] }, findings: [], investigationRevision: 5, investigationReport: { understanding: "R", generatedFromRevision: 3 }, focusedContributions: [], }); const snapBefore = load(); // Simulate failed update handler (try/catch — no save on failure): try { await fetch("/api/cases/overview", { method: "POST" }); } catch { /* failure path — no save */ } const snapAfter = load(); expect(snapAfter.investigationReport?.understanding).toBe("R"); expect(snapAfter.investigationReport?.generatedFromRevision).toBe(3); global.fetch = origFetch; }); it("Update available retained after failure (still differing)", async () => { const origFetch = global.fetch; global.fetch = vi.fn().mockRejectedValue(new Error("network")); const { load, save } = await getStorage(); save({ scenario: "Test", situationGraph: { centralStatement: "CS", nodes: [], edges: [] }, findings: [], investigationRevision: 5, investigationReport: { understanding: "R", generatedFromRevision: 3 }, focusedContributions: [], }); try { await fetch("/api/cases/overview", { method: "POST" }); } catch { /* failure */ } const snap = load(); expect(getFreshnessState(snap.investigationRevision, snap.investigationReport?.generatedFromRevision)).toBe("updateAvailable"); global.fetch = origFetch; }); it("does NOT persist a partial replacement on failure", async () => { const origFetch = global.fetch; global.fetch = vi.fn().mockRejectedValue(new Error("network")); const { load, save } = await getStorage(); save({ scenario: "Test", situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1" }], edges: [] }, findings: [], investigationRevision: 5, investigationReport: { understanding: "R", generatedFromRevision: 3 }, focusedContributions: [], }); const reportBefore = JSON.parse(JSON.stringify(load().investigationReport)); try { await fetch("/api/cases/overview", { method: "POST" }); } catch { /* failure — no save in handler */ } const reportAfter = load().investigationReport; expect(reportAfter).toEqual(reportBefore); // unchanged global.fetch = origFetch; }); }); /* ── 13. Duplicate activation prevention (guard) ─────────────── */ describe("Duplicate activation prevention", () => { it("second click while loading causes zero additional requests", async () => { let resolveFetch; const fetchPromise = new Promise((r) => { resolveFetch = r; }); const origFetch = global.fetch; global.fetch = vi.fn(() => fetchPromise); // Simulate the guard: if (updateLoading) return; let updateLoading = false; function tryUpdateReport() { if (updateLoading) return; // guard — duplicate prevention updateLoading = true; return fetch("/api/cases/overview", { method: "POST" }).finally(() => { updateLoading = false; }); } tryUpdateReport(); // first click — sets loading, starts request tryUpdateReport(); // second click — guard returns immediately expect(global.fetch).toHaveBeenCalledTimes(1); resolveFetch({ ok: true, json: async () => ({ success: true }) }); global.fetch = origFetch; }); }); /* ── 14. Unchanged Report revisit retains zero-call behaviour ─────── */ describe("Unchanged Report revisit retains zero-call behaviour", () => { it("matching revision render does not trigger overview or save", async () => { const origFetch = global.fetch; global.fetch = vi.fn(); const { load, save } = await getStorage(); save({ scenario: "Test", situationGraph: { centralStatement: "CS", nodes: [], edges: [] }, findings: [], investigationRevision: 3, investigationReport: { understanding: "R", generatedFromRevision: 3 }, focusedContributions: [], }); const snap = load(); // No fetch — freshness state is computed purely from local data expect(getFreshnessState(snap.investigationRevision, snap.investigationReport?.generatedFromRevision)).toBe("current"); expect(global.fetch).not.toHaveBeenCalled(); global.fetch = origFetch; }); });