setup: add vitest test infrastructure with helpers, fixtures, and reactive test patterns

This commit is contained in:
2026-05-21 20:52:04 +00:00
parent 0886ba1f00
commit e49c91bed6
6 changed files with 506 additions and 0 deletions

View 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;
}

View 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
View 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
View 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
View 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,
},
];