feat: add actions

This commit is contained in:
Dmitriy Pleshevskiy 2021-08-23 11:12:08 +03:00
parent 0d377ba222
commit 8f3fd1337b
2 changed files with 185 additions and 89 deletions

View file

@ -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);
});

253
fsm.ts
View file

@ -1,64 +1,104 @@
type StateTransitions<Context, StateName extends string> = WeakMap< type StateTransitions<Ctx, SN extends string> = WeakMap<
State<Context, StateName>, State<Ctx, SN>,
WeakSet<State<Context, StateName>> WeakSet<State<Ctx, SN>>
>; >;
type StateOrName<Context, StateName extends string> = type StateOrName<Ctx, SN extends string> =
| State<Context, StateName> | State<Ctx, SN>
| StateName; | SN;
type SourceTransitions<SN extends string> = Array<[SN, Array<SN>]>;
type SourceNamedTransitions<SN extends string> = Array<
[SN, Record<string, SN>]
>;
type SourceActions<SN extends string> = Record<string, Array<[SN, SN]>>;
export const _states = Symbol("states"); export const _states = Symbol("states");
export const _stateTransitions = Symbol("state transitions"); const _transitions = Symbol("transitions");
export const _prevState = Symbol("previous state"); const _actions = Symbol("actions");
export const _currState = Symbol("current state"); const _prevState = Symbol("previous state");
const _currState = Symbol("current state");
export class StateMachineBuilder<Context, StateName extends string = string> { export class StateMachineBuilder<Ctx, SN extends string = string> {
[_states]: Map<StateName, Events<Context, StateName>>; [_states]: Map<SN, Events<Ctx, SN>>;
[_stateTransitions]: Array<[StateName, Array<StateName>]> | undefined; [_transitions]: SourceTransitions<SN> | undefined;
[_actions]: SourceActions<SN> | undefined;
constructor() { constructor() {
this[_states] = new Map(); this[_states] = new Map();
} }
withTransitions(transitions: Array<[StateName, Array<StateName>]>) { withTransitions(
this[_stateTransitions] = transitions; sourceTransitions: SourceTransitions<SN> | SourceNamedTransitions<SN>,
) {
const [t, a] =
(sourceTransitions as Array<[SN, Array<SN> | Record<string, SN>]>)
.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<SN>, SourceActions<SN>],
);
this[_transitions] = t;
this[_actions] = a;
return this; return this;
} }
withStates(names: StateName[], actions?: Events<Context, StateName>) { withStates(names: SN[], actions?: Events<Ctx, SN>) {
names.forEach((name) => this.addStateUnchecked(name, actions)); names.forEach((name) => this.addStateUnchecked(name, actions));
return this; return this;
} }
withState(name: StateName, actions?: Events<Context, StateName>) { withState(name: SN, actions?: Events<Ctx, SN>) {
this.addStateUnchecked(name, actions); this.addStateUnchecked(name, actions);
return this; return this;
} }
private addStateUnchecked( private addStateUnchecked(
name: StateName, name: SN,
actions?: Events<Context, StateName>, actions?: Events<Ctx, SN>,
) { ) {
const oldActions = this[_states].get(name); const oldActions = this[_states].get(name);
return this[_states].set(name, { ...oldActions, ...actions }); return this[_states].set(name, { ...oldActions, ...actions });
} }
build(currentStateName: StateName) { build(currentStateName: SN) {
const states = this.buildStates(); const states = this.buildStates();
const transitions = this.buildTransitions(states); const transitions = this.buildTransitions(states);
const actions = this.buildActions(states);
const currState = validStateFromName(states, currentStateName); const currState = validStateFromName(states, currentStateName);
return new StateMachine<Context, StateName>(states, transitions, currState); return new StateMachine<Ctx, SN>(currState, states, {
transitions,
actions,
});
} }
private buildStates() { private buildStates() {
return Array.from(this[_states].entries()).map((params) => return Array.from(this[_states].entries())
new State(...params) .map((params) => new State(...params));
);
} }
private buildTransitions(states: State<Context, StateName>[]) { private buildTransitions(states: State<Ctx, SN>[]) {
const sourceTransitions = this[_stateTransitions] || []; const sourceTransitions = this[_transitions];
if (!sourceTransitions) return undefined;
return new WeakMap( return new WeakMap(
sourceTransitions.map(([from, toStates]) => [ sourceTransitions.map(([from, toStates]) => [
@ -67,32 +107,63 @@ export class StateMachineBuilder<Context, StateName extends string = string> {
]), ]),
); );
} }
private buildActions(states: State<Ctx, SN>[]): Actions<Ctx, SN> | 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<Context, StateName extends string = string> { interface StateMachineOpts<Ctx, SN extends string> {
[_states]: State<Context, StateName>[]; transitions?: StateTransitions<Ctx, SN>;
actions?: Actions<Ctx, SN>;
}
[_stateTransitions]: StateTransitions<Context, StateName>; type Actions<Ctx, SN extends string> = Map<
string,
Array<[State<Ctx, SN>, State<Ctx, SN>]>
>;
[_prevState]: State<Context, StateName> | undefined; export class StateMachine<Ctx, SN extends string = string> {
[_states]: State<Ctx, SN>[];
[_currState]: State<Context, StateName>; [_transitions]: StateTransitions<Ctx, SN>;
[_actions]: Actions<Ctx, SN>;
[_prevState]: State<Ctx, SN> | undefined;
[_currState]: State<Ctx, SN>;
get currentState() {
return this[_currState];
}
constructor( constructor(
states: State<Context, StateName>[], currentState: State<Ctx, SN>,
transitions: StateTransitions<Context, StateName>, states: State<Ctx, SN>[],
currentState: State<Context, StateName>, { transitions, actions }: StateMachineOpts<Ctx, SN>,
) { ) {
this[_states] = states;
this[_stateTransitions] = transitions;
this[_currState] = currentState; this[_currState] = currentState;
this[_states] = states;
this[_transitions] = transitions || new Map();
this[_actions] = actions || new Map();
} }
async tryChangeState( async tryChangeState(
state: StateOrName<Context, StateName>, state: StateOrName<Ctx, SN>,
context: Context, context: Ctx,
) { ) {
const fromState = validState<Context, StateName>(this[_currState]); const fromState = validState<Ctx, SN>(this.currentState);
const toState = validNormalizedState(this[_states], state); const toState = validNormalizedState(this[_states], state);
if ( if (
@ -112,64 +183,76 @@ export class StateMachine<Context, StateName extends string = string> {
return this[_currState]; return this[_currState];
} }
maybeChangeState(state: StateOrName<Context, StateName>, context: Context) { maybeChangeState(state: StateOrName<Ctx, SN>, context: Ctx) {
return this.tryChangeState(state, context).catch(() => null); return this.tryChangeState(state, context).catch(() => null);
} }
hasTransition(to: StateOrName<Context, StateName>) { hasTransition(to: StateOrName<Ctx, SN>) {
return hasTransition( return hasTransition(
this[_stateTransitions], this[_transitions],
this[_currState], this[_currState],
validNormalizedState(this[_states], to), validNormalizedState(this[_states], to),
); );
} }
allowedTransitionStates() { allowedTransitionStates() {
const fromState = validState(this[_currState]);
return this[_states].filter( return this[_states].filter(
hasTransition.bind(null, this[_stateTransitions], fromState), hasTransition.bind(null, this[_transitions], this[_currState]),
); );
} }
allowedTransitionStateNames() { allowedTransitionStateNames() {
return this.allowedTransitionStates().map(String); 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 _stateName = Symbol("state name");
const _stateEvents = Symbol("state events"); const _stateEvents = Symbol("state events");
interface Events<Context, StateName extends string> { interface Events<Ctx, SN extends string> {
beforeExit?( beforeExit?(
fromState: State<Context, StateName>, fromState: State<Ctx, SN>,
toState: State<Context, StateName>, toState: State<Ctx, SN>,
context: Context, context: Ctx,
): boolean; ): boolean;
onEntry?( onEntry?(
fromState: State<Context, StateName>, fromState: State<Ctx, SN>,
toState: State<Context, StateName>, toState: State<Ctx, SN>,
context: Context, context: Ctx,
): Promise<void> | void; ): Promise<void> | void;
} }
export class State<Context, StateName extends string = string> { export class State<Ctx, SN extends string = string> {
[_stateEvents]: Events<Context, StateName>; [_stateEvents]: Events<Ctx, SN>;
[_stateName]: StateName; [_stateName]: SN;
get name(): StateName { get name(): SN {
return this[_stateName]; return this[_stateName];
} }
constructor(name: StateName, events: Events<Context, StateName> = {}) { constructor(name: SN, events: Events<Ctx, SN> = {}) {
this[_stateName] = name; this[_stateName] = name;
this[_stateEvents] = events; this[_stateEvents] = events;
} }
async entry( async entry(
fromState: State<Context, StateName>, fromState: State<Ctx, SN>,
toState: State<Context, StateName>, toState: State<Ctx, SN>,
context: Context, context: Ctx,
) { ) {
const event = this[_stateEvents].onEntry; const event = this[_stateEvents].onEntry;
if (isFn(event)) { if (isFn(event)) {
@ -177,11 +260,7 @@ export class State<Context, StateName extends string = string> {
} }
} }
exit( exit(fromState: State<Ctx, SN>, toState: State<Ctx, SN>, context: Ctx) {
fromState: State<Context, StateName>,
toState: State<Context, StateName>,
context: Context,
) {
const event = this[_stateEvents].beforeExit; const event = this[_stateEvents].beforeExit;
return isFn(event) ? event(fromState, toState, context) : true; return isFn(event) ? event(fromState, toState, context) : true;
} }
@ -195,53 +274,49 @@ export class State<Context, StateName extends string = string> {
} }
} }
function validNormalizedState<Context, StateName extends string>( function validNormalizedState<Ctx, SN extends string>(
states: State<Context, StateName>[], states: State<Ctx, SN>[],
state: StateOrName<Context, StateName>, state: StateOrName<Ctx, SN>,
) { ) {
return validState<Context, StateName>(normalizeState(states, state)); return validState<Ctx, SN>(normalizeState(states, state));
} }
function normalizeState<Context, StateName extends string>( function normalizeState<Ctx, SN extends string>(
states: State<Context, StateName>[], states: State<Ctx, SN>[],
state: StateOrName<Context, StateName>, state: StateOrName<Ctx, SN>,
): State<Context, StateName> | undefined { ): State<Ctx, SN> | undefined {
return isStr(state) ? stateFromName(states, state) : state; return isStr(state) ? stateFromName(states, state) : state;
} }
function validStateFromName<Context, StateName extends string>( function validStateFromName<Ctx, SN extends string>(
states: State<Context, StateName>[], states: State<Ctx, SN>[],
name: StateName, name: SN,
) { ) {
return validState<Context, StateName>(stateFromName(states, name)); return validState<Ctx, SN>(stateFromName(states, name));
} }
function stateFromName<Context, StateName extends string>( function stateFromName<Ctx, SN extends string>(
states: State<Context, StateName>[], states: State<Ctx, SN>[],
name: StateName, name: SN,
) { ) {
return states.find((state) => state.name === name); return states.find((state) => state.name === name);
} }
function validState<Context, StateName extends string>( function validState<Ctx, SN extends string>(val: unknown): State<Ctx, SN> {
val: unknown, if (!isState<Ctx, SN>(val)) {
): State<Context, StateName> {
if (!isState<Context, StateName>(val)) {
throw new TypeError("an instance of State class is expected"); throw new TypeError("an instance of State class is expected");
} }
return val; return val;
} }
function isState<Context, StateName extends string>( function isState<Ctx, SN extends string>(val: unknown): val is State<Ctx, SN> {
val: unknown,
): val is State<Context, StateName> {
return val instanceof State; return val instanceof State;
} }
function hasTransition<Context, StateName extends string>( function hasTransition<Ctx, SN extends string>(
transitions: StateTransitions<Context, StateName>, transitions: StateTransitions<Ctx, SN>,
from: State<Context, StateName>, from: State<Ctx, SN>,
to: State<Context, StateName>, to: State<Ctx, SN>,
) { ) {
return transitions.get(from)?.has(to) || false; return transitions.get(from)?.has(to) || false;
} }