import { describe, expect, it } from "vitest"; import { parseGraphUpdateProposal } from "@/lib/graph/update-proposal.js"; import { applyGraphUpdate } from "@/lib/graph/utils.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; function makeValidProposal(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 complaint rate.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-unknown"], affectedNodeIds: [], selectedQuestion: null, answerMeaning: { userSupportedMeaning: "The user directly provided the updated complaint rate.", possibleInference: null, supportCategory: "other", resolutionGuidance: "may_resolve", }, ...overrides, }; } describe("parseGraphUpdateProposal", () => { it("parses a valid proposal", () => { const result = parseGraphUpdateProposal(makeValidProposal()); expect(result.success).toBe(true); expect(result.proposal.updatedNodes).toHaveLength(1); }); it("fails on malformed JSON", () => { const result = parseGraphUpdateProposal("{not json"); expect(result.success).toBe(false); }); it("fails when required update content is invalid", () => { const result = parseGraphUpdateProposal({ updatedNodes: [{ nodeId: "n-unknown" }], }); expect(result.success).toBe(false); }); it("removes null array entries and logs them", () => { const result = parseGraphUpdateProposal( JSON.stringify({ ...makeValidProposal(), addedNodes: [null], }), ); expect(result.success).toBe(true); expect(result.proposal.addedNodes).toEqual([]); expect(result.normalisationsApplied).toEqual( expect.arrayContaining([ expect.objectContaining({ change: "Removed null array entry" }), ]), ); }); it("fills missing optional arrays with empty arrays", () => { const result = parseGraphUpdateProposal({ updatedNodes: [], }); expect(result.success).toBe(true); expect(result.proposal.addedNodes).toEqual([]); expect(result.proposal.addedEdges).toEqual([]); expect(result.normalisationsApplied.length).toBeGreaterThan(0); }); it("normalises confirmed node-kind alias and preserves IDs", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), addedNodes: [ { id: "n-new", label: "Reported update", description: "A new reported claim", kind: "reported_statement", status: "supported", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }, ], }); expect(result.success).toBe(true); expect(result.proposal.addedNodes[0].kind).toBe("reported_claim"); expect(result.proposal.addedNodes[0].id).toBe("n-new"); }); it("rejects supportCategory values outside the existing enum", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), answerMeaning: { userSupportedMeaning: "I'd normally avoid more risk, but for the right opportunity I might accept some.", possibleInference: "This may support later clarification, but the condition remains material.", supportCategory: "conditional_preference", resolutionGuidance: "Identify and quantify the threshold conditions that trigger risk acceptance.", }, }); expect(result.success).toBe(false); }); it("rejects the earlier live Regression B wording variant when it is outside the existing enum", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), answerMeaning: { userSupportedMeaning: "I'd normally avoid more risk, but for the right opportunity I might accept some.", possibleInference: "This may support later clarification, but the condition remains material.", supportCategory: "conditional_qualification", resolutionGuidance: "may resolve once the condition is clarified", }, }); expect(result.success).toBe(false); }); it("accepts valid structured answerMeaning enum values", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), answerMeaning: { userSupportedMeaning: "I'd normally avoid more risk, but for the right opportunity I might accept some.", possibleInference: "This may support later clarification, but the condition remains material.", supportCategory: "conditional_tradeoff", resolutionGuidance: "may_resolve", }, }); expect(result.success).toBe(true); expect(result.proposal.answerMeaning).toMatchObject({ supportCategory: "conditional_tradeoff", resolutionGuidance: "may_resolve", }); }); it("normalises the observed external relationship wording affects to canonical other at the proposal boundary", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), addedEdges: [ { id: "e-affects", fromNodeId: "n-driver", toNodeId: "n-unknown", relationship: "affects", confidence: "medium", description: "Model-style relationship wording from live output.", }, ], }); expect(result.success).toBe(true); expect(result.proposal.addedEdges[0].relationship).toBe("other"); expect(result.normalisationsApplied).toEqual( expect.arrayContaining([ expect.objectContaining({ change: "Converted affects to other" }), ]), ); }); it("leaves already-valid canonical relationship values unchanged", () => { const canonicalRelationships = [ "supports", "weakens", "contradicts", "depends_on", "causes", "may_cause", "measures", "compares_with", "updates", "other", ]; for (const relationship of canonicalRelationships) { const result = parseGraphUpdateProposal({ ...makeValidProposal(), addedEdges: [ { id: `e-${relationship}`, fromNodeId: "n-driver", toNodeId: "n-unknown", relationship, confidence: "medium", description: `Canonical relationship ${relationship}`, }, ], }); expect(result.success).toBe(true); expect(result.proposal.addedEdges[0].relationship).toBe(relationship); } }); it("still rejects unsupported arbitrary relationship values", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), addedEdges: [ { id: "e-unsupported", fromNodeId: "n-driver", toNodeId: "n-unknown", relationship: "influences_decisively", confidence: "medium", description: "Unsupported relationship wording.", }, ], }); expect(result.success).toBe(false); }); it("applies only the canonical internal relationship after normalisation", () => { const parsed = parseGraphUpdateProposal({ ...makeValidProposal({ updatedNodes: [], resolvedUnknownNodeIds: [], }), addedEdges: [ { id: "e-affects", fromNodeId: "n-driver", toNodeId: "n-unknown", relationship: "affects", confidence: "medium", description: "Model-style relationship wording from live output.", }, ], }); expect(parsed.success).toBe(true); const graph = makeGraph({ centralStatement: "Relationship application fixture", nodes: [ makeNode({ id: "n-driver", label: "Driver", status: "known" }), makeNode({ id: "n-unknown", label: "Unknown" }), ], edges: [], currentSummary: "Initial summary", }); const applied = applyGraphUpdate(graph, parsed.proposal); expect(applied.success).toBe(true); expect(applied.edges).toEqual( expect.arrayContaining([ expect.objectContaining({ relationship: "other" }), ]), ); expect(applied.edges.some((edge) => edge.relationship === "affects")).toBe( false, ); }); it("unknown node enum values still fail", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), addedNodes: [ { id: "n-new", label: "Bad node", description: "Bad node", kind: "unsupported_kind", status: "supported", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }, ], }); expect(result.success).toBe(false); }); it("rejects unsupported answerMeaning enum labels at parse time", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), answerMeaning: { userSupportedMeaning: "Risk and growth are both important.", possibleInference: null, supportCategory: "constraint_preference_mix", resolutionGuidance: "needs more nuance", }, }); expect(result.success).toBe(false); }); it("defaults missing selectedQuestion to null", () => { const result = parseGraphUpdateProposal({ addedNodes: [], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }); expect(result.success).toBe(true); expect(result.proposal.selectedQuestion).toBeNull(); }); it("defaults missing answerMeaning to null", () => { const result = parseGraphUpdateProposal({ addedNodes: [], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], selectedQuestion: null, }); expect(result.success).toBe(true); expect(result.proposal.answerMeaning).toBeNull(); }); it("parses a valid selectedQuestion", () => { const result = parseGraphUpdateProposal( makeValidProposal({ selectedQuestion: { nodeId: "n-follow-up", question: "How should commercial value be defined for this decision?", reason: "A consequential unknown remains unresolved.", }, }), ); expect(result.success).toBe(true); expect(result.proposal.selectedQuestion?.nodeId).toBe("n-follow-up"); }); it("does not invent a next question field outside the contract", () => { const result = parseGraphUpdateProposal(makeValidProposal()); expect(result.proposal.nextQuestion).toBeUndefined(); }); });