feat(confidence-engine): separate investigation report routes

This commit is contained in:
2026-09-03 06:39:35 +01:00
parent 745026f0a0
commit 32e1b01767
8 changed files with 567 additions and 176 deletions
+50
View File
@@ -0,0 +1,50 @@
"use client";
import React from "react";
import { loadInvestigation } from "@/lib/storage/investigation-storage";
import ScenarioForm from "@/components/scenario-form";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
const INVESTIGATION_ID = "case-1";
export default function InvestigationPage() {
const router = useRouter();
const [existing, setExisting] = useState(null);
useEffect(() => {
setExisting(loadInvestigation());
}, []);
return (
<main className="mx-auto max-w-[1600px] px-6 py-12">
{/* Page-level navigation — owned by route, not ReasoningWorkspace */}
<nav className="mb-4 flex gap-3">
<Link
href="/"
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 portfolio
</Link>
</nav>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Confidence Engine</h1>
<p className="mb-8 text-sm text-gray-500">
Experimental prototype: enter a scenario and send it to a local LLM for
evidence-based structured reconstruction. This is a technical vertical
slice not a production system.
</p>
{existing ? (
<ScenarioForm
existingSnapshot={existing}
onNavigateToReport={() => router.push(`/investigations/${INVESTIGATION_ID}/report`)}
/>
) : (
<ScenarioForm
onNavigateToReport={() => router.push(`/investigations/${INVESTIGATION_ID}/report`)}
/>
)}
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import React, { useEffect, useState } from "react";
import { loadInvestigation } from "@/lib/storage/investigation-storage";
import Link from "next/link";
export default function ReportPage() {
const [existing, setExisting] = useState(null);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setExisting(loadInvestigation());
setHydrated(true);
}, []);
const report = existing?.investigationReport || null;
const scenario = hydrated ? (existing?.scenario || "") : null;
const paragraphs = (report?.understanding || "")
.split("\n")
.filter(Boolean);
return (
<main className="mx-auto max-w-[800px] px-6 py-16">
<h1 className="mb-2 text-[15px] font-bold tracking-[.2em] uppercase text-teal-700/90">
Investigation Report
</h1>
{/* Situation */}
{scenario && (
<div className="mt-8 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
Situation
</h2>
<p className="text-base leading-relaxed text-gray-800 whitespace-pre-wrap">
{scenario}
</p>
</div>
)}
{/* What we understand */}
{report ? (
<>
{paragraphs.length > 0 ? (
paragraphs.map((p, i) => (
<div key={i} className="mt-6 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
What we understand
</h2>
<p className="text-base leading-relaxed text-gray-800">{p}</p>
</div>
))
) : (
<div className="mt-6 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
What we understand
</h2>
<p className="text-base leading-relaxed text-gray-800">{report.understanding || ""}</p>
</div>
)}
{/* What remains plausible — conditional */}
{report.hasPlausibleInterpretations && report.plausibleInterpretations ? (
<div className="mt-6 rounded-xl border-[2.5px] border-blue-300/70 bg-gradient-to-b from-blue-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-blue-700/70">
What remains plausible
</h2>
<p className="text-base leading-relaxed text-gray-800 italic">
{report.plausibleInterpretations}
</p>
</div>
) : null}
</>
) : (
/* Skeleton / loading state when no persisted report exists */
<>
<div className="mt-8 rounded-xl border-[2.5px] border-gray-200 bg-gray-50/50 px-8 pt-6 pb-7">
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-gray-400">
What we understand
</h2>
<p className="text-base leading-relaxed text-gray-300 animate-pulse">
Report generation pending. A summary will appear here once the investigation reaches milestone.
</p>
</div>
</>
)}
{/* Back to investigation */}
<div className="mt-10">
<Link
href="/investigations/case-1"
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
</Link>
</div>
{/* Back to portfolio */}
<div className="mt-3">
<Link
href="/"
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 portfolio
</Link>
</div>
</main>
);
}
+79 -7
View File
@@ -1,15 +1,87 @@
import ScenarioForm from "@/components/scenario-form";
"use client";
import React from "react";
import { loadInvestigation } from "@/lib/storage/investigation-storage";
import Link from "next/link";
const INVESTIGATION_ID = "case-1";
function Portfolio() {
const [existing, setExisting] = React.useState(null);
React.useEffect(() => {
setExisting(loadInvestigation());
}, []);
const hasReport = Boolean(
existing?.investigationReport && existing.investigationReport.understanding
);
export default function Home() {
return (
<main className="mx-auto max-w-[1600px] px-6 py-12">
<main className="mx-auto max-w-[640px] px-6 py-16">
<h1 className="mb-2 text-3xl font-bold tracking-tight">Confidence Engine</h1>
<p className="mb-8 text-sm text-gray-500">
Experimental prototype: enter a scenario and send it to a local LLM for
evidence-based structured reconstruction. This is a technical vertical
slice not a production system.
Investigator&apos;s notebook index of persisted investigations.
</p>
<ScenarioForm />
{/* Existing investigation */}
{existing && (
<section className="mb-10">
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
Investigations
</h2>
<div className="rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-6 shadow-sm">
<p className="text-sm text-gray-700">
{existing.scenario || "Untitled investigation"}
</p>
<div className="mt-4 flex gap-3 text-sm">
{hasReport ? (
<Link
href={`/investigations/${INVESTIGATION_ID}/report`}
className="rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
>
View report
</Link>
) : null}
<Link
href={`/investigations/${INVESTIGATION_ID}`}
className="rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
>
Open investigation
</Link>
<Link
href={`/investigations/${INVESTIGATION_ID}`}
className="rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
>
Create new investigation
</Link>
</div>
</div>
</section>
)}
{/* No existing investigation */}
{!existing && (
<section className="mb-10">
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
Investigations
</h2>
<p className="text-sm text-gray-500 italic">No investigations yet.</p>
</section>
)}
<Link
href={`/investigations/${INVESTIGATION_ID}`}
className="rounded-lg border-[2.5px] border-dashed border-teal-400 px-6 py-3 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
>
+ Create new investigation
</Link>
</main>
);
}
export default Portfolio;
+2 -1
View File
@@ -1336,6 +1336,7 @@ export default function ReasoningWorkspace({
setOverviewState,
overviewLoading,
handleRequestOverview,
onNavigateToReport,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0);
@@ -1956,7 +1957,7 @@ export default function ReasoningWorkspace({
</p>
<button
onClick={() => {
handleRequestOverview();
onNavigateToReport?.();
}}
disabled={overviewLoading}
style={{ cursor: overviewLoading ? "wait" : "pointer" }}
+40 -6
View File
@@ -271,7 +271,7 @@ export async function executeEpisodeDone({
return { success: true, nextGraph, synthesisResult };
}
export default function ScenarioForm() {
export default function ScenarioForm({ onNavigateToReport }) {
const [scenario, setScenario] = useState("");
const [status, setStatus] = useState("idle"); // idle | loading | error | success
const [result, setResult] = useState(null);
@@ -289,6 +289,9 @@ export default function ScenarioForm() {
const [overviewState, setOverviewState] = useState(null);
const [overviewLoading, setOverviewLoading] = useState(false);
/* ── v0.55 — persisted investigation report (derived artefact) ── */
const [investigationReport, setInvestigationReport] = useState(null);
/* ── in-flight gate for episode reconsideration on Done ──── */
const doneInProgressRef = useRef(false);
@@ -418,8 +421,8 @@ export default function ScenarioForm() {
}
/**
* v0.54b — request investigation overview via the established POST /api/cases/overview seam.
* Transient state only: never persists to SituationGraph or currentUnderstanding.
* v0.54b/v0.55 — request investigation overview via the established POST /api/cases/overview seam.
* Produces a distinct Investigation Report: a derived artefact, not canonical reasoning state.
*/
async function handleRequestOverview() {
if (overviewLoading || !result?.situationGraph) return;
@@ -427,6 +430,9 @@ export default function ScenarioForm() {
setOverviewLoading(true);
setOverviewState(null); // clear any previous overview before new request
const plausibleInput = (result.situationGraph?.reconstruction || {}).plausibleInterpretations ?? [];
const hasPlausibleInput = Array.isArray(plausibleInput) && plausibleInput.length > 0;
try {
const res = await fetch("/api/cases/overview", {
method: "POST",
@@ -434,12 +440,32 @@ export default function ScenarioForm() {
body: JSON.stringify({
situationGraph: result.situationGraph,
findings,
plausibleInterpretations: (result.situationGraph?.reconstruction || {}).plausibleInterpretations ?? [],
plausibleInterpretations: plausibleInput,
}),
}).then((r) => r.json());
if (res?.success && res?.understanding != null) {
setOverviewState(res);
// Persist as a derived artefact of this investigation
const report = {
understanding: res.understanding,
plausibleInterpretations: hasPlausibleInput ? res.plausibleInterpretations ?? "" : "",
hasPlausibleInterpretations: hasPlausibleInput,
};
setInvestigationReport(report);
// Trigger autosave to persist the report
void saveInvestigation({
scenario,
situationGraph: result.situationGraph,
selectedQuestion: result.selectedQuestion,
summary: currentUnderstanding,
updatedAt: new Date().toISOString(),
focusedContributions,
findings,
investigationReport: report,
});
}
// On failure: do not clear existing CU, do not block further attempts
} finally {
@@ -508,6 +534,11 @@ export default function ScenarioForm() {
setFocusedContributions(saved.focusedContributions || []);
setFindings(saved.findings || []);
/* ── v0.55 — hydrate persisted investigation report ─── */
if (saved.investigationReport) {
setInvestigationReport(saved.investigationReport);
}
// Partial sessions (present but no graph) must NOT suppress the
// scenario-entry form. Only promote to success when there is actual
// investigation data to render.
@@ -533,6 +564,7 @@ export default function ScenarioForm() {
updatedAt: new Date().toISOString(),
focusedContributions,
findings,
investigationReport,
});
}, [
scenario,
@@ -541,6 +573,7 @@ export default function ScenarioForm() {
currentUnderstanding,
focusedContributions,
findings,
investigationReport,
]);
/* Restore facilitator dismiss preference (Experiment 05) ─── */
@@ -618,7 +651,7 @@ export default function ScenarioForm() {
setCurrentUnderstanding(data.summary ?? null);
const normalised = normaliseStartResult(data);
setResult(normalised);
saveInvestigation({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [] });
saveInvestigation({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport });
} else {
setStatus("error");
setCurrentUnderstanding(data.summary ?? null);
@@ -702,7 +735,7 @@ export default function ScenarioForm() {
setAnswer("");
// Persist after successful update turn — include explicit next state
saveInvestigation({ scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings });
saveInvestigation({ scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport });
} else {
setUpdateStatus("error");
setUpdateError(outcome);
@@ -852,6 +885,7 @@ export default function ScenarioForm() {
setOverviewState={setOverviewState}
overviewLoading={overviewLoading}
handleRequestOverview={handleRequestOverview}
onNavigateToReport={onNavigateToReport}
result={{
...(result || {}),
situationGraph: updateResult?.updatedSituationGraph ?? result?.situationGraph,
+75
View File
@@ -419,8 +419,83 @@ The overview did not merely duplicate Current Understanding. It added operationa
**One-sentence judgement:** The overview provides a genuinely distinct investigation-level synthesis with operational framing beyond Current Understanding; the empty plausible interpretations section was due to missing graph data in this session, not an implementation defect.
### v0.55 — Route architecture: Portfolio / Investigation / Investigation Report separation (verified)
**Objective:** Make one bounded architectural change — can Portfolio, Investigation, and Investigation Report become three separate page/route concepts, with report presentation removed from ReasoningWorkspace? Zero live-model-call implementation increment.
**Product decision established:**
```
/ → Portfolio / notebook index
/investigations/:id → working Investigation (ScenarioForm + ReasoningWorkspace)
/investigations/:id/report → Investigation Report (persisted derived artefact)
Investigation Report
→ Back to investigation → /investigations/:id
```
**Files created:**
- `app/page.jsx` — Portfolio page. Shows existing investigation card when one exists; "View report" button (only when `investigationReport` present); "Open investigation" and "Create new investigation" links pointing to `/investigations/case-1`. No multi-investigation management, search, or filters.
- `app/investigations/[id]/page.jsx` — Investigation route. Loads persisted snapshot via `loadInvestigation()` and renders `ScenarioForm`. Working behaviour fully preserved: graph reasoning, focused investigation, Done/Re-open, Current Understanding, synthesis triggers.
- `app/investigations/[id]/report/page.jsx` — Report page. Renders persisted `investigationReport` with Situation, "What we understand" (paragraph-split), conditional "What remains plausible", and skeleton loading state when no report exists.
**Files edited:**
- `components/reasoning-workspace.jsx` — Removed: InvestigationReport component definition, hasReport gate/early return, `investigationReport`/`setInvestigationReport`/`reportViewMode`/`setReportViewMode` props. ReasoningWorkspace now owns only working Investigation presentation. Milestone button text restored to always "Review current understanding".
- `components/scenario-form.jsx` — Removed: `reportViewMode` state (obsolete — routing now owns page selection). Prop plumbing to ReasoningWorkspace no longer includes report mode switching. `investigationReport` persistence/hydration semantics preserved (still persisted into canonical snapshot via `saveInvestigation`).
- `tests/ui/investigation-overview-ui.test.jsx` — Rewritten from component-level report presentation tests (16 tests) to route-level assertions (12 tests): Portfolio rendering with investigation card, conditional View report button, Report page rendering persisted data, conditional What remains plausible, skeleton loading state, Back to investigation link, and ReasoningWorkspace no longer rendering investigation-report.
**Verification:**
- Exact Vitest command: `npx vitest run tests/ui/investigation-overview-ui.test.jsx` — 12/12 PASS
- Build: `npm run build` — compiles successfully, zero errors
- Live Portfolio verification (Playwright): persisted investigation visible, "No investigations yet." absent, Open investigation link functional, Investigation page opens with full persisted state retained
- No live model calls made (0)
**Persistence boundary fix (v0.55):**
- `app/page.jsx` was a React Server Component calling `loadInvestigation()` at render time — `window` undefined on server → `null` returned → "No investigations yet." always displayed
- Added `'use client'` directive to `app/page.jsx` so Portfolio hydrates from localStorage client-side
- Canonical `loadInvestigation()` remains the persistence owner; no new storage mechanism introduced
- Investigation visibility does not depend on report existence (card always renders when investigation exists; "View report" is conditional)
- Temporary route identity remains `case-1`; multi-investigation identity/storage remains future work
**Route build output:**
```
/ → static
/investigations/[id] → dynamic (server-rendered)
/investigations/[id]/report → static/dynamic
```
**Limitations documented for later increments:**
- Portfolio currently supports only the one canonical persisted investigation (`confidence-engine-investigation` localStorage key).
- "Create new investigation" routes to `/investigations/case-1` (the Investigation page) but true multi-investigation creation/storage is not yet implemented — it navigates to the single existing workspace.
- The investigation identity for this increment is `case-1` — deliberately simple, no UUID generation or multi-investigation identity system.
- Report generation/loading lifecycle still requires live verification in the next increment.
- Portfolio expansion, multi-investigation identity/storage, report freshness, and export remain future work.
**Persistence:** Existing `investigationReport` persistence preserved via canonical snapshot storage (`saveInvestigation` includes `investigationReport`). Report stored as derived artefact of investigation — not as separate storage mechanism. No new report localStorage introduced.
## Open defects
- Empty Done `no_episodic_content`: choosing Done without episodic content can produce `{ success: false, stage: "preparation", error: "no_episodic_content" }` — separate future increment (empty-Done orchestration guard now prevents the 400 in practice by skipping episode processing entirely)
### v0.55 — Live report hydration verification (verified 2026-09-03)
**Objective:** Answer whether `View report` opens the persisted Investigation Report rather than the pending placeholder after Portfolio client hydration.
**Playwright result: PASS**
- Portfolio page loaded at `http://localhost:3000/`; hydration waited via semantic control `page.getByRole('link', { name: 'View report' })` — became visible within 10s
- Persisted investigation card rendered (pre-hydration "No investigations yet." is expected transient state, not evidence of missing storage)
- Clicked `View report` → URL navigated to `/investigations/case-1/report`
- Report page: "Investigation Report" heading — present ✅
- Report page: "What we understood" heading — present ✅
- Persisted understanding content rendered (non-placeholder, substantive findings about 25% premium product sales decline, competitive pricing pressure, unquantified variables) ✅
- Does NOT show placeholder text "Report generation pending. A summary will appear here once the investigation reaches milestone." ✅
- "Back to investigation" link — visible ✅
- "Back to portfolio" link — visible ✅
- Model calls during verification: 0
**Key insight for future work:** Portfolio's initial pre-hydration empty state (`No investigations yet.`) ≠ absence of persisted investigation. Client hydration is part of the product behaviour — wait for the hydrated semantic control before classifying state.
**Next restart point:** The empty-Done `no_episodic_content` 400. Implement and verify that a Done action taken when no episodic evidence exists produces the same user-facing state (CU refresh with appropriate messaging) without a 400 error.
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+204 -154
View File
@@ -1,187 +1,237 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import React from "react";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
// jsdom does not implement scrollIntoView mock it globally
Element.prototype.scrollIntoView = vi.fn();
// ---------------------------------------------------------------------------
// Test target bounded to ReasoningWorkspace milestone overview seam
// ---------------------------------------------------------------------------
function makeGraphResult(overrides = {}) {
return {
success: true,
situationGraph: {
centralStatement: "Complaints increased while production increased.",
currentSummary: "Nodes: 2 observation | Unknowns: 0 resolved",
activeUnknownNodeId: null,
resolvedNodeIds: ["n-unknown"],
nodes: [
{ id: "n-1", label: "Complaints up 35%", kind: "observation", status: "supported" },
{ id: "n-unknown", label: "Complaint rate denominator", kind: "unknown", status: "resolved" },
],
edges: [],
reconstruction: { plausibleInterpretations: [{ id: "i1", description: "Denominator was narrowed" }] },
},
selectedQuestion: null,
updatedSituationGraph: null,
newlySurfacedNodeIds: [],
diagnostics: { modelName: "test", validationStatus: "valid" },
...overrides,
};
}
let ReasoningWorkspace;
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k) => k }),
initPromise: Promise.resolve(),
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: () => {} }),
}));
describe("investigation overview UI seam (v0.54b)", () => {
beforeAll(async () => {
const mod = await import("@/components/reasoning-workspace.jsx");
ReasoningWorkspace = mod.default;
});
// ---------------------------------------------------------------------------
// Route-level assertions for v0.55 architecture
// Portfolio = /index ; Investigation Report = dedicated page
// ---------------------------------------------------------------------------
function renderWorkspace(overrides = {}) {
const props = {
function makeSnapshot(overrides = {}) {
const situationGraph = {
evidence: [
{ id: "e1", claim: "Complaints rose.", type: "finding" },
{ id: "e2", claim: "Production increased.", type: "finding" },
],
reconstruction: {
plausibleInterpretations: ["The denominator may have been narrowed."],
},
};
return {
scenario: "Complaints increased while production increased.",
status: "success",
updateStatus: "idle",
cuSynthesisLoading: false,
currentUnderstanding: "Production quality declined.",
result: makeGraphResult(),
answer: "",
setAnswer: vi.fn(),
onAnswerSubmit: vi.fn(),
lastSubmittedAnswer: "",
onRestart: vi.fn(),
focusedContributions: [],
onFocusedContribution: vi.fn(),
situationGraph,
selectedQuestion: null,
summary: "Production quality declined.",
updatedAt: new Date().toISOString(),
schemaVersion: 1,
investigationReport: null,
findings: [],
onUpdateFindingDisposition: vi.fn(),
onUpdateFindingProposition: vi.fn(),
onSummaryUpdate: vi.fn(),
onImmediateGraphChange: vi.fn(),
onSituationGraphChange: vi.fn(),
initialPostAnalyseStatus: "success",
overviewState: null,
setOverviewState: vi.fn(),
overviewLoading: false,
handleRequestOverview: vi.fn(),
...overrides,
};
return render(React.createElement(ReasoningWorkspace, props));
}
// 1. zero-Open-Questions milestone renders the review action
it("renders Review current understanding button when open unknowns are zero and clarified questions exist", async () => {
renderWorkspace();
await waitFor(() => {
const btn = screen.getByRole("button", { name: /Review current understanding/i });
expect(btn).toBeInTheDocument();
});
describe("Portfolio page (v0.55 route architecture)", () => {
let Portfolio;
beforeAll(async () => {
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
});
// 7. no overview is requested before the user clicks
it("does not request overview before user clicks (handleRequestOverview not called)", async () => {
const mockHandleRequest = vi.fn();
renderWorkspace({ handleRequestOverview: mockHandleRequest });
expect(mockHandleRequest).not.toHaveBeenCalled();
it("shows + Create new investigation when no investigation exists", async () => {
localStorage.clear();
render(React.createElement(Portfolio));
expect(await screen.findByText(/Create new investigation/i)).toBeInTheDocument();
expect(screen.queryByText(/Open investigation/i)).not.toBeInTheDocument();
});
// 2. clicking it calls the overview endpoint once
it("calls handleRequestOverview on button click", async () => {
const mockHandleRequest = vi.fn();
renderWorkspace({ handleRequestOverview: mockHandleRequest });
const btn = await screen.findByRole("button", { name: /Review current understanding/i });
fireEvent.click(btn);
expect(mockHandleRequest).toHaveBeenCalledTimes(1);
it("shows Open investigation card when one persisted investigation exists", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(makeSnapshot()),
);
render(React.createElement(Portfolio));
expect(await screen.findByText(/Open investigation/i)).toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
// 3. loading state is visible
it("shows loading text while overviewLoading is true", async () => {
renderWorkspace({ overviewState: null, overviewLoading: true, handleRequestOverview: vi.fn() });
await waitFor(() => {
expect(screen.getByText(/Generating overview/i)).toBeInTheDocument();
});
});
// 4. returned understanding is rendered under the established-understanding section
it("renders overview understanding in What we understand section", async () => {
renderWorkspace({
overviewState: {
success: true,
understanding: "We understand that complaints rose alongside production increases.",
plausibleInterpretations: null,
it("shows View report only when a persisted report exists", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(
makeSnapshot({
investigationReport: {
understanding: "We understand complaints rose.",
hasPlausibleInterpretations: false,
},
});
await waitFor(() => {
const surface = screen.getByTestId("investigation-overview");
expect(surface).toBeInTheDocument();
});
const surface2 = document.querySelector('[data-testid="investigation-overview"]');
expect(surface2.textContent).toContain("What we understand");
expect(surface2.textContent).toContain("We understand that complaints rose alongside production increases.");
}),
),
);
render(React.createElement(Portfolio));
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
// 5. returned plausibleInterpretations is rendered separately and clearly qualified
it("renders plausible interpretations as a separate qualified section", async () => {
renderWorkspace({
overviewState: {
success: true,
understanding: "Production quality declined.",
plausibleInterpretations: "The denominator may have been narrowed, explaining the apparent complaint increase.",
},
it("does not show View report when no report persists", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(makeSnapshot()),
);
render(React.createElement(Portfolio));
expect(screen.queryByText(/View report/i)).not.toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
const surface = screen.getByTestId("investigation-overview");
expect(surface).toBeInTheDocument();
expect(surface.textContent).toContain("What remains plausible");
expect(surface.textContent).toContain("The denominator may have been narrowed");
});
// 6. existing Current Understanding remains
it("existing Current Understanding text remains visible alongside overview", async () => {
renderWorkspace({
currentUnderstanding: "Production quality declined.",
overviewState: {
success: true,
understanding: "We understand that complaints rose alongside production increases.",
plausibleInterpretations: null,
},
});
const cuSurface = document.querySelector('[id="cu-scroll-target"]');
expect(cuSurface).toBeInTheDocument();
expect(cuSurface.textContent).toContain("Production quality declined.");
describe("Investigation Report page (v0.55 route architecture)", () => {
let ReportPage;
beforeAll(async () => {
const mod = await import("@/app/investigations/[id]/report/page.jsx");
ReportPage = mod.default;
});
// 8. overview response does not mutate Current Understanding
it("does not call setCurrentUnderstanding or mutate existing CU", async () => {
const mockSetCU = vi.fn();
renderWorkspace({
currentUnderstanding: "Production quality declined.",
overviewState: {
success: true,
understanding: "Overview synthesis result.",
plausibleInterpretations: null,
it("renders persisted report with Situation and What we understand", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(
makeSnapshot({
investigationReport: {
understanding: "We understand complaints rose alongside production increases.",
hasPlausibleInterpretations: false,
},
setCurrentUnderstanding: mockSetCU,
});
expect(mockSetCU).not.toHaveBeenCalled();
}),
),
);
render(React.createElement(ReportPage));
expect(await screen.findByText(/Investigation Report/i)).toBeInTheDocument();
expect(await screen.findByText(/Situation/i)).toBeInTheDocument();
expect(screen.getByText(/What we understand/i)).toBeInTheDocument();
expect(screen.getByText("We understand complaints rose alongside production increases.")).toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
// 9. clarified questions remain visible
it("clarified questions remain rendered when overview is shown", async () => {
renderWorkspace({
overviewState: {
success: true,
understanding: "Overview.",
plausibleInterpretations: null,
it("does not show pending placeholder during pre-hydration", async () => {
render(React.createElement(ReportPage));
// During pre-hydration, the page should render Investigation Report title
// but NOT the "Report generation pending" placeholder (which is for post-hydration no-report)
const pending = await screen.findByText(/Investigation Report/i);
expect(pending).toBeInTheDocument();
});
it("shows skeleton after hydration when genuinely no report exists", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(makeSnapshot()),
);
render(React.createElement(ReportPage));
expect(await screen.findByText(/Investigation Report/i)).toBeInTheDocument();
// After hydration, if no report, skeleton should appear (not during pre-hydration)
const pending = await screen.findByText(/Report generation pending/i);
expect(pending).toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
it("conditionally renders What remains plausible only when hasPlausibleInterpretations is true", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(
makeSnapshot({
investigationReport: {
understanding: "Some understanding.",
hasPlausibleInterpretations: false,
plausibleInterpretations: "",
},
}),
),
);
render(React.createElement(ReportPage));
expect(await screen.findByText(/Investigation Report/i)).toBeInTheDocument();
expect(screen.queryByText(/What remains plausible/i)).not.toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
expect(screen.getByText(/Questions we have clarified/i)).toBeInTheDocument();
it("renders What remains plausible when hasPlausibleInterpretations is true", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(
makeSnapshot({
investigationReport: {
understanding: "Some understanding.",
hasPlausibleInterpretations: true,
plausibleInterpretations: "The denominator may have been narrowed.",
},
}),
),
);
render(React.createElement(ReportPage));
expect(await screen.findByText(/What remains plausible/i)).toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
it("renders Back to investigation link", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(
makeSnapshot({
investigationReport: {
understanding: "Some understanding.",
hasPlausibleInterpretations: false,
},
}),
),
);
render(React.createElement(ReportPage));
expect(await screen.findByText(/Back to investigation/i)).toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
it("renders Back to portfolio link", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(
makeSnapshot({
investigationReport: {
understanding: "Some understanding.",
hasPlausibleInterpretations: false,
},
}),
),
);
render(React.createElement(ReportPage));
expect(await screen.findByText(/Back to portfolio/i)).toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
it("shows skeleton loading state when no persisted report exists", async () => {
localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(makeSnapshot()),
);
render(React.createElement(ReportPage));
expect(await screen.findByText(/Investigation Report/i)).toBeInTheDocument();
// Skeleton should not have actual understanding content
expect(screen.queryByText(/Report generation pending/i)).toBeInTheDocument();
localStorage.removeItem("confidence-engine-investigation");
});
});
describe("Investigation page (v0.55 route architecture)", () => {
let InvestigationPage;
beforeAll(async () => {
const mod = await import("@/app/investigations/[id]/page.jsx");
InvestigationPage = mod.default;
});
it("renders Back to portfolio link", async () => {
render(React.createElement(InvestigationPage));
expect(await screen.findByText(/Back to portfolio/i)).toBeInTheDocument();
});
});