Merge branch 'feat/host-graphology'
This commit is contained in:
@@ -1 +1,324 @@
|
|||||||
export {};
|
import { DirectedGraph } from "graphology";
|
||||||
|
import { hasCycle } from "graphology-dag";
|
||||||
|
import type { HostConfig } from "@alkdev/ujsx";
|
||||||
|
import type { OperationNodeAttrs } from "../schema/node.js";
|
||||||
|
import type { TemplateNodeAttrs } from "../schema/edge.js";
|
||||||
|
import { CycleError } from "../error/index.js";
|
||||||
|
|
||||||
|
type WorkflowTag = "operation" | "sequential" | "parallel" | "conditional" | "map";
|
||||||
|
|
||||||
|
interface GraphNode {
|
||||||
|
key: string;
|
||||||
|
attributes: OperationNodeAttrs | TemplateNodeAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OperationRegistry {
|
||||||
|
resolve(name: string): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphContext {
|
||||||
|
graph: DirectedGraph;
|
||||||
|
parentStack: string[];
|
||||||
|
operationRegistry?: OperationRegistry;
|
||||||
|
_containerCounter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContainerState {
|
||||||
|
tag: WorkflowTag;
|
||||||
|
entryNodes: string[];
|
||||||
|
exitNodes: string[];
|
||||||
|
condition?: unknown;
|
||||||
|
negated?: boolean;
|
||||||
|
childEdgeInfo: ChildEdgeInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChildEdgeInfo {
|
||||||
|
entryNodes: string[];
|
||||||
|
exitNodes: string[];
|
||||||
|
edgeType: "sequential" | "conditional";
|
||||||
|
containerState?: ContainerState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const structuralContainers = new WeakMap<GraphNode, ContainerState>();
|
||||||
|
|
||||||
|
function isStructuralContainer(node: GraphNode): boolean {
|
||||||
|
return node.key.startsWith("__");
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDataFlow(edgeType: "sequential" | "conditional"): boolean {
|
||||||
|
if (edgeType === "conditional") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEdgeSafe(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
attrs: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (source === target) return;
|
||||||
|
if (!graph.hasNode(source) || !graph.hasNode(target)) return;
|
||||||
|
if (graph.hasDirectedEdge(source, target)) return;
|
||||||
|
const key = `${source}->${target}`;
|
||||||
|
graph.addEdgeWithKey(key, source, target, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEdgeAttrs(
|
||||||
|
edgeType: "sequential" | "conditional",
|
||||||
|
dataFlow: boolean,
|
||||||
|
containerState?: ContainerState,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const attrs: Record<string, unknown> = { edgeType, dataFlow };
|
||||||
|
if (containerState?.tag === "conditional") {
|
||||||
|
if (containerState.condition !== undefined) {
|
||||||
|
attrs.condition = containerState.condition;
|
||||||
|
}
|
||||||
|
if (containerState.negated === true) {
|
||||||
|
attrs.negated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectChildEdgeInfos(state: ContainerState): ChildEdgeInfo[] {
|
||||||
|
const result: ChildEdgeInfo[] = [];
|
||||||
|
switch (state.tag) {
|
||||||
|
case "sequential": {
|
||||||
|
const firstInfo = state.childEdgeInfo[0];
|
||||||
|
if (firstInfo) {
|
||||||
|
if (firstInfo.containerState && firstInfo.containerState.tag !== "conditional") {
|
||||||
|
result.push(...collectChildEdgeInfos(firstInfo.containerState));
|
||||||
|
} else {
|
||||||
|
result.push(firstInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "parallel":
|
||||||
|
case "map": {
|
||||||
|
for (const info of state.childEdgeInfo) {
|
||||||
|
if (info.containerState && info.containerState.tag !== "conditional") {
|
||||||
|
result.push(...collectChildEdgeInfos(info.containerState));
|
||||||
|
} else {
|
||||||
|
result.push(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "conditional": {
|
||||||
|
for (const info of state.childEdgeInfo) {
|
||||||
|
result.push({
|
||||||
|
...info,
|
||||||
|
edgeType: "conditional" as const,
|
||||||
|
containerState: state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectFromExitNodes(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
exitNodes: string[],
|
||||||
|
childInfo: ChildEdgeInfo,
|
||||||
|
): void {
|
||||||
|
const edgeType = childInfo.containerState?.tag === "conditional" ? "conditional" : childInfo.edgeType;
|
||||||
|
const dataFlow = inferDataFlow(edgeType);
|
||||||
|
const attrs = buildEdgeAttrs(edgeType, dataFlow, childInfo.containerState);
|
||||||
|
for (const from of exitNodes) {
|
||||||
|
for (const to of childInfo.entryNodes) {
|
||||||
|
addEdgeSafe(graph, from, to, attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenChildEdgeInfos(
|
||||||
|
childState: ContainerState | undefined,
|
||||||
|
childNode: GraphNode,
|
||||||
|
): ChildEdgeInfo[] {
|
||||||
|
if (!childState) {
|
||||||
|
return [{ entryNodes: [childNode.key], exitNodes: [childNode.key], edgeType: "sequential" }];
|
||||||
|
}
|
||||||
|
if (childState.tag === "conditional") {
|
||||||
|
return [{ entryNodes: [...childState.entryNodes], exitNodes: [...childState.exitNodes], edgeType: "conditional", containerState: childState }];
|
||||||
|
}
|
||||||
|
return collectChildEdgeInfos(childState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphologyHostConfig: HostConfig<WorkflowTag, GraphNode, GraphContext> = {
|
||||||
|
name: "graphology",
|
||||||
|
|
||||||
|
createRootContext(_container, options) {
|
||||||
|
const graph = new DirectedGraph({ type: "directed", multi: false, allowSelfLoops: false });
|
||||||
|
return {
|
||||||
|
graph,
|
||||||
|
parentStack: [],
|
||||||
|
operationRegistry: options?.registry as OperationRegistry | undefined,
|
||||||
|
_containerCounter: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizeRoot(ctx) {
|
||||||
|
if (hasCycle(ctx.graph)) {
|
||||||
|
throw new CycleError([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createInstance(tag, props, ctx, _parent) {
|
||||||
|
if (tag === "operation") {
|
||||||
|
const key = props.name as string;
|
||||||
|
const attrs: OperationNodeAttrs = {
|
||||||
|
name: key,
|
||||||
|
namespace: (props.namespace as string) ?? "",
|
||||||
|
version: (props.version as string) ?? "0.0.1",
|
||||||
|
type: (props.type as OperationNodeAttrs["type"]) ?? "mutation",
|
||||||
|
inputSchema: props.inputSchema ?? {},
|
||||||
|
outputSchema: props.outputSchema ?? {},
|
||||||
|
};
|
||||||
|
if (props.description !== undefined) {
|
||||||
|
attrs.description = props.description as string;
|
||||||
|
}
|
||||||
|
if (props.tags !== undefined) {
|
||||||
|
attrs.tags = props.tags as string[];
|
||||||
|
}
|
||||||
|
ctx.graph.addNode(key, attrs as Record<string, unknown>);
|
||||||
|
return { key, attributes: attrs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const counter = ctx._containerCounter++;
|
||||||
|
const syntheticKey = `__${tag}_${counter}`;
|
||||||
|
const node: GraphNode = { key: syntheticKey, attributes: {} as TemplateNodeAttrs };
|
||||||
|
|
||||||
|
const state: ContainerState = {
|
||||||
|
tag,
|
||||||
|
entryNodes: [],
|
||||||
|
exitNodes: [],
|
||||||
|
childEdgeInfo: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tag === "conditional") {
|
||||||
|
state.condition = props.test;
|
||||||
|
state.negated = (props.negated as boolean) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
structuralContainers.set(node, state);
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTextInstance(_text, _ctx, _parent) {
|
||||||
|
const counter = _ctx._containerCounter++;
|
||||||
|
return { key: `__text_${counter}`, attributes: {} as TemplateNodeAttrs };
|
||||||
|
},
|
||||||
|
|
||||||
|
appendChild(parent, child, ctx) {
|
||||||
|
const parentState = structuralContainers.get(parent);
|
||||||
|
const childState = structuralContainers.get(child);
|
||||||
|
|
||||||
|
if (!isStructuralContainer(parent)) {
|
||||||
|
if (isStructuralContainer(child)) {
|
||||||
|
const flattenedInfos = flattenChildEdgeInfos(childState, child);
|
||||||
|
for (const info of flattenedInfos) {
|
||||||
|
const edgeType = info.edgeType;
|
||||||
|
const dataFlow = inferDataFlow(edgeType);
|
||||||
|
const attrs = buildEdgeAttrs(edgeType, dataFlow, info.containerState);
|
||||||
|
for (const to of info.entryNodes) {
|
||||||
|
addEdgeSafe(ctx.graph, parent.key, to, attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const exitNodes = childState ? childState.exitNodes : [child.key];
|
||||||
|
ctx.parentStack = exitNodes;
|
||||||
|
} else {
|
||||||
|
const edgeType: "sequential" = "sequential";
|
||||||
|
const dataFlow = inferDataFlow(edgeType);
|
||||||
|
const attrs = buildEdgeAttrs(edgeType, dataFlow);
|
||||||
|
addEdgeSafe(ctx.graph, parent.key, child.key, attrs);
|
||||||
|
ctx.parentStack = [child.key];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentState) return;
|
||||||
|
|
||||||
|
const childEntryKeys = childState ? childState.entryNodes : [child.key];
|
||||||
|
const childExitKeys = childState ? childState.exitNodes : [child.key];
|
||||||
|
|
||||||
|
switch (parentState.tag) {
|
||||||
|
case "sequential": {
|
||||||
|
if (parentState.exitNodes.length > 0) {
|
||||||
|
const flattenedInfos = flattenChildEdgeInfos(childState, child);
|
||||||
|
for (const info of flattenedInfos) {
|
||||||
|
connectFromExitNodes(ctx.graph, parentState.exitNodes, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parentState.entryNodes.length === 0) {
|
||||||
|
parentState.entryNodes = [...childEntryKeys];
|
||||||
|
}
|
||||||
|
parentState.exitNodes = [...childExitKeys];
|
||||||
|
|
||||||
|
const childInfo: ChildEdgeInfo = childState
|
||||||
|
? { entryNodes: [...childEntryKeys], exitNodes: [...childExitKeys], edgeType: childState.tag === "conditional" ? "conditional" : "sequential", containerState: childState }
|
||||||
|
: { entryNodes: [child.key], exitNodes: [child.key], edgeType: "sequential" };
|
||||||
|
parentState.childEdgeInfo.push(childInfo);
|
||||||
|
ctx.parentStack = [...childExitKeys];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "parallel": {
|
||||||
|
parentState.entryNodes.push(...childEntryKeys);
|
||||||
|
parentState.exitNodes.push(...childExitKeys);
|
||||||
|
|
||||||
|
const childInfo: ChildEdgeInfo = childState
|
||||||
|
? { entryNodes: [...childEntryKeys], exitNodes: [...childExitKeys], edgeType: childState.tag === "conditional" ? "conditional" : "sequential", containerState: childState }
|
||||||
|
: { entryNodes: [child.key], exitNodes: [child.key], edgeType: "sequential" };
|
||||||
|
parentState.childEdgeInfo.push(childInfo);
|
||||||
|
ctx.parentStack = [...parentState.exitNodes];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "conditional": {
|
||||||
|
parentState.entryNodes.push(...childEntryKeys);
|
||||||
|
parentState.exitNodes.push(...childExitKeys);
|
||||||
|
|
||||||
|
const childInfo: ChildEdgeInfo = childState
|
||||||
|
? { entryNodes: [...childEntryKeys], exitNodes: [...childExitKeys], edgeType: "conditional", containerState: parentState }
|
||||||
|
: { entryNodes: [child.key], exitNodes: [child.key], edgeType: "conditional", containerState: parentState };
|
||||||
|
parentState.childEdgeInfo.push(childInfo);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "map": {
|
||||||
|
parentState.entryNodes.push(...childEntryKeys);
|
||||||
|
parentState.exitNodes.push(...childExitKeys);
|
||||||
|
|
||||||
|
const childInfo: ChildEdgeInfo = childState
|
||||||
|
? { entryNodes: [...childEntryKeys], exitNodes: [...childExitKeys], edgeType: childState.tag === "conditional" ? "conditional" : "sequential", containerState: childState }
|
||||||
|
: { entryNodes: [child.key], exitNodes: [child.key], edgeType: "sequential" };
|
||||||
|
parentState.childEdgeInfo.push(childInfo);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizeInstance(_instance, _ctx) {
|
||||||
|
},
|
||||||
|
|
||||||
|
removeChild(parent, child, ctx) {
|
||||||
|
if (!isStructuralContainer(parent) && !isStructuralContainer(child)) {
|
||||||
|
const key = `${parent.key}->${child.key}`;
|
||||||
|
if (ctx.graph.hasEdge(key)) {
|
||||||
|
ctx.graph.dropEdge(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prepareUpdate() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
commitUpdate() {
|
||||||
|
},
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry };
|
||||||
@@ -1 +1,2 @@
|
|||||||
export {};
|
export { GraphologyHostConfig } from "./graphology.js";
|
||||||
|
export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry } from "./graphology.js";
|
||||||
@@ -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', () => {
|
function renderTemplate(template: ReturnType<typeof h>): GraphContext {
|
||||||
it('placeholder', () => {
|
const root = createHostRoot(GraphologyHostConfig, null);
|
||||||
expect(true).toBe(true);
|
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