1343 lines
39 KiB
JavaScript
1343 lines
39 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
validateGraphReferences,
|
|
detectDuplicateNodeIds,
|
|
detectDuplicateEdges,
|
|
scoreUnknownCandidate,
|
|
findDependentNodes,
|
|
findAffectedNodes,
|
|
resolveUnknownNode,
|
|
selectActiveUnknownCandidate,
|
|
applyGraphUpdate,
|
|
validateGraphUpdate,
|
|
} from "@/lib/graph/utils.js";
|
|
import { makeNode, makeEdge, makeGraph } from "@/lib/graph/schema.js";
|
|
|
|
// ── Helper: build a minimal graph for tests ───────────
|
|
|
|
function makeTestGraph() {
|
|
const n1 = makeNode({ id: "n1", label: "Actor A" });
|
|
const n2 = makeNode({ id: "n2", label: "State B" });
|
|
const n3 = makeNode({ id: "n3", label: "Transition C" });
|
|
const n4 = makeNode({ id: "n4", label: "Unknown D" });
|
|
const n5 = makeNode({ id: "n5", label: "Unknown E" });
|
|
|
|
// n2 depends on n1; n3 depends on n2 (transitive depends on n1)
|
|
n2.dependsOn.push(n1.id);
|
|
n3.dependsOn.push(n2.id);
|
|
|
|
// n4 is an unknown not depended on
|
|
// n5 is an unknown depended upon by n3 indirectly
|
|
|
|
const e1 = makeEdge({
|
|
id: "e1",
|
|
fromNodeId: n1.id,
|
|
toNodeId: n2.id,
|
|
relationship: "depends_on",
|
|
});
|
|
const e2 = makeEdge({
|
|
id: "e2",
|
|
fromNodeId: n3.id,
|
|
toNodeId: n1.id,
|
|
relationship: "supports",
|
|
});
|
|
|
|
return makeGraph({
|
|
centralStatement: "Test graph",
|
|
nodes: [n1, n2, n3, n4, n5],
|
|
edges: [e1, e2],
|
|
activeUnknownNodeId: n4.id,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
}
|
|
|
|
describe("validateGraphReferences", () => {
|
|
it("accepts valid graph with all self-consistent references", () => {
|
|
const graph = makeTestGraph();
|
|
const result = validateGraphReferences(graph);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors.length).toBe(0);
|
|
});
|
|
|
|
it("detects invalid parentId reference", () => {
|
|
const graph = makeTestGraph();
|
|
// n1 has no parentId, so this won't trigger; let's add one manually
|
|
graph.nodes[0].parentId = "nonexistent-parent";
|
|
const result = validateGraphReferences(graph);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes("nonexistent-parent"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("detects invalid childIds reference", () => {
|
|
const graph = makeTestGraph();
|
|
graph.nodes[0].childIds.push("ghost-node");
|
|
const result = validateGraphReferences(graph);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("detects invalid dependsOn reference", () => {
|
|
const graph = makeTestGraph();
|
|
graph.nodes[0].dependsOn.push("phantom-dep");
|
|
const result = validateGraphReferences(graph);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("detects invalid affects reference", () => {
|
|
const graph = makeTestGraph();
|
|
graph.nodes[0].affects.push("void-node");
|
|
const result = validateGraphReferences(graph);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("detects edge referencing non-existent fromNodeId", () => {
|
|
const graph = makeTestGraph();
|
|
graph.edges[0].fromNodeId = "ghost-node";
|
|
const result = validateGraphReferences(graph);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
|
|
});
|
|
|
|
it("detects edge referencing non-existent toNodeId", () => {
|
|
const graph = makeTestGraph();
|
|
graph.edges[0].toNodeId = "void-node";
|
|
const result = validateGraphReferences(graph);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("allows mixed valid and invalid references", () => {
|
|
const graph = makeTestGraph();
|
|
graph.nodes[0].parentId = "missing";
|
|
graph.nodes[1].parentId = "also-missing";
|
|
|
|
const result = validateGraphReferences(graph);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("detectDuplicateNodeIds", () => {
|
|
it("returns empty for unique nodes", () => {
|
|
const graph = makeTestGraph();
|
|
const dups = detectDuplicateNodeIds(graph.nodes);
|
|
expect(dups.length).toBe(0);
|
|
});
|
|
|
|
it("detects exact duplicate IDs", () => {
|
|
const n1 = makeNode({ id: "dup", label: "First" });
|
|
const n2 = makeNode({ id: "dup", label: "Second" });
|
|
const dups = detectDuplicateNodeIds([n1, n2]);
|
|
expect(dups.length).toBe(1);
|
|
expect(dups[0].nodeId).toBe("dup");
|
|
expect(dups[0].count).toBe(2);
|
|
});
|
|
|
|
it("detects multiple duplicate groups", () => {
|
|
const nodes = [
|
|
makeNode({ id: "dup", label: "A" }),
|
|
makeNode({ id: "dup", label: "B" }),
|
|
makeNode({ id: "dup", label: "C" }),
|
|
makeNode({ id: "dup2", label: "D" }),
|
|
makeNode({ id: "dup2", label: "E" }),
|
|
];
|
|
const dups = detectDuplicateNodeIds(nodes);
|
|
expect(dups.length).toBe(2);
|
|
});
|
|
|
|
it("reports correct count for triple duplicates", () => {
|
|
const nodes = [
|
|
makeNode({ id: "trip", label: "1" }),
|
|
makeNode({ id: "trip", label: "2" }),
|
|
makeNode({ id: "trip", label: "3" }),
|
|
];
|
|
const dups = detectDuplicateNodeIds(nodes);
|
|
expect(dups[0].count).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("detectDuplicateEdges", () => {
|
|
it("returns empty for unique edges", () => {
|
|
const graph = makeTestGraph();
|
|
const dups = detectDuplicateEdges(graph.edges);
|
|
expect(dups.length).toBe(0);
|
|
});
|
|
|
|
it("detects duplicate edge (same from, to, relationship)", () => {
|
|
const n1 = makeNode({ id: "n1", label: "A" });
|
|
const n2 = makeNode({ id: "n2", label: "B" });
|
|
const e1 = makeEdge({
|
|
id: "e1",
|
|
fromNodeId: n1.id,
|
|
toNodeId: n2.id,
|
|
relationship: "supports",
|
|
});
|
|
const e2 = makeEdge({
|
|
id: "e2",
|
|
fromNodeId: n1.id,
|
|
toNodeId: n2.id,
|
|
relationship: "supports",
|
|
});
|
|
|
|
const dups = detectDuplicateEdges([e1, e2]);
|
|
expect(dups.length).toBe(1);
|
|
});
|
|
|
|
it("allows same nodes with different relationship types", () => {
|
|
const n1 = makeNode({ id: "n1", label: "A" });
|
|
const n2 = makeNode({ id: "n2", label: "B" });
|
|
const e1 = makeEdge({
|
|
id: "e1",
|
|
fromNodeId: n1.id,
|
|
toNodeId: n2.id,
|
|
relationship: "supports",
|
|
});
|
|
const e2 = makeEdge({
|
|
id: "e2",
|
|
fromNodeId: n1.id,
|
|
toNodeId: n2.id,
|
|
relationship: "weakens",
|
|
});
|
|
|
|
const dups = detectDuplicateEdges([e1, e2]);
|
|
expect(dups.length).toBe(0);
|
|
});
|
|
|
|
it("detects reversed direction as different edge", () => {
|
|
const n1 = makeNode({ id: "n1", label: "A" });
|
|
const n2 = makeNode({ id: "n2", label: "B" });
|
|
const e1 = makeEdge({
|
|
id: "e1",
|
|
fromNodeId: n1.id,
|
|
toNodeId: n2.id,
|
|
relationship: "supports",
|
|
});
|
|
const e2 = makeEdge({
|
|
id: "e2",
|
|
fromNodeId: n2.id,
|
|
toNodeId: n1.id,
|
|
relationship: "supports",
|
|
});
|
|
|
|
const dups = detectDuplicateEdges([e1, e2]);
|
|
expect(dups.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("findDependentNodes (transitive)", () => {
|
|
it("returns empty for node with no dependents", () => {
|
|
const graph = makeTestGraph();
|
|
// n5 has nothing depending on it
|
|
const deps = findDependentNodes(graph, "n5");
|
|
expect(deps.length).toBe(0);
|
|
});
|
|
|
|
it("finds direct dependents via dependsOn", () => {
|
|
const graph = makeTestGraph();
|
|
// n2 depends on n1
|
|
const deps = findDependentNodes(graph, "n1");
|
|
expect(deps).toContain("n2");
|
|
});
|
|
|
|
it("finds transitive dependents via dependsOn chain", () => {
|
|
const graph = makeTestGraph();
|
|
// n3 depends on n2 depends on n1 — so both n2 and n3 depend on n1
|
|
const deps = findDependentNodes(graph, "n1");
|
|
expect(deps).toContain("n2");
|
|
expect(deps).toContain("n3");
|
|
});
|
|
|
|
it("finds dependents via edge relationship too", () => {
|
|
const graph = makeTestGraph();
|
|
// e2: n3 -> n1 (supports), so if we query for nodes depending on n1
|
|
// the function also looks at edges where toNodeId === queriedId
|
|
const deps = findDependentNodes(graph, "n1");
|
|
expect(deps).toContain("n2");
|
|
});
|
|
|
|
it("returns self if node depends on itself", () => {
|
|
const graph = makeTestGraph();
|
|
graph.nodes[0].dependsOn.push("n1"); // n1 depends on n1 (circular)
|
|
const deps = findDependentNodes(graph, "n1");
|
|
expect(deps).toContain("n1");
|
|
});
|
|
|
|
it("handles deep dependency chains", () => {
|
|
const nodes = [];
|
|
for (let i = 1; i <= 10; i++) {
|
|
nodes.push(makeNode({ id: `n${i}`, label: `N${i}` }));
|
|
}
|
|
// Chain: n2 depends on n1, n3 depends on n2, ..., n10 depends on n9
|
|
for (let i = 2; i <= 10; i++) {
|
|
nodes[i - 1].dependsOn.push(nodes[0].id); // All depend on n1
|
|
}
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "Chain",
|
|
nodes,
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
|
|
const deps = findDependentNodes(graph, "n1");
|
|
expect(deps.length).toBe(9); // All other nodes depend on n1
|
|
});
|
|
});
|
|
|
|
describe("findAffectedNodes (transitive)", () => {
|
|
it("returns empty for node that affects nothing", () => {
|
|
const graph = makeTestGraph();
|
|
const affected = findAffectedNodes(graph, "n5");
|
|
expect(affected.length).toBe(0);
|
|
});
|
|
|
|
it("finds nodes listed in affects array", () => {
|
|
// Set up: n2 has n3 in its affects list
|
|
const graph = makeTestGraph();
|
|
graph.nodes[1].affects.push("n3");
|
|
const affected = findAffectedNodes(graph, "n2");
|
|
expect(affected).toContain("n3");
|
|
});
|
|
|
|
it("propagates through dependsOn transitive chain", () => {
|
|
// n3 depends on n2, and n2's affects includes some node that depends on n3
|
|
const graph = makeTestGraph();
|
|
// If n2 is changed and n3 depends on n2, then n3 should be affected
|
|
graph.nodes[2].dependsOn.push("n2"); // Explicit dependency
|
|
const affected = findAffectedNodes(graph, "n2");
|
|
expect(affected).toContain("n3");
|
|
});
|
|
|
|
it("handles empty graph", () => {
|
|
// build a minimal graph without triggering schema validation for this edge case
|
|
const graph = {
|
|
centralStatement: "Empty",
|
|
nodes: [],
|
|
edges: [],
|
|
resolvedNodeIds: [],
|
|
currentSummary: "",
|
|
activeUnknownNodeId: null,
|
|
};
|
|
const affected = findAffectedNodes(graph, "any-node");
|
|
expect(affected.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("resolveUnknownNode", () => {
|
|
it("returns success for valid node id", () => {
|
|
const graph = makeTestGraph();
|
|
const result = resolveUnknownNode(
|
|
graph,
|
|
"n4",
|
|
"resolved",
|
|
"Confirmed",
|
|
"User confirmed",
|
|
);
|
|
expect(result.success).toBe(true);
|
|
expect(result.newStatus).toBe("resolved");
|
|
expect(result.reason).toBe("User confirmed");
|
|
});
|
|
|
|
it("returns error for non-existent node", () => {
|
|
const graph = makeTestGraph();
|
|
const result = resolveUnknownNode(
|
|
graph,
|
|
"ghost-node",
|
|
"resolved",
|
|
null,
|
|
"reason",
|
|
);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("not found");
|
|
});
|
|
|
|
it("reports affectedNodes in result", () => {
|
|
const graph = makeTestGraph();
|
|
// n5 depends on... actually let's set up properly
|
|
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
|
|
graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B
|
|
const result = resolveUnknownNode(
|
|
graph,
|
|
"n4",
|
|
"resolved",
|
|
"Yes",
|
|
"Clarified",
|
|
);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("tracks previous status and value", () => {
|
|
const graph = makeTestGraph();
|
|
const result = resolveUnknownNode(
|
|
graph,
|
|
"n4",
|
|
"known",
|
|
"confirmed_value",
|
|
"Evidence found",
|
|
);
|
|
expect(result.previousStatus).toBe("unknown");
|
|
expect(result.newValue).toBe("confirmed_value");
|
|
});
|
|
});
|
|
|
|
describe("selectActiveUnknownCandidate", () => {
|
|
it("returns null when no unresolved unknowns", () => {
|
|
// makeTestGraph nodes default to kind "observation", not "unknown"
|
|
// Create explicit unknown-kind nodes for this test
|
|
const nUnknown = makeNode({
|
|
id: "n-unk-x",
|
|
label: "Unknown X",
|
|
kind: "unknown",
|
|
});
|
|
const graph = makeGraph({
|
|
centralStatement: "Test",
|
|
nodes: [nUnknown],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
// Mark it as resolved so no unresolved unknowns remain
|
|
const result = selectActiveUnknownCandidate(graph, ["n-unk-x"]);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("skips already-resolved nodes and returns remaining unknown", () => {
|
|
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
|
|
const n2 = makeNode({ id: "n-unk-b", label: "Unknown B", kind: "unknown" });
|
|
const graph = makeGraph({
|
|
centralStatement: "Test",
|
|
nodes: [n1, n2],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
|
|
// Skip n2 by passing it as resolved; no unknown-kind nodes remain
|
|
const result = selectActiveUnknownCandidate(graph, ["n-unk-b"]);
|
|
expect(result).toBeNull();
|
|
|
|
// Without skipping, should return n2
|
|
const result2 = selectActiveUnknownCandidate(graph, []);
|
|
expect(result2.nodeId).toBe("n-unk-b");
|
|
});
|
|
|
|
it("prioritises nodes with more dependents", () => {
|
|
const unknownA = makeNode({
|
|
id: "unknown-a",
|
|
label: "Unknown A",
|
|
kind: "unknown",
|
|
});
|
|
const unknownB = makeNode({
|
|
id: "unknown-b",
|
|
label: "Unknown B",
|
|
kind: "unknown",
|
|
});
|
|
const dependent = makeNode({
|
|
id: "dep",
|
|
label: "Dependent",
|
|
kind: "state",
|
|
});
|
|
|
|
dependent.dependsOn.push("unknown-a");
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "Priority test",
|
|
nodes: [unknownA, unknownB, dependent],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
|
|
const result = selectActiveUnknownCandidate(graph, []);
|
|
expect(result.nodeId).toBe("unknown-a"); // Has more dependents (score 2 vs 0)
|
|
expect(result.status).toBe("selected");
|
|
});
|
|
|
|
it("returns one candidate (not array)", () => {
|
|
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
|
|
const nUnknown = makeNode({
|
|
id: "n-unk",
|
|
label: "Pending",
|
|
kind: "unknown",
|
|
});
|
|
const graph = makeGraph({
|
|
centralStatement: "Test",
|
|
nodes: [n1, nUnknown],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
|
|
const result = selectActiveUnknownCandidate(graph, []);
|
|
expect(typeof result).toBe("object");
|
|
expect(result.nodeId).toBeDefined();
|
|
expect(result.label).toBeDefined();
|
|
expect(result.score).toBeDefined();
|
|
});
|
|
|
|
it("commercial value wins over pricing", () => {
|
|
const commercialValue = makeNode({
|
|
id: "n-commercial-value",
|
|
label: "Commercial value definition",
|
|
description:
|
|
"Need to define commercial value because the decision depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
});
|
|
const pricing = makeNode({
|
|
id: "n-pricing",
|
|
label: "Target price point",
|
|
description:
|
|
"Need a target price point because revenue assumptions depend on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
dependsOn: ["n-commercial-value"],
|
|
});
|
|
const decision = makeNode({
|
|
id: "n-decision",
|
|
label: "Build decision",
|
|
description: "Decision context",
|
|
kind: "state",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
dependsOn: ["n-commercial-value", "n-pricing"],
|
|
});
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "Build decision",
|
|
nodes: [commercialValue, pricing, decision],
|
|
edges: [],
|
|
activeUnknownNodeId: pricing.id,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
|
|
const result = selectActiveUnknownCandidate(graph, []);
|
|
expect(result.nodeId).toBe("n-commercial-value");
|
|
});
|
|
|
|
it("customer value wins over UI colour", () => {
|
|
const customerValue = makeNode({
|
|
id: "n-customer-value",
|
|
label: "Customer value",
|
|
description:
|
|
"Need to know customer value because adoption depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
});
|
|
const uiColour = makeNode({
|
|
id: "n-ui-colour",
|
|
label: "UI colour",
|
|
description: "Need a UI colour because presentation choices remain open.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "low",
|
|
});
|
|
const graph = makeGraph({
|
|
centralStatement: "Value question",
|
|
nodes: [customerValue, uiColour],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
|
|
const result = selectActiveUnknownCandidate(graph, []);
|
|
expect(result.nodeId).toBe("n-customer-value");
|
|
});
|
|
|
|
it("success criteria wins over marketing slogan", () => {
|
|
const successCriteria = makeNode({
|
|
id: "n-success-criteria",
|
|
label: "Success criteria",
|
|
description:
|
|
"Need success criteria because the decision requires a threshold.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
});
|
|
const slogan = makeNode({
|
|
id: "n-slogan",
|
|
label: "Marketing slogan",
|
|
description: "Need a slogan because messaging is undecided.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "low",
|
|
});
|
|
const graph = makeGraph({
|
|
centralStatement: "Threshold question",
|
|
nodes: [successCriteria, slogan],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
|
|
const result = selectActiveUnknownCandidate(graph, []);
|
|
expect(result.nodeId).toBe("n-success-criteria");
|
|
});
|
|
|
|
it("penalises unknowns with unresolved parent unknowns", () => {
|
|
const parentUnknown = makeNode({
|
|
id: "n-parent",
|
|
label: "Commercial value definition",
|
|
description:
|
|
"Need commercial value definition because the decision depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
});
|
|
const childUnknown = makeNode({
|
|
id: "n-child",
|
|
label: "Target price point",
|
|
description: "Need price point because revenue assumptions depend on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
dependsOn: ["n-parent"],
|
|
});
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "Dependency ordering",
|
|
nodes: [parentUnknown, childUnknown],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Test",
|
|
});
|
|
|
|
const parentScore = scoreUnknownCandidate(graph, parentUnknown, []);
|
|
const childScore = scoreUnknownCandidate(graph, childUnknown, []);
|
|
expect(parentScore.score).toBeGreaterThan(childScore.score);
|
|
});
|
|
|
|
it("returns ambiguous for a complete unresolved tie instead of label-based winner", () => {
|
|
const unknownA = makeNode({
|
|
id: "tie-a",
|
|
label: "Magnitude and nature of cash outflows",
|
|
description: "Magnitude and nature of cash outflows.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
});
|
|
const unknownB = makeNode({
|
|
id: "tie-b",
|
|
label:
|
|
"Whether revenue recognition timing differs from cash collection timing",
|
|
description:
|
|
"Whether revenue recognition timing differs from cash collection timing.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
});
|
|
const graph = makeGraph({
|
|
centralStatement:
|
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
|
nodes: [unknownA, unknownB],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Tie case",
|
|
});
|
|
|
|
const result = selectActiveUnknownCandidate(graph, []);
|
|
expect(result).toMatchObject({
|
|
selectedNode: null,
|
|
status: "ambiguous",
|
|
tieType: "complete_unresolved_tie",
|
|
tiedCandidateIds: ["tie-a", "tie-b"],
|
|
});
|
|
expect(result.nodeId).toBeUndefined();
|
|
});
|
|
|
|
it("alphabetical renaming does not resolve a complete tie", () => {
|
|
const unknownA = makeNode({
|
|
id: "tie-a",
|
|
label: "Unknown B",
|
|
description: "Unknown factor one.",
|
|
kind: "unknown",
|
|
});
|
|
const unknownB = makeNode({
|
|
id: "tie-b",
|
|
label: "Unknown A",
|
|
description: "Unknown factor two.",
|
|
kind: "unknown",
|
|
});
|
|
const graph = makeGraph({
|
|
centralStatement: "Two conflicting signals remain unresolved.",
|
|
nodes: [unknownA, unknownB],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Tie case",
|
|
});
|
|
|
|
const result = selectActiveUnknownCandidate(graph, []);
|
|
expect(result.status).toBe("ambiguous");
|
|
expect(result.tiedCandidateIds.sort()).toEqual(["tie-a", "tie-b"]);
|
|
});
|
|
});
|
|
|
|
describe("applyGraphUpdate", () => {
|
|
it("applies node additions correctly", () => {
|
|
const graph = makeTestGraph();
|
|
const newNode = makeNode({ id: "n-new", label: "New Node" });
|
|
|
|
const update = {
|
|
addedNodes: [newNode],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(true);
|
|
expect(result.nodes.length).toBe(graph.nodes.length + 1);
|
|
expect(result.nodes.some((n) => n.id === "n-new")).toBe(true);
|
|
});
|
|
|
|
it("applies status updates correctly", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const update = {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
previousValue: null,
|
|
newValue: "confirmed",
|
|
reason: "Answered by user",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n4"],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(true);
|
|
|
|
const updatedNode = result.nodes.find((n) => n.id === "n4");
|
|
expect(updatedNode.status).toBe("resolved");
|
|
});
|
|
|
|
it("rejects update with non-existent nodeId in updatedNodes", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const update = {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "ghost-node",
|
|
previousStatus: null,
|
|
newStatus: "known",
|
|
reason: "test",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
|
|
});
|
|
|
|
it("removes requested edges", () => {
|
|
const graph = makeTestGraph();
|
|
const edgeIdToRemove = graph.edges[0].id;
|
|
|
|
const update = {
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [edgeIdToRemove],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(true);
|
|
expect(result.edges.length).toBe(graph.edges.length - 1);
|
|
expect(result.edges.some((e) => e.id === edgeIdToRemove)).toBe(false);
|
|
});
|
|
|
|
it("adds edges and updates node dependsOn/affects", () => {
|
|
const graph = makeTestGraph();
|
|
const newEdge = makeEdge({
|
|
fromNodeId: "n1",
|
|
toNodeId: "n4",
|
|
relationship: "supports",
|
|
});
|
|
|
|
const update = {
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [newEdge],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(true);
|
|
|
|
// Check the edge was added
|
|
expect(result.edges.some((e) => e.id === newEdge.id)).toBe(true);
|
|
|
|
// Check node relationship arrays updated
|
|
const fromNode = result.nodes.find((n) => n.id === "n1");
|
|
const toNode = result.nodes.find((n) => n.id === "n4");
|
|
expect(fromNode.childIds).toContain("n4");
|
|
expect(toNode.dependsOn).toContain("n1");
|
|
});
|
|
|
|
it("accumulates resolved node IDs", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const update = {
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n4"],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(true);
|
|
expect(result.resolvedNodeIds).toContain("n4");
|
|
});
|
|
|
|
it("rejects adding duplicate node IDs", () => {
|
|
const graph = makeTestGraph();
|
|
const existingNode = graph.nodes[0]; // id: "n1"
|
|
|
|
// Use the exact same ID as an existing node to create a real duplicate
|
|
const update = {
|
|
addedNodes: [{ ...existingNode, id: "n1", label: "Dup Node" }],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects edges referencing non-existent nodes", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const update = {
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [
|
|
{
|
|
id: "e-new",
|
|
fromNodeId: "missing-node",
|
|
toNodeId: "n1",
|
|
relationship: "supports",
|
|
confidence: "medium",
|
|
description: "bad edge",
|
|
},
|
|
],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("preserves nodes not mentioned in the update", () => {
|
|
const graph = makeTestGraph();
|
|
const unchangedCount = graph.nodes.length;
|
|
|
|
const update = {
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(true);
|
|
expect(result.nodes.length).toBe(unchangedCount);
|
|
});
|
|
|
|
it("applies multiple operations in one update", () => {
|
|
const graph = makeTestGraph();
|
|
const newNode = makeNode({ id: "n-multi", label: "Multi" });
|
|
|
|
const update = {
|
|
addedNodes: [newNode],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
reason: "Multiple ops test",
|
|
},
|
|
],
|
|
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
|
|
removedEdgeIds: [graph.edges[0]?.id || ""],
|
|
resolvedUnknownNodeIds: ["n4"],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
const result = applyGraphUpdate(graph, update);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("validateGraphUpdate", () => {
|
|
it("accepts a no-op update with added nodes", () => {
|
|
const graph = makeTestGraph();
|
|
const newNode = makeNode({ id: "n-new", label: "New" });
|
|
|
|
const result = validateGraphUpdate(graph, {
|
|
addedNodes: [newNode],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it("rejects update with no meaningful change", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const result = validateGraphUpdate(graph, {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n1",
|
|
previousStatus: null,
|
|
newStatus: null,
|
|
previousValue: null,
|
|
newValue: null,
|
|
reason: "No change test",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true);
|
|
});
|
|
|
|
it("rejects duplicate node IDs in additions", () => {
|
|
const graph = makeTestGraph();
|
|
const existingNode = graph.nodes[0];
|
|
|
|
const result = validateGraphUpdate(graph, {
|
|
addedNodes: [existingNode], // Duplicate ID
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects update to non-existent node", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const result = validateGraphUpdate(graph, {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "ghost-node",
|
|
previousStatus: null,
|
|
newStatus: "known",
|
|
reason: "test",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("accepts valid status change as meaningful", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const result = validateGraphUpdate(graph, {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: "unknown",
|
|
newStatus: "known",
|
|
reason: "Confirmed",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it("rejects oversized update (>100KB)", () => {
|
|
const graph = makeTestGraph();
|
|
const largeDescription = "x".repeat(150000);
|
|
|
|
const result = validateGraphUpdate(graph, {
|
|
addedNodes: [{ label: largeDescription }], // Will create huge JSON
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(
|
|
result.errors.some((e) => e.includes("100KB") || e.includes("exceeds")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("returns empty errors array for valid update", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const result = validateGraphUpdate(graph, {
|
|
addedNodes: [makeNode({ id: "n-valid", label: "Valid" })],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── Integration: full update lifecycle ───────────────────
|
|
|
|
describe("update lifecycle integration", () => {
|
|
it("complete update cycle: validate → apply → verify", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
// Create a meaningful update
|
|
const newNode = makeNode({ id: "n-new", label: "New Discovery" });
|
|
const newEdge = makeEdge({
|
|
fromNodeId: "n1",
|
|
toNodeId: "n-new",
|
|
relationship: "supports",
|
|
});
|
|
|
|
// Validate first
|
|
const validationResult = validateGraphUpdate(graph, {
|
|
addedNodes: [newNode],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
reason: "Answered via follow-up question",
|
|
},
|
|
],
|
|
addedEdges: [newEdge],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n4"],
|
|
affectedNodeIds: [],
|
|
});
|
|
expect(validationResult.valid).toBe(true);
|
|
|
|
// Apply
|
|
const applyResult = applyGraphUpdate(graph, {
|
|
addedNodes: [newNode],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
reason: "Answered via follow-up question",
|
|
},
|
|
],
|
|
addedEdges: [newEdge],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n4"],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
expect(applyResult.success).toBe(true);
|
|
expect(applyResult.nodes.length).toBe(graph.nodes.length + 1);
|
|
expect(applyResult.edges.length).toBe(graph.edges.length + 1);
|
|
expect(applyResult.resolvedNodeIds).toContain("n4");
|
|
|
|
// Verify post-apply integrity
|
|
const postValidation = validateGraphReferences(applyResult);
|
|
expect(postValidation.valid).toBe(true);
|
|
});
|
|
|
|
it("reject and retry: invalid update should be caught", () => {
|
|
const graph = makeTestGraph();
|
|
|
|
const invalidUpdate = {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{ nodeId: "ghost-node", newStatus: "known", reason: "test" },
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
// Validation should catch it
|
|
expect(validateGraphUpdate(graph, invalidUpdate).valid).toBe(false);
|
|
|
|
// Apply should also catch it
|
|
expect(applyGraphUpdate(graph, invalidUpdate).success).toBe(false);
|
|
});
|
|
|
|
it("preserve unchanged nodes during update", () => {
|
|
const graph = makeTestGraph();
|
|
const originalNode1 = JSON.parse(JSON.stringify(graph.nodes[0]));
|
|
|
|
applyGraphUpdate(graph, {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
reason: "Test preserve",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n4"],
|
|
affectedNodeIds: [],
|
|
});
|
|
|
|
// Re-read the graph and check n1 wasn't modified
|
|
expect(graph.nodes[0].id).toBe("n1");
|
|
expect(graph.nodes[0].status).toBe("unknown"); // unchanged
|
|
});
|
|
});
|
|
|
|
// ── Semantic-to-mutation contract (57J.39) ──────────────
|
|
|
|
describe("semantic-to-mutation contract", () => {
|
|
const baseUpdate = {
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
};
|
|
|
|
// Test 1 — semantic-only no-op
|
|
it("REJECTS with specific semantic-only structural-progress error when userSupportedMeaning populated and zero structural mutation", () => {
|
|
const graph = makeTestGraph();
|
|
const update = {
|
|
...baseUpdate,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "The user states that cost reduction is a primary driver.",
|
|
possibleInference: null,
|
|
},
|
|
};
|
|
|
|
const result = validateGraphUpdate(graph, update);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(
|
|
result.errors.some((e) => e.includes("userSupportedMeaning") && e.includes("mutation")),
|
|
).toBe(true);
|
|
// Should NOT contain only the generic no-op message without the semantic-specific variant
|
|
expect(result.errors.some((e) => e === "Update contains no meaningful change")).toBe(false);
|
|
});
|
|
|
|
// Test 2 — ordinary no-op (answerMeaning null)
|
|
it("REJECTS with existing 'no meaningful change' when answerMeaning is null and zero structural mutation", () => {
|
|
const graph = makeTestGraph();
|
|
const update = {
|
|
...baseUpdate,
|
|
answerMeaning: null,
|
|
};
|
|
|
|
const result = validateGraphUpdate(graph, update);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true);
|
|
});
|
|
|
|
// Test 3 — possibleInference only
|
|
it("does NOT trigger the new userSupportedMeaning-specific error when only possibleInference is populated", () => {
|
|
const graph = makeTestGraph();
|
|
const update = {
|
|
...baseUpdate,
|
|
answerMeaning: {
|
|
userSupportedMeaning: null,
|
|
possibleInference: "Cost reduction could be achieved through staff consolidation.",
|
|
},
|
|
};
|
|
|
|
const result = validateGraphUpdate(graph, update);
|
|
|
|
expect(result.valid).toBe(false);
|
|
// Must NOT have the semantic-specific error (userSupportedMeaning is not populated)
|
|
expect(
|
|
result.errors.some((e) => e.includes("userSupportedMeaning") && e.includes("mutation")),
|
|
).toBe(false);
|
|
// Generic no-op still applies
|
|
expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true);
|
|
});
|
|
|
|
// Test 4 — update existing structure (valid structural progress)
|
|
it("ACCEPTS past new guard when userSupportedMeaning populated AND valid existing-node status change", () => {
|
|
const graph = makeTestGraph();
|
|
const update = {
|
|
...baseUpdate,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "The user confirms risk is a hard constraint.",
|
|
possibleInference: null,
|
|
},
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: "unknown",
|
|
newStatus: "known",
|
|
reason: "Confirmed by user answer",
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = validateGraphUpdate(graph, update);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors.length).toBe(0);
|
|
});
|
|
|
|
// Test 5 — resolve existing unknown (counts as structural progress)
|
|
it("counts as structural progress when userSupportedMeaning populated AND valid resolution of existing unknown", () => {
|
|
const graph = makeTestGraph();
|
|
const update = {
|
|
...baseUpdate,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "The user provides criteria for acceptable opportunity.",
|
|
possibleInference: null,
|
|
},
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
reason: "Threshold defined by user",
|
|
},
|
|
],
|
|
resolvedUnknownNodeIds: ["n4"],
|
|
};
|
|
|
|
const result = validateGraphUpdate(graph, update);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors.length).toBe(0);
|
|
});
|
|
|
|
// Test 6 — add new structure (counts as structural progress)
|
|
it("counts as structural progress when userSupportedMeaning populated AND valid added unknown", () => {
|
|
const graph = makeTestGraph();
|
|
const update = {
|
|
...baseUpdate,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "The user needs evidence for both savings realism and retention impact.",
|
|
possibleInference: null,
|
|
},
|
|
addedNodes: [makeNode({ id: "n-new-unknown", label: "New unknown" })],
|
|
};
|
|
|
|
const result = validateGraphUpdate(graph, update);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors.length).toBe(0);
|
|
});
|
|
|
|
// Test 7 — duplicate avoidance preserved
|
|
it("still rejects duplicate node IDs even with populated userSupportedMeaning", () => {
|
|
const graph = makeTestGraph();
|
|
const existingNode = graph.nodes[0];
|
|
const update = {
|
|
...baseUpdate,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "There is a new constraint the user identified.",
|
|
possibleInference: null,
|
|
},
|
|
addedNodes: [existingNode], // Duplicate ID — should still be rejected
|
|
};
|
|
|
|
const result = validateGraphUpdate(graph, update);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes("duplicate ID"))).toBe(true);
|
|
});
|
|
|
|
// Test 8 — userSupportedMeaning with meaningful value change (no status change)
|
|
it("counts as structural progress when userSupportedMeaning populated AND valid existing-node value change", () => {
|
|
const graph = makeTestGraph();
|
|
const update = {
|
|
...baseUpdate,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "The user clarified the constraint is absolute.",
|
|
possibleInference: null,
|
|
},
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n4",
|
|
previousStatus: null,
|
|
newStatus: null,
|
|
previousValue: null,
|
|
newValue: "absolute_constraint",
|
|
reason: "Clarified by user answer",
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = validateGraphUpdate(graph, update);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors.length).toBe(0);
|
|
});
|
|
});
|