feat: add situation graph foundation
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SituationKind,
|
||||
SituationStatus,
|
||||
ConfidenceLevel,
|
||||
SituationRelationship,
|
||||
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"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
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"];
|
||||
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", "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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user