init
This commit is contained in:
commit
0123a2e9b4
253
README.md
Normal file
253
README.md
Normal 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 machine’s 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
2933
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
315
src/index.js
Normal 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
39
src/sqliteDriver.js
Normal 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
595
tests/machine.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
21
tests/sqliteDriver.test.js
Normal file
21
tests/sqliteDriver.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
74
tests/sqlitePersistence.test.js
Normal file
74
tests/sqlitePersistence.test.js
Normal 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
19
tests/stepEvent.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user