src/state_machines/cosim.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import { runCosim, type CosimResult } from "./cosim";
type ToyState =
| { party: "sender"; phase: "idle" | "waiting" | "done" }
| { party: "receiver"; received: boolean };
type ToyEvent =
| { type: "start" }
| { type: "retry" }
| { type: "data"; attempt: 1 | 2 }
| { type: "ack"; attempt: 1 | 2 };
type ToyCommand =
| { type: "send-data"; attempt: 1 | 2 }
| { type: "send-ack"; attempt: 1 | 2 };
function runToyProtocol(fixed: boolean, maxSchedules = 10_000): CosimResult {
return runCosim<
"sender" | "receiver",
"data" | "ack",
ToyState,
ToyEvent,
ToyCommand
>({
participants: {
sender: {
initialState: { party: "sender", phase: "idle" },
stateKey: (state) =>
state.party === "sender" ? `sender:${state.phase}` : "not-sender",
eventKey: (event) =>
"attempt" in event ? `${event.type}:${event.attempt}` : event.type,
commandKey: (command) => `${command.type}:${command.attempt}`,
describeEvent: (event) =>
"attempt" in event ? `${event.type}#${event.attempt}` : event.type,
transition: (state, event) => {
if (state.party !== "sender") throw new Error("wrong sender state");
if (event.type === "start" && state.phase === "idle") {
return {
kind: "applied",
state: { party: "sender", phase: "waiting" },
commands: [{ type: "send-data", attempt: 1 }],
};
}
if (event.type === "retry" && state.phase === "waiting") {
return {
kind: "applied",
state,
commands: [{ type: "send-data", attempt: 2 }],
};
}
if (event.type === "ack" && state.phase === "waiting") {
return {
kind: "applied",
state: { party: "sender", phase: "done" },
commands: [],
};
}
return { kind: "applied", state, commands: [] };
},
},
receiver: {
initialState: { party: "receiver", received: false },
stateKey: (state) =>
state.party === "receiver"
? `receiver:${state.received}`
: "not-receiver",
eventKey: (event) =>
"attempt" in event ? `${event.type}:${event.attempt}` : event.type,
commandKey: (command) => `${command.type}:${command.attempt}`,
describeEvent: (event) =>
"attempt" in event ? `${event.type}#${event.attempt}` : event.type,
transition: (state, event) => {
if (state.party !== "receiver") {
throw new Error("wrong receiver state");
}
if (event.type !== "data")
return { kind: "applied", state, commands: [] };
if (!state.received) {
return {
kind: "applied",
state: { party: "receiver", received: true },
commands: [{ type: "send-ack", attempt: event.attempt }],
};
}
return fixed
? {
kind: "applied",
state,
commands: [{ type: "send-ack", attempt: event.attempt }],
}
: { kind: "ignored", state, reason: "duplicate-data" };
},
},
},
channels: {
data: {
recipient: "receiver",
eventKey: (event) =>
"attempt" in event ? `${event.type}:${event.attempt}` : event.type,
},
ack: {
recipient: "sender",
eventKey: (event) =>
"attempt" in event ? `${event.type}:${event.attempt}` : event.type,
},
},
scenario: {
actions: [
{
id: "start",
target: "participant",
participant: "sender",
event: { type: "start" },
},
{
id: "retry",
target: "participant",
participant: "sender",
event: { type: "retry" },
enabled: ({ participants }) =>
participants.sender.party === "sender" &&
participants.sender.phase === "waiting",
},
],
routeCommand: ({ command }) => {
if (command.type === "send-data") {
return [
{
target: "channel",
channel: "data",
event: { type: "data", attempt: command.attempt },
},
];
}
// Seed one deterministic network loss. A correct receiver acknowledges
// the retry too; the buggy receiver treats duplicate data as a no-op.
if (command.attempt === 1) return [];
return [
{
target: "channel",
channel: "ack",
event: { type: "ack", attempt: command.attempt },
},
];
},
},
assertions: {
perStep: ({ transitions }) => {
for (const transition of transitions) {
if (transition.ignored) {
expect(transition.result.state).toBe(transition.previousState);
}
}
},
atQuiescence: ({ participants }) => {
expect(participants.sender).toEqual({
party: "sender",
phase: "done",
});
},
},
maxSchedules,
});
}
const LOST_ACK_TRACE = [
'inject "start": send start to "sender"',
'inject "retry": send retry to "sender"',
'execute "sender" command: send-data:1 => enqueue data:1 on "data"',
'deliver "data" to "receiver": data:1',
'execute "sender" command: send-data:2 => enqueue data:2 on "data"',
'deliver "data" to "receiver": data:2 (ignored)',
'execute "receiver" command: send-ack:1 => no follow-up (dropped)',
];
describe("interleaving co-simulation", () => {
it("finds the minimal trace for a seeded lost-ack bug", () => {
const result = runToyProtocol(false);
expect(result.exhaustive).toBe(true);
expect(result.failure?.phase).toBe("quiescence");
expect(result.failure?.trace).toEqual(LOST_ACK_TRACE);
expect(result.failure?.formattedTrace).toContain(
"Co-simulation quiescence assertion failed",
);
expect(result.failure?.formattedTrace).toContain(
'6. deliver "data" to "receiver": data:2 (ignored)',
);
expect(result.failure?.formattedTrace).toContain(
'7. execute "receiver" command: send-ack:1 => no follow-up (dropped)',
);
});
it("passes the fixed protocol exhaustively with a stable schedule count", () => {
const result = runToyProtocol(true);
expect(result.failure).toBeUndefined();
expect(result.exhaustive).toBe(true);
expect(result.boundReached).toBe(false);
expect(result.schedulesExplored).toBe(16);
expect(result.quiescentSchedules).toBeGreaterThan(0);
});
it("renders deterministic failing traces", () => {
expect(runToyProtocol(false).failure?.formattedTrace).toBe(
runToyProtocol(false).failure?.formattedTrace,
);
});
it("respects and surfaces the maxSchedules bound", () => {
const result = runToyProtocol(true, 3);
expect(result.schedulesExplored).toBe(3);
expect(result.boundReached).toBe(true);
expect(result.exhaustive).toBe(false);
});
it("drains configurations admitted before reaching the bound", () => {
type State = "initial" | "after-first" | "failing" | "combined";
type Event = "first" | "fail";
const result = runCosim<"participant", never, State, Event, never>({
participants: {
participant: {
initialState: "initial",
stateKey: (state) => state,
eventKey: (event) => event,
commandKey: (command) => command,
transition: (state, event) => ({
kind: "applied",
state:
event === "first"
? "after-first"
: state === "after-first"
? "combined"
: "failing",
commands: [],
}),
},
},
channels: {},
scenario: {
actions: [
{
id: "first",
target: "participant",
participant: "participant",
event: "first",
enabled: ({ participants }) =>
participants.participant === "initial",
},
{
id: "fail",
target: "participant",
participant: "participant",
event: "fail",
},
],
routeCommand: () => [],
},
assertions: {
atQuiescence: ({ participants }) => {
expect(participants.participant).not.toBe("failing");
},
},
maxSchedules: 3,
});
expect(result.schedulesExplored).toBe(3);
expect(result.boundReached).toBe(true);
expect(result.failure?.phase).toBe("quiescence");
expect(result.failure?.trace).toEqual([
'inject "fail": send fail to "participant"',
]);
});
it("uses participant event keys when descriptions are omitted", () => {
const result = runCosim<
"participant",
never,
boolean,
{ type: "start" },
never
>({
participants: {
participant: {
initialState: false,
stateKey: String,
eventKey: (event) => event.type,
commandKey: (command) => command,
transition: () => ({ kind: "applied", state: true, commands: [] }),
},
},
channels: {},
scenario: {
actions: [
{
id: "start",
target: "participant",
participant: "participant",
event: { type: "start" },
},
],
routeCommand: () => [],
},
assertions: {
atQuiescence: () => {
throw new Error("capture trace");
},
},
});
expect(result.failure?.trace).toEqual([
'inject "start": send start to "participant"',
]);
});
it("freezes caller snapshots without narrowing generic values", () => {
class State {
readonly callback = () => this.value;
constructor(readonly value: number) {}
}
const initialState = new State(0);
const result = runCosim<"participant", never, State, "advance", "finish">({
participants: {
participant: {
initialState,
stateKey: (state) => String(state.value),
eventKey: (event) => event,
commandKey: (command) => command,
transition: (state) => ({
kind: "applied",
state: new State(state.value + 1),
commands: ["finish"],
}),
},
},
channels: {},
scenario: {
actions: [
{
id: "advance",
target: "participant",
participant: "participant",
event: "advance",
enabled: (snapshot) => {
expect(snapshot.participants.participant).toBeInstanceOf(State);
expect(snapshot.participants.participant.callback()).toBe(0);
expect(() => {
(
snapshot.participants.participant as {
value: number;
}
).value = 99;
}).toThrow(TypeError);
return true;
},
},
],
routeCommand: (_source, snapshot) => {
expect(snapshot.participants.participant).toBeInstanceOf(State);
expect(() => {
(
snapshot.participants.participant as {
value: number;
}
).value = 99;
}).toThrow(TypeError);
return [];
},
},
assertions: {
perStep: (step) => {
expect(() => {
(
step.snapshot.participants.participant as {
value: number;
}
).value = 99;
}).toThrow(TypeError);
if (step.transitions[0]) {
expect(() => {
(
step.transitions[0].result.state as {
value: number;
}
).value = 99;
}).toThrow(TypeError);
}
},
atQuiescence: (snapshot) => {
expect(snapshot.participants.participant.value).toBe(1);
expect(snapshot.participants.participant).toBeInstanceOf(State);
expect(() => {
(
snapshot.participants.participant as {
value: number;
}
).value = 99;
}).toThrow(TypeError);
},
},
});
expect(result.failure).toBeUndefined();
expect(initialState.value).toBe(0);
expect(Object.isFrozen(initialState)).toBe(true);
});
it("reuses one frozen snapshot across action guards", () => {
const snapshots: object[] = [];
const result = runCosim<"participant", never, number, "noop", never>({
participants: {
participant: {
initialState: 0,
stateKey: String,
eventKey: (event) => event,
commandKey: (command) => command,
transition: (state) => ({
kind: "applied",
state,
commands: [],
}),
},
},
channels: {},
scenario: {
actions: ["first", "second"].map((id) => ({
id,
target: "participant" as const,
participant: "participant" as const,
event: "noop" as const,
enabled: (snapshot: object) => {
snapshots.push(snapshot);
return false;
},
})),
routeCommand: () => [],
},
});
expect(result.failure).toBeUndefined();
expect(snapshots).toHaveLength(2);
expect(snapshots[1]).toBe(snapshots[0]);
});
it("does not re-traverse shared state after deeply freezing it", () => {
let ownKeysCalls = 0;
let callsAfterFirstSnapshot = 0;
const state = new Proxy(
{ value: 0 },
{
ownKeys: (target) => {
ownKeysCalls++;
return Reflect.ownKeys(target);
},
},
);
const result = runCosim<
"participant",
never,
{ value: number },
"noop",
never
>({
participants: {
participant: {
initialState: state,
stateKey: ({ value }) => String(value),
eventKey: (event) => event,
commandKey: (command) => command,
transition: (current) => ({
kind: "applied",
state: current,
commands: [],
}),
},
},
channels: {},
scenario: {
actions: [
{
id: "noop",
target: "participant",
participant: "participant",
event: "noop",
enabled: () => {
callsAfterFirstSnapshot = ownKeysCalls;
return true;
},
},
],
routeCommand: () => [],
},
});
expect(result.failure).toBeUndefined();
expect(callsAfterFirstSnapshot).toBeGreaterThan(0);
expect(ownKeysCalls).toBe(callsAfterFirstSnapshot);
});
it("rejects a transition result without state before exploring it", () => {
const result = runCosim<"participant", never, number, "advance", never>({
participants: {
participant: {
initialState: 0,
stateKey: String,
eventKey: (event) => event,
commandKey: (command) => command,
transition: () => ({ kind: "applied", commands: [] }) as never,
},
},
channels: {},
scenario: {
actions: [
{
id: "advance",
target: "participant",
participant: "participant",
event: "advance",
},
],
routeCommand: () => [],
},
});
expect(result.failure).toMatchObject({
phase: "driver",
trace: [],
});
expect(result.failure?.message).toContain("invalid transition result");
});
});