import { describe, it, expect } from "vitest"; import { FlowGraph } from "../../src/graph/construction.js"; import { topologicalOrder, hasCycles, findCycles, ancestors, descendants, reachableFrom, } from "../../src/graph/queries.js"; import { CycleError, NodeNotFoundError } from "../../src/error/index.js"; import type { DirectedGraph } from "graphology"; function buildDiamondDag(): FlowGraph { const fg = new FlowGraph(); fg.addNode("top", { name: "top" }); fg.addNode("left", { name: "left" }); fg.addNode("right", { name: "right" }); fg.addNode("bottom", { name: "bottom" }); fg.addEdge("top", "left"); fg.addEdge("top", "right"); fg.addEdge("left", "bottom"); fg.addEdge("right", "bottom"); return fg; } function buildChainDag(): FlowGraph { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addNode("c", { name: "c" }); fg.addNode("d", { name: "d" }); fg.addEdge("a", "b"); fg.addEdge("b", "c"); fg.addEdge("c", "d"); return fg; } describe("FlowGraph topologicalOrder", () => { it("returns topological order for a chain", () => { const fg = buildChainDag(); const order = fg.topologicalOrder(); expect(order).toEqual(["a", "b", "c", "d"]); }); it("returns topological order for a diamond DAG", () => { const fg = buildDiamondDag(); const order = fg.topologicalOrder(); expect(order[0]).toBe("top"); expect(order[3]).toBe("bottom"); expect(order).toContain("left"); expect(order).toContain("right"); }); it("returns empty array for empty graph", () => { const fg = new FlowGraph(); expect(fg.topologicalOrder()).toEqual([]); }); it("returns single node for graph with one node", () => { const fg = new FlowGraph(); fg.addNode("only", { name: "only" }); expect(fg.topologicalOrder()).toEqual(["only"]); }); it("throws CycleError when graph has cycles (via raw graph access)", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg._edgeKey; fg.graph.addNode("c"); fg.graph.addEdgeWithKey("a->b", "a", "b"); fg.graph.addEdgeWithKey("b->c", "b", "c"); fg.graph.addEdgeWithKey("c->a", "c", "a"); expect(() => fg.topologicalOrder()).toThrow(CycleError); }); }); describe("FlowGraph hasCycles", () => { it("returns false for a DAG", () => { const fg = buildDiamondDag(); expect(fg.hasCycles()).toBe(false); }); it("returns false for empty graph", () => { const fg = new FlowGraph(); expect(fg.hasCycles()).toBe(false); }); it("returns false for a chain", () => { const fg = buildChainDag(); expect(fg.hasCycles()).toBe(false); }); it("returns true when graph has cycles (via raw graph)", () => { const fg = new FlowGraph(); fg.graph.addNode("a"); fg.graph.addNode("b"); fg.graph.addEdgeWithKey("a->b", "a", "b"); fg.graph.addEdgeWithKey("b->a", "b", "a"); expect(fg.hasCycles()).toBe(true); }); }); describe("FlowGraph findCycles", () => { it("returns empty array for a DAG", () => { const fg = buildDiamondDag(); expect(fg.findCycles()).toEqual([]); }); it("returns empty array for empty graph", () => { const fg = new FlowGraph(); expect(fg.findCycles()).toEqual([]); }); it("finds a simple cycle (via raw graph)", () => { const fg = new FlowGraph(); fg.graph.addNode("a"); fg.graph.addNode("b"); fg.graph.addEdgeWithKey("a->b", "a", "b"); fg.graph.addEdgeWithKey("b->a", "b", "a"); const cycles = fg.findCycles(); expect(cycles.length).toBeGreaterThan(0); expect(cycles[0]!.length).toBeGreaterThan(0); }); it("finds a three-node cycle (via raw graph)", () => { const fg = new FlowGraph(); fg.graph.addNode("a"); fg.graph.addNode("b"); fg.graph.addNode("c"); fg.graph.addEdgeWithKey("a->b", "a", "b"); fg.graph.addEdgeWithKey("b->c", "b", "c"); fg.graph.addEdgeWithKey("c->a", "c", "a"); const cycles = fg.findCycles(); expect(cycles.length).toBeGreaterThan(0); }); }); describe("FlowGraph ancestors", () => { it("returns all ancestors for a node in a chain", () => { const fg = buildChainDag(); expect(fg.ancestors("d")).toEqual(["c", "b", "a"]); }); it("returns all ancestors for a node in a diamond DAG", () => { const fg = buildDiamondDag(); const anc = fg.ancestors("bottom"); expect(anc).toContain("left"); expect(anc).toContain("right"); expect(anc).toContain("top"); expect(anc.length).toBe(3); }); it("returns empty array for a root node", () => { const fg = buildChainDag(); expect(fg.ancestors("a")).toEqual([]); }); it("returns empty array for a node in a graph with no edges", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); expect(fg.ancestors("a")).toEqual([]); }); it("throws NodeNotFoundError for missing node", () => { const fg = new FlowGraph(); expect(() => fg.ancestors("missing")).toThrow(NodeNotFoundError); }); }); describe("FlowGraph descendants", () => { it("returns all descendants for a node in a chain", () => { const fg = buildChainDag(); expect(fg.descendants("a")).toEqual(["b", "c", "d"]); }); it("returns all descendants for a node in a diamond DAG", () => { const fg = buildDiamondDag(); const desc = fg.descendants("top"); expect(desc).toContain("left"); expect(desc).toContain("right"); expect(desc).toContain("bottom"); expect(desc.length).toBe(3); }); it("returns only direct and transitive children from left in diamond", () => { const fg = buildDiamondDag(); const desc = fg.descendants("left"); expect(desc).toEqual(["bottom"]); }); it("returns empty array for a leaf node", () => { const fg = buildChainDag(); expect(fg.descendants("d")).toEqual([]); }); it("throws NodeNotFoundError for missing node", () => { const fg = new FlowGraph(); expect(() => fg.descendants("missing")).toThrow(NodeNotFoundError); }); }); describe("FlowGraph reachableFrom", () => { it("returns all reachable nodes from a single start node", () => { const fg = buildChainDag(); const reachable = fg.reachableFrom(["a"]); expect(reachable).toEqual(new Set(["a", "b", "c", "d"])); }); it("returns only the start node if it's a leaf", () => { const fg = buildChainDag(); const reachable = fg.reachableFrom(["d"]); expect(reachable).toEqual(new Set(["d"])); }); it("returns union of reachable nodes from multiple start nodes", () => { const fg = buildDiamondDag(); const reachable = fg.reachableFrom(["left", "right"]); expect(reachable).toEqual(new Set(["left", "right", "bottom"])); }); it("returns empty set for empty input", () => { const fg = buildChainDag(); expect(fg.reachableFrom([])).toEqual(new Set()); }); it("skips nodes that don't exist in graph", () => { const fg = buildChainDag(); const reachable = fg.reachableFrom(["a", "missing"]); expect(reachable).toEqual(new Set(["a", "b", "c", "d"])); }); }); describe("FlowGraph filterByStatus", () => { it("returns node keys with matching status", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" }); fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "completed" }); fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "running" }); expect(fg.filterByStatus("running").sort()).toEqual(["r1", "r3"]); expect(fg.filterByStatus("completed")).toEqual(["r2"]); }); it("returns empty array when no nodes match", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" }); expect(fg.filterByStatus("failed")).toEqual([]); }); it("returns empty array for empty graph", () => { const fg = new FlowGraph(); expect(fg.filterByStatus("pending")).toEqual([]); }); }); describe("FlowGraph getRoots", () => { it("returns top-level call nodes", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" }); fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending", parentRequestId: "r1" }); fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "completed" }); const roots = fg.getRoots().sort(); expect(roots).toEqual(["r1", "r3"]); }); it("returns all nodes if none have parentRequestId", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" }); fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" }); const roots = fg.getRoots().sort(); expect(roots).toEqual(["r1", "r2"]); }); it("returns empty array for empty graph", () => { const fg = new FlowGraph(); expect(fg.getRoots()).toEqual([]); }); }); describe("FlowGraph children", () => { it("returns direct children via triggered edges", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" }); fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" }); fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "pending" }); fg.graph.addEdgeWithKey("r1->r2", "r1", "r2", { edgeType: "triggered" }); fg.graph.addEdgeWithKey("r1->r3", "r1", "r3", { edgeType: "triggered" }); const children = fg.children("r1").sort(); expect(children).toEqual(["r2", "r3"]); }); it("excludes non-triggered edges", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" }); fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" }); fg.graph.addEdgeWithKey("r1->r2", "r1", "r2", { edgeType: "depends_on" }); expect(fg.children("r1")).toEqual([]); }); it("returns empty array for a leaf node", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" }); expect(fg.children("r1")).toEqual([]); }); it("throws NodeNotFoundError for missing node", () => { const fg = new FlowGraph(); expect(() => fg.children("missing")).toThrow(NodeNotFoundError); }); }); describe("FlowGraph duration", () => { it("returns duration in milliseconds", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "completed", startedAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:05.000Z", }); expect(fg.duration("r1")).toBe(5000); }); it("returns 0 for simultaneous start and end", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "completed", startedAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:00.000Z", }); expect(fg.duration("r1")).toBe(0); }); it("throws if startedAt is missing", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running", completedAt: "2026-01-01T00:00:05.000Z", }); expect(() => fg.duration("r1")).toThrow(); }); it("throws if completedAt is missing", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running", startedAt: "2026-01-01T00:00:00.000Z", }); expect(() => fg.duration("r1")).toThrow(); }); it("throws NodeNotFoundError for missing node", () => { const fg = new FlowGraph(); expect(() => fg.duration("missing")).toThrow(NodeNotFoundError); }); }); describe("FlowGraph lineage", () => { it("returns ancestor chain from root to node", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "completed" }); fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "completed", parentRequestId: "r1" }); fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "running", parentRequestId: "r2" }); expect(fg.lineage("r3")).toEqual(["r1", "r2", "r3"]); }); it("returns single element for root node", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "completed" }); expect(fg.lineage("r1")).toEqual(["r1"]); }); it("stops if parentRequestId node does not exist in graph", () => { const fg = new FlowGraph(); fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running", parentRequestId: "r0" }); expect(fg.lineage("r1")).toEqual(["r1"]); }); it("throws NodeNotFoundError for missing node", () => { const fg = new FlowGraph(); expect(() => fg.lineage("missing")).toThrow(NodeNotFoundError); }); }); describe("standalone topologicalOrder", () => { it("returns topological order for a graph", () => { const fg = buildChainDag(); expect(topologicalOrder(fg.graph)).toEqual(["a", "b", "c", "d"]); }); }); describe("standalone hasCycles", () => { it("returns false for DAG", () => { const fg = buildChainDag(); expect(hasCycles(fg.graph)).toBe(false); }); }); describe("standalone findCycles", () => { it("returns empty for DAG", () => { const fg = buildChainDag(); expect(findCycles(fg.graph)).toEqual([]); }); }); describe("standalone ancestors", () => { it("returns ancestors for a node", () => { const fg = buildChainDag(); expect(ancestors(fg.graph, "d")).toEqual(["c", "b", "a"]); }); }); describe("standalone descendants", () => { it("returns descendants for a node", () => { const fg = buildChainDag(); expect(descendants(fg.graph, "a")).toEqual(["b", "c", "d"]); }); }); describe("standalone reachableFrom", () => { it("returns all reachable nodes", () => { const fg = buildDiamondDag(); const result = reachableFrom(fg.graph, ["top"]); expect(result).toEqual(new Set(["top", "left", "right", "bottom"])); }); it("returns union from multiple starts", () => { const fg = buildDiamondDag(); const result = reachableFrom(fg.graph, ["left", "right"]); expect(result).toEqual(new Set(["left", "right", "bottom"])); }); });