361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
/**
|
|
* tests/unit/ui/comboFlowModel.test.ts
|
|
*
|
|
* TDD for `comboFlowModel` — the pure reducer/model core of Tela B.
|
|
* Run: node --import tsx/esm --test tests/unit/ui/comboFlowModel.test.ts
|
|
*/
|
|
|
|
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
classifyFailKind,
|
|
reduceComboEvent,
|
|
comboRunToFlow,
|
|
type ComboRunModel,
|
|
type ComboEventInput,
|
|
} from "../../../src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts";
|
|
|
|
import { FLOW_EDGE_COLORS } from "../../../src/shared/components/flow/edgeStyles.ts";
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────
|
|
|
|
function mkAttempt(
|
|
targetIndex: number,
|
|
strategy = "priority",
|
|
provider = `prov${targetIndex}`,
|
|
model = `model${targetIndex}`
|
|
): ComboEventInput {
|
|
return {
|
|
comboName: "test-combo",
|
|
targetIndex,
|
|
provider,
|
|
model,
|
|
type: "attempt",
|
|
strategy,
|
|
timestamp: 1000 + targetIndex,
|
|
};
|
|
}
|
|
|
|
function mkFailed(
|
|
targetIndex: number,
|
|
error: string,
|
|
provider = `prov${targetIndex}`,
|
|
model = `model${targetIndex}`
|
|
): ComboEventInput {
|
|
return {
|
|
comboName: "test-combo",
|
|
targetIndex,
|
|
provider,
|
|
model,
|
|
type: "failed",
|
|
error,
|
|
latencyMs: 100,
|
|
timestamp: 2000 + targetIndex,
|
|
};
|
|
}
|
|
|
|
function mkSucceeded(
|
|
targetIndex: number,
|
|
provider = `prov${targetIndex}`,
|
|
model = `model${targetIndex}`
|
|
): ComboEventInput {
|
|
return {
|
|
comboName: "test-combo",
|
|
targetIndex,
|
|
provider,
|
|
model,
|
|
type: "succeeded",
|
|
latencyMs: 42,
|
|
timestamp: 3000 + targetIndex,
|
|
};
|
|
}
|
|
|
|
// Full 6-event cascade:
|
|
// attempt(0) → failed(0,"429 rate limited") → attempt(1) → failed(1,"circuit open") → attempt(2) → succeeded(2)
|
|
function buildFullRun(): ComboRunModel {
|
|
let run: ComboRunModel | null = null;
|
|
run = reduceComboEvent(run, mkAttempt(0));
|
|
run = reduceComboEvent(run, mkFailed(0, "429 rate limited"));
|
|
run = reduceComboEvent(run, mkAttempt(1));
|
|
run = reduceComboEvent(run, mkFailed(1, "circuit open"));
|
|
run = reduceComboEvent(run, mkAttempt(2));
|
|
run = reduceComboEvent(run, mkSucceeded(2));
|
|
return run;
|
|
}
|
|
|
|
// ── classifyFailKind ──────────────────────────────────────────────────────
|
|
|
|
describe("classifyFailKind", () => {
|
|
it("returns undefined when error is absent", () => {
|
|
assert.equal(classifyFailKind(undefined), undefined);
|
|
assert.equal(classifyFailKind(""), undefined);
|
|
});
|
|
|
|
it("returns 'rate-limit' for 429 messages", () => {
|
|
assert.equal(classifyFailKind("429 Too Many Requests"), "rate-limit");
|
|
assert.equal(classifyFailKind("upstream rate limit exceeded"), "rate-limit");
|
|
assert.equal(classifyFailKind("RATE_LIMIT_EXCEEDED"), "rate-limit");
|
|
});
|
|
|
|
it("returns 'circuit-open' for circuit breaker messages", () => {
|
|
assert.equal(classifyFailKind("circuit open"), "circuit-open");
|
|
assert.equal(classifyFailKind("Provider circuit breaker is open"), "circuit-open");
|
|
assert.equal(classifyFailKind("CIRCUIT_OPEN"), "circuit-open");
|
|
});
|
|
|
|
it("returns 'cooldown' for cooldown messages", () => {
|
|
assert.equal(classifyFailKind("connection cooldown active"), "cooldown");
|
|
assert.equal(classifyFailKind("cooldown period"), "cooldown");
|
|
});
|
|
|
|
it("returns 'other' for unrecognized error strings", () => {
|
|
assert.equal(classifyFailKind("Internal server error"), "other");
|
|
assert.equal(classifyFailKind("timeout"), "other");
|
|
assert.equal(classifyFailKind("some unknown failure"), "other");
|
|
});
|
|
|
|
it("circuit-open takes precedence over rate-limit when both match", () => {
|
|
// edge: a message with both circuit and rate — circuit wins by regex ordering
|
|
const result = classifyFailKind("circuit open after 429");
|
|
assert.equal(result, "circuit-open");
|
|
});
|
|
});
|
|
|
|
// ── reduceComboEvent ──────────────────────────────────────────────────────
|
|
|
|
describe("reduceComboEvent — basic events", () => {
|
|
it("creates a new run on first attempt event (null input)", () => {
|
|
const run = reduceComboEvent(null, mkAttempt(0));
|
|
|
|
assert.equal(run.comboName, "test-combo");
|
|
assert.equal(run.strategy, "priority");
|
|
assert.equal(run.outcome, "running");
|
|
assert.ok(run.startedAt > 0);
|
|
assert.equal(run.finishedAt, undefined);
|
|
assert.equal(run.targets.length, 1);
|
|
assert.equal(run.targets[0].targetIndex, 0);
|
|
assert.equal(run.targets[0].provider, "prov0");
|
|
assert.equal(run.targets[0].model, "model0");
|
|
assert.equal(run.targets[0].state, "attempting");
|
|
});
|
|
|
|
it("marks target as failed and sets failKind on failed event", () => {
|
|
let run = reduceComboEvent(null, mkAttempt(0));
|
|
run = reduceComboEvent(run, mkFailed(0, "429 rate limited"));
|
|
|
|
assert.equal(run.targets[0].state, "failed");
|
|
assert.equal(run.targets[0].failKind, "rate-limit");
|
|
assert.equal(run.targets[0].error, "429 rate limited");
|
|
assert.equal(run.outcome, "running"); // not done yet
|
|
});
|
|
|
|
it("marks target as succeeded and sets outcome + finishedAt on succeeded event", () => {
|
|
let run = reduceComboEvent(null, mkAttempt(0));
|
|
run = reduceComboEvent(run, mkSucceeded(0));
|
|
|
|
assert.equal(run.targets[0].state, "succeeded");
|
|
assert.equal(run.targets[0].latencyMs, 42);
|
|
assert.equal(run.outcome, "succeeded");
|
|
assert.ok(run.finishedAt != null);
|
|
});
|
|
});
|
|
|
|
describe("reduceComboEvent — full cascade (3 targets)", () => {
|
|
it("produces 3 targets ordered by targetIndex", () => {
|
|
const run = buildFullRun();
|
|
|
|
assert.equal(run.targets.length, 3);
|
|
assert.equal(run.targets[0].targetIndex, 0);
|
|
assert.equal(run.targets[1].targetIndex, 1);
|
|
assert.equal(run.targets[2].targetIndex, 2);
|
|
});
|
|
|
|
it("states are [failed, failed, succeeded]", () => {
|
|
const run = buildFullRun();
|
|
|
|
assert.equal(run.targets[0].state, "failed");
|
|
assert.equal(run.targets[1].state, "failed");
|
|
assert.equal(run.targets[2].state, "succeeded");
|
|
});
|
|
|
|
it("failKinds are [rate-limit, circuit-open, undefined]", () => {
|
|
const run = buildFullRun();
|
|
|
|
assert.equal(run.targets[0].failKind, "rate-limit");
|
|
assert.equal(run.targets[1].failKind, "circuit-open");
|
|
assert.equal(run.targets[2].failKind, undefined);
|
|
});
|
|
|
|
it("outcome is 'succeeded' and finishedAt is set", () => {
|
|
const run = buildFullRun();
|
|
|
|
assert.equal(run.outcome, "succeeded");
|
|
assert.ok(run.finishedAt != null);
|
|
});
|
|
|
|
it("strategy is set from the attempt payload", () => {
|
|
const run = buildFullRun();
|
|
|
|
assert.equal(run.strategy, "priority");
|
|
});
|
|
});
|
|
|
|
describe("reduceComboEvent — ordering", () => {
|
|
it("keeps targets sorted by targetIndex even if events arrive out of order", () => {
|
|
// Unusual but defensive: attempt(2) then attempt(0)
|
|
let run = reduceComboEvent(null, mkAttempt(2));
|
|
run = reduceComboEvent(run, mkAttempt(0));
|
|
|
|
assert.equal(run.targets[0].targetIndex, 0);
|
|
assert.equal(run.targets[1].targetIndex, 2);
|
|
});
|
|
|
|
it("idempotently applies a repeated attempt for the same target", () => {
|
|
let run = reduceComboEvent(null, mkAttempt(0));
|
|
run = reduceComboEvent(run, mkAttempt(0)); // duplicate
|
|
|
|
assert.equal(run.targets.length, 1);
|
|
assert.equal(run.targets[0].state, "attempting");
|
|
});
|
|
});
|
|
|
|
describe("reduceComboEvent — comboName key", () => {
|
|
it("ignores events for a different comboName when run already exists", () => {
|
|
let run = reduceComboEvent(null, mkAttempt(0));
|
|
|
|
// Event for a different combo — should be ignored, run returned unchanged
|
|
const alienEvent: ComboEventInput = {
|
|
comboName: "other-combo",
|
|
targetIndex: 99,
|
|
provider: "alien",
|
|
model: "alien",
|
|
type: "failed",
|
|
error: "some error",
|
|
timestamp: 9999,
|
|
};
|
|
run = reduceComboEvent(run, alienEvent);
|
|
|
|
assert.equal(run.targets.length, 1);
|
|
assert.equal(run.comboName, "test-combo");
|
|
});
|
|
});
|
|
|
|
// ── comboRunToFlow ────────────────────────────────────────────────────────
|
|
|
|
describe("comboRunToFlow", () => {
|
|
it("returns N+3 nodes for N targets (request + strategy + N targets + response)", () => {
|
|
const run = buildFullRun();
|
|
const { nodes } = comboRunToFlow(run);
|
|
|
|
// 3 targets → 1 request + 1 strategy + 3 targets + 1 response = 6 nodes
|
|
assert.equal(nodes.length, 6, `expected 6 nodes, got ${nodes.length}`);
|
|
});
|
|
|
|
it("node types: first=request, second=strategy, N middle=target, last=response", () => {
|
|
const run = buildFullRun();
|
|
const { nodes } = comboRunToFlow(run);
|
|
|
|
assert.equal(nodes[0].type, "request");
|
|
assert.equal(nodes[1].type, "strategy");
|
|
assert.equal(nodes[2].type, "target");
|
|
assert.equal(nodes[3].type, "target");
|
|
assert.equal(nodes[4].type, "target");
|
|
assert.equal(nodes[5].type, "response");
|
|
});
|
|
|
|
it("target nodes carry provider, model, state, failKind in data", () => {
|
|
const run = buildFullRun();
|
|
const { nodes } = comboRunToFlow(run);
|
|
|
|
const t0 = nodes[2];
|
|
assert.equal((t0.data as Record<string, unknown>).provider, "prov0");
|
|
assert.equal((t0.data as Record<string, unknown>).model, "model0");
|
|
assert.equal((t0.data as Record<string, unknown>).state, "failed");
|
|
assert.equal((t0.data as Record<string, unknown>).failKind, "rate-limit");
|
|
|
|
const t2 = nodes[4];
|
|
assert.equal((t2.data as Record<string, unknown>).state, "succeeded");
|
|
assert.equal((t2.data as Record<string, unknown>).failKind, undefined);
|
|
});
|
|
|
|
it("strategy node carries the strategy name in data", () => {
|
|
const run = buildFullRun();
|
|
const { nodes } = comboRunToFlow(run);
|
|
|
|
assert.equal((nodes[1].data as Record<string, unknown>).strategy, "priority");
|
|
assert.equal((nodes[1].data as Record<string, unknown>).targetCount, 3);
|
|
});
|
|
|
|
it("edges: N+3 sequential edges (one per node-to-node link)", () => {
|
|
const run = buildFullRun();
|
|
const { edges } = comboRunToFlow(run);
|
|
|
|
// 6 nodes → 5 edges: request→strategy, strategy→t0, t0→t1, t1→t2, t2→response
|
|
assert.equal(edges.length, 5, `expected 5 edges, got ${edges.length}`);
|
|
});
|
|
|
|
it("each edge connects consecutive nodes in order", () => {
|
|
const run = buildFullRun();
|
|
const { nodes, edges } = comboRunToFlow(run);
|
|
|
|
for (let i = 0; i < edges.length; i++) {
|
|
assert.equal(edges[i].source, nodes[i].id, `edge[${i}].source mismatch`);
|
|
assert.equal(edges[i].target, nodes[i + 1].id, `edge[${i}].target mismatch`);
|
|
}
|
|
});
|
|
|
|
it("failed edges are styled with error color", () => {
|
|
const run = buildFullRun();
|
|
const { edges } = comboRunToFlow(run);
|
|
|
|
// edge[2] is strategy→t0 (failed)… actually edges are: [0]req→strat, [1]strat→t0, [2]t0→t1, [3]t1→t2, [4]t2→resp
|
|
// edge for t0 (failed): index 1 (strategy→t0)
|
|
// The edge going INTO t0 should reflect t0 state
|
|
const t0IncomingEdge = edges[1]; // strategy→target0
|
|
const style = t0IncomingEdge.style as Record<string, unknown> | undefined;
|
|
assert.ok(style != null, "edge should have style");
|
|
assert.equal(style.stroke, FLOW_EDGE_COLORS.error, "failed target edge should be error color");
|
|
});
|
|
|
|
it("succeeded edge is styled with active/green color", () => {
|
|
const run = buildFullRun();
|
|
const { edges } = comboRunToFlow(run);
|
|
|
|
// edge[4] is t2→response (t2 succeeded)
|
|
const t2ResponseEdge = edges[4];
|
|
const style = t2ResponseEdge.style as Record<string, unknown> | undefined;
|
|
assert.ok(style != null);
|
|
assert.equal(
|
|
style.stroke,
|
|
FLOW_EDGE_COLORS.active,
|
|
"succeeded target edge should be active/green color"
|
|
);
|
|
});
|
|
|
|
it("idle/attempting edges are styled with idle or last-used color (not error/green)", () => {
|
|
// A run with one target still attempting
|
|
let run = reduceComboEvent(null, mkAttempt(0));
|
|
const { edges } = comboRunToFlow(run);
|
|
|
|
// edge[1] = strategy→t0 (attempting)
|
|
const attemptingEdge = edges[1];
|
|
const style = attemptingEdge.style as Record<string, unknown> | undefined;
|
|
assert.ok(style != null);
|
|
assert.notEqual(style.stroke, FLOW_EDGE_COLORS.error);
|
|
assert.notEqual(style.stroke, FLOW_EDGE_COLORS.active);
|
|
});
|
|
|
|
it("produces deterministic node IDs", () => {
|
|
const run = buildFullRun();
|
|
const { nodes } = comboRunToFlow(run);
|
|
|
|
assert.equal(nodes[0].id, "request");
|
|
assert.equal(nodes[1].id, "strategy");
|
|
assert.equal(nodes[2].id, "target-0");
|
|
assert.equal(nodes[3].id, "target-1");
|
|
assert.equal(nodes[4].id, "target-2");
|
|
assert.equal(nodes[5].id, "response");
|
|
});
|
|
});
|