ThreadContributionsBadge, PriorContributionsSummary, and SecondaryPreviousLearning all filtered contributions via c.targetNodeId === nodeId. Multi-turn follow-up Contributions carry a different immediate targetNodeId while the canonical origin remains on Findings (originatingTargetNodeId). Repaired: all contribution filters now match on EITHER c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId. handleDeconstructSubmit carries originatingTargetNodeId from focusedPresentationItemId as provenance for cold-return recovery.
992 lines
40 KiB
React
992 lines
40 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);
|
|
});
|
|
});
|
|
|
|
// ── Regression: same-node focused result reopen (FOCUSED-REOPEN) ─
|
|
|
|
describe("same-node focused result reopen", () => {
|
|
// Simulates the key state values in ReasoningWorkspace / OpenQuestionsPanel
|
|
function simulateFocusedReopenState() {
|
|
let selectedPresentationItemId = "u1"; // user had node u1 selected
|
|
let focusedPresentationItemId = null; // "Back to open questions" cleared this
|
|
let focusedInvestigations = {};
|
|
let formulationStep = "idle";
|
|
let processingStep = "idle";
|
|
|
|
function getFocusedInvestigation() {
|
|
if (!focusedPresentationItemId) return null;
|
|
return focusedInvestigations[focusedPresentationItemId] || null;
|
|
}
|
|
|
|
function hasFocusedContent(focusedItem) {
|
|
if (!focusedItem) return false;
|
|
const q = focusedItem.question;
|
|
return Boolean(q?.trim()) || formulationStep === "active" || processingStep === "active";
|
|
}
|
|
|
|
// Simulate deconstruct success — result populated for u1
|
|
function simulateDeconstructComplete() {
|
|
focusedInvestigations["u1"] = {
|
|
status: "formulated",
|
|
question: "What is the impact of X?",
|
|
answer: "Some answer",
|
|
result: {
|
|
observations: ["Obs 1"],
|
|
uncertainties: [],
|
|
assumptions: [],
|
|
relationships: [],
|
|
possibleFollowUpQuestions: [],
|
|
},
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
// Simulate user clicking "Back to open questions" (clears focusedPresentationItemId)
|
|
function simulateBackToOpen() {
|
|
focusedPresentationItemId = null;
|
|
}
|
|
|
|
// The OLD buggy click handler logic
|
|
function handleNodeClick_OLD(nodeId) {
|
|
const focusedItem = getFocusedInvestigation();
|
|
if (!focusedItem?.question?.trim() || (focusedItem && !hasFocusedContent(focusedItem))) {
|
|
return selectedPresentationItemId === nodeId ? null : nodeId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The NEW fixed click handler logic
|
|
function hasCompletedInvestigation(nid) {
|
|
const inv = focusedInvestigations?.[nid];
|
|
return inv && inv.status === "formulated" && inv.result && typeof inv.question === "string" && inv.question.trim();
|
|
}
|
|
|
|
function handleNodeClick_NEW(nodeId) {
|
|
const focusedItem = getFocusedInvestigation();
|
|
if (!focusedItem?.question?.trim() || (focusedItem && !hasFocusedContent(focusedItem))) {
|
|
if (hasCompletedInvestigation(nodeId) && selectedPresentationItemId === nodeId) {
|
|
focusedPresentationItemId = nodeId; // reopen!
|
|
return "reopened";
|
|
}
|
|
return selectedPresentationItemId === nodeId ? null : nodeId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
getFocusedInvestigation, hasFocusedContent, simulateDeconstructComplete,
|
|
simulateBackToOpen, handleNodeClick_OLD, handleNodeClick_NEW,
|
|
get state() {
|
|
return { selectedPresentationItemId, focusedPresentationItemId, focusedInvestigations };
|
|
},
|
|
};
|
|
}
|
|
|
|
it("BUG: old handler — clicking same node after panel collapse does nothing", () => {
|
|
const s = simulateFocusedReopenState();
|
|
// 1. User has u1 selected
|
|
s.selectedPresentationItemId = "u1";
|
|
expect(s.state.focusedPresentationItemId).toBe(null);
|
|
|
|
// 2. Submit focused answer — deconstruct completes
|
|
s.simulateDeconstructComplete();
|
|
// Result data exists but display is collapsed because focusedPresentationItemId was never set during initial selection
|
|
|
|
// 3. Panel collapse (or user navigates away)
|
|
s.simulateBackToOpen();
|
|
|
|
// 4. Click same node again — OLD handler
|
|
const result = s.handleNodeClick_OLD("u1");
|
|
// BUG: result is null because selectedPresentationItemId === "u1" → toggles to null
|
|
expect(result).toBe(null);
|
|
});
|
|
|
|
it("FIX: new handler — clicking same node after panel collapse reopens completed result", () => {
|
|
const s = simulateFocusedReopenState();
|
|
// 1. User has u1 selected
|
|
s.selectedPresentationItemId = "u1";
|
|
s.focusedPresentationItemId = null;
|
|
|
|
// 2. Deconstruct completes — result exists in focusedInvestigations
|
|
s.simulateDeconstructComplete();
|
|
|
|
// 3. User clicks "Back to open questions" → collapses panel
|
|
s.simulateBackToOpen();
|
|
|
|
// 4. Click same node again — NEW handler
|
|
const result = s.handleNodeClick_NEW("u1");
|
|
// FIX: should reopen because hasCompletedInvestigation("u1") is true and selected == u1
|
|
expect(result).toBe("reopened");
|
|
expect(s.state.focusedPresentationItemId).toBe("u1");
|
|
|
|
// 5. After reopening, focused state is restored
|
|
const focused = s.getFocusedInvestigation();
|
|
expect(focused).not.toBe(null);
|
|
expect(focused.result.observations).toEqual(["Obs 1"]);
|
|
});
|
|
|
|
it("FIX: clicking a different node first then original still works (workaround no longer required)", () => {
|
|
const s = simulateFocusedReopenState();
|
|
s.selectedPresentationItemId = "u1";
|
|
s.focusedPresentationItemId = null;
|
|
s.simulateDeconstructComplete();
|
|
s.simulateBackToOpen();
|
|
|
|
// OLD workaround: click u2 first
|
|
const resultU2 = s.handleNodeClick_NEW("u2");
|
|
expect(resultU2).toBe("u2"); // now selected is u2
|
|
|
|
// Then click u1 again — should still reopen
|
|
const resultU1 = s.handleNodeClick_NEW("u1");
|
|
expect(resultU1).toBe("reopened");
|
|
expect(s.state.focusedPresentationItemId).toBe("u1");
|
|
});
|
|
|
|
it("FIX: new node with no completed result toggles selection normally", () => {
|
|
const s = simulateFocusedReopenState();
|
|
s.selectedPresentationItemId = null;
|
|
s.focusedPresentationItemId = null;
|
|
|
|
// Click a fresh node (no results) — should set selection
|
|
const result = s.handleNodeClick_NEW("u2");
|
|
expect(result).toBe("u2");
|
|
});
|
|
|
|
it("INVARIANT: hasCompletedInvestigation only returns true for formulated+result", () => {
|
|
const focusedInvestigations = {};
|
|
|
|
function hasCompletedInvestigation(nid) {
|
|
const inv = focusedInvestigations?.[nid];
|
|
return Boolean(inv && inv.status === "formulated" && inv.result && typeof inv.question === "string" && inv.question.trim());
|
|
}
|
|
|
|
// Empty → false
|
|
expect(hasCompletedInvestigation("u1")).toBe(false);
|
|
|
|
// Formulating (not complete) → false
|
|
focusedInvestigations["u2"] = { status: "formulating", question: "", result: null };
|
|
expect(hasCompletedInvestigation("u2")).toBe(false);
|
|
|
|
// No result → false
|
|
focusedInvestigations["u3"] = { status: "formulated", question: "?", result: null };
|
|
expect(hasCompletedInvestigation("u3")).toBe(false);
|
|
|
|
// Empty question → false
|
|
focusedInvestigations["u4"] = { status: "formulated", question: "", result: {} };
|
|
expect(hasCompletedInvestigation("u4")).toBe(false);
|
|
|
|
// Complete → true
|
|
focusedInvestigations["u5"] = { status: "formulated", question: "What?", result: { observations: [] } };
|
|
expect(hasCompletedInvestigation("u5")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── INVESTIGATING cue on Open Question cards ───
|
|
|
|
describe("Focused investigation history cue", () => {
|
|
function getThreadContribs(nodeId, contributions) {
|
|
// v0.49 repaired: match on targetNodeId OR originatingTargetNodeId
|
|
return (contributions || []).filter(
|
|
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
|
|
);
|
|
}
|
|
|
|
function showsInvestigatingCue(threadContribCount) {
|
|
return threadContribCount > 0;
|
|
}
|
|
|
|
const contribA = {
|
|
id: "contrib-001",
|
|
targetNodeId: "n58lwnx",
|
|
question: "Relative contribution of shipping disclosure to abandonment",
|
|
observations: ["Late cost display identified"],
|
|
uncertainties: [],
|
|
assumptions: [],
|
|
relationships: [],
|
|
};
|
|
|
|
const contribB = {
|
|
id: "contrib-002",
|
|
targetNodeId: "u_other",
|
|
question: "Unrelated node",
|
|
observations: [],
|
|
uncertainties: [],
|
|
assumptions: [],
|
|
relationships: [],
|
|
};
|
|
|
|
const allContributions = [contribA, contribB];
|
|
|
|
it("investigated question (n58lwnx) has matching contributions by targetNodeId", () => {
|
|
const threadContribs = getThreadContribs("n58lwnx", allContributions);
|
|
expect(threadContribs).toHaveLength(1);
|
|
expect(threadContribs[0].id).toBe("contrib-001");
|
|
});
|
|
|
|
it("uninvestigated question does NOT receive cue — zero thread contributions", () => {
|
|
const otherId = "u_unknown";
|
|
const threadContribs = getThreadContribs(otherId, allContributions);
|
|
expect(threadContribs).toHaveLength(0);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
|
|
});
|
|
|
|
it("unrelated contributions do NOT mark another question", () => {
|
|
const otherNode = "u_somethingElse";
|
|
const threadContribs = getThreadContribs(otherNode, allContributions);
|
|
expect(threadContribs).toHaveLength(0);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
|
|
});
|
|
|
|
it("investigated question DOES show cue (has 1+ thread contributions)", () => {
|
|
const threadContribs = getThreadContribs("n58lwnx", allContributions);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
|
|
});
|
|
|
|
it("UNCLEAR and INVESTIGATING are independent — both can coexist on same node", () => {
|
|
const uNode = { id: "n58lwnx", kind: "unknown", status: "unclear", label: "U1" };
|
|
const hasContribs = getThreadContribs(uNode.id, allContributions).length > 0;
|
|
expect(uNode.status).toBe("unclear");
|
|
expect(hasContribs).toBe(true);
|
|
});
|
|
|
|
it("Done-for-now question retains INVESTIGATING cue when contributions exist", () => {
|
|
const doneForNowNode = "n58lwnx";
|
|
const threadContribs = getThreadContribs(doneForNowNode, allContributions);
|
|
expect(threadContribs.length).toBeGreaterThan(0);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
|
|
});
|
|
|
|
it("identity rule distinguishes contributed node from uninvestigated", () => {
|
|
const invested = getThreadContribs("n58lwnx", allContributions);
|
|
const uninvested = getThreadContribs("u_unrelated", allContributions);
|
|
expect(invested.length).toBeGreaterThan(0);
|
|
expect(uninvested.length).toBe(0);
|
|
expect(showsInvestigatingCue(invested.length)).not.toBe(showsInvestigatingCue(uninvested.length));
|
|
});
|
|
|
|
it("multiple contributions on same node still show cue", () => {
|
|
const multi = [
|
|
contribA,
|
|
{ ...contribA, id: "contrib-003", targetNodeId: "n58lwnx" },
|
|
{ ...contribA, id: "contrib-004", targetNodeId: "n58lwnx" },
|
|
];
|
|
const threadContribs = getThreadContribs("n58lwnx", multi);
|
|
expect(threadContribs).toHaveLength(3);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
|
|
});
|
|
|
|
it("null contributions produce no cue", () => {
|
|
const threadContribs = getThreadContribs("n58lwnx", null);
|
|
expect(threadContribs).toHaveLength(0);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
|
|
});
|
|
|
|
it("empty contributions produce no cue", () => {
|
|
const threadContribs = getThreadContribs("n58lwnx", []);
|
|
expect(threadContribs).toHaveLength(0);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
|
|
});
|
|
|
|
it("contributions with mismatched targetNodeId do not match", () => {
|
|
const wrong = [
|
|
{ id: "contrib-wrong", targetNodeId: "wrong-id" },
|
|
];
|
|
const threadContribs = getThreadContribs("n58lwnx", wrong);
|
|
expect(threadContribs).toHaveLength(0);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
|
|
});
|
|
|
|
// ── v0.49 MULTI-TURN FOLLOW-UP REGRESSION ───────────────────
|
|
// Hypothesis: ThreadContributionsBadge depends only on the latest/direct
|
|
// Contribution targetNodeId, causing focused history to become invisible
|
|
// when follow-up turns retain a different immediate target identity.
|
|
|
|
it("multi-turn follow-up contribution with different targetNodeId causes OPEN QUESTION A to show NO INVESTIGATING (regression)", () => {
|
|
// Turn 1: direct contribution to Open Question A — this is the baseline that works
|
|
const contribTurn1 = {
|
|
id: "contrib-001",
|
|
targetNodeId: "oq-originating", // same as the Open Question node
|
|
question: "First focused question on A",
|
|
observations: ["Fact from turn 1"],
|
|
uncertainties: [],
|
|
assumptions: [],
|
|
relationships: [],
|
|
};
|
|
|
|
// Turn 2: follow-up contribution belongs to the SAME focused investigation
|
|
// but targets a different intermediate node (the follow-up itself)
|
|
const contribTurn2 = {
|
|
id: "contrib-002",
|
|
targetNodeId: "follow_up_intermediate", // DIFFERENT from oq-originating
|
|
question: "Follow-up on turn 1",
|
|
observations: ["Fact from turn 2"],
|
|
uncertainties: [],
|
|
assumptions: [],
|
|
relationships: [],
|
|
};
|
|
|
|
const contributions = [contribTurn1, contribTurn2];
|
|
|
|
// Both contributions exist — baseline check passes
|
|
expect(getThreadContribs("oq-originating", contributions).length).toBe(1);
|
|
expect(showsInvestigatingCue(getThreadContribs("oq-originating", contributions).length)).toBe(true);
|
|
});
|
|
|
|
it("multi-turn FOLLOW-UP ONLY: Open Question A shows INVESTIGATING when Turn 2 carries originatingTargetNodeId (repaired)", () => {
|
|
// After repair: follow-up contribution carries originatingTargetNodeId linking back to A.
|
|
const contribTurn2 = {
|
|
id: "contrib-002",
|
|
targetNodeId: "follow_up_intermediate",
|
|
originatingTargetNodeId: "oq-originating",
|
|
question: "Follow-up on turn 1",
|
|
observations: ["Fact from turn 2"],
|
|
uncertainties: [],
|
|
assumptions: [],
|
|
relationships: [],
|
|
};
|
|
|
|
const contributions = [contribTurn2];
|
|
|
|
// The repaired filter matches originatingTargetNodeId back to A
|
|
const threadContribs = getThreadContribs("oq-originating", contributions);
|
|
expect(threadContribs).toHaveLength(1);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
|
|
});
|
|
|
|
it("multi-turn with contributing to A directly still shows INVESTIGATING (no regression for direct contributions)", () => {
|
|
const contribToOrigin = {
|
|
id: "contrib-005",
|
|
targetNodeId: "oq-originating",
|
|
observations: ["direct to origin"],
|
|
};
|
|
|
|
const threadContribs = getThreadContribs("oq-originating", [contribToOrigin]);
|
|
expect(threadContribs).toHaveLength(1);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
|
|
});
|
|
|
|
it("multi-turn with originatingTargetNodeId recovers INVESTIGATING for A (repaired expectation)", () => {
|
|
// Simulated repaired state: Contribution carries originatingTargetNodeId
|
|
const contribTurn1 = {
|
|
id: "contrib-001",
|
|
targetNodeId: "oq-originating",
|
|
originatingTargetNodeId: "oq-originating",
|
|
observations: ["Fact from turn 1"],
|
|
};
|
|
|
|
const contribTurn2 = {
|
|
id: "contrib-002",
|
|
targetNodeId: "follow_up_intermediate",
|
|
originatingTargetNodeId: "oq-originating",
|
|
observations: ["Fact from turn 2"],
|
|
};
|
|
|
|
const contributions = [contribTurn1, contribTurn2];
|
|
|
|
// Repaired filter: matches direct OR originating target
|
|
const getThreadContribsRepaired = (nodeId, c) =>
|
|
(c || []).filter(
|
|
(item) => item.targetNodeId === nodeId || item.originatingTargetNodeId === nodeId,
|
|
);
|
|
|
|
const threadContribs = getThreadContribsRepaired("oq-originating", contributions);
|
|
expect(threadContribs).toHaveLength(2);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
|
|
});
|
|
|
|
it("multi-turn: repaired filter recovers INVESTIGATING when ONLY follow-up exists (cold return repaired)", () => {
|
|
const contribTurn2 = {
|
|
id: "contrib-002",
|
|
targetNodeId: "follow_up_intermediate",
|
|
originatingTargetNodeId: "oq-originating",
|
|
observations: ["Fact from turn 2"],
|
|
};
|
|
|
|
const getThreadContribsRepaired = (nodeId, c) =>
|
|
(c || []).filter(
|
|
(item) => item.targetNodeId === nodeId || item.originatingTargetNodeId === nodeId,
|
|
);
|
|
|
|
const threadContribs = getThreadContribsRepaired("oq-originating", [contribTurn2]);
|
|
expect(threadContribs).toHaveLength(1);
|
|
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
|
|
});
|
|
|
|
it("unrelated follow-up does NOT falsely show INVESTIGATING on any Open Question", () => {
|
|
const unrelated = {
|
|
id: "contrib-unrelated",
|
|
targetNodeId: "some_other_node",
|
|
observations: [],
|
|
};
|
|
|
|
expect(getThreadContribs("oq-originating", [unrelated]).length).toBe(0);
|
|
expect(showsInvestigatingCue(getThreadContribs("oq-originating", [unrelated]).length)).toBe(false);
|
|
expect(getThreadContribs("some_other_node", [unrelated]).length).toBe(1);
|
|
// some_other_node would show INVESTIGATING, but it's NOT oq-originating
|
|
});
|
|
});
|