This commit is contained in:
rts-pyers 2025-08-11 16:28:38 -04:00
commit 0123a2e9b4
9 changed files with 4268 additions and 0 deletions

253
README.md Normal file
View File

@ -0,0 +1,253 @@
# FSM
A JavaScript library for creating non-deterministic finite state machines with time-based state functions.
## Basic Usage
```js
import { createMachine } from "fsm";
const light = createMachine({
data: { brightness: 0 },
states: {
off: 1, // initially active
on: 0,
dimming: 0
},
transitions: [
{
from: "off",
to: "on",
when: ctx => ctx.get("brightness") > 0
},
{
from: "on",
to: "dimming",
when: ctx => ctx.get("brightness") < 50
}
]
});
light.data.brightness = 75;
light.step(); // evaluates transitions
console.log(light.has("on")); // true
```
## State Ticking
States can have time-based functions that execute at regular intervals:
```js
const battery = createMachine({
data: { charge: 100 },
states: {
discharging: 1,
charging: 0
},
transitions: [
{
from: "discharging",
to: "charging",
when: ctx => ctx.get("charge") <= 10
}
]
});
// drain 5% every second while discharging
battery.tick("discharging", (ctx) => {
const current = ctx.get("charge");
ctx.set("charge", Math.max(0, current - 5));
}, { interval: 1000 });
// simulate time passing
setInterval(() => battery.step(), 100);
```
Ticks only execute for active states. If you want conditional behavior, handle it in the tick function:
```js
battery.tick("discharging", (ctx) => {
if (ctx.get("frozen")) return; // skip this tick
const current = ctx.get("charge");
ctx.set("charge", current - 5);
}, { interval: 1000 });
```
## Multiple Active States
This is a non-deterministic FSM, so multiple states can be active simultaneously:
```js
const person = createMachine({
data: { energy: 100, hunger: 0 },
states: {
alive: 1,
awake: 1,
hungry: 0,
tired: 0
},
transitions: [
{
to: "hungry",
when: ctx => ctx.get("hunger") > 50
},
{
to: "tired",
when: ctx => ctx.get("energy") < 30
}
]
});
// person can be alive + awake + hungry + tired all at once
```
## Transition Priorities
When multiple transitions match, the one with the highest priority wins:
```js
const machine = createMachine({
states: { idle: 1, working: 0, sleeping: 0 },
transitions: [
{
from: "idle",
to: "working",
when: ctx => ctx.get("energy") > 50
},
{
from: "idle",
to: "sleeping",
when: ctx => ctx.get("energy") > 30,
priority: 10 // this wins if both conditions are true
}
]
});
```
## Arrays in Transitions
Use arrays to specify multiple from/to states:
```js
{
from: ["awake", "alert"], // must be in BOTH states
to: ["asleep", "dreaming"] // enters BOTH states, removing BOTH "awake" and "alert"
}
```
## Hooks
Run code when entering or exiting states:
```js
const machine = createMachine({
states: { on: 0, off: 1 },
transitions: [
{ from: "off", to: "on", when: ctx => ctx.get("power") }
],
hooks: {
enter: {
on: () => console.log("lights on")
},
exit: {
off: () => console.log("powering up")
}
}
});
```
## Events
Listen for state changes and ticks:
```js
machine.on("transition", ({ from, to }) => {
// called before activeStates are mutated
});
machine.on("state:exit", ({ state, to }) => {
// called immediately after the old state is removed
});
machine.on("state:enter", ({ state, from }) => {
// called after the new state is added (machine.state reflects post-transition)
});
machine.on("tick", ({ state, interval }) => {
// called after your tick function has executed but before transitions in that step
});
machine.on("step", ({ state, data }) => {
// called once per step(), after ticks and transitions
});
```
Notes:
- “transition” handlers run before the machines activeStates set is updated.
- “state:exit” runs immediately after exiting a state.
- “state:enter” runs once the new state is active (machine.state reflects the post-transition set).
- if you only care about reacting once *after* a transition, subscribe to `state:enter`.
- “tick” fires after your tick function has executed but before transitions in that step.
- “step” fires once per step(), after ticks and transitions.
## Persistence
Save and restore machine state:
```js
import { createMachine, loadMachine, setStorageDriver } from "fsm";
import { sqliteDriver } from "fsm/sqliteDriver";
// optional, but recommended
setStorageDriver(sqliteDriver({ filename: "./my.db" }));
// create and save
const game = createMachine({
data: { score: 0 },
states: { playing: 1, paused: 0 },
transitions: [
// your transitions here
],
hooks: {
// your hooks here
}
});
await game.save("player1");
// later...
const restored = await loadMachine("player1", {
transitions: [
// your transitions here
],
hooks: {
// your hooks here
}
});
// re-register tick functions
restored.tick("playing", (ctx) => {
ctx.set("score", ctx.get("score") + 1);
}, { interval: 1000 });
```
Ticks have catch-up logic - if 5 seconds passed while saved, the tick function runs 5 times on the next step.
## API
### Machine Instance
- `step()` - process transitions and ticks
- `has(state)` - check if state is active
- `tick(state, fn, options)` - register tick function
- `save(name)` - persist to storage
- `on(event, handler)` - subscribe to events
- `off(event, handler)` - unsubscribe
- `data` - machine data object
- `state` - array of active states
### Functions
- `createMachine(config)` - create new machine
- `loadMachine(name, config)` - restore from storage

