Architecture: after successful /api/cases/update, derive explicit nextGraph + nextFindings, call synthesizeFromFindings exactly once, replace Current Understanding with reconstruction result. Key invariants: - outcome.summary retired as final CU authority → always synthesis reconstruction - Explicit derived state (no React-state reread) for graph and findings - Previous CU preserved on synthesis failure (no fallback to outcome.summary) - Graph and Findings NOT lost on synthesis failure - saveInvestigation persistence uses currentUnderstanding, not outcome.summary Deterministic regression: 7 tests (Cases A-E + 2 edges) covering all rules. Files: components/scenario-form.jsx, tests/ui/scenario-form-case-update-synthesis.test.jsx
340 lines
13 KiB
React
340 lines
13 KiB
React
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);
|
|
});
|
|
});
|