yay-machine
Version:
A modern, simple, lightweight, zero-dependency, TypeScript state-machine library
572 lines (543 loc) • 14.5 kB
text/typescript
import { expect, test } from "bun:test";
import type { SendFunction } from "../MachineDefinitionConfig";
import type { MachineInstance } from "../MachineInstance";
import { defineMachine } from "../defineMachine";
test("when sending an event to the machine in onStart, the event is only handled after the machine has evaluated the initial state first", () => {
const machine = defineMachine<{ name: "a" | "b" | "c" }, { type: "NEXT" }>({
initialState: { name: "a" },
states: {
a: {
always: { to: "b" },
on: {
NEXT: { to: "c" },
},
},
},
onStart({ send }) {
send({ type: "NEXT" });
},
})
.newInstance()
.start();
expect(machine.state).toEqual({ name: "b" });
});
test("when sending an event to the machine in onStart cleanup, the event is only handled after the machine has evaluated the initial state first", () => {
const machine = defineMachine<{ name: "a" | "b" | "c" }, { type: "NEXT" }>({
initialState: { name: "a" },
states: {
a: {
always: { to: "b" },
on: {
NEXT: { to: "c" },
},
},
},
onStart({ send }) {
return () => {
send({ type: "NEXT" });
};
},
})
.newInstance()
.start();
expect(machine.state).toEqual({ name: "b" });
machine.stop();
expect(machine.state).toEqual({ name: "b" }); // still
});
test("when sending an event to the machine in onStart, the event is only handled after the machine has evaluated the initial state first (2)", () => {
const machine = defineMachine<{ name: "a" | "b" | "c" }, { type: "NEXT" }>({
initialState: { name: "a" },
states: {
a: {
always: { to: "b" },
},
b: {
on: {
NEXT: { to: "c" },
},
},
},
onStart({ send }) {
send({ type: "NEXT" });
},
})
.newInstance()
.start();
expect(machine.state).toEqual({ name: "c" });
});
test("when sending an event to the machine in onStart cleanup, the event is only handled after the machine has evaluated the initial state first (2)", () => {
const d = defineMachine<{ name: "a" | "b" | "c" }, { type: "NEXT" }>({
initialState: { name: "a" },
states: {
a: {
always: { to: "b" },
},
b: {
on: {
NEXT: { to: "c" },
},
},
},
onStart({ send }) {
return () => {
send({ type: "NEXT" });
};
},
}).newInstance();
const machine = d.start();
expect(machine.state).toEqual({ name: "b" });
machine.stop();
expect(machine.state).toEqual({ name: "b" }); // still
});
test("when sending an event to the machine in the initial-state's onEnter, the event is only handled after the machine has evaluated the initial state first", () => {
const machine = defineMachine<{ name: "a" | "b" | "c" }, { type: "NEXT" }>({
initialState: { name: "a" },
states: {
a: {
always: { to: "b" },
on: {
NEXT: { to: "c" },
},
onEnter({ send }) {
send({ type: "NEXT" });
},
},
},
})
.newInstance()
.start();
expect(machine.state).toEqual({ name: "b" });
});
test("when sending an event to the machine in the initial-state's onEnter, the event is only handled after the machine has evaluated the initial state first (2)", () => {
const machine = defineMachine<{ name: "a" | "b" | "c" }, { type: "NEXT" }>({
initialState: { name: "a" },
states: {
a: {
always: { to: "b" },
onEnter({ send }) {
send({ type: "NEXT" });
},
},
b: {
on: {
NEXT: { to: "c" },
},
},
},
})
.newInstance()
.start();
expect(machine.state).toEqual({ name: "c" });
});
test("when sending an event to the machine in the initial-state's onEnter cleanup, the event is only handled after the machine has evaluated the initial state first (2)", () => {
const machine = defineMachine<{ name: "a" | "b" | "c" }, { type: "NEXT" }>({
initialState: { name: "a" },
states: {
a: {
always: { to: "b" },
},
b: {
on: {
NEXT: { to: "c" },
},
onEnter({ send }) {
return () => {
send({ type: "NEXT" });
};
},
},
},
})
.newInstance()
.start();
expect(machine.state).toEqual({ name: "b" });
});
test("when sending an event to a machine in the current-state's onExit while the machine is stopping, the event is discarded", () => {
const machine = defineMachine<{ name: "a" | "b" | "c" }, { type: "NEXT" }>({
initialState: { name: "a" },
states: {
a: {
always: { to: "b" },
},
b: {
on: {
NEXT: { to: "c" },
},
onExit({ send }) {
send({ type: "NEXT" });
},
},
},
})
.newInstance()
.start();
expect(machine.state).toEqual({ name: "b" });
machine.stop();
expect(machine.state).toEqual({ name: "b" });
machine.start();
expect(machine.state).toEqual({ name: "b" }); // still
});
test("error thrown if stopped while stopped", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {},
}).newInstance();
expect(() => machine.stop()).toThrow();
});
test("error sending an event while stopped", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {},
}).newInstance();
expect(() => machine.send({ type: "NEXT" })).toThrow();
});
test("error thrown if started while started", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {},
})
.newInstance()
.start();
expect(() => machine.start()).toThrow();
});
test("error thrown if stopped while starting", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
// biome-ignore lint/style/useConst: no other way
let instance: MachineInstance<State, Event>;
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {},
onStart() {
instance.stop();
},
});
instance = machine.newInstance();
expect(() => instance.start()).toThrow();
});
test("error thrown if started while stopping", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
// biome-ignore lint/style/useConst: no other way
let instance: MachineInstance<State, Event>;
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {},
onStop() {
instance.start();
},
});
instance = machine.newInstance().start();
expect(() => instance.stop()).toThrow();
});
test("events sent after onStart side effect is cleaned up are ignored", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
let capturedSend: SendFunction<Event>;
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
on: {
NEXT: { to: "b" },
},
},
b: {
on: {
NEXT: { to: "c" },
},
},
},
onStart({ send }) {
capturedSend = send;
return () => {
send({ type: "NEXT" });
};
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("a");
machine.stop();
capturedSend!({ type: "NEXT" });
expect(machine.state.name).toBe("a");
machine.start();
expect(machine.state.name).toBe("a");
});
test("events sent in onStop side effect are ignored", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
on: {
NEXT: { to: "b" },
},
},
},
// @ts-expect-error: not advertised
onStop({ send }) {
send({ type: "NEXT" });
return () => {
send({ type: "NEXT" });
};
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("a");
machine.stop();
expect(machine.state.name).toBe("a");
machine.start();
expect(machine.state.name).toBe("a");
});
test("events sent after onEnter side effect is cleaned up are ignored", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
let capturedSend: SendFunction<Event>;
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
onEnter({ send }) {
capturedSend = send;
return () => {
send({ type: "NEXT" });
};
},
on: {
NEXT: { to: "b" },
},
},
b: {
on: {
NEXT: { to: "c" },
},
},
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("a");
machine.send({ type: "NEXT" });
expect(machine.state.name).toBe("b");
capturedSend!({ type: "NEXT" });
expect(machine.state.name).toBe("b");
});
test("events sent after onExit side effect is cleaned up are ignored", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
let capturedSend: SendFunction<Event>;
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
onExit({ send }) {
capturedSend = send;
},
on: {
NEXT: { to: "b" },
},
},
b: {
on: {
NEXT: { to: "c" },
},
},
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("a");
machine.send({ type: "NEXT" });
expect(machine.state.name).toBe("b");
capturedSend!({ type: "NEXT" });
expect(machine.state.name).toBe("b");
});
test("events sent after onTransition side effect is cleaned up are ignored", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "NEXT" };
let capturedSend: SendFunction<Event>;
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
on: {
NEXT: {
to: "b",
onTransition({ send }) {
capturedSend = send;
},
},
},
},
b: {
on: {
NEXT: { to: "c" },
},
},
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("a");
machine.send({ type: "NEXT" });
expect(machine.state.name).toBe("b");
capturedSend!({ type: "NEXT" });
expect(machine.state.name).toBe("b");
});
test("events sent after reenter:false onTransition side effect is cleaned up are ignored", () => {
type State = { name: "a" | "b" | "c"; data: number };
type Event = { type: "NEXT" };
let capturedSend: SendFunction<Event>;
const machine = defineMachine<State, Event>({
initialState: { name: "a", data: 0 },
states: {
a: {
on: {
NEXT: {
to: "a",
reenter: false,
onTransition({ send }) {
capturedSend = send;
},
},
},
},
b: {
on: {
NEXT: { to: "c", data: () => ({ data: 3 }) },
},
},
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("a");
machine.send({ type: "NEXT" });
const currentState = machine.state;
capturedSend!({ type: "NEXT" });
expect(machine.state).toBe(currentState);
});
test("always transitions precede events (sent by onEnter in initial state)", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "B" };
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
onEnter: ({ send }) => send({ type: "B" }),
on: {
B: { to: "b" },
},
always: {
to: "c",
},
},
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("c");
});
test("always transitions precede events (sent by onEnter in future state)", () => {
type State = { name: "a" | "b" | "c" | "d" };
type Event = { type: "B" | "C" };
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
on: {
B: { to: "b" },
},
},
b: {
onEnter: ({ send }) => send({ type: "C" }),
on: {
C: { to: "c" },
},
always: {
to: "d",
},
},
},
})
.newInstance()
.start();
machine.send({ type: "B" });
expect(machine.state.name).toBe("d");
});
test("always transitions precede events (sent by onExit in initial state)", () => {
type State = { name: "a" | "b" | "c" };
type Event = { type: "B" };
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
onExit: ({ send }) => send({ type: "B" }),
on: {
B: { to: "b" },
},
always: {
to: "c",
},
},
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("c");
});
test("always transitions precede events (sent by onExit in future state)", () => {
type State = { name: "a" | "b" | "c" | "d" };
type Event = { type: "B" | "C" };
const machine = defineMachine<State, Event>({
initialState: { name: "a" },
states: {
a: {
on: {
B: { to: "b" },
},
},
b: {
onExit: ({ send }) => send({ type: "C" }),
on: {
C: { to: "c" },
},
always: {
to: "d",
},
},
},
})
.newInstance()
.start();
machine.send({ type: "B" });
expect(machine.state.name).toBe("d");
});
test("always transitions are non-recursive and won't cause stack-overflow errors", () => {
type State = { name: "a"; iteration: number };
type Event = { type: "NEXT" };
const machine = defineMachine<State, Event>({
initialState: { name: "a", iteration: 0 },
states: {
a: {
always: {
to: "a",
when: ({ state }) => state.iteration < 10_000,
data: ({ state }) => ({ iteration: state.iteration + 1 }),
},
},
},
})
.newInstance()
.start();
expect(machine.state.name).toBe("a");
});