diff --git a/test/helpers/graph-factory.ts b/test/helpers/graph-factory.ts new file mode 100644 index 0000000..2fb5ffa --- /dev/null +++ b/test/helpers/graph-factory.ts @@ -0,0 +1,99 @@ +import { DirectedGraph } from "graphology"; + +export function createLinearGraph( + nodes: string[], + edgeType: string = "sequential", +): DirectedGraph { + const graph = new DirectedGraph(); + for (const node of nodes) { + graph.addNode(node, { name: node }); + } + for (let i = 0; i < nodes.length - 1; i++) { + const source = nodes[i]!; + const target = nodes[i + 1]!; + graph.addEdgeWithKey(`${source}->${target}`, source, target, { edgeType }); + } + return graph; +} + +export function createDiamondGraph( + top: string, + left: string, + right: string, + bottom: string, + edgeType: string = "sequential", +): DirectedGraph { + const graph = new DirectedGraph(); + graph.addNode(top, { name: top }); + graph.addNode(left, { name: left }); + graph.addNode(right, { name: right }); + graph.addNode(bottom, { name: bottom }); + graph.addEdgeWithKey(`${top}->${left}`, top, left, { edgeType }); + graph.addEdgeWithKey(`${top}->${right}`, top, right, { edgeType }); + graph.addEdgeWithKey(`${left}->${bottom}`, left, bottom, { edgeType }); + graph.addEdgeWithKey(`${right}->${bottom}`, right, bottom, { edgeType }); + return graph; +} + +export function createOperationGraph( + specs: Array<{ + name: string; + namespace?: string; + version?: string; + type?: string; + inputSchema?: Record; + outputSchema?: Record; + }>, + edges: Array<{ source: string; target: string; compatible?: boolean; detail?: string }>, +): DirectedGraph { + const graph = new DirectedGraph(); + for (const spec of specs) { + const key = spec.namespace ? `${spec.namespace}.${spec.name}` : spec.name; + graph.addNode(key, { + name: spec.name, + namespace: spec.namespace ?? "test", + version: spec.version ?? "1.0.0", + type: spec.type ?? "query", + inputSchema: spec.inputSchema ?? {}, + outputSchema: spec.outputSchema ?? {}, + }); + } + for (const edge of edges) { + graph.addEdgeWithKey(`${edge.source}->${edge.target}`, edge.source, edge.target, { + edgeType: "typed", + compatible: edge.compatible ?? true, + detail: edge.detail ?? "", + }); + } + return graph; +} + +export function createCallGraph( + nodes: Array<{ + requestId: string; + operationId: string; + status: string; + parentRequestId?: string; + }>, +): DirectedGraph { + const graph = new DirectedGraph(); + for (const node of nodes) { + graph.addNode(node.requestId, { + requestId: node.requestId, + operationId: node.operationId, + status: node.status, + }); + if (node.parentRequestId) { + const parentKey = node.parentRequestId; + if (!graph.hasDirectedEdge(parentKey, node.requestId)) { + graph.addEdgeWithKey( + `${parentKey}->${node.requestId}`, + parentKey, + node.requestId, + { edgeType: "triggered" }, + ); + } + } + } + return graph; +} \ No newline at end of file diff --git a/test/helpers/helpers.test.ts b/test/helpers/helpers.test.ts new file mode 100644 index 0000000..e6c4705 --- /dev/null +++ b/test/helpers/helpers.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { + createLinearGraph, + createDiamondGraph, + createOperationGraph, + createCallGraph, +} from "@test/graph-factory.js"; +import { + createTestReactiveRoot, + assertStatus, + assertPreconditions, + assertBlockedByFailure, +} from "@test/reactive.js"; +import { + CompatibleInputSchema, + CompatibleOutputSchema, + NarrowOutputSchema, + WideInputSchema, + IncompatibleNumberOutputSchema, + IncompatibleStringInputSchema, + compatiblePairs, + incompatiblePairs, + unknownSchemas, +} from "@test/schemas.js"; + +describe("test helpers smoke tests", () => { + describe("graph-factory", () => { + it("creates a linear graph", () => { + const graph = createLinearGraph(["A", "B", "C"]); + expect(graph.nodes()).toEqual(["A", "B", "C"]); + expect(graph.edges()).toEqual(["A->B", "B->C"]); + }); + + it("creates a diamond graph", () => { + const graph = createDiamondGraph("top", "left", "right", "bottom"); + expect(graph.nodes()).toEqual(["top", "left", "right", "bottom"]); + expect(graph.edges()).toEqual([ + "top->left", + "top->right", + "left->bottom", + "right->bottom", + ]); + }); + + it("creates an operation graph", () => { + const graph = createOperationGraph( + [ + { name: "classify", namespace: "task" }, + { name: "enrich", namespace: "task" }, + ], + [{ source: "task.classify", target: "task.enrich" }], + ); + expect(graph.nodes()).toEqual(["task.classify", "task.enrich"]); + expect(graph.edges()).toEqual(["task.classify->task.enrich"]); + }); + + it("creates a call graph", () => { + const graph = createCallGraph([ + { requestId: "req1", operationId: "op1", status: "completed" }, + { + requestId: "req2", + operationId: "op2", + status: "running", + parentRequestId: "req1", + }, + ]); + expect(graph.nodes()).toEqual(["req1", "req2"]); + expect(graph.edges()).toEqual(["req1->req2"]); + }); + }); + + describe("reactive", () => { + it("creates reactive root with setup-transition-assert-dispose", () => { + const graph = createDiamondGraph("A", "B", "C", "D"); + const root = createTestReactiveRoot(graph); + + try { + assertStatus(root, "A", "idle"); + assertStatus(root, "B", "idle"); + assertPreconditions(root, "A", true); + assertPreconditions(root, "D", false); + + root.statusMap.get("A")!.value = "completed"; + assertPreconditions(root, "B", true); + assertPreconditions(root, "C", true); + assertPreconditions(root, "D", false); + + root.statusMap.get("B")!.value = "completed"; + root.statusMap.get("C")!.value = "completed"; + assertPreconditions(root, "D", true); + } finally { + root.dispose(); + } + }); + + it("propagates failure to blockedByFailure", () => { + const graph = createLinearGraph(["A", "B"]); + const root = createTestReactiveRoot(graph); + + try { + assertBlockedByFailure(root, "B", false); + root.statusMap.get("A")!.value = "failed"; + assertBlockedByFailure(root, "B", true); + } finally { + root.dispose(); + } + }); + + it("auto-aborts idle nodes when blockedByFailure fires", () => { + const graph = createLinearGraph(["A", "B"]); + const root = createTestReactiveRoot(graph); + + try { + assertStatus(root, "B", "idle"); + assertBlockedByFailure(root, "B", false); + + root.statusMap.get("A")!.value = "failed"; + + assertBlockedByFailure(root, "B", true); + assertStatus(root, "B", "aborted"); + } finally { + root.dispose(); + } + }); + + it("skipped satisfies preconditions", () => { + const graph = createLinearGraph(["A", "B"]); + const root = createTestReactiveRoot(graph); + + try { + root.statusMap.get("A")!.value = "skipped"; + assertPreconditions(root, "B", true); + } finally { + root.dispose(); + } + }); + }); + + describe("schemas", () => { + it("provides compatible pairs", () => { + expect(compatiblePairs.length).toBeGreaterThan(0); + for (const pair of compatiblePairs) { + expect(pair.name).toBeTruthy(); + expect(pair.output).toBeDefined(); + expect(pair.input).toBeDefined(); + } + }); + + it("provides incompatible pairs", () => { + expect(incompatiblePairs.length).toBeGreaterThan(0); + for (const pair of incompatiblePairs) { + expect(pair.name).toBeTruthy(); + expect(pair.output).toBeDefined(); + expect(pair.input).toBeDefined(); + } + }); + + it("provides unknown schemas", () => { + expect(unknownSchemas.length).toBeGreaterThan(0); + for (const item of unknownSchemas) { + expect(item.name).toBeTruthy(); + expect(item.schema).toBeDefined(); + } + }); + + it("CompatibleInputSchema and CompatibleOutputSchema have same structure", () => { + expect(CompatibleInputSchema).toBeDefined(); + expect(CompatibleOutputSchema).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/test/helpers/index.ts b/test/helpers/index.ts new file mode 100644 index 0000000..00b1679 --- /dev/null +++ b/test/helpers/index.ts @@ -0,0 +1,3 @@ +export { createLinearGraph, createDiamondGraph, createOperationGraph, createCallGraph } from "./graph-factory.js"; +export { createTestReactiveRoot, assertStatus, assertPreconditions, assertBlockedByFailure, type TestReactiveRoot, type NodeStatus } from "./reactive.js"; +export { CompatibleInputSchema, CompatibleOutputSchema, NarrowOutputSchema, WideInputSchema, IncompatibleNumberOutputSchema, IncompatibleStringInputSchema, MissingRequiredFieldOutputSchema, MissingRequiredFieldInputSchema, UnknownSchema, compatiblePairs, incompatiblePairs, unknownSchemas, type CompatibleInput, type CompatibleOutput, type NarrowOutput, type WideInput, type IncompatibleNumberOutput, type IncompatibleStringInput, type MissingRequiredFieldOutput, type MissingRequiredFieldInput } from "./schemas.js"; \ No newline at end of file diff --git a/test/helpers/reactive.ts b/test/helpers/reactive.ts new file mode 100644 index 0000000..670064c --- /dev/null +++ b/test/helpers/reactive.ts @@ -0,0 +1,136 @@ +import { signal, computed, effect } from "@preact/signals-core"; +import { DirectedGraph } from "graphology"; + +const IDLE: NodeStatus = "idle"; +const WAITING: NodeStatus = "waiting"; +const READY: NodeStatus = "ready"; +const RUNNING: NodeStatus = "running"; +const COMPLETED: NodeStatus = "completed"; +const FAILED: NodeStatus = "failed"; +const ABORTED: NodeStatus = "aborted"; +const SKIPPED: NodeStatus = "skipped"; + +type NodeStatus = + | "idle" + | "waiting" + | "ready" + | "running" + | "completed" + | "failed" + | "aborted" + | "skipped"; + +export interface TestReactiveRoot { + statusMap: Map>>; + preconditions: Map>>; + blockedByFailure: Map>>; + dispose: () => void; +} + +export function createTestReactiveRoot(graph: DirectedGraph): TestReactiveRoot { + const statusMap = new Map>>(); + const preconditions = new Map>>(); + const blockedByFailure = new Map>>(); + const disposers: (() => void)[] = []; + + for (const node of graph.nodes()) { + const predecessors = graph.inNeighbors(node); + + const status = signal(IDLE); + const preconditionsComputed = computed(() => { + return predecessors.every((pred: string) => { + const predStatus = statusMap.get(pred); + return ( + predStatus !== undefined && + (predStatus.value === COMPLETED || predStatus.value === SKIPPED) + ); + }); + }); + const blockedByFailureComputed = computed(() => { + return predecessors.some((pred: string) => { + const predStatus = statusMap.get(pred); + return ( + predStatus !== undefined && + (predStatus.value === FAILED || predStatus.value === ABORTED) + ); + }); + }); + + const blockerDisposer = effect(() => { + if (blockedByFailureComputed.value) { + if (status.value === IDLE || status.value === WAITING) { + status.value = ABORTED; + } + } + }); + + disposers.push(blockerDisposer); + statusMap.set(node, status); + preconditions.set(node, preconditionsComputed); + blockedByFailure.set(node, blockedByFailureComputed); + } + + return { + statusMap, + preconditions, + blockedByFailure, + dispose: () => { + for (const disposer of disposers) { + disposer(); + } + statusMap.clear(); + preconditions.clear(); + blockedByFailure.clear(); + }, + }; +} + +export function assertStatus( + root: TestReactiveRoot, + nodeId: string, + expected: NodeStatus, +): void { + const status = root.statusMap.get(nodeId); + if (!status) { + throw new Error(`Node ${nodeId} not found in statusMap`); + } + if (status.value !== expected) { + throw new Error( + `Node ${nodeId} status: expected ${expected}, got ${status.value}`, + ); + } +} + +export function assertPreconditions( + root: TestReactiveRoot, + nodeId: string, + expected: boolean, +): void { + const preconditions = root.preconditions.get(nodeId); + if (!preconditions) { + throw new Error(`Node ${nodeId} not found in preconditions`); + } + if (preconditions.value !== expected) { + throw new Error( + `Node ${nodeId} preconditions: expected ${expected}, got ${preconditions.value}`, + ); + } +} + +export function assertBlockedByFailure( + root: TestReactiveRoot, + nodeId: string, + expected: boolean, +): void { + const blocked = root.blockedByFailure.get(nodeId); + if (!blocked) { + throw new Error(`Node ${nodeId} not found in blockedByFailure`); + } + if (blocked.value !== expected) { + throw new Error( + `Node ${nodeId} blockedByFailure: expected ${expected}, got ${blocked.value}`, + ); + } +} + +export type { NodeStatus }; \ No newline at end of file diff --git a/test/helpers/schemas.ts b/test/helpers/schemas.ts new file mode 100644 index 0000000..dabc477 --- /dev/null +++ b/test/helpers/schemas.ts @@ -0,0 +1,96 @@ +import { Type, type Static, type TSchema } from "@alkdev/typebox"; + +export const CompatibleInputSchema = Type.Object({ + value: Type.Number(), + label: Type.Optional(Type.String()), +}); + +export const CompatibleOutputSchema = Type.Object({ + value: Type.Number(), + label: Type.Optional(Type.String()), +}); + +export type CompatibleInput = Static; +export type CompatibleOutput = Static; + +export const NarrowOutputSchema = Type.Object({ + value: Type.Number(), +}); + +export const WideInputSchema = Type.Object({ + value: Type.Number(), + label: Type.Optional(Type.String()), + metadata: Type.Optional(Type.Record(Type.String(), Type.String())), +}); + +export type NarrowOutput = Static; +export type WideInput = Static; + +export const IncompatibleNumberOutputSchema = Type.Object({ + value: Type.Number(), +}); + +export const IncompatibleStringInputSchema = Type.Object({ + value: Type.String(), +}); + +export type IncompatibleNumberOutput = Static; +export type IncompatibleStringInput = Static; + +export const MissingRequiredFieldOutputSchema = Type.Object({ + id: Type.String(), +}); + +export const MissingRequiredFieldInputSchema = Type.Object({ + id: Type.String(), + name: Type.String(), +}); + +export type MissingRequiredFieldOutput = Static; +export type MissingRequiredFieldInput = Static; + +export const UnknownSchema = Type.Unknown(); + +export const compatiblePairs: Array<{ + name: string; + output: TSchema; + input: TSchema; +}> = [ + { + name: "identical-structure", + output: CompatibleOutputSchema, + input: CompatibleInputSchema, + }, + { + name: "narrow-output-wide-input", + output: NarrowOutputSchema, + input: WideInputSchema, + }, +]; + +export const incompatiblePairs: Array<{ + name: string; + output: TSchema; + input: TSchema; +}> = [ + { + name: "type-mismatch-number-string", + output: IncompatibleNumberOutputSchema, + input: IncompatibleStringInputSchema, + }, + { + name: "missing-required-field", + output: MissingRequiredFieldOutputSchema, + input: MissingRequiredFieldInputSchema, + }, +]; + +export const unknownSchemas: Array<{ + name: string; + schema: TSchema; +}> = [ + { + name: "unknown-schema", + schema: UnknownSchema, + }, +]; \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 52134be..f340fdc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src'), + '@test': path.resolve(__dirname, 'test/helpers'), }, }, test: {