Commit 4aa58875 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'rfc/fsm' into 'master'

Add a simple Finite State Machine implementation

See merge request gitlab-org/gitlab!32365
parents 825a632b 5c5dcd30
/**
* @module finite_state_machine
*/
/**
* The states to be used with state machine definitions
* @typedef {Object} FiniteStateMachineStates
* @property {!Object} ANY_KEY - Any key that maps to a known state
* @property {!Object} ANY_KEY.on - A dictionary of transition events for the ANY_KEY state that map to a different state
* @property {!String} ANY_KEY.on.ANY_EVENT - The resulting state that the machine should end at
*/
/**
* An object whose minimum definition defined here can be used to guard UI state transitions
* @typedef {Object} StatelessFiniteStateMachineDefinition
* @property {FiniteStateMachineStates} states
*/
/**
* An object whose minimum definition defined here can be used to create a live finite state machine
* @typedef {Object} LiveFiniteStateMachineDefinition
* @property {String} initial - The initial state for this machine
* @property {FiniteStateMachineStates} states
*/
/**
* An object that allows interacting with a stateful, live finite state machine
* @typedef {Object} LiveStateMachine
* @property {String} value - The current state of this machine
* @property {Object} states - The states from when the machine definition was constructed
* @property {Function} is - {@link module:finite_state_machine~is LiveStateMachine.is}
* @property {Function} send - {@link module:finite_state_machine~send LiveStatemachine.send}
*/
// This is not user-facing functionality
/* eslint-disable @gitlab/require-i18n-strings */
function hasKeys(object, keys) {
return keys.every((key) => Object.keys(object).includes(key));
}
/**
* Get an updated state given a machine definition, a starting state, and a transition event
* @param {StatelessFiniteStateMachineDefinition} definition
* @param {String} current - The current known state
* @param {String} event - A transition event
* @returns {String} A state value
*/
export function transition(definition, current, event) {
return definition?.states?.[current]?.on[event] || current;
}
function startMachine({ states, initial } = {}) {
let current = initial;
return {
/**
* A convenience function to test arbitrary input against the machine's current state
* @param {String} testState - The value to test against the machine's current state
*/
is(testState) {
return current === testState;
},
/**
* A function to transition the live state machine using an arbitrary event
* @param {String} event - The event to send to the machine
* @returns {String} A string representing the current state. Note this may not have changed if the current state + transition event combination are not valid.
*/
send(event) {
current = transition({ states }, current, event);
return current;
},
get value() {
return current;
},
set value(forcedState) {
current = forcedState;
},
states,
};
}
/**
* Create a live state machine
* @param {LiveFiniteStateMachineDefinition} definition
* @returns {LiveStateMachine} A live state machine
*/
export function machine(definition) {
if (!hasKeys(definition, ['initial', 'states'])) {
throw new Error(
'A state machine must have an initial state (`.initial`) and a dictionary of possible states (`.states`)',
);
} else if (!hasKeys(definition.states, [definition.initial])) {
throw new Error(
`Cannot initialize the state machine to state '${definition.initial}'. Is that one of the machine's defined states?`,
);
} else {
return startMachine(definition);
}
}
import { machine, transition } from '~/lib/utils/finite_state_machine';
describe('Finite State Machine', () => {
const STATE_IDLE = 'idle';
const STATE_LOADING = 'loading';
const STATE_ERRORED = 'errored';
const TRANSITION_START_LOAD = 'START_LOAD';
const TRANSITION_LOAD_ERROR = 'LOAD_ERROR';
const TRANSITION_LOAD_SUCCESS = 'LOAD_SUCCESS';
const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR';
const definition = {
initial: STATE_IDLE,
states: {
[STATE_IDLE]: {
on: {
[TRANSITION_START_LOAD]: STATE_LOADING,
},
},
[STATE_LOADING]: {
on: {
[TRANSITION_LOAD_ERROR]: STATE_ERRORED,
[TRANSITION_LOAD_SUCCESS]: STATE_IDLE,
},
},
[STATE_ERRORED]: {
on: {
[TRANSITION_ACKNOWLEDGE_ERROR]: STATE_IDLE,
[TRANSITION_START_LOAD]: STATE_LOADING,
},
},
},
};
describe('machine', () => {
const STATE_IMPOSSIBLE = 'impossible';
const badDefinition = {
init: definition.initial,
badKeyShouldBeStates: definition.states,
};
const unstartableDefinition = {
initial: STATE_IMPOSSIBLE,
states: definition.states,
};
let liveMachine;
beforeEach(() => {
liveMachine = machine(definition);
});
it('throws an error if the machine definition is invalid', () => {
expect(() => machine(badDefinition)).toThrowError(
'A state machine must have an initial state (`.initial`) and a dictionary of possible states (`.states`)',
);
});
it('throws an error if the initial state is invalid', () => {
expect(() => machine(unstartableDefinition)).toThrowError(
`Cannot initialize the state machine to state '${STATE_IMPOSSIBLE}'. Is that one of the machine's defined states?`,
);
});
it.each`
partOfMachine | equals | description | eqDescription
${'keys'} | ${['is', 'send', 'value', 'states']} | ${'keys'} | ${'the correct array'}
${'is'} | ${expect.any(Function)} | ${'`is` property'} | ${'a function'}
${'send'} | ${expect.any(Function)} | ${'`send` property'} | ${'a function'}
${'value'} | ${definition.initial} | ${'`value` property'} | ${'the same as the `initial` value of the machine definition'}
${'states'} | ${definition.states} | ${'`states` property'} | ${'the same as the `states` value of the machine definition'}
`("The machine's $description should be $eqDescription", ({ partOfMachine, equals }) => {
const test = partOfMachine === 'keys' ? Object.keys(liveMachine) : liveMachine[partOfMachine];
expect(test).toEqual(equals);
});
it.each`
initialState | transitionEvent | expectedState
${definition.initial} | ${TRANSITION_START_LOAD} | ${STATE_LOADING}
${STATE_LOADING} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED}
${STATE_ERRORED} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLE}
${STATE_IDLE} | ${TRANSITION_START_LOAD} | ${STATE_LOADING}
${STATE_LOADING} | ${TRANSITION_LOAD_SUCCESS} | ${STATE_IDLE}
`(
'properly steps from $initialState to $expectedState when the event "$transitionEvent" is sent',
({ initialState, transitionEvent, expectedState }) => {
liveMachine.value = initialState;
liveMachine.send(transitionEvent);
expect(liveMachine.is(expectedState)).toBe(true);
expect(liveMachine.value).toBe(expectedState);
},
);
it.each`
initialState | transitionEvent
${STATE_IDLE} | ${TRANSITION_ACKNOWLEDGE_ERROR}
${STATE_IDLE} | ${TRANSITION_LOAD_SUCCESS}
${STATE_IDLE} | ${TRANSITION_LOAD_ERROR}
${STATE_IDLE} | ${'RANDOM_FOO'}
${STATE_LOADING} | ${TRANSITION_START_LOAD}
${STATE_LOADING} | ${TRANSITION_ACKNOWLEDGE_ERROR}
${STATE_LOADING} | ${'RANDOM_FOO'}
${STATE_ERRORED} | ${TRANSITION_LOAD_ERROR}
${STATE_ERRORED} | ${TRANSITION_LOAD_SUCCESS}
${STATE_ERRORED} | ${'RANDOM_FOO'}
`(
`does not perform any transition if the machine can't move from "$initialState" using the "$transitionEvent" event`,
({ initialState, transitionEvent }) => {
liveMachine.value = initialState;
liveMachine.send(transitionEvent);
expect(liveMachine.is(initialState)).toBe(true);
expect(liveMachine.value).toBe(initialState);
},
);
describe('send', () => {
it.each`
startState | transitionEvent | result
${STATE_IDLE} | ${TRANSITION_START_LOAD} | ${STATE_LOADING}
${STATE_LOADING} | ${TRANSITION_LOAD_SUCCESS} | ${STATE_IDLE}
${STATE_LOADING} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED}
${STATE_ERRORED} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLE}
${STATE_ERRORED} | ${TRANSITION_START_LOAD} | ${STATE_LOADING}
`(
'successfully transitions to $result from $startState when the transition $transitionEvent is received',
({ startState, transitionEvent, result }) => {
liveMachine.value = startState;
expect(liveMachine.send(transitionEvent)).toEqual(result);
},
);
it.each`
startState | transitionEvent
${STATE_IDLE} | ${TRANSITION_ACKNOWLEDGE_ERROR}
${STATE_IDLE} | ${TRANSITION_LOAD_SUCCESS}
${STATE_IDLE} | ${TRANSITION_LOAD_ERROR}
${STATE_IDLE} | ${'RANDOM_FOO'}
${STATE_LOADING} | ${TRANSITION_START_LOAD}
${STATE_LOADING} | ${TRANSITION_ACKNOWLEDGE_ERROR}
${STATE_LOADING} | ${'RANDOM_FOO'}
${STATE_ERRORED} | ${TRANSITION_LOAD_ERROR}
${STATE_ERRORED} | ${TRANSITION_LOAD_SUCCESS}
${STATE_ERRORED} | ${'RANDOM_FOO'}
`(
'remains as $startState if an undefined transition ($transitionEvent) is received',
({ startState, transitionEvent }) => {
liveMachine.value = startState;
expect(liveMachine.send(transitionEvent)).toEqual(startState);
},
);
describe('detached', () => {
it.each`
startState | transitionEvent | result
${STATE_IDLE} | ${TRANSITION_START_LOAD} | ${STATE_LOADING}
${STATE_LOADING} | ${TRANSITION_LOAD_SUCCESS} | ${STATE_IDLE}
${STATE_LOADING} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED}
${STATE_ERRORED} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLE}
${STATE_ERRORED} | ${TRANSITION_START_LOAD} | ${STATE_LOADING}
`(
'successfully transitions to $result from $startState when the transition $transitionEvent is received outside the context of the machine',
({ startState, transitionEvent, result }) => {
const liveSend = machine({
...definition,
initial: startState,
}).send;
expect(liveSend(transitionEvent)).toEqual(result);
},
);
it.each`
startState | transitionEvent
${STATE_IDLE} | ${TRANSITION_ACKNOWLEDGE_ERROR}
${STATE_IDLE} | ${TRANSITION_LOAD_SUCCESS}
${STATE_IDLE} | ${TRANSITION_LOAD_ERROR}
${STATE_IDLE} | ${'RANDOM_FOO'}
${STATE_LOADING} | ${TRANSITION_START_LOAD}
${STATE_LOADING} | ${TRANSITION_ACKNOWLEDGE_ERROR}
${STATE_LOADING} | ${'RANDOM_FOO'}
${STATE_ERRORED} | ${TRANSITION_LOAD_ERROR}
${STATE_ERRORED} | ${TRANSITION_LOAD_SUCCESS}
${STATE_ERRORED} | ${'RANDOM_FOO'}
`(
'remains as $startState if an undefined transition ($transitionEvent) is received',
({ startState, transitionEvent }) => {
const liveSend = machine({
...definition,
initial: startState,
}).send;
expect(liveSend(transitionEvent)).toEqual(startState);
},
);
});
});
describe('is', () => {
it.each`
bool | test | actual
${true} | ${STATE_IDLE} | ${STATE_IDLE}
${false} | ${STATE_LOADING} | ${STATE_IDLE}
${false} | ${STATE_ERRORED} | ${STATE_IDLE}
${true} | ${STATE_LOADING} | ${STATE_LOADING}
${false} | ${STATE_IDLE} | ${STATE_LOADING}
${false} | ${STATE_ERRORED} | ${STATE_LOADING}
${true} | ${STATE_ERRORED} | ${STATE_ERRORED}
${false} | ${STATE_IDLE} | ${STATE_ERRORED}
${false} | ${STATE_LOADING} | ${STATE_ERRORED}
`(
'returns "$bool" for "$test" when the current state is "$actual"',
({ bool, test, actual }) => {
liveMachine = machine({
...definition,
initial: actual,
});
expect(liveMachine.is(test)).toEqual(bool);
},
);
describe('detached', () => {
it.each`
bool | test | actual
${true} | ${STATE_IDLE} | ${STATE_IDLE}
${false} | ${STATE_LOADING} | ${STATE_IDLE}
${false} | ${STATE_ERRORED} | ${STATE_IDLE}
${true} | ${STATE_LOADING} | ${STATE_LOADING}
${false} | ${STATE_IDLE} | ${STATE_LOADING}
${false} | ${STATE_ERRORED} | ${STATE_LOADING}
${true} | ${STATE_ERRORED} | ${STATE_ERRORED}
${false} | ${STATE_IDLE} | ${STATE_ERRORED}
${false} | ${STATE_LOADING} | ${STATE_ERRORED}
`(
'returns "$bool" for "$test" when the current state is "$actual"',
({ bool, test, actual }) => {
const liveIs = machine({
...definition,
initial: actual,
}).is;
expect(liveIs(test)).toEqual(bool);
},
);
});
});
});
describe('transition', () => {
it.each`
startState | transitionEvent | result
${STATE_IDLE} | ${TRANSITION_START_LOAD} | ${STATE_LOADING}
${STATE_LOADING} | ${TRANSITION_LOAD_SUCCESS} | ${STATE_IDLE}
${STATE_LOADING} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED}
${STATE_ERRORED} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLE}
${STATE_ERRORED} | ${TRANSITION_START_LOAD} | ${STATE_LOADING}
`(
'successfully transitions to $result from $startState when the transition $transitionEvent is received',
({ startState, transitionEvent, result }) => {
expect(transition(definition, startState, transitionEvent)).toEqual(result);
},
);
it.each`
startState | transitionEvent
${STATE_IDLE} | ${TRANSITION_ACKNOWLEDGE_ERROR}
${STATE_IDLE} | ${TRANSITION_LOAD_SUCCESS}
${STATE_IDLE} | ${TRANSITION_LOAD_ERROR}
${STATE_IDLE} | ${'RANDOM_FOO'}
${STATE_LOADING} | ${TRANSITION_START_LOAD}
${STATE_LOADING} | ${TRANSITION_ACKNOWLEDGE_ERROR}
${STATE_LOADING} | ${'RANDOM_FOO'}
${STATE_ERRORED} | ${TRANSITION_LOAD_ERROR}
${STATE_ERRORED} | ${TRANSITION_LOAD_SUCCESS}
${STATE_ERRORED} | ${'RANDOM_FOO'}
`(
'remains as $startState if an undefined transition ($transitionEvent) is received',
({ startState, transitionEvent }) => {
expect(transition(definition, startState, transitionEvent)).toEqual(startState);
},
);
it('remains as the provided starting state if it is an unrecognized state', () => {
expect(transition(definition, 'RANDOM_FOO', TRANSITION_START_LOAD)).toEqual('RANDOM_FOO');
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment