refac: impl more convenient design
This commit is contained in:
parent
6ce49fbb1f
commit
094b9216da
15 changed files with 655 additions and 689 deletions
21
.github/workflows/ci.yml
vendored
Normal file
21
.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: denolib/setup-deno@v2
|
||||||
|
with:
|
||||||
|
deno-version: v1.x
|
||||||
|
- run: make tests-cov
|
||||||
|
- name: Coveralls
|
||||||
|
uses: coverallsapp/github-action@master
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
path-to-lcov: cov_profile/cov.lcov
|
92
.gitignore
vendored
92
.gitignore
vendored
|
@ -1,92 +1,4 @@
|
||||||
# Created by https://www.gitignore.io/api/vim,node
|
cov_profile/
|
||||||
# Edit at https://www.gitignore.io/?templates=vim,node
|
|
||||||
|
|
||||||
### Node ###
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
package-lock.json
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variables file
|
|
||||||
.env
|
|
||||||
.env.test
|
|
||||||
|
|
||||||
### Vim ###
|
|
||||||
# Swap
|
|
||||||
[._]*.s[a-v][a-z]
|
|
||||||
[._]*.sw[a-p]
|
|
||||||
[._]s[a-rt-v][a-z]
|
|
||||||
[._]ss[a-gi-z]
|
|
||||||
[._]sw[a-p]
|
|
||||||
|
|
||||||
# Session
|
|
||||||
Session.vim
|
|
||||||
Sessionx.vim
|
|
||||||
|
|
||||||
# Temporary
|
|
||||||
.netrwhist
|
|
||||||
*~
|
|
||||||
# Auto-generated tag files
|
|
||||||
tags
|
|
||||||
# Persistent undo
|
|
||||||
[._]*.un~
|
|
||||||
|
|
||||||
# End of https://www.gitignore.io/api/vim,node
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.c9/
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# build artifacts
|
|
||||||
target/
|
|
18
.travis.yml
18
.travis.yml
|
@ -1,18 +0,0 @@
|
||||||
sudo: false
|
|
||||||
language: node_js
|
|
||||||
node_js:
|
|
||||||
# LTS
|
|
||||||
# - "8"
|
|
||||||
- "10"
|
|
||||||
- "12"
|
|
||||||
- "14"
|
|
||||||
- "node"
|
|
||||||
before_install:
|
|
||||||
- npm install -g npm
|
|
||||||
install:
|
|
||||||
- npm install
|
|
||||||
script:
|
|
||||||
- npm test
|
|
||||||
after_success:
|
|
||||||
- npm run report-coverage
|
|
||||||
|
|
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"deno.enable": true,
|
||||||
|
"deno.lint": true
|
||||||
|
}
|
58
README.md
58
README.md
|
@ -1,38 +1,52 @@
|
||||||
# IT FSM
|
# IT FSM
|
||||||
|
|
||||||
Simple finite state machine
|
|
||||||
|
|
||||||
[![Build Status](https://travis-ci.com/icetemple/npm-it-fsm.svg?branch=master)](https://travis-ci.com/icetemple/npm-it-fsm)
|
[![Build Status](https://travis-ci.com/icetemple/npm-it-fsm.svg?branch=master)](https://travis-ci.com/icetemple/npm-it-fsm)
|
||||||
[![Coverage Status](https://coveralls.io/repos/github/icetemple/npm-it-fsm/badge.svg?branch=master)](https://coveralls.io/github/icetemple/npm-it-fsm?branch=master)
|
[![Coverage Status](https://coveralls.io/repos/github/icetemple/npm-it-fsm/badge.svg?branch=master)](https://coveralls.io/github/icetemple/npm-it-fsm?branch=master)
|
||||||
|
|
||||||
|
Simple finite state machine
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
`npm install --save it-fsm`
|
`npm install --save it-fsm`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
```javascript
|
```ts
|
||||||
import { StateMachine } from 'it-fsm';
|
import { StateMachineBuilder } from "it-fsm";
|
||||||
|
|
||||||
const fsm = new StateMachine('TODO', {
|
enum ProjectStatus {
|
||||||
TODO: {
|
Pending = "pending",
|
||||||
complete: 'COMPLETE'
|
Active = "active",
|
||||||
}
|
Completed = "completed",
|
||||||
})
|
Archived = "archive",
|
||||||
|
|
||||||
|
|
||||||
if (fsm.can('complete')) {
|
|
||||||
fsm.complete().then(() => {
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// or
|
|
||||||
if (fsm.canToState('COMPLETE')) {
|
const smbProject = new StateMachineBuilder()
|
||||||
fsm.complete().then(() => {
|
.withStates(Object.values(ProjectStatus))
|
||||||
|
.withTransitions([
|
||||||
});
|
[ProjectStatus.Pending, [ProjectStatus.Active, ProjectStatus.Archived]],
|
||||||
|
[ProjectStatus.Active, [ProjectStatus.Completed]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const project1 = { id: 1, status: ProjectStatus.Pending };
|
||||||
|
const project2 = { id: 2, status: ProjectStatus.Completed };
|
||||||
|
|
||||||
|
// Build FSM with current project status
|
||||||
|
const smForProject1 = smbProject.build(project1.status);
|
||||||
|
const smForProject2 = smbProject.build(project2.status);
|
||||||
|
|
||||||
|
console.log(smForProject2.allowedTransitionStates()); // []
|
||||||
|
|
||||||
|
console.log(smForProject1.allowedTransitionStates()); // [active, archived]
|
||||||
|
await smForProject1.changeState(ProjectStatus.Active);
|
||||||
|
|
||||||
|
console.log(smForProject1.allowedTransitionStates()); // [completed]
|
||||||
|
await smForProject1.changeState(ProjectStatus.Completed);
|
||||||
|
|
||||||
|
console.log(smForProject1.allowedTransitionStates()); // []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
46
dest/fsm.d.ts
vendored
Normal file
46
dest/fsm.d.ts
vendored
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
declare type StateTransitions<Context> = WeakMap<State<Context>, WeakSet<State<Context>>>;
|
||||||
|
export declare const _states: unique symbol;
|
||||||
|
export declare const _stateTransitions: unique symbol;
|
||||||
|
export declare const _prevState: unique symbol;
|
||||||
|
export declare const _currState: unique symbol;
|
||||||
|
export declare class StateMachineBuilder<Context> {
|
||||||
|
[_states]: Map<string, Actions<Context>>;
|
||||||
|
[_stateTransitions]: Array<[string, Array<string>]> | undefined;
|
||||||
|
constructor();
|
||||||
|
withTransitions(transitions: Array<[string, Array<string>]>): this;
|
||||||
|
withStates(names: string[], actions?: Actions<Context>): this;
|
||||||
|
withState(name: string, actions?: Actions<Context>): this;
|
||||||
|
private addStateUnchecked;
|
||||||
|
build(currentStateName: string): StateMachine<unknown>;
|
||||||
|
private buildStates;
|
||||||
|
private buildTransitions;
|
||||||
|
}
|
||||||
|
export declare class StateMachine<Context> {
|
||||||
|
[_states]: State<Context>[];
|
||||||
|
[_stateTransitions]: StateTransitions<Context>;
|
||||||
|
[_prevState]: State<Context> | undefined;
|
||||||
|
[_currState]: State<Context>;
|
||||||
|
constructor(states: State<Context>[], transitions: StateTransitions<Context>, currentState: State<Context>);
|
||||||
|
changeState(sourceState: string | State<Context>, context?: Context): Promise<void>;
|
||||||
|
hasTransition(to: string | State<Context>): boolean;
|
||||||
|
allowedTransitionStates(): State<Context>[];
|
||||||
|
}
|
||||||
|
declare const _stateName: unique symbol;
|
||||||
|
declare const _stateActions: unique symbol;
|
||||||
|
interface Actions<Context> {
|
||||||
|
beforeExit?(fromState: State<Context>, toState: State<Context>, context: Context): boolean;
|
||||||
|
onEntry?(fromState: State<Context>, toState: State<Context>, context: Context): Promise<void> | void;
|
||||||
|
}
|
||||||
|
export declare class State<Context> {
|
||||||
|
[_stateActions]: Actions<Context>;
|
||||||
|
[_stateName]: string;
|
||||||
|
get name(): string;
|
||||||
|
constructor(name: string, actions?: Actions<Context>);
|
||||||
|
entry(fromState: State<Context>, toState: State<Context>, context: Context): Promise<void>;
|
||||||
|
exit(fromState: State<Context>, toState: State<Context>, context: Context): boolean;
|
||||||
|
toString(): string;
|
||||||
|
toJSON(): string;
|
||||||
|
}
|
||||||
|
export declare class FsmError extends Error {
|
||||||
|
}
|
||||||
|
export {};
|
144
dest/fsm.js
Normal file
144
dest/fsm.js
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
"use strict";
|
||||||
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.FsmError = exports.State = exports.StateMachine = exports.StateMachineBuilder = exports._currState = exports._prevState = exports._stateTransitions = exports._states = void 0;
|
||||||
|
exports._states = Symbol("states");
|
||||||
|
exports._stateTransitions = Symbol("state transitions");
|
||||||
|
exports._prevState = Symbol("previous state");
|
||||||
|
exports._currState = Symbol("current state");
|
||||||
|
class StateMachineBuilder {
|
||||||
|
constructor() {
|
||||||
|
this[exports._states] = new Map();
|
||||||
|
}
|
||||||
|
withTransitions(transitions) {
|
||||||
|
this[exports._stateTransitions] = transitions;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
withStates(names, actions) {
|
||||||
|
names.forEach((name) => this.addStateUnchecked(name, actions));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
withState(name, actions) {
|
||||||
|
this.addStateUnchecked(name, actions);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addStateUnchecked(name, actions) {
|
||||||
|
const oldActions = this[exports._states].get(name);
|
||||||
|
return this[exports._states].set(name, Object.assign(Object.assign({}, oldActions), actions));
|
||||||
|
}
|
||||||
|
build(currentStateName) {
|
||||||
|
const states = this.buildStates();
|
||||||
|
const transitions = this.buildTransitions(states);
|
||||||
|
const currState = validStateFromName(states, currentStateName);
|
||||||
|
return new StateMachine(states, transitions, currState);
|
||||||
|
}
|
||||||
|
buildStates() {
|
||||||
|
return Array.from(this[exports._states].entries()).map((params) => new State(...params));
|
||||||
|
}
|
||||||
|
buildTransitions(states) {
|
||||||
|
const sourceTransitions = this[exports._stateTransitions] || [];
|
||||||
|
return new WeakMap(sourceTransitions.map(([from, toStates]) => [
|
||||||
|
validStateFromName(states, from),
|
||||||
|
new WeakSet(toStates.map(validStateFromName.bind(null, states))),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.StateMachineBuilder = StateMachineBuilder;
|
||||||
|
class StateMachine {
|
||||||
|
constructor(states, transitions, currentState) {
|
||||||
|
this[exports._states] = states;
|
||||||
|
this[exports._stateTransitions] = transitions;
|
||||||
|
this[exports._currState] = currentState;
|
||||||
|
}
|
||||||
|
changeState(sourceState, context) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const fromState = validState(this[exports._currState]);
|
||||||
|
const toState = validState(normalizeState(this[exports._states], sourceState));
|
||||||
|
if (!this.hasTransition(toState) ||
|
||||||
|
!fromState.exit(fromState, toState, context)) {
|
||||||
|
throw new FsmError(`cannot change state from "${fromState.name}" to "${toState.name}"`);
|
||||||
|
}
|
||||||
|
yield toState.entry(fromState, toState, context);
|
||||||
|
this[exports._currState] = toState;
|
||||||
|
this[exports._prevState] = fromState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
hasTransition(to) {
|
||||||
|
return hasTransition(this[exports._stateTransitions], this[exports._currState], validState(normalizeState(this[exports._states], to)));
|
||||||
|
}
|
||||||
|
allowedTransitionStates() {
|
||||||
|
const fromState = validState(this[exports._currState]);
|
||||||
|
return this[exports._states].filter(hasTransition.bind(null, this[exports._stateTransitions], fromState));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.StateMachine = StateMachine;
|
||||||
|
const _stateName = Symbol("state name");
|
||||||
|
const _stateActions = Symbol("state actions");
|
||||||
|
class State {
|
||||||
|
constructor(name, actions = {}) {
|
||||||
|
this[_stateName] = name;
|
||||||
|
this[_stateActions] = actions;
|
||||||
|
}
|
||||||
|
get name() {
|
||||||
|
return this[_stateName];
|
||||||
|
}
|
||||||
|
entry(fromState, toState, context) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const action = this[_stateActions].onEntry;
|
||||||
|
if (isFn(action)) {
|
||||||
|
yield action(fromState, toState, context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exit(fromState, toState, context) {
|
||||||
|
const action = this[_stateActions].beforeExit;
|
||||||
|
return isFn(action) ? action(fromState, toState, context) : true;
|
||||||
|
}
|
||||||
|
toString() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
toJSON() {
|
||||||
|
return this.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.State = State;
|
||||||
|
function stateFromName(states, name) {
|
||||||
|
return states.find((state) => state.name === name);
|
||||||
|
}
|
||||||
|
function validStateFromName(states, name) {
|
||||||
|
return validState(stateFromName(states, name));
|
||||||
|
}
|
||||||
|
function normalizeState(states, state) {
|
||||||
|
return isStr(state) ? stateFromName(states, state) : state;
|
||||||
|
}
|
||||||
|
function validState(val) {
|
||||||
|
if (!isState(val)) {
|
||||||
|
throw new TypeError("an instance of State class is expected");
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
function isState(val) {
|
||||||
|
return val instanceof State;
|
||||||
|
}
|
||||||
|
function hasTransition(transitions, from, to) {
|
||||||
|
var _a;
|
||||||
|
return ((_a = transitions.get(from)) === null || _a === void 0 ? void 0 : _a.has(to)) || false;
|
||||||
|
}
|
||||||
|
function isStr(val) {
|
||||||
|
return typeof val === "string";
|
||||||
|
}
|
||||||
|
// deno-lint-ignore ban-types
|
||||||
|
function isFn(val) {
|
||||||
|
return typeof val === "function";
|
||||||
|
}
|
||||||
|
class FsmError extends Error {
|
||||||
|
}
|
||||||
|
exports.FsmError = FsmError;
|
162
fsm.test.ts
Normal file
162
fsm.test.ts
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import {
|
||||||
|
assertEquals,
|
||||||
|
assertThrows,
|
||||||
|
assertThrowsAsync,
|
||||||
|
} from "https://deno.land/std@0.105.0/testing/asserts.ts";
|
||||||
|
import * as fsm from "./fsm.ts";
|
||||||
|
|
||||||
|
enum ProjectStatus {
|
||||||
|
Pending = "pending",
|
||||||
|
Active = "active",
|
||||||
|
Completed = "completed",
|
||||||
|
Archived = "archive",
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("should add states separately in builder", function () {
|
||||||
|
const smb = new fsm.StateMachineBuilder()
|
||||||
|
.withState(ProjectStatus.Pending)
|
||||||
|
.withState(ProjectStatus.Active)
|
||||||
|
.withState(ProjectStatus.Completed)
|
||||||
|
.withState(ProjectStatus.Archived);
|
||||||
|
|
||||||
|
const states = smb[fsm._states];
|
||||||
|
|
||||||
|
assertEquals(states.size, 4);
|
||||||
|
assertEquals(Array.from(states.keys()), [
|
||||||
|
ProjectStatus.Pending,
|
||||||
|
ProjectStatus.Active,
|
||||||
|
ProjectStatus.Completed,
|
||||||
|
ProjectStatus.Archived,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should bulk add states in builder", function () {
|
||||||
|
const stateNames = Object.values(ProjectStatus);
|
||||||
|
const smb = new fsm.StateMachineBuilder()
|
||||||
|
.withStates(stateNames);
|
||||||
|
|
||||||
|
const states = smb[fsm._states];
|
||||||
|
|
||||||
|
assertEquals(states.size, 4);
|
||||||
|
assertEquals(Array.from(states.keys()), stateNames);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should build without transitions", function () {
|
||||||
|
const sm = new fsm.StateMachineBuilder()
|
||||||
|
.withState(ProjectStatus.Pending)
|
||||||
|
.build(ProjectStatus.Pending);
|
||||||
|
|
||||||
|
assertEquals(sm.allowedTransitionStates(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should build base state machine", function () {
|
||||||
|
const sm = new fsm.StateMachineBuilder()
|
||||||
|
.withStates(Object.values(ProjectStatus))
|
||||||
|
.withTransitions([
|
||||||
|
[ProjectStatus.Pending, [ProjectStatus.Active, ProjectStatus.Archived]],
|
||||||
|
])
|
||||||
|
.build(ProjectStatus.Pending);
|
||||||
|
|
||||||
|
const [, active, , archived] = sm[fsm._states];
|
||||||
|
|
||||||
|
assertEquals(sm.allowedTransitionStates(), [active, archived]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should change state", async function () {
|
||||||
|
const sm = new fsm.StateMachineBuilder()
|
||||||
|
.withStates(Object.values(ProjectStatus))
|
||||||
|
.withTransitions([
|
||||||
|
[ProjectStatus.Pending, [ProjectStatus.Active, ProjectStatus.Archived]],
|
||||||
|
[ProjectStatus.Active, [ProjectStatus.Completed]],
|
||||||
|
])
|
||||||
|
.build(ProjectStatus.Pending);
|
||||||
|
|
||||||
|
const [, active, completed, archived] = sm[fsm._states];
|
||||||
|
|
||||||
|
assertEquals(sm.allowedTransitionStates(), [active, archived]);
|
||||||
|
|
||||||
|
await sm.changeState(ProjectStatus.Active);
|
||||||
|
assertEquals(sm.allowedTransitionStates(), [completed]);
|
||||||
|
|
||||||
|
await sm.changeState(ProjectStatus.Completed);
|
||||||
|
assertEquals(sm.allowedTransitionStates(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should trigger state actions", async function () {
|
||||||
|
const sm = new fsm.StateMachineBuilder()
|
||||||
|
.withStates(
|
||||||
|
Object.values(ProjectStatus),
|
||||||
|
{
|
||||||
|
onEntry(fromState, toState) {
|
||||||
|
console.log(`changing from ${fromState} to ${toState}`);
|
||||||
|
},
|
||||||
|
beforeExit(fromState, toState) {
|
||||||
|
console.log(`before changing from ${fromState} to ${toState}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.withTransitions([
|
||||||
|
[ProjectStatus.Pending, [ProjectStatus.Active, ProjectStatus.Archived]],
|
||||||
|
[ProjectStatus.Active, [ProjectStatus.Completed]],
|
||||||
|
])
|
||||||
|
.build(ProjectStatus.Pending);
|
||||||
|
|
||||||
|
const [, active, completed, archived] = sm[fsm._states];
|
||||||
|
|
||||||
|
assertEquals(sm.allowedTransitionStates(), [active, archived]);
|
||||||
|
|
||||||
|
await sm.changeState(ProjectStatus.Active);
|
||||||
|
assertEquals(sm.allowedTransitionStates(), [completed]);
|
||||||
|
|
||||||
|
await sm.changeState(ProjectStatus.Completed);
|
||||||
|
assertEquals(sm.allowedTransitionStates(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should stringify state", function () {
|
||||||
|
const pending = new fsm.State(ProjectStatus.Pending);
|
||||||
|
const active = new fsm.State(ProjectStatus.Active);
|
||||||
|
|
||||||
|
assertEquals(pending.toString(), ProjectStatus.Pending);
|
||||||
|
assertEquals(
|
||||||
|
[pending, active].join(),
|
||||||
|
`${ProjectStatus.Pending},${ProjectStatus.Active}`,
|
||||||
|
);
|
||||||
|
assertEquals(JSON.stringify({ pending }), '{"pending":"pending"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should throw type error if state doesn't exist", () => {
|
||||||
|
assertThrows(
|
||||||
|
() => new fsm.StateMachineBuilder().build(ProjectStatus.Pending),
|
||||||
|
TypeError,
|
||||||
|
"an instance of State class is expected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should throw error if transition to the state doesn't exist", () => {
|
||||||
|
const sm = new fsm.StateMachineBuilder()
|
||||||
|
.withStates(Object.values(ProjectStatus))
|
||||||
|
.build(ProjectStatus.Pending);
|
||||||
|
assertThrowsAsync(
|
||||||
|
() => sm.changeState(ProjectStatus.Active),
|
||||||
|
fsm.FsmError,
|
||||||
|
`cannot change state from "${ProjectStatus.Pending}" to "${ProjectStatus.Active}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("should throw error if beforeExit action returns false", () => {
|
||||||
|
const sm = new fsm.StateMachineBuilder()
|
||||||
|
.withStates(
|
||||||
|
Object.values(ProjectStatus),
|
||||||
|
{ beforeExit: () => false },
|
||||||
|
)
|
||||||
|
.withTransitions([
|
||||||
|
[ProjectStatus.Pending, [ProjectStatus.Active]],
|
||||||
|
])
|
||||||
|
.build(ProjectStatus.Pending);
|
||||||
|
assertThrowsAsync(
|
||||||
|
() => sm.changeState(ProjectStatus.Active),
|
||||||
|
fsm.FsmError,
|
||||||
|
`cannot change state from "${ProjectStatus.Pending}" to "${ProjectStatus.Active}"`,
|
||||||
|
);
|
||||||
|
});
|
217
fsm.ts
Normal file
217
fsm.ts
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
type StateTransitions<Context> = WeakMap<
|
||||||
|
State<Context>,
|
||||||
|
WeakSet<State<Context>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const _states = Symbol("states");
|
||||||
|
export const _stateTransitions = Symbol("state transitions");
|
||||||
|
export const _prevState = Symbol("previous state");
|
||||||
|
export const _currState = Symbol("current state");
|
||||||
|
|
||||||
|
export class StateMachineBuilder<Context> {
|
||||||
|
[_states]: Map<string, Actions<Context>>;
|
||||||
|
|
||||||
|
[_stateTransitions]: Array<[string, Array<string>]> | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this[_states] = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
withTransitions(transitions: Array<[string, Array<string>]>) {
|
||||||
|
this[_stateTransitions] = transitions;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withStates(names: string[], actions?: Actions<Context>) {
|
||||||
|
names.forEach((name) => this.addStateUnchecked(name, actions));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withState(name: string, actions?: Actions<Context>) {
|
||||||
|
this.addStateUnchecked(name, actions);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addStateUnchecked(name: string, actions?: Actions<Context>) {
|
||||||
|
const oldActions = this[_states].get(name);
|
||||||
|
return this[_states].set(name, { ...oldActions, ...actions });
|
||||||
|
}
|
||||||
|
|
||||||
|
build(currentStateName: string) {
|
||||||
|
const states = this.buildStates();
|
||||||
|
const transitions = this.buildTransitions(states);
|
||||||
|
const currState = validStateFromName(states, currentStateName);
|
||||||
|
return new StateMachine(states, transitions, currState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStates() {
|
||||||
|
return Array.from(this[_states].entries()).map((params) =>
|
||||||
|
new State(...params)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTransitions(states: State<Context>[]) {
|
||||||
|
const sourceTransitions = this[_stateTransitions] || [];
|
||||||
|
|
||||||
|
return new WeakMap(
|
||||||
|
sourceTransitions.map(([from, toStates]) => [
|
||||||
|
validStateFromName(states, from),
|
||||||
|
new WeakSet(toStates.map(validStateFromName.bind(null, states))),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StateMachine<Context> {
|
||||||
|
[_states]: State<Context>[];
|
||||||
|
|
||||||
|
[_stateTransitions]: StateTransitions<Context>;
|
||||||
|
|
||||||
|
[_prevState]: State<Context> | undefined;
|
||||||
|
|
||||||
|
[_currState]: State<Context>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
states: State<Context>[],
|
||||||
|
transitions: StateTransitions<Context>,
|
||||||
|
currentState: State<Context>,
|
||||||
|
) {
|
||||||
|
this[_states] = states;
|
||||||
|
this[_stateTransitions] = transitions;
|
||||||
|
this[_currState] = currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeState(sourceState: string | State<Context>, context?: Context) {
|
||||||
|
const fromState = validState(this[_currState]);
|
||||||
|
const toState = validState(normalizeState(this[_states], sourceState));
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.hasTransition(toState) ||
|
||||||
|
!fromState.exit(fromState, toState, context)
|
||||||
|
) {
|
||||||
|
throw new FsmError(
|
||||||
|
`cannot change state from "${fromState.name}" to "${toState.name}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await toState.entry(fromState, toState, context);
|
||||||
|
|
||||||
|
this[_currState] = toState;
|
||||||
|
this[_prevState] = fromState;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTransition(to: string | State<Context>) {
|
||||||
|
return hasTransition(
|
||||||
|
this[_stateTransitions],
|
||||||
|
this[_currState],
|
||||||
|
validState(normalizeState(this[_states], to)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedTransitionStates() {
|
||||||
|
const fromState = validState(this[_currState]);
|
||||||
|
return this[_states].filter(
|
||||||
|
hasTransition.bind(null, this[_stateTransitions], fromState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _stateName = Symbol("state name");
|
||||||
|
const _stateActions = Symbol("state actions");
|
||||||
|
|
||||||
|
interface Actions<Context> {
|
||||||
|
beforeExit?(
|
||||||
|
fromState: State<Context>,
|
||||||
|
toState: State<Context>,
|
||||||
|
context: Context,
|
||||||
|
): boolean;
|
||||||
|
onEntry?(
|
||||||
|
fromState: State<Context>,
|
||||||
|
toState: State<Context>,
|
||||||
|
context: Context,
|
||||||
|
): Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class State<Context> {
|
||||||
|
[_stateActions]: Actions<Context>;
|
||||||
|
|
||||||
|
[_stateName]: string;
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this[_stateName];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(name: string, actions: Actions<Context> = {}) {
|
||||||
|
this[_stateName] = name;
|
||||||
|
this[_stateActions] = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async entry(
|
||||||
|
fromState: State<Context>,
|
||||||
|
toState: State<Context>,
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
const action = this[_stateActions].onEntry;
|
||||||
|
if (isFn(action)) {
|
||||||
|
await action(fromState, toState, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(fromState: State<Context>, toState: State<Context>, context: Context) {
|
||||||
|
const action = this[_stateActions].beforeExit;
|
||||||
|
return isFn(action) ? action(fromState, toState, context) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return this.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFromName<Context>(states: State<Context>[], name: string) {
|
||||||
|
return states.find((state) => state.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validStateFromName<Context>(states: State<Context>[], name: string) {
|
||||||
|
return validState<Context>(stateFromName(states, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState<Context>(
|
||||||
|
states: State<Context>[],
|
||||||
|
state: string | State<Context>,
|
||||||
|
): State<Context> | undefined {
|
||||||
|
return isStr(state) ? stateFromName(states, state) : state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validState<Context>(val: unknown): State<Context> {
|
||||||
|
if (!isState<Context>(val)) {
|
||||||
|
throw new TypeError("an instance of State class is expected");
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isState<Context>(val: unknown): val is State<Context> {
|
||||||
|
return val instanceof State;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTransition<Context>(
|
||||||
|
transitions: StateTransitions<Context>,
|
||||||
|
from: State<Context>,
|
||||||
|
to: State<Context>,
|
||||||
|
) {
|
||||||
|
return transitions.get(from)?.has(to) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStr(val: unknown): val is string {
|
||||||
|
return typeof val === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore ban-types
|
||||||
|
function isFn(val: unknown): val is Function {
|
||||||
|
return typeof val === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FsmError extends Error {}
|
|
@ -1,8 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
testRegex: 'tests/.*\\.spec\\.ts',
|
|
||||||
testEnvironment: 'node',
|
|
||||||
preset: 'ts-jest',
|
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
|
||||||
collectCoverage: true,
|
|
||||||
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
|
|
||||||
};
|
|
16
makefile
Normal file
16
makefile
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
DENO := deno
|
||||||
|
|
||||||
|
tests: clean fmt-check
|
||||||
|
$(DENO) test --coverage=cov_profile *.test.mjs
|
||||||
|
|
||||||
|
tests-cov: tests
|
||||||
|
$(DENO) coverage cov_profile --lcov > cov_profile/cov.lcov
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
$(DENO) fmt *.mjs
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
$(DENO) fmt *.mjs --check
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf cov_profile
|
20
package.json
20
package.json
|
@ -1,17 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "it-fsm",
|
"name": "it-fsm",
|
||||||
"version": "1.0.8",
|
"version": "2.0.0",
|
||||||
"description": "Simple finite state machine for nodejs",
|
"description": "Simple finite state machine for nodejs",
|
||||||
"main": "./target/index.js",
|
"main": "./dest/fsm.js",
|
||||||
"types": "./target/index.d.ts",
|
"types": "./dest/fsm.d.ts",
|
||||||
"readme": "README.md",
|
"readme": "README.md",
|
||||||
"files": [
|
"files": [
|
||||||
"target"
|
"target"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"prepublishOnly": "rm -rf ./dest && tsc"
|
||||||
"prepublishOnly": "rm -rf ./target && tsc",
|
|
||||||
"report-coverage": "cat coverage/lcov.info | coveralls"
|
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -32,14 +30,6 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/icetemple/npm-it-fsm#readme",
|
"homepage": "https://github.com/icetemple/npm-it-fsm#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^26.0.15",
|
"typescript": "^4.3.5"
|
||||||
"@types/lodash.clonedeep": "^4.5.6",
|
|
||||||
"coveralls": "^3.1.0",
|
|
||||||
"jest": "^26.6.3",
|
|
||||||
"ts-jest": "^26.4.3",
|
|
||||||
"typescript": "^4.0.5"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"lodash.clonedeep": "^4.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
114
src/index.ts
114
src/index.ts
|
@ -1,114 +0,0 @@
|
||||||
import cloneDeep from 'lodash.clonedeep';
|
|
||||||
|
|
||||||
export type Payload = Record<string, any>
|
|
||||||
export type StateType = string | number
|
|
||||||
export type ActionConfigMap = Record<string, StateType | IActionConfig>
|
|
||||||
export type ActionEvent = (event: string, fromState: StateType, toState: StateType,
|
|
||||||
payload: Payload) => Promise<any>
|
|
||||||
|
|
||||||
|
|
||||||
export interface IConfig {
|
|
||||||
[key: string]: undefined | ActionEvent | ActionConfigMap;
|
|
||||||
onEnter?: ActionEvent;
|
|
||||||
onLeave?: ActionEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IActionConfig {
|
|
||||||
state: StateType;
|
|
||||||
onBeforeChange?: ActionEvent;
|
|
||||||
onChange?: ActionEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class StateMachine {
|
|
||||||
[key: string]: any;
|
|
||||||
|
|
||||||
private _currentState: StateType;
|
|
||||||
private _onEnter?: ActionEvent;
|
|
||||||
private _onLeave?: ActionEvent;
|
|
||||||
private _eventsByState = new Map<StateType, Record<string, (payload: Payload) => any>>();
|
|
||||||
private _statesByState = new Map<StateType, StateType[]>();
|
|
||||||
|
|
||||||
constructor(initial: StateType, config: IConfig) {
|
|
||||||
this._currentState = initial;
|
|
||||||
|
|
||||||
for (let fromStateKey in config) {
|
|
||||||
if (['onEnter', 'onLeave'].includes(fromStateKey)) {
|
|
||||||
this._onEnter = config.onEnter;
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromState: StateType = /^\d+$/.test(fromStateKey) ?
|
|
||||||
parseInt(fromStateKey, 10)
|
|
||||||
: fromStateKey;
|
|
||||||
|
|
||||||
const statesOfState: StateType[] = [];
|
|
||||||
|
|
||||||
let actions = config[fromStateKey] as ActionConfigMap;
|
|
||||||
for (let actionName in actions) {
|
|
||||||
let action = actions[actionName];
|
|
||||||
let actionConfig: IActionConfig = action.constructor === Object ?
|
|
||||||
action as IActionConfig
|
|
||||||
: { state: action as StateType };
|
|
||||||
|
|
||||||
statesOfState.push(actionConfig.state);
|
|
||||||
this._statesByState.set(fromState, statesOfState);
|
|
||||||
|
|
||||||
this._initChangeState(actionName, fromState, actionConfig.state, actionConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private _initChangeState(eventName: string, fromState: StateType, toState: StateType, actionConfig: IActionConfig): void {
|
|
||||||
const { onBeforeChange, onChange } = actionConfig;
|
|
||||||
const _runEvent = async (method?: ActionEvent, payload: Payload = {}): Promise<void> => {
|
|
||||||
if (method) {
|
|
||||||
await method(eventName, fromState, toState, payload);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = this._eventsByState.get(fromState) ?? {};
|
|
||||||
events[eventName] = async (sourcePayload: Payload = {}) => {
|
|
||||||
const payload = cloneDeep(sourcePayload);
|
|
||||||
await _runEvent(this._onEnter, payload);
|
|
||||||
await _runEvent(onBeforeChange, payload);
|
|
||||||
this._currentState = toState;
|
|
||||||
await _runEvent(onChange, payload);
|
|
||||||
await _runEvent(this._onLeave, payload);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
this._eventsByState.set(fromState, events);
|
|
||||||
|
|
||||||
if (!this[eventName]) {
|
|
||||||
this[eventName] = async (payload: Payload = {}) => {
|
|
||||||
const events = this._eventsByState.get(this._currentState);
|
|
||||||
if (events && events[eventName]) {
|
|
||||||
return events[eventName](payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCurrentState(): StateType {
|
|
||||||
return this._currentState;
|
|
||||||
}
|
|
||||||
|
|
||||||
public can(eventName: string): boolean {
|
|
||||||
return this.getAvailableActions().includes(eventName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public canToState(stateName: StateType) {
|
|
||||||
return this.getAvailableStates().includes(stateName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAvailableStates(): StateType[] {
|
|
||||||
return this._statesByState.get(this._currentState) ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAvailableActions(): string[] {
|
|
||||||
return Object.keys(this._eventsByState.get(this._currentState) ?? {});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,419 +0,0 @@
|
||||||
import { StateMachine } from '../src/index'
|
|
||||||
|
|
||||||
|
|
||||||
describe('StateMachine', () => {
|
|
||||||
let simpleIntFSM: StateMachine;
|
|
||||||
let objectIntFSM: StateMachine;
|
|
||||||
let simpleStrFSM: StateMachine;
|
|
||||||
let objectStrFSM: StateMachine;
|
|
||||||
|
|
||||||
enum IntStatus {
|
|
||||||
PENDING = 1,
|
|
||||||
ACTIVE = 2,
|
|
||||||
ARCHIVED = 3,
|
|
||||||
DELETED = 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum StrStatus {
|
|
||||||
PENDING = 'PENDING',
|
|
||||||
ACTIVE = 'ACTIVE',
|
|
||||||
ARCHIVED = 'ARCHIVED',
|
|
||||||
DELETED = 'DELETED',
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
const emptyHandler = async () => {};
|
|
||||||
|
|
||||||
const onEnter = emptyHandler;
|
|
||||||
const onLeave = emptyHandler;
|
|
||||||
const onBeforeChange = emptyHandler;
|
|
||||||
const onChange = emptyHandler;
|
|
||||||
|
|
||||||
simpleIntFSM = new StateMachine(IntStatus.PENDING, {
|
|
||||||
onEnter,
|
|
||||||
onLeave,
|
|
||||||
[IntStatus.PENDING]: {
|
|
||||||
active: IntStatus.ACTIVE,
|
|
||||||
delete: IntStatus.DELETED,
|
|
||||||
},
|
|
||||||
[IntStatus.ACTIVE]: {
|
|
||||||
toDraft: IntStatus.PENDING,
|
|
||||||
archive: IntStatus.ARCHIVED,
|
|
||||||
doNothing: IntStatus.ACTIVE,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
objectIntFSM = new StateMachine(IntStatus.PENDING, {
|
|
||||||
onEnter,
|
|
||||||
onLeave,
|
|
||||||
[IntStatus.PENDING]: {
|
|
||||||
active: {
|
|
||||||
state: IntStatus.ACTIVE,
|
|
||||||
onBeforeChange,
|
|
||||||
onChange,
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
state: IntStatus.DELETED,
|
|
||||||
onBeforeChange,
|
|
||||||
onChange,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[IntStatus.ACTIVE]: {
|
|
||||||
toDraft: {
|
|
||||||
state: IntStatus.PENDING,
|
|
||||||
},
|
|
||||||
archive: {
|
|
||||||
state: IntStatus.ARCHIVED,
|
|
||||||
},
|
|
||||||
doNothing: {
|
|
||||||
state: IntStatus.ACTIVE,
|
|
||||||
onBeforeChange,
|
|
||||||
onChange,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
simpleStrFSM = new StateMachine(StrStatus.PENDING, {
|
|
||||||
onEnter,
|
|
||||||
onLeave,
|
|
||||||
[StrStatus.PENDING]: {
|
|
||||||
active: StrStatus.ACTIVE,
|
|
||||||
delete: StrStatus.DELETED,
|
|
||||||
},
|
|
||||||
[StrStatus.ACTIVE]: {
|
|
||||||
toDraft: StrStatus.PENDING,
|
|
||||||
archive: StrStatus.ARCHIVED,
|
|
||||||
doNothing: StrStatus.ACTIVE,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
objectStrFSM = new StateMachine(StrStatus.PENDING, {
|
|
||||||
onEnter,
|
|
||||||
onLeave,
|
|
||||||
[StrStatus.PENDING]: {
|
|
||||||
active: {
|
|
||||||
state: StrStatus.ACTIVE,
|
|
||||||
onBeforeChange,
|
|
||||||
onChange,
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
state: StrStatus.DELETED,
|
|
||||||
onBeforeChange,
|
|
||||||
onChange
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[StrStatus.ACTIVE]: {
|
|
||||||
toDraft: {
|
|
||||||
state: StrStatus.PENDING,
|
|
||||||
},
|
|
||||||
archive: {
|
|
||||||
state: StrStatus.ARCHIVED,
|
|
||||||
},
|
|
||||||
doNothing: {
|
|
||||||
state: StrStatus.ACTIVE,
|
|
||||||
onBeforeChange,
|
|
||||||
onChange,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// @ts-ignore
|
|
||||||
simpleIntFSM._currentState = IntStatus.PENDING;
|
|
||||||
// @ts-ignore
|
|
||||||
objectIntFSM._currentState = IntStatus.PENDING;
|
|
||||||
// @ts-ignore
|
|
||||||
simpleStrFSM._currentState = StrStatus.PENDING;
|
|
||||||
// @ts-ignore
|
|
||||||
objectStrFSM._currentState = StrStatus.PENDING;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('::new', () => {
|
|
||||||
it('should init fsm model successfully', () => {
|
|
||||||
|
|
||||||
const simpleIntFSM = new StateMachine(IntStatus.PENDING, {
|
|
||||||
[IntStatus.PENDING]: {
|
|
||||||
active: IntStatus.ACTIVE,
|
|
||||||
delete: IntStatus.DELETED,
|
|
||||||
},
|
|
||||||
[IntStatus.ACTIVE]: {
|
|
||||||
toDraft: IntStatus.PENDING,
|
|
||||||
archive: IntStatus.ARCHIVED,
|
|
||||||
doNothing: IntStatus.ACTIVE,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(simpleIntFSM).toBeDefined();
|
|
||||||
expect(simpleIntFSM.getCurrentState()).toBe(IntStatus.PENDING);
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('.getCurrentState', () => {
|
|
||||||
describe('<IntStatus>', () => {
|
|
||||||
it('should return initial int state for simple fsm model', () => {
|
|
||||||
expect(simpleIntFSM.getCurrentState()).toBe(IntStatus.PENDING);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return changed int state after action for simple fsm model', async () => {
|
|
||||||
await simpleIntFSM.active();
|
|
||||||
expect(simpleIntFSM.getCurrentState()).toBe(IntStatus.ACTIVE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return initial int state after action for simple fsm model if initial states equals next state', async () => {
|
|
||||||
await simpleIntFSM.doNothing();
|
|
||||||
expect(simpleIntFSM.getCurrentState()).toBe(IntStatus.PENDING);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should return initial int state for object fsm model', () => {
|
|
||||||
expect(objectIntFSM.getCurrentState()).toBe(IntStatus.PENDING);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return changed int state after action for object fsm model', async () => {
|
|
||||||
await objectIntFSM.active();
|
|
||||||
expect(objectIntFSM.getCurrentState()).toBe(IntStatus.ACTIVE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return initial int state after action for object fsm model if initial state equals next state', async () => {
|
|
||||||
await objectIntFSM.doNothing();
|
|
||||||
expect(objectIntFSM.getCurrentState()).toBe(IntStatus.PENDING);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('<StrStatus>', () => {
|
|
||||||
it('should return initial str state for simple fsm model', () => {
|
|
||||||
expect(simpleStrFSM.getCurrentState()).toBe(StrStatus.PENDING);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return changed str state after action for simple fsm model', async () => {
|
|
||||||
await simpleStrFSM.active();
|
|
||||||
expect(simpleStrFSM.getCurrentState()).toBe(StrStatus.ACTIVE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return initial str state after action for simple fsm model if initial states equals next state', async () => {
|
|
||||||
await simpleStrFSM.doNothing();
|
|
||||||
expect(simpleStrFSM.getCurrentState()).toBe(StrStatus.PENDING);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should return initial str state for object fsm model', () => {
|
|
||||||
expect(objectStrFSM.getCurrentState()).toBe(StrStatus.PENDING);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return changed str state after action for object fsm model', async () => {
|
|
||||||
await objectStrFSM.active();
|
|
||||||
expect(objectStrFSM.getCurrentState()).toBe(StrStatus.ACTIVE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return initial str state after action for object fsm model if initial state equals next state', async () => {
|
|
||||||
await objectStrFSM.doNothing();
|
|
||||||
expect(objectStrFSM.getCurrentState()).toBe(StrStatus.PENDING);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('.can', () => {
|
|
||||||
describe('<IntStatus>', () => {
|
|
||||||
it('should return true for simple fsm model', () => {
|
|
||||||
expect(simpleIntFSM.can('active')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for simple fsm model if check undefined action', () => {
|
|
||||||
expect(simpleIntFSM.can('archive')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after action for simple fsm model', async () => {
|
|
||||||
await simpleIntFSM.active();
|
|
||||||
expect(simpleIntFSM.can('archive')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after action for simple fsm model if check undefined action', async () => {
|
|
||||||
await simpleIntFSM.active();
|
|
||||||
expect(simpleIntFSM.can('active')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if config for state is not defined in simple fsm model', async () => {
|
|
||||||
await simpleIntFSM.delete();
|
|
||||||
expect(simpleIntFSM.can('active')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should return true for object fsm model', () => {
|
|
||||||
expect(objectIntFSM.can('active')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for object fsm model if check undefined action', () => {
|
|
||||||
expect(objectIntFSM.can('archive')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after action for object fsm model', async () => {
|
|
||||||
await objectIntFSM.active();
|
|
||||||
expect(objectIntFSM.can('archive')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after action for object fsm model if check undefined action', async () => {
|
|
||||||
await objectIntFSM.active();
|
|
||||||
expect(objectIntFSM.can('active')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if config for state is not defined in object fsm model', async () => {
|
|
||||||
await objectIntFSM.delete();
|
|
||||||
expect(objectIntFSM.can('active')).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('<StrStatus>', () => {
|
|
||||||
it('should return true for simple fsm model', () => {
|
|
||||||
expect(simpleStrFSM.can('active')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for simple fsm model if check undefined action', () => {
|
|
||||||
expect(simpleStrFSM.can('archive')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after action for simple fsm model', async () => {
|
|
||||||
await simpleStrFSM.active();
|
|
||||||
expect(simpleStrFSM.can('archive')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after action for simple fsm model if check undefined action', async () => {
|
|
||||||
await simpleStrFSM.active();
|
|
||||||
expect(simpleStrFSM.can('active')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if config for state is not defined in simple fsm model', async () => {
|
|
||||||
await simpleStrFSM.delete();
|
|
||||||
expect(simpleStrFSM.can('active')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should return true for object fsm model', () => {
|
|
||||||
expect(objectStrFSM.can('active')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for object fsm model if check undefined action', () => {
|
|
||||||
expect(objectStrFSM.can('archive')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after action for object fsm model', async () => {
|
|
||||||
await objectStrFSM.active();
|
|
||||||
expect(objectStrFSM.can('archive')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after action for object fsm model if check undefined action', async () => {
|
|
||||||
await objectStrFSM.active();
|
|
||||||
expect(objectStrFSM.can('active')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if config for state is not defined in object fsm model', async () => {
|
|
||||||
await objectStrFSM.delete();
|
|
||||||
expect(objectStrFSM.can('active')).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
describe('.canToState', () => {
|
|
||||||
describe('<IntStatus>', () => {
|
|
||||||
it('should return true for simple fsm model', () => {
|
|
||||||
expect(simpleIntFSM.canToState(IntStatus.ACTIVE)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for simple fsm model if check undefined state', () => {
|
|
||||||
expect(simpleIntFSM.canToState(IntStatus.ARCHIVED)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after action for simple fsm model', async () => {
|
|
||||||
await simpleIntFSM.active();
|
|
||||||
expect(simpleIntFSM.canToState(IntStatus.ARCHIVED)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after action for simple fsm model if check undefined state', async () => {
|
|
||||||
await simpleIntFSM.active();
|
|
||||||
expect(simpleIntFSM.canToState(IntStatus.DELETED)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if config for state is not defined in simple fsm model', async () => {
|
|
||||||
await simpleIntFSM.delete();
|
|
||||||
expect(simpleIntFSM.canToState(IntStatus.ACTIVE)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should return true for object fsm model', () => {
|
|
||||||
expect(objectIntFSM.canToState(IntStatus.ACTIVE)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for object fsm model if check undefined action', () => {
|
|
||||||
expect(objectIntFSM.canToState(IntStatus.ARCHIVED)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after action for object fsm model', async () => {
|
|
||||||
await objectIntFSM.active();
|
|
||||||
expect(objectIntFSM.canToState(IntStatus.ARCHIVED)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after action for object fsm model if check undefined state', async () => {
|
|
||||||
await objectIntFSM.active();
|
|
||||||
expect(objectIntFSM.canToState(IntStatus.DELETED)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if config for state is not defined in simple fsm model', async () => {
|
|
||||||
await simpleIntFSM.delete();
|
|
||||||
expect(simpleIntFSM.canToState(IntStatus.ACTIVE)).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('<StrStatus>', () => {
|
|
||||||
it('should return true for simple fsm model', () => {
|
|
||||||
expect(simpleStrFSM.canToState(StrStatus.ACTIVE)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for simple fsm model if check undefined action', () => {
|
|
||||||
expect(simpleStrFSM.canToState(StrStatus.ARCHIVED)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after action for simple fsm model', async () => {
|
|
||||||
await simpleStrFSM.active();
|
|
||||||
expect(simpleStrFSM.canToState(StrStatus.ARCHIVED)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after action for simple fsm model if check undefined state', async () => {
|
|
||||||
await simpleStrFSM.active();
|
|
||||||
expect(simpleStrFSM.canToState(StrStatus.DELETED)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if config for state is not defined in simple fsm model', async () => {
|
|
||||||
await simpleStrFSM.delete();
|
|
||||||
expect(simpleStrFSM.canToState(StrStatus.ACTIVE)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should return true for object fsm model', () => {
|
|
||||||
expect(objectStrFSM.canToState(StrStatus.ACTIVE)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for object fsm model if check undefined state', () => {
|
|
||||||
expect(objectStrFSM.canToState(StrStatus.ARCHIVED)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after action for object fsm model', async () => {
|
|
||||||
await objectStrFSM.active();
|
|
||||||
expect(objectStrFSM.canToState(StrStatus.ARCHIVED)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after action for object fsm model if check undefined state', async () => {
|
|
||||||
await objectStrFSM.active();
|
|
||||||
expect(objectStrFSM.canToState(StrStatus.DELETED)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if config for state is not defined in simple fsm model', async () => {
|
|
||||||
await simpleStrFSM.delete();
|
|
||||||
expect(simpleStrFSM.canToState(StrStatus.ACTIVE)).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
|
@ -18,10 +18,9 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"rootDir": "src",
|
"outDir": "dest"
|
||||||
"outDir": "target"
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"fsm.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue