import { describe, expect, it } from "vitest"; import { submitAnswerForUpdateCase, synthesizeFromFindings } from "@/components/scenario-form.jsx"; import { normalizeFindings } from "@/lib/graph/finding-helpers.js"; /* ───────────── Case A — graph-only update ───────────── */ describe("case/update synthesis — Case A (graph-only update)", () => { it("produces exactly 1 synthesis call with updated graph + complete existing Findings", async () => { const originalGraph = { centralStatement: "Q3 financial decline", nodes: [{ id: "n1" }], edges: [] }; const findings = [ { id: "f-1", proposition: "Revenue dropped 22%", sourceObservation: "Revenue dropped 22%" }, { id: "f-2", proposition: "Competitor launched pricing campaign", sourceObservation: "Competitor launched pricing campaign" }, ]; let synthesisCalls = []; const mockFetch = async (url, init) => { if (url === "/api/cases/update") { const body = JSON.parse(init.body); return new Response(JSON.stringify({ success: true, updatedSituationGraph: { ...body.situationGraph, centralStatement: "Updated Q3 financial decline" }, selectedQuestion: "What is the root cause?", appendedFindings: [], // graph only — no new findings }), { status: 200, headers: { "Content-Type": "application/json" } }); } if (url === "/api/cases/synthesis") { synthesisCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ currentUnderstanding: "DEDICATED RECONSTRUCTION" }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return new Response(JSON.stringify({}), { status: 200 }); }; const submission = await submitAnswerForUpdateCase(mockFetch, { situationGraph: originalGraph, previousQuestion: "What's the impact?", answer: "Answer text", findings, }); expect(submission.ok).toBe(true); expect(submission.data.success).toBe(true); // Derive next canonical state (explicit — no React reread) const nextGraph = submission.data.updatedSituationGraph; let nextFindings = [...findings]; // Trigger synthesis exactly once const synResult = await synthesizeFromFindings(mockFetch, { situationGraph: nextGraph, findings: normalizeFindings(nextFindings) }); expect(synthesisCalls).toHaveLength(1); expect(synResult.ok).toBe(true); expect(synthesisCalls[0].situationGraph.centralStatement).toBe(nextGraph.centralStatement); expect(synthesisCalls[0].findings).toHaveLength(2); // complete existing Findings }); }); /* ───────────── Case B — graph + Findings ───────────── */ describe("case/update synthesis — Case B (graph + Findings)", () => { it("produces exactly 1 synthesis call with updated graph + all Findings", async () => { const originalGraph = { centralStatement: "Q3 decline", nodes: [{ id: "n1" }], edges: [] }; const findings = [ { id: "f-1", proposition: "Revenue dropped 22%", sourceObservation: "Revenue dropped 22%" }, ]; let synthesisCalls = []; const mockFetch = async (url, init) => { if (url === "/api/cases/update") { const body = JSON.parse(init.body); return new Response(JSON.stringify({ success: true, updatedSituationGraph: { ...body.situationGraph, centralStatement: "Updated Q3 decline" }, selectedQuestion: "Root cause?", appendedFindings: [ { id: "f-2-new", proposition: "New Finding from update", sourceObservation: "New Finding from update" }, ], }), { status: 200, headers: { "Content-Type": "application/json" } }); } if (url === "/api/cases/synthesis") { synthesisCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ currentUnderstanding: "DEDICATED RECONSTRUCTION" }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return new Response(JSON.stringify({}), { status: 200 }); }; const submission = await submitAnswerForUpdateCase(mockFetch, { situationGraph: originalGraph, previousQuestion: "What's the impact?", answer: "Answer text", findings, }); expect(submission.ok).toBe(true); // Derive next canonical state (explicit) const nextGraph = submission.data.updatedSituationGraph; let nextFindings = [...findings, ...submission.data.appendedFindings]; const synResult = await synthesizeFromFindings(mockFetch, { situationGraph: nextGraph, findings: normalizeFindings(nextFindings) }); expect(synthesisCalls).toHaveLength(1); // exactly one synthesis expect(synResult.ok).toBe(true); expect(synthesisCalls[0].findings).toHaveLength(2); // original + appended expect(synthesisCalls[0].situationGraph.centralStatement).toBe(nextGraph.centralStatement); }); }); /* ───────────── Case C — dedicated reconstruction wins ───────────── */ describe("case/update synthesis — Case C (dedicated reconstruction wins)", () => { it("final CU is dedicated reconstruction, not outcome.summary", async () => { const originalGraph = { centralStatement: "test" }; let cuState = "previous understanding"; let synthesisCalls = []; const mockFetch = async (url, init) => { if (url === "/api/cases/update") { return new Response(JSON.stringify({ success: true, updatedSituationGraph: { centralStatement: "updated" }, selectedQuestion: "Q?", appendedFindings: [], summary: "OLD UPDATE SUMMARY", }), { status: 200, headers: { "Content-Type": "application/json" } }); } if (url === "/api/cases/synthesis") { synthesisCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ currentUnderstanding: "DEDICATED RECONSTRUCTION" }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return new Response(JSON.stringify({}), { status: 200 }); }; const submission = await submitAnswerForUpdateCase(mockFetch, { situationGraph: originalGraph, previousQuestion: "Q?", answer: "A", findings: [], }); expect(submission.ok).toBe(true); expect(submission.data.summary).toBe("OLD UPDATE SUMMARY"); // Derive next state and synthesize (the correct path) const nextGraph = submission.data.updatedSituationGraph; let nextFindings = []; const synResult = await synthesizeFromFindings(mockFetch, { situationGraph: nextGraph, findings: normalizeFindings(nextFindings) }); if (synResult.ok && synResult.data?.currentUnderstanding) { cuState = synResult.data.currentUnderstanding; } expect(cuState).toBe("DEDICATED RECONSTRUCTION"); expect(cuState).not.toBe("OLD UPDATE SUMMARY"); }); }); /* ───────────── Case D — synthesis failure ───────────── */ describe("case/update synthesis — Case D (synthesis failure)", () => { it("preserves updated graph and Findings, preserves previous CU, one attempt no retry", async () => { const originalGraph = { centralStatement: "test" }; const findings = [{ id: "f-1", proposition: "Fact X" }]; let cuState = "previous understanding"; let synthesisCallCount = 0; const mockFetch = async (url, init) => { if (url === "/api/cases/update") { return new Response(JSON.stringify({ success: true, updatedSituationGraph: { centralStatement: "updated from update" }, selectedQuestion: "Q?", appendedFindings: [], summary: "OLD UPDATE SUMMARY", }), { status: 200, headers: { "Content-Type": "application/json" } }); } if (url === "/api/cases/synthesis") { synthesisCallCount++; return new Response( JSON.stringify({ success: false, error: "provider timeout" }), { status: 503, headers: { "Content-Type": "application/json" } }, ); } return new Response(JSON.stringify({}), { status: 200 }); }; const submission = await submitAnswerForUpdateCase(mockFetch, { situationGraph: originalGraph, previousQuestion: "Q?", answer: "A", findings, }); expect(submission.ok).toBe(true); // Derive next state — graph and findings preserved (not lost) const nextGraph = submission.data.updatedSituationGraph; let nextFindings = [...findings]; // Synthesis attempt (explicit) const synResult = await synthesizeFromFindings(mockFetch, { situationGraph: nextGraph, findings: normalizeFindings(nextFindings) }); expect(synthesisCallCount).toBe(1); // one attempt only expect(synResult.ok).toBe(false); // failed // Updated graph and findings are still available (not lost on synthesis failure) expect(nextGraph.centralStatement).toBe("updated from update"); expect(nextFindings).toHaveLength(1); // Previous CU preserved — no fallback to outcome.summary expect(cuState).toBe("previous understanding"); }); }); /* ───────────── Case E — update failure ───────────── */ describe("case/update synthesis — Case E (update failure)", () => { it("synthesis calls = 0 when update fails", async () => { let synthesisCallCount = 0; const mockFetch = async (url, init) => { if (url === "/api/cases/update") { return new Response( JSON.stringify({ success: false, error: "update failed" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } if (url === "/api/cases/synthesis") { synthesisCallCount++; } return new Response(JSON.stringify({}), { status: 200 }); }; const submission = await submitAnswerForUpdateCase(mockFetch, { situationGraph: { centralStatement: "test" }, previousQuestion: "Q?", answer: "A", findings: [], }); expect(submission.ok).toBe(false); expect(synthesisCallCount).toBe(0); // synthesis NOT called when update fails }); }); /* ───────────── Edge: outcome.summary is NOT final CU authority ───────────── */ describe("case/update — outcome.summary retired as final CU authority", () => { it("when dedicated reconstruction succeeds, CU = reconstruction not summary", async () => { let cuState = "old"; let synthesisCalls = []; const mockFetch = async (url, init) => { if (url === "/api/cases/update") { return new Response(JSON.stringify({ success: true, updatedSituationGraph: { centralStatement: "x" }, selectedQuestion: "Q?", appendedFindings: [], summary: "WRONG FINAL CU", }), { status: 200 }); } if (url === "/api/cases/synthesis") { synthesisCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ currentUnderstanding: "DEDICATED RECONSTRUCTION" }), { status: 200 }, ); } return new Response(JSON.stringify({}), { status: 200 }); }; const submission = await submitAnswerForUpdateCase(mockFetch, { situationGraph: { centralStatement: "x" }, previousQuestion: "Q?", answer: "A", findings: [], }); expect(submission.ok).toBe(true); // The correct path: synthesize → CU = reconstruction const nextGraph = submission.data.updatedSituationGraph; let nextFindings = []; const synResult = await synthesizeFromFindings(mockFetch, { situationGraph: nextGraph, findings: normalizeFindings(nextFindings) }); if (synResult.ok && synResult.data?.currentUnderstanding) { cuState = synResult.data.currentUnderstanding; } expect(cuState).toBe("DEDICATED RECONSTRUCTION"); expect(synthesisCalls).toHaveLength(1); }); }); /* ───────────── Edge: previous CU is NOT synthesis input ───────────── */ describe("case/update — previous CU not sent to synthesis", () => { it("synthesis payload only contains situationGraph + findings, no currentUnderstanding", async () => { let capturedPayload = null; const mockFetch = async (url, init) => { if (url === "/api/cases/update") { return new Response(JSON.stringify({ success: true, updatedSituationGraph: { centralStatement: "x" }, selectedQuestion: "Q?", appendedFindings: [], }), { status: 200 }); } if (url === "/api/cases/synthesis") { capturedPayload = JSON.parse(init.body); return new Response(JSON.stringify({ currentUnderstanding: "RECON" }), { status: 200 }); } return new Response(JSON.stringify({}), { status: 200 }); }; const submission = await submitAnswerForUpdateCase(mockFetch, { situationGraph: { centralStatement: "x" }, previousQuestion: "Q?", answer: "A", findings: [], }); const nextGraph = submission.data.updatedSituationGraph; await synthesizeFromFindings(mockFetch, { situationGraph: nextGraph, findings: normalizeFindings([]) }); expect(capturedPayload).not.toHaveProperty("currentUnderstanding"); expect(capturedPayload.situationGraph).toBeDefined(); expect(Array.isArray(capturedPayload.findings)).toBe(true); }); });