Repository: jakesgordon/javascript-state-machine
Branch: master
Commit: 2ae84bbbaad1
Files: 67
Total size: 302.3 KB
Directory structure:
gitextract_31ic2yb8/
├── .ackrc
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── RELEASE_NOTES.md
├── bin/
│ ├── examples
│ └── minify
├── dist/
│ ├── state-machine-history.js
│ ├── state-machine-visualize.js
│ └── state-machine.js
├── docs/
│ ├── async-transitions.md
│ ├── contributing.md
│ ├── data-and-methods.md
│ ├── error-handling.md
│ ├── initialization.md
│ ├── lifecycle-events.md
│ ├── state-history.md
│ ├── state-machine-factory.md
│ ├── states-and-transitions.md
│ ├── upgrading-from-v2.md
│ └── visualization.md
├── examples/
│ ├── atm.dot
│ ├── atm.js
│ ├── demo/
│ │ ├── demo.css
│ │ └── demo.js
│ ├── horizontal_door.dot
│ ├── horizontal_door.js
│ ├── matter.dot
│ ├── matter.js
│ ├── vertical_door.dot
│ ├── vertical_door.js
│ ├── wizard.dot
│ └── wizard.js
├── index.html
├── lib/
│ ├── history.js
│ ├── state-machine.js
│ └── visualize.js
├── package.json
├── src/
│ ├── app.js
│ ├── config.js
│ ├── jsm.js
│ ├── plugin/
│ │ ├── history.js
│ │ └── visualize.js
│ ├── plugin.js
│ └── util/
│ ├── camelize.js
│ ├── exception.js
│ └── mixin.js
├── test/
│ ├── basics.js
│ ├── construction.js
│ ├── defaults.js
│ ├── empty.js
│ ├── errors.js
│ ├── goto.js
│ ├── helpers/
│ │ └── lifecycle_logger.js
│ ├── introspection.js
│ ├── issues.js
│ ├── lifecycle.js
│ ├── observers.js
│ ├── plugin/
│ │ ├── history.js
│ │ └── visualize.js
│ ├── plugins.js
│ ├── transitions.js
│ ├── util/
│ │ ├── camelize.js
│ │ └── mixin.js
│ └── wildcards.js
└── webpack.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .ackrc
================================================
--ignore-dir=coverage
--ignore-dir=node_modules
--ignore-dir=.nyc_output
================================================
FILE: .gitignore
================================================
node_modules
coverage
.nyc_output
*.swp
================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
- '4'
- '6'
sudo: false
================================================
FILE: LICENSE
================================================
Copyright (c) 2012, 2013, 2014, 2015, 2016, 2017, 2018, Jake Gordon and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Javascript State Machine
[](https://badge.fury.io/js/javascript-state-machine)
[](https://travis-ci.org/jakesgordon/javascript-state-machine)
A library for finite state machines.

### NOTE for existing users
> **VERSION 3.0** Is a significant rewrite from earlier versions.
Existing 2.x users should be sure to read the [Upgrade Guide](docs/upgrading-from-v2.md).
# Installation
In a browser:
```html
```
> after downloading the [source](dist/state-machine.js) or the [minified version](dist/state-machine.min.js)
Using npm:
```shell
npm install --save-dev javascript-state-machine
```
In Node.js:
```javascript
var StateMachine = require('javascript-state-machine');
```
# Usage
A state machine can be constructed using:
```javascript
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
methods: {
onMelt: function() { console.log('I melted') },
onFreeze: function() { console.log('I froze') },
onVaporize: function() { console.log('I vaporized') },
onCondense: function() { console.log('I condensed') }
}
});
```
... which creates an object with a current state property:
* `fsm.state`
... methods to transition to a different state:
* `fsm.melt()`
* `fsm.freeze()`
* `fsm.vaporize()`
* `fsm.condense()`
... observer methods called automatically during the lifecycle of a transition:
* `onMelt()`
* `onFreeze()`
* `onVaporize()`
* `onCondense()`
... along with the following helper methods:
* `fsm.is(s)` - return true if state `s` is the current state
* `fsm.can(t)` - return true if transition `t` can occur from the current state
* `fsm.cannot(t)` - return true if transition `t` cannot occur from the current state
* `fsm.transitions()` - return list of transitions that are allowed from the current state
* `fsm.allTransitions()` - return list of all possible transitions
* `fsm.allStates()` - return list of all possible states
# Terminology
A state machine consists of a set of [**States**](docs/states-and-transitions.md)
* solid
* liquid
* gas
A state machine changes state by using [**Transitions**](docs/states-and-transitions.md)
* melt
* freeze
* vaporize
* condense
A state machine can perform actions during a transition by observing [**Lifecycle Events**](docs/lifecycle-events.md)
* onBeforeMelt
* onAfterMelt
* onLeaveSolid
* onEnterLiquid
* ...
A state machine can also have arbitrary [**Data and Methods**](docs/data-and-methods.md).
Multiple instances of a state machine can be created using a [**State Machine Factory**](docs/state-machine-factory.md).
# Documentation
Read more about
* [States and Transitions](docs/states-and-transitions.md)
* [Data and Methods](docs/data-and-methods.md)
* [Lifecycle Events](docs/lifecycle-events.md)
* [Asynchronous Transitions](docs/async-transitions.md)
* [Initialization](docs/initialization.md)
* [Error Handling](docs/error-handling.md)
* [State History](docs/state-history.md)
* [Visualization](docs/visualization.md)
* [State Machine Factory](docs/state-machine-factory.md)
* [Upgrading from 2.x](docs/upgrading-from-v2.md)
# Contributing
You can [Contribute](docs/contributing.md) to this project with issues or pull requests.
# Release Notes
See [RELEASE NOTES](RELEASE_NOTES.md) file.
# License
See [MIT LICENSE](https://github.com/jakesgordon/javascript-state-machine/blob/master/LICENSE) file.
# Contact
If you have any ideas, feedback, requests or bug reports, you can reach me at
[jakesgordon@gmail.com](mailto:jakesgordon@gmail.com), or via
my website: [jakesgordon.com](https://jakesgordon.com/)
================================================
FILE: RELEASE_NOTES.md
================================================
Version 3.1.0 (July 12th 2018)
------------------------------
* Changed back to MIT license
Version 3.0.1 (June 10th 2017)
------------------------------
* First 3.x release - see 3.0.0-rc.1 release notes below
* fix issue #109 - rejection from async lifecycle method does not reject transitions promise
* fix issue #106 - async transition: forward resolved value
* fix issue #107 - lifecycle event name breaks for all uppercase
Version 3.0.0-rc.1 (January 10 2017)
------------------------------------
**IMPORTANT NOTE**: this version includes **breaking changes** that will require code updates.
Please read [UPGRADING FROM 2.x](docs/upgrading-from-v2.md) for details. Highlights include:
* Improved Construction.
* Arbitrary Data and Methods.
* Observable Transitions
* Conditional Transitions
* Promise-based Asynchronous Transitions
* Improved Transition Lifecycle Events
* State History
* Visualization
* Webpack build system
* ...
Version 2.4.0 (November 20 2016)
--------------------------------
* added npm install instructions to readme
* fix for javascript error when running in jasmine/node (issue #88)
* exclude build files from bower install (pull request #75)
* ensure WILDCARD events are included in list of available transitions() (issue #93)
* fix FSM getting stuck into "*" state when using double wildcard (issue #64)
* function (fsm.states) returning list of all available states in the machine would help automated testing (issue #54)
* state machine hides callback exceptions (issue #62)
* replaced (dev dependency) YUI compressor with uglify-js for building minified version
Version 2.3.5 (January 20 2014)
-------------------------------
* fix for broken transitions() method (issue #74)
Version 2.3.4 (January 17 2014)
-------------------------------
* helper method to list which events are allowed from the current state (issue #71 - thanks to @mgoldsborough and @chopj)
Version 2.3.3 (October 17 2014)
-------------------------------
* added web worker compatability (issue #65 - thanks to @offirmo)
Version 2.3.2 (March 16 2014)
-----------------------------
* had to bump the version number after messing up npmjs.org package registration
Version 2.3.0 (March 15 2014)
-----------------------------
* Added support for bower
* Added support for nodejs (finally)
* Added ability to run tests in console via nodejs ("npm install" to get node-qunit, then "node test/runner.js")
Version 2.2.0 (January 26th 2013)
---------------------------------
* Added optional `final` state(s) and `isFinished()` helper method (issue #23)
* extended `fsm.is()` to accept an array of states (in addition to a single state)
* Added generic event callbacks 'onbeforeevent' and 'onafterevent' (issue #28)
* Added generic state callbacks 'onleavestate' and 'onenterstate' (issue #28)
* Fixed 'undefined' event return codes (issue #34) - pull from gentooboontoo (thanks!)
* Allow async event transition to be cancelled (issue #22)
* [read more...](https://jakesgordon.com/writing/javascript-state-machine-v2-2-0/)
Version 2.1.0 (January 7th 2012)
--------------------------------
* Wrapped in self executing function to be more easily used with loaders like `require.js` or `curl.js` (issue #15)
* Allow event to be cancelled by returning `false` from `onleavestate` handler (issue #13) - WARNING: this breaks backward compatibility for async transitions (you now need to return `StateMachine.ASYNC` instead of `false`)
* Added explicit return values for event methods (issue #12)
* Added support for wildcard events that can be fired 'from' any state (issue #11)
* Added support for no-op events that transition 'to' the same state (issue #5)
* extended custom error callback to handle any exceptions caused by caller provided callbacks
* added custom error callback to override exception when an illegal state transition is attempted (thanks to cboone)
* fixed typos (thanks to cboone)
* fixed issue #4 - ensure before/after event hooks are called even if the event doesn't result in a state change
Version 2.0.0 (August 19th 2011)
--------------------------------
* adding support for asynchronous state transitions (see README) - with lots of qunit tests (see test/async.js).
* consistent arguments for ALL callbacks, first 3 args are ALWAYS event name, from state and to state, followed by whatever arguments the user passed to the original event method.
* added a generic `onchangestate(event,from,to)` callback to detect all state changes with a single function.
* allow callbacks to be declared at creation time (instead of having to attach them afterwards)
* renamed 'hooks' => 'callbacks'
* [read more...](https://jakesgordon.com/writing/javascript-state-machine-v2/)
Version 1.2.0 (June 21st 2011)
------------------------------
* allows the same event to transition to different states, depending on the current state (see 'Multiple...' section in README.md)
* [read more...](https://jakesgordon.com/writing/javascript-state-machine-v1-2-0/)
Version 1.0.0 (June 1st 2011)
-----------------------------
* initial version
* [read more...](https://jakesgordon.com/writing/javascript-state-machine/)
================================================
FILE: bin/examples
================================================
#!/usr/bin/env node
//=================================================================================================
//
// This script is used to regenerate the example visualizations
//
//=================================================================================================
var fs = require('fs'),
path = require('path'),
child = require('child_process');
//-------------------------------------------------------------------------------------------------
fs.readdirSync('examples')
.filter(function(file) { return path.extname(file) === ".js" })
.map(visualize);
//-------------------------------------------------------------------------------------------------
function visualize(example) {
var name = path.basename(example, '.js'),
fsm = require('../examples/' + example),
dot = fsm.visualize(),
svg = dot2svg(dot),
png = dot2png(dot);
console.log('visualizing examples/' + example);
fs.writeFileSync('examples/' + name + '.dot', dot);
fs.writeFileSync('examples/' + name + '.svg', svg);
fs.writeFileSync('examples/' + name + '.png', png, 'binary');
}
//-------------------------------------------------------------------------------------------------
function dot2svg(dot) {
var result = child.spawnSync("dot", ["-Tsvg"], { input: dot });
if (result.error)
dotError(result.error.errno);
return result.stdout.toString();
}
//-------------------------------------------------------------------------------------------------
function dot2png(dot) {
var result = child.spawnSync("dot", ["-Tpng"], { input: dot });
if (result.error)
dotError(result.error.errno);
return result.stdout;
}
//-------------------------------------------------------------------------------------------------
function dotError(errno) {
if (errno === 'ENOENT')
throw new Error("dot program not found. Install graphviz (http://graphviz.org)")
else
throw new Error("unexpected error: " + errno)
}
//-------------------------------------------------------------------------------------------------
================================================
FILE: bin/minify
================================================
#!/usr/bin/env node
//=================================================================================================
//
// This script is used (by npm run build) to minify the distributed source code
//
//=================================================================================================
var fs = require('fs-sync'),
path = require('path'),
uglify = require('uglify-js'),
target = 'dist';
//-------------------------------------------------------------------------------------------------
fs.expand("lib/**/*.js")
.map(minify);
//-------------------------------------------------------------------------------------------------
function minify(file) {
var name = output_name(file),
expanded = path.join(target, name + '.js'),
minified = path.join(target, name + '.min.js')
console.log('copied ' + file + ' to ' + expanded + ' and minified as ' + minified);
fs.copy(file, expanded, { force: true });
fs.write(minified, uglify.minify(expanded).code);
}
function output_name(file) {
var name = path.basename(file, '.js');
if (name === 'state-machine')
return 'state-machine'
else
return 'state-machine-' + name
}
//-------------------------------------------------------------------------------------------------
================================================
FILE: dist/state-machine-history.js
================================================
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("StateMachineHistory", [], factory);
else if(typeof exports === 'object')
exports["StateMachineHistory"] = factory();
else
root["StateMachineHistory"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 1);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
function camelize(label) {
if (label.length === 0)
return label;
var n, result, word, words = label.split(/[_-]/);
// single word with first character already lowercase, return untouched
if ((words.length === 1) && (words[0][0].toLowerCase() === words[0][0]))
return label;
result = words[0].toLowerCase();
for(n = 1 ; n < words.length ; n++) {
result = result + words[n].charAt(0).toUpperCase() + words[n].substring(1).toLowerCase();
}
return result;
}
//-------------------------------------------------------------------------------------------------
camelize.prepended = function(prepend, label) {
label = camelize(label);
return prepend + label[0].toUpperCase() + label.substring(1);
}
//-------------------------------------------------------------------------------------------------
module.exports = camelize;
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
var camelize = __webpack_require__(0);
//-------------------------------------------------------------------------------------------------
module.exports = function(options) { options = options || {};
var past = camelize(options.name || options.past || 'history'),
future = camelize( options.future || 'future'),
clear = camelize.prepended('clear', past),
back = camelize.prepended(past, 'back'),
forward = camelize.prepended(past, 'forward'),
canBack = camelize.prepended('can', back),
canForward = camelize.prepended('can', forward),
max = options.max;
var plugin = {
configure: function(config) {
config.addTransitionLifecycleNames(back);
config.addTransitionLifecycleNames(forward);
},
init: function(instance) {
instance[past] = [];
instance[future] = [];
},
lifecycle: function(instance, lifecycle) {
if (lifecycle.event === 'onEnterState') {
instance[past].push(lifecycle.to);
if (max && instance[past].length > max)
instance[past].shift();
if (lifecycle.transition !== back && lifecycle.transition !== forward)
instance[future].length = 0;
}
},
methods: {},
properties: {}
}
plugin.methods[clear] = function() {
this[past].length = 0
this[future].length = 0
}
plugin.properties[canBack] = {
get: function() {
return this[past].length > 1
}
}
plugin.properties[canForward] = {
get: function() {
return this[future].length > 0
}
}
plugin.methods[back] = function() {
if (!this[canBack])
throw Error('no history');
var from = this[past].pop(),
to = this[past].pop();
this[future].push(from);
this._fsm.transit(back, from, to, []);
}
plugin.methods[forward] = function() {
if (!this[canForward])
throw Error('no history');
var from = this.state,
to = this[future].pop();
this._fsm.transit(forward, from, to, []);
}
return plugin;
}
/***/ })
/******/ ]);
});
================================================
FILE: dist/state-machine-visualize.js
================================================
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("StateMachineVisualize", [], factory);
else if(typeof exports === 'object')
exports["StateMachineVisualize"] = factory();
else
root["StateMachineVisualize"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 1);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
module.exports = function(target, sources) {
var n, source, key;
for(n = 1 ; n < arguments.length ; n++) {
source = arguments[n];
for(key in source) {
if (source.hasOwnProperty(key))
target[key] = source[key];
}
}
return target;
}
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
var mixin = __webpack_require__(0)
//-------------------------------------------------------------------------------------------------
function visualize(fsm, options) {
return dotify(dotcfg(fsm, options));
}
//-------------------------------------------------------------------------------------------------
function dotcfg(fsm, options) {
options = options || {}
var config = dotcfg.fetch(fsm),
name = options.name,
rankdir = dotcfg.rankdir(options.orientation),
states = dotcfg.states(config, options),
transitions = dotcfg.transitions(config, options),
result = { }
if (name)
result.name = name
if (rankdir)
result.rankdir = rankdir
if (states && states.length > 0)
result.states = states
if (transitions && transitions.length > 0)
result.transitions = transitions
return result
}
//-------------------------------------------------------------------------------------------------
dotcfg.fetch = function(fsm) {
return (typeof fsm === 'function') ? fsm.prototype._fsm.config
: fsm._fsm.config
}
dotcfg.rankdir = function(orientation) {
if (orientation === 'horizontal')
return 'LR';
else if (orientation === 'vertical')
return 'TB';
}
dotcfg.states = function(config, options) {
var index, states = config.states;
if (!options.init) { // if not showing init transition, then slice out the implied init :from state
index = states.indexOf(config.init.from);
states = states.slice(0, index).concat(states.slice(index+1));
}
return states;
}
dotcfg.transitions = function(config, options) {
var n, max, transition,
init = config.init,
transitions = config.options.transitions || [], // easier to visualize using the ORIGINAL transition declarations rather than our run-time mapping
output = [];
if (options.init && init.active)
dotcfg.transition(init.name, init.from, init.to, init.dot, config, options, output)
for (n = 0, max = transitions.length ; n < max ; n++) {
transition = config.options.transitions[n]
dotcfg.transition(transition.name, transition.from, transition.to, transition.dot, config, options, output)
}
return output
}
dotcfg.transition = function(name, from, to, dot, config, options, output) {
var n, max, wildcard = config.defaults.wildcard
if (Array.isArray(from)) {
for(n = 0, max = from.length ; n < max ; n++)
dotcfg.transition(name, from[n], to, dot, config, options, output)
}
else if (from === wildcard || from === undefined) {
for(n = 0, max = config.states.length ; n < max ; n++)
dotcfg.transition(name, config.states[n], to, dot, config, options, output)
}
else if (to === wildcard || to === undefined) {
dotcfg.transition(name, from, from, dot, config, options, output)
}
else if (typeof to === 'function') {
// do nothing, can't display conditional transition
}
else {
output.push(mixin({}, { from: from, to: to, label: pad(name) }, dot || {}))
}
}
//-------------------------------------------------------------------------------------------------
function pad(name) {
return " " + name + " "
}
function quote(name) {
return "\"" + name + "\""
}
function dotify(dotcfg) {
dotcfg = dotcfg || {};
var name = dotcfg.name || 'fsm',
states = dotcfg.states || [],
transitions = dotcfg.transitions || [],
rankdir = dotcfg.rankdir,
output = [],
n, max;
output.push("digraph " + quote(name) + " {")
if (rankdir)
output.push(" rankdir=" + rankdir + ";")
for(n = 0, max = states.length ; n < max ; n++)
output.push(dotify.state(states[n]))
for(n = 0, max = transitions.length ; n < max ; n++)
output.push(dotify.edge(transitions[n]))
output.push("}")
return output.join("\n")
}
dotify.state = function(state) {
return " " + quote(state) + ";"
}
dotify.edge = function(edge) {
return " " + quote(edge.from) + " -> " + quote(edge.to) + dotify.edge.attr(edge) + ";"
}
dotify.edge.attr = function(edge) {
var n, max, key, keys = Object.keys(edge).sort(), output = [];
for(n = 0, max = keys.length ; n < max ; n++) {
key = keys[n];
if (key !== 'from' && key !== 'to')
output.push(key + "=" + quote(edge[key]))
}
return output.length > 0 ? " [ " + output.join(" ; ") + " ]" : ""
}
//-------------------------------------------------------------------------------------------------
visualize.dotcfg = dotcfg;
visualize.dotify = dotify;
//-------------------------------------------------------------------------------------------------
module.exports = visualize;
//-------------------------------------------------------------------------------------------------
/***/ })
/******/ ]);
});
================================================
FILE: dist/state-machine.js
================================================
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("StateMachine", [], factory);
else if(typeof exports === 'object')
exports["StateMachine"] = factory();
else
root["StateMachine"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 5);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
module.exports = function(target, sources) {
var n, source, key;
for(n = 1 ; n < arguments.length ; n++) {
source = arguments[n];
for(key in source) {
if (source.hasOwnProperty(key))
target[key] = source[key];
}
}
return target;
}
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
var mixin = __webpack_require__(0);
//-------------------------------------------------------------------------------------------------
module.exports = {
build: function(target, config) {
var n, max, plugin, plugins = config.plugins;
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n];
if (plugin.methods)
mixin(target, plugin.methods);
if (plugin.properties)
Object.defineProperties(target, plugin.properties);
}
},
hook: function(fsm, name, additional) {
var n, max, method, plugin,
plugins = fsm.config.plugins,
args = [fsm.context];
if (additional)
args = args.concat(additional)
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n]
method = plugins[n][name]
if (method)
method.apply(plugin, args);
}
}
}
//-------------------------------------------------------------------------------------------------
/***/ }),
/* 2 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
function camelize(label) {
if (label.length === 0)
return label;
var n, result, word, words = label.split(/[_-]/);
// single word with first character already lowercase, return untouched
if ((words.length === 1) && (words[0][0].toLowerCase() === words[0][0]))
return label;
result = words[0].toLowerCase();
for(n = 1 ; n < words.length ; n++) {
result = result + words[n].charAt(0).toUpperCase() + words[n].substring(1).toLowerCase();
}
return result;
}
//-------------------------------------------------------------------------------------------------
camelize.prepended = function(prepend, label) {
label = camelize(label);
return prepend + label[0].toUpperCase() + label.substring(1);
}
//-------------------------------------------------------------------------------------------------
module.exports = camelize;
/***/ }),
/* 3 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
var mixin = __webpack_require__(0),
camelize = __webpack_require__(2);
//-------------------------------------------------------------------------------------------------
function Config(options, StateMachine) {
options = options || {};
this.options = options; // preserving original options can be useful (e.g visualize plugin)
this.defaults = StateMachine.defaults;
this.states = [];
this.transitions = [];
this.map = {};
this.lifecycle = this.configureLifecycle();
this.init = this.configureInitTransition(options.init);
this.data = this.configureData(options.data);
this.methods = this.configureMethods(options.methods);
this.map[this.defaults.wildcard] = {};
this.configureTransitions(options.transitions || []);
this.plugins = this.configurePlugins(options.plugins, StateMachine.plugin);
}
//-------------------------------------------------------------------------------------------------
mixin(Config.prototype, {
addState: function(name) {
if (!this.map[name]) {
this.states.push(name);
this.addStateLifecycleNames(name);
this.map[name] = {};
}
},
addStateLifecycleNames: function(name) {
this.lifecycle.onEnter[name] = camelize.prepended('onEnter', name);
this.lifecycle.onLeave[name] = camelize.prepended('onLeave', name);
this.lifecycle.on[name] = camelize.prepended('on', name);
},
addTransition: function(name) {
if (this.transitions.indexOf(name) < 0) {
this.transitions.push(name);
this.addTransitionLifecycleNames(name);
}
},
addTransitionLifecycleNames: function(name) {
this.lifecycle.onBefore[name] = camelize.prepended('onBefore', name);
this.lifecycle.onAfter[name] = camelize.prepended('onAfter', name);
this.lifecycle.on[name] = camelize.prepended('on', name);
},
mapTransition: function(transition) {
var name = transition.name,
from = transition.from,
to = transition.to;
this.addState(from);
if (typeof to !== 'function')
this.addState(to);
this.addTransition(name);
this.map[from][name] = transition;
return transition;
},
configureLifecycle: function() {
return {
onBefore: { transition: 'onBeforeTransition' },
onAfter: { transition: 'onAfterTransition' },
onEnter: { state: 'onEnterState' },
onLeave: { state: 'onLeaveState' },
on: { transition: 'onTransition' }
};
},
configureInitTransition: function(init) {
if (typeof init === 'string') {
return this.mapTransition(mixin({}, this.defaults.init, { to: init, active: true }));
}
else if (typeof init === 'object') {
return this.mapTransition(mixin({}, this.defaults.init, init, { active: true }));
}
else {
this.addState(this.defaults.init.from);
return this.defaults.init;
}
},
configureData: function(data) {
if (typeof data === 'function')
return data;
else if (typeof data === 'object')
return function() { return data; }
else
return function() { return {}; }
},
configureMethods: function(methods) {
return methods || {};
},
configurePlugins: function(plugins, builtin) {
plugins = plugins || [];
var n, max, plugin;
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n];
if (typeof plugin === 'function')
plugins[n] = plugin = plugin()
if (plugin.configure)
plugin.configure(this);
}
return plugins
},
configureTransitions: function(transitions) {
var i, n, transition, from, to, wildcard = this.defaults.wildcard;
for(n = 0 ; n < transitions.length ; n++) {
transition = transitions[n];
from = Array.isArray(transition.from) ? transition.from : [transition.from || wildcard]
to = transition.to || wildcard;
for(i = 0 ; i < from.length ; i++) {
this.mapTransition({ name: transition.name, from: from[i], to: to });
}
}
},
transitionFor: function(state, transition) {
var wildcard = this.defaults.wildcard;
return this.map[state][transition] ||
this.map[wildcard][transition];
},
transitionsFor: function(state) {
var wildcard = this.defaults.wildcard;
return Object.keys(this.map[state]).concat(Object.keys(this.map[wildcard]));
},
allStates: function() {
return this.states;
},
allTransitions: function() {
return this.transitions;
}
});
//-------------------------------------------------------------------------------------------------
module.exports = Config;
//-------------------------------------------------------------------------------------------------
/***/ }),
/* 4 */
/***/ (function(module, exports, __webpack_require__) {
var mixin = __webpack_require__(0),
Exception = __webpack_require__(6),
plugin = __webpack_require__(1),
UNOBSERVED = [ null, [] ];
//-------------------------------------------------------------------------------------------------
function JSM(context, config) {
this.context = context;
this.config = config;
this.state = config.init.from;
this.observers = [context];
}
//-------------------------------------------------------------------------------------------------
mixin(JSM.prototype, {
init: function(args) {
mixin(this.context, this.config.data.apply(this.context, args));
plugin.hook(this, 'init');
if (this.config.init.active)
return this.fire(this.config.init.name, []);
},
is: function(state) {
return Array.isArray(state) ? (state.indexOf(this.state) >= 0) : (this.state === state);
},
isPending: function() {
return this.pending;
},
can: function(transition) {
return !this.isPending() && !!this.seek(transition);
},
cannot: function(transition) {
return !this.can(transition);
},
allStates: function() {
return this.config.allStates();
},
allTransitions: function() {
return this.config.allTransitions();
},
transitions: function() {
return this.config.transitionsFor(this.state);
},
seek: function(transition, args) {
var wildcard = this.config.defaults.wildcard,
entry = this.config.transitionFor(this.state, transition),
to = entry && entry.to;
if (typeof to === 'function')
return to.apply(this.context, args);
else if (to === wildcard)
return this.state
else
return to
},
fire: function(transition, args) {
return this.transit(transition, this.state, this.seek(transition, args), args);
},
transit: function(transition, from, to, args) {
var lifecycle = this.config.lifecycle,
changed = this.config.options.observeUnchangedState || (from !== to);
if (!to)
return this.context.onInvalidTransition(transition, from, to);
if (this.isPending())
return this.context.onPendingTransition(transition, from, to);
this.config.addState(to); // might need to add this state if it's unknown (e.g. conditional transition or goto)
this.beginTransit();
args.unshift({ // this context will be passed to each lifecycle event observer
transition: transition,
from: from,
to: to,
fsm: this.context
});
return this.observeEvents([
this.observersForEvent(lifecycle.onBefore.transition),
this.observersForEvent(lifecycle.onBefore[transition]),
changed ? this.observersForEvent(lifecycle.onLeave.state) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onLeave[from]) : UNOBSERVED,
this.observersForEvent(lifecycle.on.transition),
changed ? [ 'doTransit', [ this ] ] : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onEnter.state) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onEnter[to]) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.on[to]) : UNOBSERVED,
this.observersForEvent(lifecycle.onAfter.transition),
this.observersForEvent(lifecycle.onAfter[transition]),
this.observersForEvent(lifecycle.on[transition])
], args);
},
beginTransit: function() { this.pending = true; },
endTransit: function(result) { this.pending = false; return result; },
failTransit: function(result) { this.pending = false; throw result; },
doTransit: function(lifecycle) { this.state = lifecycle.to; },
observe: function(args) {
if (args.length === 2) {
var observer = {};
observer[args[0]] = args[1];
this.observers.push(observer);
}
else {
this.observers.push(args[0]);
}
},
observersForEvent: function(event) { // TODO: this could be cached
var n = 0, max = this.observers.length, observer, result = [];
for( ; n < max ; n++) {
observer = this.observers[n];
if (observer[event])
result.push(observer);
}
return [ event, result, true ]
},
observeEvents: function(events, args, previousEvent, previousResult) {
if (events.length === 0) {
return this.endTransit(previousResult === undefined ? true : previousResult);
}
var event = events[0][0],
observers = events[0][1],
pluggable = events[0][2];
args[0].event = event;
if (event && pluggable && event !== previousEvent)
plugin.hook(this, 'lifecycle', args);
if (observers.length === 0) {
events.shift();
return this.observeEvents(events, args, event, previousResult);
}
else {
var observer = observers.shift(),
result = observer[event].apply(observer, args);
if (result && typeof result.then === 'function') {
return result.then(this.observeEvents.bind(this, events, args, event))
.catch(this.failTransit.bind(this))
}
else if (result === false) {
return this.endTransit(false);
}
else {
return this.observeEvents(events, args, event, result);
}
}
},
onInvalidTransition: function(transition, from, to) {
throw new Exception("transition is invalid in current state", transition, from, to, this.state);
},
onPendingTransition: function(transition, from, to) {
throw new Exception("transition is invalid while previous transition is still in progress", transition, from, to, this.state);
}
});
//-------------------------------------------------------------------------------------------------
module.exports = JSM;
//-------------------------------------------------------------------------------------------------
/***/ }),
/* 5 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-----------------------------------------------------------------------------------------------
var mixin = __webpack_require__(0),
camelize = __webpack_require__(2),
plugin = __webpack_require__(1),
Config = __webpack_require__(3),
JSM = __webpack_require__(4);
//-----------------------------------------------------------------------------------------------
var PublicMethods = {
is: function(state) { return this._fsm.is(state) },
can: function(transition) { return this._fsm.can(transition) },
cannot: function(transition) { return this._fsm.cannot(transition) },
observe: function() { return this._fsm.observe(arguments) },
transitions: function() { return this._fsm.transitions() },
allTransitions: function() { return this._fsm.allTransitions() },
allStates: function() { return this._fsm.allStates() },
onInvalidTransition: function(t, from, to) { return this._fsm.onInvalidTransition(t, from, to) },
onPendingTransition: function(t, from, to) { return this._fsm.onPendingTransition(t, from, to) },
}
var PublicProperties = {
state: {
configurable: false,
enumerable: true,
get: function() {
return this._fsm.state;
},
set: function(state) {
throw Error('use transitions to change state')
}
}
}
//-----------------------------------------------------------------------------------------------
function StateMachine(options) {
return apply(this || {}, options);
}
function factory() {
var cstor, options;
if (typeof arguments[0] === 'function') {
cstor = arguments[0];
options = arguments[1] || {};
}
else {
cstor = function() { this._fsm.apply(this, arguments) };
options = arguments[0] || {};
}
var config = new Config(options, StateMachine);
build(cstor.prototype, config);
cstor.prototype._fsm.config = config; // convenience access to shared config without needing an instance
return cstor;
}
//-------------------------------------------------------------------------------------------------
function apply(instance, options) {
var config = new Config(options, StateMachine);
build(instance, config);
instance._fsm();
return instance;
}
function build(target, config) {
if ((typeof target !== 'object') || Array.isArray(target))
throw Error('StateMachine can only be applied to objects');
plugin.build(target, config);
Object.defineProperties(target, PublicProperties);
mixin(target, PublicMethods);
mixin(target, config.methods);
config.allTransitions().forEach(function(transition) {
target[camelize(transition)] = function() {
return this._fsm.fire(transition, [].slice.call(arguments))
}
});
target._fsm = function() {
this._fsm = new JSM(this, config);
this._fsm.init(arguments);
}
}
//-----------------------------------------------------------------------------------------------
StateMachine.version = '3.0.1';
StateMachine.factory = factory;
StateMachine.apply = apply;
StateMachine.defaults = {
wildcard: '*',
init: {
name: 'init',
from: 'none'
}
}
//===============================================================================================
module.exports = StateMachine;
/***/ }),
/* 6 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
module.exports = function(message, transition, from, to, current) {
this.message = message;
this.transition = transition;
this.from = from;
this.to = to;
this.current = current;
}
/***/ })
/******/ ]);
});
================================================
FILE: docs/async-transitions.md
================================================
# Asynchronous Transitions
> You should be familiar with the state machine [Lifecycle Events](lifecycle-events.md) before reading this article.
Sometimes, you need to execute some asynchronous code during a state transition and ensure the new
state is not entered until your code has completed. A good example of this is when you transition
out of a state and want to gradually fade a UI component away, or slide it off the screen, and
don't want to transition to the next state until after that animation has completed.
You can achieve this by returning a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)
object from any of the [Lifecycle Events](lifecycle-events.md).
Returning a Promise from a lifecycle event will cause the lifecycle for that transition to
pause. It can be continued by resolving the promise, or cancelled by rejecting the promise.
For example (using jQuery effects):
```javascript
var fsm = new StateMachine({
init: 'menu',
transitions: [
{ name: 'play', from: 'menu', to: 'game' },
{ name: 'quit', from: 'game', to: 'menu' }
],
methods: {
onEnterMenu: function() {
return new Promise(function(resolve, reject) {
$('#menu').fadeIn('fast', resolve)
})
},
onEnterGame: function() {
return new Promise(function(resolve, reject) {
$('#game').fadeIn('fast', resolve)
})
},
onLeaveMenu: function() {
return new Promise(function(resolve, reject) {
$('#menu').fadeOut('fast', resolve)
})
},
onLeaveGame: function() {
return new Promise(function(resolve, reject) {
$('#game').fadeOut('fast', resolve)
})
}
}
})
```
> Be sure that you always resolve (or reject) your Promise eventually, otherwise the state
machine will be stuck forever within that pending transition.
================================================
FILE: docs/contributing.md
================================================
# Contributing
The `javascript-state-machine` library is built using:
* [Webpack 2](https://webpack.js.org/concepts/) - for bundling javascript modules together
* [UglifyJS2](https://github.com/mishoo/UglifyJS2) - for minifying bundled javascript files
* [Ava](https://github.com/avajs/ava) - for testing
The directory structure includes:
```shell
/bin # - build scripts
/dist # - minified bundles for distribution
/docs # - documentation
/examples # - example visualizations
/lib # - bundled source code for npm
/src # - source code
/test # - unit tests
package.json # - npm configuration
webpack.config.js # - webpack configuration
LICENSE # - the project licensing terms
README.md # - the project readme
RELEASE_NOTES.md # - the project release notes
```
Build time dependencies can be installed using npm:
```shell
> npm install
```
A number of npm scripts are available:
```shell
> npm run test # run unit tests
> npm run build # bundle and minify files for distribution
> npm run watch # run tests if source files change
```
## Source Code
The source code is written in es5 syntax and should be supported by all [es5 compatible browsers](http://caniuse.com/#feat=es5).
[Babel](https://babeljs.io/) is **NOT** used for this project. Webpack is used to
bundle modules together for distribution.
## Submitting Pull Requests
Generally speaking, please raise an issue first and lets discuss the problem and the
proposed solution. The next step would be a pull-request - fantastic and thank you for helping out - but
please try to...
* ensure the tests pass (`npm test`).
* rebuild distribution files (`npm run build`).
* include tests for your changes.
* include documentation for your changes.
* include a great commit message.
================================================
FILE: docs/data-and-methods.md
================================================
# Data and Methods
In addition to [States](states-and-transitions.md) and [Transitions](states-and-transitions.md), a state machine can
also contain arbitrary data and methods:
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' }
],
data: {
color: 'red'
},
methods: {
describe: function() {
console.log('I am ' + this.color);
}
}
});
fsm.state; // 'A'
fsm.color; // 'red'
fsm.describe(); // 'I am red'
```
## Data and State Machine Factories
If you are constructing multiple instances from a [State Machine Factory](state-machine-factory.md) then the
`data` object will be shared amongst them. This is almost certainly **NOT** what you want! To
ensure that each instance gets unique data you should use a `data` method instead:
```javascript
var FSM = StateMachine.factory({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' }
],
data: function(color) { // <-- use a method that can be called for each instance
return {
color: color
}
},
methods: {
describe: function() {
console.log('I am ' + this.color);
}
}
});
var a = new FSM('red'),
b = new FSM('blue');
a.state; // 'A'
b.state; // 'A'
a.color; // 'red'
b.color; // 'blue'
a.describe(); // 'I am red'
b.describe(); // 'I am blue'
```
> NOTE: that arguments used when constructing each instance are passed thru to the `data` method directly.
================================================
FILE: docs/error-handling.md
================================================
# Error Handling
## Invalid Transitions
By default, if you try to fire a transition that is not allowed in the current state, the
state machine will throw an exception. If you prefer to handle the problem yourself, you can
define a custom `onInvalidTransition` handler:
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'reset', from: 'B', to: 'A' }
],
methods: {
onInvalidTransition: function(transition, from, to) {
throw new Exception("transition not allowed from that state");
}
}
});
fsm.state; // 'A'
fsm.can('step'); // true
fsm.can('reset'); // false
fsm.reset(); // <-- throws "transition not allowed from that state"
```
## Pending Transitions
By default, if you try to fire a transition during a [Lifecycle Event](lifecycle-events.md) for a
pending transition, the state machine will throw an exception. If you prefer to handle the problem
yourself, you can define a custom `onPendingTransition` handler:
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' }
],
methods: {
onLeaveA: function() {
this.step(); // <-- uh oh, trying to transition from within a lifecycle event is not allowed
},
onPendingTransition: function(transition, from, to) {
throw new Exception("transition already in progress");
}
}
});
fsm.state; // 'A'
fsm.can('step'), // true
fsm.step(); // <-- throws "transition already in progress"
```
================================================
FILE: docs/initialization.md
================================================
# Initialization Options
## Explicit Init Transition
By default, if you don't specify an initial state, the state machine will be in the `none`
state, no lifecycle events will fire during construction, and you will need to provide an
explicit transition to advance out of this state:
```javascript
var fsm = new StateMachine({
transitions: [
{ name: 'init', from: 'none', to: 'A' },
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' }
]
});
fsm.state; // 'none'
fsm.init(); // 'init()' transition is fired explicitly
fsm.state; // 'A'
```
## Implicit Init Transition
If you specify the name of your initial state (as in most of the examples in this documentation),
then an implicit `init` transition will be created for you and fired (along with appropriate
lifecycle events) when the state machine is constructed.
> This is the most common initialization strategy, and the one you should use 90% of the time
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' }
]
}); // 'init()' transition fires from 'none' to 'A' during construction
fsm.state; // 'A'
```
## Initialization and State Machine Factories
For [State Machine Factories](state-machine-factory.md), the `init` transition
is triggered for each constructed instance.
```javascript
var FSM = StateMachine.factory({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' }
]
});
var fsm1 = new FSM(), // 'init()' transition fires from 'none' to 'A' for fsm1
fsm2 = new FSM(); // 'init()' transition fires from 'none' to 'A' for fsm2
```
================================================
FILE: docs/lifecycle-events.md
================================================
# Lifecycle Events
In order to track or perform an action when a transition occurs, five
general-purpose lifecycle events can be observed:
* `onBeforeTransition` - fired before any transition
* `onLeaveState` - fired when leaving any state
* `onTransition` - fired during any transition
* `onEnterState` - fired when entering any state
* `onAfterTransition` - fired after any transition
In addition to the general-purpose events, transitions can be observed
using your specific transition and state names:
* `onBefore` - fired before a specific TRANSITION begins
* `onLeave` - fired when leaving a specific STATE
* `onEnter` - fired when entering a specific STATE
* `onAfter` - fired after a specific TRANSITION completes
For convenience, the 2 most useful events can be shortened:
* `on` - convenience shorthand for `onAfter`
* `on` - convenience shorthand for `onEnter`
## Observing Lifecycle Events
Individual lifecycle events can be observed using an observer method:
```javascript
fsm.observe('onStep', function() {
console.log('stepped');
});
```
Multiple events can be observed using an observer object:
```javascript
fsm.observe({
onStep: function() { console.log('stepped'); }
onA: function() { console.log('entered state A'); }
onB: function() { console.log('entered state B'); }
});
```
A state machine always observes its own lifecycle events:
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' }
],
methods: {
onStep: function() { console.log('stepped'); }
onA: function() { console.log('entered state A'); }
onB: function() { console.log('entered state B'); }
}
});
```
## Lifecycle Event Arguments
Observers will be passed a single argument containing a `lifecycle` object with the following attributes:
* **transition** - the transition name
* **from** - the previous state
* **to** - the next state
In addition to the `lifecycle` argument, the observer will receive any arbitrary arguments passed
into the transition method
```javascript
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'A', to: 'B' }
],
methods: {
onTransition: function(lifecycle, arg1, arg2) {
console.log(lifecycle.transition); // 'step'
console.log(lifecycle.from); // 'A'
console.log(lifecycle.to); // 'B'
console.log(arg1); // 42
console.log(arg2); // 'hello'
}
}
});
fsm.step(42, 'hello');
```
## Lifecycle Event Names
Lifecycle event names always use standard javascipt camelCase, even if your transition and
state names do not:
```javascript
var fsm = new StateMachine({
transitions: [
{ name: 'do-with-dash', from: 'has-dash', to: 'has_underscore' },
{ name: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized' },
{ name: 'doAlreadyCamelized', from: 'alreadyCamelize', to: 'has-dash' }
],
methods: {
onBeforeDoWithDash: function() { /* ... */ },
onBeforeDoWithUnderscore: function() { /* ... */ },
onBeforeDoAlreadyCamelized: function() { /* ... */ },
onLeaveHasDash: function() { /* ... */ },
onLeaveHasUnderscore: function() { /* ... */ },
onLeaveAlreadyCamelized: function() { /* ... */ },
onEnterHasDash: function() { /* ... */ },
onEnterHasUnderscore: function() { /* ... */ },
onEnterAlreadyCamelized: function() { /* ... */ },
onAfterDoWithDash: function() { /* ... */ },
onAfterDoWithUnderscore: function() { /* ... */ },
onAfterDoAlreadyCamelized: function() { /* ... */ }
}
});
```
# Lifecycle Events Listed in Order
To recap, the lifecycle of a transition occurs in the following order:
* `onBeforeTransition` - fired before any transition
* `onBefore` - fired before a specific TRANSITION
* `onLeaveState` - fired when leaving any state
* `onLeave` - fired when leaving a specific STATE
* `onTransition` - fired during any transition
* `onEnterState` - fired when entering any state
* `onEnter` - fired when entering a specific STATE
* `on` - convenience shorthand for `onEnter`
* `onAfterTransition` - fired after any transition
* `onAfter` - fired after a specific TRANSITION
* `on` - convenience shorthand for `onAfter`
# Cancelling a Transition
Any observer can cancel a transition by explicitly returning `false` during any of the following
lifecycle events:
* `onBeforeTransition`
* `onBefore`
* `onLeaveState`
* `onLeave`
* `onTransition`
All subsequent lifecycle events will be cancelled and the state will remain unchanged.
================================================
FILE: docs/state-history.md
================================================
# Remembering State History
By default, a state machine only tracks its current state. If you wish to track the state history
you can extend the state machine with the `state-machine-history` plugin.
```javascript
var StateMachineHistory = require('javascript-state-machine/lib/history')
```
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' }
],
plugins: [
new StateMachineHistory() // <-- plugin enabled here
]
})
fsm.history; // [ 'A' ]
fsm.step();
fsm.history; // [ 'A', 'B' ]
fsm.step();
fsm.history; // [ 'A', 'B', 'C' ]
fsm.clearHistory();
fsm.history; // [ ]
```
## Traversing History
You can traverse back through history using the `historyBack` and `historyForward` methods:
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' }
]
})
fsm.step();
fsm.step();
fsm.step();
fsm.state; // 'D'
fsm.history; // [ 'A', 'B', 'C', 'D' ]
fsm.historyBack();
fsm.state; // 'C'
fsm.history; // [ 'A', 'B', 'C' ]
fsm.historyBack();
fsm.state; // 'B'
fsm.history; // [ 'A', 'B' ]
fsm.historyForward();
fsm.state; // 'C'
fsm.history; // [ 'A', 'B', 'C' ]
```
You can test if history traversal is allowed using the following properties:
```javascript
fsm.canHistoryBack; // true/false
fsm.canHistoryForward; // true/false
```
A full set of [Lifecycle Events](lifecycle-events.md) will still apply when traversing history with
`historyBack` and `historyForward`.
## Limiting History
By default, the state machine history is unbounded and will continue to grow until cleared. You
can limit storage to only the last N states by configuring the plugin:
``` javascript
var fsm = new StateMachine({
plugins: [
new StateMachineHistory({ max: 100 }) // <-- plugin configuration
]
})
```
## Customizing History
If the `history` terminology clashes with your existing state machine attributes or methods, you
can enable the plugin with a different name:
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' }
],
plugins: [
new StateMachineHistory({ name: 'memory' })
]
})
fsm.step();
fsm.step();
fsm.memory; // [ 'A', 'B', 'C' ]
fsm.memoryBack();
fsm.memory; // [ 'A', 'B' ]
fsm.memoryForward();
fsm.memory; // [ 'A', 'B', 'C' ]
fsm.clearMemory();
fsm.memory; // [ ]
```
================================================
FILE: docs/state-machine-factory.md
================================================
# State Machine Factory
Most examples in this documentation construct a single state machine instance, for example:
```javascript
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
]
});
```
If you wish to construct multiple instances using the same configuration you should use a State
Machine Factory. A State Machine Factory provides a javascript constructor function (e.g. a 'class')
that can be instantiated multiple times:
```javascript
var Matter = StateMachine.factory({ // <-- the factory is constructed here
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
]
});
var a = new Matter(), // <-- instances are constructed here
b = new Matter(),
c = new Matter();
b.melt();
c.melt();
c.vaporize();
a.state; // solid
b.state; // liquid
c.state; // gas
```
Using the factory, each state machine instance is a unique javascript object. Each instance manages
its own `state` property, but methods are shared via the normal javascript prototype mechanism.
> NOTE: be aware of special case handling required for [Data and State Machine Factories](data-and-methods.md#data-and-state-machine-factories)
## Applying State Machine Behavior to Existing Objects
Occasionally, you may wish to apply state machine behavior to an already existing
object (e.g. a react component). You can achieve this using the `StateMachine.apply` method:
```javascript
var component = { /* ... */ };
StateMachine.apply(component, {
init: 'A',
transitions: {
{ name: 'step', from: 'A', to: 'B' }
}
});
```
> Be careful not to use state or transition names that will clash with existing object properties.
## Applying State Machine Factory Behavior to Existing Classes
You can also apply state machine factory behavior to an existing class, however you must now
take responsibility for initialization by calling `this._fsm()` from within your class
constructor method:
```javascript
function Person(name) {
this.name = name;
this._fsm(); // <-- IMPORTANT
}
Person.prototype = {
speak: function() {
console.log('my name is ' + this.name + ' and I am ' + this.state);
}
}
StateMachine.factory(Person, {
init: 'idle',
transitions: {
{ name: 'sleep', from: 'idle', to: 'sleeping' },
{ name: 'wake', from: 'sleeping', to: 'idle' }
}
});
var amy = new Person('amy'),
bob = new Person('bob');
bob.sleep();
amy.state; // 'idle'
bob.state; // 'sleeping'
amy.speak(); // 'my name is amy and I am idle'
bob.speak(); // 'my name is bob and I am sleeping'
```
================================================
FILE: docs/states-and-transitions.md
================================================
# States and Transitions

A state machine consists of a set of **states**, e.g:
* solid
* liquid
* gas
.. and a set of **transitions**, e.g:
* melt
* freeze
* vaporize
* condense
```javascript
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
]
});
fsm.state; // 'solid'
fsm.melt();
fsm.state; // 'liquid'
fsm.vaporize();
fsm.state; // 'gas'
```
## Multiple states for a transition

If a transition is allowed `from` multiple states then declare the transitions with the same name:
```javascript
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' }
```
If a transition with multiple `from` states always transitions `to` the same state, e.g:
```javascript
{ name: 'reset', from: 'B', to: 'A' },
{ name: 'reset', from: 'C', to: 'A' },
{ name: 'reset', from: 'D', to: 'A' }
```
... then it can be abbreviated using an array of `from` states:
```javascript
{ name: 'reset', from: [ 'B', 'C', 'D' ], to: 'A' }
```
Combining these into a single example:
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'reset', from: [ 'B', 'C', 'D' ], to: 'A' }
]
})
```
This example will create an object with 2 transition methods:
* `fsm.step()`
* `fsm.reset()`
The `reset` transition will always end up in the `A` state, while the `step` transition
will end up in a state that is dependent on the current state.
## Wildcard Transitions
If a transition is appropriate from **any** state, then a wildcard '*' `from` state can be used:
```javascript
var fsm = new StateMachine({
transitions: [
// ...
{ name: 'reset', from: '*', to: 'A' }
]
});
```
## Conditional Transitions
A transition can choose the target state at run-time by providing a function as the `to` attribute:
```javascript
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: '*', to: function(n) { return increaseCharacter(this.state, n || 1) } }
]
});
fsm.state; // A
fsm.step();
fsm.state; // B
fsm.step(5);
fsm.state; // G
// helper method to perform (c = c + n) on the 1st character in str
function increaseCharacter(str, n) {
return String.fromCharCode(str.charCodeAt(0) + n);
}
```
The `allStates` method will only include conditional states once they have been seen at run-time:
```javascript
fsm.state; // A
fsm.allStates(); // [ 'A' ]
fsm.step();
fsm.state; // B
fsm.allStates(); // [ 'A', 'B' ]
fsm.step(5);
fsm.state; // G
fsm.allStates(); // [ 'A', 'B', 'G' ]
```
## GOTO - Changing State Without a Transition
You can use a conditional transition, combined with a wildcard `from`, to implement
arbitrary `goto` behavior:
```javascript
var fsm = new StateMachine({
init: 'A'
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'goto', from: '*', to: function(s) { return s } }
]
})
fsm.state; // 'A'
fsm.goto('D');
fsm.state; // 'D'
```
A full set of [Lifecycle Events](lifecycle-events.md) still apply when using `goto`.
================================================
FILE: docs/upgrading-from-v2.md
================================================
# Upgrading from Version 2.x
Version 3.0 is a significant rewrite from earlier versions in order to support more
advanced use cases and to improve the existing use cases. Unfortunately, many of these
updates are incompatible with earlier versions, so changes are required in your code when you upgrade
to version 3.x. We want to tackle those all in one swoop and avoid any more big-bang changes
in the future.
Please read this article carefully if you are upgrading from version 2.x to 3.x.
> A [summary](#upgrade-summary) of the changes required can be found at the end of the article.
### Table of Contents
* [**Construction**](#construction) - constructing single instances follows a more idomatic javascript pattern.
* [**State Machine Factory**](#state-machine-factory) - constructing multiple instances from a class has been simplified.
* [**Data and Methods**](#data-and-methods) - A state machine can now have additional data and methods.
* [**Renamed Terminology**](#renamed-terminology) - A more consistent terminology has been applied.
* [**Lifecycle Events**](#lifecycle-events) - (previously called 'callbacks') are camelCased and observable.
* [**Async Transitions**](#promise-based-asynchronous-transitions) - Asynchronous transitions now use standard Promises.
* [**Conditional Transitions**](#conditional-transitions) - A transition can now dynamically choose its target state at run-time.
* [**Goto**](#goto) - The state can be changed without a defined transition using `goto`.
* [**State History**](#state-history) - The state history can now be retained and traversed with back/forward semantics.
* [**Visualization**](#visualization) - A state machine can now be visualized using GraphViz.
* [**Build System**](#build-system) - A new webpack-based build system has been implemented.
## Construction
Constructing a single state machine now follows a more idiomatic javascript pattern:
Version 2.x:
```javascript
var fsm = StateMachine.create({ /* ... */ })
```
**Version 3.x**:
```javascript
var fsm = new StateMachine({ /* ... */ }) // <-- more idomatic
```
## State Machine Factory
Constructing multiple instances from a state machine 'class' has been simplified:
Version 2.x:
```javascript
function FSM() { }
StateMachine.create({
target: FSM.prototype,
// ...
})
var a = new FSM(),
b = new FSM();
```
**Version 3.x**:
```javascript
var FSM = StateMachine.factory({ /* ... */ }), // <-- generate a factory (a constructor function)
a = new FSM(), // <-- then create instances
b = new FSM();
```
## Data and Methods
A state machine can now have additional (arbitrary) data and methods defined:
Version 2.x: _not supported_.
**Version 3.x**:
```javascript
var fsm = new StateMachine({
data: {
color: 'red'
},
methods: {
speak: function() { console.log('hello') }
}
});
fsm.color; // 'red'
fsm.speak(); // 'hello'
```
## Renamed Terminology
A more consistent terminology has been applied:
* A state machine consists of a set of [**States**](states-and-transitions.md).
* A state machine changes state by using [**Transitions**](states-and-transitions.md).
* A state machine can perform actions during a transition by observing [**Lifecycle Events**](lifecycle-events.md).
* A state machine can also have arbitrary [**Data and Methods**](data-and-methods.md).
Version 2.x:
```javascript
var fsm = StateMachine.create({
initial: 'ready',
events: [ /* ... */ ],
callbacks: { /* ... */ }
});
fsm.current; // 'ready'
```
**Version 3.x**:
```javascript
var fsm = new StateMachine({
init: 'ready', // <-- renamed s/initial/init/
transitions: [ /* ... */ ], // <-- renamed s/events/transitions/
data: { /* ... */ }, // <-- new
methods: { /* ... */ } // <-- renamed s/callbacks/methods/
// ... which can contain arbitrary methods AND lifecycle event callbacks
});
fsm.state; // 'ready' // <-- renamed s/current/state/
```
## Lifecycle Events
**Callbacks** have been renamed **Lifecycle Events** and are now declared as `methods` on the
state machine using a more traditional javascript camelCase for the method names:
Version 2.x:
```javascript
var fsm = StateMachine.create({
initial: 'initial-state',
events: [
{ name: 'do-something', from: 'initial-state', to: 'final-state' }
],
callbacks: {
onbeforedosomething: function() { /* ... */ },
onleaveinitialstate: function() { /* ... */ },
onenterfinalstate: function() { /* ... */ },
onafterdosomething: function() { /* ... */ }
}
})
```
**Version 3.x**:
```javascript
var fsm = new StateMachine({
init: 'initial-state',
transitions: [
{ name: 'do-something', from: 'initial-state', to: 'final-state' }
],
methods: { // <-- renamed s/callbacks/methods/
onBeforeDoSomething: function() { /* ... */ }, // <-- camelCase naming convention
onLeaveInitialState: function() { /* ... */ }, // <--
onEnterFinalState: function() { /* ... */ }, // <--
onAfterDoSomething: function() { /* ... */ } // <--
}
})
```
Lifecycle events are now passed information in a single `lifecycle` argument:
Version 2.x:
```javascript
var fsm = StateMachine.create({
events: [
{ name: 'step', from: 'none', to: 'complete' }
],
callbacks: {
onbeforestep: function(event, from, to) {
console.log('event: ' + event); // 'step'
console.log('from: ' + from); // 'none'
console.log('to: ' + to); // 'complete'
},
}
});
```
**Version 3.x**:
```javascript
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
],
methods: {
onBeforeStep: function(lifecycle) { // <-- combined into a single argument
console.log('transition: ' + lifecycle.transition); // 'step'
console.log('from: ' + lifecycle.from); // 'none'
console.log('to: ' + lifecycle.to); // 'complete'
}
}
});
```
> This change allows us to include additional information in the future without having to have a ridiculous
number of arguments to lifecycle event observer methods
Lifecycle events are also now observable by others:
Version 2.x: _not supported_.
**Version 3.x**:
```javascript
var fsm = new StateMachine({ /* ... */ });
// observe individual lifecycle events with observer methods
fsm.observe('onBeforeTransition', function() { /* ... */ });
fsm.observe('onLeaveState', function() { /* ... */ });
// or observe multiple lifecycle events with an observer object
fsm.observe({
onBeforeTransition: function() { /* ... */ },
onLeaveState: function() { /* ... */ }
});
```
The general purpose lifecycle events now use the word `transition` instead of `event` and
occur **before** their specialized versions:
Version 2.x, the lifecycle order was:
* `onbefore`
* `onbeforeevent`
* `onleave`
* `onleavestate`
* `onenter`
* `onenterstate`
* `on`
* `onafter`
* `onafterevent`
* `on`
**Version 3.x**, the lifecycle order is:
* `onBeforeTransition` - fired before any transition
* `onBefore` - fired before a specific TRANSITION
* `onLeaveState` - fired when leaving any state
* `onLeave` - fired when leaving a specific STATE
* `onTransition` - fired during any transition
* `onEnterState` - fired when entering any state
* `onEnter` - fired when entering a specific STATE
* `on` - convenience shorthand for `onEnter`
* `onAfterTransition` - fired after any transition
* `onAfter` - fired after a specific TRANSITION
* `on` - convenience shorthand for `onAfter`
> For more details, read [Lifecycle Events](lifecycle-events.md)
## Promise-Based Asynchronous Transitions
Asynchronous transitions are now implemented using standard javascript [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise).
If you return a Promise from **any** lifecycle event then the entire lifecycle for that transition
is put on hold until that Promise gets resolved. If the promise is rejected then the transition
is cancelled.
Version 2.x:
```javascript
var fsm = StateMachine.create({
events: [
{ name: 'step', from: 'none', to: 'complete' }
],
callbacks: {
onbeforestep: function() {
$('#ui').fadeOut('fast', function() {
fsm.transition();
});
return StateMachine.ASYNC;
}
}
});
```
**Version 3.x**:
```javascript
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
],
methods: {
onBeforeStep: function() {
return new Promise(function(resolve, reject) { // <-- return a Promise instead of StateMachine.ASYNC
$('#ui').fadeOut('fast', resolve); // <-- resolve the promise instead of calling .transition()
});
}
}
});
```
> For more details, read [Asynchronous Transitions](async-transitions.md)
## Conditional Transitions
A transition can now be conditional and choose the target state at run-time by providing a function
as the `to` attribute.
Version 2.x: _not supported_.
**Version 3.x**: See [Conditional Transitions](states-and-transitions.md#conditional-transitions)
## Goto
The state can now be changed without the need for a predefined transition using a conditional `goto`
transition:
Version 2.x: _not_supported_.
**Version 3.x**: See [Goto](states-and-transitions.md#goto---changing-state-without-a-transition)
## State History
A state machine can now track and traverse (back/forward) its state history.
Version 2.x: _not supported_.
**Version 3.x**: See [State History](state-history.md)
## Visualization
A state machine can now be visualized as a directed graph using GraphViz `.dot` syntax.
Version 2.x: _not_supported_.
**Version 3.x**: See [Visualization](visualization.md)
## Build System
A new [Webpack](https://webpack.js.org/concepts/) based build system has been provided along
with an [Ava](https://github.com/avajs/ava) based unit test suite.
Version 2.x: _not_supported_.
**Version 3.x**: See [Contributing](contributing.md)
## Other Breaking Changes in Version 3.0
`isFinished` is no longer built-in, you can easily add it to your state machine with a custom method:
```javascript
var fsm = new StateMachine({
methods: {
isFinished: function() { return this.state === 'done' }
}
})
```
# UPGRADE SUMMARY
The following list summarizes the above changes you might need when upgrading to version 3.0
* replace `StateMachine.create()` with `new StateMachine()`
* rename:
* `initial` to `init`
* `events` to `transitions`
* `callbacks` to `methods`
* `fsm.current` to `fsm.state`
* update your callback methods:
* rename them to use traditional javascript `camelCasing`
* refactor them to use the single `lifecycle` argument instead of individual `event,from,to` arguments
* update any asynchronous callback methods:
* return a `Promise` instead of `StateMachine.ASYNC`
* `resolve()` the promise when ready instead of calling `fsm.transition()`
* replace `StateMachine.create({ target: FOO })` with:
* if FOO is a class - `StateMachine.factory(FOO, {})`
* if FOO is an object - `StateMachine.apply(FOO, {})`
================================================
FILE: docs/visualization.md
================================================
# Visualization
It can be very helpful to visualize your state machine as a directed graph. This is possible
with the open source [GraphViz](http://www.graphviz.org/) library if we convert from our
state machine configuration to the `.dot` language expected by GraphViz using the
`visualize` method:
```javascript
var visualize = require('javascript-state-machine/lib/visualize');
var fsm = new StateMachine({
init: 'open',
transitions: [
{ name: 'close', from: 'open', to: 'closed' },
{ name: 'open', from: 'closed', to: 'open' }
]
});
visualize(fsm)
```
Generates the following .dot syntax:
```dot
digraph "fsm" {
"closed";
"open";
"closed" -> "open" [ label=" open " ];
"open" -> "closed" [ label=" close " ];
}
```
Which GraphViz displays as:

## Enhanced Visualization
You can customize the generated `.dot` output - and hence the graphviz visualization - by attaching
`dot` attributes to your transitions and (optionally) declaring an `orientation`:
```javascript
var fsm = new StateMachine({
init: 'closed',
transitions: [
{ name: 'open', from: 'closed', to: 'open', dot: { color: 'blue', headport: 'n', tailport: 'n' } },
{ name: 'close', from: 'open', to: 'closed', dot: { color: 'red', headport: 's', tailport: 's' } }
]
});
visualize(fsm, { name: 'door', orientation: 'horizontal' });
```
Generates the following (enhanced) `.dot` syntax:
```dot
digraph "door" {
rankdir=LR;
"closed";
"open";
"closed" -> "open" [ color="blue" ; headport="n" ; label=" open " ; tailport="n" ];
"open" -> "closed" [ color="red" ; headport="s" ; label=" close " ; tailport="s" ];
}
```
Which GraphViz displays as:

## Visualizing State Machine Factories
You can use the same `visualize` method to generate `.dot` output for a state machine factory:
```javascript
var Matter = StateMachine.factory({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid', dot: { headport: 'nw' } },
{ name: 'freeze', from: 'liquid', to: 'solid', dot: { headport: 'se' } },
{ name: 'vaporize', from: 'liquid', to: 'gas', dot: { headport: 'nw' } },
{ name: 'condense', from: 'gas', to: 'liquid', dot: { headport: 'se' } }
]
});
visualize(Matter, { name: 'matter', orientation: 'horizontal' })
```
Generates the following .dot syntax:
```dot
digraph "matter" {
rankdir=LR;
"solid";
"liquid";
"gas";
"solid" -> "liquid" [ headport="nw" ; label=" melt " ];
"liquid" -> "solid" [ headport="se" ; label=" freeze " ];
"liquid" -> "gas" [ headport="nw" ; label=" vaporize " ];
"gas" -> "liquid" [ headport="se" ; label=" condense " ];
}
```
Which GraphViz displays as:

## Other Examples
```javascript
var Wizard = StateMachine.factory({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B', dot: { headport: 'w', tailport: 'ne' } },
{ name: 'step', from: 'B', to: 'C', dot: { headport: 'w', tailport: 'e' } },
{ name: 'step', from: 'C', to: 'D', dot: { headport: 'w', tailport: 'e' } },
{ name: 'reset', from: [ 'B', 'C', 'D' ], to: 'A', dot: { headport: 'se', tailport: 's' } }
]
});
visualize(Wizard, { orientation: 'horizontal' })
```
Generates:
```dot
digraph "wizard" {
rankdir=LR;
"A";
"B";
"C";
"D";
"A" -> "B" [ headport="w" ; label=" step " ; tailport="ne" ];
"B" -> "C" [ headport="w" ; label=" step " ; tailport="e" ];
"C" -> "D" [ headport="w" ; label=" step " ; tailport="e" ];
"B" -> "A" [ headport="se" ; label=" reset " ; tailport="s" ];
"C" -> "A" [ headport="se" ; label=" reset " ; tailport="s" ];
"D" -> "A" [ headport="se" ; label=" reset " ; tailport="s" ];
}
```
Displays:

```javascript
var ATM = StateMachine.factory({
init: 'ready',
transitions: [
{ name: 'insert-card', from: 'ready', to: 'pin' },
{ name: 'confirm', from: 'pin', to: 'action' },
{ name: 'reject', from: 'pin', to: 'return-card' },
{ name: 'withdraw', from: 'return-card', to: 'ready' },
{ name: 'deposit', from: 'action', to: 'deposit-account' },
{ name: 'provide', from: 'deposit-account', to: 'deposit-amount' },
{ name: 'provide', from: 'deposit-amount', to: 'confirm-deposit' },
{ name: 'confirm', from: 'confirm-deposit', to: 'collect-envelope' },
{ name: 'provide', from: 'collect-envelope', to: 'continue' },
{ name: 'withdraw', from: 'action', to: 'withdrawal-account' },
{ name: 'provide', from: 'withdrawal-account', to: 'withdrawal-amount' },
{ name: 'provide', from: 'withdrawal-amount', to: 'confirm-withdrawal' },
{ name: 'confirm', from: 'confirm-withdrawal', to: 'dispense-cash' },
{ name: 'withdraw', from: 'dispense-cash', to: 'continue' },
{ name: 'continue', from: 'continue', to: 'action' },
{ name: 'finish', from: 'continue', to: 'return-card' }
]
})
visualize(ATM)
```
Generates:
```dot
digraph "ATM" {
"ready";
"pin";
"action";
"return-card";
"deposit-account";
"deposit-amount";
"confirm-deposit";
"collect-envelope";
"continue";
"withdrawal-account";
"withdrawal-amount";
"confirm-withdrawal";
"dispense-cash";
"ready" -> "pin" [ label=" insert-card " ];
"pin" -> "action" [ label=" confirm " ];
"pin" -> "return-card" [ label=" reject " ];
"return-card" -> "ready" [ label=" withdraw " ];
"action" -> "deposit-account" [ label=" deposit " ];
"deposit-account" -> "deposit-amount" [ label=" provide " ];
"deposit-amount" -> "confirm-deposit" [ label=" provide " ];
"confirm-deposit" -> "collect-envelope" [ label=" confirm " ];
"collect-envelope" -> "continue" [ label=" provide " ];
"action" -> "withdrawal-account" [ label=" withdraw " ];
"withdrawal-account" -> "withdrawal-amount" [ label=" provide " ];
"withdrawal-amount" -> "confirm-withdrawal" [ label=" provide " ];
"confirm-withdrawal" -> "dispense-cash" [ label=" confirm " ];
"dispense-cash" -> "continue" [ label=" withdraw " ];
"continue" -> "action" [ label=" continue " ];
"continue" -> "return-card" [ label=" finish " ];
}
```
Displays:

================================================
FILE: examples/atm.dot
================================================
digraph "ATM" {
"ready";
"pin";
"action";
"return-card";
"deposit-account";
"deposit-amount";
"confirm-deposit";
"collect-envelope";
"continue";
"withdrawal-account";
"withdrawal-amount";
"confirm-withdrawal";
"dispense-cash";
"ready" -> "pin" [ label=" insert-card " ];
"pin" -> "action" [ label=" confirm " ];
"pin" -> "return-card" [ label=" reject " ];
"return-card" -> "ready" [ label=" withdraw " ];
"action" -> "deposit-account" [ label=" deposit " ];
"deposit-account" -> "deposit-amount" [ label=" provide " ];
"deposit-amount" -> "confirm-deposit" [ label=" provide " ];
"confirm-deposit" -> "collect-envelope" [ label=" confirm " ];
"collect-envelope" -> "continue" [ label=" provide " ];
"action" -> "withdrawal-account" [ label=" withdraw " ];
"withdrawal-account" -> "withdrawal-amount" [ label=" provide " ];
"withdrawal-amount" -> "confirm-withdrawal" [ label=" provide " ];
"confirm-withdrawal" -> "dispense-cash" [ label=" confirm " ];
"dispense-cash" -> "continue" [ label=" withdraw " ];
"continue" -> "action" [ label=" continue " ];
"continue" -> "return-card" [ label=" finish " ];
}
================================================
FILE: examples/atm.js
================================================
var StateMachine = require('../src/app'),
visualize = require('../src/plugin/visualize');
var ATM = StateMachine.factory({
init: 'ready',
transitions: [
{ name: 'insert-card', from: 'ready', to: 'pin' },
{ name: 'confirm', from: 'pin', to: 'action' },
{ name: 'reject', from: 'pin', to: 'return-card' },
{ name: 'withdraw', from: 'return-card', to: 'ready' },
{ name: 'deposit', from: 'action', to: 'deposit-account' },
{ name: 'provide', from: 'deposit-account', to: 'deposit-amount' },
{ name: 'provide', from: 'deposit-amount', to: 'confirm-deposit' },
{ name: 'confirm', from: 'confirm-deposit', to: 'collect-envelope' },
{ name: 'provide', from: 'collect-envelope', to: 'continue' },
{ name: 'withdraw', from: 'action', to: 'withdrawal-account' },
{ name: 'provide', from: 'withdrawal-account', to: 'withdrawal-amount' },
{ name: 'provide', from: 'withdrawal-amount', to: 'confirm-withdrawal' },
{ name: 'confirm', from: 'confirm-withdrawal', to: 'dispense-cash' },
{ name: 'withdraw', from: 'dispense-cash', to: 'continue' },
{ name: 'continue', from: 'continue', to: 'action' },
{ name: 'finish', from: 'continue', to: 'return-card' }
]
})
ATM.visualize = function() {
return visualize(ATM, { name: 'ATM' })
}
module.exports = ATM
================================================
FILE: examples/demo/demo.css
================================================
#demo { width: 400px; margin: 0 auto; text-align: center; }
#controls { text-align: center; }
#demo #notes { margin-bottom: 1em; }
#demo #diagram { width: 400px; height: 275px; }
#demo #output { width: 100%; height: 30em; }
#demo.green #diagram { background: url(images/alerts.green.png); }
#demo.yellow #diagram { background: url(images/alerts.yellow.png); }
#demo.red #diagram { background: url(images/alerts.red.png); }
================================================
FILE: examples/demo/demo.js
================================================
Demo = function() {
var output = document.getElementById('output'),
demo = document.getElementById('demo'),
panic = document.getElementById('panic'),
warn = document.getElementById('warn'),
calm = document.getElementById('calm'),
clear = document.getElementById('clear'),
count = 0;
var log = function(msg, separate) {
count = count + (separate ? 1 : 0);
output.value = count + ": " + msg + "\n" + (separate ? "\n" : "") + output.value;
refreshUI();
};
var refreshUI = function() {
setTimeout(function() {
demo.className = fsm.state;
panic.disabled = fsm.cannot('panic', true);
warn.disabled = fsm.cannot('warn', true);
calm.disabled = fsm.cannot('calm', true);
clear.disabled = fsm.cannot('clear', true);
}, 0); // defer until end of current tick to allow fsm to complete transaction
};
var fsm = new StateMachine({
transitions: [
{ name: 'start', from: 'none', to: 'green' },
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'green', to: 'red' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'red', to: 'green' },
{ name: 'clear', from: 'yellow', to: 'green' },
],
methods: {
onBeforeTransition: function(lifecycle) {
log("BEFORE: " + lifecycle.transition, true);
},
onLeaveState: function(lifecycle) {
log("LEAVE: " + lifecycle.from);
},
onEnterState: function(lifecycle) {
log("ENTER: " + lifecycle.to);
},
onAfterTransition: function(lifecycle) {
log("AFTER: " + lifecycle.transition);
},
onTransition: function(lifecycle) {
log("DURING: " + lifecycle.transition + " (from " + lifecycle.from + " to " + lifecycle.to + ")");
},
onLeaveRed: function(lifecycle) {
return new Promise(function(resolve, reject) {
var msg = lifecycle.transition + ' to ' + lifecycle.to;
log("PENDING " + msg + " in ...3");
setTimeout(function() {
log("PENDING " + msg + " in ...2");
setTimeout(function() {
log("PENDING " + msg + " in ...1");
setTimeout(function() {
resolve();
}, 1000);
}, 1000);
}, 1000);
});
}
}
});
fsm.start();
return fsm;
}();
================================================
FILE: examples/horizontal_door.dot
================================================
digraph "door" {
rankdir=LR;
"closed";
"open";
"closed" -> "open" [ color="blue" ; headport="n" ; label=" open " ; tailport="n" ];
"open" -> "closed" [ color="red" ; headport="s" ; label=" close " ; tailport="s" ];
}
================================================
FILE: examples/horizontal_door.js
================================================
var StateMachine = require('../src/app'),
visualize = require('../src/plugin/visualize');
var Door = StateMachine.factory({
init: 'closed',
transitions: [
{ name: 'open', from: 'closed', to: 'open', dot: { color: 'blue', headport: 'n', tailport: 'n' } },
{ name: 'close', from: 'open', to: 'closed', dot: { color: 'red', headport: 's', tailport: 's' } }
]
});
Door.visualize = function() {
return visualize(Door, { name: 'door', orientation: 'horizontal' })
}
module.exports = Door
================================================
FILE: examples/matter.dot
================================================
digraph "matter" {
rankdir=LR;
"solid";
"liquid";
"gas";
"solid" -> "liquid" [ headport="nw" ; label=" melt " ];
"liquid" -> "solid" [ headport="se" ; label=" freeze " ];
"liquid" -> "gas" [ headport="nw" ; label=" vaporize " ];
"gas" -> "liquid" [ headport="se" ; label=" condense " ];
}
================================================
FILE: examples/matter.js
================================================
var StateMachine = require('../src/app'),
visualize = require('../src/plugin/visualize');
var Matter = StateMachine.factory({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid', dot: { headport: 'nw' } },
{ name: 'freeze', from: 'liquid', to: 'solid', dot: { headport: 'se' } },
{ name: 'vaporize', from: 'liquid', to: 'gas', dot: { headport: 'nw' } },
{ name: 'condense', from: 'gas', to: 'liquid', dot: { headport: 'se' } }
]
});
Matter.visualize = function() {
return visualize(Matter, { name: 'matter', orientation: 'horizontal' })
}
module.exports = Matter
================================================
FILE: examples/vertical_door.dot
================================================
digraph "fsm" {
"closed";
"open";
"closed" -> "open" [ label=" open " ];
"open" -> "closed" [ label=" close " ];
}
================================================
FILE: examples/vertical_door.js
================================================
var StateMachine = require('../src/app'),
visualize = require('../src/plugin/visualize');
var Door = StateMachine.factory({
init: 'closed',
transitions: [
{ name: 'open', from: 'closed', to: 'open' },
{ name: 'close', from: 'open', to: 'closed' }
]
});
Door.visualize = function() {
return visualize(Door)
}
module.exports = Door
================================================
FILE: examples/wizard.dot
================================================
digraph "wizard" {
rankdir=LR;
"A";
"B";
"C";
"D";
"A" -> "B" [ headport="w" ; label=" step " ; tailport="ne" ];
"B" -> "C" [ headport="w" ; label=" step " ; tailport="e" ];
"C" -> "D" [ headport="w" ; label=" step " ; tailport="e" ];
"B" -> "A" [ headport="se" ; label=" reset " ; tailport="s" ];
"C" -> "A" [ headport="se" ; label=" reset " ; tailport="s" ];
"D" -> "A" [ headport="se" ; label=" reset " ; tailport="s" ];
}
================================================
FILE: examples/wizard.js
================================================
var StateMachine = require('../src/app'),
visualize = require('../src/plugin/visualize');
var Wizard = StateMachine.factory({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B', dot: { headport: 'w', tailport: 'ne' } },
{ name: 'step', from: 'B', to: 'C', dot: { headport: 'w', tailport: 'e' } },
{ name: 'step', from: 'C', to: 'D', dot: { headport: 'w', tailport: 'e' } },
{ name: 'reset', from: [ 'B', 'C', 'D' ], to: 'A', dot: { headport: 'se', tailport: 's' } }
]
});
Wizard.visualize = function() {
return visualize(Wizard, { name: 'wizard', orientation: 'horizontal' })
}
module.exports = Wizard
================================================
FILE: index.html
================================================
Javascript Finite State Machine
Finite State Machine
dashed lines are asynchronous state transitions (3 seconds)
================================================
FILE: lib/history.js
================================================
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("StateMachineHistory", [], factory);
else if(typeof exports === 'object')
exports["StateMachineHistory"] = factory();
else
root["StateMachineHistory"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 1);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
function camelize(label) {
if (label.length === 0)
return label;
var n, result, word, words = label.split(/[_-]/);
// single word with first character already lowercase, return untouched
if ((words.length === 1) && (words[0][0].toLowerCase() === words[0][0]))
return label;
result = words[0].toLowerCase();
for(n = 1 ; n < words.length ; n++) {
result = result + words[n].charAt(0).toUpperCase() + words[n].substring(1).toLowerCase();
}
return result;
}
//-------------------------------------------------------------------------------------------------
camelize.prepended = function(prepend, label) {
label = camelize(label);
return prepend + label[0].toUpperCase() + label.substring(1);
}
//-------------------------------------------------------------------------------------------------
module.exports = camelize;
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
var camelize = __webpack_require__(0);
//-------------------------------------------------------------------------------------------------
module.exports = function(options) { options = options || {};
var past = camelize(options.name || options.past || 'history'),
future = camelize( options.future || 'future'),
clear = camelize.prepended('clear', past),
back = camelize.prepended(past, 'back'),
forward = camelize.prepended(past, 'forward'),
canBack = camelize.prepended('can', back),
canForward = camelize.prepended('can', forward),
max = options.max;
var plugin = {
configure: function(config) {
config.addTransitionLifecycleNames(back);
config.addTransitionLifecycleNames(forward);
},
init: function(instance) {
instance[past] = [];
instance[future] = [];
},
lifecycle: function(instance, lifecycle) {
if (lifecycle.event === 'onEnterState') {
instance[past].push(lifecycle.to);
if (max && instance[past].length > max)
instance[past].shift();
if (lifecycle.transition !== back && lifecycle.transition !== forward)
instance[future].length = 0;
}
},
methods: {},
properties: {}
}
plugin.methods[clear] = function() {
this[past].length = 0
this[future].length = 0
}
plugin.properties[canBack] = {
get: function() {
return this[past].length > 1
}
}
plugin.properties[canForward] = {
get: function() {
return this[future].length > 0
}
}
plugin.methods[back] = function() {
if (!this[canBack])
throw Error('no history');
var from = this[past].pop(),
to = this[past].pop();
this[future].push(from);
this._fsm.transit(back, from, to, []);
}
plugin.methods[forward] = function() {
if (!this[canForward])
throw Error('no history');
var from = this.state,
to = this[future].pop();
this._fsm.transit(forward, from, to, []);
}
return plugin;
}
/***/ })
/******/ ]);
});
================================================
FILE: lib/state-machine.js
================================================
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("StateMachine", [], factory);
else if(typeof exports === 'object')
exports["StateMachine"] = factory();
else
root["StateMachine"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 5);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
module.exports = function(target, sources) {
var n, source, key;
for(n = 1 ; n < arguments.length ; n++) {
source = arguments[n];
for(key in source) {
if (source.hasOwnProperty(key))
target[key] = source[key];
}
}
return target;
}
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
var mixin = __webpack_require__(0);
//-------------------------------------------------------------------------------------------------
module.exports = {
build: function(target, config) {
var n, max, plugin, plugins = config.plugins;
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n];
if (plugin.methods)
mixin(target, plugin.methods);
if (plugin.properties)
Object.defineProperties(target, plugin.properties);
}
},
hook: function(fsm, name, additional) {
var n, max, method, plugin,
plugins = fsm.config.plugins,
args = [fsm.context];
if (additional)
args = args.concat(additional)
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n]
method = plugins[n][name]
if (method)
method.apply(plugin, args);
}
}
}
//-------------------------------------------------------------------------------------------------
/***/ }),
/* 2 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
function camelize(label) {
if (label.length === 0)
return label;
var n, result, word, words = label.split(/[_-]/);
// single word with first character already lowercase, return untouched
if ((words.length === 1) && (words[0][0].toLowerCase() === words[0][0]))
return label;
result = words[0].toLowerCase();
for(n = 1 ; n < words.length ; n++) {
result = result + words[n].charAt(0).toUpperCase() + words[n].substring(1).toLowerCase();
}
return result;
}
//-------------------------------------------------------------------------------------------------
camelize.prepended = function(prepend, label) {
label = camelize(label);
return prepend + label[0].toUpperCase() + label.substring(1);
}
//-------------------------------------------------------------------------------------------------
module.exports = camelize;
/***/ }),
/* 3 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
var mixin = __webpack_require__(0),
camelize = __webpack_require__(2);
//-------------------------------------------------------------------------------------------------
function Config(options, StateMachine) {
options = options || {};
this.options = options; // preserving original options can be useful (e.g visualize plugin)
this.defaults = StateMachine.defaults;
this.states = [];
this.transitions = [];
this.map = {};
this.lifecycle = this.configureLifecycle();
this.init = this.configureInitTransition(options.init);
this.data = this.configureData(options.data);
this.methods = this.configureMethods(options.methods);
this.map[this.defaults.wildcard] = {};
this.configureTransitions(options.transitions || []);
this.plugins = this.configurePlugins(options.plugins, StateMachine.plugin);
}
//-------------------------------------------------------------------------------------------------
mixin(Config.prototype, {
addState: function(name) {
if (!this.map[name]) {
this.states.push(name);
this.addStateLifecycleNames(name);
this.map[name] = {};
}
},
addStateLifecycleNames: function(name) {
this.lifecycle.onEnter[name] = camelize.prepended('onEnter', name);
this.lifecycle.onLeave[name] = camelize.prepended('onLeave', name);
this.lifecycle.on[name] = camelize.prepended('on', name);
},
addTransition: function(name) {
if (this.transitions.indexOf(name) < 0) {
this.transitions.push(name);
this.addTransitionLifecycleNames(name);
}
},
addTransitionLifecycleNames: function(name) {
this.lifecycle.onBefore[name] = camelize.prepended('onBefore', name);
this.lifecycle.onAfter[name] = camelize.prepended('onAfter', name);
this.lifecycle.on[name] = camelize.prepended('on', name);
},
mapTransition: function(transition) {
var name = transition.name,
from = transition.from,
to = transition.to;
this.addState(from);
if (typeof to !== 'function')
this.addState(to);
this.addTransition(name);
this.map[from][name] = transition;
return transition;
},
configureLifecycle: function() {
return {
onBefore: { transition: 'onBeforeTransition' },
onAfter: { transition: 'onAfterTransition' },
onEnter: { state: 'onEnterState' },
onLeave: { state: 'onLeaveState' },
on: { transition: 'onTransition' }
};
},
configureInitTransition: function(init) {
if (typeof init === 'string') {
return this.mapTransition(mixin({}, this.defaults.init, { to: init, active: true }));
}
else if (typeof init === 'object') {
return this.mapTransition(mixin({}, this.defaults.init, init, { active: true }));
}
else {
this.addState(this.defaults.init.from);
return this.defaults.init;
}
},
configureData: function(data) {
if (typeof data === 'function')
return data;
else if (typeof data === 'object')
return function() { return data; }
else
return function() { return {}; }
},
configureMethods: function(methods) {
return methods || {};
},
configurePlugins: function(plugins, builtin) {
plugins = plugins || [];
var n, max, plugin;
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n];
if (typeof plugin === 'function')
plugins[n] = plugin = plugin()
if (plugin.configure)
plugin.configure(this);
}
return plugins
},
configureTransitions: function(transitions) {
var i, n, transition, from, to, wildcard = this.defaults.wildcard;
for(n = 0 ; n < transitions.length ; n++) {
transition = transitions[n];
from = Array.isArray(transition.from) ? transition.from : [transition.from || wildcard]
to = transition.to || wildcard;
for(i = 0 ; i < from.length ; i++) {
this.mapTransition({ name: transition.name, from: from[i], to: to });
}
}
},
transitionFor: function(state, transition) {
var wildcard = this.defaults.wildcard;
return this.map[state][transition] ||
this.map[wildcard][transition];
},
transitionsFor: function(state) {
var wildcard = this.defaults.wildcard;
return Object.keys(this.map[state]).concat(Object.keys(this.map[wildcard]));
},
allStates: function() {
return this.states;
},
allTransitions: function() {
return this.transitions;
}
});
//-------------------------------------------------------------------------------------------------
module.exports = Config;
//-------------------------------------------------------------------------------------------------
/***/ }),
/* 4 */
/***/ (function(module, exports, __webpack_require__) {
var mixin = __webpack_require__(0),
Exception = __webpack_require__(6),
plugin = __webpack_require__(1),
UNOBSERVED = [ null, [] ];
//-------------------------------------------------------------------------------------------------
function JSM(context, config) {
this.context = context;
this.config = config;
this.state = config.init.from;
this.observers = [context];
}
//-------------------------------------------------------------------------------------------------
mixin(JSM.prototype, {
init: function(args) {
mixin(this.context, this.config.data.apply(this.context, args));
plugin.hook(this, 'init');
if (this.config.init.active)
return this.fire(this.config.init.name, []);
},
is: function(state) {
return Array.isArray(state) ? (state.indexOf(this.state) >= 0) : (this.state === state);
},
isPending: function() {
return this.pending;
},
can: function(transition) {
return !this.isPending() && !!this.seek(transition);
},
cannot: function(transition) {
return !this.can(transition);
},
allStates: function() {
return this.config.allStates();
},
allTransitions: function() {
return this.config.allTransitions();
},
transitions: function() {
return this.config.transitionsFor(this.state);
},
seek: function(transition, args) {
var wildcard = this.config.defaults.wildcard,
entry = this.config.transitionFor(this.state, transition),
to = entry && entry.to;
if (typeof to === 'function')
return to.apply(this.context, args);
else if (to === wildcard)
return this.state
else
return to
},
fire: function(transition, args) {
return this.transit(transition, this.state, this.seek(transition, args), args);
},
transit: function(transition, from, to, args) {
var lifecycle = this.config.lifecycle,
changed = this.config.options.observeUnchangedState || (from !== to);
if (!to)
return this.context.onInvalidTransition(transition, from, to);
if (this.isPending())
return this.context.onPendingTransition(transition, from, to);
this.config.addState(to); // might need to add this state if it's unknown (e.g. conditional transition or goto)
this.beginTransit();
args.unshift({ // this context will be passed to each lifecycle event observer
transition: transition,
from: from,
to: to,
fsm: this.context
});
return this.observeEvents([
this.observersForEvent(lifecycle.onBefore.transition),
this.observersForEvent(lifecycle.onBefore[transition]),
changed ? this.observersForEvent(lifecycle.onLeave.state) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onLeave[from]) : UNOBSERVED,
this.observersForEvent(lifecycle.on.transition),
changed ? [ 'doTransit', [ this ] ] : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onEnter.state) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onEnter[to]) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.on[to]) : UNOBSERVED,
this.observersForEvent(lifecycle.onAfter.transition),
this.observersForEvent(lifecycle.onAfter[transition]),
this.observersForEvent(lifecycle.on[transition])
], args);
},
beginTransit: function() { this.pending = true; },
endTransit: function(result) { this.pending = false; return result; },
failTransit: function(result) { this.pending = false; throw result; },
doTransit: function(lifecycle) { this.state = lifecycle.to; },
observe: function(args) {
if (args.length === 2) {
var observer = {};
observer[args[0]] = args[1];
this.observers.push(observer);
}
else {
this.observers.push(args[0]);
}
},
observersForEvent: function(event) { // TODO: this could be cached
var n = 0, max = this.observers.length, observer, result = [];
for( ; n < max ; n++) {
observer = this.observers[n];
if (observer[event])
result.push(observer);
}
return [ event, result, true ]
},
observeEvents: function(events, args, previousEvent, previousResult) {
if (events.length === 0) {
return this.endTransit(previousResult === undefined ? true : previousResult);
}
var event = events[0][0],
observers = events[0][1],
pluggable = events[0][2];
args[0].event = event;
if (event && pluggable && event !== previousEvent)
plugin.hook(this, 'lifecycle', args);
if (observers.length === 0) {
events.shift();
return this.observeEvents(events, args, event, previousResult);
}
else {
var observer = observers.shift(),
result = observer[event].apply(observer, args);
if (result && typeof result.then === 'function') {
return result.then(this.observeEvents.bind(this, events, args, event))
.catch(this.failTransit.bind(this))
}
else if (result === false) {
return this.endTransit(false);
}
else {
return this.observeEvents(events, args, event, result);
}
}
},
onInvalidTransition: function(transition, from, to) {
throw new Exception("transition is invalid in current state", transition, from, to, this.state);
},
onPendingTransition: function(transition, from, to) {
throw new Exception("transition is invalid while previous transition is still in progress", transition, from, to, this.state);
}
});
//-------------------------------------------------------------------------------------------------
module.exports = JSM;
//-------------------------------------------------------------------------------------------------
/***/ }),
/* 5 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-----------------------------------------------------------------------------------------------
var mixin = __webpack_require__(0),
camelize = __webpack_require__(2),
plugin = __webpack_require__(1),
Config = __webpack_require__(3),
JSM = __webpack_require__(4);
//-----------------------------------------------------------------------------------------------
var PublicMethods = {
is: function(state) { return this._fsm.is(state) },
can: function(transition) { return this._fsm.can(transition) },
cannot: function(transition) { return this._fsm.cannot(transition) },
observe: function() { return this._fsm.observe(arguments) },
transitions: function() { return this._fsm.transitions() },
allTransitions: function() { return this._fsm.allTransitions() },
allStates: function() { return this._fsm.allStates() },
onInvalidTransition: function(t, from, to) { return this._fsm.onInvalidTransition(t, from, to) },
onPendingTransition: function(t, from, to) { return this._fsm.onPendingTransition(t, from, to) },
}
var PublicProperties = {
state: {
configurable: false,
enumerable: true,
get: function() {
return this._fsm.state;
},
set: function(state) {
throw Error('use transitions to change state')
}
}
}
//-----------------------------------------------------------------------------------------------
function StateMachine(options) {
return apply(this || {}, options);
}
function factory() {
var cstor, options;
if (typeof arguments[0] === 'function') {
cstor = arguments[0];
options = arguments[1] || {};
}
else {
cstor = function() { this._fsm.apply(this, arguments) };
options = arguments[0] || {};
}
var config = new Config(options, StateMachine);
build(cstor.prototype, config);
cstor.prototype._fsm.config = config; // convenience access to shared config without needing an instance
return cstor;
}
//-------------------------------------------------------------------------------------------------
function apply(instance, options) {
var config = new Config(options, StateMachine);
build(instance, config);
instance._fsm();
return instance;
}
function build(target, config) {
if ((typeof target !== 'object') || Array.isArray(target))
throw Error('StateMachine can only be applied to objects');
plugin.build(target, config);
Object.defineProperties(target, PublicProperties);
mixin(target, PublicMethods);
mixin(target, config.methods);
config.allTransitions().forEach(function(transition) {
target[camelize(transition)] = function() {
return this._fsm.fire(transition, [].slice.call(arguments))
}
});
target._fsm = function() {
this._fsm = new JSM(this, config);
this._fsm.init(arguments);
}
}
//-----------------------------------------------------------------------------------------------
StateMachine.version = '3.0.1';
StateMachine.factory = factory;
StateMachine.apply = apply;
StateMachine.defaults = {
wildcard: '*',
init: {
name: 'init',
from: 'none'
}
}
//===============================================================================================
module.exports = StateMachine;
/***/ }),
/* 6 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
module.exports = function(message, transition, from, to, current) {
this.message = message;
this.transition = transition;
this.from = from;
this.to = to;
this.current = current;
}
/***/ })
/******/ ]);
});
================================================
FILE: lib/visualize.js
================================================
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("StateMachineVisualize", [], factory);
else if(typeof exports === 'object')
exports["StateMachineVisualize"] = factory();
else
root["StateMachineVisualize"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 1);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
module.exports = function(target, sources) {
var n, source, key;
for(n = 1 ; n < arguments.length ; n++) {
source = arguments[n];
for(key in source) {
if (source.hasOwnProperty(key))
target[key] = source[key];
}
}
return target;
}
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
//-------------------------------------------------------------------------------------------------
var mixin = __webpack_require__(0)
//-------------------------------------------------------------------------------------------------
function visualize(fsm, options) {
return dotify(dotcfg(fsm, options));
}
//-------------------------------------------------------------------------------------------------
function dotcfg(fsm, options) {
options = options || {}
var config = dotcfg.fetch(fsm),
name = options.name,
rankdir = dotcfg.rankdir(options.orientation),
states = dotcfg.states(config, options),
transitions = dotcfg.transitions(config, options),
result = { }
if (name)
result.name = name
if (rankdir)
result.rankdir = rankdir
if (states && states.length > 0)
result.states = states
if (transitions && transitions.length > 0)
result.transitions = transitions
return result
}
//-------------------------------------------------------------------------------------------------
dotcfg.fetch = function(fsm) {
return (typeof fsm === 'function') ? fsm.prototype._fsm.config
: fsm._fsm.config
}
dotcfg.rankdir = function(orientation) {
if (orientation === 'horizontal')
return 'LR';
else if (orientation === 'vertical')
return 'TB';
}
dotcfg.states = function(config, options) {
var index, states = config.states;
if (!options.init) { // if not showing init transition, then slice out the implied init :from state
index = states.indexOf(config.init.from);
states = states.slice(0, index).concat(states.slice(index+1));
}
return states;
}
dotcfg.transitions = function(config, options) {
var n, max, transition,
init = config.init,
transitions = config.options.transitions || [], // easier to visualize using the ORIGINAL transition declarations rather than our run-time mapping
output = [];
if (options.init && init.active)
dotcfg.transition(init.name, init.from, init.to, init.dot, config, options, output)
for (n = 0, max = transitions.length ; n < max ; n++) {
transition = config.options.transitions[n]
dotcfg.transition(transition.name, transition.from, transition.to, transition.dot, config, options, output)
}
return output
}
dotcfg.transition = function(name, from, to, dot, config, options, output) {
var n, max, wildcard = config.defaults.wildcard
if (Array.isArray(from)) {
for(n = 0, max = from.length ; n < max ; n++)
dotcfg.transition(name, from[n], to, dot, config, options, output)
}
else if (from === wildcard || from === undefined) {
for(n = 0, max = config.states.length ; n < max ; n++)
dotcfg.transition(name, config.states[n], to, dot, config, options, output)
}
else if (to === wildcard || to === undefined) {
dotcfg.transition(name, from, from, dot, config, options, output)
}
else if (typeof to === 'function') {
// do nothing, can't display conditional transition
}
else {
output.push(mixin({}, { from: from, to: to, label: pad(name) }, dot || {}))
}
}
//-------------------------------------------------------------------------------------------------
function pad(name) {
return " " + name + " "
}
function quote(name) {
return "\"" + name + "\""
}
function dotify(dotcfg) {
dotcfg = dotcfg || {};
var name = dotcfg.name || 'fsm',
states = dotcfg.states || [],
transitions = dotcfg.transitions || [],
rankdir = dotcfg.rankdir,
output = [],
n, max;
output.push("digraph " + quote(name) + " {")
if (rankdir)
output.push(" rankdir=" + rankdir + ";")
for(n = 0, max = states.length ; n < max ; n++)
output.push(dotify.state(states[n]))
for(n = 0, max = transitions.length ; n < max ; n++)
output.push(dotify.edge(transitions[n]))
output.push("}")
return output.join("\n")
}
dotify.state = function(state) {
return " " + quote(state) + ";"
}
dotify.edge = function(edge) {
return " " + quote(edge.from) + " -> " + quote(edge.to) + dotify.edge.attr(edge) + ";"
}
dotify.edge.attr = function(edge) {
var n, max, key, keys = Object.keys(edge).sort(), output = [];
for(n = 0, max = keys.length ; n < max ; n++) {
key = keys[n];
if (key !== 'from' && key !== 'to')
output.push(key + "=" + quote(edge[key]))
}
return output.length > 0 ? " [ " + output.join(" ; ") + " ]" : ""
}
//-------------------------------------------------------------------------------------------------
visualize.dotcfg = dotcfg;
visualize.dotify = dotify;
//-------------------------------------------------------------------------------------------------
module.exports = visualize;
//-------------------------------------------------------------------------------------------------
/***/ })
/******/ ]);
});
================================================
FILE: package.json
================================================
{
"name": "javascript-state-machine",
"description": "A finite state machine library",
"homepage": "https://github.com/jakesgordon/javascript-state-machine",
"repository": {
"type": "git",
"url": "git://github.com/jakesgordon/javascript-state-machine.git"
},
"keywords": [
"finite state machine",
"state machine",
"server",
"client"
],
"author": {
"name": "Jake Gordon",
"email": "jakesgordon@gmail.com"
},
"maintainers": [
{
"name": "Jake Gordon",
"email": "jakesgordon@gmail.com"
}
],
"license": "MIT",
"main": "lib/state-machine.js",
"files": [
"lib/**/*.js",
"dist/**/*.js"
],
"directories": {},
"devDependencies": {
"ava": "^0.17.0",
"fs-sync": "^1.0.3",
"glob": "^7.1.1",
"nyc": "^10.0.0",
"pascal-case": "^2.0.0",
"uglify-js": "^2.7.5",
"webpack": "^2.2.0-rc.1"
},
"version": "3.1.0",
"scripts": {
"start": "npm run watch",
"build": "npm run bundle && npm run minify",
"bundle": "webpack",
"minify": "bin/minify",
"watch": "ava --watch",
"test": "nyc ava -v && nyc report --reporter=html"
},
"ava": {
"files": [
"test/**/*.js"
],
"source": [
"src/**/*.js"
]
}
}
================================================
FILE: src/app.js
================================================
'use strict'
//-----------------------------------------------------------------------------------------------
var mixin = require('./util/mixin'),
camelize = require('./util/camelize'),
plugin = require('./plugin'),
Config = require('./config'),
JSM = require('./jsm');
//-----------------------------------------------------------------------------------------------
var PublicMethods = {
is: function(state) { return this._fsm.is(state) },
can: function(transition) { return this._fsm.can(transition) },
cannot: function(transition) { return this._fsm.cannot(transition) },
observe: function() { return this._fsm.observe(arguments) },
transitions: function() { return this._fsm.transitions() },
allTransitions: function() { return this._fsm.allTransitions() },
allStates: function() { return this._fsm.allStates() },
onInvalidTransition: function(t, from, to) { return this._fsm.onInvalidTransition(t, from, to) },
onPendingTransition: function(t, from, to) { return this._fsm.onPendingTransition(t, from, to) },
}
var PublicProperties = {
state: {
configurable: false,
enumerable: true,
get: function() {
return this._fsm.state;
},
set: function(state) {
throw Error('use transitions to change state')
}
}
}
//-----------------------------------------------------------------------------------------------
function StateMachine(options) {
return apply(this || {}, options);
}
function factory() {
var cstor, options;
if (typeof arguments[0] === 'function') {
cstor = arguments[0];
options = arguments[1] || {};
}
else {
cstor = function() { this._fsm.apply(this, arguments) };
options = arguments[0] || {};
}
var config = new Config(options, StateMachine);
build(cstor.prototype, config);
cstor.prototype._fsm.config = config; // convenience access to shared config without needing an instance
return cstor;
}
//-------------------------------------------------------------------------------------------------
function apply(instance, options) {
var config = new Config(options, StateMachine);
build(instance, config);
instance._fsm();
return instance;
}
function build(target, config) {
if ((typeof target !== 'object') || Array.isArray(target))
throw Error('StateMachine can only be applied to objects');
plugin.build(target, config);
Object.defineProperties(target, PublicProperties);
mixin(target, PublicMethods);
mixin(target, config.methods);
config.allTransitions().forEach(function(transition) {
target[camelize(transition)] = function() {
return this._fsm.fire(transition, [].slice.call(arguments))
}
});
target._fsm = function() {
this._fsm = new JSM(this, config);
this._fsm.init(arguments);
}
}
//-----------------------------------------------------------------------------------------------
StateMachine.version = '3.0.1';
StateMachine.factory = factory;
StateMachine.apply = apply;
StateMachine.defaults = {
wildcard: '*',
init: {
name: 'init',
from: 'none'
}
}
//===============================================================================================
module.exports = StateMachine;
================================================
FILE: src/config.js
================================================
'use strict'
//-------------------------------------------------------------------------------------------------
var mixin = require('./util/mixin'),
camelize = require('./util/camelize');
//-------------------------------------------------------------------------------------------------
function Config(options, StateMachine) {
options = options || {};
this.options = options; // preserving original options can be useful (e.g visualize plugin)
this.defaults = StateMachine.defaults;
this.states = [];
this.transitions = [];
this.map = {};
this.lifecycle = this.configureLifecycle();
this.init = this.configureInitTransition(options.init);
this.data = this.configureData(options.data);
this.methods = this.configureMethods(options.methods);
this.map[this.defaults.wildcard] = {};
this.configureTransitions(options.transitions || []);
this.plugins = this.configurePlugins(options.plugins, StateMachine.plugin);
}
//-------------------------------------------------------------------------------------------------
mixin(Config.prototype, {
addState: function(name) {
if (!this.map[name]) {
this.states.push(name);
this.addStateLifecycleNames(name);
this.map[name] = {};
}
},
addStateLifecycleNames: function(name) {
this.lifecycle.onEnter[name] = camelize.prepended('onEnter', name);
this.lifecycle.onLeave[name] = camelize.prepended('onLeave', name);
this.lifecycle.on[name] = camelize.prepended('on', name);
},
addTransition: function(name) {
if (this.transitions.indexOf(name) < 0) {
this.transitions.push(name);
this.addTransitionLifecycleNames(name);
}
},
addTransitionLifecycleNames: function(name) {
this.lifecycle.onBefore[name] = camelize.prepended('onBefore', name);
this.lifecycle.onAfter[name] = camelize.prepended('onAfter', name);
this.lifecycle.on[name] = camelize.prepended('on', name);
},
mapTransition: function(transition) {
var name = transition.name,
from = transition.from,
to = transition.to;
this.addState(from);
if (typeof to !== 'function')
this.addState(to);
this.addTransition(name);
this.map[from][name] = transition;
return transition;
},
configureLifecycle: function() {
return {
onBefore: { transition: 'onBeforeTransition' },
onAfter: { transition: 'onAfterTransition' },
onEnter: { state: 'onEnterState' },
onLeave: { state: 'onLeaveState' },
on: { transition: 'onTransition' }
};
},
configureInitTransition: function(init) {
if (typeof init === 'string') {
return this.mapTransition(mixin({}, this.defaults.init, { to: init, active: true }));
}
else if (typeof init === 'object') {
return this.mapTransition(mixin({}, this.defaults.init, init, { active: true }));
}
else {
this.addState(this.defaults.init.from);
return this.defaults.init;
}
},
configureData: function(data) {
if (typeof data === 'function')
return data;
else if (typeof data === 'object')
return function() { return data; }
else
return function() { return {}; }
},
configureMethods: function(methods) {
return methods || {};
},
configurePlugins: function(plugins, builtin) {
plugins = plugins || [];
var n, max, plugin;
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n];
if (typeof plugin === 'function')
plugins[n] = plugin = plugin()
if (plugin.configure)
plugin.configure(this);
}
return plugins
},
configureTransitions: function(transitions) {
var i, n, transition, from, to, wildcard = this.defaults.wildcard;
for(n = 0 ; n < transitions.length ; n++) {
transition = transitions[n];
from = Array.isArray(transition.from) ? transition.from : [transition.from || wildcard]
to = transition.to || wildcard;
for(i = 0 ; i < from.length ; i++) {
this.mapTransition({ name: transition.name, from: from[i], to: to });
}
}
},
transitionFor: function(state, transition) {
var wildcard = this.defaults.wildcard;
return this.map[state][transition] ||
this.map[wildcard][transition];
},
transitionsFor: function(state) {
var wildcard = this.defaults.wildcard;
return Object.keys(this.map[state]).concat(Object.keys(this.map[wildcard]));
},
allStates: function() {
return this.states;
},
allTransitions: function() {
return this.transitions;
}
});
//-------------------------------------------------------------------------------------------------
module.exports = Config;
//-------------------------------------------------------------------------------------------------
================================================
FILE: src/jsm.js
================================================
var mixin = require('./util/mixin'),
Exception = require('./util/exception'),
plugin = require('./plugin'),
UNOBSERVED = [ null, [] ];
//-------------------------------------------------------------------------------------------------
function JSM(context, config) {
this.context = context;
this.config = config;
this.state = config.init.from;
this.observers = [context];
}
//-------------------------------------------------------------------------------------------------
mixin(JSM.prototype, {
init: function(args) {
mixin(this.context, this.config.data.apply(this.context, args));
plugin.hook(this, 'init');
if (this.config.init.active)
return this.fire(this.config.init.name, []);
},
is: function(state) {
return Array.isArray(state) ? (state.indexOf(this.state) >= 0) : (this.state === state);
},
isPending: function() {
return this.pending;
},
can: function(transition) {
return !this.isPending() && !!this.seek(transition);
},
cannot: function(transition) {
return !this.can(transition);
},
allStates: function() {
return this.config.allStates();
},
allTransitions: function() {
return this.config.allTransitions();
},
transitions: function() {
return this.config.transitionsFor(this.state);
},
seek: function(transition, args) {
var wildcard = this.config.defaults.wildcard,
entry = this.config.transitionFor(this.state, transition),
to = entry && entry.to;
if (typeof to === 'function')
return to.apply(this.context, args);
else if (to === wildcard)
return this.state
else
return to
},
fire: function(transition, args) {
return this.transit(transition, this.state, this.seek(transition, args), args);
},
transit: function(transition, from, to, args) {
var lifecycle = this.config.lifecycle,
changed = this.config.options.observeUnchangedState || (from !== to);
if (!to)
return this.context.onInvalidTransition(transition, from, to);
if (this.isPending())
return this.context.onPendingTransition(transition, from, to);
this.config.addState(to); // might need to add this state if it's unknown (e.g. conditional transition or goto)
this.beginTransit();
args.unshift({ // this context will be passed to each lifecycle event observer
transition: transition,
from: from,
to: to,
fsm: this.context
});
return this.observeEvents([
this.observersForEvent(lifecycle.onBefore.transition),
this.observersForEvent(lifecycle.onBefore[transition]),
changed ? this.observersForEvent(lifecycle.onLeave.state) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onLeave[from]) : UNOBSERVED,
this.observersForEvent(lifecycle.on.transition),
changed ? [ 'doTransit', [ this ] ] : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onEnter.state) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.onEnter[to]) : UNOBSERVED,
changed ? this.observersForEvent(lifecycle.on[to]) : UNOBSERVED,
this.observersForEvent(lifecycle.onAfter.transition),
this.observersForEvent(lifecycle.onAfter[transition]),
this.observersForEvent(lifecycle.on[transition])
], args);
},
beginTransit: function() { this.pending = true; },
endTransit: function(result) { this.pending = false; return result; },
failTransit: function(result) { this.pending = false; throw result; },
doTransit: function(lifecycle) { this.state = lifecycle.to; },
observe: function(args) {
if (args.length === 2) {
var observer = {};
observer[args[0]] = args[1];
this.observers.push(observer);
}
else {
this.observers.push(args[0]);
}
},
observersForEvent: function(event) { // TODO: this could be cached
var n = 0, max = this.observers.length, observer, result = [];
for( ; n < max ; n++) {
observer = this.observers[n];
if (observer[event])
result.push(observer);
}
return [ event, result, true ]
},
observeEvents: function(events, args, previousEvent, previousResult) {
if (events.length === 0) {
return this.endTransit(previousResult === undefined ? true : previousResult);
}
var event = events[0][0],
observers = events[0][1],
pluggable = events[0][2];
args[0].event = event;
if (event && pluggable && event !== previousEvent)
plugin.hook(this, 'lifecycle', args);
if (observers.length === 0) {
events.shift();
return this.observeEvents(events, args, event, previousResult);
}
else {
var observer = observers.shift(),
result = observer[event].apply(observer, args);
if (result && typeof result.then === 'function') {
return result.then(this.observeEvents.bind(this, events, args, event))
.catch(this.failTransit.bind(this))
}
else if (result === false) {
return this.endTransit(false);
}
else {
return this.observeEvents(events, args, event, result);
}
}
},
onInvalidTransition: function(transition, from, to) {
throw new Exception("transition is invalid in current state", transition, from, to, this.state);
},
onPendingTransition: function(transition, from, to) {
throw new Exception("transition is invalid while previous transition is still in progress", transition, from, to, this.state);
}
});
//-------------------------------------------------------------------------------------------------
module.exports = JSM;
//-------------------------------------------------------------------------------------------------
================================================
FILE: src/plugin/history.js
================================================
'use strict'
//-------------------------------------------------------------------------------------------------
var camelize = require('../util/camelize');
//-------------------------------------------------------------------------------------------------
module.exports = function(options) { options = options || {};
var past = camelize(options.name || options.past || 'history'),
future = camelize( options.future || 'future'),
clear = camelize.prepended('clear', past),
back = camelize.prepended(past, 'back'),
forward = camelize.prepended(past, 'forward'),
canBack = camelize.prepended('can', back),
canForward = camelize.prepended('can', forward),
max = options.max;
var plugin = {
configure: function(config) {
config.addTransitionLifecycleNames(back);
config.addTransitionLifecycleNames(forward);
},
init: function(instance) {
instance[past] = [];
instance[future] = [];
},
lifecycle: function(instance, lifecycle) {
if (lifecycle.event === 'onEnterState') {
instance[past].push(lifecycle.to);
if (max && instance[past].length > max)
instance[past].shift();
if (lifecycle.transition !== back && lifecycle.transition !== forward)
instance[future].length = 0;
}
},
methods: {},
properties: {}
}
plugin.methods[clear] = function() {
this[past].length = 0
this[future].length = 0
}
plugin.properties[canBack] = {
get: function() {
return this[past].length > 1
}
}
plugin.properties[canForward] = {
get: function() {
return this[future].length > 0
}
}
plugin.methods[back] = function() {
if (!this[canBack])
throw Error('no history');
var from = this[past].pop(),
to = this[past].pop();
this[future].push(from);
this._fsm.transit(back, from, to, []);
}
plugin.methods[forward] = function() {
if (!this[canForward])
throw Error('no history');
var from = this.state,
to = this[future].pop();
this._fsm.transit(forward, from, to, []);
}
return plugin;
}
================================================
FILE: src/plugin/visualize.js
================================================
'use strict'
//-------------------------------------------------------------------------------------------------
var mixin = require('../util/mixin')
//-------------------------------------------------------------------------------------------------
function visualize(fsm, options) {
return dotify(dotcfg(fsm, options));
}
//-------------------------------------------------------------------------------------------------
function dotcfg(fsm, options) {
options = options || {}
var config = dotcfg.fetch(fsm),
name = options.name,
rankdir = dotcfg.rankdir(options.orientation),
states = dotcfg.states(config, options),
transitions = dotcfg.transitions(config, options),
result = { }
if (name)
result.name = name
if (rankdir)
result.rankdir = rankdir
if (states && states.length > 0)
result.states = states
if (transitions && transitions.length > 0)
result.transitions = transitions
return result
}
//-------------------------------------------------------------------------------------------------
dotcfg.fetch = function(fsm) {
return (typeof fsm === 'function') ? fsm.prototype._fsm.config
: fsm._fsm.config
}
dotcfg.rankdir = function(orientation) {
if (orientation === 'horizontal')
return 'LR';
else if (orientation === 'vertical')
return 'TB';
}
dotcfg.states = function(config, options) {
var index, states = config.states;
if (!options.init) { // if not showing init transition, then slice out the implied init :from state
index = states.indexOf(config.init.from);
states = states.slice(0, index).concat(states.slice(index+1));
}
return states;
}
dotcfg.transitions = function(config, options) {
var n, max, transition,
init = config.init,
transitions = config.options.transitions || [], // easier to visualize using the ORIGINAL transition declarations rather than our run-time mapping
output = [];
if (options.init && init.active)
dotcfg.transition(init.name, init.from, init.to, init.dot, config, options, output)
for (n = 0, max = transitions.length ; n < max ; n++) {
transition = config.options.transitions[n]
dotcfg.transition(transition.name, transition.from, transition.to, transition.dot, config, options, output)
}
return output
}
dotcfg.transition = function(name, from, to, dot, config, options, output) {
var n, max, wildcard = config.defaults.wildcard
if (Array.isArray(from)) {
for(n = 0, max = from.length ; n < max ; n++)
dotcfg.transition(name, from[n], to, dot, config, options, output)
}
else if (from === wildcard || from === undefined) {
for(n = 0, max = config.states.length ; n < max ; n++)
dotcfg.transition(name, config.states[n], to, dot, config, options, output)
}
else if (to === wildcard || to === undefined) {
dotcfg.transition(name, from, from, dot, config, options, output)
}
else if (typeof to === 'function') {
// do nothing, can't display conditional transition
}
else {
output.push(mixin({}, { from: from, to: to, label: pad(name) }, dot || {}))
}
}
//-------------------------------------------------------------------------------------------------
function pad(name) {
return " " + name + " "
}
function quote(name) {
return "\"" + name + "\""
}
function dotify(dotcfg) {
dotcfg = dotcfg || {};
var name = dotcfg.name || 'fsm',
states = dotcfg.states || [],
transitions = dotcfg.transitions || [],
rankdir = dotcfg.rankdir,
output = [],
n, max;
output.push("digraph " + quote(name) + " {")
if (rankdir)
output.push(" rankdir=" + rankdir + ";")
for(n = 0, max = states.length ; n < max ; n++)
output.push(dotify.state(states[n]))
for(n = 0, max = transitions.length ; n < max ; n++)
output.push(dotify.edge(transitions[n]))
output.push("}")
return output.join("\n")
}
dotify.state = function(state) {
return " " + quote(state) + ";"
}
dotify.edge = function(edge) {
return " " + quote(edge.from) + " -> " + quote(edge.to) + dotify.edge.attr(edge) + ";"
}
dotify.edge.attr = function(edge) {
var n, max, key, keys = Object.keys(edge).sort(), output = [];
for(n = 0, max = keys.length ; n < max ; n++) {
key = keys[n];
if (key !== 'from' && key !== 'to')
output.push(key + "=" + quote(edge[key]))
}
return output.length > 0 ? " [ " + output.join(" ; ") + " ]" : ""
}
//-------------------------------------------------------------------------------------------------
visualize.dotcfg = dotcfg;
visualize.dotify = dotify;
//-------------------------------------------------------------------------------------------------
module.exports = visualize;
//-------------------------------------------------------------------------------------------------
================================================
FILE: src/plugin.js
================================================
'use strict'
//-------------------------------------------------------------------------------------------------
var mixin = require('./util/mixin');
//-------------------------------------------------------------------------------------------------
module.exports = {
build: function(target, config) {
var n, max, plugin, plugins = config.plugins;
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n];
if (plugin.methods)
mixin(target, plugin.methods);
if (plugin.properties)
Object.defineProperties(target, plugin.properties);
}
},
hook: function(fsm, name, additional) {
var n, max, method, plugin,
plugins = fsm.config.plugins,
args = [fsm.context];
if (additional)
args = args.concat(additional)
for(n = 0, max = plugins.length ; n < max ; n++) {
plugin = plugins[n]
method = plugins[n][name]
if (method)
method.apply(plugin, args);
}
}
}
//-------------------------------------------------------------------------------------------------
================================================
FILE: src/util/camelize.js
================================================
'use strict'
//-------------------------------------------------------------------------------------------------
function camelize(label) {
if (label.length === 0)
return label;
var n, result, word, words = label.split(/[_-]/);
// single word with first character already lowercase, return untouched
if ((words.length === 1) && (words[0][0].toLowerCase() === words[0][0]))
return label;
result = words[0].toLowerCase();
for(n = 1 ; n < words.length ; n++) {
result = result + words[n].charAt(0).toUpperCase() + words[n].substring(1).toLowerCase();
}
return result;
}
//-------------------------------------------------------------------------------------------------
camelize.prepended = function(prepend, label) {
label = camelize(label);
return prepend + label[0].toUpperCase() + label.substring(1);
}
//-------------------------------------------------------------------------------------------------
module.exports = camelize;
================================================
FILE: src/util/exception.js
================================================
'use strict'
module.exports = function(message, transition, from, to, current) {
this.message = message;
this.transition = transition;
this.from = from;
this.to = to;
this.current = current;
}
================================================
FILE: src/util/mixin.js
================================================
'use strict'
module.exports = function(target, sources) {
var n, source, key;
for(n = 1 ; n < arguments.length ; n++) {
source = arguments[n];
for(key in source) {
if (source.hasOwnProperty(key))
target[key] = source[key];
}
}
return target;
}
================================================
FILE: test/basics.js
================================================
import test from 'ava';
import StateMachine from '../src/app';
//-------------------------------------------------------------------------------------------------
test('version', t => {
t.is(StateMachine.version, '3.0.1');
});
//-------------------------------------------------------------------------------------------------
test('state machine', t => {
var fsm = new StateMachine({
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'yellow', to: 'green' }
]
})
t.is(fsm.state, 'green')
fsm.warn(); t.is(fsm.state, 'yellow')
fsm.panic(); t.is(fsm.state, 'red')
fsm.calm(); t.is(fsm.state, 'yellow')
fsm.clear(); t.is(fsm.state, 'green')
});
//-----------------------------------------------------------------------------
test('state machine factory', t => {
var Alarm = StateMachine.factory({
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'yellow', to: 'green' }
]
}),
a = new Alarm(),
b = new Alarm();
t.is(a.state, 'green')
t.is(b.state, 'green')
a.warn(); t.is(a.state, 'yellow'); t.is(b.state, 'green')
a.panic(); t.is(a.state, 'red'); t.is(b.state, 'green')
a.calm(); t.is(a.state, 'yellow'); t.is(b.state, 'green')
a.clear(); t.is(a.state, 'green'); t.is(b.state, 'green')
b.warn(); t.is(a.state, 'green'); t.is(b.state, 'yellow')
b.panic(); t.is(a.state, 'green'); t.is(b.state, 'red')
b.calm(); t.is(a.state, 'green'); t.is(b.state, 'yellow')
b.clear(); t.is(a.state, 'green'); t.is(b.state, 'green')
});
//-----------------------------------------------------------------------------
test('state machine - applied to existing object', t => {
var obj = { name: 'alarm' }
StateMachine.apply(obj, {
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'yellow', to: 'green' }
]
});
t.is(obj.name, 'alarm');
t.is(obj.state, 'green');
obj.warn(); t.is(obj.state, 'yellow')
obj.panic(); t.is(obj.state, 'red')
obj.calm(); t.is(obj.state, 'yellow')
obj.clear(); t.is(obj.state, 'green')
});
//-----------------------------------------------------------------------------
test('state machine factory - applied to existing class', t => {
function Alarm(name) {
this.name = name
this._fsm(); // manual step needed to construct this FSM instance
}
StateMachine.factory(Alarm, {
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'yellow', to: 'green' }
]
});
var a = new Alarm('A'),
b = new Alarm('B');
t.is(a.name, 'A')
t.is(b.name, 'B')
t.is(a.state, 'green')
t.is(b.state, 'green')
a.warn(); t.is(a.state, 'yellow'); t.is(b.state, 'green')
a.panic(); t.is(a.state, 'red'); t.is(b.state, 'green')
a.calm(); t.is(a.state, 'yellow'); t.is(b.state, 'green')
a.clear(); t.is(a.state, 'green'); t.is(b.state, 'green')
b.warn(); t.is(a.state, 'green'); t.is(b.state, 'yellow')
b.panic(); t.is(a.state, 'green'); t.is(b.state, 'red')
b.calm(); t.is(a.state, 'green'); t.is(b.state, 'yellow')
b.clear(); t.is(a.state, 'green'); t.is(b.state, 'green')
});
//-----------------------------------------------------------------------------
================================================
FILE: test/construction.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
//-------------------------------------------------------------------------------------------------
test('singleton construction', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'init', from: 'none', to: 'A' },
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
]
});
t.is(fsm.state, 'none')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm.allTransitions(), [ 'init', 'step1', 'step2' ])
t.deepEqual(fsm.transitions(), [ 'init' ])
})
//-------------------------------------------------------------------------------------------------
test('singleton construction - with init state', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
]
});
t.is(fsm.state, 'A')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm.allTransitions(), [ 'init', 'step1', 'step2' ])
t.deepEqual(fsm.transitions(), [ 'step1' ])
})
//-------------------------------------------------------------------------------------------------
test('singleton construction - with init state and transition', t => {
var fsm = new StateMachine({
init: { name: 'boot', to: 'A' },
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
]
});
t.is(fsm.state, 'A')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm.allTransitions(), [ 'boot', 'step1', 'step2' ])
t.deepEqual(fsm.transitions(), [ 'step1' ])
})
//-------------------------------------------------------------------------------------------------
test('singleton construction - with init state, transition, AND from state', t => {
var fsm = new StateMachine({
init: { name: 'boot', from: 'booting', to: 'A' },
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
]
});
t.is(fsm.state, 'A')
t.deepEqual(fsm.allStates(), [ 'booting', 'A', 'B', 'C' ])
t.deepEqual(fsm.allTransitions(), [ 'boot', 'step1', 'step2' ])
t.deepEqual(fsm.transitions(), [ 'step1' ])
})
//-------------------------------------------------------------------------------------------------
test('singleton construction - with custom data and methods', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
],
data: {
value: 42
},
methods: {
talk: function() {
return this.state + ' - ' + this.value
}
}
});
t.is(fsm.state, 'A')
t.is(fsm.value, 42)
t.is(fsm.talk(), 'A - 42')
fsm.step1()
t.is(fsm.state, 'B')
t.is(fsm.value, 42)
t.is(fsm.talk(), 'B - 42')
fsm.value = 99
t.is(fsm.state, 'B')
t.is(fsm.value, 99)
t.is(fsm.talk(), 'B - 99')
})
//-------------------------------------------------------------------------------------------------
test('factory construction', t => {
var MyClass = StateMachine.factory({
init: 'A',
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
]
});
var fsm1 = new MyClass(),
fsm2 = new MyClass(),
fsm3 = new MyClass();
fsm2.step1()
fsm3.step1()
fsm3.step2()
t.is(fsm1.state, 'A')
t.is(fsm2.state, 'B')
t.is(fsm3.state, 'C')
t.deepEqual(fsm1.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm2.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm3.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm1.allTransitions(), [ 'init', 'step1', 'step2' ])
t.deepEqual(fsm2.allTransitions(), [ 'init', 'step1', 'step2' ])
t.deepEqual(fsm3.allTransitions(), [ 'init', 'step1', 'step2' ])
t.deepEqual(fsm1.transitions(), [ 'step1' ])
t.deepEqual(fsm2.transitions(), [ 'step2' ])
t.deepEqual(fsm3.transitions(), [ ])
t.is(fsm1.allStates, MyClass.prototype.allStates)
t.is(fsm2.allStates, MyClass.prototype.allStates)
t.is(fsm3.allStates, MyClass.prototype.allStates)
})
//-------------------------------------------------------------------------------------------------
test('factory construction - with custom data and methods', t => {
var MyClass = StateMachine.factory({
init: 'A',
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
],
data: function(value) {
return {
value: value
}
},
methods: {
talk: function() {
return this.state + ' - ' + this.value
}
}
});
var fsm1 = new MyClass(1),
fsm2 = new MyClass(2),
fsm3 = new MyClass(3);
t.is(fsm1.state, 'A')
t.is(fsm2.state, 'A')
t.is(fsm3.state, 'A')
t.is(fsm1.talk(), 'A - 1')
t.is(fsm2.talk(), 'A - 2')
t.is(fsm3.talk(), 'A - 3')
fsm2.step1()
fsm3.step1()
fsm3.step2()
t.is(fsm1.state, 'A')
t.is(fsm2.state, 'B')
t.is(fsm3.state, 'C')
t.is(fsm1.talk(), 'A - 1')
t.is(fsm2.talk(), 'B - 2')
t.is(fsm3.talk(), 'C - 3')
t.deepEqual(fsm1.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm2.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm3.allStates(), [ 'none', 'A', 'B', 'C' ])
t.deepEqual(fsm1.allTransitions(), [ 'init', 'step1', 'step2' ])
t.deepEqual(fsm2.allTransitions(), [ 'init', 'step1', 'step2' ])
t.deepEqual(fsm3.allTransitions(), [ 'init', 'step1', 'step2' ])
t.deepEqual(fsm1.transitions(), [ 'step1' ])
t.deepEqual(fsm2.transitions(), [ 'step2' ])
t.deepEqual(fsm3.transitions(), [ ])
t.is(fsm1.allStates, MyClass.prototype.allStates)
t.is(fsm2.allStates, MyClass.prototype.allStates)
t.is(fsm3.allStates, MyClass.prototype.allStates)
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/defaults.js
================================================
import test from 'ava';
import StateMachine from '../src/app';
//-------------------------------------------------------------------------------------------------
const defaults = JSON.stringify(StateMachine.defaults);
test.afterEach.always('restore defaults', t => {
StateMachine.defaults = JSON.parse(defaults);
});
//-------------------------------------------------------------------------------------------------
test.serial('override global initialization defaults', t => {
StateMachine.defaults.init = {
name: 'boot',
from: 'booting'
}
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
]
});
t.is(fsm.state, 'A');
t.deepEqual(fsm.allStates(), [ 'booting', 'A', 'B', 'C' ]);
t.deepEqual(fsm.allTransitions(), [ 'boot', 'step1', 'step2' ]);
t.deepEqual(fsm.transitions(), [ 'step1' ]);
});
//-------------------------------------------------------------------------------------------------
test.serial('override global initialization defaults (again)', t => {
StateMachine.defaults.init = {
name: 'start',
from: 'unknown'
}
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' }
]
});
t.is(fsm.state, 'A');
t.deepEqual(fsm.allStates(), [ 'unknown', 'A', 'B', 'C' ]);
t.deepEqual(fsm.allTransitions(), [ 'start', 'step1', 'step2' ]);
t.deepEqual(fsm.transitions(), [ 'step1' ]);
});
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/empty.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
//-------------------------------------------------------------------------------------------------
test('empty state machine', t => {
var fsm = new StateMachine();
t.is(fsm.state, 'none')
t.deepEqual(fsm.allStates(), [ 'none' ])
t.deepEqual(fsm.allTransitions(), [ ])
t.deepEqual(fsm.transitions(), [ ])
})
//-------------------------------------------------------------------------------------------------
test('empty state machine - but caller forgot new keyword', t => {
var fsm = StateMachine() // NOTE: missing 'new'
t.is(fsm.state, 'none')
t.deepEqual(fsm.allStates(), [ 'none' ])
t.deepEqual(fsm.allTransitions(), [ ])
t.deepEqual(fsm.transitions(), [ ])
})
//-------------------------------------------------------------------------------------------------
test('empty state machine - applied to existing object', t => {
var fsm = {};
StateMachine.apply(fsm)
t.is(fsm.state, 'none')
t.deepEqual(fsm.allStates(), [ 'none' ])
t.deepEqual(fsm.allTransitions(), [ ])
t.deepEqual(fsm.transitions(), [ ])
})
//-------------------------------------------------------------------------------------------------
test('empty state machine factory', t => {
var FSM = StateMachine.factory(),
fsm = new FSM();
t.is(fsm.state, 'none')
t.deepEqual(fsm.allStates(), [ 'none' ])
t.deepEqual(fsm.allTransitions(), [ ])
t.deepEqual(fsm.transitions(), [ ])
})
//-------------------------------------------------------------------------------------------------
test('empty state machine factory - applied to existing class', t => {
var FSM = function() { this._fsm() };
StateMachine.factory(FSM)
var fsm = new FSM()
t.is(fsm.state, 'none')
t.deepEqual(fsm.allStates(), [ 'none' ])
t.deepEqual(fsm.allTransitions(), [ ])
t.deepEqual(fsm.transitions(), [ ])
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/errors.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
//-------------------------------------------------------------------------------------------------
test('state cannot be modified directly', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
]
})
t.is(fsm.state, 'none')
var error = t.throws(() => {
fsm.state = 'other'
})
t.is(error.message, 'use transitions to change state')
t.is(fsm.state, 'none')
})
//-------------------------------------------------------------------------------------------------
test('StateMachine.apply only allowed on objects', t => {
var config = {
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
]
};
var error = t.throws(() => {
StateMachine.apply(function() {}, config)
})
t.is(error.message, 'StateMachine can only be applied to objects')
error = t.throws(() => {
StateMachine.apply([], config)
})
t.is(error.message, 'StateMachine can only be applied to objects')
error = t.throws(() => {
StateMachine.apply(42, config)
})
t.is(error.message, 'StateMachine can only be applied to objects')
})
//-------------------------------------------------------------------------------------------------
test('invalid transition raises an exception', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'step1', from: 'none', to: 'A' },
{ name: 'step2', from: 'A', to: 'B' }
]
});
t.is(fsm.state, 'none')
t.is(fsm.can('step1'), true)
t.is(fsm.can('step2'), false)
const error = t.throws(() => {
fsm.step2();
})
t.is(error.message, 'transition is invalid in current state')
t.is(error.transition, 'step2')
t.is(error.from, 'none')
t.is(error.to, undefined)
t.is(error.current, 'none')
})
//-------------------------------------------------------------------------------------------------
test('invalid transition handler can be customized', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'step1', from: 'none', to: 'A' },
{ name: 'step2', from: 'A', to: 'B' }
],
methods: {
onInvalidTransition: function() { return 'custom error'; }
}
});
t.is(fsm.state, 'none')
t.is(fsm.can('step1'), true)
t.is(fsm.can('step2'), false)
t.is(fsm.step2(), 'custom error')
t.is(fsm.state, 'none')
})
//-------------------------------------------------------------------------------------------------
test('fire transition while existing transition is still in process raises an exception', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'A' },
{ name: 'other', from: '*', to: 'X' }
],
methods: {
onBeforeStep: function() { this.other(); },
onBeforeOther: function() { t.fail('should never happen') },
onEnterX: function() { t.fail('should never happen') }
}
});
t.is(fsm.state, 'none')
t.is(fsm.can('step'), true)
t.is(fsm.can('other'), true)
const error = t.throws(() => {
fsm.step()
})
t.is(error.message, 'transition is invalid while previous transition is still in progress')
t.is(error.transition, 'other')
t.is(error.from, 'none')
t.is(error.to, 'X')
t.is(error.current, 'none')
t.is(fsm.state, 'none', 'entire transition was cancelled by the exception')
})
//-------------------------------------------------------------------------------------------------
test('pending transition handler can be customized', t => {
var error = "",
fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'A' },
{ name: 'other', from: '*', to: 'X' }
],
methods: {
onBeforeStep: function() { error = this.other(); return false },
onPendingTransition: function() { return 'custom error' },
onBeforeOther: function() { t.fail('should never happen') },
onEnterX: function() { t.fail('should never happen') }
}
});
t.is(fsm.state, 'none')
t.is(fsm.can('step'), true)
t.is(fsm.can('other'), true)
t.is(fsm.step(), false)
t.is(fsm.state, 'none')
t.is(error, 'custom error')
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/goto.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
import LifecycleLogger from './helpers/lifecycle_logger'
//-------------------------------------------------------------------------------------------------
function goto(state) {
return state
}
//-------------------------------------------------------------------------------------------------
test('goto transition', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'goto', from: '*', to: goto }
],
methods: {
onBeforeTransition: logger,
onBeforeInit: logger,
onBeforeStep: logger,
onBeforeGoto: logger,
onLeaveState: logger,
onLeaveNone: logger,
onLeaveA: logger,
onLeaveB: logger,
onLeaveC: logger,
onLeaveD: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onEnterA: logger,
onEnterB: logger,
onEnterC: logger,
onEnterD: logger,
onAfterTransition: logger,
onAfterInit: logger,
onAfterStep: logger,
onAfterGoto: logger
}
});
t.is(fsm.state, 'A')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C', 'D' ])
t.deepEqual(fsm.allTransitions(), [ 'init', 'step', 'goto' ])
logger.clear()
fsm.goto('C')
t.is(fsm.state, 'C')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'goto', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onBeforeGoto', transition: 'goto', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onLeaveState', transition: 'goto', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onLeaveA', transition: 'goto', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onTransition', transition: 'goto', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onEnterState', transition: 'goto', from: 'A', to: 'C', current: 'C', args: [ 'C' ] },
{ event: 'onEnterC', transition: 'goto', from: 'A', to: 'C', current: 'C', args: [ 'C' ] },
{ event: 'onAfterTransition', transition: 'goto', from: 'A', to: 'C', current: 'C', args: [ 'C' ] },
{ event: 'onAfterGoto', transition: 'goto', from: 'A', to: 'C', current: 'C', args: [ 'C' ] }
])
})
//-------------------------------------------------------------------------------------------------
test('goto can have additional arguments', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'goto', from: '*', to: goto }
],
methods: {
onStep: logger,
onGoto: logger
}
});
t.is(fsm.state, 'A')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C', 'D' ])
t.deepEqual(fsm.allTransitions(), [ 'init', 'step', 'goto' ])
logger.clear()
fsm.goto('C', 'with', 4, 'additional', 'arguments')
t.is(fsm.state, 'C')
t.deepEqual(logger.log, [
{ event: 'onGoto', transition: 'goto', from: 'A', to: 'C', current: 'C', args: [ 'C', 'with', 4, 'additional', 'arguments' ] }
])
})
//-------------------------------------------------------------------------------------------------
test('goto can go to an unknown state', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'goto', from: '*', to: goto }
]
})
t.is(fsm.state, 'A')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C', 'D' ]);
fsm.goto('B')
t.is(fsm.state, 'B')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C', 'D' ]);
fsm.goto('X')
t.is(fsm.state, 'X')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C', 'D', 'X' ]);
})
//-------------------------------------------------------------------------------------------------
test('goto can be configured with a custom name', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'jump', from: '*', to: goto }
],
methods: {
onBeforeTransition: logger,
onBeforeInit: logger,
onBeforeStep: logger,
onBeforeJump: logger,
onLeaveState: logger,
onLeaveNone: logger,
onLeaveA: logger,
onLeaveB: logger,
onLeaveC: logger,
onLeaveD: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onEnterA: logger,
onEnterB: logger,
onEnterC: logger,
onEnterD: logger,
onAfterTransition: logger,
onAfterInit: logger,
onAfterStep: logger,
onAfterJump: logger
}
});
t.is(fsm.state, 'A')
t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'C', 'D' ])
t.deepEqual(fsm.allTransitions(), [ 'init', 'step', 'jump' ])
t.is(fsm.goto, undefined)
logger.clear()
fsm.jump('C')
t.is(fsm.state, 'C')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'jump', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onBeforeJump', transition: 'jump', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onLeaveState', transition: 'jump', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onLeaveA', transition: 'jump', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onTransition', transition: 'jump', from: 'A', to: 'C', current: 'A', args: [ 'C' ] },
{ event: 'onEnterState', transition: 'jump', from: 'A', to: 'C', current: 'C', args: [ 'C' ] },
{ event: 'onEnterC', transition: 'jump', from: 'A', to: 'C', current: 'C', args: [ 'C' ] },
{ event: 'onAfterTransition', transition: 'jump', from: 'A', to: 'C', current: 'C', args: [ 'C' ] },
{ event: 'onAfterJump', transition: 'jump', from: 'A', to: 'C', current: 'C', args: [ 'C' ] }
])
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/helpers/lifecycle_logger.js
================================================
module.exports = function() {
'use strict'
var entries = [],
logger = function(lifecycle) {
var entry = {
event: lifecycle.event,
transition: lifecycle.transition,
from: lifecycle.from,
to: lifecycle.to,
current: lifecycle.fsm.state
}
if (arguments.length > 1)
entry.args = [].slice.call(arguments, 1);
entries.push(entry);
};
logger.clear = function() {
entries.length = 0;
}
logger.log = entries;
return logger;
}
================================================
FILE: test/introspection.js
================================================
import test from 'ava';
import StateMachine from '../src/app';
//-----------------------------------------------------------------------------
test('is', t => {
var fsm = new StateMachine({
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'yellow', to: 'green' }
]});
t.is(fsm.state, 'green')
t.is(fsm.is('green'), true)
t.is(fsm.is('yellow'), false)
t.is(fsm.is(['green', 'red']), true, 'current state should match when included in array')
t.is(fsm.is(['yellow', 'red']), false, 'current state should NOT match when not included in array')
fsm.warn()
t.is(fsm.state, 'yellow')
t.is(fsm.is('green'), false)
t.is(fsm.is('yellow'), true)
t.is(fsm.is(['green', 'red']), false, 'current state should NOT match when not included in array')
t.is(fsm.is(['yellow', 'red']), true, 'current state should match when included in array')
});
//-----------------------------------------------------------------------------
test('can & cannot', t => {
var fsm = new StateMachine({
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
]
});
t.is(fsm.state, 'green')
t.is(fsm.can('warn'), true)
t.is(fsm.can('panic'), false)
t.is(fsm.can('calm'), false)
t.is(fsm.cannot('warn'), false)
t.is(fsm.cannot('panic'), true)
t.is(fsm.cannot('calm'), true)
fsm.warn();
t.is(fsm.state, 'yellow')
t.is(fsm.can('warn'), false)
t.is(fsm.can('panic'), true)
t.is(fsm.can('calm'), false)
t.is(fsm.cannot('warn'), true)
t.is(fsm.cannot('panic'), false)
t.is(fsm.cannot('calm'), true)
fsm.panic();
t.is(fsm.state, 'red')
t.is(fsm.can('warn'), false)
t.is(fsm.can('panic'), false)
t.is(fsm.can('calm'), true)
t.is(fsm.cannot('warn'), true)
t.is(fsm.cannot('panic'), true)
t.is(fsm.cannot('calm'), false)
t.is(fsm.can('jibber'), false, "unknown event should not crash")
t.is(fsm.cannot('jabber'), true, "unknown event should not crash")
});
//-----------------------------------------------------------------------------
test('can is always false during lifecycle events', t => {
t.plan(81);
var fsm = new StateMachine({
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
],
methods: {
assertTransitionsNotAllowed: function() {
t.false(this.can('warn'))
t.false(this.can('panic'))
t.false(this.can('calm'))
},
onBeforeTransition: function() { this.assertTransitionsNotAllowed(); },
onBeforeWarn: function() { this.assertTransitionsNotAllowed(); },
onBeforePanic: function() { this.assertTransitionsNotAllowed(); },
onBeforeCalm: function() { this.assertTransitionsNotAllowed(); },
onLeaveState: function() { this.assertTransitionsNotAllowed(); },
onLeaveNone: function() { this.assertTransitionsNotAllowed(); },
onLeaveGreen: function() { this.assertTransitionsNotAllowed(); },
onLeaveYellow: function() { this.assertTransitionsNotAllowed(); },
onLeaveRed: function() { this.assertTransitionsNotAllowed(); },
onTransition: function() { this.assertTransitionsNotAllowed(); },
onEnterState: function() { this.assertTransitionsNotAllowed(); },
onEnterNone: function() { this.assertTransitionsNotAllowed(); },
onEnterGreen: function() { this.assertTransitionsNotAllowed(); },
onEnterYellow: function() { this.assertTransitionsNotAllowed(); },
onEnterRed: function() { this.assertTransitionsNotAllowed(); },
onAfterTransition: function() { this.assertTransitionsNotAllowed(); },
onAfterInit: function() { this.assertTransitionsNotAllowed(); },
onAfterWarn: function() { this.assertTransitionsNotAllowed(); },
onAfterPanic: function() { this.assertTransitionsNotAllowed(); },
onAfterCalm: function() { this.assertTransitionsNotAllowed(); }
}
});
t.is(fsm.state, 'green')
fsm.warn()
t.is(fsm.state, 'yellow')
fsm.panic()
t.is(fsm.state, 'red')
});
//-----------------------------------------------------------------------------
test('all states', t => {
var fsm = new StateMachine({
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'yellow', to: 'green' },
{ name: 'finish', from: 'green', to: 'done' },
]});
t.deepEqual(fsm.allStates(), [ 'none', 'green', 'yellow', 'red', 'done' ]);
});
//-----------------------------------------------------------------------------
test("all transitions", t => {
var fsm = new StateMachine({
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'yellow', to: 'green' },
{ name: 'finish', from: 'green', to: 'done' },
]});
t.deepEqual(fsm.allTransitions(), [
'init', 'warn', 'panic', 'calm', 'clear', 'finish'
]);
})
//-----------------------------------------------------------------------------
test("valid transitions", t => {
var fsm = new StateMachine({
init: 'green',
transitions: [
{ name: 'warn', from: 'green', to: 'yellow' },
{ name: 'panic', from: 'yellow', to: 'red' },
{ name: 'calm', from: 'red', to: 'yellow' },
{ name: 'clear', from: 'yellow', to: 'green' },
{ name: 'finish', from: 'green', to: 'done' },
]});
t.is(fsm.state, 'green')
t.deepEqual(fsm.transitions(), ['warn', 'finish'])
fsm.warn();
t.is(fsm.state, 'yellow')
t.deepEqual(fsm.transitions(), ['panic', 'clear'])
fsm.panic();
t.is(fsm.state, 'red')
t.deepEqual(fsm.transitions(), ['calm'])
fsm.calm();
t.is(fsm.state, 'yellow')
t.deepEqual(fsm.transitions(), ['panic', 'clear'])
fsm.clear();
t.is(fsm.state, 'green')
t.deepEqual(fsm.transitions(), ['warn', 'finish'])
fsm.finish();
t.is(fsm.state, 'done')
t.deepEqual(fsm.transitions(), [])
});
//-----------------------------------------------------------------------------
================================================
FILE: test/issues.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
import LifecycleLogger from './helpers/lifecycle_logger'
//-------------------------------------------------------------------------------------------------
test('github issue #12 - transition return values', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'init', from: 'none', to: 'A' },
{ name: 'cancelled', from: 'A', to: 'X' },
{ name: 'async', from: 'A', to: 'X' }
],
methods: {
onBeforeCancelled: function() { return false; },
onBeforeAsync: function() { return new Promise(function(resolve, reject) {}); }
}
});
t.is(fsm.init(), true, 'successful (synchronous) transition returns true')
t.is(fsm.cancelled(), false, 'cancelled (synchronous) transition returns true')
var promise = fsm.async();
t.is(typeof promise.then, 'function', 'asynchronous transition returns a promise');
})
//-------------------------------------------------------------------------------------------------
test('github issue #17 - exceptions in lifecycle events are NOT swallowed', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
],
methods: {
onTransition: function() { throw Error('oops') }
}
});
t.is(fsm.state, 'none')
const error = t.throws(() => {
fsm.step();
})
t.is(error.message, 'oops')
})
//-------------------------------------------------------------------------------------------------
test('github issue #19 - lifecycle events have correct this when applying StateMachine to a custom class', t => {
var FSM = function() {
this.stepped = false;
this._fsm();
}
FSM.prototype.onStep = function(lifecycle) { this.stepped = true }
StateMachine.factory(FSM, {
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
]
})
var a = new FSM(),
b = new FSM();
t.is(a.state, 'none')
t.is(b.state, 'none')
t.is(a.stepped, false)
t.is(b.stepped, false)
a.step();
t.is(a.state, 'complete')
t.is(b.state, 'none')
t.is(a.stepped, true)
t.is(b.stepped, false)
});
//-------------------------------------------------------------------------------------------------
test('github issue #64 - double wildcard transition does not change state', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: '*' /* no-op */ }
]
});
t.is(fsm.state, 'none')
fsm.step(); t.is(fsm.state, 'none')
fsm.step(); t.is(fsm.state, 'none')
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/lifecycle.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
import LifecycleLogger from './helpers/lifecycle_logger'
//-------------------------------------------------------------------------------------------------
test('lifecycle events occur in correct order', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
],
methods: {
onBeforeTransition: logger,
onBeforeStep: logger,
onLeaveState: logger,
onLeaveNone: logger,
onLeaveComplete: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onEnterComplete: logger,
onAfterTransition: logger,
onAfterStep: logger
}
});
t.is(fsm.state, 'none')
fsm.step()
t.is(fsm.state, 'complete')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onAfterStep', transition: 'step', from: 'none', to: 'complete', current: 'complete' }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events occur in correct order - for same state transition', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
transitions: [
{ name: 'noop', from: 'none', to: 'none' }
],
methods: {
onBeforeTransition: logger,
onBeforeNoop: logger,
onLeaveState: logger,
onLeaveNone: logger,
onLeaveComplete: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onEnterComplete: logger,
onAfterTransition: logger,
onAfterNoop: logger
}
});
t.is(fsm.state, 'none')
fsm.noop()
t.is(fsm.state, 'none')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onBeforeNoop', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onAfterTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onAfterNoop', transition: 'noop', from: 'none', to: 'none', current: 'none' }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events using shortcut names', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
methods: {
onNone: logger,
onSolid: logger,
onLiquid: logger,
onGas: logger,
onInit: logger,
onMelt: logger,
onFreeze: logger,
onVaporize: logger,
onCondense: logger
}
});
t.is(fsm.state, 'solid')
t.deepEqual(logger.log, [
{ event: "onSolid", transition: "init", from: "none", to: "solid", current: "solid" },
{ event: "onInit", transition: "init", from: "none", to: "solid", current: "solid" }
])
logger.clear()
fsm.melt()
t.is(fsm.state, 'liquid')
t.deepEqual(logger.log, [
{ event: "onLiquid", transition: "melt", from: "solid", to: "liquid", current: "liquid" },
{ event: "onMelt", transition: "melt", from: "solid", to: "liquid", current: "liquid" }
]);
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events with dash or underscore are camelized', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'has-dash',
transitions: [
{ name: 'do-with-dash', from: 'has-dash', to: 'has_underscore' },
{ name: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized' },
{ name: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash' }
],
methods: {
onBeforeTransition: logger,
onBeforeInit: logger,
onBeforeDoWithDash: logger,
onBeforeDoWithUnderscore: logger,
onBeforeDoAlreadyCamelized: logger,
onLeaveState: logger,
onLeaveNone: logger,
onLeaveHasDash: logger,
onLeaveHasUnderscore: logger,
onLeaveAlreadyCamelized: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onEnterHasDash: logger,
onEnterHasUnderscore: logger,
onEnterAlreadyCamelized: logger,
onAfterTransition: logger,
onAfterInit: logger,
onAfterDoWithDash: logger,
onAfterDoWithUnderscore: logger,
onAfterDoAlreadyCamelized: logger
}
});
t.is(fsm.state, 'has-dash')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'init', from: 'none', to: 'has-dash', current: 'none' },
{ event: 'onBeforeInit', transition: 'init', from: 'none', to: 'has-dash', current: 'none' },
{ event: 'onLeaveState', transition: 'init', from: 'none', to: 'has-dash', current: 'none' },
{ event: 'onLeaveNone', transition: 'init', from: 'none', to: 'has-dash', current: 'none' },
{ event: 'onTransition', transition: 'init', from: 'none', to: 'has-dash', current: 'none' },
{ event: 'onEnterState', transition: 'init', from: 'none', to: 'has-dash', current: 'has-dash' },
{ event: 'onEnterHasDash', transition: 'init', from: 'none', to: 'has-dash', current: 'has-dash' },
{ event: 'onAfterTransition', transition: 'init', from: 'none', to: 'has-dash', current: 'has-dash' },
{ event: 'onAfterInit', transition: 'init', from: 'none', to: 'has-dash', current: 'has-dash' }
])
logger.clear()
fsm.doWithDash()
t.is(fsm.state, 'has_underscore')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has-dash' },
{ event: 'onBeforeDoWithDash', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has-dash' },
{ event: 'onLeaveState', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has-dash' },
{ event: 'onLeaveHasDash', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has-dash' },
{ event: 'onTransition', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has-dash' },
{ event: 'onEnterState', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has_underscore' },
{ event: 'onEnterHasUnderscore', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has_underscore' },
{ event: 'onAfterTransition', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has_underscore' },
{ event: 'onAfterDoWithDash', transition: 'do-with-dash', from: 'has-dash', to: 'has_underscore', current: 'has_underscore' }
])
logger.clear()
fsm.doWithUnderscore()
t.is(fsm.state, 'alreadyCamelized')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'has_underscore' },
{ event: 'onBeforeDoWithUnderscore', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'has_underscore' },
{ event: 'onLeaveState', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'has_underscore' },
{ event: 'onLeaveHasUnderscore', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'has_underscore' },
{ event: 'onTransition', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'has_underscore' },
{ event: 'onEnterState', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'alreadyCamelized' },
{ event: 'onEnterAlreadyCamelized', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'alreadyCamelized' },
{ event: 'onAfterTransition', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'alreadyCamelized' },
{ event: 'onAfterDoWithUnderscore', transition: 'do_with_underscore', from: 'has_underscore', to: 'alreadyCamelized', current: 'alreadyCamelized' }
])
logger.clear()
fsm.doAlreadyCamelized()
t.is(fsm.state, 'has-dash')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'alreadyCamelized' },
{ event: 'onBeforeDoAlreadyCamelized', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'alreadyCamelized' },
{ event: 'onLeaveState', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'alreadyCamelized' },
{ event: 'onLeaveAlreadyCamelized', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'alreadyCamelized' },
{ event: 'onTransition', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'alreadyCamelized' },
{ event: 'onEnterState', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'has-dash' },
{ event: 'onEnterHasDash', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'has-dash' },
{ event: 'onAfterTransition', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'has-dash' },
{ event: 'onAfterDoAlreadyCamelized', transition: 'doAlreadyCamelized', from: 'alreadyCamelized', to: 'has-dash', current: 'has-dash' }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle event names that are all uppercase are camelized', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'FIRST',
transitions: [
{ name: 'GO', from: 'FIRST', to: 'SECOND_STATE' },
{ name: 'DO_IT', from: 'SECOND_STATE', to: 'FIRST' }
],
methods: {
onBeforeGo: logger,
onBeforeDoIt: logger,
onLeaveFirst: logger,
onLeaveSecondState: logger,
onEnterFirst: logger,
onEnterSecondState: logger,
onAfterGo: logger,
onAfterDoIt: logger
}
});
t.is(fsm.state, 'FIRST')
t.deepEqual(logger.log, [
{ event: 'onEnterFirst', transition: 'init', from: 'none', to: 'FIRST', current: 'FIRST' },
])
logger.clear()
fsm.go()
t.is(fsm.state, 'SECOND_STATE')
t.deepEqual(logger.log, [
{ event: 'onBeforeGo', transition: 'GO', from: 'FIRST', to: 'SECOND_STATE', current: 'FIRST' },
{ event: 'onLeaveFirst', transition: 'GO', from: 'FIRST', to: 'SECOND_STATE', current: 'FIRST' },
{ event: 'onEnterSecondState', transition: 'GO', from: 'FIRST', to: 'SECOND_STATE', current: 'SECOND_STATE' },
{ event: 'onAfterGo', transition: 'GO', from: 'FIRST', to: 'SECOND_STATE', current: 'SECOND_STATE' }
])
logger.clear();
fsm.doIt();
t.is(fsm.state, 'FIRST')
t.deepEqual(logger.log, [
{ event: 'onBeforeDoIt', transition: 'DO_IT', from: 'SECOND_STATE', to: 'FIRST', current: 'SECOND_STATE' },
{ event: 'onLeaveSecondState', transition: 'DO_IT', from: 'SECOND_STATE', to: 'FIRST', current: 'SECOND_STATE' },
{ event: 'onEnterFirst', transition: 'DO_IT', from: 'SECOND_STATE', to: 'FIRST', current: 'FIRST' },
{ event: 'onAfterDoIt', transition: 'DO_IT', from: 'SECOND_STATE', to: 'FIRST', current: 'FIRST' }
])
});
//-------------------------------------------------------------------------------------------------
test('lifecycle events receive arbitrary transition arguments', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
transitions: [
{ name: 'init', from: 'none', to: 'A' },
{ name: 'step', from: 'A', to: 'B' }
],
methods: {
onBeforeTransition: logger,
onBeforeInit: logger,
onBeforeStep: logger,
onLeaveState: logger,
onLeaveNone: logger,
onLeaveA: logger,
onLeaveB: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onEnterA: logger,
onEnterB: logger,
onAfterTransition: logger,
onAfterInit: logger,
onAfterStep: logger
}
});
t.is(fsm.state, 'none')
t.deepEqual(logger.log, [])
fsm.init()
t.is(fsm.state, 'A')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onBeforeInit', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onLeaveState', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onLeaveNone', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onTransition', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onEnterState', transition: 'init', from: 'none', to: 'A', current: 'A' },
{ event: 'onEnterA', transition: 'init', from: 'none', to: 'A', current: 'A' },
{ event: 'onAfterTransition', transition: 'init', from: 'none', to: 'A', current: 'A' },
{ event: 'onAfterInit', transition: 'init', from: 'none', to: 'A', current: 'A' }
])
logger.clear()
fsm.step('with', 4, 'more', 'arguments')
t.is(fsm.state, 'B')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'A', to: 'B', current: 'A', args: [ 'with', 4, 'more', 'arguments' ] },
{ event: 'onBeforeStep', transition: 'step', from: 'A', to: 'B', current: 'A', args: [ 'with', 4, 'more', 'arguments' ] },
{ event: 'onLeaveState', transition: 'step', from: 'A', to: 'B', current: 'A', args: [ 'with', 4, 'more', 'arguments' ] },
{ event: 'onLeaveA', transition: 'step', from: 'A', to: 'B', current: 'A', args: [ 'with', 4, 'more', 'arguments' ] },
{ event: 'onTransition', transition: 'step', from: 'A', to: 'B', current: 'A', args: [ 'with', 4, 'more', 'arguments' ] },
{ event: 'onEnterState', transition: 'step', from: 'A', to: 'B', current: 'B', args: [ 'with', 4, 'more', 'arguments' ] },
{ event: 'onEnterB', transition: 'step', from: 'A', to: 'B', current: 'B', args: [ 'with', 4, 'more', 'arguments' ] },
{ event: 'onAfterTransition', transition: 'step', from: 'A', to: 'B', current: 'B', args: [ 'with', 4, 'more', 'arguments' ] },
{ event: 'onAfterStep', transition: 'step', from: 'A', to: 'B', current: 'B', args: [ 'with', 4, 'more', 'arguments' ] }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events are cancelable', t => {
var FSM = StateMachine.factory({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
],
data: function(cancel) {
return {
cancel: cancel,
logger: new LifecycleLogger()
}
},
methods: {
onBeforeTransition: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onBeforeStep: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onEnterState: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onEnterNone: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onEnterComplete: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onTransition: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onLeaveState: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onLeaveNone: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onLeaveComplete: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onAfterTransition: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel },
onAfterStep: function(lifecycle) { this.logger(lifecycle); return lifecycle.event !== this.cancel }
}
});
var cancelledBeforeTransition = new FSM('onBeforeTransition'),
cancelledBeforeStep = new FSM('onBeforeStep'),
cancelledLeaveState = new FSM('onLeaveState'),
cancelledLeaveNone = new FSM('onLeaveNone'),
cancelledTransition = new FSM('onTransition'),
cancelledEnterState = new FSM('onEnterState'),
cancelledEnterComplete = new FSM('onEnterComplete'),
cancelledAfterTransition = new FSM('onAfterTransition'),
cancelledAfterStep = new FSM('onAfterStep');
t.is(cancelledBeforeTransition.state, 'none')
t.is(cancelledBeforeStep.state, 'none')
t.is(cancelledLeaveState.state, 'none')
t.is(cancelledLeaveNone.state, 'none')
t.is(cancelledTransition.state, 'none')
t.is(cancelledEnterState.state, 'none')
t.is(cancelledEnterComplete.state, 'none')
t.is(cancelledAfterTransition.state, 'none')
t.is(cancelledAfterStep.state, 'none')
cancelledBeforeTransition.step()
cancelledBeforeStep.step()
cancelledLeaveState.step()
cancelledLeaveNone.step()
cancelledTransition.step()
cancelledEnterState.step()
cancelledEnterComplete.step()
cancelledAfterTransition.step()
cancelledAfterStep.step()
t.is(cancelledBeforeTransition.state, 'none')
t.is(cancelledBeforeStep.state, 'none')
t.is(cancelledLeaveState.state, 'none')
t.is(cancelledLeaveNone.state, 'none')
t.is(cancelledTransition.state, 'none')
t.is(cancelledEnterState.state, 'complete')
t.is(cancelledEnterComplete.state, 'complete')
t.is(cancelledAfterTransition.state, 'complete')
t.is(cancelledAfterStep.state, 'complete')
t.deepEqual(cancelledBeforeTransition.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' }
])
t.deepEqual(cancelledBeforeStep.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' }
])
t.deepEqual(cancelledLeaveState.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' }
])
t.deepEqual(cancelledLeaveNone.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none' }
])
t.deepEqual(cancelledTransition.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' }
])
t.deepEqual(cancelledEnterState.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete' }
])
t.deepEqual(cancelledEnterComplete.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete' }
])
t.deepEqual(cancelledAfterTransition.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete' }
])
t.deepEqual(cancelledAfterStep.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onAfterStep', transition: 'step', from: 'none', to: 'complete', current: 'complete' }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events can be deferred using a promise', t => {
return new Promise(function(resolveTest, rejectTest) {
var logger = new LifecycleLogger(),
start = Date.now(),
pause = function(ms) { return new Promise(function(resolve, reject) { setTimeout(function() { resolve('resolved') }, ms); }); },
fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
],
methods: {
onBeforeTransition: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onBeforeStep: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onEnterState: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onEnterNone: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onEnterComplete: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onTransition: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onLeaveState: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onLeaveNone: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onLeaveComplete: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onAfterTransition: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onAfterStep: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); }
}
});
function done(answer) {
var duration = Date.now() - start;
t.is(fsm.state, 'complete')
t.is(duration > 600, true)
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterStep', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
])
t.is(answer, 'resolved');
resolveTest()
}
fsm.step('additional', 'arguments')
.then(done);
});
});
//-------------------------------------------------------------------------------------------------
test('lifecycle events can be cancelled using a promise', t => {
return new Promise(function(resolveTest, rejectTest) {
var logger = new LifecycleLogger(),
start = Date.now(),
pause = function(ms) {
return new Promise(function(resolve, reject) {
setTimeout(function() { resolve('resolved') }, ms);
});
},
cancel = function(ms) {
return new Promise(function(resolve, reject) {
setTimeout(function() { reject('rejected'); }, ms);
});
},
fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
],
methods: {
onBeforeTransition: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onBeforeStep: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onEnterState: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onEnterNone: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onEnterComplete: function(lifecycle, a, b) { logger(lifecycle, a, b); return pause(100); },
onTransition: function(lifecycle, a, b) { logger(lifecycle, a, b); return cancel(100); },
onLeaveState: function(lifecycle, a, b) { logger(lifecycle, a, b); },
onLeaveNone: function(lifecycle, a, b) { logger(lifecycle, a, b); },
onLeaveComplete: function(lifecycle, a, b) { logger(lifecycle, a, b); },
onAfterTransition: function(lifecycle, a, b) { logger(lifecycle, a, b); },
onAfterStep: function(lifecycle, a, b) { logger(lifecycle, a, b); }
}
});
function done(answer) {
var duration = Date.now() - start;
t.is(fsm.state, 'none');
t.is(duration > 300, true);
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] }
]);
t.is(answer, 'rejected');
resolveTest();
}
fsm.step('additional', 'arguments')
.then(function() { done('promise was rejected so this should never happen'); })
.catch(done)
})
})
//-------------------------------------------------------------------------------------------------
test('transition cannot fire while lifecycle event is in progress', t => {
t.plan(20);
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'other', from: '*', to: 'X' }
],
methods: {
onBeforeStep: function(lifecycle) {
t.false(this.can('other'));
const error = t.throws(function() {
fsm.other();
});
t.is(error.message, 'transition is invalid while previous transition is still in progress');
t.is(error.transition, 'other');
t.is(error.from, 'A');
t.is(error.to, 'X');
t.is(error.current, 'A');
},
onAfterStep: function(lifecycle) {
t.false(this.can('other'));
const error = t.throws(function() {
fsm.other();
});
t.is(error.message, 'transition is invalid while previous transition is still in progress');
t.is(error.transition, 'other');
t.is(error.from, 'B');
t.is(error.to, 'X');
t.is(error.current, 'B');
},
onBeforeOther: function(lifecycle) { t.fail('should never happen') },
onAfterOther: function(lifecycle) { t.fail('should never happen') },
onLeaveA: function(lifecycle) { t.false(this.can('other')) },
onEnterB: function(lifecycle) { t.false(this.can('other')) },
onLeaveB: function(lifecycle) { t.fail('should never happen') },
onEnterX: function(lifecycle) { t.fail('should never happen') },
onLeaveX: function(lifecycle) { t.fail('should never happen') }
}
});
t.is(fsm.state, 'A')
t.true(fsm.can('other'))
fsm.step()
t.is(fsm.state, 'B')
t.true(fsm.can('other'))
})
//-------------------------------------------------------------------------------------------------
test('transition cannot fire while asynchronous lifecycle event is in progress', t => {
return new Promise(function(resolveTest, rejectTest) {
t.plan(20);
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'other', from: '*', to: 'X' }
],
methods: {
onBeforeStep: function(lifecycle) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
t.false(fsm.can('other'));
const error = t.throws(function() {
fsm.other();
});
t.is(error.message, 'transition is invalid while previous transition is still in progress');
t.is(error.transition, 'other');
t.is(error.from, 'A');
t.is(error.to, 'X');
t.is(error.current, 'A');
resolve();
}, 200);
});
},
onAfterStep: function(lifecycle) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
t.false(fsm.can('other'));
const error = t.throws(function() {
fsm.other();
});
t.is(error.message, 'transition is invalid while previous transition is still in progress');
t.is(error.transition, 'other');
t.is(error.from, 'B');
t.is(error.to, 'X');
t.is(error.current, 'B');
resolve();
setTimeout(done, 0); // HACK - let lifecycle finish before calling done()
}, 200);
});
},
onBeforeOther: function(lifecycle) { t.fail('should never happen') },
onAfterOther: function(lifecycle) { t.fail('should never happen') },
onLeaveA: function(lifecycle) { t.false(this.can('other')) },
onEnterB: function(lifecycle) { t.false(this.can('other')) },
onLeaveB: function(lifecycle) { t.fail('should never happen') },
onEnterX: function(lifecycle) { t.fail('should never happen') },
onLeaveX: function(lifecycle) { t.fail('should never happen') }
}
});
t.is(fsm.state, 'A')
t.true(fsm.can('other'))
function done() {
t.is(fsm.state, 'B');
t.true(fsm.can('other'));
resolveTest();
}
fsm.step(); // kick off the async behavior
})
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events for transitions with multiple :from or :to states', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'hungry',
transitions: [
{ name: 'eat', from: 'hungry', to: 'satisfied' },
{ name: 'eat', from: 'satisfied', to: 'full' },
{ name: 'rest', from: [ 'satisfied', 'full' ], to: 'hungry' }
],
methods: {
onBeforeTransition: logger,
onBeforeEat: logger,
onBeforeRest: logger,
onLeaveState: logger,
onLeaveHungry: logger,
onLeaveSatisfied: logger,
onLeaveFull: logger,
onTransition: logger,
onEnterState: logger,
onEnterHungry: logger,
onEnterSatisfied: logger,
onEnterFull: logger,
onAfterTransition: logger,
onAfterEat: logger,
onAfterRest: logger
}
});
t.is(fsm.state, 'hungry')
logger.clear()
fsm.eat()
t.is(fsm.state, 'satisfied')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'hungry' },
{ event: 'onBeforeEat', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'hungry' },
{ event: 'onLeaveState', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'hungry' },
{ event: 'onLeaveHungry', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'hungry' },
{ event: 'onTransition', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'hungry' },
{ event: 'onEnterState', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'satisfied' },
{ event: 'onEnterSatisfied', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'satisfied' },
{ event: 'onAfterTransition', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'satisfied' },
{ event: 'onAfterEat', transition: 'eat', from: 'hungry', to: 'satisfied', current: 'satisfied' }
])
logger.clear()
fsm.eat()
t.is(fsm.state, 'full')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'eat', from: 'satisfied', to: 'full', current: 'satisfied' },
{ event: 'onBeforeEat', transition: 'eat', from: 'satisfied', to: 'full', current: 'satisfied' },
{ event: 'onLeaveState', transition: 'eat', from: 'satisfied', to: 'full', current: 'satisfied' },
{ event: 'onLeaveSatisfied', transition: 'eat', from: 'satisfied', to: 'full', current: 'satisfied' },
{ event: 'onTransition', transition: 'eat', from: 'satisfied', to: 'full', current: 'satisfied' },
{ event: 'onEnterState', transition: 'eat', from: 'satisfied', to: 'full', current: 'full' },
{ event: 'onEnterFull', transition: 'eat', from: 'satisfied', to: 'full', current: 'full' },
{ event: 'onAfterTransition', transition: 'eat', from: 'satisfied', to: 'full', current: 'full' },
{ event: 'onAfterEat', transition: 'eat', from: 'satisfied', to: 'full', current: 'full' }
])
logger.clear()
fsm.rest()
t.is(fsm.state, 'hungry')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'rest', from: 'full', to: 'hungry', current: 'full' },
{ event: 'onBeforeRest', transition: 'rest', from: 'full', to: 'hungry', current: 'full' },
{ event: 'onLeaveState', transition: 'rest', from: 'full', to: 'hungry', current: 'full' },
{ event: 'onLeaveFull', transition: 'rest', from: 'full', to: 'hungry', current: 'full' },
{ event: 'onTransition', transition: 'rest', from: 'full', to: 'hungry', current: 'full' },
{ event: 'onEnterState', transition: 'rest', from: 'full', to: 'hungry', current: 'hungry' },
{ event: 'onEnterHungry', transition: 'rest', from: 'full', to: 'hungry', current: 'hungry' },
{ event: 'onAfterTransition', transition: 'rest', from: 'full', to: 'hungry', current: 'hungry' },
{ event: 'onAfterRest', transition: 'rest', from: 'full', to: 'hungry', current: 'hungry' }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events for factory generated state machines', t => {
var FSM = StateMachine.factory({
transitions: [
{ name: 'stepA', from: 'none', to: 'A' },
{ name: 'stepB', from: 'none', to: 'B' }
],
data: function(name) {
return {
name: name,
logger: new LifecycleLogger()
}
},
methods: {
onBeforeTransition: function(lifecycle) { this.logger(lifecycle) },
onBeforeStepA: function(lifecycle) { this.logger(lifecycle) },
onBeforeStepB: function(lifecycle) { this.logger(lifecycle) },
onLeaveState: function(lifecycle) { this.logger(lifecycle) },
onLeaveNone: function(lifecycle) { this.logger(lifecycle) },
onLeaveA: function(lifecycle) { this.logger(lifecycle) },
onLeaveB: function(lifecycle) { this.logger(lifecycle) },
onTransition: function(lifecycle) { this.logger(lifecycle) },
onEnterState: function(lifecycle) { this.logger(lifecycle) },
onEnterNone: function(lifecycle) { this.logger(lifecycle) },
onEnterA: function(lifecycle) { this.logger(lifecycle) },
onEnterB: function(lifecycle) { this.logger(lifecycle) },
onAfterTransition: function(lifecycle) { this.logger(lifecycle) },
onAfterStepA: function(lifecycle) { this.logger(lifecycle) },
onAfterStepB: function(lifecycle) { this.logger(lifecycle) }
}
});
var a = new FSM('a'),
b = new FSM('b');
t.is(a.state, 'none')
t.is(b.state, 'none')
t.deepEqual(a.logger.log, [])
t.deepEqual(b.logger.log, [])
a.stepA()
b.stepB()
t.is(a.state, 'A')
t.is(b.state, 'B')
t.deepEqual(a.logger.log, [
{ event: 'onBeforeTransition', transition: 'stepA', from: 'none', to: 'A', current: 'none' },
{ event: 'onBeforeStepA', transition: 'stepA', from: 'none', to: 'A', current: 'none' },
{ event: 'onLeaveState', transition: 'stepA', from: 'none', to: 'A', current: 'none' },
{ event: 'onLeaveNone', transition: 'stepA', from: 'none', to: 'A', current: 'none' },
{ event: 'onTransition', transition: 'stepA', from: 'none', to: 'A', current: 'none' },
{ event: 'onEnterState', transition: 'stepA', from: 'none', to: 'A', current: 'A' },
{ event: 'onEnterA', transition: 'stepA', from: 'none', to: 'A', current: 'A' },
{ event: 'onAfterTransition', transition: 'stepA', from: 'none', to: 'A', current: 'A' },
{ event: 'onAfterStepA', transition: 'stepA', from: 'none', to: 'A', current: 'A' }
])
t.deepEqual(b.logger.log, [
{ event: 'onBeforeTransition', transition: 'stepB', from: 'none', to: 'B', current: 'none' },
{ event: 'onBeforeStepB', transition: 'stepB', from: 'none', to: 'B', current: 'none' },
{ event: 'onLeaveState', transition: 'stepB', from: 'none', to: 'B', current: 'none' },
{ event: 'onLeaveNone', transition: 'stepB', from: 'none', to: 'B', current: 'none' },
{ event: 'onTransition', transition: 'stepB', from: 'none', to: 'B', current: 'none' },
{ event: 'onEnterState', transition: 'stepB', from: 'none', to: 'B', current: 'B' },
{ event: 'onEnterB', transition: 'stepB', from: 'none', to: 'B', current: 'B' },
{ event: 'onAfterTransition', transition: 'stepB', from: 'none', to: 'B', current: 'B' },
{ event: 'onAfterStepB', transition: 'stepB', from: 'none', to: 'B', current: 'B' }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events for custom init transition', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: { name: 'boot', from: 'booting', to: 'complete' },
methods: {
onBeforeTransition: logger,
onBeforeInit: logger,
onBeforeBoot: logger,
onLeaveState: logger,
onLeaveNone: logger,
onLeaveBooting: logger,
onLeaveComplete: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onEnterBooting: logger,
onEnterComplete: logger,
onAfterTransition: logger,
onAfterInit: logger,
onAfterBoot: logger
}
});
t.is(fsm.state, 'complete')
t.deepEqual(fsm.allStates(), [ 'booting', 'complete' ])
t.deepEqual(fsm.allTransitions(), [ 'boot' ])
t.deepEqual(fsm.transitions(), [ ])
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'boot', from: 'booting', to: 'complete', current: 'booting' },
{ event: 'onBeforeBoot', transition: 'boot', from: 'booting', to: 'complete', current: 'booting' },
{ event: 'onLeaveState', transition: 'boot', from: 'booting', to: 'complete', current: 'booting' },
{ event: 'onLeaveBooting', transition: 'boot', from: 'booting', to: 'complete', current: 'booting' },
{ event: 'onTransition', transition: 'boot', from: 'booting', to: 'complete', current: 'booting' },
{ event: 'onEnterState', transition: 'boot', from: 'booting', to: 'complete', current: 'complete' },
{ event: 'onEnterComplete', transition: 'boot', from: 'booting', to: 'complete', current: 'complete' },
{ event: 'onAfterTransition', transition: 'boot', from: 'booting', to: 'complete', current: 'complete' },
{ event: 'onAfterBoot', transition: 'boot', from: 'booting', to: 'complete', current: 'complete' }
])
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/observers.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
import LifecycleLogger from './helpers/lifecycle_logger'
//-------------------------------------------------------------------------------------------------
test('lifecycle events can be observed by external observer methods', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
]
});
fsm.observe("onBeforeTransition", logger)
fsm.observe("onBeforeStep", logger)
fsm.observe("onLeaveState", logger)
fsm.observe("onLeaveNone", logger)
fsm.observe("onLeaveComplete", logger)
fsm.observe("onTransition", logger)
fsm.observe("onEnterState", logger)
fsm.observe("onEnterNone", logger)
fsm.observe("onEnterComplete", logger)
fsm.observe("onAfterTransition", logger)
fsm.observe("onAfterStep", logger)
fsm.step('additional', 'arguments')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterStep', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events can be observed by external observer classes', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
]
});
fsm.observe({
"onBeforeTransition": logger,
"onBeforeStep": logger,
"onLeaveState": logger,
"onLeaveNone": logger,
"onLeaveComplete": logger,
"onTransition": logger,
"onEnterState": logger,
"onEnterNone": logger,
"onEnterComplete": logger,
"onAfterTransition": logger,
"onAfterStep": logger,
})
fsm.step('additional', 'arguments')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterStep', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] }
])
})
//-------------------------------------------------------------------------------------------------
test('lifecycle events can be observed by multiple observers', t => {
var logger1 = new LifecycleLogger(),
logger2 = new LifecycleLogger(),
fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
]
});
fsm.observe("onBeforeTransition", logger1)
fsm.observe("onBeforeStep", logger1)
fsm.observe("onLeaveState", logger1)
fsm.observe("onLeaveNone", logger1)
fsm.observe("onLeaveComplete", logger1)
fsm.observe("onTransition", logger1)
fsm.observe("onEnterState", logger1)
fsm.observe("onEnterNone", logger1)
fsm.observe("onEnterComplete", logger1)
fsm.observe("onAfterTransition", logger1)
fsm.observe("onAfterStep", logger1)
fsm.observe({
"onBeforeTransition": logger2,
"onBeforeStep": logger2,
"onLeaveState": logger2,
"onLeaveNone": logger2,
"onLeaveComplete": logger2,
"onTransition": logger2,
"onEnterState": logger2,
"onEnterNone": logger2,
"onEnterComplete": logger2,
"onAfterTransition": logger2,
"onAfterStep": logger2,
})
fsm.step('additional', 'arguments')
t.deepEqual(logger1.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterStep', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] }
])
t.deepEqual(logger2.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] },
{ event: 'onAfterStep', transition: 'step', from: 'none', to: 'complete', current: 'complete', args: [ 'additional', 'arguments' ] }
])
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/plugin/history.js
================================================
import test from 'ava'
import StateMachine from '../../src/app'
import StateMachineHistory from '../../src/plugin/history'
import LifecycleLogger from '../helpers/lifecycle_logger'
//-------------------------------------------------------------------------------------------------
test('history', t => {
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
plugins: [
StateMachineHistory
]
})
t.is(fsm.state, 'solid'); t.deepEqual(fsm.history, [ 'solid' ])
fsm.melt(); t.is(fsm.state, 'liquid'); t.deepEqual(fsm.history, [ 'solid', 'liquid' ])
fsm.vaporize(); t.is(fsm.state, 'gas'); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas' ])
fsm.condense(); t.is(fsm.state, 'liquid'); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas', 'liquid' ]);
})
//-------------------------------------------------------------------------------------------------
test('history can be cleared', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'init', from: 'none', to: 'A' },
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'A' }
],
plugins: [
StateMachineHistory
]
})
fsm.init()
fsm.step()
t.is(fsm.state, 'B')
t.deepEqual(fsm.history, ['A', 'B'])
fsm.clearHistory()
t.is(fsm.state, 'B')
t.deepEqual(fsm.history, [])
})
//-------------------------------------------------------------------------------------------------
test('history does not record no-op transitions', t => {
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' },
{ name: 'noop', from: '*', to: '*' }
],
plugins: [
StateMachineHistory
]
})
t.is(fsm.state, 'solid'); t.deepEqual(fsm.history, [ 'solid' ])
fsm.noop(); t.is(fsm.state, 'solid'); t.deepEqual(fsm.history, [ 'solid' ])
fsm.melt(); t.is(fsm.state, 'liquid'); t.deepEqual(fsm.history, [ 'solid', 'liquid' ])
fsm.noop(); t.is(fsm.state, 'liquid'); t.deepEqual(fsm.history, [ 'solid', 'liquid' ])
fsm.vaporize(); t.is(fsm.state, 'gas'); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas' ])
fsm.noop(); t.is(fsm.state, 'gas'); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas' ])
})
//-------------------------------------------------------------------------------------------------
test('history with configurable names', t => {
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
plugins: [
new StateMachineHistory({ name: 'memory', future: 'yonder' })
]
})
t.is(fsm.state, 'solid'); t.deepEqual(fsm.memory, [ 'solid' ])
fsm.melt(); t.is(fsm.state, 'liquid'); t.deepEqual(fsm.memory, [ 'solid', 'liquid' ])
fsm.vaporize(); t.is(fsm.state, 'gas'); t.deepEqual(fsm.memory, [ 'solid', 'liquid', 'gas' ])
fsm.condense(); t.is(fsm.state, 'liquid'); t.deepEqual(fsm.memory, [ 'solid', 'liquid', 'gas', 'liquid' ])
t.is(fsm.canMemoryBack, true)
t.is(fsm.canMemoryForward, false)
t.deepEqual(fsm.yonder, [ ])
fsm.memoryBack()
t.is(fsm.state, 'gas')
t.deepEqual(fsm.memory, [ 'solid', 'liquid', 'gas' ])
t.deepEqual(fsm.yonder, [ 'liquid' ])
fsm.clearMemory()
t.deepEqual(fsm.memory, [])
t.deepEqual(fsm.yonder, [])
})
//-------------------------------------------------------------------------------------------------
test('history, by default, just keeps growing', t => {
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
plugins: [
new StateMachineHistory()
]
})
t.is(fsm.state, 'solid')
t.deepEqual(fsm.history, [ 'solid' ])
fsm.melt(); t.deepEqual(fsm.history, [ 'solid', 'liquid' ])
fsm.vaporize(); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas' ])
fsm.condense(); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas', 'liquid' ])
fsm.freeze(); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas', 'liquid', 'solid' ])
fsm.melt(); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas', 'liquid', 'solid', 'liquid' ])
fsm.vaporize(); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas', 'liquid', 'solid', 'liquid', 'gas' ])
})
//-------------------------------------------------------------------------------------------------
test('history can be limited to N entries', t => {
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
plugins: [
new StateMachineHistory({ max: 3 })
]
})
t.is(fsm.state, 'solid')
t.deepEqual(fsm.history, [ 'solid' ])
fsm.melt(); t.deepEqual(fsm.history, [ 'solid', 'liquid' ])
fsm.vaporize(); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas' ])
fsm.condense(); t.deepEqual(fsm.history, [ 'liquid', 'gas', 'liquid' ])
fsm.freeze(); t.deepEqual(fsm.history, [ 'gas', 'liquid', 'solid' ])
fsm.melt(); t.deepEqual(fsm.history, [ 'liquid', 'solid', 'liquid' ])
fsm.vaporize(); t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas' ])
})
//-------------------------------------------------------------------------------------------------
test('history back and forward', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' }
],
plugins: [
StateMachineHistory
]
})
t.is(fsm.state, 'A')
t.is(fsm.canHistoryBack, false)
t.deepEqual(fsm.history, [ 'A' ])
t.deepEqual(fsm.future, [ ])
var error = t.throws(() => {
fsm.historyBack()
})
t.is(error.message, 'no history')
fsm.step()
fsm.step()
fsm.step()
t.is(fsm.state, 'D')
t.is(fsm.canHistoryBack, true)
t.is(fsm.canHistoryForward, false)
t.deepEqual(fsm.history, [ 'A', 'B', 'C', 'D' ])
t.deepEqual(fsm.future, [])
fsm.historyBack()
t.is(fsm.state, 'C')
t.deepEqual(fsm.history, [ 'A', 'B', 'C' ])
t.deepEqual(fsm.future, [ 'D' ])
t.is(fsm.canHistoryBack, true)
t.is(fsm.canHistoryForward, true)
fsm.historyBack()
t.is(fsm.state, 'B')
t.deepEqual(fsm.history, [ 'A', 'B' ])
t.deepEqual(fsm.future, [ 'D', 'C' ])
t.is(fsm.canHistoryBack, true)
t.is(fsm.canHistoryForward, true)
fsm.historyBack()
t.is(fsm.state, 'A')
t.deepEqual(fsm.history, [ 'A' ])
t.deepEqual(fsm.future, [ 'D', 'C', 'B' ])
t.is(fsm.canHistoryBack, false)
t.is(fsm.canHistoryForward, true)
fsm.historyForward()
t.is(fsm.state, 'B')
t.deepEqual(fsm.history, [ 'A', 'B' ])
t.deepEqual(fsm.future, [ 'D', 'C' ])
t.is(fsm.canHistoryBack, true)
t.is(fsm.canHistoryForward, true)
fsm.historyForward()
t.is(fsm.state, 'C')
t.deepEqual(fsm.history, [ 'A', 'B', 'C' ])
t.deepEqual(fsm.future, [ 'D' ])
t.is(fsm.canHistoryBack, true)
t.is(fsm.canHistoryForward, true)
fsm.step()
t.is(fsm.state, 'D')
t.deepEqual(fsm.history, [ 'A', 'B', 'C', 'D' ])
t.deepEqual(fsm.future, [ ])
t.is(fsm.canHistoryBack, true)
t.is(fsm.canHistoryForward, false)
error = t.throws(() => {
fsm.historyForward()
})
t.is(error.message, 'no history')
})
//-------------------------------------------------------------------------------------------------
test('history back and forward lifecycle events', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' }
],
methods: {
onBeforeTransition: logger,
onBeforeStep: logger,
onBeforeHistoryBack: logger,
onBeforeHistoryForward: logger,
onLeaveState: logger,
onLeaveA: logger,
onLeaveB: logger,
onLeaveC: logger,
onLeaveD: logger,
onTransition: logger,
onEnterState: logger,
onEnterA: logger,
onEnterB: logger,
onEnterC: logger,
onEnterD: logger,
onAfterTransition: logger,
onAfterStep: logger,
onAfterHistoryBack: logger,
onAfterHistoryForward: logger
},
plugins: [
StateMachineHistory
]
})
fsm.step()
fsm.step()
fsm.step()
logger.clear()
t.is(fsm.state, 'D')
t.deepEqual(fsm.history, [ 'A', 'B', 'C', 'D' ])
t.deepEqual(fsm.future, [ ])
fsm.historyBack()
t.is(fsm.state, 'C')
t.deepEqual(fsm.history, [ 'A', 'B', 'C' ])
t.deepEqual(fsm.future, [ 'D' ])
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'historyBack', from: 'D', to: 'C', current: 'D' },
{ event: 'onBeforeHistoryBack', transition: 'historyBack', from: 'D', to: 'C', current: 'D' },
{ event: 'onLeaveState', transition: 'historyBack', from: 'D', to: 'C', current: 'D' },
{ event: 'onLeaveD', transition: 'historyBack', from: 'D', to: 'C', current: 'D' },
{ event: 'onTransition', transition: 'historyBack', from: 'D', to: 'C', current: 'D' },
{ event: 'onEnterState', transition: 'historyBack', from: 'D', to: 'C', current: 'C' },
{ event: 'onEnterC', transition: 'historyBack', from: 'D', to: 'C', current: 'C' },
{ event: 'onAfterTransition', transition: 'historyBack', from: 'D', to: 'C', current: 'C' },
{ event: 'onAfterHistoryBack', transition: 'historyBack', from: 'D', to: 'C', current: 'C' }
])
logger.clear()
fsm.historyForward()
t.is(fsm.state, 'D')
t.deepEqual(fsm.history, [ 'A', 'B', 'C', 'D' ])
t.deepEqual(fsm.future, [ ])
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'historyForward', from: 'C', to: 'D', current: 'C' },
{ event: 'onBeforeHistoryForward', transition: 'historyForward', from: 'C', to: 'D', current: 'C' },
{ event: 'onLeaveState', transition: 'historyForward', from: 'C', to: 'D', current: 'C' },
{ event: 'onLeaveC', transition: 'historyForward', from: 'C', to: 'D', current: 'C' },
{ event: 'onTransition', transition: 'historyForward', from: 'C', to: 'D', current: 'C' },
{ event: 'onEnterState', transition: 'historyForward', from: 'C', to: 'D', current: 'D' },
{ event: 'onEnterD', transition: 'historyForward', from: 'C', to: 'D', current: 'D' },
{ event: 'onAfterTransition', transition: 'historyForward', from: 'C', to: 'D', current: 'D' },
{ event: 'onAfterHistoryForward', transition: 'historyForward', from: 'C', to: 'D', current: 'D' }
])
})
//-------------------------------------------------------------------------------------------------
test('history can be used with a state machine factory', t => {
var FSM = StateMachine.factory({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
plugins: [
StateMachineHistory
]
})
var a = new FSM(),
b = new FSM();
t.is(a.state, 'solid')
t.is(b.state, 'solid')
t.deepEqual(a.history, [ 'solid' ])
t.deepEqual(b.history, [ 'solid' ])
a.melt()
a.vaporize()
a.condense()
a.freeze()
t.is(a.state, 'solid')
t.is(b.state, 'solid')
t.deepEqual(a.history, [ 'solid', 'liquid', 'gas', 'liquid', 'solid' ])
t.deepEqual(b.history, [ 'solid' ])
b.melt()
b.freeze()
t.is(a.state, 'solid')
t.is(b.state, 'solid')
t.deepEqual(a.history, [ 'solid', 'liquid', 'gas', 'liquid', 'solid' ])
t.deepEqual(b.history, [ 'solid', 'liquid', 'solid' ])
})
//-------------------------------------------------------------------------------------------------
test('history can be used with a singleton state machine applied to existing object', t => {
var fsm = {
name: 'jake'
}
StateMachine.apply(fsm, {
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
plugins: [
StateMachineHistory
]
})
t.is(fsm.name, 'jake')
t.is(fsm.state, 'solid')
t.deepEqual(fsm.history, [ 'solid' ])
fsm.melt();
t.is(fsm.state, 'liquid')
t.deepEqual(fsm.history, [ 'solid', 'liquid' ])
fsm.vaporize();
t.is(fsm.state, 'gas')
t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas' ])
fsm.condense()
t.is(fsm.state, 'liquid')
t.deepEqual(fsm.history, [ 'solid', 'liquid', 'gas', 'liquid' ])
})
//-------------------------------------------------------------------------------------------------
test('history can be used with a state machine factory applied to existing class', t => {
function FSM(name) {
this.name = name
this._fsm()
}
StateMachine.factory(FSM, {
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
],
plugins: [
StateMachineHistory
]
})
var a = new FSM('A'),
b = new FSM('B');
t.is(a.name, 'A')
t.is(b.name, 'B')
t.is(a.state, 'solid')
t.is(b.state, 'solid')
t.deepEqual(a.history, [ 'solid' ])
t.deepEqual(b.history, [ 'solid' ])
a.melt()
a.vaporize()
a.condense()
a.freeze()
t.is(a.state, 'solid')
t.is(b.state, 'solid')
t.deepEqual(a.history, [ 'solid', 'liquid', 'gas', 'liquid', 'solid' ])
t.deepEqual(b.history, [ 'solid' ])
b.melt()
b.freeze()
t.is(a.state, 'solid')
t.is(b.state, 'solid')
t.deepEqual(a.history, [ 'solid', 'liquid', 'gas', 'liquid', 'solid' ])
t.deepEqual(b.history, [ 'solid', 'liquid', 'solid' ])
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/plugin/visualize.js
================================================
import test from 'ava'
import StateMachine from '../../src/app'
import visualize from '../../src/plugin/visualize'
var dotcfg = visualize.dotcfg, // converts FSM to DOT CONFIG
dotify = visualize.dotify; // converts DOT CONFIG to DOT OUTPUT
//-------------------------------------------------------------------------------------------------
test('visualize state machine', t => {
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
]
})
t.is(visualize(fsm), `digraph "fsm" {
"solid";
"liquid";
"gas";
"solid" -> "liquid" [ label=" melt " ];
"liquid" -> "solid" [ label=" freeze " ];
"liquid" -> "gas" [ label=" vaporize " ];
"gas" -> "liquid" [ label=" condense " ];
}`)
})
//-------------------------------------------------------------------------------------------------
test('visualize state machine factory', t => {
var FSM = StateMachine.factory({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
]
})
t.is(visualize(FSM), `digraph "fsm" {
"solid";
"liquid";
"gas";
"solid" -> "liquid" [ label=" melt " ];
"liquid" -> "solid" [ label=" freeze " ];
"liquid" -> "gas" [ label=" vaporize " ];
"gas" -> "liquid" [ label=" condense " ];
}`)
})
//-------------------------------------------------------------------------------------------------
test('visualize with custom .dot markup', t => {
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid', dot: { color: 'red', headport: 'nw', tailport: 'ne' } },
{ name: 'freeze', from: 'liquid', to: 'solid', dot: { color: 'grey', headport: 'se', tailport: 'sw' } },
{ name: 'vaporize', from: 'liquid', to: 'gas', dot: { color: 'yellow', headport: 'nw', tailport: 'ne' } },
{ name: 'condense', from: 'gas', to: 'liquid', dot: { color: 'brown', headport: 'se', tailport: 'sw' } }
]
})
t.is(visualize(fsm, { name: 'matter', orientation: 'horizontal' }), `digraph "matter" {
rankdir=LR;
"solid";
"liquid";
"gas";
"solid" -> "liquid" [ color="red" ; headport="nw" ; label=" melt " ; tailport="ne" ];
"liquid" -> "solid" [ color="grey" ; headport="se" ; label=" freeze " ; tailport="sw" ];
"liquid" -> "gas" [ color="yellow" ; headport="nw" ; label=" vaporize " ; tailport="ne" ];
"gas" -> "liquid" [ color="brown" ; headport="se" ; label=" condense " ; tailport="sw" ];
}`)
})
//=================================================================================================
// TEST FSM => DOTCFG
//=================================================================================================
test('dotcfg simple state machine', t => {
var fsm = new StateMachine({
init: 'solid',
transitions: [
{ name: 'melt', from: 'solid', to: 'liquid' },
{ name: 'freeze', from: 'liquid', to: 'solid' },
{ name: 'vaporize', from: 'liquid', to: 'gas' },
{ name: 'condense', from: 'gas', to: 'liquid' }
]
})
t.deepEqual(dotcfg(fsm), {
states: [ 'solid', 'liquid', 'gas' ],
transitions: [
{ from: 'solid', to: 'liquid', label: ' melt ' },
{ from: 'liquid', to: 'solid', label: ' freeze ' },
{ from: 'liquid', to: 'gas', label: ' vaporize ' },
{ from: 'gas', to: 'liquid', label: ' condense ' }
]
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg for state machine - optionally include :init transition', t => {
var fsm = new StateMachine({
init: { name: 'boot', from: 'booting', to: 'ready', dot: { color: 'red' } }
})
t.deepEqual(dotcfg(fsm, { init: false }), {
states: [ 'ready' ]
})
t.deepEqual(dotcfg(fsm, { init: true }), {
states: [ 'booting', 'ready' ],
transitions: [
{ from: 'booting', to: 'ready', label: ' boot ', color: 'red' }
]
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg for fsm with multiple transitions with same :name', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' }
]
})
t.deepEqual(dotcfg(fsm), {
states: [ 'A', 'B', 'C', 'D' ],
transitions: [
{ from: 'A', to: 'B', label: ' step ' },
{ from: 'B', to: 'C', label: ' step ' },
{ from: 'C', to: 'D', label: ' step ' }
]
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg for fsm transition with multiple :from', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'reset', from: [ 'A', 'B' ], to: 'A' }
]
})
t.deepEqual(dotcfg(fsm), {
states: [ 'A', 'B', 'C', 'D' ],
transitions: [
{ from: 'A', to: 'B', label: ' step ' },
{ from: 'B', to: 'C', label: ' step ' },
{ from: 'C', to: 'D', label: ' step ' },
{ from: 'A', to: 'A', label: ' reset ' },
{ from: 'B', to: 'A', label: ' reset ' }
]
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg for fsm with wildcard/missing :from', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'reset', from: '*', to: 'A' },
{ name: 'finish', /* missing */ to: 'X' }
]
})
t.deepEqual(dotcfg(fsm), {
states: [ 'A', 'B', 'C', 'D', 'X' ],
transitions: [
{ from: 'A', to: 'B', label: ' step ' },
{ from: 'B', to: 'C', label: ' step ' },
{ from: 'C', to: 'D', label: ' step ' },
{ from: 'none', to: 'A', label: ' reset ' },
{ from: 'A', to: 'A', label: ' reset ' },
{ from: 'B', to: 'A', label: ' reset ' },
{ from: 'C', to: 'A', label: ' reset ' },
{ from: 'D', to: 'A', label: ' reset ' },
{ from: 'X', to: 'A', label: ' reset ' },
{ from: 'none', to: 'X', label: ' finish ' },
{ from: 'A', to: 'X', label: ' finish ' },
{ from: 'B', to: 'X', label: ' finish ' },
{ from: 'C', to: 'X', label: ' finish ' },
{ from: 'D', to: 'X', label: ' finish ' },
{ from: 'X', to: 'X', label: ' finish ' }
]
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg for fsm with wildcard/missing :to', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'step', from: 'B', to: 'C' },
{ name: 'step', from: 'C', to: 'D' },
{ name: 'stay', from: 'A', to: 'A' },
{ name: 'stay', from: 'B', to: '*' },
{ name: 'stay', from: 'C' /* missing */ },
{ name: 'noop', from: '*', to: '*' }
]
})
t.deepEqual(dotcfg(fsm), {
states: [ 'A', 'B', 'C', 'D' ],
transitions: [
{ from: 'A', to: 'B', label: ' step ' },
{ from: 'B', to: 'C', label: ' step ' },
{ from: 'C', to: 'D', label: ' step ' },
{ from: 'A', to: 'A', label: ' stay ' },
{ from: 'B', to: 'B', label: ' stay ' },
{ from: 'C', to: 'C', label: ' stay ' },
{ from: 'none', to: 'none', label: ' noop ' },
{ from: 'A', to: 'A', label: ' noop ' },
{ from: 'B', to: 'B', label: ' noop ' },
{ from: 'C', to: 'C', label: ' noop ' },
{ from: 'D', to: 'D', label: ' noop ' }
]
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg for fsm - conditional transition is not displayed', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: '*', to: function(n) { return this.skip(n) } },
],
methods: {
skip: function(amount) {
var code = this.state.charCodeAt(0);
return String.fromCharCode(code + (amount || 1));
}
}
});
t.deepEqual(dotcfg(fsm), {
states: [ 'A' ]
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg with custom transition .dot edge markup', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B', dot: { color: "red", headport: 'nw', tailport: 'ne', label: 'A2B' } },
{ name: 'step', from: 'B', to: 'C', dot: { color: "green", headport: 'sw', tailport: 'se', label: 'B2C' } }
]
})
t.deepEqual(dotcfg(fsm), {
states: [ 'A', 'B', 'C' ],
transitions: [
{ from: 'A', to: 'B', label: 'A2B', color: "red", headport: "nw", tailport: "ne" },
{ from: 'B', to: 'C', label: 'B2C', color: "green", headport: "sw", tailport: "se" }
]
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg with custom name', t => {
var fsm = new StateMachine();
t.deepEqual(dotcfg(fsm, { name: 'bob' }), {
name: 'bob',
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg with custom orientation', t => {
var fsm = new StateMachine();
t.deepEqual(dotcfg(fsm, { orientation: 'horizontal' }), {
rankdir: 'LR',
})
t.deepEqual(dotcfg(fsm, { orientation: 'vertical' }), {
rankdir: 'TB',
})
})
//-------------------------------------------------------------------------------------------------
test('dotcfg for empty state machine', t => {
var fsm = new StateMachine();
t.deepEqual(dotcfg(fsm), {})
})
//=================================================================================================
// TEST DOTCFG => DOT OUTPUT
//=================================================================================================
test('dotify empty', t => {
var expected = `digraph "fsm" {
}`
t.is(dotify(), expected)
t.is(dotify({}), expected)
})
//-------------------------------------------------------------------------------------------------
test('dotify name', t => {
t.is(dotify({ name: 'bob' }), `digraph "bob" {
}`)
})
//-------------------------------------------------------------------------------------------------
test('dotify rankdir', t => {
t.is(dotify({ rankdir: 'LR' }), `digraph "fsm" {
rankdir=LR;
}`)
})
//-------------------------------------------------------------------------------------------------
test('dotify states', t => {
var states = [ 'A', 'B' ];
t.is(dotify({ states: states }), `digraph "fsm" {
"A";
"B";
}`)
})
//-------------------------------------------------------------------------------------------------
test('dotify transitions', t => {
var transitions = [
{ from: 'A', to: 'B' },
{ from: 'B', to: 'C' },
];
t.is(dotify({ transitions: transitions }), `digraph "fsm" {
"A" -> "B";
"B" -> "C";
}`)
})
//-------------------------------------------------------------------------------------------------
test('dotify transitions with labels', t => {
var transitions = [
{ from: 'A', to: 'B', label: 'first' },
{ from: 'B', to: 'C', label: 'second' }
];
t.is(dotify({ transitions: transitions }), `digraph "fsm" {
"A" -> "B" [ label="first" ];
"B" -> "C" [ label="second" ];
}`)
})
//-------------------------------------------------------------------------------------------------
test('dotify transitions with custom .dot edge markup', t => {
var transitions = [
{ from: 'A', to: 'B', label: 'first', color: 'red', headport: 'nw', tailport: 'ne' },
{ from: 'B', to: 'A', label: 'second', color: 'green', headport: 'se', tailport: 'sw' }
]
t.is(dotify({ transitions: transitions }), `digraph "fsm" {
"A" -> "B" [ color="red" ; headport="nw" ; label="first" ; tailport="ne" ];
"B" -> "A" [ color="green" ; headport="se" ; label="second" ; tailport="sw" ];
}`)
})
//-------------------------------------------------------------------------------------------------
test('dotify kitchen sink', t => {
var name = "my fsm",
rankdir = "LR",
states = [ 'none', 'solid', 'liquid', 'gas' ],
transitions = [
{ from: 'none', to: 'solid', color: 'red', label: 'init' },
{ from: 'solid', to: 'liquid', color: 'red', label: 'melt' },
{ from: 'liquid', to: 'solid', color: 'green', label: 'freeze' },
{ from: 'liquid', to: 'gas', color: 'red', label: 'vaporize' },
{ from: 'gas', to: 'liquid', color: 'green', label: 'condense' }
];
t.is(dotify({ name: name, rankdir: rankdir, states: states, transitions: transitions }), `digraph "my fsm" {
rankdir=LR;
"none";
"solid";
"liquid";
"gas";
"none" -> "solid" [ color="red" ; label="init" ];
"solid" -> "liquid" [ color="red" ; label="melt" ];
"liquid" -> "solid" [ color="green" ; label="freeze" ];
"liquid" -> "gas" [ color="red" ; label="vaporize" ];
"gas" -> "liquid" [ color="green" ; label="condense" ];
}`)
})
//=================================================================================================
================================================
FILE: test/plugins.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
import LifecycleLogger from './helpers/lifecycle_logger'
//-------------------------------------------------------------------------------------------------
test('an empty plugin object', t => {
var plugin = {
init: function(instance) {
instance.plugged = true
}
};
var fsm = new StateMachine({
plugins: [ plugin ]
});
t.is(fsm.state, 'none')
t.is(fsm.plugged, true)
})
//-------------------------------------------------------------------------------------------------
test('an empty plugin function', t => {
var plugin = function() {
return {
init: function(instance) {
instance.plugged = true
}
}
};
var fsm = new StateMachine({
plugins: [ plugin ]
});
t.is(fsm.state, 'none')
t.is(fsm.plugged, true)
})
//-------------------------------------------------------------------------------------------------
test('an empty plugin function with configuration', t => {
var plugin = function(value) {
return {
init: function(instance) {
instance.plugged = value
}
}
};
var fsm = new StateMachine({
plugins: [ new plugin(42) ]
});
t.is(fsm.state, 'none')
t.is(fsm.plugged, 42)
})
//-------------------------------------------------------------------------------------------------
test('plugin can add methods', t => {
var plugin = {
methods: {
foo: function() { return 'FOO' },
bar: function() { return 'BAR' }
}
};
var fsm = new StateMachine({
plugins: [ plugin ]
});
t.is(fsm.state, 'none')
t.is(fsm.foo(), 'FOO')
t.is(fsm.bar(), 'BAR')
})
//-------------------------------------------------------------------------------------------------
test('plugin can add properties', t => {
var plugin = {
properties: {
color: { get: function() { return 'red' } }
}
};
var fsm = new StateMachine({
plugins: [ plugin ]
});
t.is(fsm.state, 'none')
t.is(fsm.color, 'red')
})
//-------------------------------------------------------------------------------------------------
test('plugin lifecycle hook', t => {
var plugin = {
init: function(instance) {
instance.logger = new LifecycleLogger();
},
lifecycle: function(instance, lifecycle) {
instance.logger(lifecycle)
}
};
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'complete' }
],
plugins: [ plugin ]
});
t.is(fsm.state, 'none')
t.deepEqual(fsm.logger.log, [])
fsm.step()
t.is(fsm.state, 'complete')
t.deepEqual(fsm.logger.log, [
{ event: 'onBeforeTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onBeforeStep', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveState', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onLeaveNone', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onTransition', transition: 'step', from: 'none', to: 'complete', current: 'none' },
{ event: 'onEnterState', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onEnterComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onComplete', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onAfterTransition', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onAfterStep', transition: 'step', from: 'none', to: 'complete', current: 'complete' },
{ event: 'onStep', transition: 'step', from: 'none', to: 'complete', current: 'complete' }
])
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/transitions.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
import LifecycleLogger from './helpers/lifecycle_logger'
//-----------------------------------------------------------------------------
test('basic transition from state to state', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step1', from: 'A', to: 'B' },
{ name: 'step2', from: 'B', to: 'C' },
{ name: 'step3', from: 'C', to: 'D' }
]
});
t.is(fsm.state, 'A')
fsm.step1(); t.is(fsm.state, 'B')
fsm.step2(); t.is(fsm.state, 'C')
fsm.step3(); t.is(fsm.state, 'D')
})
//-----------------------------------------------------------------------------
test('multiple transitions with same name', t => {
var fsm = new StateMachine({
init: 'hungry',
transitions: [
{ name: 'eat', from: 'hungry', to: 'satisfied' },
{ name: 'eat', from: 'satisfied', to: 'full' },
{ name: 'eat', from: 'full', to: 'sick' },
{ name: 'rest', from: '*', to: 'hungry' }
]
});
t.is(fsm.state, 'hungry')
t.is(fsm.can('eat'), true)
t.is(fsm.can('rest'), true)
fsm.eat()
t.is(fsm.state, 'satisfied')
t.is(fsm.can('eat'), true)
t.is(fsm.can('rest'), true)
fsm.eat()
t.is(fsm.state, 'full')
t.is(fsm.can('eat'), true)
t.is(fsm.can('rest'), true)
fsm.eat()
t.is(fsm.state, 'sick')
t.is(fsm.can('eat'), false)
t.is(fsm.can('rest'), true)
fsm.rest()
t.is(fsm.state, 'hungry')
t.is(fsm.can('eat'), true)
t.is(fsm.can('rest'), true)
})
//-----------------------------------------------------------------------------
test('transitions with multiple from states', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'start', from: 'none', to: 'green' },
{ name: 'warn', from: ['green', 'red'], to: 'yellow' },
{ name: 'panic', from: ['green', 'yellow'], to: 'red' },
{ name: 'clear', from: ['red', 'yellow'], to: 'green' }
]
});
t.deepEqual(fsm.allStates(), [ 'none', 'green', 'yellow', 'red' ])
t.deepEqual(fsm.allTransitions(), [ 'start', 'warn', 'panic', 'clear' ])
t.is(fsm.state, 'none')
t.is(fsm.can('start'), true)
t.is(fsm.can('warn'), false)
t.is(fsm.can('panic'), false)
t.is(fsm.can('clear'), false)
t.deepEqual(fsm.transitions(), ['start'])
fsm.start()
t.is(fsm.state, 'green')
t.is(fsm.can('start'), false)
t.is(fsm.can('warn'), true)
t.is(fsm.can('panic'), true)
t.is(fsm.can('clear'), false)
t.deepEqual(fsm.transitions(), ['warn', 'panic'])
fsm.warn()
t.is(fsm.state, 'yellow')
t.is(fsm.can('start'), false)
t.is(fsm.can('warn'), false)
t.is(fsm.can('panic'), true)
t.is(fsm.can('clear'), true)
t.deepEqual(fsm.transitions(), ['panic', 'clear'])
fsm.panic()
t.is(fsm.state, 'red')
t.is(fsm.can('start'), false)
t.is(fsm.can('warn'), true)
t.is(fsm.can('panic'), false)
t.is(fsm.can('clear'), true)
t.deepEqual(fsm.transitions(), ['warn', 'clear'])
fsm.clear()
t.is(fsm.state, 'green')
t.is(fsm.can('start'), false)
t.is(fsm.can('warn'), true)
t.is(fsm.can('panic'), true)
t.is(fsm.can('clear'), false)
t.deepEqual(fsm.transitions(), ['warn', 'panic'])
})
//-------------------------------------------------------------------------------------------------
test("transitions that dont change state, dont trigger enter/leave lifecycle events", t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
transitions: [
{ name: 'noop', from: 'none', to: 'none' }
],
methods: {
onBeforeTransition: logger,
onBeforeNoop: logger,
onLeaveState: logger,
onLeaveNone: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onNone: logger,
onAfterTransition: logger,
onAfterNoop: logger,
onNoop: logger
}
})
t.is(fsm.state, 'none')
t.deepEqual(logger.log, [])
fsm.noop()
t.is(fsm.state, 'none')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onBeforeNoop', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onAfterTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onAfterNoop', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onNoop', transition: 'noop', from: 'none', to: 'none', current: 'none' }
])
})
//-------------------------------------------------------------------------------------------------
test("transitions that dont change state, can be configured to trigger enter/leave lifecycle events", t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
observeUnchangedState: true,
transitions: [
{ name: 'noop', from: 'none', to: 'none' }
],
methods: {
onBeforeTransition: logger,
onBeforeNoop: logger,
onLeaveState: logger,
onLeaveNone: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onNone: logger,
onAfterTransition: logger,
onAfterNoop: logger,
onNoop: logger
}
})
t.is(fsm.state, 'none')
t.deepEqual(logger.log, [])
fsm.noop()
t.is(fsm.state, 'none')
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onBeforeNoop', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onLeaveState', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onLeaveNone', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onEnterState', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onEnterNone', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onNone', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onAfterTransition', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onAfterNoop', transition: 'noop', from: 'none', to: 'none', current: 'none' },
{ event: 'onNoop', transition: 'noop', from: 'none', to: 'none', current: 'none' }
])
})
//-------------------------------------------------------------------------------------------------
test("transition methods with dash or underscore are camelized", t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'do-with-dash', from: 'A', to: 'B' },
{ name: 'do_with_underscore', from: 'B', to: 'C' },
{ name: 'doAlreadyCamelized', from: 'C', to: 'D' }
]
});
t.is(fsm.state, 'A')
fsm.doWithDash(); t.is(fsm.state, 'B')
fsm.doWithUnderscore(); t.is(fsm.state, 'C')
fsm.doAlreadyCamelized(); t.is(fsm.state, 'D')
})
//-------------------------------------------------------------------------------------------------
test('conditional transitions', t => {
var logger = new LifecycleLogger(),
fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: '*', to: function(n) { return this.skip(n) } },
],
methods: {
skip: function(amount) {
var code = this.state.charCodeAt(0);
return String.fromCharCode(code + (amount || 1));
},
onBeforeTransition: logger,
onBeforeInit: logger,
onBeforeStep: logger,
onLeaveState: logger,
onLeaveNone: logger,
onLeaveA: logger,
onLeaveB: logger,
onLeaveG: logger,
onTransition: logger,
onEnterState: logger,
onEnterNone: logger,
onEnterA: logger,
onEnterB: logger,
onEnterG: logger,
onAfterTransition: logger,
onAfterInit: logger,
onAfterStep: logger
}
});
t.is(fsm.state, 'A')
t.deepEqual(fsm.allStates(), [ 'none', 'A' ]);
fsm.step(); t.is(fsm.state, 'B'); t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B' ])
fsm.step(5); t.is(fsm.state, 'G'); t.deepEqual(fsm.allStates(), [ 'none', 'A', 'B', 'G' ])
t.deepEqual(logger.log, [
{ event: 'onBeforeTransition', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onBeforeInit', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onLeaveState', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onLeaveNone', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onTransition', transition: 'init', from: 'none', to: 'A', current: 'none' },
{ event: 'onEnterState', transition: 'init', from: 'none', to: 'A', current: 'A' },
{ event: 'onEnterA', transition: 'init', from: 'none', to: 'A', current: 'A' },
{ event: 'onAfterTransition', transition: 'init', from: 'none', to: 'A', current: 'A' },
{ event: 'onAfterInit', transition: 'init', from: 'none', to: 'A', current: 'A' },
{ event: 'onBeforeTransition', transition: 'step', from: 'A', to: 'B', current: 'A' },
{ event: 'onBeforeStep', transition: 'step', from: 'A', to: 'B', current: 'A' },
{ event: 'onLeaveState', transition: 'step', from: 'A', to: 'B', current: 'A' },
{ event: 'onLeaveA', transition: 'step', from: 'A', to: 'B', current: 'A' },
{ event: 'onTransition', transition: 'step', from: 'A', to: 'B', current: 'A' },
{ event: 'onEnterState', transition: 'step', from: 'A', to: 'B', current: 'B' },
{ event: 'onEnterB', transition: 'step', from: 'A', to: 'B', current: 'B' },
{ event: 'onAfterTransition', transition: 'step', from: 'A', to: 'B', current: 'B' },
{ event: 'onAfterStep', transition: 'step', from: 'A', to: 'B', current: 'B' },
{ event: 'onBeforeTransition', transition: 'step', from: 'B', to: 'G', current: 'B', args: [ 5 ] },
{ event: 'onBeforeStep', transition: 'step', from: 'B', to: 'G', current: 'B', args: [ 5 ] },
{ event: 'onLeaveState', transition: 'step', from: 'B', to: 'G', current: 'B', args: [ 5 ] },
{ event: 'onLeaveB', transition: 'step', from: 'B', to: 'G', current: 'B', args: [ 5 ] },
{ event: 'onTransition', transition: 'step', from: 'B', to: 'G', current: 'B', args: [ 5 ] },
{ event: 'onEnterState', transition: 'step', from: 'B', to: 'G', current: 'G', args: [ 5 ] },
{ event: 'onEnterG', transition: 'step', from: 'B', to: 'G', current: 'G', args: [ 5 ] },
{ event: 'onAfterTransition', transition: 'step', from: 'B', to: 'G', current: 'G', args: [ 5 ] },
{ event: 'onAfterStep', transition: 'step', from: 'B', to: 'G', current: 'G', args: [ 5 ] },
])
})
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/util/camelize.js
================================================
import test from 'ava';
import camelize from '../../src/util/camelize';
test('camelize', t => {
t.is(camelize(""), "");
t.is(camelize("word"), "word");
t.is(camelize("Word"), "word");
t.is(camelize("WORD"), "word");
t.is(camelize("word-with-dash"), "wordWithDash");
t.is(camelize("word_with_underscore"), "wordWithUnderscore");
t.is(camelize("word--with--double--dash"), "wordWithDoubleDash");
t.is(camelize("word_WITH_mixed_CASE"), "wordWithMixedCase");
t.is(camelize("alreadyCamelizedString"), "alreadyCamelizedString");
});
================================================
FILE: test/util/mixin.js
================================================
import test from 'ava';
import mixin from '../../src/util/mixin';
//-------------------------------------------------------------------------------------------------
test('mixin', t => {
var a = { first: 'Jake', key: 'a' },
b = { last: 'Gordon', key: 'b' };
t.deepEqual(mixin({}, a), { first: 'Jake', key: 'a' });
t.deepEqual(mixin({}, b), { last: 'Gordon', key: 'b' });
t.deepEqual(mixin({}, a, b), { first: 'Jake', last: 'Gordon', key: 'b' });
t.deepEqual(mixin({}, b, a), { first: 'Jake', last: 'Gordon', key: 'a' });
});
//-------------------------------------------------------------------------------------------------
test('mixin only mixes in owned properties', t => {
var MyClass = function(name) { this.name = name }
MyClass.prototype = {
answer: 42
}
var a = new MyClass('a'),
b = new MyClass('b');
t.is(a.name, 'a');
t.is(a.answer, 42);
t.is(b.name, 'b');
t.is(b.answer, 42);
t.is(a.hasOwnProperty('name'), true);
t.is(a.hasOwnProperty('answer'), false);
t.is(b.hasOwnProperty('name'), true);
t.is(b.hasOwnProperty('answer'), false);
t.deepEqual(mixin({}, a), { name: 'a' });
t.deepEqual(mixin({}, b), { name: 'b' });
t.deepEqual(mixin({}, a, b), { name: 'b' });
t.deepEqual(mixin({}, b, a), { name: 'a' });
b.answer = 99;
t.is(a.name, 'a');
t.is(a.answer, 42);
t.is(b.name, 'b');
t.is(b.answer, 99);
t.is(a.hasOwnProperty('name'), true);
t.is(a.hasOwnProperty('answer'), false);
t.is(b.hasOwnProperty('name'), true);
t.is(b.hasOwnProperty('answer'), true);
t.deepEqual(mixin({}, a), { name: 'a' });
t.deepEqual(mixin({}, b), { name: 'b', answer: 99 });
t.deepEqual(mixin({}, a, b), { name: 'b', answer: 99 });
t.deepEqual(mixin({}, b, a), { name: 'a', answer: 99 });
});
//-------------------------------------------------------------------------------------------------
================================================
FILE: test/wildcards.js
================================================
import test from 'ava'
import StateMachine from '../src/app'
//-----------------------------------------------------------------------------
test('wildcard :from allows transition from any state', t => {
var fsm = new StateMachine({
init: 'stopped',
transitions: [
{ name: 'prepare', from: 'stopped', to: 'ready' },
{ name: 'start', from: 'ready', to: 'running' },
{ name: 'resume', from: 'paused', to: 'running' },
{ name: 'pause', from: 'running', to: 'paused' },
{ name: 'stop', from: '*', to: 'stopped' }
]});
t.is(fsm.state, 'stopped', "initial state should be stopped");
fsm.prepare(); t.is(fsm.state, 'ready')
fsm.stop(); t.is(fsm.state, 'stopped')
fsm.prepare(); t.is(fsm.state, 'ready')
fsm.start(); t.is(fsm.state, 'running')
fsm.stop(); t.is(fsm.state, 'stopped')
fsm.prepare(); t.is(fsm.state, 'ready')
fsm.start(); t.is(fsm.state, 'running')
fsm.pause(); t.is(fsm.state, 'paused')
fsm.stop(); t.is(fsm.state, 'stopped')
fsm.stop(); t.is(fsm.state, 'stopped')
t.deepEqual(fsm.transitions(), ["prepare", "stop"], "ensure wildcard transition (stop) is included in available transitions")
fsm.prepare(); t.deepEqual(fsm.transitions(), ["start", "stop"], "ensure wildcard transition (stop) is included in available transitions")
fsm.start(); t.deepEqual(fsm.transitions(), ["pause", "stop"], "ensure wildcard transition (stop) is included in available transitions")
fsm.stop(); t.deepEqual(fsm.transitions(), ["prepare", "stop"], "ensure wildcard transition (stop) is included in available transitions")
})
//-----------------------------------------------------------------------------
test('missing :from allows transition from any state', t => {
var fsm = new StateMachine({
init: 'stopped',
transitions: [
{ name: 'prepare', from: 'stopped', to: 'ready' },
{ name: 'start', from: 'ready', to: 'running' },
{ name: 'resume', from: 'paused', to: 'running' },
{ name: 'pause', from: 'running', to: 'paused' },
{ name: 'stop', /* any from state */ to: 'stopped' }
]});
t.is(fsm.state, 'stopped', "initial state should be stopped")
fsm.prepare(); t.is(fsm.state, 'ready')
fsm.stop(); t.is(fsm.state, 'stopped')
fsm.prepare(); t.is(fsm.state, 'ready')
fsm.start(); t.is(fsm.state, 'running')
fsm.stop(); t.is(fsm.state, 'stopped')
fsm.prepare(); t.is(fsm.state, 'ready')
fsm.start(); t.is(fsm.state, 'running')
fsm.pause(); t.is(fsm.state, 'paused')
fsm.stop(); t.is(fsm.state, 'stopped')
t.deepEqual(fsm.transitions(), ["prepare", "stop"], "ensure missing :from transition (stop) is included in available transitions")
fsm.prepare(); t.deepEqual(fsm.transitions(), ["start", "stop"], "ensure missing :from transition (stop) is included in available transitions")
fsm.start(); t.deepEqual(fsm.transitions(), ["pause", "stop"], "ensure missing :from transition (stop) is included in available transitions")
fsm.stop(); t.deepEqual(fsm.transitions(), ["prepare", "stop"], "ensure missing :from transition (stop) is included in available transitions")
})
//-----------------------------------------------------------------------------
test('wildcard :from allows transition to a state that is never declared in any other :from transition ', t => {
var fsm = new StateMachine({
transitions: [
{ name: 'step', from: 'none', to: 'mystery' }, // NOTE: 'mystery' is only ever declared in :to, never :from
{ name: 'other', from: '*', to: 'complete' }
]
});
t.is(fsm.state, 'none')
t.is(fsm.can('step'), true)
t.is(fsm.can('other'), true)
fsm.step()
t.is(fsm.state, 'mystery')
t.is(fsm.can('step'), false)
t.is(fsm.can('other'), true)
})
//-----------------------------------------------------------------------------
test('wildcard :to allows no-op transitions', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'stayA', from: 'A', to: '*' },
{ name: 'stayB', from: 'B', to: '*' },
{ name: 'noop', from: '*', to: '*' },
{ name: 'step', from: 'A', to: 'B' }
]
});
t.is(fsm.state, 'A')
t.is(fsm.can('noop'), true)
t.is(fsm.can('step'), true)
t.is(fsm.can('stayA'), true)
t.is(fsm.can('stayB'), false)
fsm.stayA(); t.is(fsm.state, 'A')
fsm.noop(); t.is(fsm.state, 'A')
fsm.step();
t.is(fsm.state, 'B')
t.is(fsm.can('noop'), true)
t.is(fsm.can('step'), false)
t.is(fsm.can('stayA'), false)
t.is(fsm.can('stayB'), true)
fsm.stayB(); t.is(fsm.state, 'B')
fsm.noop(); t.is(fsm.state, 'B')
})
//-----------------------------------------------------------------------------
test('missing :to allows no-op transitions', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'stayA', from: 'A' /* no-op */ },
{ name: 'stayB', from: 'B' /* no-op */ },
{ name: 'noop', from: '*' /* no-op */ },
{ name: 'step', from: 'A', to: 'B' }
]
});
t.is(fsm.state, 'A')
t.is(fsm.can('noop'), true)
t.is(fsm.can('step'), true)
t.is(fsm.can('stayA'), true)
t.is(fsm.can('stayB'), false)
fsm.stayA(); t.is(fsm.state, 'A')
fsm.noop(); t.is(fsm.state, 'A')
fsm.step();
t.is(fsm.state, 'B')
t.is(fsm.can('noop'), true)
t.is(fsm.can('step'), false)
t.is(fsm.can('stayA'), false)
t.is(fsm.can('stayB'), true)
fsm.stayB(); t.is(fsm.state, 'B')
fsm.noop(); t.is(fsm.state, 'B')
})
//-----------------------------------------------------------------------------
test('no-op transitions with multiple from states', t => {
var fsm = new StateMachine({
init: 'A',
transitions: [
{ name: 'step', from: 'A', to: 'B' },
{ name: 'noop1', from: ['A', 'B'] /* no-op */ },
{ name: 'noop2', from: '*' /* no-op */ },
{ name: 'noop3', from: ['A', 'B'], to: '*' },
{ name: 'noop4', from: '*', to: '*' }
]
});
t.is(fsm.state, 'A')
t.is(fsm.can('step'), true)
t.is(fsm.can('noop1'), true)
t.is(fsm.can('noop2'), true)
t.is(fsm.can('noop3'), true)
t.is(fsm.can('noop4'), true)
fsm.noop1(); t.is(fsm.state, 'A')
fsm.noop2(); t.is(fsm.state, 'A')
fsm.noop3(); t.is(fsm.state, 'A')
fsm.noop4(); t.is(fsm.state, 'A')
fsm.step();
t.is(fsm.state, 'B')
t.is(fsm.can('step'), false)
t.is(fsm.can('noop1'), true)
t.is(fsm.can('noop2'), true)
t.is(fsm.can('noop3'), true)
t.is(fsm.can('noop4'), true)
fsm.noop1(); t.is(fsm.state, 'B')
fsm.noop2(); t.is(fsm.state, 'B')
fsm.noop3(); t.is(fsm.state, 'B')
fsm.noop4(); t.is(fsm.state, 'B')
})
//-----------------------------------------------------------------------------
================================================
FILE: webpack.config.js
================================================
module.exports = function(env) {
'use strict'
const webpack = require('webpack'),
glob = require('glob'),
path = require('path'),
pascalize = require('pascal-case'),
source = 'src',
output = 'lib',
config = [];
config.push({
name: 'state-machine',
library: 'StateMachine',
entry: 'app'
})
glob.sync("src/plugin/*.js").forEach(function(plugin) {
const name = path.basename(plugin, '.js');
config.push({
library: pascalize('state-machine-' + name),
entry: 'plugin/' + name,
name: name
})
});
return config.map(function(cfg) {
return {
entry: cfg.entry,
resolve: {
modules: [ source ]
},
output: {
filename: path.join(output, cfg.name + '.js'),
library: cfg.library,
libraryTarget: 'umd',
umdNamedDefine: true
}
}
});
}