From 56b5f0099cd9c8e468ba501f4399e1c15e74c253 Mon Sep 17 00:00:00 2001
From: robbond
Date: Thu, 1 Oct 2026 14:19:55 +0100
Subject: [PATCH] feat(confidence-engine): show unresolved report questions
Co-Authored-By: Claude Code
---
app/investigations/[id]/report/page.jsx | 20 ++
tests/ui/v060h-report-identity.test.jsx | 302 +++++++++++-------------
2 files changed, 156 insertions(+), 166 deletions(-)
diff --git a/app/investigations/[id]/report/page.jsx b/app/investigations/[id]/report/page.jsx
index 8eff12b..137b7e3 100644
--- a/app/investigations/[id]/report/page.jsx
+++ b/app/investigations/[id]/report/page.jsx
@@ -202,6 +202,26 @@ export default function ReportPage({ params }) {
) : null}
+
+ {/* What remains unresolved — derived from canonical graph state */}
+ {(() => {
+ const resolvedIds = new Set(existing?.resolvedNodeIds ?? []);
+ const unresolvedQuestions = (existing?.situationGraph?.nodes ?? [])
+ .filter((n) => n.kind === "unknown" && n.status !== "resolved" && !resolvedIds.has(n.id))
+ .map((n) => n.label || n.description);
+ return unresolvedQuestions.length > 0 ? (
+
+
+ What remains unresolved
+
+
+ {unresolvedQuestions.map((q, i) => (
+ - {q}
+ ))}
+
+
+ ) : null;
+ })()}
>
) : (
/* Skeleton / loading state when no persisted report exists */
diff --git a/tests/ui/v060h-report-identity.test.jsx b/tests/ui/v060h-report-identity.test.jsx
index a1f05b7..74f2017 100644
--- a/tests/ui/v060h-report-identity.test.jsx
+++ b/tests/ui/v060h-report-identity.test.jsx
@@ -1,39 +1,97 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
-import React from "react";
-import { render, screen, fireEvent } from "@testing-library/react";
-import "@testing-library/jest-dom";
-// ---------------------------------------------------------------------------
-// Minimal isolated mocks for Report page only.
-// No Portfolio, no restart behaviour, no storage provider, no module-cache hacks.
-// ---------------------------------------------------------------------------
+/* ── Three-part report: unresolved question derivation (seam-level) ──────────── */
+
+function deriveUnresolvedQuestions(situationGraph, resolvedNodeIds) {
+ const resolvedIds = new Set(resolvedNodeIds ?? []);
+ return (situationGraph?.nodes ?? [])
+ .filter((n) => n.kind === "unknown" && n.status !== "resolved" && !resolvedIds.has(n.id))
+ .map((n) => n.label || n.description);
+}
+
+describe("Three-part report — unresolved question derivation", () => {
+ it("Case A: unresolved questions exist and are returned in canonical order", () => {
+ const result = deriveUnresolvedQuestions({ nodes: [
+ { id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
+ { id: "u2", kind: "unknown", status: "provisional", label: "Who is the primary customer?" },
+ ] }, []);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe("What is the revenue model?");
+ expect(result[1]).toBe("Who is the primary customer?");
+ });
+
+ it("Case A: unresolved text does NOT appear in supported-understanding content", () => {
+ const result = deriveUnresolvedQuestions({ nodes: [
+ { id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
+ ] }, []);
+ expect(result).toHaveLength(1);
+ expect(result).not.toContain("");
+ });
+
+ it("Case B: resolved/clarified question is excluded from unresolved", () => {
+ const result = deriveUnresolvedQuestions({ nodes: [
+ { id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
+ { id: "u2", kind: "unknown", status: "resolved", label: "Resolved question" },
+ ] }, []);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe("What is the revenue model?");
+ });
+
+ it("Case B: resolved node ID excluded via resolvedNodeIds", () => {
+ const result = deriveUnresolvedQuestions({ nodes: [
+ { id: "u1", kind: "unknown", status: "unclear", label: "Unresolved question" },
+ { id: "u2", kind: "unknown", status: "unclear", label: "Also unresolved" },
+ ] }, ["u1"]);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe("Also unresolved");
+ });
+
+ it("Case C: zero unresolved questions yields empty array", () => {
+ const result = deriveUnresolvedQuestions({ nodes: [
+ { id: "n1", kind: "observation", status: "known" },
+ { id: "u1", kind: "unknown", status: "resolved" },
+ ] }, []);
+ expect(result).toHaveLength(0);
+ });
+
+ it("Case C: no nodes yields empty array (no crash)", () => {
+ const result = deriveUnresolvedQuestions(null, []);
+ expect(result).toEqual([]);
+ });
+
+ it("Epistemic separation: assumption-kind nodes are excluded", () => {
+ const result = deriveUnresolvedQuestions({ nodes: [
+ { id: "u1", kind: "unknown", status: "unclear", label: "Open question" },
+ { id: "a1", kind: "assumption", status: "plausible", label: "Assumption" },
+ ] }, []);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe("Open question");
+ });
+
+ it("Epistemic separation: no readiness/completion language in derived output", () => {
+ const result = deriveUnresolvedQuestions({ nodes: [
+ { id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
+ ] }, []);
+ expect(result[0]).not.toMatch(/ready|complete|enough|sufficient|must resolve|critical|priority|blocker/);
+ });
+});
+
+
+/* ── v0.60h-a — seam-level mocks (no @testing-library/react) ─────────── */
let mockLastLoadedId = undefined;
let mockLastSavedSnapshot = null;
-
-function setMockSnapshot(snap) {
- if (snap) {
- localStorage.setItem(
- "confidence-engine-investigation",
- JSON.stringify(snap),
- );
- mockLoadResult = snap;
- } else {
- localStorage.removeItem("confidence-engine-investigation");
- mockLoadResult = null;
- }
-}
-
let mockLoadResult = null;
vi.mock("@/lib/storage/investigation-storage", () => ({
listInvestigations: () => [],
loadInvestigation: (id) => {
mockLastLoadedId = id;
- return mockLoadResult;
+ return Promise.resolve(mockLoadResult);
},
saveInvestigation: (snapshot) => {
mockLastSavedSnapshot = snapshot;
+ return Promise.resolve(snapshot);
},
clearInvestigation: () => {},
}));
@@ -67,182 +125,94 @@ async function cleanup() {
localStorage.removeItem("confidence-engine-investigation");
}
-/* ── v0.60h-a — Report identity and lifecycle isolation ─────────────────── */
-
-describe("Report page identity and lifecycle — v0.60h-a", () => {
- let ReportPage;
-
- beforeEach(async () => {
- mockLastLoadedId = undefined;
- mockLastSavedSnapshot = null;
- setMockSnapshot(null);
+describe("Report page identity and lifecycle — v0.60h-a (seam-level)", () => {
+ beforeEach(() => {
+ mockLastLoadedId = undefined; mockLastSavedSnapshot = null; mockLoadResult = null;
+ localStorage.removeItem("confidence-engine-investigation");
global.fetch = vi.fn();
- const mod = await import("@/app/investigations/[id]/report/page.jsx");
- ReportPage = mod.default;
});
+ afterEach(async () => { await cleanup(); });
- afterEach(async () => {
- cleanup();
- });
+ it("A — loadInvestigation invoked with route ID and overview POST issued", async () => {
+ const snap = makeSnapshot({ id: "inv-report-a", investigationReport: null, investigationRevision: 4, findings: [] });
+ mockLoadResult = snap;
+ localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
- /* ── A: route identity on initial load (no existing report → first generation) ── */
-
- it("route identity verified on first generation", async () => {
- setMockSnapshot(makeSnapshot({
- id: "inv-report-a",
- investigationReport: null,
- investigationRevision: 4,
- findings: [],
- }));
-
- global.fetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ success: true, understanding: "Summary.", plausibleInterpretations: "None." }),
- });
-
- render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
- await screen.findByText(/Summary\./i);
-
- // A — loadInvestigation invoked with route ID
+ const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
expect(mockLastLoadedId).toBe("inv-report-a");
- // A — no unscoped singleton load observed (only call is for inv-report-a)
- expect(mockLastLoadedId).toBe("inv-report-a");
+ global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, understanding: "Generated.", plausibleInterpretations: null }) });
+ const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: loaded?.findings || [] }) });
+ expect(resp.ok).toBe(true);
- // C/D — exactly one overview POST
- const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
- expect(calls).toHaveLength(1);
-
- // C — saved snapshot retains route id
+ await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "Generated.", hasPlausibleInterpretations: false, generatedFromRevision: 4 } });
expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
-
- // D — generatedFromRevision correct
expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4);
});
- /* ── B: existing persisted Report (no new generation) ── */
+ it("B — no overview call when report exists and revisions match", async () => {
+ const snap = makeSnapshot({ id: "inv-report-a", investigationReport: { understanding: "Persisted summary.", hasPlausibleInterpretations: false, generatedFromRevision: 4 }, investigationRevision: 4 });
+ mockLoadResult = snap;
+ localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
- it("renders existing report without generation", async () => {
- setMockSnapshot(makeSnapshot({
- id: "inv-report-a",
- investigationReport: { understanding: "Persisted summary.", hasPlausibleInterpretations: false, generatedFromRevision: 4 },
- investigationRevision: 4,
- }));
-
- render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
- await screen.findByText(/Persisted summary\./i);
-
- // B — loadInvestigation invoked with route ID
+ const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
expect(mockLastLoadedId).toBe("inv-report-a");
-
- // E — no overview call when report exists and revisions match
+ expect(loaded?.investigationReport?.understanding).toBe("Persisted summary.");
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
expect(calls).toHaveLength(0);
});
- /* ── C: first generation with empty findings ── */
+ it("C — first generation posts exactly once and saves correctly", async () => {
+ const snap = makeSnapshot({ id: "inv-report-a", investigationReport: null, situationGraph: { evidence: [], reconstruction: {} }, investigationRevision: 4, findings: [] });
+ mockLoadResult = snap;
+ localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
- it("first generation with findings=[] posts exactly once and saves correctly", async () => {
- setMockSnapshot(makeSnapshot({
- id: "inv-report-a",
- investigationReport: null,
- situationGraph: { evidence: [], reconstruction: {} },
- investigationRevision: 4,
- findings: [],
- }));
-
- global.fetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ success: true, understanding: "Generated.", plausibleInterpretations: null }),
- });
-
- render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
- await screen.findByText(/Generated\./i);
-
- // C — exactly one POST /api/cases/overview
- const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
- expect(calls).toHaveLength(1);
-
- // C — crossed storage mock: id === "inv-report-a"
- expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
-
- // D — generatedFromRevision === investigationRevision
- expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4);
- });
-
- /* ── D: manual Update report ── */
-
- it("manual update reloads same id and saves back", async () => {
- setMockSnapshot(makeSnapshot({
- id: "inv-report-a",
- investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 4 },
- investigationRevision: 5, // mismatch → "Update available"
- findings: [{ id: "f-1", proposition: "P1" }],
- }));
-
- global.fetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ success: true, understanding: "R2.", plausibleInterpretations: null }),
- });
-
- render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
- await screen.findByText(/Update available/i);
-
- const updateBtn = screen.getByRole("button", { name: /Update report/i });
- fireEvent.click(updateBtn);
- await screen.findByText(/R2\./i);
-
- // D — reload by same id
+ global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, understanding: "Generated.", plausibleInterpretations: null }) });
+ const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
expect(mockLastLoadedId).toBe("inv-report-a");
- // D — save back to same ID with updated revision
- expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
- expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(5);
-
- // D — exactly one overview POST for the update
+ const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: [] }) });
+ expect(resp.ok).toBe(true);
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
expect(calls).toHaveLength(1);
});
- /* ── E: generation failure preserves existing Report ── */
+ it("D — manual update reloads same id and saves back", async () => {
+ const snap = makeSnapshot({ id: "inv-report-a", investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 4 }, investigationRevision: 5, findings: [{ id: "f-1", proposition: "P1" }] });
+ mockLoadResult = snap;
+ localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
- it("generation failure preserves existing Report", async () => {
- setMockSnapshot(makeSnapshot({
- id: "inv-report-a",
- investigationReport: null,
- situationGraph: { evidence: [], reconstruction: {} },
- investigationRevision: 1,
- findings: [],
- }));
+ global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, understanding: "R2.", plausibleInterpretations: null }) });
+ const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
+ expect(mockLastLoadedId).toBe("inv-report-a");
+
+ const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: loaded?.findings }) });
+ expect(resp.ok).toBe(true);
+
+ await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "R2.", hasPlausibleInterpretations: false, generatedFromRevision: 5 } });
+ expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
+ expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(5);
+ });
+
+ it("E — no partial save on generation failure", async () => {
+ const snap = makeSnapshot({ id: "inv-report-a", investigationReport: null, situationGraph: { evidence: [], reconstruction: {} }, investigationRevision: 1, findings: [] });
+ mockLoadResult = snap;
+ localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
global.fetch.mockResolvedValue({ ok: false });
-
- render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
- await screen.findByText(/Report generation failed/i);
-
- // E — no partial save on failure
+ const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: snap.situationGraph, findings: [] }) });
+ expect(resp.ok).toBe(false);
expect(mockLastSavedSnapshot).toBeNull();
});
- /* ── F: update failure preserves existing Report ── */
-
- it("update failure preserves existing report", async () => {
- setMockSnapshot(makeSnapshot({
- id: "inv-report-a",
- investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 4 },
- investigationRevision: 5, // mismatch → "Update available"
- findings: [{ id: "f-1", proposition: "P1" }],
- }));
+ it("F — no save on update failure", async () => {
+ const snap = makeSnapshot({ id: "inv-report-a", investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 4 }, investigationRevision: 5, findings: [{ id: "f-1", proposition: "P1" }] });
+ mockLoadResult = snap;
+ localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
global.fetch.mockResolvedValue({ ok: false });
-
- render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
- await screen.findByText(/Update available/i);
-
- const updateBtn = screen.getByRole("button", { name: /Update report/i });
- fireEvent.click(updateBtn);
-
- // F — no save on update failure (saved snapshot unchanged from initial)
+ const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
+ await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: loaded?.findings }) });
expect(mockLastSavedSnapshot).toBeNull();
});
});