import { describe, expect, it } from "vitest"; import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; import { validateGraphReferences } from "@/lib/graph/utils.js"; function makeApplicationFixture() { const complaintRateUnknown = makeNode({ id: "n-complaint-rate-unknown", label: "Complaint rate", description: "Need the complaint rate per 100 units", kind: "unknown", status: "unknown", confidence: "high", affects: ["n-quality-deterioration"], }); const staffingUnknown = makeNode({ id: "n-staffing-unknown", label: "Staffing change", description: "Need to know if staffing changed", kind: "unknown", status: "unknown", confidence: "medium", }); const qualityDeterioration = makeNode({ id: "n-quality-deterioration", label: "Quality deterioration conclusion", description: "Conclusion that quality deteriorated", kind: "conclusion", status: "supported", confidence: "medium", dependsOn: ["n-complaint-rate-unknown"], }); const complaintCount = makeNode({ id: "n-complaint-count", label: "Complaint count observation", description: "Complaint count increased", kind: "observation", status: "supported", confidence: "high", value: 135, unit: "count", }); const productionCount = makeNode({ id: "n-production-count", label: "Production count observation", description: "Production increased", kind: "observation", status: "supported", confidence: "high", value: 7100, unit: "units", }); const graph = makeGraph({ centralStatement: "Complaints rose while production also rose.", nodes: [ complaintRateUnknown, staffingUnknown, qualityDeterioration, complaintCount, productionCount, ], edges: [ makeEdge({ id: "e-quality-depends-rate", fromNodeId: complaintRateUnknown.id, toNodeId: qualityDeterioration.id, relationship: "supports", confidence: "medium", description: "The rate informs the quality conclusion", }), ], activeUnknownNodeId: complaintRateUnknown.id, resolvedNodeIds: [], currentSummary: "Initial summary", }); const proposal = { addedNodes: [], updatedNodes: [ { nodeId: complaintRateUnknown.id, previousStatus: "unknown", newStatus: "resolved", previousValue: "2.0 complaints per 100 units", newValue: "1.9 complaints per 100 units", reason: "The answer provides the updated normalized complaint rate.", }, { nodeId: qualityDeterioration.id, previousStatus: "supported", newStatus: "weakened", previousValue: null, newValue: null, reason: "The improved rate weakens the deterioration conclusion.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [complaintRateUnknown.id], affectedNodeIds: [qualityDeterioration.id], }; return { graph, proposal, ids: { complaintRateUnknown: complaintRateUnknown.id, staffingUnknown: staffingUnknown.id, qualityDeterioration: qualityDeterioration.id, complaintCount: complaintCount.id, productionCount: productionCount.id, }, }; } describe("applyValidatedProposal", () => { it("applies a valid proposal successfully", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result).toMatchObject({ success: true, graphUpdate: proposal, resolvedUnknownNodeIds: [ids.complaintRateUnknown], previousActiveUnknownNodeId: ids.complaintRateUnknown, newActiveUnknownNodeId: ids.staffingUnknown, }); expect( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.complaintRateUnknown, )?.status, ).toBe("resolved"); expect( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.qualityDeterioration, )?.status, ).toBe("weakened"); }); it("rejects an invalid graph before application", () => { const { graph, proposal } = makeApplicationFixture(); graph.nodes[0].dependsOn.push("missing-node"); const original = JSON.parse(JSON.stringify(graph)); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(false); expect(result.stage).toBe("graph_validation"); expect(graph).toEqual(original); }); it("rejects updates referencing nonexistent nodes", () => { const { graph, proposal } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, updatedNodes: [ ...proposal.updatedNodes, { nodeId: "ghost-node", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: null, reason: "Invalid reference", }, ], }, }); expect(result).toMatchObject({ success: false, stage: "proposal_compatibility", }); expect(result.errors).toEqual( expect.arrayContaining([ expect.stringContaining( 'Cannot update non-existent node: "ghost-node"', ), ]), ); }); it("rejects added edges with invalid references", () => { const { graph, proposal } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, addedEdges: [ makeEdge({ id: "e-invalid", fromNodeId: "missing-node", toNodeId: "n-quality-deterioration", relationship: "supports", confidence: "medium", description: "Invalid edge", }), ], }, }); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_compatibility"); }); it("rejects duplicate IDs", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, addedNodes: [ makeNode({ id: ids.qualityDeterioration, label: "Duplicate", description: "Duplicate node id", }), ], }, }); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_compatibility"); expect(result.errors.join(" ")).toContain("duplicate node ID"); }); it("preserves unrelated nodes byte-for-byte", () => { const { graph, proposal, ids } = makeApplicationFixture(); const originalComplaintCount = JSON.stringify( graph.nodes.find((node) => node.id === ids.complaintCount), ); const originalProductionCount = JSON.stringify( graph.nodes.find((node) => node.id === ids.productionCount), ); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect( JSON.stringify( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.complaintCount, ), ), ).toBe(originalComplaintCount); expect( JSON.stringify( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.productionCount, ), ), ).toBe(originalProductionCount); }); it("adds resolved unknowns to resolvedNodeIds", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); 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", () => { const { graph, ids } = makeApplicationFixture(); const proposal = { addedNodes: [], updatedNodes: [ { nodeId: ids.qualityDeterioration, previousStatus: "supported", newStatus: "weakened", previousValue: null, newValue: null, reason: "Only the conclusion changes", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [ids.qualityDeterioration], }; const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect(result.previousActiveUnknownNodeId).toBe(ids.complaintRateUnknown); expect(result.newActiveUnknownNodeId).toBe(ids.complaintRateUnknown); }); it("reports affected node ids", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect(result.affectedNodeIds).toEqual( expect.arrayContaining([ ids.complaintRateUnknown, ids.qualityDeterioration, ]), ); }); it("revalidates the completed graph references", () => { const { graph, proposal } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({ valid: true, errors: [], }); }); it("is atomic on failure", () => { const { graph, proposal } = makeApplicationFixture(); const originalGraph = JSON.parse(JSON.stringify(graph)); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, addedEdges: [ makeEdge({ id: "e-bad", fromNodeId: "missing-node", toNodeId: "n-quality-deterioration", relationship: "supports", confidence: "medium", description: "Invalid edge", }), ], }, }); expect(result.success).toBe(false); expect(graph).toEqual(originalGraph); }); it("rejects a proposal with no meaningful change", () => { const { graph } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { addedNodes: [], updatedNodes: [ { nodeId: "n-quality-deterioration", previousStatus: null, newStatus: null, previousValue: null, newValue: null, reason: "No change", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }, }); expect(result).toMatchObject({ success: false, stage: "proposal_compatibility", }); expect(result.errors).toEqual( expect.arrayContaining([expect.stringContaining("no meaningful change")]), ); }); });