feat(host): implement GraphologyHostConfig — render ujsx templates to graphology DAG
This commit is contained in:
@@ -1,7 +1,454 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { h, createHostRoot } from "@alkdev/ujsx";
|
||||
import { Operation, Sequential, Parallel, Conditional, Map } from "../../src/component/index.js";
|
||||
import { GraphologyHostConfig } from "../../src/host/graphology.js";
|
||||
import type { GraphContext } from "../../src/host/graphology.js";
|
||||
import { CycleError } from "../../src/error/index.js";
|
||||
|
||||
describe('graphology host', () => {
|
||||
it('placeholder', () => {
|
||||
expect(true).toBe(true);
|
||||
function renderTemplate(template: ReturnType<typeof h>): GraphContext {
|
||||
const root = createHostRoot(GraphologyHostConfig, null);
|
||||
root.render(template);
|
||||
return root.ctx;
|
||||
}
|
||||
|
||||
describe("GraphologyHostConfig", () => {
|
||||
it("createRootContext creates fresh DirectedGraph with DAG constraints", () => {
|
||||
const ctx = GraphologyHostConfig.createRootContext(null);
|
||||
expect(ctx.graph).toBeDefined();
|
||||
expect(ctx.graph.order).toBe(0);
|
||||
expect(ctx.parentStack).toEqual([]);
|
||||
});
|
||||
|
||||
it("createRootContext accepts options with registry", () => {
|
||||
const registry = { resolve: (name: string) => name };
|
||||
const ctx = GraphologyHostConfig.createRootContext(null, { registry });
|
||||
expect(ctx.operationRegistry).toBe(registry);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sequential rendering", () => {
|
||||
it("creates sequential edges between consecutive siblings", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
h(Operation, { name: "C" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["A", "B", "C"]);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "B")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("B", "C")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "C")).toBe(false);
|
||||
});
|
||||
|
||||
it("creates sequential edges with correct edgeType attribute", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const edgeKey = ctx.graph.edge("A", "B");
|
||||
const attrs = ctx.graph.getEdgeAttributes(edgeKey);
|
||||
expect(attrs.edgeType).toBe("sequential");
|
||||
});
|
||||
|
||||
it("single child produces no edges", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes()).toEqual(["A"]);
|
||||
expect(ctx.graph.size).toBe(0);
|
||||
});
|
||||
|
||||
it("empty Sequential produces no nodes or edges", () => {
|
||||
const template = h(Sequential, {});
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.order).toBe(0);
|
||||
expect(ctx.graph.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parallel rendering", () => {
|
||||
it("creates no inter-child edges", () => {
|
||||
const template = h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
h(Operation, { name: "C" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["A", "B", "C"]);
|
||||
expect(ctx.graph.size).toBe(0);
|
||||
});
|
||||
|
||||
it("parallel inside sequential connects predecessor to all parallel children", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "pre" }),
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["A", "B", "pre"]);
|
||||
expect(ctx.graph.hasDirectedEdge("pre", "A")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("pre", "B")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "B")).toBe(false);
|
||||
});
|
||||
|
||||
it("parallel inside sequential: successors connect to all parallel children", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
h(Operation, { name: "post" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["A", "B", "post"]);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "post")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("B", "post")).toBe(true);
|
||||
});
|
||||
|
||||
it("full parallel sandwich in sequential", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "pre" }),
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
h(Operation, { name: "post" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["A", "B", "post", "pre"]);
|
||||
expect(ctx.graph.hasDirectedEdge("pre", "A")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("pre", "B")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "post")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("B", "post")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conditional rendering", () => {
|
||||
it("creates conditional edges with dataFlow true", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Conditional, { test: "A" },
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["A", "B"]);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "B")).toBe(true);
|
||||
|
||||
const edgeKey = ctx.graph.edge("A", "B");
|
||||
const attrs = ctx.graph.getEdgeAttributes(edgeKey);
|
||||
expect(attrs.edgeType).toBe("conditional");
|
||||
expect(attrs.dataFlow).toBe(true);
|
||||
});
|
||||
|
||||
it("conditional edge carries condition attribute", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Conditional, { test: "A" },
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const edgeKey = ctx.graph.edge("A", "B");
|
||||
const attrs = ctx.graph.getEdgeAttributes(edgeKey);
|
||||
expect(attrs.condition).toBe("A");
|
||||
});
|
||||
|
||||
it("conditional with function test carries condition attribute", () => {
|
||||
const testFn = (results: Record<string, unknown>) => true;
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Conditional, { test: testFn },
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const edgeKey = ctx.graph.edge("A", "B");
|
||||
const attrs = ctx.graph.getEdgeAttributes(edgeKey);
|
||||
expect(attrs.condition).toBe(testFn);
|
||||
expect(attrs.edgeType).toBe("conditional");
|
||||
expect(attrs.dataFlow).toBe(true);
|
||||
});
|
||||
|
||||
it("conditional between sequential operations", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Conditional, { test: "A" },
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
h(Operation, { name: "C" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.hasDirectedEdge("A", "B")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("B", "C")).toBe(true);
|
||||
|
||||
const abEdge = ctx.graph.edge("A", "B");
|
||||
const abAttrs = ctx.graph.getEdgeAttributes(abEdge);
|
||||
expect(abAttrs.edgeType).toBe("conditional");
|
||||
|
||||
const bcEdge = ctx.graph.edge("B", "C");
|
||||
const bcAttrs = ctx.graph.getEdgeAttributes(bcEdge);
|
||||
expect(bcAttrs.edgeType).toBe("sequential");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested compositions", () => {
|
||||
it("parallel inside sequential inside parallel", () => {
|
||||
const template = h(Parallel, {},
|
||||
h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
h(Sequential, {},
|
||||
h(Operation, { name: "C" }),
|
||||
h(Operation, { name: "D" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["A", "B", "C", "D"]);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "B")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("C", "D")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "C")).toBe(false);
|
||||
expect(ctx.graph.hasDirectedEdge("B", "D")).toBe(false);
|
||||
});
|
||||
|
||||
it("sequential inside parallel inside sequential", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "pre" }),
|
||||
h(Parallel, {},
|
||||
h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
h(Operation, { name: "C" }),
|
||||
),
|
||||
h(Operation, { name: "post" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["A", "B", "C", "post", "pre"]);
|
||||
expect(ctx.graph.hasDirectedEdge("pre", "A")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("pre", "C")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("A", "B")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("B", "post")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("C", "post")).toBe(true);
|
||||
});
|
||||
|
||||
it("conditional inside sequential inside parallel", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "start" }),
|
||||
h(Parallel, {},
|
||||
h(Sequential, {},
|
||||
h(Conditional, { test: "start" },
|
||||
h(Operation, { name: "condA" }),
|
||||
),
|
||||
h(Operation, { name: "afterA" }),
|
||||
),
|
||||
h(Operation, { name: "branchB" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["afterA", "branchB", "condA", "start"]);
|
||||
expect(ctx.graph.hasDirectedEdge("start", "condA")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("start", "branchB")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("condA", "afterA")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("afterA", undefined as unknown as string)).toBe(false);
|
||||
|
||||
const startCondAKey = ctx.graph.edge("start", "condA");
|
||||
const startCondAAttrs = ctx.graph.getEdgeAttributes(startCondAKey);
|
||||
expect(startCondAAttrs.edgeType).toBe("conditional");
|
||||
expect(startCondAAttrs.dataFlow).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dataFlow inference", () => {
|
||||
it("sequential edges default to dataFlow false", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const edgeKey = ctx.graph.edge("A", "B");
|
||||
const attrs = ctx.graph.getEdgeAttributes(edgeKey);
|
||||
expect(attrs.edgeType).toBe("sequential");
|
||||
expect(attrs.dataFlow).toBe(false);
|
||||
});
|
||||
|
||||
it("conditional edges always have dataFlow true", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Conditional, { test: "A" },
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const edgeKey = ctx.graph.edge("A", "B");
|
||||
const attrs = ctx.graph.getEdgeAttributes(edgeKey);
|
||||
expect(attrs.dataFlow).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cycle detection", () => {
|
||||
it("finalizeRoot throws CycleError when graph has a cycle", () => {
|
||||
const ctx = GraphologyHostConfig.createRootContext(null);
|
||||
ctx.graph.addNode("A", {});
|
||||
ctx.graph.addNode("B", {});
|
||||
ctx.graph.addNode("C", {});
|
||||
ctx.graph.addEdgeWithKey("A->B", "A", "B", {});
|
||||
ctx.graph.addEdgeWithKey("B->C", "B", "C", {});
|
||||
ctx.graph.addEdgeWithKey("C->A", "C", "A", {});
|
||||
|
||||
expect(() => GraphologyHostConfig.finalizeRoot?.(ctx)).toThrow(CycleError);
|
||||
});
|
||||
|
||||
it("finalizeRoot does not throw for a valid DAG", () => {
|
||||
const ctx = GraphologyHostConfig.createRootContext(null);
|
||||
ctx.graph.addNode("A", {});
|
||||
ctx.graph.addNode("B", {});
|
||||
ctx.graph.addEdgeWithKey("A->B", "A", "B", {});
|
||||
|
||||
expect(() => GraphologyHostConfig.finalizeRoot?.(ctx)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Operation node attributes", () => {
|
||||
it("creates graph node with OperationNodeAttrs", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "classify" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const attrs = ctx.graph.getNodeAttributes("classify");
|
||||
expect(attrs.name).toBe("classify");
|
||||
expect(attrs.namespace).toBe("");
|
||||
expect(attrs.version).toBe("0.0.1");
|
||||
expect(attrs.type).toBe("mutation");
|
||||
});
|
||||
|
||||
it("operation with custom attributes", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, {
|
||||
name: "enrich",
|
||||
namespace: "task",
|
||||
version: "1.0.0",
|
||||
type: "query",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: { type: "string" },
|
||||
description: "Enriches data",
|
||||
tags: ["core", "data"],
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const attrs = ctx.graph.getNodeAttributes("enrich");
|
||||
expect(attrs.name).toBe("enrich");
|
||||
expect(attrs.namespace).toBe("task");
|
||||
expect(attrs.version).toBe("1.0.0");
|
||||
expect(attrs.type).toBe("query");
|
||||
expect(attrs.inputSchema).toEqual({ type: "object" });
|
||||
expect(attrs.outputSchema).toEqual({ type: "string" });
|
||||
expect(attrs.description).toBe("Enriches data");
|
||||
expect(attrs.tags).toEqual(["core", "data"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeChild", () => {
|
||||
it("removes edge between operation nodes", () => {
|
||||
const ctx = GraphologyHostConfig.createRootContext(null);
|
||||
const parent: import("../../src/host/graphology.js").GraphNode = { key: "A", attributes: {} as any };
|
||||
const child: import("../../src/host/graphology.js").GraphNode = { key: "B", attributes: {} as any };
|
||||
ctx.graph.addNode("A", {});
|
||||
ctx.graph.addNode("B", {});
|
||||
ctx.graph.addEdgeWithKey("A->B", "A", "B", {});
|
||||
|
||||
GraphologyHostConfig.removeChild(parent, child, ctx);
|
||||
|
||||
expect(ctx.graph.hasDirectedEdge("A", "B")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInstance structural containers", () => {
|
||||
it("structural containers get synthetic keys", () => {
|
||||
const ctx = GraphologyHostConfig.createRootContext(null);
|
||||
|
||||
const seqNode = GraphologyHostConfig.createInstance("sequential", {}, ctx);
|
||||
expect(seqNode.key.startsWith("__sequential_")).toBe(true);
|
||||
|
||||
const parNode = GraphologyHostConfig.createInstance("parallel", {}, ctx);
|
||||
expect(parNode.key.startsWith("__parallel_")).toBe(true);
|
||||
});
|
||||
|
||||
it("structural containers do not create graph nodes", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const graphNodes = ctx.graph.nodes();
|
||||
expect(graphNodes).toEqual(["A"]);
|
||||
expect(graphNodes.some((k) => k.startsWith("__"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("render via createRoot", () => {
|
||||
it("renders a complete pipeline template", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "architect" }),
|
||||
h(Operation, { name: "reviewer" }),
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "decomposer" }),
|
||||
h(Operation, { name: "specialist" }),
|
||||
),
|
||||
h(Operation, { name: "synthesizer" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.graph.nodes().sort()).toEqual(["architect", "decomposer", "reviewer", "specialist", "synthesizer"]);
|
||||
expect(ctx.graph.hasDirectedEdge("architect", "reviewer")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("reviewer", "decomposer")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("reviewer", "specialist")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("decomposer", "synthesizer")).toBe(true);
|
||||
expect(ctx.graph.hasDirectedEdge("specialist", "synthesizer")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user