import React from "react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { ScenarioResultPanels, UpdateErrorPanel, submitAnswerForUpdateCase, submitScenarioForStartCase, } from "@/components/scenario-form.jsx"; import { deriveFindingsFromContributions, normalizeFindings, validateSingleFinding } from "@/lib/graph/finding-helpers.js"; import { synthesizeFromFindings } from "@/components/scenario-form.jsx"; // ── Simulated appendFocusedContribution logic (mirrors ScenarioForm) ───────── function simulateAppend(contribs, findings, contribution) { const seq = contribs.length + 1; const storedContribution = { ...contribution, sequence: seq, id: `contrib-${String(seq).padStart(4, "0")}` }; const newFindings = deriveFindingsFromContributions([storedContribution]).findings; const mergedFindings = normalizeFindings([...findings, ...newFindings]); return { contribs: [...contribs, storedContribution], findings: mergedFindings }; } // ── Test: Contribution with observations → Findings ────────────────────────── describe("Contribution → Finding derivation seam", () => { it("appending Contribution with 2 observations creates 2 Findings", () => { const contrib = { targetNodeId: "n-1", observations: ["Observation A", "Observation B"] }; const result = simulateAppend([], [], contrib); expect(result.contribs).toHaveLength(1); expect(result.findings).toHaveLength(2); }); it("each Finding references the actual stored contributionId", () => { const contrib = { targetNodeId: "n-5", observations: ["Fact X"] }; const result = simulateAppend([], [], contrib); expect(result.contribs[0].id).toBe("contrib-0001"); expect(result.findings[0].contributionId).toBe("contrib-0001"); }); it("sourceObservation equals the immutable original observation text", () => { const obsText = "Revenue dropped 22% in Q3"; const contrib = { targetNodeId: "n-2", observations: [obsText] }; const result = simulateAppend([], [], contrib); expect(result.findings[0].sourceObservation).toBe(obsText); }); it("proposition initially equals sourceObservation", () => { const obsText = "Market share eroded by competitor pricing"; const contrib = { targetNodeId: "n-3", observations: [obsText] }; const result = simulateAppend([], [], contrib); expect(result.findings[0].proposition).toBe(obsText); expect(result.findings[0].sourceObservation).toBe(obsText); }); it("existing Findings remain when another Contribution is appended", () => { const firstObs = "First observation"; const secondObs = "Second observation"; let state = simulateAppend([], [], { observations: [firstObs] }); expect(state.findings).toHaveLength(1); state = simulateAppend(state.contribs, state.findings, { observations: [secondObs] }); expect(state.findings).toHaveLength(2); expect(state.findings[0].sourceObservation).toBe(firstObs); expect(state.findings[1].sourceObservation).toBe(secondObs); }); it("exact duplicate derivation does not create duplicate Finding ids", () => { // When a single contribution has two identical observations, both produce the same // finding id because deriveFindingId(observation, contributionId) is deterministic. const contrib = { targetNodeId: "n-1", observations: ["Same fact", "Same fact"] }; const result = deriveFindingsFromContributions([contrib]); expect(result.findings).toHaveLength(2); // raw: two findings with same id const normalized = normalizeFindings(result.findings); expect(normalized).toHaveLength(1); // deduplicated by id }); it("deriveFindingId uses stored contributionId, not derived sequence", () => { // Real contributions have their own ids — different id → different finding const contribA = { targetNodeId: "n-1", observations: ["Fact X"], id: "contrib-A" }; const contribB = { targetNodeId: "n-2", observations: ["Fact X"], id: "contrib-B" }; const rA = deriveFindingsFromContributions([contribA]); const rB = deriveFindingsFromContributions([contribB]); expect(rA.findings[0].id).not.toBe(rB.findings[0].id); expect(rA.findings[0].contributionId).toBe("contrib-A"); expect(rB.findings[0].contributionId).toBe("contrib-B"); }); it("no Finding is created from uncertainties, assumptions, relationships, or possibleFollowUpQuestions", () => { const contrib = { targetNodeId: "n-4", observations: ["Valid observation"], uncertainties: ["Some uncertainty"], assumptions: ["Some assumption"], relationships: [{ from: "n1", to: "n2" }], possibleFollowUpQuestions: ["What about X?"], }; const result = simulateAppend([], [], contrib); expect(result.findings).toHaveLength(1); expect(result.findings[0].sourceObservation).toBe("Valid observation"); }); it("empty observations produce no Findings", () => { const contrib = { targetNodeId: "n-6", observations: [] }; const result = simulateAppend([], [], contrib); expect(result.findings).toHaveLength(0); expect(result.contribs).toHaveLength(1); // Contribution is still stored }); it("no UI behaviour changes — ScenarioResultPanels still renders correctly", () => { const html = renderToStaticMarkup( , ); expect(html).toContain("Error: Test error"); }); it("no UI behaviour changes — UpdateErrorPanel still renders correctly", () => { const html = renderToStaticMarkup( , ); expect(html).toContain("Update error: Test update error"); }); it("no UI behaviour changes — submitAnswerForUpdateCase still sends findings", async () => { const fetchImpl = { mockResolvedValue: undefined }; // Verify the import chain works — ScenarioForm imports finding-helpers // which should not break any existing render or API behavior expect(typeof deriveFindingsFromContributions).toBe("function"); expect(typeof normalizeFindings).toBe("function"); }); }); // ── Synthesis trigger regressions (v0.50) ─────────────────── describe("Synthesis trigger — focused findings commit (STATE-B)", () => { let capturedFetchCalls; let capturedCU; beforeEach(() => { capturedFetchCalls = []; capturedCU = "previous understanding"; global.fetch = vi.fn(async (url, init) => { if (url === "/api/cases/synthesis") { capturedFetchCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ currentUnderstanding: "Reconstructed understanding." }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return new Response(JSON.stringify({}), { status: 200 }); }); }); function simulateAppendWithSynthesis(contribs, findings, contribution) { const seq = contribs.length + 1; const storedContribution = { ...contribution, sequence: seq, id: `contrib-${String(seq).padStart(4, "0")}` }; const newFindingsRaw = deriveFindingsFromContributions([storedContribution]).findings; // Simulate appendFocusedContribution body (synchronous snapshot) const completeNextFindings = normalizeFindings([...findings, ...newFindingsRaw]); const mergedContribs = [...contribs, storedContribution]; const mergedFindings = [...findings, ...newFindingsRaw]; // Trigger synthesis if new findings exist if (newFindingsRaw.length === 0) return { contribs: mergedContribs, findings: completeNextFindings }; void synthesizeFromFindings(fetch, { situationGraph: { centralStatement: "x" }, findings: completeNextFindings, }).then((res) => { if (res.ok && res.data?.currentUnderstanding) { capturedCU = res.data.currentUnderstanding; } }); return { contribs: mergedContribs, findings: completeNextFindings }; } /* ── A: new Finding triggers synthesis once ─────────────── */ it("A — one Contribution derives one Finding → synthesis called exactly once", () => { const contrib = { targetNodeId: "n-1", observations: ["Revenue dropped 22%"] }; simulateAppendWithSynthesis([], [], contrib); expect(capturedFetchCalls).toHaveLength(1); expect(capturedFetchCalls[0].findings).toHaveLength(1); }); /* ── B: complete explicit nextFindings ─────────────────── */ it("B — existing Finding + new Finding both present in synthesis request", () => { const existing = deriveFindingsFromContributions([ { ...{ targetNodeId: "n-a" }, observations: ["Existing fact"], sequence: 1, id: "contrib-0001" }, ]).findings; simulateAppendWithSynthesis( [{ id: "contrib-0001", observations: ["Existing fact"], sequence: 1 }], normalizeFindings(existing), { targetNodeId: "n-b", observations: ["New fact"] }, ); expect(capturedFetchCalls[0].findings).toHaveLength(2); expect(capturedFetchCalls[0].findings[0].proposition).toContain("Existing"); expect(capturedFetchCalls[0].findings[1].proposition).toContain("New"); }); /* ── C: no stale React state ─────────────────────────── */ it("C — newly derived Finding is already present in synthesis request (not via post-setter read)", () => { // Simulate: old findings = empty, but contribution produces a new finding. // The synthesis request MUST contain the newly derived finding. simulateAppendWithSynthesis( [], // no existing findings — simulates stale pre-setter state [], // same — if we read stale state this would be wrong { targetNodeId: "n-9", observations: ["Derive me now"] }, ); expect(capturedFetchCalls[0].findings).toHaveLength(1); expect(capturedFetchCalls[0].findings[0].sourceObservation).toBe("Derive me now"); }); /* ── D: multiple Findings still one call ─────────────── */ it("D — one Contribution derives multiple Findings → synthesis called exactly once", () => { const contrib = { targetNodeId: "n-2", observations: ["Fact A", "Fact B"] }; simulateAppendWithSynthesis([], [], contrib); expect(capturedFetchCalls).toHaveLength(1); expect(capturedFetchCalls[0].findings).toHaveLength(2); }); /* ── E: zero new Findings ───────────────────────────── */ it("E — Contribution with empty observations → synthesis NOT called", () => { const contrib = { targetNodeId: "n-3", observations: [] }; simulateAppendWithSynthesis([], [], contrib); expect(capturedFetchCalls).toHaveLength(0); }); /* ── F: narrative replacement ───────────────────────── */ it("F — synthesis response replaces CU exactly (no append)", async () => { capturedCU = "old understanding"; // pre-setter value const contrib = { targetNodeId: "n-4", observations: ["Reconstruction fact"] }; simulateAppendWithSynthesis([], [], contrib); await new Promise((r) => setTimeout(r, 10)); // settle microtask expect(capturedCU).toBe("Reconstructed understanding."); expect(capturedCU).not.toContain("old"); }); /* ── G: synthesis failure ───────────────────────────── */ it("G — synthesis failure preserves Contribution, Findings, and previous CU", async () => { capturedCU = "previous understanding"; // Override fetch for this test to simulate failure global.fetch = vi.fn(async (url) => { if (url === "/api/cases/synthesis") { return new Response( JSON.stringify({ success: false, error: "provider timeout" }), { status: 503 }, ); } return new Response(JSON.stringify({}), { status: 200 }); }); const contrib = { targetNodeId: "n-5", observations: ["Failure test fact"] }; simulateAppendWithSynthesis([], [], contrib); await new Promise((r) => setTimeout(r, 10)); // settle microtask // CU unchanged expect(capturedCU).toBe("previous understanding"); // synthesis was called exactly once (we know from capturedFetchCalls) // We verified the failure scenario — no fallback append occurred. expect(global.fetch).toHaveBeenCalledTimes(1); }); }); // ── Correction trigger tests (v0.50) ──────────────────── describe("Synthesis trigger — corrected Finding (CORRECTION-A)", () => { let capturedFetchCalls; let capturedCU; beforeEach(() => { capturedFetchCalls = []; capturedCU = "previous understanding"; global.fetch = vi.fn(async (url, init) => { if (url === "/api/cases/synthesis") { capturedFetchCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ currentUnderstanding: "Reconstructed from corrected findings." }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return new Response(JSON.stringify({}), { status: 200 }); }); }); function simulateCorrectionWithSynthesis(findings, findingId, newProposition, situationGraph) { // Mirror updateFindingProposition body: explicit next state const nextFindings = findings.map((f) => f.id === findingId ? { ...f, proposition: newProposition, userDisposition: null } : f, ); if (!situationGraph) return { findings: nextFindings }; void synthesizeFromFindings(fetch, { situationGraph, findings: normalizeFindings(nextFindings), }).then((res) => { if (res.ok && res.data?.currentUnderstanding) { capturedCU = res.data.currentUnderstanding; } }); return { findings: nextFindings }; } /* ── Case 1: Not quite alone ───────────────────────── */ it("Case 1 — clicking Not quite produces 0 synthesis calls", () => { // Clicking Notquite only calls startEditing locally in reasoning-workspace. // No parent callback (updateFindingProposition) is invoked. // Therefore no synthesis occurs. expect(capturedFetchCalls).toHaveLength(0); }); /* ── Case 2: save correction ─────────────────────── */ it("Case 2 — saving corrected proposition triggers synthesis", async () => { const originalProposition = "Revenue dropped 22%"; const correctedProposition = "Revenue dropped approximately 18% in Q3"; const findingId = "finding-001"; const existingFindings = [ { id: findingId, proposition: originalProposition, userDisposition: "not_quite", sourceObservation: originalProposition, contributionId: "contrib-0001", originatingTargetNodeId: "n-a", }, ]; simulateCorrectionWithSynthesis( existingFindings, findingId, correctedProposition, { centralStatement: "Q3 financial decline" }, ); // synthesis called exactly once expect(capturedFetchCalls).toHaveLength(1); // correct proposition sent expect(capturedFetchCalls[0].findings[0].proposition).toBe(correctedProposition); expect(capturedFetchCalls[0].findings[0].userDisposition).toBeNull(); // userDisposition reset to null on correction save // same id preserved expect(capturedFetchCalls[0].findings[0].id).toBe(findingId); // same sourceObservation preserved expect(capturedFetchCalls[0].findings[0].sourceObservation).toBe(originalProposition); // old proposition not present for this finding expect(capturedFetchCalls[0].findings[0].proposition).not.toBe(originalProposition); // await microtask to complete CU update await new Promise((r) => setTimeout(r, 10)); expect(capturedCU).toBe("Reconstructed from corrected findings."); expect(capturedCU).not.toContain("previous"); }); /* ── Case 3: multiple Findings ───────────────────── */ it("Case 3 — correcting one Finding sends all canonical Findings", async () => { const findingA = "finding-aaa"; const findingB = "finding-bbb"; const findingC = "finding-ccc"; const existingFindings = [ { id: findingA, proposition: "Fact A originally", userDisposition: null, sourceObservation: "Fact A originally" }, { id: findingB, proposition: "Fact B originally", userDisposition: "agree", sourceObservation: "Fact B originally" }, { id: findingC, proposition: "Fact C original text", userDisposition: null, sourceObservation: "Fact C original text" }, ]; simulateCorrectionWithSynthesis( existingFindings, findingB, "Fact B corrected version", { centralStatement: "Multi-finding scenario" }, ); expect(capturedFetchCalls).toHaveLength(1); expect(capturedFetchCalls[0].findings).toHaveLength(3); // Only findingB changed expect(capturedFetchCalls[0].findings.find((f) => f.id === findingA).proposition).toBe("Fact A originally"); expect(capturedFetchCalls[0].findings.find((f) => f.id === findingB).proposition).toBe("Fact B corrected version"); expect(capturedFetchCalls[0].findings.find((f) => f.id === findingC).proposition).toBe("Fact C original text"); // Only findingB has userDisposition reset to null (normalised away) const bFinding = capturedFetchCalls[0].findings.find((f) => f.id === findingB); expect(bFinding.userDisposition).toBeNull(); }); /* ── Case 4: synthesis success replaces CU ───────── */ it("Case 4 — successful synthesis replaces Current Understanding", async () => { capturedCU = "old evidence block text"; const existingFindings = [ { id: "f-1", proposition: "Original text", userDisposition: "not_quite", sourceObservation: "Original text" }, ]; simulateCorrectionWithSynthesis( existingFindings, "f-1", "Corrected text", { centralStatement: "test" }, ); await new Promise((r) => setTimeout(r, 10)); expect(capturedCU).toBe("Reconstructed from corrected findings."); expect(capturedCU).not.toContain("old"); expect(capturedCU).not.toContain("evidence block text"); }); /* ── Case 5: synthesis failure semantics ─────────── */ it("Case 5 — on synthesis failure: correction preserved, CU unchanged, no retry", async () => { capturedCU = "previous understanding"; // Override fetch to fail (but still capture the call) global.fetch = vi.fn(async (url, init) => { if (url === "/api/cases/synthesis") { capturedFetchCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ success: false, error: "provider timeout" }), { status: 503 }, ); } return new Response(JSON.stringify({}), { status: 200 }); }); const originalProposition = "Fact X"; const existingFindings = [ { id: "f-1", proposition: originalProposition, userDisposition: null, sourceObservation: originalProposition }, ]; const result = simulateCorrectionWithSynthesis( existingFindings, "f-1", "Corrected Fact X", { centralStatement: "test" }, ); // Correction was applied to nextFindings locally (explicit state) expect(result.findings[0].proposition).toBe("Corrected Fact X"); expect(result.findings[0].userDisposition).toBeNull(); expect(result.findings[0].id).toBe("f-1"); expect(result.findings[0].sourceObservation).toBe(originalProposition); await new Promise((r) => setTimeout(r, 10)); // CU unchanged — no legacy append fallback expect(capturedCU).toBe("previous understanding"); // synthesis called exactly once (no automatic retry) expect(global.fetch).toHaveBeenCalledTimes(1); // Request body contains corrected proposition, not stale original const reqFindings = capturedFetchCalls[0].findings; expect(reqFindings.length).toBeGreaterThan(0); }); }); // ── Not Relevant synthesis trigger tests (v0.50) ──────────── describe("Synthesis trigger — Not Relevant disposition (NOTREL-A)", () => { let capturedFetchCalls; let capturedCU; beforeEach(() => { capturedFetchCalls = []; capturedCU = "previous understanding"; global.fetch = vi.fn(async (url, init) => { if (url === "/api/cases/synthesis") { capturedFetchCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ currentUnderstanding: "Reconstructed from not relevant findings." }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return new Response(JSON.stringify({}), { status: 200 }); }); }); function simulateNotRelevantWithSynthesis(findings, findingId, situationGraph) { // Mirror updateFindingDisposition body for not_relevant trigger: explicit next state const nextFindings = findings.map((f) => f.id === findingId ? { ...f, userDisposition: "not_relevant" } : f, ); if (!situationGraph) return { findings: nextFindings }; void synthesizeFromFindings(fetch, { situationGraph, findings: normalizeFindings(nextFindings), }).then((res) => { if (res.ok && res.data?.currentUnderstanding) { capturedCU = res.data.currentUnderstanding; } }); return { findings: nextFindings }; } function simulateRestoreWithSynthesis(findings, findingId, situationGraph) { // Mirror updateFindingDisposition body for Restore trigger: explicit next state const nextFindings = findings.map((f) => f.id === findingId ? { ...f, userDisposition: null } : f, ); if (!situationGraph) return { findings: nextFindings }; void synthesizeFromFindings(fetch, { situationGraph, findings: normalizeFindings(nextFindings), }).then((res) => { if (res.ok && res.data?.currentUnderstanding) { capturedCU = res.data.currentUnderstanding; } }); return { findings: nextFindings }; } /* ── Case 1: eligible Finding → Not Relevant triggers synthesis ─────────────── */ it("Case 1 — clicking Not relevant on eligible Finding produces exactly 1 synthesis call", () => { const existingFindings = [ { id: "f-1", proposition: "Revenue dropped 22%", userDisposition: null, sourceObservation: "Revenue dropped 22%" }, ]; simulateNotRelevantWithSynthesis( existingFindings, "f-1", { centralStatement: "Q3 financial decline" }, ); expect(capturedFetchCalls).toHaveLength(1); expect(capturedFetchCalls[0].findings[0].userDisposition).toBe("not_relevant"); }); /* ── Case 2: complete next state with multiple Findings ─────────────────────── */ it("Case 2 — mark A Not Relevant, synthesis receives current SituationGraph + complete Findings array", async () => { const findingA = "finding-A"; const findingB = "finding-B"; const findingC = "finding-C"; const existingFindings = [ { id: findingA, proposition: "Fact A", userDisposition: null, sourceObservation: "Fact A" }, { id: findingB, proposition: "Fact B", userDisposition: null, sourceObservation: "Fact B" }, { id: findingC, proposition: "Fact C", userDisposition: null, sourceObservation: "Fact C" }, ]; simulateNotRelevantWithSynthesis( existingFindings, findingA, { centralStatement: "Multi-finding scenario" }, ); expect(capturedFetchCalls).toHaveLength(1); expect(capturedFetchCalls[0].findings).toHaveLength(3); expect(capturedFetchCalls[0].findings.find((f) => f.id === findingA).userDisposition).toBe("not_relevant"); expect(capturedFetchCalls[0].findings.find((f) => f.id === findingB).userDisposition).toBeNull(); expect(capturedFetchCalls[0].findings.find((f) => f.id === findingC).userDisposition).toBeNull(); await new Promise((r) => setTimeout(r, 10)); expect(capturedCU).toBe("Reconstructed from not relevant findings."); }); /* ── Case 3: identity/provenance preservation ───────────────────────────────── */ it("Case 3 — target Finding preserves id, proposition, sourceObservation, contributionId", async () => { const findingId = "finding-provenance-001"; const proposition = "Market share eroded by competitor pricing"; const sourceObservation = "Competitor X launched aggressive Q3 pricing campaign"; const contributionId = "contrib-0042"; const existingFindings = [ { id: findingId, proposition, userDisposition: null, sourceObservation, contributionId }, ]; const result = simulateNotRelevantWithSynthesis( existingFindings, findingId, { centralStatement: "test" }, ); expect(result.findings[0].id).toBe(findingId); expect(result.findings[0].proposition).toBe(proposition); expect(result.findings[0].sourceObservation).toBe(sourceObservation); expect(result.findings[0].contributionId).toBe(contributionId); expect(result.findings[0].userDisposition).toBe("not_relevant"); await new Promise((r) => setTimeout(r, 10)); expect(capturedCU).toBe("Reconstructed from not relevant findings."); }); /* ── Case 4: synthesis success replaces CU exactly (no append) ──────────────── */ it("Case 4 — successful synthesis replaces Current Understanding", async () => { capturedCU = "old evidence block text"; const existingFindings = [ { id: "f-1", proposition: "Some fact", userDisposition: null, sourceObservation: "Some fact" }, ]; simulateNotRelevantWithSynthesis( existingFindings, "f-1", { centralStatement: "test" }, ); await new Promise((r) => setTimeout(r, 10)); expect(capturedCU).toBe("Reconstructed from not relevant findings."); expect(capturedCU).not.toContain("old"); expect(capturedCU).not.toContain("evidence block text"); }); /* ── Case 5: synthesis failure semantics ────────────────────────────────────── */ it("Case 5 — on synthesis failure: not_relevant preserved, previous CU remains, no retry", async () => { capturedCU = "previous understanding"; // Override fetch to simulate non-2xx failure global.fetch = vi.fn(async (url, init) => { if (url === "/api/cases/synthesis") { capturedFetchCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ success: false, error: "provider timeout" }), { status: 503 }, ); } return new Response(JSON.stringify({}), { status: 200 }); }); const existingFindings = [ { id: "f-1", proposition: "Fact X", userDisposition: null, sourceObservation: "Fact X" }, ]; const result = simulateNotRelevantWithSynthesis( existingFindings, "f-1", { centralStatement: "test" }, ); // Finding remains not_relevant expect(result.findings[0].userDisposition).toBe("not_relevant"); await new Promise((r) => setTimeout(r, 10)); // Previous CU preserved — no fallback append expect(capturedCU).toBe("previous understanding"); // synthesis called exactly once (no automatic retry) expect(global.fetch).toHaveBeenCalledTimes(1); }); /* ── Case 6: Restore remains unwired for this increment ─────────────────────── */ /* ── Case 6: Restore triggers synthesis once ─────────────────────────── */ it("Case 6 — clicking Restore on not_relevant Finding triggers exactly 1 synthesis call", async () => { const existingFindings = [ { id: "f-1", proposition: "Fact X", userDisposition: "not_relevant", sourceObservation: "Fact X" }, ]; const prevCU = capturedCU; simulateRestoreWithSynthesis( existingFindings, "f-1", { centralStatement: "Test" }, ); await new Promise((r) => setTimeout(r, 0)); expect(capturedFetchCalls).toHaveLength(1); expect(capturedFetchCalls[0].findings[0].userDisposition).toBeNull(); expect(capturedCU).not.toBe(prevCU); }); /* ── Case 7: complete nextFindings payload ─────────────────────────── */ it("Case 7 — synthesis receives complete Findings array with Restore", async () => { const findingA = "finding-A"; const findingB = "finding-B"; const findingC = "finding-C"; const existingFindings = [ { id: findingA, proposition: "Fact A", userDisposition: "not_relevant", sourceObservation: "Obs A" }, { id: findingB, proposition: "Fact B", userDisposition: null, sourceObservation: "Obs B" }, { id: findingC, proposition: "Fact C", userDisposition: "agree", sourceObservation: "Obs C" }, ]; simulateRestoreWithSynthesis( existingFindings, findingA, { centralStatement: "Multi-finding scenario" }, ); expect(capturedFetchCalls).toHaveLength(1); expect(capturedFetchCalls[0].findings).toHaveLength(3); expect(capturedFetchCalls[0].findings.find((f) => f.id === findingA).userDisposition).toBeNull(); expect(capturedFetchCalls[0].findings.find((f) => f.id === findingB).userDisposition).toBeNull(); expect(capturedFetchCalls[0].findings.find((f) => f.id === findingC).userDisposition).toBe("agree"); }); /* ── Case 8: identity/provenance preserved on Restore ─────────────── */ it("Case 8 — restored Finding preserves id, proposition, sourceObservation, contributionId", async () => { const existingFindings = [ { id: "f-restore-id", proposition: "Proposition A", userDisposition: "not_relevant", sourceObservation: "Source Obs A", contributionId: "contrib-0001", origin: "manual", }, ]; simulateRestoreWithSynthesis( existingFindings, "f-restore-id", { centralStatement: "Identity test" }, ); const sent = capturedFetchCalls[0].findings[0]; expect(sent.id).toBe("f-restore-id"); expect(sent.proposition).toBe("Proposition A"); expect(sent.sourceObservation).toBe("Source Obs A"); expect(sent.contributionId).toBe("contrib-0001"); expect(sent.userDisposition).toBeNull(); }); /* ── Case 9: successful reconstruction replaces CU exactly ──────── */ it("Case 9 — synthesis success replaces Current Understanding (no append)", async () => { const existingFindings = [ { id: "f-1", proposition: "X", userDisposition: "not_relevant", sourceObservation: "X" }, ]; simulateRestoreWithSynthesis( existingFindings, "f-1", { centralStatement: "CU replacement test" }, ); await new Promise((r) => setTimeout(r, 0)); expect(capturedCU).toBe("Reconstructed from not relevant findings."); }); /* ── Case 10: synthesis failure preserves restored Finding + previous CU ─ */ it("Case 10 — on synthesis failure: Finding remains restored, previous CU preserved, no retry", async () => { global.fetch.mockClear(); capturedFetchCalls = []; capturedCU = "previous understanding"; let callCount = 0; global.fetch = vi.fn(async (url, init) => { if (url === "/api/cases/synthesis") { callCount++; capturedFetchCalls.push(JSON.parse(init.body)); return new Response( JSON.stringify({ error: "synthesis failed" }), { status: 500, headers: { "Content-Type": "application/json" } }, ); } return new Response(JSON.stringify({}), { status: 200 }); }); const existingFindings = [ { id: "f-1", proposition: "X", userDisposition: "not_relevant", sourceObservation: "X" }, ]; simulateRestoreWithSynthesis( existingFindings, "f-1", { centralStatement: "Failure test" }, ); await new Promise((r) => setTimeout(r, 0)); expect(callCount).toBe(1); expect(capturedCU).toBe("previous understanding"); }); /* ── Case 11: null → null no-op does not synthesize ─────────── */ it("Case 11 — null → null does NOT trigger synthesis", async () => { // Direct handler-level test: transition where previousDisposition === null const nextFindings = [ { id: "f-1", proposition: "X", userDisposition: null, sourceObservation: "X" }, ]; const findingId = "f-1"; const newDisposition = null; const prevFinding = nextFindings.find((f) => f.id === findingId); const previousDisposition = prevFinding?.userDisposition; const eligibilityChanged = previousDisposition === "not_relevant" && newDisposition === null; expect(eligibilityChanged).toBe(false); }); /* ── Case 7: Not Relevant regression — eligible → not_relevant still triggers ─ */ it("Case 12 — eligible → not_relevant still produces exactly 1 synthesis call", () => { const existingFindings = [ { id: "f-reg", proposition: "Regression check", userDisposition: null, sourceObservation: "check" }, ]; simulateNotRelevantWithSynthesis( existingFindings, "f-reg", { centralStatement: "Regression test" }, ); expect(capturedFetchCalls).toHaveLength(1); }); });