2933
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "fsm",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"test": "vitest --run"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"sqlite3": "^5.1.6"
},
"devDependencies": {
"vitest": "^3.2.4"
}
}

315
src/index.js Normal file
View File

@ -0,0 +1,315 @@
/**
* @typedef {Object} FSMConfig
* @property {Object} [data]
* @property {Object.<string, Object>} states
* @property {Array<FSMTransition>} transitions
* @property {Object} [hooks]
*/
/**
* @typedef {Object} FSMTransition
* @property {string|string[]} from
* @property {string|string[]} to
* @property {function(Object): boolean} [when]
* @property {function(Object): void} [then]
* @property {function(Object): void} [action]
* @property {number} [priority]
*/
/**
* @typedef {Object} FSMInstance
* @property {function(): void} step
* @property {function(string, function): void} on
* @property {function(string, function): void} off
* @property {function(string): boolean} has
* @property {function(string, function, Object): void} tick
* @property {function(string): void} save
* @property {Object} data
* @property {Array<string>} state
*/
let storageDriver = {
set(key, value) {
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.setItem(`fsm_${key}`, value);
} else if (typeof global !== "undefined") {
global.__FSM_STORAGE__ = global.__FSM_STORAGE__ || {};
global.__FSM_STORAGE__[`fsm_${key}`] = value;
}
},
get(key) {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage.getItem(`fsm_${key}`);
} else if (typeof global !== "undefined" && global.__FSM_STORAGE__) {
return global.__FSM_STORAGE__[`fsm_${key}`] ?? null;
}
return null;
},
};
export function setStorageDriver(driver) {
storageDriver = driver;
}
function createMachine(config) {
if (!config || typeof config !== "object")
throw new Error("FSM config required");
const {
data = {},
states,
transitions,
hooks = {},
activeStates: initialActiveStates,
} = config;
if (!states || typeof states !== "object")
throw new Error("FSM states required");
if (!Array.isArray(transitions))
throw new Error("FSM transitions must be an array");
const activeStates = new Set();
if (Array.isArray(initialActiveStates)) {
for (const state of initialActiveStates) {
activeStates.add(state);
}
} else {
for (const [state, val] of Object.entries(states)) {
if (val) activeStates.add(state);
}
}
const listeners = {};
const stateTicks = {};
let savedStateTicks = {};
function emit(event, ...args) {
(listeners[event] || []).forEach((fn) => fn(...args));
}
function on(event, handler) {
(listeners[event] = listeners[event] || []).push(handler);
}
function off(event, handler) {
if (listeners[event])
listeners[event] = listeners[event].filter((fn) => fn !== handler);
}
function runHook(type, state, ...args) {
if (typeof hooks[type] === "function") hooks[type](state, ...args);
if (hooks[type] && typeof hooks[type][state] === "function")
hooks[type][state](...args);
if (states[state] && typeof states[state][type] === "function")
states[state][type](...args);
}
// this finds a valid transition from any active state, handles priority
function findTransition() {
const validTransitions = [];
for (const t of transitions) {
const fromStates =
t.from !== undefined ? (Array.isArray(t.from) ? t.from : [t.from]) : [];
if (t.from !== undefined && !fromStates.some((s) => activeStates.has(s)))
continue;
if (t.from === undefined) {
const toStates = Array.isArray(t.to) ? t.to : [t.to];
if (toStates.some((state) => activeStates.has(state))) continue;
}
const ctx = {
data,
state: Array.from(activeStates),
has: (s) => activeStates.has(s),
get: (k) => data[k],
set: (k, v) => {
data[k] = v;
},
};
if (t.when && !t.when(ctx)) continue;
validTransitions.push({ t, ctx, fromStates });
}
if (validTransitions.length === 0) return null;
const transitionsWithPriority = validTransitions.filter(
({ t }) => typeof t.priority === "number",
);
if (transitionsWithPriority.length > 0) {
return transitionsWithPriority.reduce((highest, current) =>
current.t.priority > highest.t.priority ? current : highest,
);
}
return validTransitions[0];
}
// this does all state transitions, runs hooks, updates state, and emits events
function step() {
processStateTicks();
let transitionOccurred = false;
do {
transitionOccurred = false;
const found = findTransition();
if (!found) break;
const { t: transition, ctx, fromStates } = found;
const toStates =
transition.to !== undefined
? Array.isArray(transition.to)
? transition.to
: [transition.to]
: [];
const actuallyFrom = fromStates.filter((s) => activeStates.has(s));
actuallyFrom.forEach((fromState) => {
runHook("exit", fromState, toStates[0]);
runHook("onExit", fromState, toStates[0]);
emit("state:exit", { state: fromState, to: toStates[0] });
});
if (typeof transition.then === "function") {
transition.then(ctx);
}
if (typeof transition.action === "function") {
transition.action({ data, from: actuallyFrom, to: toStates });
}
if (typeof hooks.onTransition === "function") {
hooks.onTransition({ from: actuallyFrom, to: toStates, data });
}
emit("transition", { from: actuallyFrom, to: toStates });
actuallyFrom.forEach((s) => activeStates.delete(s));
toStates.forEach((s) => {
activeStates.add(s);
if (stateTicks[s]) {
stateTicks[s].lastTickTime = Date.now();
}
});
toStates.forEach((toState) => {
runHook("enter", toState, actuallyFrom[0]);
runHook("onEnter", toState, actuallyFrom[0]);
emit("state:enter", { state: toState, from: actuallyFrom[0] });
});
transitionOccurred = true;
} while (transitionOccurred);
emit("step", { state: Array.from(activeStates), data });
}
// this handles all active state ticks and catch-up logic
function processStateTicks() {
const now = Date.now();
for (const state of activeStates) {
const tickConfig = stateTicks[state];
if (!tickConfig) continue;
const { tickFn, interval, lastTickTime } = tickConfig;
const elapsed = now - lastTickTime;
if (elapsed >= interval) {
const tickCount = Math.floor(elapsed / interval);
const ctx = {
data,
state: Array.from(activeStates),
has: (s) => activeStates.has(s),
get: (k) => data[k],
set: (k, v) => {
data[k] = v;
},
};
for (let i = 0; i < tickCount; i++) {
tickFn(ctx);
}
tickConfig.lastTickTime = now - (elapsed % interval);
emit("tick", { state, ctx, interval, elapsed });
}
}
}
function tick(state, tickFn, options = {}) {
const { interval = 1000 } = options;
const savedData = savedStateTicks[state];
stateTicks[state] = {
tickFn,
interval: savedData ? savedData.interval : interval,
lastTickTime: savedData ? savedData.lastTickTime : Date.now(),
};
}
function has(state) {
return activeStates.has(state);
}
async function save(name) {
const serialized = JSON.stringify({
data,
activeStates: Array.from(activeStates),
stateTicks: Object.fromEntries(
Object.entries(stateTicks).map(([state, config]) => [
state,
{
interval: config.interval,
lastTickTime: config.lastTickTime,
},
]),
),
});
await storageDriver.set(name, serialized);
}
function wrappedStep() {
step();
}
return {
step: wrappedStep,
on,
off,
data,
get state() {
return Array.from(activeStates);
},
has,
save,
tick,
stateTicks,
_setSavedStateTicks: (data) => {
savedStateTicks = data;
},
};
}
async function loadMachine(name, config) {
const serialized = await storageDriver.get(name);
if (!serialized) throw new Error(`No saved FSM state for: ${name}`);
const {
data,
activeStates,
stateTicks: savedStateTicks = {},
} = JSON.parse(serialized);
const fullConfig = { ...config, data, activeStates };
if (!fullConfig.states) {
fullConfig.states = Object.fromEntries(activeStates.map((s) => [s, 1]));
}
const machine = createMachine(fullConfig);
// this just stores saved tick data for later restoration when tick functions are re-registered
machine._setSavedStateTicks(savedStateTicks);
return machine;
}
export { createMachine, loadMachine };

