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

1831 lines
57 KiB
React

import React from "react";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import DiagnosticsView from "@/components/diagnostics-view.jsx";
import GraphUpdateView from "@/components/graph-update-view.jsx";
import SituationGraphView from "@/components/situation-graph-view.jsx";
import ReasoningWorkspace, {
useLoadingStatus,
INITIAL_MESSAGES,
UPDATE_MESSAGES,
LoadingOverlay,
resolveCurrentSummary,
isTechnicalSummary,
} from "@/components/reasoning-workspace.jsx";
import {
ScenarioResultPanels,
UpdateErrorPanel,
submitAnswerForUpdateCase,
submitScenarioForStartCase,
} from "@/components/scenario-form.jsx";
function makeGraphResult(overrides = {}) {
return {
success: true,
situationGraph: {
centralStatement: "Complaints increased while production increased.",
currentSummary:
"Nodes: 2 observation, 1 unknown | Edges: 2 total | Unknowns: 1 unresolved",
activeUnknownNodeId: "n-unknown",
resolvedNodeIds: [],
nodes: [
{
id: "n-1",
label: "Complaints up 35%",
description: "Complaints increased by 35%",
kind: "observation",
status: "supported",
confidence: "high",
value: 35,
unit: "%",
},
{
id: "n-2",
label: "Production up 40%",
description: "Production increased by 40%",
kind: "observation",
status: "supported",
confidence: "high",
value: 40,
unit: "%",
},
{
id: "n-unknown",
label: "Complaint rate denominator",
description: "Need the denominator for complaint rate",
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
},
],
edges: [
{ id: "e1", fromNodeId: "n-1", toNodeId: "n-unknown" },
{ id: "e2", fromNodeId: "n-2", toNodeId: "n-unknown" },
],
},
selectedQuestion: {
question: "What denominator is being used for the complaint rate?",
},
diagnostics: {
modelName: "test",
responseDurationMs: 1234,
validationStatus: "valid",
promptVersion: "test-prompt",
nodeCount: 3,
edgeCount: 2,
graphReferenceValidation: { valid: true, errors: [] },
},
...overrides,
};
}
function makeUpdateSuccess(overrides = {}) {
return {
success: true,
stage: "update_applied",
updatedSituationGraph: {
centralStatement: "Complaints increased while production increased.",
currentSummary: "Updated summary",
activeUnknownNodeId: "n-child-1",
resolvedNodeIds: ["n-unknown"],
nodes: [
{
id: "n-1",
label: "Complaints up 35%",
description: "Complaints increased by 35%",
kind: "observation",
status: "supported",
confidence: "high",
value: 35,
unit: "%",
},
{
id: "n-conclusion",
label: "Quality deterioration",
description: "Quality deterioration conclusion",
kind: "conclusion",
status: "weakened",
confidence: "medium",
value: null,
unit: null,
},
{
id: "n-unknown",
label: "Complaint rate denominator",
description: "Need the denominator for complaint rate",
kind: "unknown",
status: "resolved",
confidence: "medium",
value: "1.9 complaints per 100 units",
unit: null,
},
{
id: "n-next-unknown",
label:
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
description:
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
},
{
id: "n-child-1",
label: "How the two observations were measured",
description:
"Need evidence about the measure used for each observation, because that could help explain revenue increased by 18%, but cash in the bank fell over the same period.",
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
parentId: "n-next-unknown",
},
],
edges: [
{
id: "e-rel-next",
fromNodeId: "n-conclusion",
toNodeId: "n-next-unknown",
relationship: "depends_on",
confidence: "medium",
description:
"This unresolved explanation arises from the now-assessed relationship between the observations.",
},
{
id: "e-child-next",
fromNodeId: "n-child-1",
toNodeId: "n-next-unknown",
relationship: "depends_on",
confidence: "medium",
description:
"This child unknown must be investigated before the broader parent explanation can be resolved.",
},
],
},
proposal: {
addedNodes: [
{
id: "n-next-unknown",
label:
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
description:
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: ["n-conclusion"],
affects: [],
parentId: "n-conclusion",
childIds: [],
},
{
id: "n-child-1",
label: "How the two observations were measured",
description:
"Need evidence about the measure used for each observation, because that could help explain revenue increased by 18%, but cash in the bank fell over the same period.",
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: "n-next-unknown",
childIds: [],
},
],
updatedNodes: [
{ nodeId: "n-unknown", newStatus: "resolved", reason: "answered" },
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: ["n-conclusion"],
selectedQuestion: {
nodeId: "n-child-1",
question:
"What evidence would clarify how the two observations were measured?",
reason:
"Formulated from graph context using the evidence_gathering investigation strategy.",
},
},
selectedQuestion: {
nodeId: "n-child-1",
question:
"What evidence would clarify how the two observations were measured?",
reason:
"Formulated from graph context using the evidence_gathering investigation strategy.",
},
affectedNodeIds: ["n-conclusion"],
resolvedUnknownNodeIds: ["n-unknown"],
previousActiveUnknownNodeId: "n-unknown",
newActiveUnknownNodeId: "n-child-1",
emergentReasoningNodeCreated: true,
emergentReasoningNodeId: "n-next-unknown",
emergentReasoningNodeReason:
"Created a new unresolved reasoning unknown so the next justified question is backed by the graph.",
atomicityAssessment: "composite",
decompositionPerformed: true,
childUnknownCount: 1,
childNodeIds: ["n-child-1"],
atomicityReason:
"Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question.",
previousReasoningState: {
comparabilityStatus: "uncertain",
reasoningStages: [
{
stage: "comparability",
status: "uncertain",
outcome:
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
},
{
stage: "relationship",
status: "insufficient_information",
outcome: "not assessed until comparability is established",
},
],
},
reasoningState: {
comparabilityStatus: "confirmed",
relationshipStatus: "insufficient_information",
reasoningStages: [
{
stage: "comparability",
status: "confirmed",
outcome:
"Comparability was confirmed by the user answer covering the same period and source basis.",
},
{
stage: "relationship",
status: "insufficient_information",
outcome:
"There is not enough structure to classify the relationship safely.",
},
],
},
changesApplied: {
updatedNodeCount: 2,
resolvedUnknownCount: 1,
affectedNodeCount: 1,
},
diagnostics: { responseDurationMs: 100 },
...overrides,
};
}
function makeCommercialUpdateSuccess(overrides = {}) {
return {
success: true,
stage: "update_applied",
updatedSituationGraph: {
centralStatement:
"I have developed a new reasoning method that aims to help people determine whether they have enough justified confidence to make a decision. I believe it could become a commercial product, but I do not yet know whether it solves a genuine problem, whether people would value it enough to pay for it, or whether it is fundamentally different from existing AI tools. Before investing significant time and money into building it further, I want to determine whether continuing development is commercially justified.",
currentSummary: "Commercial update summary",
activeUnknownNodeId: "n-other-people",
resolvedNodeIds: ["n-who"],
nodes: [
{
id: "n-commercial-parent",
label:
"Commercial justification for whether continuing development is commercially justified",
description:
"Need to know whether this solves a genuine problem, whether people would value it enough to pay for it, and whether it is commercially justified before continuing development.",
kind: "unknown",
status: "provisional",
confidence: "medium",
},
{
id: "n-who",
label: "Who experiences this problem",
description:
"Need to know who experiences this problem, because that must be clear before deciding whether it is commercially justified.",
kind: "unknown",
status: "resolved",
confidence: "medium",
value:
"I experience it myself when I am trying to decide whether a project, idea or investment is justified, but I do not yet know how common that problem is for other people.",
parentId: "n-commercial-parent",
},
{
id: "n-other-people",
label: "Whether other people experience this problem",
description:
"Need to know whether other people experience this problem, because that must be established before deciding whether the problem is broadly important.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: "n-commercial-parent",
},
],
edges: [
{
id: "e-other-parent",
fromNodeId: "n-other-people",
toNodeId: "n-commercial-parent",
relationship: "depends_on",
confidence: "medium",
description:
"This child unknown must be investigated before the broader parent explanation can be resolved.",
},
],
},
proposal: {
addedNodes: [],
updatedNodes: [{ nodeId: "n-who", newStatus: "resolved", reason: "answered" }],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-who"],
affectedNodeIds: ["n-commercial-parent"],
selectedQuestion: null,
},
selectedQuestion: {
nodeId: "n-other-people",
question: "What makes you think other people experience this problem too?",
reason:
"Formulated as a direct foundational question because this child unknown should be answered one step at a time.",
reasoningPattern: "decision",
questionFamily: "decision_foundation",
},
previousActiveUnknownNodeId: "n-who",
newActiveUnknownNodeId: "n-other-people",
affectedNodeIds: ["n-commercial-parent"],
resolvedUnknownNodeIds: ["n-who"],
diagnostics: {
unresolvedCandidateCount: 2,
eligibleCandidateCount: 1,
candidateNodeIds: ["n-other-people"],
resolvedCurrentTurnNodeIds: ["n-who"],
noQuestionReason: null,
},
...overrides,
};
}
describe("scenario-form UI helpers", () => {
it("submits to /api/cases/start", async () => {
const fetchImpl = vi.fn().mockResolvedValue({ ok: true });
await submitScenarioForStartCase(fetchImpl, "Scenario text");
expect(fetchImpl).toHaveBeenCalledWith(
"/api/cases/start",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
});
it("empty answer is rejected without fetch", async () => {
const fetchImpl = vi.fn();
const result = await submitAnswerForUpdateCase(fetchImpl, {
situationGraph: { nodes: [] },
previousQuestion: "What changed?",
answer: " ",
});
expect(result.skipped).toBe(true);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("update request body contains graph, previousQuestion and answer", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const graph = { nodes: [{ id: "n1" }], edges: [] };
await submitAnswerForUpdateCase(fetchImpl, {
situationGraph: graph,
previousQuestion: "What changed?",
answer: "The rate fell.",
});
expect(fetchImpl).toHaveBeenCalledWith(
"/api/cases/update",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
situationGraph: graph,
previousQuestion: "What changed?",
answer: "The rate fell.",
}),
}),
);
});
});
describe("graph-backed UI rendering", () => {
it("renders central statement from successful graph response", () => {
const data = makeGraphResult();
const html = renderToStaticMarkup(
<SituationGraphView
situationGraph={data.situationGraph}
selectedQuestion={data.selectedQuestion}
/>,
);
expect(html).toContain("Central statement");
expect(html).toContain("Complaints increased while production increased.");
});
it("renders active unknown", () => {
const data = makeGraphResult();
const html = renderToStaticMarkup(
<SituationGraphView
situationGraph={data.situationGraph}
selectedQuestion={data.selectedQuestion}
/>,
);
expect(html).toContain("Active unknown");
expect(html).toContain("Complaint rate denominator");
});
it("renders selected question exactly once", () => {
const data = makeGraphResult();
const html = renderToStaticMarkup(
<SituationGraphView
situationGraph={data.situationGraph}
selectedQuestion={data.selectedQuestion}
/>,
);
expect(
html.match(/What denominator is being used for the complaint rate\?/g) ||
[],
).toHaveLength(1);
});
it("no answer form appears when selectedQuestion is null", () => {
const html = renderToStaticMarkup(
<SituationGraphView
situationGraph={makeGraphResult().situationGraph}
selectedQuestion={null}
/>,
);
expect(html).not.toContain("Update situation");
});
it("renders the initial graph-backed commercial question when start-case reselection succeeds", () => {
const html = renderToStaticMarkup(
<ScenarioResultPanels
result={makeGraphResult({
situationGraph: {
...makeGraphResult().situationGraph,
activeUnknownNodeId: "n-who",
nodes: [
...makeGraphResult().situationGraph.nodes,
{
id: "n-who",
label: "Who experiences this problem",
description:
"Need to know who experiences this problem, because that must be clear before deciding whether it is commercially justified.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: "n-parent",
},
],
},
selectedQuestion: {
nodeId: "n-who",
question: "Who experiences this problem?",
reason: "Graph-backed commercial child selection.",
},
diagnostics: {
...makeGraphResult().diagnostics,
noQuestionReason: null,
},
})}
/>,
);
expect(html).toContain("Who experiences this problem?");
expect(html).toContain("Selected Question");
});
it("renders diagnostics", () => {
const html = renderToStaticMarkup(
<DiagnosticsView result={makeGraphResult()} />,
);
expect(html).toContain("Diagnostics");
expect(html).toContain("test");
expect(html).toContain("1234ms");
expect(html).toContain("Node count");
expect(html).toContain("Edge count");
expect(html).toContain("Graph references");
});
it("hides empty sections", () => {
const base = makeGraphResult();
const result = makeGraphResult({
selectedQuestion: null,
situationGraph: {
...base.situationGraph,
activeUnknownNodeId: null,
nodes: [base.situationGraph.nodes[0]],
edges: [],
},
});
const html = renderToStaticMarkup(
<SituationGraphView
situationGraph={result.situationGraph}
selectedQuestion={result.selectedQuestion}
/>,
);
expect(html).not.toContain("Selected Question");
expect(html).not.toContain("Active unknown");
});
it("displays API error clearly", () => {
const html = renderToStaticMarkup(
<ScenarioResultPanels
status="error"
result={{ error: "Invalid start-case request" }}
/>,
);
expect(html).toContain("Error: Invalid start-case request");
});
it("renders expandable raw graph JSON", () => {
const data = makeGraphResult();
const html = renderToStaticMarkup(
<SituationGraphView
situationGraph={data.situationGraph}
selectedQuestion={data.selectedQuestion}
/>,
);
expect(html).toContain("Raw graph JSON");
expect(html).toContain("&quot;centralStatement&quot;");
});
it("resolved unknowns render", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("Resolved unknowns");
expect(html).toContain("Complaint rate denominator");
});
it("newly surfaced unknowns render", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("Newly surfaced unknowns");
expect(html).toContain(
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
);
});
it("affected nodes render", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("Affected nodes");
expect(html).toContain("Quality deterioration");
});
it("renders validated next question when present", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain(
"What evidence would clarify how the two observations were measured?",
);
});
it("no fake next question appears when there is none", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess({
newActiveUnknownNodeId: null,
selectedQuestion: null,
proposal: {
...makeUpdateSuccess().proposal,
selectedQuestion: null,
},
}),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("No next question selected yet.");
});
it("previous and new active unknowns render labels", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("Previous active unknown");
expect(html).toContain("Complaint rate denominator");
expect(html).toContain("New active unknown");
expect(html).toContain(
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
);
});
it("successful update renders prior and new state together", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("Previous active unknown");
expect(html).toContain("Resolved unknowns");
expect(html).toContain("Newly surfaced unknowns");
expect(html).toContain("New active unknown");
expect(html).toContain("Next question");
expect(html).toContain(
"What evidence would clarify how the two observations were measured?",
);
});
it("update view shows comparability progression without raw ids in the normal view", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess({
selectedQuestion: {
nodeId: "n-next-unknown",
question:
"What evidence would clarify timing or measurement basis?",
reason: "A broad follow-up is now justified.",
},
}),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("Comparability:");
expect(html).toContain("uncertain → confirmed");
expect(html).toContain("Relationship status:");
expect(html).toContain("insufficient_information");
expect(html).toContain("Reasoning stages:");
expect(html).toContain("comparability: confirmed");
expect(html).toContain("relationship: insufficient_information");
expect(html).toContain(
"What evidence would clarify how the two observations were measured?",
);
expect(html).not.toContain("reasoning:comparability");
});
it("situation graph marks newly surfaced and active unknowns", () => {
const html = renderToStaticMarkup(
<SituationGraphView
situationGraph={makeUpdateSuccess().updatedSituationGraph}
selectedQuestion={makeUpdateSuccess().selectedQuestion}
newlySurfacedNodeIds={["n-next-unknown"]}
/>,
);
expect(html).toContain("newly surfaced unknown");
expect(html).toContain("active unknown");
expect(html).toContain("resolved unknown");
});
it("disabled follow-up form is shown only as prototype limitation", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={makeUpdateSuccess()}
/>,
);
expect(html).toContain(
"What evidence would clarify how the two observations were measured?",
);
});
it("raw ids remain only in collapsed proposal details", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("Proposal details");
expect(html).toContain("&quot;resolvedUnknownNodeIds&quot;");
});
it("update diagnostics render valid values", () => {
const html = renderToStaticMarkup(
<DiagnosticsView
result={{
diagnostics: {
promptVersion: "v0.4",
modelName: "configured-model",
responseDurationMs: 456,
validationStatus: "valid",
nodeCount: 7,
edgeCount: 3,
graphReferenceValidation: { valid: true, errors: [] },
},
}}
/>,
);
expect(html).toContain("v0.4");
expect(html).toContain("456ms");
expect(html).toContain("7");
expect(html).toContain("3");
expect(html).toContain("✅ valid");
});
it("structured update error renders", () => {
const html = renderToStaticMarkup(
<UpdateErrorPanel
updateError={{
error: "Invalid graph update proposal",
proposalErrors: [{ message: "bad proposal" }],
}}
/>,
);
expect(html).toContain("Update error: Invalid graph update proposal");
expect(html).toContain("bad proposal");
});
it("failed update does not fabricate history", () => {
const html = renderToStaticMarkup(
<>
<UpdateErrorPanel
updateError={{
error: "Update case failed",
errors: [
'New unknown must be explicitly related to an answer-derived node: "nu_commercial_val"',
],
}}
/>
<GraphUpdateView updateResult={null} />
</>,
);
expect(html).toContain("Update error: Update case failed");
expect(html).toContain("nu_commercial_val");
expect(html).not.toContain("Previous active unknown");
expect(html).not.toContain("Resolved unknowns");
expect(html).not.toContain("Newly surfaced unknowns");
expect(html).not.toContain("New active unknown");
expect(html).not.toContain("Proposal details");
});
it("proposal details remain collapsible", () => {
const html = renderToStaticMarkup(
<GraphUpdateView updateResult={makeUpdateSuccess()} />,
);
expect(html).toContain("Proposal details");
});
it("does not show the no-question fallback when a commercial follow-up sibling exists", () => {
const html = renderToStaticMarkup(
<GraphUpdateView updateResult={makeCommercialUpdateSuccess()} />,
);
expect(html).toContain(
"What makes you think other people experience this problem too?",
);
expect(html).toContain("New active unknown");
expect(html).not.toContain("No next question selected yet.");
});
});
// ── ReasoningWorkspace tests ────────────────────────────────
describe("ReasoningWorkspace UI", () => {
function makeWorkspaceResult(overrides = {}) {
return {
success: true,
situationGraph: makeGraphResult().situationGraph,
selectedQuestion: { question: "What denominator is being used for the complaint rate?" },
newlySurfacedNodeIds: [],
diagnostics: {
modelName: "test",
responseDurationMs: 1234,
validationStatus: "valid",
nodeCount: 3,
edgeCount: 2,
graphReferenceValidation: { valid: true, errors: [] },
},
...overrides,
};
}
function makeWorkspaceProps(overrides = {}) {
return {
status: "success",
updateStatus: "idle",
result: makeWorkspaceResult(),
answer: "",
setAnswer: vi.fn(),
onAnswerSubmit: vi.fn(),
...overrides,
};
}
it("renders by default with a successful start result", () => {
const html = renderToStaticMarkup(<ReasoningWorkspace {...makeWorkspaceProps()} />);
expect(html).toContain("Your situation");
expect(html).toContain("Complaints increased while production increased.");
});
it("shows what we've established section with plain-language summary", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
{...makeWorkspaceProps({
result: makeWorkspaceResult({
situationGraph: {
...makeWorkspaceResult().situationGraph,
currentSummary: "We have identified a key question to investigate further.",
},
}),
})}
/>,
);
expect(html).toContain("What we&#x27;ve established");
expect(html).toContain("We have identified a key question to investigate further.");
});
it("filters technical summaries from the main view (not developer details)", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
// Fallback text shown in main view (currentSummary filtered)
expect(html).toContain("We have separated what is known from what still needs checking.");
// Developer details preserves the full technical summary
expect(html).toContain("Developer details");
});
it("keeps technical summary available in Developer details", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
// Developer details is the mechanism for accessing full technical data
expect(html).toContain("Developer details");
});
it("prominently displays the current investigation", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Current investigation");
expect(html).toContain("What denominator is being used for the complaint rate?");
});
it("shows confidence-building progress text", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
{...makeWorkspaceProps({
result: makeWorkspaceResult({
situationGraph: {
...makeWorkspaceResult().situationGraph,
resolvedNodeIds: ["n-1"],
},
}),
})}
/>,
);
expect(html).toContain("We are still building confidence about your situation");
expect(html).not.toContain("3 remaining");
});
it("explains areas that still need investigation in plain language", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
// With one remaining unknown, plural "areas" or singular "area" should not appear as a bare count
expect(html).not.toContain("unknown nodes");
expect(html).not.toContain("unresolved nodes");
});
it("shows active unknown in plain language within progress card", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Current focus");
expect(html).toContain("Complaint rate denominator");
});
it("shows no active investigation fallback when none available", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
{...makeWorkspaceProps({
result: makeWorkspaceResult({
situationGraph: {
...makeWorkspaceResult().situationGraph,
activeUnknownNodeId: null,
},
}),
})}
/>,
);
expect(html).not.toContain("Current focus");
});
it("renders the answer form when a question is available", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Your response");
expect(html).toContain("Update situation");
});
it("renders developer details section (collapsed)", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Developer details");
});
it("preserves the full situation graph in developer details", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Situation Graph");
expect(html).toContain("Central statement");
});
it("preserves diagnostics in developer details", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Diagnostics");
expect(html).toContain("test");
expect(html).toContain("1234ms");
});
it("user-facing sections show labels, not raw node IDs", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
// Labels and human-readable text should be present
expect(html).toContain("Your situation");
expect(html).toContain("What we&#x27;ve established");
expect(html).toContain("Current investigation");
expect(html).toContain("Complaint rate denominator");
// Raw node IDs only appear in developer details (collapsed), not in user-facing sections
expect(html).toContain("Developer details");
});
it("technical graph counts appear only inside Developer details", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
// Technical data is available in collapsed developer details
expect(html).toContain("Developer details");
// "3 remaining" no longer appears in the main view
expect(html).not.toContain("remaining");
});
it("progress card explains what is being investigated", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Current focus");
expect(html).not.toContain("Reasoning progress");
});
it("active unknown is shown in plain language, not raw IDs", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
// Plain language label appears
expect(html).toContain("Complaint rate denominator");
// Raw node ID does not appear outside developer details section in user context
// Developer details remains collapsed by default
expect(html).toContain("Developer details");
});
it("progress card handles zero remaining gracefully", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
{...makeWorkspaceProps({
result: makeWorkspaceResult({
situationGraph: {
...makeWorkspaceResult().situationGraph,
resolvedNodeIds: ["n-unknown"],
},
}),
})}
/>,
);
expect(html).toContain("All areas under investigation are now complete");
});
it("loading card appears immediately with spinner and heading", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="loading"
updateStatus="idle"
result={null}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("Working through your situation");
expect(html).toContain("Reading your situation");
});
it("textarea and button are present when canAnswer allows", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Your response");
expect(html).toContain("Update situation");
});
it("loading message changes with mocked timers", () => {
vi.useFakeTimers();
const initialProps = {
status: "loading",
updateStatus: "idle",
result: null,
answer: "",
setAnswer: vi.fn(),
onAnswerSubmit: vi.fn(),
};
// Initial render — elapsed is 0
let html = renderToStaticMarkup(<ReasoningWorkspace {...initialProps} />);
expect(html).toContain("Reading your situation");
// Advance time by 15 seconds
vi.advanceTimersByTime(15000);
// After 16s elapsed, the second message should be active
// (useEffect fires in real React; here we verify via hook export)
expect(INITIAL_MESSAGES[1].min).toBe(10);
expect(UPDATE_MESSAGES[0].text).toBe("Considering your answer");
expect(UPDATE_MESSAGES[1].text).toBe("Updating the situation");
expect(UPDATE_MESSAGES[2].text).toBe("Checking what changed");
expect(UPDATE_MESSAGES[3].text).toBe("Choosing the next question");
vi.useRealTimers();
});
it("error state remains visible", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="error"
updateStatus="idle"
result={{ error: "Invalid start-case request" }}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("Error: Invalid start-case request");
});
it("no-question state is handled clearly", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="idle"
result={makeWorkspaceResult({
selectedQuestion: null,
situationGraph: {
...makeWorkspaceResult().situationGraph,
activeUnknownNodeId: null,
nodes: makeWorkspaceResult().situationGraph.nodes.map((n) =>
n.id === "n-unknown" ? { ...n, status: "resolved", value: "the denominator is total units sold" } : n,
),
resolvedNodeIds: ["n-unknown"],
},
diagnostics: { ...makeWorkspaceResult().diagnostics, noQuestionReason: "All unknowns resolved" },
})}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("fully investigated");
});
it("update loading state appears with spinner heading", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="loading"
result={makeWorkspaceResult()}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("Working through your situation");
});
it("elapsed time text appears during loading", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="loading"
updateStatus="idle"
result={null}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("This has been running for");
});
it("empty state shows guidance when idle", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="idle"
updateStatus="idle"
result={null}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
// Should not show any result panels or workspace
expect(html).not.toContain("Your situation");
expect(html).not.toContain("Current investigation");
});
it("update error is visible", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="error"
result={{
situationGraph: makeWorkspaceResult().situationGraph,
selectedQuestion: null,
updateError: { error: "Invalid graph update proposal" },
}}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("Update error: Invalid graph update proposal");
});
it("message pools are exported for external use", () => {
expect(INITIAL_MESSAGES.length).toBeGreaterThan(0);
expect(UPDATE_MESSAGES.length).toBeGreaterThan(0);
expect(INITIAL_MESSAGES[0].min).toBe(0);
expect(UPDATE_MESSAGES[0].min).toBe(0);
});
it("no answer form shown when there is no question", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="idle"
result={makeWorkspaceResult({ selectedQuestion: null })}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).not.toContain("Your response");
expect(html).not.toContain("Update situation");
});
// ── Technical summary detection ───────────────────────
it("isTechnicalSummary detects node counts", () => {
expect(isTechnicalSummary("Nodes: 2 observation, 1 unknown")).toBe(true);
expect(isTechnicalSummary("Nodes: 3 observations total")).toBe(true);
});
it("isTechnicalSummary detects edge counts", () => {
expect(isTechnicalSummary("Edges: 5 total")).toBe(true);
expect(isTechnicalSummary("Edges: 2")).toBe(true);
});
it("isTechnicalSummary detects graph enum language", () => {
expect(isTechnicalSummary("1 unknown remaining")).toBe(true);
expect(isTechnicalSummary("Observation nodes identified")).toBe(true);
expect(isTechnicalSummary("Conclusion verified")).toBe(true);
});
it("isTechnicalSummary allows plain-language summaries", () => {
expect(isTechnicalSummary("We have separated what is known from what still needs checking.")).toBe(false);
expect(isTechnicalSummary("Updated summary")).toBe(false);
expect(isTechnicalSummary("")).toBe(false);
expect(isTechnicalSummary(null)).toBe(false);
expect(isTechnicalSummary(undefined)).toBe(false);
});
it("resolveCurrentSummary filters technical and passes plain", () => {
expect(resolveCurrentSummary("Nodes: 1 state, 3 observations")).toBe(null);
expect(resolveCurrentSummary("Updated summary")).toBe("Updated summary");
expect(resolveCurrentSummary(null)).toBe(null);
expect(resolveCurrentSummary(undefined)).toBe(null);
});
it("update result merges into workspace correctly", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="success"
result={makeWorkspaceResult({
situationGraph: makeUpdateSuccess().updatedSituationGraph,
selectedQuestion: makeUpdateSuccess().selectedQuestion,
})}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("Updated summary");
expect(html).toContain(
"What evidence would clarify how the two observations were measured?",
);
});
// ── Loading card UI tests ───────────────────────────────
it("LoadingOverlay shows spinner and heading when isLoading is true", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={0}
currentMessage="Reading your situation"
variant="initial"
/>,
);
expect(html).toContain("Working through your situation");
expect(html).toContain("Reading your situation");
});
it("LoadingOverlay is hidden when isLoading is false", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={false}
elapsed={0}
currentMessage="Reading your situation"
variant="initial"
/>,
);
expect(html).not.toContain("Working through your situation");
});
it("LoadingOverlay shows correct initial status at elapsed 0", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={0}
currentMessage="Reading your situation"
variant="initial"
/>,
);
expect(html).toContain("Reading your situation");
});
it("LoadingOverlay shows correct status at elapsed >= 10", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={12}
currentMessage="Building a structured understanding"
variant="initial"
/>,
);
expect(html).toContain("Building a structured understanding");
});
it("LoadingOverlay shows correct status at elapsed >= 25", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={30}
currentMessage="Identifying what is known and still unclear"
variant="initial"
/>,
);
expect(html).toContain("Identifying what is known and still unclear");
});
it("LoadingOverlay shows correct status at elapsed >= 45", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={50}
currentMessage="Selecting the next useful question"
variant="initial"
/>,
);
expect(html).toContain("Selecting the next useful question");
});
it("LoadingOverlay shows reassurance text after 45s for initial variant", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={60}
currentMessage="Selecting the next useful question"
variant="initial"
/>,
);
expect(html).toContain("This can take around a minute");
});
it("LoadingOverlay hides reassurance text for update variant regardless of elapsed", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={90}
currentMessage="Choosing the next question"
variant="update"
/>,
);
expect(html).not.toContain("This can take around a minute");
});
it("LoadingOverlay displays elapsed time", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={42}
currentMessage="Building a structured understanding"
variant="initial"
/>,
);
expect(html).toContain("This has been running for 42s.");
});
it("LoadingOverlay has aria-busy and role for accessibility", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={0}
currentMessage="Reading your situation"
variant="initial"
/>,
);
expect(html).toContain("aria-busy");
expect(html).toContain("role=\"status\"");
});
it("LoadingOverlay uses aria-live on elapsed time element", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={5}
currentMessage="Reading your situation"
variant="initial"
/>,
);
expect(html).toContain("aria-live");
});
it("scenario-form hides Analyse button and shows loading card during initial analysis", () => {
// We verify ReasoningWorkspace renders LoadingOverlay for initial status=loading
// which is the direct proxy for the scenario form's loading behavior
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="loading"
updateStatus="idle"
result={null}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
// Loading overlay is present
expect(html).toContain("Working through your situation");
expect(html).toContain("Reading your situation");
// No result content appears during loading
expect(html).not.toContain("Your situation");
expect(html).not.toContain("Next question");
});
it("scenario-form shows update loading overlay when updateStatus is loading", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="loading"
result={makeWorkspaceResult()}
answer="some answer"
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("Working through your situation");
expect(html).toContain("Considering your answer");
});
it("success state restores normal controls — no loading overlay", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="idle"
result={makeWorkspaceResult()}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).not.toContain("Working through your situation");
expect(html).toContain("Current investigation");
expect(html).toContain("Update situation");
});
it("error state remains visible and shows no loading overlay", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="error"
updateStatus="idle"
result={{ error: "Invalid start-case request" }}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).not.toContain("Working through your situation");
expect(html).toContain("Error: Invalid start-case request");
});
it("spinner renders without breaking layout when reduced-motion is active", () => {
const html = renderToStaticMarkup(
<LoadingOverlay
isLoading={true}
elapsed={3}
currentMessage="Reading your situation"
variant="initial"
/>,
);
// Spinner element present (CSS media query in globals.css handles reduced-motion)
expect(html).toContain("rounded-full");
// No JS animation dependency for rendering
expect(html).not.toContain("animation-delay");
});
it("loading card appears while Analyse button is hidden (duplicate prevention)", () => {
// During status=loading: LoadingOverlay is shown and Analyse button is not rendered
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="loading"
updateStatus="idle"
result={null}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
// Loading card visible (main signal)
expect(html).toContain("Working through your situation");
// No form controls remain during loading
expect(html).not.toContain("Update situation");
});
it("investigation complete message shows confident language when satisfied", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="idle"
result={makeWorkspaceResult({
selectedQuestion: null,
situationGraph: {
...makeWorkspaceResult().situationGraph,
activeUnknownNodeId: null,
nodes: makeWorkspaceResult().situationGraph.nodes.map((n) =>
n.id === "n-unknown" ? { ...n, status: "resolved", value: "1.9 complaints per 100 units" } : n,
),
resolvedNodeIds: ["n-unknown"],
},
diagnostics: { ...makeWorkspaceResult().diagnostics, noQuestionReason: "All unknowns satisfied" },
})}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("fully investigated");
expect(html).not.toContain("There is no next question at the moment");
});
it("no-question state with unresolved areas shows holding, not completion", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="idle"
result={makeWorkspaceResult({
selectedQuestion: null,
situationGraph: {
...makeWorkspaceResult().situationGraph,
activeUnknownNodeId: "n-unknown",
nodes: makeWorkspaceResult().situationGraph.nodes.map((n) =>
n.id === "n-unknown" ? { ...n, status: "unknown", value: null } : n,
),
resolvedNodeIds: [],
},
diagnostics: { ...makeWorkspaceResult().diagnostics, noQuestionReason: null },
})}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("There is no further question the engine can justify at the moment");
expect(html).not.toContain("We have established enough for now");
expect(html).not.toContain("All areas under investigation are now complete");
});
// ── Update loading tests ───────────────────────────────
it("update loading card appears immediately after answer submit", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="loading"
result={makeWorkspaceResult()}
answer="some answer"
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).toContain("Working through your situation");
expect(html).toContain("Considering your answer");
});
it("normal update button is hidden while loading", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="loading"
result={makeWorkspaceResult()}
answer="some answer"
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).not.toContain("Update situation");
expect(html).not.toContain("Your response");
});
it("update status messages change with mocked timers", () => {
vi.useFakeTimers();
const baseProps = {
status: "success",
updateStatus: "loading",
result: makeWorkspaceResult(),
answer: "test",
setAnswer: vi.fn(),
onAnswerSubmit: vi.fn(),
};
let html = renderToStaticMarkup(<ReasoningWorkspace {...baseProps} />);
expect(html).toContain("Considering your answer");
vi.advanceTimersByTime(10000);
vi.useRealTimers();
// We rely on the exported UPDATE_MESSAGES to verify message pool correctness
expect(UPDATE_MESSAGES[0].text).toBe("Considering your answer");
expect(UPDATE_MESSAGES[1].text).toBe("Updating the situation");
expect(UPDATE_MESSAGES[2].text).toBe("Checking what changed");
expect(UPDATE_MESSAGES[3].text).toBe("Choosing the next question");
vi.useRealTimers();
});
it("update prevents duplicate submission by removing controls while loading", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="loading"
result={makeWorkspaceResult()}
answer="some answer"
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).not.toContain("Update situation");
expect(html).toContain("Working through your situation");
});
// ── Outcome state tests ────────────────────────────────
it("next-question state does not show completion wording", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
{...makeWorkspaceProps({
result: makeWorkspaceResult({
selectedQuestion: { question: "What denominator is being used?" },
}),
})}
/>,
);
expect(html).toContain("Current investigation");
expect(html).not.toContain("We have established enough for now");
expect(html).not.toContain("All areas under investigation are now complete");
});
it("unresolved-no-question state does not imply completion", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="idle"
result={makeWorkspaceResult({
selectedQuestion: null,
situationGraph: {
...makeWorkspaceResult().situationGraph,
activeUnknownNodeId: "n-unknown",
nodes: [
...makeWorkspaceResult().situationGraph.nodes.filter(
(n) => n.id !== "n-unknown" || n.kind === "observation",
),
{
id: "n-unknown",
label: "Complaint rate denominator",
description: "Need the denominator for complaint rate",
kind: "unknown",
status: "unknown",
confidence: "medium",
},
],
resolvedNodeIds: [],
},
})}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).not.toContain("All areas under investigation are now complete");
expect(html).not.toContain("We have established enough for now");
expect(html).toContain("There is no further question the engine can justify at the moment");
});
it("genuine completion hides active investigation and remaining-area wording", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="idle"
result={makeWorkspaceResult({
selectedQuestion: null,
situationGraph: {
...makeWorkspaceResult().situationGraph,
activeUnknownNodeId: null,
nodes: makeWorkspaceResult().situationGraph.nodes.map((n) =>
n.id === "n-unknown" ? { ...n, status: "resolved", value: "total units" } : n,
),
resolvedNodeIds: ["n-unknown"],
},
})}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
expect(html).not.toContain("Current investigation");
expect(html).not.toContain("areas remain");
expect(html).toContain("We have established enough for now");
});
it("no contradictory completion and unresolved focus appear together", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace
status="success"
updateStatus="idle"
result={makeWorkspaceResult({
selectedQuestion: null,
situationGraph: {
...makeWorkspaceResult().situationGraph,
activeUnknownNodeId: "n-unknown",
resolvedNodeIds: [],
},
})}
answer=""
setAnswer={vi.fn()}
onAnswerSubmit={vi.fn()}
/>,
);
const hasCompletion = html.includes("All areas under investigation are now complete") || html.includes("We have established enough for now");
expect(hasCompletion).toBe(false);
});
it("Developer details remains collapsed and unchanged", () => {
const html = renderToStaticMarkup(
<ReasoningWorkspace {...makeWorkspaceProps()} />,
);
expect(html).toContain("Developer details");
});
});