Files
flowgraph/test/host/reactive.test.ts

507 lines
16 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { h, createHostRoot } from "@alkdev/ujsx";
import { Operation, Sequential, Parallel, Conditional, Map } from "../../src/component/index.js";
import { ReactiveHostConfig } from "../../src/host/reactive.js";
import type { WorkflowNode, ReactiveContext } from "../../src/host/reactive.js";
function renderTemplate(template: ReturnType<typeof h>): ReactiveContext {
const root = createHostRoot(ReactiveHostConfig, null);
root.render(template);
return root.ctx;
}
describe("ReactiveHostConfig", () => {
describe("createRootContext", () => {
it("creates fresh ReactiveContext with empty maps", () => {
const ctx = ReactiveHostConfig.createRootContext(null);
expect(ctx.nodes.size).toBe(0);
expect(ctx.statusSignals.size).toBe(0);
expect(ctx.parentMap.size).toBe(0);
expect(ctx.siblingMap.size).toBe(0);
});
it("accepts options with registry", () => {
const registry = { resolve: (name: string) => name };
const ctx = ReactiveHostConfig.createRootContext(null, { registry });
expect(ctx.operationRegistry).toBe(registry);
});
});
describe("createInstance for operation", () => {
it("creates WorkflowNode with correct key and type", () => {
const template = h(Sequential, {},
h(Operation, { name: "classify" }),
);
const ctx = renderTemplate(template);
const node = ctx.nodes.get("classify");
expect(node).toBeDefined();
expect(node!.key).toBe("classify");
expect(node!.type).toBe("operation");
expect(node!.operationId).toBe("classify");
});
it("creates WorkflowNode with idle status signal", () => {
const template = h(Sequential, {},
h(Operation, { name: "classify" }),
);
const ctx = renderTemplate(template);
const node = ctx.nodes.get("classify")!;
expect(node.status.value).toBe("idle");
});
it("registers node in context maps", () => {
const template = h(Sequential, {},
h(Operation, { name: "classify" }),
);
const ctx = renderTemplate(template);
expect(ctx.statusSignals.has("classify")).toBe(true);
expect(ctx.preconditions.has("classify")).toBe(true);
expect(ctx.blockedByFailure.has("classify")).toBe(true);
});
it("creates output signal for operation nodes", () => {
const template = h(Sequential, {},
h(Operation, { name: "classify" }),
);
const ctx = renderTemplate(template);
const node = ctx.nodes.get("classify")!;
expect(node.output).toBeDefined();
expect(node.output!.value).toBeUndefined();
});
});
describe("createInstance for structural containers", () => {
it("creates WorkflowNode tracking children for Sequential", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const seqNode = ctx.nodes.get("__sequential_0");
expect(seqNode).toBeDefined();
expect(seqNode!.type).toBe("sequential");
expect(seqNode!.children.length).toBe(2);
});
it("creates WorkflowNode for Parallel with children", () => {
const template = h(Parallel, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const parNode = ctx.nodes.get("__parallel_0");
expect(parNode).toBeDefined();
expect(parNode!.type).toBe("parallel");
expect(parNode!.children.length).toBe(2);
});
it("creates WorkflowNode for Conditional with children", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Conditional, { test: "A" },
h(Operation, { name: "B" }),
),
);
const ctx = renderTemplate(template);
const condNode = ctx.nodes.get("__conditional_1");
expect(condNode).toBeDefined();
expect(condNode!.type).toBe("conditional");
});
it("structural containers do not have operationId", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
);
const ctx = renderTemplate(template);
const seqNode = ctx.nodes.get("__sequential_0")!;
expect(seqNode.operationId).toBeUndefined();
});
});
describe("signal initial states", () => {
it("root operation has preconditions met (no predecessors)", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const nodeA = ctx.nodes.get("A")!;
expect(nodeA.preconditions.value).toBe(true);
});
it("second operation in sequential has unmet preconditions", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const nodeB = ctx.nodes.get("B")!;
expect(nodeB.preconditions.value).toBe(false);
});
it("parallel children have preconditions met (no inter-child deps)", () => {
const template = h(Parallel, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
expect(ctx.nodes.get("A")!.preconditions.value).toBe(true);
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
});
it("all initial blockedByFailure are false", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
expect(ctx.nodes.get("A")!.blockedByFailure.value).toBe(false);
expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(false);
});
});
describe("precondition transition on predecessor completion", () => {
it("sequential: completing predecessor updates dependent preconditions", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const nodeA = ctx.nodes.get("A")!;
const nodeB = ctx.nodes.get("B")!;
expect(nodeB.preconditions.value).toBe(false);
nodeA.status.value = "completed";
expect(nodeB.preconditions.value).toBe(true);
});
it("sequential: skipped predecessor satisfies preconditions", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const nodeA = ctx.nodes.get("A")!;
nodeA.status.value = "skipped";
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
});
it("sequential chain: A→B→C, completing A then B", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
h(Operation, { name: "C" }),
);
const ctx = renderTemplate(template);
expect(ctx.nodes.get("B")!.preconditions.value).toBe(false);
expect(ctx.nodes.get("C")!.preconditions.value).toBe(false);
ctx.nodes.get("A")!.status.value = "completed";
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
expect(ctx.nodes.get("C")!.preconditions.value).toBe(false);
ctx.nodes.get("B")!.status.value = "completed";
expect(ctx.nodes.get("C")!.preconditions.value).toBe(true);
});
it("parallel children preconditions independent of each other", () => {
const template = h(Sequential, {},
h(Operation, { name: "pre" }),
h(Parallel, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
),
);
const ctx = renderTemplate(template);
expect(ctx.nodes.get("A")!.preconditions.value).toBe(false);
expect(ctx.nodes.get("B")!.preconditions.value).toBe(false);
ctx.nodes.get("pre")!.status.value = "completed";
expect(ctx.nodes.get("A")!.preconditions.value).toBe(true);
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
});
});
describe("failure propagation", () => {
it("sequential: failed predecessor causes blockedByFailure to be true", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
ctx.nodes.get("A")!.status.value = "failed";
expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(true);
});
it("sequential: aborted predecessor causes blockedByFailure to be true", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
ctx.nodes.get("A")!.status.value = "aborted";
expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(true);
});
it("parallel siblings are independent for failure propagation", () => {
const template = h(Parallel, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
ctx.nodes.get("A")!.status.value = "failed";
expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(false);
});
it("failed predecessor does not satisfy preconditions", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
ctx.nodes.get("A")!.status.value = "failed";
expect(ctx.nodes.get("B")!.preconditions.value).toBe(false);
});
});
describe("appendChild", () => {
it("appends children to parent's children array", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const seqNode = ctx.nodes.get("__sequential_0")!;
expect(seqNode.children.length).toBe(2);
expect(seqNode.children[0]!.key).toBe("A");
expect(seqNode.children[1]!.key).toBe("B");
});
it("does not duplicate children", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
);
const ctx = renderTemplate(template);
const seqNode = ctx.nodes.get("__sequential_0")!;
const childNode = ctx.nodes.get("A")!;
ReactiveHostConfig.appendChild(seqNode, childNode, ctx);
expect(seqNode.children.length).toBe(1);
});
});
describe("removeChild", () => {
it("removes child from parent's children array", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const seqNode = ctx.nodes.get("__sequential_0")!;
const childB = ctx.nodes.get("B")!;
ReactiveHostConfig.removeChild(seqNode, childB, ctx);
expect(seqNode.children.length).toBe(1);
expect(seqNode.children[0]!.key).toBe("A");
});
it("preconditions auto-reevaluate after removeChild", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
h(Operation, { name: "C" }),
);
const ctx = renderTemplate(template);
const seqNode = ctx.nodes.get("__sequential_0")!;
const childB = ctx.nodes.get("B")!;
ctx.nodes.get("A")!.status.value = "completed";
expect(childB.preconditions.value).toBe(true);
ReactiveHostConfig.removeChild(seqNode, childB, ctx);
const childC = ctx.nodes.get("C")!;
expect(childC.preconditions.value).toBe(true);
});
});
describe("parentMap and siblingMap", () => {
it("registers parent-child relationships in parentMap", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
expect(ctx.parentMap.get("A")).toBe("__sequential_0");
expect(ctx.parentMap.get("B")).toBe("__sequential_0");
});
it("registers siblings in siblingMap", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
const siblings = ctx.siblingMap.get("__sequential_0");
expect(siblings).toEqual(["A", "B"]);
});
it("nested structures register correct parent", () => {
const template = h(Sequential, {},
h(Operation, { name: "pre" }),
h(Parallel, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
),
);
const ctx = renderTemplate(template);
expect(ctx.parentMap.get("pre")).toBe("__sequential_0");
expect(ctx.parentMap.get("A")).toBe("__parallel_1");
expect(ctx.parentMap.get("B")).toBe("__parallel_1");
const seqSiblings = ctx.siblingMap.get("__sequential_0");
expect(seqSiblings).toEqual(["pre", "__parallel_1"]);
const parSiblings = ctx.siblingMap.get("__parallel_1");
expect(parSiblings).toEqual(["A", "B"]);
});
});
describe("Conditional as error boundary", () => {
it("conditional node is created with correct type", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
h(Conditional, { test: "A" },
h(Operation, { name: "B" }),
),
);
const ctx = renderTemplate(template);
const condNode = ctx.nodes.get("__conditional_1");
expect(condNode).toBeDefined();
expect(condNode!.type).toBe("conditional");
});
it("conditional child has preconditions met (no inter-child ordering)", () => {
const template = h(Conditional, { test: "A" },
h(Operation, { name: "B" }),
);
const ctx = renderTemplate(template);
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
});
});
describe("full template rendering", () => {
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.nodes.get("architect")).toBeDefined();
expect(ctx.nodes.get("reviewer")).toBeDefined();
expect(ctx.nodes.get("decomposer")).toBeDefined();
expect(ctx.nodes.get("specialist")).toBeDefined();
expect(ctx.nodes.get("synthesizer")).toBeDefined();
expect(ctx.nodes.get("architect")!.preconditions.value).toBe(true);
expect(ctx.nodes.get("reviewer")!.preconditions.value).toBe(false);
ctx.nodes.get("architect")!.status.value = "completed";
expect(ctx.nodes.get("reviewer")!.preconditions.value).toBe(true);
});
it("deeply nested template registers all nodes", () => {
const template = h(Sequential, {},
h(Parallel, {},
h(Sequential, {},
h(Operation, { name: "A" }),
h(Operation, { name: "B" }),
),
h(Operation, { name: "C" }),
),
);
const ctx = renderTemplate(template);
expect(ctx.nodes.has("A")).toBe(true);
expect(ctx.nodes.has("B")).toBe(true);
expect(ctx.nodes.has("C")).toBe(true);
});
});
describe("shared signal references", () => {
it("WorkflowNode.status and statusSignals point to same signal", () => {
const template = h(Sequential, {},
h(Operation, { name: "A" }),
);
const ctx = renderTemplate(template);
const node = ctx.nodes.get("A")!;
const statusFromMap = ctx.statusSignals.get("A")!;
expect(node.status).toBe(statusFromMap);
statusFromMap.value = "running";
expect(node.status.value).toBe("running");
node.status.value = "completed";
expect(statusFromMap.value).toBe("completed");
});
});
});