Ctrl K
ComunidadGuías…

src/state_machines/testing.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, expect, it } from "vitest";
import {
  assertAllCommandsProducible,
  assertAllStatesReachable,
  assertCapabilityTransitionConsistency,
  assertReferenceStability,
  commandsOf,
  createRecordingCommandRunner,
  driveTransitionMatrix,
  exploreReachableStates,
  ignoreReasonOf,
} from "./testing";
import { change, ignore, stay, type TransitionResult } from "./types";

describe("state-machine test kit", () => {
  it("constructs ignored, changed, and command-only applied results", () => {
    const state = { value: 1 };
    expect(ignore(state, "no-change")).toEqual({
      kind: "ignored",
      state,
      reason: "no-change",
    });
    expect(change({ value: 2 })).toEqual({
      kind: "applied",
      state: { value: 2 },
      commands: [],
    });
    expect(stay(state, ["refresh"])).toEqual({
      kind: "applied",
      state,
      commands: ["refresh"],
    });
  });

  it("makes commands on ignored results a compile error", () => {
    const invalid: TransitionResult<number, string> = {
      kind: "ignored",
      state: 0,
      reason: "no-change",
      // @ts-expect-error ignored results cannot carry commands
      commands: ["invalid"],
    };
    expect(invalid).toBeDefined();
  });

  it("drives every state and event pair", () => {
    const results = driveTransitionMatrix<number, string, never>({
      states: [0, 1],
      events: ["a", "b"],
      transition: (state) => ignore(state, "test-ignore"),
    });
    expect(results).toHaveLength(4);
  });

  it("checks enabled, disabled, and payload-dependent capability cases", () => {
    type State = { phase: "idle" | "busy"; selected: string };
    type Event = { type: "select"; value: string };
    const states: State[] = [
      { phase: "idle", selected: "current" },
      { phase: "busy", selected: "current" },
    ];

    expect(() =>
      assertCapabilityTransitionConsistency({
        states,
        selectCapabilities: (state) => ({
          canSelect: state.phase === "idle",
        }),
        transition: (state: State, event: Event) =>
          state.phase === "busy"
            ? ignore(state, "busy")
            : event.value === state.selected
              ? ignore(state, "no-change")
              : change({ ...state, selected: event.value }),
        cases: {
          canSelect: {
            representativeEvents: (state) => ({
              valid: [{ type: "select", value: "next" } satisfies Event],
              invalid:
                state.phase === "idle"
                  ? [
                      {
                        event: {
                          type: "select",
                          value: state.selected,
                        } satisfies Event,
                        reason: "no-change" as const,
                      },
                    ]
                  : [],
            }),
            disabledReason: "busy",
          },
        },
      }),
    ).not.toThrow();
  });

  it("rejects enabled capabilities without a representative valid event", () => {
    expect(() =>
      assertCapabilityTransitionConsistency({
        states: [{ phase: "idle" as const }],
        selectCapabilities: () => ({ canSelect: true }),
        transition: (state) => ignore(state, "no-change"),
        cases: {
          canSelect: {
            representativeEvents: () => ({ valid: [] }),
          },
        },
      }),
    ).toThrow(/canSelect[\s\S]*at least one representative valid event/);
  });

  it("rejects enabled capabilities whose representative event is ignored", () => {
    expect(() =>
      assertCapabilityTransitionConsistency({
        states: [{ phase: "idle" as const }],
        selectCapabilities: () => ({ canSelect: true }),
        transition: (state) => ignore(state, "no-change"),
        cases: {
          canSelect: {
            representativeEvents: () => ({
              valid: [{ type: "select" as const }],
            }),
          },
        },
      }),
    ).toThrow(/canSelect[\s\S]*must apply/);
  });

  it("rejects value-equal snapshots with new references", () => {
    const previous = { value: 1 };
    const result: TransitionResult<typeof previous, never> = {
      kind: "applied",
      state: { value: 1 },
      commands: [],
    };
    expect(() =>
      assertReferenceStability(
        previous,
        result,
        (left, right) => left.value === right.value,
      ),
    ).toThrow(/value-equal/);
  });

  it("validates every matrix result with useful transition context", () => {
    expect(() =>
      driveTransitionMatrix({
        states: [{ value: 1 }],
        events: ["repeat"],
        transition: (state) =>
          ({
            kind: "ignored",
            state,
            reason: "no-change",
            commands: ["invalid"],
          }) as unknown as TransitionResult<typeof state, string, "no-change">,
      }),
    ).toThrow(
      /Ignored transitions must not emit commands[\s\S]*Source state[\s\S]*repeat[\s\S]*Explored path/,
    );
  });

  it("records commands and emitted events", async () => {
    const runner = createRecordingCommandRunner<string, number>(
      (_command, emit) => emit(3),
    );
    const emitted: number[] = [];
    await runner.run("run", (event) => emitted.push(event));
    expect(runner.commands).toEqual(["run"]);
    expect(runner.events).toEqual([3]);
    expect(emitted).toEqual([3]);
  });

  it("returns reachable nodes, edges, paths, and predecessors", () => {
    const graph = exploreReachableStates<number, "increment" | "reset", never>({
      initialState: 0,
      events: ["increment", "reset"],
      transition: (state, event) => ({
        kind: "applied",
        state: event === "reset" ? 0 : Math.min(state + 1, 2),
        commands: [],
      }),
      stateKey: String,
    });

    expect(graph.nodes.map(({ state }) => state)).toEqual([0, 1, 2]);
    expect(graph.edges).toHaveLength(6);
    expect(graph.nodes[2].path).toEqual(["increment", "increment"]);
    expect(graph.predecessors.get("2")?.source.state).toBe(1);
  });

  it("reports exploration validation failures with the explored path", () => {
    expect(() =>
      exploreReachableStates({
        initialState: 0,
        events: ["advance"],
        transition: (state) =>
          state === 0
            ? change(1)
            : ({
                kind: "ignored",
                state: 0,
                reason: "bad-reference",
              } as TransitionResult<number, never, "bad-reference">),
        stateKey: String,
      }),
    ).toThrow(/exact state reference[\s\S]*advance[\s\S]*Explored path/);
  });

  it("asserts all inventoried state kinds are reachable", () => {
    const options = {
      initialState: "idle" as "idle" | "running",
      events: ["start"] as const,
      transition: (state: "idle" | "running") =>
        state === "idle" ? change("running" as const) : ignore(state, "busy"),
      stateKey: String,
      stateKind: (state: "idle" | "running") => state,
      inventory: ["idle", "running"] as const,
    };
    expect(assertAllStatesReachable(options).nodes).toHaveLength(2);
    expect(() =>
      assertAllStatesReachable({
        ...options,
        inventory: ["idle", "running", "dead"] as const,
      }),
    ).toThrow(/State "dead"/);
    expect(() =>
      assertAllStatesReachable({
        ...options,
        inventory: ["idle", "running", "dead"] as const,
        exclusions: [{ kind: "dead" as const, reason: "reserved protocol" }],
      }),
    ).not.toThrow();
  });

  it("asserts all inventoried command kinds are producible", () => {
    const options = {
      initialState: "idle",
      events: ["start"] as const,
      transition: () => stay("idle", ["launch"] as const),
      stateKey: String,
      commandKind: (command: "launch") => command,
      inventory: ["launch"] as const,
    };
    expect(assertAllCommandsProducible(options).edges).toHaveLength(1);
    expect(() =>
      assertAllCommandsProducible({
        ...options,
        inventory: ["launch", "dead"] as const,
      }),
    ).toThrow(/Command "dead"/);
    expect(() =>
      assertAllCommandsProducible({
        ...options,
        inventory: ["launch", "dead"] as const,
        exclusions: [{ kind: "dead" as const, reason: "reserved protocol" }],
      }),
    ).not.toThrow();
  });

  it("projects commands and reasons from discriminated results", () => {
    expect(commandsOf(stay(0, ["refresh"]))).toEqual(["refresh"]);
    expect(commandsOf(ignore(0, "idle"))).toEqual([]);
    expect(ignoreReasonOf(ignore(0, "idle"))).toBe("idle");
    expect(ignoreReasonOf(change(1))).toBeUndefined();
  });

  it("bounds accidental infinite state spaces", () => {
    expect(() =>
      exploreReachableStates<number, "increment", never>({
        initialState: 0,
        events: ["increment"],
        transition: (state) => ({
          kind: "applied",
          state: state + 1,
          commands: [],
        }),
        stateKey: String,
        maxStates: 3,
      }),
    ).toThrow(/maxStates/);
  });
});