diff --git a/app/investigations/[id]/report/page.jsx b/app/investigations/[id]/report/page.jsx index e2e4aee..ca55865 100644 --- a/app/investigations/[id]/report/page.jsx +++ b/app/investigations/[id]/report/page.jsx @@ -4,7 +4,8 @@ import React, { useEffect, useRef, useState } from "react"; import { loadInvestigation, saveInvestigation } from "@/lib/storage/investigation-storage"; import Link from "next/link"; -export default function ReportPage() { +export default function ReportPage({ params }) { + const routeId = (typeof params === "object" && params?.id != null) ? String(params.id) : ""; const [existing, setExisting] = useState(null); const [hydrated, setHydrated] = useState(false); const [generationLoading, setGenerationLoading] = useState(false); @@ -14,7 +15,7 @@ export default function ReportPage() { const generationAttempted = useRef(false); useEffect(() => { - setExisting(loadInvestigation()); + setExisting(loadInvestigation(routeId)); setHydrated(true); }, []); @@ -72,7 +73,7 @@ export default function ReportPage() { if (updateLoading) return; setUpdateLoading(true); - const snap = loadInvestigation(); + const snap = loadInvestigation(routeId); const situationGraph = snap?.situationGraph; const findings = snap?.findings ?? []; const rev = snap?.investigationRevision ?? 0; @@ -213,7 +214,7 @@ export default function ReportPage() { {/* Back to investigation */}
Back to investigation diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 37e13f5..3fa8c4e 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -549,6 +549,54 @@ This defect will be addressed in a separate increment (v0.60h). It is NOT caused v0.60h: Migrate Report route to use route `[id]` for all identity operations (initial load, generation, update). Preserve existing v0.58/v0.59 Report lifecycle and freshness semantics. This is an identity migration only — no Report redesign. +## v0.60h — Report Route Identity Migration + +**Purpose:** Fix the Report route's two unscoped `loadInvestigation()` calls so it reads by route `[id]` instead of the legacy singleton path. Preserve all existing v0.58/v0.59 Report lifecycle and freshness semantics. + +### What was implemented + +| File | Change | +|---|---| +| `app/investigations/[id]/report/page.jsx` | Extract `routeId` from `params.id`; both `loadInvestigation()` calls scoped to `routeId`; "Back to investigation" link uses dynamic `/investigations/${routeId}`; save spreads snapshot carrying `id` (identity preserved by existing v0.60d contract) | +| `tests/ui/investigation-overview-ui.test.jsx` | Enhanced storage mock to capture `loadInvestigation(id)` argument and `saveInvestigation(snapshot)` argument; added `listInvestigations: () => []` mock (pre-existing apparatus gap); 5 new identity assertions in "Report route identity — v0.60h" describe block | + +### Deterministic evidence + +- **Owning test file:** `tests/ui/investigation-overview-ui.test.jsx` +- **Identity assertions (new):** + - inv-a identified load + first generation: **PASS** (load called with "inv-a", fetch POST count = 1, saved snapshot retains id="inv-a", generatedFromRevision = investigationRevision) + - existing report for inv-b renders without generation: **PASS** (load called with "inv-b", zero overview calls) + - manual update reloads by same id and saves back: **PASS** (load called with "inv-c" after update click, save retains "inv-c", generatedFromRevision updated) + - existing report hydration retains zero-call behaviour: **PASS** (no overview call when revisions match) + - generation failure preserves existing Report: **PASS** (no partial persist on failure) +- **Existing Report lifecycle tests (unchanged):** All 10 PASS — no regression from identity migration +- **Pre-existing apparatus gaps:** 15 failures in Portfolio/restart sections due to `listInvestigations` mock gap (unrelated to v0.60h; pre-existing from v0.60g2) + +### Identity mechanism + +- Route params prop (`params.id`) → `routeId` (string or empty fallback) +- `loadInvestigation(routeId)` — initial hydration + manual update +- `saveInvestigation({ ...snapshot, investigationReport })` — identity preserved by snapshot spread; storage layer uses `snapshot.id` per v0.60d contract +- "Back to investigation" link → `/investigations/${routeId}` (dynamic) + +### Product files changed + +| File | Scope | +|---|---| +| `app/investigations/[id]/report/page.jsx` | Route identity migration only (3 load/save scopes + 1 link update) | +| `tests/ui/investigation-overview-ui.test.jsx` | Mock enhancement (load/save argument capture, listInvestigations stub) + 5 identity assertions | + +### NOT changed + +- Report generation logic +- Overview API semantics +- Empty-Done semantics +- Findings workflow +- Storage contract (`investigation-storage.js`, `local-storage.js`) +- Portfolio/ScenarioForm/SituationGraph + +--- + ## Next restart point Consult `docs/design-evolution/README.md` for progressive loading of product reasoning and provenance chronology; load the relevant chapter only when a specific historical question requires it. diff --git a/tests/ui/investigation-overview-ui.test.jsx b/tests/ui/investigation-overview-ui.test.jsx index 5352628..53963f2 100644 --- a/tests/ui/investigation-overview-ui.test.jsx +++ b/tests/ui/investigation-overview-ui.test.jsx @@ -28,6 +28,8 @@ Object.defineProperty(global, "crypto", { let mockClearStorage = vi.fn(); let mockLoadResult = null; +let mockLastLoadedId = undefined; +let mockLastSavedSnapshot = null; function setMockSnapshot(snap) { if (snap) { @@ -43,8 +45,14 @@ function setMockSnapshot(snap) { } vi.mock("@/lib/storage/investigation-storage", () => ({ - loadInvestigation: () => mockLoadResult, - saveInvestigation: () => {}, + listInvestigations: () => [], + loadInvestigation: (id) => { + mockLastLoadedId = id; + return mockLoadResult; + }, + saveInvestigation: (snapshot) => { + mockLastSavedSnapshot = snapshot; + }, clearInvestigation: () => { localStorage.removeItem("confidence-engine-investigation"); mockClearStorage(); @@ -483,3 +491,139 @@ describe("Report page lifecycle — v0.58", () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); }); + +/* ── v0.60h — Report route identity assertions ─────────────────────────── */ + +describe("Report route identity — v0.60h", () => { + let ReportPage; + + beforeEach(async () => { + mockLastLoadedId = undefined; + mockLastSavedSnapshot = null; + setMockSnapshot(makeSnapshot()); + global.fetch = vi.fn(); + const mod = await import("@/app/investigations/[id]/report/page.jsx"); + ReportPage = mod.default; + }); + + afterEach(async () => { + cleanup(); + }); + + it("inv-a identified load + first generation", async () => { + setMockSnapshot(makeSnapshot({ + id: "inv-a", + investigationReport: null, + investigationRevision: 3, + findings: [], + })); + + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, understanding: "Summary.", plausibleInterpretations: "None." }), + }); + + render(React.createElement(ReportPage, { params: { id: "inv-a" } })); + await screen.findByText(/Summary\./i); + + // A — identified load + expect(mockLastLoadedId).toBe("inv-a"); + + // C — first generation reaches overview exactly once + const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview"); + expect(calls).toHaveLength(1); + + // C — saved snapshot retains durable id + expect(mockLastSavedSnapshot.id).toBe("inv-a"); + + // C — generatedFromRevision correct + expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(3); + }); + + it("existing report for inv-b renders without generation", async () => { + setMockSnapshot(makeSnapshot({ + id: "inv-b", + investigationReport: { understanding: "Already generated.", hasPlausibleInterpretations: false, generatedFromRevision: 5 }, + investigationRevision: 5, + })); + + render(React.createElement(ReportPage, { params: { id: "inv-b" } })); + await screen.findByText(/Already generated\./i); + + // A — identified load even for existing report + expect(mockLastLoadedId).toBe("inv-b"); + + // E — no overview call + const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview"); + expect(calls).toHaveLength(0); + }); + + it("manual update reloads by same id and saves back", async () => { + setMockSnapshot(makeSnapshot({ + id: "inv-c", + investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 2 }, + investigationRevision: 4, // revision mismatch → "Update available" + findings: [{ id: "f-1", proposition: "P1" }], + })); + + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, understanding: "R2.", plausibleInterpretations: "None." }), + }); + + render(React.createElement(ReportPage, { params: { id: "inv-c" } })); + await screen.findByText(/Update available/i); + + // Find and click Update report button + const updateBtn = screen.getByRole("button", { name: /Update report/i }); + fireEvent.click(updateBtn); + await screen.findByText(/R2\./i); + + // D — manual update uses same durable id + expect(mockLastLoadedId).toBe("inv-c"); + + // D — save back to same identified Investigation + expect(mockLastSavedSnapshot.id).toBe("inv-c"); + expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4); + + // D — exactly one overview POST for the update + const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview"); + expect(calls).toHaveLength(1); + }); + + it("existing report hydration retains zero-call behaviour", async () => { + setMockSnapshot(makeSnapshot({ + id: "inv-d", + investigationReport: { understanding: "R.", hasPlausibleInterpretations: false, generatedFromRevision: 3 }, + investigationRevision: 3, // matching → Current + })); + + render(React.createElement(ReportPage, { params: { id: "inv-d" } })); + await screen.findByText(/Current/i); + + // E — no singleton/unscoped load happens during Report lifecycle + expect(mockLastLoadedId).toBe("inv-d"); + + // E — no overview call when report exists and revisions match + const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview"); + expect(calls).toHaveLength(0); + }); + + it("generation failure preserves existing Report", async () => { + setMockSnapshot(makeSnapshot({ + id: "inv-e", + investigationReport: null, + situationGraph: { evidence: [], reconstruction: {} }, + investigationRevision: 1, + findings: [], + })); + + global.fetch.mockResolvedValue({ ok: false }); + + render(React.createElement(ReportPage, { params: { id: "inv-e" } })); + await screen.findByText(/Report generation failed/i); + + // F — failure semantics: no partial Report persisted + expect(mockLastSavedSnapshot).toBeNull(); + }); +});