Merge branch 'feat/setup-test-infrastructure'
This commit is contained in:
99
test/helpers/graph-factory.ts
Normal file
99
test/helpers/graph-factory.ts
Normal file
@@ -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<string, unknown>;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
}>,
|
||||
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;
|
||||
}
|
||||
171
test/helpers/helpers.test.ts
Normal file
171
test/helpers/helpers.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
3
test/helpers/index.ts
Normal file
3
test/helpers/index.ts
Normal file
@@ -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";
|
||||
136
test/helpers/reactive.ts
Normal file
136
test/helpers/reactive.ts
Normal file
@@ -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<string, ReturnType<typeof signal<NodeStatus>>>;
|
||||
preconditions: Map<string, ReturnType<typeof computed<boolean>>>;
|
||||
blockedByFailure: Map<string, ReturnType<typeof computed<boolean>>>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createTestReactiveRoot(graph: DirectedGraph): TestReactiveRoot {
|
||||
const statusMap = new Map<string, ReturnType<typeof signal<NodeStatus>>>();
|
||||
const preconditions = new Map<string, ReturnType<typeof computed<boolean>>>();
|
||||
const blockedByFailure = new Map<string, ReturnType<typeof computed<boolean>>>();
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
for (const node of graph.nodes()) {
|
||||
const predecessors = graph.inNeighbors(node);
|
||||
|
||||
const status = signal<NodeStatus>(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 };
|
||||
96
test/helpers/schemas.ts
Normal file
96
test/helpers/schemas.ts
Normal file
@@ -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<typeof CompatibleInputSchema>;
|
||||
export type CompatibleOutput = Static<typeof CompatibleOutputSchema>;
|
||||
|
||||
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<typeof NarrowOutputSchema>;
|
||||
export type WideInput = Static<typeof WideInputSchema>;
|
||||
|
||||
export const IncompatibleNumberOutputSchema = Type.Object({
|
||||
value: Type.Number(),
|
||||
});
|
||||
|
||||
export const IncompatibleStringInputSchema = Type.Object({
|
||||
value: Type.String(),
|
||||
});
|
||||
|
||||
export type IncompatibleNumberOutput = Static<typeof IncompatibleNumberOutputSchema>;
|
||||
export type IncompatibleStringInput = Static<typeof IncompatibleStringInputSchema>;
|
||||
|
||||
export const MissingRequiredFieldOutputSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
});
|
||||
|
||||
export const MissingRequiredFieldInputSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export type MissingRequiredFieldOutput = Static<typeof MissingRequiredFieldOutputSchema>;
|
||||
export type MissingRequiredFieldInput = Static<typeof MissingRequiredFieldInputSchema>;
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@test': path.resolve(__dirname, 'test/helpers'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user