diff --git a/app/investigations/[id]/report/page.jsx b/app/investigations/[id]/report/page.jsx index 093b217..e2e4aee 100644 --- a/app/investigations/[id]/report/page.jsx +++ b/app/investigations/[id]/report/page.jsx @@ -9,6 +9,7 @@ export default function ReportPage() { const [hydrated, setHydrated] = useState(false); const [generationLoading, setGenerationLoading] = useState(false); const [generationError, setGenerationError] = useState(false); + const [updateLoading, setUpdateLoading] = useState(false); const generationAttempted = useRef(false); @@ -66,6 +67,48 @@ export default function ReportPage() { })(); }, [hydrated, existing]); + // Manual Report update (v0.59b — freshness manual update) + const handleUpdateReport = async () => { + if (updateLoading) return; + setUpdateLoading(true); + + const snap = loadInvestigation(); + const situationGraph = snap?.situationGraph; + const findings = snap?.findings ?? []; + const rev = snap?.investigationRevision ?? 0; + + if (!situationGraph) { + setUpdateLoading(false); + return; + } + + try { + const res = await fetch("/api/cases/overview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ situationGraph, findings }), + }); + + if (!res.ok) { + setUpdateLoading(false); + return; + } + + const data = await res.json(); + if (data.success) { + const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev }; + setExisting((p) => { + saveInvestigation({ ...p, investigationReport: reportData }); + return { ...p, investigationReport: reportData }; + }); + } + } catch { + /* failure: retain existing Report and updateAvailable state */ + } finally { + setUpdateLoading(false); + } + }; + const report = existing?.investigationReport || null; const scenario = hydrated ? (existing?.scenario || "") : null; @@ -79,6 +122,28 @@ export default function ReportPage() { Investigation Report + {/* Report freshness — only when a Report exists */} + {report ? ( +
+ {existing?.investigationRevision === report.generatedFromRevision ? ( + Current + ) : ( +
+ Update available + The investigation has changed since this report was generated. + +
+ )} +
+ ) : null} + {/* Situation */} {scenario && (
diff --git a/app/page.jsx b/app/page.jsx index 7c79580..139d9f3 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -18,6 +18,11 @@ function Portfolio() { existing?.investigationReport && existing.investigationReport.understanding ); + const reportIsCurrent = + hasReport && + existing.investigationReport.generatedFromRevision === + (existing.investigationRevision ?? 0); + return (

Confidence Engine

@@ -39,12 +44,24 @@ function Portfolio() {
{hasReport ? ( - - View report - +
+ + View report + + + {reportIsCurrent ? ( + + Current + + ) : ( + + Update available + + )} +
) : null} { investigationReport: { understanding: "We understand complaints rose.", hasPlausibleInterpretations: false, + generatedFromRevision: 0, }, }), ); @@ -161,6 +163,7 @@ describe("Portfolio page (pre-confirmation v0.55/v0.56)", () => { investigationReport: { understanding: "We understand complaints rose.", hasPlausibleInterpretations: false, + generatedFromRevision: 0, }, }), ); diff --git a/tests/v059b-report-freshness.test.jsx b/tests/v059b-report-freshness.test.jsx new file mode 100644 index 0000000..59a3a13 --- /dev/null +++ b/tests/v059b-report-freshness.test.jsx @@ -0,0 +1,354 @@ +/** + * 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; + }); +}); diff --git a/tests/v059c-portfolio-freshness.test.jsx b/tests/v059c-portfolio-freshness.test.jsx new file mode 100644 index 0000000..2164d7a --- /dev/null +++ b/tests/v059c-portfolio-freshness.test.jsx @@ -0,0 +1,172 @@ +/** + * v0.59c — targeted deterministic tests for Portfolio Report freshness UI. + * + * Exercises four cases on the Portfolio: + * 1. Report + matching revisions → "Current" + * 2. Report + differing revisions → "Update available" + * 3. differing revisions still render View report action + * 4. no Report → neither Current nor Update available + * 5. zero model/fetch calls during render + * 6. Portfolio does not mutate Investigation storage + * 7. existing Continue / Restart actions unchanged + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: () => {} }), +})); + +let mockClearStorage = vi.fn(); +let mockLoadResult = null; + +function setMockSnapshot(snap) { + if (snap) { + localStorage.setItem( + "confidence-engine-investigation", + JSON.stringify(snap), + ); + mockLoadResult = snap; + } else { + localStorage.removeItem("confidence-engine-investigation"); + mockLoadResult = null; + } +} + +vi.mock("@/lib/storage/investigation-storage", () => ({ + loadInvestigation: () => mockLoadResult, + saveInvestigation: vi.fn(), + clearInvestigation: () => { + localStorage.removeItem("confidence-engine-investigation"); + mockClearStorage(); + }, +})); + +/** Creates a snapshot with report. revisionMatch controls freshness state. */ +function makeFreshSnapshot(investigationRevision, revisionMatch) { + return { + scenario: "Test investigation", + situationGraph: { centralStatement: "CS", nodes: [], edges: [] }, + selectedQuestion: null, + summary: "Test summary.", + updatedAt: new Date().toISOString(), + schemaVersion: 1, + investigationReport: { + understanding: "We understand complaints rose.", + hasPlausibleInterpretations: false, + generatedFromRevision: revisionMatch ? investigationRevision : (investigationRevision - 2), + }, + investigationRevision, + findings: [], + }; +} + +/** Creates a snapshot with NO report. */ +function makeNoReportSnapshot(investigationRevision) { + return { + scenario: "Test investigation", + situationGraph: { centralStatement: "CS", nodes: [], edges: [] }, + selectedQuestion: null, + summary: "Test summary.", + updatedAt: new Date().toISOString(), + schemaVersion: 1, + investigationReport: null, + investigationRevision, + findings: [], + }; +} + +async function cleanup() { + localStorage.removeItem("confidence-engine-investigation"); +} + +describe("Portfolio freshness — Report + matching revisions", () => { + it("renders Current alongside View report", async () => { + setMockSnapshot(makeFreshSnapshot(3, true)); + const mod = await import("@/app/page.jsx"); + render(React.createElement(mod.default)); + expect(await screen.findByText(/View report/i)).toBeInTheDocument(); + expect(screen.getByText("Current")).toBeInTheDocument(); + await cleanup(); + }); + + it("makes zero fetch calls during render", async () => { + global.fetch = vi.fn(); + setMockSnapshot(makeFreshSnapshot(3, true)); + const mod = await import("@/app/page.jsx"); + render(React.createElement(mod.default)); + + try { + await waitFor(() => screen.findByText(/View report/i), { timeout: 5000 }); + } catch {} + + // Portfolio should not call fetch at all — derives state from localStorage only + expect(global.fetch).not.toHaveBeenCalled(); + global.fetch = undefined; + await cleanup(); + }); +}); + +describe("Portfolio freshness — Report + differing revisions", () => { + it("renders Update available alongside View report", async () => { + setMockSnapshot(makeFreshSnapshot(5, false)); + const mod = await import("@/app/page.jsx"); + render(React.createElement(mod.default)); + expect(await screen.findByText(/View report/i)).toBeInTheDocument(); + expect(screen.getByText("Update available")).toBeInTheDocument(); + await cleanup(); + }); + + it("View report still present as link for differing revisions", async () => { + setMockSnapshot(makeFreshSnapshot(5, false)); + const mod = await import("@/app/page.jsx"); + render(React.createElement(mod.default)); + const link = await screen.findByRole("link", { name: /View report/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/investigations/case-1/report"); + await cleanup(); + }); +}); + +describe("Portfolio freshness — no Report", () => { + it("renders neither Current nor Update available when no report exists", async () => { + setMockSnapshot(makeNoReportSnapshot(0)); + const mod = await import("@/app/page.jsx"); + render(React.createElement(mod.default)); + expect(screen.queryByText(/View report/i)).not.toBeInTheDocument(); + expect(screen.queryByText("Current")).not.toBeInTheDocument(); + expect(screen.queryByText("Update available")).not.toBeInTheDocument(); + await cleanup(); + }); +}); + +describe("Portfolio existing actions preserved", () => { + it("Continue investigation and Restart remain when a report exists", async () => { + setMockSnapshot(makeFreshSnapshot(3, true)); + const mod = await import("@/app/page.jsx"); + render(React.createElement(mod.default)); + expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument(); + expect(await screen.findByText(/Restart investigation/i)).toBeInTheDocument(); + await cleanup(); + }); +}); + +describe("Portfolio freshness — differing revisions no-additional-calls", () => { + it("refreshing with differing data does not trigger additional API calls", async () => { + global.fetch = vi.fn(); + setMockSnapshot(makeFreshSnapshot(5, false)); + const mod = await import("@/app/page.jsx"); + + render(React.createElement(mod.default)); + try { + await waitFor(() => screen.findByText(/Update available/i), { timeout: 2000 }); + } catch {} + + expect(global.fetch).not.toHaveBeenCalled(); + global.fetch = undefined; + await cleanup(); + }); +});