├── .browserslistrc ├── .eslintrc ├── .gitignore ├── .npmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assert ├── index.cjs ├── index.cjs.map ├── index.d.ts └── index.mjs ├── babel.config.json ├── build-config ├── rollup.common.js ├── rollup.config.assert.js ├── rollup.config.browser.js ├── rollup.config.esm.js └── rollup.config.node.js ├── dist ├── browser │ ├── statebot.js │ ├── statebot.js.map │ └── statebot.min.js ├── cjs │ ├── statebot.js │ └── statebot.js.map ├── esm │ ├── statebot.mjs │ └── statebot.mjs.map └── umd │ ├── statebot.js │ ├── statebot.js.map │ └── statebot.min.js ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── main.js │ ├── search.js │ ├── style.css │ ├── widgets.png │ └── widgets@2x.png ├── functions │ ├── assert.assertRoute.html │ ├── assert.routeIsPossible.html │ ├── hooks_mithril.useStatebot.html │ ├── hooks_mithril.useStatebotEvent.html │ ├── hooks_mithril.useStatebotFactory.html │ ├── hooks_react.useStatebot.html │ ├── hooks_react.useStatebotEvent.html │ ├── hooks_react.useStatebotFactory.html │ ├── index.Statebot.html │ ├── index.decomposeChart.html │ └── index.isStatebot.html ├── index.html ├── interfaces │ ├── assert.TAssertRouteOptions.html │ ├── index.TStatebotFsm.html │ └── index.TStatebotOptions.html ├── modules.html ├── modules │ ├── assert.html │ ├── hooks_mithril.html │ ├── hooks_react.html │ └── index.html └── types │ ├── index.TEventName.html │ ├── index.TListenersRemover.html │ └── index.TStatebotChart.html ├── hooks ├── make-hooks.mjs ├── mithril │ ├── README.md │ ├── index.d.ts │ ├── index.mjs │ └── package.json └── react │ ├── README.md │ ├── index.d.ts │ ├── index.mjs │ └── package.json ├── index.d.ts ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── browser.js ├── index.js ├── mermaid.js ├── parsing.js ├── statebot.js ├── types.js └── utils.js ├── tests ├── assertions.test.js ├── conditionals.test.js ├── emit-arity.test.js ├── enter-arity.test.js ├── events.test.js ├── leaks.test.js ├── mermaid.test.js ├── ordering.test.js ├── parsing.test.js ├── pause.test.js ├── peek.test.js └── traffic-lights.mmd └── tsconfig.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 11, 4 | "sourceType": "module" 5 | }, 6 | "env": { 7 | "node": true, 8 | "browser": true, 9 | "es6": true, 10 | "jest/globals": true 11 | }, 12 | "extends": ["eslint:recommended"], 13 | "plugins": ["eslint-plugin-jest"] 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _unsorted 2 | .DS_Store 3 | .vscode 4 | node_modules 5 | pnpm-debug.log 6 | vs.code-workspace 7 | PUBLISH.md 8 | publish.sh 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "arrowParens": "avoid", 5 | "printWidth": 80, 6 | "trailingComma": "none", 7 | "semi": false, 8 | "singleQuote": true, 9 | "bracketSpacing": true 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [3.1.3] - 2023-04-13 11 | 12 | ### Fixed 13 | 14 | - Oh dear: require() should be import 15 | 16 | ## [3.1.2] - 2023-04-13 17 | 18 | ### Fixed 19 | 20 | - Bad require() path 21 | 22 | ## [3.1.1] - 2023-04-13 23 | 24 | ### Updated 25 | 26 | - README tweaks 27 | 28 | ## [3.1.0] - 2023-04-13 29 | 30 | ### Added 31 | 32 | - `mermaid` helper function to allow Mermaid state-diagrams to be used with Statebot: 33 | 34 | ```js 35 | import { Statebot, mermaid } from 'statebot' 36 | 37 | const fsm = Statebot('traffic-lights', { 38 | chart: mermaid` 39 | stateDiagram 40 | direction LR 41 | go --> prepareToStop 42 | prepareToStop --> stop 43 | 44 | %% ...gotta keep that traffic flowing 45 | stop --> prepareToGo 46 | prepareToGo --> go 47 | ` 48 | }) 49 | ``` 50 | 51 | Front-matter is ignored: 52 | 53 | ```js 54 | import { Statebot, mermaid } from 'statebot' 55 | 56 | const fsm = Statebot('traffic-lights', { 57 | chart: mermaid` 58 | --- 59 | title: Traffic lights 60 | --- 61 | stateDiagram 62 | direction LR 63 | go --> prepareToStop 64 | prepareToStop --> stop 65 | 66 | %% ...gotta keep that traffic flowing 67 | stop --> prepareToGo 68 | prepareToGo --> go 69 | ` 70 | }) 71 | ``` 72 | 73 | `:::` blocks are also ignored, which is useful because the Mermaid Preview extension for VS Code will display a chart when the cursor is placed between the blocks: 74 | 75 | ```js 76 | import { Statebot, mermaid as mmd } from 'statebot' 77 | 78 | const fsm = Statebot('traffic-lights', { 79 | chart: mmd` 80 | ::: mermaid 81 | stateDiagram 82 | direction LR 83 | go --> prepareToStop 84 | prepareToStop --> stop 85 | 86 | %% ...gotta keep that traffic flowing 87 | stop --> prepareToGo 88 | prepareToGo --> go 89 | ::: 90 | ` 91 | }) 92 | ``` 93 | 94 | > **Note:** Mermaid start `[*] -->` and stop `--> [*]` states will become `__START__` and `__STOP__` Statebot states respectively. 95 | 96 | ## [3.0.7] - 2023-02-03 97 | 98 | ### Fixed 99 | 100 | - package.json .mjs exports for Hooks 101 | 102 | ## [3.0.6] - 2023-02-03 103 | 104 | ### Changed 105 | 106 | - Most .js extensions are now .mjs. Hopefully this fixes compatibility with newer Jest versions 107 | 108 | ## [3.0.5] - 2022-07-09 109 | 110 | ### Updated 111 | 112 | - README tweak 113 | 114 | ## [3.0.4] - 2022-07-09 115 | 116 | ### Fixed 117 | 118 | - Typedefs for TStatebotFsm should be methods rather than props 119 | 120 | ## [3.0.3] - 2022-07-07 121 | 122 | ### Updated 123 | 124 | - Exclude mitt from cjs/esm build: It's specified as a regular dependency now 125 | - Remove .dev from build filenames 126 | - Tweak README as JSDoc lives in index.d.ts now 127 | 128 | ## [3.0.2] - 2022-07-07 129 | 130 | ### Fixed 131 | 132 | - Out of date types locations in package.json 133 | 134 | ## [3.0.1] - 2022-07-07 135 | 136 | ### Updated 137 | 138 | - Links to hooks 139 | 140 | ## [3.0.0] - 2022-07-07 141 | 142 | ### BREAKING CHANGES 143 | 144 | Imports updated: 145 | 146 | - `routeIsPossible` / `assertRoute` now come from 'statebot/assert' 147 | - React/Mithril Hooks can be imported from 'statebot/hooks/react' and 'statebot/hooks/mithril'. There are no longer separate packages for these. 148 | 149 | ### Updated 150 | 151 | - Documentation now built with Typedoc 152 | - Typedefs are now manually updated instead of generated from jsdoc 153 | 154 | ## [2.9.3] - 2022-01-11 155 | 156 | ### Fixed 157 | 158 | - Fix esbuild error by re-ordering package.json exports 159 | 160 | ## [2.9.2] - 2021-12-20 161 | 162 | ### Fixed 163 | 164 | - Fix WebPack error: "Default condition should be last one" 165 | 166 | ## [2.9.1] - 2021-12-18 167 | 168 | ### Fixed 169 | 170 | - Replace nullish coalescing `??` with ternary to fix Bundlephobia error. 171 | 172 | ## [2.9.0] - 2021-12-18 173 | 174 | ### Added 175 | 176 | - peek(eventName, stateObject?), tests, documentation 177 | - canTransitionTo('state', { afterEvent: 'event' }), tests, documentation 178 | 179 | ### Updated 180 | 181 | - Updated dependencies 182 | 183 | ## [2.8.1] - 2021-09-04 184 | 185 | ### Updated 186 | 187 | - Updated dependencies, tweaked README 188 | 189 | ## [2.8.0] - 2021-06-07 190 | 191 | ### Added 192 | 193 | - If a `performTransition` `then` method or `onTransitions` callback 194 | return a function, it will be invoked when the state is exited in the 195 | same manner as if an `.onExiting()` handler was created using it. 196 | 197 | ## [2.7.4] - 2021-04-25 198 | 199 | ### Fixed 200 | 201 | - Guard against wrapped-on() args not being an array 202 | 203 | ## [2.7.3] - 2021-04-25 204 | 205 | ### Updated 206 | 207 | - Dependencies, package.json tweaks 208 | 209 | ## [2.7.2] - 2021-01-22 210 | 211 | ### Fixed 212 | 213 | - Wrong package.json setting for Node 214 | 215 | ## [2.7.1] - 2021-01-17 216 | 217 | ### Fixed 218 | 219 | - Updated README example required commas 220 | 221 | ## [2.7.0] - 2021-01-17 222 | 223 | ### Added 224 | 225 | - inState/InState now supports an object for config as well as a string: 226 | 227 | ```js 228 | inState('idle') 229 | // true | false 230 | 231 | inState('idle', 'waiting') 232 | // "waiting" | null 233 | 234 | inState({ 235 | idle: 'hold up', 236 | success: () => 'fn-result', 237 | done: 238 | }) 239 | // "hold up" | "fn-result" | | null 240 | ``` 241 | 242 | ## [2.6.3] - 2021-01-13 243 | 244 | ### Added 245 | 246 | - package.json exports 247 | 248 | ## [2.6.2] - 2021-01-04 249 | 250 | ### Fixed 251 | 252 | - Revert previous argument-defaults tweak, as it bugged Enter(). Fixed, regression test added 253 | 254 | ### Added 255 | 256 | - Further tests for arity of emit/Emit 257 | 258 | ### Changed 259 | 260 | - Replace padLeft/padRight with padEnd/padStart respectively 261 | 262 | ## [2.6.1] - 2021-01-02 263 | 264 | ### Updated 265 | 266 | - Add code-comments to make CodeFactor happy with documentation page 267 | - Remove argument-defaults to reduce compiled/minified code-size slightly 268 | 269 | ## [2.6.0] - 2020-12-30 270 | 271 | ### Changed 272 | 273 | - Now using Mitt for events 274 | - Changed license from ISC to MIT 275 | 276 | ## [2.5.1] - 2020-08-11 277 | 278 | ### Fixed 279 | 280 | - Custom event-emitter support broken in previous commit (emit not working) 281 | 282 | ## [2.5.0] - 2020-08-10 283 | 284 | ### Updated 285 | 286 | - Dependencies 287 | - Throws if invalid event-emitter passed-in 288 | 289 | ### Added 290 | 291 | - Compatibility with mitt event-emitter library 292 | - inState + statesAvailableFromHere tests 293 | - More links for pause/resume/paused in docs 294 | - Build-comments for CodeFactor 295 | 296 | ## [2.4.0] - 2020-07-23 297 | 298 | ### Updated 299 | 300 | - Dependencies 301 | 302 | ### Added 303 | 304 | - pause/paused/resume methods, tests, docs 305 | 306 | ## [2.3.10] - 2020-07-15 307 | 308 | ### Updated 309 | 310 | - Put an example at the top of the README to get to the point more quickly :P 311 | 312 | ## [2.3.9] - 2020-07-14 313 | 314 | ### Fixed 315 | 316 | - routeIsPossible() did not support "backtracking" in some cases 317 | 318 | ### Added 319 | 320 | - Basic tests for backtracking 321 | 322 | ## [2.3.8] - 2020-07-11 323 | 324 | ### Fixed 325 | 326 | - Charts with empty-strings for states were not always parsing properly 327 | 328 | ### Added 329 | 330 | - Added tests for charts with empty-strings 331 | - Added tests for callback-counts + ordering 332 | - Tweak Hook-examples in the README 333 | 334 | ## [2.3.7] - 2020-07-06 335 | 336 | ### Fixed 337 | 338 | - .DS_Store snuck into dist/ 339 | - Build index.d.ts automatically, fixing broken autocompletion 340 | - ESM build renamed from .mjs to .js, since tsc won't read it to build index.d.ts otherwise 341 | 342 | ## [2.3.6] - 2020-07-06 343 | 344 | ### Changed 345 | 346 | - Use ES6 import/export syntax in source-files (slightly small dist/ files resulted) 347 | - Put dev source-maps in their own files 348 | 349 | ### Added 350 | 351 | - Build ES6 module in dist/esm 352 | - Got started with some basic tests 353 | - React Hooks :) and Mithril ones, too 354 | 355 | ## [2.3.5] - 2020-06-22 356 | 357 | ### Fixed 358 | 359 | - Fix require().default regression in Rollup config 360 | 361 | ## [2.3.4] - 2020-06-22 362 | 363 | ### Added 364 | 365 | - Build documentation.js JSON in `docs/` for tinkering 366 | 367 | ### Fixed 368 | 369 | - Compatibility with projects using Rollup 370 | 371 | ### Updated 372 | 373 | - Emphasise in docs that Statebot isn't bound to a particular framework 374 | - Updated babel, documentation, eslint, rollup 375 | 376 | ## [2.3.3] - 2020-06-14 377 | 378 | ### Added 379 | 380 | - Include a React example in docs + README 381 | 382 | ## [2.3.2] - 2020-05-29 383 | 384 | ### Fixed 385 | 386 | - Typo in code 387 | 388 | ### Changed 389 | 390 | - A few more README tweaks 391 | 392 | ## [2.3.1] - 2020-05-27 393 | 394 | ### Fixed 395 | 396 | - Fix docs for updated Enter/Emit 397 | 398 | ## [2.3.0] - 2020-05-27 399 | 400 | ### Added 401 | 402 | - Enter/Emit can accept arguments that will curry into the functions 403 | they return. 404 | - inState now fully documented. 405 | - InState supports currying arguments into outputWhenTrue() argument. 406 | 407 | ## [2.2.1] - 2020-05-26 408 | 409 | ### Fixed 410 | 411 | - A few JSDoc links weren't working 412 | - VS Code autocomplete wasn't working fully 413 | 414 | ## [2.2.0] - 2020-05-25 415 | 416 | ### Changed 417 | 418 | - Migrated from Webpack to Rollup; smaller min builds, UMD build 419 | - Add "files" to package.json to reduce npm module size 420 | - Reduce code-size a little 421 | 422 | ## [2.1.1] - 2020-05-23 423 | 424 | ### Changed 425 | 426 | - README tweaks 427 | - Upgrade dev-dependencies 428 | 429 | ## [2.1.0] - 2020-04-24 430 | 431 | ### Fixed 432 | 433 | - Browser build now uses 'var' webpack option rather than 'umd' 434 | 435 | ### Changed 436 | 437 | - Lua inspired 'coroutine' chart in the JSDocs 438 | 439 | ## [2.0.0] - 2020-04-20 440 | 441 | ### Changed 442 | 443 | - Updated disallowed characters for cross-env compatibility of charts. 444 | 445 | This was a tiny change to Statebot, but will break any charts using 446 | the characters now excluded (probably none at this point!) Still, 447 | it's good to show semver willing, eh? ;) 448 | 449 | ### Added 450 | 451 | - Include links to the shell-port, Statebot-sh 452 | 453 | ## [1.0.6] - 2020-04-13 454 | 455 | ### Fixed 456 | 457 | - Various post-publishing documentation fixes 458 | 459 | ## [1.0.0] - 2020-04-13 460 | 461 | ### Added 462 | 463 | - Statebot :) 464 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Conan Theobald 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 | -------------------------------------------------------------------------------- /assert/index.d.ts: -------------------------------------------------------------------------------- 1 | import { TStatebotFsm } from 'statebot' 2 | 3 | declare module 'statebot/assert' { 4 | /** 5 | * {@link assertRoute} options. 6 | */ 7 | export interface TAssertRouteOptions { 8 | /** 9 | * Describe the success-condition for this assertion. 10 | */ 11 | description?: string 12 | /** 13 | * Wait for the machine to be in this state before assertion begins. 14 | */ 15 | fromState?: string 16 | /** 17 | * Run this function just before starting the assertion. 18 | */ 19 | run?: Function 20 | /** 21 | * If we hit an unexpected state during assertion, this is a "deviation". 22 | * It might be that the FSM will come back to the expected state again 23 | * after a certain number of these. For example, if your FSM has a 24 | * "retry" route configured, this number can account for it. 25 | */ 26 | permittedDeviations?: number 27 | /** 28 | * Permitted length of time for the entire assertion, in milliseconds. 29 | */ 30 | timeoutInMs?: number 31 | /** 32 | * Normally we want logs for assertions, right? Well, you can tune 33 | * them just like you can with {@link index.TStatebotOptions}. 34 | */ 35 | logLevel?: 0 | 1 | 2 | 3 36 | } 37 | 38 | /** 39 | * Assert that a {@link index.TStatebotFsm} traced the route specified. 40 | * 41 | * Whereas {@link routeIsPossible} only checks 42 | * that a particular route can be followed, `assertRoute` will hook-into 43 | * a machine and wait for it to trace the specified path within a 44 | * timeout period. 45 | * 46 | * @param machine 47 | * The machine to run the assertion on. 48 | * @param expectedRoute 49 | * The expected route as an arrow-delimited string: 50 | * 51 | * ` 52 | * "idle -> pending -> success -> done" 53 | * ` 54 | * @param [options] 55 | * @returns 56 | * 57 | * @example 58 | * ```js 59 | * import { Statebot } from 'statebot' 60 | * import { assertRoute } from 'statebot/assert' 61 | * 62 | * let machine = Statebot(...) 63 | * 64 | * assertRoute( 65 | * machine, 'prepare -> debounce -> sending -> done -> idle', 66 | * { 67 | * description: 'Email sent with no issues', 68 | * fromState: 'idle', 69 | * timeoutInMs: 1000 * 20, 70 | * permittedDeviations: 0, 71 | * logLevel: 3 72 | * } 73 | * ) 74 | * .then(() => console.log('Assertion passed!')) 75 | * .catch(err => console.error(`Whoops: ${err}`)) 76 | * 77 | * machine.enter('idle') 78 | * ``` 79 | */ 80 | export function assertRoute( 81 | machine: TStatebotFsm, 82 | expectedRoute: string | string[], 83 | options?: TAssertRouteOptions 84 | ): Promise 85 | 86 | /** 87 | * Assert that a certain route can be followed by a 88 | * {@link index.TStatebotFsm}. 89 | * 90 | * This merely tests that a certain path can be taken through a 91 | * state-machine. It doesn't assert that the states are moved-through 92 | * while the machine is working, as with 93 | * {@link assertRoute}. 94 | * 95 | * @param machine 96 | * The machine to test the route on. 97 | * @param route 98 | * The route to test as an arrow-delimited string: 99 | * 100 | * ` 101 | * "idle -> pending -> success -> done" 102 | * ` 103 | * @returns 104 | * 105 | * @example 106 | * ```js 107 | * import { Statebot } from 'statebot' 108 | * import { routeIsPossible } from 'statebot/assert' 109 | * 110 | * let machine = Statebot(...) 111 | * 112 | * routeIsPossible(machine, 113 | * 'walking -> falling -> splatting -> walking' 114 | * ) 115 | * // false 116 | * ``` 117 | */ 118 | export function routeIsPossible( 119 | machine: TStatebotFsm, 120 | route: string | string[] 121 | ): boolean 122 | } 123 | -------------------------------------------------------------------------------- /assert/index.mjs: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // STATEBOT ASSERTION HELPERS 4 | // 5 | 6 | export { 7 | routeIsPossible, 8 | assertRoute 9 | } 10 | 11 | import { isStatebot } from '../src/statebot' 12 | import { decomposeRoute } from '../src/parsing' 13 | import { isTemplateLiteral, ArgTypeError } from '../src/types' 14 | import { 15 | Defer, 16 | Once, 17 | Revokable, 18 | Logger, 19 | } from '../src/utils' 20 | 21 | const argTypeError = ArgTypeError('statebot.') 22 | 23 | function routeIsPossible (machine, route) { 24 | const err = argTypeError( 25 | { machine: isStatebot, route: isTemplateLiteral } 26 | )('routeIsPossible')(machine, route) 27 | if (err) { 28 | throw TypeError(err) 29 | } 30 | 31 | const _route = decomposeRoute(route) 32 | return _route.every((state, index) => { 33 | if (index === _route.length - 1) { 34 | return true 35 | } else { 36 | const nextState = _route[index + 1] 37 | const availableStates = machine.statesAvailableFromHere(state) 38 | const passes = availableStates.includes(nextState) 39 | return passes 40 | } 41 | }) 42 | } 43 | 44 | let assertionId = 0 45 | 46 | function assertRoute (machine, expectedRoute, options) { 47 | const err = argTypeError( 48 | { machine: isStatebot, expectedRoute: isTemplateLiteral } 49 | )('assertRoute')(machine, expectedRoute) 50 | if (err) { 51 | throw TypeError(err) 52 | } 53 | 54 | assertionId += 1 55 | 56 | const { 57 | description = 'Assertion complete', 58 | fromState = '', 59 | run = () => {}, 60 | permittedDeviations = 0, 61 | timeoutInMs = 1000, 62 | logLevel = 3 63 | } = options || {} 64 | 65 | const console = Logger(logLevel) 66 | 67 | const prefix = `Statebot[${machine.name()}]: aId<${assertionId}>` 68 | const route = decomposeRoute(expectedRoute) 69 | 70 | console.log(`\n${prefix}: Asserting route: [${route.join(' > ')}]`) 71 | console.log(`${prefix}: > Assertion will start from state: "${fromState}"`) 72 | 73 | const fromStateActionFn = Defer(run) 74 | let removeFromStateActionFn = () => { } 75 | 76 | const totalTimeTaken = TimeTaken() 77 | let stateTimeTaken = TimeTaken() 78 | let assertionTimeoutTimer 79 | let deviations = 0 80 | let pending = true 81 | let unexpected = false 82 | 83 | const consumeRoute = [...route] 84 | const report = Table( 85 | ['state', 'expected', 'info', 'took'], 86 | ['center', 'center', 'left', 'right'] 87 | ) 88 | 89 | const finaliseReport = Once(err => { 90 | addRow('', '', '', 'TOTAL: ' + totalTimeTaken()) 91 | report.lock() 92 | console.log(`\n${prefix}: ${description}: [${err ? 'FAILED' : 'SUCCESS'}]`) 93 | console.table(report.content()) 94 | return err 95 | }) 96 | 97 | const { addRow } = report 98 | function enteredState (state) { 99 | if (pending) { 100 | addRow(state, '-', 'PENDING') 101 | } else { 102 | const expectedState = consumeRoute[0] 103 | if (expectedState === state) { 104 | addRow(state, expectedState, unexpected ? 'REALIGNED' : 'OKAY', stateTimeTaken()) 105 | unexpected = false 106 | consumeRoute.shift() 107 | } else { 108 | addRow(state, expectedState, 'WRONG STATE', stateTimeTaken()) 109 | unexpected = true 110 | deviations += 1 111 | } 112 | stateTimeTaken = TimeTaken() 113 | } 114 | } 115 | 116 | return new Promise((resolve, reject) => { 117 | if (consumeRoute.length === 0) { 118 | reject(finaliseReport(new Error('NO ROUTE TO TEST'))) 119 | return 120 | } 121 | 122 | const clearTimeoutAndResolve = (...args) => { 123 | clearTimeout(assertionTimeoutTimer) 124 | removeFromStateActionFn() 125 | removeOnSwitchingListener() 126 | resolve(...args) 127 | } 128 | 129 | const clearTimeoutAndReject = err => { 130 | clearTimeout(assertionTimeoutTimer) 131 | removeFromStateActionFn() 132 | removeOnSwitchingListener() 133 | reject(err) 134 | } 135 | 136 | const bailout = message => { 137 | while (consumeRoute.length) { 138 | const expectedState = consumeRoute.shift() 139 | addRow(machine.currentState(), `(${expectedState})`, message) 140 | unexpected = false 141 | } 142 | clearTimeoutAndReject(finaliseReport(new Error(message))) 143 | } 144 | 145 | if (machine.inState(fromState)) { 146 | pending = false 147 | removeFromStateActionFn = fromStateActionFn() 148 | } 149 | 150 | const { revoke, fn } = Revokable(state => { 151 | assertionTimeoutTimer = setTimeout(() => { 152 | revoke() 153 | bailout('TIMEOUT') 154 | }, timeoutInMs) 155 | 156 | enteredState(state) 157 | if (pending && state === fromState) { 158 | pending = false 159 | removeFromStateActionFn = fromStateActionFn() 160 | } 161 | if (deviations > permittedDeviations) { 162 | revoke() 163 | bailout('TOO MANY DEVIATIONS') 164 | } 165 | if (consumeRoute.length <= 0) { 166 | revoke() 167 | clearTimeoutAndResolve(finaliseReport()) 168 | } 169 | }) 170 | 171 | const removeOnSwitchingListener = machine.onSwitching(fn) 172 | }) 173 | } 174 | 175 | function Table (columns, alignments) { 176 | columns = columns || [] 177 | alignments = alignments || [] 178 | 179 | const table = [] 180 | const alignment = columns.map((_, index) => alignments[index] || 'center') 181 | 182 | let locked = false 183 | function lock () { 184 | locked = true 185 | } 186 | 187 | function addRow (...args) { 188 | if (locked) { 189 | return 190 | } 191 | const obj = columns.reduce((acc, col, index) => { 192 | const row = args[index] || '' 193 | return { 194 | ...acc, 195 | [col]: row 196 | } 197 | }, {}) 198 | table.push(obj) 199 | } 200 | 201 | function colSizes () { 202 | return table.reduce( 203 | (acc, row) => columns.map( 204 | (col, index) => Math.max(row[col].length, acc[index]) 205 | ), columns.map(() => 0) 206 | ) 207 | } 208 | 209 | function content () { 210 | const sizes = colSizes() 211 | function formatField (value, index) { 212 | const size = sizes[index] 213 | const align = alignment[index] 214 | if (align === 'left') { 215 | return value.padEnd(size) 216 | } 217 | if (align === 'right') { 218 | return value.padStart(size) 219 | } 220 | return value 221 | } 222 | const output = table.reduce((acc, row) => { 223 | const formattedRow = columns.reduce((acc, col, index) => ({ 224 | ...acc, 225 | [col]: formatField(row[col], index) 226 | }), {}) 227 | return [...acc, formattedRow] 228 | }, []) 229 | return output 230 | } 231 | 232 | return { 233 | lock: lock, 234 | addRow: addRow, 235 | content: content 236 | } 237 | } 238 | 239 | function TimeTaken () { 240 | const startTime = Date.now() 241 | 242 | function fmt (num, digits) { 243 | return num.toFixed(digits).replace(/\.0+$/, '') 244 | } 245 | 246 | return function () { 247 | const duration = Date.now() - startTime 248 | 249 | if (duration < 500) { 250 | return `${fmt(duration)} ms` 251 | } else if (duration < 5000) { 252 | return `${fmt(duration / 1000, 2)} s ` 253 | } else if (duration < 60000) { 254 | return `${fmt(duration / 1000, 1)} s ` 255 | } else { 256 | return `${fmt(duration / 1000 / 60, 1)} m ` 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /build-config/rollup.common.js: -------------------------------------------------------------------------------- 1 | export function banner (pkg, extra = '') { 2 | return ` 3 | /* 4 | * Statebot 5 | * v${pkg.version} 6 | * ${pkg.homepage} 7 | * License: ${pkg.license} 8 | */ 9 | ${extra}` 10 | } 11 | 12 | export const terserConfig = { 13 | output: { 14 | wrap_iife: true, 15 | comments: (node, comment) => { 16 | var text = comment.value; 17 | var type = comment.type; 18 | if (type == "comment2") { 19 | // multiline comment 20 | return /License: /.test(text); 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /build-config/rollup.config.assert.js: -------------------------------------------------------------------------------- 1 | import json from '@rollup/plugin-json' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import builtins from 'rollup-plugin-node-builtins' 5 | import cleanup from 'rollup-plugin-cleanup' 6 | 7 | import pkg from '../package.json' 8 | import { banner } from './rollup.common.js' 9 | 10 | export default { 11 | input: 'assert/index.mjs', 12 | output: [ 13 | { 14 | file: 'assert/index.cjs', 15 | banner: banner(pkg), 16 | format: 'cjs', 17 | sourcemap: true 18 | } 19 | ], 20 | plugins: [ 21 | json(), 22 | builtins(), 23 | resolve({ preferBuiltins: false }), 24 | commonjs(), 25 | cleanup() 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /build-config/rollup.config.browser.js: -------------------------------------------------------------------------------- 1 | import json from '@rollup/plugin-json' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import babel from '@rollup/plugin-babel' 5 | import builtins from 'rollup-plugin-node-builtins' 6 | import cleanup from 'rollup-plugin-cleanup' 7 | import { terser } from 'rollup-plugin-terser' 8 | 9 | import pkg from '../package.json' 10 | import { banner, terserConfig } from './rollup.common.js' 11 | 12 | const eslintComment = 13 | '/* eslint-disable no-func-assign, no-unsafe-finally, no-unused-vars */' 14 | 15 | export default { 16 | input: 'src/browser.js', 17 | output: [ 18 | { 19 | file: 'dist/browser/statebot.js', 20 | banner: banner(pkg, `/* exported statebot */\n${eslintComment}`), 21 | format: 'iife', 22 | name: 'statebot', 23 | exports: 'named', 24 | sourcemap: true 25 | }, 26 | { 27 | file: 'dist/browser/statebot.min.js', 28 | banner: banner(pkg), 29 | format: 'iife', 30 | name: 'statebot', 31 | exports: 'named', 32 | plugins: [terser(terserConfig)] 33 | }, 34 | { 35 | file: 'dist/umd/statebot.js', 36 | banner: banner(pkg, `/* global define, globalThis */\n${eslintComment}`), 37 | format: 'umd', 38 | name: 'statebot', 39 | exports: 'named', 40 | sourcemap: true 41 | }, 42 | { 43 | file: 'dist/umd/statebot.min.js', 44 | banner: banner(pkg), 45 | format: 'umd', 46 | name: 'statebot', 47 | exports: 'named', 48 | plugins: [terser(terserConfig)] 49 | } 50 | ], 51 | plugins: [ 52 | json(), 53 | builtins(), 54 | resolve(), 55 | commonjs(), 56 | cleanup(), 57 | babel({ babelHelpers: 'bundled' }) 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /build-config/rollup.config.esm.js: -------------------------------------------------------------------------------- 1 | import builtins from 'rollup-plugin-node-builtins' 2 | import cleanup from 'rollup-plugin-cleanup' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import json from '@rollup/plugin-json' 5 | import resolve from '@rollup/plugin-node-resolve' 6 | 7 | import pkg from '../package.json' 8 | import { banner } from './rollup.common.js' 9 | 10 | export default { 11 | input: 'src/index.js', 12 | external: ['mitt'], 13 | output: [ 14 | { 15 | file: 'dist/esm/statebot.mjs', 16 | banner: banner(pkg), 17 | format: 'es', 18 | name: 'statebot', 19 | exports: 'named', 20 | sourcemap: true 21 | } 22 | ], 23 | plugins: [json(), builtins(), resolve(), commonjs(), cleanup()] 24 | } 25 | -------------------------------------------------------------------------------- /build-config/rollup.config.node.js: -------------------------------------------------------------------------------- 1 | import json from '@rollup/plugin-json' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import builtins from 'rollup-plugin-node-builtins' 5 | import cleanup from 'rollup-plugin-cleanup' 6 | 7 | import pkg from '../package.json' 8 | import { banner } from './rollup.common.js' 9 | 10 | export default { 11 | input: 'src/index.js', 12 | external: ['mitt'], 13 | output: [ 14 | { 15 | file: 'dist/cjs/statebot.js', 16 | banner: banner(pkg), 17 | format: 'cjs', 18 | sourcemap: true 19 | } 20 | ], 21 | plugins: [json(), builtins(), resolve(), commonjs(), cleanup()] 22 | } 23 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #AF00DB; 3 | --dark-hl-0: #C586C0; 4 | --light-hl-1: #000000; 5 | --dark-hl-1: #D4D4D4; 6 | --light-hl-2: #001080; 7 | --dark-hl-2: #9CDCFE; 8 | --light-hl-3: #A31515; 9 | --dark-hl-3: #CE9178; 10 | --light-hl-4: #0000FF; 11 | --dark-hl-4: #569CD6; 12 | --light-hl-5: #0070C1; 13 | --dark-hl-5: #4FC1FF; 14 | --light-hl-6: #795E26; 15 | --dark-hl-6: #DCDCAA; 16 | --light-hl-7: #098658; 17 | --dark-hl-7: #B5CEA8; 18 | --light-hl-8: #800000; 19 | --dark-hl-8: #808080; 20 | --light-hl-9: #800000; 21 | --dark-hl-9: #569CD6; 22 | --light-hl-10: #FF0000; 23 | --dark-hl-10: #9CDCFE; 24 | --light-hl-11: #008000; 25 | --dark-hl-11: #6A9955; 26 | --light-hl-12: #000000FF; 27 | --dark-hl-12: #D4D4D4; 28 | --light-hl-13: #267F99; 29 | --dark-hl-13: #4EC9B0; 30 | --light-code-background: #FFFFFF; 31 | --dark-code-background: #1E1E1E; 32 | } 33 | 34 | @media (prefers-color-scheme: light) { :root { 35 | --hl-0: var(--light-hl-0); 36 | --hl-1: var(--light-hl-1); 37 | --hl-2: var(--light-hl-2); 38 | --hl-3: var(--light-hl-3); 39 | --hl-4: var(--light-hl-4); 40 | --hl-5: var(--light-hl-5); 41 | --hl-6: var(--light-hl-6); 42 | --hl-7: var(--light-hl-7); 43 | --hl-8: var(--light-hl-8); 44 | --hl-9: var(--light-hl-9); 45 | --hl-10: var(--light-hl-10); 46 | --hl-11: var(--light-hl-11); 47 | --hl-12: var(--light-hl-12); 48 | --hl-13: var(--light-hl-13); 49 | --code-background: var(--light-code-background); 50 | } } 51 | 52 | @media (prefers-color-scheme: dark) { :root { 53 | --hl-0: var(--dark-hl-0); 54 | --hl-1: var(--dark-hl-1); 55 | --hl-2: var(--dark-hl-2); 56 | --hl-3: var(--dark-hl-3); 57 | --hl-4: var(--dark-hl-4); 58 | --hl-5: var(--dark-hl-5); 59 | --hl-6: var(--dark-hl-6); 60 | --hl-7: var(--dark-hl-7); 61 | --hl-8: var(--dark-hl-8); 62 | --hl-9: var(--dark-hl-9); 63 | --hl-10: var(--dark-hl-10); 64 | --hl-11: var(--dark-hl-11); 65 | --hl-12: var(--dark-hl-12); 66 | --hl-13: var(--dark-hl-13); 67 | --code-background: var(--dark-code-background); 68 | } } 69 | 70 | :root[data-theme='light'] { 71 | --hl-0: var(--light-hl-0); 72 | --hl-1: var(--light-hl-1); 73 | --hl-2: var(--light-hl-2); 74 | --hl-3: var(--light-hl-3); 75 | --hl-4: var(--light-hl-4); 76 | --hl-5: var(--light-hl-5); 77 | --hl-6: var(--light-hl-6); 78 | --hl-7: var(--light-hl-7); 79 | --hl-8: var(--light-hl-8); 80 | --hl-9: var(--light-hl-9); 81 | --hl-10: var(--light-hl-10); 82 | --hl-11: var(--light-hl-11); 83 | --hl-12: var(--light-hl-12); 84 | --hl-13: var(--light-hl-13); 85 | --code-background: var(--light-code-background); 86 | } 87 | 88 | :root[data-theme='dark'] { 89 | --hl-0: var(--dark-hl-0); 90 | --hl-1: var(--dark-hl-1); 91 | --hl-2: var(--dark-hl-2); 92 | --hl-3: var(--dark-hl-3); 93 | --hl-4: var(--dark-hl-4); 94 | --hl-5: var(--dark-hl-5); 95 | --hl-6: var(--dark-hl-6); 96 | --hl-7: var(--dark-hl-7); 97 | --hl-8: var(--dark-hl-8); 98 | --hl-9: var(--dark-hl-9); 99 | --hl-10: var(--dark-hl-10); 100 | --hl-11: var(--dark-hl-11); 101 | --hl-12: var(--dark-hl-12); 102 | --hl-13: var(--dark-hl-13); 103 | --code-background: var(--dark-code-background); 104 | } 105 | 106 | .hl-0 { color: var(--hl-0); } 107 | .hl-1 { color: var(--hl-1); } 108 | .hl-2 { color: var(--hl-2); } 109 | .hl-3 { color: var(--hl-3); } 110 | .hl-4 { color: var(--hl-4); } 111 | .hl-5 { color: var(--hl-5); } 112 | .hl-6 { color: var(--hl-6); } 113 | .hl-7 { color: var(--hl-7); } 114 | .hl-8 { color: var(--hl-8); } 115 | .hl-9 { color: var(--hl-9); } 116 | .hl-10 { color: var(--hl-10); } 117 | .hl-11 { color: var(--hl-11); } 118 | .hl-12 { color: var(--hl-12); } 119 | .hl-13 { color: var(--hl-13); } 120 | pre, code { background: var(--code-background); } 121 | -------------------------------------------------------------------------------- /docs/assets/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuckster/statebot/d8a58dfe35e18d9756dfe9846952145392bdd6ea/docs/assets/widgets.png -------------------------------------------------------------------------------- /docs/assets/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuckster/statebot/d8a58dfe35e18d9756dfe9846952145392bdd6ea/docs/assets/widgets@2x.png -------------------------------------------------------------------------------- /docs/functions/assert.routeIsPossible.html: -------------------------------------------------------------------------------- 1 | routeIsPossible | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 | 16 |

