import { describe, it, expect } from "vitest"; import { SituationKind, SituationStatus, ConfidenceLevel, SituationRelationship, answerResolutionGuidance, answerSupportCategory, situationNodeSchema, situationEdgeSchema, situationGraphSchema, graphUpdateSchema, startCaseRequestSchema, updateCaseRequestSchema, makeNodeId, makeNode, makeEdge, makeGraph, } from "@/lib/graph/schema.js"; describe("situationNodeSchema", () => { const validNode = { id: "n1", label: "Test Node", description: "A test node", kind: "observation", status: "known", confidence: "high", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }; it("validates a complete valid node", () => { const result = situationNodeSchema.safeParse(validNode); expect(result.success).toBe(true); }); it("requires id", () => { const invalid = { ...validNode, id: "" }; const result = situationNodeSchema.safeParse(invalid); expect(result.success).toBe(false); }); it("requires label", () => { const invalid = { ...validNode, label: "" }; const result = situationNodeSchema.safeParse(invalid); expect(result.success).toBe(false); }); it("rejects invalid kind", () => { const invalid = { ...validNode, kind: "nonexistent" }; const result = situationNodeSchema.safeParse(invalid); expect(result.success).toBe(false); }); it("rejects invalid status", () => { const invalid = { ...validNode, status: "unknown_status" }; const result = situationNodeSchema.safeParse(invalid); expect(result.success).toBe(false); }); it("rejects invalid confidence", () => { const invalid = { ...validNode, confidence: "extreme" }; const result = situationNodeSchema.safeParse(invalid); expect(result.success).toBe(false); }); it("allows numeric value", () => { const node = { ...validNode, value: 42 }; const result = situationNodeSchema.safeParse(node); expect(result.success).toBe(true); }); it("allows string value", () => { const node = { ...validNode, value: "active" }; const result = situationNodeSchema.safeParse(node); expect(result.success).toBe(true); }); }); describe("situationEdgeSchema", () => { const validEdge = { id: "e1", fromNodeId: "n1", toNodeId: "n2", relationship: "supports", confidence: "medium", description: "Edge between nodes", }; it("validates a complete valid edge", () => { const result = situationEdgeSchema.safeParse(validEdge); expect(result.success).toBe(true); }); it("rejects invalid relationship type", () => { const invalid = { ...validEdge, relationship: "invalid_rel" }; const result = situationEdgeSchema.safeParse(invalid); expect(result.success).toBe(false); }); it("validates all relationship types", () => { for (const rel of Object.values(SituationRelationship)) { const edge = { ...validEdge, relationship: rel }; const result = situationEdgeSchema.safeParse(edge); expect(result.success).toBe(true); } }); it("rejects self-referencing edges", () => { // Self-refs are structurally valid but semantically questionable const edge = { ...validEdge, fromNodeId: "n1", toNodeId: "n1" }; const result = situationEdgeSchema.safeParse(edge); expect(result.success).toBe(true); // Structure is valid; semantics checked elsewhere }); }); describe("situationGraphSchema", () => { const validGraph = { centralStatement: "Test graph summary", nodes: [makeNode({ id: "n1", label: "Node 1" })], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Initial summary", }; it("validates a complete valid graph", () => { const result = situationGraphSchema.safeParse(validGraph); expect(result.success).toBe(true); }); it("requires at least one node", () => { const invalid = { ...validGraph, nodes: [] }; const result = situationGraphSchema.safeParse(invalid); expect(result.success).toBe(false); }); it("allows empty edges array", () => { const graph = { ...validGraph, edges: [] }; const result = situationGraphSchema.safeParse(graph); expect(result.success).toBe(true); }); it("rejects missing centralStatement", () => { const invalid = { ...validGraph, centralStatement: "" }; const result = situationGraphSchema.safeParse(invalid); expect(result.success).toBe(false); }); }); describe("graphUpdateSchema", () => { it("validates empty update (no-op proposal)", () => { const result = graphUpdateSchema.safeParse({}); expect(result.success).toBe(true); }); it("validates a complete update", () => { const node = makeNode({ id: "n2", label: "New Node" }); const edge = makeEdge({ fromNodeId: "n1", toNodeId: "n2" }); const result = graphUpdateSchema.safeParse({ addedNodes: [node], updatedNodes: [ { nodeId: "n1", newStatus: "resolved", previousStatus: "unknown", reason: "Question answered", }, ], addedEdges: [edge], removedEdgeIds: ["e-old"], resolvedUnknownNodeIds: ["n2"], affectedNodeIds: ["n3"], selectedQuestion: { nodeId: "n2", question: "What does this new node mean?", reason: "A follow-up unknown remains.", }, answerMeaning: { userSupportedMeaning: "The user directly established a concrete answer.", possibleInference: null, supportCategory: "other", resolutionGuidance: "may_resolve", }, }); expect(result.success).toBe(true); }); it("allows null selectedQuestion", () => { const result = graphUpdateSchema.safeParse({ selectedQuestion: null, }); expect(result.success).toBe(true); }); it("allows null answerMeaning", () => { const result = graphUpdateSchema.safeParse({ answerMeaning: null, }); expect(result.success).toBe(true); }); it("validates structured answerMeaning when present", () => { const result = graphUpdateSchema.safeParse({ answerMeaning: { userSupportedMeaning: "Risk matters more to me.", possibleInference: "This may imply caution, but does not establish a hard constraint.", supportCategory: answerSupportCategory.relative_priority_only, resolutionGuidance: answerResolutionGuidance.must_remain_unresolved, }, }); expect(result.success).toBe(true); }); it("rejects invalid supportCategory values", () => { const result = graphUpdateSchema.safeParse({ answerMeaning: { userSupportedMeaning: "Risk matters more to me.", possibleInference: null, supportCategory: "relative priority only", resolutionGuidance: answerResolutionGuidance.must_remain_unresolved, }, }); expect(result.success).toBe(false); }); it("rejects invalid resolutionGuidance values", () => { const result = graphUpdateSchema.safeParse({ answerMeaning: { userSupportedMeaning: "Risk matters more to me.", possibleInference: null, supportCategory: answerSupportCategory.relative_priority_only, resolutionGuidance: "leave unresolved", }, }); expect(result.success).toBe(false); }); it("rejects update with invalid node kind in addedNodes", () => { const invalid = graphUpdateSchema.safeParse({ addedNodes: [ { id: "x", label: "Test", kind: "invalid_kind", description: "test", status: "unknown", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }, ], }); expect(invalid.success).toBe(false); }); }); describe("API request schemas", () => { describe("startCaseRequestSchema", () => { it("validates scenario field", () => { const result = startCaseRequestSchema.safeParse({ scenario: "Test scenario", }); expect(result.success).toBe(true); }); it("rejects empty scenario", () => { const result = startCaseRequestSchema.safeParse({ scenario: "" }); expect(result.success).toBe(false); }); it("rejects scenario over 10000 chars", () => { const longScenario = "a".repeat(10001); const result = startCaseRequestSchema.safeParse({ scenario: longScenario, }); expect(result.success).toBe(false); }); it("accepts optional promptVersion", () => { const result = startCaseRequestSchema.safeParse({ scenario: "Test", promptVersion: "v0.3", }); expect(result.success).toBe(true); }); }); describe("updateCaseRequestSchema", () => { it("validates complete update request", () => { const graph = makeGraph({ centralStatement: "Test scenario", nodes: [makeNode({ id: "n1", label: "N" })], currentSummary: "Current state of situation", }); const result = updateCaseRequestSchema.safeParse({ situationGraph: graph, previousQuestion: "What happened?", answer: "This is the answer", }); expect(result.success).toBe(true); }); it("rejects missing situationGraph", () => { const result = updateCaseRequestSchema.safeParse({ previousQuestion: "Q?", answer: "A", }); expect(result.success).toBe(false); }); it("rejects answer over 5000 chars", () => { const graph = makeGraph({ centralStatement: "Test", nodes: [makeNode({ id: "n1", label: "N" })], currentSummary: "Test summary", }); const result = updateCaseRequestSchema.safeParse({ situationGraph: graph, previousQuestion: "Q?", answer: "x".repeat(5001), }); expect(result.success).toBe(false); }); }); }); describe("deterministic ID generation", () => { it("generate consistent IDs for same label", () => { const id1 = makeNodeId("Same Label"); const id2 = makeNodeId("Same Label"); expect(id1).toBe(id2); }); it("generates different IDs for different labels", () => { const id1 = makeNodeId("Label A"); const id2 = makeNodeId("Label B"); expect(id1).not.toBe(id2); }); it("IDs are prefixed with 'n' and short", () => { const id = makeNodeId( "A very long label that would produce a longer hash if not truncated", ); expect(id.startsWith("n")).toBe(true); expect(id.length).toBeLessThan(15); }); it("same kind of nodes get deterministic IDs", () => { for (let i = 0; i < 10; i++) { expect(makeNodeId("Test Node")).toBe(makeNodeId("Test Node")); } }); }); describe("helper functions", () => { describe("makeNode", () => { it("creates a minimal node with defaults", () => { const node = makeNode({ label: "Minimal" }); const result = situationNodeSchema.safeParse(node); expect(result.success).toBe(true); expect(node.kind).toBe("observation"); expect(node.status).toBe("unknown"); expect(node.confidence).toBe("medium"); }); it("creates a node with custom kind/status", () => { const node = makeNode({ label: "Custom", kind: "metric", status: "known", confidence: "high", value: 42, unit: "count", }); expect(node.kind).toBe("metric"); expect(node.status).toBe("known"); expect(node.confidence).toBe("high"); expect(node.value).toBe(42); expect(node.unit).toBe("count"); }); it("generates ID from label if none provided", () => { const node = makeNode({ label: "Auto-ID" }); expect(node.id.startsWith("n")).toBe(true); }); }); describe("makeEdge", () => { it("creates a minimal edge with defaults", () => { const edge = makeEdge({ fromNodeId: "n1", toNodeId: "n2" }); const result = situationEdgeSchema.safeParse(edge); expect(result.success).toBe(true); }); it("generates description from node ids if not provided", () => { const edge = makeEdge({ fromNodeId: "n-alpha", toNodeId: "n-beta" }); expect(edge.description).toContain("alpha"); expect(edge.description).toContain("beta"); }); }); describe("makeGraph", () => { it("creates a minimal graph with defaults", () => { const graph = makeGraph({ centralStatement: "Test", currentSummary: "Default summary", nodes: [makeNode({ id: "n1", label: "Placeholder" })], }); const result = situationGraphSchema.safeParse(graph); expect(result.success).toBe(true); }); it("allows specifying nodes and edges", () => { const graph = makeGraph({ centralStatement: "Full Graph", currentSummary: "Full summary", nodes: [makeNode({ id: "n1", label: "N1" })], edges: [makeEdge({ fromNodeId: "n1", toNodeId: "n2" })], }); expect(graph.nodes.length).toBe(1); expect(graph.edges.length).toBe(1); }); }); }); describe("enum values completeness", () => { it("SituationKind has all expected values", () => { const expected = [ "observation", "reported_claim", "metric", "state", "transition", "relationship", "assumption", "unknown", "conclusion", "option", ]; const actual = Object.values(SituationKind); expect(actual).toEqual(expect.arrayContaining(expected)); }); it("SituationStatus has all expected values", () => { const expected = [ "known", "unknown", "provisional", "supported", "weakened", "contradicted", "resolved", ]; const actual = Object.values(SituationStatus); expect(actual).toEqual(expect.arrayContaining(expected)); }); it("SituationRelationship has all expected values", () => { const expected = [ "supports", "weakens", "contradicts", "depends_on", "causes", "may_cause", "measures", "compares_with", "updates", "contained_in", "other", ]; const actual = Object.values(SituationRelationship); expect(actual).toEqual(expect.arrayContaining(expected)); }); it("ConfidenceLevel has all expected values", () => { const actual = Object.values(ConfidenceLevel); expect(actual).toContain("low"); expect(actual).toContain("medium"); expect(actual).toContain("high"); }); }); // ── Experiment 60A.3 — Decision Option Vocabulary Boundary ────────── describe("60A.3 option node kind", () => { it("accepts 'option' as a valid SituationKind on a node", () => { const result = situationNodeSchema.safeParse({ id: "n-opt-a", label: "Option A", description: "Candidate path A", kind: "option", status: "unknown", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }); expect(result.success).toBe(true); }); it("existing node kinds are still accepted", () => { for (const kind of Object.values(SituationKind)) { const result = situationNodeSchema.safeParse({ id: "n-x", label: "Test", description: "Test", kind, status: "known", confidence: "high", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }); expect(result.success).toBe(true); } }); it("still rejects invalid node kind", () => { const result = situationNodeSchema.safeParse({ id: "n-x", label: "Test", description: "Test", kind: "decision", status: "known", confidence: "high", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }); expect(result.success).toBe(false); }); it("rejects 'alternative' as a node kind", () => { const result = situationNodeSchema.safeParse({ id: "n-x", label: "Test", description: "Test", kind: "alternative", status: "known", confidence: "high", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }); expect(result.success).toBe(false); }); it("rejects 'baseline' as a node kind", () => { const result = situationNodeSchema.safeParse({ id: "n-x", label: "Test", description: "Test", kind: "baseline", status: "known", confidence: "high", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }); expect(result.success).toBe(false); }); it("rejects 'outcome' as a node kind", () => { const result = situationNodeSchema.safeParse({ id: "n-x", label: "Test", description: "Test", kind: "outcome", status: "known", confidence: "high", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [], }); expect(result.success).toBe(false); }); }); describe("60A.3 contained_in relationship", () => { it("accepts 'contained_in' as a valid SituationRelationship on an edge", () => { const result = situationEdgeSchema.safeParse({ id: "e-opt-a-to-ctx", fromNodeId: "n-opt-a", toNodeId: "n-decision-ctx", relationship: "contained_in", confidence: "medium", description: "Option A belongs to decision context", }); expect(result.success).toBe(true); }); it("existing relationships are still accepted", () => { for (const rel of Object.values(SituationRelationship)) { const result = situationEdgeSchema.safeParse({ id: "e-x", fromNodeId: "n-a", toNodeId: "n-b", relationship: rel, confidence: "high", description: "test", }); expect(result.success).toBe(true); } }); it("rejects invalid relationship type", () => { const result = situationEdgeSchema.safeParse({ id: "e-x", fromNodeId: "n-a", toNodeId: "n-b", relationship: "contains_option", confidence: "high", description: "test", }); expect(result.success).toBe(false); }); it("rejects 'alternative_to' as a SituationRelationship", () => { const result = situationEdgeSchema.safeParse({ id: "e-x", fromNodeId: "n-a", toNodeId: "n-b", relationship: "alternative_to", confidence: "high", description: "test", }); expect(result.success).toBe(false); }); it("rejects 'option_for' as a SituationRelationship", () => { const result = situationEdgeSchema.safeParse({ id: "e-x", fromNodeId: "n-a", toNodeId: "n-b", relationship: "option_for", confidence: "high", description: "test", }); expect(result.success).toBe(false); }); }); describe("60A.3 native two-option graph fixture", () => { it("constructs and validates a complete two-option decision graph", () => { const unknownCtx = makeNode({ id: "n-decision-ctx", label: "Which path leaves us better off?", description: "Unresolved decision context for the current choice.", kind: "unknown", status: "unknown", confidence: "medium", }); const optionA = makeNode({ id: "n-option-a", label: "Path A — Act", description: "Candidate action A with its own consequences.", kind: "option", status: "unknown", confidence: "medium", }); const optionB = makeNode({ id: "n-option-b", label: "Path B — Do nothing", description: "Candidate action B (do-nothing / stay-put).", kind: "option", status: "unknown", confidence: "medium", }); const consequenceA = makeNode({ id: "n-cons-a", label: "Benefit of Path A", description: "Consequence that applies to option A.", kind: "metric", status: "known", confidence: "high", value: 100, }); const consequenceB = makeNode({ id: "n-cons-b", label: "Cost of Path B", description: "Consequence that applies to option B.", kind: "observation", status: "known", confidence: "high", }); const graph = makeGraph({ centralStatement: "Test two-option decision.", nodes: [unknownCtx, optionA, optionB, consequenceA, consequenceB], edges: [ makeEdge({ id: "e-opt-a-contained", fromNodeId: optionA.id, toNodeId: unknownCtx.id, relationship: "contained_in", confidence: "high", description: "Option A belongs to decision context", }), makeEdge({ id: "e-opt-b-contained", fromNodeId: optionB.id, toNodeId: unknownCtx.id, relationship: "contained_in", confidence: "high", description: "Option B belongs to decision context", }), makeEdge({ id: "e-cons-a-to-opt-a", fromNodeId: consequenceA.id, toNodeId: optionA.id, relationship: "supports", confidence: "high", description: "Consequence supports option A", }), makeEdge({ id: "e-cons-b-to-opt-b", fromNodeId: consequenceB.id, toNodeId: optionB.id, relationship: "weakens", confidence: "high", description: "Consequence weakens option B", }), ], activeUnknownNodeId: unknownCtx.id, resolvedNodeIds: [], currentSummary: "2 options under 1 decision context.", }); expect(graph.nodes.length).toBe(5); expect(graph.edges.length).toBe(4); // Both options share the same decision-context unknown const optionEdges = graph.edges.filter((e) => e.relationship === "contained_in"); expect(optionEdges.length).toBe(2); expect(optionEdges[0].toNodeId).toBe(optionEdges[1].toNodeId); // Each option has its own consequence attached const aConsequences = graph.edges.filter( (e) => e.toNodeId === optionA.id, ); const bConsequences = graph.edges.filter( (e) => e.toNodeId === optionB.id, ); expect(aConsequences.length).toBeGreaterThan(0); expect(bConsequences.length).toBeGreaterThan(0); // No decision node kind exists expect(graph.nodes.some((n) => n.kind === "option")).toBe(true); expect(graph.nodes.some((n) => n.kind === "unknown")).toBe(true); }); });