944 lines
29 KiB
JavaScript
944 lines
29 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { validateGraphReferences } from "@/lib/graph/utils.js";
|
|
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: "configured-model",
|
|
responseDurationMs: 321,
|
|
rawResponse: undefined,
|
|
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: [],
|
|
selectedQuestion: null,
|
|
...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: "configured-model",
|
|
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();
|
|
expect(result.diagnostics.unknownSelectionExplanation?.selectedNodeId).toBe(
|
|
result.situationGraph.activeUnknownNodeId,
|
|
);
|
|
});
|
|
|
|
it("returns an ambiguous tie result instead of choosing by label order", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(
|
|
makeAnalysisResult({
|
|
reconstruction: {
|
|
summary: "Revenue up while cash falls",
|
|
actors: [],
|
|
systemsOrObjects: [],
|
|
expectedStates: [],
|
|
observedStates: [
|
|
{
|
|
id: "obs-1",
|
|
label: "Revenue increased by 18%.",
|
|
description: "Revenue increased by 18%.",
|
|
confidence: "high",
|
|
},
|
|
{
|
|
id: "obs-2",
|
|
label: "Cash in the bank decreased over the same period.",
|
|
description: "Cash in the bank decreased over the same period.",
|
|
confidence: "high",
|
|
},
|
|
],
|
|
differences: [],
|
|
knownTransitions: [],
|
|
unexplainedTransitions: [],
|
|
contradictions: [
|
|
{
|
|
id: "c-1",
|
|
label:
|
|
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
|
description:
|
|
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
|
confidence: "medium",
|
|
},
|
|
],
|
|
importantUnknowns: [
|
|
{
|
|
id: "unk-1",
|
|
label:
|
|
"Whether revenue recognition timing differs from cash collection timing.",
|
|
description:
|
|
"Whether revenue recognition timing differs from cash collection timing.",
|
|
confidence: "high",
|
|
},
|
|
{
|
|
id: "unk-2",
|
|
label:
|
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
|
description:
|
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
|
confidence: "high",
|
|
},
|
|
],
|
|
plausibleInterpretations: [],
|
|
},
|
|
}),
|
|
);
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({
|
|
scenario:
|
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.situationGraph.activeUnknownNodeId).toBeNull();
|
|
expect(result.selectedQuestion).toMatchObject({
|
|
id: "q_tie_resolution",
|
|
selectionStatus: "ambiguous",
|
|
question:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
tiedCandidateIds: expect.arrayContaining([expect.any(String)]),
|
|
comparabilityStatus: "uncertain",
|
|
contradictionReasoningAllowed: false,
|
|
});
|
|
expect(result.diagnostics.unknownSelectionExplanation).toMatchObject({
|
|
status: "ambiguous",
|
|
tieType: "complete_unresolved_tie",
|
|
selectedNodeId: null,
|
|
alphabeticalUsedAsReasoning: false,
|
|
tieResolutionQuestion:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
});
|
|
});
|
|
|
|
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: "configured-model",
|
|
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("preserves selected question bytes while adding selection explanation diagnostics", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(
|
|
makeProposal({
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-build-decision",
|
|
label: "Build Confidence Engine decision",
|
|
description: "Decision introduced by the answer.",
|
|
kind: "state",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
}),
|
|
makeNode({
|
|
id: "n-commercial-value",
|
|
label: "Commercial value definition",
|
|
description:
|
|
"Need a concrete definition because the decision depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
}),
|
|
],
|
|
addedEdges: [
|
|
{
|
|
id: "e-build-commercial-value",
|
|
fromNodeId: "n-build-decision",
|
|
toNodeId: "n-commercial-value",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description:
|
|
"The decision depends on commercial value definition.",
|
|
},
|
|
],
|
|
selectedQuestion: {
|
|
nodeId: "n-commercial-value",
|
|
question:
|
|
"How should commercial value be defined for this decision?",
|
|
reason: "Consequential unresolved uncertainty remains.",
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
|
|
const first = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
const second = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
|
|
expect(first.selectedQuestion.question).toBe(
|
|
second.selectedQuestion.question,
|
|
);
|
|
expect(first.selectedQuestion.reason).toBe(second.selectedQuestion.reason);
|
|
expect(first.diagnostics.unknownSelectionExplanation).toBeTruthy();
|
|
});
|
|
|
|
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",
|
|
nodeCount: 2,
|
|
edgeCount: 0,
|
|
validationStatus: "valid",
|
|
},
|
|
});
|
|
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();
|
|
});
|
|
|
|
it("returns selectedQuestion from applied update proposal", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(
|
|
makeProposal({
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-build-decision",
|
|
label: "Build Confidence Engine decision",
|
|
description: "Decision introduced by the answer.",
|
|
kind: "state",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
}),
|
|
makeNode({
|
|
id: "n-commercial-value",
|
|
label: "Commercial value definition",
|
|
description:
|
|
"Need a concrete definition because the decision depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
}),
|
|
],
|
|
addedEdges: [
|
|
{
|
|
id: "e-build-commercial-value",
|
|
fromNodeId: "n-build-decision",
|
|
toNodeId: "n-commercial-value",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description:
|
|
"The decision depends on commercial value definition.",
|
|
},
|
|
],
|
|
selectedQuestion: {
|
|
nodeId: "n-commercial-value",
|
|
question:
|
|
"How should commercial value be defined for this decision?",
|
|
reason: "Consequential unresolved uncertainty remains.",
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
|
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
|
expect(result.selectedQuestion?.question).not.toBe(
|
|
"How should commercial value be defined for this decision?",
|
|
);
|
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
|
"how should uncertainty regarding",
|
|
);
|
|
});
|
|
|
|
it("deterministically prioritises customer value over pricing follow-up", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(
|
|
makeProposal({
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-value",
|
|
label: "Customer value",
|
|
description:
|
|
"Need customer value because purchase decisions depend on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
}),
|
|
makeNode({
|
|
id: "n-price",
|
|
label: "Target price point",
|
|
description:
|
|
"Need a target price point because revenue assumptions depend on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
dependsOn: ["n-value"],
|
|
}),
|
|
makeNode({
|
|
id: "n-decision",
|
|
label: "Build Confidence Engine decision",
|
|
description: "Decision introduced by the answer.",
|
|
kind: "state",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
}),
|
|
],
|
|
addedEdges: [
|
|
{
|
|
id: "e-decision-value",
|
|
fromNodeId: "n-decision",
|
|
toNodeId: "n-value",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description: "The decision depends on customer value.",
|
|
},
|
|
{
|
|
id: "e-value-price",
|
|
fromNodeId: "n-value",
|
|
toNodeId: "n-price",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description: "Pricing depends on customer value.",
|
|
},
|
|
{
|
|
id: "e-decision-price",
|
|
fromNodeId: "n-decision",
|
|
toNodeId: "n-price",
|
|
relationship: "depends_on",
|
|
confidence: "low",
|
|
description: "The decision references pricing assumptions.",
|
|
},
|
|
],
|
|
selectedQuestion: {
|
|
nodeId: "n-price",
|
|
question: "What is the price point?",
|
|
reason: "Model chose pricing.",
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.selectedQuestion?.nodeId).toBe("n-value");
|
|
});
|
|
|
|
it("defaults to proposal-only mode", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const applyValidatedProposal = vi.fn();
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyValidatedProposal,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stage).toBe("proposal_ready");
|
|
expect(applyValidatedProposal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies the proposal only when explicitly enabled", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const request = makeUpdateRequest({
|
|
situationGraph: makeGraph({
|
|
centralStatement:
|
|
"Complaint counts increased while production also increased.",
|
|
nodes: [
|
|
makeNode({
|
|
id: "n-rate",
|
|
label: "Complaint rate",
|
|
description: "Need complaint rate",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
affects: ["n-conclusion"],
|
|
}),
|
|
makeNode({
|
|
id: "n-other-unknown",
|
|
label: "Other unknown",
|
|
description: "Another unresolved unknown",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
}),
|
|
makeNode({
|
|
id: "n-conclusion",
|
|
label: "Quality deterioration",
|
|
description: "Quality conclusion",
|
|
kind: "conclusion",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
dependsOn: ["n-rate"],
|
|
}),
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: "n-rate",
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Initial summary",
|
|
}),
|
|
});
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue({
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n-rate",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
previousValue: "2.0 complaints per 100 units",
|
|
newValue: "1.9 complaints per 100 units",
|
|
reason: "The answer provides the updated rate.",
|
|
},
|
|
{
|
|
nodeId: "n-conclusion",
|
|
previousStatus: "supported",
|
|
newStatus: "weakened",
|
|
previousValue: null,
|
|
newValue: null,
|
|
reason: "The updated rate weakens the conclusion.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n-rate"],
|
|
affectedNodeIds: ["n-conclusion"],
|
|
}),
|
|
};
|
|
|
|
const result = await updateCase(request, {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
stage: "update_applied",
|
|
affectedNodeIds: expect.arrayContaining(["n-rate", "n-conclusion"]),
|
|
resolvedUnknownNodeIds: ["n-rate"],
|
|
previousActiveUnknownNodeId: "n-rate",
|
|
newActiveUnknownNodeId: "n-other-unknown",
|
|
});
|
|
expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({
|
|
valid: true,
|
|
errors: [],
|
|
});
|
|
});
|
|
|
|
it("startCase behaviour remains unchanged", 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.selectedQuestion).toEqual({
|
|
id: "q-1",
|
|
question: "What denominator is being used for the complaint rate?",
|
|
});
|
|
});
|
|
});
|