Function routeIsPossible

17 |
18 |
    19 |
  • routeIsPossible(machine: TStatebotFsm, route: string | string[]): boolean
20 |
    21 |
  • 22 |

    Assert that a certain route can be followed by a 23 | TStatebotFsm.

    24 |

    This merely tests that a certain path can be taken through a 25 | state-machine. It doesn't assert that the states are moved-through 26 | while the machine is working, as with 27 | assertRoute.

    28 | 29 |

    Returns

    30 |

    Example

    import { Statebot } from 'statebot'
    import { routeIsPossible } from 'statebot/assert'

    let machine = Statebot(...)

    routeIsPossible(machine,
    'walking -> falling -> splatting -> walking'
    )
    // false 31 |
    32 |
    33 |
    34 |

    Parameters

    35 |
      36 |
    • 37 |
      machine: TStatebotFsm
      38 |

      The machine to test the route on.

      39 |
    • 40 |
    • 41 |
      route: string | string[]
      42 |

      The route to test as an arrow-delimited string:

      43 |

      "idle -> pending -> success -> done"

      44 |
    45 |

    Returns boolean

46 |
76 |
77 |

Generated using TypeDoc

78 |
-------------------------------------------------------------------------------- /docs/functions/index.Statebot.html: -------------------------------------------------------------------------------- 1 | Statebot | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 | 16 |

