feat: add one-turn situation graph update UI
This commit is contained in:
@@ -280,6 +280,60 @@ describe("applyValidatedProposal", () => {
|
||||
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
|
||||
ids.complaintRateUnknown,
|
||||
);
|
||||
expect(
|
||||
result.updatedSituationGraph.nodes.find(
|
||||
(node) => node.id === ids.complaintRateUnknown,
|
||||
)?.status,
|
||||
).toBe("resolved");
|
||||
});
|
||||
|
||||
it("rejects resolvedUnknownNodeIds that do not reference actual unknown nodes", () => {
|
||||
const { graph, proposal, ids } = makeApplicationFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
...proposal,
|
||||
resolvedUnknownNodeIds: [ids.qualityDeterioration],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stage).toBe("proposal_compatibility");
|
||||
expect(result.errors.join(" ")).toContain(
|
||||
"Resolved unknown must reference an existing unknown node",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a duplicate semantic node without resolution", () => {
|
||||
const { graph, proposal } = makeApplicationFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
...proposal,
|
||||
resolvedUnknownNodeIds: [],
|
||||
updatedNodes: proposal.updatedNodes.filter(
|
||||
(update) => update.nodeId !== "n-complaint-rate-unknown",
|
||||
),
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-parallel-rate",
|
||||
label: "Complaint rate",
|
||||
description: "Need the complaint rate per 100 units",
|
||||
kind: "observation",
|
||||
status: "supported",
|
||||
confidence: "medium",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stage).toBe("proposal_compatibility");
|
||||
expect(result.errors.join(" ")).toContain(
|
||||
"duplicating unresolved unknown meaning",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the active unknown when it remains unresolved", () => {
|
||||
|
||||
@@ -13,9 +13,9 @@ function makeAnalysisResult(overrides = {}) {
|
||||
return {
|
||||
success: true,
|
||||
validationStatus: "valid",
|
||||
modelName: "llama3",
|
||||
modelName: "configured-model",
|
||||
responseDurationMs: 321,
|
||||
rawResponse: "{}",
|
||||
rawResponse: undefined,
|
||||
promptVersion: "v0.3",
|
||||
reconstruction: {
|
||||
summary: "Revenue and complaints diverge",
|
||||
@@ -164,7 +164,7 @@ describe("lib/graph/orchestrator startCase", () => {
|
||||
expect(result.situationGraph.currentSummary).toContain("Nodes:");
|
||||
expect(result.diagnostics).toMatchObject({
|
||||
validationStatus: "valid",
|
||||
modelName: "llama3",
|
||||
modelName: "configured-model",
|
||||
graphReferenceValidation: { valid: true, errors: [] },
|
||||
});
|
||||
});
|
||||
@@ -208,7 +208,7 @@ describe("lib/graph/orchestrator startCase", () => {
|
||||
error: "Provider unavailable",
|
||||
errors: ["socket hang up"],
|
||||
rawResponse: null,
|
||||
modelName: "llama3",
|
||||
modelName: "configured-model",
|
||||
responseDurationMs: 99,
|
||||
promptVersion: "v0.3",
|
||||
validationStatus: "invalid",
|
||||
@@ -279,8 +279,9 @@ describe("lib/graph/orchestrator startCase", () => {
|
||||
diagnostics: {
|
||||
promptVersion: "v0.4",
|
||||
modelName: "configured",
|
||||
graphNodeCount: 2,
|
||||
graphEdgeCount: 0,
|
||||
nodeCount: 2,
|
||||
edgeCount: 0,
|
||||
validationStatus: "valid",
|
||||
},
|
||||
});
|
||||
expect(provider.generateReconstruction).toHaveBeenCalledTimes(1);
|
||||
|
||||
+43
-12
@@ -4,7 +4,7 @@ const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000";
|
||||
|
||||
test.setTimeout(300000);
|
||||
|
||||
test("graph-backed start flow smoke test", async ({ page }) => {
|
||||
test("graph-backed one-turn update smoke test", async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
|
||||
// Page should load without error
|
||||
@@ -26,7 +26,11 @@ test("graph-backed start flow smoke test", async ({ page }) => {
|
||||
await page.getByRole("button", { name: /Analyse/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Selected Question/i }),
|
||||
page
|
||||
.locator("section")
|
||||
.filter({ hasText: /Selected Question/i })
|
||||
.last()
|
||||
.getByRole("heading", { name: /Selected Question/i }),
|
||||
).toBeVisible({ timeout: 180000 });
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Situation Graph/i }),
|
||||
@@ -40,13 +44,43 @@ test("graph-backed start flow smoke test", async ({ page }) => {
|
||||
await rawJsonToggle.click();
|
||||
await expect(page.getByText(/centralStatement/i)).toBeVisible();
|
||||
|
||||
const selectedQuestionSections = page
|
||||
.locator("section")
|
||||
.filter({ hasText: "Selected Question" });
|
||||
await expect(selectedQuestionSections).toHaveCount(1);
|
||||
const questionText = await selectedQuestionSections.first().innerText();
|
||||
expect(questionText.length).toBeGreaterThan(25);
|
||||
|
||||
const answerTextarea = page.locator(
|
||||
"textarea[placeholder*='Enter the answer']",
|
||||
);
|
||||
await expect(answerTextarea).toBeVisible();
|
||||
await answerTextarea.fill(
|
||||
"The complaint rate fell from 2.0 complaints per 100 units to 1.9 complaints per 100 units.",
|
||||
);
|
||||
await page.getByRole("button", { name: /Update situation/i }).click();
|
||||
|
||||
await expect(page.getByText(/Graph update applied/i)).toBeVisible({
|
||||
timeout: 240000,
|
||||
});
|
||||
await expect(page.getByText(/Resolved unknowns/i)).toBeVisible();
|
||||
await expect(page.getByText(/Affected nodes/i)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/No next question selected yet\./i),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Error:/i)).toHaveCount(0);
|
||||
await expect(page.getByText(/Update error:/i)).toHaveCount(0);
|
||||
await expect(answerTextarea).toHaveValue("");
|
||||
|
||||
const proposalToggle = page.getByText(/Proposal details/i);
|
||||
await expect(proposalToggle).toBeVisible();
|
||||
|
||||
await rawJsonToggle.click();
|
||||
await expect(page.getByText(/resolvedNodeIds/i)).toBeVisible();
|
||||
|
||||
// Get full body text for verification
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
|
||||
console.log("\n=== UI Smoke Test Results ===");
|
||||
console.log("Page title:", await page.title());
|
||||
console.log("Body content length:", bodyText.length);
|
||||
|
||||
// Check key content indicators
|
||||
const hasSelectedQuestion = bodyText.includes("Selected Question");
|
||||
const hasComplaints =
|
||||
@@ -59,12 +93,9 @@ test("graph-backed start flow smoke test", async ({ page }) => {
|
||||
bodyText.toLowerCase().includes("denominator") ||
|
||||
bodyText.toLowerCase().includes("per-unit");
|
||||
|
||||
console.log("Has selected question heading:", hasSelectedQuestion);
|
||||
console.log("Has complaints reference:", hasComplaints);
|
||||
console.log("Has production reference:", hasProduction);
|
||||
console.log("Has rate context (rate/unit/denominator):", hasRateContext);
|
||||
|
||||
// Basic structural checks
|
||||
expect(bodyText.length).toBeGreaterThan(200);
|
||||
expect(bodyText.length).toBeGreaterThan(400);
|
||||
expect(hasSelectedQuestion).toBe(true);
|
||||
expect(bodyText.includes("Resolved unknowns")).toBe(true);
|
||||
expect(bodyText.includes("Affected nodes")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,12 @@ 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 {
|
||||
ScenarioResultPanels,
|
||||
UpdateErrorPanel,
|
||||
submitAnswerForUpdateCase,
|
||||
submitScenarioForStartCase,
|
||||
} from "@/components/scenario-form.jsx";
|
||||
|
||||
@@ -70,6 +73,73 @@ function makeGraphResult(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeUpdateSuccess(overrides = {}) {
|
||||
return {
|
||||
success: true,
|
||||
stage: "update_applied",
|
||||
updatedSituationGraph: {
|
||||
centralStatement: "Complaints increased while production increased.",
|
||||
currentSummary: "Updated summary",
|
||||
activeUnknownNodeId: "n-next-unknown",
|
||||
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,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
proposal: {
|
||||
addedNodes: [],
|
||||
updatedNodes: [
|
||||
{ nodeId: "n-unknown", newStatus: "resolved", reason: "answered" },
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: ["n-unknown"],
|
||||
affectedNodeIds: ["n-conclusion"],
|
||||
},
|
||||
affectedNodeIds: ["n-conclusion"],
|
||||
resolvedUnknownNodeIds: ["n-unknown"],
|
||||
previousActiveUnknownNodeId: "n-unknown",
|
||||
newActiveUnknownNodeId: "n-next-unknown",
|
||||
changesApplied: {
|
||||
updatedNodeCount: 2,
|
||||
resolvedUnknownCount: 1,
|
||||
affectedNodeCount: 1,
|
||||
},
|
||||
diagnostics: { responseDurationMs: 100 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("scenario-form UI helpers", () => {
|
||||
it("submits to /api/cases/start", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: true });
|
||||
@@ -84,6 +154,46 @@ describe("scenario-form UI helpers", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -128,6 +238,17 @@ describe("graph-backed UI rendering", () => {
|
||||
).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 diagnostics", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<DiagnosticsView result={makeGraphResult()} />,
|
||||
@@ -187,4 +308,121 @@ describe("graph-backed UI rendering", () => {
|
||||
expect(html).toContain("Raw graph JSON");
|
||||
expect(html).toContain(""centralStatement"");
|
||||
});
|
||||
|
||||
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("affected nodes render", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GraphUpdateView
|
||||
updateResult={{
|
||||
...makeUpdateSuccess(),
|
||||
previousSituationGraph: makeGraphResult().situationGraph,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Affected nodes");
|
||||
expect(html).toContain("Quality deterioration");
|
||||
});
|
||||
|
||||
it("no fake next question appears", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GraphUpdateView
|
||||
updateResult={{
|
||||
...makeUpdateSuccess({ newActiveUnknownNodeId: 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("Unknown node (ID: n-next-unknown)");
|
||||
});
|
||||
|
||||
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(""resolvedUnknownNodeIds"");
|
||||
});
|
||||
|
||||
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("proposal details remain collapsible", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GraphUpdateView updateResult={makeUpdateSuccess()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Proposal details");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user