feat(confidence-engine): v0.60h migrate report route to use route [id] identity
This commit is contained in:
@@ -4,7 +4,8 @@ import React, { useEffect, useRef, useState } from "react";
|
|||||||
import { loadInvestigation, saveInvestigation } from "@/lib/storage/investigation-storage";
|
import { loadInvestigation, saveInvestigation } from "@/lib/storage/investigation-storage";
|
||||||
import Link from "next/link";
|
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 [existing, setExisting] = useState(null);
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
const [generationLoading, setGenerationLoading] = useState(false);
|
const [generationLoading, setGenerationLoading] = useState(false);
|
||||||
@@ -14,7 +15,7 @@ export default function ReportPage() {
|
|||||||
const generationAttempted = useRef(false);
|
const generationAttempted = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExisting(loadInvestigation());
|
setExisting(loadInvestigation(routeId));
|
||||||
setHydrated(true);
|
setHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ export default function ReportPage() {
|
|||||||
if (updateLoading) return;
|
if (updateLoading) return;
|
||||||
setUpdateLoading(true);
|
setUpdateLoading(true);
|
||||||
|
|
||||||
const snap = loadInvestigation();
|
const snap = loadInvestigation(routeId);
|
||||||
const situationGraph = snap?.situationGraph;
|
const situationGraph = snap?.situationGraph;
|
||||||
const findings = snap?.findings ?? [];
|
const findings = snap?.findings ?? [];
|
||||||
const rev = snap?.investigationRevision ?? 0;
|
const rev = snap?.investigationRevision ?? 0;
|
||||||
@@ -213,7 +214,7 @@ export default function ReportPage() {
|
|||||||
{/* Back to investigation */}
|
{/* Back to investigation */}
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<Link
|
<Link
|
||||||
href="/investigations/case-1"
|
href={`/investigations/${routeId}`}
|
||||||
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
|
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
|
||||||
>
|
>
|
||||||
Back to investigation
|
Back to investigation
|
||||||
|
|||||||
@@ -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: 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ Object.defineProperty(global, "crypto", {
|
|||||||
|
|
||||||
let mockClearStorage = vi.fn();
|
let mockClearStorage = vi.fn();
|
||||||
let mockLoadResult = null;
|
let mockLoadResult = null;
|
||||||
|
let mockLastLoadedId = undefined;
|
||||||
|
let mockLastSavedSnapshot = null;
|
||||||
|
|
||||||
function setMockSnapshot(snap) {
|
function setMockSnapshot(snap) {
|
||||||
if (snap) {
|
if (snap) {
|
||||||
@@ -43,8 +45,14 @@ function setMockSnapshot(snap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("@/lib/storage/investigation-storage", () => ({
|
vi.mock("@/lib/storage/investigation-storage", () => ({
|
||||||
loadInvestigation: () => mockLoadResult,
|
listInvestigations: () => [],
|
||||||
saveInvestigation: () => {},
|
loadInvestigation: (id) => {
|
||||||
|
mockLastLoadedId = id;
|
||||||
|
return mockLoadResult;
|
||||||
|
},
|
||||||
|
saveInvestigation: (snapshot) => {
|
||||||
|
mockLastSavedSnapshot = snapshot;
|
||||||
|
},
|
||||||
clearInvestigation: () => {
|
clearInvestigation: () => {
|
||||||
localStorage.removeItem("confidence-engine-investigation");
|
localStorage.removeItem("confidence-engine-investigation");
|
||||||
mockClearStorage();
|
mockClearStorage();
|
||||||
@@ -483,3 +491,139 @@ describe("Report page lifecycle — v0.58", () => {
|
|||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user