├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── bin └── fsm2.dart ├── example ├── main.dart └── yaml_string.dart ├── images └── app.png ├── lib ├── fsm2.dart └── src │ ├── builders │ ├── co_region_builder.dart │ ├── fork_builder.dart │ ├── graph_builder.dart │ └── state_builder.dart │ ├── definitions │ ├── co_region_definition.dart │ ├── fork_definition.dart │ ├── join_definition.dart │ └── state_definition.dart │ ├── exceptions.dart │ ├── export │ ├── branches.dart │ ├── dot.dart │ ├── exporter.dart │ ├── mermaid.dart │ ├── smc_pseudo_state.dart │ ├── smc_state.dart │ ├── smc_transition.dart │ ├── smcat.dart │ └── state_machine_cat.dart │ ├── graph.dart │ ├── state_machine.dart │ ├── state_of_mind.dart │ ├── state_path.dart │ ├── static_analysis.dart │ ├── tracker.dart │ ├── transitions │ ├── fork_transition.dart │ ├── join_transition.dart │ ├── noop_transition.dart │ ├── on_transition.dart │ ├── transition_definition.dart │ └── transition_notification.dart │ ├── types.dart │ ├── util │ └── file_util.dart │ ├── version │ └── version.g.dart │ ├── virtual_root.dart │ └── visualise │ ├── progress.dart │ ├── size.dart │ ├── smcat_file.dart │ ├── smcat_folder.dart │ ├── svg_file.dart │ └── watch_folder.dart ├── pubspec.yaml ├── test ├── cleaning_air_test.dart ├── fork_join_test.dart ├── fsm_test.dart ├── initial_event.dart ├── late_join.dart ├── life_test.dart ├── nested_test.dart ├── no_double_dispatch.dart ├── page_break_test.dart ├── registration_test.dart ├── src │ ├── stackoverflow │ │ └── typedef.dart │ └── visualise │ │ └── watch_folder_test.dart ├── toaster_oven_test.dart ├── watcher.dart └── watcher.mocks.dart └── tool ├── export_life.dart └── gen_smcat.dart /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [bsutton] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .dart_tool/ 3 | .packages 4 | # Remove the following pattern if you wish to check in your lock file 5 | pubspec.lock 6 | 7 | # Conventional directory for build outputs 8 | build/ 9 | 10 | # Directory created by dartdoc 11 | doc/api/ 12 | 13 | .idea 14 | *.iml 15 | 16 | .history 17 | test/gv/ 18 | test/*.gv 19 | test/smcat/* 20 | tool/*.smcat 21 | .failed_tracker 22 | test/src/stackoverflow/try2.dart 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "watch and show", 9 | "program": "bin/fsm2.dart", 10 | "request": "launch", 11 | "type": "dart", 12 | "args": [ 13 | "-w", 14 | "-s", 15 | "-v", 16 | "test/smcat/registration" 17 | ], 18 | "console": "terminal", 19 | }, 20 | { 21 | "name": "example", 22 | "cwd": "example", 23 | "request": "launch", 24 | "type": "dart" 25 | }, 26 | { 27 | "name": "All Test", 28 | "type": "dart", 29 | "program": "test/", 30 | "request": "launch", 31 | "args": [ 32 | "-j1" 33 | ] 34 | 35 | 36 | }, 37 | 38 | { 39 | "name": "Page Break Generation", 40 | "program": "bin/fsm2.dart", 41 | "request": "launch", 42 | "type": "dart", 43 | "console": "terminal", 44 | "args": [ 45 | "-s", 46 | "test/smcat/page_break_test" 47 | ] 48 | 49 | } 50 | 51 | ] 52 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.2.1 2 | - expose GraphBuilder as part of the public API. 3 | 4 | # 3.2.0 5 | - fix for #23 - double dispatch of events. 6 | 7 | # 3.1.1 8 | Fixed #18 - coregion resolves before all onJoin events are received (2nd time) when sideEffect resolves one state 9 | 10 | Again thanks to panghy for this patch. 11 | 12 | # 3.1.0 13 | Upgraded to latest dcli 4.0 14 | Merge PR from panghy which fixes #17 and #18. 15 | 16 | # 3.1.0-alpha.1 17 | Upgraded to dcli 4.0 18 | 19 | # 3.0.0 20 | Upgraded to Dart 3.x 21 | Breaking changes: 22 | Deprecated waitUntilQuiescent and replaced it with [StateMachine.complete] 23 | StateMachine will now set its initialState to the first [State] that is added 24 | if [intialState] isn't called. Previously it would throw a late initialisation 25 | error. 26 | 27 | isInState is new asynchronise as it needs to wait for all outstanding events to complete before it checks the state. 28 | # 2.0.7 29 | Merged backports to masters. 30 | 31 | # 2.0.6 32 | - Fixed broken link to documentation. 33 | 34 | # 2.0.5 35 | Updated packages. 36 | Updated documentation and repository links in pubspec.yaml. 37 | 38 | 39 | # 2.0.4 40 | Fixes #7 41 | 42 | # 2.0.3 43 | Added missing initialisation for quality in unit tests. 44 | 45 | # 2.0.3 46 | Added missing guardconditions to fork and join. No unit tests as yet. 47 | 48 | # 2.0.2 49 | 50 | # 2.0.0 51 | Migrated to nnbd. 52 | Added option to force regeneration. 53 | Converted to nnbd. Fixed #4. 54 | 55 | # 1.0.1 56 | Back ported changes from 2.0.0 57 | 58 | # 1.0.0 59 | First stable release of fsm2. 60 | 61 | # 0.17.4 62 | Upgraded to latest version of dcli. 63 | 64 | # 0.17.3 65 | moved dcli to the dev dependencies. 66 | 67 | # 0.17.2 68 | un ignored version.g.dart 69 | releasd 0.17.1 70 | Improvements to the documentation. 71 | released 0.17 72 | removed unused code. 73 | Made sideEffects typesafe. 74 | Added state colours for the export. 75 | Exposed co_region_builder and state_builder as part of the api. 76 | 77 | # 0.17.1 78 | released 0.17 79 | removed unused code. 80 | Made sideEffects typesafe. 81 | Added state colours for the export. 82 | Exposed co_region_builder and state_builder as part of the api. 83 | 84 | # 0.17.0 85 | Changes to help fsm2_viewer implementation. 86 | 87 | smcatfile now wraps an svg file. Provided default empty content for svg file if it doesn't exist. 88 | added image to readme 89 | 90 | # 0.16.1 91 | Fixes 92 | smcatfile now wraps and svg file. Provided default empty content for svg file if it doesn't exist. 93 | added image to readme. 94 | 95 | # 0.16.0 96 | refactored code so that the ability to generate and watch for changes are now part of the public api so that fms2_viewer can use them. 97 | 98 | # 0.15.0 99 | Breaking changes: 100 | The onEnter and onExit handlers now expect both a state and the original event. 101 | 102 | Unit tests and fixes for onJoin. 103 | Also provide a better mapping mechanism when a transition definition generates multiple or zero transitions. 104 | 105 | The --watch option form fsm2 app now supports filenames with no extension when there is only a single page. 106 | This way you can launch fsm2 without having to consider wether the smcat is a single page or multiple pages. 107 | 108 | # 0.14.0 109 | Breaking change: 110 | Now passing the Event to sideEffect. 111 | 112 | Improvements in exports. 113 | Create SMCStates for each pseudo states. 114 | Added missing logic to stop a join triggering until all events received. 115 | Cleaned up the public api a little, hiding some internal methods. 116 | Added logic in the exporter to handle transitions from states in two different branches on different pages. 117 | Create _SCMStatePath to describe states as a path. 118 | Fixed a bug where a coregion at the top level created its own virtualRoot rather than reusing the existing one. 119 | increased the page height and move the page no to the bottom. 120 | 121 | # 0.14.0 122 | Breaking Changes 123 | 124 | the 'sideEffect' lambda is now passed the event that caused the transition. 125 | 126 | # 0.13.0 127 | Fixed for #2. We now call onEnter for all initial states when the statemachine is first created. 128 | 129 | # 0.12.0 130 | Added test of expected output when exporting page break tests. 131 | improved unit tests for page breaks. 132 | partial move to nullsaftey packages. 133 | Made watch option more robust in the face of rendering errors. Added Page No.s to svg output. 134 | Improvements to state rendering for straddled states. 135 | Fixed bugs with join and fork rendering. Removed duplicate psuedo transitions. 136 | Added condition and sideEffect labels. 137 | 138 | 139 | # 0.11.0 140 | Major work on the creation of svgs. We now support adding page breaks to the statemachine to have the digram span multiple pages. 141 | added lint package. 142 | 143 | # 0.10.1 144 | updated the readme.md 145 | 146 | # 0.10.0 147 | Added watcher which re-renders svg and displays it. repalced .gv with .smcat. Minor fixes to the engine. 148 | work on statecat implementation. 149 | Use correct example layout 150 | 151 | # 0.9.2 152 | co-regions implemented. 153 | diagramming now works. 154 | moved back to released version of synchronized. 155 | creating unit tests for exports. 156 | 157 | # 0.9.1 158 | Improvements to exportor for graphwiz. 159 | 160 | # 0.9.0 161 | Unit tests now working except for registration test which is just because the statemachine isn't defined correctly. 162 | Added enum _ChildrenType as part of implemenation of co-states. 163 | Added toaster oven example 164 | spelling. 165 | Added stream method which outputs a StateOfMind that indicates the full set of states the statemachine is in. 166 | Added test for duplicate states. 167 | Added DuplicateStateException and remove NestedStateException as duplicate states are never allowed. 168 | Change to static so we could test the analyse and export functions. 169 | added color coding to state boxes. 170 | Added check that no state is in the path twice. 171 | added pedantic. 172 | dot export appers to be mostly working. 173 | documented the analyse and export methods. 174 | Improved documentation. renamed getTranstion to evaluateCondtion. Renamed Condition to GuardCondition. Renamed transition methods to be clearer and cleanup up problems with searching the tree for a transition. Added ability to export a fsm to a dot file and a general traverseTree method. Added an implicit TerminalState to help when generating diagrams. Added additional unit testes. 175 | 176 | # 0.8.2 177 | Corrected spelling. 178 | 179 | # 0.8.1 180 | documentation improvements. 181 | 182 | # 0.8.0 183 | First release after diverging from fsm package. 184 | ## 0.0.5 185 | - Introduced stronger typing in builder methods. 186 | - Added onEnter and onExits state listeners. 187 | 188 | ## 0.0.4 189 | - Restructured code. 190 | - Updated documentation. 191 | 192 | ## 0.0.3 193 | - Updated example. 194 | 195 | ## 0.0.2 196 | - Updated README. 197 | 198 | ## 0.0.1 199 | - Initial version. 200 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2020, Kirill Bubochkin 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | FSM2 provides an implementation of the core design aspects of the UML2 state diagrams. 3 | 4 | FMS2 supports: 5 | * Nested States 6 | * Concurrent Regions 7 | * Guard Conditions 8 | * sideEffects 9 | * onEnter/onExit 10 | * streams 11 | * static analysis tools 12 | * visualisation tools. 13 | 14 | 15 | # Overview 16 | FSM2 uses a builder to delcare each state machine. 17 | 18 | Your application may declare as many statemachines as necessary. 19 | 20 | ## Example 21 | 22 | ```dart 23 | import 'package:fsm2/fsm2.dart'; 24 | 25 | void main() { 26 | final machine = StateMachine.create((g) => g 27 | ..initialState(Solid()) 28 | ..state((b) => b 29 | ..on(sideEffect: (e) async => print("I'm melting")) 30 | ..state((b) {}) 31 | )); 32 | 33 | ``` 34 | 35 | The above examples creates a Finite State Machine (machine) which declares its initial state as being `Solid` and then declares a single 36 | transition which occurs when the event `OnMelted` event is triggered causing a transition to a new state `Liquid`. 37 | 38 | To trigger an event: 39 | 40 | ```dart 41 | machine.applyEvent(OnMelted()); 42 | ``` 43 | 44 | # Documentation 45 | Full documentation is available on gitbooks at: 46 | 47 | https://fsm2.onepub.dev/ 48 | 49 | 50 | 51 | ## Example 2 52 | A simple example showing the life cycle of H2O. 53 | 54 | 55 | ```dart 56 | import 'package:fsm2/fsm2.dart'; 57 | 58 | void main() { 59 | final machine = StateMachine.create((g) => g 60 | ..initialState(Solid()) 61 | ..state((b) => b 62 | ..on(sideEffect: (e) => print('Melted'), 63 | ))) 64 | ..state((b) => b 65 | ..onEnter((s, e) => print('Entering ${s.runtimeType} State')) 66 | ..onExit((s, e) => print('Exiting ${s.runtimeType} State')) 67 | ..on(sideEffect: (e) => print('Frozen'))) 68 | ..on(sideEffect: (e) => print('Vaporized')))) 69 | ..state((b) => b 70 | ..on(sideEffect: (e) => print('Condensed')))) 71 | ..onTransition((t) => print( 72 | 'Received Event ${t.event.runtimeType} in State ${t.fromState.runtimeType} transitioning to State ${t.toState.runtimeType}'))); 73 | 74 | await machine.complete; 75 | 76 | /// machine.isInState() 77 | 78 | print(machine.isInSt); // TRUE 79 | 80 | machine.transition(OnMelted()); 81 | print(machine.isInState); // TRUE 82 | 83 | machine.transition(OnFroze()); 84 | print(machine.isInState); // TRUE 85 | } 86 | 87 | ``` 88 | 89 | # Credits: 90 | 91 | FMS2 is derived from the FSM library which in turn was inspired by [Tinder StateMachine library](https://github.com/Tinder/StateMachine). -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | 2 | include: package:lint_hard/all.yaml 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /bin/fsm2.dart: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env dcli 2 | 3 | import 'dart:async'; 4 | import 'dart:io'; 5 | 6 | import 'package:args/args.dart'; 7 | import 'package:dcli_core/dcli_core.dart'; 8 | import 'package:dcli_terminal/dcli_terminal.dart'; 9 | import 'package:fsm2/src/util/file_util.dart'; 10 | import 'package:fsm2/src/visualise/smcat_folder.dart'; 11 | import 'package:fsm2/src/visualise/svg_file.dart'; 12 | import 'package:path/path.dart'; 13 | 14 | /// dcli create show.dart 15 | /// 16 | /// See 17 | /// https://pub.dev/packages/dcli#-installing-tab- 18 | /// 19 | /// For details on installing dcli. 20 | /// 21 | // ignore_for_file: avoid_print 22 | Future main(List args) async { 23 | final parser = ArgParser() 24 | ..addFlag( 25 | 'verbose', 26 | abbr: 'v', 27 | negatable: false, 28 | help: 'Logs additional details to the cli', 29 | ) 30 | ..addFlag( 31 | 'help', 32 | abbr: 'h', 33 | negatable: false, 34 | help: 'Shows the help message', 35 | ) 36 | ..addFlag( 37 | 'show', 38 | abbr: 's', 39 | negatable: false, 40 | help: 41 | 'After generating the image file it will be displayed using firefox.', 42 | ) 43 | ..addFlag( 44 | 'watch', 45 | abbr: 'w', 46 | negatable: false, 47 | help: 'Monitors the smcat files and regenerates the svg if they change.', 48 | ) 49 | ..addFlag( 50 | 'install', 51 | abbr: 'i', 52 | negatable: false, 53 | help: 'Install the smcat dependencies', 54 | ) 55 | ..addFlag( 56 | 'force', 57 | abbr: 'f', 58 | negatable: false, 59 | help: 'Force regeneration of svg files even if they are upto date.', 60 | ); 61 | 62 | final parsed = parser.parse(args); 63 | 64 | if (parsed.wasParsed('help')) { 65 | showUsage(parser); 66 | } 67 | 68 | if (parsed.wasParsed('verbose')) { 69 | await Settings().setVerbose(enabled: true); 70 | } 71 | 72 | if (parsed.wasParsed('install')) { 73 | await install(); 74 | exit(0); 75 | } 76 | 77 | if (parsed.rest.length != 1) { 78 | print(red( 79 | 'You must pass a to path the basename of the smcat file. e.g. test/life_cycle')); 80 | showUsage(parser); 81 | } 82 | 83 | final show = parsed.wasParsed('show'); 84 | 85 | final watch = parsed.wasParsed('watch'); 86 | 87 | final force = parsed.wasParsed('force'); 88 | 89 | final pathTo = parsed.rest[0]; 90 | final folder = 91 | SMCatFolder(folderPath: dirname(pathTo), basename: getBasename(pathTo)); 92 | await generate(folder, show: show, watch: watch, force: force); 93 | } 94 | 95 | Future generate(SMCatFolder folder, 96 | {required bool watch, required bool force, bool? show}) async { 97 | var count = 0; 98 | await folder.generateAll( 99 | force: force, 100 | progress: (line) { 101 | print(line); 102 | count++; 103 | }); 104 | 105 | if (count == 0) { 106 | print(''' 107 | No files found that needed generating. Use -f to regenerate all files.'''); 108 | } 109 | if (watch) { 110 | folder.watch(); 111 | 112 | if (show!) { 113 | print(folder.listSvgs); 114 | await SvgFile.showList(folder.listSvgs, progress: print); 115 | await for (final svgFile in folder.stream) { 116 | await svgFile.show(progress: print); 117 | } 118 | } 119 | } else if (show!) { 120 | await folder.show(progress: print); 121 | } 122 | } 123 | 124 | Future install() async { 125 | if (which('npm').notfound) { 126 | print(red('Please install npm and then try again')); 127 | exit(1); 128 | } 129 | await Process.start('npm', ['install', '--global', 'state-machine-cat']); 130 | } 131 | 132 | void showUsage(ArgParser parser) { 133 | print(''' 134 | Usage: ${basename(Platform.executable)} 135 | Converts a set of smcat files into svg files. 136 | If your smcat file has multiple parts due to page breaks then each page will be processed. 137 | ${parser.usage}'''); 138 | exit(1); 139 | } 140 | -------------------------------------------------------------------------------- /example/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | 3 | import 'package:fsm2/fsm2.dart'; 4 | 5 | void main() async { 6 | final machine = await StateMachine.create((g) => g 7 | ..initialState() 8 | ..state((b) => b 9 | ..on(sideEffect: (e) async => log('Melted')) 10 | ..onEnter((s, e) async => log('Entering $s State')) 11 | ..onExit((s, e) async => log('Exiting $s State'))) 12 | ..state((b) => b 13 | ..onEnter((s, e) async => log('Entering $s State')) 14 | ..onExit((s, e) async => log('Exiting $s State')) 15 | ..on(sideEffect: (e) async => log('Frozen')) 16 | ..on(sideEffect: (e) async => log('Vaporized'))) 17 | ..state((b) => b 18 | ..onEnter((s, e) async => log('Entering $s State')) 19 | ..onExit((s, e) async => log('Exiting $s State')) 20 | ..on(sideEffect: (e) async => log('Condensed'))) 21 | ..onTransition((from, e, to) => log( 22 | '''Received Event $e in State ${from!.stateType} transitioning to State ${to!.stateType}'''))); 23 | machine 24 | ..analyse() 25 | ..export('test/smcat/water.smcat') 26 | ..applyEvent(OnMelted()) 27 | ..applyEvent(OnFroze()); 28 | } 29 | 30 | class Solid implements State {} 31 | 32 | class Liquid implements State {} 33 | 34 | class Gas implements State {} 35 | 36 | class OnMelted implements Event {} 37 | 38 | class OnFroze implements Event {} 39 | 40 | class OnVaporized implements Event {} 41 | 42 | class OnCondensed implements Event {} 43 | -------------------------------------------------------------------------------- /example/yaml_string.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | 3 | import 'package:fsm2/fsm2.dart'; 4 | 5 | /// 6 | /// Parser a yaml scalar string such as the 'description' 7 | /// in a pubspec.yaml file. 8 | class YamlStringParser { 9 | final result = StringBuffer(); 10 | 11 | Future<(StateMachine, String)> parseString(String input) async { 12 | final machine = await buildMachine(); 13 | 14 | /// send stream to state machine 15 | for (final c in input.codeUnits) { 16 | final charType = classify(c); 17 | machine.applyEvent(charType); 18 | } 19 | 20 | await machine.complete; 21 | return (machine, result.toString()); 22 | } 23 | 24 | void append(OnChar e) { 25 | result.writeCharCode(e.character); 26 | } 27 | 28 | /// Build the FSM to parse yaml scalar strings 29 | Future buildMachine() async => StateMachine.create((g) => g 30 | ..initialState() 31 | ..state((b) => b 32 | // allow leading whitespace. 33 | ..on( 34 | condition: (e) => e.asString == '' || e.asString == '\t') 35 | // a non-special character as the first char means we have a plain flow 36 | ..on(sideEffect: (e) async => append(e)) 37 | // a double quote as the first character means a double quoted flow 38 | ..on( 39 | sideEffect: (e) async => append(e)) 40 | // a double quote as the first character means a double quoted flow 41 | ..on( 42 | sideEffect: (e) async => append(e)) 43 | // a '>' as the first character means a block where we replace newlines 44 | // with spaces. 45 | ..onFork( 46 | (b) => b 47 | ..target() 48 | ..target() 49 | ..target(), 50 | sideEffect: (e) async => append(e)) 51 | // a '|' as the first character means a block where we keep newlines 52 | ..onFork( 53 | (b) => b 54 | ..target() 55 | ..target() 56 | ..target(), 57 | sideEffect: (e) async => append(e))) 58 | ..state((b) {}) 59 | 60 | /// Flow Plain - Unquoted String 61 | ..state((b) => flowPlainBuilder(b, append)) 62 | 63 | /// Double quoted String 64 | ..state((b) => doubleQuoteBuilder(b, append)) 65 | 66 | /// Single quoted string 67 | ..state((b) => singleQuoteBuilder(b, append)) 68 | 69 | /// Folding Block 70 | ..coregion((b) => foldingBlockBuilder(b, append)) 71 | ..onTransition( 72 | (fromState, event, toState) => print('$fromState $event $toState '))); 73 | 74 | /// 75 | /// Build the Plain Flow state machine 76 | /// 77 | StateBuilder flowPlainBuilder( 78 | StateBuilder b, void Function(OnChar) append) => 79 | b 80 | ..on() 81 | ..on(sideEffect: (e) async => append(e)) 82 | ..on(sideEffect: (e) async => append(e)) 83 | 84 | /// We just saw an escape charater \ 85 | ..state((b) => b 86 | // nested escape char is just a slash 87 | ..on( 88 | sideEffect: (e) async => append(e)) 89 | ..on( 90 | sideEffect: (e) async => onEscapeChar(e, append))); 91 | 92 | /// 93 | /// Build the single quote state machine 94 | /// 95 | StateBuilder singleQuoteBuilder( 96 | StateBuilder b, void Function(OnChar) append) => 97 | b 98 | ..on() 99 | ..state((b) => b 100 | // nested escape char is just a slash 101 | ..on( 102 | sideEffect: (e) async => append(e))) 103 | ..on(); 104 | 105 | /// 106 | /// build the double quote state machien 107 | /// 108 | StateBuilder doubleQuoteBuilder( 109 | StateBuilder b, void Function(OnChar) append) => 110 | b 111 | ..on() 112 | ..on(sideEffect: (e) async => append(e)) 113 | ..on(sideEffect: (e) async => append(e)) 114 | 115 | /// We just saw an escape charater \ 116 | ..state((b) => b 117 | // nested escape char is just a slash 118 | ..on( 119 | sideEffect: (e) async => append(e)) 120 | ..on( 121 | sideEffect: (e) async => onEscapeChar(e, append))); 122 | 123 | /// 124 | /// Build the folding block state machine 125 | /// 126 | StateBuilder foldingBlockBuilder( 127 | StateBuilder b, void Function(OnChar) append) => 128 | b 129 | ..state((b) => b 130 | ..onFork((b) => b..target(), 131 | sideEffect: (e) async => append(e)) 132 | ..onFork((b) => b..target(), 133 | sideEffect: (e) async => append(e)) 134 | ..on() 135 | ..on( 136 | sideEffect: (e) async => append(e)) 137 | 138 | /// We just saw an escape charater \ 139 | ..state((b) => b 140 | // nested escape char is just a slash 141 | ..on( 142 | sideEffect: (e) async => append(e)) 143 | ..on( 144 | sideEffect: (e) async => onEscapeChar(e, append)))) 145 | 146 | /// costate 147 | ..state((b) => b 148 | ..on(condition: (e) => false) 149 | ..state( 150 | (b) => b.on(sideEffect: (e) async => clip())) 151 | ..state((b) => {}) 152 | ..state((b) => {})) 153 | 154 | /// costate 155 | ..state((b) => b 156 | ..state((b) => {}) 157 | ..state((b) => {})); 158 | 159 | /// 160 | /// We have seen an escape character so decided 161 | /// how to process the following char. 162 | void onEscapeChar(OnChar char, void Function(OnChar p1) append) { 163 | /// \n is translated to a space. 164 | if (char.asString == 'n') { 165 | append(OnEmitChar(' ')); 166 | } else { 167 | // all other chacters are output verbatum. 168 | append(char); 169 | } 170 | } 171 | 172 | void clip() { 173 | if (result.isEmpty) { 174 | return; 175 | } 176 | // int count = 0; 177 | // var content = result.toString(); 178 | // int index = content.length; 179 | // for (var char in content.substring(length - 2, length -1).) 180 | // { 181 | 182 | // } 183 | } 184 | } 185 | 186 | Event classify(int c) { 187 | if (c == '"'.codeUnitAt(0)) { 188 | return OnDoubleQuoteChar(c); 189 | } else if (c == "'".codeUnitAt(0)) { 190 | return OnSingleQuoteChar(c); 191 | } else if (c == r'\'.codeUnitAt(0)) { 192 | return OnEscapeChar(c); 193 | } else if (c == '>'.codeUnitAt(0)) { 194 | return OnBlockReplaceChar(c); 195 | } else if (c == '|'.codeUnitAt(0)) { 196 | return OnBlockKeepChar(c); 197 | } 198 | 199 | return OnSimpleChar(c); 200 | } 201 | 202 | class Start extends State {} 203 | 204 | class Finished extends State {} 205 | 206 | class FlowPlain extends State {} 207 | 208 | class FlowDoubleQuoted extends State {} 209 | 210 | class FlowSingleQuoted extends State {} 211 | 212 | class DoubleQuotedEscaping extends State {} 213 | 214 | class SingleQuotedEscaping extends State {} 215 | 216 | class Folding extends State {} 217 | 218 | class FoldingBlock extends State {} 219 | 220 | class NewLine extends State {} 221 | 222 | class KeepNewLines extends State {} 223 | 224 | class ReplaceNewLines extends State {} 225 | 226 | /// Controls how we process newlines at the end 227 | /// of the string 228 | class Trim extends State {} 229 | 230 | // keep a single trailing newline 231 | class TrimClip extends State {} 232 | 233 | /// strip all trailing new lines 234 | class TrimStrip extends State {} 235 | 236 | /// Keep all trailing newlines 237 | class TrimKeep extends State {} 238 | 239 | class FoldingEscaping extends State {} 240 | 241 | class PlainEscaping extends State {} 242 | 243 | class OnSingleQuoteChar extends OnChar { 244 | OnSingleQuoteChar(super.character); 245 | } 246 | 247 | class OnDoubleQuoteChar extends OnChar { 248 | OnDoubleQuoteChar(super.character); 249 | } 250 | 251 | class OnEscapeChar extends OnChar { 252 | OnEscapeChar(super.character); 253 | } 254 | 255 | // > 256 | class OnBlockReplaceChar extends OnChar { 257 | OnBlockReplaceChar(super.character); 258 | } 259 | 260 | // | 261 | class OnBlockKeepChar extends OnChar { 262 | OnBlockKeepChar(super.character); 263 | } 264 | 265 | class OnPlusChar extends OnChar { 266 | OnPlusChar(super.character); 267 | } 268 | 269 | class OnMinusChar extends OnChar { 270 | OnMinusChar(super.character); 271 | } 272 | 273 | class OnSimpleChar extends OnChar { 274 | OnSimpleChar(super.character); 275 | } 276 | 277 | /// Little bit of a hack when we need 278 | /// to pass an alternate character to 279 | /// the append function. 280 | class OnEmitChar extends OnChar { 281 | OnEmitChar(String character) : super(character.codeUnitAt(0)); 282 | } 283 | 284 | /// Once we enter the finish state emit the [OnApplyTrim] to 285 | /// cleanup the trailing newlines based on the [Trim] state. 286 | class OnApplyTrim extends Event {} 287 | 288 | class OnChar extends Event { 289 | OnChar(this.character); 290 | int character; 291 | 292 | String get asString => String.fromCharCode(character); 293 | 294 | @override 295 | // ignore: no_runtimetype_tostring 296 | String toString() => '$runtimeType: $asString'; 297 | } 298 | -------------------------------------------------------------------------------- /images/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onepub-dev/fsm2/9b8821fff66226a67bd6b0d8e721af700ab78af0/images/app.png -------------------------------------------------------------------------------- /lib/fsm2.dart: -------------------------------------------------------------------------------- 1 | export 'src/builders/co_region_builder.dart'; 2 | export 'src/builders/graph_builder.dart'; 3 | export 'src/builders/state_builder.dart'; 4 | export 'src/definitions/state_definition.dart' 5 | hide addCoRegion, onEnter, onExit; 6 | export 'src/exceptions.dart'; 7 | export 'src/state_machine.dart' hide findStateDefinition, oldestAncestor; 8 | export 'src/state_of_mind.dart' hide addPath, removePath; 9 | export 'src/transitions/transition_definition.dart'; 10 | export 'src/types.dart' show Event, OnEnter, OnExit, State; 11 | export 'src/visualise/smcat_file.dart'; 12 | export 'src/visualise/smcat_folder.dart'; 13 | export 'src/visualise/svg_file.dart'; 14 | export 'src/visualise/watch_folder.dart'; 15 | -------------------------------------------------------------------------------- /lib/src/builders/co_region_builder.dart: -------------------------------------------------------------------------------- 1 | import '../definitions/co_region_definition.dart'; 2 | import '../types.dart'; 3 | import 'state_builder.dart'; 4 | 5 | class CoRegionBuilder extends StateBuilder { 6 | CoRegionBuilder(super.parent, CoRegionDefinition super.coRegion); 7 | 8 | // void onJoin(BuildJoin buildJoin, 9 | //{Function(JS, Event) condition}) { 10 | // final builder = JoinBuilder(_stateDefinition); 11 | // buildJoin(builder); 12 | // final definition = builder.build(); 13 | 14 | // var choice = JoinTransitionDefinition(_stateDefinition, definition); 15 | 16 | // _stateDefinition.addTransition(choice); 17 | // } 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/builders/fork_builder.dart: -------------------------------------------------------------------------------- 1 | import '../definitions/fork_definition.dart'; 2 | import '../types.dart'; 3 | import 'graph_builder.dart'; 4 | 5 | /// State builder. 6 | /// 7 | /// Instance of this class is passed to [GraphBuilder.state] method. 8 | class ForkBuilder { 9 | ForkBuilder() : _forkDefinition = ForkDefinition(); 10 | final ForkDefinition _forkDefinition; 11 | 12 | ForkDefinition build() => _forkDefinition; 13 | 14 | void target() { 15 | _forkDefinition.addTarget(S); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/builders/graph_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import '../definitions/co_region_definition.dart'; 4 | import '../definitions/state_definition.dart'; 5 | import '../graph.dart'; 6 | import '../state_machine.dart'; 7 | import '../types.dart'; 8 | import '../virtual_root.dart'; 9 | import 'state_builder.dart'; 10 | 11 | /// Builder for FSM. 12 | /// 13 | /// Instance of this class is passed to [StateMachine.create] method. 14 | class GraphBuilder { 15 | Type? _initialState; 16 | final _stateDefinitions = []; 17 | final List _onTransitionListeners = []; 18 | final StateDefinition virtualRoot = 19 | StateDefinition(VirtualRoot); 20 | 21 | String? _initialStateLabel; 22 | 23 | /// Sets initial State. 24 | /// If initialState isn't called then the first [State] added 25 | /// via [state] will be set as the initial state. 26 | void initialState({String? label}) { 27 | _initialState = S; 28 | _initialStateLabel = label ?? _initialState.toString(); 29 | } 30 | 31 | /// Adds State definition. 32 | void state( 33 | BuildState buildState, 34 | ) { 35 | _initialState ?? initialState(); 36 | final builder = StateBuilder(virtualRoot, StateDefinition(S)); 37 | buildState(builder); 38 | final definition = builder.build(); 39 | 40 | _stateDefinitions.add(definition); 41 | } 42 | 43 | /// Adds [coregion]] definition. 44 | /// A [coregion] is where the statemachine can 45 | /// be in multiple states at the same time. 46 | /// The parent state (defined by the call to [coregion]) 47 | /// treats all child states as [coregion]s. 48 | /// 49 | /// ```dart 50 | /// .coregion((builder) => 51 | /// .state ... 52 | /// .state ... 53 | /// ``` 54 | /// 55 | /// In the above example the [StateMachine] is considered 56 | /// to be in both the 'AcquireMobile' state and the 57 | /// 'RegistrationType' state. 58 | /// 59 | /// The [coregion] 'MobileAndRegistrationType' is also a parent state 60 | /// so the machine is said to be in three states at once. 61 | /// 62 | void coregion( 63 | BuildState buildState, 64 | ) { 65 | final builder = StateBuilder(virtualRoot, CoRegionDefinition(S)); 66 | buildState(builder); 67 | final definition = builder.build(); 68 | _stateDefinitions.add(definition); 69 | } 70 | 71 | /// Sets [listener] that will be called on each transition. 72 | void onTransition(TransitionListener listener) => 73 | _onTransitionListeners.add(listener); 74 | 75 | Graph build() => Graph(virtualRoot, _initialState, _stateDefinitions, 76 | _onTransitionListeners, _initialStateLabel); 77 | 78 | @visibleForTesting 79 | 80 | /// returns a shallow copy of the [_stateDefinitions] map. 81 | List get stateDefinitions => 82 | List.from(_stateDefinitions); 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/builders/state_builder.dart: -------------------------------------------------------------------------------- 1 | import '../definitions/state_definition.dart'; 2 | import '../exceptions.dart'; 3 | import '../state_machine.dart'; 4 | import '../transitions/fork_transition.dart'; 5 | import '../transitions/join_transition.dart'; 6 | import '../transitions/on_transition.dart'; 7 | import '../types.dart'; 8 | import 'fork_builder.dart'; 9 | import 'graph_builder.dart'; 10 | 11 | /// State builder. 12 | /// 13 | /// Instance of this class is passed to [GraphBuilder.state] method. 14 | class StateBuilder { 15 | StateBuilder(StateDefinition parent, this._stateDefinition) { 16 | _stateDefinition.setParent(parent); 17 | } 18 | final StateDefinition _stateDefinition; 19 | 20 | /// The initial state for the substate 21 | /// If there are no child states then this is just 'this'. 22 | Type _initialState = _UndefinedInitialState; 23 | 24 | /// Places a page break into the export file rendering all nested 25 | /// states on a new page. 26 | void get pageBreak => _stateDefinition.pageBreak = true; 27 | 28 | /// Statically declares a transition that will occur when Event of type [E] 29 | /// is sent to machine via [StateMachine.applyEvent] method. 30 | /// 31 | /// 32 | /// ```dart 33 | /// ..state((builder) => builder 34 | /// ..on()) 35 | /// ``` 36 | /// 37 | /// The [condition] argument implements the UML concept a 'guard condition' 38 | /// and allows you to register multiple transitions for a single Event. 39 | /// Guard conditions allow you to implement a UML 'Choice psuedostate'. 40 | /// When the Event is fired each transition will be evaluated in the order 41 | /// they are added to the State. 42 | /// The first transition whose guard [condition] method returns true will 43 | /// be triggered, any later 44 | /// conditions will not be evaluated. 45 | /// 46 | /// ```dart 47 | /// ..state((builder) => builder 48 | /// ..on(condition: (state, event) 49 | /// => event.subscribed == true)) 50 | /// ..on(condition: (state, event) 51 | /// => event.subscribed == false)) 52 | /// ```a 53 | /// 54 | /// There MAY be only one transition with a null [condition] 55 | /// and it MUST be the last 56 | /// transition added to the [S]. A transition with a null [condition] 57 | /// is considered the 58 | /// 'else' condition in that it fires if none of the transitions 59 | /// with a [condition] evaluate to true. 60 | /// 61 | /// An [NullConditionMustBeLastException] will be thrown if you try 62 | /// to register two 63 | /// transitions for a given Event type with a null [condition] or you 64 | /// try to add a 65 | /// transition with a non-null [condition] after adding a transition 66 | /// with a null [condition]. 67 | /// 68 | /// The [conditionLabel] is optional and is only used when exporting. 69 | /// The [conditionLabel] is used 70 | /// to annotate the transition on the diagram. 71 | void on( 72 | {GuardCondition condition = noopGuardCondition, 73 | SideEffect? sideEffect, 74 | String? conditionLabel, 75 | String? sideEffectLabel}) { 76 | final onTransition = OnTransitionDefinition( 77 | _stateDefinition, condition, TOSTATE, sideEffect, 78 | conditionLabel: conditionLabel, sideEffectLabel: sideEffectLabel); 79 | 80 | _stateDefinition.addTransition(onTransition); 81 | } 82 | 83 | /// Adds a nested State definition as per the UML2 84 | /// specification for `hierarchically nested states`. 85 | void state( 86 | BuildState buildState, 87 | ) { 88 | addNestedState(_stateDefinition, buildState); 89 | } 90 | 91 | /// Adds a [coregion] State definition as per the UML2 92 | /// specification for `orthogonal regions`. 93 | /// 94 | /// A [coregion] is where the statemachine can 95 | /// be in multiple states at the same time. 96 | /// 97 | /// The parent state (defined by the call to [coregion]) 98 | /// treats all child states as [coregion]s. 99 | /// 100 | /// ```dart 101 | /// .coregion((builder) => 102 | /// .state ... 103 | /// .state ... 104 | /// ``` 105 | /// 106 | /// In the above example the [StateMachine] is considered 107 | /// to be in both the 'AcquireMobile' state and the 108 | /// 'RegistrationType' state. 109 | /// 110 | /// The [coregion] 'MobileAndRegistrationType' is also a parent state 111 | /// so the machine is said to be in three states at once. 112 | /// 113 | void coregion(BuildCoRegion buildState) { 114 | addCoRegion(_stateDefinition, buildState); 115 | } 116 | 117 | /// Used to enter a co-region by targeting the set of states within the 118 | /// coregion to transition to. 119 | void onFork(BuildFork buildFork, 120 | {GuardCondition condition = noopGuardCondition, 121 | SideEffect? sideEffect, 122 | String? conditionLabel, 123 | String? sideEffectLabel}) { 124 | final builder = ForkBuilder(); 125 | buildFork(builder); 126 | final definition = builder.build(); 127 | 128 | final choice = ForkTransitionDefinition( 129 | _stateDefinition, definition, condition, sideEffect, 130 | sideEffectLabel: sideEffectLabel, conditionLabel: conditionLabel); 131 | 132 | _stateDefinition.addTransition(choice); 133 | } 134 | 135 | /// Adds an event to the set of events that must be triggered to leave 136 | /// the owner[coregion]. 137 | /// Every onJoin in a coregion must target the same external state. 138 | void onJoin( 139 | {GuardCondition condition = noopGuardCondition, 140 | SideEffect? sideEffect}) { 141 | final onTransition = JoinTransitionDefinition( 142 | _stateDefinition, condition, sideEffect); 143 | 144 | _stateDefinition.addTransition(onTransition); 145 | } 146 | 147 | /// Sets callback that will be called right after machine enters this State. 148 | void onEnter(OnEnter onEnter, {String? label}) { 149 | _stateDefinition 150 | ..onEnter = onEnter 151 | ..onEnterLabel = label; 152 | } 153 | 154 | /// Sets callback that will be called right before machine exits this State. 155 | void onExit(OnExit onExit, {String? label}) { 156 | _stateDefinition 157 | ..onExit = onExit 158 | ..onExitLabel = label; 159 | } 160 | 161 | StateDefinition build() { 162 | if (_stateDefinition.isLeaf) { 163 | _initialState = _stateDefinition.stateType; 164 | return _stateDefinition; 165 | } else { 166 | /// If no initial state then the first state is the initial state. 167 | if (_initialState == _UndefinedInitialState && 168 | _stateDefinition.childStateDefinitions.isNotEmpty) { 169 | _initialState = _stateDefinition.childStateDefinitions[0].stateType; 170 | } 171 | 172 | assert(_initialState != _UndefinedInitialState, 'unexpeted state'); 173 | final sd = _stateDefinition.findStateDefintion(_initialState, 174 | includeChildren: false); 175 | if (sd == null) { 176 | throw InvalidInitialStateException( 177 | '''The initialState $_initialState MUST be a child state of ${_stateDefinition.stateType}.'''); 178 | } 179 | 180 | _stateDefinition.initialState = _initialState; 181 | 182 | return _stateDefinition; 183 | } 184 | } 185 | 186 | void initialState() { 187 | _initialState = I; 188 | } 189 | } 190 | 191 | class _UndefinedInitialState extends State {} 192 | -------------------------------------------------------------------------------- /lib/src/definitions/co_region_definition.dart: -------------------------------------------------------------------------------- 1 | // part of 'state_definition.dart'; 2 | 3 | // library state_definition; 4 | 5 | // part 'co_region_definition.dart'; 6 | 7 | import 'package:meta/meta.dart'; 8 | 9 | import '../transitions/join_transition.dart'; 10 | import '../types.dart'; 11 | import 'state_definition.dart'; 12 | 13 | class CoRegionDefinition extends StateDefinition { 14 | CoRegionDefinition(super.stateType); 15 | 16 | /// List of Events that must be received for the join to trigger. 17 | /// As we receive each one we set the value to true. 18 | final expectedJoinEvents = {}; 19 | 20 | /// Marks a Join event as received and returns true if all required 21 | /// events have been received. 22 | bool _onReceived(Type event) { 23 | expectedJoinEvents[event] = true; 24 | 25 | /// true if all events have been received. 26 | return expectedJoinEvents.values.every((element) => element == true); 27 | } 28 | 29 | /// Clean up [expectedJoinEvents] when we exit the coregion. 30 | @override 31 | @mustCallSuper 32 | Future internalOnExit(Type fromState, Event? event) async { 33 | await super.internalOnExit(fromState, event); 34 | for (final key in expectedJoinEvents.keys) { 35 | expectedJoinEvents[key] = false; 36 | } 37 | } 38 | 39 | /// default implementation. 40 | @override 41 | bool canTrigger(Type event) => _onReceived(event); 42 | 43 | /// There can only be one join target state. 44 | Type? _joinTargetState; 45 | 46 | List> joinTransitions = 47 | []; 48 | 49 | /// The onJoin transitions need to register with the owning coregion 50 | /// as they can be nested in a substate and we need to know about all of then. 51 | void registerJoin(JoinTransitionDefinition joinTransitionDefinition) { 52 | _joinTargetState ??= joinTransitionDefinition.targetStates[0]; 53 | 54 | /// All joins for a coregion must target the same state. 55 | assert(_joinTargetState == joinTransitionDefinition.targetStates[0], 56 | 'unexpected state'); 57 | 58 | joinTransitions.add(joinTransitionDefinition); 59 | 60 | expectedJoinEvents[joinTransitionDefinition.triggerEvents[0]] = false; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/definitions/fork_definition.dart: -------------------------------------------------------------------------------- 1 | import '../types.dart'; 2 | 3 | class ForkDefinition { 4 | ForkDefinition(); 5 | 6 | /// List of starts that are the targets of this fork. 7 | List stateTargets = []; 8 | 9 | void addTarget(Type s) { 10 | stateTargets.add(s); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/src/definitions/join_definition.dart: -------------------------------------------------------------------------------- 1 | import '../types.dart'; 2 | 3 | class JoinDefinition { 4 | JoinDefinition(this.toState); 5 | 6 | /// The state this join is targeting. 7 | final Type toState; 8 | 9 | /// The set of events that must occur (when in the owning state) for this join 10 | /// to fire. 11 | final events = []; 12 | 13 | void addEvent(Type e) { 14 | events.add(e); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/src/exceptions.dart: -------------------------------------------------------------------------------- 1 | import 'definitions/state_definition.dart'; 2 | import 'state_of_mind.dart'; 3 | import 'types.dart'; 4 | 5 | /// When creating the statemachine a state has defined 6 | /// an Event without a condition and it is not 7 | /// the last event defined for the state. 8 | class NullConditionMustBeLastException implements Exception { 9 | NullConditionMustBeLastException(this.eventType); 10 | Type eventType; 11 | 12 | @override 13 | String toString() => 14 | '''The Event $eventType already has a transition with a null 'condition'. Only one is allowed'''; 15 | } 16 | 17 | /// You called stateMachine.applyEvent with an event that is not 18 | /// defined in a transition 19 | /// for the current state. 20 | class InvalidTransitionException implements Exception { 21 | InvalidTransitionException(this.stateOfMind, this.event); 22 | Event event; 23 | StateOfMind stateOfMind; 24 | 25 | @override 26 | String toString() => 27 | '''There is no transition for Event ${event.runtimeType} from the State $stateOfMind.'''; 28 | } 29 | 30 | /// You defined a transition 'on', 'fork', 'join' with a target state 31 | /// which is not know to the 32 | /// state engine. 33 | class UnknownStateException implements Exception { 34 | UnknownStateException(this.message); 35 | String message; 36 | 37 | @override 38 | String toString() => message; 39 | } 40 | 41 | /// You have passed a State as an initialState that isn't either a 42 | /// top level state or a leaf state. 43 | class InvalidInitialStateException implements Exception { 44 | InvalidInitialStateException(this.message); 45 | String message; 46 | 47 | @override 48 | String toString() => message; 49 | } 50 | 51 | /// YOu have tried to define the same state twice. 52 | class DuplicateStateException implements Exception { 53 | DuplicateStateException(StateDefinition state) 54 | : message = 55 | '''The state ${state.stateType} is already in use. Every State must be unique.'''; 56 | String message; 57 | 58 | @override 59 | String toString() => message; 60 | } 61 | 62 | /// The statemachine has been defined in an invalid fashion. 63 | class InvalidStateMachine implements Exception { 64 | InvalidStateMachine(this.message); 65 | String message; 66 | 67 | @override 68 | String toString() => message; 69 | } 70 | 71 | /// An onJoin statement was used incorrectly. 72 | class JoinWithNoCoregionException implements Exception { 73 | JoinWithNoCoregionException(this.message); 74 | String message; 75 | 76 | @override 77 | String toString() => message; 78 | } 79 | 80 | /// You tried to get the parent of a StatePath that 81 | /// is just a VirtualRoot. 82 | class NoParentException implements Exception { 83 | NoParentException(this.message); 84 | String message; 85 | 86 | @override 87 | String toString() => message; 88 | } 89 | -------------------------------------------------------------------------------- /lib/src/export/branches.dart: -------------------------------------------------------------------------------- 1 | import 'smc_state.dart'; 2 | 3 | class Branches { 4 | Branches({required this.from, required this.to}); 5 | SMCState from; 6 | SMCState to; 7 | } 8 | -------------------------------------------------------------------------------- /lib/src/export/dot.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | import 'dart:io'; 3 | 4 | import '../definitions/state_definition.dart'; 5 | import '../state_machine.dart'; 6 | import '../transitions/transition_definition.dart'; 7 | import '../virtual_root.dart'; 8 | 9 | /// Class exports a [StateMachine] to a dot notation file so that the 10 | /// FMS can be visualised. 11 | /// 12 | /// Exports the [StateMachine] to dot notation which can then 13 | /// be used by xdot to display a diagram of the state machine. 14 | /// 15 | /// apt install xdot 16 | /// 17 | /// https://www.graphviz.org/doc/info/lang.html 18 | /// 19 | /// To visualise the resulting file graph run: 20 | /// 21 | /// ``` 22 | /// xdot 23 | /// ``` 24 | class DotExporter { 25 | DotExporter(this.stateMachine); 26 | final StateMachine stateMachine; 27 | final _edgePaths = <_EdgePath>[]; 28 | var _terminalStateOrdinal = 1; 29 | 30 | /// creates a map of the terminal ordinals to what 31 | /// parent state they belong to. 32 | final _terminalsOwnedByRegion = >{}; 33 | 34 | Future export(String path) async { 35 | await stateMachine 36 | .traverseTree((stateDefinition, transitionDefinitions) async { 37 | for (final transitionDefinition in transitionDefinitions) { 38 | if (stateDefinition.isLeaf) { 39 | await _addEdgePath(stateDefinition, transitionDefinition); 40 | } 41 | } 42 | }); 43 | 44 | _saveToDot(path); 45 | } 46 | 47 | Future _addEdgePath(StateDefinition stateDefinition, 48 | TransitionDefinition transitionDefinition) async { 49 | var appended = false; 50 | 51 | String? region; 52 | 53 | final targetStates = transitionDefinition.targetStates; 54 | 55 | for (final targetState in targetStates) { 56 | final toDef = findStateDefinition(stateMachine, targetState); 57 | 58 | if (toDef != null && toDef.parent!.stateType != VirtualRoot) { 59 | region = toDef.parent!.stateType.toString(); 60 | } 61 | 62 | for (final event in transitionDefinition.triggerEvents) { 63 | final node = _Edge(stateDefinition, event, toDef, 64 | region: region, terminal: toDef!.isTerminal); 65 | 66 | /// see if we have an existing path that ends with [fromState] 67 | for (final path in _edgePaths) { 68 | if (path.last.fromDefinition.stateType == stateDefinition.stateType) { 69 | path.append(node); 70 | appended = true; 71 | break; 72 | } 73 | } 74 | 75 | if (!appended) { 76 | _edgePaths.add(_EdgePath(node)); 77 | } 78 | } 79 | } 80 | } 81 | 82 | void _saveToDot(String path) { 83 | final file = File(path); 84 | final raf = file.openSync(mode: FileMode.write) 85 | ..writeStringSync('digraph fsm2 {\n'); 86 | 87 | final edge = _edgePaths.first.first; 88 | raf 89 | ..writeStringSync('\tInitialState [shape=point];\n') 90 | ..writeStringSync( 91 | '\tInitialState -> ${edge.fromDefinition.stateType};\n'); 92 | 93 | writeTransitions(raf); 94 | 95 | writeTerminals(raf, VirtualRoot, 0); 96 | 97 | _writeRegions(raf); 98 | raf 99 | ..writeStringSync('}') 100 | ..closeSync(); 101 | } 102 | 103 | /// writes out a dot line for every transition. 104 | /// It also records any terminal states and creates a virtual transition 105 | /// to a terminal state. 106 | void writeTransitions(RandomAccessFile raf) { 107 | for (final edgePath in _edgePaths) { 108 | _Edge? edge = edgePath.first; 109 | 110 | while (edge != null) { 111 | raf.writeStringSync( 112 | '''\t${edge.fromDefinition.stateType} -> ${edge.toDefinition!.stateType} [label="${edge.event}"];\n'''); 113 | 114 | // if the toState is a terminal state we need to write an extra 115 | // entry to show a transition to the virtual terminal state. 116 | if (edge.isDestinationTerminal) { 117 | addTerminalToSubGraph(edge.toDefinition!, _terminalStateOrdinal); 118 | 119 | /// we don't label terminal events and we make them a small dot. 120 | raf.writeStringSync( 121 | '''\t${edge.toDefinition!.stateType} -> TerminalState${_terminalStateOrdinal++};\n'''); 122 | } 123 | 124 | edge = edge.next; 125 | } 126 | } 127 | } 128 | 129 | void _writeRegions(RandomAccessFile raf) { 130 | for (final stateDefinition in stateMachine.topStateDefinitions) { 131 | _writeRegion(raf, stateDefinition, 1); 132 | } 133 | } 134 | 135 | // digraph fsm2 { 136 | // Alive -> Young [label="OnBirthday"]; 137 | // Alive -> MiddleAged [label="OnBirthday"]; 138 | // Alive -> Old [label="OnBirthday"]; 139 | // Alive -> Dead [label="OnDeath", lhead="clusterDead", compound=true]; 140 | // Dead -> InHeaven [label="OnGood"]; 141 | // Dead -> InHeaven [label="OnGood"]; 142 | // Dead -> InHell [label="OnBad"]; 143 | // subgraph clusterDead { 144 | // graph [label="Dead", compound=true]; 145 | // Dead, InHeaven; InHell; 146 | // } 147 | // } 148 | // ~ 149 | 150 | /// Each regions (nested or concurrent) we write as a graphwiz 'subgraph' 151 | void _writeRegion(RandomAccessFile raf, StateDefinition region, int level) { 152 | if (region.nestedStateDefinitions.isNotEmpty) { 153 | final regionName = region.stateType; 154 | 155 | raf.writeStringSync(''' 156 | ${'\t' * level}// State: $regionName 157 | ${'\t' * level}subgraph cluster_$regionName { 158 | \t${'\t' * level}graph [label="$regionName", bgcolor="/bugn9/$level", fontsize="20" ]; 159 | '''); 160 | // ignore: parameter_assignments 161 | level++; 162 | 163 | /// put the parent state in the cluster box as well. 164 | // raf.writeStringSync( 165 | // '${'\t' * level}${regionName} [style="filled" fillcolor="grey" color="black" fontsize="20"];\n'); 166 | 167 | /// place each child state into the cluster box. 168 | // ignore: unnecessary_parenthesis 169 | raf.writeStringSync('${'\t' * (level)}// nested states\n'); 170 | 171 | /// new line after 4 children. 172 | var breakCount = 4; 173 | raf.writeStringSync('\t' * level); 174 | for (final child in region.nestedStateDefinitions) { 175 | if (child.nestedStateDefinitions.isEmpty) { 176 | if (breakCount == 0) { 177 | raf.writeStringSync('\n${'\t' * level}'); 178 | breakCount = 4; 179 | } 180 | raf.writeStringSync('${child.stateType}; '); 181 | breakCount--; 182 | } 183 | } 184 | raf.writeStringSync('\n'); 185 | 186 | for (final child in region.nestedStateDefinitions) { 187 | if (child.nestedStateDefinitions.isNotEmpty) { 188 | _writeRegion(raf, child, level); 189 | } 190 | } 191 | 192 | writeTerminals(raf, regionName, level); 193 | 194 | raf.writeStringSync('''\n${'\t' * (level - 1)}}\n'''); 195 | } 196 | } 197 | 198 | void writeTerminals(RandomAccessFile raf, Type regionName, int level) { 199 | final terminals = _terminalsOwnedByRegion[regionName]; 200 | 201 | if (terminals != null) { 202 | /// write out a unique terminal state as a point for each state 203 | /// that had a terminal state. 204 | raf.writeStringSync('${'\t' * level}// terminal states\n'); 205 | for (final terminal in terminals) { 206 | raf.writeStringSync( 207 | '${'\t' * level}TerminalState$terminal [shape=point];\n'); 208 | } 209 | } 210 | } 211 | 212 | void addTerminalToSubGraph( 213 | StateDefinition terminalStateDefinition, int terminalStateOrdinal) { 214 | var terminalState = terminalStateDefinition.stateType; 215 | 216 | /// If a state has no children then it is a leaf. 217 | /// If it has a parent then the terminals should be displayed in the 218 | /// parents subgraph. 219 | if (terminalStateDefinition.nestedStateDefinitions.isEmpty && 220 | terminalStateDefinition.parent.runtimeType != VirtualRoot) { 221 | terminalState = terminalStateDefinition.parent!.stateType; 222 | } 223 | 224 | final terminals = _terminalsOwnedByRegion[terminalState] ?? [] 225 | ..add(terminalStateOrdinal); 226 | 227 | /// the state is owned by its parent. 228 | _terminalsOwnedByRegion[terminalStateDefinition.parent!.stateType] = 229 | terminals; 230 | } 231 | } 232 | 233 | /// Describes a linked list of edges 234 | /// for the purposes of writing out each 235 | /// transition to the dotx file. 236 | class _EdgePath { 237 | _EdgePath(this.first) : last = first; 238 | _Edge first; 239 | _Edge last; 240 | 241 | void append(_Edge node) { 242 | last.next = node; 243 | last = node; 244 | } 245 | } 246 | 247 | /// Describes an event that transition from one state to another 248 | class _Edge { 249 | _Edge(this.fromDefinition, this.event, this.toDefinition, 250 | {required this.terminal, this.region}) { 251 | log('''edge ${fromDefinition.stateType}:$event -> ${toDefinition!.stateType}'''); 252 | } 253 | StateDefinition fromDefinition; 254 | Type event; 255 | StateDefinition? toDefinition; 256 | String? guard; 257 | String? region; 258 | 259 | /// If the toState is a terminal state (no events leave the state) 260 | bool terminal; 261 | 262 | _Edge? next; 263 | _Edge? prev; 264 | 265 | /// true if the toState is a terminal state. 266 | bool get isDestinationTerminal => terminal; 267 | } 268 | -------------------------------------------------------------------------------- /lib/src/export/exporter.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | abstract class Exporter { 4 | /// writes to a new line by writting a leading \n 5 | /// and the writing [indent] tabs. 6 | void write(String string, 7 | {required int indent, required int? page, bool endOfLine = false}); 8 | 9 | /// append to an existing line 10 | void append(String string, {required int? page, bool endOfLine = false}); 11 | } 12 | 13 | class ExportedPages { 14 | /// The set of page files that have been exported. 15 | List pages = []; 16 | 17 | void add(String file) { 18 | pages.add(ExportedPage(file)); 19 | } 20 | 21 | void write(int page, String string) { 22 | pages[page].write(string); 23 | } 24 | } 25 | 26 | class ExportedPage { 27 | ExportedPage(this.path) { 28 | final file = File(path); 29 | raf = file.openSync(mode: FileMode.write); 30 | } 31 | final String path; 32 | late RandomAccessFile raf; 33 | 34 | void write(String string) { 35 | raf.writeStringSync(string); 36 | } 37 | 38 | void close() { 39 | raf.closeSync(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/src/export/mermaid.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import '../definitions/state_definition.dart'; 4 | import '../state_machine.dart'; 5 | import '../transitions/fork_transition.dart'; 6 | import '../transitions/join_transition.dart'; 7 | import '../types.dart'; 8 | 9 | /// Class exports a [StateMachine] to a Mermaid notation file so 10 | /// that the FMS can be visualised. 11 | /// 12 | /// https://github.com/mermaid-js/mermaid 13 | /// 14 | /// Mermaid has cli tools which you can use during development: 15 | /// 16 | /// https://github.com/mermaid-js/mermaid-cli 17 | /// 18 | /// To visualise the resulting file graph run: 19 | /// 20 | /// ``` 21 | /// xdot 22 | /// ``` 23 | class MermaidExporter { 24 | // var terminalStateOrdinal = 1; 25 | 26 | /// creates a map of the terminal ordinals to what 27 | /// parent state they belong to. 28 | /// var terminalsOwnedByRegion = >{}; 29 | MermaidExporter(this.stateMachine); 30 | final StateMachine stateMachine; 31 | 32 | void export(String path) { 33 | // await stateMachine.traverseTree((stateDefinition, 34 | //transitionDefinitions) async { 35 | // for (var transitionDefinition in transitionDefinitions) { 36 | // if (stateDefinition.isLeaf) { 37 | // await _addEdgePath(stateDefinition, transitionDefinition); 38 | // } 39 | // } 40 | // }); 41 | 42 | _save(path); 43 | } 44 | 45 | void _save(String path) { 46 | final file = File(path); 47 | final raf = file.openSync(mode: FileMode.write) 48 | 49 | /// version 50 | ..writeStringSync('stateDiagram-v2\n'); 51 | 52 | const level = 0; 53 | 54 | for (final sd in stateMachine.topStateDefinitions) { 55 | if (sd.isAbstract) { 56 | writeRegion(raf, sd, level); 57 | } else { 58 | writeState(raf, sd, level); 59 | } 60 | } 61 | 62 | raf.closeSync(); 63 | } 64 | 65 | String indent(int level) => '\t' * level; 66 | 67 | void writeRegion(RandomAccessFile raf, StateDefinition sd, int level) { 68 | // ignore: parameter_assignments 69 | level++; 70 | 71 | /// Delcare the enter/exit points for the region 72 | raf 73 | ..writeStringSync('${indent(level)}[*] --> ${sd.stateType}\n') 74 | ..writeStringSync('${indent(level)}${sd.stateType} --> [*]\n') 75 | ..writeStringSync('${indent(level)}state ${sd.stateType} {\n'); 76 | 77 | for (final child in sd.childStateDefinitions) { 78 | if (child.isAbstract) { 79 | writeRegion(raf, child, level); 80 | } else { 81 | writeState(raf, child, level); 82 | } 83 | } 84 | writeTransitions(raf, sd, level); 85 | raf.writeStringSync('${indent(level)}}\n'); 86 | } 87 | 88 | void writeState(RandomAccessFile raf, StateDefinition sd, int level) { 89 | // ignore: parameter_assignments 90 | level++; 91 | raf.writeStringSync('${indent(level)}${sd.stateType}\n'); 92 | 93 | writeTransitions(raf, sd, level); 94 | } 95 | 96 | void writeTransitions(RandomAccessFile raf, StateDefinition sd, int level) { 97 | var pseudoStateId = 1; 98 | 99 | for (final transition in sd.getTransitions(includeInherited: false)) { 100 | if (transition is ForkTransitionDefinition) { 101 | writeFork(raf, sd, transition, level, pseudoStateId); 102 | } else if (transition is JoinTransitionDefinition) { 103 | writeJoin(raf, sd, transition, level, pseudoStateId); 104 | } 105 | 106 | pseudoStateId++; 107 | } 108 | } 109 | 110 | void writeFork( 111 | RandomAccessFile raf, 112 | StateDefinition sd, 113 | ForkTransitionDefinition transition, 114 | int level, 115 | int pseudoStateId) { 116 | /// forks are pseudo states which in mermaid need a name. 117 | /// as we model them as a transition we don't have a name. 118 | /// As such we use the states name followed by a 119 | /// unqiue id to generate a name. 120 | final forkName = '${sd.stateType}$pseudoStateId'; 121 | raf 122 | ..writeStringSync('${indent(level)}state $forkName <> \n') 123 | 124 | /// Add a transition into the fork 125 | ..writeStringSync( 126 | '''${indent(level)}${transition.fromStateDefinition.stateType} --> $forkName\n''') 127 | ..writeStringSync('${indent(level)}[*] --> $forkName\n'); 128 | 129 | /// now add a transition from the fork to each target. 130 | for (final target in transition.targetStates) { 131 | raf.writeStringSync('${indent(level)}$forkName --> $target\n'); 132 | } 133 | } 134 | 135 | void writeJoin( 136 | RandomAccessFile raf, 137 | StateDefinition sd, 138 | JoinTransitionDefinition transition, 139 | int level, 140 | int pseudoStateId) { 141 | /// joins are pseudo states which in mermaid need a name. 142 | /// as we model them as a transition we don't have a name. 143 | /// As such we use the states name followed by a 144 | /// unqiue id to generate a name. 145 | final joinName = '${sd.stateType}$pseudoStateId'; 146 | raf.writeStringSync('${indent(level)}state $joinName <> \n'); 147 | 148 | /// Add a transition into the fork 149 | // raf.writeStringSync('${indent(level)}${transition.fromStateDefinition.stateType} --> ${joinName}\n'); 150 | 151 | // /// now add a transition from each the fork to each target. 152 | // for (var event in transition.triggerEvents) { 153 | // raf.writeStringSync('${indent(level)}${event} --> $joinName\n'); 154 | // } 155 | 156 | for (final state in sd.childStateDefinitions) { 157 | raf.writeStringSync('${indent(level)}${state.stateType} --> $joinName\n'); 158 | } 159 | 160 | raf.writeStringSync('${indent(level)}$joinName --> [*] \n'); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /lib/src/export/smc_pseudo_state.dart: -------------------------------------------------------------------------------- 1 | import 'smc_state.dart'; 2 | 3 | class SMCPseudoState extends SMCState { 4 | SMCPseudoState({ 5 | required SMCState super.parent, 6 | required super.name, 7 | required super.type, 8 | }) : super.pseudo(pageBreak: false); 9 | } 10 | 11 | class SMCInitialState extends SMCPseudoState { 12 | SMCInitialState(SMCState parent, Type stateType) 13 | : super( 14 | parent: parent, 15 | name: stateType.toString(), 16 | type: SMCStateType.initial, 17 | ); 18 | static const initial = 'initial'; 19 | 20 | @override 21 | String get name => ']${super.name}.$initial'; 22 | } 23 | 24 | /// Will be represented as a UML2 fork 'bar' in the diagram 25 | class SMCForkState extends SMCPseudoState { 26 | SMCForkState(SMCState parent, Type stateType) 27 | : super( 28 | parent: parent, 29 | name: stateType.toString(), 30 | type: SMCStateType.fork); 31 | static const fork = 'fork'; 32 | 33 | @override 34 | String get name => ']${super.name}.$fork'; 35 | } 36 | 37 | /// Will be represented as a UML2 join 'bar' in the diagram 38 | class SMCJoinState extends SMCPseudoState { 39 | SMCJoinState(SMCState parent, Type stateType) 40 | : super( 41 | parent: parent, 42 | name: stateType.toString(), 43 | type: SMCStateType.join); 44 | static const join = 'join'; 45 | 46 | @override 47 | String get name => ']${super.name}.$join'; 48 | } 49 | 50 | class SMCTerminalState extends SMCPseudoState { 51 | SMCTerminalState(SMCState parent, Type type) 52 | : super( 53 | parent: parent, name: type.toString(), type: SMCStateType.terminal); 54 | static const finalState = 'final'; 55 | 56 | @override 57 | String get name => ']${super.name}.$finalState'; 58 | } 59 | -------------------------------------------------------------------------------- /lib/src/export/smc_state.dart: -------------------------------------------------------------------------------- 1 | import '../definitions/state_definition.dart'; 2 | import '../state_machine.dart'; 3 | import '../types.dart'; 4 | import 'branches.dart'; 5 | import 'exporter.dart'; 6 | import 'smc_transition.dart'; 7 | 8 | enum Color { none, blue, orange } 9 | 10 | extension ColorExt on Color { 11 | String get name => toString().split('.')[1].replaceAll('_', ' '); 12 | } 13 | 14 | enum SMCStateType { 15 | root, 16 | initial, 17 | coregion, 18 | region, 19 | fork, 20 | join, 21 | simple, 22 | terminal 23 | } 24 | 25 | class SMCState { 26 | SMCState.root({required this.name, required this.pageBreak, String? label}) 27 | : type = SMCStateType.root, 28 | parent = null, 29 | _label = label ?? name; 30 | 31 | SMCState.pseudo( 32 | {required this.parent, 33 | required this.name, 34 | required this.type, 35 | required this.pageBreak, 36 | String? label}) 37 | : _label = label ?? name; 38 | 39 | /// 40 | /// Build the SMSState tree 41 | /// 42 | SMCState.child( 43 | {required this.sd, required this.pageNo, this.parent, bool? pageBreak}) { 44 | this.pageBreak = pageBreak ?? sd.pageBreak; 45 | 46 | var childPageNo = pageNo; 47 | if (this.pageBreak) { 48 | childPageNo++; 49 | } 50 | 51 | if (sd.isLeaf) { 52 | name = sd.stateType.toString(); 53 | label = name; 54 | type = SMCStateType.simple; 55 | return; 56 | } else { 57 | if (sd.isCoRegion) { 58 | name = '${sd.stateType}.parallel'; 59 | label = sd.stateType.toString(); 60 | type = SMCStateType.coregion; 61 | } else { 62 | name = sd.stateType.toString(); 63 | label = name; 64 | type = SMCStateType.region; 65 | initialChildState = sd.initialState.toString(); 66 | } 67 | for (final child in sd.childStateDefinitions) { 68 | children 69 | .add(SMCState.child(parent: this, sd: child, pageNo: childPageNo)); 70 | } 71 | } 72 | } 73 | late final SMCState? parent; 74 | late final StateDefinition sd; 75 | late final String name; 76 | late final String? _label; 77 | late final SMCStateType type; 78 | 79 | /// If true then this state and all child states will 80 | /// be written to new page file. 81 | /// We say that the state where the page break occurs 'straddles' 82 | /// two pages. We call this a 'straddle' state. 83 | 84 | late final bool pageBreak; 85 | 86 | /// the page no. this SMCState appears. 87 | /// A state with a page break will actually appear on two pages. 88 | /// 89 | /// It will appear on the page of its parent and it will 90 | /// also be the 'top level' state on the new page. 91 | /// This [pageNo] refers to the page that its parent is on. 92 | int pageNo = 0; 93 | 94 | /// for a region one of its child are the initial state. 95 | String? initialChildState; 96 | List transitions = []; 97 | List children = []; 98 | 99 | /// We say that the state where the page break occurs 'straddles' 100 | /// two pages. We call this a 'straddle' state. 101 | bool get isStraddleState => pageBreak; 102 | 103 | String get label => _label ?? name; 104 | 105 | set label(String label) => _label = label; 106 | 107 | /// If this state straddles two pages 108 | /// then this is the pageNo of the child page 109 | /// where we display the state as a top level nested state. 110 | int get straddleChildPage { 111 | assert(isStraddleState, 'state cannot straddle two pages'); 112 | 113 | return pageNo + 1; 114 | } 115 | 116 | /// If this state straddles two pages 117 | /// then this is the pageNo of the parent page 118 | /// where we display the state as a simple state. 119 | int? get straddleParentPage { 120 | assert(isStraddleState, 'state cannot straddle two pages'); 121 | 122 | return pageNo; 123 | } 124 | 125 | bool get isRoot => type == SMCStateType.root; 126 | 127 | /// If the state is a co-region then this method returns 128 | /// the original state name. 129 | /// 130 | /// e.g. Dead.parallel returns Dead. 131 | String? get baseName { 132 | var baseName = name; 133 | // if (name.startsWith(']')) { 134 | if (sd.isCoRegion) { 135 | /// psudo names are of the form ']state.type] 136 | /// and we just want the state name. 137 | baseName = name.split('.')[0]; 138 | } 139 | return baseName; 140 | } 141 | 142 | void buildTransitions(StateMachine stateMachine) { 143 | for (final transition in sd.getTransitions(includeInherited: false)) { 144 | transitions.addAll(SMCTransition.build(stateMachine, this, transition)); 145 | } 146 | } 147 | 148 | void addChild(SMCState child) { 149 | children.add(child); 150 | } 151 | 152 | /// 153 | /// write the state to the file. 154 | /// 155 | void write(Exporter exporter, {required int indent}) { 156 | var transitionPage = pageNo; 157 | var bodyPage = pageNo; 158 | if (isStraddleState) { 159 | bodyPage = straddleChildPage; 160 | transitionPage = straddleChildPage; 161 | } 162 | 163 | /// we don't write out the name of the VirtualRoot 164 | if (type != SMCStateType.root) { 165 | _writeName(exporter, pageNo, indent, 166 | color: isStraddleState ? Color.blue : Color.none); 167 | 168 | /// straddle states appear on two pages. 169 | if (isStraddleState) { 170 | _writeName(exporter, bodyPage, indent, color: Color.none); 171 | } 172 | } 173 | 174 | if (hasBody()) { 175 | exporter.append(' {', page: bodyPage); 176 | } 177 | 178 | /// children will be on a new page so reset indent. 179 | if (pageBreak) { 180 | // ignore: parameter_assignments 181 | indent = 0; 182 | } 183 | 184 | /// write out child states. 185 | for (final child in children) { 186 | child.write(exporter, indent: indent + 1); 187 | if (child != children.last) { 188 | exporter.append(',', page: bodyPage); 189 | } 190 | } 191 | 192 | if (children.isNotEmpty) { 193 | exporter.append(';', page: bodyPage); 194 | } 195 | 196 | /// Write out the initial state if this isn't a leaf 197 | if (initialChildState != null) { 198 | exporter.write('$initialChildState.initial => $initialChildState;', 199 | page: bodyPage, indent: indent + 1); 200 | } 201 | 202 | /// write out child transitions 203 | for (final transition in transitions) { 204 | transition.write(exporter, indent: indent + 1, page: transitionPage); 205 | } 206 | 207 | if (hasBody()) { 208 | exporter.write('}', indent: indent, page: bodyPage); 209 | if (isStraddleState) { 210 | exporter.append(';', page: bodyPage); 211 | } 212 | } 213 | } 214 | 215 | bool hasBody() => 216 | (children.isNotEmpty || transitions.isNotEmpty) && 217 | type != SMCStateType.root; 218 | 219 | void _writeName(Exporter exporter, int pageNo, int indent, 220 | {required Color color}) { 221 | final line = StringBuffer(); 222 | var closeBracket = false; 223 | if (name == label) { 224 | line.write(name); 225 | if (color != Color.none) { 226 | closeBracket = true; 227 | line.write(' [color="${color.name}"'); 228 | } 229 | } else { 230 | closeBracket = true; 231 | line.write('$name [label="$label"'); 232 | if (color != Color.none) { 233 | line.write(' color="${color.name}"'); 234 | } 235 | } 236 | if (closeBracket) { 237 | line.write(']'); 238 | } 239 | 240 | exporter.write(line.toString(), indent: indent, page: pageNo); 241 | } 242 | 243 | @override 244 | // ignore: avoid_equals_and_hash_code_on_mutable_classes 245 | bool operator ==(covariant SMCState other) => hashCode == other.hashCode; 246 | 247 | @override 248 | // ignore: avoid_equals_and_hash_code_on_mutable_classes 249 | int get hashCode => name.hashCode + label.hashCode + type.hashCode; 250 | 251 | @override 252 | String toString() => name; 253 | 254 | /// Check if this state is a decendant of [other] 255 | bool isDescendantOf(SMCState other) { 256 | var current = this; 257 | 258 | while (!current.isRoot && current != other) { 259 | current = current.parent!; 260 | } 261 | 262 | /// if we didn't get to the root then we found the other 263 | /// so we must be a descendant. 264 | return !current.isRoot; 265 | } 266 | 267 | /// Find the common ancestor of the two given states 268 | /// and returns the two branches from that ancestor 269 | /// that lead to those states. 270 | Branches findBranchPoint(SMCState to) { 271 | final otherPath = _SMCStatePath.fromState(to); 272 | 273 | final ourPath = _SMCStatePath.fromState(this); 274 | 275 | final ancestor = _findCommonAncestor(ourPath, otherPath); 276 | 277 | late final SMCState _to; 278 | late final SMCState _from; 279 | 280 | for (final state in ourPath.path) { 281 | if (state.parent == ancestor) { 282 | _from = state; 283 | break; 284 | } 285 | } 286 | 287 | for (final state in otherPath.path) { 288 | if (state.parent == ancestor) { 289 | _to = state; 290 | break; 291 | } 292 | } 293 | return Branches(from: _from, to: _to); 294 | } 295 | 296 | SMCState? _findCommonAncestor( 297 | _SMCStatePath ourPath, _SMCStatePath otherPath) { 298 | for (final state in ourPath.path) { 299 | if (otherPath.isInPath(state)) { 300 | return state; 301 | } 302 | } 303 | 304 | /// we should never get here as the root is a common ancestor. 305 | assert(false, 'should never happen'); 306 | return null; 307 | } 308 | 309 | /// Check whether this and other reside on a common page. 310 | /// We are checking both straddle pages if necessary. 311 | int? findCommonPage(SMCState other) { 312 | final pages = {}..add(pageNo); 313 | if (pages.contains(other.pageNo)) { 314 | return pageNo; 315 | } 316 | 317 | pages.add(other.pageNo); 318 | 319 | if (isStraddleState) { 320 | if (pages.contains(straddleChildPage)) { 321 | return straddleChildPage; 322 | } 323 | pages.add(straddleChildPage); 324 | } 325 | 326 | if (other.isStraddleState) { 327 | if (pages.contains(other.straddleChildPage)) { 328 | return other.straddleChildPage; 329 | } 330 | } 331 | 332 | /// no common page. 333 | return null; 334 | } 335 | 336 | bool isSiblingOf(SMCState other) => other.parent == parent; 337 | 338 | bool isAncestorOf(SMCState other) { 339 | SMCState? parent = other; 340 | while (parent != this && !parent!.isRoot) { 341 | parent = parent.parent; 342 | } 343 | return parent == this; 344 | } 345 | } 346 | 347 | /// describes the set of ancestors to a state. 348 | class _SMCStatePath { 349 | _SMCStatePath.fromState(SMCState state) { 350 | var parent = state; 351 | while (!parent.isRoot) { 352 | path.add(parent); 353 | parent = parent.parent!; 354 | } 355 | 356 | // add the virtual root. 357 | path.add(parent); 358 | } 359 | 360 | /// list of states 361 | List path = []; 362 | 363 | /// true if the passed state is in the path. 364 | bool isInPath(SMCState state) => path.contains(state); 365 | } 366 | -------------------------------------------------------------------------------- /lib/src/export/smcat.dart: -------------------------------------------------------------------------------- 1 | import 'package:path/path.dart'; 2 | import 'package:tree_iterator/tree_iterator.dart'; 3 | 4 | import '../state_machine.dart'; 5 | import 'exporter.dart'; 6 | import 'smc_state.dart'; 7 | import 'smc_transition.dart'; 8 | 9 | /// Class exports a [StateMachine] to a state-machine-cat notation 10 | /// file so that the FMS can be visualised. 11 | /// 12 | /// https://github.com/sverweij/state-machine-cat 13 | /// 14 | /// You can visualise your statemachine online 15 | /// 16 | /// https://state-machine-cat.js.org 17 | /// 18 | /// state-machine-cat has cli tools which you can use during development: 19 | /// 20 | /// npm install --global state-machine-cat 21 | /// 22 | /// To visualise the resulting file graph run: 23 | /// 24 | /// ``` 25 | /// run: smcat 26 | /// ``` 27 | 28 | class SMCatExporter implements Exporter { 29 | /// creates a map of the terminal ordinals to what 30 | /// parent state they belong to. 31 | /// var terminalsOwnedByRegion = >{}; 32 | SMCatExporter(this.stateMachine); 33 | final StateMachine stateMachine; 34 | final SMCState virtualRoot = SMCState.root(name: 'initial', pageBreak: false); 35 | 36 | /// we need to suppress any duplicate transitions which can 37 | /// happen when we are forking. 38 | Set seenTransitions = {}; 39 | 40 | final exports = ExportedPages(); 41 | 42 | ExportedPages export(String path) => _save(path); 43 | 44 | ExportedPages _save(String path) { 45 | final smcRoot = _build(); 46 | 47 | _openPageFiles(smcRoot, path); 48 | 49 | const indent = 0; 50 | smcRoot.write(this, indent: indent); 51 | 52 | final ancestor = oldestAncestor(stateMachine, stateMachine.initialState); 53 | 54 | write('initial => $ancestor : ${stateMachine.initialStateLabel};', 55 | page: 0, indent: 0, endOfLine: true); 56 | 57 | _closePageFiles(); 58 | 59 | return exports; 60 | } 61 | 62 | SMCState _build() { 63 | virtualRoot.pageNo = 0; 64 | for (final child in stateMachine.topStateDefinitions) { 65 | virtualRoot 66 | .addChild(SMCState.child(parent: virtualRoot, sd: child, pageNo: 0)); 67 | } 68 | 69 | // we can only build the transitions once the full 70 | // statemachine tree is built. 71 | traverseTree(virtualRoot, (node) => node.children, (node) { 72 | node.buildTransitions(stateMachine); 73 | 74 | return true; 75 | }); 76 | 77 | /// now remove duplicates. 78 | /// We can't do this in the above traverseTree as transitions can be 79 | /// added to an ancestor so we can't see them easily 80 | /// 81 | /// 82 | traverseTree(virtualRoot, (node) => node.children, (node) { 83 | final toBeRemoved = []; 84 | 85 | /// mark any duplicate transitions. 86 | for (final transition in node.transitions) { 87 | if (seenTransitions.contains(transition)) { 88 | toBeRemoved.add(transition); 89 | } else { 90 | seenTransitions.add(transition); 91 | } 92 | } 93 | 94 | // remove any marked transitions. 95 | for (final transition in toBeRemoved) { 96 | node.transitions.remove(transition); 97 | } 98 | return true; 99 | }); 100 | 101 | return virtualRoot; 102 | } 103 | 104 | void _closePageFiles() { 105 | for (final page in exports.pages) { 106 | page.close(); 107 | } 108 | } 109 | 110 | void _openPageFiles(SMCState smcRoot, String filepath) { 111 | var pageBreaks = _countPageBreaks(smcRoot); 112 | 113 | if (pageBreaks == 0) { 114 | exports.add(filepath); 115 | } else { 116 | pageBreaks++; 117 | final ext = extension(filepath); 118 | final base = basenameWithoutExtension(filepath); 119 | final dir = dirname(filepath); 120 | for (var i = 1; i <= pageBreaks; i++) { 121 | exports.add('${join(dir, base)}.$i$ext'); 122 | } 123 | } 124 | } 125 | 126 | int _countPageBreaks(SMCState smcRoot) { 127 | var pageBreaks = 0; 128 | 129 | if (smcRoot.pageBreak) { 130 | pageBreaks++; 131 | } 132 | for (final child in smcRoot.children) { 133 | pageBreaks += _countPageBreaks(child); 134 | } 135 | return pageBreaks; 136 | } 137 | 138 | /// writes a string to the given page file. 139 | @override 140 | void write(String string, 141 | {required int indent, required int? page, bool endOfLine = false}) { 142 | exports.write(page!, '\n${_indent(indent)}$string'); 143 | if (endOfLine) { 144 | exports.write(page, '\n'); 145 | } 146 | } 147 | 148 | /// writes a string to the given page file. 149 | @override 150 | void append(String string, {required int? page, bool endOfLine = false}) { 151 | exports.write(page!, string); 152 | 153 | if (endOfLine) { 154 | exports.write(page, '\n'); 155 | } 156 | } 157 | 158 | String _indent(int level) => '\t' * (level - 1); 159 | } 160 | -------------------------------------------------------------------------------- /lib/src/export/state_machine_cat.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import '../definitions/state_definition.dart'; 4 | import '../state_machine.dart'; 5 | import '../transitions/fork_transition.dart'; 6 | import '../transitions/join_transition.dart'; 7 | import '../transitions/on_transition.dart'; 8 | import '../types.dart'; 9 | 10 | /// Class exports a [StateMachine] to a Mermaid notation file 11 | /// so that the FMS can be visualised. 12 | /// 13 | /// https://github.com/mermaid-js/mermaid 14 | /// 15 | /// Mermaid has cli tools which you can use during development: 16 | /// 17 | /// https://github.com/mermaid-js/mermaid-cli 18 | /// 19 | /// To visualise the resulting file graph run: 20 | /// 21 | /// ``` 22 | /// xdot 23 | /// ``` 24 | class StartMachineCatExporter { 25 | // var terminalStateOrdinal = 1; 26 | 27 | /// creates a map of the terminal ordinals to what 28 | /// parent state they belong to. 29 | /// var terminalsOwnedByRegion = >{}; 30 | StartMachineCatExporter(this.stateMachine); 31 | final StateMachine stateMachine; 32 | 33 | void export(String path) { 34 | // await stateMachine.traverseTree((stateDefinition, 35 | // transitionDefinitions) async { 36 | // for (var transitionDefinition in transitionDefinitions) { 37 | // if (stateDefinition.isLeaf) { 38 | // await _addEdgePath(stateDefinition, transitionDefinition); 39 | // } 40 | // } 41 | // }); 42 | 43 | _save(path); 44 | } 45 | 46 | void _save(String path) { 47 | final file = File(path); 48 | final raf = file.openSync(mode: FileMode.write) 49 | 50 | /// version 51 | ..writeStringSync('initial,\n'); 52 | 53 | const level = 0; 54 | 55 | var firstpass = true; 56 | for (final sd in stateMachine.topStateDefinitions) { 57 | if (!firstpass) { 58 | raf.writeStringSync(',\n'); 59 | } 60 | firstpass = false; 61 | if (sd.isAbstract) { 62 | writeRegion(raf, sd, level); 63 | } else { 64 | writeState(raf, sd, level); 65 | } 66 | } 67 | raf 68 | ..writeStringSync(';\n') 69 | ..closeSync(); 70 | } 71 | 72 | String indent(int level) => '\t' * (level - 1); 73 | 74 | void writeRegion(RandomAccessFile raf, StateDefinition sd, int level) { 75 | // ignore: parameter_assignments 76 | level++; 77 | 78 | /// start the region 79 | raf 80 | ..writeStringSync('${indent(level)}${sd.stateType}\n') 81 | ..writeStringSync('${indent(level)}{\n'); 82 | 83 | // /// Delcare the enter/exit points for the region 84 | // raf.writeStringSync('${indent(level)}[*] => ${sd.stateType}\n'); 85 | 86 | // raf.writeStringSync('${indent(level)}${sd.stateType} => [*]\n'); 87 | // raf.writeStringSync('${indent(level)}state ${sd.stateType} {\n'); 88 | 89 | var firstpass = true; 90 | var sawState = false; 91 | 92 | for (final child in sd.childStateDefinitions) { 93 | if (!firstpass) { 94 | raf.writeStringSync(',\n'); 95 | } 96 | firstpass = false; 97 | 98 | if (child.isAbstract) { 99 | sawState = true; 100 | writeRegion(raf, child, level); 101 | } else { 102 | sawState = true; 103 | writeState(raf, child, level); 104 | } 105 | } 106 | if (sawState) { 107 | raf.writeStringSync(';\n'); 108 | } 109 | raf.writeStringSync('${indent(level)}}'); 110 | writeTransitions(raf, sd, level); 111 | // raf.writeStringSync('\n${indent(level)}}\n'); 112 | } 113 | 114 | void writeState(RandomAccessFile raf, StateDefinition sd, int level) { 115 | // ignore: parameter_assignments 116 | level++; 117 | raf.writeStringSync('${indent(level)}${sd.stateType}'); 118 | 119 | writeTransitions(raf, sd, level); 120 | } 121 | 122 | void writeTransitions(RandomAccessFile raf, StateDefinition sd, int level) { 123 | var firstpass = true; 124 | 125 | for (final transition in sd.getTransitions(includeInherited: false)) { 126 | if (firstpass) { 127 | raf.writeStringSync('${indent(level)}\n'); 128 | firstpass = false; 129 | } 130 | if (transition is OnTransitionDefinition) { 131 | raf.writeStringSync( 132 | '''${indent(level)}${transition.fromStateDefinition.stateType} => ${transition.toState} : ${transition.triggerEvents.first},\n'''); 133 | } 134 | 135 | // else if (transition is ForkTransitionDefinition) { 136 | // writeFork(raf, sd, transition, level, pseudoStateId); 137 | // } else if (transition is JoinTransitionDefinition) { 138 | // writeJoin(raf, sd, transition, level, pseudoStateId); 139 | // } 140 | } 141 | if (firstpass == false) { 142 | raf.writeStringSync('${indent(level)}\n'); 143 | } 144 | } 145 | 146 | void writeFork( 147 | RandomAccessFile raf, 148 | StateDefinition sd, 149 | ForkTransitionDefinition transition, 150 | int level, 151 | int pseudoStateId) { 152 | /// forks are pseudo states which in mermaid need a name. 153 | /// as we model them as a transition we don't have a name. 154 | /// As such we use the states name followed by a 155 | /// unqiue id to generate a name. 156 | final forkName = ']${sd.stateType}$pseudoStateId'; 157 | raf 158 | ..writeStringSync('${indent(level)}$forkName; \n') 159 | 160 | /// Add a transition into the fork 161 | ..writeStringSync( 162 | '''${indent(level)}${transition.fromStateDefinition.stateType} => $forkName;\n'''); 163 | // raf.writeStringSync('${indent(level)}[*] => $forkName;\n'); 164 | 165 | // /// now add a transition from the fork to each target. 166 | // for (var target in transition.targetStates) { 167 | // raf.writeStringSync('${indent(level)}$forkName => ${target};\n'); 168 | // } 169 | } 170 | 171 | void writeJoin( 172 | RandomAccessFile raf, 173 | StateDefinition sd, 174 | JoinTransitionDefinition transition, 175 | int level, 176 | int pseudoStateId) { 177 | /// joins are pseudo states which in mermaid need a name. 178 | /// as we model them as a transition we don't have a name. 179 | /// As such we use the states name followed by a 180 | /// unqiue id to generate a name. 181 | final joinName = ']${sd.stateType}$pseudoStateId'; 182 | raf.writeStringSync('${indent(level)}$joinName \n'); 183 | 184 | // for (var state in sd.childStateDefinitions.values) { 185 | // raf.writeStringSync('${indent(level)}${state.stateType} => $joinName;\n'); 186 | // } 187 | 188 | // raf.writeStringSync('${indent(level)}$joinName => [*] \n'); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /lib/src/graph.dart: -------------------------------------------------------------------------------- 1 | import 'definitions/state_definition.dart'; 2 | import 'exceptions.dart'; 3 | import 'types.dart'; 4 | import 'virtual_root.dart'; 5 | 6 | class Graph { 7 | Graph(this.virtualRoot, this.initialState, this.topStateDefinitions, 8 | this.onTransitionListeners, this.initialStateLabel) 9 | : stateDefinitions = 10 | _expandStateDefinitions(virtualRoot, topStateDefinitions); 11 | 12 | StateDefinition virtualRoot; 13 | Type? initialState; 14 | String? initialStateLabel; 15 | 16 | /// a full set of stateDefinitions including nested and coregions. 17 | final Map stateDefinitions; 18 | 19 | /// a subset of [stateDefinitions] that only includes the top level states. 20 | final List topStateDefinitions; 21 | 22 | /// List of listeners to call each time we transition to a new state. 23 | final List onTransitionListeners; 24 | 25 | /// searches the entire tree of [StateDefinition] looking for a matching 26 | /// state. 27 | StateDefinition? findStateDefinition(Type? runtimeType) => 28 | stateDefinitions[runtimeType!]; 29 | 30 | /// Checks if the given [stateType] is a top level state. 31 | bool isTopLevelState(Type? stateType) { 32 | for (final sd in topStateDefinitions) { 33 | if (sd.stateType == stateType) { 34 | return true; 35 | } 36 | } 37 | return false; 38 | } 39 | 40 | StateDefinition? findStateDefinitionFromString(String stateTypeName) { 41 | for (final state in stateDefinitions.values) { 42 | if (state.stateType.toString() == stateTypeName) { 43 | return state; 44 | } 45 | } 46 | return null; 47 | } 48 | 49 | /// wire the top state definitions into the stateDefinition map 50 | /// and into the [VirtualRoot] 51 | static Map _expandStateDefinitions( 52 | StateDefinition virtualRoot, 53 | List> topStateDefinitions) { 54 | final definitions = {}; 55 | 56 | addStateDefinition(definitions, virtualRoot); 57 | 58 | for (final stateDefinition in topStateDefinitions) { 59 | addStateDefinition(definitions, stateDefinition); 60 | 61 | /// wire the top level states into the virtual root. 62 | virtualRoot.childStateDefinitions.add(stateDefinition); 63 | 64 | final nested = stateDefinition.nestedStateDefinitions; 65 | for (final nestedStateDefinition in nested) { 66 | addStateDefinition(definitions, nestedStateDefinition); 67 | } 68 | } 69 | return definitions; 70 | } 71 | 72 | static void addStateDefinition( 73 | Map> stateDefinitions, 74 | StateDefinition stateDefinition) { 75 | if (stateDefinitions.containsKey(stateDefinition.stateType)) { 76 | throw DuplicateStateException(stateDefinition); 77 | } 78 | stateDefinitions[stateDefinition.stateType] = stateDefinition; 79 | } 80 | 81 | /// Get's the parent of the given state 82 | Type? getParent(Type? childState) { 83 | final def = findStateDefinition(childState)!; 84 | 85 | return def.parent?.stateType; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/src/state_of_mind.dart: -------------------------------------------------------------------------------- 1 | import 'definitions/state_definition.dart'; 2 | import 'state_path.dart'; 3 | import 'virtual_root.dart'; 4 | 5 | class StateOfMind { 6 | /// List of the active leaf states. 7 | /// For each active leaf we retain a [StatePath]. 8 | /// 9 | /// For a simple nested state the list will just have 10 | /// a single leafPath. 11 | /// If we have any active coregions then we will have a list 12 | /// of leafPaths. 13 | final _leafPaths = []; 14 | 15 | bool isInState(Type state) { 16 | for (final statePath in _leafPaths) { 17 | if (statePath.isInState(state)) { 18 | return true; 19 | } 20 | } 21 | return false; 22 | } 23 | 24 | void _removePath(StatePath path) { 25 | StatePath? toBeRemoved; 26 | for (final statePath in _leafPaths) { 27 | if (statePath == path) { 28 | toBeRemoved = statePath; 29 | } 30 | } 31 | 32 | // assert(toBeRemoved != null); 33 | _leafPaths.remove(toBeRemoved); 34 | 35 | dedup(); 36 | } 37 | 38 | void _addPath(StatePath path) { 39 | _leafPaths.add(path); 40 | 41 | dedup(); 42 | } 43 | 44 | StatePath? pathForLeafState(Type leafState) { 45 | for (final path in _leafPaths) { 46 | if (path.leaf.stateType == leafState) { 47 | return path; 48 | } 49 | } 50 | return null; 51 | } 52 | 53 | /// returns a StateDefinition for all active states 54 | List activeLeafStates() { 55 | final defs = []; 56 | 57 | for (final active in _leafPaths) { 58 | if (active.isNotEmpty) { 59 | defs.add(active.leaf); 60 | } 61 | } 62 | return defs; 63 | } 64 | 65 | @override 66 | String toString() { 67 | final details = StringBuffer(); 68 | 69 | var firststate = true; 70 | for (final statePath in _leafPaths) { 71 | if (!firststate) { 72 | details.write('\n and '); 73 | } 74 | firststate = false; 75 | var firstpart = true; 76 | for (final path in statePath.path) { 77 | if (!firstpart) { 78 | details.write('->'); 79 | } 80 | firstpart = false; 81 | details.write(path.stateType.toString()); 82 | } 83 | } 84 | return details.toString(); 85 | } 86 | 87 | /// With co-regions we can have multiple states that collapse 88 | /// into a single state. 89 | /// This can result in duplicate paths so we need to reduce 90 | /// the duplicates to a single state. 91 | void dedup() { 92 | final deduped = _leafPaths.toSet().toList(); 93 | if (deduped.length != _leafPaths.length) { 94 | _leafPaths 95 | ..clear() 96 | ..addAll(deduped); 97 | } 98 | } 99 | 100 | /// We don't need a discrete parent StatePath if the 101 | /// state of mind also contains a child of that parent. 102 | /// 103 | void stripRedundantParents() { 104 | final seen = {}; 105 | final remove = {}; 106 | 107 | /// sortest paths first as we need to see the parent paths first 108 | _leafPaths.sort((lhs, rhs) => lhs.path.length - rhs.path.length); 109 | 110 | for (final path in _leafPaths) { 111 | var next = path; 112 | 113 | /// chain up the path so we can compare each 'parent' path 114 | /// to the list of parents we have [seen]. 115 | while (next.leaf.stateType != VirtualRoot().runtimeType) { 116 | if (seen.contains(next)) { 117 | remove.add(next); 118 | } 119 | 120 | next = next.parent; 121 | } 122 | seen.add(path); 123 | } 124 | 125 | for (final path in remove) { 126 | _leafPaths.remove(path); 127 | } 128 | } 129 | } 130 | 131 | /// Used to hide internal implementation details. 132 | /// 133 | 134 | void removePath(StateOfMind som, StatePath path) { 135 | som._removePath(path); 136 | } 137 | 138 | void addPath(StateOfMind som, StatePath path) { 139 | som._addPath(path); 140 | } 141 | -------------------------------------------------------------------------------- /lib/src/state_path.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'definitions/state_definition.dart'; 4 | import 'exceptions.dart'; 5 | import 'graph.dart'; 6 | import 'types.dart'; 7 | import 'virtual_root.dart'; 8 | 9 | /// Identical to a [StatePath] but we use this class 10 | /// when we don't guarentee that each path goes 11 | /// all the way back to to the root. 12 | /// 13 | /// This class should only be used to store 14 | /// a path which starts from an active leaf. 15 | @immutable 16 | class PartialStatePath { 17 | PartialStatePath() : this._internal(); 18 | 19 | PartialStatePath._internal() : _path = []; 20 | 21 | const PartialStatePath.fromPath(this._path); 22 | 23 | /// List of states from the leaf (stored as the first element in the array) 24 | /// to the root state. 25 | final List _path; 26 | 27 | /// Returns the [StateDefinition] for the tip of the branch. 28 | /// Throws a StateError if you try to access [leaf] for an 29 | /// empty [PartialStatePath] 30 | StateDefinition get leaf => _path.first; 31 | 32 | /// returns a unmodifiable list with the full path from the leaf to its root. 33 | List get path => List.unmodifiable(_path); 34 | 35 | bool get isNotEmpty => _path.isNotEmpty; 36 | 37 | bool isInState(Type state) { 38 | for (final stateDef in _path) { 39 | if (stateDef.stateType == state) { 40 | return true; 41 | } 42 | } 43 | return false; 44 | } 45 | 46 | /// Adds [stateDefinition] to the existing path 47 | /// as the oldest ancestor. 48 | void addAncestor(StateDefinition stateDefinition) { 49 | _path.add(stateDefinition); 50 | } 51 | 52 | /// converts a [PartialStatePath] to a full [StatePath] 53 | StatePath fullPath(Graph graph) => StatePath.fromLeaf(graph, leaf.stateType); 54 | 55 | @override 56 | bool operator ==(covariant PartialStatePath other) { 57 | if (_path.isEmpty && other._path.isEmpty) { 58 | return true; 59 | } 60 | if (_path.length != other._path.length) { 61 | return false; 62 | } 63 | 64 | return leaf.stateType == other.leaf.stateType; 65 | } 66 | 67 | @override 68 | int get hashCode => _path.fold(0, _calcHash); 69 | 70 | int _calcHash(int hash, StateDefinition? def) { 71 | var _hash = hash; 72 | 73 | if (def != null) { 74 | return _hash += def.stateType.hashCode; 75 | } else { 76 | return _hash; 77 | } 78 | } 79 | } 80 | 81 | /// describes a path from a leaf state up to the root state. 82 | class StatePath extends PartialStatePath { 83 | StatePath(List path) 84 | : super.fromPath(List.unmodifiable(path)); 85 | 86 | /// Creates a [StatePath] from a leaf by tracing 87 | /// up the graph to determine the complete list 88 | /// of ancestors to the root. 89 | StatePath.fromLeaf(Graph graph, Type leafState) { 90 | final ancestors = PartialStatePath(); 91 | final ancestor = graph.findStateDefinition(leafState); 92 | if (ancestor != null) { 93 | ancestors.addAncestor(ancestor); 94 | } 95 | var parent = graph.getParent(leafState); 96 | 97 | while (parent != VirtualRoot) { 98 | final ancestor = graph.findStateDefinition(parent); 99 | if (ancestor != null) { 100 | ancestors.addAncestor(ancestor); 101 | } 102 | parent = graph.getParent(parent); 103 | } 104 | 105 | ancestors.addAncestor(graph.virtualRoot); 106 | 107 | _path.addAll(ancestors._path); 108 | } 109 | 110 | StatePath get parent { 111 | if (_path.length == 1) { 112 | throw NoParentException('The StatePath $_path does not have a parent'); 113 | } 114 | return StatePath(_path.sublist(1, _path.length)); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /lib/src/static_analysis.dart: -------------------------------------------------------------------------------- 1 | import 'definitions/state_definition.dart'; 2 | import 'graph.dart'; 3 | import 'types.dart'; 4 | import 'virtual_root.dart'; 5 | 6 | /// * Checks the state machine to ensure that leaf every [State] 7 | /// can be reached. 8 | /// 9 | /// We do this by checking that each state has at 10 | /// least one event that leads to that [State]. 11 | /// 12 | /// The [analyse] method will only work if all [State] 13 | /// transitions are explicity declared. If you use any 14 | /// dynamic transitions (where you have a function that 15 | /// works out the transition) then the call to [analyse] 16 | /// will fail. 17 | /// 18 | /// * Checks that there are no duplicate states 19 | /// 20 | /// * Checks that no transition exist to an abstract state. 21 | /// 22 | /// * Checks that all transitions target a registered state. 23 | /// 24 | /// The [analyse] method prints any problems it finds. 25 | /// 26 | /// Returns true if all States are reachable. 27 | bool analyse(Graph graph) { 28 | var allGood = true; 29 | final stateDefinitionMap = 30 | Map>.from(graph.stateDefinitions); 31 | 32 | final remainingStateMap = 33 | Map>.from(graph.stateDefinitions) 34 | 35 | /// always remove the virtual root as is never directly used. 36 | ..remove(VirtualRoot); 37 | stateDefinitionMap.remove(VirtualRoot); 38 | 39 | /// the initial state is alwasy reachable. 40 | remainingStateMap.remove(graph.initialState); 41 | 42 | /// Check each state is reachable 43 | for (final stateDefinition in stateDefinitionMap.values) { 44 | /// print('Found state: ${stateDefinition.stateType}'); 45 | for (final transitionDefinition in stateDefinition.getTransitions()) { 46 | final targetStates = transitionDefinition.targetStates; 47 | for (final targetState in targetStates) { 48 | final targetDefinition = stateDefinitionMap[targetState]; 49 | 50 | /// we have a target for an unregistered state. 51 | if (targetDefinition == null) { 52 | /// this will be reported later. 53 | continue; 54 | } 55 | remainingStateMap 56 | ..remove(targetState) 57 | 58 | /// If the targetDefinition can be reached then the 59 | /// initial state can be reached. 60 | ..remove(targetDefinition.initialState); 61 | 62 | // if the stateDefinition can be reached so can all its parents. 63 | var parent = targetDefinition.parent!; 64 | while (parent.stateType != VirtualRoot) { 65 | remainingStateMap.remove(parent.stateType); 66 | parent = parent.parent!; 67 | } 68 | } 69 | } 70 | } 71 | 72 | if (remainingStateMap.isNotEmpty) { 73 | allGood = false; 74 | // ignore: avoid_print 75 | print('Error: The following States cannot be reached.'); 76 | 77 | for (final state in remainingStateMap.values) { 78 | // ignore: avoid_print 79 | print('Error: State: ${state.stateType}'); 80 | } 81 | } 82 | 83 | /// check for duplicate states. 84 | final seen = {}; 85 | for (final stateDefinition in graph.stateDefinitions.values) { 86 | if (seen.contains(stateDefinition.stateType)) { 87 | allGood = false; 88 | // ignore: avoid_print 89 | print( 90 | '''Error: Found duplicate state ${stateDefinition.stateType}. Each state MUST only appear once in the FSM.'''); 91 | } 92 | } 93 | 94 | /// Check that no transition points to an invalid state. 95 | /// 1) the toState must be defined 96 | /// 2) the toState must be a leaf state 97 | for (final stateDefinition in stateDefinitionMap.values) { 98 | if (stateDefinition.stateType == VirtualRoot) { 99 | continue; 100 | } 101 | // print('Found state: ${stateDefinition.stateType}'); 102 | for (final transitionDefinition 103 | in stateDefinition.getTransitions(includeInherited: false)) { 104 | final targetStates = transitionDefinition.targetStates; 105 | for (final targetState in targetStates) { 106 | // Ignore our special terminal state. 107 | if (targetState == TerminalState) { 108 | continue; 109 | } 110 | final toStateDefinition = graph.stateDefinitions[targetState]; 111 | if (toStateDefinition == null) { 112 | allGood = false; 113 | // ignore: avoid_print 114 | print('Found transition to non-existant state $targetState.'); 115 | continue; 116 | } 117 | 118 | // if (toStateDefinition.isAbstract) { 119 | // allGood = false; 120 | // print('Found transition to abstract state ${targetState}. 121 | //Only leaf states may be the target of a transition'); 122 | // } 123 | } 124 | } 125 | } 126 | 127 | /// check that all [coregion]s have at least two children 128 | for (final stateDefinition in stateDefinitionMap.values) { 129 | if (stateDefinition.isCoRegion) { 130 | if (stateDefinition.childStateDefinitions.isEmpty) { 131 | allGood = false; 132 | // ignore: avoid_print 133 | print( 134 | '''Found coregion ${stateDefinition.stateType} which has no children.'''); 135 | } 136 | 137 | if (stateDefinition.childStateDefinitions.length == 1) { 138 | allGood = false; 139 | // ignore: avoid_print 140 | print( 141 | '''Found coregion ${stateDefinition.stateType} which has a single child. CoRegions must have at least two chilren.'''); 142 | } 143 | } 144 | } 145 | 146 | /// Check that all child joins of a coregion target the same external state 147 | /// and that they only target states that are external to the coregion 148 | /// InitialStates MUST target a child state (i.e. they can't target 149 | /// grand children) 150 | for (final stateDefinition in stateDefinitionMap.values) { 151 | if (stateDefinition.initialState == null) { 152 | continue; 153 | } 154 | if (!stateDefinition.isDirectChild(stateDefinition.initialState!)) { 155 | allGood = false; 156 | // ignore: avoid_print 157 | print( 158 | '''The initialState for ${stateDefinition.stateType} must target a child. ${stateDefinition.initialState} is not a child.'''); 159 | } 160 | } 161 | 162 | return allGood; 163 | } 164 | -------------------------------------------------------------------------------- /lib/src/tracker.dart: -------------------------------------------------------------------------------- 1 | import 'package:stacktrace_impl/stacktrace_impl.dart'; 2 | 3 | import 'state_of_mind.dart'; 4 | import 'types.dart'; 5 | 6 | class Tracker { 7 | Tracker(this.stateOfMind, this.transitionedBy) { 8 | stackTrace = StackTraceImpl(skipFrames: 1); 9 | } 10 | StackTraceImpl? stackTrace; 11 | 12 | StateOfMind stateOfMind; 13 | Event transitionedBy; 14 | } 15 | -------------------------------------------------------------------------------- /lib/src/transitions/fork_transition.dart: -------------------------------------------------------------------------------- 1 | import '../definitions/fork_definition.dart'; 2 | import '../definitions/state_definition.dart'; 3 | import '../graph.dart'; 4 | import '../types.dart'; 5 | import 'transition_definition.dart'; 6 | import 'transition_notification.dart'; 7 | 8 | class ForkTransitionDefinition 9 | extends TransitionDefinition { 10 | ForkTransitionDefinition(super.stateDefinition, this.definition, 11 | GuardCondition condition, SideEffect? sideEffect, 12 | {super.conditionLabel, super.sideEffectLabel}) 13 | : super(condition: condition, sideEffect: sideEffect); 14 | 15 | /// List of state types that are the target of this fork. 16 | 17 | final ForkDefinition definition; 18 | 19 | @override 20 | List get targetStates => definition.stateTargets; 21 | 22 | @override 23 | 24 | /// A ForkDefintion only has a single triggerEvent. 25 | List get triggerEvents => [E]; 26 | 27 | @override 28 | List> transitions( 29 | Graph graph, StateDefinition? from, Event event) { 30 | final transitions = >[]; 31 | for (final targetState in targetStates) { 32 | final targetDefinition = graph.findStateDefinition(targetState); 33 | 34 | final _event = event as E; 35 | 36 | final notification = 37 | TransitionNotification(this, from, _event, targetDefinition); 38 | if (notification.event != event) { 39 | notification.skipExit = true; 40 | } 41 | 42 | transitions.add(notification); 43 | } 44 | return transitions; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/transitions/join_transition.dart: -------------------------------------------------------------------------------- 1 | import '../definitions/co_region_definition.dart'; 2 | import '../definitions/join_definition.dart'; 3 | import '../definitions/state_definition.dart'; 4 | import '../exceptions.dart'; 5 | import '../graph.dart'; 6 | import '../types.dart'; 7 | import 'transition_definition.dart'; 8 | import 'transition_notification.dart'; 9 | 10 | /// A Join 11 | /// [E] the event that triggers this transition 12 | /// [S] the state that we will transition to once all other Joins are triggered 13 | /// for the parent [coregion] 14 | class JoinTransitionDefinition extends TransitionDefinition { 16 | /// For a Join transition the 'to' State is the parent [coregion]. 17 | JoinTransitionDefinition(StateDefinition parentStateDefinition, 18 | GuardCondition condition, SideEffect? sideEffect, 19 | {String? conditionLabel, String? sideEffectLabel}) 20 | : definition = JoinDefinition(TOSTATE), 21 | super(parentStateDefinition, 22 | condition: condition, 23 | sideEffect: sideEffect, 24 | conditionLabel: conditionLabel, 25 | sideEffectLabel: sideEffectLabel) { 26 | definition.addEvent(E); 27 | 28 | var parent = parentStateDefinition; 29 | 30 | while (!parent.isCoRegion && !parent.isVirtualRoot) { 31 | parent = parent.parent!; 32 | } 33 | 34 | /// we need to register the join with the owning co-region 35 | if (parent is CoRegionDefinition) { 36 | coregion = parent; 37 | parent.registerJoin(this); 38 | } else { 39 | throw JoinWithNoCoregionException( 40 | '''onJoin for ${parentStateDefinition.stateType} MUST have a coregion anscestor.'''); 41 | } 42 | } 43 | final JoinDefinition definition; 44 | 45 | // bool _hasTriggered = false; 46 | 47 | /// The ancestor coregion this join is associated with. 48 | late CoRegionDefinition coregion; 49 | 50 | /// used to trigger the last event that triggered this transition. 51 | late E _triggeredBy; 52 | 53 | @override 54 | bool canTrigger(E event) { 55 | _triggeredBy = event; 56 | return coregion.canTrigger(E); 57 | } 58 | 59 | @override 60 | List get targetStates => [definition.toState]; 61 | 62 | @override 63 | List get triggerEvents => definition.events; 64 | 65 | /// When a join is triggered we need to reflect that multiple transitions will 66 | /// now occur. One for each 'onJoin' statement each of which can 67 | /// belong to a different 68 | /// state. 69 | /// 70 | /// returns the list of transitions that this definition 71 | /// causes when triggered. 72 | @override 73 | List transitions( 74 | Graph graph, StateDefinition? from, Event event) { 75 | final transitions = []; 76 | 77 | // Gather all of the other transition in the coregion defined by onJoins 78 | // ignore: flutter_style_todos 79 | // TODO: what about states that don't have an onjoin, do we need 80 | // create transitions for each of those? 81 | for (final join in coregion.joinTransitions) { 82 | final notification = join.buildTransitionNotification(graph); 83 | 84 | if (notification.event != event) { 85 | notification.skipEnter = true; 86 | } 87 | transitions.add(notification); 88 | } 89 | 90 | return transitions; 91 | } 92 | 93 | TransitionNotification buildTransitionNotification(Graph graph) { 94 | // join only ever has one target state. 95 | final targetState = targetStates[0]; 96 | final targetStateDefinition = graph.findStateDefinition(targetState); 97 | 98 | return TransitionNotification( 99 | this, fromStateDefinition, _triggeredBy, targetStateDefinition); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/src/transitions/noop_transition.dart: -------------------------------------------------------------------------------- 1 | import '../definitions/state_definition.dart'; 2 | import '../graph.dart'; 3 | import '../state_machine.dart'; 4 | import '../state_of_mind.dart'; 5 | import '../types.dart'; 6 | import 'transition_definition.dart'; 7 | import 'transition_notification.dart'; 8 | 9 | /// When a valid event is passed to [StateMachine.applyEvent] 10 | /// but no [condition] method 11 | /// evaluated to true so no transition will occur. 12 | /// Also used by Join transitions when not all of the 13 | /// join prerequisite events have been met. 14 | /// 15 | /// The StateMachine will stay in [S] state. 16 | class NoOpTransitionDefinition 17 | extends TransitionDefinition { 18 | /// no transition so [fromStateDefinition] == [targetStates]. 19 | NoOpTransitionDefinition(super.fromStateDefinition, this.eventType); 20 | final Type eventType; 21 | @override 22 | Future trigger(Graph graph, StateOfMind stateOfMind, 23 | TransitionNotification transition, 24 | {bool applySideEffects = true}) async => 25 | Future.value(stateOfMind); 26 | 27 | @override 28 | List get targetStates => [fromStateDefinition.stateType]; 29 | 30 | @override 31 | List get triggerEvents => [eventType]; 32 | 33 | @override 34 | List transitions( 35 | Graph graph, StateDefinition? from, Event event) => 36 | []; 37 | } 38 | -------------------------------------------------------------------------------- /lib/src/transitions/on_transition.dart: -------------------------------------------------------------------------------- 1 | import '../builders/state_builder.dart'; 2 | import '../definitions/state_definition.dart'; 3 | import '../graph.dart'; 4 | import '../types.dart'; 5 | import 'transition_definition.dart'; 6 | import 'transition_notification.dart'; 7 | 8 | /// An [OnTransitionDefinition] is used to store details 9 | /// of an transition defined by [StateBuilder.on] 10 | class OnTransitionDefinition extends TransitionDefinition { 12 | OnTransitionDefinition(super.stateDefinition, GuardCondition condition, 13 | this.toState, SideEffect? sideEffect, 14 | {super.conditionLabel, super.sideEffectLabel}) 15 | : super(condition: condition, sideEffect: sideEffect); 16 | 17 | /// If this [OnTransitionDefinition] is trigger [toState] 18 | /// will be the new [State] 19 | Type toState; 20 | 21 | @override 22 | List get targetStates => [toState]; 23 | 24 | @override 25 | List get triggerEvents => [E]; 26 | 27 | /// list of transitions that this definition will cause when triggered. 28 | /// Each transition may need to overload this if anything other than 29 | /// a single transition occurs. 30 | @override 31 | List transitions( 32 | Graph graph, StateDefinition? from, Event event) { 33 | final transitions = [ 34 | buildTransitionNotification(graph, from, event as E) 35 | ]; 36 | return transitions; 37 | } 38 | 39 | TransitionNotification buildTransitionNotification( 40 | Graph graph, StateDefinition? from, E event) { 41 | final targetDefinition = graph.findStateDefinition(toState); 42 | 43 | return TransitionNotification(this, from, event, targetDefinition); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/src/transitions/transition_definition.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | import 'dart:math' as math; 3 | 4 | import '../definitions/state_definition.dart'; 5 | import '../graph.dart'; 6 | import '../state_of_mind.dart'; 7 | import '../state_path.dart'; 8 | import '../types.dart'; 9 | import 'transition_notification.dart'; 10 | 11 | /// Defines FSM transition: the change from one state to another. 12 | abstract class TransitionDefinition { 13 | TransitionDefinition(this.fromStateDefinition, 14 | {this.condition = noopGuardCondition, 15 | this.sideEffect, 16 | this.conditionLabel, 17 | this.sideEffectLabel}); 18 | 19 | /// The state this transition is attached to. 20 | final StateDefinition fromStateDefinition; 21 | 22 | /// The condition that must be met for this [TransitionDefinition] 23 | /// to be triggered. 24 | /// If [condition] is null then it always evaluates to true and the 25 | /// event will be triggered. Null conditions are always the last [condition] 26 | /// to be evaulated against an event, so any other [condition] 27 | /// that returns true 28 | /// will be fired in preference to a null [condition]. 29 | final GuardCondition condition; 30 | 31 | /// An optional label used only on the exported diagrams to 32 | /// given the [condition] 33 | /// a descriptive label. @See [label] 34 | final String? conditionLabel; 35 | 36 | /// The [SideEffect] function to call when this choice is 37 | /// selected as the transition. 38 | final SideEffect? sideEffect; 39 | 40 | /// An optional label used only on the exported diagrams to 41 | /// given the [sideEffect] 42 | /// a descriptive label. @See [label] 43 | final String? sideEffectLabel; 44 | 45 | /// list of the target states the will be transitioned into. 46 | List get targetStates; 47 | 48 | /// The list of events that this transition will trigger on. 49 | /// Whether the events are and/or'ed together is an 50 | /// implementation detail of the [TransitionDefinition] implementation. 51 | List get triggerEvents; 52 | 53 | /// 54 | /// Returns a transition label for use on exported diagrams. The 55 | /// label takes the standard UML2 form of: 56 | /// ``` 57 | /// transition[condition]/sideeffect 58 | /// ``` 59 | /// If the transition supports multiple events we just take use 60 | /// the first one. 61 | /// 62 | String get label => labelForEvent(triggerEvents[0]); 63 | 64 | /// 65 | /// Returns a transition label for use on exported diagrams. The 66 | /// label takes the standard UML2 form of: 67 | /// ``` 68 | /// transition[condition]/sideeffect 69 | /// ``` 70 | /// 71 | String labelForEvent(Type event) { 72 | final buf = StringBuffer()..write(event.toString()); 73 | 74 | if (conditionLabel != null) { 75 | buf.write(' [$conditionLabel]'); 76 | } 77 | if (sideEffectLabel != null) { 78 | buf.write('/$sideEffectLabel'); 79 | } 80 | return buf.toString(); 81 | } 82 | 83 | /// 84 | /// Applies [transition] to the current statemachine and returns 85 | /// the resulting 86 | /// [StateOfMind]. 87 | /// 88 | /// As the statemachine can be in multiple states the [stateOfMind] 89 | /// argument indicates what 90 | /// [stateOfMind] the [transition] is to be processed against. 91 | /// / 92 | Future trigger(Graph graph, StateOfMind stateOfMind, 93 | TransitionNotification transition, 94 | {required bool applySideEffects}) async { 95 | final exitPaths = []; 96 | final enterPaths = []; 97 | 98 | final fromPath = StatePath.fromLeaf(graph, transition.from!.stateType); 99 | final toPath = StatePath.fromLeaf(graph, transition.to!.stateType); 100 | 101 | final commonAncestor = findCommonAncestor(graph, fromPath, toPath); 102 | 103 | if (!transition.skipExit) { 104 | exitPaths.addAll(_getExitPaths(stateOfMind, fromPath, commonAncestor)); 105 | } 106 | 107 | if (!transition.skipEnter) { 108 | enterPaths.add(_getEnterPaths(toPath, commonAncestor)); 109 | } 110 | 111 | final exitStates = dedupPaths(exitPaths); 112 | final enterStates = dedupPaths(enterPaths); 113 | 114 | final fromStateDefinition = 115 | graph.stateDefinitions[transition.from.runtimeType]; 116 | 117 | if (applySideEffects) { 118 | await _callOnExits(fromStateDefinition, transition.event, exitStates); 119 | await _callSideEffect(transition); 120 | } 121 | 122 | // when entering we must start from the root and work 123 | // towards the leaf. 124 | await _callOnEnters(enterStates.reversed.toList(), transition.event); 125 | 126 | for (final statePath in exitPaths.toSet()) { 127 | /// check that a transition occured 128 | if (statePath.isNotEmpty) { 129 | removePath(stateOfMind, statePath.fullPath(graph)); 130 | } 131 | } 132 | 133 | for (final statePath in enterPaths.toSet()) { 134 | /// check that a transition occured 135 | if (statePath.isNotEmpty) { 136 | addPath(stateOfMind, statePath.fullPath(graph)); 137 | } 138 | } 139 | 140 | // If we transitioned up to a parent [toPath] 141 | // then we will have removed the child (and all its parents) so 142 | // we need re-add the parent [toPath]. 143 | // No need to call onEnter as we were already in the parent state. 144 | if (toPath.leaf == commonAncestor) { 145 | addPath(stateOfMind, toPath); 146 | } 147 | 148 | /// If we transition from a child to a parent then we need to 149 | /// remove the parent 150 | /// No need to run onExit as we are still in the parent state 151 | /// due to the child. 152 | stateOfMind.stripRedundantParents(); 153 | 154 | log('Updated stateOfMind: $stateOfMind'); 155 | 156 | return stateOfMind; 157 | } 158 | 159 | /// 160 | /// Used by onJoin and the likes to suppress a trigger if not all 161 | /// pre-conditions have been met. 162 | /// 163 | bool canTrigger(E event) => true; 164 | 165 | /// 166 | /// We must call onExit for the [fromPath] and all its ancestors 167 | /// as well as any active children and their ancestors 168 | List _getExitPaths(StateOfMind stateOfMind, 169 | StatePath fromPath, StateDefinition? commonAncestor) { 170 | final exitTargets = []; 171 | final anscestorTargets = PartialStatePath(); 172 | 173 | /// add each state until (but excluding) the common ancestor 174 | for (final fromAncestor in fromPath.path) { 175 | if (fromAncestor.stateType == commonAncestor!.stateType) { 176 | break; 177 | } 178 | 179 | anscestorTargets.addAncestor(fromAncestor); 180 | } 181 | 182 | if (anscestorTargets.isNotEmpty) { 183 | exitTargets.add(anscestorTargets); 184 | } 185 | 186 | /// We now need to add any active child states. 187 | for (final state in stateOfMind.activeLeafStates()) { 188 | if (state.isDecendentOf(fromPath)) { 189 | /// add each state until (but excluding) the common ancestor 190 | for (final childState in state.statePath.path) { 191 | if (childState.stateType == fromPath.leaf.stateType) { 192 | break; 193 | } 194 | exitTargets.add(state.statePath); 195 | } 196 | } 197 | } 198 | 199 | return exitTargets; 200 | } 201 | 202 | /// 203 | /// 204 | PartialStatePath _getEnterPaths( 205 | StatePath toAncestors, StateDefinition? commonAncestor) { 206 | final enterTargets = PartialStatePath(); 207 | 208 | for (final toAncestor in toAncestors.path) { 209 | if (toAncestor.stateType == commonAncestor!.stateType) { 210 | break; 211 | } 212 | 213 | enterTargets.addAncestor(toAncestor); 214 | } 215 | return enterTargets; 216 | } 217 | 218 | /// Walks up the tree looking for an ancestor that is common 219 | /// to the [fromAncestors] and [toAncestors] paths. 220 | /// 221 | /// If no common ancestor is found then null is returned; 222 | StateDefinition? findCommonAncestor( 223 | Graph graph, StatePath fromAncestors, StatePath toAncestors) { 224 | final toAncestorSet = toAncestors.path.toSet(); 225 | 226 | for (final ancestor in fromAncestors.path) { 227 | if (toAncestorSet.contains(ancestor)) { 228 | return ancestor; 229 | } 230 | } 231 | return null; 232 | } 233 | 234 | /// 235 | /// When exiting a state we have to exit all ancestor states and 236 | /// active concurrent states. 237 | /// To do this we walk up the tree (towards the root) and call onExit 238 | /// for each ancestor up to but not including the common 239 | /// ancestor of the state we are entering. 240 | /// 241 | Future _callOnExits(StateDefinition? fromState, Event? event, 242 | List states) async { 243 | for (final fromState in states) { 244 | await onExit(fromState, fromState.stateType, event); 245 | } 246 | } 247 | 248 | /// 249 | ///When entering a state we have to enter all ancestor states and 250 | /// including concurrent states. 251 | /// To do this we walk down the tree (from the root) and call onEnter 252 | /// for each ancestor starting from but not including the common 253 | /// ancestor of the state we are exiting. 254 | /// 255 | /// Can you join a concurrent state 256 | /// 257 | Future _callOnEnters(List paths, Event? event) async { 258 | for (final toStateDefinition in paths) { 259 | await onEnter(toStateDefinition, toStateDefinition.stateType, event); 260 | } 261 | } 262 | 263 | /// 264 | /// Takes a list of [PartialStatePath]s and returns an ordered list 265 | /// of states from the leaf on the longest branch. 266 | /// It is expected that the partial paths are all relative to a common 267 | /// ancestor. 268 | /// We also dedup the States as we build the path. 269 | /// 270 | List dedupPaths(List paths) { 271 | final maxLength = paths.fold( 272 | 0, (longest, element) => math.max(longest, element.path.length)); 273 | 274 | final seenPaths = {}; 275 | final orderedPaths = []; 276 | for (var i = 0; i < maxLength; i++) { 277 | for (final statePath in paths) { 278 | /// Only take if the path is long enough 279 | if (statePath.path.length >= (maxLength - i)) { 280 | final ofInterest = 281 | statePath.path[i - (maxLength - statePath.path.length)]; 282 | if (!seenPaths.contains(ofInterest)) { 283 | orderedPaths.add(ofInterest); 284 | } 285 | seenPaths.add(ofInterest); 286 | } 287 | } 288 | } 289 | return orderedPaths; 290 | } 291 | 292 | /// 293 | /// list of transitions that this definition will cause when triggered. 294 | /// 295 | List transitions( 296 | Graph graph, StateDefinition? from, Event event); 297 | 298 | Future _callSideEffect(TransitionNotification transition) async { 299 | if (sideEffect != null) { 300 | try { 301 | log('FSM calling sideEffect due to ${transition.event} '); 302 | await sideEffect!(transition.event); 303 | log('FSM completed sideEffect due to ${transition.event} '); 304 | // ignore: avoid_catches_without_on_clauses 305 | } catch (e) { 306 | log('FSM sideEffect due to ${transition.event} threw $e'); 307 | rethrow; 308 | } 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /lib/src/transitions/transition_notification.dart: -------------------------------------------------------------------------------- 1 | import '../definitions/state_definition.dart'; 2 | import '../types.dart'; 3 | import 'transition_definition.dart'; 4 | 5 | class TransitionNotification { 6 | TransitionNotification(this.definition, this.from, this.event, this.to); 7 | TransitionDefinition definition; 8 | StateDefinition? from; 9 | E event; 10 | StateDefinition? to; 11 | 12 | /// Some transitions (fork/join) cause multiple transitions to/from a state. 13 | /// We only want to trigger the onEnter/onExit methods once so these 14 | /// flags allow then onEnter/onExit methods to be skipped. 15 | bool skipEnter = false; 16 | bool skipExit = false; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/types.dart: -------------------------------------------------------------------------------- 1 | import 'builders/co_region_builder.dart'; 2 | import 'builders/fork_builder.dart'; 3 | import 'builders/graph_builder.dart'; 4 | import 'builders/state_builder.dart'; 5 | import 'definitions/co_region_definition.dart'; 6 | import 'definitions/state_definition.dart'; 7 | import 'state_machine.dart'; 8 | 9 | /// Base class for all States that you pass to the FSM. 10 | /// 11 | /// All your [State] classes MUST extend this [State] class. 12 | /// 13 | /// ```dart 14 | /// class Solid extends State 15 | /// { 16 | /// } 17 | /// 18 | /// final machine = StateMachine.create((g) => g 19 | /// ..state((b) => b 20 | /// 21 | /// ... 22 | /// ) 23 | /// ``` 24 | abstract class State {} 25 | 26 | /// Special class used to represent the terminal state of the FSM. 27 | /// If you add an 'on' transition to [TerminalState] then an 28 | /// transition arrow to a terminal state 29 | /// icon will be displayed when you export your statemachine to a diagram. 30 | class TerminalState extends State {} 31 | 32 | /// Used by the [StateMachine.history] to represent a pseudo 'first' event 33 | /// that indicates how we got into the FSM initialState. 34 | class InitialEvent extends Event {} 35 | 36 | /// Base class for all Events that you pass to the FSM. 37 | /// 38 | /// All your [Event] class MUST extends the [Event] class 39 | /// 40 | /// ```dart 41 | /// class Heat extends Event 42 | /// { 43 | /// int joules; 44 | /// 45 | /// Heat({this.joules}); 46 | /// } 47 | /// 48 | /// stateMachine.applyEvent(Heat(joules: 2000)); 49 | /// 50 | /// ... 51 | /// 52 | /// ..on(condition: (s,e) => applyHeatAndReturnTemp(e.joules) > 0) 53 | /// ``` 54 | abstract class Event { 55 | @override 56 | String toString() => runtimeType.toString(); 57 | } 58 | 59 | typedef GuardCondition = bool Function(E event); 60 | bool noopGuardCondition(Event v) => true; 61 | 62 | typedef BuildGraph = void Function(GraphBuilder); 63 | 64 | typedef SideEffect = Future Function(E event); 65 | 66 | /// The method signature for a [State]s [onEnter] method 67 | typedef OnEnter = Future? Function(Type fromState, Event? event); 68 | 69 | /// The method signature for a [State]s [onExit] method 70 | typedef OnExit = Future? Function(Type toState, Event? event); 71 | 72 | /// Callback when a transition occurs. 73 | /// We pass, fromState, Event that triggered the transition 74 | /// and the target state. 75 | /// A single event may result in multiple calls to the listener when we have 76 | /// active concurrent regions. 77 | typedef TransitionListener = void Function( 78 | StateDefinition?, Event?, StateDefinition?); 79 | 80 | /// The builder for a state. 81 | typedef BuildState = void Function(StateBuilder); 82 | 83 | /// Builder for [CoRegionDefinition] 84 | typedef BuildCoRegion = void Function(CoRegionBuilder); 85 | 86 | /// Builder for onFork 87 | typedef BuildFork = void Function(ForkBuilder); 88 | 89 | // /// Builder for onJoin 90 | // typedef BuildJoin = void Function(JoinBuilder); 91 | -------------------------------------------------------------------------------- /lib/src/util/file_util.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:path/path.dart' as p; 4 | 5 | /// returns the filename without any extension and without a path. 6 | String getBasename(String pathTo) { 7 | var basename = p.basenameWithoutExtension(pathTo); 8 | if (basename.contains('.')) { 9 | basename = p.basenameWithoutExtension(basename); 10 | } 11 | return basename; 12 | } 13 | 14 | /// Extracts a page no from a file name of the form: 15 | /// 16 | /// name.1.xxx 17 | /// 18 | /// If the filename doesn't contain a page no. then 0 is returned. 19 | /// 20 | int extractPageNo(String filename) { 21 | final extension = p.extension(p.basenameWithoutExtension(filename)); 22 | 23 | var nPageNo = 0; 24 | if (extension.startsWith('.')) { 25 | nPageNo = int.tryParse(extension.substring(1)) ?? 0; 26 | } 27 | 28 | return nPageNo; 29 | } 30 | 31 | bool exists(String pathTo) => File(pathTo).existsSync(); 32 | -------------------------------------------------------------------------------- /lib/src/version/version.g.dart: -------------------------------------------------------------------------------- 1 | /// GENERATED BY pub_release do not modify. 2 | /// Instance of 'Name' version 3 | String packageVersion = '3.2.1'; 4 | -------------------------------------------------------------------------------- /lib/src/virtual_root.dart: -------------------------------------------------------------------------------- 1 | import 'types.dart'; 2 | 3 | class VirtualRoot implements State {} 4 | -------------------------------------------------------------------------------- /lib/src/visualise/progress.dart: -------------------------------------------------------------------------------- 1 | typedef Progress = void Function(String line); 2 | 3 | Progress noOp(String line) => (line) {}; 4 | -------------------------------------------------------------------------------- /lib/src/visualise/size.dart: -------------------------------------------------------------------------------- 1 | class Size { 2 | Size(this.width, this.height); 3 | Size.copyFrom(Size pageSize) 4 | : width = pageSize.width, 5 | height = pageSize.height; 6 | int width; 7 | int height; 8 | } 9 | -------------------------------------------------------------------------------- /lib/src/visualise/smcat_file.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:path/path.dart' as p; 5 | 6 | import '../util/file_util.dart'; 7 | import 'progress.dart'; 8 | import 'svg_file.dart'; 9 | 10 | class SMCatFile { 11 | SMCatFile(this.pathTo) { 12 | pageNo = extractPageNo(pathTo); 13 | 14 | _svgFile = SvgFile(svgPath); 15 | } 16 | String pathTo; 17 | int pageNo = 0; 18 | 19 | late SvgFile _svgFile; 20 | 21 | String get svgPath { 22 | final basename = getBasename(pathTo); 23 | if (pageNo == 0) { 24 | return '${p.join(p.dirname(pathTo), basename)}.svg'; 25 | } else { 26 | return '${p.join(p.dirname(pathTo), basename)}.$pageNo.svg'; 27 | } 28 | } 29 | 30 | SvgFile get svgFile => _svgFile; 31 | 32 | int get height => svgFile.height; 33 | 34 | int get width => svgFile.width; 35 | 36 | /// creates an Svg image from the smcat file. 37 | /// 38 | /// Requires that the 'smcat' cli tools are installed. 39 | /// 40 | /// If [force] is true, the conversion will be performed even if the 41 | /// svg file already exists. 42 | /// 43 | /// Throws [SMCatException if the conversion fails] 44 | /// 45 | Future convert( 46 | {required bool force, Progress progress = noOp}) async { 47 | if (!force && !isConversionRequired()) { 48 | return svgFile; 49 | } 50 | 51 | progress('Generating: $svgPath '); 52 | if (File(svgPath).existsSync()) { 53 | File(svgPath).deleteSync(); 54 | } 55 | 56 | final process = await Process.start('smcat', [p.basename(pathTo)], 57 | workingDirectory: p.dirname(pathTo)); 58 | 59 | process.stdout.transform(utf8.decoder).listen((data) { 60 | progress(data); 61 | }); 62 | 63 | process.stderr.transform(utf8.decoder).listen((data) { 64 | if (!data.contains('viz.js:33')) { 65 | progress(data); 66 | } 67 | }); 68 | 69 | final exitCode = await process.exitCode; 70 | 71 | if (exitCode == 0) { 72 | /// See if the filename contains a page no. 73 | progress('Generation of $svgPath complete.'); 74 | _svgFile = SvgFile(svgPath); 75 | await _svgFile.addPageNo(); 76 | return _svgFile; 77 | } else { 78 | progress('Generation of $svgPath failed.'); 79 | throw SMCatException( 80 | 'Generation of $svgPath failed. exitCode: $exitCode'); 81 | } 82 | } 83 | 84 | int compareTo(SMCatFile other) => pageNo - other.pageNo; 85 | 86 | @override 87 | String toString() => pathTo; 88 | 89 | bool isConversionRequired() { 90 | final smc = FileStat.statSync(pathTo).modified; 91 | 92 | if (exists(svgPath)) { 93 | final svg = FileStat.statSync(svgPath).modified; 94 | return smc.isAfter(svg); 95 | } 96 | return true; 97 | } 98 | } 99 | 100 | class SMCatException implements Exception { 101 | SMCatException(this.message); 102 | String message; 103 | 104 | @override 105 | String toString() => message; 106 | } 107 | -------------------------------------------------------------------------------- /lib/src/visualise/smcat_folder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:developer'; 3 | import 'dart:io'; 4 | 5 | import 'package:path/path.dart' as p; 6 | import 'package:synchronized/synchronized.dart'; 7 | 8 | import '../util/file_util.dart' as u; 9 | import 'progress.dart'; 10 | import 'smcat_file.dart'; 11 | import 'svg_file.dart'; 12 | import 'watch_folder.dart'; 13 | 14 | /// Used to manage/monitor a folder containing smcat files. 15 | /// 16 | class SMCatFolder { 17 | /// [folderPath] is the name of the folder holding the smcat files. 18 | SMCatFolder({required this.folderPath, required this.basename}); 19 | String folderPath; 20 | String basename; 21 | 22 | final lock = Lock(); 23 | 24 | /// We add an event each time an SvgFile is generated. 25 | final _generatedController = StreamController(); 26 | 27 | /// when we see a mod we want to delay the generation as we often 28 | /// see multiple modifications when a file is being updated. 29 | final _toGenerate = []; 30 | 31 | /// returns the list of smcat files in this folder that match 32 | /// the passed basename. 33 | List get list { 34 | final all = Directory(folderPath).listSync(); 35 | 36 | final matching = []; 37 | 38 | for (final one in all) { 39 | final file = one.path; 40 | if (p.extension(file) == '.smcat' && getBasename(file) == basename) { 41 | matching.add(SMCatFile(file)); 42 | } 43 | } 44 | 45 | matching.sort((lhs, rhs) => lhs.compareTo(rhs)); 46 | 47 | return matching; 48 | } 49 | 50 | /// returns the list of svg files in this folder that match 51 | /// the passed basename. 52 | List get listSvgs { 53 | final smcats = list; 54 | 55 | final found = []; 56 | 57 | for (final smcat in smcats) { 58 | if (u.exists(smcat.svgPath)) { 59 | found.add(SvgFile(smcat.svgPath)); 60 | } 61 | } 62 | return found; 63 | } 64 | 65 | void watch() { 66 | WatchFolder(pathTo: folderPath, extension: 'smcat', onChanged: _onChanged); 67 | } 68 | 69 | /// returns a stream of SvgFile. An SvgFile is added to the 70 | /// stream each time it is generated. A single SvgFile 71 | /// will be generated each time you change the smcat file 72 | /// or call [queueGeneration] 73 | Stream get stream => _generatedController.stream; 74 | 75 | Future delayedGeneration() async { 76 | await lock.synchronized(() async { 77 | final files = _toGenerate.toSet().toList() 78 | ..sort((lhs, rhs) => lhs.compareTo(rhs)); 79 | for (final file in files) { 80 | try { 81 | final svgFile = await file.convert(force: false, progress: log); 82 | _generatedController.add(svgFile); 83 | } on SMCatException catch (e, _) { 84 | /// already logged. 85 | } 86 | } 87 | _toGenerate.clear(); 88 | }); 89 | } 90 | 91 | void _onChanged(String file, FolderChangeAction action) { 92 | if (action != FolderChangeAction.delete) { 93 | queueGeneration(SMCatFile(file)); 94 | } 95 | } 96 | 97 | /// Used by the watch mechanism to queue a smcat file for 98 | /// conversion to an svg file. 99 | /// 100 | /// You can however also queue files for generation via this mechanism. 101 | void queueGeneration(SMCatFile smCatFile) { 102 | _toGenerate.add(smCatFile); 103 | 104 | Future.delayed(const Duration(microseconds: 1500), delayedGeneration); 105 | } 106 | 107 | /// Generate the svg files for all smcat files in [folderPath] 108 | /// with a matching [basename] 109 | /// 110 | /// Throws [SMCatException] if the file does not exist. 111 | Future generateAll( 112 | {required bool force, Progress progress = noOp}) async { 113 | final files = await Directory(folderPath).list().toList(); 114 | for (final entity in files) { 115 | // print('testing $entity'); 116 | final file = entity.path; 117 | if (getBasename(file) == basename && p.extension(file) == '.smcat') { 118 | await SMCatFile(file).convert(force: force, progress: progress); 119 | } 120 | } 121 | } 122 | 123 | static String getBasename(String file) => u.getBasename(file); 124 | 125 | Future show({Progress? progress}) async { 126 | final files = await Directory(folderPath).list().toList(); 127 | 128 | for (final entity in files) { 129 | final file = entity.path; 130 | if (p.extension(file) == 'svg') { 131 | await SvgFile(file).show(progress: (line) => progress!(line)); 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /lib/src/visualise/svg_file.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:developer'; 3 | import 'dart:io'; 4 | 5 | import 'package:path/path.dart'; 6 | 7 | import '../util/file_util.dart'; 8 | import 'progress.dart'; 9 | import 'size.dart'; 10 | 11 | class SvgFile { 12 | SvgFile(this.pathTo) { 13 | pageNo = extractPageNo(pathTo); 14 | lastModified; 15 | } 16 | final String pathTo; 17 | 18 | late final int pageNo; 19 | 20 | bool _hasSize = false; 21 | Size? _size; 22 | 23 | DateTime? _lastModified; 24 | 25 | bool get hasChanged => _lastModified != lastModified; 26 | 27 | int get width => size!.width; 28 | 29 | int get height => size!.height; 30 | 31 | void reload() {} 32 | 33 | Future show({Progress? progress}) async { 34 | progress ??= noOp; 35 | 36 | final filename = basename(pathTo); 37 | final workingDir = dirname(pathTo); 38 | 39 | final process = await Process.start('firefox', [filename], 40 | workingDirectory: workingDir); 41 | 42 | process.stdout.transform(utf8.decoder).listen((data) { 43 | progress!(data); 44 | }); 45 | 46 | process.stderr.transform(utf8.decoder).listen((data) { 47 | progress!(data); 48 | }); 49 | } 50 | 51 | DateTime? get lastModified { 52 | if (_lastModified == null) { 53 | if (exists(pathTo)) { 54 | _lastModified = File(pathTo).lastModifiedSync(); 55 | } else { 56 | _lastModified = DateTime.now().subtract(const Duration(days: 1000)); 57 | } 58 | } 59 | 60 | return _lastModified; 61 | } 62 | 63 | /// Add a page no. at the top of the page. 64 | /// We add the svg elements at the very end of the file. 65 | Future addPageNo() async { 66 | await _addInkscapeNamespace(pathTo); 67 | 68 | const xPos = 40; 69 | final yPos = size!.height + 20; 70 | final svgPageNo = ''' 71 | Page: $pageNo 82 | 83 | '''; 84 | 85 | await replace(pathTo, '', svgPageNo); 86 | 87 | final newPageSize = Size.copyFrom(size!)..height = yPos + 10; 88 | await updatePageHeight(pathTo, size!, newPageSize); 89 | } 90 | 91 | /// We increase the page hieght so we can fit the page no. at 92 | /// the bottom of the 93 | /// page without it being over any of the diagram. 94 | Future updatePageHeight( 95 | String svgPath, Size pageSize, Size newPageSize) async { 96 | final existing = 97 | ' _addInkscapeNamespace(String svgPath) async { 108 | const existing = 'xmlns="http://www.w3.org/2000/svg"'; 109 | 110 | const replacement = 111 | 'xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"'; 112 | 113 | await replace(svgPath, existing, replacement); 114 | } 115 | 116 | int compareTo(SvgFile other) => pageNo - other.pageNo; 117 | 118 | Size? get size { 119 | if (!_hasSize) { 120 | _size = _getPageSize(); 121 | _hasSize = true; 122 | } 123 | return _size; 124 | } 125 | 126 | /// gets the page height from the svg file. 127 | Size _getPageSize() { 128 | final size = Size(0, 0); 129 | final lines = load(); 130 | final svgLine = lines.firstWhere( 131 | (line) => line.trim().startsWith(' ' load() { 154 | var lines = []; 155 | if (exists(pathTo)) { 156 | lines = File(pathTo).readAsLinesSync().toList(); 157 | } else { 158 | lines = _empty.split('\n'); 159 | } 160 | return lines; 161 | } 162 | 163 | int? getAttributeInt(String attribute) { 164 | final parts = attribute.split('='); 165 | assert(parts.length == 2, 'expected key value paire'); 166 | 167 | var pts = parts[1]; 168 | pts = pts.replaceAll('pt', ''); 169 | pts = pts.replaceAll('px', ''); 170 | pts = pts.replaceAll('"', ''); 171 | 172 | return int.tryParse(pts); 173 | } 174 | 175 | @override 176 | // ignore: avoid_equals_and_hash_code_on_mutable_classes 177 | int get hashCode => pathTo.hashCode; 178 | 179 | @override 180 | // ignore: avoid_equals_and_hash_code_on_mutable_classes 181 | bool operator ==(covariant SvgFile other) => pathTo == other.pathTo; 182 | 183 | Future replace( 184 | String svgPath, String existing, String replacement) async { 185 | try { 186 | log('replace on $svgPath'); 187 | final svgFile = File(svgPath); 188 | final lines = load(); 189 | 190 | final backupPath = '$svgPath.bak'; 191 | final backupFile = File(backupPath); 192 | final backup = backupFile.openWrite(); 193 | 194 | for (final line in lines) { 195 | backup.writeln(line.replaceAll(existing, replacement)); 196 | } 197 | 198 | await backup.flush(); 199 | await backup.close(); 200 | 201 | svgFile.deleteSync(); 202 | 203 | backupFile.renameSync(svgPath); 204 | log('replace complete $svgPath'); 205 | // ignore: avoid_catches_without_on_clauses 206 | } catch (e, st) { 207 | log('Excepton in replace: $e, $st'); 208 | } 209 | } 210 | 211 | @override 212 | String toString() => pathTo; 213 | 214 | static Future showList(List files, 215 | {Progress? progress}) async { 216 | progress ??= noOp; 217 | 218 | final paths = files.map((file) => file.pathTo).toList(); 219 | 220 | final process = await Process.start('firefox', paths); 221 | 222 | process.stdout.transform(utf8.decoder).listen((data) { 223 | progress!(data); 224 | }); 225 | 226 | process.stderr.transform(utf8.decoder).listen((data) { 227 | progress!(data); 228 | }); 229 | } 230 | } 231 | 232 | const _empty = ''' 233 | 234 | 236 | 238 | 239 | 241 | 242 | state transitions 243 | 244 | 245 | 246 | '''; 247 | -------------------------------------------------------------------------------- /lib/src/visualise/watch_folder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:developer'; 3 | import 'dart:io'; 4 | 5 | import 'package:path/path.dart' as p; 6 | 7 | enum FolderChangeAction { create, modify, move, delete } 8 | 9 | typedef OnFolderChanged = void Function( 10 | String pathTo, FolderChangeAction changeType); 11 | 12 | /// Used to manage/monitor a folder containing files with the given extension. 13 | /// 14 | class WatchFolder { 15 | /// The file [extension] to filter what we are interested in. 16 | /// The [extension] should NOT start with a period. 17 | WatchFolder( 18 | {required this.pathTo, 19 | required this.extension, 20 | required this.onChanged, 21 | this.recursive = false}); 22 | String pathTo; 23 | String extension; 24 | 25 | bool recursive; 26 | 27 | OnFolderChanged onChanged; 28 | 29 | /// Watches the folder for any changes which involve a file ending 30 | /// in .[extension] 31 | Future watch() async { 32 | // ignore: avoid_print 33 | log('watching $pathTo'); 34 | Directory(pathTo).watch().listen(_controller.add); 35 | 36 | await _startDispatcher(); 37 | } 38 | 39 | final _controller = StreamController(); 40 | late StreamSubscription subscriber; 41 | 42 | Future _startDispatcher() async { 43 | subscriber = _controller.stream.listen((event) async { 44 | // serialise the events 45 | // otherwise we end up trying to move multiple files 46 | // at once and that doesn't work. 47 | subscriber.pause(); 48 | onFileSystemEvent(event); 49 | subscriber.resume(); 50 | }); 51 | } 52 | 53 | /// Call this method to stop watching a folder. 54 | Future stop() async { 55 | await subscriber.cancel(); 56 | await _controller.close(); 57 | } 58 | 59 | void onFileSystemEvent(FileSystemEvent event) { 60 | if (event is FileSystemCreateEvent) { 61 | onCreateEvent(event); 62 | } else if (event is FileSystemModifyEvent) { 63 | onModifyEvent(event); 64 | } else if (event is FileSystemMoveEvent) { 65 | onMoveEvent(event); 66 | } else if (event is FileSystemDeleteEvent) { 67 | onDeleteEvent(event); 68 | } 69 | } 70 | 71 | void onCreateEvent(FileSystemCreateEvent event) { 72 | if (recursive && event.isDirectory) { 73 | Directory(event.path).watch().listen(_controller.add); 74 | } else { 75 | // if (lastDeleted != null) { 76 | // if (basename(event.path) == basename(lastDeleted)) { 77 | // // print(red('Move from: $lastDeleted to: ${event.path}')); 78 | // onChanged(event.path, FolderChangeType.move); 79 | // lastDeleted = null; 80 | // } else { 81 | _onChanged(event.path, FolderChangeAction.create); 82 | // } 83 | } 84 | } 85 | 86 | void onModifyEvent(FileSystemModifyEvent event) { 87 | _onChanged(event.path, FolderChangeAction.modify); 88 | } 89 | 90 | // String lastDeleted; 91 | 92 | void onDeleteEvent(FileSystemDeleteEvent event) { 93 | // // ignore: avoid_print 94 | // print('Delete: ${event.path}'); 95 | // if (!event.isDirectory) { 96 | // lastDeleted = event.path; 97 | // } 98 | 99 | _onChanged(event.path, FolderChangeAction.delete); 100 | } 101 | 102 | void onMoveEvent(FileSystemMoveEvent event) { 103 | _onChanged(event.path, FolderChangeAction.move); 104 | // var actioned = false; 105 | 106 | // var from = event.path; 107 | // var to = event.destination; 108 | 109 | // if (event.isDirectory) { 110 | // actioned = true; 111 | // await MoveCommand().importMoveDirectory(from: libRelative(from), 112 | //to: libRelative(to), alreadyMoved: true); 113 | // } else { 114 | // if (extension(from) == '.dart') { 115 | // /// we don't process the move if the 'to' isn't a dart file. 116 | // /// e.g. ignore a target of .dart.bak 117 | // if (isDirectory(to) || isFile(to) && extension(to) == '.dart') { 118 | // actioned = true; 119 | // await MoveCommand() 120 | // .moveFile(from: libRelative(from), to: libRelative(to), 121 | // fromDirectory: false, alreadyMoved: true); 122 | // } 123 | // } 124 | // } 125 | // if (actioned) { 126 | // print('Move: directory: ${event.isDirectory} ${event.path} 127 | //destination: ${event.destination}'); 128 | // } 129 | } 130 | 131 | void _onChanged(String path, FolderChangeAction type) { 132 | if (p.extension(path) == '.$extension') { 133 | onChanged(path, type); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: fsm2 2 | version: 3.2.1 3 | homepage: https://onepub.gitbook.io/fsm2/ 4 | documentation: https://onepub.gitbook.io/fsm2/ 5 | description: FSM2 provides an implementation of the core design aspects of the UML state diagrams allowing both declarative transitions and dynamic transitions along with Guard Conditions. 6 | repository: https://github.com/bsutton/fsm2 7 | environment: 8 | sdk: '>=3.2.0 <4.0.0' 9 | dependencies: 10 | args: ^2.4.2 11 | completer_ex: ^4.0.0 12 | dcli_core: ^4.0.0 13 | dcli_terminal: ^4.0.0 14 | logger: ^2.0.1 15 | meta: ^1.6.0 16 | path: ^1.7.0 17 | stacktrace_impl: ^2.0.1 18 | synchronized: ^3.0.0 19 | tree_iterator: ^3.0.0 20 | dev_dependencies: 21 | dcli: ^4.0.0 22 | lint_hard: ^4.0.0 23 | mockito: ^5.0.0 24 | test: ^1.0.0 25 | executables: 26 | fsm2: 27 | -------------------------------------------------------------------------------- /test/cleaning_air_test.dart: -------------------------------------------------------------------------------- 1 | @Timeout(Duration(minutes: 30)) 2 | library; 3 | 4 | import 'package:dcli/dcli.dart'; 5 | import 'package:dcli_core/dcli_core.dart' as core; 6 | import 'package:fsm2/fsm2.dart'; 7 | import 'package:fsm2/src/virtual_root.dart'; 8 | import 'package:mockito/mockito.dart'; 9 | import 'package:path/path.dart' hide equals; 10 | import 'package:test/test.dart'; 11 | 12 | import 'watcher.mocks.dart'; 13 | 14 | final onBadAir = OnBadAir(8); 15 | 16 | final onGoodAir = OnGoodAir(); 17 | 18 | final onFanRunning = OnFanRunning(); 19 | final onLampOn = OnLampOn(); 20 | 21 | void main() { 22 | test('fork', () async { 23 | final watcher = MockWatcher(); 24 | final machine = await createMachine(watcher); 25 | expect(await machine.isInState(), equals(true)); 26 | machine.applyEvent(onBadAir); 27 | await machine.complete; 28 | expect(await machine.isInState(), equals(true)); 29 | expect(await machine.isInState(), equals(true)); 30 | expect(await machine.isInState(), equals(true)); 31 | expect(await machine.isInState(), equals(true)); 32 | 33 | final som = machine.stateOfMind; 34 | final paths = som.activeLeafStates(); 35 | expect(paths.length, equals(3)); 36 | var types = som 37 | .pathForLeafState(HandleFan)! 38 | .path 39 | .map((sd) => sd.stateType) 40 | .toList(); 41 | expect(types, equals([HandleFan, CleanAir, MaintainAir, VirtualRoot])); 42 | types = som 43 | .pathForLeafState(HandleLamp)! 44 | .path 45 | .map((sd) => sd.stateType) 46 | .toList(); 47 | expect(types, equals([HandleLamp, CleanAir, MaintainAir, VirtualRoot])); 48 | types = som 49 | .pathForLeafState(WaitForGoodAir)! 50 | .path 51 | .map((sd) => sd.stateType) 52 | .toList(); 53 | expect(types, equals([WaitForGoodAir, CleanAir, MaintainAir, VirtualRoot])); 54 | // ignore: avoid_print 55 | print('done1'); 56 | // ignore: avoid_print 57 | print('done2'); 58 | }, skip: false); 59 | 60 | test('join', () async { 61 | final watcher = MockWatcher(); 62 | final machine = await createMachine(watcher); 63 | expect(await machine.isInState(), equals(true)); 64 | expect(await machine.isInState(), equals(true)); 65 | verify(watcher.onEnter(MonitorAir, machine.initialEvent)); 66 | 67 | /// trigger the fork 68 | machine.applyEvent(onBadAir); 69 | await machine.complete; 70 | expect(await machine.isInState(), equals(true)); 71 | expect(await machine.isInState(), equals(true)); 72 | expect(await machine.isInState(), equals(true)); 73 | expect(await machine.isInState(), equals(true)); 74 | 75 | verify(watcher.onExit(MonitorAir, onBadAir)).called(1); 76 | verify(watcher.onEnter(HandleFan, onBadAir)).called(1); 77 | verify(watcher.onEnter(HandleLamp, onBadAir)).called(1); 78 | verify(watcher.onEnter(WaitForGoodAir, onBadAir)).called(1); 79 | 80 | /// trigger the join 81 | machine.applyEvent(onFanRunning); 82 | await machine.complete; 83 | expect(await machine.isInState(), equals(true)); 84 | expect(await machine.isInState(), equals(true)); 85 | expect(await machine.isInState(), equals(true)); 86 | expect(await machine.isInState(), equals(true)); 87 | 88 | machine.applyEvent(onLampOn); 89 | await machine.complete; 90 | expect(await machine.isInState(), equals(true)); 91 | expect(await machine.isInState(), equals(true)); 92 | expect(await machine.isInState(), equals(true)); 93 | expect(await machine.isInState(), equals(true)); 94 | 95 | machine.applyEvent(onGoodAir); 96 | await machine.complete; 97 | expect(await machine.isInState(), equals(true)); 98 | 99 | verify(watcher.onExit(HandleFan, onFanRunning)).called(1); 100 | verify(watcher.onExit(HandleLamp, onLampOn)).called(1); 101 | verify(watcher.onExit(WaitForGoodAir, onGoodAir)).called(1); 102 | verify(watcher.onEnter(MonitorAir, onGoodAir)).called(1); 103 | 104 | /// check that no extraneous actions were performed. 105 | verifyNoMoreInteractions(watcher); 106 | 107 | final som = machine.stateOfMind; 108 | final paths = som.activeLeafStates(); 109 | expect(paths.length, equals(1)); 110 | final types = som 111 | .pathForLeafState(MonitorAir)! 112 | .path 113 | .map((sd) => sd.stateType) 114 | .toList(); 115 | 116 | expect(types, equals([MonitorAir, MaintainAir, VirtualRoot])); 117 | 118 | // ignore: avoid_print 119 | print(som); 120 | }, skip: false); 121 | 122 | test('export', () async { 123 | final watcher = MockWatcher(); 124 | await core.withTempDirAsync((tempDir) async { 125 | final pathTo = join(tempDir, 'cleaning_air_test.smcat'); 126 | (await createMachine(watcher)).export(pathTo); 127 | final lines = 128 | read(pathTo).toList().reduce((value, line) => value += '\n$line'); 129 | 130 | expect(lines, equals(_smcGraph)); 131 | }); 132 | }, skip: false); 133 | } 134 | 135 | Future createMachine(MockWatcher watcher) async { 136 | late StateMachine machine; 137 | 138 | // ignore: unused_local_variable 139 | var lightOn = false; 140 | // ignore: unused_local_variable 141 | var fanOn = false; 142 | 143 | // ignore: join_return_with_assignment 144 | machine = await StateMachine.create((g) => g 145 | ..initialState() 146 | ..state((b) => b 147 | ..state((b) => b 148 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 149 | ..onExit((s, e) async => watcher.onExit(s, e)) 150 | ..onFork( 151 | (b) => b 152 | ..target() 153 | ..target() 154 | ..target(), 155 | condition: (e) => e.quality < 10)) 156 | ..coregion((b) => b 157 | ..state((b) => b 158 | ..onEnter((s, e) async { 159 | fanOn = true; 160 | await watcher.onEnter(s, e); 161 | }) 162 | ..onExit((s, e) async { 163 | fanOn = false; 164 | await watcher.onExit(s, e); 165 | }) 166 | ..onJoin(condition: ((e) => e.speed > 5)) 167 | ..state((b) => b 168 | ..on(sideEffect: (e) async => lightOn = true)) 169 | ..state((b) => b 170 | ..onEnter((s, e) async => machine.applyEvent(OnFanRunning())) 171 | ..on( 172 | sideEffect: (e) async => lightOn = false))) 173 | ..state((b) => b 174 | ..onEnter((s, e) async { 175 | lightOn = true; 176 | await watcher.onEnter(s, e); 177 | }) 178 | ..onExit((s, e) async { 179 | lightOn = false; 180 | await watcher.onExit(s, e); 181 | }) 182 | ..onJoin() 183 | ..state((b) => b 184 | ..on(sideEffect: (e) async => lightOn = true)) 185 | ..state((b) => b 186 | ..onEnter((s, e) async => machine.applyEvent(OnLampOn())) 187 | ..on( 188 | sideEffect: (e) async => lightOn = false))) 189 | ..state((b) => b 190 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 191 | ..onExit((s, e) async => watcher.onExit(s, e)) 192 | ..onJoin()))) 193 | ..onTransition((s, e, st) {})); 194 | 195 | return machine; 196 | } 197 | 198 | var _smcGraph = ''' 199 | 200 | MaintainAir { 201 | MonitorAir { 202 | MonitorAir => ]MonitorAir.fork : OnBadAir; 203 | ]MonitorAir.fork => HandleFan ; 204 | ]MonitorAir.fork => HandleLamp ; 205 | ]MonitorAir.fork => WaitForGoodAir ; 206 | }, 207 | CleanAir.parallel [label="CleanAir"] { 208 | HandleFan { 209 | FanOff { 210 | FanOff => FanOn : OnTurnFanOn; 211 | }, 212 | FanOn { 213 | FanOn => FanOff : OnTurnFanOff; 214 | }; 215 | FanOff.initial => FanOff; 216 | }, 217 | HandleLamp { 218 | LampOff { 219 | LampOff => LampOn : OnTurnLampOn; 220 | }, 221 | LampOn { 222 | LampOn => LampOff : OnTurnLampOff; 223 | }; 224 | LampOff.initial => LampOff; 225 | }, 226 | WaitForGoodAir; 227 | HandleFan => ]MonitorAir.join : OnFanRunning; 228 | HandleLamp => ]MonitorAir.join : OnLampOn; 229 | WaitForGoodAir => ]MonitorAir.join : OnGoodAir; 230 | ]MonitorAir.join => MonitorAir ; 231 | }; 232 | MonitorAir.initial => MonitorAir; 233 | }; 234 | initial => MaintainAir : MaintainAir;'''; 235 | 236 | // ignore: unused_element 237 | var _graph = ''' 238 | stateDiagram-v2 239 | [*] --> MaintainAir 240 | state MaintainAir { 241 | [*] --> MonitorAir 242 | 243 | CleanAir --> MonitorAir : onGoodAir 244 | MonitorAir --> CleanAir : OnBadAir 245 | 246 | state CleanAir { 247 | [*] --> HandleEquipment 248 | HandleEquipment --> [*] 249 | state HandleEquipment { 250 | HandleLamp 251 | HandleFan 252 | WaitForGoodAir 253 | 254 | state BBB <> 255 | [*] --> BBB 256 | BBB --> HandleLamp 257 | BBB --> HandleFan 258 | BBB --> WaitForGoodAir 259 | 260 | state AAA <> 261 | HandleLamp --> AAA 262 | HandleFan --> AAA 263 | WaitForGoodAir --> AAA 264 | AAA --> [*] 265 | } 266 | } 267 | } 268 | '''; 269 | 270 | class MonitorAir extends State {} 271 | 272 | class CleanAir extends State {} 273 | 274 | class HandleFan extends State {} 275 | 276 | class FanOn extends State {} 277 | 278 | class FanOff extends State {} 279 | 280 | class HandleLamp extends State {} 281 | 282 | class LampOff extends State {} 283 | 284 | class LampOn extends State {} 285 | 286 | class WaitForGoodAir extends State {} 287 | 288 | class MaintainAir extends State {} 289 | 290 | class OnBadAir extends Event { 291 | OnBadAir(this.quality); 292 | int quality; 293 | } 294 | 295 | class OnTurnLampOff extends Event {} 296 | 297 | class OnTurnLampOn extends Event {} 298 | 299 | class OnLampOn extends Event {} 300 | 301 | class OnTurnFanOff extends Event {} 302 | 303 | class OnTurnFanOn extends Event {} 304 | 305 | class OnFanRunning extends Event { 306 | int get speed => 6; 307 | } 308 | 309 | class OnGoodAir extends Event {} 310 | -------------------------------------------------------------------------------- /test/fork_join_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fsm2/fsm2.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | late StateMachine fsm; 6 | 7 | /// https://github.com/onepub-dev/fsm2/issues/18 8 | test('test fork join with sideEffect', () async { 9 | fsm = await StateMachine.create( 10 | (g) => g 11 | ..initialState() 12 | ..state((b) => b.onFork( 13 | (b) => b 14 | ..target() 15 | ..target(), 16 | sideEffect: (e) async => fsm.applyEvent(ResolveStateA()))) 17 | ..coregion((b) => b 18 | ..state((b) => b.onJoin()) 19 | ..state((b) => b.onJoin())) 20 | ..state((b) => b.on()), 21 | production: true) 22 | // go into the coregion 23 | ..applyEvent(Fork()); 24 | await fsm.complete; 25 | // we are now in the coregion 26 | expect(await fsm.isInState(), equals(true), 27 | reason: fsm.stateOfMind.toString()); 28 | // StateA is already resolved by the sideEffect. 29 | // StateB is not resolved yet. 30 | fsm.applyEvent(ResolveStateB()); 31 | await fsm.complete; 32 | // we are now at the end state. 33 | expect(await fsm.isInState(), equals(true), 34 | reason: fsm.stateOfMind.toString()); 35 | // go back to the start. 36 | fsm.applyEvent(GoBackToStart()); 37 | await fsm.complete; 38 | expect(await fsm.isInState(), equals(true), 39 | reason: fsm.stateOfMind.toString()); 40 | // go into the coregion again. 41 | fsm.applyEvent(Fork()); 42 | await fsm.complete; 43 | // if the coregion does not reset the join events, we will fail here. 44 | expect(await fsm.isInState(), equals(true), 45 | reason: fsm.stateOfMind.toString()); 46 | fsm.applyEvent(ResolveStateB()); 47 | await fsm.complete; 48 | expect(await fsm.isInState(), equals(true), 49 | reason: fsm.stateOfMind.toString()); 50 | }); 51 | } 52 | 53 | class CanFork extends State {} 54 | 55 | class Fork extends Event {} 56 | 57 | class Coregion extends State {} 58 | 59 | class StateA extends State {} 60 | 61 | class ResolveStateA extends Event {} 62 | 63 | class StateB extends State {} 64 | 65 | class ResolveStateB extends Event {} 66 | 67 | class EndState extends State {} 68 | 69 | class GoBackToStart extends Event {} 70 | -------------------------------------------------------------------------------- /test/fsm_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dcli/dcli.dart'; 2 | import 'package:dcli_core/dcli_core.dart' as core; 3 | import 'package:fsm2/fsm2.dart'; 4 | import 'package:mockito/mockito.dart'; 5 | import 'package:path/path.dart' hide equals; 6 | import 'package:test/test.dart'; 7 | 8 | import 'watcher.mocks.dart'; 9 | 10 | class Solid implements State {} 11 | 12 | class Liquid implements State {} 13 | 14 | class Gas implements State {} 15 | 16 | class OnMelted implements Event {} 17 | 18 | class OnFroze implements Event {} 19 | 20 | class OnVaporized implements Event {} 21 | 22 | class OnCondensed implements Event {} 23 | 24 | void main() { 25 | late MockWatcher watcher; 26 | 27 | setUp(() { 28 | watcher = MockWatcher(); 29 | }); 30 | 31 | test('export', () async { 32 | await core.withTempDirAsync((tempDir) async { 33 | final pathTo = join(tempDir, 'fsm_test.smcat'); 34 | await _createMachine(watcher) 35 | ..analyse() 36 | ..export(pathTo); 37 | 38 | const graph = ''' 39 | 40 | Solid { 41 | Solid => Liquid : OnMelted; 42 | }, 43 | Liquid { 44 | Liquid => Solid : OnFroze; 45 | Liquid => Gas : OnVaporized; 46 | }, 47 | Gas { 48 | Gas => Liquid : OnCondensed; 49 | }; 50 | initial => Solid : Solid;'''; 51 | 52 | final lines = 53 | read(pathTo).toList().reduce((value, line) => value += '\n$line'); 54 | 55 | expect(lines, equals(graph)); 56 | }); 57 | }); 58 | 59 | test('initial State should be solid', () async { 60 | final machine = await _createMachine(watcher); 61 | 62 | expect(await machine.isInState(), equals(true)); 63 | }); 64 | 65 | test('State Solid with OnMelted should transition to Liquid and log', 66 | () async { 67 | final machine = await _createMachine(watcher); 68 | 69 | machine.applyEvent(OnMelted()); 70 | await machine.complete; 71 | expect(await machine.isInState(), equals(true)); 72 | verifyInOrder([watcher.log(onMeltedMessage)]); 73 | }); 74 | 75 | test('condition', () async { 76 | final machine = await StateMachine.create((b) => b 77 | ..state((b) => b.on(condition: (e) => false)) 78 | ..state((b) {})); 79 | machine.applyEvent(OnMelted()); 80 | await machine.complete; 81 | }); 82 | 83 | test('State Liquid with OnFroze should transition to Solid and log', 84 | () async { 85 | final machine = await _createMachine(watcher) 86 | ..applyEvent(OnFroze()); 87 | await machine.complete; 88 | expect(await machine.isInState(), equals(true)); 89 | verifyInOrder([watcher.log(onFrozenMessage)]); 90 | }); 91 | 92 | test('State Liquid with OnVaporized should transition to Gas and log', 93 | () async { 94 | final machine = await _createMachine(watcher); 95 | machine.applyEvent(OnVaporized()); 96 | await machine.complete; 97 | expect(await machine.isInState(), equals(true)); 98 | verifyInOrder([watcher.log(onVaporizedMessage)]); 99 | }); 100 | 101 | test('calls onEnter, but not onExit', () async { 102 | final watcher = MockWatcher(); 103 | final onMelted = OnMelted(); 104 | final machine = await _createMachine(watcher); 105 | machine.applyEvent(onMelted); 106 | await machine.complete; 107 | expect(await machine.isInState(), equals(true)); 108 | verify(watcher.onEnter(Liquid, onMelted)); 109 | verifyNever(watcher.onExit(Liquid, onMelted)); 110 | }); 111 | 112 | test('calls onExit', () async { 113 | final watcher = MockWatcher(); 114 | final onMelted = OnMelted(); 115 | final onVaporized = OnVaporized(); 116 | 117 | final machine = await _createMachine(watcher) 118 | ..applyEvent(onMelted) 119 | ..applyEvent(onVaporized); 120 | await machine.complete; 121 | verify(watcher.onExit(Liquid, onVaporized)); 122 | }); 123 | 124 | test('onEntry for initial state', () async { 125 | final machine = await _createMachine(watcher); 126 | await machine.complete; 127 | verify(watcher.onEnter(Solid, machine.initialEvent)); 128 | }); 129 | } 130 | 131 | Future _createMachine( 132 | MockWatcher watcher, 133 | ) async => 134 | StateMachine.create( 135 | (g) => g 136 | ..initialState() 137 | ..state((b) => b 138 | ..on( 139 | sideEffect: (e) async => watcher.log(onMeltedMessage)) 140 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 141 | ..onExit((s, e) async => watcher.onExit(s, e))) 142 | ..state((b) => b 143 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 144 | ..onExit((s, e) async => watcher.onExit(s, e)) 145 | ..on( 146 | sideEffect: (e) async => watcher.log(onFrozenMessage)) 147 | ..on( 148 | sideEffect: (e) async => watcher.log(onVaporizedMessage))) 149 | ..state((b) => b 150 | ..on( 151 | sideEffect: (e) async => watcher.log(onCondensedMessage))) 152 | // ignore: avoid_print 153 | ..onTransition((from, event, to) => print('$from $event $to ')), 154 | production: true); 155 | 156 | const onMeltedMessage = 'onMeltedMessage'; 157 | const onFrozenMessage = 'onFrozenMessage'; 158 | const onVaporizedMessage = 'onVaporizedMessage'; 159 | const onCondensedMessage = 'onCondensedMessage'; 160 | -------------------------------------------------------------------------------- /test/initial_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:fsm2/fsm2.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import 'watcher.mocks.dart'; 5 | 6 | void main() { 7 | test('initial event', () async { 8 | final watcher = MockWatcher(); 9 | await createMachine(watcher); 10 | }, skip: false); 11 | } 12 | 13 | class TypingFormula implements State {} 14 | 15 | class Typing implements State {} 16 | 17 | // class Idle implements State {} 18 | 19 | Future createMachine(MockWatcher watcher) async { 20 | final machine = await StateMachine.create((g) => g 21 | ..initialState() 22 | ..state((b) => b 23 | ..onFork( 24 | (b) => b 25 | ..target() 26 | ..target(), 27 | condition: (e) => true, // Some logic to check if it is a formula 28 | ) 29 | ..coregion((b) => b 30 | ..onJoin( 31 | condition: (e) => false, // Some logic to check if it is a formula 32 | ) 33 | 34 | // Autocomplete state machine 35 | ..state((b) => b 36 | // Autocomplete events 37 | ..on() 38 | ..on() 39 | ..on() 40 | // Autocomplete states 41 | ..state((b) => b) 42 | ..state((b) => b) 43 | ..state((b) => b)) 44 | // Point mode state-mahcine 45 | ..state((b) => b 46 | // Point mode events 47 | ..on() 48 | ..on() 49 | ..on() 50 | ..on() 51 | // Point mode states 52 | ..state((b) => b) 53 | ..state((b) => b) 54 | ..state((b) => b) 55 | ..state((b) => b)))) 56 | // ignore: avoid_print 57 | ..onTransition((from, e, to) => print( 58 | '''Received Event $e in State ${from!.stateType} transitioning to State ${to!.stateType}'''))); 59 | return machine; 60 | } 61 | 62 | class PointReference extends State {} 63 | 64 | class PointUnavailable extends State {} 65 | 66 | class OnPointInvalidSelection extends Event {} 67 | 68 | class PointDisabled extends State {} 69 | 70 | class OnDisablePoint extends Event {} 71 | 72 | class PointSlot extends State {} 73 | 74 | class OnSlotSelection extends Event {} 75 | 76 | // class PointReferencene extends State {} 77 | 78 | class OnReferenceSelection extends Event {} 79 | 80 | class OnFunctionSelection extends Event {} 81 | 82 | class OnAutocompleteInvalidSelection extends Event {} 83 | 84 | class AutocompleteUnavailable extends State {} 85 | 86 | class AutocompleteDetails extends State {} 87 | 88 | class OnCandidateSelection extends Event {} 89 | 90 | class AutocompleteList extends State {} 91 | 92 | class Point extends State {} 93 | 94 | class Autocomplete extends State {} 95 | 96 | // class OnBlur extends Event {} 97 | 98 | // class OnFocus extends Event {} 99 | 100 | class OnValueChange implements Event { 101 | // ignore: unreachable_from_main 102 | const OnValueChange({required this.isFormula}); 103 | // ignore: unreachable_from_main 104 | final bool isFormula; 105 | } 106 | -------------------------------------------------------------------------------- /test/late_join.dart: -------------------------------------------------------------------------------- 1 | import 'package:fsm2/fsm2.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import 'watcher.mocks.dart'; 5 | 6 | void main() { 7 | test('late join', () async { 8 | final watcher = MockWatcher(); 9 | final machine = await createMachine(watcher); 10 | 11 | expect(machine.isInState(), isTrue); 12 | 13 | machine 14 | ..applyEvent(OnFocus()) 15 | ..applyEvent(const OnValueChange(isFormula: true)); 16 | await machine.complete; 17 | 18 | expect(machine.isInState(), isTrue); 19 | 20 | machine.applyEvent(const OnValueChange(isFormula: false)); 21 | await machine.complete; 22 | 23 | expect(machine.isInState(), isTrue); 24 | expect(machine.isInState(), isFalse); 25 | }, skip: false); 26 | } 27 | 28 | class TypingFormula implements State {} 29 | 30 | class Typing implements State {} 31 | 32 | class Idle implements State {} 33 | 34 | Future createMachine(MockWatcher watcher) async { 35 | final machine = await StateMachine.create((g) => g 36 | ..initialState() 37 | ..state((b) => b..on()) 38 | ..state((b) => b 39 | ..on() 40 | ..onFork( 41 | (b) => b 42 | ..target() 43 | ..target(), 44 | condition: (e) => e.isFormula) 45 | ..coregion((b) => b 46 | ..onJoin(condition: (e) => !e.isFormula) 47 | 48 | // Autocomplete state machine 49 | ..state((b) => b) 50 | // Point mode state-mahcine 51 | ..state((b) => b))) 52 | // ignore: avoid_print 53 | ..onTransition((from, e, to) => print( 54 | '''Received Event $e in State ${from!.stateType} transitioning to State ${to!.stateType}'''))); 55 | 56 | return machine; 57 | } 58 | 59 | class Point extends State {} 60 | 61 | class Autocomplete extends State {} 62 | 63 | class OnBlur extends Event {} 64 | 65 | class OnFocus extends Event {} 66 | 67 | class OnValueChange implements Event { 68 | const OnValueChange({required this.isFormula}); 69 | final bool isFormula; 70 | } 71 | -------------------------------------------------------------------------------- /test/life_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dcli/dcli.dart'; 2 | import 'package:dcli_core/dcli_core.dart' as core; 3 | import 'package:fsm2/fsm2.dart'; 4 | import 'package:path/path.dart' hide equals; 5 | import 'package:test/test.dart'; 6 | 7 | late StateMachine machine; 8 | void main() { 9 | test('export', () async { 10 | await core.withTempDirAsync((tempDir) async { 11 | final pathToTest = join(tempDir, 'life_test.smcat'); 12 | 13 | await _createMachine(); 14 | machine 15 | ..analyse() 16 | ..export(pathToTest); 17 | 18 | const graph = ''' 19 | 20 | Twinkle { 21 | Twinkle => Gestation : Conception; 22 | }, 23 | Gestation { 24 | Gestation => Baby : Born; 25 | }, 26 | Baby { 27 | Baby => Teenager : Puberty; 28 | }, 29 | Teenager { 30 | Teenager => Adult : GetDrunk; 31 | }, 32 | Adult { 33 | Adult => Dead : Death; 34 | }, 35 | Dead; 36 | initial => Twinkle : Twinkle;'''; 37 | 38 | final lines = 39 | read(pathToTest).toList().reduce((value, line) => value += '\n$line'); 40 | 41 | expect(lines, equals(graph)); 42 | }); 43 | }); 44 | } 45 | 46 | Future _createMachine() async { 47 | machine = await StateMachine.create((g) => g 48 | ..initialState() 49 | ..state((b) => b..on()) 50 | ..state((b) => b..on()) 51 | ..state((b) => b..on()) 52 | ..state((b) => b..on()) 53 | ..state((b) => b..on()) 54 | ..state((b) {})); 55 | } 56 | 57 | class Twinkle implements State {} 58 | 59 | class Gestation implements State {} 60 | 61 | class Baby implements State {} 62 | 63 | class Teenager implements State {} 64 | 65 | class Adult implements State {} 66 | 67 | // class Taxes implements State {} 68 | 69 | class Dead implements State {} 70 | 71 | // class Adulthood implements State {} 72 | 73 | class Conception implements Event {} 74 | 75 | class Born implements Event {} 76 | 77 | class Puberty implements Event {} 78 | 79 | class GetDrunk implements Event {} 80 | 81 | class Death implements Event {} 82 | -------------------------------------------------------------------------------- /test/nested_test.dart: -------------------------------------------------------------------------------- 1 | @Timeout(Duration(minutes: 10)) 2 | library; 3 | 4 | import 'package:dcli/dcli.dart'; 5 | import 'package:dcli_core/dcli_core.dart' as core; 6 | import 'package:fsm2/fsm2.dart'; 7 | import 'package:mockito/mockito.dart'; 8 | import 'package:path/path.dart' hide equals; 9 | import 'package:test/test.dart'; 10 | 11 | import 'watcher.mocks.dart'; 12 | 13 | void main() { 14 | late MockWatcher watcher; 15 | late Human human; 16 | 17 | setUp(() { 18 | watcher = MockWatcher(); 19 | human = Human(); 20 | }); 21 | 22 | test('initial State should be Alive and Young', () async { 23 | final machine = await _createMachine(watcher, human); 24 | await machine.complete; 25 | 26 | expect(await machine.isInState(), equals(true)); 27 | expect(await machine.isInState(), equals(true)); 28 | }); 29 | 30 | test('traverse tree', () async { 31 | final machine = await _createMachine(watcher, human); 32 | final states = {}; 33 | final transitions = []; 34 | await machine.traverseTree((sd, tds) { 35 | transitions.addAll(tds); 36 | states[sd] = sd; 37 | }, includeInherited: false); 38 | expect(states.length, equals(14)); 39 | expect(transitions.length, equals(8)); 40 | expect(await machine.isInState(), equals(true)); 41 | }); 42 | 43 | test('Test no op transition', () async { 44 | final machine = await _createMachine(watcher, human); 45 | machine.applyEvent(OnBirthday()); 46 | await machine.complete; 47 | expect(await machine.isInState(), equals(true)); 48 | expect(await machine.isInState(), equals(true)); 49 | verifyInOrder([watcher.log('OnBirthday')]); 50 | }); 51 | 52 | test('Test simple transition', () async { 53 | final machine = await _createMachine(watcher, human); 54 | for (var i = 0; i < 19; i++) { 55 | machine.applyEvent(OnBirthday()); 56 | } 57 | await machine.complete; 58 | expect(await machine.isInState(), equals(true)); 59 | expect(await machine.isInState(), equals(true)); 60 | verifyInOrder([watcher.log('OnBirthday')]); 61 | }); 62 | 63 | test('Test multiple transitions', () async { 64 | final machine = await _createMachine(watcher, human); 65 | machine.applyEvent(OnBirthday()); 66 | await machine.complete; 67 | expect(await machine.isInState(), equals(true)); 68 | machine.applyEvent(OnDeath()); 69 | await machine.complete; 70 | expect(await machine.isInState(), equals(true)); 71 | 72 | verifyInOrder([watcher.log('OnBirthday')]); 73 | }); 74 | 75 | test('Invalid transition', () async { 76 | final watcher = MockWatcher(); 77 | final machine = await _createMachine(watcher, human); 78 | try { 79 | machine.applyEvent(OnBirthday()); 80 | await machine.complete; 81 | fail('InvalidTransitionException not thrown'); 82 | // ignore: avoid_catches_without_on_clauses 83 | } catch (e) { 84 | expect(e, isA()); 85 | } 86 | }); 87 | 88 | test('Transition to child state', () async { 89 | final machine = await _createMachine(watcher, human); 90 | machine.applyEvent(OnDeath()); 91 | await machine.complete; 92 | expect(await machine.isInState(), equals(true)); 93 | machine.applyEvent(OnJudged(Judgement.morallyAmbiguous)); 94 | await machine.complete; 95 | expect(await machine.isInState(), equals(true)); 96 | expect(await machine.isInState(), equals(true)); 97 | expect(await machine.isInState(), equals(true)); 98 | 99 | /// We should be MiddleAged but Alive should not be a separate path. 100 | expect(machine.stateOfMind.activeLeafStates().length, 1); 101 | }); 102 | 103 | test('Unreachable State.', () async { 104 | final machine = await StateMachine.create( 105 | (g) => g 106 | ..initialState() 107 | ..state((b) => b..state((b) {})), 108 | production: true); 109 | 110 | expect(machine.analyse(), equals(false)); 111 | }); 112 | 113 | test('Transition in nested state.', () async { 114 | final watcher = MockWatcher(); 115 | final machine = await _createMachine(watcher, human); 116 | 117 | machine.applyEvent(OnJudged(Judgement.good)); 118 | await machine.complete; 119 | 120 | /// should be in both states. 121 | expect(await machine.isInState(), equals(true)); 122 | expect(await machine.isInState(), equals(true)); 123 | }); 124 | 125 | test('calls onExit/onEnter', () async { 126 | final watcher = MockWatcher(); 127 | final machine = await _createMachine(watcher, human); 128 | 129 | /// age this boy until they are middle aged. 130 | final onBirthday = OnBirthday(); 131 | for (var i = 0; i < 19; i++) { 132 | machine.applyEvent(onBirthday); 133 | } 134 | await machine.complete; 135 | verify(await watcher.onExit(Young, onBirthday)); 136 | verify(await watcher.onEnter(MiddleAged, onBirthday)); 137 | }); 138 | 139 | test('Test onExit/onEnter for nested state change', () async { 140 | final watcher = MockWatcher(); 141 | final machine = await _createMachine(watcher, human); 142 | 143 | /// age this boy until they are middle aged. 144 | final onDeath = OnDeath(); 145 | machine.applyEvent(onDeath); 146 | await machine.complete; 147 | verify(await watcher.onExit(Young, onDeath)); 148 | verify(await watcher.onExit(Alive, onDeath)); 149 | verify(await watcher.onEnter(Dead, onDeath)); 150 | verify(await watcher.onEnter(Purgatory, onDeath)); 151 | }); 152 | 153 | test('Export', () async { 154 | await core.withTempDirAsync((tempDir) async { 155 | final pathToSmCat = join(tempDir, 'nested_test.smcat'); 156 | final machine = await _createMachine(watcher, human); 157 | machine 158 | ..analyse() 159 | ..export(pathToSmCat); 160 | 161 | final lines = read(pathToSmCat) 162 | .toList() 163 | .reduce((value, line) => value += '\n$line'); 164 | 165 | expect(lines, equals(_graph)); 166 | }); 167 | }); 168 | } 169 | 170 | Future _createMachine( 171 | MockWatcher watcher, 172 | Human human, 173 | ) async { 174 | final machine = StateMachine.create((g) => g 175 | ..initialState() 176 | ..state((b) => b 177 | ..initialState() 178 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 179 | ..onExit((s, e) async => watcher.onExit(s, e)) 180 | ..on( 181 | condition: (e) => human.age < 18, 182 | sideEffect: (e) async => human.age++) 183 | ..on( 184 | condition: (e) => human.age < 50, 185 | sideEffect: (e) async => human.age++) 186 | ..on( 187 | condition: (e) => human.age < 80, 188 | sideEffect: (e) async => human.age++) 189 | ..on() 190 | ..state((b) => b..onExit((s, e) async => watcher.onExit(s, e))) 191 | ..state( 192 | (b) => b..onEnter((s, e) async => watcher.onEnter(s, e))) 193 | ..state((b) => b)) 194 | ..state((b) => b 195 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 196 | 197 | /// ..initialState() 198 | ..state((b) => b 199 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 200 | ..on( 201 | condition: (e) => e.judgement == Judgement.good) 202 | ..on(condition: (e) => e.judgement == Judgement.bad) 203 | ..on( 204 | condition: (e) => e.judgement == Judgement.ugly) 205 | ..on( 206 | condition: (e) => e.judgement == Judgement.morallyAmbiguous) 207 | ..state((_) {})) 208 | ..state((b) => b..state((b) => b)) 209 | ..state((b) => b 210 | ..state((b) => b 211 | ..state((b) {}) 212 | ..state((b) => b)))) 213 | ..onTransition((from, event, to) => watcher.log('${event.runtimeType}'))); 214 | return machine; 215 | } 216 | 217 | class Human { 218 | int age = 0; 219 | } 220 | 221 | class Alive extends State {} 222 | 223 | class Dead extends State {} 224 | 225 | class Young extends Alive {} 226 | 227 | class MiddleAged extends State {} 228 | 229 | class Old extends State {} 230 | 231 | class Purgatory extends State {} 232 | 233 | class Matrix extends State {} 234 | 235 | class InHeaven extends State {} 236 | 237 | class InHell extends State {} 238 | 239 | class Christian extends State {} 240 | 241 | class Buddhist extends State {} 242 | 243 | class Catholic extends State {} 244 | 245 | class SalvationArmy extends State {} 246 | 247 | /// events 248 | 249 | class OnBirthday extends Event {} 250 | 251 | class OnDeath extends Event {} 252 | 253 | enum Judgement { good, bad, ugly, morallyAmbiguous } 254 | 255 | class OnJudged implements Event { 256 | OnJudged(this.judgement); 257 | Judgement judgement; 258 | } 259 | 260 | var _graph = ''' 261 | 262 | Alive { 263 | Young, 264 | MiddleAged, 265 | Old; 266 | Young.initial => Young; 267 | Alive => Young : OnBirthday; 268 | Alive => MiddleAged : OnBirthday; 269 | Alive => Old : OnBirthday; 270 | Alive => Purgatory : OnDeath; 271 | }, 272 | Dead { 273 | Purgatory { 274 | Matrix; 275 | Matrix.initial => Matrix; 276 | Purgatory => Buddhist : OnJudged; 277 | Purgatory => Catholic : OnJudged; 278 | Purgatory => SalvationArmy : OnJudged; 279 | Purgatory => Matrix : OnJudged; 280 | }, 281 | InHeaven { 282 | Buddhist; 283 | Buddhist.initial => Buddhist; 284 | }, 285 | InHell { 286 | Christian { 287 | SalvationArmy, 288 | Catholic; 289 | SalvationArmy.initial => SalvationArmy; 290 | }; 291 | Christian.initial => Christian; 292 | }; 293 | Purgatory.initial => Purgatory; 294 | }; 295 | initial => Alive : Alive;'''; 296 | -------------------------------------------------------------------------------- /test/no_double_dispatch.dart: -------------------------------------------------------------------------------- 1 | import 'package:fsm2/fsm2.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | late StateMachine fsm; 6 | 7 | // test for https://github.com/onepub-dev/fsm2/issues/23 8 | test('test no double dispatch', () async { 9 | fsm = await StateMachine.create( 10 | (g) => g 11 | ..initialState() 12 | ..state((b) => b 13 | ..on() 14 | ..state((b) => b) 15 | ..state((b) => b 16 | ..onEnter((s, e) async { 17 | fsm.applyEvent(TestEvent2()); 18 | // this delay ensure that the dispatch for TestEvent2 would 19 | // fire which before the fix was applied would re-apply 20 | // TestEvent which is wrong. 21 | await Future.delayed( 22 | const Duration(milliseconds: 100), () => true); 23 | }) 24 | ..on())), 25 | production: true) 26 | // trigger the transition from StartA to Loop (and the onEnter for Loop). 27 | ..applyEvent(TestEvent()); 28 | 29 | await fsm.complete; 30 | }); 31 | } 32 | 33 | class Start extends State {} 34 | 35 | class StartA extends State {} 36 | 37 | class Loop extends State {} 38 | 39 | class TestEvent extends Event {} 40 | 41 | class TestEvent2 extends Event {} 42 | -------------------------------------------------------------------------------- /test/page_break_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dcli/dcli.dart'; 2 | import 'package:dcli_core/dcli_core.dart' as core; 3 | import 'package:fsm2/fsm2.dart'; 4 | import 'package:path/path.dart' hide equals; 5 | import 'package:test/test.dart'; 6 | 7 | import 'watcher.mocks.dart'; 8 | 9 | // class Watcher extends Mock { 10 | // Future onEnter(Type fromState, Event? event); 11 | // Future onExit(Type toState, Event? event); 12 | 13 | // void log(String message); 14 | // } 15 | 16 | void main() { 17 | late MockWatcher watcher; 18 | late Human human; 19 | 20 | setUp(() { 21 | watcher = MockWatcher(); 22 | human = Human(); 23 | }); 24 | 25 | test('Export', () async { 26 | await core.withTempDirAsync((tempDir) async { 27 | final pathTo = join(tempDir, 'page_break_test.smcat'); 28 | final machine = await _createMachine(watcher, human); 29 | machine.analyse(); 30 | // ignore: unused_local_variable 31 | final pages = machine.export(pathTo); 32 | 33 | var pageNo = 0; 34 | for (final page in pages.pages) { 35 | final lines = read(page.path) 36 | .toList() 37 | .reduce((value, line) => value += '\n$line'); 38 | expect(lines, equals(_graphs[pageNo++])); 39 | } 40 | }); 41 | }); 42 | } 43 | 44 | Future _createMachine( 45 | MockWatcher watcher, 46 | Human human, 47 | ) async { 48 | final machine = StateMachine.create((g) => g 49 | ..initialState() 50 | ..state((b) => b 51 | ..initialState() 52 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 53 | ..onExit((s, e) async => watcher.onExit(s, e)) 54 | ..on( 55 | condition: (e) => human.age < 18, 56 | sideEffect: (e) async => human.age++) 57 | ..on( 58 | condition: (e) => human.age < 50, 59 | sideEffect: (e) async => human.age++) 60 | ..on( 61 | condition: (e) => human.age < 80, 62 | sideEffect: (e) async => human.age++) 63 | ..on() 64 | ..state((b) => b..onExit((s, e) async => watcher.onExit(s, e))) 65 | ..state( 66 | (b) => b..onEnter((s, e) async => watcher.onEnter(s, e))) 67 | ..state((b) => b)) 68 | ..state((b) => b 69 | ..pageBreak 70 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 71 | 72 | /// ..initialState() 73 | ..state((b) => b 74 | ..pageBreak 75 | ..onEnter((s, e) async => watcher.onEnter(s, e)) 76 | ..on( 77 | condition: (e) => e.judgement == Judgement.good, 78 | conditionLabel: 'good') 79 | ..on( 80 | condition: (e) => e.judgement == Judgement.bad, 81 | conditionLabel: 'bad') 82 | ..on( 83 | condition: (e) => e.judgement == Judgement.ugly, 84 | conditionLabel: 'ugly')) 85 | ..state((b) => b..state((b) => b)) 86 | ..state((b) => b 87 | ..state((b) => b 88 | ..state((b) {}) 89 | ..state((b) => b)))) 90 | ..onTransition((from, event, to) => watcher.log('${event.runtimeType}'))); 91 | return machine; 92 | } 93 | 94 | class Human { 95 | int age = 0; 96 | } 97 | 98 | class Alive implements State {} 99 | 100 | class Dead implements State {} 101 | 102 | class Young extends Alive {} 103 | 104 | class MiddleAged implements State {} 105 | 106 | class Old implements State {} 107 | 108 | class Purgatory implements State {} 109 | 110 | class InHeaven implements State {} 111 | 112 | class InHell implements State {} 113 | 114 | class Christian implements State {} 115 | 116 | class Buddhist implements State {} 117 | 118 | class Catholic implements State {} 119 | 120 | class SalvationArmy implements State {} 121 | 122 | /// events 123 | 124 | class OnBirthday implements Event {} 125 | 126 | class OnDeath implements Event {} 127 | 128 | enum Judgement { good, bad, ugly } 129 | 130 | class OnJudged implements Event { 131 | // ignore: unreachable_from_main 132 | OnJudged(this.judgement); 133 | Judgement judgement; 134 | } 135 | 136 | // ignore: unused_element 137 | var _graphs = [ 138 | /// page 1 139 | ''' 140 | 141 | Alive { 142 | Young, 143 | MiddleAged, 144 | Old; 145 | Young.initial => Young; 146 | Alive => Young : OnBirthday; 147 | Alive => MiddleAged : OnBirthday; 148 | Alive => Old : OnBirthday; 149 | Alive => Dead : OnDeath; 150 | }, 151 | Dead [color="blue"]; 152 | initial => Alive : Alive;''', 153 | 154 | /// page 2 155 | ''' 156 | 157 | Dead { 158 | Purgatory [color="blue"], 159 | InHeaven { 160 | Buddhist; 161 | Buddhist.initial => Buddhist; 162 | }, 163 | InHell { 164 | Christian { 165 | SalvationArmy, 166 | Catholic; 167 | SalvationArmy.initial => SalvationArmy; 168 | }; 169 | Christian.initial => Christian; 170 | }; 171 | Purgatory.initial => Purgatory; 172 | ]Purgatory.initial => Purgatory : OnDeath; 173 | };''', 174 | 175 | /// page 3 176 | ''' 177 | 178 | Purgatory { 179 | Purgatory => Buddhist : OnJudged [good]; 180 | Purgatory => Catholic : OnJudged [bad]; 181 | Purgatory => SalvationArmy : OnJudged [ugly]; 182 | };''' 183 | ]; 184 | -------------------------------------------------------------------------------- /test/registration_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dcli_core/dcli_core.dart' as core; 2 | import 'package:fsm2/fsm2.dart'; 3 | import 'package:path/path.dart' hide equals; 4 | import 'package:test/test.dart'; 5 | 6 | // enum RWStates 7 | // { 8 | // AppLaunched, 9 | // RegistrationRequired 10 | // } 11 | 12 | // /// registration wizard events 13 | 14 | void main() { 15 | test('analyse', () async { 16 | final fsm = await createMachine(); 17 | expect(fsm.analyse(), equals(true)); 18 | }); 19 | 20 | test('Export', () async { 21 | await core.withTempDirAsync((tempDir) async { 22 | final pathTo = join(tempDir, 'registration.scmcat'); 23 | final fsm = await createMachine(); 24 | // var exports = 25 | fsm.export(pathTo); 26 | 27 | // for (var page in exports.pages) { 28 | // var lines = read(page.path).toList().reduce((value, line) => value += '\n' + line); 29 | // expect(lines, equals(graph)); 30 | // } 31 | }); 32 | }); 33 | } 34 | 35 | Future createMachine() async { 36 | final stateMachine = await StateMachine.create( 37 | (g) => g 38 | ..initialState() 39 | 40 | /// AppLaunched 41 | ..state((builder) => builder 42 | ..onEnter((s, e) async => fetchUserStatus()) 43 | ..on( 44 | sideEffect: (e) async => RegistrationWizard.restart()) 45 | // ignore: avoid_print 46 | ..on( 47 | // ignore: avoid_print 48 | sideEffect: (e) async => print('hi')) 49 | ..on()) 50 | 51 | /// Registered is normally the final state we are looking for 52 | /// but there a few circumstance where we force the user to register. 53 | ..state((builder) => builder 54 | ..on( 55 | sideEffect: (e) async => RegistrationWizard.restart) 56 | // ..pageBreak 57 | ) 58 | 59 | ///RegistrationRequired 60 | ..coregion(registrationRequired) 61 | ..state((b) {}) 62 | ..onTransition(log), 63 | production: true); 64 | 65 | return stateMachine; 66 | } 67 | 68 | class OnForceRegistration implements Event {} 69 | 70 | void registrationRequired(StateBuilder builder) { 71 | builder 72 | ..on( 73 | condition: (e) => e.type == RegistrationType.acceptInvite, 74 | conditionLabel: 'AcceptInvite') 75 | ..on( 76 | condition: (e) => e.type == RegistrationType.newOrganisation, 77 | conditionLabel: 'New Organisation') 78 | ..on( 79 | condition: (e) => e.type == RegistrationType.recoverAccount, 80 | conditionLabel: 'Recover Account') 81 | 82 | /// HasRegistrationType 83 | ..state(registrationTypeSelected) 84 | // ..pageBreak 85 | ..state(regionPage) 86 | ..state(namePage) 87 | ..state(emailPage) 88 | ..state(trialPhonePage) 89 | 90 | /// for missing transitions 91 | ..on(condition: (e) => true) 92 | ..on(condition: (e) => true) 93 | ..on(condition: (e) => true) 94 | ..on(condition: (e) => true) 95 | ..on(condition: (e) => true) 96 | ..on(condition: (e) => true) 97 | ..state((_) {}) 98 | ..state((_) {}) 99 | ..state((_) {}); 100 | } 101 | 102 | StateBuilder registrationTypeSelected( 103 | StateBuilder builder) => 104 | builder 105 | // ..pageBreak 106 | ..state((builder) => builder 107 | ..onEnter((s, e) async => 108 | RegistrationWizard.setType(RegistrationType.acceptInvite))) 109 | ..state((builder) => builder 110 | ..onEnter((s, e) async => 111 | RegistrationWizard.setType(RegistrationType.newOrganisation)) 112 | ..on()) 113 | ..state(acceptInvitation) 114 | ..coregion( 115 | mobileAndRegistationTypeAcquired); 116 | 117 | StateBuilder acceptInvitation( 118 | StateBuilder builder) => 119 | builder 120 | ..onEnter((s, e) async => 121 | RegistrationWizard.setType(RegistrationType.recoverAccount)) 122 | ..on() 123 | ..on() 124 | ..on() 125 | ..on(); 126 | 127 | class NoInviteFound extends Event {} 128 | 129 | class ExpiredInvite extends Event {} 130 | 131 | class AskCAForInvite extends State {} 132 | 133 | CoRegionBuilder 134 | mobileAndRegistationTypeAcquired( 135 | CoRegionBuilder builder) => 136 | builder 137 | ..state((_) {}) 138 | ..state((builder) => builder 139 | ..on() 140 | 141 | /// HasMobileNo 142 | ..state((builder) => builder 143 | //..pageBreak 144 | ..onEnter((s, e) async => fetchUserDetails()) 145 | ..on() 146 | 147 | /// we fetch the user's state based on their mobile. 148 | ..state((builder) => builder 149 | ..on() 150 | ..on() 151 | ..on() 152 | ..on()) 153 | 154 | /// The user's account is active 155 | ..state((builder) => builder 156 | ..on() 157 | ..on() 158 | ..on()) 159 | 160 | // hacks for missing states 161 | ..state((_) {}) 162 | ..state((_) {}) 163 | ..state((_) {}) 164 | ..state((_) {}) 165 | ..state((_) {}) 166 | ..state((_) {}) 167 | ..state((_) {}))); 168 | 169 | StateBuilder emailPage(StateBuilder b) => b 170 | ..initialState() 171 | //..pageBreak 172 | ..on() 173 | ..on() 174 | ..on() 175 | ..state((_) {}) 176 | ..state((_) {}); 177 | 178 | StateBuilder namePage(StateBuilder builder) => builder 179 | ..initialState() 180 | //..pageBreak 181 | ..on() 182 | ..on() 183 | ..on() 184 | ..state((_) {}) 185 | ..state((_) {}) 186 | ..state((_) {}); 187 | 188 | StateBuilder trialPhonePage( 189 | StateBuilder builder) => 190 | builder 191 | ..initialState() 192 | //..pageBreak 193 | ..on() 194 | ..on() 195 | ..on(); 196 | 197 | StateBuilder regionPage(StateBuilder builder) => builder 198 | ..initialState() 199 | //..pageBreak 200 | ..on() 201 | ..on() 202 | ..on() 203 | ..state((_) {}) 204 | ..state((_) {}) 205 | ..state((_) {}); 206 | 207 | class OnEmailNotRequired implements Event {} 208 | 209 | class OnNameNotRequired implements Event {} 210 | 211 | class EmailNotRequired implements State {} 212 | 213 | class OnEmailValidated implements Event {} 214 | 215 | class EmailAcquired implements State {} 216 | 217 | class OnEmailInvalid implements Event {} 218 | 219 | class EmailPage implements State {} 220 | 221 | class NameNotRequired implements State {} 222 | 223 | class OnNameValidated implements Event {} 224 | 225 | class NameAcquired implements State {} 226 | 227 | class OnNameInvalid implements Event {} 228 | 229 | class NameRequired implements State {} 230 | 231 | class NamePage implements State {} 232 | 233 | class TrialAcquired implements State {} 234 | 235 | class TrailRequired implements State {} 236 | 237 | class TrialNotRequired implements State {} 238 | 239 | class OnTrialNotRequired implements Event {} 240 | 241 | class TrailAcquired implements State {} 242 | 243 | class OnTrailValidated implements Event {} 244 | 245 | class OnTrailInvalid implements Event {} 246 | 247 | class TrialRequired implements State {} 248 | 249 | class TrialPhonePage implements State {} 250 | 251 | class RegionAcquired implements State {} 252 | 253 | class RegionNotRequired implements State {} 254 | 255 | class OnRegionNotRequired implements Event {} 256 | 257 | class OnRegionValidated implements Event {} 258 | 259 | class OnRegionInvalid implements Event {} 260 | 261 | class RegionRequired implements State {} 262 | 263 | class RegionPage implements State {} 264 | 265 | // class Pages implements State {} 266 | 267 | void fetchUserDetails() {} 268 | 269 | void fetchUserStatus() {} 270 | 271 | // ignore: avoid_classes_with_only_static_members 272 | class RegistrationWizard { 273 | static void restart() {} 274 | 275 | static void setType(RegistrationType acceptInvite) {} 276 | } 277 | 278 | enum RegistrationType { acceptInvite, newOrganisation, recoverAccount } 279 | 280 | /// states 281 | 282 | class AppLaunched implements State {} 283 | 284 | class RegistrationRequired implements State {} 285 | 286 | class RegistrationTypeSelected implements State {} 287 | 288 | class NewOrganisation implements State {} 289 | 290 | class RecoverAccount implements State {} 291 | 292 | class AcceptInvitation implements State {} 293 | 294 | class AcquireMobileNo implements State {} 295 | 296 | class MobileNoAcquired implements State {} 297 | 298 | class AcquireUser implements State {} 299 | 300 | class AccountEnabled implements State {} 301 | 302 | class EmailRequired implements State {} 303 | 304 | class Registered implements State {} 305 | 306 | class AccountDisabledTerminal implements State {} 307 | 308 | class InactiveCustomerTerminal implements State {} 309 | 310 | class ActiveCustomer implements State {} 311 | 312 | class ViableInvitation implements State {} 313 | 314 | class UserAcquistionRetryRequired implements State {} 315 | 316 | class MobileAndRegistrationTypeAcquired implements State {} 317 | 318 | /// events 319 | class OnUserNotFound implements Event {} 320 | 321 | class OnInActiveCustomerFound implements Event {} 322 | 323 | class OnActiveCustomerFound implements Event {} 324 | 325 | class OnViableInvitiationFound implements Event {} 326 | 327 | class OnUserEnteredMobile implements Event {} 328 | 329 | class OnMobileValidated implements Event {} 330 | 331 | class OnUserDisabled implements Event {} 332 | 333 | class OnUserEnabled implements Event {} 334 | 335 | class OnUserAcquisitionFailed implements Event {} 336 | 337 | class OnMissingApiKey implements Event {} 338 | 339 | class OnHasApiKey implements Event {} 340 | 341 | class OnRegistrationType implements Event { 342 | RegistrationType? type; 343 | } 344 | 345 | void log(StateDefinition? from, Event? event, StateDefinition? to) {} 346 | 347 | // ignore: unused_element 348 | var _graph = ''' 349 | 350 | AppLaunched { 351 | AppLaunched => RegistrationRequired : OnForceRegistration; 352 | AppLaunched => RegistrationRequired : OnMissingApiKey; 353 | AppLaunched => Registered : OnHasApiKey; 354 | }, 355 | Registered { 356 | Registered => RegistrationRequired : OnForceRegistration; 357 | }, 358 | RegistrationRequired { 359 | RegistrationTypeAcquired { 360 | NewOrganisation, 361 | RecoverAccount { 362 | RecoverAccount => EmailRequired : OnUserNotFound; 363 | }, 364 | AcceptInvitation { 365 | AcceptInvitation => EmailRequired : OnUserNotFound; 366 | AcceptInvitation => MobileNoAcquired : OnUserEnteredMobile; 367 | }, 368 | MobileAndRegistrationTypeAcquired.parallel [label="MobileAndRegistrationTypeAcquired"] { 369 | AcquireMobileNo { 370 | MobileNoAcquired { 371 | AcquireUser { 372 | AcquireUser => EmailRequired : OnUserNotFound; 373 | AcquireUser => AccountDisabledTerminal : OnUserDisabled; 374 | AcquireUser => AccountEnabled : OnUserEnabled; 375 | AcquireUser => UserAcquistionRetryRequired : OnUserAcquisitionFailed; 376 | }, 377 | AccountEnabled { 378 | AccountEnabled => InactiveCustomerTerminal : OnInActiveCustomerFound; 379 | AccountEnabled => ActiveCustomer : OnActiveCustomerFound; 380 | AccountEnabled => ViableInvitation : OnViableInvitiationFound; 381 | }, 382 | Pages.parallel [label="Pages"] { 383 | RegionPage { 384 | RegionRequired, 385 | RegionAcquired, 386 | RegionNotRequired; 387 | RegionRequired.initial => RegionRequired; 388 | RegionPage => RegionRequired : OnRegionInvalid; 389 | RegionPage => RegionAcquired : OnRegionValidated; 390 | RegionPage => RegionNotRequired : OnRegionNotRequired; 391 | }, 392 | TrialPhonePage, 393 | TrailRequired, 394 | TrialAcquired, 395 | TrialNotRequired; 396 | Pages.parallel => TrialRequired : OnTrailInvalid; 397 | Pages.parallel => TrailAcquired : OnTrailValidated; 398 | Pages.parallel => TrialNotRequired : OnTrialNotRequired; 399 | }, 400 | NamePage, 401 | NameRequired, 402 | NameAcquired, 403 | NameNotRequired; 404 | AcquireUser.initial => AcquireUser; 405 | MobileNoAcquired => AcquireUser : OnMobileValidated; 406 | MobileNoAcquired => NameRequired : OnNameInvalid; 407 | MobileNoAcquired => NameAcquired : OnNameValidated; 408 | MobileNoAcquired => NameNotRequired : OnNameNotRequired; 409 | }, 410 | EmailPage, 411 | EmailRequired, 412 | EmailAcquired, 413 | EmailNotRequired; 414 | MobileNoAcquired.initial => MobileNoAcquired; 415 | AcquireMobileNo => MobileNoAcquired : OnUserEnteredMobile; 416 | AcquireMobileNo => EmailRequired : OnEmailInvalid; 417 | AcquireMobileNo => EmailAcquired : OnEmailValidated; 418 | AcquireMobileNo => EmailNotRequired : OnEmailNotRequired; 419 | }; 420 | }; 421 | NewOrganisation.initial => NewOrganisation; 422 | }; 423 | RegistrationTypeAcquired.initial => RegistrationTypeAcquired; 424 | RegistrationRequired => AcceptInvitation : OnRegistrationType; 425 | RegistrationRequired => NewOrganisation : OnRegistrationType; 426 | RegistrationRequired => RecoverAccount : OnRegistrationType; 427 | }; 428 | initial => AppLaunched : AppLaunched;'''; 429 | -------------------------------------------------------------------------------- /test/src/stackoverflow/typedef.dart: -------------------------------------------------------------------------------- 1 | // import 'dart:async'; 2 | 3 | // class Event {} 4 | 5 | // class OnBadAir extends Event { 6 | // OnBadAir(this.quality); 7 | // int quality; 8 | // } 9 | 10 | // class State {} 11 | 12 | // class FanOn extends State {} 13 | 14 | // class FanOff extends State {} 15 | 16 | // typedef GuardCondition = bool Function(E event); 17 | // bool noopGuardCondition(Event v) => true; 18 | 19 | // class _QueuedEvent { 20 | // _QueuedEvent(this.event); 21 | // Event event; 22 | // } 23 | 24 | // class StateDefinition { 25 | // // List transitions = []; 26 | // final Map> _eventTranstionsMap = {}; 27 | 28 | // void addTransition( 30 | // TransitionDefinition transitionDefinition) { 31 | // var transitionDefinitions = _eventTranstionsMap[E]; 32 | // transitionDefinitions ??= >[]; 34 | 35 | // if (transitionDefinition.condition == noopGuardCondition) { 36 | // print('no-op'); 37 | // } 38 | 39 | // transitionDefinitions.add(transitionDefinition); 40 | // _eventTranstionsMap[E] = transitionDefinitions; 41 | // } 42 | // } 43 | 44 | // class StateMachine { 45 | // final _eventQueue = <_QueuedEvent>[]; 46 | 47 | // // final List _stateDefinitions = []; 48 | 49 | // void on( 50 | // {GuardCondition condition = noopGuardCondition}) { 51 | // // final onTransition = TransitionDefinition( 52 | // // _stateDefinition, 53 | // // condition, 54 | // // TOSTATE, 55 | // // ); 56 | 57 | // // _stateDefinition.addTransition(onTransition); 58 | // } 59 | 60 | // void queue(E event) { 61 | // final qe = _QueuedEvent(event); 62 | // _eventQueue.add(qe); 63 | 64 | // /// process the event on a microtask. 65 | // Future.delayed(Duration.zero, _dispatch); 66 | // } 67 | 68 | // /// dequeue the next event and transition it. 69 | // Future _dispatch() async { 70 | // assert(_eventQueue.isNotEmpty, 'The event queue is in an invalid state'); 71 | // final event = _eventQueue.first; 72 | 73 | // /// crashes here. 74 | // // if (td.condition(event.event as E)) { 75 | // // print('it worked'); 76 | // // } 77 | // } 78 | // } 79 | 80 | // class TransitionDefinition { 82 | // TransitionDefinition(this.fromStateDefinition, this.toState, 83 | // {this.condition = noopGuardCondition}); 84 | // final GuardCondition condition; 85 | 86 | // final StateDefinition fromStateDefinition; 87 | // Type toState; 88 | // } 89 | 90 | // void main() async { 91 | // // final fork = Fork( 92 | // // TransitionDefinition(condition: (e) => e.quality < 10)) 93 | // // ..queue(OnBadAir(10)); 94 | 95 | // // await fork._dispatch(); 96 | // } 97 | -------------------------------------------------------------------------------- /test/src/visualise/watch_folder_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:developer'; 3 | 4 | import 'package:fsm2/src/visualise/watch_folder.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import '../../registration_test.dart' hide log; 8 | 9 | /// This test doesn't work. We need some method to trigger the watch. 10 | void main() { 11 | test('watch folder', () async { 12 | final done = Completer(); 13 | 14 | var count = 0; 15 | 16 | WatchFolder( 17 | pathTo: 'test/smcat', 18 | extension: 'smcat', 19 | onChanged: (file, action) { 20 | log('$file $action'); 21 | count++; 22 | 23 | if (count == 5) { 24 | done.complete(true); 25 | } 26 | }); 27 | 28 | final fsm = await createMachine(); 29 | const file = 'test/smcat/registration.smcat'; 30 | // var exports = 31 | fsm.export(file); 32 | 33 | await done.future; 34 | }, skip: true); 35 | } 36 | -------------------------------------------------------------------------------- /test/toaster_oven_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dcli_core/dcli_core.dart' as core; 2 | import 'package:fsm2/fsm2.dart'; 3 | import 'package:path/path.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | test('export', () async { 8 | await core.withTempDirAsync((tempDir) async { 9 | await _createMachine() 10 | ..analyse() 11 | ..export(join(tempDir, 'toaster_oven.smcat')); 12 | }); 13 | }, skip: false); 14 | } 15 | 16 | Future _createMachine() async => StateMachine.create((g) => g 17 | ..initialState() 18 | ..state((b) => b 19 | ..on() 20 | ..on()) 21 | ..state((b) => b..on()) 22 | ..state((b) => b 23 | ..on() 24 | ..state((b) {}) 25 | ..state((b) {}))); 26 | 27 | class DoorOpen implements State {} 28 | 29 | class DoorClosed implements State {} 30 | 31 | class Toasting implements State {} 32 | 33 | class Baking implements State {} 34 | 35 | class Heating implements State {} 36 | 37 | // class LightOn implements State {} 38 | 39 | class OnOpenDoor implements Event {} 40 | 41 | class OnCloseDoor implements Event {} 42 | 43 | // class OnTurnOff implements Event {} 44 | 45 | class OnToast implements Event {} 46 | 47 | class OnBake implements Event {} 48 | -------------------------------------------------------------------------------- /test/watcher.dart: -------------------------------------------------------------------------------- 1 | import 'package:fsm2/src/types.dart'; 2 | import 'package:mockito/annotations.dart'; 3 | 4 | @GenerateMocks([Watcher]) 5 | class Watcher { 6 | Future onEnter(Type t, Event? e) => Future.value(); 7 | 8 | Future onExit(Type t, Event? e) => Future.value(); 9 | 10 | void log(String message) {} 11 | } 12 | -------------------------------------------------------------------------------- /test/watcher.mocks.dart: -------------------------------------------------------------------------------- 1 | // Mocks generated by Mockito 5.0.0 from annotations 2 | // in fsm2/test/mock_watcher.dart. 3 | // Do not manually edit this file. 4 | 5 | // ignore_for_file: inference_failure_on_instance_creation 6 | 7 | import 'dart:async' as i3; 8 | 9 | import 'package:fsm2/src/types.dart' as i4; 10 | import 'package:mockito/mockito.dart' as i1; 11 | 12 | import 'watcher.dart' as i2; 13 | 14 | // ignore_for_file: comment_references 15 | // ignore_for_file: unnecessary_parenthesis 16 | 17 | /// A class which mocks [Watcher]. 18 | /// 19 | /// See the documentation for Mockito's code generation for more information. 20 | class MockWatcher extends i1.Mock implements i2.Watcher { 21 | MockWatcher() { 22 | i1.throwOnMissingStub(this); 23 | } 24 | 25 | @override 26 | i3.Future onEnter(Type? t, i4.Event? e) => 27 | (super.noSuchMethod(Invocation.method(#onEnter, [t, e]), 28 | returnValue: Future.value(), 29 | returnValueForMissingStub: Future.value()) as i3.Future); 30 | @override 31 | i3.Future onExit(Type? t, i4.Event? e) => 32 | (super.noSuchMethod(Invocation.method(#onExit, [t, e]), 33 | returnValue: Future.value(), 34 | returnValueForMissingStub: Future.value()) as i3.Future); 35 | @override 36 | void log(String? message) => 37 | super.noSuchMethod(Invocation.method(#log, [message]), 38 | returnValueForMissingStub: null); 39 | } 40 | -------------------------------------------------------------------------------- /tool/export_life.dart: -------------------------------------------------------------------------------- 1 | void main() {} 2 | -------------------------------------------------------------------------------- /tool/gen_smcat.dart: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env dcli 2 | 3 | import 'dart:io'; 4 | 5 | import 'package:args/args.dart'; 6 | 7 | import '../test/registration_test.dart'; 8 | 9 | /// dcli script generated by: 10 | /// dcli create gen_smcat.dart 11 | /// 12 | /// See 13 | /// https://pub.dev/packages/dcli#-installing-tab- 14 | /// 15 | /// For details on installing dcli. 16 | /// 17 | 18 | void main(List args) async { 19 | final machine = await createMachine(); 20 | machine.export('registration.smcat'); 21 | } 22 | 23 | // ignore: unreachable_from_main 24 | void showUsage(ArgParser parser) { 25 | // ignore: avoid_print 26 | print('Usage: gen_smcat.dart -v -prompt '); 27 | // ignore: avoid_print 28 | print(parser.usage); 29 | exit(1); 30 | } 31 | --------------------------------------------------------------------------------