Function Statebot

17 |
18 | 20 |
    21 |
  • 22 |

    Create a TStatebotFsm object.

    23 | 24 |

    Example

    import { Statebot } from 'statebot'

    let machine = Statebot('lemming', {
    chart: `
    walking -> (digging | building | falling) ->
    walking

    falling -> splatting
    walking -> exiting
    `
    }) 25 |
    26 |
    27 |
    28 |

    Parameters

    29 |
    36 |

    Returns TStatebotFsm

37 |
72 |
73 |

Generated using TypeDoc

74 |
-------------------------------------------------------------------------------- /docs/functions/index.isStatebot.html: -------------------------------------------------------------------------------- 1 | isStatebot | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 | 16 |

Function isStatebot

17 |
18 |
    19 |
  • isStatebot(object: any): boolean
20 |
    21 |
  • 22 |

    Tests that an object is a TStatebotFsm.

    23 | 24 |

    Example

    import { Statebot } from 'statebot'

    let machine = Statebot(...)

    isStatebot(machine)
    // true 25 |
    26 | 27 |

    Returns

    28 |
    29 |

    Parameters

    30 |
      31 |
    • 32 |
      object: any
      33 |

      The object to test.

      34 |
    35 |

    Returns boolean

36 |
71 |
72 |

Generated using TypeDoc

73 |
-------------------------------------------------------------------------------- /docs/modules.html: -------------------------------------------------------------------------------- 1 | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 |

statebot

13 |
14 |
15 |

Index

16 |
17 |

Modules

18 |
assert 19 | hooks/mithril 20 | hooks/react 21 | index 22 |
23 |
48 |
49 |

Generated using TypeDoc

50 |
-------------------------------------------------------------------------------- /docs/modules/assert.html: -------------------------------------------------------------------------------- 1 | assert | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 | 15 |

Module assert

16 |
17 |
18 |
19 |
20 |

Index

21 |
22 |

Interfaces

23 |
25 |
26 |

Functions

27 |
30 |
60 |
61 |

Generated using TypeDoc

62 |
-------------------------------------------------------------------------------- /docs/modules/hooks_mithril.html: -------------------------------------------------------------------------------- 1 | hooks/mithril | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 | 15 |

Module hooks/mithril

16 |
17 |
18 |
19 |
20 |

Index

21 |
22 |

Functions

23 |
27 |
57 |
58 |

Generated using TypeDoc

59 |
-------------------------------------------------------------------------------- /docs/modules/hooks_react.html: -------------------------------------------------------------------------------- 1 | hooks/react | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 | 15 |

Module hooks/react

16 |
17 |
18 |
19 |
20 |

Index

21 |
22 |

Functions

23 |
27 |
57 |
58 |

Generated using TypeDoc

59 |
-------------------------------------------------------------------------------- /docs/types/index.TEventName.html: -------------------------------------------------------------------------------- 1 | TEventName | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 | 16 |

Type alias TEventName

17 |
TEventName: "onEntered" | "onEntering" | "onEvent" | "onExited" | "onExiting" | "onSwitched" | "onSwitching"
20 |
55 |
56 |

Generated using TypeDoc

57 |
-------------------------------------------------------------------------------- /docs/types/index.TListenersRemover.html: -------------------------------------------------------------------------------- 1 | TListenersRemover | statebot
2 |
3 | 8 |
9 |
10 |
11 |
12 | 16 |

Type alias TListenersRemover

17 |
TListenersRemover: Function
20 |
55 |
56 |

Generated using TypeDoc

