441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
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"]));
|
|
});
|
|
}); |