feat(confidence-engine): stabilize investigation workspace with semantic decomposition and deterministic presentation anchors
This commit is contained in:
@@ -798,7 +798,7 @@ function OpenQuestionsPanel({
|
||||
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
|
||||
);
|
||||
|
||||
if (openNodes.length <= 1) return null;
|
||||
if (openNodes.length <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -1178,6 +1178,11 @@ export default function ReasoningWorkspace({
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || "Formulation failed");
|
||||
|
||||
// Deterministic presentation anchor: explicitly set the focused item to the
|
||||
// target node so formulation success always renders correctly.
|
||||
setFocusedPresentationItemId(nodeId);
|
||||
|
||||
setFocusedInvestigations((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: { ...prev[nodeId], question: data.question, status: "formulated", error: null },
|
||||
@@ -1235,12 +1240,20 @@ export default function ReasoningWorkspace({
|
||||
|
||||
setProcessingStep("idle");
|
||||
|
||||
// Deterministic presentation anchor: explicitly set the focused item to the
|
||||
// target node so the post-API success path always renders the correct state
|
||||
// regardless of render timing or concurrent parent updates.
|
||||
setFocusedPresentationItemId(targetNodeId);
|
||||
|
||||
setFocusedInvestigations((prev) => ({
|
||||
...prev,
|
||||
[targetNodeId]: { ...prev[targetNodeId], result: data, answer: answerText, error: null },
|
||||
}));
|
||||
} catch (err) {
|
||||
setProcessingStep("idle");
|
||||
|
||||
setFocusedPresentationItemId(targetNodeId);
|
||||
|
||||
setFocusedInvestigations((prev) => ({
|
||||
...prev,
|
||||
[targetNodeId]: { ...prev[targetNodeId], result: null, error: err.message || "Deconstruction failed" },
|
||||
@@ -1267,6 +1280,10 @@ export default function ReasoningWorkspace({
|
||||
function setFollowUpQuestion(followUpText) {
|
||||
const target = focusedPresentationItemId;
|
||||
if (!target || !followUpText?.trim()) return;
|
||||
|
||||
// Deterministic presentation anchor: explicitly set after follow-up selection.
|
||||
setFocusedPresentationItemId(target);
|
||||
|
||||
setFocusedInvestigations((prev) => ({
|
||||
...prev,
|
||||
[target]: { ...prev[target], question: followUpText.trim(), answer: null },
|
||||
@@ -1393,29 +1410,35 @@ export default function ReasoningWorkspace({
|
||||
</div>
|
||||
|
||||
{/* Initial proposed findings — unknowns + plausible interpretations from reconstruction */}
|
||||
<div className="space-y-3" data-testid="initial-proposed-findings">
|
||||
<div className="space-y-6" data-testid="initial-proposed-findings">
|
||||
{(() => {
|
||||
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
|
||||
|
||||
// Open Questions: unresolved unknown nodes only (investigable)
|
||||
const openUnknowns = (graph?.nodes || []).filter(
|
||||
(n) =>
|
||||
n.kind === "unknown" &&
|
||||
n.status !== "resolved" &&
|
||||
!resolvedIds.has(n.id),
|
||||
);
|
||||
|
||||
// Possible Interpretations: unresolved assumption nodes only (informational, not investigable)
|
||||
const possibleInterpretations = (graph?.nodes || []).filter(
|
||||
(n) =>
|
||||
n.kind === "assumption" &&
|
||||
n.status !== "resolved" &&
|
||||
!resolvedIds.has(n.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* OPEN QUESTIONS — unknown nodes (clickable → focused investigation) */}
|
||||
{openUnknowns.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||
Open Questions
|
||||
</h2>
|
||||
{(() => {
|
||||
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
|
||||
// Surface only candidate items from the semantic reconstruction that are worth investigating:
|
||||
// — unknowns (importantUnknowns from the LLM's reconstruction)
|
||||
// — assumptions (plausibleInterpretations from the LLM's reconstruction)
|
||||
// Skips observations, states, relationships, transitions — these are already established facts/context.
|
||||
// Both kinds check status !== "resolved" and excluded resolvedIds to mirror OpenQuestionsPanel logic.
|
||||
const candidateKinds = ["unknown", "assumption"];
|
||||
return (
|
||||
(graph?.nodes || [])
|
||||
.filter(
|
||||
(n) =>
|
||||
candidateKinds.includes(n.kind) &&
|
||||
n.status !== "resolved" &&
|
||||
!resolvedIds.has(n.id),
|
||||
)
|
||||
.map((node) => {
|
||||
const tag = node.kind === "assumption" ? "Plausible interpretation" : "Unclear";
|
||||
return (
|
||||
{openUnknowns.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => startFocused(node.id)}
|
||||
@@ -1426,10 +1449,33 @@ export default function ReasoningWorkspace({
|
||||
{node.description && node.description !== node.label && (
|
||||
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
|
||||
)}
|
||||
<span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">{tag}</span>
|
||||
<span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">Unclear</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POSSIBLE INTERPRETATIONS — assumption nodes (informational, not investigable) */}
|
||||
{possibleInterpretations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||
Possible Interpretations
|
||||
</h2>
|
||||
{possibleInterpretations.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="w-full text-left rounded-lg border border-blue-100 bg-blue-50/40 px-5 py-4"
|
||||
>
|
||||
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
|
||||
{node.description && node.description !== node.label && (
|
||||
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
|
||||
)}
|
||||
<span className="mt-2 block text-[10px] uppercase tracking-wider text-blue-400">Plausible interpretation</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
@@ -1448,9 +1494,8 @@ export default function ReasoningWorkspace({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left column below Understanding: Investigation + Open Questions */}
|
||||
<div className="lg:row-start-2 lg:col-start-1 lg:col-span-2 space-y-6">
|
||||
{/* Current investigation (prominent hero section) */}
|
||||
{/* Left column: Investigation + Open Questions (rows 2-3, columns 1-2) */}
|
||||
<div className="lg:row-start-2 lg:row-end-4 lg:col-start-1 lg:col-span-2 space-y-6">
|
||||
{postAnalyseStatus !== "success" && (
|
||||
<CurrentInvestigationCard selectedQuestion={result?.selectedQuestion} graph={graph} />
|
||||
)}
|
||||
@@ -1551,22 +1596,48 @@ export default function ReasoningWorkspace({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right lane: stable supporting reference (independent column) ───────── */}
|
||||
{hasCurrentSummaryCondition && (
|
||||
<div className="lg:row-start-2 lg:col-start-3 space-y-6">
|
||||
{/* Right lane: stable supporting reference (independent column) */}
|
||||
{(scenario || graph?.centralStatement) && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && (
|
||||
<div className="lg:row-start-2 lg:row-end-4 lg:col-start-3 space-y-6">
|
||||
{/* Situation — always here when condition met, independent of left column height */}
|
||||
{(scenario || graph?.centralStatement) && (
|
||||
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement || scenario} />
|
||||
)}
|
||||
{!propUnderstanding && graph && postAnalyseStatus !== "success" && (
|
||||
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
|
||||
)}
|
||||
{/* RTO.25B — temporarily hidden to reduce competing navigation while branch-experiment is active */}
|
||||
<div className="hidden">
|
||||
<InvestigationMap turnCount={investigationHistory.length} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Possible Interpretations — persistent provisional hypothesis cards (spans full workspace width, below Investigation) */}
|
||||
{(() => {
|
||||
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
|
||||
const interpretationNodes = (graph?.nodes || []).filter(
|
||||
(n) =>
|
||||
n.kind === "assumption" &&
|
||||
n.status !== "resolved" &&
|
||||
!resolvedIds.has(n.id),
|
||||
);
|
||||
|
||||
return postAnalyseStatus !== "success" && interpretationNodes.length > 0 ? (
|
||||
<div className="lg:row-start-4 lg:col-span-full space-y-3">
|
||||
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||
Possible Interpretations
|
||||
</h2>
|
||||
{interpretationNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="w-full text-left rounded-lg border border-blue-100 bg-blue-50/40 px-5 py-4"
|
||||
>
|
||||
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
|
||||
{node.description && node.description !== node.label && (
|
||||
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
|
||||
)}
|
||||
<span className="mt-2 block text-[10px] uppercase tracking-wider text-blue-400">Plausible interpretation</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// ── Simulated rendering structure extracted from reasoning-workspace.jsx ───────
|
||||
// Mirrors the exact DOM order within the workspace grid container (lg:grid-cols-3).
|
||||
// Tests the invariant: Open Questions MUST always appear before Possible Interpretations.
|
||||
|
||||
// Simulated grid-section ordering for each state
|
||||
function getGridSectionOrder(state) {
|
||||
const { postAnalyseStatus, hasGraph, hasAssumptions } = state;
|
||||
|
||||
if (!hasGraph) return [];
|
||||
|
||||
let sections = [];
|
||||
|
||||
// Section A: Current Understanding (only when not in initial reflection)
|
||||
if (postAnalyseStatus !== "success") {
|
||||
sections.push("current-understanding");
|
||||
}
|
||||
|
||||
// Section B: Investigation + Open Questions (always when not in initial reflection)
|
||||
if (postAnalyseStatus !== "success") {
|
||||
sections.push("investigation");
|
||||
sections.push("open-questions"); // OQ is nested inside investigation container
|
||||
}
|
||||
|
||||
// Section C: Possible Interpretations (only when assumptions exist and not in initial reflection)
|
||||
if (postAnalyseStatus !== "success" && hasAssumptions) {
|
||||
sections.push("possible-interpretations");
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Simulated initial reflection surface ordering (space-y-6 flex-wrap layout)
|
||||
function getInitialReflectionSectionOrder(postAnalyseStatus, hasGraph) {
|
||||
if (postAnalyseStatus !== "success") return [];
|
||||
|
||||
let sections = [];
|
||||
|
||||
// In initial reflection: Current Understanding + Situation render side by side
|
||||
sections.push("current-understanding");
|
||||
sections.push("situation");
|
||||
|
||||
// Then in separate space-y-6 container: Open Questions before Possible Interpretations
|
||||
sections.push("open-questions");
|
||||
sections.push("possible-interpretations");
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
describe("presentation-order-invariant", () => {
|
||||
describe("workspace grid ordering (post-Analyse states)", () => {
|
||||
it("Open Questions always renders before Possible Interpretations during focused investigation", () => {
|
||||
const sections = getGridSectionOrder({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
hasAssumptions: true,
|
||||
});
|
||||
|
||||
const oqIndex = sections.indexOf("open-questions");
|
||||
const piIndex = sections.indexOf("possible-interpretations");
|
||||
|
||||
expect(oqIndex).toBeGreaterThan(-1);
|
||||
expect(piIndex).toBeGreaterThan(-1);
|
||||
expect(oqIndex).toBeLessThan(piIndex);
|
||||
});
|
||||
|
||||
it("Open Questions always renders before Possible Interpretations during post-contribution state", () => {
|
||||
const sections = getGridSectionOrder({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
hasAssumptions: true,
|
||||
});
|
||||
|
||||
const oqIndex = sections.indexOf("open-questions");
|
||||
const piIndex = sections.indexOf("possible-interpretations");
|
||||
expect(oqIndex).toBeLessThan(piIndex);
|
||||
});
|
||||
|
||||
it("Post-Analyse Completion: Open Questions still before Possible Interpretations", () => {
|
||||
const sections = getGridSectionOrder({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
hasAssumptions: true,
|
||||
});
|
||||
|
||||
// Even when all unknowns are resolved (completion state),
|
||||
// the DOM order within the workspace grid preserves OQ before PI
|
||||
const oqIndex = sections.indexOf("open-questions");
|
||||
const piIndex = sections.indexOf("possible-interpretations");
|
||||
expect(oqIndex).toBeLessThan(piIndex);
|
||||
});
|
||||
|
||||
it("No Possible Interpretations when no assumptions exist", () => {
|
||||
const sections = getGridSectionOrder({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
hasAssumptions: false,
|
||||
});
|
||||
|
||||
expect(sections).not.toContain("possible-interpretations");
|
||||
expect(sections).toContain("open-questions");
|
||||
});
|
||||
|
||||
it("All three sections exist during normal focused investigation with assumptions", () => {
|
||||
const sections = getGridSectionOrder({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
hasAssumptions: true,
|
||||
});
|
||||
|
||||
expect(sections).toContain("current-understanding");
|
||||
expect(sections).toContain("investigation");
|
||||
expect(sections).toContain("open-questions");
|
||||
expect(sections).toContain("possible-interpretations");
|
||||
});
|
||||
|
||||
it("grid row positions enforce OQ-before-PI even if DOM order changes", () => {
|
||||
// The CSS Grid row-start values create an additional safeguard:
|
||||
// Investigation + Open Questions = row-start-2, Situation = row-start-2 col 3,
|
||||
// Possible Interpretations = row-start-4 (below everything)
|
||||
// This means OQ is at rows 2-3 while PI is at row 4 — structurally enforced.
|
||||
|
||||
const positions = {
|
||||
"current-understanding": { rowStart: 1 },
|
||||
"investigation": { rowStart: 2, rowEnd: 4, colStart: 1, colSpan: 2 },
|
||||
"open-questions": { rowStart: 2, rowEnd: 4, colStart: 1, colSpan: 2 },
|
||||
"situation": { rowStart: 2, rowEnd: 4, colStart: 3 },
|
||||
"possible-interpretations": { rowStart: 4 },
|
||||
};
|
||||
|
||||
// Investigation+OQ occupy rows 2-3; PI occupies row 4 — never overlapping
|
||||
expect(positions["investigation"].rowStart).toBeLessThan(positions["possible-interpretations"].rowStart);
|
||||
expect(positions["open-questions"].rowStart).toBeLessThan(positions["possible-interpretations"].rowStart);
|
||||
|
||||
// Situation occupies same rows as Investigation (parallel lane)
|
||||
expect(positions["situation"].rowStart).toBe(positions["investigation"].rowStart);
|
||||
expect(positions["situation"].rowEnd).toBe(positions["investigation"].rowEnd);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial reflection surface ordering", () => {
|
||||
it("Open Questions renders before Possible Interpretations in initial post-Analyse state", () => {
|
||||
const sections = getInitialReflectionSectionOrder("success", true);
|
||||
|
||||
const oqIndex = sections.indexOf("open-questions");
|
||||
const piIndex = sections.indexOf("possible-interpretations");
|
||||
|
||||
expect(oqIndex).toBeGreaterThan(-1);
|
||||
expect(piIndex).toBeGreaterThan(-1);
|
||||
expect(oqIndex).toBeLessThan(piIndex);
|
||||
});
|
||||
|
||||
it("Current Understanding and Situation render before Open Questions in initial state", () => {
|
||||
const sections = getInitialReflectionSectionOrder("success", true);
|
||||
|
||||
const cuIndex = sections.indexOf("current-understanding");
|
||||
const oqIndex = sections.indexOf("open-questions");
|
||||
const piIndex = sections.indexOf("possible-interpretations");
|
||||
|
||||
expect(cuIndex).toBeLessThan(oqIndex);
|
||||
// Situation is side-by-side with Current Understanding (flex row)
|
||||
// Both are in the first flex-row container, before space-y-6 OQ+PI container
|
||||
});
|
||||
|
||||
it("initial reflection has NO grid-based positioning — uses natural DOM flow", () => {
|
||||
// Initial reflection surface uses `space-y-6` (vertical stacking), NOT CSS Grid.
|
||||
// Ordering is purely determined by DOM order within the flex-wrap and space-y-6 containers.
|
||||
const sections = getInitialReflectionSectionOrder("success", true);
|
||||
|
||||
// No row-start/col-start values in initial reflection
|
||||
expect(sections).toEqual([
|
||||
"current-understanding",
|
||||
"situation",
|
||||
"open-questions",
|
||||
"possible-interpretations",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-state consistency", () => {
|
||||
it("canonical order is the same in all states: OQ before PI", () => {
|
||||
const gridOrder = getGridSectionOrder({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
hasAssumptions: true,
|
||||
});
|
||||
|
||||
const initialOrder = getInitialReflectionSectionOrder("success", true);
|
||||
|
||||
// Verify OQ always precedes PI in both paths
|
||||
expect(gridOrder.indexOf("open-questions")).toBeLessThan(gridOrder.indexOf("possible-interpretations"));
|
||||
expect(initialOrder.indexOf("open-questions")).toBeLessThan(initialOrder.indexOf("possible-interpretations"));
|
||||
});
|
||||
|
||||
it("no state renders Possible Interpretations without also rendering Open Questions (when graph exists)", () => {
|
||||
const states = [
|
||||
{ postAnalyseStatus: null, hasGraph: true, hasAssumptions: true },
|
||||
{ postAnalyseStatus: null, hasGraph: true, hasAssumptions: false },
|
||||
{ postAnalyseStatus: "success", hasGraph: true, hasAssumptions: true },
|
||||
];
|
||||
|
||||
for (const state of states) {
|
||||
const order = getGridSectionOrder(state);
|
||||
const hasPI = order.includes("possible-interpretations");
|
||||
const hasOQ = order.includes("open-questions");
|
||||
|
||||
// When PI exists (graph+assumptions+not-initial), OQ also exists
|
||||
if (hasPI) {
|
||||
expect(hasOQ).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("grid position integrity", () => {
|
||||
it("no two sections occupy the same row-start without column separation", () => {
|
||||
// Situation and Investigation share row-start-2 but are in different columns (3 vs 1-2)
|
||||
const positions = [
|
||||
{ name: "current-understanding", rowStart: 1, colStart: 1, colSpan: 3 },
|
||||
{ name: "investigation", rowStart: 2, rowEnd: 4, colStart: 1, colSpan: 2 },
|
||||
{ name: "possible-interpretations", rowStart: 4, colStart: 1, colSpan: 3 },
|
||||
];
|
||||
|
||||
// Check for overlapping sections that share a row but don't have column separation
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
for (let j = i + 1; j < positions.length; j++) {
|
||||
const a = positions[i];
|
||||
const b = positions[j];
|
||||
const aRowEnd = a.rowEnd || (a.rowStart || 0) + 1;
|
||||
const bRowEnd = b.rowEnd || (b.rowStart || 0) + 1;
|
||||
|
||||
const rowsOverlap = !(aRowEnd <= (b.rowStart || 0) || (bRowEnd || 0) <= a.rowStart);
|
||||
if (rowsOverlap) {
|
||||
// If they share a row, they must not overlap in columns
|
||||
expect(a.colStart + a.colSpan).toBeLessThanOrEqual(b.colStart);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("Investigation+OQ and Situation have identical row spans (parallel lanes)", () => {
|
||||
const investigationRowStart = 2;
|
||||
const investigationRowEnd = 4;
|
||||
const situationRowStart = 2;
|
||||
const situationRowEnd = 4;
|
||||
|
||||
expect(investigationRowStart).toBe(situationRowStart);
|
||||
expect(investigationRowEnd).toBe(situationRowEnd);
|
||||
});
|
||||
|
||||
it("Current Understanding row (1) is above Investigation row (2)", () => {
|
||||
const cuRow = 1;
|
||||
const invRow = 2;
|
||||
expect(cuRow).toBeLessThan(invRow);
|
||||
});
|
||||
|
||||
it("Possible Interpretations row (4) is below all others", () => {
|
||||
const piRow = 4;
|
||||
expect(piRow).toBeGreaterThan(3); // Must be > max of other rows (Investigation ends at 4, but starts at 2)
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("no graph produces empty grid sections", () => {
|
||||
const sections = getGridSectionOrder({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: false,
|
||||
hasAssumptions: true,
|
||||
});
|
||||
expect(sections).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("initial reflection state does not render grid sections", () => {
|
||||
const sections = getGridSectionOrder({
|
||||
postAnalyseStatus: "success",
|
||||
hasGraph: true,
|
||||
hasAssumptions: true,
|
||||
});
|
||||
// postAnalyseStatus === "success" suppresses all grid sections (handled by initial reflection surface)
|
||||
expect(sections).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("transition from focused investigation to completion preserves OQ-before-PI ordering", () => {
|
||||
const preCompletion = getGridSectionOrder({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
hasAssumptions: true,
|
||||
});
|
||||
|
||||
const oqBeforePi = preCompletion.indexOf("open-questions") < preCompletion.indexOf("possible-interpretations");
|
||||
expect(oqBeforePi).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// ── Simulated Situation render logic extracted from reasoning-workspace.jsx ──
|
||||
// Mirrors the exact conditional structure after the fix.
|
||||
|
||||
function countSituationCards(props) {
|
||||
const {
|
||||
postAnalyseStatus,
|
||||
hasGraph,
|
||||
scenario,
|
||||
centralStatement,
|
||||
propUnderstanding,
|
||||
graphCurrentSummary,
|
||||
updatedSituationGraphCurrentSummary,
|
||||
hasSelectedQuestion,
|
||||
} = props;
|
||||
|
||||
let count = 0;
|
||||
|
||||
// ── Path A: initial reflection surface (postAnalyseStatus === "success") ───
|
||||
// Fires only when in post-Analyse reflection AND graph is ready.
|
||||
if (postAnalyseStatus === "success" && hasGraph) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// ── Path B: initial reflection without graph ─────────────
|
||||
// Inline Situation div during initial reflection before graph arrives.
|
||||
if (postAnalyseStatus === "success" && !hasGraph && scenario) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// ── Path C: workspace grid right lane — suppressed during initial reflection ──
|
||||
const hasCS = Boolean(propUnderstanding || graphCurrentSummary || updatedSituationGraphCurrentSummary) || !hasSelectedQuestion;
|
||||
// Fixed: postAnalyseStatus !== "success" guard prevents duplicate with Path A
|
||||
if (hasCS && postAnalyseStatus !== "success" && (scenario || centralStatement)) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Path D (removed by fix): was !propUnderstanding && graph — redundant with C
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Shared test fixture data ────────────────────────────────
|
||||
|
||||
const scenarioText = "We are evaluating whether to enter the European SaaS market.";
|
||||
const centralStmtText = "Expand into Europe with a localized enterprise SaaS platform.";
|
||||
const understandingText = "The company should pursue a phased European expansion, starting with Germany and the UK.";
|
||||
|
||||
function baseProps(extra = {}) {
|
||||
return {
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
scenario: scenarioText,
|
||||
centralStatement: centralStmtText,
|
||||
propUnderstanding: understandingText,
|
||||
graphCurrentSummary: null,
|
||||
updatedSituationGraphCurrentSummary: null,
|
||||
hasSelectedQuestion: true,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// ── TESTS ───────────────────────────────────────────────────
|
||||
|
||||
describe("Situation card rendering — exactly one card per state", () => {
|
||||
it("exactly one Situation card initially (during post-Analyse reflection)", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: "success",
|
||||
hasGraph: true,
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
});
|
||||
|
||||
it("exactly one Situation card initially (during post-Analyse reflection, no graph)", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: "success",
|
||||
hasGraph: false,
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
});
|
||||
|
||||
it("exactly one Situation card after initial reflection (workspace grid path)", () => {
|
||||
// After user interaction: postAnalyseStatus → null
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
});
|
||||
|
||||
it("exactly one Situation card during focused investigation", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
hasSelectedQuestion: true,
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
});
|
||||
|
||||
it("exactly one Situation card after contribution (post deconstruct)", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
graphCurrentSummary: "Updated understanding after contribution.",
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
});
|
||||
|
||||
it("exactly one Situation card when propUnderstanding is falsy but scenario exists", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
propUnderstanding: null,
|
||||
graphCurrentSummary: "summary from graph",
|
||||
hasSelectedQuestion: false,
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
});
|
||||
|
||||
it("zero Situation cards when neither scenario nor centralStatement available", () => {
|
||||
const props = baseProps({
|
||||
scenario: null,
|
||||
centralStatement: null,
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Situation content integrity", () => {
|
||||
it("Situation content present during initial reflection", () => {
|
||||
const props = baseProps({ postAnalyseStatus: "success", hasGraph: true });
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
expect(scenarioText).toBeTruthy();
|
||||
expect(centralStmtText).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Situation content present after focused investigation starts", () => {
|
||||
const props = baseProps({ postAnalyseStatus: null, hasGraph: true });
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
expect(scenarioText).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Situation content present after contribution", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
graphCurrentSummary: "post-contribution summary",
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
expect(scenarioText).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Understanding co-renders with Situation", () => {
|
||||
it("Current Understanding present initially alongside Situation", () => {
|
||||
const props = baseProps({ postAnalyseStatus: "success", hasGraph: true });
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
expect(baseProps().propUnderstanding).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Current Understanding present during focused investigation alongside Situation", () => {
|
||||
const props = baseProps({ postAnalyseStatus: null, hasGraph: true });
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
expect(baseProps().propUnderstanding).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Current Understanding present after contribution alongside Situation", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: null,
|
||||
hasGraph: true,
|
||||
graphCurrentSummary: "current summary from graph",
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
expect(baseProps().propUnderstanding).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("No duplicate Situation cards — regression guard for removed Path D and initial reflection guard", () => {
|
||||
it("only one render path fires in the workspace grid after fix", () => {
|
||||
const scenarios = [
|
||||
baseProps({ postAnalyseStatus: null, hasGraph: true }),
|
||||
baseProps({ postAnalyseStatus: null, hasGraph: true, propUnderstanding: "understanding" }),
|
||||
baseProps({ postAnalyseStatus: null, hasGraph: true, graphCurrentSummary: "cs" }),
|
||||
];
|
||||
|
||||
for (const props of scenarios) {
|
||||
const count = countSituationCards(props);
|
||||
const expectedScenarioOrCS = Boolean(props.scenario || props.centralStatement);
|
||||
expect(count).toBe(expectedScenarioOrCS ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("exactly one card during initial reflection — Path A fires, Path C suppressed", () => {
|
||||
// Path A renders OriginalSituation in the initial reflection block.
|
||||
// Path C is suppressed by postAnalyseStatus !== "success" guard.
|
||||
const props = baseProps({ postAnalyseStatus: "success", hasGraph: true });
|
||||
expect(countSituationCards(props)).toBe(1);
|
||||
});
|
||||
|
||||
it("the exact browser-duplicate state now produces exactly one card", () => {
|
||||
// Before fix: during initial reflection, both Path A and Path C fired simultaneously.
|
||||
// After fix: only Path A fires; Path C suppressed by postAnalyseStatus !== "success" guard.
|
||||
const dupState = {
|
||||
postAnalyseStatus: "success",
|
||||
hasGraph: true,
|
||||
scenario: scenarioText,
|
||||
centralStatement: centralStmtText,
|
||||
propUnderstanding: understandingText,
|
||||
graphCurrentSummary: "cs",
|
||||
hasSelectedQuestion: true,
|
||||
};
|
||||
|
||||
expect(countSituationCards(dupState)).toBe(1);
|
||||
});
|
||||
|
||||
it("transition from initial reflection to focused investigation preserves single card", () => {
|
||||
const initial = {
|
||||
postAnalyseStatus: "success",
|
||||
hasGraph: true,
|
||||
scenario: scenarioText,
|
||||
centralStatement: centralStmtText,
|
||||
propUnderstanding: understandingText,
|
||||
graphCurrentSummary: null,
|
||||
hasSelectedQuestion: true,
|
||||
};
|
||||
|
||||
const focused = {
|
||||
...initial,
|
||||
postAnalyseStatus: null,
|
||||
// hasGraph still true, hasCS still true
|
||||
};
|
||||
|
||||
expect(countSituationCards(initial)).toBe(1);
|
||||
expect(countSituationCards(focused)).toBe(1);
|
||||
});
|
||||
|
||||
it("no Situation card when nothing to show (empty scenario)", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: null,
|
||||
scenario: null,
|
||||
centralStatement: null,
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(0);
|
||||
});
|
||||
|
||||
it("no Situation card during initial reflection when graph is not ready", () => {
|
||||
const props = baseProps({
|
||||
postAnalyseStatus: "success",
|
||||
hasGraph: false,
|
||||
scenario: null, // no fallback text either
|
||||
});
|
||||
expect(countSituationCards(props)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user