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