868 lines
33 KiB
React
868 lines
33 KiB
React
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(
|
|
<ScenarioResultPanels
|
|
status="error"
|
|
result={{ error: "Test error" }}
|
|
/>,
|
|
);
|
|
|
|
expect(html).toContain("Error: Test error");
|
|
});
|
|
|
|
it("no UI behaviour changes — UpdateErrorPanel still renders correctly", () => {
|
|
const html = renderToStaticMarkup(
|
|
<UpdateErrorPanel
|
|
updateError={{ error: "Test update error" }}
|
|
/>,
|
|
);
|
|
|
|
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);
|
|
});
|
|
});
|