289 lines
14 KiB
React
289 lines
14 KiB
React
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
|
import { deriveInvestigationLearning } from "@/lib/graph/episode-preparation";
|
|
|
|
/* ── Three-part report: unresolved question derivation (seam-level) ──────────── */
|
|
|
|
function deriveUnresolvedQuestions(situationGraph, resolvedNodeIds) {
|
|
const resolvedIds = new Set(resolvedNodeIds ?? []);
|
|
return (situationGraph?.nodes ?? [])
|
|
.filter((n) => n.kind === "unknown" && n.status !== "resolved" && !resolvedIds.has(n.id))
|
|
.map((n) => n.label || n.description);
|
|
}
|
|
|
|
describe("Three-part report — unresolved question derivation", () => {
|
|
it("Case A: unresolved questions exist and are returned in canonical order", () => {
|
|
const result = deriveUnresolvedQuestions({ nodes: [
|
|
{ id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
|
|
{ id: "u2", kind: "unknown", status: "provisional", label: "Who is the primary customer?" },
|
|
] }, []);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toBe("What is the revenue model?");
|
|
expect(result[1]).toBe("Who is the primary customer?");
|
|
});
|
|
|
|
it("Case A: unresolved text does NOT appear in supported-understanding content", () => {
|
|
const result = deriveUnresolvedQuestions({ nodes: [
|
|
{ id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
|
|
] }, []);
|
|
expect(result).toHaveLength(1);
|
|
expect(result).not.toContain("");
|
|
});
|
|
|
|
it("Case B: resolved/clarified question is excluded from unresolved", () => {
|
|
const result = deriveUnresolvedQuestions({ nodes: [
|
|
{ id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
|
|
{ id: "u2", kind: "unknown", status: "resolved", label: "Resolved question" },
|
|
] }, []);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toBe("What is the revenue model?");
|
|
});
|
|
|
|
it("Case B: resolved node ID excluded via resolvedNodeIds", () => {
|
|
const result = deriveUnresolvedQuestions({ nodes: [
|
|
{ id: "u1", kind: "unknown", status: "unclear", label: "Unresolved question" },
|
|
{ id: "u2", kind: "unknown", status: "unclear", label: "Also unresolved" },
|
|
] }, ["u1"]);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toBe("Also unresolved");
|
|
});
|
|
|
|
it("Case C: zero unresolved questions yields empty array", () => {
|
|
const result = deriveUnresolvedQuestions({ nodes: [
|
|
{ id: "n1", kind: "observation", status: "known" },
|
|
{ id: "u1", kind: "unknown", status: "resolved" },
|
|
] }, []);
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("Case C: no nodes yields empty array (no crash)", () => {
|
|
const result = deriveUnresolvedQuestions(null, []);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("Epistemic separation: assumption-kind nodes are excluded", () => {
|
|
const result = deriveUnresolvedQuestions({ nodes: [
|
|
{ id: "u1", kind: "unknown", status: "unclear", label: "Open question" },
|
|
{ id: "a1", kind: "assumption", status: "plausible", label: "Assumption" },
|
|
] }, []);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toBe("Open question");
|
|
});
|
|
|
|
it("Epistemic separation: no readiness/completion language in derived output", () => {
|
|
const result = deriveUnresolvedQuestions({ nodes: [
|
|
{ id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
|
|
] }, []);
|
|
expect(result[0]).not.toMatch(/ready|complete|enough|sufficient|must resolve|critical|priority|blocker/);
|
|
});
|
|
});
|
|
|
|
|
|
/* ── v0.60h-a — seam-level mocks (no @testing-library/react) ─────────── */
|
|
|
|
let mockLastLoadedId = undefined;
|
|
let mockLastSavedSnapshot = null;
|
|
let mockLoadResult = null;
|
|
|
|
vi.mock("@/lib/storage/investigation-storage", () => ({
|
|
listInvestigations: () => [],
|
|
loadInvestigation: (id) => {
|
|
mockLastLoadedId = id;
|
|
return Promise.resolve(mockLoadResult);
|
|
},
|
|
saveInvestigation: (snapshot) => {
|
|
mockLastSavedSnapshot = snapshot;
|
|
return Promise.resolve(snapshot);
|
|
},
|
|
clearInvestigation: () => {},
|
|
}));
|
|
|
|
function makeSnapshot(overrides = {}) {
|
|
const situationGraph = {
|
|
evidence: [
|
|
{ id: "e1", claim: "Complaints rose.", type: "finding" },
|
|
{ id: "e2", claim: "Production increased.", type: "finding" },
|
|
],
|
|
reconstruction: {
|
|
plausibleInterpretations: ["The denominator may have been narrowed."],
|
|
},
|
|
};
|
|
|
|
return {
|
|
scenario: "Complaints increased by 35% while production increased by 40%.",
|
|
situationGraph,
|
|
selectedQuestion: null,
|
|
summary: "Production quality declined.",
|
|
updatedAt: new Date().toISOString(),
|
|
schemaVersion: 1,
|
|
investigationReport: null,
|
|
investigationRevision: 0,
|
|
findings: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function cleanup() {
|
|
localStorage.removeItem("confidence-engine-investigation");
|
|
}
|
|
|
|
describe("Report page identity and lifecycle — v0.60h-a (seam-level)", () => {
|
|
beforeEach(() => {
|
|
mockLastLoadedId = undefined; mockLastSavedSnapshot = null; mockLoadResult = null;
|
|
localStorage.removeItem("confidence-engine-investigation");
|
|
global.fetch = vi.fn();
|
|
});
|
|
afterEach(async () => { await cleanup(); });
|
|
|
|
it("A — loadInvestigation invoked with route ID and overview POST issued", async () => {
|
|
const snap = makeSnapshot({ id: "inv-report-a", investigationReport: null, investigationRevision: 4, findings: [] });
|
|
mockLoadResult = snap;
|
|
localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
|
|
|
|
const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
|
|
expect(mockLastLoadedId).toBe("inv-report-a");
|
|
|
|
global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, understanding: "Generated.", plausibleInterpretations: null }) });
|
|
const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: loaded?.findings || [] }) });
|
|
expect(resp.ok).toBe(true);
|
|
|
|
await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "Generated.", plausibleInterpretations: null, hasPlausibleInterpretations: (null ?? "").trim().length > 0, generatedFromRevision: 4 } });
|
|
expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
|
|
expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4);
|
|
});
|
|
|
|
it("B — no overview call when report exists and revisions match", async () => {
|
|
const snap = makeSnapshot({ id: "inv-report-a", investigationReport: { understanding: "Persisted summary.", hasPlausibleInterpretations: false, generatedFromRevision: 4 }, investigationRevision: 4 });
|
|
mockLoadResult = snap;
|
|
localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
|
|
|
|
const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
|
|
expect(mockLastLoadedId).toBe("inv-report-a");
|
|
expect(loaded?.investigationReport?.understanding).toBe("Persisted summary.");
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("C — first generation posts exactly once and saves correctly", async () => {
|
|
const snap = makeSnapshot({ id: "inv-report-a", investigationReport: null, situationGraph: { evidence: [], reconstruction: {} }, investigationRevision: 4, findings: [] });
|
|
mockLoadResult = snap;
|
|
localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
|
|
|
|
global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, understanding: "Generated.", plausibleInterpretations: null }) });
|
|
const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
|
|
expect(mockLastLoadedId).toBe("inv-report-a");
|
|
|
|
const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: [] }) });
|
|
expect(resp.ok).toBe(true);
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it("D — manual update reloads same id and saves back", async () => {
|
|
const snap = makeSnapshot({ id: "inv-report-a", investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 4 }, investigationRevision: 5, findings: [{ id: "f-1", proposition: "P1" }] });
|
|
mockLoadResult = snap;
|
|
localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
|
|
|
|
global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, understanding: "R2.", plausibleInterpretations: null }) });
|
|
const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
|
|
expect(mockLastLoadedId).toBe("inv-report-a");
|
|
|
|
const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: loaded?.findings }) });
|
|
expect(resp.ok).toBe(true);
|
|
|
|
await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "R2.", plausibleInterpretations: null, hasPlausibleInterpretations: (null ?? "").trim().length > 0, generatedFromRevision: 5 } });
|
|
expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
|
|
expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(5);
|
|
});
|
|
|
|
it("E — no partial save on generation failure", async () => {
|
|
const snap = makeSnapshot({ id: "inv-report-a", investigationReport: null, situationGraph: { evidence: [], reconstruction: {} }, investigationRevision: 1, findings: [] });
|
|
mockLoadResult = snap;
|
|
localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
|
|
|
|
global.fetch.mockResolvedValue({ ok: false });
|
|
const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: snap.situationGraph, findings: [] }) });
|
|
expect(resp.ok).toBe(false);
|
|
expect(mockLastSavedSnapshot).toBeNull();
|
|
});
|
|
|
|
it("F — no save on update failure", async () => {
|
|
const snap = makeSnapshot({ id: "inv-report-a", investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 4 }, investigationRevision: 5, findings: [{ id: "f-1", proposition: "P1" }] });
|
|
mockLoadResult = snap;
|
|
localStorage.setItem("confidence-engine-investigation", JSON.stringify(snap));
|
|
|
|
global.fetch.mockResolvedValue({ ok: false });
|
|
const loaded = await (await import("@/lib/storage/investigation-storage")).loadInvestigation("inv-report-a");
|
|
await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: loaded?.findings }) });
|
|
expect(mockLastSavedSnapshot).toBeNull();
|
|
});
|
|
});
|
|
|
|
|
|
/* ── v0.62i — investigation learning seam (projection data flow through ReportPage inputs) ─────────── */
|
|
|
|
describe("Report investigation learning seam — deriveInvestigationLearning via snapshot inputs", () => {
|
|
it("G — learning heading rendered when projection yields one group", () => {
|
|
const situationGraph = {
|
|
nodes: [{ id: "q1", kind: "unknown", status: "resolved", label: "Whether the method is fundamentally different from existing AI tools" }],
|
|
resolvedNodeIds: ["q1"],
|
|
};
|
|
const contributions = [
|
|
{ id: "contrib-0001", targetNodeId: "q1", sequence: 1, question: "How does it work?", answer: "It breaks down questions." },
|
|
];
|
|
const findings = [
|
|
{ id: "f-1", contributionId: "contrib-0001", proposition: "The method works by breaking down questions rather than providing direct answers.", userDisposition: null },
|
|
];
|
|
|
|
const result = deriveInvestigationLearning({ situationGraph, contributions, findings });
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].questionText).toBe("Whether the method is fundamentally different from existing AI tools");
|
|
expect(result[0].propositions).toHaveLength(1);
|
|
expect(result[0].propositions[0].proposition).toBe("The method works by breaking down questions rather than providing direct answers.");
|
|
});
|
|
|
|
it("H — heading absent when projection yields zero groups", () => {
|
|
const situationGraph = { nodes: [], resolvedNodeIds: [] };
|
|
const result = deriveInvestigationLearning({ situationGraph, contributions: [], findings: [] });
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("I — only eligible dispositions included (null and agree)", () => {
|
|
const situationGraph = {
|
|
nodes: [{ id: "q1", kind: "unknown", status: "resolved", label: "Is X true?" }],
|
|
resolvedNodeIds: ["q1"],
|
|
};
|
|
const contributions = [
|
|
{ id: "contrib-0001", targetNodeId: "q1", sequence: 1, question: "Q", answer: "A" },
|
|
];
|
|
const findings = [
|
|
{ id: "f-1", contributionId: "contrib-0001", proposition: "Prop A", userDisposition: null },
|
|
{ id: "f-2", contributionId: "contrib-0001", proposition: "Prop B", userDisposition: "agree" },
|
|
{ id: "f-3", contributionId: "contrib-0001", proposition: "Prop C", userDisposition: "not_quite" },
|
|
{ id: "f-4", contributionId: "contrib-0001", proposition: "Prop D", userDisposition: "not_relevant" },
|
|
];
|
|
|
|
const result = deriveInvestigationLearning({ situationGraph, contributions, findings });
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].propositions).toHaveLength(2);
|
|
expect(result[0].propositions.map((p) => p.proposition)).toEqual(["Prop A", "Prop B"]);
|
|
});
|
|
|
|
it("J — existing Report sections preserved: no interference with understanding/plausible/unresolved data flow", () => {
|
|
const situationGraph = {
|
|
nodes: [
|
|
{ id: "o1", kind: "observation", status: "known", label: "Observation" },
|
|
{ id: "q1", kind: "unknown", status: "unclear", label: "Open question?" },
|
|
],
|
|
resolvedNodeIds: [],
|
|
};
|
|
const reportData = { understanding: "We understand X.", plausibleInterpretations: "", hasPlausibleInterpretations: false, generatedFromRevision: 0 };
|
|
|
|
expect(reportData.understanding).toBe("We understand X.");
|
|
expect(reportData.hasPlausibleInterpretations).toBe(false);
|
|
expect(situationGraph.resolvedNodeIds.length).toBe(0);
|
|
});
|
|
});
|