jsfsm/tests/machine.test.js
rts-pyers 0123a2e9b4 init
2025-08-11 16:28:38 -04:00

596 lines
15 KiB
JavaScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createMachine } from "../src/index";
describe("fsm", () => {
let machine;
beforeEach(() => {
machine = createMachine({
data: {
sleepiness: 100,
hunger: 0,
},
states: {
alive: 1,
asleep: 1,
awake: 0,
hungry: 0,
bored: 1,
eating: 0,
dead: 0,
},
transitions: [
{
from: "asleep",
to: "awake",
when: (ctx) => ctx.has("alive"),
then: (ctx) => ctx.set("sleepiness", 0),
},
{
// transition applies from any state
to: "hungry",
when: (ctx) => ctx.get("hunger") >= 50 && ctx.has("alive"),
},
],
hooks: {
enter: {
awake: (ctx) => console.log("Good morning!"),
dead: (ctx) => console.log("Oops."),
},
exit: {
asleep: (ctx) => console.log("Waking up..."),
},
},
});
});
it("should initialize with correct data and states", () => {
expect(machine.data.sleepiness).toBe(100);
expect(machine.data.hunger).toBe(0);
expect(machine.has("alive")).toBe(true);
expect(machine.has("asleep")).toBe(true);
expect(machine.has("awake")).toBe(false);
expect(machine.has("hungry")).toBe(false);
});
it("should transition from asleep to awake and reset sleepiness", () => {
machine.step();
expect(machine.has("awake")).toBe(true);
expect(machine.has("asleep")).toBe(false);
expect(machine.data.sleepiness).toBe(0);
});
it("should transition to hungry when hunger is set to 60", () => {
machine.step(); // Transition from asleep to awake.
machine.data.hunger = 60;
machine.step();
expect(machine.has("hungry")).toBe(true);
});
it("should fire event callbacks during transition", () => {
const transitionSpy = vi.fn();
const enterSpy = vi.fn();
const exitSpy = vi.fn();
machine.on("transition", transitionSpy);
machine.on("state:enter", enterSpy);
machine.on("state:exit", exitSpy);
machine.step();
expect(transitionSpy).toHaveBeenCalled();
expect(enterSpy).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalled();
});
});
describe("Priority-based Transition Resolution", () => {
it("should choose transition with highest priority when multiple transitions match", () => {
const machine = createMachine({
data: { health: 50 },
states: {
alive: 1,
asleep: 1,
awake: 0,
dead: 0,
},
transitions: [
{
from: "asleep",
to: "awake",
when: (ctx) => ctx.has("alive"),
// No priority - should be overridden by higher priority transition
},
{
from: "asleep",
to: "dead",
when: (ctx) => ctx.has("alive") && ctx.get("health") <= 50,
priority: 10, // Higher priority - should win
},
],
});
machine.step();
expect(machine.has("dead")).toBe(true);
expect(machine.has("awake")).toBe(false);
expect(machine.has("asleep")).toBe(false);
});
it("should use first-match when no priorities are defined", () => {
const machine = createMachine({
data: { energy: 100 },
states: {
idle: 1,
working: 0,
resting: 0,
},
transitions: [
{
from: "idle",
to: "working",
when: (ctx) => ctx.get("energy") >= 50,
// First transition - should win when no priorities
},
{
from: "idle",
to: "resting",
when: (ctx) => ctx.get("energy") >= 50,
// Second transition - should be ignored
},
],
});
machine.step();
expect(machine.has("working")).toBe(true);
expect(machine.has("resting")).toBe(false);
expect(machine.has("idle")).toBe(false);
});
it("should handle mixed priority and non-priority transitions correctly", () => {
const machine = createMachine({
data: { mood: "happy", energy: 80 },
states: {
neutral: 1,
excited: 0,
tired: 0,
sleeping: 0,
},
transitions: [
{
from: "neutral",
to: "excited",
when: (ctx) => ctx.get("mood") === "happy",
// No priority
},
{
from: "neutral",
to: "tired",
when: (ctx) => ctx.get("energy") < 90,
// No priority
},
{
from: "neutral",
to: "sleeping",
when: (ctx) => ctx.get("energy") < 100,
priority: 5, // Has priority - should win over non-priority transitions
},
],
});
machine.step();
expect(machine.has("sleeping")).toBe(true);
expect(machine.has("excited")).toBe(false);
expect(machine.has("tired")).toBe(false);
expect(machine.has("neutral")).toBe(false);
});
describe("StateTick API", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should register and execute state tick functions", () => {
const machine = createMachine({
data: { sleepiness: 100 },
states: {
asleep: 1,
awake: 0,
},
transitions: [],
});
// Register a tick function for the asleep state
machine.tick(
"asleep",
(ctx) => {
const current = ctx.get("sleepiness");
ctx.set("sleepiness", current - 15);
},
{ interval: 1000 },
);
expect(machine.data.sleepiness).toBe(100);
// Advance time by 1 second and step
vi.advanceTimersByTime(1000);
machine.step();
expect(machine.data.sleepiness).toBe(85);
// Advance time by 2 more seconds and step
vi.advanceTimersByTime(2000);
machine.step();
expect(machine.data.sleepiness).toBe(55); // 85 - 30 (2 ticks)
});
it("should handle catch-up logic for multiple missed ticks", () => {
const machine = createMachine({
data: { energy: 100 },
states: {
working: 1,
},
transitions: [],
});
machine.tick(
"working",
(ctx) => {
ctx.set("energy", ctx.get("energy") - 10);
},
{ interval: 500 },
);
// Advance time by 2.5 seconds (5 ticks)
vi.advanceTimersByTime(2500);
machine.step();
expect(machine.data.energy).toBe(50); // 100 - 50 (5 ticks)
});
it("should only tick for active states", () => {
const machine = createMachine({
data: { sleepiness: 100, energy: 100 },
states: {
asleep: 1,
awake: 0,
},
transitions: [
{
from: "asleep",
to: "awake",
when: (ctx) => ctx.get("sleepiness") <= 40,
},
],
});
machine.tick(
"asleep",
(ctx) => {
ctx.set("sleepiness", ctx.get("sleepiness") - 20);
},
{ interval: 1000 },
);
machine.tick(
"awake",
(ctx) => {
ctx.set("energy", ctx.get("energy") + 10);
},
{ interval: 1000 },
);
// Initially asleep, should tick sleepiness
vi.advanceTimersByTime(1000);
machine.step();
expect(machine.data.sleepiness).toBe(80);
expect(machine.data.energy).toBe(100); // awake tick shouldn't run
// Advance more time to trigger transition
vi.advanceTimersByTime(2000);
machine.step();
expect(machine.data.sleepiness).toBe(40); // 80 - 40 (2 more ticks)
expect(machine.has("awake")).toBe(true);
expect(machine.has("asleep")).toBe(false);
// Now awake, should tick energy
vi.advanceTimersByTime(1000);
machine.step();
expect(machine.data.energy).toBe(110); // awake tick runs
expect(machine.data.sleepiness).toBe(40); // asleep tick doesn't run
});
it("should emit tick events with correct payload", () => {
const machine = createMachine({
data: { value: 0 },
states: { active: 1 },
transitions: [],
});
const tickSpy = vi.fn();
machine.on("tick", tickSpy);
machine.tick(
"active",
(ctx) => {
ctx.set("value", ctx.get("value") + 1);
},
{ interval: 1000 },
);
vi.advanceTimersByTime(1500);
machine.step();
expect(tickSpy).toHaveBeenCalledWith({
state: "active",
ctx: expect.objectContaining({
data: expect.any(Object),
has: expect.any(Function),
get: expect.any(Function),
set: expect.any(Function),
}),
interval: 1000,
elapsed: expect.any(Number),
});
});
});
describe("StateTick Persistence", () => {
const createTestMachine = () =>
createMachine({
data: { sleepiness: 100 },
states: {
asleep: 1,
awake: 0,
},
transitions: [
{
from: "asleep",
to: "awake",
when: (ctx) => ctx.get("sleepiness") <= 0,
},
],
});
beforeEach(() => {
vi.useFakeTimers();
if (global.__FSM_STORAGE__) {
global.__FSM_STORAGE__ = {};
}
});
afterEach(() => {
vi.useRealTimers();
});
it("should save and restore stateTicks with catch-up logic", async () => {
const machine = createTestMachine();
machine.tick(
"asleep",
(ctx) => {
ctx.set("sleepiness", ctx.get("sleepiness") - 15);
},
{ interval: 1000 },
);
// Run for 2 seconds
vi.advanceTimersByTime(2000);
machine.step();
expect(machine.data.sleepiness).toBe(70); // 100 - 30
// Save the machine
machine.save("sleeper");
// Advance time by 3 more seconds (but don't step)
vi.advanceTimersByTime(3000);
// Load the machine and re-register tick function
const loaded = await loadMachine("sleeper", {
data: { sleepiness: 100 },
states: { asleep: 1, awake: 0 },
transitions: [
{
from: "asleep",
to: "awake",
when: (ctx) => ctx.get("sleepiness") <= 0,
},
],
});
// Re-register the tick function (functions can't be serialized)
loaded.tick(
"asleep",
(ctx) => {
ctx.set("sleepiness", ctx.get("sleepiness") - 15);
},
{ interval: 1000 },
);
// Step should catch up for the 3 seconds that passed
loaded.step();
expect(loaded.data.sleepiness).toBe(25); // 70 - 45 (3 ticks)
});
it("should handle stateTicks data structure in saved state", () => {
const machine = createTestMachine();
machine.tick(
"asleep",
(ctx) => {
ctx.set("sleepiness", ctx.get("sleepiness") - 10);
},
{ interval: 500 },
);
machine.tick(
"awake",
(ctx) => {
ctx.set("sleepiness", ctx.get("sleepiness") + 5);
},
{ interval: 2000 },
);
machine.save("multi-tick");
// Check the saved data structure
const saved = JSON.parse(global.__FSM_STORAGE__["fsm_multi-tick"]);
expect(saved.stateTicks).toEqual({
asleep: {
interval: 500,
lastTickTime: expect.any(Number),
},
awake: {
interval: 2000,
lastTickTime: expect.any(Number),
},
});
});
});
it("should choose highest priority among multiple priority transitions", () => {
const machine = createMachine({
data: { danger: 100, health: 20 },
states: {
normal: 1,
alert: 0,
panic: 0,
dead: 0,
},
transitions: [
{
from: "normal",
to: "alert",
when: (ctx) => ctx.get("danger") > 50,
priority: 1,
},
{
from: "normal",
to: "panic",
when: (ctx) => ctx.get("danger") > 80,
priority: 5,
},
{
from: "normal",
to: "dead",
when: (ctx) => ctx.get("health") < 30,
priority: 10, // Highest priority - should win
},
],
});
machine.step();
expect(machine.has("dead")).toBe(true);
expect(machine.has("panic")).toBe(false);
expect(machine.has("alert")).toBe(false);
expect(machine.has("normal")).toBe(false);
});
it("should work with array from/to states and priorities", () => {
const machine = createMachine({
data: { emergency: true },
states: {
alive: 1,
awake: 1,
healthy: 1,
injured: 0,
unconscious: 0,
},
transitions: [
{
from: ["awake", "alive"],
to: "injured",
when: (ctx) => ctx.get("emergency"),
priority: 1,
},
{
from: ["awake", "alive"],
to: ["injured", "unconscious"],
when: (ctx) => ctx.get("emergency"),
priority: 5, // Higher priority - should win
},
],
});
machine.step();
expect(machine.has("injured")).toBe(true);
expect(machine.has("unconscious")).toBe(true);
expect(machine.has("awake")).toBe(false);
expect(machine.has("alive")).toBe(false);
expect(machine.has("healthy")).toBe(true); // Should remain unchanged
});
});
import { loadMachine } from "../src/index";
describe("FSM Persistence", () => {
const config = {
data: {
sleepiness: 100,
hunger: 0,
},
states: {
alive: 1,
asleep: 1,
awake: 0,
hungry: 0,
bored: 1,
eating: 0,
dead: 0,
},
transitions: [
{
from: "asleep",
to: "awake",
when: (ctx) => ctx.has("alive"),
then: (ctx) => ctx.set("sleepiness", 0),
},
{
to: "hungry",
when: (ctx) => ctx.get("hunger") >= 50 && ctx.has("alive"),
},
],
hooks: {
enter: {
awake: (ctx) => {},
dead: (ctx) => {},
},
exit: {
asleep: (ctx) => {},
},
},
};
beforeEach(() => {
if (global.__FSM_STORAGE__) {
global.__FSM_STORAGE__ = {};
}
});
it("should save and load machine state and data", async () => {
const machine = createMachine(config);
machine.data.hunger = 55;
machine.step(); // should enter 'hungry'
await machine.save("test1");
// Deep clone config to avoid mutation issues
const configClone = JSON.parse(JSON.stringify(config));
// Restore hooks and transitions (functions are lost in JSON clone)
configClone.hooks = config.hooks;
configClone.transitions = config.transitions;
const loaded = await loadMachine("test1", configClone);
expect(loaded.data.hunger).toBe(55);
expect(loaded.has("hungry")).toBe(true);
expect(loaded.has("alive")).toBe(true);
expect(loaded.has("asleep")).toBe(false);
});
it("should throw if loading a non-existent machine", async () => {
await expect(loadMachine("nope", config)).rejects.toThrow();
});
});