Files
confidence-engine/tests/ui/scenario-form-finding-derivation.test.jsx
T

301 lines
12 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);
});
});