UNPKG

@test-runner/web

Version:
290 lines (257 loc) 7.65 kB
/** * @module obso */ const _listeners = new WeakMap(); /** * @alias module:obso */ class Emitter { constructor () { _listeners.set(this, []); } /** * Emit an event. * @param {string} eventName - the event name to emit. * @param ...args {*} - args to pass to the event handler */ emit (eventName, ...args) { const listeners = _listeners.get(this); if (listeners && listeners.length > 0) { const toRemove = []; /* invoke each relevant listener */ for (const listener of listeners) { const handlerArgs = args.slice(); if (listener.eventName === '__ALL__') { handlerArgs.unshift(eventName); } if (listener.eventName === '__ALL__' || listener.eventName === eventName) { listener.handler.call(this, ...handlerArgs); /* remove once handler */ if (listener.once) toRemove.push(listener); } } toRemove.forEach(listener => { listeners.splice(listeners.indexOf(listener), 1); }); } /* bubble event up */ if (this.parent) this.parent._emitTarget(eventName, this, ...args); } _emitTarget (eventName, target, ...args) { const listeners = _listeners.get(this); if (listeners && listeners.length > 0) { const toRemove = []; /* invoke each relevant listener */ for (const listener of listeners) { const handlerArgs = args.slice(); if (listener.eventName === '__ALL__') { handlerArgs.unshift(eventName); } if (listener.eventName === '__ALL__' || listener.eventName === eventName) { listener.handler.call(target, ...handlerArgs); /* remove once handler */ if (listener.once) toRemove.push(listener); } } toRemove.forEach(listener => { listeners.splice(listeners.indexOf(listener), 1); }); } /* bubble event up */ if (this.parent) this.parent._emitTarget(eventName, target || this, ...args); } /** * Register an event listener. * @param {string} [eventName] - The event name to watch. Omitting the name will catch all events. * @param {function} handler - The function to be called when `eventName` is emitted. Invocated with `this` set to `emitter`. * @param {object} [options] * @param {boolean} [options.once] - If `true`, the handler will be invoked once then removed. */ on (eventName, handler, options) { const listeners = _listeners.get(this); options = options || {}; if (arguments.length === 1 && typeof eventName === 'function') { handler = eventName; eventName = '__ALL__'; } if (!handler) { throw new Error('handler function required') } else if (handler && typeof handler !== 'function') { throw new Error('handler arg must be a function') } else { listeners.push({ eventName, handler: handler, once: options.once }); } } /** * Remove an event listener. * @param eventName {string} - the event name * @param handler {function} - the event handler */ removeEventListener (eventName, handler) { const listeners = _listeners.get(this); if (!listeners || listeners.length === 0) return const index = listeners.findIndex(function (listener) { return listener.eventName === eventName && listener.handler === handler }); if (index > -1) listeners.splice(index, 1); } /** * Once. * @param {string} eventName - the event name to watch * @param {function} handler - the event handler */ once (eventName, handler) { /* TODO: the once option is browser-only */ this.on(eventName, handler, { once: true }); } } /** * Alias for `on`. */ Emitter.prototype.addEventListener = Emitter.prototype.on; /** * Takes any input and guarantees an array back. * * - Converts array-like objects (e.g. `arguments`, `Set`) to a real array. * - Converts `undefined` to an empty array. * - Converts any another other, singular value (including `null`, objects and iterables other than `Set`) into an array containing that value. * - Ignores input which is already an array. * * @module array-back * @example * > const arrayify = require('array-back') * * > arrayify(undefined) * [] * * > arrayify(null) * [ null ] * * > arrayify(0) * [ 0 ] * * > arrayify([ 1, 2 ]) * [ 1, 2 ] * * > arrayify(new Set([ 1, 2 ])) * [ 1, 2 ] * * > function f(){ return arrayify(arguments); } * > f(1,2,3) * [ 1, 2, 3 ] */ function isObject (input) { return typeof input === 'object' && input !== null } function isArrayLike (input) { return isObject(input) && typeof input.length === 'number' } /** * @param {*} - The input value to convert to an array * @returns {Array} * @alias module:array-back */ function arrayify (input) { if (Array.isArray(input)) { return input } else if (input === undefined) { return [] } else if (isArrayLike(input) || input instanceof Set) { return Array.from(input) } else { return [input] } } /** * @module fsm-base * @typicalname stateMachine */ const _initialState = new WeakMap(); const _state = new WeakMap(); const _validMoves = new WeakMap(); /** * @alias module:fsm-base * @extends {Emitter} */ class StateMachine extends Emitter { /** * @param {string} - Initial state, e.g. 'pending'. * @param {object[]} - Array of valid move rules. */ constructor (initialState, validMoves) { super(); _validMoves.set(this, arrayify(validMoves).map(move => { move.from = arrayify(move.from); move.to = arrayify(move.to); return move })); _state.set(this, initialState); _initialState.set(this, initialState); } /** * The current state * @type {string} state * @throws `INVALID_MOVE` if an invalid move made */ get state () { return _state.get(this) } set state (state) { this.setState(state); } /** * Set the current state. The second arg onward will be sent as event args. * @param {string} state */ setState (state, ...args) { /* nothing to do */ if (this.state === state) return const validTo = _validMoves.get(this).some(move => move.to.indexOf(state) > -1); if (!validTo) { const msg = `Invalid state: ${state}`; const err = new Error(msg); err.name = 'INVALID_MOVE'; throw err } let moved = false; const prevState = this.state; _validMoves.get(this).forEach(move => { if (move.from.indexOf(this.state) > -1 && move.to.indexOf(state) > -1) { _state.set(this, state); moved = true; /** * fired on every state change * @event module:fsm-base#state * @param state {string} - the new state * @param prev {string} - the previous state */ this.emit('state', state, prevState); /** * fired on every state change */ this.emit(state, ...args); } }); if (!moved) { const froms = _validMoves.get(this) .filter(move => move.to.indexOf(state) > -1) .map(move => move.from.map(from => `'${from}'`)) .flat(); const msg = `Can only move to '${state}' from ${froms.join(' or ') || '<unspecified>'} (not '${prevState}')`; const err = new Error(msg); err.name = 'INVALID_MOVE'; throw err } } /** * Reset to initial state. * @emits "reset" */ resetState () { const prevState = this.state; const initialState = _initialState.get(this); _state.set(this, initialState); this.emit('reset', prevState); } } export default StateMachine;