UNPKG

state-transducer

Version:

Extended Hierarchical State Transducer library

733 lines (624 loc) 25.1 kB
// Ramda fns import { ACTION_FACTORY_DESC, DEEP, ENTRY_ACTION_FACTORY_DESC, FUNCTION_THREW_ERROR, HISTORY_PREFIX, HISTORY_STATE_NAME, INIT_EVENT, INIT_STATE, INVALID_ACTION_FACTORY_EXECUTED, INVALID_PREDICATE_EXECUTED, NO_OUTPUT, PREDICATE_DESC, SHALLOW, WRONG_EVENT_FORMAT_ERROR } from "./properties" import { objectTreeLenses, PRE_ORDER, traverseObj } from "fp-rosetree" export const noop = () => {}; export const emptyConsole = { log: noop, warn: noop, info: noop, debug: noop, error: noop, trace: noop }; export function isBoolean(x){ return typeof x === 'boolean' } export function isFunction(x) { return typeof x === 'function' } export function isControlState(x) { return x && typeof x === 'string' || isHistoryControlState(x) } export function isEvent(x) { return x && typeof x === 'string' } export function isActionFactory(x) { return x && typeof x === 'function' } export function make_states(stateList) { return stateList.reduce((acc, state) => { acc[state] = ""; return acc }, {}) } export function make_events(eventList) { return eventList } /** * Returns the name of the function as taken from its source definition. * For instance, function do_something(){} -> "do_something" * @param fn {Function} * @returns {String} */ export function get_fn_name(fn) { const tokens = /^[\s\r\n]*function[\s\r\n]*([^\(\s\r\n]*?)[\s\r\n]*\([^\)\s\r\n]*\)[\s\r\n]*\{((?:[^}]*\}?)+)\}\s*$/ .exec(fn.toString()); return tokens[1]; } export function wrap(str) { return ['-', str, '-'].join(""); } export function times(fn, n) { return Array.apply(null, { length: n }).map(Number.call, Number).map(fn) } export function always(x) {return x} export function keys(obj) {return Object.keys(obj)} export function merge(a, b) { return Object.assign({}, a, b) } // Contracts export function is_history_transition(transition) { return transition.to.startsWith(HISTORY_PREFIX) } export function is_entry_transition(transition) { return transition.event === INIT_EVENT } export function is_from_control_state(controlState) { return function (transition) { return transition.from === controlState } } export function is_to_history_control_state_of(controlState) { return function (transition) { return is_history_control_state_of(controlState, transition.to) } } export function is_history_control_state_of(controlState, state) { return state.substring(HISTORY_PREFIX.length) === controlState } export function format_transition_label(_event, predicate, action) { const event = _event || ''; return predicate && action ? `${event} [${predicate.name}] / ${action.name}` : predicate ? `${event} [${predicate.name}]}` : action ? `${event} / ${action.name}` : `${event}` } export function format_history_transition_state_name({ from, to }) { return `${from}.${to.substring(HISTORY_PREFIX.length)}.${HISTORY_STATE_NAME}` } export function get_all_transitions(transition) { const { from, event, guards } = transition; return guards ? guards.map(({ predicate, to, action }) => ({ from, event, predicate, to, action })) : [transition]; } /** * 'this_name' => 'this name' * @param {String} str * @returns {String} */ export function getDisplayName(str) { return str.replace(/_/g, ' ') } /** * This function MERGES extended state updates. That means that given two state updates, the resulting state update * will be the concatenation of the two, in the order in which they are passed * @param {function[]} arrayUpdateActions * @returns {function(*=, *=, *=): {updates: *}} */ export function mergeModelUpdates(arrayUpdateActions) { return function (extendedState, eventData, settings) { return { updates: arrayUpdateActions.reduce((acc, updateAction) => { const update = updateAction(extendedState, eventData, settings).updates; if (update) { return acc.concat(update) } else { return acc } }, []), outputs: NO_OUTPUT } } } /** * This function CHAINS extended state updates, in the order in which they are passed. It is thus similar to a pipe. * The second update function receives the state updated by the first update function. * @param {function[]} arrayUpdateActions */ export function chainModelUpdates(arrayUpdateActions) { return function (extendedState, eventData, settings) { const { updateState } = settings; return { updates: arrayUpdateActions .reduce((acc, updateAction) => { const { extendedState, updates } = acc; const update = updateAction(extendedState, eventData, settings).updates; const updatedState = updateState(extendedState, updates) return { extendedState: updatedState, updates: update } }, { extendedState, updates: [] }) .updates || [], outputs: NO_OUTPUT } } } /** * * @param {function (Array<Array<MachineOutput>>) : Array<MachineOutput>} mergeOutputFn * @param {Array<ActionFactory>} arrayActionFactory * @returns {function(*=, *=, *=): {updates: *[], outputs: *|null}} */ export function mergeActionFactories(mergeOutputFn, arrayActionFactory) { return function (extendedState, eventData, settings) { const arrayActions = arrayActionFactory.map(factory => factory(extendedState, eventData, settings)); const arrayStateUpdates = arrayActions.map(x => x.updates || []); const arrayOutputs = arrayActions.map(x => x.outputs || {}); return { updates: [].concat(...arrayStateUpdates), // for instance, mergeFn = R.mergeAll or some variations around R.mergeDeepLeft outputs: mergeOutputFn(arrayOutputs) } } } /** @type ActionFactory*/ export function identity(extendedState, eventData, settings) { return { updates: [], outputs: NO_OUTPUT } } export function lastOf(arr) { return arr[arr.length - 1]; } function formatActionName(action, from, event, to, predicate) { const predicateName = predicate ? predicate.name : ""; const formattedPredicate = predicateName ? `[${predicateName}]` : ""; const actionName = action ? action.name : "identity"; const formattedAction = actionName ? actionName : "unnamed action"; return `${formattedAction}:${from}-${event}->${to} ${formattedPredicate}`; } export function getFsmStateList(states) { const { getLabel } = objectTreeLenses; const traverse = { strategy: PRE_ORDER, seed: {}, visit: (accStateList, traversalState, tree) => { const treeLabel = getLabel(tree); const controlState = Object.keys(treeLabel)[0]; accStateList[controlState] = ""; return accStateList; } }; const stateHashMap = traverseObj(traverse, states); return stateHashMap } export function getStatesType(statesTree) { const { getLabel, isLeafLabel } = objectTreeLenses; const traverse = { strategy: PRE_ORDER, seed: {}, visit: (acc, traversalState, tree) => { const treeLabel = getLabel(tree); const controlState = Object.keys(treeLabel)[0]; // true iff control state is a compound state return isLeafLabel(treeLabel) ? (acc[controlState] = false, acc) : (acc[controlState] = true, acc) } }; return traverseObj(traverse, statesTree); } export function getStatesPath(statesTree) { const { getLabel } = objectTreeLenses; const traverse = { strategy: PRE_ORDER, seed: {}, visit: (acc, traversalState, tree) => { const pathStr = traversalState.get(tree).path.join('.'); const treeLabel = getLabel(tree); const controlState = Object.keys(treeLabel)[0]; return (acc[controlState] = pathStr, acc) } }; return traverseObj(traverse, statesTree); } export function getStatesTransitionsMap(transitions) { // Map a control state to the transitions which it as origin return transitions.reduce((acc, transition) => { const { from, event } = transition; // NOTE: that should never be, but we need to be defensive here to keep semantics if (isHistoryControlState(from)) return acc acc[from] = acc[from] || {}; acc[from][event] = transition; return acc }, {}) || {} } export function getStatesTransitionsMaps(transitions) { // Map a control state to the transitions which it as origin return transitions.reduce((acc, transition) => { const { from, event } = transition; // NOTE: that should never be, but we need to be defensive here to keep semantics if (isHistoryControlState(from)) return acc acc[from] = acc[from] || {}; acc[from][event] = acc[from][event] ? acc[from][event].concat(transition) : [transition]; return acc }, {}) || {} } export function getEventTransitionsMaps(transitions) { // Map an event to the origin control states of the transitions it triggers return transitions.reduce((acc, transition) => { const { from, event } = transition; // NOTE: that should never be, but we need to be defensive here to keep semantics if (isHistoryControlState(from)) return acc acc[event] = acc[event] || {}; acc[event][from] = acc[event][from] ? acc[event][from].concat(transition) : [transition]; return acc }, {}) || {} } export function getHistoryStatesMap(transitions) { return reduceTransitions((map, flatTransition, guardIndex, transitionIndex) => { const { from, event, to, action, predicate, gen } = flatTransition; if (isHistoryControlState(from)) { const underlyingControlState = getHistoryUnderlyingState(from); map.set(underlyingControlState, (map.get(underlyingControlState) || []).concat([flatTransition])); } else if (isHistoryControlState(to)) { const underlyingControlState = getHistoryUnderlyingState(to); map.set(underlyingControlState, (map.get(underlyingControlState) || []).concat([flatTransition])); } return map }, new Map(), transitions) || {}; } export function getTargetStatesMap(transitions) { return reduceTransitions((map, flatTransition, guardIndex, transitionIndex) => { const { to } = flatTransition; map.set(to, (map.get(to) || []).concat([flatTransition])); return map }, new Map(), transitions) || {}; } export function getAncestorMap(statesTree) { const { getLabel, getChildren } = objectTreeLenses; const traverse = { strategy: PRE_ORDER, seed: {}, visit: (acc, traversalState, tree) => { const treeLabel = getLabel(tree); const controlState = Object.keys(treeLabel)[0]; const children = getChildren(tree) const childrenControlStates = children.map(tree => Object.keys(getLabel(tree))[0]); childrenControlStates.forEach(state => { acc[state] = acc[state] || []; acc[state] = acc[state].concat(controlState); }); return acc } }; return traverseObj(traverse, statesTree); } export function computeHistoryMaps(control_states) { if (Object.keys(control_states).length === 0) {throw `computeHistoryMaps : passed empty control states parameter?`} const { getLabel, isLeafLabel } = objectTreeLenses; const traverse = { strategy: PRE_ORDER, seed: { stateList: [], stateAncestors: { [DEEP]: {}, [SHALLOW]: {} } }, visit: (acc, traversalState, tree) => { const treeLabel = getLabel(tree); const controlState = Object.keys(treeLabel)[0]; acc.stateList = acc.stateList.concat(controlState); // NOTE : we don't have to worry about path having only one element // that case correspond to the root of the tree which is excluded from visiting const { path } = traversalState.get(tree); traversalState.set(JSON.stringify(path), controlState); const parentPath = path.slice(0, -1); if (parentPath.length === 1) { // That's the root traversalState.set(JSON.stringify(parentPath), INIT_STATE); } else { const parentControlState = traversalState.get(JSON.stringify(parentPath)); acc.stateAncestors[SHALLOW][controlState] = [parentControlState]; const { ancestors } = path.reduce((acc, _) => { const parentPath = acc.path.slice(0, -1); acc.path = parentPath; if (parentPath.length > 1) { const parentControlState = traversalState.get(JSON.stringify(parentPath)); acc.ancestors = acc.ancestors.concat(parentControlState); } return acc }, { ancestors: [], path }); acc.stateAncestors[DEEP][controlState] = ancestors; } return acc } }; const { stateList, stateAncestors } = traverseObj(traverse, control_states); return { stateList, stateAncestors } } export function mapOverTransitionsActions(mapFn, transitions) { return reduceTransitions(function (acc, transition, guardIndex, transitionIndex) { const { from, event, to, action, predicate } = transition; const mappedAction = mapFn(action, transition, guardIndex, transitionIndex); mappedAction.displayName = mappedAction.displayName || (action && (action.name || action.displayName || formatActionName(action, from, event, to, predicate))); if (typeof(predicate) === 'undefined') { acc.push({ from, event, to, action: mappedAction }) } else { if (guardIndex === 0) { acc.push({ from, event, guards: [{ to, predicate, action: mappedAction }] }) } else { acc[acc.length - 1].guards.push({ to, predicate, action: mappedAction }) } } return acc }, [], transitions) } export function reduceTransitions(reduceFn, seed, transitions) { const result = transitions.reduce((acc, transitionStruct, transitionIndex) => { let { from, event, to, gen, action, guards } = transitionStruct; // Edge case when no guards are defined if (!guards) { guards = gen ? [{ to, action, gen, predicate: undefined }] : [{ to, action, predicate: undefined }] } return guards.reduce((acc, guard, guardIndex) => { const { to, action, gen, predicate } = guard; return gen ? reduceFn(acc, { from, event, to, action, predicate, gen }, guardIndex, transitionIndex) : reduceFn(acc, { from, event, to, action, predicate }, guardIndex, transitionIndex) }, acc); }, seed); return result } export function everyTransition(pred, transition) { return reduceTransitions((acc, flatTransition) => { return acc && pred(flatTransition) }, true, [transition]) } export function computeTimesCircledOn(edgePath, edge) { return edgePath.reduce((acc, edgeInEdgePath) => edgeInEdgePath === edge ? acc + 1 : acc, 0); } export function isInitState(s) {return s === INIT_STATE} export function isInitEvent(e) {return e === INIT_EVENT} export function isEventless(e) {return typeof e === 'undefined'} export function arrayizeOutput(output) { return output === NO_OUTPUT ? NO_OUTPUT : Array.isArray(output) ? output : [output] } export function isHistoryControlState(to) { return typeof to === 'object' && (DEEP in to || SHALLOW in to) } export function getHistoryParentState(to) { return to[SHALLOW] || to[DEEP] } export function isShallowHistory(to) { return to[SHALLOW] } export function isDeepHistory(to) { return to[DEEP] } export function getHistoryType(history) { return history[DEEP] ? DEEP : SHALLOW } export function getHistoryUnderlyingState(history) { return history[getHistoryType(history)] } export function isHistoryStateEdge(edge) { return typeof edge.history !== 'undefined' } /** * Creates a history object from a state list. The created history object represents the history states when no * control states have been entered or exited. * @param stateList * @returns {History} */ export function initHistoryDataStructure(stateList) { // NOTE : we update history in place, so we need two different objects here, even // when they start with the same value const initHistory = () => stateList.reduce((acc, state) => (acc[state] = '', acc), {}); return { [DEEP]: initHistory(), [SHALLOW]: initHistory() }; } export function isCompoundState(analyzedStates, controlState) { const { statesAdjacencyList } = analyzedStates; return statesAdjacencyList[controlState] && statesAdjacencyList[controlState].length !== 0 } export function isAtomicState(analyzedStates, controlState) { return !isCompoundState(analyzedStates, controlState) } /** * Updates the history state (both deep and shallow) after `state_from_name` has been exited. Impacted states are the * `stateAncestors` which are the ancestors for the exited state. * @param {History} history Contains deep history and shallow history for all * control states, except the INIT_STATE (not that the concept has no value for atomic state). The function * `updateHistory` allows to update the history as transitions occur in the state machine. * @param {Object.<DEEP|SHALLOW, Object.<ControlState, Array<ControlState>>>} stateAncestors * @returns {History} * @modifies history */ export function updateHistory(history, stateAncestors, state_from_name) { // Edge case, we start with INIT_STATE but that is not kept in the history (no transition to it!!) if (state_from_name === INIT_STATE) { return history } else { [SHALLOW, DEEP].forEach(historyType => { // ancestors for the state which is exited const ancestors = stateAncestors[historyType][state_from_name] || []; ancestors.forEach(ancestor => { // set the exited state in the history of all ancestors history[historyType][ancestor] = state_from_name }); }); return history } } /** * for all parentState, computes history(parentState), understood as the last control state descending from the * parent state. Last can be understood two ways : DEEP and SHALLOW. Deep history state refer to the last atomic * control state which is a children of the parent state and was exited. Shallow history states refer to the last * control state which is a direct child of the parent state and was exited. * @param {FSM_States} states * @param {Array<ControlState>} controlStateSequence Sequence of control states which has been entered and exited, * and from which the history must be computed * @param {DEEP | SHALLOW} historyType * @param {ControlState} historyParentState * @returns {Object.<DEEP|SHALLOW, Object.<ControlState, ControlState>>} */ export function computeHistoryState(states, controlStateSequence, historyType, historyParentState) { // NOTE : we compute the whole story every time. This is inefficient, but for now sufficient const { stateList, stateAncestors } = computeHistoryMaps(states); let history = initHistoryDataStructure(stateList); history = controlStateSequence.reduce( (history, controlState) => updateHistory(history, stateAncestors, controlState), history ); return history[historyType][historyParentState] } export function findInitTransition(transitions) { return transitions.find(transition => { return transition.from === INIT_STATE && transition.event === INIT_EVENT }) } export function tryCatch(fn, errCb) { return function tryCatch(...args) { try {return fn.apply(fn, args);} catch (e) { return errCb(e, args); } }; } export function tryCatchMachineFn(fnType, fn, argsDesc = []) { return tryCatch(fn, (e, args) => { const err = new Error(e); const fnName = getFunctionName(fn); // NOTE : we concatenate causes but not `info` const probableCause = FUNCTION_THREW_ERROR(fnName, fnType); err.probableCause = e.probableCause ? [e.probableCause, probableCause].join('\n') : probableCause; const info = { fnName, params: argsDesc.reduce((acc, argDesc, index) => { return acc[argDesc]=args[index], acc }, {}) }; err.info = e.info ? [].concat([e.info]).concat([info]) : info; return err }) } export function getFunctionName(actionFactory) { return actionFactory.name || actionFactory.displayName || 'anonymous' } /** * * @param {function: true | Error} contract Contract returns either true (fulfilled contract) or an Error with an * optional info properties to give more details about the cause of the error * @param {Array} arrayParams Parameters to be passed to the conract * @returns {undefined} if the contract is fulfilled * @throws if the contract fails */ export function assert(contract, arrayParams) { const isFulfilledOrError = contract.apply(null, arrayParams); if (isFulfilledOrError === true) return void 0 else { const info = isFulfilledOrError.info; console.error(`ERROR: failed contract ${contract.name || ""}. ${info ? "Error info:" : ""}`, isFulfilledOrError.info); throw isFulfilledOrError } } export function notifyThrows(console, error) { console.error(error); error.probableCause && console.error(`Probable cause: ${error.probableCause}`); error.info && console.error(`ERROR: additional info`, error.info); } /** * false iff no errors or invalid actions * if not throws an exception * @param {{debug, console}} notify * @param {*} execInfo Information about the call - should include the function, and the parameters for the function * call * @param {Actions | Error} actionResultOrError * @param {function} throwFn handles when the action factory throws during its execution * @param {function} invalidResultFn handles when the action factory returns invalid actions * @returns {boolean} * @param postCondition */ export function handleFnExecError(notify, execInfo, actionResultOrError, postCondition, throwFn, invalidResultFn){ const {debug, console} = notify; if (debug && actionResultOrError instanceof Error) { throwFn({debug, console}, actionResultOrError, execInfo) return true } else if (debug && !postCondition(actionResultOrError)) { invalidResultFn({debug, console}, actionResultOrError, execInfo) return true } else return false } export function notifyAndRethrow({debug, console}, actionResultOrError){ notifyThrows(console, actionResultOrError) throw actionResultOrError } export function throwIfInvalidActionResult({debug, console}, actionResultOrError, exec) { const {action, extendedState, eventData, settings } = exec; const actionName = getFunctionName(action); const error = new Error(INVALID_ACTION_FACTORY_EXECUTED(actionName, ACTION_FACTORY_DESC)); error.info = { fnName: getFunctionName(action), params: { updatedExtendedState: extendedState, eventData, settings }, returned: actionResultOrError }; notifyThrows(console, error) throw error } export function throwIfInvalidGuardResult({debug, console}, resultOrError, exec) { const predName = getFunctionName(exec.predicate); const error = new Error(INVALID_PREDICATE_EXECUTED(predName, PREDICATE_DESC)); error.info = { predicateName: predName, params: exec, returned: resultOrError }; notifyThrows(console, error) throw error } export function throwIfInvalidEntryActionResult({debug, console}, exitActionResultOrError, exec) { const {action, extendedState, eventData, settings } = exec; const actionName = getFunctionName(action); const error = new Error(INVALID_ACTION_FACTORY_EXECUTED(actionName, ENTRY_ACTION_FACTORY_DESC)); error.info = { fnName: getFunctionName(action), params: { updatedExtendedState: extendedState, eventData, settings }, returned: exitActionResultOrError }; notifyThrows(console, error) throw error } export function isActions(obj) { return obj && `updates` in obj && `outputs` in obj && (obj.outputs === NO_OUTPUT || Array.isArray(obj.outputs)) && Array.isArray(obj.updates) } /** * That is a Either contract, not a Boolean contract! * @param obj * @returns {boolean|Error} */ export function isEventStruct(obj) { let trueOrError; if (!obj || typeof obj !== 'object') { trueOrError = new Error(WRONG_EVENT_FORMAT_ERROR); trueOrError.info = { event: obj, cause: `not an object!` } } else if (Object.keys(obj).length > 1) { trueOrError = new Error(WRONG_EVENT_FORMAT_ERROR); trueOrError.info = { event: obj, cause: `Event objects must have only one key which is the event name!` } } else trueOrError = true; return trueOrError }