From 8f3fd1337bc3f807d7c08425b0b66574169d42d8 Mon Sep 17 00:00:00 2001 From: Dmitriy Pleshevskiy Date: Mon, 23 Aug 2021 11:12:08 +0300 Subject: [PATCH] feat: add actions --- fsm.test.ts | 21 +++++ fsm.ts | 253 ++++++++++++++++++++++++++++++++++------------------ 2 files changed, 185 insertions(+), 89 deletions(-) diff --git a/fsm.test.ts b/fsm.test.ts index ba3a8f6..a02498c 100644 --- a/fsm.test.ts +++ b/fsm.test.ts @@ -208,3 +208,24 @@ Deno.test("should reuse one builder for many entities", () => { ); } }); + +Deno.test("should trigger builded transition actions", async () => { + const states = ["locked", "unlocked"] as const; + const [locked, unlocked] = states; + + const sm = new fsm.StateMachineBuilder() + .withStates([locked, unlocked]) + .withTransitions([ + [locked, { coin: unlocked }], + [unlocked, { push: locked }], + ]) + .build(locked); + + const [lockedState, unlockedState] = sm[fsm._states]; + + assertEquals(await sm.trigger("coin", {}), unlockedState); + assertEquals(await sm.trigger("coin", {}), unlockedState); + + assertEquals(await sm.trigger("push", {}), lockedState); + assertEquals(await sm.trigger("push", {}), lockedState); +}); diff --git a/fsm.ts b/fsm.ts index db374c7..6ec3e32 100644 --- a/fsm.ts +++ b/fsm.ts @@ -1,64 +1,104 @@ -type StateTransitions = WeakMap< - State, - WeakSet> +type StateTransitions = WeakMap< + State, + WeakSet> >; -type StateOrName = - | State - | StateName; +type StateOrName = + | State + | SN; + +type SourceTransitions = Array<[SN, Array]>; +type SourceNamedTransitions = Array< + [SN, Record] +>; +type SourceActions = Record>; export const _states = Symbol("states"); -export const _stateTransitions = Symbol("state transitions"); -export const _prevState = Symbol("previous state"); -export const _currState = Symbol("current state"); +const _transitions = Symbol("transitions"); +const _actions = Symbol("actions"); +const _prevState = Symbol("previous state"); +const _currState = Symbol("current state"); -export class StateMachineBuilder { - [_states]: Map>; +export class StateMachineBuilder { + [_states]: Map>; - [_stateTransitions]: Array<[StateName, Array]> | undefined; + [_transitions]: SourceTransitions | undefined; + + [_actions]: SourceActions | undefined; constructor() { this[_states] = new Map(); } - withTransitions(transitions: Array<[StateName, Array]>) { - this[_stateTransitions] = transitions; + withTransitions( + sourceTransitions: SourceTransitions | SourceNamedTransitions, + ) { + const [t, a] = + (sourceTransitions as Array<[SN, Array | Record]>) + .reduce( + ([transitions, actions], [fromState, sources]) => { + const toStates = Array.isArray(sources) + ? sources + : Object.values(sources); + transitions.push([fromState, toStates]); + + if (!Array.isArray(sources)) { + Object.entries(sources).forEach(([actionName, toState]) => { + const actionTransitions = actions[actionName] || []; + actions[actionName] = [ + ...actionTransitions, + [fromState, toState], + ]; + }); + } + + return [transitions, actions]; + }, + [[], {}] as [SourceTransitions, SourceActions], + ); + + this[_transitions] = t; + this[_actions] = a; return this; } - withStates(names: StateName[], actions?: Events) { + withStates(names: SN[], actions?: Events) { names.forEach((name) => this.addStateUnchecked(name, actions)); return this; } - withState(name: StateName, actions?: Events) { + withState(name: SN, actions?: Events) { this.addStateUnchecked(name, actions); return this; } private addStateUnchecked( - name: StateName, - actions?: Events, + name: SN, + actions?: Events, ) { const oldActions = this[_states].get(name); return this[_states].set(name, { ...oldActions, ...actions }); } - build(currentStateName: StateName) { + build(currentStateName: SN) { const states = this.buildStates(); const transitions = this.buildTransitions(states); + const actions = this.buildActions(states); const currState = validStateFromName(states, currentStateName); - return new StateMachine(states, transitions, currState); + return new StateMachine(currState, states, { + transitions, + actions, + }); } private buildStates() { - return Array.from(this[_states].entries()).map((params) => - new State(...params) - ); + return Array.from(this[_states].entries()) + .map((params) => new State(...params)); } - private buildTransitions(states: State[]) { - const sourceTransitions = this[_stateTransitions] || []; + private buildTransitions(states: State[]) { + const sourceTransitions = this[_transitions]; + if (!sourceTransitions) return undefined; return new WeakMap( sourceTransitions.map(([from, toStates]) => [ @@ -67,32 +107,63 @@ export class StateMachineBuilder { ]), ); } + + private buildActions(states: State[]): Actions | undefined { + const actions = this[_actions]; + if (!actions) return undefined; + return new Map( + Object.entries(actions).map(([actionName, variants]) => [ + actionName, + variants.map(([fromState, toState]) => [ + validStateFromName(states, fromState), + validStateFromName(states, toState), + ]), + ]), + ); + } } -export class StateMachine { - [_states]: State[]; +interface StateMachineOpts { + transitions?: StateTransitions; + actions?: Actions; +} - [_stateTransitions]: StateTransitions; +type Actions = Map< + string, + Array<[State, State]> +>; - [_prevState]: State | undefined; +export class StateMachine { + [_states]: State[]; - [_currState]: State; + [_transitions]: StateTransitions; + + [_actions]: Actions; + + [_prevState]: State | undefined; + + [_currState]: State; + + get currentState() { + return this[_currState]; + } constructor( - states: State[], - transitions: StateTransitions, - currentState: State, + currentState: State, + states: State[], + { transitions, actions }: StateMachineOpts, ) { - this[_states] = states; - this[_stateTransitions] = transitions; this[_currState] = currentState; + this[_states] = states; + this[_transitions] = transitions || new Map(); + this[_actions] = actions || new Map(); } async tryChangeState( - state: StateOrName, - context: Context, + state: StateOrName, + context: Ctx, ) { - const fromState = validState(this[_currState]); + const fromState = validState(this.currentState); const toState = validNormalizedState(this[_states], state); if ( @@ -112,64 +183,76 @@ export class StateMachine { return this[_currState]; } - maybeChangeState(state: StateOrName, context: Context) { + maybeChangeState(state: StateOrName, context: Ctx) { return this.tryChangeState(state, context).catch(() => null); } - hasTransition(to: StateOrName) { + hasTransition(to: StateOrName) { return hasTransition( - this[_stateTransitions], + this[_transitions], this[_currState], validNormalizedState(this[_states], to), ); } allowedTransitionStates() { - const fromState = validState(this[_currState]); return this[_states].filter( - hasTransition.bind(null, this[_stateTransitions], fromState), + hasTransition.bind(null, this[_transitions], this[_currState]), ); } allowedTransitionStateNames() { return this.allowedTransitionStates().map(String); } + + trigger(actionName: string, context: Ctx) { + const currState = this[_currState]; + + const variants = this[_actions]?.get(actionName); + if (!variants) return currState; + + const [, toState] = + variants.find(([fromState]) => fromState === currState) || []; + if (!toState) return currState; + + return this.tryChangeState(toState, context); + } } const _stateName = Symbol("state name"); const _stateEvents = Symbol("state events"); -interface Events { +interface Events { beforeExit?( - fromState: State, - toState: State, - context: Context, + fromState: State, + toState: State, + context: Ctx, ): boolean; onEntry?( - fromState: State, - toState: State, - context: Context, + fromState: State, + toState: State, + context: Ctx, ): Promise | void; } -export class State { - [_stateEvents]: Events; +export class State { + [_stateEvents]: Events; - [_stateName]: StateName; + [_stateName]: SN; - get name(): StateName { + get name(): SN { return this[_stateName]; } - constructor(name: StateName, events: Events = {}) { + constructor(name: SN, events: Events = {}) { this[_stateName] = name; this[_stateEvents] = events; } async entry( - fromState: State, - toState: State, - context: Context, + fromState: State, + toState: State, + context: Ctx, ) { const event = this[_stateEvents].onEntry; if (isFn(event)) { @@ -177,11 +260,7 @@ export class State { } } - exit( - fromState: State, - toState: State, - context: Context, - ) { + exit(fromState: State, toState: State, context: Ctx) { const event = this[_stateEvents].beforeExit; return isFn(event) ? event(fromState, toState, context) : true; } @@ -195,53 +274,49 @@ export class State { } } -function validNormalizedState( - states: State[], - state: StateOrName, +function validNormalizedState( + states: State[], + state: StateOrName, ) { - return validState(normalizeState(states, state)); + return validState(normalizeState(states, state)); } -function normalizeState( - states: State[], - state: StateOrName, -): State | undefined { +function normalizeState( + states: State[], + state: StateOrName, +): State | undefined { return isStr(state) ? stateFromName(states, state) : state; } -function validStateFromName( - states: State[], - name: StateName, +function validStateFromName( + states: State[], + name: SN, ) { - return validState(stateFromName(states, name)); + return validState(stateFromName(states, name)); } -function stateFromName( - states: State[], - name: StateName, +function stateFromName( + states: State[], + name: SN, ) { return states.find((state) => state.name === name); } -function validState( - val: unknown, -): State { - if (!isState(val)) { +function validState(val: unknown): State { + if (!isState(val)) { throw new TypeError("an instance of State class is expected"); } return val; } -function isState( - val: unknown, -): val is State { +function isState(val: unknown): val is State { return val instanceof State; } -function hasTransition( - transitions: StateTransitions, - from: State, - to: State, +function hasTransition( + transitions: StateTransitions, + from: State, + to: State, ) { return transitions.get(from)?.has(to) || false; }