UNPKG

yay-machine

Version:

A modern, simple, lightweight, zero-dependency, TypeScript state-machine library

310 lines (278 loc) 10.9 kB
import { expect, mock, test } from "bun:test"; import type { MachineOnStartSideEffectFunction, MachineOnStopSideEffectFunction, StateLifecycleSideEffectFunction, } from "../MachineDefinitionConfig"; import { defineMachine } from "../defineMachine"; interface EffectsStateData { readonly onStart: MachineOnStartSideEffectFunction< EffectsState, EffectsEvent >; readonly onStop: MachineOnStopSideEffectFunction<EffectsState>; readonly onEnterA: StateLifecycleSideEffectFunction< EffectsState, EffectsEvent >; readonly onExitA: StateLifecycleSideEffectFunction< EffectsState, EffectsEvent >; readonly onEnterB: StateLifecycleSideEffectFunction< EffectsState, EffectsEvent >; readonly onExitB: StateLifecycleSideEffectFunction< EffectsState, EffectsEvent >; readonly onTransitionAToB: (param: any) => void; } type EffectsState = AState | BState; interface AState extends EffectsStateData { readonly name: "a"; readonly onlyExistsInA: true; } interface BState extends EffectsStateData { readonly name: "b"; readonly onlyExistsInB: true; } interface EffectsEvent { readonly type: "TO_A" | "TO_B"; } const effectMachine = defineMachine<EffectsState, EffectsEvent>({ initialState: { name: "a", onlyExistsInA: true, onStart: () => {}, onStop: () => {}, onEnterA: () => {}, onExitA: () => {}, onEnterB: () => {}, onExitB: () => {}, onTransitionAToB: () => {}, }, onStart: (param) => param.state.onStart(param), onStop: (param) => param.state.onStop(param), states: { a: { onEnter: (param) => param.state.onEnterA(param), onExit: (param) => param.state.onExitA(param), on: { TO_B: { to: "b", data: ({ state: { name, ...effects } }) => ({ ...effects, onlyExistsInB: true, }), onTransition: (param) => param.state.onTransitionAToB(param), }, }, }, b: { onEnter: (param) => param.state.onEnterB(param), onExit: (param) => param.state.onExitB(param), on: { TO_A: { to: "a", data: ({ state: { name, ...effects } }) => ({ ...effects, onlyExistsInA: true, }), }, }, }, }, }); const createInitialState = () => { const calls: string[] = []; const effectMock = (name: string) => { return mock(() => { calls.push(name); return mock(); }); }; return { calls, initialState: { name: "a", onlyExistsInA: true, onStart: effectMock("onStart"), onStop: effectMock("onStop"), onEnterA: effectMock("onEnterA"), onExitA: effectMock("onExitA"), onEnterB: effectMock("onEnterB"), onExitB: effectMock("onExitB"), onTransitionAToB: effectMock("onTransitionAToB"), }, } as const; }; test("machine onStart called when machine starts", () => { const { initialState } = createInitialState(); const machine = effectMachine.newInstance({ initialState }); expect(initialState.onStart).not.toHaveBeenCalled(); expect(initialState.onStop).not.toHaveBeenCalled(); expect(initialState.onEnterA).not.toHaveBeenCalled(); expect(initialState.onExitA).not.toHaveBeenCalled(); expect(initialState.onEnterB).not.toHaveBeenCalled(); expect(initialState.onExitB).not.toHaveBeenCalled(); expect(initialState.onTransitionAToB).not.toHaveBeenCalled(); machine.start(); expect(machine.state).toMatchObject({ name: "a" }); expect(initialState.onStart).toHaveBeenCalledTimes(1); }); test("machine onStart effect cleaned-up when machine stopped", () => { const { initialState } = createInitialState(); const machine = effectMachine.newInstance({ initialState }); machine.start(); expect(initialState.onStart).toHaveBeenCalledTimes(1); expect(machine.state).toMatchObject({ name: "a" }); machine.send({ type: "TO_B" }); expect(machine.state).toMatchObject({ name: "b" }); expect(initialState.onStart).toHaveBeenCalledTimes(1); // still expect(initialState.onStart.mock.results[0]?.value).not.toHaveBeenCalled(); machine.stop(); expect(initialState.onStart).toHaveBeenCalledTimes(1); // still expect(initialState.onStart.mock.results[0]?.value).toHaveBeenCalledTimes(1); }); test("machine onStop effect called and cleaned-up when machine stopped", () => { const { initialState } = createInitialState(); const machine = effectMachine.newInstance({ initialState }); machine.start(); expect(initialState.onStop).not.toHaveBeenCalled(); expect(machine.state).toMatchObject({ name: "a" }); machine.send({ type: "TO_B" }); expect(machine.state).toMatchObject({ name: "b" }); expect(initialState.onStop).not.toHaveBeenCalled(); // still machine.stop(); expect(initialState.onStop).toHaveBeenCalledTimes(1); expect(initialState.onStop.mock.results[0]?.value).toHaveBeenCalledTimes(1); }); test("state onEnter called for the initial state", () => { const { initialState } = createInitialState(); const machine = effectMachine.newInstance({ initialState }); machine.start(); expect(machine.state).toMatchObject({ name: "a" }); expect(initialState.onEnterA).toHaveBeenCalledTimes(1); expect(initialState.onEnterA.mock.results[0]?.value).not.toHaveBeenCalled(); machine.stop(); expect(initialState.onEnterA).toHaveBeenCalledTimes(1); // still expect(initialState.onEnterA.mock.results[0]?.value).toHaveBeenCalledTimes(1); expect(initialState.onExitA).toHaveBeenCalledTimes(1); expect(initialState.onExitA.mock.results[0]?.value).toHaveBeenCalledTimes(1); }); test("state onEnter, onExit and onTransition effects called and cleaned-up when transitioned", () => { const { initialState, calls } = createInitialState(); const machine = effectMachine.newInstance({ initialState }); machine.start(); expect(machine.state).toMatchObject({ name: "a" }); expect(initialState.onEnterA).toHaveBeenCalledTimes(1); expect(initialState.onEnterA.mock.results[0]?.value).not.toHaveBeenCalled(); expect(initialState.onExitA).not.toHaveBeenCalled(); expect(initialState.onTransitionAToB).not.toHaveBeenCalled(); machine.send({ type: "TO_B" }); expect(machine.state).toMatchObject({ name: "b" }); expect(initialState.onEnterA).toHaveBeenCalledTimes(1); // still expect(initialState.onEnterA.mock.results[0]?.value).toHaveBeenCalledTimes(1); expect(initialState.onExitA).toHaveBeenCalledTimes(1); // still expect(initialState.onExitA.mock.results[0]?.value).toHaveBeenCalledTimes(1); expect(initialState.onTransitionAToB).toHaveBeenCalledTimes(1); expect( initialState.onTransitionAToB.mock.results[0]?.value, ).toHaveBeenCalledTimes(1); expect(initialState.onEnterB).toHaveBeenCalledTimes(1); // still expect(initialState.onEnterB.mock.results[0]?.value).not.toHaveBeenCalled(); expect(initialState.onExitB).not.toHaveBeenCalled(); machine.send({ type: "TO_A" }); expect(machine.state).toMatchObject({ name: "a" }); expect(initialState.onEnterB).toHaveBeenCalledTimes(1); // still expect(initialState.onEnterB.mock.results[0]?.value).toHaveBeenCalledTimes(1); expect(initialState.onExitB).toHaveBeenCalledTimes(1); // still expect(initialState.onExitB.mock.results[0]?.value).toHaveBeenCalledTimes(1); expect(initialState.onTransitionAToB).toHaveBeenCalledTimes(1); // still expect( initialState.onTransitionAToB.mock.results[0]?.value, ).toHaveBeenCalledTimes(1); // still expect(initialState.onEnterA).toHaveBeenCalledTimes(2); expect(initialState.onEnterA.mock.results[1]?.value).not.toHaveBeenCalled(); expect(initialState.onExitA).toHaveBeenCalledTimes(1); // still expect(initialState.onExitA.mock.results[0]?.value).toHaveBeenCalledTimes(1); machine.send({ type: "TO_B" }); expect(machine.state).toMatchObject({ name: "b" }); expect(initialState.onEnterA).toHaveBeenCalledTimes(2); expect(initialState.onEnterA.mock.results[1]?.value).toHaveBeenCalledTimes(1); expect(initialState.onExitA).toHaveBeenCalledTimes(2); expect(initialState.onExitA.mock.results[1]?.value).toHaveBeenCalledTimes(1); expect(initialState.onTransitionAToB).toHaveBeenCalledTimes(2); expect( initialState.onTransitionAToB.mock.results[1]?.value, ).toHaveBeenCalledTimes(1); expect(initialState.onEnterB).toHaveBeenCalledTimes(2); expect(initialState.onEnterB.mock.results[1]?.value).not.toHaveBeenCalled(); expect(initialState.onExitB).toHaveBeenCalledTimes(1); // still machine.stop(); expect(initialState.onEnterA).toHaveBeenCalledTimes(2); expect(initialState.onEnterA.mock.results[1]?.value).toHaveBeenCalledTimes(1); expect(initialState.onExitA).toHaveBeenCalledTimes(2); expect(initialState.onExitA.mock.results[1]?.value).toHaveBeenCalledTimes(1); expect(initialState.onEnterB).toHaveBeenCalledTimes(2); expect(initialState.onEnterB.mock.results[1]?.value).toHaveBeenCalledTimes(1); expect(initialState.onExitB).toHaveBeenCalledTimes(2); expect(initialState.onExitB.mock.results[1]?.value).toHaveBeenCalledTimes(1); expect(calls).toMatchInlineSnapshot(` [ "onStart", "onEnterA", "onExitA", "onTransitionAToB", "onEnterB", "onExitB", "onEnterA", "onExitA", "onTransitionAToB", "onEnterB", "onExitB", "onStop", ] `); }); test("effect functions receive current state and send function plus next state when transitioning", () => { const { initialState } = createInitialState(); const machine = effectMachine.newInstance({ initialState }); machine.start(); expect(initialState.onStart).toHaveBeenCalledWith({ state: expect.objectContaining({ name: "a", onlyExistsInA: true }), send: expect.any(Function), }); expect(initialState.onEnterA).toHaveBeenCalledWith({ state: expect.objectContaining({ name: "a", onlyExistsInA: true }), send: expect.any(Function), }); machine.send({ type: "TO_B" }); expect(initialState.onExitA).toHaveBeenCalledWith({ state: expect.objectContaining({ name: "a", onlyExistsInA: true }), event: { type: "TO_B" }, send: expect.any(Function), }); expect(initialState.onTransitionAToB).toHaveBeenCalledWith({ state: expect.objectContaining({ name: "a", onlyExistsInA: true }), send: expect.any(Function), event: { type: "TO_B" }, next: expect.objectContaining({ name: "b", onlyExistsInB: true }), }); expect(initialState.onEnterB).toHaveBeenCalledWith({ state: expect.objectContaining({ name: "b", onlyExistsInB: true }), event: { type: "TO_B" }, send: expect.any(Function), }); machine.stop(); expect(initialState.onExitB).toHaveBeenCalledWith({ state: expect.objectContaining({ name: "b", onlyExistsInB: true }), send: expect.any(Function), }); expect(initialState.onStop).toHaveBeenCalledWith({ state: expect.objectContaining({ name: "b", onlyExistsInB: true }), send: expect.any(Function), }); });