feat(graph): implement FlowGraph class wrapping graphology DirectedGraph
Implements the core FlowGraph class with generic type parameters, DAG-enforced mutations, cycle detection via graphology-dag, node/edge CRUD, traversal methods, and static factory stubs.
This commit is contained in:
@@ -1 +1,199 @@
|
||||
export {};
|
||||
import { DirectedGraph } from "graphology";
|
||||
import type { TSchema, Static } from "@alkdev/typebox";
|
||||
import { willCreateCycle } from "graphology-dag";
|
||||
import {
|
||||
DuplicateNodeError,
|
||||
DuplicateEdgeError,
|
||||
NodeNotFoundError,
|
||||
CycleError,
|
||||
} from "../error/index.js";
|
||||
|
||||
export interface FlowGraphOptions {
|
||||
type?: "directed";
|
||||
multi?: false;
|
||||
allowSelfLoops?: false;
|
||||
}
|
||||
|
||||
type Attrs = Record<string, unknown>;
|
||||
|
||||
export class FlowGraph<
|
||||
NodeAttrs extends TSchema = TSchema,
|
||||
EdgeAttrs extends TSchema = TSchema,
|
||||
> {
|
||||
private _graph: DirectedGraph;
|
||||
|
||||
constructor(_options?: FlowGraphOptions) {
|
||||
this._graph = new DirectedGraph({
|
||||
type: "directed",
|
||||
multi: false,
|
||||
allowSelfLoops: false,
|
||||
});
|
||||
}
|
||||
|
||||
get graph(): DirectedGraph {
|
||||
return this._graph;
|
||||
}
|
||||
|
||||
_edgeKey(source: string, target: string): string {
|
||||
return `${source}->${target}`;
|
||||
}
|
||||
|
||||
addNode(key: string, attrs: Static<NodeAttrs>): void {
|
||||
if (this._graph.hasNode(key)) {
|
||||
throw new DuplicateNodeError(key);
|
||||
}
|
||||
this._graph.addNode(key, attrs as Attrs);
|
||||
}
|
||||
|
||||
removeNode(key: string): void {
|
||||
if (!this._graph.hasNode(key)) {
|
||||
throw new NodeNotFoundError(key);
|
||||
}
|
||||
this._graph.dropNode(key);
|
||||
}
|
||||
|
||||
updateNode(key: string, attrs: Partial<Static<NodeAttrs>>): void {
|
||||
if (!this._graph.hasNode(key)) {
|
||||
throw new NodeNotFoundError(key);
|
||||
}
|
||||
this._graph.mergeNodeAttributes(key, attrs as Attrs);
|
||||
}
|
||||
|
||||
hasNode(key: string): boolean {
|
||||
return this._graph.hasNode(key);
|
||||
}
|
||||
|
||||
getNodeAttributes(key: string): Static<NodeAttrs> {
|
||||
if (!this._graph.hasNode(key)) {
|
||||
throw new NodeNotFoundError(key);
|
||||
}
|
||||
return this._graph.getNodeAttributes(key) as Static<NodeAttrs>;
|
||||
}
|
||||
|
||||
addEdge(source: string, target: string, attrs?: Static<EdgeAttrs>): void {
|
||||
if (!this._graph.hasNode(source)) {
|
||||
throw new NodeNotFoundError(source);
|
||||
}
|
||||
if (!this._graph.hasNode(target)) {
|
||||
throw new NodeNotFoundError(target);
|
||||
}
|
||||
if (this._graph.hasDirectedEdge(source, target)) {
|
||||
throw new DuplicateEdgeError(source, target);
|
||||
}
|
||||
if (willCreateCycle(this._graph, source, target)) {
|
||||
const path = this._findPath(target, source);
|
||||
const cycle = [source, ...path, source];
|
||||
throw new CycleError([cycle]);
|
||||
}
|
||||
const key = this._edgeKey(source, target);
|
||||
this._graph.addEdgeWithKey(key, source, target, (attrs ?? {}) as Attrs);
|
||||
}
|
||||
|
||||
removeEdge(source: string, target: string): void {
|
||||
if (this._graph.hasDirectedEdge(source, target)) {
|
||||
this._graph.dropDirectedEdge(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
hasEdge(source: string, target: string): boolean {
|
||||
return this._graph.hasDirectedEdge(source, target);
|
||||
}
|
||||
|
||||
getEdgeAttributes(source: string, target: string): Static<EdgeAttrs> {
|
||||
if (!this._graph.hasDirectedEdge(source, target)) {
|
||||
throw new Error(
|
||||
`Edge "${this._edgeKey(source, target)}" not found`,
|
||||
);
|
||||
}
|
||||
const edgeKey = this._graph.edge(source, target);
|
||||
return this._graph.getEdgeAttributes(edgeKey) as Static<EdgeAttrs>;
|
||||
}
|
||||
|
||||
nodes(): string[] {
|
||||
return this._graph.nodes();
|
||||
}
|
||||
|
||||
edges(): string[] {
|
||||
return this._graph.edges();
|
||||
}
|
||||
|
||||
get order(): number {
|
||||
return this._graph.order;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._graph.size;
|
||||
}
|
||||
|
||||
forEachNode(
|
||||
callback: (key: string, attrs: Static<NodeAttrs>) => void,
|
||||
): void {
|
||||
this._graph.forEachNode((key, attrs) => {
|
||||
callback(key, attrs as Static<NodeAttrs>);
|
||||
});
|
||||
}
|
||||
|
||||
forEachEdge(
|
||||
callback: (
|
||||
key: string,
|
||||
attrs: Static<EdgeAttrs>,
|
||||
source: string,
|
||||
target: string,
|
||||
) => void,
|
||||
): void {
|
||||
this._graph.forEachEdge((key, attrs, source, target) => {
|
||||
callback(key, attrs as Static<EdgeAttrs>, source!, target!);
|
||||
});
|
||||
}
|
||||
|
||||
predecessors(nodeId: string): string[] {
|
||||
return this._graph.inNeighbors(nodeId) ?? [];
|
||||
}
|
||||
|
||||
successors(nodeId: string): string[] {
|
||||
return this._graph.outNeighbors(nodeId) ?? [];
|
||||
}
|
||||
|
||||
static fromSpecs(
|
||||
_specs: unknown[],
|
||||
): FlowGraph<TSchema, TSchema> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
static fromCallEvents(
|
||||
_events: unknown[],
|
||||
): FlowGraph<TSchema, TSchema> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
static fromJSON(
|
||||
_data: unknown,
|
||||
): FlowGraph<TSchema, TSchema> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
private _findPath(from: string, to: string): string[] {
|
||||
const visited = new Set<string>();
|
||||
const queue: Array<{ node: string; path: string[] }> = [
|
||||
{ node: from, path: [from] },
|
||||
];
|
||||
visited.add(from);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (current.node === to) {
|
||||
return current.path;
|
||||
}
|
||||
const neighbors = this._graph.outNeighbors(current.node) ?? [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
visited.add(neighbor);
|
||||
queue.push({
|
||||
node: neighbor,
|
||||
path: [...current.path, neighbor],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export { FlowGraph, type FlowGraphOptions } from "./construction.js";
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./error/index.js";
|
||||
export * from "./error/index.js";
|
||||
|
||||
export { FlowGraph, type FlowGraphOptions } from "./graph/index.js";
|
||||
@@ -1,7 +1,371 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { FlowGraph } from "../../src/graph/construction.js";
|
||||
import {
|
||||
DuplicateNodeError,
|
||||
DuplicateEdgeError,
|
||||
NodeNotFoundError,
|
||||
CycleError,
|
||||
} from "../../src/error/index.js";
|
||||
|
||||
describe('graph construction', () => {
|
||||
it('placeholder', () => {
|
||||
expect(true).toBe(true);
|
||||
describe("FlowGraph constructor", () => {
|
||||
it("creates an empty graph", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(fg.order).toBe(0);
|
||||
expect(fg.size).toBe(0);
|
||||
expect(fg.nodes()).toEqual([]);
|
||||
expect(fg.edges()).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes the underlying graphology instance via graph getter", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(fg.graph).toBeDefined();
|
||||
expect(fg.graph.order).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts FlowGraphOptions", () => {
|
||||
const fg = new FlowGraph({ type: "directed", multi: false, allowSelfLoops: false });
|
||||
expect(fg.order).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph._edgeKey", () => {
|
||||
it("produces deterministic keys", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(fg._edgeKey("a", "b")).toBe("a->b");
|
||||
expect(fg._edgeKey("task.classify", "task.enrich")).toBe("task.classify->task.enrich");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph node operations", () => {
|
||||
it("addNode adds a node with attributes", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
expect(fg.hasNode("a")).toBe(true);
|
||||
expect(fg.getNodeAttributes("a")).toEqual({ name: "a" });
|
||||
expect(fg.order).toBe(1);
|
||||
});
|
||||
|
||||
it("addNode throws DuplicateNodeError on duplicate", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
expect(() => fg.addNode("a", { name: "a2" })).toThrow(DuplicateNodeError);
|
||||
expect(() => fg.addNode("a", { name: "a2" })).toThrow('Node with key "a" already exists');
|
||||
});
|
||||
|
||||
it("removeNode removes a node and its edges", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b");
|
||||
fg.removeNode("a");
|
||||
expect(fg.hasNode("a")).toBe(false);
|
||||
expect(fg.hasEdge("a", "b")).toBe(false);
|
||||
expect(fg.size).toBe(0);
|
||||
});
|
||||
|
||||
it("removeNode throws NodeNotFoundError if missing", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(() => fg.removeNode("missing")).toThrow(NodeNotFoundError);
|
||||
});
|
||||
|
||||
it("updateNode partially merges attributes", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a", version: "1.0" });
|
||||
fg.updateNode("a", { version: "2.0" });
|
||||
expect(fg.getNodeAttributes("a")).toEqual({ name: "a", version: "2.0" });
|
||||
});
|
||||
|
||||
it("updateNode throws NodeNotFoundError if missing", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(() => fg.updateNode("missing", { x: 1 })).toThrow(NodeNotFoundError);
|
||||
});
|
||||
|
||||
it("hasNode returns correct boolean", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(fg.hasNode("a")).toBe(false);
|
||||
fg.addNode("a", { name: "a" });
|
||||
expect(fg.hasNode("a")).toBe(true);
|
||||
});
|
||||
|
||||
it("getNodeAttributes returns attributes", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a", x: 42 });
|
||||
expect(fg.getNodeAttributes("a")).toEqual({ name: "a", x: 42 });
|
||||
});
|
||||
|
||||
it("getNodeAttributes throws NodeNotFoundError if missing", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(() => fg.getNodeAttributes("missing")).toThrow(NodeNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph edge operations", () => {
|
||||
it("addEdge adds a directed edge with attributes", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b", { edgeType: "typed" });
|
||||
expect(fg.hasEdge("a", "b")).toBe(true);
|
||||
expect(fg.size).toBe(1);
|
||||
});
|
||||
|
||||
it("addEdge creates deterministic edge key", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b");
|
||||
expect(fg.edges()).toEqual(["a->b"]);
|
||||
});
|
||||
|
||||
it("addEdge throws NodeNotFoundError for missing source", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("b", { name: "b" });
|
||||
expect(() => fg.addEdge("a", "b")).toThrow(NodeNotFoundError);
|
||||
});
|
||||
|
||||
it("addEdge throws NodeNotFoundError for missing target", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
expect(() => fg.addEdge("a", "b")).toThrow(NodeNotFoundError);
|
||||
});
|
||||
|
||||
it("addEdge throws DuplicateEdgeError if edge exists", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b");
|
||||
expect(() => fg.addEdge("a", "b")).toThrow(DuplicateEdgeError);
|
||||
});
|
||||
|
||||
it("addEdge throws CycleError if edge creates cycle", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addNode("c", { name: "c" });
|
||||
fg.addEdge("a", "b");
|
||||
fg.addEdge("b", "c");
|
||||
expect(() => fg.addEdge("c", "a")).toThrow(CycleError);
|
||||
});
|
||||
|
||||
it("CycleError includes cycle path", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b");
|
||||
try {
|
||||
fg.addEdge("b", "a");
|
||||
expect.unreachable("should throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(CycleError);
|
||||
const ce = e as CycleError;
|
||||
expect(ce.cycles.length).toBeGreaterThan(0);
|
||||
expect(ce.cycles[0]![0]).toBe("b");
|
||||
expect(ce.cycles[0]!.at(-1)).toBe("b");
|
||||
}
|
||||
});
|
||||
|
||||
it("addEdge allows non-cycle edges in DAG", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addNode("c", { name: "c" });
|
||||
fg.addEdge("a", "b");
|
||||
fg.addEdge("a", "c");
|
||||
fg.addEdge("b", "c");
|
||||
expect(fg.size).toBe(3);
|
||||
});
|
||||
|
||||
it("removeEdge removes an existing edge", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b");
|
||||
fg.removeEdge("a", "b");
|
||||
expect(fg.hasEdge("a", "b")).toBe(false);
|
||||
expect(fg.size).toBe(0);
|
||||
});
|
||||
|
||||
it("removeEdge is a no-op if edge doesn't exist", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
expect(() => fg.removeEdge("a", "b")).not.toThrow();
|
||||
});
|
||||
|
||||
it("hasEdge returns correct boolean", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
expect(fg.hasEdge("a", "b")).toBe(false);
|
||||
fg.addEdge("a", "b");
|
||||
expect(fg.hasEdge("a", "b")).toBe(true);
|
||||
});
|
||||
|
||||
it("getEdgeAttributes returns edge attributes", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b", { edgeType: "typed", compatible: true });
|
||||
const attrs = fg.getEdgeAttributes("a", "b");
|
||||
expect(attrs.edgeType).toBe("typed");
|
||||
expect(attrs.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("getEdgeAttributes throws if edge doesn't exist", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
expect(() => fg.getEdgeAttributes("a", "b")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph query methods", () => {
|
||||
it("nodes returns all node keys", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
expect(fg.nodes()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("edges returns all edge keys", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b");
|
||||
expect(fg.edges()).toEqual(["a->b"]);
|
||||
});
|
||||
|
||||
it("order returns node count", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
expect(fg.order).toBe(2);
|
||||
});
|
||||
|
||||
it("size returns edge count", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b");
|
||||
expect(fg.size).toBe(1);
|
||||
});
|
||||
|
||||
it("forEachNode iterates over all nodes", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
const collected: Array<{ key: string; attrs: Record<string, unknown> }> = [];
|
||||
fg.forEachNode((key, attrs) => collected.push({ key, attrs }));
|
||||
expect(collected.length).toBe(2);
|
||||
expect(collected[0]!.key).toBe("a");
|
||||
expect(collected[1]!.key).toBe("b");
|
||||
});
|
||||
|
||||
it("forEachEdge iterates over all edges", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b", { edgeType: "typed" });
|
||||
const collected: Array<{ key: string; source: string; target: string }> = [];
|
||||
fg.forEachEdge((key, _attrs, source, target) =>
|
||||
collected.push({ key, source, target }),
|
||||
);
|
||||
expect(collected.length).toBe(1);
|
||||
expect(collected[0]!.key).toBe("a->b");
|
||||
expect(collected[0]!.source).toBe("a");
|
||||
expect(collected[0]!.target).toBe("b");
|
||||
});
|
||||
|
||||
it("predecessors returns in-neighbors", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addNode("c", { name: "c" });
|
||||
fg.addEdge("a", "c");
|
||||
fg.addEdge("b", "c");
|
||||
expect(fg.predecessors("c")).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("successors returns out-neighbors", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addNode("c", { name: "c" });
|
||||
fg.addEdge("a", "b");
|
||||
fg.addEdge("a", "c");
|
||||
expect(fg.successors("a")).toEqual(["b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph static stubs", () => {
|
||||
it("fromSpecs throws not implemented", () => {
|
||||
expect(() => FlowGraph.fromSpecs([])).toThrow("not implemented");
|
||||
});
|
||||
|
||||
it("fromCallEvents throws not implemented", () => {
|
||||
expect(() => FlowGraph.fromCallEvents([])).toThrow("not implemented");
|
||||
});
|
||||
|
||||
it("fromJSON throws not implemented", () => {
|
||||
expect(() => FlowGraph.fromJSON({})).toThrow("not implemented");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph re-exports", () => {
|
||||
it("is re-exported from src/graph/index.ts", async () => {
|
||||
const mod = await import("../../src/graph/index.js");
|
||||
expect(mod.FlowGraph).toBeDefined();
|
||||
expect(mod.FlowGraphOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is re-exported from src/index.ts", async () => {
|
||||
const mod = await import("../../src/index.js");
|
||||
expect(mod.FlowGraph).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph cycle detection", () => {
|
||||
it("prevents simple two-node cycle", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addEdge("a", "b");
|
||||
expect(() => fg.addEdge("b", "a")).toThrow(CycleError);
|
||||
});
|
||||
|
||||
it("prevents longer cycle", () => {
|
||||
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");
|
||||
expect(() => fg.addEdge("d", "a")).toThrow(CycleError);
|
||||
});
|
||||
|
||||
it("allows diamond DAG", () => {
|
||||
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");
|
||||
expect(fg.size).toBe(4);
|
||||
expect(() => fg.addEdge("bottom", "top")).toThrow(CycleError);
|
||||
});
|
||||
|
||||
it("does not create false positive cycle detection", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addNode("c", { name: "c" });
|
||||
fg.addEdge("a", "b");
|
||||
fg.addEdge("a", "c");
|
||||
expect(() => fg.addEdge("b", "c")).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user