├── .circleci └── config.yml ├── .dart-version ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README-ja.md ├── README.md ├── analysis_options.yaml ├── example └── dart_fsm_example.dart ├── lib ├── dart_fsm.dart ├── dart_fsm_test_tools.dart └── src │ ├── state_machine │ ├── graph │ │ ├── graph.dart │ │ ├── graph_builder.dart │ │ └── transition.dart │ ├── implementation │ │ └── state_machine_impl.dart │ ├── side_effect │ │ ├── side_effect_creator_interface.dart │ │ ├── side_effect_creators.dart │ │ ├── side_effect_interface.dart │ │ └── side_effects.dart │ ├── state_machine.dart │ ├── state_machine_creator.dart │ └── subscription │ │ └── subscription.dart │ └── tester │ ├── mock_state_machine.dart │ ├── state_machine_tester.dart │ └── tester_state_machine.dart ├── pubspec.yaml └── test ├── after_side_effect_test.dart ├── before_side_effect_test.dart ├── finally_side_effect_test.dart ├── state_machine_close_test.dart ├── subscription_test.dart ├── test_state_machine ├── test_side_effect_creators.dart ├── test_side_effects.dart ├── test_state_graph.dart ├── test_state_machine_action.dart ├── test_state_machine_state.dart └── test_subscription.dart └── transition_test.dart /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | commands: 4 | install_dart: 5 | description: "Install Dart from .dart-version with caching" 6 | steps: 7 | - run: 8 | name: Check Dart Installation 9 | command: | 10 | echo "Installing Dart" 11 | DART_VERSION=$(cat .dart-version) 12 | sudo apt update 13 | sudo apt install -y apt-transport-https 14 | sudo sh -c 'wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -' 15 | sudo sh -c 'wget -qO- https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list' 16 | sudo apt update 17 | sudo apt-cache madison dart 18 | sudo apt install -y dart=$DART_VERSION 19 | 20 | jobs: 21 | build-and-test: 22 | docker: 23 | - image: cimg/base:stable 24 | resource_class: medium+ 25 | steps: 26 | - checkout 27 | - install_dart 28 | - run: 29 | name: Run Lint 30 | command: | 31 | echo "Running lint" 32 | dart pub get 33 | dart analyze 34 | - run: 35 | name: Run Tests 36 | command: | 37 | echo "Running tests" 38 | dart pub get 39 | dart test 40 | # 41 | # publish: 42 | # docker: 43 | # - image: cimg/base:stable 44 | # resource_class: medium+ 45 | # steps: 46 | # - checkout 47 | # - install_dart 48 | # - run: 49 | # name: Publish to Github Packages 50 | # command: | 51 | # dart pub publish --dry-run 52 | 53 | workflows: 54 | version: 2 55 | test-and-lint: 56 | jobs: 57 | - build-and-test 58 | # - publish: 59 | # requires: 60 | # - build-and-test 61 | # filters: 62 | # branches: 63 | # only: 64 | # - main -------------------------------------------------------------------------------- /.dart-version: -------------------------------------------------------------------------------- 1 | 3.4.4-1 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 1. Go to '...' 15 | 2. Click on '...' 16 | 3. Scroll down to '...' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Desktop (please complete the following information):** 26 | - OS: [e.g. iOS] 27 | - Browser [e.g. chrome, safari] 28 | - Version [e.g. 22] 29 | 30 | **Smartphone (please complete the following information):** 31 | - Device: [e.g. iPhone 8] 32 | - OS: [e.g. iOS 14.4] 33 | - Browser [e.g. stock browser, safari] 34 | - Version [e.g. 22] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request Description 2 | 3 | 4 | 5 | ## Related Issues 6 | 7 | 8 | 9 | ## Changes Proposed 10 | 11 | 12 | 13 | 14 | 15 | 16 | 1. 17 | 2. 18 | 3. 19 | 20 | ## Testing Checklist 21 | 22 | 23 | 24 | 25 | - [ ] 26 | - [ ] 27 | - [ ] 28 | 29 | ## Checklist 30 | 31 | - [ ] Code follows the project's coding style 32 | - [ ] Documentation has been updated if necessary 33 | - [ ] Tests have been added or updated if necessary 34 | 35 | ## Additional Comments 36 | 37 | 38 | --- 39 | _This project is licensed under the [BSD-3-Clause License](../LICENSE)._ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | .idea/ 5 | # Avoid committing pubspec.lock for library packages; see 6 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 7 | pubspec.lock 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.2.2 2 | - fix: Prevent dispatching actions after state machine is closed 3 | 4 | ## 1.2.1 5 | - chore: Relax version constraint for test package to ensure forward compatibility 6 | 7 | ## 1.2.0 8 | - chore: Control class visibility from outside the package 9 | 10 | ## 1.1.1 11 | - chore: downgrade meta package version to 1.15.0 12 | 13 | ## 1.1.0 14 | - fix: Fixed that AfterSideEffectCreator and FinallySideEffectCreator was treating the state after transition as prevState. 15 | - fix: Block duplicate call state and on method logic 16 | 17 | ## 1.0.0 18 | - Initial release. 19 | ### Changes from 0.0.1 20 | - Updated the version to 1.0.0. 21 | - Removed `validTransition` from the arguments of the `execute` method of `AfterSideEffect`. 22 | - This is because `validTransition` is no longer needed as it is received via the constructor from `SideEffectCreator`. 23 | - Improved documentation and other maintenance tasks. 24 | 25 | 26 | ## 0.0.1 27 | 28 | - Initial version. 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | Thank you for your interest in contributing to this project! By following the guidelines outlined 4 | below, you can help us improve and grow the project effectively. 5 | 6 | ## Code Contribution 7 | 8 | 1. **Fork the Repository**: Fork the repository to your GitHub account. 9 | 2. **Create a Branch**: Create a new branch for your feature or bug fix. 10 | 11 | ```git checkout -b feature/your-feature-name``` 12 | 13 | 3. **Commit Your Changes**: Commit your changes with a clear and descriptive commit message. 14 | 15 | ```git commit -m 'Add feature: description of the feature'``` 16 | 17 | 4. **Push the Branch**: Push the branch to your forked repository. 18 | 19 | ```git push origin feature/your-feature-name``` 20 | 21 | 5. **Create a Pull Request**: Open a pull request against the main repository's main branch from your 22 | forked repository. Provide a detailed description of your changes so that they can be reviewed 23 | efficiently. 24 | 25 | ## Reporting Issues 26 | 27 | 1. **Search Existing Issues**: Before opening a new issue, please search the existing issues to ensure 28 | that your issue has not already been reported. 29 | 2. **Open a New Issue**: If your issue is not listed, open a new issue and provide the necessary 30 | details: 31 | * A clear and descriptive title. 32 | * Detailed steps to reproduce the issue. 33 | * Expected and actual results. 34 | * Any relevant screenshots or logs. 35 | 36 | ## Code Style Guidelines 37 | 38 | * Formatting: Use dart format to format your code before committing. 39 | * Linting: Follow the coding standards and guidelines specified in analysis_options.yaml. 40 | 41 | ## Testing 42 | 43 | * Add Tests: Ensure that you add relevant tests for any new functionality or bug fixes. 44 | * Run Existing Tests: Verify that all pre-existing tests pass before submitting your changes. 45 | 46 | ## License 47 | This project is licensed under the BSD-3-Clause License. By contributing to this project, you 48 | agree that your contributions will be licensed under the same license. 49 | 50 | ## Communication 51 | * Issues and Discussions: For any questions or suggestions, feel free to open an issue or join the 52 | discussion. Constructive feedback and collaborative discussions are encouraged. 53 | * Respectful Interaction: Please interact with all contributors respectfully. Harassment or 54 | disrespectful behavior will not be tolerated. 55 | 56 | We appreciate your contributions and look forward to collaborating with you to improve this 57 | project! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, teamLab inc. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README-ja.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | # dart_fsm 15 | Dartで有限オートマトンを実現するためのパッケージ。 16 | 17 | ## 有限オートマトンについて 18 | 有限オートマトンとは、有限個の状態と遷移と動作の組み合わせからなる数学的に抽象化された「ふるまいのモデル」です。 19 | アプリケーションの取りうる状態を有限オートマトンとしてモデル化することで以下のような恩恵が得られます。 20 | * アプリケーションの設計段階での考慮漏れが少なくなる 21 | * 設計段階で有限オートマトンを設計することで、設計と実装の間のギャップを埋めることができる 22 | * 状態を有限オートマトンに則って定義することで不要なnullチェックを排除することができる 23 | * 状態を有限オートマトンに則って定義することで、考慮していない存在しない状態の発生を防ぐことができる 24 | * アプリケーションの状態が意図せず変更されることを防ぐことができる 25 | * テストが容易になる 26 | 27 | 有限オートマトンを用いたアプリケーションの設計は、アプリケーションの複雑さが増すにつれて有効性が高まります。 28 | 29 | ## モチベーション 30 | 有限オートマトンを用いた状態管理が有用であることは説明した通りですが、switch文やif文を用いた有限オートマトンの実装は状態の遷移が複雑になるにつれて可読性が低下し、バグの発生源となりやすくなります。 31 | また、開発者により状態の遷移が異なる実装になることがあり、コードの保守性が低下します。 32 | そのため、有限オートマトンを用いた状態管理を行う際には、状態の遷移を明確に定義し、自動的に行う仕組みが必要です。 33 | また、アプリケーションでの状態管理に使用する場合、状態の遷移にともなう副作用としてAPIの呼び出しなどを発生させたい場合があります。 34 | そのような副作用の実装もパッケージを利用しない場合、開発者により異なる実装になり、コードの保守性が低下することがあります。 35 | 36 | そのため、本パッケージでは有限オートマトンを用いた状態管理を行う際に状態の遷移を明確に定義し、状態の遷移を自動的に行う仕組みを提供します。 37 | また、状態の遷移にともなう副作用の実装もパッケージ内で定義することで、開発者による実装の違いを排除し、コードの保守性を向上させます。 38 | ## 機能 39 | 1. 副作用の入らない状態遷移図の記述DSL 40 | 2. 状態遷移に伴う副作用の実装方法の提供 41 | 3. Streamなどの断続的に値が流れてくる状況における有限オートマトンの実装方法の提供 42 | 4. 有限オートマトンを用いたテストの実装方法の提供 43 | 5. 有限オートマトン自体のテストの実装方法の提供 44 | 45 | ## 使用方法 46 | ### 状態遷移図の記述 47 | 有限オートマトンは通常状態遷移図を用いて表現されます。 48 | 本パッケージでは状態遷移図を記述するためのDSLを提供します。 49 | 例えば以下のような有限オートマトンの遷移図を考えてみましょう。 50 | ```mermaid 51 | stateDiagram-v2 52 | [*] --> Initial 53 | Initial --> Loading: Fetch 54 | Loading --> Success: Succeed 55 | Loading --> Error: Fail 56 | ``` 57 | この遷移図をdart_fsmで記述します。まずは状態とアクションの定義を行います。 58 | 状態の定義はsealed classを用いて以下のように行います。 59 | ```dart 60 | sealed class SampleState { 61 | const SampleState(); 62 | } 63 | 64 | final class SampleStateInitial extends SampleState { 65 | const SampleStateInitial(); 66 | } 67 | 68 | 69 | final class SampleStateLoading extends SampleState { 70 | const SampleStateLoading(); 71 | } 72 | 73 | final class SampleStateSuccess extends SampleState { 74 | const SampleStateSuccess(this.data); 75 | 76 | final Data data; 77 | } 78 | 79 | final class SampleStateError extends SampleState { 80 | const SampleStateError(this.exception); 81 | 82 | final Exception exception; 83 | } 84 | ``` 85 | アクションについても同様にsealed classを用いて以下のように行います。 86 | ```dart 87 | sealed class SampleAction { 88 | const SampleAction(); 89 | } 90 | 91 | final class SampleActionFetch extends SampleAction { 92 | const SampleActionFetch(); 93 | } 94 | 95 | final class SampleActionSucceed extends SampleAction { 96 | const SampleActionSucceed(this.data); 97 | 98 | final Data data; 99 | } 100 | 101 | final class SampleActionFail extends SampleAction { 102 | const SampleActionFail(this.exception); 103 | 104 | final Exception exception; 105 | } 106 | ``` 107 | 次に状態遷移図を記述します。本パッケージにはGraphBuilderというクラスが用意されており、これを用いて状態遷移図を記述します。 108 | まずGraphBuilderをインスタンス化し、stateとonメソッドを用いて状態遷移図を記述します。 109 | onメソッドは遷移前の状態とアクションを受け取り、遷移後の状態を返す関数を引数に取ります。 110 | ```dart 111 | final stateGraph = GraphBuilder() 112 | ..state( 113 | (b) => b 114 | ..on( 115 | (state, action) => b.transitionTo(const SampleStateLoading()), 116 | ), 117 | ) 118 | ..state( 119 | (b) => b 120 | ..on( 121 | (state, action) => b.transitionTo(SampleStateSuccess(action.data)), 122 | ) 123 | ..on( 124 | (state, action) => b.transitionTo(SampleStateError(action.exception)), 125 | ), 126 | ); 127 | ``` 128 | 状態遷移図の記述が完了したら、状態遷移図を用いて有限オートマトンを生成します。 129 | ```dart 130 | final stateMachine = createStateMachine( 131 | initialState: const SampleStateInitial(), 132 | graphBuilder: stateGraph, 133 | ); 134 | ``` 135 | これで有限オートマトンが生成されました。状態遷移はdispatchメソッドを用いて有限オートマトンにアクションを発行することで行います。 136 | ```dart 137 | stateMachine.dispatch(const SampleActionFetch()); 138 | ``` 139 | 有限オートマトンの状態はstateプロパティ、またはstateStreamプロパティを用いてStreamで取得することができます。 140 | ```dart 141 | print(stateMachine.state); 142 | stateMachine.stateStream.listen((state) { 143 | print(state); 144 | }); 145 | ``` 146 | 147 | ### 副作用の実装 148 | 先ほどの状態遷移図をもう一度見てみましょう。 149 | ```mermaid 150 | stateDiagram-v2 151 | [*] --> Initial 152 | Initial --> Loading: Fetch 153 | Loading --> Success: Succeed 154 | Loading --> Error: Fail 155 | ``` 156 | この状態遷移図において、Loading状態に遷移した際にはApiを呼び出す副作用を発生させたいとします。 157 | このような副作用を発生させるためにはSideEffectCreatorおよびSideEffectを用いて副作用を定義します。 158 | SideEffectCreatorは副作用を生成するためのクラスであり、SideEffectは副作用を表すクラスです。 159 | Before、After、Finallyの3種類が存在し以下のタイミングで呼び出されます。 160 | * AfterSideEffectCreator: アクションが発行された直後、状態遷移前に実行される 161 | * BeforeSideEffectCreator: アクションが発行され、状態の遷移が行われた後に実行される 162 | * FinallySideEffectCreator: アクションが発行された後、状態の遷移が行われたか否かにかかわらず実行される 163 | 164 | 最も使用頻度が高いのは多くの場合でAfterSideEffectCreatorであり、 165 | 遷移にともなうApiの呼び出しやデータの保存などの副作用を発生させるための使用に適しています。 166 | では、Loading状態に遷移した際にApiを呼び出す副作用を発生させるための実装を行います。 167 | ```dart 168 | final class SampleSideEffectCreator 169 | implements AfterSideEffectCreator { 170 | const SampleSideEffectCreator(this.apiClient); 171 | 172 | final ApiClient apiClient; 173 | 174 | @override 175 | SampleSideEffect? create(SampleState state, SampleAction action) { 176 | return switch (action) { 177 | SampleActionFetch() => SampleSideEffect(apiClient), 178 | _ => null, 179 | }; 180 | } 181 | } 182 | 183 | final class SampleSideEffect 184 | implements AfterSideEffect { 185 | const SampleSideEffect(this.apiClient); 186 | 187 | final ApiClient apiClient; 188 | 189 | @override 190 | Future execute( 191 | StateMachine stateMachine) async { 192 | try { 193 | final data = await apiClient.fetchData(); 194 | stateMachine.dispatch(SampleActionSucceed(data)); 195 | } on Exception catch (e) { 196 | stateMachine.dispatch(SampleActionFail(e)); 197 | } 198 | } 199 | } 200 | ``` 201 | > [!NOTE] 202 | > ApiClientはApiを呼び出すためのクラスであり、コンストラクタで受け取るようにしていますが、これは疎結合性を高めるためです。 203 | 204 | これでApiを呼び出すSampleSideEffectと、遷移時の条件によってSampleSideEffectを生成するSampleSideEffectCreatorが定義されました。 205 | 次にこれらを有限オートマトンに登録します。 206 | ```dart 207 | final stateMachine = createStateMachine( 208 | initialState: const SampleStateInitial(), 209 | graphBuilder: stateGraph, 210 | sideEffectCreator: SampleSideEffectCreator(apiClient), 211 | ); 212 | ``` 213 | これでApiを呼び出す副作用が有限オートマトンに登録されました。 214 | 状態遷移が行われるたびにSampleSideEffectCreatorが呼び出され、その状態遷移を発生させたアクションがFetchであればSampleSideEffectが生成され、Apiが呼び出されます。 215 | 216 | ### Streamなどの断続的に値が流れてくる状況における有限オートマトンの実装 217 | Streamなどの断続的に値が流れてくる状況において有限オートマトンを実装する場合、Subscriptionというクラスを用いて実装します。 218 | SubscriptionはStateMachineのインスタンが生成される時に1回のみ呼び出され、その後はStateMachineのインスタンスが破棄されるまで有効です。 219 | 以下のような状態遷移図を考えてみましょう。 220 | ```mermaid 221 | stateDiagram-v2 222 | [*] --> Initial 223 | Initial --> Loading: Fetch 224 | Loading --> Success: Succeed 225 | Success --> Success: UpdateData 226 | Loading --> Error: Fail 227 | ``` 228 | Success状態に遷移した後もUpdateDataアクションが発行されるたびにデータを更新するとします。 229 | StreamをもとにUpdateDataアクションを発行するSubscriptionを実装します。 230 | ```dart 231 | final class SampleSubscription 232 | implements Subscription { 233 | SampleSubscription(this.webSocketClient); 234 | 235 | final WebSocketClient webSocketClient; 236 | 237 | StreamSubscription? _subscription; 238 | 239 | @override 240 | void subscribe(StateMachine stateMachine) { 241 | _subscription = webSocketClient.subscribeData().listen((data) { 242 | stateMachine.dispatch(SampleActionUpdate(data)); 243 | }); 244 | } 245 | 246 | @override 247 | void dispose() { 248 | _subscription?.cancel(); 249 | } 250 | } 251 | ``` 252 | これでWebSocketClientからデータを受け取り、データをもとにSampleActionUpdateを発行するSubscriptionが定義されました。 253 | これをSideEffectCreator同様にStateMachineに登録します。 254 | ```dart 255 | final stateMachine = createStateMachine( 256 | initialState: const SampleStateInitial(), 257 | graphBuilder: stateGraph, 258 | sideEffectCreator: SampleSideEffectCreator(apiClient), 259 | subscription: SampleSubscription(webSocketClient), 260 | ); 261 | ``` 262 | これでWebSocketClientからデータを受け取り、データをもとにSampleActionUpdateを発行するSubscriptionが有限オートマトンに登録されました。 263 | 264 | ### 有限オートマトンを用いたテストの実装 265 | TODO 266 | 267 | 268 | ## 使用例 269 | ```dart 270 | import 'package:dart_fsm/dart_fsm.dart'; 271 | 272 | // State 273 | sealed class SampleState { 274 | const SampleState(); 275 | } 276 | 277 | final class SampleStateA extends SampleState { 278 | const SampleStateA(); 279 | } 280 | 281 | final class SampleStateB extends SampleState { 282 | const SampleStateB(); 283 | } 284 | 285 | // Action 286 | sealed class SampleAction { 287 | const SampleAction(); 288 | } 289 | 290 | final class SampleActionA extends SampleAction { 291 | const SampleActionA(); 292 | } 293 | 294 | void main() { 295 | final stateMachineGraph = GraphBuilder() 296 | ..state( 297 | (b) => b 298 | ..on( 299 | (state, action) => b.transitionTo(const SampleStateB()), 300 | ), 301 | ); 302 | 303 | final stateMachine = createStateMachine( 304 | initialState: const SampleStateA(), 305 | graphBuilder: stateMachineGraph, 306 | ); 307 | 308 | print(stateMachine.state); // SampleStateA 309 | 310 | stateMachine.dispatch(const SampleActionA()); 311 | 312 | print(stateMachine.state); // SampleStateB 313 | } 314 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | # dart_fsm 15 | A package for implementing finite state machines in Dart. 16 | 17 | ## About Finite State Machines 18 | A finite state machine is a mathematically abstracted "behavior model" composed of a finite number of states, transitions, and actions. Modeling the possible states of an application as a finite state machine provides several benefits: 19 | * Reduces the likelihood of missed considerations during the application design phase 20 | * Bridges the gap between design and implementation by designing the finite state machine at the design stage 21 | * Eliminates unnecessary null checks by defining states in line with the finite state machine 22 | * Prevents the occurrence of unforeseen non-existent states by defining states in accordance with the finite state machine 23 | * Prevents unintentional changes to the application state 24 | * Makes testing easier 25 | 26 | The effectiveness of designing applications using finite state machines increases as the complexity of the application grows. 27 | 28 | ## Motivation 29 | While state management using finite state machines is useful, implementing finite state machines using switch statements or if statements can become unreadable and prone to bugs as state transitions become complex. Additionally, different developers may implement state transitions differently, reducing code maintainability. Therefore, when managing state with finite state machines, it is necessary to clearly define state transitions and automate them. Furthermore, when using state management in applications, there may be cases where you want to trigger side effects, such as API calls, during state transitions. Without a package, varying implementations by developers can reduce code maintainability. 30 | 31 | This package provides a mechanism to clearly define state transitions when managing state with finite state machines and automates state transitions. It also defines the implementation of side effects within the package to eliminate implementation differences between developers and improves code maintainability. 32 | 33 | ## Features 34 | 1. DSL (Domain Specific Language) for describing state transition diagrams without side effects 35 | 2. Providing implementation methods for side effects accompanying state transitions 36 | 3. Providing implementation methods for finite state machines in scenarios where values flow intermittently, such as Streams 37 | 4. Providing implementation methods for tests using finite state machines 38 | 5. Providing implementation methods for testing finite state machines themselves 39 | 40 | ## Usage 41 | ### Describing State Transition Diagrams 42 | Finite state machines are typically represented using state transition diagrams. This package provides a DSL for describing state transition diagrams. Consider the following state transition diagram: 43 | ```mermaid 44 | stateDiagram-v2 45 | [*] --> Initial 46 | Initial --> Loading: Fetch 47 | Loading --> Success: Succeed 48 | Loading --> Error: Fail 49 | ``` 50 | We will describe this transition diagram using dart_fsm. First, define the states and actions. States are defined using sealed classes as shown below. 51 | ```dart 52 | sealed class SampleState { 53 | const SampleState(); 54 | } 55 | 56 | final class SampleStateInitial extends SampleState { 57 | const SampleStateInitial(); 58 | } 59 | 60 | 61 | final class SampleStateLoading extends SampleState { 62 | const SampleStateLoading(); 63 | } 64 | 65 | final class SampleStateSuccess extends SampleState { 66 | const SampleStateSuccess(this.data); 67 | 68 | final Data data; 69 | } 70 | 71 | final class SampleStateError extends SampleState { 72 | const SampleStateError(this.exception); 73 | 74 | final Exception exception; 75 | } 76 | ``` 77 | Similarly, actions are defined using sealed classes as shown below. 78 | ```dart 79 | sealed class SampleAction { 80 | const SampleAction(); 81 | } 82 | 83 | final class SampleActionFetch extends SampleAction { 84 | const SampleActionFetch(); 85 | } 86 | 87 | final class SampleActionSucceed extends SampleAction { 88 | const SampleActionSucceed(this.data); 89 | 90 | final Data data; 91 | } 92 | 93 | final class SampleActionFail extends SampleAction { 94 | const SampleActionFail(this.exception); 95 | 96 | final Exception exception; 97 | } 98 | ``` 99 | Next, describe the state transition diagram. This package provides a class called `GraphBuilder`, which can be used to describe state transition diagrams. Instantiate the `GraphBuilder` and use the `state` and `on` methods to describe the state transition diagram. 100 | The `on` method takes a function that accepts the previous state and action and returns the next state as arguments. 101 | ```dart 102 | final stateGraph = GraphBuilder() 103 | ..state( 104 | (b) => b 105 | ..on( 106 | (state, action) => b.transitionTo(const SampleStateLoading()), 107 | ), 108 | ) 109 | ..state( 110 | (b) => b 111 | ..on( 112 | (state, action) => b.transitionTo(SampleStateSuccess(action.data)), 113 | ) 114 | ..on( 115 | (state, action) => b.transitionTo(SampleStateError(action.exception)), 116 | ), 117 | ); 118 | ``` 119 | After describing the state transition diagram, generate the finite state machine using the state transition diagram. 120 | ```dart 121 | final stateMachine = createStateMachine( 122 | initialState: const SampleStateInitial(), 123 | graphBuilder: stateGraph, 124 | ); 125 | ``` 126 | This generates the finite state machine. State transitions are performed by issuing actions to the finite state machine using the `dispatch` method. 127 | ```dart 128 | stateMachine.dispatch(const SampleActionFetch()); 129 | ``` 130 | The state of the finite state machine can be obtained using the `state` property or the `stateStream` property to acquire a Stream. 131 | ```dart 132 | print(stateMachine.state); 133 | stateMachine.stateStream.listen((state) { 134 | print(state); 135 | }); 136 | ``` 137 | 138 | ### Implementing Side Effects 139 | Let's take another look at the state transition diagram: 140 | ```mermaid 141 | stateDiagram-v2 142 | [*] --> Initial 143 | Initial --> Loading: Fetch 144 | Loading --> Success: Succeed 145 | Loading --> Error: Fail 146 | ``` 147 | Suppose you want to trigger a side effect of making an API call when transitioning to the `Loading` state. To implement such side effects, define the side effects using `SideEffectCreator` and `SideEffect`. `SideEffectCreator` is a class for generating side effects, and `SideEffect` represents the side effects. 148 | There are three types: `After`, `Before`, and `Finally`, which are called at the following times: 149 | 150 | • `AfterSideEffectCreator`: Executed immediately after an action is issued, before the state transitions. 151 | • `BeforeSideEffectCreator`: Executed after an action is issued and the state transition has occurred. 152 | • `FinallySideEffectCreator`: Executed after an action is issued, regardless of whether the state transition has occurred. 153 | 154 | In many cases, `AfterSideEffectCreator` is the most frequently used and is suitable for generating side effects such as API calls or saving data that occur as a result of state transitions. 155 | 156 | Let's implement the side effect to make an API call when transitioning to the `Loading` state. 157 | ```dart 158 | final class SampleSideEffectCreator 159 | implements AfterSideEffectCreator { 160 | const SampleSideEffectCreator(this.apiClient); 161 | 162 | final ApiClient apiClient; 163 | 164 | @override 165 | SampleSideEffect? create(SampleState state, SampleAction action) { 166 | return switch (action) { 167 | SampleActionFetch() => SampleSideEffect(apiClient), 168 | _ => null, 169 | }; 170 | } 171 | } 172 | 173 | final class SampleSideEffect 174 | implements AfterSideEffect { 175 | const SampleSideEffect(this.apiClient); 176 | 177 | final ApiClient apiClient; 178 | 179 | @override 180 | Future execute( 181 | StateMachine stateMachine) async { 182 | try { 183 | final data = await apiClient.fetchData(); 184 | stateMachine.dispatch(SampleActionSucceed(data)); 185 | } on Exception catch (e) { 186 | stateMachine.dispatch(SampleActionFail(e)); 187 | } 188 | } 189 | } 190 | ``` 191 | > [!NOTE] 192 | > The `ApiClient` is a class for making API calls, and it is received in the constructor to enhance loose coupling. 193 | 194 | Now that the `SampleSideEffect` to make API calls and the `SampleSideEffectCreator` to generate the `SampleSideEffect` based on transition conditions have been defined, register them with the finite state machine. 195 | ```dart 196 | final stateMachine = createStateMachine( 197 | initialState: const SampleStateInitial(), 198 | graphBuilder: stateGraph, 199 | sideEffectCreator: SampleSideEffectCreator(apiClient), 200 | ); 201 | ``` 202 | This registers the side effect to make API calls with the finite state machine. Each time a state transition occurs, the `SampleSideEffectCreator` is called, and if the action that caused the transition is `Fetch`, a `SampleSideEffect` is generated, and the API is called. 203 | 204 | ### Implementing Finite State Machines in Intermittent Value Scenarios such as Streams 205 | 206 | When implementing finite state machines in scenarios where values flow intermittently, such as Streams, the `Subscription` class is used. `Subscription` is called only once when the `StateMachine` instance is generated and remains valid until the `StateMachine` instance is disposed of. Consider the following state transition diagram: 207 | ```mermaid 208 | stateDiagram-v2 209 | [*] --> Initial 210 | Initial --> Loading: Fetch 211 | Loading --> Success: Succeed 212 | Success --> Success: UpdateData 213 | Loading --> Error: Fail 214 | ``` 215 | Suppose you want to update the data each time the `UpdateData` action is issued after transitioning to the `Success` state. Implement a `Subscription` that issues the `SampleActionUpdate` action based on a Stream. 216 | ```dart 217 | final class SampleSubscription 218 | implements Subscription { 219 | SampleSubscription(this.webSocketClient); 220 | 221 | final WebSocketClient webSocketClient; 222 | 223 | StreamSubscription? _subscription; 224 | 225 | @override 226 | void subscribe(StateMachine stateMachine) { 227 | _subscription = webSocketClient.subscribeData().listen((data) { 228 | stateMachine.dispatch(SampleActionUpdate(data)); 229 | }); 230 | } 231 | 232 | @override 233 | void dispose() { 234 | _subscription?.cancel(); 235 | } 236 | } 237 | ``` 238 | This defines a `Subscription` to receive data from the `WebSocketClient` and issue `SampleActionUpdate` based on the data. Register this `Subscription` with the `StateMachine` similarly to the `SideEffectCreator`. 239 | ```dart 240 | final stateMachine = createStateMachine( 241 | initialState: const SampleStateInitial(), 242 | graphBuilder: stateGraph, 243 | sideEffectCreator: SampleSideEffectCreator(apiClient), 244 | subscription: SampleSubscription(webSocketClient), 245 | ); 246 | ``` 247 | This registers the `Subscription` with the finite state machine to receive data from the `WebSocketClient` and issue `SampleActionUpdate` based on the data. 248 | 249 | ### Implementing Tests Using Finite State Machines 250 | TODO 251 | 252 | 253 | ## Example Usage 254 | ```dart 255 | import 'package:dart_fsm/dart_fsm.dart'; 256 | 257 | // State 258 | sealed class SampleState { 259 | const SampleState(); 260 | } 261 | 262 | final class SampleStateA extends SampleState { 263 | const SampleStateA(); 264 | } 265 | 266 | final class SampleStateB extends SampleState { 267 | const SampleStateB(); 268 | } 269 | 270 | // Action 271 | sealed class SampleAction { 272 | const SampleAction(); 273 | } 274 | 275 | final class SampleActionA extends SampleAction { 276 | const SampleActionA(); 277 | } 278 | 279 | void main() { 280 | final stateMachineGraph = GraphBuilder() 281 | ..state( 282 | (b) => b 283 | ..on( 284 | (state, action) => b.transitionTo(const SampleStateB()), 285 | ), 286 | ); 287 | 288 | final stateMachine = createStateMachine( 289 | initialState: const SampleStateA(), 290 | graphBuilder: stateMachineGraph, 291 | ); 292 | 293 | print(stateMachine.state); // SampleStateA 294 | 295 | stateMachine.dispatch(const SampleActionA()); 296 | 297 | print(stateMachine.state); // SampleStateB 298 | } 299 | ``` -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:very_good_analysis/analysis_options.yaml 15 | 16 | # Uncomment the following section to specify additional rules. 17 | 18 | # linter: 19 | # rules: 20 | # - camel_case_types 21 | 22 | # analyzer: 23 | # exclude: 24 | # - path/to/excluded/files/** 25 | 26 | # For more information about the core and recommended set of lints, see 27 | # https://dart.dev/go/core-lints 28 | 29 | # For additional information about configuring this file, see 30 | # https://dart.dev/guides/language/analysis-options 31 | -------------------------------------------------------------------------------- /example/dart_fsm_example.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | 7 | // State 8 | sealed class SampleState { 9 | const SampleState(); 10 | } 11 | 12 | final class SampleStateA extends SampleState { 13 | const SampleStateA(); 14 | } 15 | 16 | final class SampleStateB extends SampleState { 17 | const SampleStateB(); 18 | } 19 | 20 | // Action 21 | sealed class SampleAction { 22 | const SampleAction(); 23 | } 24 | 25 | final class SampleActionA extends SampleAction { 26 | const SampleActionA(); 27 | } 28 | 29 | void main() { 30 | final stateMachineGraph = GraphBuilder() 31 | ..state( 32 | (b) => b 33 | ..on( 34 | (state, action) => b.transitionTo(const SampleStateB()), 35 | ), 36 | ); 37 | 38 | final stateMachine = createStateMachine( 39 | initialState: const SampleStateA(), 40 | graphBuilder: stateMachineGraph, 41 | ); 42 | 43 | print(stateMachine.state); // SampleStateA 44 | 45 | stateMachine.dispatch(const SampleActionA()); 46 | 47 | print(stateMachine.state); // SampleStateB 48 | } 49 | -------------------------------------------------------------------------------- /lib/dart_fsm.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | export 'src/state_machine/state_machine.dart'; 6 | -------------------------------------------------------------------------------- /lib/dart_fsm_test_tools.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | export 'src/tester/mock_state_machine.dart'; 6 | export 'src/tester/state_machine_tester.dart'; 7 | export 'src/tester/tester_state_machine.dart'; 8 | -------------------------------------------------------------------------------- /lib/src/state_machine/graph/graph.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:meta/meta.dart'; 6 | 7 | /// A class representing state transitions 8 | /// It has a map representing the pattern of state transitions 9 | @immutable 10 | final class Graph { 11 | /// Creates a new graph with the given transition pattern map 12 | const Graph(this.transitionPatternMap); 13 | 14 | /// A map of [Matcher] and [GraphState] 15 | /// Specify the transition before the state with [Matcher], and 16 | /// specify the action required for the transition and the state after the 17 | /// transition with [GraphState] 18 | /// Typically instantiated by GraphBuilder 19 | final Map, GraphState> transitionPatternMap; 20 | } 21 | 22 | /// A class representing the information of which state to transition to when a 23 | /// particular action is dispatched 24 | /// As for the information of the state before the transition, it is specified 25 | /// in the transitionPatternMap of Graph, so here, it holds the information of 26 | /// the action required for the transition and the state after the transition 27 | /// Typically instantiated by StateConfigBuilder 28 | final class GraphState { 29 | /// Creates a new graph state with the given transition map 30 | const GraphState(this.transitionMap); 31 | 32 | /// A map of [Matcher] and 33 | /// [StateTransitionFunction]. Specify the action 34 | /// required for the transition with [Matcher], and specify the state 35 | /// after the transition with [StateTransitionFunction] 36 | /// Typically instantiated by StateConfigBuilder. 37 | /// The state before the transition is specified in the transitionPatternMap 38 | /// of Graph so here, it holds the information of the action required for the 39 | /// transition and the state after the transition. 40 | final Map, StateTransitionFunction> 41 | transitionMap; 42 | } 43 | 44 | /// A function that takes the state before the transition and the action and 45 | /// returns the new state after the transition in the form of [TransitionTo] 46 | typedef StateTransitionFunction 48 | = TransitionTo Function(ON_STATE currentState, ACTION action); 49 | 50 | /// A class representing the state after the 51 | @immutable 52 | final class TransitionTo { 53 | /// Creates a new transition to with the given state 54 | const TransitionTo(this.toState); 55 | 56 | /// The state after the transition 57 | final STATE toState; 58 | } 59 | 60 | /// A class that performs type matching. 61 | /// By specifying this as the key of a Map, you can write branching logic that 62 | @immutable 63 | final class Matcher { 64 | /// Creates a new matcher 65 | const Matcher(); 66 | 67 | @override 68 | bool operator ==(Object other) { 69 | return other is Matcher; 70 | } 71 | 72 | @override 73 | int get hashCode => T.hashCode; 74 | 75 | /// Returns true if the given value matches the type T 76 | bool matches(dynamic value) { 77 | final result = value is T; 78 | return result; 79 | } 80 | } 81 | 82 | /// The type of function that the Builder for building the Graph receives 83 | typedef StateConfigBuilderFunction 85 | = StateConfigBuilder Function( 86 | StateConfigBuilder, 87 | ); 88 | 89 | /// Builder for building [GraphState] 90 | @immutable 91 | class StateConfigBuilder { 93 | final GraphState _stateFactor = GraphState({}); 94 | 95 | /// When a specific Action is dispatched, transition to the State specified in 96 | /// transition. 97 | void on( 98 | StateTransitionFunction transition, 99 | ) { 100 | assert( 101 | _stateFactor.transitionMap[Matcher()] == null, 102 | 'Duplicate action: $ON_ACTION', 103 | ); 104 | _stateFactor.transitionMap[Matcher()] = (currentState, action) { 105 | return transition(currentState as ON_STATE, action as ON_ACTION); 106 | }; 107 | } 108 | 109 | /// Use this when you want to execute AfterSideEffect without transitioning 110 | void noTransitionOn() { 111 | _stateFactor.transitionMap[Matcher()] = (currentState, action) { 112 | return TransitionTo(currentState as ON_STATE); 113 | }; 114 | } 115 | 116 | /// When any Action is dispatched, transition to the State specified in 117 | /// transition. 118 | void onAny(StateTransitionFunction transition) { 119 | on(transition); 120 | } 121 | 122 | /// Builds a GraphState 123 | GraphState build() { 124 | return _stateFactor; 125 | } 126 | 127 | /// A function to specify the state after the transition 128 | TransitionTo transitionTo(STATE newState) { 129 | return TransitionTo(newState); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/src/state_machine/graph/graph_builder.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | part of '../state_machine.dart'; 6 | 7 | /// A builder for building the [Graph] 8 | @immutable 9 | class GraphBuilder { 10 | /// 11 | final Map, GraphState> _stateConfigMap = {}; 12 | 13 | /// Used to define actions that can be taken in a specific state. 14 | /// When ACTION is dispatched while it's ON_STATE, the transition to the 15 | /// specified state will happen. 16 | void state( 17 | StateConfigBuilderFunction stateConfigBuilder, 18 | ) { 19 | assert( 20 | !_stateConfigMap.containsKey(Matcher()), 21 | 'Duplicate state: $ON_STATE', 22 | ); 23 | // Generate a StateConfigBuilder here and register it in the Map 24 | _stateConfigMap[Matcher()] = 25 | stateConfigBuilder(StateConfigBuilder()) 26 | .build(); 27 | } 28 | 29 | /// Builds a Graph 30 | Graph build() { 31 | return Graph(_stateConfigMap); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/state_machine/graph/transition.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | part of '../state_machine.dart'; 6 | 7 | /// A parent class for representing state transition patterns 8 | /// The parent of Valid and Invalid class, which holds the state before the 9 | /// transition and the action which caused the transition. 10 | sealed class Transition { 11 | const Transition(this.fromState, this.action); 12 | 13 | /// The state before the transition 14 | final STATE fromState; 15 | 16 | /// The action when the transition is made 17 | final ACTION action; 18 | } 19 | 20 | /// A class representing the state transition pattern when the transition is 21 | /// valid. In addition to the properties defined by the parent(Transition) 22 | /// it also holds the state after the transition.The state before the transition 23 | /// and the action are inherited from Transition, and the state after the 24 | /// transition is added. 25 | @immutable 26 | final class Valid 27 | extends Transition { 28 | /// Creates a new valid transition with the given state 29 | const Valid(super.fromState, super.action, this.toState); 30 | 31 | /// The state after the transition 32 | final STATE toState; 33 | } 34 | 35 | /// A class representing the state transition pattern when the transition is 36 | /// invalid. It has the same state before the transition and the action as 37 | /// Transition, but it does not have the state after the transition. 38 | @immutable 39 | final class Invalid 40 | extends Transition { 41 | /// Creates a new invalid transition 42 | const Invalid(super.fromState, super.action); 43 | } 44 | -------------------------------------------------------------------------------- /lib/src/state_machine/implementation/state_machine_impl.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | 7 | import 'package:collection/collection.dart'; 8 | import 'package:dart_fsm/dart_fsm.dart'; 9 | import 'package:dart_fsm/src/state_machine/graph/graph.dart'; 10 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_creator_interface.dart'; 11 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_interface.dart'; 12 | 13 | /// A state machine implementation. 14 | class StateMachineImpl 15 | implements StateMachine { 16 | /// Creates a state machine. 17 | /// [graphBuilder] is a builder for the state machine's graph. 18 | /// [initialState] is the initial state of the state machine. 19 | /// [sideEffectCreators] is a list of side effect creators. 20 | /// [subscriptions] is a list of subscriptions. 21 | StateMachineImpl({ 22 | required GraphBuilder graphBuilder, 23 | required STATE initialState, 24 | List> sideEffectCreators = 25 | const [], 26 | List> subscriptions = const [], 27 | }) : _initialState = initialState, 28 | _graph = graphBuilder.build(), 29 | _state = initialState, 30 | _sideEffectCreators = sideEffectCreators, 31 | _subscriptions = subscriptions { 32 | _controller.add(_initialState); 33 | if (_isEnd(_initialState)) { 34 | close(); 35 | } 36 | for (final subscription in subscriptions) { 37 | subscription.subscribe(this); 38 | } 39 | } 40 | 41 | final Graph _graph; 42 | 43 | final STATE _initialState; 44 | 45 | final List> _sideEffectCreators; 46 | 47 | final List> _subscriptions; 48 | 49 | final StreamController _controller = 50 | StreamController.broadcast(); 51 | 52 | @override 53 | Stream get stateStream => _controller.stream.asBroadcastStream(); 54 | 55 | @override 56 | STATE get state => _state; 57 | 58 | STATE _state; 59 | 60 | @override 61 | void close() { 62 | _controller.close(); 63 | for (final subscription in _subscriptions) { 64 | subscription.dispose(); 65 | } 66 | } 67 | 68 | @override 69 | void dispatch(ACTION action) { 70 | if (_controller.isClosed) { 71 | return; 72 | } 73 | beforeJob(action); 74 | final transition = findTransition(_state, action); 75 | if (transition is Valid) { 76 | _state = (transition as Valid).toState as STATE; 77 | _controller.add(_state); 78 | afterJob(action, transition as Valid); 79 | } 80 | finallyJob(transition); 81 | } 82 | 83 | /// Finds a [Transition] corresponding to [currentState] and [action] from the 84 | /// [Graph] of the [StateMachine]. 85 | /// If found, it returns the destination [STATE] as [Valid], and if not found, 86 | /// it returns [Invalid]. 87 | Transition findTransition(STATE currentState, ACTION action) { 88 | final stateConfig = 89 | _graph.transitionPatternMap.entries.firstWhereOrNull((element) { 90 | if (element.key.matches(currentState)) { 91 | return element.value.transitionMap.entries 92 | .firstWhereOrNull((element) => element.key.matches(action)) != 93 | null; 94 | } else { 95 | return false; 96 | } 97 | }); 98 | if (stateConfig == null) { 99 | return Invalid(currentState, action); 100 | } 101 | 102 | final transition = stateConfig.value.transitionMap.entries 103 | .firstWhere((element) => element.key.matches(action)) 104 | .value(currentState, action); 105 | 106 | return Valid(currentState, action, transition.toState); 107 | } 108 | 109 | /// Create [BeforeSideEffect] from [BeforeSideEffectCreator] 110 | List findBeforeJob(ACTION action) { 111 | final sideEffects = []; 112 | for (final sideEffectCreator 113 | in _sideEffectCreators.whereType()) { 114 | final sideEffect = sideEffectCreator.create(_state, action); 115 | if (sideEffect != null) { 116 | sideEffects.add(sideEffect); 117 | } 118 | } 119 | return sideEffects; 120 | } 121 | 122 | /// Execute [BeforeSideEffect] 123 | Future beforeJob(ACTION action) async { 124 | findBeforeJob(action).forEach((sideEffect) { 125 | unawaited(sideEffect.execute(_state, action)); 126 | }); 127 | } 128 | 129 | /// Create [AfterSideEffect] from [AfterSideEffectCreator] 130 | List findAfterJob( 131 | ACTION action, 132 | Valid validTransition, 133 | ) { 134 | final sideEffects = []; 135 | for (final sideEffectCreator 136 | in _sideEffectCreators.whereType()) { 137 | final sideEffect = 138 | sideEffectCreator.create(validTransition.fromState, action); 139 | if (sideEffect != null) { 140 | sideEffects.add(sideEffect); 141 | } 142 | } 143 | return sideEffects; 144 | } 145 | 146 | /// Execute [AfterSideEffect] 147 | Future afterJob( 148 | ACTION action, 149 | Valid validTransition, 150 | ) async { 151 | findAfterJob(action, validTransition).forEach((sideEffect) { 152 | unawaited(sideEffect.execute(this)); 153 | }); 154 | if (_isEnd(validTransition.toState)) { 155 | close(); 156 | } 157 | } 158 | 159 | /// Create [FinallySideEffect] from [FinallySideEffectCreator] 160 | List findFinallyJob( 161 | Transition transition, 162 | ) { 163 | final sideEffects = []; 164 | for (final sideEffectCreator 165 | in _sideEffectCreators.whereType()) { 166 | final sideEffect = 167 | sideEffectCreator.create(transition.fromState, transition.action); 168 | if (sideEffect != null) { 169 | sideEffects.add(sideEffect); 170 | } 171 | } 172 | return sideEffects; 173 | } 174 | 175 | /// Execute [FinallySideEffect] 176 | Future finallyJob(Transition transition) async { 177 | findFinallyJob(transition).forEach((sideEffect) { 178 | unawaited(sideEffect.execute(this, transition)); 179 | }); 180 | } 181 | 182 | /// Is the state end of [Graph]? 183 | bool _isEnd(STATE state) { 184 | return _graph.transitionPatternMap.entries 185 | .where((element) => element.key.matches(state)) 186 | .map((e) => e.value.transitionMap.entries) 187 | .expand((element) => element) 188 | .isEmpty; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /lib/src/state_machine/side_effect/side_effect_creator_interface.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_interface.dart'; 6 | 7 | /// Interface of the class that generates SideEffect 8 | // ignore: one_member_abstracts 9 | abstract interface class SideEffectCreator { 11 | const SideEffectCreator._(); // coverage:ignore-line 12 | 13 | /// Create a [SIDE_EFFECT] from the [STATE] and [ACTION] before the transition 14 | /// [prevState] The state before the transition 15 | /// [action] The action that was executed 16 | /// [SIDE_EFFECT] The generated side effect 17 | SIDE_EFFECT? create(STATE prevState, ACTION action); 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/state_machine/side_effect/side_effect_creators.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | part of '../state_machine.dart'; 5 | 6 | /// Interface of the class that generates SideEffect after the transition 7 | abstract interface class AfterSideEffectCreator 9 | implements SideEffectCreator { 10 | const AfterSideEffectCreator._(); // coverage:ignore-line 11 | } 12 | 13 | /// Interface of the class that generates SideEffect before the transition 14 | abstract interface class BeforeSideEffectCreator 16 | implements SideEffectCreator { 17 | const BeforeSideEffectCreator._(); // coverage:ignore-line 18 | } 19 | 20 | /// Interface of the class that generates SideEffect that is executed at the 21 | /// end of the process when an Action is dispatched regardless of whether 22 | /// a transition is made 23 | abstract interface class FinallySideEffectCreator 25 | implements SideEffectCreator { 26 | const FinallySideEffectCreator._(); // coverage:ignore-line 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/state_machine/side_effect/side_effect_interface.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | 7 | /// The abstract class of side effects generated by the transition of 8 | /// [StateMachine] 9 | /// [SideEffect] can be one of: 10 | /// [AfterSideEffect] - executed after the transition, 11 | /// [BeforeSideEffect] - executed before the transition, 12 | /// [FinallySideEffect] - executed regardless of whether a transition is made 13 | /// after an Action is dispatched. 14 | abstract interface class SideEffect { 15 | const SideEffect(); // coverage:ignore-line 16 | } 17 | -------------------------------------------------------------------------------- /lib/src/state_machine/side_effect/side_effects.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | part of '../state_machine.dart'; 6 | 7 | /// A type of [SideEffect] which is executed after the [ACTION] is dispatched 8 | /// and then the [STATE] is changed accordingly. This [SideEffect] is executed 9 | /// only when the transition is successfully made. 10 | abstract interface class AfterSideEffect implements SideEffect { 12 | const AfterSideEffect(); // coverage:ignore-line 13 | 14 | /// The method executed after the instance of [AfterSideEffect] is generated 15 | /// by [AfterSideEffectCreator]. 16 | /// The current [StateMachine] and the [Transition] when this [SideEffect] was 17 | /// generated are passed as arguments. 18 | Future execute( 19 | StateMachine stateMachine, 20 | ); 21 | } 22 | 23 | /// A type of [SideEffect] executed after the [ACTION] is dispatched, before the 24 | /// [STATE] is changed, and regardless of whether the transition is made. 25 | abstract interface class BeforeSideEffect implements SideEffect { 27 | const BeforeSideEffect(); // coverage:ignore-line 28 | 29 | /// The method executed after the instance of [BeforeSideEffect] is generated 30 | /// by [BeforeSideEffectCreator]. 31 | /// The current [STATE] and the [ACTION] when this [SideEffect] was generated 32 | /// are passed as arguments. 33 | Future execute( 34 | STATE currentState, 35 | ACTION action, 36 | ); 37 | } 38 | 39 | /// A type of [SideEffect] executed after the [ACTION] is dispatched, after all 40 | /// other processes are finished, and regardless of whether the transition is 41 | /// made. 42 | abstract interface class FinallySideEffect implements SideEffect { 44 | const FinallySideEffect(); // coverage:ignore-line 45 | 46 | /// The method executed after the instance of [FinallySideEffect] is generated 47 | /// by [FinallySideEffectCreator]. 48 | /// The current [STATE] and the [ACTION] when this [SideEffect] was generated 49 | /// are passed as arguments. 50 | Future execute( 51 | StateMachine stateMachine, 52 | Transition transition, 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /lib/src/state_machine/state_machine.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | 7 | import 'package:dart_fsm/src/state_machine/graph/graph.dart'; 8 | import 'package:dart_fsm/src/state_machine/implementation/state_machine_impl.dart'; 9 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_creator_interface.dart'; 10 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_interface.dart'; 11 | import 'package:meta/meta.dart'; 12 | 13 | part './graph/graph_builder.dart'; 14 | part './graph/transition.dart'; 15 | part './side_effect/side_effect_creators.dart'; 16 | part './side_effect/side_effects.dart'; 17 | part './subscription/subscription.dart'; 18 | part 'state_machine_creator.dart'; 19 | 20 | /// A state machine. 21 | abstract interface class StateMachine { 23 | const StateMachine(); // coverage:ignore-line 24 | 25 | /// The current state of the state machine. 26 | STATE get state; 27 | 28 | /// A stream of the state machine's state. 29 | Stream get stateStream; 30 | 31 | /// Dispatches an action to the state machine. 32 | void dispatch(ACTION action); 33 | 34 | /// Closes the state machine and releases resources. 35 | void close(); 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/state_machine/state_machine_creator.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | part of 'state_machine.dart'; 6 | 7 | /// This function creates a [StateMachine] with the given [graphBuilder], 8 | /// [initialState], [sideEffectCreators], and [subscriptions]. 9 | /// This function exists to hide [StateMachineImpl]. 10 | StateMachine 11 | createStateMachine({ 12 | required GraphBuilder graphBuilder, 13 | required STATE initialState, 14 | List> sideEffectCreators = 15 | const [], 16 | List> subscriptions = const [], 17 | }) { 18 | return StateMachineImpl( 19 | graphBuilder: graphBuilder, 20 | initialState: initialState, 21 | sideEffectCreators: sideEffectCreators, 22 | subscriptions: subscriptions, 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/state_machine/subscription/subscription.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | part of '../state_machine.dart'; 6 | 7 | /// [Subscription] is a component which can be registered to [StateMachine] to 8 | /// monitor changes of the state. 9 | /// This is mainly used to monitor a change in state and to instruct 10 | /// [StateMachine] to perform some processing based on that change. 11 | /// For example, it is conceivable to monitor the connection status of WebSocket 12 | /// and instruct [StateMachine] to attempt to reconnect if the connection 13 | /// is lost. 14 | // ignore: one_member_abstracts 15 | abstract interface class Subscription { 17 | const Subscription(); // coverage:ignore-line 18 | 19 | /// The method executed after the instance of [Subscription] is registered 20 | /// with [StateMachine]. 21 | /// The current [StateMachine] is passed as an argument. 22 | /// This method is used to instruct [StateMachine] to perform some processing 23 | /// based on the change in state. 24 | void subscribe( 25 | StateMachine stateMachine, 26 | ); 27 | 28 | /// The method executed after the instance of [Subscription] is unregistered 29 | void dispose(); 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/tester/mock_state_machine.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | 7 | import 'package:dart_fsm/dart_fsm.dart'; 8 | import 'package:test/test.dart'; 9 | 10 | /// This is a mock implementation of [StateMachine] that can be used for testing 11 | class MockStateMachine 12 | implements StateMachine { 13 | /// Creates a mock state machine. 14 | MockStateMachine(STATE initialState) : _state = initialState { 15 | _controller.close(); 16 | } 17 | 18 | /// The actions that have been dispatched to the state machine. 19 | List get dispatchedActions => _dispatchedActions; 20 | 21 | /// The latest action that was dispatched to the state machine. 22 | void expectLatestDispatch(ACTION action) { 23 | expect(_dispatchedActions.last, action); 24 | } 25 | 26 | final _dispatchedActions = []; 27 | 28 | final STATE _state; 29 | 30 | final _controller = StreamController.broadcast(); 31 | 32 | @override 33 | void close() { 34 | _controller.close(); 35 | } 36 | 37 | @override 38 | void dispatch(ACTION action) { 39 | _dispatchedActions.add(action); 40 | } 41 | 42 | @override 43 | STATE get state => _state; 44 | 45 | @override 46 | Stream get stateStream => _controller.stream.asBroadcastStream(); 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/tester/state_machine_tester.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:collection/collection.dart'; 6 | import 'package:dart_fsm/dart_fsm.dart'; 7 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_creator_interface.dart'; 8 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_interface.dart'; 9 | import 'package:dart_fsm/src/tester/tester_state_machine.dart'; 10 | import 'package:test/test.dart'; 11 | 12 | /// This class is used to test the state machine. 13 | class StateMachineTester { 14 | /// Creates a state machine tester. 15 | StateMachineTester({ 16 | required GraphBuilder graphBuilder, 17 | List> sideEffectCreators = 18 | const [], 19 | }) : _sideEffectCreators = sideEffectCreators, 20 | _graphBuilder = graphBuilder; 21 | 22 | final List> _sideEffectCreators; 23 | final GraphBuilder _graphBuilder; 24 | 25 | final Map>> _testCases = {}; 26 | 27 | /// Sets the test case. 28 | void setTestCase({ 29 | required STATE beforeState, 30 | required List> testCases, 31 | }) { 32 | _testCases[beforeState] = testCases; 33 | } 34 | 35 | TesterStateMachine _createStateMachine( 36 | STATE beforeState, 37 | ) { 38 | return TesterStateMachine( 39 | graphBuilder: _graphBuilder, 40 | initialState: beforeState, 41 | sideEffectCreators: _sideEffectCreators, 42 | ); 43 | } 44 | 45 | /// Runs the test. 46 | void runTest() { 47 | for (final entry in _testCases.entries) { 48 | for (final obj in entry.value) { 49 | final stateMachine = _createStateMachine(entry.key) 50 | ..dispatch(obj.action); 51 | test( 52 | 'When ${entry.key.runtimeType} dispatch ${obj.action.runtimeType}', 53 | () { 54 | if (stateMachine.isPrevTransitionValid) { 55 | if (stateMachine.state != obj.afterState) { 56 | fail( 57 | // ignore: lines_longer_than_80_chars 58 | 'The state after the transition is different from the expected value' 59 | '\n${obj.createFailMessage(stateMachine)}', 60 | ); 61 | } 62 | for (final e in stateMachine.createdSideEffect) { 63 | final matchedSideEffect = 64 | obj.createdSideEffect.firstWhereOrNull( 65 | (element) => element.runtimeType == e.runtimeType, 66 | ); 67 | if (matchedSideEffect == null) { 68 | fail( 69 | // ignore: lines_longer_than_80_chars 70 | 'A different side effect was generated from the expected value' 71 | '\n${obj.createFailMessage(stateMachine)}', 72 | ); 73 | } 74 | } 75 | } 76 | }, 77 | ); 78 | stateMachine.close(); 79 | } 80 | } 81 | } 82 | } 83 | 84 | /// This class is used to store the test case information. 85 | final class SMAssertObject { 86 | /// Creates a test case object. 87 | const SMAssertObject({ 88 | required this.action, 89 | this.afterState, 90 | this.createdSideEffect = const [], 91 | }); 92 | 93 | /// The action to be dispatched. 94 | final A action; 95 | 96 | /// The expected state after the action is dispatched. 97 | final S? afterState; 98 | 99 | /// The expected side effect after the action is dispatched. 100 | final List createdSideEffect; 101 | 102 | /// Create a fail message. 103 | String createFailMessage(TesterStateMachine stateMachine) { 104 | final message = StringBuffer() 105 | ..writeln('Expected State: ${afterState.runtimeType}') 106 | ..writeln('Actual State: ${stateMachine.state.runtimeType}') 107 | ..write('Expected SideEffect: ') 108 | ..writeln(createdSideEffect.map((e) => e.runtimeType).toList()) 109 | ..write('Actual SideEffect: ') 110 | ..writeln( 111 | stateMachine.createdSideEffect.map((e) => e.runtimeType).toList(), 112 | ); 113 | return message.toString(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /lib/src/tester/tester_state_machine.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | import 'package:dart_fsm/src/state_machine/implementation/state_machine_impl.dart'; 7 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_creator_interface.dart'; 8 | import 'package:dart_fsm/src/state_machine/side_effect/side_effect_interface.dart'; 9 | 10 | /// A state machine for testing. 11 | class TesterStateMachine 12 | extends StateMachineImpl { 13 | /// Creates a state machine for testing. 14 | TesterStateMachine({ 15 | required super.graphBuilder, 16 | required super.initialState, 17 | List> 18 | super.sideEffectCreators = const [], 19 | super.subscriptions, 20 | }); 21 | 22 | /// The created side effects. 23 | List get createdSideEffect => _createdSideEffect; 24 | 25 | final List _createdSideEffect = []; 26 | 27 | /// Whether the previous transition is valid. 28 | bool get isPrevTransitionValid => _isPrevTransitionValid; 29 | 30 | bool _isPrevTransitionValid = false; 31 | 32 | @override 33 | void dispatch(ACTION action) { 34 | _createdSideEffect.clear(); 35 | _isPrevTransitionValid = findTransition(state, action) is Valid; 36 | super.dispatch(action); 37 | } 38 | 39 | @override 40 | Future beforeJob(ACTION action) async { 41 | findBeforeJob(action).forEach(_createdSideEffect.add); 42 | } 43 | 44 | @override 45 | Future afterJob( 46 | ACTION action, 47 | Valid validTransition, 48 | ) async { 49 | findAfterJob(action, validTransition).forEach(_createdSideEffect.add); 50 | } 51 | 52 | @override 53 | Future finallyJob(Transition transition) async { 54 | findFinallyJob(transition).forEach(_createdSideEffect.add); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: dart_fsm 2 | description: A package for implementing finite state machines in Dart. 3 | version: 1.2.2 4 | homepage: https://team-lab.com 5 | repository: https://github.com/team-lab/dart_fsm 6 | 7 | environment: 8 | sdk: ">=3.4.0 <4.0.0" 9 | 10 | # Add regular dependencies here. 11 | dependencies: 12 | collection: ^1.18.0 13 | meta: ^1.15.0 14 | test: ^1.24.0 15 | 16 | dev_dependencies: 17 | lints: ^3.0.0 18 | very_good_analysis: ^5.1.0 19 | -------------------------------------------------------------------------------- /test/after_side_effect_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import 'test_state_machine/test_side_effect_creators.dart'; 9 | import 'test_state_machine/test_side_effects.dart'; 10 | import 'test_state_machine/test_state_graph.dart'; 11 | import 'test_state_machine/test_state_machine_action.dart'; 12 | import 'test_state_machine/test_state_machine_state.dart'; 13 | 14 | void main() { 15 | group('AfterSideEffectCreator test', () { 16 | test('create method called when valid transition', () { 17 | var isSideEffectCreatorCalled = false; 18 | 19 | final sideEffectCreator = TestAfterSideEffectCreator( 20 | (prevState, action) { 21 | expect(prevState, const TestStateA()); 22 | expect(action, const TestActionA()); 23 | isSideEffectCreatorCalled = true; 24 | return null; 25 | }, 26 | ); 27 | 28 | createStateMachine( 29 | initialState: const TestStateA(), 30 | graphBuilder: testStateGraph, 31 | sideEffectCreators: [sideEffectCreator], 32 | ).dispatch(const TestActionA()); 33 | 34 | expect(isSideEffectCreatorCalled, isTrue); 35 | }); 36 | 37 | test('create method called when noTransition valid transition', () { 38 | var isSideEffectCreatorCalled = false; 39 | 40 | final sideEffectCreator = TestAfterSideEffectCreator( 41 | (prevState, action) { 42 | expect(prevState, const TestStateC()); 43 | expect(action, const TestActionD()); 44 | isSideEffectCreatorCalled = true; 45 | return null; 46 | }, 47 | ); 48 | 49 | createStateMachine( 50 | initialState: const TestStateC(), 51 | graphBuilder: testStateGraph, 52 | sideEffectCreators: [sideEffectCreator], 53 | ).dispatch(const TestActionD()); 54 | 55 | expect(isSideEffectCreatorCalled, isTrue); 56 | }); 57 | 58 | test('not create method called when invalid transition', () { 59 | var isSideEffectCreatorCalled = false; 60 | 61 | final sideEffectCreator = TestAfterSideEffectCreator( 62 | (prevState, action) { 63 | isSideEffectCreatorCalled = true; 64 | return null; 65 | }, 66 | ); 67 | 68 | createStateMachine( 69 | initialState: const TestStateA(), 70 | graphBuilder: testStateGraph, 71 | sideEffectCreators: [sideEffectCreator], 72 | ).dispatch(const TestActionB()); 73 | 74 | expect(isSideEffectCreatorCalled, isFalse); 75 | }); 76 | 77 | test('execute method called when create method returns side effect', () { 78 | var isSideEffectExecuteCalled = false; 79 | 80 | final sideEffectCreator = TestAfterSideEffectCreator( 81 | (prevState, action) { 82 | return TestAfterSideEffect( 83 | (stateMachine) { 84 | // state transition is done 85 | expect(stateMachine.state, const TestStateB()); 86 | isSideEffectExecuteCalled = true; 87 | return Future.value(); 88 | }, 89 | ); 90 | }, 91 | ); 92 | 93 | createStateMachine( 94 | initialState: const TestStateA(), 95 | graphBuilder: testStateGraph, 96 | sideEffectCreators: [sideEffectCreator], 97 | ).dispatch(const TestActionA()); 98 | 99 | expect(isSideEffectExecuteCalled, isTrue); 100 | }); 101 | 102 | test( 103 | // ignore: lines_longer_than_80_chars 104 | 'execute method called when create method returns side effect and noTransition valid transition', 105 | () { 106 | var isSideEffectExecuteCalled = false; 107 | 108 | final sideEffectCreator = TestAfterSideEffectCreator( 109 | (prevState, action) { 110 | return TestAfterSideEffect( 111 | (stateMachine) { 112 | // state transition is done 113 | expect(stateMachine.state, const TestStateC()); 114 | isSideEffectExecuteCalled = true; 115 | return Future.value(); 116 | }, 117 | ); 118 | }, 119 | ); 120 | 121 | createStateMachine( 122 | initialState: const TestStateC(), 123 | graphBuilder: testStateGraph, 124 | sideEffectCreators: [sideEffectCreator], 125 | ).dispatch(const TestActionD()); 126 | 127 | expect(isSideEffectExecuteCalled, isTrue); 128 | }); 129 | }); 130 | } 131 | -------------------------------------------------------------------------------- /test/before_side_effect_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import 'test_state_machine/test_side_effect_creators.dart'; 9 | import 'test_state_machine/test_side_effects.dart'; 10 | import 'test_state_machine/test_state_graph.dart'; 11 | import 'test_state_machine/test_state_machine_action.dart'; 12 | import 'test_state_machine/test_state_machine_state.dart'; 13 | 14 | void main() { 15 | group('BeforeSideEffectCreator test', () { 16 | test('create method called when valid transition', () { 17 | var isSideEffectCreatorCalled = false; 18 | 19 | final sideEffectCreator = TestBeforeSideEffectCreator( 20 | (prevState, action) { 21 | expect(prevState, const TestStateA()); 22 | expect(action, const TestActionA()); 23 | isSideEffectCreatorCalled = true; 24 | return null; 25 | }, 26 | ); 27 | 28 | createStateMachine( 29 | initialState: const TestStateA(), 30 | graphBuilder: testStateGraph, 31 | sideEffectCreators: [sideEffectCreator], 32 | ).dispatch(const TestActionA()); 33 | 34 | expect(isSideEffectCreatorCalled, isTrue); 35 | }); 36 | 37 | test('create method called when invalid transition', () { 38 | var isSideEffectCreatorCalled = false; 39 | 40 | final sideEffectCreator = TestBeforeSideEffectCreator( 41 | (prevState, action) { 42 | expect(prevState, const TestStateA()); 43 | expect(action, const TestActionB()); 44 | isSideEffectCreatorCalled = true; 45 | return null; 46 | }, 47 | ); 48 | 49 | createStateMachine( 50 | initialState: const TestStateA(), 51 | graphBuilder: testStateGraph, 52 | sideEffectCreators: [sideEffectCreator], 53 | ).dispatch(const TestActionB()); 54 | 55 | expect(isSideEffectCreatorCalled, isTrue); 56 | }); 57 | 58 | test('execute method called when create method returns side effect', () { 59 | var isSideEffectExecuteCalled = false; 60 | 61 | final sideEffectCreator = TestBeforeSideEffectCreator( 62 | (prevState, action) { 63 | return TestBeforeSideEffect( 64 | (currentState, action) async { 65 | expect(currentState, const TestStateA()); 66 | expect(action, const TestActionA()); 67 | isSideEffectExecuteCalled = true; 68 | }, 69 | ); 70 | }, 71 | ); 72 | 73 | createStateMachine( 74 | initialState: const TestStateA(), 75 | graphBuilder: testStateGraph, 76 | sideEffectCreators: [sideEffectCreator], 77 | ).dispatch(const TestActionA()); 78 | 79 | expect(isSideEffectExecuteCalled, isTrue); 80 | }); 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /test/finally_side_effect_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import 'test_state_machine/test_side_effect_creators.dart'; 9 | import 'test_state_machine/test_side_effects.dart'; 10 | import 'test_state_machine/test_state_graph.dart'; 11 | import 'test_state_machine/test_state_machine_action.dart'; 12 | import 'test_state_machine/test_state_machine_state.dart'; 13 | 14 | void main() { 15 | group('FinallySideEffectCreator test', () { 16 | test('create method called when valid transition', () { 17 | var isSideEffectCreatorCalled = false; 18 | 19 | final sideEffectCreator = TestFinallySideEffectCreator( 20 | (prevState, action) { 21 | expect(prevState, const TestStateA()); 22 | expect(action, const TestActionA()); 23 | isSideEffectCreatorCalled = true; 24 | return null; 25 | }, 26 | ); 27 | 28 | createStateMachine( 29 | initialState: const TestStateA(), 30 | graphBuilder: testStateGraph, 31 | sideEffectCreators: [sideEffectCreator], 32 | ).dispatch(const TestActionA()); 33 | 34 | expect(isSideEffectCreatorCalled, isTrue); 35 | }); 36 | 37 | test('create method called when invalid transition', () { 38 | var isSideEffectCreatorCalled = false; 39 | 40 | final sideEffectCreator = TestFinallySideEffectCreator( 41 | (prevState, action) { 42 | expect(prevState, const TestStateA()); 43 | expect(action, const TestActionB()); 44 | isSideEffectCreatorCalled = true; 45 | return null; 46 | }, 47 | ); 48 | 49 | createStateMachine( 50 | initialState: const TestStateA(), 51 | graphBuilder: testStateGraph, 52 | sideEffectCreators: [sideEffectCreator], 53 | ).dispatch(const TestActionB()); 54 | 55 | expect(isSideEffectCreatorCalled, isTrue); 56 | }); 57 | 58 | test( 59 | // ignore: lines_longer_than_80_chars 60 | 'execute method called when create method returns side effect and transition was valid', 61 | () { 62 | var isSideEffectExecuteCalled = false; 63 | 64 | final sideEffectCreator = TestFinallySideEffectCreator( 65 | (prevState, action) { 66 | return TestFinallySideEffect( 67 | (stateMachine, transition) { 68 | // state transition is done 69 | expect(stateMachine.state, const TestStateB()); 70 | expect( 71 | (transition as Valid).fromState, 72 | const TestStateA(), 73 | ); 74 | expect(transition.toState, const TestStateB()); 75 | expect(transition.action, const TestActionA()); 76 | isSideEffectExecuteCalled = true; 77 | return Future.value(); 78 | }, 79 | ); 80 | }, 81 | ); 82 | 83 | createStateMachine( 84 | initialState: const TestStateA(), 85 | graphBuilder: testStateGraph, 86 | sideEffectCreators: [sideEffectCreator], 87 | ).dispatch(const TestActionA()); 88 | 89 | expect(isSideEffectExecuteCalled, isTrue); 90 | }); 91 | 92 | test( 93 | // ignore: lines_longer_than_80_chars 94 | 'execute method called when create method returns side effect and transition was invalid', 95 | () { 96 | var isSideEffectExecuteCalled = false; 97 | 98 | final sideEffectCreator = TestFinallySideEffectCreator( 99 | (prevState, action) { 100 | return TestFinallySideEffect( 101 | (stateMachine, transition) { 102 | // state transition is done 103 | expect(stateMachine.state, const TestStateA()); 104 | expect( 105 | (transition as Invalid).fromState, 106 | const TestStateA(), 107 | ); 108 | expect(transition.action, const TestActionB()); 109 | isSideEffectExecuteCalled = true; 110 | return Future.value(); 111 | }, 112 | ); 113 | }, 114 | ); 115 | 116 | createStateMachine( 117 | initialState: const TestStateA(), 118 | graphBuilder: testStateGraph, 119 | sideEffectCreators: [sideEffectCreator], 120 | ).dispatch(const TestActionB()); 121 | 122 | expect(isSideEffectExecuteCalled, isTrue); 123 | }); 124 | }); 125 | } 126 | -------------------------------------------------------------------------------- /test/state_machine_close_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import 'test_state_machine/test_state_machine_action.dart'; 9 | import 'test_state_machine/test_state_machine_state.dart'; 10 | import 'test_state_machine/test_subscription.dart'; 11 | 12 | void main() { 13 | group('StateMachine Close Test', () { 14 | final simpleTestStateGraph = GraphBuilder() 15 | ..state( 16 | (b) => b 17 | ..on( 18 | (state, action) => b.transitionTo(const TestStateB()), 19 | ), 20 | ); 21 | 22 | test('close method called when state machine state is end', () { 23 | var isCloseCalled = false; 24 | 25 | final subscription = TestSubscription( 26 | testSubscribe: (stateMachine) {}, 27 | testDispose: () { 28 | isCloseCalled = true; 29 | }, 30 | ); 31 | 32 | final stateMachine = createStateMachine( 33 | initialState: const TestStateA(), 34 | graphBuilder: simpleTestStateGraph, 35 | subscriptions: [subscription], 36 | ); 37 | 38 | expect(isCloseCalled, isFalse); 39 | 40 | stateMachine.dispatch(const TestActionA()); 41 | 42 | expect(isCloseCalled, isTrue); 43 | }); 44 | 45 | test('close method called when state machine initial state is end', () { 46 | var isCloseCalled = false; 47 | 48 | final subscription = TestSubscription( 49 | testSubscribe: (stateMachine) {}, 50 | testDispose: () { 51 | isCloseCalled = true; 52 | }, 53 | ); 54 | 55 | createStateMachine( 56 | initialState: const TestStateB(), 57 | graphBuilder: simpleTestStateGraph, 58 | subscriptions: [subscription], 59 | ); 60 | 61 | expect(isCloseCalled, isTrue); 62 | }); 63 | 64 | test('dispatch after state machine is closed should be ignored', () { 65 | final stateMachine = createStateMachine( 66 | initialState: const TestStateA(), 67 | graphBuilder: simpleTestStateGraph, 68 | ) 69 | ..close() 70 | ..dispatch(const TestActionA()); 71 | 72 | expect(stateMachine.state, const TestStateA()); 73 | }); 74 | 75 | test('dispatch after state machine is closed should not throw error', () { 76 | final stateMachine = createStateMachine( 77 | initialState: const TestStateA(), 78 | graphBuilder: simpleTestStateGraph, 79 | )..close(); 80 | 81 | expect(() => stateMachine.dispatch(const TestActionA()), returnsNormally); 82 | }); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /test/subscription_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import 'test_state_machine/test_state_graph.dart'; 9 | import 'test_state_machine/test_state_machine_state.dart'; 10 | import 'test_state_machine/test_subscription.dart'; 11 | 12 | void main() { 13 | group('Subscription Test', () { 14 | test('subscribe method called when state machine created', () { 15 | var isSubscriptionCalled = false; 16 | 17 | final subscription = TestSubscription( 18 | testSubscribe: (stateMachine) { 19 | expect(stateMachine.state, const TestStateA()); 20 | isSubscriptionCalled = true; 21 | }, 22 | testDispose: () {}, 23 | ); 24 | 25 | createStateMachine( 26 | initialState: const TestStateA(), 27 | graphBuilder: testStateGraph, 28 | subscriptions: [subscription], 29 | ); 30 | 31 | expect(isSubscriptionCalled, isTrue); 32 | }); 33 | 34 | test('dispose method called when valid transition', () { 35 | var isDisposeCalled = false; 36 | 37 | final subscription = TestSubscription( 38 | testSubscribe: (stateMachine) {}, 39 | testDispose: () { 40 | isDisposeCalled = true; 41 | }, 42 | ); 43 | 44 | final stateMachine = createStateMachine( 45 | initialState: const TestStateA(), 46 | graphBuilder: testStateGraph, 47 | subscriptions: [subscription], 48 | ); 49 | 50 | expect(isDisposeCalled, isFalse); 51 | 52 | stateMachine.close(); 53 | 54 | expect(isDisposeCalled, isTrue); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /test/test_state_machine/test_side_effect_creators.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | 7 | import 'test_side_effects.dart'; 8 | import 'test_state_machine_action.dart'; 9 | import 'test_state_machine_state.dart'; 10 | 11 | final class TestBeforeSideEffectCreator 12 | implements 13 | BeforeSideEffectCreator { 14 | const TestBeforeSideEffectCreator(this.testCreate); 15 | 16 | final TestBeforeSideEffect? Function( 17 | TestState prevState, 18 | TestAction action, 19 | ) testCreate; 20 | 21 | @override 22 | TestBeforeSideEffect? create(TestState prevState, TestAction action) { 23 | return testCreate(prevState, action); 24 | } 25 | } 26 | 27 | final class TestAfterSideEffectCreator 28 | implements 29 | AfterSideEffectCreator { 30 | const TestAfterSideEffectCreator(this.testCreate); 31 | 32 | final TestAfterSideEffect? Function( 33 | TestState prevState, 34 | TestAction action, 35 | ) testCreate; 36 | 37 | @override 38 | TestAfterSideEffect? create(TestState prevState, TestAction action) { 39 | return testCreate(prevState, action); 40 | } 41 | } 42 | 43 | final class TestFinallySideEffectCreator 44 | implements 45 | FinallySideEffectCreator { 46 | const TestFinallySideEffectCreator(this.testCreate); 47 | 48 | final TestFinallySideEffect? Function( 49 | TestState prevState, 50 | TestAction action, 51 | ) testCreate; 52 | 53 | @override 54 | TestFinallySideEffect? create(TestState prevState, TestAction action) { 55 | return testCreate(prevState, action); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/test_state_machine/test_side_effects.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | 7 | import 'test_state_machine_action.dart'; 8 | import 'test_state_machine_state.dart'; 9 | 10 | final class TestBeforeSideEffect 11 | implements BeforeSideEffect { 12 | const TestBeforeSideEffect(this.testExecute); 13 | 14 | final Future Function(TestState currentState, TestAction action) 15 | testExecute; 16 | 17 | @override 18 | Future execute(TestState currentState, TestAction action) async { 19 | await testExecute(currentState, action); 20 | } 21 | } 22 | 23 | final class TestAfterSideEffect 24 | implements AfterSideEffect { 25 | const TestAfterSideEffect(this.testExecute); 26 | 27 | final Future Function( 28 | StateMachine stateMachine, 29 | ) testExecute; 30 | 31 | @override 32 | Future execute( 33 | StateMachine stateMachine, 34 | ) async { 35 | await testExecute(stateMachine); 36 | } 37 | } 38 | 39 | final class TestFinallySideEffect 40 | implements FinallySideEffect { 41 | const TestFinallySideEffect(this.testExecute); 42 | 43 | final Future Function( 44 | StateMachine stateMachine, 45 | Transition transition, 46 | ) testExecute; 47 | 48 | @override 49 | Future execute( 50 | StateMachine stateMachine, 51 | Transition transition, 52 | ) async { 53 | await testExecute(stateMachine, transition); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/test_state_machine/test_state_graph.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | 7 | import 'test_state_machine_action.dart'; 8 | import 'test_state_machine_state.dart'; 9 | 10 | /* 11 | ┌─────ActionB──────┐ 12 | │ │ 13 | │ ┌──ActionA───►StateB 14 | │ │ ┌───┐ 15 | ▼ │ │ │ 16 | StateA┼──ActionC───►StateC ActionD 17 | ▲ │ ▲ │ 18 | │ │ └───┘ 19 | │ └──ActionD───►StateD 20 | │ │ 21 | └─────AnyAction────┘ 22 | */ 23 | 24 | final testStateGraph = GraphBuilder() 25 | ..state( 26 | (b) => b 27 | ..on( 28 | (state, action) => b.transitionTo(const TestStateB()), 29 | ) 30 | ..on( 31 | (state, action) => b.transitionTo(const TestStateC()), 32 | ) 33 | ..on( 34 | (state, action) => b.transitionTo(const TestStateD()), 35 | ), 36 | ) 37 | ..state( 38 | (b) => b 39 | ..on( 40 | (state, action) => b.transitionTo(const TestStateA()), 41 | ), 42 | ) 43 | ..state( 44 | (b) => b..noTransitionOn(), 45 | ) 46 | ..state( 47 | (b) => b 48 | ..onAny( 49 | (state, action) => b.transitionTo(const TestStateA()), 50 | ), 51 | ); 52 | -------------------------------------------------------------------------------- /test/test_state_machine/test_state_machine_action.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | sealed class TestAction { 6 | const TestAction(); 7 | } 8 | 9 | final class TestActionA extends TestAction { 10 | const TestActionA(); 11 | } 12 | 13 | final class TestActionB extends TestAction { 14 | const TestActionB(); 15 | } 16 | 17 | final class TestActionC extends TestAction { 18 | const TestActionC(); 19 | } 20 | 21 | final class TestActionD extends TestAction { 22 | const TestActionD(); 23 | } 24 | -------------------------------------------------------------------------------- /test/test_state_machine/test_state_machine_state.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | sealed class TestState { 6 | const TestState(); 7 | } 8 | 9 | final class TestStateA extends TestState { 10 | const TestStateA(); 11 | } 12 | 13 | final class TestStateB extends TestState { 14 | const TestStateB(); 15 | } 16 | 17 | final class TestStateC extends TestState { 18 | const TestStateC(); 19 | } 20 | 21 | final class TestStateD extends TestState { 22 | const TestStateD(); 23 | } 24 | -------------------------------------------------------------------------------- /test/test_state_machine/test_subscription.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | 7 | import 'test_state_machine_action.dart'; 8 | import 'test_state_machine_state.dart'; 9 | 10 | final class TestSubscription implements Subscription { 11 | const TestSubscription({ 12 | required this.testSubscribe, 13 | required this.testDispose, 14 | }); 15 | 16 | final void Function(StateMachine stateMachine) 17 | testSubscribe; 18 | final void Function() testDispose; 19 | 20 | @override 21 | void subscribe(StateMachine stateMachine) { 22 | testSubscribe(stateMachine); 23 | } 24 | 25 | @override 26 | void dispose() { 27 | testDispose(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/transition_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, teamLab inc. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'package:dart_fsm/dart_fsm.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import 'test_state_machine/test_state_graph.dart'; 9 | import 'test_state_machine/test_state_machine_action.dart'; 10 | import 'test_state_machine/test_state_machine_state.dart'; 11 | 12 | /* 13 | ┌─────ActionB──────┐ 14 | │ │ 15 | │ ┌──ActionA───►StateB 16 | │ │ ┌───┐ 17 | ▼ │ │ │ 18 | StateA┼──ActionC───►StateC ActionD 19 | ▲ │ ▲ │ 20 | │ │ └───┘ 21 | │ └──ActionD───►StateD 22 | │ │ 23 | └─────AnyAction────┘ 24 | */ 25 | 26 | void main() { 27 | group('Transition Test', () { 28 | test('transition to next state when valid transition', () { 29 | final stateMachine = createStateMachine( 30 | initialState: const TestStateA(), 31 | graphBuilder: testStateGraph, 32 | ); 33 | 34 | expect(stateMachine.state, const TestStateA()); 35 | 36 | stateMachine.dispatch(const TestActionA()); 37 | 38 | expect(stateMachine.state, const TestStateB()); 39 | }); 40 | test('not transition to next state when invalid action', () { 41 | final stateMachine = createStateMachine( 42 | initialState: const TestStateA(), 43 | graphBuilder: testStateGraph, 44 | ); 45 | 46 | expect(stateMachine.state, const TestStateA()); 47 | 48 | stateMachine.dispatch(const TestActionB()); 49 | 50 | expect(stateMachine.state, const TestStateA()); 51 | }); 52 | test('not transition to next state when no transition', () { 53 | final stateMachine = createStateMachine( 54 | initialState: const TestStateC(), 55 | graphBuilder: testStateGraph, 56 | ); 57 | 58 | expect(stateMachine.state, const TestStateC()); 59 | 60 | stateMachine.dispatch(const TestActionD()); 61 | 62 | expect(stateMachine.state, const TestStateC()); 63 | }); 64 | test('transition to next state when any action', () { 65 | final actions = [ 66 | const TestActionA(), 67 | const TestActionB(), 68 | const TestActionC(), 69 | const TestActionD(), 70 | ]; 71 | for (final action in actions) { 72 | final stateMachine = createStateMachine( 73 | initialState: const TestStateD(), 74 | graphBuilder: testStateGraph, 75 | ); 76 | 77 | expect(stateMachine.state, const TestStateD()); 78 | 79 | stateMachine.dispatch(action); 80 | 81 | expect(stateMachine.state, const TestStateA()); 82 | } 83 | }); 84 | test('test transition using stateStream', () { 85 | final stateMachine = createStateMachine( 86 | initialState: const TestStateA(), 87 | graphBuilder: testStateGraph, 88 | ); 89 | 90 | expect(stateMachine.state, const TestStateA()); 91 | 92 | stateMachine.stateStream.listen((state) { 93 | expect(state, const TestStateB()); 94 | }); 95 | 96 | stateMachine.dispatch(const TestActionA()); 97 | }); 98 | test('duplication GraphBuilder state method call cause error', () { 99 | expect( 100 | () => GraphBuilder() 101 | ..state( 102 | (b) => b 103 | ..on( 104 | (state, action) => b.transitionTo(const TestStateB()), 105 | ), 106 | ) 107 | ..state( 108 | (b) => b 109 | ..on( 110 | (state, action) => b.transitionTo(const TestStateB()), 111 | ), 112 | ), 113 | throwsA(isA()), 114 | ); 115 | }); 116 | test('duplicate GraphBuilder on method call cause error', () { 117 | expect( 118 | () => GraphBuilder() 119 | ..state( 120 | (b) => b 121 | ..on( 122 | (state, action) => b.transitionTo(const TestStateB()), 123 | ) 124 | ..on( 125 | (state, action) => b.transitionTo(const TestStateB()), 126 | ), 127 | ), 128 | throwsA(isA()), 129 | ); 130 | }); 131 | }); 132 | } 133 | --------------------------------------------------------------------------------