├── .vscode └── settings.json ├── .npmignore ├── .github ├── workflows │ ├── jest.yml │ ├── njsscan.yml │ ├── eslint.yml │ └── semgrep.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── SECURITY.md ├── tsconfig.json ├── LICENSE.md ├── .gitignore ├── package.json ├── src ├── __test__ │ ├── toMermaid.test.ts │ ├── syncStateMachine.test.ts │ └── stateMachine.test.ts └── stateMachine.ts ├── .eslintrc.json ├── README.md └── jest.config.js /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sarif-viewer.connectToGithubCodeScanning": "on" 3 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /config 3 | /coverage 4 | /src 5 | /.vscode 6 | .travis.yml 7 | /dist/__test__ 8 | 9 | *.env 10 | *.config.js 11 | *.log 12 | -------------------------------------------------------------------------------- /.github/workflows/jest.yml: -------------------------------------------------------------------------------- 1 | name: Jest 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | jest: 11 | name: Run Jest tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Run Jest 19 | run: npm run test 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | < 5.0 | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Open a new issue and add "security" to the title. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "./dist", 6 | "sourceMap": true, 7 | "esModuleInterop": true, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true, 11 | "skipLibCheck": true, 12 | "resolveJsonModule": true, 13 | "noImplicitAny": true, 14 | "declaration": true, 15 | }, 16 | "include": [ 17 | "src/**/*", 18 | "*.config.js", 19 | ] 20 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 eram 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | build 35 | dist/ 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # build of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build build 63 | .next 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-fsm", 3 | "version": "1.6.0", 4 | "description": "finite state machine with async callbacks", 5 | "main": "./dist/stateMachine.js", 6 | "typings": "./dist/stateMachine.d.ts", 7 | "keywords": [ 8 | "fsm", 9 | "state-machine", 10 | "promise", 11 | "typescript", 12 | "generics" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/eram/typescript-fsm.git" 17 | }, 18 | "engine": { 19 | "node": ">=20" 20 | }, 21 | "author": "eram@weblegions.com", 22 | "license": "Apache 2.0", 23 | "bugs": { 24 | "url": "https://github.com/eram/typescript-fsm/issues" 25 | }, 26 | "homepage": "https://github.com/eram/typescript-fsm#readme", 27 | "scripts": { 28 | "build": "npm run lint && tsc --pretty", 29 | "test": "eslint --color src/**/*.ts && node --test", 30 | "clean": "rimraf coverage *.log logs dist coverage", 31 | "lint": "eslint --color --fix src/**/*.ts && echo Lint complete.", 32 | "watch": "node --watch --test" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^22.14.0", 36 | "@typescript-eslint/eslint-plugin": "^6.12.0", 37 | "@typescript-eslint/parser": "^6.12.0", 38 | "eslint": "^8.54.0", 39 | "eslint-config-airbnb-typescript": "^17.1.0", 40 | "eslint-plugin-filenames": "^1.3.2", 41 | "eslint-plugin-import": "^2.29.0", 42 | "eslint-plugin-no-null": "^1.0.2", 43 | "eslint-plugin-sonarjs": "^0.23.0", 44 | "rimraf": "^3.0.2", 45 | "typescript": "^5.1.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/njsscan.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow integrates njsscan with GitHub's Code Scanning feature 7 | # nodejsscan is a static security code scanner that finds insecure code patterns in your Node.js applications 8 | 9 | name: njsscan sarif 10 | 11 | on: 12 | push: 13 | branches: [ "master" ] 14 | pull_request: 15 | # The branches below must be a subset of the branches above 16 | branches: [ "master" ] 17 | schedule: 18 | - cron: '22 16 * * 6' 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | njsscan: 25 | permissions: 26 | contents: read # for actions/checkout to fetch code 27 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | runs-on: ubuntu-latest 30 | name: njsscan code scanning 31 | steps: 32 | - name: Checkout the code 33 | uses: actions/checkout@v3 34 | - name: nodejsscan scan 35 | id: njsscan 36 | uses: ajinabraham/njsscan-action@7237412fdd36af517e2745077cedbf9d6900d711 37 | with: 38 | args: '. --sarif --output results.sarif || true' 39 | - name: Upload njsscan report 40 | uses: github/codeql-action/upload-sarif@v2 41 | with: 42 | sarif_file: results.sarif 43 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # ESLint is a tool for identifying and reporting on patterns 6 | # found in ECMAScript/JavaScript code. 7 | # More details at https://github.com/eslint/eslint 8 | # and https://eslint.org 9 | 10 | name: ESLint 11 | 12 | on: 13 | push: 14 | branches: [ "master" ] 15 | pull_request: 16 | # The branches below must be a subset of the branches above 17 | branches: [ "master" ] 18 | schedule: 19 | - cron: '21 8 * * 5' 20 | 21 | jobs: 22 | eslint: 23 | name: Run eslint scanning 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | security-events: write 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Install ESLint 34 | run: | 35 | npm install eslint@8.10.0 36 | npm install @microsoft/eslint-formatter-sarif@2.1.7 37 | 38 | - name: Run ESLint 39 | run: npx eslint . 40 | --config .eslintrc.json 41 | --ext .js,.jsx,.ts,.tsx 42 | --format @microsoft/eslint-formatter-sarif 43 | --output-file eslint-results.sarif 44 | continue-on-error: true 45 | 46 | - name: Upload analysis results to GitHub 47 | uses: github/codeql-action/upload-sarif@v2 48 | with: 49 | sarif_file: eslint-results.sarif 50 | wait-for-processing: true 51 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow file requires a free account on Semgrep.dev to 7 | # manage rules, file ignores, notifications, and more. 8 | # 9 | # See https://semgrep.dev/docs 10 | 11 | name: Semgrep 12 | 13 | on: 14 | push: 15 | branches: [ "master" ] 16 | pull_request: 17 | # The branches below must be a subset of the branches above 18 | branches: [ "master" ] 19 | schedule: 20 | - cron: '21 10 * * 2' 21 | 22 | permissions: 23 | contents: read 24 | 25 | jobs: 26 | semgrep: 27 | permissions: 28 | contents: read # for actions/checkout to fetch code 29 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 30 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 31 | name: Scan 32 | runs-on: ubuntu-latest 33 | steps: 34 | # Checkout project source 35 | - uses: actions/checkout@v3 36 | 37 | # Scan code using project's configuration on https://semgrep.dev/manage 38 | - uses: returntocorp/semgrep-action@fcd5ab7459e8d91cb1777481980d1b18b4fc6735 39 | with: 40 | publishToken: ${{ secrets.SEMGREP_APP_TOKEN }} 41 | publishDeployment: ${{ secrets.SEMGREP_DEPLOYMENT_ID }} 42 | generateSarif: "1" 43 | 44 | # Upload SARIF file generated in previous step 45 | - name: Upload SARIF file 46 | uses: github/codeql-action/upload-sarif@v2 47 | with: 48 | sarif_file: semgrep.sarif 49 | if: always() 50 | -------------------------------------------------------------------------------- /src/__test__/toMermaid.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { describe, test } from "node:test"; 3 | import assert from "node:assert/strict"; 4 | import { t, StateMachine } from "../stateMachine"; 5 | 6 | export enum States { 7 | closing = "closing", 8 | closed = "closed", 9 | opening = "opening", 10 | opened = "opened", 11 | breaking = "breaking", 12 | broken = "broken", 13 | locking = "locking", 14 | locked = "locked", 15 | unlocking = "unlocking", 16 | } 17 | 18 | export enum Events { 19 | open = "open", 20 | openComplete = "openComplete", 21 | close = "close", 22 | closeComplete = "closeComplete", 23 | break = "break", 24 | breakComplete = "breakComplete", 25 | lock = "lock", 26 | lockComplete = "lockComplete", 27 | unlock = "unlock", 28 | unlockComplete = "unlockComplete", 29 | unlockFailed = "unlockFailed", 30 | } 31 | 32 | export class StringyDoor extends StateMachine { 33 | private readonly _id = `Door${(Math.floor(Math.random() * 10000))}`; 34 | private readonly _key: number; 35 | 36 | constructor(key = 0, init = States.closed) { 37 | super(init); 38 | this._key = key; 39 | 40 | const s = States; 41 | const e = Events; 42 | 43 | this.addTransitions([ 44 | t(s.closed, e.open, s.opening), 45 | t(s.opening, e.openComplete, s.opened), 46 | t(s.opened, e.close, s.closing), 47 | t(s.closing, e.closeComplete, s.closed), 48 | t(s.opened, e.break, s.breaking), 49 | t(s.closed, e.break, s.breaking), 50 | t(s.closed, e.lock, s.locking), 51 | t(s.locking, e.lockComplete, s.locked), 52 | t(s.locked, e.unlock, s.unlocking ), 53 | t(s.unlocking, e.unlockComplete, s.closed), 54 | t(s.unlocking, e.unlockFailed, s.locked), 55 | t(s.breaking, e.breakComplete, s.broken), 56 | ]); 57 | } 58 | } 59 | 60 | describe("StateMachine#toMermaid()", () => { 61 | const door = new StringyDoor(); 62 | 63 | test("generate state diagram", async () => { 64 | const mmd = door.toMermaid(); 65 | const lines = mmd.split("\n"); 66 | 67 | assert.equal(lines[0], "stateDiagram-v2"); 68 | assert.equal(lines[1], " [*] --> closed"); 69 | assert.equal(lines[2], " closed --> opening: open"); 70 | assert.equal(lines[3], " opening --> opened: openComplete"); 71 | assert.equal(lines[4], " opened --> closing: close"); 72 | assert.equal(lines[5], " closing --> closed: closeComplete"); 73 | assert.equal(lines[6], " opened --> breaking: break"); 74 | assert.equal(lines[7], " closed --> breaking: break"); 75 | assert.equal(lines[8], " closed --> locking: lock"); 76 | assert.equal(lines[9], " locking --> locked: lockComplete"); 77 | assert.equal(lines[10], " locked --> unlocking: unlock"); 78 | assert.equal(lines[11], " unlocking --> closed: unlockComplete"); 79 | assert.equal(lines[12], " unlocking --> locked: unlockFailed"); 80 | assert.equal(lines[13], " breaking --> broken: breakComplete"); 81 | assert.equal(lines[14], " broken --> [*]"); 82 | }); 83 | 84 | test("generate state diagram with title", async () => { 85 | const mmd = door.toMermaid("The Door Machine"); 86 | const lines = mmd.split("\n"); 87 | 88 | assert.equal(lines[0], "---"); 89 | assert.equal(lines[1], "title: The Door Machine"); 90 | assert.equal(lines[2], "---"); 91 | }); 92 | }); -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-typescript/base", 4 | "plugin:sonarjs/recommended" 5 | ], 6 | "plugins": [ 7 | "no-null", 8 | "@typescript-eslint", 9 | "filenames", 10 | "sonarjs" 11 | ], 12 | "env": { 13 | "node": true 14 | }, 15 | "parserOptions": { 16 | "ecmaVersion": 2020, 17 | "sourceType": "module", 18 | "project": "./tsconfig.json" 19 | }, 20 | "rules": { 21 | "import/no-extraneous-dependencies": "off", 22 | "import/extensions": "off", 23 | "no-null/no-null": "error", 24 | "sonarjs/cognitive-complexity": ["error", 30], 25 | "sonarjs/no-unused-collection": "off", 26 | "sonarjs/prefer-immediate-return": "off", 27 | "filenames/match-regex": [2, "^[a-z]+.*[a-z]*$"], 28 | "filenames/match-exported": 2, 29 | "max-classes-per-file": "off", 30 | "max-len": ["error", { "code": 125, "ignoreUrls": true, "ignoreTemplateLiterals": true, "ignoreStrings": true }], 31 | "import/prefer-default-export": "off", 32 | "no-underscore-dangle": "off", 33 | "function-paren-newline": "off", 34 | "class-methods-use-this": "off", 35 | "no-param-reassign": "off", 36 | "no-plusplus": "off", 37 | "no-multi-assign": "off", 38 | "prefer-destructuring": "off", 39 | "no-console": "off", 40 | "padded-blocks": "off", 41 | "no-void": ["error", { "allowAsStatement": true }], 42 | "arrow-parens": "off", 43 | "no-cond-assign": "off", 44 | "no-multiple-empty-lines": ["error", { "max": 2 }], 45 | "no-multi-spaces": ["error", { "ignoreEOLComments": true, "exceptions": { "Property": false } }], 46 | "key-spacing": "off", 47 | "no-nested-ternary": "off", 48 | "radix": "off", 49 | "object-curly-newline":"off", 50 | "object-property-newline":"off", 51 | "no-spaced-func": "off", 52 | "linebreak-style": "off", 53 | "no-constant-condition": "off", 54 | "@typescript-eslint/no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }], 55 | "quotes": "off", 56 | "@typescript-eslint/quotes": ["error", "double"], 57 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 58 | "@typescript-eslint/comma-dangle": ["error", "always-multiline"], 59 | "@typescript-eslint/no-explicit-any": "error", 60 | "lines-between-class-members": "off", 61 | "@typescript-eslint/lines-between-class-members": "off", 62 | "@typescript-eslint/no-floating-promises": "error", 63 | "@typescript-eslint/naming-convention":["error", 64 | { 65 | "selector": "default", 66 | "format": ["camelCase", "UPPER_CASE"], 67 | "leadingUnderscore": "allow", 68 | "trailingUnderscore": "allow" 69 | }, 70 | { 71 | "selector": "variable", 72 | "format": ["camelCase", "UPPER_CASE"], 73 | "leadingUnderscore": "allow", 74 | "trailingUnderscore": "allow" 75 | }, 76 | { 77 | "selector": "parameter", 78 | "format": ["camelCase"], 79 | "leadingUnderscore": "allow", 80 | "trailingUnderscore": "allow" 81 | }, 82 | { 83 | "selector": "memberLike", 84 | "modifiers": ["private"], 85 | "format": ["camelCase"], 86 | "leadingUnderscore": "require" 87 | }, 88 | { 89 | "selector": "typeLike", 90 | "format": ["PascalCase"] 91 | }, 92 | { 93 | "selector": "interface", 94 | "format": ["PascalCase"], 95 | "custom": { 96 | "regex": "^I[A-Z]", 97 | "match": true 98 | } 99 | } 100 | ] 101 | } 102 | } -------------------------------------------------------------------------------- /src/__test__/syncStateMachine.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { describe, test } from "node:test"; 3 | import assert from "node:assert/strict"; 4 | import { t, SyncStateMachine } from "../stateMachine"; 5 | 6 | /*** 7 | * SyncStateMachine 8 | */ 9 | describe("SyncStateMachine tests", () => { 10 | // Define the States and Events enums just for the sync tests 11 | enum States { CLOSED = 1, OPENED, BROKEN, LOCKED } 12 | enum Events { OPEN = 100, CLOSE, BREAK, LOCK, UNLOCK } 13 | 14 | class SyncDoor extends SyncStateMachine { 15 | private readonly _id = `SyncDoor${(Math.floor(Math.random() * 10000))}`; 16 | private readonly _key: number; 17 | private _callbackCalled = false; 18 | 19 | constructor(key = 0, init = States.CLOSED) { 20 | super(init); 21 | this._key = key; 22 | 23 | const s = States; 24 | const e = Events; 25 | 26 | /* eslint-disable no-multi-spaces */ 27 | this.addTransitions([ 28 | // fromState event toState callback 29 | t(s.CLOSED, e.OPEN, s.OPENED, this.#onOpen), 30 | t(s.OPENED, e.CLOSE, s.CLOSED, this.#onClose), 31 | t(s.OPENED, e.BREAK, s.BROKEN, this.#onBreak), 32 | t(s.CLOSED, e.BREAK, s.BROKEN, this.#onBreak), 33 | t(s.CLOSED, e.LOCK, s.LOCKED, this.#onLock), 34 | t(s.LOCKED, e.UNLOCK, s.CLOSED, this.#onUnlock), 35 | t(s.LOCKED, e.BREAK, s.BROKEN, this.#onBreak), 36 | ]); 37 | /* eslint-enable no-multi-spaces */ 38 | } 39 | 40 | // public methods 41 | open = () => this.syncDispatch(Events.OPEN); 42 | close = () => this.syncDispatch(Events.CLOSE); 43 | break = () => this.syncDispatch(Events.BREAK); 44 | lock = () => this.syncDispatch(Events.LOCK); 45 | unlock = (key: number) => this.syncDispatch(Events.UNLOCK, key); 46 | 47 | wasCallbackCalled(): boolean { return this._callbackCalled; } 48 | resetCallbackFlag(): void { this._callbackCalled = false; } 49 | 50 | isBroken(): boolean { return this.isFinal(); } 51 | isOpen(): boolean { return this.getState() === States.OPENED; } 52 | isLocked(): boolean { return this.getState() === States.LOCKED; } 53 | 54 | // transition callbacks 55 | #onOpen(): void { 56 | this._callbackCalled = true; 57 | this.logger.log(`${this._id} onOpen: now ${States[this.getState()]}`); 58 | } 59 | 60 | #onClose(): void { 61 | this._callbackCalled = true; 62 | this.logger.log(`${this._id} onClose: now ${States[this.getState()]}`); 63 | } 64 | 65 | #onBreak(): void { 66 | this._callbackCalled = true; 67 | this.logger.log(`${this._id} onBreak: now ${States[this.getState()]}`); 68 | } 69 | 70 | #onLock(): void { 71 | this._callbackCalled = true; 72 | this.logger.log(`${this._id} onLock: now ${States[this.getState()]}`); 73 | } 74 | 75 | #onUnlock(key: number): void { 76 | this._callbackCalled = true; 77 | this.logger.log(`${this._id} onUnlock with key=${key}...`); 78 | if (key !== this._key) { 79 | throw new Error(`${key} failed to unlock ${this._id}`); 80 | } 81 | } 82 | } 83 | 84 | // ---- TEST CASES ---- 85 | 86 | test("should open a closed door synchronously", () => { 87 | const door = new SyncDoor(); 88 | 89 | assert.ok(!door.isOpen()); 90 | assert.ok(!door.isBroken()); 91 | assert.ok(door.can(Events.OPEN)); 92 | 93 | const result = door.open(); 94 | assert.ok(result); 95 | assert.ok(door.isOpen()); 96 | assert.ok(door.wasCallbackCalled()); 97 | }); 98 | 99 | test("should return false for invalid transitions", () => { 100 | const door = new SyncDoor(undefined, States.OPENED); 101 | assert.ok(!door.can(Events.OPEN)); 102 | 103 | const result = door.open(); 104 | assert.equal(result, false); 105 | assert.ok(door.isOpen()); // state remains unchanged 106 | }); 107 | 108 | test("should break a door synchronously", () => { 109 | const door = new SyncDoor(); 110 | assert.ok(!door.isBroken()); 111 | 112 | const result = door.break(); 113 | assert.ok(result); 114 | assert.ok(door.isBroken()); 115 | }); 116 | 117 | test("should throw on dispatch call", () => { 118 | const door = new SyncDoor(); 119 | assert.throws(() => door.dispatch(Events.OPEN)); 120 | }); 121 | 122 | test("should unlock with correct key", () => { 123 | const key = 12345; 124 | const door = new SyncDoor(key, States.LOCKED); 125 | assert.ok(door.unlock(key)); 126 | assert.ok(!door.isLocked()); 127 | }); 128 | 129 | test("should throw with incorrect key", () => { 130 | const key = 12345; 131 | const door = new SyncDoor(key, States.LOCKED); 132 | assert.ok(door.isLocked()); 133 | assert.throws(() => door.unlock(key + 3)); 134 | assert.ok(door.isLocked()); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript State Machine (typescript-fsm) 2 | 3 | [![Build Status](https://app.travis-ci.com/eram/typescript-fsm.svg?branch=master)](https://app.travis-ci.com/github/eram/typescript-fsm) 4 | [![npm package](https://img.shields.io/npm/v/typescript-fsm.svg?logo=nodedotjs&color=00a400)](https://www.npmjs.com/package/typescript-fsm) 5 | It's already here! 6 | 7 | Finite state machines are useful for modeling complicated flows and keeping track of state. TS-FSM is a strongly typed finite state machine for TypeScript that is using promises for async operations. 8 | I'm using this state-machine as a simple replacement for Redux in some ReactJs based apps. Example [here](https://github.com/eram/tensorflow-stack-ts/blob/master/client/src/components/server-status-card/StatusCardModel.ts) 9 | 10 | ## Features 11 | 12 | - TypeScript native (compiles to ES6) 13 | - Only 1 KB (minified) and zero dependencies!!! 14 | - Hooks after state change - - async or sync callbacks 15 | - Promises are used for async transition completion 16 | - Generics for states and events types 17 | - Simple tabular state machine definition 18 | - Use with NodeJS or JS client 19 | 20 | ## Get it 21 | 22 | ```script 23 | git clone https://github.com/eram/typescript-fsm.git 24 | cd typescript-fsm 25 | npm install 26 | npm test 27 | ``` 28 | 29 | ## Use it 30 | 31 | ```script 32 | npm install typescript-fsm 33 | ``` 34 | 35 | ## Basic Example 36 | 37 | I'm modeling a "door" here. One can open the door, close it or break it. Each action is done async: when you open it goes into opening state and then resolved to opened state etc. Once broken, it reaches a final state. 38 | 39 | Door state machine 40 | 41 | Let's code it in Typescript! Note that the same code can be run in Javascript, just remove the generics. 42 | 43 | ```typescript 44 | import { t, StateMachine } from "typescript-fsm"; 45 | 46 | // these are the states and events for the door 47 | enum States { closing = 0, closed, opening, opened, broken }; 48 | enum Events { open = 100, openComplete, close, closeComplete, break }; 49 | 50 | // lets define the transitions that will govern the state-machine 51 | const transitions = [ 52 | /* fromState event toState callback */ 53 | t(States.closed, Events.open, States.opening, onOpen), 54 | t(States.opening, Events.openComplete, States.opened, justLog), 55 | t(States.opened, Events.close, States.closing, onClose), 56 | t(States.closing, Events.closeComplete, States.closed, justLog), 57 | t(States.closed, Events.break, States.broken, justLog), 58 | t(States.opened, Events.break, States.broken, justLog), 59 | t(States.opening, Events.break, States.broken, justLog), 60 | t(States.closing, Events.break, States.broken, justLog), 61 | ]; 62 | 63 | // initialize the state machine 64 | const door = new StateMachine( 65 | States.closed, // initial state 66 | transitions, // array of transitions 67 | ); 68 | 69 | // transition callbacks - async functions 70 | async function onOpen() { 71 | console.log("onOpen..."); 72 | return door.dispatch(Events.openComplete); 73 | } 74 | 75 | async function onClose() { 76 | console.log("onClose..."); 77 | return door.dispatch(Events.closeComplete); 78 | } 79 | 80 | // synchronous callbacks are also ok 81 | function justLog() { 82 | console.log(`${States[door.getState()]}`); 83 | } 84 | 85 | // we are ready for action - run a few state-machine steps... 86 | new Promise(async (resolve) => { 87 | 88 | // open the door and wait for it to be opened 89 | await door.dispatch(Events.open); 90 | door.getState(); // => States.opened 91 | 92 | // check if the door can be closed 93 | door.can(Events.close); // => true 94 | 95 | // break the door async 96 | door.dispatch(Events.break).then(() => { 97 | // did we get to a finite state? 98 | door.isFinal(); // => true 99 | }); 100 | 101 | // door is now broken. It cannot be closed... 102 | try { 103 | await door.dispatch(Events.close); 104 | assert("should not get here!"); 105 | } catch (e) { 106 | // we're good 107 | } 108 | 109 | // let the async complete 110 | setTimeout(resolve, 10); 111 | }); 112 | 113 | ``` 114 | 115 | ## Another example 116 | 117 | Check out [the test code](https://github.com/eram/typescript-fsm/blob/master/src/__test__/stateMachine.test.ts) - a class that implements a state machine with method binding, method params and more transitions. 100% coverage here! 118 | 119 | ## Beautiful :-) 120 | 121 | Comments and suggestions are [welcome](https://github.com/eram/typescript-fsm/issues/new). 122 | -------------------------------------------------------------------------------- /src/stateMachine.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * StateMachine.ts 3 | * TypeScript finite state machine class with async transformations using promises. 4 | */ 5 | export type SyncCallback = ((...args: unknown[]) => void); 6 | export type Callback = ((...args: unknown[]) => Promise) | SyncCallback; 7 | 8 | export interface ITransition { 9 | fromState: STATE; 10 | event: EVENT; 11 | toState: STATE; 12 | cb: CALLBACK; 13 | } 14 | 15 | export function t( 16 | fromState: STATE, event: EVENT, toState: STATE, 17 | cb?: CALLBACK): ITransition { 18 | return { fromState, event, toState, cb }; 19 | } 20 | 21 | export type ILogger = Partial & { error(...data: unknown[]): void }; 22 | 23 | /** 24 | * StateMachine 25 | * TypeScript finite state machine class with async transformations. 26 | */ 27 | export class StateMachine< 28 | STATE extends string | number | symbol, 29 | EVENT extends string | number | symbol, 30 | CALLBACK extends Record = Record, 31 | > { 32 | 33 | protected _current: STATE; 34 | 35 | // initialize the state-machine 36 | constructor( 37 | protected init: STATE, 38 | protected transitions: ITransition[] = [], 39 | protected readonly logger: ILogger = console, 40 | ) { 41 | this._current = init; 42 | } 43 | 44 | addTransitions(transitions: ITransition[]): void { 45 | 46 | // bind any unbound method 47 | transitions.forEach((_tran) => { 48 | const tran: ITransition = Object.create(_tran); 49 | if (tran.cb && !tran.cb.name?.startsWith("bound ")) { 50 | tran.cb = tran.cb.bind(this); 51 | } 52 | this.transitions.push(tran); 53 | }); 54 | } 55 | 56 | getState(): STATE { return this._current; } 57 | 58 | can(event: EVENT): boolean { 59 | return this.transitions.some((trans) => (trans.fromState === this._current && trans.event === event)); 60 | } 61 | 62 | getNextState(event: EVENT): STATE | undefined { 63 | const transition = this.transitions.find((tran) => tran.fromState === this._current && tran.event === event); 64 | return transition?.toState; 65 | } 66 | 67 | isFinal(): boolean { 68 | // search for a transition that starts from current state. 69 | // if none is found it's a terminal state. 70 | return this.transitions.every((trans) => (trans.fromState !== this._current)); 71 | } 72 | 73 | protected formatErr(fromState: STATE, event: EVENT) { 74 | return `No transition: from ${String(fromState)} event ${String(event)}`; 75 | } 76 | 77 | // post event async 78 | async dispatch(event: E, ...args: unknown[]): Promise { 79 | return new Promise((resolve, reject) => { 80 | 81 | // delay execution to make it async 82 | setTimeout((me: this) => { 83 | 84 | // find transition 85 | const found = this.transitions.some((tran) => { 86 | if (tran.fromState === me._current && tran.event === event) { 87 | me._current = tran.toState; 88 | if (tran.cb) { 89 | try { 90 | const p = tran.cb(...args); 91 | if (p instanceof Promise) { 92 | p.then(resolve).catch(reject); 93 | } else { 94 | resolve(); 95 | } 96 | } catch (e) { 97 | this.logger.error("Exception in callback", e); 98 | reject(e); 99 | } 100 | } else { 101 | resolve(); 102 | } 103 | return true; 104 | } 105 | return false; 106 | }); 107 | 108 | // no such transition 109 | if (!found) { 110 | const errorMessage = this.formatErr(me._current, event); 111 | this.logger.error(errorMessage); 112 | reject(new Error(errorMessage)); 113 | } 114 | }, 0, this); 115 | }); 116 | } 117 | 118 | /** 119 | * Generate a Mermaid StateDiagram of the current machine. 120 | */ 121 | toMermaid( title?: string ) { 122 | const diagram: string[] = []; 123 | if (title) { 124 | diagram.push("---"); 125 | diagram.push(`title: ${title}`); 126 | diagram.push("---"); 127 | } 128 | diagram.push("stateDiagram-v2"); 129 | diagram.push(` [*] --> ${String(this.init)}`); 130 | 131 | this.transitions.forEach(({ event, fromState, toState }) => { 132 | const from = String(fromState); 133 | const to = String(toState); 134 | const evt = String(event); 135 | diagram.push(` ${from} --> ${to}: ${evt}`); 136 | }); 137 | 138 | // find terminal states 139 | const ts = new Set(); 140 | this.transitions.forEach(({ toState }) => ts.add(toState)); 141 | this.transitions.forEach(({ fromState }) => ts.delete(fromState)); 142 | ts.forEach((state) => diagram.push(` ${String(state)} --> [*]`)); 143 | 144 | return diagram.join("\n"); 145 | } 146 | 147 | } 148 | 149 | /** 150 | * SyncStateMachine 151 | * TypeScript finite state machine class with sync transformations. 152 | */ 153 | export class SyncStateMachine< 154 | STATE extends string | number | symbol, 155 | EVENT extends string | number | symbol, 156 | CALLBACK extends Record = Record, 157 | > extends StateMachine { 158 | 159 | constructor( 160 | init: STATE, 161 | transitions: ITransition[] = [], 162 | logger: ILogger = console, 163 | ) { 164 | super(init, transitions, logger); 165 | } 166 | 167 | override dispatch(_event: E, ..._args: unknown[]): Promise { 168 | throw new Error("SyncStateMachine does not support async dispatch."); 169 | } 170 | 171 | // post sync event 172 | // returns true if the event was handled, false otherwise 173 | syncDispatch(event: E, ...args: unknown[]): boolean { 174 | // find transition 175 | const found = this.transitions.some((tran) => { 176 | if (tran.fromState === this._current && tran.event === event) { 177 | const current = this._current; 178 | this._current = tran.toState; 179 | if (tran.cb) { 180 | try { 181 | tran.cb(...args); 182 | } catch (e) { 183 | this._current = current; // revert to previous state 184 | this.logger.error("Exception in callback", e); 185 | throw e; 186 | } 187 | return true; 188 | } 189 | return false; // search for more transitions 190 | } 191 | }); 192 | 193 | // no such transition 194 | if (!found) { 195 | const errorMessage = this.formatErr(this._current, event); 196 | this.logger.error(errorMessage); 197 | } 198 | 199 | return (!!found); 200 | } 201 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | 6 | // All imported modules in your tests should be mocked automatically 7 | // automock: false, 8 | 9 | // Stop running tests after `n` failures 10 | // bail: 0, 11 | 12 | // Respect "browser" field in package.json when resolving modules 13 | // browser: false, 14 | 15 | // The directory where Jest should store its cached dependency information 16 | // cacheDirectory: "C:\\Users\\EthanRam\\AppData\\Local\\Temp\\jest", 17 | 18 | // Automatically clear mock calls and instances between every test 19 | // clearMocks: false, 20 | 21 | // Indicates whether the coverage information should be collected while executing the test 22 | collectCoverage: false, 23 | 24 | // An array of glob patterns indicating a set of files for which coverage information should be collected 25 | collectCoverageFrom: ["src/**"], 26 | 27 | // The directory where Jest should output its coverage files 28 | coverageDirectory: "coverage", 29 | 30 | // An array of regexp pattern strings used to skip coverage collection 31 | coveragePathIgnorePatterns: [ 32 | "\\\\node_modules\\\\", 33 | "\\index.(tsx?|jsx?)$", 34 | "\\\\coverage\\\\", 35 | ], 36 | 37 | // A list of reporter names that Jest uses when writing coverage reports 38 | coverageReporters: [ 39 | // "json", 40 | "text", "lcov", 41 | // "clover" 42 | ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // Force coverage collection from ignored files using an array of glob patterns 54 | // forceCoverageMatch: [], 55 | 56 | // A path to a module which exports an async function that is triggered once before all test suites 57 | // globalSetup: "./scripts/jestSetup.ts", 58 | 59 | // A path to a module which exports an async function that is triggered once after all test suites 60 | // globalTeardown: "./scripts/jestTeardown.ts", 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. 66 | // E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. 67 | // maxWorkers: 2 will use a maximum of 2 workers. 68 | // maxWorkers: "50%", 69 | 70 | // An array of directory names to be searched recursively up from the requiring module's location 71 | // moduleDirectories: [ 72 | // "node_modules" 73 | // ], 74 | 75 | // An array of file extensions your modules use 76 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 77 | 78 | // A map from regular expressions to module names that allow to stub out resources with a single module 79 | // moduleNameMapper: {}, 80 | 81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 82 | // modulePathIgnorePatterns: [], 83 | 84 | // Activates notifications for test results 85 | // notify: false, 86 | 87 | // An enum that specifies notification mode. Requires { notify: true } 88 | // notifyMode: "failure-change", 89 | 90 | // A preset that is used as a base for Jest's configuration 91 | preset: "ts-jest", 92 | 93 | // Run tests from one or more projects 94 | // projects: undefined, 95 | 96 | // Use this configuration option to add custom reporters to Jest 97 | // reporters: undefined, 98 | 99 | // Automatically reset mock state between every test 100 | // resetMocks: false, 101 | 102 | // Reset the module registry before running each individual test 103 | // resetModules: false, 104 | 105 | // A path to a custom resolver 106 | // resolver: undefined, 107 | 108 | // Automatically restore mock state between every test 109 | // restoreMocks: false, 110 | 111 | // The root directory that Jest should scan for tests and modules within 112 | // rootDir: "src", 113 | 114 | // A list of paths to directories that Jest should use to search for files in 115 | roots: [ 116 | "src", 117 | ], 118 | 119 | // Allows you to use a custom runner instead of Jest's default test runner 120 | // runner: "jest-runner", 121 | 122 | // The paths to modules that run some code to configure or set up the testing environment before each test 123 | // setupFiles: [], 124 | 125 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 126 | // setupFilesAfterEnv: ["./scripts/jestSetup.ts"], 127 | 128 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 129 | // snapshotSerializers: [], 130 | 131 | // The test environment that will be used for testing 132 | testEnvironment: "node", 133 | 134 | // Options that will be passed to the testEnvironment 135 | // testEnvironmentOptions: {}, 136 | 137 | // Adds a location field to test results 138 | // testLocationInResults: false, 139 | 140 | // The glob patterns Jest uses to detect test files 141 | // testMatch: [ 142 | // "**/__tests__/**/*.[jt]s?(x)", 143 | // "**/?(*.)+(spec|test).[tj]s?(x)" 144 | // ], 145 | 146 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 147 | // testPathIgnorePatterns: [ 148 | // "/node_modules/" 149 | // ], 150 | 151 | // The regexp pattern or array of patterns that Jest uses to detect test files 152 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 153 | 154 | // This option allows the use of a custom results processor 155 | // testResultsProcessor: undefined, 156 | 157 | // This option allows use of a custom test runner 158 | // testRunner: "jasmine2", 159 | 160 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 161 | // testURL: "http://localhost", 162 | 163 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 164 | // timers: "real", 165 | 166 | // A map from regular expressions to paths to transformers 167 | transform: { 168 | "^.+\\.tsx?$": "ts-jest", 169 | }, 170 | 171 | // An array of regexp pattern strings that are matched against all source file paths, matched files 172 | // will skip transformation 173 | transformIgnorePatterns: [ 174 | "\\\\node_modules\\\\", 175 | ], 176 | 177 | // An array of regexp pattern strings that are matched against all modules before the module loader 178 | // will automatically return a mock for them 179 | // unmockedModulePathPatterns: undefined, 180 | 181 | // Indicates whether each individual test should be reported during the run 182 | // verbose: undefined, 183 | 184 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 185 | // watchPathIgnorePatterns: [], 186 | 187 | // Whether to use watchman for file crawling 188 | // watchman: true, 189 | }; 190 | -------------------------------------------------------------------------------- /src/__test__/stateMachine.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { describe, test } from "node:test"; 3 | import assert from "node:assert/strict"; 4 | import { t, StateMachine, type ILogger } from "../stateMachine"; 5 | 6 | describe("stateMachine tests", async () => { 7 | 8 | // testing Door state machine 9 | enum States { closing = 0, closed, opening, opened, breaking, broken, locking, locked, unlocking } 10 | enum Events { 11 | open = 100, openComplete, 12 | close, closeComplete, 13 | break, breakComplete, 14 | lock, lockComplete, 15 | unlock, unlockComplete, unlockFailed, 16 | } 17 | 18 | class Door extends StateMachine { 19 | 20 | private readonly _id = `Door${(Math.floor(Math.random() * 10000))}`; 21 | private readonly _key: number; 22 | 23 | // ctor 24 | constructor(key = 0, init = States.closed, logger?: ILogger) { 25 | 26 | super(init, [], logger); 27 | this._key = key; 28 | 29 | const s = States; 30 | const e = Events; 31 | 32 | /* eslint-disable no-multi-spaces */ 33 | this.addTransitions([ 34 | // fromState event toState callback 35 | t(s.closed, e.open, s.opening, this.#onOpen), 36 | t(s.opening, e.openComplete, s.opened, this.#justLog), 37 | t(s.opened, e.close, s.closing, this.#onClose), 38 | t(s.closing, e.closeComplete, s.closed, this.#justLog), 39 | t(s.opened, e.break, s.breaking, this.#onBreak), 40 | t(s.breaking, e.breakComplete, s.broken), 41 | t(s.closed, e.break, s.breaking, this.#onBreak), 42 | t(s.closed, e.lock, s.locking, this.#onLock), 43 | t(s.locking, e.lockComplete, s.locked, this.#justLog), 44 | t(s.locked, e.unlock, s.unlocking, this.#onUnlock), 45 | t(s.unlocking, e.unlockComplete, s.closed, this.#justLog), 46 | t(s.unlocking, e.unlockFailed, s.locked, this.#justLog), 47 | ]); 48 | /* eslint-enable no-multi-spaces */ 49 | } 50 | 51 | // public methods 52 | open = async () => this.dispatch(Events.open); 53 | close = async () => this.dispatch(Events.close); 54 | break = async () => this.dispatch(Events.break); 55 | async lock() { return this.dispatch(Events.lock); } 56 | async unlock(key: number) { return this.dispatch(Events.unlock, key); } 57 | 58 | isBroken = () => this.isFinal(); 59 | isOpen = () => (this.getState() === States.opened); 60 | isLocked = () => (this.getState() === States.locked); 61 | 62 | // transition callbacks 63 | async #onOpen() { 64 | this.logger.log(`${this._id} onOpen...`); 65 | return this.dispatch(Events.openComplete); 66 | } 67 | 68 | async #onClose() { 69 | this.logger.log(`${this._id} onClose...`); 70 | return this.dispatch(Events.closeComplete); 71 | } 72 | 73 | async #onBreak() { 74 | this.logger.log(`${this._id} onBreak...`); 75 | return this.dispatch(Events.breakComplete); 76 | } 77 | 78 | async #onLock() { 79 | this.logger.log(`${this._id} onLock...`); 80 | return this.dispatch(Events.lockComplete); 81 | } 82 | 83 | async #onUnlock(key: number) { 84 | this.logger.log(`${this._id} onUnlock with key=${key}...`); 85 | if (key === this._key) { 86 | return this.dispatch(Events.unlockComplete); 87 | } 88 | await this.dispatch(Events.unlockFailed); 89 | throw new Error(`${key} failed to unlock ${this._id}`); 90 | } 91 | 92 | // sync callback 93 | #justLog() { 94 | console.log(`${this._id} ${States[this.getState()]}`); 95 | } 96 | } 97 | 98 | // ---- TEST CASES ---- 99 | 100 | test("should correctly report current state", async () => { 101 | const door = new Door(undefined, States.opened); 102 | assert.equal(door.getState(), States.opened); 103 | await door.close(); 104 | assert.equal(door.getState(), States.closed); 105 | }); 106 | 107 | test("should handle multiple transition registrations", () => { 108 | const door = new Door(); 109 | // Add a new transition that wasn't in the constructor 110 | door.addTransitions([ 111 | t(States.closed, Events.unlock, States.closed, () => { /* noop */ }), 112 | ]); 113 | assert.ok(door.can(Events.unlock)); 114 | }); 115 | 116 | test("test opening a closed door", async () => { 117 | const door = new Door(); 118 | 119 | assert.ok(!door.isOpen()); 120 | assert.ok(!door.isBroken()); 121 | assert.ok(door.can(Events.open)); 122 | assert.equal(door.getNextState(Events.open), States.opening); 123 | 124 | await door.open(); 125 | assert.ok(door.isOpen()); 126 | }); 127 | 128 | test("test a failed event", async () => { 129 | const door = new Door(undefined, States.opened); 130 | assert.ok(!door.can(Events.open)); 131 | assert.equal(door.getNextState(Events.open), undefined); 132 | 133 | await assert.rejects(door.open()); 134 | }); 135 | 136 | test("test closing an open door", async () => { 137 | const door = new Door(undefined, States.opened); 138 | assert.ok(door.isOpen()); 139 | 140 | await door.close(); 141 | assert.ok(!door.isOpen()); 142 | }); 143 | 144 | test("test breaking a door", async () => { 145 | const door = new Door(); 146 | assert.ok(!door.isBroken()); 147 | 148 | await door.break(); 149 | assert.ok(door.isBroken()); 150 | assert.ok(!door.isOpen()); 151 | }); 152 | 153 | test("broken door cannot be opened or closed", async () => { 154 | const door = new Door(undefined, States.broken); 155 | assert.ok(door.isBroken()); 156 | 157 | await assert.rejects(door.open(), { 158 | message: `No transition: from ${States.broken} event ${Events.open}`, 159 | }); 160 | }); 161 | 162 | test("should throw on intermediate state", async () => { 163 | const door = new Door(undefined, States.opened); 164 | assert.ok(door.isOpen()); 165 | 166 | const closePromise = door.close(); 167 | assert.ok(door.isOpen()); 168 | 169 | await assert.rejects(door.break(), { 170 | message: `No transition: from ${States.closing} event ${Events.break}`, 171 | }); 172 | 173 | await closePromise; 174 | }); 175 | 176 | test("should throw if callback throws", async () => { 177 | const door = new Door(undefined, States.opened); 178 | let called = false; 179 | 180 | door.addTransitions([ 181 | t(States.opened, Events.open, States.opening, () => { called = true; throw new Error("bad"); }), 182 | ]); 183 | 184 | assert.ok(door.isOpen()); 185 | 186 | await assert.rejects(door.open(), Error); 187 | 188 | assert.ok(called); 189 | }); 190 | 191 | test("should unlock with correct key", async () => { 192 | const key = 12345; 193 | const door = new Door(key, States.locked); 194 | await door.unlock(key); 195 | assert.ok(!door.isLocked()); 196 | }); 197 | 198 | test("should not unlock with incorrect key", async () => { 199 | const key = 12345; 200 | const door = new Door(key, States.locked); 201 | 202 | await assert.rejects(door.unlock(key + 3)); 203 | 204 | assert.ok(door.isLocked()); 205 | }); 206 | 207 | 208 | void test("should support custom loggers", async () => { 209 | let errorCalled = false; 210 | const mockLogger = { 211 | error: () => { errorCalled = true; }, 212 | log: () => {}, 213 | }; 214 | 215 | const door = new Door(0, States.closed, mockLogger); 216 | 217 | await assert.rejects(door.dispatch(Events.unlock)); 218 | 219 | assert.ok(errorCalled); 220 | }); 221 | }); --------------------------------------------------------------------------------