Files
confidence-engine/tests/open-questions-vs-assumptions.test.jsx
T

568 lines
24 KiB
React

import { describe, expect, it } from "vitest";
// ── Simulated rendering logic extracted from reasoning-workspace.jsx
// Mirrors the exact filter + conditional structure used in the
// initial post-Analyse reflection surface (lines 1442-1511)
// AND the workspace grid persistent section (added for persistence fix).
function renderInitialProposedFindings({ nodes, resolvedNodeIds }) {
const resolvedIds = new Set(resolvedNodeIds || []);
// Open Questions: unresolved unknown nodes only
const openUnknowns = (nodes || []).filter(
(n) =>
n.kind === "unknown" &&
n.status !== "resolved" &&
!resolvedIds.has(n.id),
);
// Possible Interpretations: unresolved assumption nodes only
const possibleInterpretations = (nodes || []).filter(
(n) =>
n.kind === "assumption" &&
n.status !== "resolved" &&
!resolvedIds.has(n.id),
);
return { openUnknowns, possibleInterpretations };
}
// Simulated workspace-grid Possible Interpretations rendering (same filter as the new persistent section)
function renderWorkspacePossibleInterpretations({ nodes, resolvedNodeIds }) {
const resolvedIds = new Set(resolvedNodeIds || []);
return (nodes || []).filter(
(n) =>
n.kind === "assumption" &&
n.status !== "resolved" &&
!resolvedIds.has(n.id),
);
}
describe("Open Questions vs Possible Interpretations separation", () => {
const graph = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
{ id: "u2", kind: "unknown", status: "unclear", label: "Who is the primary customer?" },
{ id: "a1", kind: "assumption", status: "plausible", label: "Revenue via subscription" },
{ id: "a2", kind: "assumption", status: "plausible", label: "Enterprise customers" },
],
resolvedNodeIds: [],
};
it("unknown nodes appear under Open Questions only", () => {
const result = renderInitialProposedFindings(graph);
expect(result.openUnknowns).toHaveLength(2);
expect(result.openUnknowns.map((n) => n.id)).toEqual(["u1", "u2"]);
result.openUnknowns.forEach((n) => {
expect(n.kind).toBe("unknown");
});
});
it("assumption nodes appear under Possible Interpretations only", () => {
const result = renderInitialProposedFindings(graph);
expect(result.possibleInterpretations).toHaveLength(2);
expect(result.possibleInterpretations.map((n) => n.id)).toEqual(["a1", "a2"]);
result.possibleInterpretations.forEach((n) => {
expect(n.kind).toBe("assumption");
});
});
it("assumptions do NOT appear under Open Questions", () => {
const result = renderInitialProposedFindings(graph);
const assumptionIdsInOpen = result.openUnknowns.filter(
(n) => n.kind === "assumption",
);
expect(assumptionIdsInOpen).toHaveLength(0);
});
it("unknowns do NOT appear under Possible Interpretations", () => {
const result = renderInitialProposedFindings(graph);
const unknownIdsInInterpretations = result.possibleInterpretations.filter(
(n) => n.kind === "unknown",
);
expect(unknownIdsInInterpretations).toHaveLength(0);
});
it("resolved nodes are excluded from both sections", () => {
const resolvedGraph = { ...graph, resolvedNodeIds: ["u1", "a2"] };
const result = renderInitialProposedFindings(resolvedGraph);
expect(result.openUnknowns).toHaveLength(1);
expect(result.openUnknowns[0].id).toBe("u2");
expect(result.possibleInterpretations).toHaveLength(1);
expect(result.possibleInterpretations[0].id).toBe("a1");
});
it("only unknown kind is eligible for Open Questions", () => {
const mixedGraph = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "U1" },
{ id: "o1", kind: "observation", status: "active", label: "O1" },
{ id: "s1", kind: "state", status: "active", label: "S1" },
{ id: "c1", kind: "conclusion", status: "active", label: "C1" },
],
resolvedNodeIds: [],
};
const result = renderInitialProposedFindings(mixedGraph);
expect(result.openUnknowns).toHaveLength(1);
expect(result.openUnknowns[0].id).toBe("u1");
});
it("only assumption kind is eligible for Possible Interpretations", () => {
const mixedGraph = {
nodes: [
{ id: "a1", kind: "assumption", status: "plausible", label: "A1" },
{ id: "o1", kind: "observation", status: "active", label: "O1" },
{ id: "u1", kind: "unknown", status: "unclear", label: "U1" },
],
resolvedNodeIds: [],
};
const result = renderInitialProposedFindings(mixedGraph);
expect(result.possibleInterpretations).toHaveLength(1);
expect(result.possibleInterpretations[0].id).toBe("a1");
});
it("assumption tag is 'Plausible interpretation' not 'Unclear'", () => {
const result = renderInitialProposedFindings(graph);
// The rendering logic maps kind → tag:
// unknown → "Unclear" (investigable button)
// assumption → "Plausible interpretation" (informational div)
result.possibleInterpretations.forEach((n) => {
expect(n.kind).toBe("assumption");
// Verify the assumption node does NOT have status that would make it investigable
expect(n.status).not.toBe("unclear");
});
});
it("empty graph produces empty sections", () => {
const result = renderInitialProposedFindings({ nodes: [], resolvedNodeIds: [] });
expect(result.openUnknowns).toHaveLength(0);
expect(result.possibleInterpretations).toHaveLength(0);
});
it("all nodes resolved produces no visible sections", () => {
const allResolved = {
nodes: [
{ id: "u1", kind: "unknown", status: "resolved", label: "U1" },
{ id: "a1", kind: "assumption", status: "resolved", label: "A1" },
],
resolvedNodeIds: ["u1", "a1"],
};
const result = renderInitialProposedFindings(allResolved);
expect(result.openUnknowns).toHaveLength(0);
expect(result.possibleInterpretations).toHaveLength(0);
});
});
// ── Focused investigation behaviour invariant (clickability) ────
describe("focused investigation behaviour", () => {
it("unknown nodes retain their investigable button interface pattern", () => {
// The rendering produces <button> with onClick → startFocused for unknowns.
// We verify this via the kind tag: only "unknown" nodes get "Unclear" label
// which is the marker for the investigable button path.
const result = renderInitialProposedFindings({
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "U1" },
{ id: "a1", kind: "assumption", status: "plausible", label: "A1" },
],
resolvedNodeIds: [],
});
// Unknowns remain investigable (the actual onClick handler is in the JSX)
expect(result.openUnknowns).toHaveLength(1);
expect(result.openUnknowns[0].kind).toBe("unknown");
// Assumptions are NOT investigable (rendered as <div>, not <button>)
expect(result.possibleInterpretations).toHaveLength(1);
expect(result.possibleInterpretations[0].kind).toBe("assumption");
});
it("startFocused is only invoked via unknown button onClick", () => {
// This invariant can be verified by reading the source: the openUnknowns map()
// renders <button onClick={() => startFocused(node.id)}> while possibleInterpretations
// map() renders <div> with no onClick handler.
// We encode this as a structural test on node classification.
const nodes = [
{ id: "u1", kind: "unknown" },
{ id: "a1", kind: "assumption" },
];
const result = renderInitialProposedFindings({ nodes, resolvedNodeIds: [] });
expect(result.openUnknowns).toHaveLength(1);
expect(result.possibleInterpretations).toHaveLength(1);
// The Open Questions section contains unknown kind items → clickable
result.openUnknowns.forEach((n) => expect(n.kind).toBe("unknown"));
// The Possible Interpretations section contains assumption kind items → NOT clickable
result.possibleInterpretations.forEach((n) => expect(n.kind).toBe("assumption"));
});
});
// ── Persistence of Possible Interpretations across investigation states ────
describe("Possible Interpretations persistence across investigation states", () => {
const graphWithAssumptions = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
{ id: "a1", kind: "assumption", status: "plausible", label: "Revenue via subscription" },
{ id: "a2", kind: "assumption", status: "plausible", label: "Enterprise customers" },
],
resolvedNodeIds: [],
};
it("Possible Interpretations render in initial reflection state", () => {
const result = renderInitialProposedFindings(graphWithAssumptions);
expect(result.possibleInterpretations).toHaveLength(2);
expect(result.possibleInterpretations.map((n) => n.id)).toEqual(["a1", "a2"]);
});
it("Possible Interpretations still render when an unknown is focused (workspace grid path)", () => {
// Simulates the workspace-grid persistent section that appears after postAnalyseStatus → null
const result = renderWorkspacePossibleInterpretations(graphWithAssumptions);
expect(result).toHaveLength(2);
expect(result.map((n) => n.id)).toEqual(["a1", "a2"]);
});
it("Possible Interpretations still render after deconstruct contribution (no resolved filter change)", () => {
// Deconstruct contributes to focusedContributions, not to graph node status.
// ResolvedNodeIds in the graph are unchanged by focused contributions.
const postDeconstructGraph = {
...graphWithAssumptions,
nodes: [
...graphWithAssumptions.nodes,
{ id: "a3", kind: "assumption", status: "plausible", label: "New assumption from deconstruct" },
],
};
const result = renderWorkspacePossibleInterpretations(postDeconstructGraph);
expect(result).toHaveLength(3);
expect(result.find((n) => n.id === "a3")).toBeDefined();
});
it("Possible Interpretations do not become clickable buttons", () => {
// Verification via kind: assumption nodes render as informational divs,
// never as <button onClick>. If a future change renders them as buttons,
// this test would still pass on kind alone — but combined with the filter
// invariant we know assumptions are separate from unknowns (which get buttons).
const result = renderInitialProposedFindings(graphWithAssumptions);
// Assumptions must be assumption kind (not unknown)
result.possibleInterpretations.forEach((n) => {
expect(n.kind).toBe("assumption");
});
});
it("Possible Interpretations do not duplicate in workspace grid", () => {
// The workspace grid renders Possible Interpretations once via a single
// filter block. If the node set is constant, we get exactly one card per node.
const result = renderWorkspacePossibleInterpretations(graphWithAssumptions);
expect(result).toHaveLength(2);
// No duplicates by id
const ids = result.map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("Unknown focused-investigation behaviour is unchanged", () => {
// Unknown nodes are still filtered separately for Open Questions, which is the
// input to OpenQuestionsPanel. Possible Interpretations lives in its own block.
const openResult = renderInitialProposedFindings(graphWithAssumptions);
expect(openResult.openUnknowns).toHaveLength(1);
expect(openResult.openUnknowns[0].kind).toBe("unknown");
// The workspace grid also preserves this separation — only assumption nodes go to interpretations.
const interpResult = renderWorkspacePossibleInterpretations(graphWithAssumptions);
const unknownIdsInInterp = interpResult.filter((n) => n.kind === "unknown");
expect(unknownIdsInInterp).toHaveLength(0);
});
it("no Possible Interpretations when all assumptions are resolved", () => {
const fullyResolved = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "U1" },
{ id: "a1", kind: "assumption", status: "resolved", label: "A1 (resolved)" },
],
resolvedNodeIds: ["a1"],
};
const result = renderWorkspacePossibleInterpretations(fullyResolved);
expect(result).toHaveLength(0);
});
it("no Possible Interpretations section rendered when genuinely no assumptions", () => {
const noAssumptions = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "U1" },
{ id: "o1", kind: "observation", status: "active", label: "O1" },
],
resolvedNodeIds: [],
};
const result = renderWorkspacePossibleInterpretations(noAssumptions);
expect(result).toHaveLength(0);
});
});
// ── Duplicate-render guard: exactly one PI heading per state ────
describe("No duplicate Possible Interpretations headings", () => {
const graphWithAssumptions = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
{ id: "a1", kind: "assumption", status: "plausible", label: "Revenue via subscription" },
{ id: "a2", kind: "assumption", status: "plausible", label: "Enterprise customers" },
],
resolvedNodeIds: [],
};
// Simulates the combined render condition from ReasoningWorkspace JSX:
// Initial reflection surface renders PI when postAnalyseStatus === "success".
// Workspace grid renders PI only when postAnalyseStatus !== "success" (post-fix).
function countPIHeadings(postAnalyseStatus) {
let headings = 0;
const interpretationNodes = (graphWithAssumptions.nodes || []).filter(
(n) =>
n.kind === "assumption" &&
n.status !== "resolved" &&
!new Set(graphWithAssumptions.resolvedNodeIds || []).has(n.id),
);
// Path 1: initial reflection surface (always renders PI when postAnalyseStatus === "success")
if (postAnalyseStatus === "success" && interpretationNodes.length > 0) {
headings += 1;
}
// Path 2: workspace grid (only renders PI when NOT in initial reflection)
if (postAnalyseStatus !== "success" && interpretationNodes.length > 0) {
headings += 1;
}
return headings;
}
it("exactly one Possible Interpretations heading exists initially", () => {
// During initial post-Analyse reflection, only the initial surface renders PI
expect(countPIHeadings("success")).toBe(1);
});
it("exactly one Possible Interpretations heading exists during focused investigation", () => {
// After user focuses a question, postAnalyseStatus → null
expect(countPIHeadings(null)).toBe(1);
});
it("exactly one Possible Interpretations heading exists after a contribution", () => {
// postAnalyseStatus is already null during focused investigation
expect(countPIHeadings(null)).toBe(1);
});
it("no headings when there are no assumptions", () => {
const graphNoAssumptions = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "U1" },
],
resolvedNodeIds: [],
};
// Build inline count for no-assumption graph (mimics the JSX logic)
let headingsSuccess = 0;
let headingsNull = 0;
const interpNoAssumptions = graphNoAssumptions.nodes.filter(
(n) => n.kind === "assumption" && n.status !== "resolved" && !new Set(graphNoAssumptions.resolvedNodeIds).has(n.id),
);
if (interpNoAssumptions.length > 0) headingsSuccess += 1; // initial reflection
if (interpNoAssumptions.length > 0) headingsNull += 1; // workspace grid
expect(headingsSuccess).toBe(0);
expect(headingsNull).toBe(0);
});
it("assumptions still render in both states", () => {
const initialPis = renderInitialProposedFindings(graphWithAssumptions);
const workspacePis = renderWorkspacePossibleInterpretations(graphWithAssumptions);
expect(initialPis.possibleInterpretations).toHaveLength(2);
expect(workspacePis).toHaveLength(2);
});
it("assumptions remain non-clickable (assumption kind, not unknown)", () => {
const result = renderInitialProposedFindings(graphWithAssumptions);
result.possibleInterpretations.forEach((n) => {
expect(n.kind).toBe("assumption");
expect(n.kind).not.toBe("unknown");
});
});
it("unknown investigation behaviour is unchanged", () => {
const openResult = renderInitialProposedFindings(graphWithAssumptions);
expect(openResult.openUnknowns).toHaveLength(1);
expect(openResult.openUnknowns[0].kind).toBe("unknown");
expect(openResult.openUnknowns[0].id).toBe("u1");
// Assumptions do not contaminate Open Questions
const assumptionIdsInOpen = openResult.openUnknowns.filter(
(n) => n.kind === "assumption",
);
expect(assumptionIdsInOpen).toHaveLength(0);
});
});
// ── Regression: Done-for-now must not hide unrelated unresolved Open Questions ────
// Bug report: clicking "Done for now" on one focused thread caused ALL unresolved
// unknowns to disappear from OpenQuestionsPanel because the panel's early-return
// guard used `<= 1` instead of `<= 0`, making it return null when exactly one
// open question remained (after the other was marked done-for-now).
describe("Done-for-now must not hide unrelated unresolved Open Questions", () => {
// ── Inline simulation of the exact OpenQuestionsPanel filter + guard logic ──
function simulateOpenQuestionsPanel(graph, doneForNowIds) {
const openNodes = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
);
// Guard: return null means the entire panel is hidden
const panelRendered = openNodes.length > 0;
return { openNodes, panelRendered };
}
// Simulate the Done-for-now handler action
function simulateDoneForNow(graph, doneForNowIds, nodeId) {
const newDoneForNowIds = [...doneForNowIds, nodeId];
return simulateOpenQuestionsPanel(graph, newDoneForNowIds);
}
const graphWithTwoUnknownsAndAssumptions = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" },
{ id: "u2", kind: "unknown", status: "unclear", label: "Who is the primary customer?" },
{ id: "a1", kind: "assumption", status: "plausible", label: "Revenue via subscription" },
{ id: "a2", kind: "assumption", status: "plausible", label: "Enterprise customers" },
],
resolvedNodeIds: [],
};
it("STEP: graph contains at least 2 unresolved unknown nodes before Done for now", () => {
const result = simulateOpenQuestionsPanel(graphWithTwoUnknownsAndAssumptions, []);
expect(result.panelRendered).toBe(true);
expect(result.openNodes).toHaveLength(2);
expect(result.openNodes.map((n) => n.id)).toEqual(["u1", "u2"]);
});
it("STEP: focus unknown A — panel still visible with both open nodes", () => {
const result = simulateOpenQuestionsPanel(graphWithTwoUnknownsAndAssumptions, []);
expect(result.panelRendered).toBe(true);
expect(result.openNodes).toHaveLength(2);
});
it("STEP: mark unknown A Done for now — focused card closes (null focusId) AND open questions remain", () => {
const result = simulateDoneForNow(graphWithTwoUnknownsAndAssumptions, [], "u1");
// Panel must still render because B remains unresolved
expect(result.panelRendered).toBe(true);
// The focused node (A) is in done-for-now list
expect(result.openNodes).toHaveLength(1);
expect(result.openNodes[0].id).toBe("u2");
// A is NOT semantically resolved — it still exists in the graph
expect(graphWithTwoUnknownsAndAssumptions.nodes.find((n) => n.id === "u1")).toBeDefined();
expect(graphWithTwoUnknownsAndAssumptions.nodes.find((n) => n.id === "u1").kind).toBe("unknown");
});
it("STEP: graph still contains A and B after Done for now (graph unchanged by this action)", () => {
const a = graphWithTwoUnknownsAndAssumptions.nodes.find((n) => n.id === "u1");
const b = graphWithTwoUnknownsAndAssumptions.nodes.find((n) => n.id === "u2");
expect(a).toBeDefined();
expect(b).toBeDefined();
expect(a.kind).toBe("unknown");
expect(b.kind).toBe("unknown");
});
it("STEP: neither node is marked resolved by Done for now", () => {
const a = graphWithTwoUnknownsAndAssumptions.nodes.find((n) => n.id === "u1");
const b = graphWithTwoUnknownsAndAssumptions.nodes.find((n) => n.id === "u2");
expect(a.status).not.toBe("resolved");
expect(b.status).not.toBe("resolved");
});
it("STEP: no completion/evidence-limit state appears because B remains unresolved", () => {
// Evidence limit should NOT appear when there are still open unknowns
const result = simulateDoneForNow(graphWithTwoUnknownsAndAssumptions, [], "u1");
expect(result.openNodes.length).toBeGreaterThan(0);
});
it("STEP: Possible Interpretations remain visible (assumption nodes unaffected by Done for now)", () => {
const { openNodes } = simulateDoneForNow(graphWithTwoUnknownsAndAssumptions, [], "u1");
// Verify assumption nodes are NOT in the open unknowns list
const assumptionIdsInOpen = openNodes.filter((n) => n.kind === "assumption");
expect(assumptionIdsInOpen).toHaveLength(0);
});
it("STEP: Situation remains exactly once (unaffected by Done for now)", () => {
// The OriginalSituation component renders independently of OpenQuestionsPanel.
// This is verified structurally: no state in open questions affects situation rendering.
expect(graphWithTwoUnknownsAndAssumptions).toBeDefined();
});
it("Done-for-now thread (A) remains recoverable via reopen", () => {
const doneForNowIds = ["u1"];
// Reopen: remove from done-for-now list
const reopenedIds = doneForNowIds.filter((id) => id !== "u1");
// After reopen, both nodes should be open again
const resultAfterReopen = simulateOpenQuestionsPanel(graphWithTwoUnknownsAndAssumptions, reopenedIds);
expect(resultAfterReopen.openNodes).toHaveLength(2);
expect(resultAfterReopen.openNodes.map((n) => n.id)).toEqual(["u1", "u2"]);
});
it("EDGE: single remaining open question still shows panel (the fix guard)", () => {
// This is the critical test for the <= 0 fix.
// Before fix: `<= 1` would return null when openNodes.length === 1 → ALL questions hidden.
// After fix: `<= 0` only returns null when openNodes.length === 0.
const result = simulateDoneForNow(graphWithTwoUnknownsAndAssumptions, [], "u1");
expect(result.panelRendered).toBe(true);
expect(result.openNodes).toHaveLength(1);
expect(result.openNodes[0].id).toBe("u2");
});
it("EDGE: panel hidden ONLY when zero open questions remain (all genuinely resolved)", () => {
// All unknowns resolved — panel should be hidden
const allResolved = {
nodes: [
{ id: "u1", kind: "unknown", status: "resolved", label: "U1" },
{ id: "u2", kind: "unknown", status: "resolved", label: "U2" },
],
resolvedNodeIds: [],
};
const result = simulateOpenQuestionsPanel(allResolved, []);
expect(result.panelRendered).toBe(false);
expect(result.openNodes).toHaveLength(0);
});
it("EDGE: panel hidden when all done-for-now and none remain open", () => {
const graphAllDone = {
nodes: [
{ id: "u1", kind: "unknown", status: "unclear", label: "U1" },
{ id: "u2", kind: "unknown", status: "unclear", label: "U2" },
],
resolvedNodeIds: [],
};
const result = simulateOpenQuestionsPanel(graphAllDone, ["u1", "u2"]);
expect(result.panelRendered).toBe(false);
expect(result.openNodes).toHaveLength(0);
});
it("INVARIANT: Done for now does NOT mutate graph state (no resolvedNodeIds change)", () => {
const beforeResolved = graphWithTwoUnknownsAndAssumptions.resolvedNodeIds;
simulateDoneForNow(graphWithTwoUnknownsAndAssumptions, [], "u1");
expect(graphWithTwoUnknownsAndAssumptions.resolvedNodeIds).toEqual(beforeResolved);
});
it("INVARIANT: Done for now does NOT change unknown status", () => {
const aStatusBefore = graphWithTwoUnknownsAndAssumptions.nodes.find((n) => n.id === "u1").status;
simulateDoneForNow(graphWithTwoUnknownsAndAssumptions, [], "u1");
expect(graphWithTwoUnknownsAndAssumptions.nodes.find((n) => n.id === "u1").status).toBe(aStatusBefore);
});
});