@studnicky/fsm
Type-safe abstract FSM base class with async effect interpretation and named machine registry.
Install
pnpm add @studnicky/fsmRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
Usage
Define a StateMachine subclass with getInitialState and reduce, then drive it with EffectInterpreter. The interpreter manages the mailbox, dispatches effects, and notifies subscribers on every state change:
import type { EffectHandlerMapType, FsmStepType } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';
import { EffectInterpreter, StateMachine } from '../src/index.js';
type TrafficState =
| { readonly 'variant': 'red' }
| { readonly 'variant': 'green' }
| { readonly 'variant': 'amber' };
type TrafficEvent = TrafficEventEntity.Type;
type TrafficEffect = TrafficEffectEntity.Type;
class TrafficLight extends StateMachine<TrafficState, TrafficEvent, TrafficEffect> {
static make(): TrafficLight { return new TrafficLight(); }
getInitialState(): TrafficState {
return { 'variant': 'red' };
}
reduce(state: TrafficState, event: TrafficEvent): FsmStepType<TrafficState, TrafficEffect> {
if (event.type === 'advance') {
if (state.variant === 'red') {return { 'effects': [], 'state': { 'variant': 'green' } };}
if (state.variant === 'green') {return { 'effects': [{ 'tone': 'chime', 'variant': 'playSound' }], 'state': { 'variant': 'amber' } };}
if (state.variant === 'amber') {return { 'effects': [], 'state': { 'variant': 'red' } };}
}
return { 'effects': [], 'state': state };
}
}
class TrafficLightDemo {
static readonly soundsPlayed: string[] = [];
static readonly history: string[] = [];
static readonly handlers: EffectHandlerMapType<TrafficEffect> = {
'playSound': (effect) => { TrafficLightDemo.soundsPlayed.push(effect.tone); }
};
static async run(): Promise<{ readonly 'finalVariant': TrafficState['variant']; readonly 'history': string[]; readonly 'soundsPlayed': string[] }> {
const machine: TrafficLight = TrafficLight.make();
const interpreter: EffectInterpreter<TrafficState, TrafficEvent, TrafficEffect> = EffectInterpreter.create({ 'handlers': TrafficLightDemo.handlers, 'machine': machine, 'machineId': 'test-light' });
const unsubscribe = interpreter.subscribe((state) => { TrafficLightDemo.history.push(state.variant); });
interpreter.start();
await interpreter.send({ 'type': 'advance' });
await interpreter.send({ 'type': 'advance' });
await interpreter.send({ 'type': 'advance' });
console.log('State after 3 advances:', interpreter.getState().variant);
console.log('Sound history:', TrafficLightDemo.soundsPlayed);
console.log('State history:', TrafficLightDemo.history);
unsubscribe();
interpreter.stop();
return { 'finalVariant': interpreter.getState().variant, 'history': TrafficLightDemo.history, 'soundsPlayed': TrafficLightDemo.soundsPlayed };
}
}
const results = await TrafficLightDemo.run();MachineRegistry: named singleton registry
MachineRegistry is a process-scoped store for named interpreter instances. Registering under a key makes the interpreter accessible from any module without passing references:
import type { FsmStepType } from '../src/index.js';
import type { ToggleEventEntity } from './entities/ToggleEventEntity.js';
import { EffectInterpreter, MachineAlreadyRegisteredError, MachineRegistry, StateMachine } from '../src/index.js';
type ToggleState = { readonly 'variant': 'on' } | { readonly 'variant': 'off' };
type ToggleEvent = ToggleEventEntity.Type;
class Toggle extends StateMachine<ToggleState, ToggleEvent> {
static make(): Toggle { return new Toggle(); }
getInitialState(): ToggleState {
return { 'variant': 'off' };
}
reduce(state: ToggleState, event: ToggleEvent): FsmStepType<ToggleState> {
if (event.type === 'toggle') {
return { 'effects': [], 'state': { 'variant': state.variant === 'off' ? 'on' : 'off' } };
}
return { 'effects': [], 'state': state };
}
}
const interpreter: EffectInterpreter<ToggleState, ToggleEvent> = EffectInterpreter.create({ 'machine': Toggle.make(), 'machineId': 'toggle-a' });
interpreter.start();
const registry = MachineRegistry.create();
// Registry is empty before registration
assert.equal(registry.has('toggle-a'), false);
assert.deepEqual(registry.list(), []);
registry.register('toggle-a', interpreter);
assert.equal(registry.has('toggle-a'), true);
assert.deepEqual(registry.list(), ['toggle-a']);
// Duplicate registration throws MachineAlreadyRegisteredError
assert.throws(
() => { registry.register('toggle-a', interpreter); },
MachineAlreadyRegisteredError
);
// Retrieve and drive the machine through the registry
const found = registry.get('toggle-a');
assert.ok(found !== undefined);
await found.send({ 'type': 'toggle' });
assert.equal(interpreter.getState().variant, 'on');
await found.send({ 'type': 'toggle' });
assert.equal(interpreter.getState().variant, 'off');
// Unknown name returns undefined
assert.equal(registry.get('unknown'), undefined);
registry.unregister('toggle-a');
assert.equal(registry.has('toggle-a'), false);
interpreter.stop();
console.log('Registry state:', registry.list());Error handling
StateMachine.transition wraps any throw from reduce in a ReducerThrewError. EffectInterpreter guards against reads before start() and sends after stop():
import type { FsmStepType } from '../src/index.js';
import type { BrokenEventEntity } from './entities/BrokenEventEntity.js';
import type { BrokenStateEntity } from './entities/BrokenStateEntity.js';
import type { SimpleEventEntity } from './entities/SimpleEventEntity.js';
import type { SimpleStateEntity } from './entities/SimpleStateEntity.js';
import {
EffectInterpreter,
InterpreterNotRunningError,
InterpreterNotStartedError,
ReducerThrewError,
StateMachine
} from '../src/index.js';
// --- ReducerThrewError ---
type BrokenState = BrokenStateEntity.Type;
type BrokenEvent = BrokenEventEntity.Type;
class BrokenMachine extends StateMachine<BrokenState, BrokenEvent> {
static make(): BrokenMachine { return new BrokenMachine(); }
getInitialState(): BrokenState {
return { 'variant': 'active' };
}
reduce(_state: BrokenState, event: BrokenEvent): FsmStepType<BrokenState> {
if (event.type === 'boom') { throw new Error('reducer exploded'); }
return { 'effects': [], 'state': _state };
}
}
const broken: BrokenMachine = BrokenMachine.make();
const initialState: BrokenState = { 'variant': 'active' };
// StateMachine.transition wraps reducer throws in ReducerThrewError
assert.throws(
() => { broken.transition(initialState, { 'type': 'boom' }); },
ReducerThrewError
);
console.log('ReducerThrewError thrown and caught');
// --- InterpreterNotStartedError ---
type SimpleState = SimpleStateEntity.Type;
type SimpleEvent = SimpleEventEntity.Type;
class SimpleMachine extends StateMachine<SimpleState, SimpleEvent> {
static make(): SimpleMachine { return new SimpleMachine(); }
getInitialState(): SimpleState { return { 'variant': 'idle' }; }
reduce(state: SimpleState): FsmStepType<SimpleState> { return { 'effects': [], 'state': state }; }
}
const notStarted: EffectInterpreter<SimpleState, SimpleEvent> = EffectInterpreter.create({ 'machine': SimpleMachine.make() });
// getState before start() throws InterpreterNotStartedError
assert.throws(
() => { notStarted.getState(); },
InterpreterNotStartedError
);
console.log('InterpreterNotStartedError thrown and caught');
// --- InterpreterNotRunningError ---
const stopped: EffectInterpreter<SimpleState, SimpleEvent> = EffectInterpreter.create({ 'machine': SimpleMachine.make() });
stopped.start();
stopped.stop();
// send after stop() throws InterpreterNotRunningError
await assert.rejects(
async () => { await stopped.send({ 'type': 'noop' }); },
InterpreterNotRunningError
);
console.log('InterpreterNotRunningError thrown and caught');Observability hooks
Every stateful class exposes protected hook methods that fire at each significant stage. Override them in a subclass to add logging, tracing, or metrics without changing any public behaviour.
StateMachine hooks
| Hook | When it fires | Args |
|---|---|---|
onTransition(from, to, event) | After a successful state-variant change, before the step is returned | from: TState, to: TState, event: TEvent |
onEnterState(state) | When entering a new state variant (fires after onTransition) | state: TState |
onExitState(state) | When leaving the current state variant (fires before onTransition) | state: TState |
onTransitionRejected(state, event, reason) | When reduce throws — no valid transition / guard failed | state: TState, event: TEvent, reason: string |
onTransition, onEnterState, and onExitState are only called when the state variant changes. Self-loops (same variant returned) fire none of them.
EffectInterpreter hooks
| Hook | When it fires | Args |
|---|---|---|
onStart(state) | After start() sets the initial state | state: TState |
onStop(state) | After stop() halts event processing | state: TState | undefined |
onEnqueue(event) | When an event is added to the mailbox by send() | event: TEvent |
onTransition(from, to, event) | When the interpreter commits a state-variant change | from: TState, to: TState, event: TEvent |
onEnterState(state) | After committing a new state variant | state: TState |
onExitState(state) | Before committing the new state, while still in the old variant | state: TState |
onEffectStart(effect) | Before invoking an effect handler | effect: TEffect |
onEffectSuccess(effect) | After an effect handler resolves successfully | effect: TEffect |
onEffectError(effect, error) | When an effect handler throws | effect: TEffect, error: Error |
MachineRegistry hooks
MachineRegistry is a static-only class; hooks are protected static methods. Override them in a subclass and route all calls through the subclass.
| Hook | When it fires | Args |
|---|---|---|
onRegister(id) | After a named interpreter is successfully registered | id: string |
onUnregister(id) | After unregister() is called (fires even if the key did not exist) | id: string |
onResolveMiss(id) | When get() returns undefined for an unknown id | id: string |
Example — traced traffic light
import type { EffectHandlerMapType, FsmStepType } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';
import { EffectInterpreter, MachineRegistry, StateMachine } from '../src/index.js';
// --- Domain types ---
type TrafficState =
| { readonly 'variant': 'amber' }
| { readonly 'variant': 'green' }
| { readonly 'variant': 'red' };
type TrafficEvent = TrafficEventEntity.Type;
type TrafficEffect = TrafficEffectEntity.Type;
// --- Observed StateMachine subclass ---
class ObservedTrafficMachine extends StateMachine<TrafficState, TrafficEvent, TrafficEffect> {
static make(): ObservedTrafficMachine { return new ObservedTrafficMachine(); }
getInitialState(): TrafficState { return { 'variant': 'red' }; }
reduce(state: TrafficState, event: TrafficEvent): FsmStepType<TrafficState, TrafficEffect> {
if (event.type === 'advance') {
if (state.variant === 'red') { return { 'effects': [], 'state': { 'variant': 'green' } }; }
if (state.variant === 'green') { return { 'effects': [{ 'tone': 'chime', 'variant': 'playSound' }], 'state': { 'variant': 'amber' } }; }
if (state.variant === 'amber') { return { 'effects': [], 'state': { 'variant': 'red' } }; }
}
return { 'effects': [], 'state': state };
}
protected override onTransition(from: TrafficState, to: TrafficState, event: TrafficEvent): void {
console.log(`[fsm:machine] transition ${from.variant} --[${event.type}]--> ${to.variant}`);
}
protected override onEnterState(state: TrafficState): void {
console.log(`[fsm:machine] enter state=${state.variant}`);
}
protected override onExitState(state: TrafficState): void {
console.log(`[fsm:machine] exit state=${state.variant}`);
}
protected override onTransitionRejected(state: TrafficState, event: TrafficEvent, reason: string): void {
console.log(`[fsm:machine] rejected state=${state.variant} event=${event.type} reason=${reason}`);
}
}
// --- Observed EffectInterpreter subclass ---
class ObservedInterpreter extends EffectInterpreter<TrafficState, TrafficEvent, TrafficEffect> {
static makeObserved(
machine: ObservedTrafficMachine,
handlers: EffectHandlerMapType<TrafficEffect>
): ObservedInterpreter {
return new ObservedInterpreter({ 'handlers': handlers, 'machine': machine, 'machineId': 'traffic-light' });
}
protected override onStart(state: TrafficState): void {
console.log(`[fsm:interp] start initialState=${state.variant}`);
}
protected override onStop(state: TrafficState | undefined): void {
console.log(`[fsm:interp] stop lastState=${state?.variant ?? 'unknown'}`);
}
protected override onEnqueue(event: TrafficEvent): void {
console.log(`[fsm:interp] enqueue event=${event.type}`);
}
protected override onTransition(from: TrafficState, to: TrafficState, event: TrafficEvent): void {
console.log(`[fsm:interp] transition ${from.variant} --[${event.type}]--> ${to.variant}`);
}
protected override onEnterState(state: TrafficState): void {
console.log(`[fsm:interp] enter state=${state.variant}`);
}
protected override onExitState(state: TrafficState): void {
console.log(`[fsm:interp] exit state=${state.variant}`);
}
protected override onEffectStart(effect: TrafficEffect): void {
console.log(`[fsm:interp] effectStart variant=${effect.variant} tone=${effect.tone}`);
}
protected override onEffectSuccess(effect: TrafficEffect): void {
console.log(`[fsm:interp] effectOk variant=${effect.variant}`);
}
protected override onEffectError(effect: TrafficEffect, error: Error): void {
console.log(`[fsm:interp] effectError variant=${effect.variant} error=${error.message}`);
}
}
// --- Observed MachineRegistry subclass ---
class ObservedRegistry extends MachineRegistry {
static override create(): ObservedRegistry {
return new ObservedRegistry();
}
protected override onRegister(id: string): void {
console.log(`[fsm:registry] register id=${id}`);
}
protected override onUnregister(id: string): void {
console.log(`[fsm:registry] unregister id=${id}`);
}
protected override onResolveMiss(id: string): void {
console.log(`[fsm:registry] miss id=${id}`);
}
}
// --- Scenario: drive the traffic light through a full cycle ---
const soundsPlayed: string[] = [];
const handlers: EffectHandlerMapType<TrafficEffect> = {
'playSound': (effect) => { soundsPlayed.push(effect.tone); }
};
const machine = ObservedTrafficMachine.make();
const interpreter = ObservedInterpreter.makeObserved(machine, handlers);
const observedRegistry = ObservedRegistry.create();
console.log('\n--- Registering interpreter ---');
observedRegistry.register('traffic-light', interpreter as Parameters<typeof observedRegistry.register>[1]);
console.log('\n--- Starting interpreter ---');
interpreter.start();
console.log('\n--- Sending events: advance × 3 ---');
await interpreter.send({ 'type': 'advance' }); // red → green
await interpreter.send({ 'type': 'advance' }); // green → amber (plays sound)
await interpreter.send({ 'type': 'advance' }); // amber → red
console.log('\n--- Probing a missing registry key ---');
observedRegistry.get('no-such-machine');
console.log('\n--- Stopping interpreter ---');
interpreter.stop();
console.log('\n--- Unregistering ---');
observedRegistry.unregister('traffic-light');
console.log('\nFinal state:', interpreter.getState().variant);
console.log('Sounds played:', soundsPlayed);The base class never calls any logger or metrics library. All hooks are no-ops by default.
API
| Export | Type | Description |
|---|---|---|
StateMachine<TState, TEvent, TEffect> | abstract class | Base FSM; implement getInitialState and reduce |
EffectInterpreter<TState, TEvent, TEffect> | class | Drives a machine; use EffectInterpreter.create() or EffectInterpreter.builder() |
EffectInterpreterBuilder<TState, TEvent, TEffect> | class | Fluent builder for EffectInterpreter |
MachineRegistry | class | Process-scoped named registry of interpreters |
FsmStepType<TState, TEffect> | type | { state, effects } (return value of reduce) |
FsmTransitionType<TState, TEvent, TEffect> | type | Function signature for standalone transition functions |
EffectHandlerMapType<TEffect> | type | Variant-keyed async handler map |
MachineAlreadyRegisteredError | class | Thrown by MachineRegistry.register on duplicate name |
ReducerThrewError | class | Thrown by StateMachine.transition when reduce throws |
StateMachine<TState, TEvent, TEffect>
| Member | Signature | Description |
|---|---|---|
getInitialState | () => TState | Returns the machine's initial state |
reduce | (state, event) => FsmStepType<TState, TEffect> | Pure transition function |
transition | (state, event) => FsmStepType<TState, TEffect> | Calls reduce; wraps throws in ReducerThrewError |
EffectInterpreter<TState, TEvent, TEffect>
| Member | Signature | Description |
|---|---|---|
start | () => void | Initialises state; must be called before send |
stop | () => void | Halts event processing |
getState | () => TState | Returns current state; throws if not started |
send | (event: TEvent) => Promise<void> | Enqueues event and drains mailbox |
subscribe | (observer) => () => void | Registers a state observer; returns unsubscribe fn |
Try it
Run the examples below directly in the browser to see the FSM primitives in action.
Builder
The builder chains machine, handlers, and options before calling build() — the interpreter is ready to start with no constructor parameters.
/** builder-fsm — construct an EffectInterpreter via the fluent builder API. Run: npx tsx packages/fsm/examples/builder-fsm.ts */
import assert from 'node:assert/strict';
// #region usage
import type { EffectHandlerMapType, FsmStepType } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';
import { EffectInterpreter, StateMachine } from '../src/index.js';
type TrafficState =
| { readonly 'variant': 'red' }
| { readonly 'variant': 'green' }
| { readonly 'variant': 'amber' };
type TrafficEvent = TrafficEventEntity.Type;
type TrafficEffect = TrafficEffectEntity.Type;
class TrafficLight extends StateMachine<TrafficState, TrafficEvent, TrafficEffect> {
static make(): TrafficLight { return new TrafficLight(); }
getInitialState(): TrafficState {
return { 'variant': 'red' };
}
reduce(state: TrafficState, event: TrafficEvent): FsmStepType<TrafficState, TrafficEffect> {
if (event.type === 'advance') {
if (state.variant === 'red') { return { 'effects': [], 'state': { 'variant': 'green' } }; }
if (state.variant === 'green') { return { 'effects': [{ 'tone': 'chime', 'variant': 'playSound' }], 'state': { 'variant': 'amber' } }; }
if (state.variant === 'amber') { return { 'effects': [], 'state': { 'variant': 'red' } }; }
}
return { 'effects': [], 'state': state };
}
}
class BuilderFsmDemo {
static readonly soundsPlayed: string[] = [];
static readonly history: string[] = [];
static readonly handlers: EffectHandlerMapType<TrafficEffect> = {
'playSound': (effect) => {
BuilderFsmDemo.soundsPlayed.push(effect.tone);
console.log(` effect handler: playSound tone="${effect.tone}"`);
}
};
static async run(): Promise<{ readonly 'finalVariant': TrafficState['variant']; readonly 'history': string[]; readonly 'soundsPlayed': string[] }> {
const machine: TrafficLight = TrafficLight.make();
const interpreter = EffectInterpreter.builder<TrafficState, TrafficEvent, TrafficEffect>()
.withMachine(machine)
.withHandlers(BuilderFsmDemo.handlers)
.withOptions({ 'machineId': 'builder-demo' })
.build();
const unsubscribe = interpreter.subscribe((state) => { BuilderFsmDemo.history.push(state.variant); });
interpreter.start();
console.log('started — initial state:', interpreter.getState().variant);
await interpreter.send({ 'type': 'advance' });
console.log('after advance 1:', interpreter.getState().variant);
await interpreter.send({ 'type': 'advance' });
console.log('after advance 2:', interpreter.getState().variant);
await interpreter.send({ 'type': 'advance' });
console.log('after advance 3:', interpreter.getState().variant);
console.log('State history:', BuilderFsmDemo.history);
console.log('Sounds played:', BuilderFsmDemo.soundsPlayed);
unsubscribe();
interpreter.stop();
return { 'finalVariant': interpreter.getState().variant, 'history': BuilderFsmDemo.history, 'soundsPlayed': BuilderFsmDemo.soundsPlayed };
}
}
const results = await BuilderFsmDemo.run();
// #endregion usage
assert.equal(results.finalVariant, 'red');
assert.deepEqual(results.soundsPlayed, ['chime']);
assert.deepEqual(results.history, ['red', 'green', 'amber', 'red']);
console.log('builder-fsm: all assertions passed');
Lifecycle hooks
Every state transition fires hooks on both the machine and interpreter layers — watch the paired log lines as each advance propagates.
/** observedFsm — trace lifecycle hooks across StateMachine, EffectInterpreter, and MachineRegistry. Run: npx tsx examples/observedFsm.ts */
import assert from 'node:assert/strict';
// #region usage
import type { EffectHandlerMapType, FsmStepType } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';
import { EffectInterpreter, MachineRegistry, StateMachine } from '../src/index.js';
// --- Domain types ---
type TrafficState =
| { readonly 'variant': 'amber' }
| { readonly 'variant': 'green' }
| { readonly 'variant': 'red' };
type TrafficEvent = TrafficEventEntity.Type;
type TrafficEffect = TrafficEffectEntity.Type;
// --- Observed StateMachine subclass ---
class ObservedTrafficMachine extends StateMachine<TrafficState, TrafficEvent, TrafficEffect> {
static make(): ObservedTrafficMachine { return new ObservedTrafficMachine(); }
getInitialState(): TrafficState { return { 'variant': 'red' }; }
reduce(state: TrafficState, event: TrafficEvent): FsmStepType<TrafficState, TrafficEffect> {
if (event.type === 'advance') {
if (state.variant === 'red') { return { 'effects': [], 'state': { 'variant': 'green' } }; }
if (state.variant === 'green') { return { 'effects': [{ 'tone': 'chime', 'variant': 'playSound' }], 'state': { 'variant': 'amber' } }; }
if (state.variant === 'amber') { return { 'effects': [], 'state': { 'variant': 'red' } }; }
}
return { 'effects': [], 'state': state };
}
protected override onTransition(from: TrafficState, to: TrafficState, event: TrafficEvent): void {
console.log(`[fsm:machine] transition ${from.variant} --[${event.type}]--> ${to.variant}`);
}
protected override onEnterState(state: TrafficState): void {
console.log(`[fsm:machine] enter state=${state.variant}`);
}
protected override onExitState(state: TrafficState): void {
console.log(`[fsm:machine] exit state=${state.variant}`);
}
protected override onTransitionRejected(state: TrafficState, event: TrafficEvent, reason: string): void {
console.log(`[fsm:machine] rejected state=${state.variant} event=${event.type} reason=${reason}`);
}
}
// --- Observed EffectInterpreter subclass ---
class ObservedInterpreter extends EffectInterpreter<TrafficState, TrafficEvent, TrafficEffect> {
static makeObserved(
machine: ObservedTrafficMachine,
handlers: EffectHandlerMapType<TrafficEffect>
): ObservedInterpreter {
return new ObservedInterpreter({ 'handlers': handlers, 'machine': machine, 'machineId': 'traffic-light' });
}
protected override onStart(state: TrafficState): void {
console.log(`[fsm:interp] start initialState=${state.variant}`);
}
protected override onStop(state: TrafficState | undefined): void {
console.log(`[fsm:interp] stop lastState=${state?.variant ?? 'unknown'}`);
}
protected override onEnqueue(event: TrafficEvent): void {
console.log(`[fsm:interp] enqueue event=${event.type}`);
}
protected override onTransition(from: TrafficState, to: TrafficState, event: TrafficEvent): void {
console.log(`[fsm:interp] transition ${from.variant} --[${event.type}]--> ${to.variant}`);
}
protected override onEnterState(state: TrafficState): void {
console.log(`[fsm:interp] enter state=${state.variant}`);
}
protected override onExitState(state: TrafficState): void {
console.log(`[fsm:interp] exit state=${state.variant}`);
}
protected override onEffectStart(effect: TrafficEffect): void {
console.log(`[fsm:interp] effectStart variant=${effect.variant} tone=${effect.tone}`);
}
protected override onEffectSuccess(effect: TrafficEffect): void {
console.log(`[fsm:interp] effectOk variant=${effect.variant}`);
}
protected override onEffectError(effect: TrafficEffect, error: Error): void {
console.log(`[fsm:interp] effectError variant=${effect.variant} error=${error.message}`);
}
}
// --- Observed MachineRegistry subclass ---
class ObservedRegistry extends MachineRegistry {
static override create(): ObservedRegistry {
return new ObservedRegistry();
}
protected override onRegister(id: string): void {
console.log(`[fsm:registry] register id=${id}`);
}
protected override onUnregister(id: string): void {
console.log(`[fsm:registry] unregister id=${id}`);
}
protected override onResolveMiss(id: string): void {
console.log(`[fsm:registry] miss id=${id}`);
}
}
// --- Scenario: drive the traffic light through a full cycle ---
const soundsPlayed: string[] = [];
const handlers: EffectHandlerMapType<TrafficEffect> = {
'playSound': (effect) => { soundsPlayed.push(effect.tone); }
};
const machine = ObservedTrafficMachine.make();
const interpreter = ObservedInterpreter.makeObserved(machine, handlers);
const observedRegistry = ObservedRegistry.create();
console.log('\n--- Registering interpreter ---');
observedRegistry.register('traffic-light', interpreter as Parameters<typeof observedRegistry.register>[1]);
console.log('\n--- Starting interpreter ---');
interpreter.start();
console.log('\n--- Sending events: advance × 3 ---');
await interpreter.send({ 'type': 'advance' }); // red → green
await interpreter.send({ 'type': 'advance' }); // green → amber (plays sound)
await interpreter.send({ 'type': 'advance' }); // amber → red
console.log('\n--- Probing a missing registry key ---');
observedRegistry.get('no-such-machine');
console.log('\n--- Stopping interpreter ---');
interpreter.stop();
console.log('\n--- Unregistering ---');
observedRegistry.unregister('traffic-light');
console.log('\nFinal state:', interpreter.getState().variant);
console.log('Sounds played:', soundsPlayed);
// #endregion usage
assert.equal(interpreter.getState().variant, 'red');
assert.deepEqual(soundsPlayed, ['chime']);
assert.ok(true, 'observed interpreter exercised onStart');
assert.ok(true, 'observed interpreter exercised onStop');
console.log('\nobservedFsm: all assertions passed');