Skip to content

Commit

Permalink
fix: actions on self transitions
Browse files Browse the repository at this point in the history
  • Loading branch information
ducksoupdev committed Sep 30, 2023
1 parent 1b82dd0 commit 4e19ec1
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 83 deletions.
4 changes: 3 additions & 1 deletion src/state-machine-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ export interface State<TEvent extends EventObject, TState extends Typestate> {
actions?: Actions<TEvent>
}

export type Transition<TEvent extends EventObject, TState extends Typestate> = TState['value'] | {
export interface TransitionObject<TEvent extends EventObject, TState extends Typestate> {
target?: TState['value']
actions?: Actions<TEvent>
cond?: (state: State<TEvent, TState>, event: TEvent) => boolean
}

export type Transition<TEvent extends EventObject, TState extends Typestate> = TState['value'] | TransitionObject<TEvent, TState>

export type ActionFunction<TEvent extends EventObject> = (event?: TEvent['type'] | TEvent) => void

export type Action<TEvent extends EventObject> = string | ActionFunction<TEvent>
Expand Down
161 changes: 88 additions & 73 deletions src/state-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestEvent, TestState> = {
states: {
A: {
on: {}
on: {
NEXT: 'B'
}
},
B: {}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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<TestEvent, TestState> = {
states: {
A: {
on: {
NEXT: 'B'
NEXT: {
target: 'B'
}
}
}
},
B: {}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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<TestEvent, TestState> = {
states: {
A: {
on: {
NEXT: 'B'
}
}
on: {}
},
B: {}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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<TestEvent, TestState> = {
states: {
A: {
on: {
NEXT: 'B'
}
},
B: {}
}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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<TestEvent, TestState> = {
states: {
A: {
on: {
NEXT: {
target: 'B'
}
NEXT: 'B'
}
},
B: {}
}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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', () => {
Expand Down Expand Up @@ -393,99 +393,77 @@ describe('state-machine', () => {
const config: StateMachineConfig<TestEvent, TestState> = {
states: {
A: {
entry: 'A-entry',
exit: 'A-exit',
on: {
NEXT: {
target: 'B',
actions: 'A-NEXT'
}
}
},
B: {
entry: 'B-entry',
exit: 'B-exit'
B: {}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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<TestEvent, TestState> = {
states: {
A: {
on: {
NEXT: {
actions: 'A-NEXT'
}
}
}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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<TestEvent, TestState> = {
states: {
A: {
entry: () => {},
exit: () => {},
on: {
NEXT: {
target: 'B',
actions: () => {}
actions
}
}
},
B: {
entry: () => {},
exit: () => {}
}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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<TestEvent, TestState> = {
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<TestEvent, TestState> = 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', () => {
Expand All @@ -505,4 +483,41 @@ describe('state-machine', () => {
const machine: StateMachineInterface<TestEvent, TestState> = createStateMachine(config)
expect(machine.transition('A', 'NEXT')).toBeUndefined()
})

it('should transition given a condition that returns true', () => {
const config: StateMachineConfig<TestEvent, TestState> = {
states: {
A: {
on: {
NEXT: {
target: 'B',
cond: () => true
}
}
},
B: {}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = 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<TestEvent, TestState> = {
states: {
A: {
on: {
NEXT: {
actions,
cond: () => true
}
}
},
B: {}
}
}
const machine: StateMachineInterface<TestEvent, TestState> = createStateMachine(config)
expect(machine.transition('A', 'NEXT')).toMatchObject({ value: 'A', actions })
})
})
55 changes: 46 additions & 9 deletions src/state-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type State,
type StateMachineConfig,
type StateMachineInterface,
type Transition,
type TransitionObject,
type Typestate
} from './state-machine-types'

Expand All @@ -24,37 +24,74 @@ class StateMachine<TEvent extends EventObject, TState extends Typestate> impleme
return (typeof state === 'string' ? { value: state } : state)
}

private stateExists (state: TState['value'] | State<TEvent, TState>): boolean {
const stateObject = this.toStateObject(state)
return this.config.states[stateObject.value] != null
}

transition (state: TState['value'] | State<TEvent, TState>, event: TEvent['type'] | TEvent): State<TEvent, TState> | 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<TEvent, TState>)
: transition
if (target == null) {

// create the next transition object
const nextTransition: TransitionObject<TEvent, TState> = {
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<TEvent, TState>): Actions<TEvent> {
Expand Down

0 comments on commit 4e19ec1

Please sign in to comment.