import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { deriveInvestigationLearning } from "@/lib/graph/episode-preparation"; /* ── 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; let mockLoadResult = null; vi.mock("@/lib/storage/investigation-storage", () => ({ listInvestigations: () => [], loadInvestigation: (id) => { mockLastLoadedId = id; return Promise.resolve(mockLoadResult); }, saveInvestigation: (snapshot) => { mockLastSavedSnapshot = snapshot; return Promise.resolve(snapshot); }, clearInvestigation: () => {}, })); 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 by 35% while production increased by 40%.", situationGraph, selectedQuestion: null, summary: "Production quality declined.", updatedAt: new Date().toISOString(), schemaVersion: 1, investigationReport: null, investigationRevision: 0, findings: [], ...overrides, }; } async function cleanup() { localStorage.removeItem("confidence-engine-investigation"); } 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(); }); afterEach(async () => { await 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)); const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("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); await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "Generated.", plausibleInterpretations: null, hasPlausibleInterpretations: (null ?? "").trim().length > 0, generatedFromRevision: 4 } }); expect(mockLastSavedSnapshot.id).toBe("inv-report-a"); expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4); }); 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)); const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a"); expect(mockLastLoadedId).toBe("inv-report-a"); expect(loaded?.investigationReport?.understanding).toBe("Persisted summary."); const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview"); expect(calls).toHaveLength(0); }); 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)); 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"); 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); }); 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)); 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.", plausibleInterpretations: null, hasPlausibleInterpretations: (null ?? "").trim().length > 0, 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 }); 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(); }); 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 }); 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(); }); }); /* ── v0.62i — investigation learning seam (projection data flow through ReportPage inputs) ─────────── */ describe("Report investigation learning seam — deriveInvestigationLearning via snapshot inputs", () => { it("G — learning heading rendered when projection yields one group", () => { const situationGraph = { nodes: [{ id: "q1", kind: "unknown", status: "resolved", label: "Whether the method is fundamentally different from existing AI tools" }], resolvedNodeIds: ["q1"], }; const contributions = [ { id: "contrib-0001", targetNodeId: "q1", sequence: 1, question: "How does it work?", answer: "It breaks down questions." }, ]; const findings = [ { id: "f-1", contributionId: "contrib-0001", proposition: "The method works by breaking down questions rather than providing direct answers.", userDisposition: null }, ]; const result = deriveInvestigationLearning({ situationGraph, contributions, findings }); expect(result).toHaveLength(1); expect(result[0].questionText).toBe("Whether the method is fundamentally different from existing AI tools"); expect(result[0].propositions).toHaveLength(1); expect(result[0].propositions[0].proposition).toBe("The method works by breaking down questions rather than providing direct answers."); }); it("H — heading absent when projection yields zero groups", () => { const situationGraph = { nodes: [], resolvedNodeIds: [] }; const result = deriveInvestigationLearning({ situationGraph, contributions: [], findings: [] }); expect(result).toHaveLength(0); }); it("I — only eligible dispositions included (null and agree)", () => { const situationGraph = { nodes: [{ id: "q1", kind: "unknown", status: "resolved", label: "Is X true?" }], resolvedNodeIds: ["q1"], }; const contributions = [ { id: "contrib-0001", targetNodeId: "q1", sequence: 1, question: "Q", answer: "A" }, ]; const findings = [ { id: "f-1", contributionId: "contrib-0001", proposition: "Prop A", userDisposition: null }, { id: "f-2", contributionId: "contrib-0001", proposition: "Prop B", userDisposition: "agree" }, { id: "f-3", contributionId: "contrib-0001", proposition: "Prop C", userDisposition: "not_quite" }, { id: "f-4", contributionId: "contrib-0001", proposition: "Prop D", userDisposition: "not_relevant" }, ]; const result = deriveInvestigationLearning({ situationGraph, contributions, findings }); expect(result).toHaveLength(1); expect(result[0].propositions).toHaveLength(2); expect(result[0].propositions.map((p) => p.proposition)).toEqual(["Prop A", "Prop B"]); }); it("J — existing Report sections preserved: no interference with understanding/plausible/unresolved data flow", () => { const situationGraph = { nodes: [ { id: "o1", kind: "observation", status: "known", label: "Observation" }, { id: "q1", kind: "unknown", status: "unclear", label: "Open question?" }, ], resolvedNodeIds: [], }; const reportData = { understanding: "We understand X.", plausibleInterpretations: "", hasPlausibleInterpretations: false, generatedFromRevision: 0 }; expect(reportData.understanding).toBe("We understand X."); expect(reportData.hasPlausibleInterpretations).toBe(false); expect(situationGraph.resolvedNodeIds.length).toBe(0); }); });