641 lines
23 KiB
TypeScript
641 lines
23 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { Type } from "@alkdev/typebox";
|
|
import { FlowGraph, buildTypeEdges } from "../../src/graph/construction.js";
|
|
import type { OperationSpec } from "../../src/graph/construction.js";
|
|
import {
|
|
DuplicateNodeError,
|
|
DuplicateEdgeError,
|
|
NodeNotFoundError,
|
|
CycleError,
|
|
} from "../../src/error/index.js";
|
|
|
|
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("fromCallEvents throws not implemented", () => {
|
|
expect(() => FlowGraph.fromCallEvents([])).toThrow("not implemented");
|
|
});
|
|
|
|
it("fromJSON throws not implemented", () => {
|
|
expect(() => FlowGraph.fromJSON({})).toThrow("not implemented");
|
|
});
|
|
});
|
|
|
|
describe("FlowGraph.addOperation", () => {
|
|
it("adds an operation node with namespace.name key", () => {
|
|
const fg = new FlowGraph();
|
|
const spec: OperationSpec = {
|
|
name: "classify",
|
|
namespace: "task",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ text: Type.String() }),
|
|
outputSchema: Type.Object({ label: Type.String() }),
|
|
};
|
|
fg.addOperation(spec);
|
|
expect(fg.hasNode("task.classify")).toBe(true);
|
|
const attrs = fg.getNodeAttributes("task.classify") as Record<string, unknown>;
|
|
expect(attrs.name).toBe("classify");
|
|
expect(attrs.namespace).toBe("task");
|
|
expect(attrs.version).toBe("1.0.0");
|
|
expect(attrs.type).toBe("query");
|
|
});
|
|
|
|
it("throws DuplicateNodeError for duplicate operation", () => {
|
|
const fg = new FlowGraph();
|
|
const spec: OperationSpec = {
|
|
name: "classify",
|
|
namespace: "task",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ text: Type.String() }),
|
|
outputSchema: Type.Object({ label: Type.String() }),
|
|
};
|
|
fg.addOperation(spec);
|
|
expect(() => fg.addOperation(spec)).toThrow(DuplicateNodeError);
|
|
});
|
|
|
|
it("includes optional description and tags", () => {
|
|
const fg = new FlowGraph();
|
|
const spec: OperationSpec = {
|
|
name: "classify",
|
|
namespace: "task",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ text: Type.String() }),
|
|
outputSchema: Type.Object({ label: Type.String() }),
|
|
description: "Classifies text",
|
|
tags: ["nlp", "ml"],
|
|
};
|
|
fg.addOperation(spec);
|
|
const attrs = fg.getNodeAttributes("task.classify") as Record<string, unknown>;
|
|
expect(attrs.description).toBe("Classifies text");
|
|
expect(attrs.tags).toEqual(["nlp", "ml"]);
|
|
});
|
|
});
|
|
|
|
describe("FlowGraph.addTypedEdge", () => {
|
|
it("adds a typed edge with edgeType: 'typed'", () => {
|
|
const fg = new FlowGraph();
|
|
const spec1: OperationSpec = {
|
|
name: "classify",
|
|
namespace: "task",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ text: Type.String() }),
|
|
outputSchema: Type.Object({ label: Type.String() }),
|
|
};
|
|
const spec2: OperationSpec = {
|
|
name: "enrich",
|
|
namespace: "task",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ label: Type.String() }),
|
|
outputSchema: Type.Object({ enriched: Type.String() }),
|
|
};
|
|
fg.addOperation(spec1);
|
|
fg.addOperation(spec2);
|
|
fg.addTypedEdge("task.classify", "task.enrich", { compatible: true });
|
|
expect(fg.hasEdge("task.classify", "task.enrich")).toBe(true);
|
|
const edgeAttrs = fg.getEdgeAttributes("task.classify", "task.enrich") as Record<string, unknown>;
|
|
expect(edgeAttrs.edgeType).toBe("typed");
|
|
expect(edgeAttrs.compatible).toBe(true);
|
|
});
|
|
|
|
it("throws NodeNotFoundError if source doesn't exist", () => {
|
|
const fg = new FlowGraph();
|
|
const spec: OperationSpec = {
|
|
name: "enrich",
|
|
namespace: "task",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ label: Type.String() }),
|
|
outputSchema: Type.Object({ enriched: Type.String() }),
|
|
};
|
|
fg.addOperation(spec);
|
|
expect(() => fg.addTypedEdge("task.missing", "task.enrich", { compatible: true })).toThrow(NodeNotFoundError);
|
|
});
|
|
|
|
it("throws NodeNotFoundError if target doesn't exist", () => {
|
|
const fg = new FlowGraph();
|
|
const spec: OperationSpec = {
|
|
name: "classify",
|
|
namespace: "task",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ text: Type.String() }),
|
|
outputSchema: Type.Object({ label: Type.String() }),
|
|
};
|
|
fg.addOperation(spec);
|
|
expect(() => fg.addTypedEdge("task.classify", "task.missing", { compatible: true })).toThrow(NodeNotFoundError);
|
|
});
|
|
|
|
it("includes detail and mismatches when provided", () => {
|
|
const fg = new FlowGraph();
|
|
const spec1: OperationSpec = {
|
|
name: "a",
|
|
namespace: "op",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ x: Type.String() }),
|
|
outputSchema: Type.Object({ y: Type.Number() }),
|
|
};
|
|
const spec2: OperationSpec = {
|
|
name: "b",
|
|
namespace: "op",
|
|
version: "1.0.0",
|
|
type: "query",
|
|
inputSchema: Type.Object({ x: Type.String() }),
|
|
outputSchema: Type.Object({ z: Type.Boolean() }),
|
|
};
|
|
fg.addOperation(spec1);
|
|
fg.addOperation(spec2);
|
|
fg.addTypedEdge("op.a", "op.b", {
|
|
compatible: false,
|
|
detail: "output mismatch",
|
|
mismatches: [{ path: "/y", expected: "string", actual: "number" }],
|
|
});
|
|
const edgeAttrs = fg.getEdgeAttributes("op.a", "op.b") as Record<string, unknown>;
|
|
expect(edgeAttrs.compatible).toBe(false);
|
|
expect(edgeAttrs.detail).toBe("output mismatch");
|
|
expect(edgeAttrs.mismatches).toEqual([{ path: "/y", expected: "string", actual: "number" }]);
|
|
});
|
|
|
|
it("throws CycleError if edge would create cycle", () => {
|
|
const fg = new FlowGraph();
|
|
fg.addOperation({
|
|
name: "a", namespace: "op", version: "1.0.0", type: "query",
|
|
inputSchema: Type.Object({ x: Type.String() }),
|
|
outputSchema: Type.Object({ y: Type.String() }),
|
|
});
|
|
fg.addOperation({
|
|
name: "b", namespace: "op", version: "1.0.0", type: "query",
|
|
inputSchema: Type.Object({ y: Type.String() }),
|
|
outputSchema: Type.Object({ x: Type.String() }),
|
|
});
|
|
fg.addTypedEdge("op.a", "op.b", { compatible: true });
|
|
expect(() => fg.addTypedEdge("op.b", "op.a", { compatible: true })).toThrow(CycleError);
|
|
});
|
|
});
|
|
|
|
describe("FlowGraph.fromSpecs", () => {
|
|
it("creates operation nodes for each spec", () => {
|
|
const specs: OperationSpec[] = [
|
|
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
|
|
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) },
|
|
];
|
|
const graph = FlowGraph.fromSpecs(specs);
|
|
expect(graph.order).toBe(2);
|
|
expect(graph.hasNode("task.extract")).toBe(true);
|
|
expect(graph.hasNode("task.classify")).toBe(true);
|
|
});
|
|
|
|
it("adds type-compatibility edges via buildTypeEdges", () => {
|
|
const specs: OperationSpec[] = [
|
|
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
|
|
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) },
|
|
];
|
|
const graph = FlowGraph.fromSpecs(specs);
|
|
expect(graph.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("adds compatible edge when output matches input", () => {
|
|
const specs: OperationSpec[] = [
|
|
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
|
|
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) },
|
|
];
|
|
const graph = FlowGraph.fromSpecs(specs);
|
|
expect(graph.hasEdge("task.extract", "task.classify")).toBe(true);
|
|
const attrs = graph.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
|
expect(attrs.compatible).toBe(true);
|
|
});
|
|
|
|
it("adds incompatible edge when output does not match input", () => {
|
|
const specs: OperationSpec[] = [
|
|
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) },
|
|
{ name: "count", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ count: Type.Number() }), outputSchema: Type.Object({ result: Type.Number() }) },
|
|
];
|
|
const graph = FlowGraph.fromSpecs(specs);
|
|
expect(graph.hasEdge("task.classify", "task.count")).toBe(true);
|
|
const attrs = graph.getEdgeAttributes("task.classify", "task.count") as Record<string, unknown>;
|
|
expect(attrs.compatible).toBe(false);
|
|
});
|
|
|
|
it("skips edges when target inputSchema is Unknown", () => {
|
|
const specs: OperationSpec[] = [
|
|
{ name: "source", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ result: Type.String() }) },
|
|
{ name: "unk_input", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Unknown(), outputSchema: Type.Object({ y: Type.String() }) },
|
|
];
|
|
const graph = FlowGraph.fromSpecs(specs);
|
|
expect(graph.hasEdge("op.source", "op.unk_input")).toBe(false);
|
|
});
|
|
|
|
it("skips edges when source outputSchema is Unknown", () => {
|
|
const specs: OperationSpec[] = [
|
|
{ name: "unk_output", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Unknown() },
|
|
{ name: "target", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ result: Type.String() }), outputSchema: Type.Object({ z: Type.String() }) },
|
|
];
|
|
const graph = FlowGraph.fromSpecs(specs);
|
|
expect(graph.hasEdge("op.unk_output", "op.target")).toBe(false);
|
|
});
|
|
|
|
it("throws DuplicateNodeError for duplicate specs", () => {
|
|
const stringOutput = Type.Object({ label: Type.String() });
|
|
const specs: OperationSpec[] = [
|
|
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: stringOutput },
|
|
{ name: "classify", namespace: "task", version: "2.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: stringOutput },
|
|
];
|
|
expect(() => FlowGraph.fromSpecs(specs)).toThrow(DuplicateNodeError);
|
|
});
|
|
|
|
it("returns empty graph for empty specs", () => {
|
|
const graph = FlowGraph.fromSpecs([]);
|
|
expect(graph.order).toBe(0);
|
|
expect(graph.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("buildTypeEdges (standalone)", () => {
|
|
it("adds edges to an incrementally constructed graph", () => {
|
|
const fg = new FlowGraph();
|
|
fg.addOperation({
|
|
name: "extract", namespace: "task", version: "1.0.0", type: "query",
|
|
inputSchema: Type.Object({ raw: Type.String() }),
|
|
outputSchema: Type.Object({ text: Type.String() }),
|
|
});
|
|
fg.addOperation({
|
|
name: "classify", namespace: "task", version: "1.0.0", type: "query",
|
|
inputSchema: Type.Object({ text: Type.String() }),
|
|
outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }),
|
|
});
|
|
expect(fg.size).toBe(0);
|
|
buildTypeEdges(fg);
|
|
expect(fg.size).toBeGreaterThan(0);
|
|
expect(fg.hasEdge("task.extract", "task.classify")).toBe(true);
|
|
});
|
|
|
|
it("is callable after adding more operations", () => {
|
|
const fg = new FlowGraph();
|
|
fg.addOperation({
|
|
name: "extract", namespace: "op", version: "1.0.0", type: "query",
|
|
inputSchema: Type.Object({ raw: Type.String() }),
|
|
outputSchema: Type.Object({ text: Type.String() }),
|
|
});
|
|
buildTypeEdges(fg);
|
|
expect(fg.size).toBe(0);
|
|
fg.addOperation({
|
|
name: "classify", namespace: "op", version: "1.0.0", type: "query",
|
|
inputSchema: Type.Object({ text: Type.String() }),
|
|
outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }),
|
|
});
|
|
buildTypeEdges(fg);
|
|
expect(fg.hasEdge("op.extract", "op.classify")).toBe(true);
|
|
});
|
|
});
|
|
|
|
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.buildTypeEdges).toBeDefined();
|
|
});
|
|
|
|
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();
|
|
});
|
|
}); |