diff --git a/app/assets/javascripts/lib/utils/finite_state_machine.js b/app/assets/javascripts/lib/utils/finite_state_machine.js new file mode 100644 index 0000000000000000000000000000000000000000..99eeb7cb947b0736494792168dc0ebbc85936d4f --- /dev/null +++ b/app/assets/javascripts/lib/utils/finite_state_machine.js @@ -0,0 +1,101 @@ +/** + * @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); + } +} diff --git a/spec/frontend/lib/utils/finite_state_machine_spec.js b/spec/frontend/lib/utils/finite_state_machine_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..441dd24c758bface7adabc9bf4172bdb5d099edb --- /dev/null +++ b/spec/frontend/lib/utils/finite_state_machine_spec.js @@ -0,0 +1,293 @@ +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'); + }); + }); +});