39
src/sqliteDriver.js Normal file
View File

@ -0,0 +1,39 @@
import sqlite3 from "sqlite3";
export function sqliteDriver({ filename }) {
const db = new sqlite3.Database(filename);
const init = new Promise((resolve, reject) => {
db.run(
`CREATE TABLE IF NOT EXISTS fsm_storage (
key TEXT PRIMARY KEY,
value TEXT
)`,
(err) => (err ? reject(err) : resolve()),
);
});
return {
async set(key, value) {
await init;
return new Promise((resolve, reject) => {
db.run(
`INSERT OR REPLACE INTO fsm_storage(key, value) VALUES (?, ?)`,
[key, value],
(err) => (err ? reject(err) : resolve()),
);
});
},
async get(key) {
await init;
return new Promise((resolve, reject) => {
db.get(
`SELECT value FROM fsm_storage WHERE key = ?`,
[key],
(err, row) => {
if (err) return reject(err);
resolve(row ? row.value : null);
},
);
});
},
};
}

595
tests/machine.test.js Normal file
View File

@ -0,0 +1,595 @@
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();
});
});

View File

@ -0,0 +1,21 @@
import { describe, it, expect, beforeEach } from "vitest";
import { sqliteDriver } from "../src/sqliteDriver.js";
describe("sqliteDriver", () => {
let driver;
beforeEach(() => {
driver = sqliteDriver({ filename: ":memory:" });
});
it("should set and get arbitrary keys", async () => {
await driver.set("foo", "bar");
const val = await driver.get("foo");
expect(val).toBe("bar");
});
it("should return null for missing keys", async () => {
const val = await driver.get("nope");
expect(val).toBeNull();
});
});

