import { beforeEach, describe, expect, it, vi } from "vitest"; import { makeGraph, makeNode } from "@/lib/graph/schema.js"; const mockAnalyseScenario = vi.fn(); const MOCK_CONFIG = { OLLAMA_MODEL: "configured" }; vi.mock("@/lib/analysis.js", () => ({ analyseScenario: (...args) => mockAnalyseScenario(...args), })); function makeAnalysisResult(overrides = {}) { return { success: true, validationStatus: "valid", modelName: "llama3", responseDurationMs: 321, rawResponse: "{}", promptVersion: "v0.3", reconstruction: { summary: "Revenue and complaints diverge", actors: [], systemsOrObjects: [], expectedStates: [], observedStates: [ { id: "obs-1", label: "Revenue up", description: "Revenue up 15%", confidence: "high", }, ], differences: [], knownTransitions: [], unexplainedTransitions: [], contradictions: [], importantUnknowns: [ { id: "unk-1", label: "Complaint rate denominator", description: "Need the denominator for complaint rate", confidence: "high", }, ], plausibleInterpretations: [], }, evidence: [], nextQuestion: { id: "q-1", question: "What denominator is being used for the complaint rate?", }, compatibilityApplied: false, compatibilityChanges: [], compatibilityWarnings: [], ...overrides, }; } function makeUpdateGraph() { const unknown = makeNode({ id: "n-unknown", label: "Complaint rate denominator", description: "Need the denominator for the complaint rate", kind: "unknown", status: "unknown", confidence: "high", }); const observation = makeNode({ id: "n-observation", label: "Complaint count rose", description: "Complaint count rose faster than output", kind: "observation", status: "supported", confidence: "high", }); return makeGraph({ centralStatement: "Complaint counts increased while production also increased.", nodes: [unknown, observation], edges: [], activeUnknownNodeId: unknown.id, resolvedNodeIds: [], currentSummary: "Nodes: 1 unknown, 1 observation | Edges: 0 total", }); } function makeUpdateRequest(overrides = {}) { return { situationGraph: makeUpdateGraph(), previousQuestion: "What denominator is being used for the complaint rate?", answer: "The complaint rate fell from 2.0 complaints per 100 units to 1.9 complaints per 100 units.", promptVersion: "v0.4", ...overrides, }; } function makeProposal(overrides = {}) { return { addedNodes: [], updatedNodes: [ { nodeId: "n-unknown", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "1.9 complaints per 100 units", reason: "The answer directly provides the normalized rate.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-unknown"], affectedNodeIds: [], ...overrides, }; } describe("lib/graph/orchestrator startCase", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); }); it("passes a valid request through to analyseScenario", async () => { mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Revenue increased while complaint counts rose faster.", promptVersion: "v0.3", }); expect(result.success).toBe(true); expect(mockAnalyseScenario).toHaveBeenCalledWith( "Revenue increased while complaint counts rose faster.", { promptVersion: "v0.3" }, ); }); it("rejects invalid request input without throwing", async () => { const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "" }); expect(result).toMatchObject({ success: false, error: "Invalid start-case request", statusCode: 400, }); expect(result.validationErrors).toBeInstanceOf(Array); expect(mockAnalyseScenario).not.toHaveBeenCalled(); }); it("builds a valid graph on successful analysis", async () => { mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result.success).toBe(true); expect(result.situationGraph.centralStatement).toBe("Scenario text"); expect(result.situationGraph.currentSummary).toContain("Nodes:"); expect(result.diagnostics).toMatchObject({ validationStatus: "valid", modelName: "llama3", graphReferenceValidation: { valid: true, errors: [] }, }); }); it("applies active unknown selection to the graph", async () => { mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result.success).toBe(true); expect(result.situationGraph.activeUnknownNodeId).toBeTruthy(); }); it("returns structured failure when graph reference validation fails", async () => { mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); const utils = await import("@/lib/graph/utils.js"); const validateSpy = vi .spyOn(utils, "validateGraphReferences") .mockReturnValue({ valid: false, errors: ['Edge references non-existent toNodeId "missing"'], }); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result).toMatchObject({ success: false, error: "Situation graph reference validation failed", validationErrors: ['Edge references non-existent toNodeId "missing"'], statusCode: 500, }); expect(result.diagnostics.graphReferenceValidation.valid).toBe(false); validateSpy.mockRestore(); }); it("preserves analysis/provider failure details", async () => { mockAnalyseScenario.mockResolvedValue({ success: false, error: "Provider unavailable", errors: ["socket hang up"], rawResponse: null, modelName: "llama3", responseDurationMs: 99, promptVersion: "v0.3", validationStatus: "invalid", statusCode: 502, }); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result).toMatchObject({ success: false, error: "Provider unavailable", analysisErrors: ["socket hang up"], statusCode: 502, }); }); it("returns null selectedQuestion when analysis has no nextQuestion", async () => { mockAnalyseScenario.mockResolvedValue( makeAnalysisResult({ nextQuestion: undefined }), ); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result.success).toBe(true); expect(result.selectedQuestion).toBeNull(); }); it("includes compatibility diagnostics when provided by analysis", async () => { mockAnalyseScenario.mockResolvedValue( makeAnalysisResult({ compatibilityApplied: true, compatibilityChanges: [ { path: ["evidence", 0, "source"], change: "Converted null source to undefined", }, ], compatibilityWarnings: [ "Applied deterministic reconstruction compatibility normalisation", ], }), ); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result.diagnostics.compatibilityApplied).toBe(true); expect(result.diagnostics.compatibilityChanges).toHaveLength(1); }); it("produces a validated update proposal for a valid request", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result).toMatchObject({ success: true, stage: "proposal_ready", proposal: makeProposal(), diagnostics: { promptVersion: "v0.4", modelName: "configured", graphNodeCount: 2, graphEdgeCount: 0, }, }); expect(provider.generateReconstruction).toHaveBeenCalledTimes(1); }); it("valid request reaches prompt builder", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const buildGraphUpdatePrompt = vi.fn().mockReturnValue("PROMPT"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const request = makeUpdateRequest(); const result = await updateCase(request, { buildGraphUpdatePrompt, provider, config: MOCK_CONFIG, }); expect(result.success).toBe(true); expect(buildGraphUpdatePrompt).toHaveBeenCalledWith({ situationGraph: request.situationGraph, previousQuestion: request.previousQuestion, answer: request.answer, promptVersion: request.promptVersion, }); expect(provider.generateReconstruction).toHaveBeenCalledWith( "PROMPT", "configured", ); }); it("invalid request prevents provider call", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn(), }; const result = await updateCase( { previousQuestion: "Q?", answer: "A" }, { provider, config: MOCK_CONFIG, }, ); expect(result).toMatchObject({ success: false, stage: "request_validation", error: "Invalid update-case request", statusCode: 400, }); expect(result.validationErrors).toBeInstanceOf(Array); expect(provider.generateReconstruction).not.toHaveBeenCalled(); }); it("invalid graph prevents provider call", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn(), }; const graph = makeUpdateGraph(); graph.nodes[0].dependsOn.push("missing-node"); const result = await updateCase( makeUpdateRequest({ situationGraph: graph }), { provider, config: MOCK_CONFIG, }, ); expect(result).toMatchObject({ success: false, stage: "graph_validation", error: "Invalid situation graph", statusCode: 400, }); expect(result.graphValidationErrors).toEqual( expect.arrayContaining([ expect.stringContaining('depends on "missing-node"'), ]), ); expect(provider.generateReconstruction).not.toHaveBeenCalled(); }); it("prompt includes previous question and answer", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const request = makeUpdateRequest(); await updateCase(request, { provider, config: MOCK_CONFIG, }); const prompt = provider.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain(request.previousQuestion); expect(prompt).toContain(request.answer); }); it("returns proposal validation failure for malformed JSON", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue("{not json"), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result).toMatchObject({ success: false, stage: "proposal_validation", error: "Invalid graph update proposal", diagnostics: { promptVersion: "v0.4", modelName: "configured", }, statusCode: 502, }); expect(result.proposalErrors).toBeInstanceOf(Array); }); it("returns structured errors for schema-invalid proposal", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue({ updatedNodes: [{ nodeId: "n-unknown" }], }), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_validation"); expect(result.proposalErrors).toEqual( expect.arrayContaining([ expect.objectContaining({ path: expect.any(Array), message: expect.any(String), }), ]), ); }); it("includes parser normalisations in diagnostics", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue({ updatedNodes: [], }), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result.success).toBe(true); expect(result.diagnostics.normalisationsApplied).toEqual( expect.arrayContaining([ expect.objectContaining({ change: "Filled missing optional array with []", }), ]), ); }); it("returns structured provider-stage failure", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi .fn() .mockRejectedValue(new Error("provider offline")), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result).toMatchObject({ success: false, stage: "provider", error: "Graph update proposal generation failed", providerErrors: ["provider offline"], statusCode: 502, }); }); it("does not mutate the input graph", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const request = makeUpdateRequest(); const originalGraph = JSON.parse(JSON.stringify(request.situationGraph)); await updateCase(request, { provider, config: MOCK_CONFIG, }); expect(request.situationGraph).toEqual(originalGraph); }); it("does not call applyGraphUpdate", async () => { const utils = await import("@/lib/graph/utils.js"); const applySpy = vi.spyOn(utils, "applyGraphUpdate"); const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(applySpy).not.toHaveBeenCalled(); applySpy.mockRestore(); }); it("does not invent a next question outside the proposal", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result.selectedQuestion).toBeUndefined(); expect(result.nextQuestion).toBeUndefined(); expect(result.proposal.nextQuestion).toBeUndefined(); }); });