|
|
|
@@ -298,3 +298,207 @@ describe("Synthesis trigger — focused findings commit (STATE-B)", () => {
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|