feat: implement ReactiveHostConfig with WorkflowNode, ReactiveContext, precondition computation and 34 integration tests
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
export { GraphologyHostConfig } from "./graphology.js";
|
export { GraphologyHostConfig } from "./graphology.js";
|
||||||
export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry } from "./graphology.js";
|
export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry as GraphOperationRegistry } from "./graphology.js";
|
||||||
|
export { ReactiveHostConfig } from "./reactive.js";
|
||||||
|
export type { WorkflowNode, ReactiveContext, OperationRegistry } from "./reactive.js";
|
||||||
@@ -1 +1,250 @@
|
|||||||
export {};
|
import { signal, computed } from "@preact/signals-core";
|
||||||
|
import type { Signal, ReadonlySignal } from "@preact/signals-core";
|
||||||
|
import type { HostConfig } from "@alkdev/ujsx";
|
||||||
|
import type { NodeStatus } from "../schema/enums.js";
|
||||||
|
import type { CallResult } from "../schema/edge.js";
|
||||||
|
import type { EventLogProjection } from "../reactive/workflow.js";
|
||||||
|
import type { WorkflowTag } from "./graphology.js";
|
||||||
|
|
||||||
|
export interface OperationRegistry {
|
||||||
|
resolve(name: string): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowNode {
|
||||||
|
key: string;
|
||||||
|
type: WorkflowTag;
|
||||||
|
status: Signal<NodeStatus>;
|
||||||
|
preconditions: ReadonlySignal<boolean>;
|
||||||
|
blockedByFailure: ReadonlySignal<boolean>;
|
||||||
|
operationId?: string;
|
||||||
|
output?: Signal<unknown>;
|
||||||
|
children: WorkflowNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactiveContext {
|
||||||
|
operationRegistry: OperationRegistry;
|
||||||
|
nodes: Map<string, WorkflowNode>;
|
||||||
|
statusSignals: Map<string, Signal<NodeStatus>>;
|
||||||
|
preconditions: Map<string, ReadonlySignal<boolean>>;
|
||||||
|
blockedByFailure: Map<string, ReadonlySignal<boolean>>;
|
||||||
|
resultProjection: EventLogProjection;
|
||||||
|
parentMap: Map<string, string>;
|
||||||
|
siblingMap: Map<string, string[]>;
|
||||||
|
results: Map<string, ReadonlySignal<CallResult | undefined>>;
|
||||||
|
_containerCounter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLeafPredecessors(
|
||||||
|
nodeKey: string,
|
||||||
|
ctx: ReactiveContext,
|
||||||
|
): string[] {
|
||||||
|
const parentKey = ctx.parentMap.get(nodeKey);
|
||||||
|
if (!parentKey) return [];
|
||||||
|
|
||||||
|
const parentNode = ctx.nodes.get(parentKey);
|
||||||
|
if (!parentNode) return [];
|
||||||
|
|
||||||
|
const siblings = ctx.siblingMap.get(parentKey);
|
||||||
|
if (!siblings) return [];
|
||||||
|
|
||||||
|
const idx = siblings.indexOf(nodeKey);
|
||||||
|
|
||||||
|
switch (parentNode.type) {
|
||||||
|
case "sequential": {
|
||||||
|
if (idx > 0) {
|
||||||
|
const prevKey = siblings[idx - 1]!;
|
||||||
|
const prevNode = ctx.nodes.get(prevKey);
|
||||||
|
if (prevNode && prevNode.type === "operation") {
|
||||||
|
return [prevKey];
|
||||||
|
}
|
||||||
|
return collectLeafPredecessors(prevKey, ctx);
|
||||||
|
}
|
||||||
|
return collectLeafPredecessors(parentKey, ctx);
|
||||||
|
}
|
||||||
|
case "parallel":
|
||||||
|
case "map":
|
||||||
|
case "conditional": {
|
||||||
|
return collectLeafPredecessors(parentKey, ctx);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePreconditions(
|
||||||
|
node: WorkflowNode,
|
||||||
|
ctx: ReactiveContext,
|
||||||
|
): boolean {
|
||||||
|
const predecessors = collectLeafPredecessors(node.key, ctx);
|
||||||
|
if (predecessors.length === 0) return true;
|
||||||
|
return predecessors.every((predKey) => {
|
||||||
|
const predStatus = ctx.statusSignals.get(predKey);
|
||||||
|
if (!predStatus) return true;
|
||||||
|
return predStatus.value === "completed" || predStatus.value === "skipped";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBlockedByFailure(
|
||||||
|
node: WorkflowNode,
|
||||||
|
ctx: ReactiveContext,
|
||||||
|
): boolean {
|
||||||
|
const predecessors = collectLeafPredecessors(node.key, ctx);
|
||||||
|
return predecessors.some((predKey) => {
|
||||||
|
const predStatus = ctx.statusSignals.get(predKey);
|
||||||
|
if (!predStatus) return false;
|
||||||
|
return predStatus.value === "failed" || predStatus.value === "aborted";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReactiveHostConfig: HostConfig<WorkflowTag, WorkflowNode, ReactiveContext> = {
|
||||||
|
name: "reactive",
|
||||||
|
|
||||||
|
createRootContext(_container, options) {
|
||||||
|
const ctx: ReactiveContext = {
|
||||||
|
operationRegistry: options?.registry as OperationRegistry ?? { resolve: () => undefined },
|
||||||
|
nodes: new Map(),
|
||||||
|
statusSignals: new Map(),
|
||||||
|
preconditions: new Map(),
|
||||||
|
blockedByFailure: new Map(),
|
||||||
|
resultProjection: options?.resultProjection as EventLogProjection ?? {
|
||||||
|
append() {},
|
||||||
|
getStatus: () => "idle" as NodeStatus,
|
||||||
|
getResult: () => undefined,
|
||||||
|
getEvents: () => [],
|
||||||
|
},
|
||||||
|
parentMap: new Map(),
|
||||||
|
siblingMap: new Map(),
|
||||||
|
results: new Map(),
|
||||||
|
_containerCounter: 0,
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
},
|
||||||
|
|
||||||
|
createInstance(tag, props, ctx, parent) {
|
||||||
|
if (tag === "operation") {
|
||||||
|
const key = props.name as string;
|
||||||
|
const status = signal<NodeStatus>("idle");
|
||||||
|
|
||||||
|
const node: WorkflowNode = {
|
||||||
|
key,
|
||||||
|
type: "operation",
|
||||||
|
status,
|
||||||
|
preconditions: computed(() => computePreconditions(node, ctx)),
|
||||||
|
blockedByFailure: computed(() => computeBlockedByFailure(node, ctx)),
|
||||||
|
operationId: key,
|
||||||
|
output: signal<unknown>(undefined),
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.nodes.set(key, node);
|
||||||
|
ctx.statusSignals.set(key, status);
|
||||||
|
ctx.preconditions.set(key, node.preconditions);
|
||||||
|
ctx.blockedByFailure.set(key, node.blockedByFailure);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
ctx.parentMap.set(key, parent.key);
|
||||||
|
const siblings = ctx.siblingMap.get(parent.key);
|
||||||
|
if (siblings) {
|
||||||
|
siblings.push(key);
|
||||||
|
} else {
|
||||||
|
ctx.siblingMap.set(parent.key, [key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const counter = ctx._containerCounter++;
|
||||||
|
const key = `__${tag}_${counter}`;
|
||||||
|
const status = signal<NodeStatus>("idle");
|
||||||
|
|
||||||
|
const node: WorkflowNode = {
|
||||||
|
key,
|
||||||
|
type: tag,
|
||||||
|
status,
|
||||||
|
preconditions: computed(() => computePreconditions(node, ctx)),
|
||||||
|
blockedByFailure: computed(() => computeBlockedByFailure(node, ctx)),
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.nodes.set(key, node);
|
||||||
|
ctx.statusSignals.set(key, status);
|
||||||
|
ctx.preconditions.set(key, node.preconditions);
|
||||||
|
ctx.blockedByFailure.set(key, node.blockedByFailure);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
ctx.parentMap.set(key, parent.key);
|
||||||
|
const siblings = ctx.siblingMap.get(parent.key);
|
||||||
|
if (siblings) {
|
||||||
|
siblings.push(key);
|
||||||
|
} else {
|
||||||
|
ctx.siblingMap.set(parent.key, [key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.siblingMap.has(key)) {
|
||||||
|
ctx.siblingMap.set(key, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTextInstance(_text, ctx, _parent) {
|
||||||
|
const counter = ctx._containerCounter++;
|
||||||
|
const key = `__text_${counter}`;
|
||||||
|
const status = signal<NodeStatus>("idle");
|
||||||
|
const node: WorkflowNode = {
|
||||||
|
key,
|
||||||
|
type: "sequential" as WorkflowTag,
|
||||||
|
status,
|
||||||
|
preconditions: computed(() => true),
|
||||||
|
blockedByFailure: computed(() => false),
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
ctx.nodes.set(key, node);
|
||||||
|
ctx.statusSignals.set(key, status);
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
|
||||||
|
appendChild(parent, child, _ctx) {
|
||||||
|
if (!parent.children.includes(child)) {
|
||||||
|
parent.children.push(child);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
insertBefore(parent, child, before, _ctx) {
|
||||||
|
const idx = parent.children.indexOf(before);
|
||||||
|
if (idx === -1) {
|
||||||
|
parent.children.push(child);
|
||||||
|
} else {
|
||||||
|
if (!parent.children.includes(child)) {
|
||||||
|
parent.children.splice(idx, 0, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeChild(parent, child, ctx) {
|
||||||
|
parent.children = parent.children.filter((c) => c.key !== child.key);
|
||||||
|
|
||||||
|
const siblings = ctx.siblingMap.get(parent.key);
|
||||||
|
if (siblings) {
|
||||||
|
const idx = siblings.indexOf(child.key);
|
||||||
|
if (idx !== -1) {
|
||||||
|
siblings.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prepareUpdate() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
commitUpdate() {
|
||||||
|
},
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizeInstance(_instance, _ctx) {
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,507 @@
|
|||||||
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 { ReactiveHostConfig } from "../../src/host/reactive.js";
|
||||||
|
import type { WorkflowNode, ReactiveContext } from "../../src/host/reactive.js";
|
||||||
|
|
||||||
describe('reactive host', () => {
|
function renderTemplate(template: ReturnType<typeof h>): ReactiveContext {
|
||||||
it('placeholder', () => {
|
const root = createHostRoot(ReactiveHostConfig, null);
|
||||||
expect(true).toBe(true);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user