596 lines
15 KiB
JavaScript
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();
|
|
});
|
|
});
|