@test-runner/web
Version:
290 lines (257 loc) • 7.65 kB
JavaScript
/**
* @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;