├── LICENSE ├── README.md ├── TODO.md ├── css └── qunit-1.20.0.css ├── fixtures.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── index.js ├── properties.js └── types.js └── tests ├── index.js ├── interpreter.specs.js └── parcel-index.html /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 brucou 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IMPORTANT 2 | This package is kept for historical reasons but is no longer maintained. You can instead use the [Kingly](https://github.com/brucou/kingly) state machine library which implements the sought-for architecture and patterns. The reasons for the deprecation are: 3 | 4 | - The design of `xstate` makes it really hard to build a functional layer on top of it. `xstate` seems to favor non-functional usage as it links itself to the SCXML standard. SCXML goals and design (in particular the choice of XML) reflect the interests of the telecommunications industry at the time of creation (speech processing, multi-modal interactions mostly). SCXML is oriented to process control, and processes can not be manipulated like functions. 5 | - The cost associated to the `xstate` library (15Kb in some cases) outweighs the benefits. On top of that, adding an interpreting layer that deals with the API surface and complexity of `xstate` compounds the problem. Conversely, the Kingly state machine library compiles an average state machine to below 1 KB JavaScript with zero dependencies. In practice, the extra functionalities proposed by the SCXML-oriented `xstate` (actors, activities, etc.) can be replicated without the coupling and extending the API surface. Cf. Kingly's documentation for examples. 6 | - The interpreter layer is fragile as changes in `xstate` means maintenance tasks on the side of this library. A better design avoids unnecessary dependencies. 7 | 8 | 9 | # Table of contents 10 | - [Motivation](#motivation) 11 | - [Example](#example) 12 | - [Install](#install) 13 | - [Tests](#tests) 14 | - [API](#api) 15 | * [`xstateReactInterpreter(Machine, machineConfig, interpreterConfig)`](#-xstatereactinterpreter-machine--machineconfig--interpreterconfig--) 16 | + [Description](#description) 17 | + [Semantics](#semantics) 18 | + [Contracts](#contracts) 19 | + [Tips and gotchas](#tips-and-gotchas) 20 | 21 | # Motivation 22 | The [xstate](https://github.com/davidkpiano/xstate) statecharts library has a few interpreters 23 | already available with miscelleanous design goals. In order to integrate `xstate` with React 24 | through our [`react-state-driven`](https://github.com/brucou/react-state-driven) 25 | component, we needed to adapt the xstate interface to the interface for our `Machine` component. 26 | 27 | This is so because our component achieves a decoupling of the state machine on one side, and the 28 | React component on the other side, but also separates out the event handling, state 29 | representation and effect execution concerns out of the library. The usual technique of 30 | **programming to an interface instead of to an implementation** is used to that purpose, which in 31 | turn means that the external concerns have to abide by the interface set by the `Machine` component. 32 | 33 | It turns out that we could not reuse the default interpreter and other existing 34 | interpreters for `xstate` as they do not synchronously return the list of computed actions in 35 | response to an input (listeners are instead used). Additionally some interpreters may also 36 | produce effects which in our design is forbidden. As a result we created an interpreter which : 37 | 38 | - matches the required interface to integrate xstate in React through our `Machine` component 39 | - computes and returns a list of actions in response to an input 40 | - does not produce any effects 41 | 42 | The benefits are the following : 43 | - we were able to use `json patch` and `immer` for the state representation concern. `Immutable.js` 44 | could also be used via simple interface adaptation. If that is your use case, you may even 45 | interface a reducer which updates state in place. How you update state is not a concern of the 46 | `Machine` component 47 | - similarly (`xstate` machine library + this interpreter) can be replaced by the native 48 | `react-state-driven` library, or any other machine interpreter satisfying the required interface 49 | - event handling can be done with the event library of your choice, by writing an adapter for the 50 | accepted event handling interface (we did that for `rxjs` and `most` so far) 51 | - effect execution being separated out of the machine, it is easy to mock and stub effects for 52 | testing purposes. This will allow to enjoy the benefits of automated testing without breaking 53 | glasses. 54 | - and React aside, we were able to integrate the interpreter with `cyclejs` with no major effort! 55 | Integration with `Angular2` is in progress but seems to be going the same painless way. After 56 | all, the interpreter is just a function! 57 | 58 | # Example 1 : json-patch as state reducer, rxjs for event processing 59 | 60 | ```javascript 61 | import { applyPatch } from "json-patch-es6" 62 | 63 | // The machine may produce several outputs when transitioning, they have to be merged 64 | const mergeOutputs = function (accOutputs, outputs) { 65 | return (accOutputs || []).concat(outputs) 66 | }; 67 | 68 | // The machine produces actions to update its extended state, the reducer executes those actions 69 | const jsonPatchReducer = (extendedState, extendedStateUpdateOperations) => { 70 | return applyPatch(extendedState, extendedStateUpdateOperations, false, false).newDocument; 71 | }; 72 | 73 | const actionFactoryMaps = { 74 | stringActions: { 75 | 'cancelAdmin': (extendedState, event) => { 76 | return { 77 | updates: [{ op: 'add', path: '/isAdmin', value: false }], 78 | outputs: ['admin rights overriden'] 79 | } 80 | } 81 | }, 82 | }; 83 | 84 | // xstate machine 85 | const hierarchicalMachine = { 86 | context: { isAdmin: true }, 87 | id: 'door', 88 | initial: 'closed', 89 | states: { 90 | closed: { 91 | initial: 'idle', 92 | states: { 93 | 'idle': {}, 94 | 'error': { 95 | onEntry: function logMessage(extS, ev) {return { updates: [], outputs: ['Entered .closed.error!', ev] }} 96 | } 97 | }, 98 | // NOTE : test input sequence : ['OPEN', {'CLOSE', overrideAdmin:true}, 'OPEN'] 99 | on: { 100 | OPEN: [ 101 | { target: 'opened', cond: (extState, eventObj) => extState.isAdmin }, 102 | { target: 'closed.error' } 103 | ] 104 | } 105 | }, 106 | opened: { 107 | on: { 108 | CLOSE: [ 109 | { target: 'closed', cond: (extState, eventObj) => eventObj.overrideAdmin, actions: ['cancelAdmin'] }, 110 | { target: 'closed', cond: (extState, eventObj) => !eventObj.overrideAdmin } 111 | ] 112 | } 113 | }, 114 | } 115 | }; 116 | 117 | // Test paraphernalia 118 | export const testCases = { 119 | HierarchicalMachineAndJSONPatchAndFunctionActionsAndObjectEvents: { 120 | description: '(hierarchical, json patch, mergeOutput, action functions and strings, event as object, >1 inputs)', 121 | machine: hierarchicalMachine, 122 | updateState: reducers.jsonpatchReducer, 123 | actionFactoryMap: actionFactoryMaps.stringActions, 124 | mergeOutputs, 125 | inputSequence: ['OPEN', { type: 'CLOSE', overrideAdmin: true }, 'OPEN'], 126 | outputSequence: [null, ['admin rights overriden'], ["Entered .closed.error!", "OPEN"]] 127 | }, 128 | } 129 | 130 | QUnit.test("(hierarchical, json patch, mergeOutput, action functions and strings, event as object, >1 inputs)", function exec_test(assert) { 131 | const testCase = testCases.HierarchicalMachineAndJSONPatchAndFunctionActionsAndObjectEvents; 132 | const machineConfig = testCase.machine; 133 | const interpreterConfig = { 134 | updateState: testCase.updateState, 135 | mergeOutputs: testCase.mergeOutputs, 136 | actionFactoryMap: testCase.actionFactoryMap, 137 | }; 138 | 139 | const interpreter = xstateReactInterpreter(Machine, machineConfig, interpreterConfig); 140 | const testScenario = testCase.inputSequence; 141 | const actualTestResults = testScenario.map(interpreter.yield); 142 | const expectedTestResults = testCase.outputSequence; 143 | 144 | testScenario.forEach((input, index) => { 145 | assert.deepEqual( 146 | actualTestResults[index], 147 | expectedTestResults[index], 148 | testCase.description 149 | ); 150 | }); 151 | 152 | assert.ok(testCase.machine.context === initialContextHierarchicalMachine, `json patch does not mutate state in place`); 153 | }); 154 | ``` 155 | 156 | What happens here : 157 | - the machine starts in the configured initial state with the configured extended state 158 | - we made the following choices for our interpreter : 159 | - use json patch for immutable state update 160 | - outputs of the machines are arrays 161 | - those arrays will be merged by simple concatenation 162 | - we send a `OPEN` input to the machine which triggers : 163 | - because the guard is satisfied, and there is no actions defined, the machine will move to the 164 | `opened` control state, and outputs `null`, which is the value chosen for indicating that there 165 | is no output. 166 | - we send an object input `{ type: 'CLOSE', overrideAdmin: true }` to the machine : 167 | - because `overrideAdmin` property is set in the event object, the transition chosen triggers 168 | the `cancelAdmin` action, and the entry in the `closed` control state. The `cancelAdmin` 169 | action consists of updating the `isAdmin` property of the extended state of the machine to 170 | `false`. The machine outputs are `[admin rights overrriden]`. 171 | - we then send the input `'OPEN'` to the machine : 172 | - because the property `isAdmin` is no longer set on the extended state, the machine will 173 | transition to the `'closed.error'` control state. On entering that state, the machine will 174 | outputs as configured `['Entered .closed.error!', ev]` with `ev` being `"OPEN"` 175 | 176 | In short, we have shown : 177 | - `mergeOutputs` and `updateState` configuration 178 | - how to map action strings to action factories through the mapping object `actionFactoryMap` 179 | - how to directly include action factory in the xstate machine 180 | - action factories produce two pieces of information to the interpreter : 181 | - how to update the machine's extended state 182 | - what are the machine outputs 183 | 184 | Contrary to other interpreters, the interpreter does not interpret effects. In our React 185 | integration design, that responsibility is delegated to the command handler. The interpreter 186 | simply advances the machines, thereby updating the machine state, and producing the machine's 187 | outputs. The state of the machine is hence completely encapsulated and cannot be accessed from the 188 | outside. Our interpreter is just a function producing outputs in function of the state of the 189 | underlying machine. In our React machine component design, those outputs are commands towards to 190 | the interfaced systems. 191 | 192 | # Image gallery search : immer as state reducer, rxjs for event processing 193 | 194 | ![image search interface](https://i.imgur.com/mDQQTX8.png?1) 195 | 196 | ![machine visualization](https://i.imgur.com/z4hn4Cv.png?1) 197 | 198 | ```javascript 199 | const xStateRxAdapter = { 200 | subjectFactory: () => new Rx.Subject(), 201 | // NOTE : must be bound, because, reasons 202 | merge: Rx.Observable.merge.bind(Rx.Observable), 203 | create: fn => Rx.Observable.create(fn) 204 | }; 205 | 206 | const showXstateMachine = machine => { 207 | const interpreterConfig = { 208 | updateState: machine.updateState, 209 | mergeOutputs: machine.mergeOutputs, 210 | actionFactoryMap: machine.actionFactoryMap, 211 | }; 212 | const fsm = xstateReactInterpreter(xstateMachineFactory, machine.config, interpreterConfig); 213 | 214 | return React.createElement(Machine, { 215 | eventHandler: xStateRxAdapter, 216 | preprocessor: machine.preprocessor, 217 | fsm: fsm, 218 | commandHandlers: machine.commandHandlers, 219 | componentWillUpdate: (machine.componentWillUpdate || noop)(machine.inject), 220 | componentDidUpdate: (machine.componentDidUpdate || noop)(machine.inject) 221 | }, null) 222 | }; 223 | 224 | // Displays all machines (not very beautifully, but this is just for testing) 225 | ReactDOM.render( 226 | div([ 227 | showXstateMachine(xstateMachines.xstateImageGallery) 228 | ]), 229 | document.getElementById('root') 230 | ); 231 | 232 | export const NO_IMMER_UPDATES = nothing; 233 | export const immerReducer = function (extendedState, updates) { 234 | if (updates === NO_IMMER_UPDATES) return extendedState 235 | const updateFn = updates; 236 | return produce(extendedState, updateFn) 237 | }; 238 | 239 | export const mergeOutputs = function (accOutputs, outputs) { 240 | return (accOutputs || []).concat(outputs || []) 241 | }; 242 | 243 | export const xstateMachines = { 244 | xstateImageGallery: { 245 | preprocessor: rawEventSource => rawEventSource 246 | .startWith([INIT_EVENT]) 247 | .map(ev => { 248 | const { rawEventName, rawEventData: e, ref } = destructureEvent(ev); 249 | 250 | if (rawEventName === INIT_EVENT) { 251 | return { type: INIT_EVENT, data : void 0} 252 | } 253 | // Form raw events 254 | else if (rawEventName === 'onSubmit') { 255 | e.persist(); 256 | e.preventDefault(); 257 | return { type: 'SEARCH', data: ref.current.value } 258 | } 259 | else if (rawEventName === 'onCancelClick') { 260 | return { type: 'CANCEL_SEARCH', data: void 0 } 261 | } 262 | // Gallery 263 | else if (rawEventName === 'onGalleryClick') { 264 | const item = e; 265 | return { type: 'SELECT_PHOTO', data: item } 266 | } 267 | // Photo detail 268 | else if (rawEventName === 'onPhotoClick') { 269 | return { type: 'EXIT_PHOTO', data: void 0 } 270 | } 271 | // System events 272 | else if (rawEventName === 'SEARCH_SUCCESS') { 273 | const items = e; 274 | return { type: 'SEARCH_SUCCESS', data: items } 275 | } 276 | else if (rawEventName === 'SEARCH_FAILURE') { 277 | return { type: 'SEARCH_FAILURE', data: void 0 } 278 | } 279 | 280 | return NO_INTENT 281 | }) 282 | .filter(x => x !== NO_INTENT) 283 | , 284 | // DOC : we kept the same machine but : 285 | // - added the render actions 286 | // - render must go last, in order to get the updated extended state 287 | // - added an init event to trigger an entry on the initial state 288 | config: { 289 | context: { query: '', items: [], photo: undefined, gallery: '' }, 290 | initial: 'init', 291 | states: { 292 | init: { 293 | on: { [INIT_EVENT]: 'start' } 294 | }, 295 | start: { 296 | onEntry: [renderGalleryAppImmer('start')], 297 | on: { SEARCH: 'loading' } 298 | }, 299 | loading: { 300 | onEntry: ['search', renderGalleryAppImmer('loading')], 301 | on: { 302 | SEARCH_SUCCESS: { target: 'gallery', actions: ['updateItems'] }, 303 | SEARCH_FAILURE: 'error', 304 | CANCEL_SEARCH: 'gallery' 305 | } 306 | }, 307 | error: { 308 | onEntry: [renderGalleryAppImmer('error')], 309 | on: { SEARCH: 'loading' } 310 | }, 311 | gallery: { 312 | onEntry: [renderGalleryAppImmer('gallery')], 313 | on: { 314 | SEARCH: 'loading', 315 | SELECT_PHOTO: 'photo' 316 | } 317 | }, 318 | photo: { 319 | onEntry: ['setPhoto', renderGalleryAppImmer('photo')], 320 | on: { EXIT_PHOTO: 'gallery' } 321 | } 322 | } 323 | }, 324 | actionFactoryMap: { 325 | 'search': (extendedState, { data: query }, xstateAction) => { 326 | const searchCommand = { command: COMMAND_SEARCH, params: query }; 327 | 328 | return { 329 | outputs: [searchCommand], 330 | updates: nothing 331 | } 332 | }, 333 | 'updateItems': (extendedState, { data: items }, xstateAction) => { 334 | return { 335 | updates: extendedState => {extendedState.items = items}, 336 | outputs: NO_OUTPUT 337 | } 338 | }, 339 | 'setPhoto': (extendedState, { data: item }, xstateAction) => { 340 | return { 341 | updates: extendedState => {extendedState.photo = item}, 342 | outputs: NO_OUTPUT 343 | } 344 | } 345 | }, 346 | updateState: immerReducer, 347 | mergeOutputs: mergeOutputs, 348 | commandHandlers: { 349 | [COMMAND_SEARCH]: (trigger, query) => { 350 | runSearchQuery(query) 351 | .then(data => { 352 | trigger('SEARCH_SUCCESS')(data.items) 353 | }) 354 | .catch(error => { 355 | trigger('SEARCH_FAILURE')(void 0) 356 | }); 357 | } 358 | }, 359 | inject: new Flipping(), 360 | componentWillUpdate: flipping => (machineComponent, prevProps, prevState, snapshot, settings) => {flipping.read();}, 361 | componentDidUpdate: flipping => (machineComponent, nextProps, nextState, settings) => {flipping.flip();} 362 | } 363 | ``` 364 | 365 | # Install 366 | `npm xstate-interpreter` 367 | 368 | # Tests 369 | `npm run test` 370 | 371 | # API 372 | ## `xstateReactInterpreter(Machine, machineConfig, interpreterConfig)` 373 | ### Description 374 | The factory `xstateReactInterpreter` returns an interpreter with a `yield` function by which 375 | inputs will be sent to the machine and outputs will be collected. It also returns an instance of 376 | the executable state machine. 377 | 378 | ### Semantics 379 | - the machine is initialized per its configuration and specifications 380 | - the interpreter returns a `yield` function to call the machine with an input 381 | - the machine's actions are *in fine* functions (termed action factories); 382 | - whose input parameters are the machine's extended state and event 383 | - which return : 384 | - a description of the updates to perform on its extended state as a result of the transition 385 | - the outputs for the state machine as a result of receiving the input 386 | - on transitioning, the machine produces `updates` and `outputs`. The interpreter : 387 | - perform actual updates on the machine's extended state, according to the `updateState` 388 | configured reducer 389 | - outputs from the machine's triggered action factories are merged with the configured 390 | `mergeOutputs` and returned 391 | 392 | ### Types 393 | JSDoc types available is `/src/types` : 394 | 395 | ```javascript 396 | 397 | /** 398 | * @typedef {function(ExtendedState, ExtendedStateUpdate): ExtendedState} ExtendedStateReducer 399 | */ 400 | /** 401 | * @typedef {*} Output 402 | */ 403 | /** 404 | * @typedef {Container} Outputs 405 | * `Container` is a foldable functor, for instance `Array` 406 | */ 407 | /** 408 | * @typedef {function(Outputs, Outputs): Outputs} OutputReducer 409 | */ 410 | /** 411 | * @typedef {String} xStateActionType 412 | * The type of xstate action. In xstate v4.0, this is the property `type` of the xstate action 413 | */ 414 | /** 415 | * @typedef {*} xStateEvent 416 | * cf. xState types. Usually either a string or an object with a `type` property which is a string 417 | */ 418 | /** 419 | * @typedef {*} xstateAction 420 | * cf. xState types. Usually an object with at least a `type` and `exec` property 421 | * The exec property when set (i.e. truthy) holds an action factory function. 422 | * The `type` property when set holds an identifier used to map to an action factory 423 | */ 424 | /** 425 | * @typedef {function(ExtendedState, xStateEvent, xstateAction):x} xStateActionFactory 426 | * The type of xstate action. In xstate v4.0, this is the property `type` of the xstate action 427 | */ 428 | /** 429 | * @typedef {Object} interpreterConfig 430 | * @property {ExtendedStateReducer} updateState 431 | * @property {OutputReducer} mergeOutputs 432 | * @property {Object.} actionFactoryMap 433 | */ 434 | ``` 435 | 436 | ### Contracts 437 | - `updateState` and `mergeOutput` should be pure, monoidal operations 438 | - i.e. with an empty value, and associativity properties 439 | - all functions involved in the machine and interpreter configuration should be pure functions 440 | - if you use a function as xstate action, that function must be a named function!! 441 | - type contracts 442 | - integrating `xstate-interpreter` with `react-state-driven` means that the xstate machine will 443 | receive an init event. This means a dummy initial state and an init transition should be configured 444 | towards the real initial state of the machine. Alternaively, the machine can be configured to 445 | simply ignore unaccepted events. In any case, the xstate machine cannot reuse the reserved 446 | initial event. 447 | 448 | ### Tips, gotchas and limitations 449 | - activities and delays are not currently interpreted 450 | - `xstate` has automatically configured actions (logs, assign, invoke, etc). If you use them you 451 | will have to define a matching action factory. Our interpreter comes without any predefined action 452 | factory. 453 | - you can specify xstate actions as strings or functions or objects. I recommend to pick up your 454 | poison instead of juggling with 3 different types. (Named) Functions are the best option in my 455 | eyes, provided they do not prevent the machine visualizer from doing its job. 456 | - the second parameter of the xstate machine factory i.e. `actions` is absorbed into the 457 | configuration of the interpreter to avoid confusion or duplication 458 | - if the machine does not have any actions configured for an occurring transition, it outputs 459 | a constant indicating that there is no output (in this version the constant is `null`). The 460 | machine being a function, always outputs something as a result of being called. 461 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brucou/xstate-interpreter/f0f9447d78005b73769dda7db2d8b9e636c01ff6/TODO.md -------------------------------------------------------------------------------- /css/qunit-1.20.0.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * QUnit 1.20.0 3 | * http://qunitjs.com/ 4 | * 5 | * Copyright jQuery Foundation and other contributors 6 | * Released under the MIT license 7 | * http://jquery.org/license 8 | * 9 | * Date: 2015-10-27T17:53Z 10 | */ 11 | 12 | /** Font Family and Sizes */ 13 | 14 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult { 15 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; 16 | } 17 | 18 | #qunit-testrunner-toolbar, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 19 | #qunit-tests { font-size: smaller; } 20 | 21 | 22 | /** Resets */ 23 | 24 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter { 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | 30 | /** Header */ 31 | 32 | #qunit-header { 33 | padding: 0.5em 0 0.5em 1em; 34 | 35 | color: #8699A4; 36 | background-color: #0D3349; 37 | 38 | font-size: 1.5em; 39 | line-height: 1em; 40 | font-weight: 400; 41 | 42 | border-radius: 5px 5px 0 0; 43 | } 44 | 45 | #qunit-header a { 46 | text-decoration: none; 47 | color: #C2CCD1; 48 | } 49 | 50 | #qunit-header a:hover, 51 | #qunit-header a:focus { 52 | color: #FFF; 53 | } 54 | 55 | #qunit-testrunner-toolbar label { 56 | display: inline-block; 57 | padding: 0 0.5em 0 0.1em; 58 | } 59 | 60 | #qunit-banner { 61 | height: 5px; 62 | } 63 | 64 | #qunit-testrunner-toolbar { 65 | padding: 0.5em 1em 0.5em 1em; 66 | color: #5E740B; 67 | background-color: #EEE; 68 | overflow: hidden; 69 | } 70 | 71 | #qunit-filteredTest { 72 | padding: 0.5em 1em 0.5em 1em; 73 | background-color: #F4FF77; 74 | color: #366097; 75 | } 76 | 77 | #qunit-userAgent { 78 | padding: 0.5em 1em 0.5em 1em; 79 | background-color: #2B81AF; 80 | color: #FFF; 81 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 82 | } 83 | 84 | #qunit-modulefilter-container { 85 | float: right; 86 | padding: 0.2em; 87 | } 88 | 89 | .qunit-url-config { 90 | display: inline-block; 91 | padding: 0.1em; 92 | } 93 | 94 | .qunit-filter { 95 | display: block; 96 | float: right; 97 | margin-left: 1em; 98 | } 99 | 100 | /** Tests: Pass/Fail */ 101 | 102 | #qunit-tests { 103 | list-style-position: inside; 104 | } 105 | 106 | #qunit-tests li { 107 | padding: 0.4em 1em 0.4em 1em; 108 | border-bottom: 1px solid #FFF; 109 | list-style-position: inside; 110 | } 111 | 112 | #qunit-tests > li { 113 | display: none; 114 | } 115 | 116 | #qunit-tests li.running, 117 | #qunit-tests li.pass, 118 | #qunit-tests li.fail, 119 | #qunit-tests li.skipped { 120 | display: list-item; 121 | } 122 | 123 | #qunit-tests.hidepass li.running, 124 | #qunit-tests.hidepass li.pass { 125 | visibility: hidden; 126 | position: absolute; 127 | width: 0; 128 | height: 0; 129 | padding: 0; 130 | border: 0; 131 | margin: 0; 132 | } 133 | 134 | #qunit-tests li strong { 135 | cursor: pointer; 136 | } 137 | 138 | #qunit-tests li.skipped strong { 139 | cursor: default; 140 | } 141 | 142 | #qunit-tests li a { 143 | padding: 0.5em; 144 | color: #C2CCD1; 145 | text-decoration: none; 146 | } 147 | 148 | #qunit-tests li p a { 149 | padding: 0.25em; 150 | color: #6B6464; 151 | } 152 | #qunit-tests li a:hover, 153 | #qunit-tests li a:focus { 154 | color: #000; 155 | } 156 | 157 | #qunit-tests li .runtime { 158 | float: right; 159 | font-size: smaller; 160 | } 161 | 162 | .qunit-assert-list { 163 | margin-top: 0.5em; 164 | padding: 0.5em; 165 | 166 | background-color: #FFF; 167 | 168 | border-radius: 5px; 169 | } 170 | 171 | .qunit-source { 172 | margin: 0.6em 0 0.3em; 173 | } 174 | 175 | .qunit-collapsed { 176 | display: none; 177 | } 178 | 179 | #qunit-tests table { 180 | border-collapse: collapse; 181 | margin-top: 0.2em; 182 | } 183 | 184 | #qunit-tests th { 185 | text-align: right; 186 | vertical-align: top; 187 | padding: 0 0.5em 0 0; 188 | } 189 | 190 | #qunit-tests td { 191 | vertical-align: top; 192 | } 193 | 194 | #qunit-tests pre { 195 | margin: 0; 196 | white-space: pre-wrap; 197 | word-wrap: break-word; 198 | } 199 | 200 | #qunit-tests del { 201 | background-color: #E0F2BE; 202 | color: #374E0C; 203 | text-decoration: none; 204 | } 205 | 206 | #qunit-tests ins { 207 | background-color: #FFCACA; 208 | color: #500; 209 | text-decoration: none; 210 | } 211 | 212 | /*** Test Counts */ 213 | 214 | #qunit-tests b.counts { color: #000; } 215 | #qunit-tests b.passed { color: #5E740B; } 216 | #qunit-tests b.failed { color: #710909; } 217 | 218 | #qunit-tests li li { 219 | padding: 5px; 220 | background-color: #FFF; 221 | border-bottom: none; 222 | list-style-position: inside; 223 | } 224 | 225 | /*** Passing Styles */ 226 | 227 | #qunit-tests li li.pass { 228 | color: #3C510C; 229 | background-color: #FFF; 230 | border-left: 10px solid #C6E746; 231 | } 232 | 233 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 234 | #qunit-tests .pass .test-name { color: #366097; } 235 | 236 | #qunit-tests .pass .test-actual, 237 | #qunit-tests .pass .test-expected { color: #999; } 238 | 239 | #qunit-banner.qunit-pass { background-color: #C6E746; } 240 | 241 | /*** Failing Styles */ 242 | 243 | #qunit-tests li li.fail { 244 | color: #710909; 245 | background-color: #FFF; 246 | border-left: 10px solid #EE5757; 247 | white-space: pre; 248 | } 249 | 250 | #qunit-tests > li:last-child { 251 | border-radius: 0 0 5px 5px; 252 | } 253 | 254 | #qunit-tests .fail { color: #000; background-color: #EE5757; } 255 | #qunit-tests .fail .test-name, 256 | #qunit-tests .fail .module-name { color: #000; } 257 | 258 | #qunit-tests .fail .test-actual { color: #EE5757; } 259 | #qunit-tests .fail .test-expected { color: #008000; } 260 | 261 | #qunit-banner.qunit-fail { background-color: #EE5757; } 262 | 263 | /*** Skipped tests */ 264 | 265 | #qunit-tests .skipped { 266 | background-color: #EBECE9; 267 | } 268 | 269 | #qunit-tests .qunit-skipped-label { 270 | background-color: #F4FF77; 271 | display: inline-block; 272 | font-style: normal; 273 | color: #366097; 274 | line-height: 1.8em; 275 | padding: 0 0.5em; 276 | margin: -0.4em 0.4em -0.4em 0; 277 | } 278 | 279 | /** Result */ 280 | 281 | #qunit-testresult { 282 | padding: 0.5em 1em 0.5em 1em; 283 | 284 | color: #2B81AF; 285 | background-color: #D2E0E6; 286 | 287 | border-bottom: 1px solid #FFF; 288 | } 289 | #qunit-testresult .module-name { 290 | font-weight: 700; 291 | } 292 | 293 | /** Fixture */ 294 | 295 | #qunit-fixture { 296 | position: absolute; 297 | top: -10000px; 298 | left: -10000px; 299 | width: 1000px; 300 | height: 1000px; 301 | } 302 | -------------------------------------------------------------------------------- /fixtures.js: -------------------------------------------------------------------------------- 1 | import produce, { nothing } from "immer" 2 | import { applyPatch } from "json-patch-es6" 3 | 4 | export const emptyArray = []; 5 | // cf. http://davidkpiano.github.io/xstate/docs/#/api/config 6 | // NOTE : so it turns that xstate action as string is not a possibility, so only action as object remaining to test 7 | const nonHierarchicalMachine = { 8 | context: emptyArray, 9 | initial: 'green', 10 | states: { 11 | green: { 12 | on: { 13 | TIMER: { 14 | target: 'yellow', // since 4.0 15 | // specify that 'startYellowTimer' action should be executed 16 | actions: ['incGreenTimer'] 17 | } 18 | } 19 | }, 20 | yellow: { 21 | onEntry: ['incYellowTimer'], 22 | on: { 23 | TIMER: [ 24 | { target: 'red', cond: timer => timer.filter(x => x === 'yellow').length > 1 }, 25 | { target: 'yellow', cond: timer => timer.filter(x => x === 'yellow').length <= 1 }, 26 | ] 27 | } 28 | }, 29 | red: { 30 | on: { 31 | TIMER: { 32 | target: 'green', 33 | actions: ['logGreen'] 34 | } 35 | } 36 | }, 37 | } 38 | }; 39 | // https://xstate.js.org/docs/#/guides/guards 40 | export const initialContextHierarchicalMachine = { isAdmin: true }; 41 | const hierarchicalMachine = { 42 | context: initialContextHierarchicalMachine, 43 | id: 'door', 44 | initial: 'closed', 45 | states: { 46 | closed: { 47 | initial: 'idle', 48 | states: { 49 | 'idle': {}, 50 | 'error': { 51 | onEntry: [function logMessage(extS, ev) {return { updates: [], outputs: ['Entered .closed.error!', ev] }}] 52 | } 53 | }, 54 | // 'OPEN', {CLOSE, overrideAdmin:true}, 'OPEN'} 55 | on: { 56 | OPEN: [ 57 | { target: 'opened', cond: (extState, eventObj) => extState.isAdmin }, 58 | { target: 'closed.error' } 59 | ] 60 | } 61 | }, 62 | opened: { 63 | on: { 64 | CLOSE: [ 65 | { target: 'closed', cond: (extState, eventObj) => eventObj.overrideAdmin, actions: { type: 'cancelAdmin' } }, 66 | { target: 'closed', cond: (extState, eventObj) => !eventObj.overrideAdmin } 67 | ] 68 | } 69 | }, 70 | } 71 | }; 72 | // cf. http://davidkpiano.github.io/xstate/docs/#/api/config 73 | const parallelMachine = { 74 | key: 'intersection', 75 | parallel: true, 76 | states: { 77 | northSouthLight: { 78 | initial: 'green', 79 | states: { 80 | green: { on: { TIMER: 'yellow' } }, 81 | yellow: { on: { TIMER: 'red' } }, 82 | red: { on: { TIMER: 'green' } }, 83 | } 84 | }, 85 | eastWestLight: { 86 | initial: 'red', 87 | states: { 88 | green: { on: { TIMER: 'yellow' } }, 89 | yellow: { on: { TIMER: 'red' } }, 90 | red: { on: { TIMER: 'green' } }, 91 | } 92 | } 93 | } 94 | }; 95 | 96 | const actionFactoryMaps = { 97 | stringActions: { 98 | 'incYellowTimer': (extendedState, event) => { 99 | return { 100 | updates: extendedState => {extendedState.push('yellow')}, 101 | outputs: [extendedState, event] 102 | } 103 | }, 104 | 'incGreenTimer': (extendedState, event) => { 105 | return { 106 | updates: extendedState => {extendedState.push('green')}, 107 | outputs: [extendedState, event] 108 | } 109 | }, 110 | 'logGreen': (extendedState, event) => { 111 | return { 112 | updates: extendedState => {extendedState.pop(), extendedState.pop()}, 113 | outputs: [extendedState, event] 114 | } 115 | }, 116 | 'cancelAdmin': (extendedState, event, action) => { 117 | return { 118 | updates: [{ op: 'add', path: '/isAdmin', value: false }], 119 | outputs: ['admin rights overriden'] 120 | } 121 | } 122 | }, 123 | }; 124 | 125 | // DOC : for immer, updates are ONE function, not an array 126 | export const NO_IMMER_UPDATES = nothing; 127 | export const immerReducer = function (extendedState, updates) { 128 | if (updates === NO_IMMER_UPDATES) return extendedState 129 | const updateFn = updates; 130 | return produce(extendedState, updateFn) 131 | }; 132 | 133 | export const jsonPatchReducer = (extendedState, extendedStateUpdateOperations) => { 134 | // NOTE : we don't validate operations, to avoid throwing errors when for instance the value property for an 135 | // `add` JSON operation is `undefined` ; and of course we don't mutate the document in place 136 | return applyPatch(extendedState, extendedStateUpdateOperations, false, false).newDocument; 137 | }; 138 | export const NO_JSON_PATCH_UPDATES = []; 139 | 140 | // DOC : outputs is an array of output = command 141 | export const mergeOutputs = function (accOutputs, outputs) { 142 | return (accOutputs || []).concat(outputs || []) 143 | }; 144 | 145 | const reducers = { 146 | immerReducer: immerReducer, 147 | jsonpatchReducer: jsonPatchReducer 148 | }; 149 | 150 | export const testCases = { 151 | // NOTE : THAT was tough to check - but precisely gives a good idea 152 | StandardMachineAndImmerAndStringsActionAndEvents: { 153 | description: '(non-hierarchical, immer, mergeOutput, all action strings or none, event as string, >1 inputs)', 154 | machine: nonHierarchicalMachine, 155 | updateState: reducers.immerReducer, 156 | actionFactoryMap: actionFactoryMaps.stringActions, 157 | mergeOutputs, 158 | inputSequence: ['TIMER', 'TIMER', 'TIMER', 'TIMER', 'TIMER'], 159 | outputSequence: [ 160 | [[], "TIMER", ["green"], "TIMER"], 161 | [["green", "yellow"], "TIMER"], 162 | null, 163 | [["green", "yellow", "yellow"], "TIMER"], 164 | [["green",], "TIMER", ["green", "green"], "TIMER"], 165 | ] 166 | }, 167 | HierarchicalMachineAndJSONPatchAndFunctionActionsAndObjectEvents: { 168 | description: '(hierarchical, json patch, mergeOutput, action functions and strings, event as object, >1 inputs)', 169 | machine: hierarchicalMachine, 170 | updateState: reducers.jsonpatchReducer, 171 | actionFactoryMap: actionFactoryMaps.stringActions, 172 | mergeOutputs, 173 | inputSequence: ['OPEN', { type: 'CLOSE', overrideAdmin: true }, 'OPEN'], 174 | outputSequence: [null, ['admin rights overriden'], ["Entered .closed.error!", "OPEN"]] 175 | }, 176 | }; 177 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "git@github.com:brucou/xstate-interpreter.git", 3 | "name": "xstate-interpreter", 4 | "author": "brucou", 5 | "version": "0.1.1", 6 | "license": "MIT", 7 | "description": "An effectless stateful interpreter for xstate", 8 | "main": "dist/xstate-interpreter.js", 9 | "module": "dist/xstate-interpreter.es.js", 10 | "files": [ 11 | "DISCLAIMER", 12 | "dist" 13 | ], 14 | "sideEffects": false, 15 | "scripts": { 16 | "prebuild": "rimraf dist", 17 | "build": "rollup -c", 18 | "prepublish": "npm run build", 19 | "start": "webpack-dev-server --open", 20 | "test": "parcel tests/parcel-index.html" 21 | }, 22 | "devDependencies": { 23 | "babel-core": "^6.26.3", 24 | "babel-loader": "^7.1.4", 25 | "babel-plugin-annotate-pure-calls": "^0.3.0", 26 | "babel-plugin-external-helpers": "^6.22.0", 27 | "babel-plugin-idx": "^2.2.0", 28 | "babel-plugin-transform-class-properties": "^6.24.1", 29 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 30 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 31 | "babel-plugin-transform-react-remove-prop-types": "^0.4.13", 32 | "babel-preset-env": "^1.7.0", 33 | "babel-preset-react": "^6.24.1", 34 | "html-webpack-plugin": "^3.2.0", 35 | "idx": "^2.3.0", 36 | "prettier": "^1.14.2", 37 | "rimraf": "^2.6.2", 38 | "rollup": "^0.64.1", 39 | "rollup-plugin-babel": "^3.0.4", 40 | "rollup-plugin-node-resolve": "^3.4.0", 41 | "webpack": "^4.16.5", 42 | "webpack-cli": "^3.0.1", 43 | "webpack-dev-server": "^3.1.4", 44 | "parcel-bundler": "^1.9.5", 45 | "qunitjs": "^1.20.0", 46 | "immer": "^1.7.4" 47 | }, 48 | "dependencies": { 49 | "xstate": "^4.0.0-15", 50 | "state-transducer": "^0.9.0" 51 | }, 52 | "peerDependencies": {} 53 | } 54 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import pkg from './package.json' 3 | import resolve from 'rollup-plugin-node-resolve'; 4 | 5 | const makeExternalPredicate = externalArr => { 6 | if (externalArr.length === 0) { 7 | return () => false 8 | } 9 | const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`) 10 | return id => pattern.test(id) 11 | } 12 | 13 | export default { 14 | input: 'src/index.js', 15 | 16 | output: [ 17 | { file: pkg.main, format: 'cjs' }, 18 | { file: pkg.module, format: 'es' }, 19 | ], 20 | 21 | external: makeExternalPredicate([ 22 | ...Object.keys(pkg.dependencies || {}), 23 | ...Object.keys(pkg.peerDependencies || {}), 24 | ]), 25 | 26 | plugins: [ 27 | babel({ plugins: ['external-helpers'] }), 28 | resolve({ 29 | extensions: [ '.mjs', '.js', '.jsx', '.json' ], // Default: [ '.mjs', '.js', '.json', '.node' ], 30 | only: [ 31 | /^state-transducer$/, 32 | ], // Default: null 33 | }) 34 | ], 35 | } 36 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { INIT_EVENT, NO_OUTPUT } from "state-transducer" 2 | 3 | // Helpers 4 | function actionTypeContract(actionType) { 5 | return actionType === 'string' || actionType === 'object' || actionType === 'function' 6 | } 7 | 8 | function assertActionType(xstateAction) { 9 | if (!actionTypeContract(typeof xstateAction)) throw new Error(`xstateBridge > yyield > assertActionType : action must be either a function, a string or an object!`) 10 | return true 11 | } 12 | 13 | function actionFactoryFromXstateAction(xstateAction, actionFactoryMap) { 14 | let actionFactory = void 0; 15 | 16 | switch (typeof xstateAction.exec) { 17 | case 'function' : 18 | actionFactory = xstateAction.exec; 19 | break; 20 | // NOTE: never happens even though in the config, action is a string 21 | // case 'string': 22 | // actionFactory = xstateAction in actionFactoryMap && actionFactoryMap[xstateAction]; 23 | // break; 24 | case 'undefined': 25 | // NOTE : have to do this even if it makes it heavier, because there might be other data in xstateAction 26 | actionFactory = xstateAction.type in actionFactoryMap && actionFactoryMap[xstateAction.type] 27 | break; 28 | default: 29 | // We should never get there, we allegedly checked types already 30 | throw new Error(`actionFactoryFromXstateAction : mmm unexpected type for action!`) 31 | } 32 | 33 | if (!actionFactory) throw new Error(`actionFactoryFromXstateAction : could not find action factory for action!`) 34 | 35 | return actionFactory 36 | } 37 | 38 | export function xstateReactInterpreter(Machine, machineConfig, interpreterConfig) { 39 | // Contracts 40 | // - cannot produce effects 41 | // - state reducer must be configurable 42 | // - actionFactory must return the required type {command, params} 43 | // - actionFactoryMap must be configured 44 | 45 | const { updateState, mergeOutputs, actionFactoryMap } = interpreterConfig; 46 | const xMachine = Machine(machineConfig); 47 | // Load the initial state of the machine 48 | let controlState = xMachine.initialState; 49 | let extendedState = machineConfig.context; 50 | 51 | // can't name that function yield, it is a reserved keyword 52 | function yyield(event) { 53 | 54 | const nextControlState = xMachine.transition(controlState, event, extendedState); 55 | const { actions: actionFactories } = nextControlState; 56 | 57 | const { accExtendedState, accOutputs } = actionFactories.reduce((acc, xstateAction) => { 58 | assertActionType(xstateAction); 59 | const actionFactory = actionFactoryFromXstateAction(xstateAction, actionFactoryMap); 60 | 61 | let { accExtendedState, accOutputs } = acc; 62 | 63 | const actions = actionFactory(accExtendedState, event, xstateAction); 64 | // NOTE : `updates` holds the changes to the extended state, `outputs` what to return 65 | const { updates, outputs } = actions; 66 | 67 | return { 68 | accOutputs: mergeOutputs(accOutputs, outputs), 69 | accExtendedState: updateState(accExtendedState, updates) 70 | } 71 | }, { accExtendedState: extendedState, accOutputs: NO_OUTPUT }); 72 | 73 | // Update interpreter state 74 | controlState = nextControlState; 75 | extendedState = accExtendedState; 76 | 77 | return accOutputs 78 | } 79 | 80 | return { 81 | start: () => yyield({ type: INIT_EVENT, data : void 0 }), 82 | yield: yyield, 83 | // started machine - we cannot stop it but it is ok because we won't use listeners 84 | machine: xMachine 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/properties.js: -------------------------------------------------------------------------------- 1 | import { NO_OUTPUT } from "state-transducer"; 2 | 3 | export const CONTRACT_MODEL_UPDATE_FN_RETURN_VALUE = `Model update function must return valid update operations!`; 4 | export const ERR_COMMAND_HANDLERS = command => (`Cannot find valid executor for command ${command}`) 5 | export const COMMAND_RENDER = 'render'; 6 | export const NO_STATE_UPDATE = []; 7 | export const BUTTON_CLICKED = 'button_clicked'; 8 | export const KEY_PRESSED = 'key_pressed'; 9 | export const INPUT_KEY_PRESSED = 'input_key_pressed'; 10 | export const ENTER_KEY_PRESSED = 'enter_key_pressed'; 11 | export const INPUT_CHANGED = 'input_changed'; 12 | export const NO_ACTIONS = () => ({ outputs: NO_OUTPUT, updates: NO_STATE_UPDATE }); 13 | export const KEY_ENTER = `Enter`; 14 | export const NO_INTENT = null; 15 | export const COMMAND_SEARCH = 'command_search'; 16 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {function(ExtendedState, ExtendedStateUpdate): ExtendedState} ExtendedStateReducer 3 | */ 4 | /** 5 | * @typedef {*} Output 6 | */ 7 | /** 8 | * @typedef {Container} Outputs 9 | * `Container` is a foldable functor, for instance `Array` 10 | */ 11 | /** 12 | * @typedef {function(Outputs, Outputs): Outputs} OutputReducer 13 | */ 14 | /** 15 | * @typedef {String} xStateActionType 16 | * The type of xstate action. In xstate v4.0, this is the property `type` of the xstate action 17 | */ 18 | /** 19 | * @typedef {*} xStateEvent 20 | * cf. xState types. Usually either a string or an object with a `type` property which is a string 21 | */ 22 | /** 23 | * @typedef {*} xstateAction 24 | * cf. xState types. Usually an object with at least a `type` and `exec` property 25 | * The exec property when set (i.e. truthy) holds an action factory function. 26 | * The `type` property when set holds an identifier used to map to an action factory 27 | */ 28 | /** 29 | * @typedef {function(ExtendedState, xStateEvent, xstateAction):x} xStateActionFactory 30 | * The type of xstate action. In xstate v4.0, this is the property `type` of the xstate action 31 | */ 32 | /** 33 | * @typedef {Object} interpreterConfig 34 | * @property {ExtendedStateReducer} updateState 35 | * @property {OutputReducer} mergeOutputs 36 | * @property {Object.} actionFactoryMap 37 | */ 38 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import './interpreter.specs' 2 | 3 | QUnit.dump.maxDepth = 50; 4 | -------------------------------------------------------------------------------- /tests/interpreter.specs.js: -------------------------------------------------------------------------------- 1 | import { Machine } from "xstate" 2 | import { xstateReactInterpreter } from "../src" 3 | import * as QUnit from "qunitjs" 4 | import { emptyArray, initialContextHierarchicalMachine, testCases } from "../fixtures" 5 | 6 | /** 7 | * Test strategy 8 | * Specs : FORALL machineConfig, interpreterConfig, FORALL input, f(seq) produces the right outputs 9 | * That is a lot to test but remember that we do not test the machine itself. 10 | * 11 | * Hypothesis : 12 | * - machineConfig : already tested so it does not really matter, but there are three interesting cases 13 | * - non-hierarchical machines 14 | * - hierarchical machines, no parallel states 15 | * - hierarchical machines, with parallel states 16 | * - interpreterConfig : 17 | * - updateState : two different updating mechanism should suffice 18 | * - immer 19 | * - json patch 20 | * - mergeOutputs : one should suffice 21 | * - actionFactoryMap : three should suffice 22 | * - xstate action as string 23 | * - xstate action as object 24 | * - xstate action as function 25 | * - input : two kinds of inputs x [1 input, >1 inputs] 26 | * - xstate event as string 27 | * - xstate event as object 28 | * 29 | * Sooo 1 x 2 x 1 x 3 x 2 x 2) tests = 24 tests!! 30 | * 31 | * Hypothesis : 32 | * - interpreterConfig independent from the rest of the variables, except actionFactoryMap 33 | * - so we use 3 machineConfig featuring the three possible actionFactoryMap 34 | * => 1 x max (2 , 1 , 3) x 2 x 2) = 12 tests! 35 | * - input type independent of anything else 36 | * => 1 x max (2 , 1 , 3, 2) x 2) = 6 tests! 37 | * - BUT! testing >1 inputs includes testing 1 inputs on the way 38 | * => 1 x max (2 , 1 , 3, 2) x 1) = 3 tests! 39 | * 40 | * We can live with that. So here are our tests: 41 | * 42 | * 1. (non-hierarchical, immer, mergeOutput, all action strings or none, event as string, >1 inputs) 43 | * 2. (hierarchical, json patch, mergeOutput, ~action objects~ and strings, event as object, >1 inputs) 44 | * 3. (parallel, immer, mergeOutput, ~action objects~ and strings and functions, event as object, >1 inputs) 45 | * 4. edge case!! actually that one too : no actions!! 46 | * 47 | * So it turns out we will only do two tests: 48 | * - there are only two disjunctives for xstate action : .exec undefined or not 49 | * - we tested the case of transition without action while testing the other cases 50 | */ 51 | QUnit.module("xstateReactInterpreter(Machine, machineConfig, interpreterConfig)", {}); 52 | 53 | QUnit.test("(non-hierarchical, immer, mergeOutput, all action strings, event as string, >1 inputs)", function exec_test(assert) { 54 | const machineConfig = testCases.StandardMachineAndImmerAndStringsActionAndEvents.machine; 55 | const interpreterConfig = { 56 | updateState: testCases.StandardMachineAndImmerAndStringsActionAndEvents.updateState, 57 | mergeOutputs: testCases.StandardMachineAndImmerAndStringsActionAndEvents.mergeOutputs, 58 | actionFactoryMap: testCases.StandardMachineAndImmerAndStringsActionAndEvents.actionFactoryMap, 59 | }; 60 | 61 | const interpreter = xstateReactInterpreter(Machine, machineConfig, interpreterConfig); 62 | const testScenario = testCases.StandardMachineAndImmerAndStringsActionAndEvents.inputSequence; 63 | const actualTestResults = testScenario.map(interpreter.yield); 64 | const expectedTestResults = testCases.StandardMachineAndImmerAndStringsActionAndEvents.outputSequence; 65 | 66 | testScenario.forEach((input, index) => { 67 | assert.deepEqual( 68 | actualTestResults[index], 69 | expectedTestResults[index], 70 | testCases.StandardMachineAndImmerAndStringsActionAndEvents.description 71 | ); 72 | }); 73 | 74 | assert.ok( 75 | testCases.StandardMachineAndImmerAndStringsActionAndEvents.machine.context === emptyArray, 76 | `immer is indeed immutable library` 77 | ); 78 | }); 79 | 80 | QUnit.test("(hierarchical, json patch, mergeOutput, action functions and strings, event as object, >1 inputs)", function exec_test(assert) { 81 | const testCase = testCases.HierarchicalMachineAndJSONPatchAndFunctionActionsAndObjectEvents; 82 | const machineConfig = testCase.machine; 83 | const interpreterConfig = { 84 | updateState: testCase.updateState, 85 | mergeOutputs: testCase.mergeOutputs, 86 | actionFactoryMap: testCase.actionFactoryMap, 87 | }; 88 | 89 | const interpreter = xstateReactInterpreter(Machine, machineConfig, interpreterConfig); 90 | const testScenario = testCase.inputSequence; 91 | const actualTestResults = testScenario.map(interpreter.yield); 92 | const expectedTestResults = testCase.outputSequence; 93 | 94 | testScenario.forEach((input, index) => { 95 | assert.deepEqual( 96 | actualTestResults[index], 97 | expectedTestResults[index], 98 | testCase.description 99 | ); 100 | }); 101 | 102 | assert.ok(testCase.machine.context === initialContextHierarchicalMachine, `json patch does not mutate state in place`); 103 | }); 104 | -------------------------------------------------------------------------------- /tests/parcel-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | 17 | 18 |
19 |
20 | 21 |

QUnit Test Suite

22 |

23 |
24 |

25 |
    test markup, hidden.
26 | 27 | 28 | 29 | 30 | --------------------------------------------------------------------------------