View File

@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createMachine, loadMachine, setStorageDriver } from "../src/index";
import { sqliteDriver } from "../src/sqliteDriver.js";
describe("sqlite persistence", () => {
let driver;
beforeEach(() => {
driver = sqliteDriver({ filename: ":memory:" });
setStorageDriver(driver);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should save and load simple data+state", async () => {
const transitions = [{ from: "a", to: "b", when: () => true }];
const m = createMachine({
data: { x: 1 },
states: { a: 1, b: 0 },
transitions,
});
m.data.x = 42;
m.step();
await m.save("testSql");
const loaded = await loadMachine("testSql", {
transitions,
});
expect(loaded.data.x).toBe(42);
expect(loaded.has("b")).toBe(true);
});
it("should catch up missed ticks after reload", async () => {
const make = () =>
createMachine({
data: { count: 0 },
states: { run: 1 },
transitions: [],
});
const m1 = make();
m1.tick("run", (ctx) => ctx.set("count", ctx.get("count") + 1), {
interval: 1000,
});
vi.advanceTimersByTime(3000);
m1.step();
expect(m1.data.count).toBe(3);
await m1.save("tickTest");
vi.advanceTimersByTime(2000);
const m2 = await loadMachine("tickTest", {
transitions: [],
});
m2.tick("run", (ctx) => ctx.set("count", ctx.get("count") + 1), {
interval: 1000,
});
m2.step();
expect(m2.data.count).toBe(5);
});
});

19
tests/stepEvent.test.js Normal file
View File

@ -0,0 +1,19 @@
import { describe, it, expect, vi } from "vitest";
import { createMachine } from "../src/index";
describe("step event", () => {
it("should emit step event after ticks and transitions", () => {
const machine = createMachine({
data: {},
states: { x: 1, y: 0 },
transitions: [{ from: "x", to: "y", when: () => true }],
});
const stepSpy = vi.fn();
machine.on("step", stepSpy);
machine.step();
expect(stepSpy).toHaveBeenCalledWith({
state: ["y"],
data: machine.data,
});
});
});