diff --git a/src/state-machine-types.ts b/src/state-machine-types.ts index bb9c8a9..49275d1 100644 --- a/src/state-machine-types.ts +++ b/src/state-machine-types.ts @@ -11,12 +11,14 @@ export interface State { actions?: Actions } -export type Transition = TState['value'] | { +export interface TransitionObject { target?: TState['value'] actions?: Actions cond?: (state: State, event: TEvent) => boolean } +export type Transition = TState['value'] | TransitionObject + export type ActionFunction = (event?: TEvent['type'] | TEvent) => void export type Action = string | ActionFunction diff --git a/src/state-machine.test.ts b/src/state-machine.test.ts index 1401d80..967f980 100644 --- a/src/state-machine.test.ts +++ b/src/state-machine.test.ts @@ -74,81 +74,81 @@ describe('state-machine', () => { expect(machine.transition(machine.initialState, 'NEXT')).toBeUndefined() }) - it('should not transition when the event is not defined', () => { + it('should create a state machine given string transitions', () => { const config: StateMachineConfig = { states: { A: { - on: {} + on: { + NEXT: 'B' + } }, B: {} } } const machine: StateMachineInterface = createStateMachine(config) expect(machine.initialState).toMatchObject({ value: 'A' }) - expect(machine.transition(machine.initialState, 'NEXT')).toBeUndefined() + expect(machine.transition(machine.initialState, 'NEXT')).toMatchObject({ value: 'B' }) }) - it('should not transition given an invalid state', () => { + it('should create a state machine given object transitions', () => { const config: StateMachineConfig = { states: { A: { on: { - NEXT: 'B' + NEXT: { + target: 'B' + } } - } + }, + B: {} } } const machine: StateMachineInterface = createStateMachine(config) - expect(machine.transition('D', 'NEXT')).toBeUndefined() + expect(machine.initialState).toMatchObject({ value: 'A' }) + expect(machine.transition(machine.initialState, 'NEXT')).toMatchObject({ value: 'B' }) }) - it('should not transition when state is not defined', () => { + it('should not transition when the event is not defined', () => { const config: StateMachineConfig = { states: { A: { - on: { - NEXT: 'B' - } - } + on: {} + }, + B: {} } } const machine: StateMachineInterface = createStateMachine(config) expect(machine.initialState).toMatchObject({ value: 'A' }) - expect(machine.transition('A', 'NEXT')).toBeUndefined() + expect(machine.transition(machine.initialState, 'NEXT')).toBeUndefined() }) - it('should create a state machine given string transitions', () => { + it('should not transition given an invalid state', () => { const config: StateMachineConfig = { states: { A: { on: { NEXT: 'B' } - }, - B: {} + } } } const machine: StateMachineInterface = createStateMachine(config) - expect(machine.initialState).toMatchObject({ value: 'A' }) - expect(machine.transition(machine.initialState, 'NEXT')).toMatchObject({ value: 'B' }) + expect(machine.transition('D', 'NEXT')).toBeUndefined() }) - it('should create a state machine given object transitions', () => { + it('should not transition when the target state is not defined', () => { const config: StateMachineConfig = { states: { A: { on: { - NEXT: { - target: 'B' - } + NEXT: 'B' } - }, - B: {} + } } } const machine: StateMachineInterface = createStateMachine(config) expect(machine.initialState).toMatchObject({ value: 'A' }) - expect(machine.transition(machine.initialState, 'NEXT')).toMatchObject({ value: 'B' }) + expect(machine.transition('A', 'NEXT')).toBeUndefined() }) it('should not transition given an empty target', () => { @@ -393,8 +393,6 @@ describe('state-machine', () => { const config: StateMachineConfig = { states: { A: { - entry: 'A-entry', - exit: 'A-exit', on: { NEXT: { target: 'B', @@ -402,90 +400,70 @@ describe('state-machine', () => { } } }, - B: { - entry: 'B-entry', - exit: 'B-exit' + B: {} + } + } + const machine: StateMachineInterface = createStateMachine(config) + expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'B', actions: 'A-NEXT' }) + }) + + it('should return actions as strings given an empty target', () => { + const config: StateMachineConfig = { + states: { + A: { + on: { + NEXT: { + actions: 'A-NEXT' + } + } } } } const machine: StateMachineInterface = createStateMachine(config) - expect(machine.entryActions('A')).toEqual('A-entry') - expect(machine.entryActions('B')).toEqual('B-entry') - expect(machine.exitActions('A')).toEqual('A-exit') - expect(machine.exitActions('B')).toEqual('B-exit') - expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'B' }) - expect(machine.transition('B', 'NEXT')).toBeUndefined() + expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'A', actions: 'A-NEXT' }) }) it('should return actions as functions', () => { + const actions = (): void => {} const config: StateMachineConfig = { states: { A: { - entry: () => {}, - exit: () => {}, on: { NEXT: { target: 'B', - actions: () => {} + actions } } }, B: { - entry: () => {}, - exit: () => {} } } } const machine: StateMachineInterface = createStateMachine(config) - expect(machine.entryActions('A')).toEqual(config.states.A.entry) - expect(machine.entryActions('B')).toEqual(config.states.B.entry) - expect(machine.exitActions('A')).toEqual(config.states.A.exit) - expect(machine.exitActions('B')).toEqual(config.states.B.exit) - expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'B' }) - expect(machine.transition('B', 'NEXT')).toBeUndefined() + expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'B', actions }) }) it('should return actions as array of strings and functions', () => { + const actions = [ + 'A-NEXT-1', + (): void => {} + ] const config: StateMachineConfig = { states: { A: { - entry: [ - 'A-entry-1', - () => {} - ], - exit: [ - 'A-exit-1', - () => {} - ], on: { NEXT: { target: 'B', - actions: [ - 'A-NEXT-1', - () => {} - ] + actions } } }, B: { - entry: [ - 'B-entry-1', - () => {} - ], - exit: [ - 'B-exit-1', - () => {} - ] } } } const machine: StateMachineInterface = createStateMachine(config) - expect(machine.entryActions('A')).toEqual(config.states.A.entry) - expect(machine.entryActions('B')).toEqual(config.states.B.entry) - expect(machine.exitActions('A')).toEqual(config.states.A.exit) - expect(machine.exitActions('B')).toEqual(config.states.B.exit) - expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'B' }) - expect(machine.transition('B', 'NEXT')).toBeUndefined() + expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'B', actions }) }) it('should not transition given a condition that returns false', () => { @@ -505,4 +483,41 @@ describe('state-machine', () => { const machine: StateMachineInterface = createStateMachine(config) expect(machine.transition('A', 'NEXT')).toBeUndefined() }) + + it('should transition given a condition that returns true', () => { + const config: StateMachineConfig = { + states: { + A: { + on: { + NEXT: { + target: 'B', + cond: () => true + } + } + }, + B: {} + } + } + const machine: StateMachineInterface = createStateMachine(config) + expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'B' }) + }) + + it('should self transition given a condition that returns true', () => { + const actions = (): void => {} + const config: StateMachineConfig = { + states: { + A: { + on: { + NEXT: { + actions, + cond: () => true + } + } + }, + B: {} + } + } + const machine: StateMachineInterface = createStateMachine(config) + expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'A', actions }) + }) }) diff --git a/src/state-machine.ts b/src/state-machine.ts index afb21e4..1dee836 100644 --- a/src/state-machine.ts +++ b/src/state-machine.ts @@ -4,7 +4,7 @@ import { type State, type StateMachineConfig, type StateMachineInterface, - type Transition, + type TransitionObject, type Typestate } from './state-machine-types' @@ -24,37 +24,74 @@ class StateMachine impleme return (typeof state === 'string' ? { value: state } : state) } + private stateExists (state: TState['value'] | State): boolean { + const stateObject = this.toStateObject(state) + return this.config.states[stateObject.value] != null + } + transition (state: TState['value'] | State, event: TEvent['type'] | TEvent): State | undefined { + // don't transition if the state is undefined if (state == null) { return undefined } const eventObject = this.toEventObject(event) const stateObject = this.toStateObject(state) const stateConfig = this.config.states[stateObject.value] + + // don't transition if the state is not defined in the config if (stateConfig == null) { return undefined } + + // don't transition if transitions are not defined in the config if (stateConfig.on == null) { return undefined } + const transition = stateConfig.on[eventObject.type as TEvent['type']] + + // don't transition if the event is not defined in the config if (transition == null) { return undefined } - const { target, actions, cond } = typeof transition === 'string' - ? ({ target: transition, actions: undefined, cond: undefined } satisfies Transition) - : transition - if (target == null) { + + // create the next transition object + const nextTransition: TransitionObject = { + target: stateObject.value, // default to self-transition + actions: undefined, + cond: undefined + } + if (typeof transition === 'string') { + nextTransition.target = this.stateExists(transition) ? transition : undefined + } + if (typeof transition === 'object') { + nextTransition.target = transition.target != null && this.stateExists(transition.target) ? transition.target : stateObject.value + if (transition.actions != null) { + nextTransition.actions = transition.actions + } + if (transition.cond != null) { + nextTransition.cond = transition.cond + } + } + + // don't transition if the target is not defined for the transition + if (nextTransition.target == null) { return undefined } - const targetState = this.config.states[target] - if (targetState == null) { + + // don't transition if the target is the same as the current state and there are no actions + if (nextTransition.target === stateObject.value && nextTransition.actions == null) { return undefined } - if (cond != null && !cond(stateObject, eventObject)) { + + // create the next state object + const nextStateObject = this.toStateObject({ value: nextTransition.target, actions: nextTransition.actions }) + + // don't transition if the condition is not met + if (nextTransition.cond != null && !nextTransition.cond(stateObject, eventObject)) { return undefined } - return this.toStateObject(actions != null ? { value: target, actions } : target) + return nextStateObject } exitActions (state: TState['value'] | State): Actions {