From 575b8fd9713564ff16d9af77ec4264c39d13798b Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 2 Aug 2026 07:21:40 +0100 Subject: [PATCH] feat: connect UI to situation graph start flow --- components/diagnostics-view.jsx | 50 ++++++-- components/scenario-form.jsx | 109 ++++++++-------- components/situation-graph-view.jsx | 121 ++++++++++++++++++ docs/v0.4-route-status.md | 12 +- tests/smoke.test.js | 69 ++++++---- tests/ui/scenario-form.test.jsx | 190 ++++++++++++++++++++++++++++ 6 files changed, 450 insertions(+), 101 deletions(-) create mode 100644 components/situation-graph-view.jsx create mode 100644 tests/ui/scenario-form.test.jsx diff --git a/components/diagnostics-view.jsx b/components/diagnostics-view.jsx index 60a5d05..07433ea 100644 --- a/components/diagnostics-view.jsx +++ b/components/diagnostics-view.jsx @@ -1,3 +1,5 @@ +import React from "react"; + const ValidationIndicator = ({ status }) => { const styles = { valid: "text-green-600", @@ -27,23 +29,53 @@ const validationIcons = { export default function DiagnosticsView({ result }) { if (!result) return null; + const diagnostics = result.diagnostics || result; + const metrics = [ - { label: "Model", value: result.modelName || "?" }, + { label: "Model", value: diagnostics.modelName || result.modelName || "?" }, { label: "Provider", value: "Ollama" }, - { label: "Prompt version", value: result.promptVersion || "?" }, + { + label: "Prompt version", + value: diagnostics.promptVersion || result.promptVersion || "?", + }, { label: "Duration", value: - result.responseDurationMs != null - ? `${result.responseDurationMs}ms` + diagnostics.responseDurationMs != null + ? `${diagnostics.responseDurationMs}ms` : "?", }, { label: "Validation", value: ( - + ), }, + { + label: "Node count", + value: diagnostics.nodeCount != null ? diagnostics.nodeCount : "?", + }, + { + label: "Edge count", + value: diagnostics.edgeCount != null ? diagnostics.edgeCount : "?", + }, + { + label: "Graph references", + value: + diagnostics.graphReferenceValidation == null + ? "?" + : diagnostics.graphReferenceValidation.valid + ? `${validationIcons.valid} valid` + : `${validationIcons.invalid} invalid`, + }, + ]; + + const errors = [ + ...(result.errors || []), + ...(result.validationErrors || []), + ...(result.analysisErrors || []), ]; return ( @@ -72,14 +104,14 @@ export default function DiagnosticsView({ result }) { )} {/* Errors if present */} - {result.errors && result.errors.length > 0 && ( + {errors.length > 0 && (
- Validation errors ({result.errors.length}) + Validation errors ({errors.length})
    - {result.errors.map((err, i) => ( -
  • {err}
  • + {errors.map((err, i) => ( +
  • {typeof err === "string" ? err : err?.message || JSON.stringify(err)}
  • ))}
diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index fb2e7d8..e71bc28 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -1,14 +1,59 @@ "use client"; +import React from "react"; import { useState, useRef } from "react"; -import ReconstructionView from "@/components/reconstruction-view"; import DiagnosticsView from "@/components/diagnostics-view"; +import SituationGraphView from "@/components/situation-graph-view"; const MAX_LENGTH = 10000; +export async function submitScenarioForStartCase(fetchImpl, scenario) { + return fetchImpl("/api/cases/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenario }), + }); +} + +export function ScenarioResultPanels({ status, result }) { + if (!result) return null; + + const hasGraph = Boolean(result.situationGraph); + const hasQuestion = Boolean(result.selectedQuestion?.question); + const hasDiagnostics = Boolean(result.diagnostics); + + return ( + <> + {status === "error" && ( +
+ {result.error && ( +
+ Error: {result.error} +
+ )} + {!hasGraph && !hasQuestion && ( +
+ Validation failed — no structured graph output was produced. +
+ )} +
+ )} + + {(status === "success" || hasGraph || hasQuestion) && ( + + )} + + {hasDiagnostics && } + + ); +} + export default function ScenarioForm() { const [scenario, setScenario] = useState(""); - const [status, setStatus] = useState("idle"); // idle | loading | error | success | partial + const [status, setStatus] = useState("idle"); // idle | loading | error | success const [result, setResult] = useState(null); const textareaRef = useRef(null); @@ -18,19 +63,11 @@ export default function ScenarioForm() { setResult(null); try { - const res = await fetch("/api/analyse", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ scenario }), - }); + const res = await submitScenarioForStartCase(fetch, scenario); const data = await res.json(); - if (res.ok && data.validationStatus === "valid") { - setStatus("success"); - setResult(data); - } else if (data.success) { - // Success in analysis but validation may be partial + if (res.ok && data.success) { setStatus("success"); setResult(data); } else { @@ -43,14 +80,6 @@ export default function ScenarioForm() { } }; - // Determine if we have meaningful content to display - const hasClassification = result?.inputClassification; - const hasReconstruction = result?.reconstruction; - const hasNextQuestion = result?.nextQuestion; - const hasEvidence = result?.evidence && result.evidence.length > 0; - const hasMeaningfulContent = - hasClassification || hasReconstruction || hasNextQuestion || hasEvidence; - return (
@@ -76,38 +105,7 @@ export default function ScenarioForm() {
- {/* Error state */} - {status === "error" && ( -
- {result?.error && ( -
- Error: {result.error} -
- )} - {/* Show partial content even on validation failure */} - {(hasClassification || hasReconstruction) && ( -
- ⚠ Partial result — some fields failed validation. Showing what was - accepted. -
- )} - {hasReconstruction && ( - - )} -
- )} - - {/* Success state */} - {status === "success" && hasMeaningfulContent && ( -
- -
- )} - - {/* Always show diagnostics when we have any result */} - {(hasClassification || hasReconstruction || hasNextQuestion) && ( - - )} + {status === "loading" && (
@@ -123,13 +121,6 @@ export default function ScenarioForm() {

)} - - {/* Invalid result with no partial data */} - {status === "error" && !result?.error && !hasMeaningfulContent && ( -
- Validation failed — no structured output was produced. -
- )} ); } diff --git a/components/situation-graph-view.jsx b/components/situation-graph-view.jsx new file mode 100644 index 0000000..81bd33a --- /dev/null +++ b/components/situation-graph-view.jsx @@ -0,0 +1,121 @@ +"use client"; + +import React from "react"; + +function NodeBadge({ children, tone = "gray" }) { + const tones = { + gray: "border-gray-200 bg-gray-50 text-gray-700", + blue: "border-blue-200 bg-blue-50 text-blue-700", + green: "border-green-200 bg-green-50 text-green-700", + yellow: "border-yellow-200 bg-yellow-50 text-yellow-700", + }; + + return ( + + {children} + + ); +} + +function NodeGroup({ title, nodes }) { + if (!nodes?.length) return null; + + return ( +
+

+ {title} ({nodes.length}) +

+
    + {nodes.map((node) => ( +
  • +
    + {node.label} + {node.status} + {node.confidence} + {node.value != null && ( + + {node.value} + {node.unit ? ` ${node.unit}` : ""} + + )} +
    + {node.description && node.description !== node.label && ( +

    {node.description}

    + )} +
  • + ))} +
+
+ ); +} + +export default function SituationGraphView({ + situationGraph, + selectedQuestion, +}) { + if (!situationGraph) return null; + + const activeUnknown = situationGraph.activeUnknownNodeId + ? situationGraph.nodes.find((node) => node.id === situationGraph.activeUnknownNodeId) + : null; + + const nodesByKind = situationGraph.nodes.reduce((acc, node) => { + if (!acc[node.kind]) acc[node.kind] = []; + acc[node.kind].push(node); + return acc; + }, {}); + + return ( +
+ {selectedQuestion?.question && ( +
+

Selected Question

+

{selectedQuestion.question}

+
+ )} + +
+

Situation Graph

+
+
+
Central statement
+
{situationGraph.centralStatement}
+
+ {situationGraph.currentSummary && ( +
+
Current summary
+
{situationGraph.currentSummary}
+
+ )} + {activeUnknown && ( +
+
Active unknown
+
{activeUnknown.label}
+
+ )} +
+
Edge count
+
{situationGraph.edges.length}
+
+
+
+ + {Object.entries(nodesByKind).map(([kind, nodes]) => ( + + ))} + +
+ + Raw graph JSON + +
+          {JSON.stringify(situationGraph, null, 2)}
+        
+
+
+ ); +} \ No newline at end of file diff --git a/docs/v0.4-route-status.md b/docs/v0.4-route-status.md index ff52c37..f423afa 100644 --- a/docs/v0.4-route-status.md +++ b/docs/v0.4-route-status.md @@ -5,10 +5,9 @@ - Covered by `tests/app/api/cases-start-route.test.js`. - `app/api/start-case/route.js` - - Untracked earlier experiment / duplicate start route. - - Not referenced by the current UI. - - Still mentioned in untracked handoff docs. - - Leave untracked for now; recommend deletion once route migration is explicitly confirmed. + - Earlier experiment / duplicate start route. + - No repository UI/test references were found. + - Deleted from the working tree during UI connection cleanup. - `app/api/update-case/route.js` - Untracked future `updateCase` work. @@ -16,5 +15,6 @@ - Leave untracked for the current milestone. - Current UI status - - `components/scenario-form.jsx` still calls `/api/analyse`. - - No active UI path currently calls `/api/cases/start`, `/api/start-case`, or `/api/update-case`. + - `components/scenario-form.jsx` now calls `/api/cases/start` for the main experimental flow. + - `/api/analyse` remains available for compatibility. + - No active UI path currently calls `/api/update-case`. diff --git a/tests/smoke.test.js b/tests/smoke.test.js index f5cef5e..a15f642 100644 --- a/tests/smoke.test.js +++ b/tests/smoke.test.js @@ -1,55 +1,70 @@ import { test, expect } from "@playwright/test"; -test("v0.3 UI smoke test with live model response", async ({ page }) => { - await page.goto("http://localhost:3000"); +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"; + +test.setTimeout(300000); + +test("graph-backed start flow smoke test", async ({ page }) => { + await page.goto(BASE_URL); // Page should load without error await expect(page.getByText(/Confidence Engine/i)).toBeVisible(); // Type the scenario const textarea = page.locator("textarea[placeholder*='Describe']"); - await textarea.fill("Complaints increased by 35% while production increased by 40%."); + await textarea.fill( + "Complaints increased by 35% while production increased by 40%.", + ); + await expect(textarea).toHaveValue( + "Complaints increased by 35% while production increased by 40%.", + ); // Button should be enabled await expect(page.getByRole("button", { name: /Analyse/i })).toBeEnabled(); - // Click Analyse and wait for diagnostics panel + // Click Analyse and wait for graph-backed result await page.getByRole("button", { name: /Analyse/i }).click(); - - // Wait for result section (ReconstructionView rendered) - await expect(page.getByRole("heading", { name: /Next Question/i })).toBeVisible({ timeout: 180000 }); - // Take screenshot of result page - await page.screenshot({ path: "tests-results/smoke-v0.3.png", fullPage: true }); + await expect( + page.getByRole("heading", { name: /Selected Question/i }), + ).toBeVisible({ timeout: 180000 }); + await expect( + page.getByRole("heading", { name: /Situation Graph/i }), + ).toBeVisible({ timeout: 180000 }); + await expect(page.getByText(/Central statement/i)).toBeVisible(); + await expect(page.getByText(/Active unknown/i)).toBeVisible(); + await expect(page.getByText(/Error:/i)).toHaveCount(0); - // Verify diagnostics panel exists and contains relevant info - const diagPanel = page.locator('details summary').first(); - if (await diagPanel.isVisible()) { - console.log("Raw response viewer:", await diagPanel.innerText().catch(() => "not visible")); - } + const rawJsonToggle = page.getByText(/Raw graph JSON/i); + await expect(rawJsonToggle).toBeVisible(); + await rawJsonToggle.click(); + await expect(page.getByText(/centralStatement/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 hasNextQ = bodyText.includes("Next Question"); - const hasComplaints = bodyText.includes("Complaint") || bodyText.includes("complaint"); - const hasProduction = bodyText.includes("production") || bodyText.includes("Production"); - const hasRateContext = bodyText.toLowerCase().includes("rate") || - bodyText.toLowerCase().includes("unit") || - bodyText.toLowerCase().includes("denominator") || - bodyText.toLowerCase().includes("per-unit"); - - console.log("Has Next Question heading:", hasNextQ); + const hasSelectedQuestion = bodyText.includes("Selected Question"); + const hasComplaints = + bodyText.includes("Complaint") || bodyText.includes("complaint"); + const hasProduction = + bodyText.includes("production") || bodyText.includes("Production"); + const hasRateContext = + bodyText.toLowerCase().includes("rate") || + bodyText.toLowerCase().includes("unit") || + 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(hasNextQ).toBe(true); -}, { timeout: 300000 }); + expect(hasSelectedQuestion).toBe(true); +}); diff --git a/tests/ui/scenario-form.test.jsx b/tests/ui/scenario-form.test.jsx new file mode 100644 index 0000000..e08dcbc --- /dev/null +++ b/tests/ui/scenario-form.test.jsx @@ -0,0 +1,190 @@ +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 SituationGraphView from "@/components/situation-graph-view.jsx"; +import { + ScenarioResultPanels, + 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, + }; +} + +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" }, + }), + ); + }); +}); + +describe("graph-backed UI rendering", () => { + it("renders central statement from successful graph response", () => { + const data = makeGraphResult(); + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Central statement"); + expect(html).toContain("Complaints increased while production increased."); + }); + + it("renders active unknown", () => { + const data = makeGraphResult(); + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Active unknown"); + expect(html).toContain("Complaint rate denominator"); + }); + + it("renders selected question exactly once", () => { + const data = makeGraphResult(); + const html = renderToStaticMarkup( + , + ); + + expect( + html.match(/What denominator is being used for the complaint rate\?/g) || + [], + ).toHaveLength(1); + }); + + it("renders diagnostics", () => { + const html = renderToStaticMarkup( + , + ); + + 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( + , + ); + + expect(html).not.toContain("Selected Question"); + expect(html).not.toContain("Active unknown"); + }); + + it("displays API error clearly", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Error: Invalid start-case request"); + }); + + it("renders expandable raw graph JSON", () => { + const data = makeGraphResult(); + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Raw graph JSON"); + expect(html).toContain(""centralStatement""); + }); +}); \ No newline at end of file