From 094b9216dace7962c64d432fe1555e1e8ecc0116 Mon Sep 17 00:00:00 2001 From: Dmitriy Pleshevskiy Date: Fri, 20 Aug 2021 01:32:01 +0300 Subject: [PATCH] refac: impl more convenient design --- .github/workflows/ci.yml | 21 ++ .gitignore | 92 +------- .travis.yml | 18 -- .vscode/settings.json | 4 + README.md | 58 +++-- dest/fsm.d.ts | 46 ++++ dest/fsm.js | 144 +++++++++++++ fsm.test.ts | 162 ++++++++++++++ fsm.ts | 217 +++++++++++++++++++ jest.config.js | 8 - makefile | 16 ++ package.json | 20 +- src/index.ts | 114 ---------- tests/statemachine.spec.ts | 419 ------------------------------------- tsconfig.json | 5 +- 15 files changed, 655 insertions(+), 689 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml create mode 100644 .vscode/settings.json create mode 100644 dest/fsm.d.ts create mode 100644 dest/fsm.js create mode 100644 fsm.test.ts create mode 100644 fsm.ts delete mode 100644 jest.config.js create mode 100644 makefile delete mode 100644 src/index.ts delete mode 100644 tests/statemachine.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ffafa8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4f530c3..b4fe6ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,92 +1,4 @@ -# Created by https://www.gitignore.io/api/vim,node -# Edit at https://www.gitignore.io/?templates=vim,node +cov_profile/ -### 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/ -jspm_packages/ - -# 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/ +package-lock.json \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d53d997..0000000 --- a/.travis.yml +++ /dev/null @@ -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 - diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..eaf16e4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "deno.enable": true, + "deno.lint": true +} \ No newline at end of file diff --git a/README.md b/README.md index d1d8c4a..2ae8be6 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,52 @@ # 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) [![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 `npm install --save it-fsm` - - ### Usage -```javascript -import { StateMachine } from 'it-fsm'; +```ts +import { StateMachineBuilder } from "it-fsm"; -const fsm = new StateMachine('TODO', { - TODO: { - complete: 'COMPLETE' - } -}) - - -if (fsm.can('complete')) { - fsm.complete().then(() => { - - }) +enum ProjectStatus { + Pending = "pending", + Active = "active", + Completed = "completed", + Archived = "archive", } -// or -if (fsm.canToState('COMPLETE')) { - fsm.complete().then(() => { - - }); + +const smbProject = new StateMachineBuilder() + .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(); + ``` diff --git a/dest/fsm.d.ts b/dest/fsm.d.ts new file mode 100644 index 0000000..47ba9a3 --- /dev/null +++ b/dest/fsm.d.ts @@ -0,0 +1,46 @@ +declare type StateTransitions = WeakMap, WeakSet>>; +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 { + [_states]: Map>; + [_stateTransitions]: Array<[string, Array]> | undefined; + constructor(); + withTransitions(transitions: Array<[string, Array]>): this; + withStates(names: string[], actions?: Actions): this; + withState(name: string, actions?: Actions): this; + private addStateUnchecked; + build(currentStateName: string): StateMachine; + private buildStates; + private buildTransitions; +} +export declare class StateMachine { + [_states]: State[]; + [_stateTransitions]: StateTransitions; + [_prevState]: State | undefined; + [_currState]: State; + constructor(states: State[], transitions: StateTransitions, currentState: State); + changeState(sourceState: string | State, context?: Context): Promise; + hasTransition(to: string | State): boolean; + allowedTransitionStates(): State[]; +} +declare const _stateName: unique symbol; +declare const _stateActions: unique symbol; +interface Actions { + beforeExit?(fromState: State, toState: State, context: Context): boolean; + onEntry?(fromState: State, toState: State, context: Context): Promise | void; +} +export declare class State { + [_stateActions]: Actions; + [_stateName]: string; + get name(): string; + constructor(name: string, actions?: Actions); + entry(fromState: State, toState: State, context: Context): Promise; + exit(fromState: State, toState: State, context: Context): boolean; + toString(): string; + toJSON(): string; +} +export declare class FsmError extends Error { +} +export {}; diff --git a/dest/fsm.js b/dest/fsm.js new file mode 100644 index 0000000..369f4ef --- /dev/null +++ b/dest/fsm.js @@ -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; diff --git a/fsm.test.ts b/fsm.test.ts new file mode 100644 index 0000000..7ecfa60 --- /dev/null +++ b/fsm.test.ts @@ -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}"`, + ); +}); diff --git a/fsm.ts b/fsm.ts new file mode 100644 index 0000000..3935be0 --- /dev/null +++ b/fsm.ts @@ -0,0 +1,217 @@ +type StateTransitions = WeakMap< + State, + WeakSet> +>; + +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 { + [_states]: Map>; + + [_stateTransitions]: Array<[string, Array]> | undefined; + + constructor() { + this[_states] = new Map(); + } + + withTransitions(transitions: Array<[string, Array]>) { + this[_stateTransitions] = transitions; + return this; + } + + withStates(names: string[], actions?: Actions) { + names.forEach((name) => this.addStateUnchecked(name, actions)); + return this; + } + + withState(name: string, actions?: Actions) { + this.addStateUnchecked(name, actions); + return this; + } + + private addStateUnchecked(name: string, actions?: Actions) { + 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[]) { + 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 { + [_states]: State[]; + + [_stateTransitions]: StateTransitions; + + [_prevState]: State | undefined; + + [_currState]: State; + + constructor( + states: State[], + transitions: StateTransitions, + currentState: State, + ) { + this[_states] = states; + this[_stateTransitions] = transitions; + this[_currState] = currentState; + } + + async changeState(sourceState: string | State, 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) { + 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 { + beforeExit?( + fromState: State, + toState: State, + context: Context, + ): boolean; + onEntry?( + fromState: State, + toState: State, + context: Context, + ): Promise | void; +} + +export class State { + [_stateActions]: Actions; + + [_stateName]: string; + + get name(): string { + return this[_stateName]; + } + + constructor(name: string, actions: Actions = {}) { + this[_stateName] = name; + this[_stateActions] = actions; + } + + async entry( + fromState: State, + toState: State, + context: Context, + ) { + const action = this[_stateActions].onEntry; + if (isFn(action)) { + await action(fromState, toState, context); + } + } + + exit(fromState: State, toState: State, 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(states: State[], name: string) { + return states.find((state) => state.name === name); +} + +function validStateFromName(states: State[], name: string) { + return validState(stateFromName(states, name)); +} + +function normalizeState( + states: State[], + state: string | State, +): State | undefined { + return isStr(state) ? stateFromName(states, state) : state; +} + +function validState(val: unknown): State { + if (!isState(val)) { + throw new TypeError("an instance of State class is expected"); + } + return val; +} + +function isState(val: unknown): val is State { + return val instanceof State; +} + +function hasTransition( + transitions: StateTransitions, + from: State, + to: State, +) { + 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 {} diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 06f5c53..0000000 --- a/jest.config.js +++ /dev/null @@ -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'], -}; diff --git a/makefile b/makefile new file mode 100644 index 0000000..147506a --- /dev/null +++ b/makefile @@ -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 diff --git a/package.json b/package.json index 531ce67..e436978 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,15 @@ { "name": "it-fsm", - "version": "1.0.8", + "version": "2.0.0", "description": "Simple finite state machine for nodejs", - "main": "./target/index.js", - "types": "./target/index.d.ts", + "main": "./dest/fsm.js", + "types": "./dest/fsm.d.ts", "readme": "README.md", "files": [ "target" ], "scripts": { - "test": "jest", - "prepublishOnly": "rm -rf ./target && tsc", - "report-coverage": "cat coverage/lcov.info | coveralls" + "prepublishOnly": "rm -rf ./dest && tsc" }, "repository": { "type": "git", @@ -32,14 +30,6 @@ }, "homepage": "https://github.com/icetemple/npm-it-fsm#readme", "devDependencies": { - "@types/jest": "^26.0.15", - "@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" + "typescript": "^4.3.5" } } diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 3edc8bd..0000000 --- a/src/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -import cloneDeep from 'lodash.clonedeep'; - -export type Payload = Record -export type StateType = string | number -export type ActionConfigMap = Record -export type ActionEvent = (event: string, fromState: StateType, toState: StateType, - payload: Payload) => Promise - - -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 any>>(); - private _statesByState = new Map(); - - 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 => { - 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) ?? {}); - } -} diff --git a/tests/statemachine.spec.ts b/tests/statemachine.spec.ts deleted file mode 100644 index f82802a..0000000 --- a/tests/statemachine.spec.ts +++ /dev/null @@ -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('', () => { - 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('', () => { - 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('', () => { - 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('', () => { - 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('', () => { - 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('', () => { - 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(); - }); - }); - }) - - -}); diff --git a/tsconfig.json b/tsconfig.json index d14e20f..fa0192b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,10 +18,9 @@ "strict": true, "strictNullChecks": true, "esModuleInterop": true, - "rootDir": "src", - "outDir": "target" + "outDir": "dest" }, "include": [ - "src" + "fsm.ts" ] }