From efded0946e889da779495cfd585b106d3c978ea2 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 21:23:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(host):=20implement=20GraphologyHostConfig?= =?UTF-8?q?=20=E2=80=94=20render=20ujsx=20templates=20to=20graphology=20DA?= =?UTF-8?q?G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/host/graphology.ts | 325 ++++++++++++++++++++++++- src/host/index.ts | 3 +- test/host/graphology.test.ts | 455 ++++++++++++++++++++++++++++++++++- 3 files changed, 777 insertions(+), 6 deletions(-) diff --git a/src/host/graphology.ts b/src/host/graphology.ts index 8cec2e9..d6f55f8 100644 --- a/src/host/graphology.ts +++ b/src/host/graphology.ts @@ -1 +1,324 @@ -export {}; \ No newline at end of file +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(); + +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, +): 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 { + const attrs: Record = { 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 = { + 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); + 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 }; \ No newline at end of file diff --git a/src/host/index.ts b/src/host/index.ts index 8cec2e9..5be592c 100644 --- a/src/host/index.ts +++ b/src/host/index.ts @@ -1 +1,2 @@ -export {}; \ No newline at end of file +export { GraphologyHostConfig } from "./graphology.js"; +export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry } from "./graphology.js"; \ No newline at end of file diff --git a/test/host/graphology.test.ts b/test/host/graphology.test.ts index d1585b2..473e7ac 100644 --- a/test/host/graphology.test.ts +++ b/test/host/graphology.test.ts @@ -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): 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) => 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); }); }); \ No newline at end of file