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(); }); });