57 |
-------------------------------------------------------------------------------- /hooks/make-hooks.mjs: -------------------------------------------------------------------------------- 1 | export const makeHooks = ({ Statebot, useEffect, useState, useMemo }) => { 2 | if (![useEffect, useState, useMemo].every(x => typeof x === 'function')) { 3 | console.warn('Statebot Hooks unavailable: React or Mithril not found') 4 | } 5 | 6 | function useStatebot(bot) { 7 | const [state, setState] = useState(bot.currentState()) 8 | 9 | useEffect(() => { 10 | let done = false 11 | 12 | const removeListener = bot.onSwitched((toState) => { 13 | if (done) { 14 | return 15 | } 16 | setState(toState) 17 | }) 18 | 19 | return () => { 20 | done = true 21 | removeListener() 22 | } 23 | }, [bot]) 24 | 25 | return state 26 | } 27 | 28 | function useStatebotFactory(name, config) { 29 | // We memoise Statebot since it's based on EventEmitter, 30 | // so we create it once and add/remove listeners for 31 | // the life-cycle of the component 32 | const { bot, listeners } = useMemo(() => { 33 | const { 34 | performTransitions = {}, 35 | onTransitions = {}, 36 | ...botConfig 37 | } = config || {} 38 | 39 | const bot = Statebot(name, botConfig) 40 | const listeners = [ 41 | bot.performTransitions(performTransitions), 42 | bot.onTransitions(onTransitions), 43 | ] 44 | 45 | return { 46 | bot, 47 | listeners, 48 | } 49 | }, []) 50 | 51 | useEffect( 52 | () => () => { 53 | if (typeof bot.pause === 'function') { 54 | bot.pause() 55 | } 56 | listeners.forEach((off) => off()) 57 | }, 58 | [bot, listeners] 59 | ) 60 | 61 | const state = useStatebot(bot) 62 | 63 | return { state, bot } 64 | } 65 | 66 | function useStatebotEvent(bot, eventName, stateOrFn, maybeFn) { 67 | useEffect(() => { 68 | let done = false 69 | 70 | function onSwitchFn(...args) { 71 | if (done) { 72 | return 73 | } 74 | stateOrFn(...args) 75 | } 76 | function onEnterOrExitFn(...args) { 77 | if (done) { 78 | return 79 | } 80 | maybeFn(...args) 81 | } 82 | 83 | const args = 84 | typeof maybeFn === 'function' 85 | ? [stateOrFn, onEnterOrExitFn] 86 | : [onSwitchFn] 87 | 88 | const removeListener = bot[eventName](...args) 89 | 90 | return () => { 91 | done = true 92 | removeListener() 93 | } 94 | }, [bot, eventName, stateOrFn, maybeFn]) 95 | } 96 | 97 | return { 98 | useStatebot, 99 | useStatebotFactory, 100 | useStatebotEvent, 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /hooks/mithril/README.md: -------------------------------------------------------------------------------- 1 | # statebot/hooks/mithril 2 | 3 | Mithril Hooks for Statebot. 4 | 5 | - [Statebot](https://github.com/shuckster/statebot) is a Finite State Machine library. 6 | - [Mithril](https://mithril.js.org/) is an [SPA](https://en.wikipedia.org/wiki/Single-page_application) framework. 7 | - [mithril-hooks](https://github.com/ArthurClemens/mithril-hooks) bring React-like Hooks to Mithril. 8 | 9 | For React itself, see: [statebot/hooks/react](https://github.com/shuckster/statebot/tree/master/hooks/react) 10 | 11 | - [Examples](#examples) 12 | - [useStatebot](#usestatebot) 13 | - [useStatebotFactory](#usestatebotfactory) 14 | - [useStatebotEvent](#useStatebotEvent) 15 | - [Notes](#notes) 16 | - [useRef](#useref) 17 | - [Contributing](#contributing) 18 | - [License](#license) 19 | 20 | # Examples 21 | 22 | Installation: 23 | 24 | ```sh 25 | npm i mithril mithril-hooks statebot 26 | ``` 27 | 28 | ## useStatebot 29 | 30 | For hooking-into Statebots that have life-cycles independent of the components that use them, `useStatebot`: 31 | 32 | ```jsx 33 | import m from 'mithril' 34 | import { withHooks as MithrilComponent } from 'mithril-hooks' 35 | 36 | import { Statebot } from 'statebot' 37 | import { useStatebot } from 'statebot/hooks/mithril' 38 | 39 | export const loadMachine = Statebot('loader', { 40 | chart: ` 41 | idle -> 42 | waiting -> 43 | loaded | failed -> 44 | idle 45 | ` 46 | }) 47 | 48 | loadMachine.performTransitions(({ emit }) => ({ 49 | 'idle -> waiting': { 50 | on: 'start-loading', 51 | then: () => { 52 | // Fail half the time for this demo 53 | const fail = Math.random() > 0.5 54 | setTimeout(() => { 55 | fail ? emit('error') : emit('success') 56 | }, 1000) 57 | } 58 | }, 59 | 'waiting -> loaded': { 60 | on: 'success' 61 | }, 62 | 'waiting -> failed': { 63 | on: 'error' 64 | } 65 | })) 66 | 67 | const { Enter, Emit, inState } = loadMachine 68 | 69 | const LoadingButton = MithrilComponent(() => { 70 | const state = useStatebot(loadMachine) 71 | 72 | return m( 73 | 'button', 74 | { 75 | className: state, 76 | onclick: Emit('start-loading'), 77 | disabled: !inState('idle') 78 | }, 79 | inState('idle', 'Load'), 80 | inState('waiting', 'Please wait...'), 81 | inState('loaded', 'Done!'), 82 | inState('failed', 'Whoops!') 83 | ) 84 | }) 85 | 86 | const ResetButton = MithrilComponent(() => { 87 | return m( 88 | 'button', 89 | { 90 | onclick: Enter('idle') 91 | }, 92 | 'Reset' 93 | ) 94 | }) 95 | ``` 96 | 97 | You can play around with this one in a [CodeSandbox](https://codesandbox.io/s/statebot-mithril-brvwl?file=/src/Loader.js). 98 | 99 | ## useStatebotFactory 100 | 101 | For Statebots whose life-cycles are tied to the components using them, `useStatebotFactory`: 102 | 103 | ```jsx 104 | import m from 'mithril' 105 | import { withHooks } from 'mithril-hooks' 106 | import { useStatebotFactory } from 'statebot/hooks/mithril' 107 | 108 | const CHART = ` 109 | idle -> 110 | loading -> (loaded | failed) -> 111 | idle 112 | ` 113 | 114 | const EVENT = { 115 | START_LOADING: 'start-loading', 116 | LOAD_SUCCESS: 'load-success', 117 | LOAD_ERROR: 'load-error' 118 | } 119 | 120 | const LoadingButton = withHooks(props => { 121 | const { state, bot } = useStatebotFactory('loading-button', { 122 | chart: CHART, 123 | startIn: 'idle', 124 | logLevel: 4, 125 | 126 | performTransitions: ({ Emit }) => ({ 127 | 'idle -> loading': { 128 | on: EVENT.START_LOADING, 129 | then: () => setTimeout(Emit(EVENT.LOAD_SUCCESS), 1000) 130 | }, 131 | 'loading -> loaded': { 132 | on: EVENT.LOAD_SUCCESS 133 | }, 134 | 'loading -> failed': { 135 | on: EVENT.LOAD_ERROR 136 | } 137 | }), 138 | 139 | onTransitions: () => ({ 140 | 'loading -> failed': () => { 141 | console.log('Oops...') 142 | } 143 | }) 144 | }) 145 | 146 | return ( 147 | 156 | ) 157 | }) 158 | ``` 159 | 160 | ## useStatebotEvent 161 | 162 | To hook-into [onEvent](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onEvent), [onEntering](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onEntering)/[ed](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onEntered), [onExiting](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onExiting)/[ed](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onExited), [onSwitching](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onSwitching)/[ed](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onSwitched) with side-effects cleanup, `useStatebotEvent`: 163 | 164 | ```jsx 165 | import m from 'mithril' 166 | import { withHooks } from 'mithril-hooks' 167 | import { Statebot } from 'statebot' 168 | import { useStatebot, useStatebotEvent } from 'statebot/hooks/mithril' 169 | 170 | const bot = Statebot('loader', { 171 | chart: ` 172 | idle -> 173 | loading -> (loaded | failed) -> 174 | idle 175 | ` 176 | }) 177 | 178 | const { Enter, Emit, inState } = bot 179 | 180 | const LoadingButton = withHooks(props => { 181 | const state = useStatebot(bot) 182 | 183 | useStatebotEvent(bot, 'onEntered', 'loading', () => 184 | setTimeout(bot.Emit(EVENT.LOAD_SUCCESS), seconds(1)) 185 | ) 186 | 187 | // You can achieve the same with useEffect, and you 188 | // get more control over the dependencies, too: 189 | useEffect(() => { 190 | const cleanupFn = bot.onExited('loading', () => 191 | setTimeout(bot.Enter('idle'), seconds(2)) 192 | ) 193 | return cleanupFn 194 | }, [bot]) 195 | 196 | return ( 197 | 206 | ) 207 | }) 208 | 209 | function seconds(n) { 210 | return n * 1000 211 | } 212 | ``` 213 | 214 | # Notes 215 | 216 | As you can see, the examples use [JSX](https://reactjs.org/docs/introducing-jsx.html), which is not always typical of a Mithril project. 217 | 218 | Here are some of the config settings for getting this working, if you're interested (lifted from the [Mithril docs](https://mithril.js.org/jsx.html)): 219 | 220 | ```sh 221 | npm i --save-dev @babel/plugin-transform-react-jsx 222 | ``` 223 | 224 | ```js 225 | // .babelrc 226 | { 227 | "plugins": [ 228 | ["@babel/plugin-transform-react-jsx", { 229 | "pragma": "m", 230 | "pragmaFrag": "'['" 231 | }] 232 | ] 233 | } 234 | 235 | ``` 236 | 237 | ```sh 238 | npm i --save-dev eslint-plugin-mithril 239 | ``` 240 | 241 | ```js 242 | // .eslintrc 243 | { 244 | "parserOptions": { 245 | "ecmaFeatures": { 246 | "jsx": true 247 | } 248 | }, 249 | "extends": ["plugin:mithril/recommended"] 250 | } 251 | ``` 252 | 253 | ## useRef 254 | 255 | There's no `useRef` hook provided by `mithril-hooks`, but the following technique works for me: 256 | 257 | ```jsx 258 | import m from 'mithril' 259 | import { withHooks, useState } from 'mithril-hooks' 260 | 261 | const ContainerWithRef = withHooks(props => { 262 | const { children, setRef = () => {} } = props || {} 263 | return
setRef(vnode.dom)}>{children}
264 | }) 265 | 266 | // Later... 267 | 268 | const MyComponentThatNeedsAnElementRef = withHooks(props => { 269 | const { children } = props || {} 270 | const [elementRef, setElementRef] = useState() 271 | 272 | useEffect(() => { 273 | console.log('elementRef = ', elementRef) 274 | }, [elementRef]) 275 | 276 | return ( 277 | <> 278 | {children} 279 | 280 | ) 281 | }) 282 | ``` 283 | 284 | # Contributing 285 | 286 | This is a pretty basic implementation of hooks for Statebot. I don't _think_ much else is needed, but by all means fork and tinker with it as you like. 287 | 288 | Of course, please stop-by the [Statebot repo](https://github.com/shuckster/statebot) itself. :) 289 | 290 | ## License 291 | 292 | Statebot was written by [Conan Theobald](https://github.com/shuckster/) and is [MIT licensed](./LICENSE). 293 | -------------------------------------------------------------------------------- /hooks/mithril/index.d.ts: -------------------------------------------------------------------------------- 1 | import { TStatebotFsm, TStatebotOptions, TEventName } from 'statebot' 2 | 3 | declare module 'statebot/hooks/mithril' { 4 | /** 5 | * For hooking-into Statebots that have life-cycles independent of the components that use them, `useStatebot`: 6 | * 7 | * You can play around with this one in a [CodeSandbox](https://codesandbox.io/s/statebot-mithril-brvwl?file=/src/Loader.js). 8 | * 9 | * @example 10 | * ```jsx 11 | * import m from 'mithril' 12 | * import { withHooks as MithrilComponent } from 'mithril-hooks' 13 | * 14 | * import { Statebot } from 'statebot' 15 | * import { useStatebot } from 'statebot/hooks/mithril' 16 | * 17 | * let loadMachine = Statebot('loader', { 18 | * chart: ` 19 | * idle -> 20 | * waiting -> 21 | * loaded | failed -> 22 | * idle 23 | * ` 24 | * }) 25 | * 26 | * loadMachine.performTransitions(({ emit }) => ({ 27 | * 'idle -> waiting': { 28 | * on: 'start-loading', 29 | * then: () => { 30 | * // Fail half the time for this demo 31 | * let fail = Math.random() > 0.5 32 | * setTimeout(() => { 33 | * fail ? emit('error') : emit('success') 34 | * }, 1000) 35 | * } 36 | * }, 37 | * 'waiting -> loaded': { 38 | * on: 'success' 39 | * }, 40 | * 'waiting -> failed': { 41 | * on: 'error' 42 | * } 43 | * })) 44 | * 45 | * let { Enter, Emit, inState } = loadMachine 46 | * 47 | * let LoadingButton = MithrilComponent(() => { 48 | * let state = useStatebot(loadMachine) 49 | * 50 | * return m( 51 | * 'button', 52 | * { 53 | * className: state, 54 | * onclick: Emit('start-loading'), 55 | * disabled: !inState('idle') 56 | * }, 57 | * inState('idle', 'Load'), 58 | * inState('waiting', 'Please wait...'), 59 | * inState('loaded', 'Done!'), 60 | * inState('failed', 'Whoops!') 61 | * ) 62 | * }) 63 | * 64 | * let ResetButton = MithrilComponent(() => { 65 | * return m( 66 | * 'button', 67 | * { 68 | * onclick: Enter('idle') 69 | * }, 70 | * 'Reset' 71 | * ) 72 | * }) 73 | * ``` 74 | * 75 | * @param bot The machine to hook-into. 76 | * @returns The current state of the machine. 77 | */ 78 | export function useStatebot(bot: TStatebotFsm): string 79 | 80 | /** 81 | * For Statebots whose life-cycles are tied to the components using them, `useStatebotFactory`: 82 | * 83 | * @example 84 | * ```jsx 85 | * import m from 'mithril' 86 | * import { withHooks } from 'mithril-hooks' 87 | * 88 | * import { useStatebotFactory } from 'statebot/hooks/mithril' 89 | * 90 | * const CHART = ` 91 | * idle -> 92 | * loading -> (loaded | failed) -> 93 | * idle 94 | * ` 95 | * 96 | * const EVENT = { 97 | * START_LOADING: 'start-loading', 98 | * LOAD_SUCCESS: 'load-success', 99 | * LOAD_ERROR: 'load-error' 100 | * } 101 | * 102 | * const LoadingButton = withHooks(props => { 103 | * const { state, bot } = useStatebotFactory( 104 | * 'loading-button', 105 | * { 106 | * chart: CHART, 107 | * startIn: 'idle', 108 | * logLevel: 4, 109 | * 110 | * performTransitions: ({ Emit }) => ({ 111 | * 'idle -> loading': { 112 | * on: EVENT.START_LOADING, 113 | * then: () => setTimeout( 114 | * Emit(EVENT.LOAD_SUCCESS), 115 | * 1000 116 | * ) 117 | * }, 118 | * 'loading -> loaded': { 119 | * on: EVENT.LOAD_SUCCESS 120 | * }, 121 | * 'loading -> failed': { 122 | * on: EVENT.LOAD_ERROR 123 | * } 124 | * }), 125 | * 126 | * onTransitions: () => ({ 127 | * 'loading -> failed': () => { 128 | * console.log('Oops...') 129 | * } 130 | * }) 131 | * } 132 | * ) 133 | * 134 | * return ( 135 | * 144 | * ) 145 | * }) 146 | * ``` 147 | * @param name The name of the machine. 148 | * @param config The chart and other configuration. 149 | * @returns The current state of the machine and the machine itself. 150 | */ 151 | export function useStatebotFactory( 152 | name: string, 153 | config: TStatebotOptions & { 154 | performTransitions?: any 155 | onTransitions?: any 156 | } 157 | ): { state: string; bot: TStatebotFsm } 158 | 159 | /** 160 | * To hook-into {@link index.TStatebotFsm.onEvent}, {@link index.TStatebotFsm.onEntering}/{@link index.TStatebotFsm.onEntered ed}, {@link index.TStatebotFsm.onExiting}/{@link index.TStatebotFsm.onExited ed}, {@link index.TStatebotFsm.onSwitching}/{@link index.TStatebotFsm.onSwitched ed} with side-effects cleanup, `useStatebotEvent`: 161 | * 162 | * @example 163 | * ```jsx 164 | * import m from 'mithril' 165 | * import { withHooks } from 'mithril-hooks' 166 | * 167 | * import { Statebot } from 'statebot' 168 | * import { useStatebot, useStatebotEvent } from 'statebot/hooks/mithril' 169 | * 170 | * const bot = Statebot('loader', { 171 | * chart: ` 172 | * idle -> 173 | * loading -> (loaded | failed) -> 174 | * idle 175 | * ` 176 | * }) 177 | * 178 | * const { Enter, Emit, inState } = bot 179 | * 180 | * const LoadingButton = withHooks(props => { 181 | * const state = useStatebot(bot) 182 | * 183 | * useStatebotEvent(bot, 'onEntered', 'loading', () => 184 | * setTimeout( 185 | * bot.Emit(EVENT.LOAD_SUCCESS), 186 | * seconds(1) 187 | * ) 188 | * ) 189 | * 190 | * // You can achieve the same with useEffect, and you 191 | * // get more control over the dependencies, too: 192 | * useEffect(() => { 193 | * const cleanupFn = bot.onExited('loading', () => 194 | * setTimeout( 195 | * bot.Enter('idle'), 196 | * seconds(2) 197 | * ) 198 | * ) 199 | * return cleanupFn 200 | * }, [bot]) 201 | * 202 | * return ( 203 | * 212 | * ) 213 | * }) 214 | * 215 | * function seconds(n) { 216 | * return n * 1000 217 | * } 218 | * ``` 219 | * 220 | * @param bot The machine to hook-into. 221 | * @param eventName The event to listen for. 222 | * @param state Fire the callback when we transition to this state. 223 | * @param callback The callback. 224 | */ 225 | export function useStatebotEvent( 226 | bot: TStatebotFsm, 227 | eventName: TEventName, 228 | state: string, 229 | cb: Function 230 | ) 231 | 232 | /** 233 | * To hook-into {@link index.TStatebotFsm.onEvent}, {@link index.TStatebotFsm.onEntering}/{@link index.TStatebotFsm.onEntered ed}, {@link index.TStatebotFsm.onExiting}/{@link index.TStatebotFsm.onExited ed}, {@link index.TStatebotFsm.onSwitching}/{@link index.TStatebotFsm.onSwitched ed} with side-effects cleanup, `useStatebotEvent`: 234 | * 235 | * @example 236 | * ```jsx 237 | * import m from 'mithril' 238 | * import { withHooks } from 'mithril-hooks' 239 | * 240 | * import { Statebot } from 'statebot' 241 | * import { useStatebot, useStatebotEvent } from 'statebot/hooks/mithril' 242 | * 243 | * const bot = Statebot('loader', { 244 | * chart: ` 245 | * idle -> 246 | * loading -> (loaded | failed) -> 247 | * idle 248 | * ` 249 | * }) 250 | * 251 | * const { Enter, Emit, inState } = bot 252 | * 253 | * const LoadingButton = withHooks(props => { 254 | * const state = useStatebot(bot) 255 | * 256 | * useStatebotEvent(bot, 'onEntered', (toState) => 257 | * if (toState !== 'loading') { 258 | * return 259 | * } 260 | * setTimeout( 261 | * bot.Emit(EVENT.LOAD_SUCCESS), 262 | * seconds(1) 263 | * ) 264 | * ) 265 | * 266 | * // You can achieve the same with useEffect, and you 267 | * // get more control over the dependencies, too: 268 | * useEffect(() => { 269 | * const cleanupFn = bot.onExited('loading', () => 270 | * setTimeout( 271 | * bot.Enter('idle'), 272 | * seconds(2) 273 | * ) 274 | * ) 275 | * return cleanupFn 276 | * }, [bot]) 277 | * 278 | * return ( 279 | * 288 | * ) 289 | * }) 290 | * 291 | * function seconds(n) { 292 | * return n * 1000 293 | * } 294 | * ``` 295 | * @param bot The machine to hook-into. 296 | * @param eventName The event to listen for. 297 | * @param callback Fire this callback when the event is emitted. 298 | */ 299 | export function useStatebotEvent( 300 | bot: TStatebotFsm, 301 | eventName: TEventName, 302 | cb: Function 303 | ) 304 | } 305 | -------------------------------------------------------------------------------- /hooks/mithril/index.mjs: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo } from 'mithril-hooks' 2 | import { Statebot } from 'statebot' 3 | import { makeHooks } from '../make-hooks.mjs' 4 | 5 | export const { useStatebot, useStatebotFactory, useStatebotEvent } = makeHooks({ 6 | Statebot, 7 | useEffect, 8 | useState, 9 | useMemo 10 | }) 11 | -------------------------------------------------------------------------------- /hooks/mithril/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "main": "index.mjs", 4 | "module": "index.mjs", 5 | "peerDependencies": { 6 | "mithril-hooks": "^0.7 || ^0.6 || ^0.5.6", 7 | "statebot": "^2" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /hooks/react/README.md: -------------------------------------------------------------------------------- 1 | # statebot/hooks/react 2 | 3 | React Hooks for Statebot. 4 | 5 | - [Statebot](https://github.com/shuckster/statebot) is a Finite State Machine library. 6 | - [React](https://reactjs.org/) is a JavaScript library for building user interfaces. 7 | 8 | For a Mithril version, see: [statebot/hooks/mithril](https://github.com/shuckster/statebot/tree/master/hooks/mithril) 9 | 10 | - [Examples](#examples) 11 | - [useStatebot](#usestatebot) 12 | - [useStatebotFactory](#usestatebotfactory) 13 | - [useStatebotEvent](#useStatebotEvent) 14 | - [Contributing](#contributing) 15 | - [License](#license) 16 | 17 | # Examples 18 | 19 | Installation: 20 | 21 | ```sh 22 | npm i react react-dom statebot 23 | ``` 24 | 25 | ## useStatebot 26 | 27 | For hooking-into Statebots that have life-cycles independent of the components that use them, `useStatebot`: 28 | 29 | ```jsx 30 | import React from 'react' 31 | 32 | import { Statebot } from 'statebot' 33 | import { useStatebot } from 'statebot/hooks/react' 34 | 35 | export const loadMachine = Statebot('loader', { 36 | chart: ` 37 | idle -> 38 | waiting -> 39 | loaded | failed -> 40 | idle 41 | ` 42 | }) 43 | 44 | loadMachine.performTransitions(({ emit }) => ({ 45 | 'idle -> waiting': { 46 | on: 'start-loading', 47 | then: () => { 48 | // Fail half the time for this demo 49 | const fail = Math.random() > 0.5 50 | setTimeout(() => { 51 | fail ? emit('error') : emit('success') 52 | }, 1000) 53 | } 54 | }, 55 | 'waiting -> loaded': { 56 | on: 'success' 57 | }, 58 | 'waiting -> failed': { 59 | on: 'error' 60 | } 61 | })) 62 | 63 | const { Enter, Emit, inState } = loadMachine 64 | 65 | function LoadingButton() { 66 | const state = useStatebot(loadMachine) 67 | 68 | return ( 69 | 79 | ) 80 | } 81 | 82 | function ResetButton() { 83 | return 84 | } 85 | ``` 86 | 87 | You can play around with this one in a [CodeSandbox](https://codesandbox.io/s/statebot-react-ot3xe?file=/src/Loader.js). 88 | 89 | ## useStatebotFactory 90 | 91 | For Statebots whose life-cycles are tied to the components using them, `useStatebotFactory`: 92 | 93 | ```jsx 94 | import React from 'react' 95 | import { useStatebotFactory } from 'statebot/hooks/react' 96 | 97 | const CHART = ` 98 | idle -> 99 | loading -> (loaded | failed) -> 100 | idle 101 | ` 102 | 103 | const EVENT = { 104 | START_LOADING: 'start-loading', 105 | LOAD_SUCCESS: 'load-success', 106 | LOAD_ERROR: 'load-error' 107 | } 108 | 109 | function LoadingButton (props) { 110 | const { state, bot } = useStatebotFactory( 111 | 'loading-button', 112 | { 113 | chart: CHART, 114 | startIn: 'idle', 115 | logLevel: 4, 116 | 117 | performTransitions: ({ Emit }) => ({ 118 | 'idle -> loading': { 119 | on: EVENT.START_LOADING, 120 | then: () => setTimeout( 121 | Emit(EVENT.LOAD_SUCCESS), 122 | 1000 123 | ) 124 | }, 125 | 'loading -> loaded': { 126 | on: EVENT.LOAD_SUCCESS 127 | }, 128 | 'loading -> failed': { 129 | on: EVENT.LOAD_ERROR 130 | } 131 | }), 132 | 133 | onTransitions: () => ({ 134 | 'loading -> failed': () => { 135 | console.log('Oops...') 136 | } 137 | }) 138 | } 139 | ) 140 | 141 | return ( 142 | 151 | ) 152 | }) 153 | ``` 154 | 155 | ## useStatebotEvent 156 | 157 | To hook-into [onEvent](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onEvent), [onEntering](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onEntering)/[ed](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onEntered), [onExiting](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onExiting)/[ed](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onExited), [onSwitching](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onSwitching)/[ed](https://zansh.in/statebot/interfaces/index.TStatebotFsm.html#onSwitched) with side-effects cleanup, `useStatebotEvent`: 158 | 159 | ```jsx 160 | import React from 'react' 161 | import { Statebot } from 'statebot' 162 | import { useStatebot, useStatebotEvent } from 'statebot/hooks/react' 163 | 164 | const bot = Statebot('loader', { 165 | chart: ` 166 | idle -> 167 | loading -> (loaded | failed) -> 168 | idle 169 | ` 170 | }) 171 | 172 | const { Enter, Emit, inState } = bot 173 | 174 | function LoadingButton() { 175 | const state = useStatebot(bot) 176 | 177 | useStatebotEvent(bot, 'onEntered', 'loading', () => 178 | setTimeout(bot.Emit(EVENT.LOAD_SUCCESS), seconds(1)) 179 | ) 180 | 181 | // You can achieve the same with useEffect, and you 182 | // get more control over the dependencies, too: 183 | useEffect(() => { 184 | const cleanupFn = bot.onExited('loading', () => 185 | setTimeout(bot.Enter('idle'), seconds(2)) 186 | ) 187 | return cleanupFn 188 | }, [bot]) 189 | 190 | return ( 191 | 200 | ) 201 | } 202 | 203 | function seconds(n) { 204 | return n * 1000 205 | } 206 | ``` 207 | 208 | # Contributing 209 | 210 | This is a pretty basic implementation of hooks for Statebot. I don't _think_ much else is needed, but by all means fork and tinker with it as you like. 211 | 212 | Of course, please stop-by the [Statebot repo](https://github.com/shuckster/statebot) itself. :) 213 | 214 | ## License 215 | 216 | Statebot was written by [Conan Theobald](https://github.com/shuckster/) and is [MIT licensed](./LICENSE). 217 | -------------------------------------------------------------------------------- /hooks/react/index.d.ts: -------------------------------------------------------------------------------- 1 | import { TStatebotFsm, TStatebotOptions, TEventName } from 'statebot' 2 | 3 | declare module 'statebot/hooks/react' { 4 | /** 5 | * For hooking-into Statebots that have life-cycles independent of the components that use them, `useStatebot`: 6 | * 7 | * You can play around with this one in a [CodeSandbox](https://codesandbox.io/s/statebot-react-ot3xe?file=/src/Loader.js). 8 | * 9 | * @example 10 | * ```jsx 11 | * import React from 'react' 12 | * 13 | * import { Statebot } from 'statebot' 14 | * import { useStatebot } from 'statebot/hooks/react' 15 | * 16 | * export let loadMachine = Statebot('loader', { 17 | * chart: ` 18 | * idle -> 19 | * waiting -> 20 | * loaded | failed -> 21 | * idle 22 | * ` 23 | * }) 24 | * 25 | * loadMachine.performTransitions(({ emit }) => ({ 26 | * 'idle -> waiting': { 27 | * on: 'start-loading', 28 | * then: () => { 29 | * // Fail half the time for this demo 30 | * let fail = Math.random() > 0.5 31 | * setTimeout(() => { 32 | * fail ? emit('error') : emit('success') 33 | * }, 1000) 34 | * } 35 | * }, 36 | * 'waiting -> loaded': { 37 | * on: 'success' 38 | * }, 39 | * 'waiting -> failed': { 40 | * on: 'error' 41 | * } 42 | * })) 43 | * 44 | * let { Enter, Emit, inState } = loadMachine 45 | * 46 | * function LoadingButton() { 47 | * let state = useStatebot(loadMachine) 48 | * 49 | * return ( 50 | * 60 | * ) 61 | * } 62 | * 63 | * function ResetButton() { 64 | * return 65 | * } 66 | * ``` 67 | * 68 | * @param bot The machine to hook-into. 69 | * @returns The current state of the machine. 70 | */ 71 | export function useStatebot(bot: TStatebotFsm) 72 | 73 | /** 74 | * For Statebots whose life-cycles are tied to the components using them, `useStatebotFactory`: 75 | * 76 | * @example 77 | * ```jsx 78 | * import React from 'react' 79 | * 80 | * import { useStatebotFactory } from 'statebot/hooks/react' 81 | * 82 | * let CHART = ` 83 | * idle -> 84 | * loading -> (loaded | failed) -> 85 | * idle 86 | * ` 87 | * 88 | * let EVENT = { 89 | * START_LOADING: 'start-loading', 90 | * LOAD_SUCCESS: 'load-success', 91 | * LOAD_ERROR: 'load-error' 92 | * } 93 | * 94 | * function LoadingButton (props) { 95 | * let { state, bot } = useStatebotFactory( 96 | * 'loading-button', 97 | * { 98 | * chart: CHART, 99 | * startIn: 'idle', 100 | * logLevel: 4, 101 | * 102 | * performTransitions: ({ Emit }) => ({ 103 | * 'idle -> loading': { 104 | * on: EVENT.START_LOADING, 105 | * then: () => setTimeout( 106 | * Emit(EVENT.LOAD_SUCCESS), 107 | * 1000 108 | * ) 109 | * }, 110 | * 'loading -> loaded': { 111 | * on: EVENT.LOAD_SUCCESS 112 | * }, 113 | * 'loading -> failed': { 114 | * on: EVENT.LOAD_ERROR 115 | * } 116 | * }), 117 | * 118 | * onTransitions: () => ({ 119 | * 'loading -> failed': () => { 120 | * console.log('Oops...') 121 | * } 122 | * }) 123 | * } 124 | * ) 125 | * 126 | * return ( 127 | * 136 | * ) 137 | * } 138 | * ``` 139 | * @param name The name of the machine. 140 | * @param config The chart and other configuration. 141 | * @returns The current state of the machine and the machine itself. 142 | */ 143 | export function useStatebotFactory( 144 | name: string, 145 | config: TStatebotOptions & { 146 | performTransitions?: any 147 | onTransitions?: any 148 | } 149 | ): { state: string; bot: TStatebotFsm } 150 | 151 | /** 152 | * To hook-into {@link index.TStatebotFsm.onEvent}, {@link index.TStatebotFsm.onEntering}/{@link index.TStatebotFsm.onEntered ed}, {@link index.TStatebotFsm.onExiting}/{@link index.TStatebotFsm.onExited ed}, {@link index.TStatebotFsm.onSwitching}/{@link index.TStatebotFsm.onSwitched ed} with side-effects cleanup, `useStatebotEvent`: 153 | * 154 | * @example 155 | * ```jsx 156 | * import React from 'react' 157 | * 158 | * import { Statebot } from 'statebot' 159 | * import { useStatebot, useStatebotEvent } from 'statebot/hooks/react' 160 | * 161 | * let bot = Statebot('loader', { 162 | * chart: ` 163 | * idle -> 164 | * loading -> (loaded | failed) -> 165 | * idle 166 | * ` 167 | * }) 168 | * 169 | * let { Enter, Emit, inState } = bot 170 | * 171 | * function LoadingButton() { 172 | * let state = useStatebot(bot) 173 | * 174 | * useStatebotEvent(bot, 'onEntered', 'loading', () => 175 | * setTimeout( 176 | * bot.Emit(EVENT.LOAD_SUCCESS), 177 | * seconds(1) 178 | * ) 179 | * ) 180 | * 181 | * // You can achieve the same with useEffect, and you 182 | * // get more control over the dependencies, too: 183 | * useEffect(() => { 184 | * let cleanupFn = bot.onExited('loading', () => 185 | * setTimeout( 186 | * bot.Enter('idle'), 187 | * seconds(2) 188 | * ) 189 | * ) 190 | * return cleanupFn 191 | * }, [bot]) 192 | * 193 | * return ( 194 | * 203 | * ) 204 | * } 205 | * 206 | * function seconds(n) { 207 | * return n * 1000 208 | * } 209 | * ``` 210 | * @param bot The machine to hook-into. 211 | * @param eventName The event to listen for. 212 | * @param state Fire the callback when we transition to this state. 213 | * @param callback The callback. 214 | */ 215 | export function useStatebotEvent( 216 | bot: TStatebotFsm, 217 | eventName: TEventName, 218 | state: string, 219 | cb: Function 220 | ) 221 | 222 | /** 223 | * To hook-into {@link index.TStatebotFsm.onEvent}, {@link index.TStatebotFsm.onEntering}/{@link index.TStatebotFsm.onEntered ed}, {@link index.TStatebotFsm.onExiting}/{@link index.TStatebotFsm.onExited ed}, {@link index.TStatebotFsm.onSwitching}/{@link index.TStatebotFsm.onSwitched ed} with side-effects cleanup, `useStatebotEvent`: 224 | * 225 | * @example 226 | * ```jsx 227 | * import React from 'react' 228 | * 229 | * import { Statebot } from 'statebot' 230 | * import { useStatebot, useStatebotEvent } from 'statebot/hooks/react' 231 | * 232 | * let bot = Statebot('loader', { 233 | * chart: ` 234 | * idle -> 235 | * loading -> (loaded | failed) -> 236 | * idle 237 | * ` 238 | * }) 239 | * 240 | * let { Enter, Emit, inState } = bot 241 | * 242 | * function LoadingButton() { 243 | * let state = useStatebot(bot) 244 | * 245 | * useStatebotEvent(bot, 'onEntered', (toState) => 246 | * if (toState !== 'loading') { 247 | * return 248 | * } 249 | * setTimeout( 250 | * bot.Emit(EVENT.LOAD_SUCCESS), 251 | * seconds(1) 252 | * ) 253 | * ) 254 | * 255 | * // You can achieve the same with useEffect, and you 256 | * // get more control over the dependencies, too: 257 | * useEffect(() => { 258 | * let cleanupFn = bot.onExited('loading', () => 259 | * setTimeout( 260 | * bot.Enter('idle'), 261 | * seconds(2) 262 | * ) 263 | * ) 264 | * return cleanupFn 265 | * }, [bot]) 266 | * 267 | * return ( 268 | * 277 | * ) 278 | * } 279 | * 280 | * function seconds(n) { 281 | * return n * 1000 282 | * } 283 | * ``` 284 | * @param bot The machine to hook-into. 285 | * @param eventName The event to listen for. 286 | * @param callback Fire this callback when the event is emitted. 287 | */ 288 | export function useStatebotEvent( 289 | bot: TStatebotFsm, 290 | eventName: TEventName, 291 | cb: Function 292 | ) 293 | } 294 | -------------------------------------------------------------------------------- /hooks/react/index.mjs: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo } from 'react' 2 | import { Statebot } from 'statebot' 3 | import { makeHooks } from '../make-hooks.mjs' 4 | 5 | export const { useStatebot, useStatebotFactory, useStatebotEvent } = makeHooks({ 6 | Statebot, 7 | useEffect, 8 | useState, 9 | useMemo 10 | }) 11 | -------------------------------------------------------------------------------- /hooks/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "main": "index.mjs", 4 | "module": "index.mjs", 5 | "peerDependencies": { 6 | "react": "^18 || ^17 || ^16.8", 7 | "statebot": "^2" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /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 | coverageProvider: "v8", 6 | roots: [ 7 | "/tests" 8 | ], 9 | testEnvironment: "node" 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "statebot", 3 | "description": "Describe the states and allowed transitions of a program using a flowchart-like syntax. Switch to states directly, or by wiring-up events. Statebot is an FSM.", 4 | "version": "3.1.3", 5 | "author": "Conan Theobald", 6 | "license": "MIT", 7 | "keywords": [ 8 | "finite automata", 9 | "state machine", 10 | "state chart", 11 | "adjacency graph", 12 | "events", 13 | "fsm" 14 | ], 15 | "types": "index.d.ts", 16 | "main": "./dist/cjs/statebot.js", 17 | "module": "dist/esm/statebot.mjs", 18 | "exports": { 19 | ".": { 20 | "types": "./index.d.ts", 21 | "import": "./dist/esm/statebot.mjs", 22 | "require": "./dist/cjs/statebot.js", 23 | "default": "./dist/cjs/statebot.js" 24 | }, 25 | "./assert": { 26 | "types": "./assert/index.d.ts", 27 | "import": "./assert/index.mjs", 28 | "require": "./assert/index.cjs", 29 | "default": "./assert/index.cjs" 30 | }, 31 | "./hooks/react": { 32 | "types": "./hooks/react/index.d.ts", 33 | "import": "./hooks/react/index.mjs", 34 | "require": "./hooks/react/index.mjs", 35 | "default": "./hooks/react/index.mjs" 36 | }, 37 | "./hooks/mithril": { 38 | "types": "./hooks/mithril/index.d.ts", 39 | "import": "./hooks/mithril/index.mjs", 40 | "require": "./hooks/mithril/index.mjs", 41 | "default": "./hooks/mithril/index.mjs" 42 | } 43 | }, 44 | "files": [ 45 | "dist", 46 | "assert", 47 | "hooks", 48 | "CHANGELOG.md", 49 | "README.md", 50 | "LICENSE", 51 | "index.d.ts" 52 | ], 53 | "engines": { 54 | "node": ">=10" 55 | }, 56 | "homepage": "https://shuckster.github.io/statebot/", 57 | "repository": { 58 | "type": "git", 59 | "url": "https://github.com/shuckster/statebot" 60 | }, 61 | "bugs": { 62 | "url": "https://github.com/shuckster/statebot/issues", 63 | "email": "bugs+statebot@conans.co.uk" 64 | }, 65 | "scripts": { 66 | "lint": "eslint src/*.js tests/*.js", 67 | "test": "jest --bail", 68 | "test:watch": "jest --watchAll", 69 | "clean": "rimraf dist", 70 | "build:all": "pnpm run clean ; concurrently \"pnpm run build:browser\" \"pnpm run build:node\" \"pnpm run build:esm\" \"pnpm run build:assert\"", 71 | "build:browser": "rollup --config ./build-config/rollup.config.browser.js", 72 | "build:node": "rollup --config ./build-config/rollup.config.node.js", 73 | "build:esm": "rollup --config ./build-config/rollup.config.esm.js", 74 | "build:assert": "rollup --config ./build-config/rollup.config.assert.js", 75 | "build:docs": "typedoc" 76 | }, 77 | "dependencies": { 78 | "mitt": "^3.0.0" 79 | }, 80 | "devDependencies": { 81 | "@babel/core": "^7.21.4", 82 | "@babel/preset-env": "^7.21.4", 83 | "@rollup/plugin-babel": "^6.0.3", 84 | "@rollup/plugin-commonjs": "^23.0.7", 85 | "@rollup/plugin-json": "^4.1.0", 86 | "@rollup/plugin-node-resolve": "^14.1.0", 87 | "concurrently": "^7.6.0", 88 | "eslint": "^8.38.0", 89 | "eslint-plugin-import": "^2.27.5", 90 | "eslint-plugin-jest": "^27.2.1", 91 | "eslint-plugin-n": "^15.7.0", 92 | "eslint-plugin-node": "^11.1.0", 93 | "eslint-plugin-promise": "^6.1.1", 94 | "jest": "^29.5.0", 95 | "rimraf": "^4.4.1", 96 | "rollup": "^2.79.1", 97 | "rollup-plugin-cleanup": "^3.2.1", 98 | "rollup-plugin-node-builtins": "^2.1.2", 99 | "rollup-plugin-terser": "^7.0.2", 100 | "typedoc": "^0.23.28", 101 | "typescript": "^4.9.5" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/browser.js: -------------------------------------------------------------------------------- 1 | // 2 | // STATEBOT EXPORTS 3 | // 4 | 5 | import { Statebot, isStatebot } from './statebot' 6 | import { assertRoute, routeIsPossible } from '../assert' 7 | import { decomposeChart } from './parsing' 8 | import { mermaid } from './mermaid' 9 | import { makeHooks } from '../hooks/make-hooks.mjs' 10 | 11 | const { useEffect, useState, useMemo } = (global => 12 | typeof React !== 'undefined' 13 | ? // eslint-disable-next-line no-undef 14 | React 15 | : global)(window) 16 | 17 | const { useStatebot, useStatebotFactory, useStatebotEvent } = makeHooks({ 18 | Statebot, 19 | useEffect, 20 | useState, 21 | useMemo 22 | }) 23 | 24 | export { 25 | Statebot, 26 | isStatebot, 27 | routeIsPossible, 28 | assertRoute, 29 | decomposeChart, 30 | mermaid, 31 | useStatebot, 32 | useStatebotFactory, 33 | useStatebotEvent 34 | } 35 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // STATEBOT EXPORTS 4 | // 5 | 6 | import { Statebot, isStatebot } from './statebot' 7 | import { decomposeChart } from './parsing' 8 | import { mermaid } from './mermaid' 9 | 10 | export { 11 | Statebot, 12 | isStatebot, 13 | decomposeChart, 14 | mermaid 15 | } 16 | -------------------------------------------------------------------------------- /src/mermaid.js: -------------------------------------------------------------------------------- 1 | import { cxArrow, linesFrom } from './parsing' 2 | 3 | const rxFrontMatter = /---[\r\n]+[\w\W]*---[\r\n]+[\r\n\s]*/m 4 | const rxMermaidHeader = /stateDiagram(-v2)?[\r\n\s]*/g 5 | const rxMermaidDirection = /direction\s+(TB|TD|BT|RL|LR)[\r\n\s]*/g 6 | const rxMermaidComment = /%%/g 7 | const rxMermaidArrow = /-->/g 8 | const rxMermaidStartState = /\[\*\]\s*-->/g 9 | const rxMermaidStopState = /-->\s*\[\*\]/g 10 | 11 | /** 12 | * Support the Mermaid Preview extension for VS Code using 13 | * VSTS syntax for code blocks. 14 | * 15 | * @link https://marketplace.visualstudio.com/items?itemName=vstirbu.vscode-mermaid-preview 16 | * @link https://github.com/vstirbu/vscode-mermaid-preview 17 | * 18 | * For RegExp, see: 19 | * @see https://github.com/vstirbu/vscode-mermaid-preview/blob/v1.6.3/lib/find-diagram.js#L38 20 | * 21 | * @example 22 | // If you have the extension installed, enable the preview 23 | // window and place your cursor in the code block below. 24 | 25 | let mmd = ` 26 | ::: mermaid 27 | stateDiagram 28 | direction LR 29 | go --> prepareToStop 30 | prepareToStop --> stop 31 | 32 | %% ...gotta keep that traffic flowing 33 | stop --> prepareToGo 34 | prepareToGo --> go 35 | ::: 36 | ` 37 | */ 38 | const rxMermaidPreviewVsts = /::: ?mermaid([\s\S]*?):::/g 39 | 40 | export function mermaid(mmd) { 41 | return linesFrom(mmd) 42 | .join('\n') 43 | .replace(rxMermaidPreviewVsts, '$1') 44 | .replace(rxFrontMatter, '') 45 | .replace(rxMermaidHeader, '') 46 | .replace(rxMermaidDirection, '') 47 | .replace(rxMermaidComment, '//') 48 | .replace(rxMermaidStartState, '__START__ -->') 49 | .replace(rxMermaidStopState, '--> __STOP__') 50 | .replace(rxMermaidArrow, cxArrow) 51 | } 52 | -------------------------------------------------------------------------------- /src/parsing.js: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // STATEBOT CHART/ROUTE PARSING 4 | // 5 | 6 | const rxCRLF = /[\r\n]/ 7 | const cxPipe = '|' 8 | const cxArrow = '->' 9 | const rxOperators = [cxPipe, cxArrow] 10 | .map(rxUnsafe => rxUnsafe.replace('|', '\\|')) 11 | .join('|') 12 | 13 | const rxLineContinuations = new RegExp(`(${rxOperators})$`) 14 | const rxDisallowedCharacters = /[^a-z0-9!@#$%^&*:_+=<>|~.\x2D]/gi 15 | const rxComment = /(\/\/[^\n\r]*)/ 16 | 17 | export { 18 | cxPipe, 19 | cxArrow, 20 | rxDisallowedCharacters, 21 | decomposeChart, 22 | decomposeRoute 23 | } 24 | 25 | import { uniq } from './utils' 26 | import { isTemplateLiteral, ArgTypeError } from './types' 27 | 28 | const argTypeError = ArgTypeError('statebot.') 29 | 30 | function decomposeRoute (templateLiteral) { 31 | const err = argTypeError( 32 | { templateLiteral: isTemplateLiteral } 33 | )('decomposeRoute')(templateLiteral) 34 | if (err) { 35 | throw TypeError(err) 36 | } 37 | 38 | const lines = condensedLines(templateLiteral) 39 | const linesOfTokens = tokenisedLines(lines) 40 | const route = linesOfTokens.flat(2) 41 | 42 | return route 43 | } 44 | 45 | function decomposeChart (chart) { 46 | const err = argTypeError( 47 | { chart: isTemplateLiteral } 48 | )('decomposeChart')(chart) 49 | if (err) { 50 | throw TypeError(err) 51 | } 52 | 53 | const lines = condensedLines(chart) 54 | const linesOfTokens = tokenisedLines(lines) 55 | const linesOfRoutes = linesOfTokens 56 | .flatMap(decomposeRouteFromTokens) 57 | 58 | const linesOfTransitions = linesOfRoutes 59 | .flatMap(decomposeTransitionsFromRoute) 60 | 61 | let emptyStateFound = false 62 | const routeKeys = linesOfTransitions.map(route => { 63 | if (route.includes('')) { 64 | emptyStateFound = true 65 | } 66 | return route.join(cxArrow) 67 | }) 68 | 69 | const filteredRoutes = uniq(routeKeys) 70 | const filteredStates = uniq(linesOfTokens.flat(3)) 71 | 72 | return { 73 | transitions: filteredRoutes.map(route => route.split(cxArrow)), 74 | routes: filteredRoutes, 75 | states: !emptyStateFound 76 | ? filteredStates.filter(Boolean) 77 | : filteredStates 78 | } 79 | } 80 | 81 | export function linesFrom (strOrArr) { 82 | return [strOrArr] 83 | .flat() 84 | .reduce((acc, line) => [...acc, ...line.split(rxCRLF)], []) 85 | } 86 | 87 | function condensedLines (strOrArr) { 88 | const input = linesFrom(strOrArr) 89 | const output = [] 90 | 91 | let previousLineHasContinuation = false 92 | 93 | const condenseLine = (condensedLine, line) => { 94 | const sanitisedLine = line 95 | .replace(rxComment, '') 96 | .replace(rxDisallowedCharacters, '') 97 | 98 | if (!sanitisedLine) { 99 | return condensedLine 100 | } 101 | 102 | previousLineHasContinuation = rxLineContinuations 103 | .test(sanitisedLine) 104 | 105 | if (previousLineHasContinuation) { 106 | return condensedLine + sanitisedLine 107 | } 108 | 109 | output.push(condensedLine + sanitisedLine) 110 | return '' 111 | } 112 | 113 | const finalCondensedLine = input 114 | .reduce(condenseLine, '') 115 | 116 | if (previousLineHasContinuation || finalCondensedLine) { 117 | return [...output, finalCondensedLine] 118 | } 119 | 120 | return [...output] 121 | } 122 | 123 | function tokenisedLines (lines) { 124 | return lines 125 | .map(line => line 126 | .split(cxArrow) 127 | .map(str => str.split(cxPipe)) 128 | ) 129 | } 130 | 131 | function decomposeRouteFromTokens (line) { 132 | const output = [] 133 | 134 | line.reduce((previousStates, states) => { 135 | if (previousStates === false) { 136 | return [...states] 137 | } 138 | 139 | output.push([previousStates, [...states]]) 140 | return [...states] 141 | }, false) 142 | 143 | return output 144 | } 145 | 146 | function decomposeTransitionsFromRoute([fromStates, toStates]) { 147 | return fromStates.reduce( 148 | (acc, fromState) => ( 149 | acc.push(...toStates.map(toState => [fromState, toState])), acc 150 | ), 151 | [] 152 | ) 153 | } 154 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // 2 | // RUNTIME TYPE CHECKING 3 | // 4 | 5 | export { 6 | isArray, 7 | isArguments, 8 | isBoolean, 9 | isEventEmitter, 10 | isFunction, 11 | isPojo, 12 | isString, 13 | isAllStrings, 14 | isTemplateLiteral, 15 | isNumber, 16 | isThisValue, 17 | isNull, 18 | isUnset, 19 | isUndefined, 20 | ArgTypeError, 21 | ObjTypeError 22 | } 23 | 24 | // 25 | // isType 26 | // 27 | 28 | // isEventEmitter 29 | // 30 | function isEventEmitter(obj) { 31 | return ( 32 | isObject(obj) && 33 | isFunction(obj.emit) && 34 | (isFunction(obj.addListener) || isFunction(obj.on)) && 35 | (isFunction(obj.removeListener) || isFunction(obj.off)) 36 | ) 37 | } 38 | 39 | isEventEmitter.displayName = 'isEventEmitter' 40 | 41 | // isUnset 42 | // 43 | function isUnset(obj) { 44 | return isNull(obj) || isUndefined(obj) 45 | } 46 | 47 | isArray.displayName = 'isUnset' 48 | 49 | // isArray 50 | // 51 | function isArray(obj) { 52 | return Array.isArray(obj) 53 | } 54 | 55 | isArray.displayName = 'isArray' 56 | 57 | // isArguments 58 | // 59 | function isArguments(obj) { 60 | return Object.prototype.toString.call(obj) === '[object Arguments]' 61 | } 62 | 63 | isArguments.displayName = 'isArguments' 64 | 65 | // isBoolean 66 | // 67 | function isBoolean(obj) { 68 | return obj === true || obj === false 69 | } 70 | 71 | isBoolean.displayName = 'isBoolean' 72 | 73 | // isFunction 74 | // 75 | function isFunction(obj) { 76 | return typeof obj === 'function' 77 | } 78 | 79 | isFunction.displayName = 'isFunction' 80 | 81 | // isString 82 | // 83 | function isString(obj) { 84 | return typeof obj === 'string' 85 | } 86 | 87 | isString.displayName = 'isString' 88 | 89 | // isAllStrings 90 | // 91 | function isAllStrings (arr) { 92 | return isArray(arr) && arr.every(isString) 93 | } 94 | 95 | isAllStrings.displayName = 'isAllStrings' 96 | 97 | // isUndefined 98 | // 99 | function isUndefined(obj) { 100 | return obj === undefined 101 | } 102 | 103 | isUndefined.displayName = 'isUndefined' 104 | 105 | // isNull 106 | // 107 | function isNull(obj) { 108 | return obj === null 109 | } 110 | 111 | isNull.displayName = 'isNull' 112 | 113 | // isNumber 114 | // 115 | function isNumber(obj) { 116 | return typeof obj === 'number' 117 | } 118 | 119 | isNumber.displayName = 'isNumber' 120 | 121 | // isObject 122 | // 123 | function isObject(obj) { 124 | return typeof obj === 'object' && !isNull(obj) 125 | } 126 | 127 | isObject.displayName = 'isObject' 128 | 129 | // isPojo 130 | // 131 | function isPojo(obj) { 132 | if (isNull(obj) || !isObject(obj) || isArguments(obj)) { 133 | return false 134 | } 135 | return Object.getPrototypeOf(obj) === Object.prototype 136 | } 137 | 138 | isPojo.displayName = 'isPojo' 139 | 140 | // isTemplateLiteral 141 | // 142 | function isTemplateLiteral(obj) { 143 | if (isString(obj)) { 144 | return true 145 | } 146 | if (!isArray(obj)) { 147 | return false 148 | } 149 | return obj.every(isString) 150 | } 151 | 152 | isTemplateLiteral.displayName = 'isTemplateLiteral' 153 | 154 | // isThisValue 155 | // 156 | function isThisValue(value) { 157 | function inObject(obj) { 158 | return obj === value 159 | } 160 | inObject.displayName = `isThisValue(${value})` 161 | return inObject 162 | } 163 | 164 | // 165 | // ArgTypeError 166 | // 167 | 168 | const typeErrorStringIfFnReturnsFalse = (argName, argTypeFn, arg) => { 169 | return argTypeFn(arg) 170 | ? undefined 171 | : (argTypeFn.displayName || argTypeFn.name) + 172 | `(${argName}) did not return true` 173 | } 174 | 175 | const typeErrorStringIfTypeOfFails = (argName, argType, arg) => { 176 | return typeof arg === argType 177 | ? undefined 178 | : `Argument "${argName}" should be a ${argType}` 179 | } 180 | 181 | const typeErrorStringFromArgument = argMap => (arg, index) => { 182 | if (index >= argMap.length) { 183 | return 184 | } 185 | 186 | const { argName, argType } = argMap[index] 187 | if (isUndefined(arg)) { 188 | return `Argument undefined: "${argName}"` 189 | } 190 | 191 | const permittedArgTypes = Array.isArray(argType) ? argType : [argType] 192 | 193 | const errorDescs = permittedArgTypes 194 | .map(argType => 195 | isFunction(argType) 196 | ? typeErrorStringIfFnReturnsFalse(argName, argType, arg) 197 | : typeErrorStringIfTypeOfFails(argName, argType, arg) 198 | ) 199 | .filter(isString) 200 | 201 | const multipleTypesSpecified = permittedArgTypes.length > 1 202 | const shouldError = multipleTypesSpecified 203 | ? errorDescs.length > 1 204 | : errorDescs.length 205 | 206 | if (shouldError) { 207 | return ( 208 | errorDescs.join('\n| ') + 209 | `\n> typeof ${argName} === ${typeof arg}(${JSON.stringify(arg)})` 210 | ) 211 | } 212 | } 213 | 214 | function ArgTypeError(namespace) { 215 | return typeMap => { 216 | const argMap = Object.entries(typeMap).map(([argName, argType]) => ({ 217 | argName, 218 | argType 219 | })) 220 | 221 | return fnName => 222 | (...args) => { 223 | const processedArgs = Array 224 | .from(args, x => isArguments(x) ? Array.from(x) : x) 225 | .flat(1) 226 | 227 | const err = processedArgs 228 | .map(typeErrorStringFromArgument(argMap)) 229 | .filter(isString) 230 | 231 | if (!err.length) { 232 | return 233 | } 234 | 235 | const signature = Object.keys(typeMap).join(', ') 236 | return ( 237 | `\n${namespace || ''}${fnName}(${signature}):\n` + 238 | `${err.map(err => `| ${err}`).join('\n')}` 239 | ) 240 | } 241 | } 242 | } 243 | 244 | function ObjTypeError(namespace) { 245 | return typeMap => { 246 | const keys = Object.keys(typeMap) 247 | const objTypeError = ArgTypeError(namespace)(typeMap) 248 | return fnName => obj => { 249 | const values = valuesOf(obj, { keys }) 250 | const err = objTypeError(fnName)(...values) 251 | return err 252 | } 253 | } 254 | } 255 | 256 | function valuesOf(obj, options) { 257 | const { keys } = options 258 | if (!Array.isArray(keys)) { 259 | return Object.values(obj) 260 | } 261 | return keys.reduce((acc, key) => { 262 | return [...acc, obj[key]] 263 | }, []) 264 | } 265 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // STATEBOT UTILS 4 | // 5 | 6 | export { 7 | Defer, 8 | Definitions, 9 | Logger, 10 | Once, 11 | Pausables, 12 | ReferenceCounter, 13 | Revokable, 14 | uniq, 15 | wrapEmitter, 16 | } 17 | 18 | import { isString } from './types' 19 | 20 | function wrapEmitter (events) { 21 | const emit = (eventName, ...args) => 22 | events.emit(eventName, args) 23 | 24 | const addListener = events.addListener 25 | ? (...args) => events.addListener(...args) 26 | : (...args) => events.on(...args) 27 | 28 | const removeListener = events.removeListener 29 | ? (...args) => events.removeListener(...args) 30 | : (...args) => events.off(...args) 31 | 32 | const wrapMap = new Map() 33 | 34 | function on (eventName, fn) { 35 | let fnMeta = wrapMap.get(fn) 36 | if (!fnMeta) { 37 | fnMeta = { 38 | handleEvent: (args = []) => fn(...[args].flat()), 39 | refCount: 0 40 | } 41 | wrapMap.set(fn, fnMeta) 42 | } 43 | 44 | fnMeta.refCount += 1 45 | addListener(eventName, fnMeta.handleEvent) 46 | } 47 | 48 | function off (eventName, fn) { 49 | let fnMeta = wrapMap.get(fn) 50 | if (!fnMeta) { 51 | return 52 | } 53 | 54 | removeListener(eventName, fnMeta.handleEvent) 55 | fnMeta.refCount -= 1 56 | if (fnMeta.refCount === 0) { 57 | wrapMap.delete(fn) 58 | } 59 | } 60 | 61 | return { 62 | emit, 63 | on, 64 | off 65 | } 66 | } 67 | 68 | // 69 | // uniq 70 | // 71 | 72 | function uniq (input) { 73 | return input.reduce((acc, one) => 74 | acc.indexOf(one) === -1 75 | ? (acc.push(one), acc) 76 | : acc 77 | , [] 78 | ) 79 | } 80 | 81 | // 82 | // defer 83 | // 84 | 85 | function defer (fn, ...args) { 86 | const timer = setTimeout(fn, 0, ...args) 87 | return () => { clearTimeout(timer) } 88 | } 89 | 90 | function Defer (fn) { 91 | return (...args) => defer(fn, ...args) 92 | } 93 | 94 | // 95 | // Revokable 96 | // 97 | 98 | function Once (fn) { 99 | const { revoke, fn: _fn } = Revokable(fn) 100 | let result 101 | return function (...args) { 102 | result = _fn(...args) 103 | revoke() 104 | return result 105 | } 106 | } 107 | 108 | function Revokable (fn) { 109 | let revoked = false 110 | let result 111 | return { 112 | fn: (...args) => { 113 | if (!revoked) { 114 | result = fn(...args) 115 | } 116 | return result 117 | }, 118 | revoke: () => { 119 | revoked = true 120 | } 121 | } 122 | } 123 | 124 | // 125 | // Pausables 126 | // 127 | 128 | function Pausables (startPaused, runFnWhenPaused) { 129 | runFnWhenPaused = runFnWhenPaused || function () {} 130 | let paused = !!startPaused 131 | 132 | function Pausable (fn) { 133 | return (...args) => { 134 | if (paused) { 135 | runFnWhenPaused() 136 | return false 137 | } 138 | return fn(...args) 139 | } 140 | } 141 | 142 | return { 143 | Pausable, 144 | paused: () => paused, 145 | pause: () => { paused = true }, 146 | resume: () => { paused = false }, 147 | } 148 | } 149 | 150 | // 151 | // ReferenceCounter 152 | // 153 | 154 | function ReferenceCounter (logPrefix, kind, description, ...expecting) { 155 | const _refs = [...expecting] 156 | .flat() 157 | .reduce((acc, ref) => ({ ...acc, [ref]: 0 }), {}) 158 | 159 | function increase (ref) { 160 | _refs[ref] = countOf(ref) + 1 161 | return () => { decrease(ref) } 162 | } 163 | function decrease (ref) { 164 | const count = countOf(ref) - 1 165 | _refs[ref] = Math.max(count, 0) 166 | } 167 | function countOf (ref) { 168 | return _refs[ref] || 0 169 | } 170 | function refs () { 171 | return { ..._refs } 172 | } 173 | function table () { 174 | return Object.keys(_refs) 175 | .sort((a, b) => a - b) 176 | .map(key => [key, _refs[key]]) 177 | .map(([ref, count]) => { 178 | return { 179 | [kind]: ref, 180 | refs: count || 'None' 181 | } 182 | }) 183 | } 184 | function toValue () { 185 | return { 186 | description: `${logPrefix}: ${description}:`, 187 | table: table() 188 | } 189 | } 190 | return { 191 | increase, 192 | decrease, 193 | countOf, 194 | toValue, 195 | refs 196 | } 197 | } 198 | 199 | // 200 | // Definitions 201 | // 202 | 203 | function Definitions() { 204 | const dictionary = {} 205 | 206 | function undefine(word, definition) { 207 | dictionary[word] = (dictionary[word] || []).filter( 208 | (next) => next !== definition 209 | ) 210 | if (dictionary[word].length === 0) { 211 | delete dictionary[word] 212 | } 213 | } 214 | 215 | function define(word, definition) { 216 | dictionary[word] = dictionary[word] || [] 217 | dictionary[word].push(definition) 218 | return () => undefine(word, definition) 219 | } 220 | 221 | function definitionsOf(word) { 222 | return dictionary[word] || [] 223 | } 224 | 225 | return { 226 | define, 227 | undefine, 228 | definitionsOf, 229 | } 230 | } 231 | 232 | // 233 | // Logger 234 | // 235 | 236 | function Logger (level, _console) { 237 | if (isString(level)) { 238 | level = ({ 239 | info: 3, 240 | log: 2, 241 | warn: 1, 242 | none: 0 243 | })[level] || 3 244 | } 245 | function canWarn () { 246 | return level >= 1 247 | } 248 | function canLog () { 249 | return level >= 2 250 | } 251 | function canInfo () { 252 | return level >= 3 253 | } 254 | const { info, table, log, warn, error } = _console || console 255 | return { 256 | canWarn, 257 | canLog, 258 | canInfo, 259 | 260 | info: (...args) => { canInfo() && info(...args) }, 261 | table: (...args) => { canLog() && table(...args) }, 262 | log: (...args) => { canLog() && log(...args) }, 263 | warn: (...args) => { canWarn() && warn(...args) }, 264 | error: (...args) => { error(...args) } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /tests/assertions.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | const { routeIsPossible } = require('../assert/index.cjs') 4 | 5 | const bot = Statebot('chart-with-backtracking', { 6 | chart: ` 7 | 8 | hidden -> prompt -> hidden 9 | 10 | prompt | failed -> 11 | submitting -> 12 | submitting-slowly 13 | 14 | submitting | submitting-slowly -> 15 | confirmed | failed -> 16 | hidden 17 | 18 | `, 19 | logLevel: 0 20 | }) 21 | 22 | const ROUTES_WITH_BACKTRACKING = [ 23 | 'hidden -> prompt -> hidden', 24 | 'failed -> submitting -> confirmed -> hidden', 25 | 'hidden -> prompt -> submitting -> failed -> submitting -> confirmed -> hidden', 26 | 'hidden -> prompt -> submitting -> submitting-slowly -> failed -> submitting -> confirmed -> hidden' 27 | ] 28 | 29 | test(`routeIsPossible() expects at least two arguments`, () => { 30 | expect(() => routeIsPossible()).toThrow() 31 | }) 32 | 33 | ROUTES_WITH_BACKTRACKING.forEach(route => { 34 | test(`bot should be able to trace this route:\n${route}`, () => { 35 | expect(true).toEqual(routeIsPossible(bot, route)) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tests/conditionals.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | 4 | const bot = Statebot('simple', { 5 | chart: ` 6 | 7 | idle -> failure | success -> done 8 | 9 | `, 10 | logLevel: 0 11 | }) 12 | 13 | // inState('string') 14 | 15 | test(`inState() expects at least one argument`, () => { 16 | bot.reset() 17 | 18 | expect(() => bot.inState()).toThrow() 19 | }) 20 | 21 | test(`inState() returns true/false when passed a string`, () => { 22 | bot.reset() 23 | 24 | expect(true).toEqual(bot.inState('idle')) 25 | expect(false).toEqual(bot.inState('done')) 26 | expect(false).toEqual(bot.inState('-')) 27 | }) 28 | 29 | // inState('string', 'result-if-in-state') 30 | 31 | test(`inState() returns literal or null`, () => { 32 | bot.reset() 33 | 34 | expect('okay').toEqual(bot.inState('idle', 'okay')) 35 | expect(null).toEqual(bot.inState('done', 'okay')) 36 | expect(null).toEqual(bot.inState('-', 'okay')) 37 | }) 38 | 39 | test(`inState() returns fn-result or null`, () => { 40 | bot.reset() 41 | 42 | const Good = () => 'good' 43 | 44 | expect('good').toEqual(bot.inState('idle', Good)) 45 | expect(null).toEqual(bot.inState('done', Good)) 46 | expect(null).toEqual(bot.inState('-', Good)) 47 | }) 48 | 49 | test(`inState() returns fn-result or null with arguments`, () => { 50 | bot.reset() 51 | 52 | const K = arg => arg 53 | 54 | expect('arg-result').toEqual(bot.inState('idle', K, 'arg-result')) 55 | expect(null).toEqual(bot.inState('-', K, 'arg-result')) 56 | }) 57 | 58 | // inState(object) 59 | 60 | test(`inState() can work with an object`, () => { 61 | bot.reset() 62 | 63 | // idle -> failure | success -> done 64 | const stateObject = { 65 | 'idle': 'one', 66 | 'failure': () => 'two', 67 | 'success': 'three', 68 | 'done': arg => arg, 69 | } 70 | 71 | // idle 72 | expect('one').toEqual(bot.inState(stateObject)) 73 | // failure 74 | bot.enter('failure') 75 | expect('two').toEqual(bot.inState(stateObject)) 76 | // done 77 | bot.enter('done') 78 | expect('arg-result').toEqual(bot.inState(stateObject, 'arg-result')) 79 | 80 | bot.reset() 81 | 82 | // success 83 | bot.enter('success') 84 | expect('three').toEqual(bot.inState(stateObject)) 85 | 86 | const noResults = { 87 | 'not-found-1': 'one', 88 | 'not-found-2': () => 'two', 89 | 'not-found-3': 'three', 90 | 'not-found-4': arg => arg, 91 | } 92 | 93 | // no states found in object keys 94 | expect(null).toEqual(bot.inState(noResults)) 95 | }) 96 | 97 | // InState(object) 98 | 99 | test(`InState() can work with an object`, () => { 100 | bot.reset() 101 | 102 | // idle -> failure | success -> done 103 | const stateObject = { 104 | 'idle': 'one', 105 | 'failure': () => 'two', 106 | 'success': 'three', 107 | 'done': arg => arg, 108 | } 109 | const stateToValue = bot.InState(stateObject) 110 | 111 | // idle 112 | expect('one').toEqual(stateToValue()) 113 | // failure 114 | bot.enter('failure') 115 | expect('two').toEqual(stateToValue()) 116 | // done 117 | bot.enter('done') 118 | expect('arg-result').toEqual(stateToValue('arg-result')) 119 | 120 | bot.reset() 121 | 122 | // success 123 | bot.enter('success') 124 | expect('three').toEqual(stateToValue()) 125 | 126 | const noResults = { 127 | 'not-found-1': 'one', 128 | 'not-found-2': () => 'two', 129 | 'not-found-3': 'three', 130 | 'not-found-4': arg => arg, 131 | } 132 | const stateToNoValue = bot.InState(noResults) 133 | 134 | // no states found in object keys 135 | expect(null).toEqual(stateToNoValue()) 136 | }) 137 | 138 | // statesAvailableFromHere() 139 | 140 | test(`statesAvailableFromHere() does the right thing`, () => { 141 | bot.reset() 142 | 143 | expect(['failure', 'success']).toEqual(bot.statesAvailableFromHere()) 144 | expect(['done']).toEqual(bot.statesAvailableFromHere('failure')) 145 | expect(['done']).toEqual(bot.statesAvailableFromHere('success')) 146 | 147 | bot.enter('failure') 148 | expect(['done']).toEqual(bot.statesAvailableFromHere()) 149 | 150 | bot.reset() 151 | bot.enter('success') 152 | expect(['done']).toEqual(bot.statesAvailableFromHere()) 153 | expect([]).toEqual(bot.statesAvailableFromHere('done')) 154 | 155 | bot.enter('done') 156 | expect([]).toEqual(bot.statesAvailableFromHere()) 157 | }) 158 | -------------------------------------------------------------------------------- /tests/emit-arity.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | 4 | const bot = Statebot('test-events-only', { 5 | chart: ` 6 | 7 | idle -> pending -> 8 | (rejected | resolved) -> 9 | 10 | finished 11 | 12 | `, 13 | logLevel: 0 14 | }) 15 | 16 | const EXPECTED_EVENT_ARITY_COUNT = 114 * 2 17 | 18 | let callCount = 0 19 | function bumpEventArityCount(...args) { 20 | callCount += args.length 21 | } 22 | 23 | bot.performTransitions({ 24 | 'idle -> pending': { 25 | on: 'start', 26 | then: bumpEventArityCount 27 | }, 28 | 'pending -> resolved': { 29 | on: 'pass', 30 | then: bumpEventArityCount 31 | }, 32 | 'pending -> rejected': { 33 | on: 'fail', 34 | then: bumpEventArityCount 35 | }, 36 | 'rejected | resolved -> finished': { 37 | on: 'done', 38 | then: bumpEventArityCount 39 | }, 40 | }) 41 | 42 | bot.onTransitions({ 43 | 'idle -> pending': bumpEventArityCount, 44 | 'pending -> rejected | resolved': bumpEventArityCount, 45 | 'rejected | resolved -> finished': bumpEventArityCount, 46 | }) 47 | 48 | bot.onExiting('idle', bumpEventArityCount) 49 | bot.onExited('idle', bumpEventArityCount) 50 | 51 | bot.onEntering('pending', bumpEventArityCount) 52 | bot.onEntered('pending', bumpEventArityCount) 53 | bot.onExiting('pending', bumpEventArityCount) 54 | bot.onExited('pending', bumpEventArityCount) 55 | 56 | bot.onEntering('resolved', bumpEventArityCount) 57 | bot.onEntered('resolved', bumpEventArityCount) 58 | bot.onExiting('resolved', bumpEventArityCount) 59 | bot.onExited('resolved', bumpEventArityCount) 60 | 61 | bot.onEntering('rejected', bumpEventArityCount) 62 | bot.onEntered('rejected', bumpEventArityCount) 63 | bot.onExiting('rejected', bumpEventArityCount) 64 | bot.onExited('rejected', bumpEventArityCount) 65 | 66 | bot.onEntering('finished', bumpEventArityCount) 67 | bot.onEntered('finished', bumpEventArityCount) 68 | bot.onExiting('finished', bumpEventArityCount) 69 | bot.onExited('finished', bumpEventArityCount) 70 | 71 | test(`ran event callbacks with expected arity`, () => { 72 | // 73 | // Emit more events than required in order to test transition-guarding 74 | // 75 | 76 | bot.emit('start', 1, 2) 77 | bot.emit('start', 1, 2) 78 | bot.emit('start', 1, 2) 79 | bot.emit('pass', 1, 2, 3, 4) 80 | bot.emit('pass', 1, 2, 3, 4) 81 | bot.emit('pass', 1, 2, 3, 4) 82 | bot.emit('done', 1, 2, 3) 83 | bot.emit('done', 1, 2, 3) 84 | bot.emit('done', 1, 2, 3) 85 | 86 | bot.reset() 87 | 88 | bot.emit('start', 1) 89 | bot.emit('start', 1) 90 | bot.emit('start', 1) 91 | bot.emit('fail') 92 | bot.emit('fail') 93 | bot.emit('fail') 94 | bot.emit('done', 1, 2, 3, 4, 5) 95 | bot.emit('done', 1, 2, 3, 4, 5) 96 | bot.emit('done', 1, 2, 3, 4, 5) 97 | 98 | bot.reset() 99 | 100 | const emitStart1 = bot.Emit('start', 1, 2) 101 | const emitPass1 = bot.Emit('pass', 1, 2, 3, 4) 102 | const emitDone1 = bot.Emit('done', 1, 2, 3) 103 | 104 | emitStart1() 105 | emitStart1() 106 | emitStart1() 107 | emitPass1() 108 | emitPass1() 109 | emitPass1() 110 | emitDone1() 111 | emitDone1() 112 | emitDone1() 113 | 114 | bot.reset() 115 | 116 | const emitStart2 = bot.Emit('start', 1) 117 | const emitFail2 = bot.Emit('fail') 118 | const emitDone2 = bot.Emit('done', 1, 2, 3, 4, 5) 119 | 120 | emitStart2() 121 | emitStart2() 122 | emitStart2() 123 | emitFail2() 124 | emitFail2() 125 | emitFail2() 126 | emitDone2() 127 | emitDone2() 128 | emitDone2() 129 | 130 | expect(callCount).toEqual(EXPECTED_EVENT_ARITY_COUNT) 131 | }) 132 | 133 | -------------------------------------------------------------------------------- /tests/enter-arity.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | 4 | const bot = Statebot('test-enter-only', { 5 | chart: ` 6 | 7 | one -> 8 | two -> 9 | three -> 10 | four -> 11 | one 12 | 13 | `, 14 | logLevel: 0 15 | }) 16 | 17 | const EXPECTED_ENTER_ARITY_COUNT = 132 18 | 19 | let callCount = 0 20 | function bumpEnterArityCount(...args) { 21 | callCount += args.length 22 | } 23 | 24 | bot.onTransitions({ 25 | 'one -> two -> three -> four -> one': bumpEnterArityCount, 26 | }) 27 | 28 | bot.onEntering('one', bumpEnterArityCount) 29 | bot.onEntered('one', bumpEnterArityCount) 30 | bot.onExiting('one', bumpEnterArityCount) 31 | bot.onExited('one', bumpEnterArityCount) 32 | 33 | bot.onEntering('two', bumpEnterArityCount) 34 | bot.onEntered('two', bumpEnterArityCount) 35 | bot.onExiting('two', bumpEnterArityCount) 36 | bot.onExited('two', bumpEnterArityCount) 37 | 38 | bot.onEntering('three', bumpEnterArityCount) 39 | bot.onEntered('three', bumpEnterArityCount) 40 | bot.onExiting('three', bumpEnterArityCount) 41 | bot.onExited('three', bumpEnterArityCount) 42 | 43 | bot.onEntering('four', bumpEnterArityCount) 44 | bot.onEntered('four', bumpEnterArityCount) 45 | bot.onExiting('four', bumpEnterArityCount) 46 | bot.onExited('four', bumpEnterArityCount) 47 | 48 | test(`enter states with correct number of arguments passed-down`, () => { 49 | // 50 | // Enter states multiple times to test transition-guarding 51 | // 52 | 53 | bot.enter('two', 1, 2) 54 | bot.enter('two', 1, 2) 55 | bot.enter('two', 1, 2) 56 | bot.enter('three', 1, 2, 3) 57 | bot.enter('three', 1, 2, 3) 58 | bot.enter('three', 1, 2, 3) 59 | bot.enter('four', 1, 2, 3, 4) 60 | bot.enter('four', 1, 2, 3, 4) 61 | bot.enter('four', 1, 2, 3, 4) 62 | bot.enter('one', 1) 63 | bot.enter('one', 1) 64 | bot.enter('one', 1) 65 | 66 | bot.reset() 67 | 68 | const enterOne = bot.Enter('one', 1) 69 | const enterTwo = bot.Enter('two', 1, 2) 70 | const enterThree = bot.Enter('three', 1, 2, 3) 71 | const enterFour = bot.Enter('four', 1, 2, 3, 4) 72 | 73 | enterTwo() 74 | enterTwo() 75 | enterTwo() 76 | enterThree() 77 | enterThree() 78 | enterThree() 79 | enterFour() 80 | enterFour() 81 | enterFour() 82 | enterOne() 83 | enterOne() 84 | enterOne() 85 | 86 | expect(callCount).toEqual(EXPECTED_ENTER_ARITY_COUNT) 87 | }) 88 | 89 | -------------------------------------------------------------------------------- /tests/events.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | 4 | const bot = Statebot('test-events-only', { 5 | chart: ` 6 | 7 | idle -> pending -> 8 | (rejected | resolved) -> 9 | 10 | finished 11 | 12 | `, 13 | logLevel: 0 14 | }) 15 | 16 | const EXPECTED_CALL_COUNT = 36 17 | 18 | let callCount = 0 19 | function bumpCallCount() { 20 | callCount += 1 21 | } 22 | 23 | bot.performTransitions({ 24 | 'idle -> pending': { 25 | on: 'start', 26 | then: bumpCallCount 27 | }, 28 | 'pending -> resolved': { 29 | on: 'pass', 30 | then: bumpCallCount 31 | }, 32 | 'pending -> rejected': { 33 | on: 'fail', 34 | then: bumpCallCount 35 | }, 36 | 'rejected | resolved -> finished': { 37 | on: 'done', 38 | then: bumpCallCount 39 | }, 40 | }) 41 | 42 | bot.onTransitions({ 43 | 'idle -> pending': bumpCallCount, 44 | 'pending -> rejected | resolved': bumpCallCount, 45 | 'rejected | resolved -> finished': bumpCallCount, 46 | }) 47 | 48 | bot.onExiting('idle', bumpCallCount) 49 | bot.onExited('idle', bumpCallCount) 50 | 51 | bot.onEntering('pending', bumpCallCount) 52 | bot.onEntered('pending', bumpCallCount) 53 | bot.onExiting('pending', bumpCallCount) 54 | bot.onExited('pending', bumpCallCount) 55 | 56 | bot.onEntering('resolved', bumpCallCount) 57 | bot.onEntered('resolved', bumpCallCount) 58 | bot.onExiting('resolved', bumpCallCount) 59 | bot.onExited('resolved', bumpCallCount) 60 | 61 | bot.onEntering('rejected', bumpCallCount) 62 | bot.onEntered('rejected', bumpCallCount) 63 | bot.onExiting('rejected', bumpCallCount) 64 | bot.onExited('rejected', bumpCallCount) 65 | 66 | bot.onEntering('finished', bumpCallCount) 67 | bot.onEntered('finished', bumpCallCount) 68 | bot.onExiting('finished', bumpCallCount) 69 | bot.onExited('finished', bumpCallCount) 70 | 71 | test(`expecting this many callbacks to have run`, () => { 72 | // 73 | // Emit more events than required in order to test transition-guarding 74 | // 75 | 76 | bot.emit('start') 77 | bot.emit('start') 78 | bot.emit('start') 79 | bot.emit('pass') 80 | bot.emit('pass') 81 | bot.emit('pass') 82 | bot.emit('done') 83 | bot.emit('done') 84 | bot.emit('done') 85 | 86 | bot.reset() 87 | 88 | bot.emit('start') 89 | bot.emit('start') 90 | bot.emit('start') 91 | bot.emit('fail') 92 | bot.emit('fail') 93 | bot.emit('fail') 94 | bot.emit('done') 95 | bot.emit('done') 96 | bot.emit('done') 97 | 98 | expect(callCount).toEqual(EXPECTED_CALL_COUNT) 99 | }) 100 | 101 | -------------------------------------------------------------------------------- /tests/leaks.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | 4 | const EXPECTED_CALL_COUNT = 20 5 | 6 | let bot, callCount, cleanup 7 | 8 | test(`expecting this many callbacks to have run`, () => { 9 | ({ bot, callCount, cleanup } = initStatebot()) 10 | bot.emit('step') 11 | bot.emit('step') 12 | bot.emit('step') 13 | cleanup() 14 | 15 | expect(callCount()).toEqual(EXPECTED_CALL_COUNT) 16 | }) 17 | 18 | test(`still expecting this many callbacks to have run`, () => { 19 | bot.emit('step') 20 | bot.emit('step') 21 | bot.emit('step') 22 | 23 | expect(callCount()).toEqual(EXPECTED_CALL_COUNT) 24 | }) 25 | 26 | function initStatebot () { 27 | const bot = Statebot('event-handlers-should-be-removable', { 28 | chart: ` 29 | 30 | idle -> next -> idle 31 | 32 | `, 33 | logLevel: 0 34 | }) 35 | 36 | let callCount = 0 37 | function bumpCallCount() { 38 | callCount += 1 39 | } 40 | 41 | const cleanupFns = [ 42 | bot.performTransitions({ 43 | 'idle -> next -> idle': { 44 | on: 'step', 45 | then: bumpCallCount 46 | } 47 | }), 48 | 49 | bot.onTransitions({ 50 | 'idle -> next -> idle': bumpCallCount 51 | }), 52 | 53 | bot.onSwitching(bumpCallCount), 54 | bot.onSwitched(bumpCallCount), 55 | bot.onEntering('next', bumpCallCount), 56 | bot.onEntered('next', bumpCallCount), 57 | bot.onExiting('idle', bumpCallCount), 58 | bot.onExited('idle', bumpCallCount), 59 | ] 60 | 61 | return { 62 | bot, 63 | callCount: () => callCount, 64 | cleanup: () => cleanupFns.forEach(fn => fn()) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/mermaid.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { mermaid } = require('../src/mermaid') 4 | const { decomposeChart } = require('../src/parsing') 5 | 6 | function readFile(fileName) { 7 | return fs.readFileSync(path.join(__dirname, fileName), 'utf8') 8 | } 9 | 10 | const mmdtrafficLights = readFile('./traffic-lights.mmd') 11 | 12 | const tests = [ 13 | { 14 | mmd: ` 15 | --- 16 | title: Traffic lights 17 | --- 18 | ::: mermaid 19 | stateDiagram 20 | direction LR 21 | go --> prepareToStop 22 | prepareToStop --> stop 23 | 24 | %% ...gotta keep that traffic flowing 25 | stop --> prepareToGo 26 | prepareToGo --> go 27 | ::: 28 | `, 29 | expected: ` 30 | go -> prepareToStop 31 | prepareToStop -> stop 32 | 33 | // ...gotta keep that traffic flowing 34 | stop -> prepareToGo 35 | prepareToGo -> go 36 | ` 37 | }, 38 | { 39 | mmd: mmdtrafficLights, 40 | expected: ` 41 | go -> prepareToStop 42 | prepareToStop -> stop 43 | 44 | // ...gotta keep that traffic flowing 45 | stop -> prepareToGo 46 | prepareToGo -> go 47 | ` 48 | }, 49 | { 50 | mmd: ` 51 | :::mermaid 52 | stateDiagram-v2 53 | [*] --> Still 54 | Still --> [*] 55 | 56 | Still --> Moving 57 | Moving --> Still 58 | Moving --> Crash 59 | Crash --> [*] 60 | ::: 61 | `, 62 | expected: ` 63 | __START__ -> Still 64 | Still -> __STOP__ 65 | 66 | Still -> Moving 67 | Moving -> Still 68 | Moving -> Crash 69 | Crash -> __STOP__ 70 | ` 71 | } 72 | ] 73 | 74 | const rxStartAndEndWhitespace = /^[\s\r\n]+|[\s\r\n]+$/g 75 | const rxStartOfLineWhitespace = /^\s+/gm 76 | 77 | function compact(str) { 78 | return str 79 | .replace(rxStartAndEndWhitespace, '') 80 | .replace(rxStartOfLineWhitespace, '') 81 | } 82 | 83 | test.each(tests)( 84 | 'mermaid: can parse to Statebot chart', 85 | ({ mmd, expected }) => { 86 | const parsedMmd = mermaid(mmd) 87 | expect(compact(parsedMmd)).toEqual(compact(expected)) 88 | const mmdDecomposed = decomposeChart(parsedMmd) 89 | const sbDecomposed = decomposeChart(expected) 90 | expect(mmdDecomposed).toEqual(sbDecomposed) 91 | } 92 | ) 93 | 94 | test('mermaid: template literal test', () => { 95 | const parsedMmd = mermaid` 96 | --- 97 | title: Traffic lights 98 | --- 99 | stateDiagram 100 | direction LR 101 | go --> prepareToStop 102 | prepareToStop --> stop 103 | 104 | %% ...gotta keep that traffic flowing 105 | stop --> prepareToGo 106 | prepareToGo --> go 107 | ` 108 | 109 | const expectedChart = ` 110 | go -> prepareToStop 111 | prepareToStop -> stop 112 | 113 | // ...gotta keep that traffic flowing 114 | stop -> prepareToGo 115 | prepareToGo -> go 116 | ` 117 | 118 | expect(compact(parsedMmd)).toEqual(compact(expectedChart)) 119 | const mmdDecomposed = decomposeChart(parsedMmd) 120 | const sbDecomposed = decomposeChart(expectedChart) 121 | expect(mmdDecomposed).toEqual(sbDecomposed) 122 | }) 123 | -------------------------------------------------------------------------------- /tests/ordering.test.js: -------------------------------------------------------------------------------- 1 | const mitt = require('mitt') 2 | const EventEmitter = require('events') 3 | const { Statebot } = require('../src/statebot') 4 | 5 | const EXPECTED_CALL_ORDER = [ 6 | 'idle :: onExiting', 7 | 'pending :: onEntering', 8 | 'pending :: onTransition :: callback', 9 | 'idle :: onExited', 10 | 'pending :: onEntered', 11 | 'pending :: performTransition :: then', 12 | 'pending :: onExiting', 13 | 'resolved :: onEntering', 14 | 'pending :: onTransition :: callback-leaving', 15 | 'rejected | resolved :: onTransition :: callback', 16 | 'pending :: onExited', 17 | 'resolved :: onEntered', 18 | 'resolved :: performTransition :: then', 19 | 'resolved :: onExiting', 20 | 'finished :: onEntering', 21 | 'resolved :: performTransition :: then-leaving', 22 | 'finished :: onTransition :: callback', 23 | 'resolved :: onExited', 24 | 'finished :: onEntered', 25 | 'finished :: performTransition :: then', 26 | 'idle :: onExiting', 27 | 'pending :: onEntering', 28 | 'pending :: onTransition :: callback', 29 | 'idle :: onExited', 30 | 'pending :: onEntered', 31 | 'pending :: performTransition :: then', 32 | 'pending :: onExiting', 33 | 'rejected :: onEntering', 34 | 'pending :: onTransition :: callback-leaving', 35 | 'rejected | resolved :: onTransition :: callback', 36 | 'pending :: onExited', 37 | 'rejected :: onEntered', 38 | 'rejected :: performTransition :: then', 39 | 'rejected :: onExiting', 40 | 'finished :: onEntering', 41 | 'finished :: onTransition :: callback', 42 | 'rejected :: onExited', 43 | 'finished :: onEntered', 44 | 'finished :: performTransition :: then' 45 | ] 46 | 47 | test(`Throws if bad event-emitter passed-in`, () => { 48 | 49 | expect(() => initStatebotWithEventEmitter({})).toThrow() 50 | }) 51 | 52 | test(`EventEmitter: expecting callbacks to appear in the correct order`, () => { 53 | const nodeEmitter = new EventEmitter() 54 | const { bot, calls } = initStatebotWithEventEmitter(nodeEmitter) 55 | 56 | // 57 | // Emit more events than required in order to test transition-guarding 58 | // 59 | 60 | bot.emit('start') 61 | bot.emit('start') 62 | bot.emit('start') 63 | bot.emit('pass') 64 | bot.emit('pass') 65 | bot.emit('pass') 66 | bot.emit('done') 67 | bot.emit('done') 68 | bot.emit('done') 69 | 70 | bot.reset() 71 | 72 | bot.emit('start') 73 | bot.emit('start') 74 | bot.emit('start') 75 | bot.emit('fail') 76 | bot.emit('fail') 77 | bot.emit('fail') 78 | bot.emit('done') 79 | bot.emit('done') 80 | bot.emit('done') 81 | 82 | expect(calls.length).toEqual(EXPECTED_CALL_ORDER.length) 83 | expect(calls).toEqual(expect.arrayContaining(EXPECTED_CALL_ORDER)) 84 | expect(calls.join('/')).toEqual(EXPECTED_CALL_ORDER.join('/')) 85 | }) 86 | 87 | test(`mitt: expecting callbacks to appear in the correct order`, () => { 88 | const mittEmitter = mitt() 89 | const { bot, calls } = initStatebotWithEventEmitter(mittEmitter) 90 | 91 | // 92 | // Emit more events than required in order to test transition-guarding 93 | // 94 | 95 | mittEmitter.emit('start') 96 | mittEmitter.emit('start') 97 | mittEmitter.emit('start') 98 | mittEmitter.emit('pass') 99 | bot.emit('pass') 100 | bot.emit('pass') 101 | bot.emit('done') 102 | bot.emit('done') 103 | bot.emit('done') 104 | 105 | bot.reset() 106 | 107 | mittEmitter.emit('start') 108 | mittEmitter.emit('start') 109 | mittEmitter.emit('start') 110 | mittEmitter.emit('fail') 111 | bot.emit('fail') 112 | bot.emit('fail') 113 | bot.emit('done') 114 | bot.emit('done') 115 | bot.emit('done') 116 | 117 | expect(calls.length).toEqual(EXPECTED_CALL_ORDER.length) 118 | expect(calls).toEqual(expect.arrayContaining(EXPECTED_CALL_ORDER)) 119 | expect(calls.join('/')).toEqual(EXPECTED_CALL_ORDER.join('/')) 120 | }) 121 | 122 | function initStatebotWithEventEmitter(events) { 123 | const bot = Statebot('test-events-and-ordering', { 124 | events, 125 | chart: ` 126 | 127 | idle -> pending -> 128 | (rejected | resolved) -> 129 | 130 | finished 131 | 132 | `, 133 | logLevel: 0 134 | }) 135 | 136 | const calls = [] 137 | 138 | function Called (eventName) { 139 | return () => { 140 | calls.push(eventName) 141 | } 142 | } 143 | 144 | bot.performTransitions({ 145 | 'idle -> pending': { 146 | on: 'start', 147 | then: Called('pending :: performTransition :: then') 148 | }, 149 | 'pending -> resolved': { 150 | on: 'pass', 151 | then: () => { 152 | Called('resolved :: performTransition :: then')() 153 | return Called('resolved :: performTransition :: then-leaving') 154 | } 155 | }, 156 | 'pending -> rejected': { 157 | on: 'fail', 158 | then: Called('rejected :: performTransition :: then') 159 | }, 160 | 'rejected | resolved -> finished': { 161 | on: 'done', 162 | then: Called('finished :: performTransition :: then') 163 | }, 164 | }) 165 | 166 | bot.onTransitions({ 167 | 'idle -> pending': () => { 168 | Called('pending :: onTransition :: callback')() 169 | return Called('pending :: onTransition :: callback-leaving') 170 | }, 171 | 172 | 'pending -> rejected | resolved': 173 | Called('rejected | resolved :: onTransition :: callback'), 174 | 175 | 'rejected | resolved -> finished': 176 | Called('finished :: onTransition :: callback'), 177 | }) 178 | 179 | bot.onExited('idle', Called('idle :: onExited')) 180 | bot.onExiting('idle', Called('idle :: onExiting')) 181 | 182 | bot.onEntered('pending', Called('pending :: onEntered')) 183 | bot.onEntering('pending', Called('pending :: onEntering')) 184 | bot.onExited('pending', Called('pending :: onExited')) 185 | bot.onExiting('pending', Called('pending :: onExiting')) 186 | 187 | bot.onEntered('resolved', Called('resolved :: onEntered')) 188 | bot.onEntering('resolved', Called('resolved :: onEntering')) 189 | bot.onExited('resolved', Called('resolved :: onExited')) 190 | bot.onExiting('resolved', Called('resolved :: onExiting')) 191 | 192 | bot.onEntered('rejected', Called('rejected :: onEntered')) 193 | bot.onEntering('rejected', Called('rejected :: onEntering')) 194 | bot.onExited('rejected', Called('rejected :: onExited')) 195 | bot.onExiting('rejected', Called('rejected :: onExiting')) 196 | 197 | bot.onEntered('finished', Called('finished :: onEntered')) 198 | bot.onEntering('finished', Called('finished :: onEntering')) 199 | bot.onExited('finished', Called('finished :: onExited')) 200 | bot.onExiting('finished', Called('finished :: onExiting')) 201 | 202 | return { 203 | bot, 204 | calls 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tests/parsing.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | const { decomposeChart, decomposeRoute } = require('../src/parsing') 4 | const { routeIsPossible } = require('../assert/index.cjs') 5 | 6 | const SEMANTICALLY_IDENTICAL_CHARTS = [ 7 | { 8 | description: 'Minimal', 9 | charts: [ 10 | `idle -> done`, 11 | `idle -> 12 | done`, 13 | `idle -> // Comment 14 | done`, 15 | ], 16 | decomposeTo: { 17 | states: ['idle', 'done'], 18 | routes: ['idle->done'], 19 | transitions: [['idle', 'done']] 20 | }, 21 | assertions: { 22 | possibleRoutes: ['idle->done'], 23 | impossibleRoutes: ['done->idle'] 24 | } 25 | }, 26 | { 27 | description: 'Promise-like', 28 | charts: [ 29 | ` 30 | idle -> pending -> (rejected | resolved) -> finished 31 | `, 32 | ` 33 | idle -> pending 34 | pending -> (rejected | resolved) 35 | (rejected | resolved) -> finished 36 | `, 37 | ` 38 | idle -> pending 39 | pending -> rejected 40 | pending -> resolved 41 | rejected -> finished 42 | resolved -> finished 43 | `, 44 | ` 45 | rejected -> finished 46 | resolved -> finished 47 | idle -> pending 48 | pending -> (rejected | // Comment 49 | resolved) 50 | `, 51 | ` 52 | (rejected | resolved) -> // Comment 53 | finished 54 | pending -> // Comment 55 | (rejected | resolved) // Comment 56 | idle -> 57 | pending 58 | ` 59 | ], 60 | decomposeTo: { 61 | states: [ 62 | 'idle', 63 | 'pending', 64 | 'rejected', 65 | 'resolved', 66 | 'finished' 67 | ], 68 | routes: [ 69 | 'idle->pending', 70 | 'pending->rejected', 71 | 'pending->resolved', 72 | 'rejected->finished', 73 | 'resolved->finished' 74 | ], 75 | transitions: [ 76 | ['idle', 'pending'], 77 | ['pending', 'rejected'], 78 | ['pending', 'resolved'], 79 | ['rejected', 'finished'], 80 | ['resolved', 'finished'] 81 | ] 82 | }, 83 | assertions: { 84 | possibleRoutes: [ 85 | 'idle -> pending -> resolved -> finished', 86 | 'idle -> pending -> rejected -> finished' 87 | ], 88 | impossibleRoutes: [ 89 | 'idle -> resolved', 90 | 'idle -> rejected', 91 | 'idle -> finished', 92 | 'pending -> finished' 93 | ] 94 | } 95 | } 96 | ] 97 | 98 | SEMANTICALLY_IDENTICAL_CHARTS.forEach(chartTests => { 99 | const { description, charts, decomposeTo, assertions } = chartTests 100 | 101 | charts.forEach(chartToTest => { 102 | const { states, routes, transitions } = decomposeChart(chartToTest) 103 | 104 | test(`${description} :: Semantically identical charts produce the same states/routes\n${chartToTest}\n`, () => { 105 | 106 | // States 107 | expect(states.length) 108 | .toEqual(decomposeTo.states.length) 109 | expect(states) 110 | .toEqual(expect.arrayContaining(decomposeTo.states)) 111 | 112 | // Routes 113 | expect(routes.length) 114 | .toEqual(decomposeTo.routes.length) 115 | expect(routes) 116 | .toEqual(expect.arrayContaining(decomposeTo.routes)) 117 | 118 | // Transitions 119 | expect(transitions.length) 120 | .toEqual(decomposeTo.transitions.length) 121 | expect(transitions) 122 | .toEqual(expect.arrayContaining(decomposeTo.transitions)) 123 | 124 | }) 125 | 126 | if (!assertions) { 127 | return 128 | } 129 | 130 | const bot = Statebot(description, { chart: chartToTest }) 131 | const { possibleRoutes, impossibleRoutes } = assertions 132 | 133 | possibleRoutes.forEach(route => { 134 | test(`${description} :: route possible :: ${route}`, () => { 135 | expect(true).toEqual(routeIsPossible(bot, route)) 136 | }) 137 | }) 138 | 139 | impossibleRoutes.forEach(route => { 140 | test(`${description} :: route not possible :: ${route}`, () => { 141 | expect(false).toEqual(routeIsPossible(bot, route)) 142 | }) 143 | }) 144 | }) 145 | }) 146 | 147 | const CHARTS_WITH_EMPTY_STRINGS_FOR_STATES_SHOULD_BE_FINE = [ 148 | { 149 | chart: 'idle ->', 150 | states: ['idle', ''] 151 | }, 152 | { 153 | chart: 'idle -> ->', 154 | states: ['idle', ''] 155 | }, 156 | { 157 | chart: '-> idle -> ->', 158 | states: ['', 'idle'] 159 | }, 160 | { 161 | chart: 'idle -> -> done', 162 | states: ['idle', '', 'done'] 163 | }, 164 | { 165 | chart: 'idle -> waiting', 166 | states: ['idle', 'waiting'] 167 | }, 168 | { 169 | chart: 'idle -> waiting ->', 170 | states: ['idle', 'waiting', ''] 171 | }, 172 | { 173 | chart: 'idle -> waiting -> done', 174 | states: ['idle', 'waiting', 'done'] 175 | }, 176 | ] 177 | 178 | CHARTS_WITH_EMPTY_STRINGS_FOR_STATES_SHOULD_BE_FINE.forEach(regressionTest => { 179 | const { chart, states: testStates } = regressionTest 180 | const { states } = decomposeChart(chart) 181 | 182 | test(`decomposeChart() :: empty-strings are valid states\n${chart}`, () => { 183 | expect(states).toEqual(testStates) 184 | }) 185 | }) 186 | 187 | const DECOMPOSED_ROUTES = [ 188 | { 189 | route: 'hidden -> prompt -> submitting -> failed -> submitting -> confirmed -> hidden ->', 190 | expectedStates: ['hidden', 'prompt', 'submitting', 'failed', 'submitting', 'confirmed', 'hidden', ''], 191 | description: 'empty-string state should be in this route' 192 | }, 193 | { 194 | route: 'hidden -> prompt -> submitting -> failed -> submitting -> confirmed -> hidden', 195 | expectedStates: ['hidden', 'prompt', 'submitting', 'failed', 'submitting', 'confirmed', 'hidden'], 196 | description: 'empty-string state should NOT be in this route' 197 | }, 198 | ] 199 | 200 | DECOMPOSED_ROUTES.forEach(({ route, expectedStates, description }) => { 201 | test(`decomposeRoute() :: ${description}\n${route}`, 202 | () => expect(decomposeRoute(route)).toEqual(expectedStates) 203 | ) 204 | }) 205 | -------------------------------------------------------------------------------- /tests/pause.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | 4 | const bot = Statebot('pause-and-resume-should-work', { 5 | chart: ` 6 | 7 | idle -> next -> idle 8 | 9 | `, 10 | logLevel: 0 11 | }) 12 | 13 | let callCount = 0 14 | function bumpCallCount() { 15 | callCount += 1 16 | } 17 | 18 | const cleanupFns = [ 19 | bot.performTransitions({ 20 | 'idle -> next -> idle': { 21 | on: 'step', 22 | then: bumpCallCount 23 | } 24 | }), 25 | 26 | bot.onTransitions({ 27 | 'idle -> next -> idle': bumpCallCount 28 | }), 29 | 30 | bot.onSwitching(bumpCallCount), 31 | bot.onSwitched(bumpCallCount), 32 | bot.onEntering('next', bumpCallCount), 33 | bot.onEntered('next', bumpCallCount), 34 | bot.onExiting('idle', bumpCallCount), 35 | bot.onExited('idle', bumpCallCount) 36 | ] 37 | 38 | const EXPECTED_CALL_COUNT = 20 39 | const EXPECTED_CALL_COUNT_AFTER_PAUSING = 20 40 | const EXPECTED_CALL_COUNT_AFTER_RESUMING = 36 41 | const EXPECTED_CALL_COUNT_AFTER_CLEANUP = 36 42 | 43 | test(`expecting not to be paused by default`, () => { 44 | expect(false).toEqual(bot.paused()) 45 | }) 46 | 47 | test(`expecting this many callbacks to have run`, () => { 48 | bot.emit('step') 49 | bot.emit('step') 50 | bot.emit('step') 51 | 52 | expect(callCount).toEqual(EXPECTED_CALL_COUNT) 53 | }) 54 | 55 | test(`still expecting this many callbacks to have run`, () => { 56 | bot.pause() 57 | 58 | expect(true).toEqual(bot.paused()) 59 | 60 | bot.emit('step') 61 | bot.emit('step') 62 | bot.emit('step') 63 | 64 | expect(callCount).toEqual(EXPECTED_CALL_COUNT_AFTER_PAUSING) 65 | }) 66 | 67 | test(`expecting a few more callbacks to have run after resuming`, () => { 68 | bot.resume() 69 | 70 | expect(false).toEqual(bot.paused()) 71 | 72 | bot.emit('step') 73 | bot.emit('step') 74 | bot.emit('step') 75 | 76 | expect(callCount).toEqual(EXPECTED_CALL_COUNT_AFTER_RESUMING) 77 | }) 78 | 79 | test(`expecting NO more callbacks to have run after cleanup`, () => { 80 | cleanupFns.forEach(fn => fn()) 81 | 82 | expect(callCount).toEqual(EXPECTED_CALL_COUNT_AFTER_CLEANUP) 83 | }) 84 | -------------------------------------------------------------------------------- /tests/peek.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Statebot } = require('../src/statebot') 3 | 4 | const bot = Statebot('test-peek', { 5 | chart: ` 6 | 7 | idle -> pending -> 8 | (rejected | resolved) -> 9 | 10 | finished 11 | 12 | `, 13 | logLevel: 0 14 | }) 15 | 16 | const addGoodHandlers = () => bot.performTransitions({ 17 | 'idle -> pending': { 18 | on: 'start', 19 | }, 20 | 'pending -> resolved': { 21 | on: 'pass', 22 | }, 23 | 'pending -> rejected': { 24 | on: 'fail', 25 | }, 26 | 'rejected | resolved -> finished': { 27 | on: 'done', 28 | }, 29 | }) 30 | 31 | const addBadHandlers = () => bot.performTransitions({ 32 | 'idle -> pending': { 33 | on: 'start', 34 | }, 35 | // Bad config! 36 | 'pending -> resolved | rejected': { 37 | on: 'pass', 38 | }, 39 | 'rejected | resolved -> finished': { 40 | on: 'done', 41 | }, 42 | }) 43 | 44 | test(`test basic canTransitionTo() throws with wrong args`, () => { 45 | expect(() => bot.canTransitionTo(1)).toThrow() 46 | expect(() => bot.canTransitionTo(undefined, null, 'string')).toThrow() 47 | expect(() => bot.canTransitionTo('', {})).toThrow() 48 | expect(() => bot.canTransitionTo('', {}, '')).toThrow() 49 | expect(() => bot.canTransitionTo('', { afterEmitting: '' }, '')).toThrow() 50 | expect(() => bot.canTransitionTo('', { afterEmitting: '' })).not.toThrow() 51 | expect(() => bot.canTransitionTo('')).not.toThrow() 52 | expect(() => bot.canTransitionTo([''])).not.toThrow() 53 | expect(() => bot.canTransitionTo('', '')).not.toThrow() 54 | expect(() => bot.canTransitionTo(['', ''])).not.toThrow() 55 | }) 56 | 57 | test(`test basic peek() usage`, () => { 58 | const removeHandlers = addGoodHandlers() 59 | 60 | expect(bot.peek('start')).toBe('pending') 61 | expect(bot.peek('pass')).toBe('idle') 62 | expect(bot.peek('fail')).toBe('idle') 63 | expect(bot.peek('done')).toBe('idle') 64 | 65 | expect(bot.canTransitionTo('pending', { afterEmitting: 'start' })).toBe(true) 66 | bot.emit('start') 67 | expect(bot.canTransitionTo('pending', { afterEmitting: 'start' })).toBe(false) 68 | 69 | expect(bot.peek('start')).toBe('pending') 70 | expect(bot.peek('pass')).toBe('resolved') 71 | expect(bot.peek('fail')).toBe('rejected') 72 | expect(bot.peek('done')).toBe('pending') 73 | 74 | expect(bot.canTransitionTo('resolved', { afterEmitting: 'pass' })).toBe(true) 75 | bot.emit('pass') 76 | expect(bot.canTransitionTo('resolved', { afterEmitting: 'pass' })).toBe(false) 77 | 78 | expect(bot.peek('start')).toBe('resolved') 79 | expect(bot.peek('pass')).toBe('resolved') 80 | expect(bot.peek('fail')).toBe('resolved') 81 | expect(bot.peek('done')).toBe('finished') 82 | 83 | expect(bot.peek('done', { finished: true })).toBe(true) 84 | expect(bot.peek('done', { resolved: true })).toBe(null) 85 | 86 | expect(bot.canTransitionTo('finished', { afterEmitting: 'done' })).toBe(true) 87 | bot.emit('done') 88 | expect(bot.canTransitionTo('finished', { afterEmitting: 'done' })).toBe(false) 89 | 90 | expect(bot.peek('done', {})).toBe(null) 91 | expect(bot.peek('done', { undefined })).toBe(undefined) 92 | expect(bot.peek('done', { undefined: () => null })).toBe(null) 93 | 94 | bot.reset() 95 | removeHandlers() 96 | }) 97 | 98 | test(`test extended peek() usage`, () => { 99 | const removeHandlers = addGoodHandlers() 100 | 101 | const peekConfig1 = { 102 | undefined: 'idle', 103 | 'pending': () => 'pending', 104 | } 105 | 106 | expect(bot.peek('start', peekConfig1)).toBe('pending') 107 | expect(bot.peek('pass', peekConfig1)).toBe('idle') 108 | expect(bot.peek('fail', peekConfig1)).toBe('idle') 109 | expect(bot.peek('done', peekConfig1)).toBe('idle') 110 | 111 | bot.emit('start') 112 | 113 | const peekConfig2 = { 114 | undefined: 'pending', 115 | 'resolved': 'resolved', 116 | 'rejected': () => 'rejected', 117 | } 118 | 119 | expect(bot.peek('start', peekConfig2)).toBe('pending') 120 | expect(bot.peek('pass', peekConfig2)).toBe('resolved') 121 | expect(bot.peek('fail', peekConfig2)).toBe('rejected') 122 | expect(bot.peek('done', peekConfig2)).toBe('pending') 123 | 124 | bot.emit('pass') 125 | 126 | const peekConfig3 = { 127 | undefined: 'resolved', 128 | 'finished': () => 'finished', 129 | } 130 | 131 | expect(bot.peek('start', peekConfig3)).toBe('resolved') 132 | expect(bot.peek('pass', peekConfig3)).toBe('resolved') 133 | expect(bot.peek('fail', peekConfig3)).toBe('resolved') 134 | expect(bot.peek('done', peekConfig3)).toBe('finished') 135 | 136 | expect(bot.peek('done', { finished: true })).toBe(true) 137 | expect(bot.peek('done', { resolved: true })).toBe(null) 138 | 139 | bot.emit('done') 140 | 141 | expect(bot.peek('done', {})).toBe(null) 142 | expect(bot.peek('done', { undefined })).toBe(undefined) 143 | expect(bot.peek('done', { undefined: () => null })).toBe(null) 144 | 145 | bot.reset() 146 | removeHandlers() 147 | }) 148 | 149 | test(`test bad performTransitions() config`, () => { 150 | const removeHandlers = addBadHandlers() 151 | 152 | expect(bot.peek('start')).toBe('pending') 153 | expect(bot.peek('pass')).toBe('idle') 154 | expect(bot.peek('fail')).toBe('idle') 155 | expect(bot.peek('done')).toBe('idle') 156 | 157 | bot.emit('start') 158 | 159 | expect(bot.peek('start')).toBe('pending') 160 | expect(() => bot.peek('pass')).toThrow() 161 | expect(() => bot.canTransitionTo('resolved', { afterEmitting: 'pass'})).toThrow() 162 | expect(bot.peek('fail')).toBe('pending') 163 | expect(bot.peek('done')).toBe('pending') 164 | 165 | expect(() => bot.emit('pass')).toThrow() 166 | 167 | expect(bot.peek('start')).toBe('pending') 168 | expect(() => bot.peek('pass')).toThrow() 169 | expect(() => bot.canTransitionTo('resolved', { afterEmitting: 'pass'})).toThrow() 170 | expect(bot.peek('fail')).toBe('pending') 171 | expect(bot.peek('done')).toBe('pending') 172 | 173 | expect(bot.peek('done', { finished: true })).toBe(null) 174 | expect(bot.peek('done', { resolved: true })).toBe(null) 175 | 176 | bot.emit('done') 177 | 178 | expect(bot.peek('done', {})).toBe(null) 179 | expect(bot.peek('done', { undefined })).toBe(undefined) 180 | expect(bot.peek('done', { undefined: () => null })).toBe(null) 181 | 182 | bot.reset() 183 | removeHandlers() 184 | }) 185 | -------------------------------------------------------------------------------- /tests/traffic-lights.mmd: -------------------------------------------------------------------------------- 1 | stateDiagram 2 | direction LR 3 | go --> prepareToStop 4 | prepareToStop --> stop 5 | 6 | %% ...gotta keep that traffic flowing 7 | stop --> prepareToGo 8 | prepareToGo --> go 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "outDir": "./docs" 5 | }, 6 | "typedocOptions": { 7 | "entryPoints": [ 8 | "./index.d.ts", 9 | "./assert/index.d.ts", 10 | "./hooks/react/index.d.ts", 11 | "./hooks/mithril/index.d.ts" 12 | ], 13 | "out": "docs" 14 | } 15 | } 16 | --------------------------------------------------------------------------------