├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ ├── FEATURE_REQUEST.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── appveyor.yml ├── assets └── finite_machine_logo.png ├── benchmarks └── memory_usage.rb ├── examples ├── atm.rb ├── bug_system.rb └── definition.rb ├── finite_machine.gemspec ├── lib ├── finite_machine.rb └── finite_machine │ ├── async_call.rb │ ├── callable.rb │ ├── catchable.rb │ ├── choice_merger.rb │ ├── const.rb │ ├── definition.rb │ ├── dsl.rb │ ├── env.rb │ ├── event_definition.rb │ ├── events_map.rb │ ├── hook_event.rb │ ├── hooks.rb │ ├── listener.rb │ ├── logger.rb │ ├── message_queue.rb │ ├── observer.rb │ ├── safety.rb │ ├── state_definition.rb │ ├── state_machine.rb │ ├── state_parser.rb │ ├── subscribers.rb │ ├── threadable.rb │ ├── transition.rb │ ├── transition_builder.rb │ ├── transition_event.rb │ ├── two_phase_lock.rb │ ├── undefined_transition.rb │ └── version.rb ├── spec ├── integration │ └── system_spec.rb ├── performance │ └── benchmark_spec.rb ├── spec_helper.rb └── unit │ ├── alias_target_spec.rb │ ├── async_callbacks_spec.rb │ ├── auto_methods_spec.rb │ ├── callable │ └── call_spec.rb │ ├── callbacks_spec.rb │ ├── can_spec.rb │ ├── cancel_callbacks_spec.rb │ ├── choice_spec.rb │ ├── define_spec.rb │ ├── definition_spec.rb │ ├── event_names_spec.rb │ ├── events_map │ ├── add_spec.rb │ ├── choice_transition_spec.rb │ ├── clear_spec.rb │ ├── events_spec.rb │ ├── inspect_spec.rb │ ├── match_transition_spec.rb │ ├── move_to_spec.rb │ └── states_for_spec.rb │ ├── events_spec.rb │ ├── handlers_spec.rb │ ├── hook_event │ ├── any_state_or_event_spec.rb │ ├── build_spec.rb │ ├── eql_spec.rb │ ├── initialize_spec.rb │ └── notify_spec.rb │ ├── hooks │ ├── clear_spec.rb │ ├── find_spec.rb │ ├── inspect_spec.rb │ └── register_spec.rb │ ├── if_unless_spec.rb │ ├── initial_spec.rb │ ├── inspect_spec.rb │ ├── is_spec.rb │ ├── log_transitions_spec.rb │ ├── logger_spec.rb │ ├── message_queue_spec.rb │ ├── new_spec.rb │ ├── respond_to_spec.rb │ ├── state_parser │ └── parse_spec.rb │ ├── states_spec.rb │ ├── subscribers_spec.rb │ ├── target_spec.rb │ ├── terminated_spec.rb │ ├── transition │ ├── check_conditions_spec.rb │ ├── inspect_spec.rb │ ├── matches_spec.rb │ ├── states_spec.rb │ └── to_state_spec.rb │ ├── trigger_spec.rb │ └── undefined_transition │ └── eql_spec.rb └── tasks ├── console.rake ├── coverage.rake └── spec.rake /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.rb] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: piotrmurach 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report something not working correctly or as expected 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ### Describe the problem 10 | 11 | A brief description of the issue. 12 | 13 | ### Steps to reproduce the problem 14 | 15 | ``` 16 | Your code here to reproduce the issue 17 | ``` 18 | 19 | ### Actual behaviour 20 | 21 | What happened? This could be a description, log output, error raised etc. 22 | 23 | ### Expected behaviour 24 | 25 | What did you expect to happen? 26 | 27 | ### Describe your environment 28 | 29 | * OS version: 30 | * Ruby version: 31 | * FiniteMachine version: 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest new functionality 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ### Describe the problem 10 | 11 | A brief description of the problem you're trying to solve. 12 | 13 | ### How would the new feature work? 14 | 15 | A short explanation of the new feature. 16 | 17 | ``` 18 | Example code that shows possible usage 19 | ``` 20 | 21 | ### Drawbacks 22 | 23 | Can you see any potential drawbacks? 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: FiniteMachine Community Discussions 4 | url: https://github.com/piotrmurach/finite_machine/discussions 5 | about: Suggest ideas, ask and answer questions 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Describe the change 2 | What does this Pull Request do? 3 | 4 | ### Why are we doing this? 5 | Any related context as to why is this is a desirable change. 6 | 7 | ### Benefits 8 | How will the library improve? 9 | 10 | ### Drawbacks 11 | Possible drawbacks applying this change. 12 | 13 | ### Requirements 14 | 15 | - [ ] Tests written & passing locally? 16 | - [ ] Code style checked? 17 | - [ ] Rebased with `master` branch? 18 | - [ ] Documentation updated? 19 | - [ ] Changelog updated? 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - "benchmarks/**" 9 | - "examples/**" 10 | - "*.md" 11 | pull_request: 12 | branches: 13 | - master 14 | paths-ignore: 15 | - "benchmarks/**" 16 | - "examples/**" 17 | - "*.md" 18 | jobs: 19 | tests: 20 | name: Ruby ${{ matrix.ruby }} 21 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | ruby: 26 | - "2.0" 27 | - "2.1" 28 | - "2.3" 29 | - "2.4" 30 | - "2.5" 31 | - "2.6" 32 | - "3.0" 33 | - "3.1" 34 | - "3.2" 35 | - "3.3" 36 | - ruby-head 37 | - jruby-9.2 38 | - jruby-9.3 39 | - jruby-9.4 40 | - jruby-head 41 | - truffleruby-head 42 | include: 43 | - ruby: "2.2" 44 | os: ubuntu-20.04 45 | - ruby: "2.7" 46 | coverage: true 47 | env: 48 | COVERAGE: ${{ matrix.coverage }} 49 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 50 | continue-on-error: ${{ endsWith(matrix.ruby, 'head') }} 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Set up Ruby 54 | uses: ruby/setup-ruby@v1 55 | with: 56 | ruby-version: ${{ matrix.ruby }} 57 | bundler-cache: true 58 | - name: Run tests 59 | run: bundle exec rake ci 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.sw[a-z] 19 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | --order random 4 | --warnings 5 | --require spec_helper 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | Lint/AssignmentInCondition: 5 | Enabled: false 6 | 7 | Metrics/AbcSize: 8 | Max: 30 9 | 10 | Metrics/BlockLength: 11 | CountComments: true 12 | Max: 25 13 | ExcludedMethods: [] 14 | Exclude: 15 | - "spec/**/*" 16 | 17 | Metrics/ClassLength: 18 | Max: 1500 19 | 20 | Metrics/CyclomaticComplexity: 21 | Enabled: false 22 | 23 | Layout/LineLength: 24 | Max: 80 25 | 26 | Metrics/MethodLength: 27 | Max: 20 28 | 29 | Naming/BinaryOperatorParameterName: 30 | Enabled: false 31 | 32 | Style/AsciiComments: 33 | Enabled: false 34 | 35 | Style/LambdaCall: 36 | SupportedStyles: 37 | - call 38 | - braces 39 | 40 | Style/StringLiterals: 41 | EnforcedStyle: double_quotes 42 | 43 | Style/TrivialAccessors: 44 | Enabled: false 45 | 46 | # { ... } for multi-line blocks is okay 47 | Style/BlockDelimiters: 48 | Enabled: false 49 | 50 | Style/CommentedKeyword: 51 | Enabled: false 52 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | piotr@piotrmurach.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "json", "2.4.1" if RUBY_VERSION == "2.0.0" 6 | 7 | group :development do 8 | gem "ruby-prof", "~> 0.17.0", platforms: :mri 9 | gem "pry", "~> 0.10.1" 10 | gem "rspec-benchmark", RUBY_VERSION < "2.1.0" ? "~> 0.4" : "~> 0.6" 11 | end 12 | 13 | group :metrics do 14 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.5.0") 15 | gem "coveralls_reborn", "~> 0.21.0" 16 | gem "simplecov", "~> 0.21.0" 17 | end 18 | gem "yardstick", "~> 0.9.9" 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Piotr Murach (piotrmurach.com) 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | FileList["tasks/**/*.rake"].each(&method(:import)) 6 | 7 | mri = RUBY_ENGINE == "ruby" 8 | specs = ["spec"] 9 | specs.unshift("spec:perf") if mri 10 | 11 | desc "Run all specs" 12 | task ci: specs 13 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | --- 2 | skip_commits: 3 | files: 4 | - "benchmarks/**" 5 | - "examples/**" 6 | - "*.md" 7 | install: 8 | - SET PATH=C:\Ruby%ruby_version%\bin;%PATH% 9 | - gem install bundler -v '< 2.0' 10 | - bundle install 11 | before_test: 12 | - ruby -v 13 | - gem -v 14 | - bundle -v 15 | build: off 16 | test_script: 17 | - bundle exec rake ci 18 | environment: 19 | matrix: 20 | - ruby_version: "200" 21 | - ruby_version: "200-x64" 22 | - ruby_version: "21" 23 | - ruby_version: "21-x64" 24 | - ruby_version: "22" 25 | - ruby_version: "22-x64" 26 | - ruby_version: "23" 27 | - ruby_version: "23-x64" 28 | - ruby_version: "24" 29 | - ruby_version: "24-x64" 30 | - ruby_version: "25" 31 | - ruby_version: "25-x64" 32 | - ruby_version: "26" 33 | - ruby_version: "26-x64" 34 | matrix: 35 | allow_failures: 36 | - ruby_version: "200" 37 | - ruby_version: "200-x64" 38 | -------------------------------------------------------------------------------- /assets/finite_machine_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piotrmurach/finite_machine/34229a2eb258afbbbb3af2527572b453d9aad39c/assets/finite_machine_logo.png -------------------------------------------------------------------------------- /benchmarks/memory_usage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../lib/finite_machine' 4 | 5 | 5.times do 6 | puts 7 | 8 | GC.start 9 | 10 | gc_before = GC.stat 11 | objects_before = ObjectSpace.count_objects 12 | p objects_before[:T_OBJECT] 13 | 14 | 1_000.times do 15 | FiniteMachine.new do 16 | initial :green 17 | 18 | events { event :slow, :green => :yellow } 19 | end 20 | end 21 | 22 | objects_after = ObjectSpace.count_objects 23 | gc_after = GC.stat 24 | p objects_after[:T_OBJECT] 25 | 26 | p "GC count: #{gc_after[:count] - gc_before[:count]}" 27 | p "Objects count: #{objects_after[:T_OBJECT] - objects_before[:T_OBJECT]}" 28 | end 29 | -------------------------------------------------------------------------------- /examples/atm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/finite_machine" 4 | 5 | class Account 6 | attr_accessor :message 7 | 8 | def verify(account_number, pin) 9 | account_number == 123456 && pin == 666 10 | end 11 | end 12 | 13 | account = Account.new 14 | 15 | ATM = FiniteMachine.define do 16 | alias_target :account 17 | 18 | initial :unauthorized 19 | 20 | event :authorize, :unauthorized => :authorized 21 | event :deauthorize, :authorized => :unauthorized 22 | 23 | on_exit :unauthorized do |event, account_number, pin| 24 | if account.verify(account_number, pin) 25 | account.message = "Welcome to your Account" 26 | else 27 | account.message = "Invalid Account and/or PIN" 28 | cancel_event 29 | end 30 | end 31 | end 32 | 33 | atm = ATM.new(account) 34 | 35 | atm.authorize(111222, 666) 36 | puts "authorized: #{atm.authorized?}" 37 | puts "Number: #{account.message}" 38 | 39 | atm.authorize(123456, 666) 40 | puts "authorized: #{atm.authorized?}" 41 | puts "Number: #{account.message}" 42 | -------------------------------------------------------------------------------- /examples/bug_system.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/finite_machine" 4 | 5 | class User 6 | attr_accessor :name 7 | 8 | def initialize(name) 9 | @name = name 10 | end 11 | end 12 | 13 | class Manager < User 14 | attr_accessor :developers 15 | 16 | def initialize(name) 17 | super 18 | @developers = [] 19 | end 20 | 21 | def manages(developer) 22 | @developers << developer 23 | end 24 | 25 | def assign(bug) 26 | developer = @developers.first 27 | bug.assign 28 | developer.bug = bug 29 | end 30 | end 31 | 32 | class Tester < User 33 | def report(bug) 34 | bug.report 35 | end 36 | 37 | def reopen(bug) 38 | bug.reopen 39 | end 40 | end 41 | 42 | class Developer < User 43 | attr_accessor :bug 44 | 45 | def work_on 46 | bug.start 47 | end 48 | 49 | def resolve 50 | bug.close 51 | end 52 | end 53 | 54 | class BugSystem 55 | attr_accessor :managers 56 | 57 | def initialize(managers = []) 58 | @managers = managers 59 | end 60 | 61 | def notify_manager(bug) 62 | manager = @managers.first 63 | puts "Notifying #{manager.name.inspect} about #{bug.name.inspect}" 64 | manager.assign(bug) 65 | end 66 | end 67 | 68 | class BugStatus < FiniteMachine::Definition 69 | alias_target :bug 70 | 71 | event :report, :none => :new 72 | event :assign, :new => :assigned 73 | event :start, :assigned => :in_progress 74 | event :close, [:in_progress, :reopened] => :resolved 75 | event :reopen, :resolved => :reopened 76 | 77 | on_enter :new do |event| 78 | bug.notify_manager 79 | end 80 | end 81 | 82 | class Bug 83 | attr_reader :name 84 | attr_reader :priority 85 | attr_reader :status 86 | 87 | # fake belongs_to relationship 88 | attr_accessor :bug_system 89 | 90 | def initialize(name, priority) 91 | @name = name 92 | @priority = priority 93 | @status = BugStatus.new(self) 94 | end 95 | 96 | def report 97 | status.report 98 | end 99 | 100 | def assign 101 | status.assign 102 | end 103 | 104 | def start 105 | status.start 106 | end 107 | 108 | def close 109 | status.close 110 | end 111 | 112 | def reopen 113 | status.reopen 114 | end 115 | 116 | def notify_manager 117 | bug_system.notify_manager(self) 118 | end 119 | end 120 | 121 | tester = Tester.new("John") 122 | manager = Manager.new("David") 123 | developer = Developer.new("Piotr") 124 | 125 | manager.manages(developer) 126 | 127 | bug_system = BugSystem.new([manager]) 128 | bug = Bug.new(:trojan, :high) 129 | bug.bug_system = bug_system 130 | 131 | puts "A BUG's LIFE" 132 | puts "#1 #{bug.status.current}" 133 | 134 | tester.report(bug) 135 | puts "#2 #{bug.status.current}" 136 | 137 | developer.work_on 138 | puts "#3 #{bug.status.current}" 139 | 140 | developer.resolve 141 | puts "#4 #{bug.status.current}" 142 | 143 | tester.reopen(bug) 144 | puts "#5 #{bug.status.current}" 145 | 146 | developer.resolve 147 | puts "#6 #{bug.status.current}" 148 | -------------------------------------------------------------------------------- /examples/definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/finite_machine" 4 | 5 | class Engine 6 | def initialize 7 | @engine = false 8 | end 9 | 10 | def turn_off 11 | @engine = false 12 | end 13 | 14 | def turn_on 15 | @engine = true 16 | end 17 | 18 | def engine_on? 19 | @engine 20 | end 21 | end 22 | 23 | Car = FiniteMachine.define do 24 | alias_target :engine 25 | 26 | initial :neutral 27 | 28 | event :ignite, :neutral => :one, unless: "engine_on?" 29 | event :stop, :one => :neutral, if: "engine_on?" 30 | 31 | on_before_ignite { |event| engine.turn_on } 32 | on_after_stop { |event| engine.turn_off } 33 | end 34 | 35 | engine = Engine.new 36 | car = Car.new(engine) 37 | 38 | puts "Engine on?: #{engine.engine_on?}" 39 | car.ignite 40 | puts "Engine on?: #{engine.engine_on?}" 41 | -------------------------------------------------------------------------------- /finite_machine.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/finite_machine/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "finite_machine" 7 | spec.version = FiniteMachine::VERSION 8 | spec.authors = ["Piotr Murach"] 9 | spec.email = ["piotr@piotrmurach.com"] 10 | spec.description = "A minimal finite state machine with a straightforward syntax. You can quickly model states, add callbacks and use object-oriented techniques to integrate with ORMs." 11 | spec.summary = "A minimal finite state machine with a straightforward syntax." 12 | spec.homepage = "https://piotrmurach.github.io/finite_machine/" 13 | spec.license = "MIT" 14 | 15 | if spec.respond_to?(:metadata) 16 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 17 | spec.metadata["bug_tracker_uri"] = "https://github.com/piotrmurach/finite_machine/issues" 18 | spec.metadata["changelog_uri"] = "https://github.com/piotrmurach/finite_machine/blob/master/CHANGELOG.md" 19 | spec.metadata["documentation_uri"] = "https://www.rubydoc.info/gems/finite_machine" 20 | spec.metadata["homepage_uri"] = spec.homepage 21 | spec.metadata["rubygems_mfa_required"] = "true" 22 | spec.metadata["source_code_uri"] = "https://github.com/piotrmurach/finite_machine" 23 | end 24 | 25 | spec.files = Dir["lib/**/*"] 26 | spec.extra_rdoc_files = ["README.md", "CHANGELOG.md", "LICENSE.txt"] 27 | spec.require_paths = ["lib"] 28 | spec.required_ruby_version = ">= 2.0.0" 29 | 30 | spec.add_runtime_dependency "concurrent-ruby", "~> 1.0" 31 | spec.add_runtime_dependency "sync", "~> 0.5" 32 | 33 | spec.add_development_dependency "rake" 34 | spec.add_development_dependency "rspec", ">= 3.0" 35 | end 36 | -------------------------------------------------------------------------------- /lib/finite_machine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | require_relative "finite_machine/const" 6 | require_relative "finite_machine/logger" 7 | require_relative "finite_machine/definition" 8 | require_relative "finite_machine/state_machine" 9 | require_relative "finite_machine/version" 10 | 11 | module FiniteMachine 12 | # Default state name 13 | DEFAULT_STATE = :none 14 | 15 | # Initial default event name 16 | DEFAULT_EVENT_NAME = :init 17 | 18 | # Describe any transition state 19 | ANY_STATE = Const.new(:any) 20 | 21 | # Describe any event name 22 | ANY_EVENT = Const.new(:any_event) 23 | 24 | # When transition between states is invalid 25 | TransitionError = Class.new(::StandardError) 26 | 27 | # When failed to process callback 28 | CallbackError = Class.new(::StandardError) 29 | 30 | # Raised when transitioning to invalid state 31 | InvalidStateError = Class.new(::ArgumentError) 32 | 33 | InvalidEventError = Class.new(::NoMethodError) 34 | 35 | # Raised when a callback is defined with invalid name 36 | InvalidCallbackNameError = Class.new(::StandardError) 37 | 38 | # Raised when event has no transitions 39 | NotEnoughTransitionsError = Class.new(::ArgumentError) 40 | 41 | # Raised when initial event specified without state name 42 | MissingInitialStateError = Class.new(::StandardError) 43 | 44 | # Raised when event queue is already dead 45 | MessageQueueDeadError = Class.new(::StandardError) 46 | 47 | # Raised when argument is already defined 48 | AlreadyDefinedError = Class.new(::ArgumentError) 49 | 50 | module ClassMethods 51 | attr_accessor :logger 52 | 53 | # Initialize an instance of finite machine 54 | # 55 | # @example 56 | # FiniteMachine.new do 57 | # ... 58 | # end 59 | # 60 | # @return [FiniteMachine::StateMachine] 61 | # 62 | # @api public 63 | def new(*args, &block) 64 | StateMachine.new(*args, &block) 65 | end 66 | 67 | # A factory method for creating reusable FiniteMachine definitions 68 | # 69 | # @example 70 | # TrafficLights = FiniteMachine.define 71 | # lights_fm_a = TrafficLights.new 72 | # lights_fm_b = TrafficLights.new 73 | # 74 | # @return [Class] 75 | # 76 | # @api public 77 | def define(&block) 78 | Class.new(Definition, &block) 79 | end 80 | end 81 | 82 | extend ClassMethods 83 | end # FiniteMachine 84 | 85 | FiniteMachine.logger = Logger.new(STDERR) 86 | -------------------------------------------------------------------------------- /lib/finite_machine/async_call.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # An immutable asynchronouse call representation that wraps 5 | # the {Callable} object 6 | # 7 | # Used internally by {MessageQueue} to dispatch events 8 | # 9 | # @api private 10 | class AsyncCall 11 | # Create asynchronous call instance 12 | # 13 | # @param [Object] context 14 | # @param [Callable] callable 15 | # @param [Array] args 16 | # @param [#call] block 17 | # 18 | # @example 19 | # AsyncCall.new(context, Callable.new(:method), :a, :b) 20 | # 21 | # @api public 22 | def initialize(context, callable, *args, &block) 23 | @context = context 24 | @callable = callable 25 | @arguments = args.dup 26 | @block = block 27 | freeze 28 | end 29 | 30 | # Dispatch the event to the context 31 | # 32 | # @return [nil] 33 | # 34 | # @api private 35 | def dispatch 36 | @callable.call(@context, *@arguments, &@block) 37 | end 38 | end # AsyncCall 39 | end # FiniteMachine 40 | -------------------------------------------------------------------------------- /lib/finite_machine/callable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # A generic interface for executing strings, symbol methods or procs. 5 | class Callable 6 | 7 | attr_reader :object 8 | 9 | # Initialize a Callable 10 | # 11 | # @param [Symbol, String, Proc] object 12 | # the callable object 13 | # 14 | # @api public 15 | def initialize(object) 16 | @object = object 17 | end 18 | 19 | # Invert callable 20 | # 21 | # @api public 22 | def invert 23 | ->(*args, &block) { !call(*args, &block) } 24 | end 25 | 26 | # Execute action 27 | # 28 | # @param [Object] target 29 | # 30 | # @api public 31 | def call(target, *args, &block) 32 | case object 33 | when Symbol 34 | target.public_send(object.to_sym, *args, &block) 35 | when String 36 | string = args.empty? ? "-> { #{object} }" : "-> { #{object}(*#{args}) }" 37 | value = eval(string) 38 | target.instance_exec(&value) 39 | when ::Proc 40 | object.arity.zero? ? object.call : object.call(target, *args) 41 | else 42 | raise ArgumentError, "Unknown callable #{object}" 43 | end 44 | end 45 | end # Callable 46 | end # FiniteMachine 47 | -------------------------------------------------------------------------------- /lib/finite_machine/catchable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # A mixin to allow for specifying error handlers 5 | module Catchable 6 | # Extends object with error handling methods 7 | # 8 | # @api private 9 | def self.included(base) 10 | base.module_eval do 11 | attr_threadsafe :error_handlers, default: [] 12 | end 13 | end 14 | 15 | # Rescue exception raised in state machine 16 | # 17 | # @param [Array[Exception]] exceptions 18 | # 19 | # @example 20 | # handle TransitionError, with: :pretty_errors 21 | # 22 | # @example 23 | # handle TransitionError do |exception| 24 | # logger.info exception.message 25 | # raise exception 26 | # end 27 | # 28 | # @api public 29 | def handle(*exceptions, &block) 30 | options = exceptions.last.is_a?(Hash) ? exceptions.pop : {} 31 | 32 | unless options.key?(:with) 33 | if block_given? 34 | options[:with] = block 35 | else 36 | raise ArgumentError, "Need to provide error handler." 37 | end 38 | end 39 | evaluate_exceptions(exceptions, options) 40 | end 41 | 42 | # Catches error and finds a handler 43 | # 44 | # @param [Exception] exception 45 | # 46 | # @return [Boolean] 47 | # true if handler is found, nil otherwise 48 | # 49 | # @api public 50 | def catch_error(exception) 51 | if handler = handler_for_error(exception) 52 | handler.arity.zero? ? handler.call : handler.call(exception) 53 | true 54 | end 55 | end 56 | 57 | private 58 | 59 | def handler_for_error(exception) 60 | _, handler = error_handlers.reverse.find do |class_name, _| 61 | klass = FiniteMachine.const_get(class_name) rescue nil 62 | klass ||= extract_const(class_name) 63 | exception <= klass 64 | end 65 | evaluate_handler(handler) 66 | end 67 | 68 | # Find constant in state machine namespace 69 | # 70 | # @param [String] class_name 71 | # 72 | # @api private 73 | def extract_const(class_name) 74 | class_name.split("::").reduce(FiniteMachine) do |constant, part| 75 | constant.const_get(part) 76 | end 77 | end 78 | 79 | # Executes given handler 80 | # 81 | # @api private 82 | def evaluate_handler(handler) 83 | case handler 84 | when Symbol 85 | target.method(handler) 86 | when Proc 87 | if handler.arity.zero? 88 | -> { instance_exec(&handler) } 89 | else 90 | ->(exception) { instance_exec(exception, &handler) } 91 | end 92 | end 93 | end 94 | 95 | # Check if exception inherits from Exception class and add to error handlers 96 | # 97 | # @param [Array[Exception]] exceptions 98 | # 99 | # @param [Hash] options 100 | # 101 | # @api private 102 | def evaluate_exceptions(exceptions, options) 103 | exceptions.each do |exception| 104 | key = extract_exception_name(exception) 105 | error_handlers << [key, options[:with]] 106 | end 107 | end 108 | 109 | # Extract exception name 110 | # 111 | # @param [Class,Exception,String] exception 112 | # 113 | # @api private 114 | def extract_exception_name(exception) 115 | if exception.is_a?(Class) && exception <= Exception 116 | exception.name 117 | elsif exception.is_a?(String) 118 | exception 119 | else 120 | raise ArgumentError, "#{exception} isn't an Exception" 121 | end 122 | end 123 | end # Catchable 124 | end # FiniteMachine 125 | -------------------------------------------------------------------------------- /lib/finite_machine/choice_merger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "transition_builder" 4 | 5 | module FiniteMachine 6 | # A class responsible for merging choice options 7 | class ChoiceMerger 8 | # Initialize a ChoiceMerger 9 | # 10 | # @param [StateMachine] machine 11 | # @param [String] name 12 | # @param [Hash] transitions 13 | # the transitions and attributes 14 | # 15 | # @api private 16 | def initialize(machine, name, transitions = {}) 17 | @machine = machine 18 | @name = name 19 | @transitions = transitions 20 | end 21 | 22 | # Create choice transition 23 | # 24 | # @example 25 | # event :stop, from: :green do 26 | # choice :yellow 27 | # end 28 | # 29 | # @param [Symbol] to 30 | # the to state 31 | # @param [Hash] conditions 32 | # the conditions associated with this choice 33 | # 34 | # @return [FiniteMachine::Transition] 35 | # 36 | # @api public 37 | def choice(to, conditions = {}) 38 | transition_builder = TransitionBuilder.new(@machine, @name, 39 | @transitions.merge(conditions)) 40 | transition_builder.call(@transitions[:from] => to) 41 | end 42 | alias default choice 43 | end # ChoiceMerger 44 | end # FiniteMachine 45 | -------------------------------------------------------------------------------- /lib/finite_machine/const.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | class Const 5 | def initialize(name) 6 | @name = name.to_s 7 | freeze 8 | end 9 | 10 | def to_s 11 | @name 12 | end 13 | alias to_str to_s 14 | alias inspect to_s 15 | end # Const 16 | end # FiniteMachine 17 | -------------------------------------------------------------------------------- /lib/finite_machine/definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # Responsible for defining a standalone state machine 5 | # 6 | # @api public 7 | class Definition 8 | # The any event constant 9 | # 10 | # @example 11 | # on_before(any_event) { ... } 12 | # 13 | # @return [FiniteMachine::Const] 14 | # 15 | # @api public 16 | def self.any_event 17 | ANY_EVENT 18 | end 19 | 20 | # The any state constant 21 | # 22 | # @example 23 | # event :go, any_state => :green 24 | # 25 | # @example 26 | # on_enter(any_state) { ... } 27 | # 28 | # @return [FiniteMachine::Const] 29 | # 30 | # @api public 31 | def self.any_state 32 | ANY_STATE 33 | end 34 | 35 | # Initialize a StateMachine 36 | # 37 | # @example 38 | # class Engine < FiniteMachine::Definition 39 | # ... 40 | # end 41 | # 42 | # engine = Engine.new 43 | # 44 | # @return [FiniteMachine::StateMachine] 45 | # 46 | # @api public 47 | def self.new(*args) 48 | context = self 49 | FiniteMachine.new(*args) do 50 | context.deferreds.each { |d| d.call(self) } 51 | end 52 | end 53 | 54 | # Add deferred methods to the subclass 55 | # 56 | # @param [Class] subclass 57 | # the inheriting subclass 58 | # 59 | # @return [void] 60 | # 61 | # @api private 62 | def self.inherited(subclass) 63 | super 64 | 65 | deferreds.each { |d| subclass.add_deferred(d) } 66 | end 67 | 68 | # The state machine deferreds 69 | # 70 | # @return [Array] 71 | # 72 | # @api private 73 | def self.deferreds 74 | @deferreds ||= [] 75 | end 76 | 77 | # Add deferred 78 | # 79 | # @param [Proc] deferred 80 | # the deferred execution 81 | # 82 | # @return [Array] 83 | # 84 | # @api private 85 | def self.add_deferred(deferred) 86 | deferreds << deferred 87 | end 88 | 89 | # Delay lookup of DSL method 90 | # 91 | # @param [Symbol] method_name 92 | # the method name 93 | # @param [Array] arguments 94 | # the method arguments 95 | # 96 | # @return [void] 97 | # 98 | # @api private 99 | def self.method_missing(method_name, *arguments, &block) 100 | deferred = proc do |name, args, blok, object| 101 | object.send(name, *args, &blok) 102 | end 103 | deferred = deferred.curry(4)[method_name][arguments][block] 104 | add_deferred(deferred) 105 | end 106 | end # Definition 107 | end # FiniteMachine 108 | -------------------------------------------------------------------------------- /lib/finite_machine/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "choice_merger" 4 | require_relative "safety" 5 | require_relative "transition_builder" 6 | 7 | module FiniteMachine 8 | # A generic DSL for describing the state machine 9 | class GenericDSL 10 | # Initialize a generic DSL 11 | # 12 | # @api public 13 | def initialize(machine, attrs) 14 | @machine = machine 15 | @attrs = attrs 16 | end 17 | 18 | # Expose any state constant 19 | # @api public 20 | def any_state 21 | ANY_STATE 22 | end 23 | 24 | # Expose any event constant 25 | # @api public 26 | def any_event 27 | ANY_EVENT 28 | end 29 | 30 | # Delegate attributes to machine instance 31 | # 32 | # @api private 33 | def method_missing(method_name, *args, &block) 34 | if @machine.respond_to?(method_name) 35 | @machine.send(method_name, *args, &block) 36 | else 37 | super 38 | end 39 | end 40 | 41 | # Check if message can be handled by this DSL 42 | # 43 | # @api private 44 | def respond_to_missing?(method_name, include_private = false) 45 | @machine.respond_to?(method_name) || super 46 | end 47 | 48 | # Configure state machine properties 49 | # 50 | # @api private 51 | def call(&block) 52 | instance_eval(&block) 53 | end 54 | end # GenericDSL 55 | 56 | # A class responsible for adding state machine specific dsl 57 | class DSL < GenericDSL 58 | include Safety 59 | 60 | # Initialize top level DSL 61 | # 62 | # @api public 63 | def initialize(machine, attrs) 64 | super(machine, attrs) 65 | 66 | @machine.state = FiniteMachine::DEFAULT_STATE 67 | @defer_initial = true 68 | @silent_initial = true 69 | 70 | initial(@attrs[:initial]) if @attrs[:initial] 71 | terminal(@attrs[:terminal]) if @attrs[:terminal] 72 | log_transitions(@attrs.fetch(:log_transitions, false)) 73 | end 74 | 75 | # Add aliases for the target object 76 | # 77 | # @example 78 | # FiniteMachine.define do 79 | # target_alias :engine 80 | # 81 | # on_transition do |event| 82 | # engine.state = event.to 83 | # end 84 | # end 85 | # 86 | # @param [Array] aliases 87 | # the names for target alias 88 | # 89 | # @api public 90 | def alias_target(*aliases) 91 | aliases.each do |alias_name| 92 | next if env.aliases.include?(alias_name) 93 | 94 | env.aliases << alias_name 95 | end 96 | end 97 | 98 | # Define initial state 99 | # 100 | # @param [Symbol] value 101 | # The initial state name. 102 | # @param [Hash[Symbol]] options 103 | # @option options [Symbol] :event 104 | # The event name. 105 | # @option options [Symbol] :defer 106 | # Set to true to defer initial state transition. 107 | # Default false. 108 | # @option options [Symbol] :silent 109 | # Set to true to disable callbacks. 110 | # Default true. 111 | # 112 | # @example 113 | # initial :green 114 | # 115 | # @example Defer initial event 116 | # initial state: green, defer: true 117 | # 118 | # @example Trigger callbacks 119 | # initial :green, silent: false 120 | # 121 | # @example Redefine event name 122 | # initial :green, event: :start 123 | # 124 | # @param [String, Hash] value 125 | # 126 | # @return [StateMachine] 127 | # 128 | # @api public 129 | def initial(value, options = {}) 130 | state = (value && !value.is_a?(Hash)) ? value : raise_missing_state 131 | name, @defer_initial, @silent_initial = *parse_initial(options) 132 | @initial_event = name 133 | event(name, FiniteMachine::DEFAULT_STATE => state, silent: @silent_initial) 134 | end 135 | 136 | # Trigger initial event 137 | # 138 | # @return [nil] 139 | # 140 | # @api private 141 | def trigger_init 142 | method = @silent_initial ? :transition : :trigger 143 | @machine.public_send(method, :"#{@initial_event}") unless @defer_initial 144 | end 145 | 146 | # Define terminal state 147 | # 148 | # @example 149 | # terminal :red 150 | # 151 | # @return [FiniteMachine::StateMachine] 152 | # 153 | # @api public 154 | def terminal(*values) 155 | self.terminal_states = values 156 | end 157 | 158 | # Create event and associate transition 159 | # 160 | # @example 161 | # event :go, :green => :yellow 162 | # event :go, :green => :yellow, if: :lights_on? 163 | # 164 | # @param [Symbol] name 165 | # the event name 166 | # @param [Hash] transitions 167 | # the event transitions and conditions 168 | # 169 | # @return [Transition] 170 | # 171 | # @api public 172 | def event(name, transitions = {}, &block) 173 | detect_event_conflict!(name) if machine.auto_methods? 174 | 175 | if block_given? 176 | merger = ChoiceMerger.new(machine, name, transitions) 177 | merger.instance_eval(&block) 178 | else 179 | transition_builder = TransitionBuilder.new(machine, name, transitions) 180 | transition_builder.call(transitions) 181 | end 182 | end 183 | 184 | # Add error handler 185 | # 186 | # @param [Array] exceptions 187 | # 188 | # @example 189 | # handle InvalidStateError, with: :log_errors 190 | # 191 | # @return [Array[Exception]] 192 | # 193 | # @api public 194 | def handle(*exceptions, &block) 195 | @machine.handle(*exceptions, &block) 196 | end 197 | 198 | # Decide whether to log transitions 199 | # 200 | # @api public 201 | def log_transitions(value) 202 | self.log_transitions = value 203 | end 204 | 205 | private 206 | 207 | # Parse initial options 208 | # 209 | # @param [Hash] options 210 | # the options to extract for initial state setup 211 | # 212 | # @return [Array[Symbol,String]] 213 | # 214 | # @api private 215 | def parse_initial(options) 216 | [options.fetch(:event) { FiniteMachine::DEFAULT_EVENT_NAME }, 217 | options.fetch(:defer) { false }, 218 | options.fetch(:silent) { true }] 219 | end 220 | 221 | # Raises missing state error 222 | # 223 | # @raise [MissingInitialStateError] 224 | # Raised when state name is not provided for initial. 225 | # 226 | # @return [nil] 227 | # 228 | # @api private 229 | def raise_missing_state 230 | raise MissingInitialStateError, 231 | "Provide state to transition :to for the initial event" 232 | end 233 | end # DSL 234 | end # FiniteMachine 235 | -------------------------------------------------------------------------------- /lib/finite_machine/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "threadable" 4 | 5 | module FiniteMachine 6 | # Holds references to targets and aliases 7 | # 8 | # @api public 9 | class Env 10 | include Threadable 11 | 12 | attr_threadsafe :target 13 | 14 | attr_threadsafe :aliases 15 | 16 | def initialize(target, aliases = []) 17 | @target = target 18 | @aliases = aliases 19 | end 20 | end # Env 21 | end # FiniteMachine 22 | -------------------------------------------------------------------------------- /lib/finite_machine/event_definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # A class responsible for defining event methods on state machine 5 | # 6 | # Used to add event definitions from {TransitionBuilder} to 7 | # the {StateMachine} to obtain convenience helpers. 8 | # 9 | # @api private 10 | class EventDefinition 11 | # The current state machine 12 | attr_reader :machine 13 | 14 | # Initialize an EventDefinition 15 | # 16 | # @param [StateMachine] machine 17 | # 18 | # @api private 19 | def initialize(machine) 20 | @machine = machine 21 | end 22 | 23 | # Define transition event names as state machine events 24 | # 25 | # @param [Symbol] event_name 26 | # the event name for which definition is created 27 | # 28 | # @return [nil] 29 | # 30 | # @api public 31 | def apply(event_name, silent = false) 32 | define_event_transition(event_name, silent) 33 | define_event_bang(event_name, silent) 34 | end 35 | 36 | private 37 | 38 | # Define transition event 39 | # 40 | # @param [Symbol] event_name 41 | # the event name 42 | # 43 | # @param [Boolean] silent 44 | # if true don't trigger callbacks, otherwise do 45 | # 46 | # @return [nil] 47 | # 48 | # @api private 49 | def define_event_transition(event_name, silent) 50 | machine.send(:define_singleton_method, event_name) do |*data, &block| 51 | method = silent ? :transition : :trigger 52 | machine.public_send(method, event_name, *data, &block) 53 | end 54 | end 55 | 56 | # Define event that skips validations and callbacks 57 | # 58 | # @param [Symbol] event_name 59 | # the event name 60 | # 61 | # @param [Boolean] silent 62 | # if true don't trigger callbacks, otherwise do 63 | # 64 | # @return [nil] 65 | # 66 | # @api private 67 | def define_event_bang(event_name, silent) 68 | machine.send(:define_singleton_method, "#{event_name}!") do |*data, &block| 69 | method = silent ? :transition! : :trigger! 70 | machine.public_send(method, event_name, *data, &block) 71 | end 72 | end 73 | end # EventBuilder 74 | end # FiniteMachine 75 | -------------------------------------------------------------------------------- /lib/finite_machine/events_map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "concurrent/map" 4 | require "forwardable" 5 | 6 | require_relative "threadable" 7 | require_relative "undefined_transition" 8 | 9 | module FiniteMachine 10 | # A class responsible for storing mappings between event namess and 11 | # their transition objects. 12 | # 13 | # Used internally by {StateMachine}. 14 | # 15 | # @api private 16 | class EventsMap 17 | extend Forwardable 18 | 19 | def_delegators :@events_map, :empty?, :size 20 | 21 | # Initialize a EventsMap 22 | # 23 | # @api private 24 | def initialize 25 | @events_map = Concurrent::Map.new 26 | end 27 | 28 | # Check if event is present 29 | # 30 | # @example 31 | # events_map.exists?(:go) # => true 32 | # 33 | # @param [Symbol] name 34 | # the event name 35 | # 36 | # @return [Boolean] 37 | # true if event is present, false otherwise 38 | # 39 | # @api public 40 | def exists?(name) 41 | @events_map.key?(name) 42 | end 43 | 44 | # Add transition under name 45 | # 46 | # @param [Symbol] the event name 47 | # 48 | # @param [Transition] transition 49 | # the transition to add under event name 50 | # 51 | # @return [nil] 52 | # 53 | # @api public 54 | def add(name, transition) 55 | if exists?(name) 56 | @events_map[name] << transition 57 | else 58 | @events_map[name] = [transition] 59 | end 60 | self 61 | end 62 | 63 | # Finds transitions for the event name 64 | # 65 | # @param [Symbol] name 66 | # 67 | # @example 68 | # events_map[:start] # => [] 69 | # 70 | # @return [Array[Transition]] 71 | # the transitions matching event name 72 | # 73 | # @api public 74 | def find(name) 75 | @events_map.fetch(name) { [] } 76 | end 77 | alias [] find 78 | 79 | # Retrieve all event names 80 | # 81 | # @example 82 | # events_map.events # => [:init, :start, :stop] 83 | # 84 | # @return [Array[Symbol]] 85 | # All event names 86 | # 87 | # @api public 88 | def events 89 | @events_map.keys 90 | end 91 | 92 | # Retreive all unique states 93 | # 94 | # @example 95 | # events_map.states # => [:yellow, :green, :red] 96 | # 97 | # @return [Array[Symbol]] 98 | # the array of all unique states 99 | # 100 | # @api public 101 | def states 102 | @events_map.values.flatten.map(&:states).map(&:to_a).flatten.uniq 103 | end 104 | 105 | # Retrieves all state transitions 106 | # 107 | # @return [Array[Hash]] 108 | # 109 | # @api public 110 | def state_transitions 111 | @events_map.values.flatten.map(&:states) 112 | end 113 | 114 | # Retrieve from states for the event name 115 | # 116 | # @param [Symbol] event_name 117 | # 118 | # @example 119 | # events_map.states_for(:start) # => [:yellow, :green] 120 | # 121 | # @api public 122 | def states_for(name) 123 | find(name).map(&:states).flat_map(&:keys) 124 | end 125 | 126 | # Check if event is valid and transition can be performed 127 | # 128 | # @return [Boolean] 129 | # 130 | # @api public 131 | def can_perform?(name, from_state, *conditions) 132 | !match_transition_with(name, from_state, *conditions).nil? 133 | end 134 | 135 | # Check if event has branching choice transitions or not 136 | # 137 | # @example 138 | # events_map.choice_transition?(:go, :green) # => true 139 | # 140 | # @param [Symbol] name 141 | # the event name 142 | # 143 | # @param [Symbol] from_state 144 | # the transition from state 145 | # 146 | # @return [Boolean] 147 | # true if transition has any branches, false otherwise 148 | # 149 | # @api public 150 | def choice_transition?(name, from_state) 151 | find(name).select { |trans| trans.matches?(from_state) }.size > 1 152 | end 153 | 154 | # Find transition without checking conditions 155 | # 156 | # @param [Symbol] name 157 | # the event name 158 | # 159 | # @param [Symbol] from_state 160 | # the transition from state 161 | # 162 | # @return [Transition, nil] 163 | # returns transition, nil otherwise 164 | # 165 | # @api private 166 | def match_transition(name, from_state) 167 | find(name).find { |trans| trans.matches?(from_state) } 168 | end 169 | 170 | # Examine transitions for event name that start in from state 171 | # and find one matching condition. 172 | # 173 | # @param [Symbol] name 174 | # the event name 175 | # 176 | # @param [Symbol] from_state 177 | # the current context from_state 178 | # 179 | # @return [Transition] 180 | # The choice transition that matches 181 | # 182 | # @api public 183 | def match_transition_with(name, from_state, *conditions) 184 | find(name).find do |trans| 185 | trans.matches?(from_state) && trans.check_conditions(*conditions) 186 | end 187 | end 188 | 189 | # Select transition that matches conditions 190 | # 191 | # @param [Symbol] name 192 | # the event name 193 | # @param [Symbol] from_state 194 | # the transition from state 195 | # @param [Array[Object]] conditions 196 | # the conditional data 197 | # 198 | # @return [Transition] 199 | # 200 | # @api public 201 | def select_transition(name, from_state, *conditions) 202 | if choice_transition?(name, from_state) 203 | match_transition_with(name, from_state, *conditions) 204 | else 205 | match_transition(name, from_state) 206 | end 207 | end 208 | 209 | # Find state that this machine can move to 210 | # 211 | # @example 212 | # evenst_map.move_to(:go, :green) # => :red 213 | # 214 | # @param [Symbol] name 215 | # the event name 216 | # 217 | # @param [Symbol] from_state 218 | # the transition from state 219 | # 220 | # @param [Array] conditions 221 | # the data associated with this transition 222 | # 223 | # @return [Symbol] 224 | # the transition `to` state 225 | # 226 | # @api public 227 | def move_to(name, from_state, *conditions) 228 | transition = select_transition(name, from_state, *conditions) 229 | transition ||= UndefinedTransition.new(name) 230 | transition.to_state(from_state) 231 | end 232 | 233 | # Reset map 234 | # 235 | # @return [self] 236 | # 237 | # @api public 238 | def clear 239 | @events_map.clear 240 | self 241 | end 242 | 243 | # Return string representation of this map 244 | # 245 | # @return [String] 246 | # 247 | # @api public 248 | def to_s 249 | hash = {} 250 | @events_map.each_pair do |name, trans| 251 | hash[name] = trans 252 | end 253 | hash.to_s 254 | end 255 | 256 | # Inspect map content 257 | # 258 | # @example 259 | # events_map.inspect 260 | # 261 | # @return [String] 262 | # 263 | # @api public 264 | def inspect 265 | "<##{self.class} @events_map=#{self}>" 266 | end 267 | end # EventsMap 268 | end # FiniteMachine 269 | -------------------------------------------------------------------------------- /lib/finite_machine/hook_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # A class responsible for event notification 5 | class HookEvent 6 | include Comparable 7 | 8 | class Anystate < HookEvent; end 9 | 10 | class Enter < Anystate; end 11 | 12 | class Transition < Anystate; end 13 | 14 | class Exit < Anystate; end 15 | 16 | class Anyaction < HookEvent; end 17 | 18 | class Before < Anyaction; end 19 | 20 | class After < Anyaction; end 21 | 22 | EVENTS = Anystate, Enter, Transition, Exit, Anyaction, Before, After 23 | 24 | MESSAGE = :emit 25 | 26 | # Extract event name 27 | # 28 | # @return [String] the event name 29 | # 30 | # @api public 31 | def self.event_name 32 | name.split("::").last.downcase.to_sym 33 | end 34 | 35 | # String representation 36 | # 37 | # @return [String] the event name 38 | # 39 | # @api public 40 | def self.to_s 41 | event_name.to_s 42 | end 43 | 44 | # Choose any state or event name based on even type 45 | # 46 | # @param [HookEvent] event_type 47 | # 48 | # @return [Symbol] 49 | # out of :any or :any_event 50 | # 51 | # @api public 52 | def self.any_state_or_event(event_type) 53 | event_type < Anyaction ? ANY_EVENT : ANY_STATE 54 | end 55 | 56 | # Build event hook 57 | # 58 | # @param [Symbol] :state 59 | # The state or action name. 60 | # 61 | # @param [Symbol] :event_name 62 | # The event name associted with this hook. 63 | # 64 | # @return [self] 65 | # 66 | # @api public 67 | def self.build(state, event_name, from) 68 | state_or_action = self < Anystate ? state : event_name 69 | new(state_or_action, event_name, from) 70 | end 71 | 72 | EVENTS.each do |event| 73 | (class << self; self; end).class_eval do 74 | define_method(event.event_name) { event } 75 | end 76 | end 77 | 78 | # HookEvent state or action name 79 | attr_reader :name 80 | 81 | # HookEvent type 82 | attr_reader :type 83 | 84 | # The from state this hook has been fired 85 | attr_reader :from 86 | 87 | # The event name triggering this hook event 88 | attr_reader :event_name 89 | 90 | # Instantiate a new HookEvent object 91 | # 92 | # @param [Symbol] name 93 | # The action or state name 94 | # 95 | # @param [Symbol] event_name 96 | # The event name associated with this hook event. 97 | # 98 | # @example 99 | # HookEvent.new(:green, :move, :green) 100 | # 101 | # @return [self] 102 | # 103 | # @api public 104 | def initialize(name, event_name, from) 105 | @name = name 106 | @type = self.class 107 | @event_name = event_name 108 | @from = from 109 | freeze 110 | end 111 | 112 | # Notify subscriber about this event 113 | # 114 | # @param [Observer] subscriber 115 | # the object subscribed to be notified about this event 116 | # 117 | # @param [Array] data 118 | # the data associated with the triggered event 119 | # 120 | # @return [nil] 121 | # 122 | # @api public 123 | def notify(subscriber, *data) 124 | return unless subscriber.respond_to?(MESSAGE) 125 | 126 | subscriber.public_send(MESSAGE, self, *data) 127 | end 128 | 129 | # Compare whether the instance is greater, less then or equal to other 130 | # 131 | # @return [-1 0 1] 132 | # 133 | # @api public 134 | def <=>(other) 135 | other.is_a?(type) && 136 | [name, from, event_name] <=> [other.name, other.from, other.event_name] 137 | end 138 | alias eql? == 139 | end # HookEvent 140 | end # FiniteMachine 141 | -------------------------------------------------------------------------------- /lib/finite_machine/hooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "concurrent/array" 4 | require "concurrent/map" 5 | 6 | require_relative "hook_event" 7 | 8 | module FiniteMachine 9 | # A class reponsible for registering callbacks 10 | class Hooks 11 | attr_reader :hooks_map 12 | 13 | # Initialize a hooks_map of hooks 14 | # 15 | # @example 16 | # Hooks.new 17 | # 18 | # @api public 19 | def initialize 20 | @hooks_map = Concurrent::Map.new do |events_hash, hook_event| 21 | events_hash.compute_if_absent(hook_event) do 22 | Concurrent::Map.new do |state_hash, name| 23 | state_hash.compute_if_absent(name) do 24 | Concurrent::Array.new 25 | end 26 | end 27 | end 28 | end 29 | end 30 | 31 | # Finds all hooks for the event type 32 | # 33 | # @param [Symbol] name 34 | # 35 | # @example 36 | # hooks[HookEvent::Enter][:go] # => [-> { }] 37 | # 38 | # @return [Array[Transition]] 39 | # the transitions matching event name 40 | # 41 | # @api public 42 | def find(name) 43 | @hooks_map[name] 44 | end 45 | alias [] find 46 | 47 | # Register callback 48 | # 49 | # @param [String] hook_event 50 | # @param [String] name 51 | # @param [Proc] callback 52 | # 53 | # @example 54 | # hooks.register HookEvent::Enter, :green do ... end 55 | # 56 | # @example 57 | # hooks.register HookEvent::Before, any_state do ... end 58 | # 59 | # @return [Hash] 60 | # 61 | # @api public 62 | def register(hook_event, name, callback) 63 | @hooks_map[hook_event][name] << callback 64 | end 65 | 66 | # Unregister callback 67 | # 68 | # @param [String] hook_event 69 | # @param [String] name 70 | # @param [Proc] callback 71 | # 72 | # @example 73 | # hooks.unregister HookEvent::Enter, :green do ... end 74 | # 75 | # @return [Hash] 76 | # 77 | # @api public 78 | def unregister(hook_event, name, callback) 79 | @hooks_map[hook_event][name].delete(callback) 80 | end 81 | 82 | # Check if hooks_map has any elements 83 | # 84 | # @return [Boolean] 85 | # 86 | # @api public 87 | def empty? 88 | @hooks_map.empty? 89 | end 90 | 91 | # Remove all callbacks 92 | # 93 | # @api public 94 | def clear 95 | @hooks_map.clear 96 | end 97 | 98 | # String representation 99 | # 100 | # @return [String] 101 | # 102 | # @api public 103 | def to_s 104 | hash = {} 105 | @hooks_map.each_pair do |hook_event, nested_hash| 106 | hash[hook_event] = {} 107 | nested_hash.each_pair do |name, callbacks| 108 | hash[hook_event][name] = callbacks 109 | end 110 | end 111 | hash.to_s 112 | end 113 | 114 | # String representation 115 | # 116 | # @return [String] 117 | # 118 | # @api public 119 | def inspect 120 | "<##{self.class}:0x#{object_id.to_s(16)} @hooks_map=#{self}>" 121 | end 122 | end # Hooks 123 | end # FiniteMachine 124 | -------------------------------------------------------------------------------- /lib/finite_machine/listener.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # A generic listener interface 5 | class Listener 6 | # Initialize a listener 7 | # 8 | # @api private 9 | def initialize(*args) 10 | @name = args.unshift 11 | end 12 | 13 | # Define event delivery handler 14 | # 15 | # @api public 16 | def on_delivery(&block) 17 | @on_delivery = block 18 | self 19 | end 20 | 21 | # Invoke event handler 22 | # 23 | # @api private 24 | def call(*args) 25 | @on_delivery.call(*args) if @on_delivery 26 | end 27 | alias handle_delivery call 28 | end # Listener 29 | end # FiniteMachine 30 | -------------------------------------------------------------------------------- /lib/finite_machine/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | module Logger 5 | module_function 6 | 7 | def debug(message) 8 | FiniteMachine.logger.debug(message) 9 | end 10 | 11 | def info(message) 12 | FiniteMachine.logger.info(message) 13 | end 14 | 15 | def warn(message) 16 | FiniteMachine.logger.warn(message) 17 | end 18 | 19 | def error(message) 20 | FiniteMachine.logger.error(message) 21 | end 22 | 23 | def format_error(error) 24 | message = ["#{error.class}: #{error.message}\n\t"] 25 | if error.backtrace 26 | message << "occured at #{error.backtrace.join("\n\t")}" 27 | else 28 | message << "EMPTY BACKTRACE\n\t" 29 | end 30 | message.join 31 | end 32 | 33 | def report_transition(name, from, to, *args) 34 | message = ["Transition: @event=#{name} "] 35 | unless args.empty? 36 | message << "@with=[#{args.join(',')}] " 37 | end 38 | message << "#{from} -> #{to}" 39 | info(message.join) 40 | end 41 | end # Logger 42 | end # FiniteMachine 43 | -------------------------------------------------------------------------------- /lib/finite_machine/message_queue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "listener" 4 | require "thread" 5 | 6 | module FiniteMachine 7 | # Responsible for storage of asynchronous messages such as events 8 | # and callbacks. 9 | # 10 | # Used internally by {Observer} 11 | # 12 | # @api private 13 | class MessageQueue 14 | # Initialize a MessageQueue 15 | # 16 | # @example 17 | # message_queue = FiniteMachine::MessageQueue.new 18 | # 19 | # @api public 20 | def initialize 21 | @not_empty = ConditionVariable.new 22 | @mutex = Mutex.new 23 | @queue = Queue.new 24 | @dead = false 25 | @listeners = [] 26 | @thread = nil 27 | end 28 | 29 | # Start a new thread with a queue of callback events to run 30 | # 31 | # @example 32 | # message_queue.start 33 | # 34 | # @return [Thread, nil] 35 | # 36 | # @api private 37 | def start 38 | return if running? 39 | 40 | @mutex.synchronize { spawn_thread } 41 | end 42 | 43 | # Spawn a new background thread 44 | # 45 | # @return [Thread] 46 | # 47 | # @api private 48 | def spawn_thread 49 | @thread = Thread.new do 50 | Thread.current.abort_on_exception = true 51 | process_events 52 | end 53 | end 54 | 55 | # Check whether or not the message queue is running 56 | # 57 | # @example 58 | # message_queue.running? 59 | # 60 | # @return [Boolean] 61 | # 62 | # @api public 63 | def running? 64 | !@thread.nil? && alive? 65 | end 66 | 67 | # Add an asynchronous event to the message queue to process 68 | # 69 | # @example 70 | # message_queue << AsyncCall.build(...) 71 | # 72 | # @param [FiniteMachine::AsyncCall] event 73 | # the event to add 74 | # 75 | # @return [void] 76 | # 77 | # @api public 78 | def <<(event) 79 | @mutex.synchronize do 80 | if @dead 81 | discard_message(event) 82 | else 83 | @queue << event 84 | @not_empty.signal 85 | end 86 | end 87 | end 88 | 89 | # Add a listener for the message queue to receive notifications 90 | # 91 | # @example 92 | # message_queue.subscribe { |event| ... } 93 | # 94 | # @return [void] 95 | # 96 | # @api public 97 | def subscribe(*args, &block) 98 | @mutex.synchronize do 99 | listener = Listener.new(*args) 100 | listener.on_delivery(&block) 101 | @listeners << listener 102 | end 103 | end 104 | 105 | # Check whether or not there are any messages to handle 106 | # 107 | # @example 108 | # message_queue.empty? 109 | # 110 | # @api public 111 | def empty? 112 | @mutex.synchronize { @queue.empty? } 113 | end 114 | 115 | # Check whether or not the message queue is alive 116 | # 117 | # @example 118 | # message_queue.alive? 119 | # 120 | # @return [Boolean] 121 | # 122 | # @api public 123 | def alive? 124 | @mutex.synchronize { !@dead } 125 | end 126 | 127 | # Join the message queue from the current thread 128 | # 129 | # @param [Fixnum] timeout 130 | # the time limit 131 | # 132 | # @example 133 | # message_queue.join 134 | # 135 | # @return [Thread, nil] 136 | # 137 | # @api public 138 | def join(timeout = nil) 139 | return unless @thread 140 | 141 | timeout.nil? ? @thread.join : @thread.join(timeout) 142 | end 143 | 144 | # Shut down this message queue and clean it up 145 | # 146 | # @example 147 | # message_queue.shutdown 148 | # 149 | # @raise [FiniteMachine::MessageQueueDeadError] 150 | # 151 | # @return [Boolean] 152 | # 153 | # @api public 154 | def shutdown 155 | raise MessageQueueDeadError, "message queue already dead" if @dead 156 | 157 | queue = [] 158 | @mutex.synchronize do 159 | @dead = true 160 | @not_empty.broadcast 161 | 162 | queue = @queue 163 | @queue.clear 164 | end 165 | while !queue.empty? 166 | discard_message(queue.pop) 167 | end 168 | true 169 | end 170 | 171 | # The number of messages waiting for processing 172 | # 173 | # @example 174 | # message_queue.size 175 | # 176 | # @return [Integer] 177 | # 178 | # @api public 179 | def size 180 | @mutex.synchronize { @queue.size } 181 | end 182 | 183 | # Inspect this message queue 184 | # 185 | # @example 186 | # message_queue.inspect 187 | # 188 | # @return [String] 189 | # 190 | # @api public 191 | def inspect 192 | @mutex.synchronize do 193 | "#<#{self.class}:#{object_id.to_s(16)} @size=#{size}, @dead=#{@dead}>" 194 | end 195 | end 196 | 197 | private 198 | 199 | # Notify listeners about the event 200 | # 201 | # @param [FiniteMachine::AsyncCall] event 202 | # the event to notify listeners about 203 | # 204 | # @return [void] 205 | # 206 | # @api private 207 | def notify_listeners(event) 208 | @listeners.each { |listener| listener.handle_delivery(event) } 209 | end 210 | 211 | # Process all the events 212 | # 213 | # @return [Thread] 214 | # 215 | # @api private 216 | def process_events 217 | until @dead 218 | @mutex.synchronize do 219 | while @queue.empty? 220 | @not_empty.wait(@mutex) 221 | end 222 | event = @queue.pop 223 | break unless event 224 | 225 | notify_listeners(event) 226 | event.dispatch 227 | end 228 | end 229 | rescue Exception => ex 230 | Logger.error "Error while running event: #{Logger.format_error(ex)}" 231 | end 232 | 233 | # Log discarded message 234 | # 235 | # @param [FiniteMachine::AsyncCall] message 236 | # the message to discard 237 | # 238 | # @return [void] 239 | # 240 | # @api private 241 | def discard_message(message) 242 | Logger.debug "Discarded message: #{message}" if $DEBUG 243 | end 244 | end # EventQueue 245 | end # FiniteMachine 246 | -------------------------------------------------------------------------------- /lib/finite_machine/observer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | require_relative "async_call" 6 | require_relative "callable" 7 | require_relative "hook_event" 8 | require_relative "hooks" 9 | require_relative "message_queue" 10 | require_relative "safety" 11 | require_relative "transition_event" 12 | 13 | module FiniteMachine 14 | # A class responsible for observing state changes 15 | class Observer < GenericDSL 16 | include Safety 17 | 18 | # The current state machine 19 | attr_reader :machine 20 | 21 | # The hooks to trigger around the transition lifecycle. 22 | attr_reader :hooks 23 | 24 | # Initialize an Observer 25 | # 26 | # @param [StateMachine] machine 27 | # reference to the current machine 28 | # 29 | # @api public 30 | def initialize(machine) 31 | @machine = machine 32 | @hooks = Hooks.new 33 | 34 | @machine.subscribe(self) 35 | end 36 | 37 | # Evaluate in current context 38 | # 39 | # @api private 40 | def call(&block) 41 | instance_eval(&block) 42 | end 43 | 44 | # Register callback for a given hook type 45 | # 46 | # @param [HookEvent] hook_type 47 | # @param [Symbol] state_or_event_name 48 | # @param [Proc] callback 49 | # 50 | # @example 51 | # observer.on HookEvent::Enter, :green 52 | # 53 | # @api public 54 | def on(hook_type, state_or_event_name = nil, async = nil, &callback) 55 | sync_exclusive do 56 | if state_or_event_name.nil? 57 | state_or_event_name = HookEvent.any_state_or_event(hook_type) 58 | end 59 | async = false if async.nil? 60 | ensure_valid_callback_name!(hook_type, state_or_event_name) 61 | callback.extend(Async) if async == :async 62 | hooks.register(hook_type, state_or_event_name, callback) 63 | end 64 | end 65 | 66 | # Unregister callback for a given event 67 | # 68 | # @api public 69 | def off(hook_type, name = ANY_STATE, &callback) 70 | sync_exclusive do 71 | hooks.unregister hook_type, name, callback 72 | end 73 | end 74 | 75 | module Once; end 76 | 77 | module Async; end 78 | 79 | def on_enter(*args, &callback) 80 | on HookEvent::Enter, *args, &callback 81 | end 82 | 83 | def on_transition(*args, &callback) 84 | on HookEvent::Transition, *args, &callback 85 | end 86 | 87 | def on_exit(*args, &callback) 88 | on HookEvent::Exit, *args, &callback 89 | end 90 | 91 | def once_on_enter(*args, &callback) 92 | on HookEvent::Enter, *args, &callback.extend(Once) 93 | end 94 | 95 | def once_on_transition(*args, &callback) 96 | on HookEvent::Transition, *args, &callback.extend(Once) 97 | end 98 | 99 | def once_on_exit(*args, &callback) 100 | on HookEvent::Exit, *args, &callback.extend(Once) 101 | end 102 | 103 | def on_before(*args, &callback) 104 | on HookEvent::Before, *args, &callback 105 | end 106 | 107 | def on_after(*args, &callback) 108 | on HookEvent::After, *args, &callback 109 | end 110 | 111 | def once_on_before(*args, &callback) 112 | on HookEvent::Before, *args, &callback.extend(Once) 113 | end 114 | 115 | def once_on_after(*args, &callback) 116 | on HookEvent::After, *args, &callback.extend(Once) 117 | end 118 | 119 | # Execute each of the hooks in order with supplied data 120 | # 121 | # @param [HookEvent] event 122 | # the hook event 123 | # 124 | # @param [Array[Object]] data 125 | # 126 | # @return [nil] 127 | # 128 | # @api public 129 | def emit(event, *data) 130 | sync_exclusive do 131 | [event.type].each do |hook_type| 132 | any_state_or_event = HookEvent.any_state_or_event(hook_type) 133 | [any_state_or_event, event.name].each do |event_name| 134 | hooks[hook_type][event_name].each do |hook| 135 | handle_callback(hook, event, *data) 136 | off(hook_type, event_name, &hook) if hook.is_a?(Once) 137 | end 138 | end 139 | end 140 | end 141 | end 142 | 143 | # Cancel the current event 144 | # 145 | # This should be called inside a on_before or on_exit callbacks 146 | # to prevent event transition. 147 | # 148 | # @param [String] msg 149 | # the message used for failure 150 | # 151 | # @api public 152 | def cancel_event(msg = nil) 153 | raise CallbackError.new(msg) 154 | end 155 | 156 | private 157 | 158 | # Handle callback and decide if run synchronously or asynchronously 159 | # 160 | # @param [Proc] :hook 161 | # The hook to evaluate 162 | # 163 | # @param [HookEvent] :event 164 | # The event for which the hook is called 165 | # 166 | # @param [Array[Object]] :data 167 | # 168 | # @api private 169 | def handle_callback(hook, event, *data) 170 | to = machine.events_map.move_to(event.event_name, event.from, *data) 171 | trans_event = TransitionEvent.new(event.event_name, event.from, to) 172 | callable = create_callable(hook) 173 | 174 | if hook.is_a?(Async) 175 | defer(callable, trans_event, *data) 176 | else 177 | callable.(trans_event, *data) 178 | end 179 | end 180 | 181 | # Defer callback execution 182 | # 183 | # @api private 184 | def defer(callable, trans_event, *data) 185 | async_call = AsyncCall.new(machine, callable, trans_event, *data) 186 | callback_queue.start unless callback_queue.running? 187 | callback_queue << async_call 188 | end 189 | 190 | # Get an existing callback queue or create a new one 191 | # 192 | # @return [FiniteMachine::MessageQueue] 193 | # 194 | # @api private 195 | def callback_queue 196 | @callback_queue ||= MessageQueue.new.tap do 197 | @queue_id = SecureRandom.uuid 198 | ObjectSpace.define_finalizer(@queue_id, proc do 199 | cleanup_callback_queue 200 | end) 201 | end 202 | end 203 | 204 | # Clean up the callback queue 205 | # 206 | # @return [Boolean, nil] 207 | # 208 | # @api private 209 | def cleanup_callback_queue 210 | ObjectSpace.undefine_finalizer(@queue_id) if @queue_id 211 | return unless @callback_queue && callback_queue.alive? 212 | 213 | begin 214 | callback_queue.shutdown 215 | rescue MessageQueueDeadError 216 | end 217 | end 218 | 219 | # Create callable instance 220 | # 221 | # @api private 222 | def create_callable(hook) 223 | callback = proc do |trans_event, *data| 224 | machine.instance_exec(trans_event, *data, &hook) 225 | end 226 | Callable.new(callback) 227 | end 228 | 229 | # Callback names including all states and events 230 | # 231 | # @return [Array[Symbol]] 232 | # valid callback names 233 | # 234 | # @api private 235 | def callback_names 236 | machine.states + machine.events + [ANY_EVENT, ANY_STATE] 237 | end 238 | 239 | # Forward the message to observer 240 | # 241 | # @param [String] method_name 242 | # 243 | # @param [Array] args 244 | # 245 | # @return [self] 246 | # 247 | # @api private 248 | def method_missing(method_name, *args, &block) 249 | _, event_name, callback_name = *method_name.to_s.match(/^(\w*?on_\w+?)_(\w+)$/) 250 | if callback_name && callback_names.include?(callback_name.to_sym) 251 | public_send(event_name, :"#{callback_name}", *args, &block) 252 | else 253 | super 254 | end 255 | end 256 | 257 | # Test if a message can be handled by observer 258 | # 259 | # @param [String] method_name 260 | # 261 | # @param [Boolean] include_private 262 | # 263 | # @return [Boolean] 264 | # 265 | # @api private 266 | def respond_to_missing?(method_name, include_private = false) 267 | *_, callback_name = *method_name.to_s.match(/^(\w*?on_\w+?)_(\w+)$/) 268 | callback_name && callback_names.include?(:"#{callback_name}") 269 | end 270 | end # Observer 271 | end # FiniteMachine 272 | -------------------------------------------------------------------------------- /lib/finite_machine/safety.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "hook_event" 4 | 5 | module FiniteMachine 6 | # Module responsible for safety checks against known methods 7 | module Safety 8 | EVENT_CONFLICT_MESSAGE = \ 9 | "You tried to define an event named \"%{name}\", however this would " \ 10 | "generate \"%{type}\" method \"%{method}\", which is already defined " \ 11 | "by %{source}" 12 | 13 | STATE_CALLBACK_CONFLICT_MESSAGE = \ 14 | "\"%{type}\" callback is a state listener and cannot be used " \ 15 | "with \"%{name}\" event name. Please use on_before or on_after instead." 16 | 17 | EVENT_CALLBACK_CONFLICT_MESSAGE = \ 18 | "\"%{type}\" callback is an event listener and cannot be used " \ 19 | "with \"%{name}\" state name. Please use on_enter, on_transition or " \ 20 | "on_exit instead." 21 | 22 | CALLBACK_INVALID_MESSAGE = \ 23 | "\"%{name}\" is not a valid callback name. " \ 24 | "Valid callback names are \"%{callbacks}" 25 | 26 | # Raise error when the method is already defined 27 | # 28 | # @example 29 | # detect_event_conflict!(:test, "test=") 30 | # 31 | # @raise [FiniteMachine::AlreadyDefinedError] 32 | # 33 | # @return [nil] 34 | # 35 | # @api public 36 | def detect_event_conflict!(event_name, method_name = event_name) 37 | if method_already_implemented?(method_name) 38 | raise FiniteMachine::AlreadyDefinedError, EVENT_CONFLICT_MESSAGE % { 39 | name: event_name, 40 | type: :instance, 41 | method: method_name, 42 | source: "FiniteMachine" 43 | } 44 | end 45 | end 46 | 47 | # Raise error when the callback name is not valid 48 | # 49 | # @example 50 | # ensure_valid_callback_name!(HookEvent::Enter, ":state_name") 51 | # 52 | # @raise [FiniteMachine::InvalidCallbackNameError] 53 | # 54 | # @return [nil] 55 | # 56 | # @api public 57 | def ensure_valid_callback_name!(event_type, name) 58 | message = if wrong_event_name?(name, event_type) 59 | EVENT_CALLBACK_CONFLICT_MESSAGE % { 60 | type: "on_#{event_type}", 61 | name: name 62 | } 63 | elsif wrong_state_name?(name, event_type) 64 | STATE_CALLBACK_CONFLICT_MESSAGE % { 65 | type: "on_#{event_type}", 66 | name: name 67 | } 68 | elsif !callback_names.include?(name) 69 | CALLBACK_INVALID_MESSAGE % { 70 | name: name, 71 | callbacks: callback_names.to_a.inspect 72 | } 73 | else 74 | nil 75 | end 76 | message && raise_invalid_callback_error(message) 77 | end 78 | 79 | private 80 | 81 | # Check if event name exists 82 | # 83 | # @param [Symbol] name 84 | # 85 | # @param [FiniteMachine::HookEvent] event_type 86 | # 87 | # @return [Boolean] 88 | # 89 | # @api private 90 | def wrong_event_name?(name, event_type) 91 | machine.states.include?(name) && 92 | !machine.events.include?(name) && 93 | event_type < HookEvent::Anyaction 94 | end 95 | 96 | # Check if state name exists 97 | # 98 | # @param [Symbol] name 99 | # 100 | # @param [FiniteMachine::HookEvent] event_type 101 | # 102 | # @return [Boolean] 103 | # 104 | # @api private 105 | def wrong_state_name?(name, event_type) 106 | machine.events.include?(name) && 107 | !machine.states.include?(name) && 108 | event_type < HookEvent::Anystate 109 | end 110 | 111 | def raise_invalid_callback_error(message) 112 | exception = InvalidCallbackNameError 113 | machine.catch_error(exception) || raise(exception, message) 114 | end 115 | 116 | # Check if method is already implemented inside StateMachine 117 | # 118 | # @param [String] name 119 | # the method name 120 | # 121 | # @return [Boolean] 122 | # 123 | # @api private 124 | def method_already_implemented?(name) 125 | method_defined_within?(name, FiniteMachine::StateMachine) 126 | end 127 | 128 | # Check if method is defined within a given class 129 | # 130 | # @param [String] name 131 | # the method name 132 | # 133 | # @param [Object] klass 134 | # 135 | # @return [Boolean] 136 | # 137 | # @api private 138 | def method_defined_within?(name, klass) 139 | klass.method_defined?(name) || klass.private_method_defined?(name) 140 | end 141 | end # Safety 142 | end # FiniteMachine 143 | -------------------------------------------------------------------------------- /lib/finite_machine/state_definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # A class responsible for defining state query methods on state machine 5 | # 6 | # Used by {TranstionBuilder} to add state query definition 7 | # to the {StateMachine} instance. 8 | # 9 | # @api private 10 | class StateDefinition 11 | # Initialize a StateDefinition 12 | # 13 | # @param [StateMachine] machine 14 | # 15 | # @api public 16 | def initialize(machine) 17 | @machine = machine 18 | end 19 | 20 | # Define query methods for states 21 | # 22 | # @param [Hash] states 23 | # the states that require query helpers 24 | # 25 | # @return [nil] 26 | # 27 | # @api public 28 | def apply(states) 29 | define_state_query_methods(states) 30 | end 31 | 32 | private 33 | 34 | # The current state machine 35 | attr_reader :machine 36 | 37 | # Define helper state mehods for the transition states 38 | # 39 | # @param [Hash] states 40 | # the states to define helpers for 41 | # 42 | # @return [nil] 43 | # 44 | # @api private 45 | def define_state_query_methods(states) 46 | states.to_a.flatten.each do |state| 47 | define_state_query_method(state) 48 | end 49 | end 50 | 51 | # Define state helper method 52 | # 53 | # @param [Symbol] state 54 | # the state to define helper for 55 | # 56 | # @api private 57 | def define_state_query_method(state) 58 | return if machine.respond_to?("#{state}?") 59 | machine.send(:define_singleton_method, "#{state}?") do 60 | machine.is?(state.to_sym) 61 | end 62 | end 63 | end # StateDefinition 64 | end # FiniteMachine 65 | -------------------------------------------------------------------------------- /lib/finite_machine/state_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # A class responsible for converting transition arguments to states 5 | # 6 | # Used by {TransitionBuilder} to parse user input state transitions. 7 | # 8 | # @api private 9 | class StateParser 10 | NON_STATE_KEYS = %i[name if unless silent].freeze 11 | 12 | STATE_KEYS = %i[from to].freeze 13 | 14 | # Extract states from user defined attributes 15 | # 16 | # @example 17 | # StateParser.parse({from: [:green, :blue], to: :red}) 18 | # # => {green: :red, green: :blue} 19 | # 20 | # @param [Proc] block 21 | # 22 | # @yield [Hash[Symbol]] the resolved states 23 | # 24 | # @return [Hash[Symbol]] the resolved states 25 | # 26 | # @api public 27 | def self.parse(attributes, &block) 28 | attrs = ensure_only_states!(attributes) 29 | states = extract_states(attrs) 30 | block ? states.each(&block) : states 31 | end 32 | 33 | # Extract only states from attributes 34 | # 35 | # @return [Hash[Symbol]] 36 | # 37 | # @api private 38 | def self.ensure_only_states!(attrs) 39 | attributes = attrs.dup 40 | NON_STATE_KEYS.each { |key| attributes.delete(key) } 41 | raise_not_enough_transitions unless attributes.any? 42 | attributes 43 | end 44 | private_class_method :ensure_only_states! 45 | 46 | # Perform extraction of states from user supplied definitions 47 | # 48 | # @return [Hash[Symbol]] the resolved states 49 | # 50 | # @api private 51 | def self.extract_states(attrs) 52 | if contains_from_to_keys?(attrs) 53 | convert_from_to_attributes_to_states_hash(attrs) 54 | else 55 | convert_attributes_to_states_hash(attrs) 56 | end 57 | end 58 | private_class_method :extract_states 59 | 60 | # Check if attributes contain :from or :to key 61 | # 62 | # @example 63 | # StateParser.contains_from_to_keys?({from: :green, to: :red}) 64 | # # => true 65 | # 66 | # @example 67 | # StateParser.contains_from_to_keys?({:green => :red}) 68 | # # => false 69 | # 70 | # @return [Boolean] 71 | # 72 | # @api public 73 | def self.contains_from_to_keys?(attrs) 74 | STATE_KEYS.any? { |key| attrs.keys.include?(key) } 75 | end 76 | private_class_method :contains_from_to_keys? 77 | 78 | # Convert attrbiutes with :from, :to keys to states hash 79 | # 80 | # @return [Hash[Symbol]] 81 | # 82 | # @api private 83 | def self.convert_from_to_attributes_to_states_hash(attrs) 84 | Array(attrs[:from] || ANY_STATE).each_with_object({}) do |state, hash| 85 | hash[state] = attrs[:to] || state 86 | end 87 | end 88 | private_class_method :convert_from_to_attributes_to_states_hash 89 | 90 | # Convert collapsed attributes to states hash 91 | # 92 | # @example 93 | # StateParser.convert_attributes_to_states_hash([:green, :red] => :yellow) 94 | # # => {green: :yellow, red: :yellow} 95 | # 96 | # @param [Hash] attrs 97 | # the attributes to convert to a simple hash 98 | # 99 | # @return [Hash[Symbol]] 100 | # 101 | # @api private 102 | def self.convert_attributes_to_states_hash(attrs) 103 | attrs.each_with_object({}) do |(k, v), hash| 104 | if k.respond_to?(:to_ary) 105 | k.each { |el| hash[el] = v } 106 | else 107 | hash[k] = v 108 | end 109 | end 110 | end 111 | private_class_method :convert_attributes_to_states_hash 112 | 113 | # Raise error when not enough transitions are provided 114 | # 115 | # @raise [NotEnoughTransitionsError] 116 | # if the event has no transitions 117 | # 118 | # @return [nil] 119 | # 120 | # @api private 121 | def self.raise_not_enough_transitions 122 | raise NotEnoughTransitionsError, "please provide state transitions" 123 | end 124 | private_class_method :raise_not_enough_transitions 125 | end # StateParser 126 | end # FiniteMachine 127 | -------------------------------------------------------------------------------- /lib/finite_machine/subscribers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "monitor" 4 | 5 | module FiniteMachine 6 | # A class responsibile for storage of event subscribers 7 | class Subscribers 8 | include Enumerable 9 | include MonitorMixin 10 | 11 | # Initialize a subscribers collection 12 | # 13 | # @api public 14 | def initialize 15 | super 16 | @subscribers = [] 17 | end 18 | 19 | # Iterate over subscribers 20 | # 21 | # @api public 22 | def each(&block) 23 | @subscribers.each(&block) 24 | end 25 | 26 | # Return index of the subscriber 27 | # 28 | # @api public 29 | def index(subscriber) 30 | @subscribers.index(subscriber) 31 | end 32 | 33 | # Check if anyone is subscribed 34 | # 35 | # @return [Boolean] 36 | # 37 | # @api public 38 | def empty? 39 | @subscribers.empty? 40 | end 41 | 42 | # Add listener to subscribers 43 | # 44 | # @param [Array[#trigger]] observers 45 | # 46 | # @return [undefined] 47 | # 48 | # @api public 49 | def subscribe(*observers) 50 | synchronize do 51 | observers.each { |observer| @subscribers << observer } 52 | end 53 | end 54 | 55 | # Visit subscribers and notify 56 | # 57 | # @param [HookEvent] hook_event 58 | # the callback event to notify about 59 | # 60 | # @return [undefined] 61 | # 62 | # @api public 63 | def visit(hook_event, *data) 64 | each { |subscriber| 65 | synchronize { hook_event.notify(subscriber, *data) } 66 | } 67 | end 68 | 69 | # Number of subscribed listeners 70 | # 71 | # @return [Integer] 72 | # 73 | # @api public 74 | def size 75 | synchronize { @subscribers.size } 76 | end 77 | 78 | # Reset subscribers 79 | # 80 | # @return [self] 81 | # 82 | # @api public 83 | def reset 84 | @subscribers.clear 85 | self 86 | end 87 | end # Subscribers 88 | end # FiniteMachine 89 | -------------------------------------------------------------------------------- /lib/finite_machine/threadable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "two_phase_lock" 4 | 5 | module FiniteMachine 6 | # A mixin to allow instance methods to be synchronized 7 | module Threadable 8 | module InstanceMethods 9 | # Exclusive lock 10 | # 11 | # @return [nil] 12 | # 13 | # @api public 14 | def sync_exclusive(&block) 15 | TwoPhaseLock.synchronize(:EX, &block) 16 | end 17 | 18 | # Shared lock 19 | # 20 | # @return [nil] 21 | # 22 | # @api public 23 | def sync_shared(&block) 24 | TwoPhaseLock.synchronize(:SH, &block) 25 | end 26 | end 27 | 28 | # Module hook 29 | # 30 | # @return [nil] 31 | # 32 | # @api private 33 | def self.included(base) 34 | base.extend ClassMethods 35 | base.module_eval do 36 | include InstanceMethods 37 | end 38 | end 39 | 40 | private_class_method :included 41 | 42 | module ClassMethods 43 | include InstanceMethods 44 | 45 | # Defines threadsafe attributes for a class 46 | # 47 | # @example 48 | # attr_threadable :errors, :events 49 | # 50 | # @example 51 | # attr_threadable :errors, default: [] 52 | # 53 | # @return [nil] 54 | # 55 | # @api public 56 | def attr_threadsafe(*attrs) 57 | opts = attrs.last.is_a?(::Hash) ? attrs.pop : {} 58 | default = opts.fetch(:default, nil) 59 | attrs.flatten.each do |attr| 60 | class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 61 | def #{attr}(*args) 62 | value = args.shift 63 | if value 64 | self.#{attr} = value 65 | elsif instance_variables.include?(:@#{attr}) 66 | sync_shared { @#{attr} } 67 | elsif #{!default.nil?} 68 | sync_shared { instance_variable_set(:@#{attr}, #{default}) } 69 | end 70 | end 71 | alias_method '#{attr}?', '#{attr}' 72 | 73 | def #{attr}=(value) 74 | sync_exclusive { @#{attr} = value } 75 | end 76 | RUBY_EVAL 77 | end 78 | end 79 | end 80 | end # Threadable 81 | end # FiniteMachine 82 | -------------------------------------------------------------------------------- /lib/finite_machine/transition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "callable" 4 | require_relative "threadable" 5 | 6 | module FiniteMachine 7 | # Class describing a transition associated with a given event 8 | # 9 | # The {Transition} is created with the `event` helper. 10 | # 11 | # @example Converting event into {Transition} 12 | # event :go, :red => :green 13 | # 14 | # will be translated to 15 | # 16 | # Transition.new(context, :go, {states: {:red => :green}}) 17 | # 18 | # @api private 19 | class Transition 20 | include Threadable 21 | 22 | # The event name 23 | attr_threadsafe :name 24 | 25 | # Predicates before transitioning 26 | attr_threadsafe :conditions 27 | 28 | # The current state machine context 29 | attr_threadsafe :context 30 | 31 | # All states for this transition event 32 | attr_threadsafe :states 33 | 34 | # Initialize a Transition 35 | # 36 | # @example 37 | # attributes = {states: {green: :yellow}} 38 | # Transition.new(context, :go, attributes) 39 | # 40 | # @param [Object] context 41 | # the context this transition evaluets conditions in 42 | # 43 | # @param [Hash] attrs 44 | # 45 | # @return [Transition] 46 | # 47 | # @api public 48 | def initialize(context, name, attrs = {}) 49 | @context = context 50 | @name = name 51 | @states = attrs.fetch(:states, {}) 52 | @if = Array(attrs.fetch(:if, [])) 53 | @unless = Array(attrs.fetch(:unless, [])) 54 | @conditions = make_conditions 55 | freeze 56 | end 57 | 58 | # Reduce conditions 59 | # 60 | # @return [Array[Callable]] 61 | # 62 | # @api private 63 | def make_conditions 64 | @if.map { |c| Callable.new(c) } + 65 | @unless.map { |c| Callable.new(c).invert } 66 | end 67 | 68 | # Verify conditions returning true if all match, false otherwise 69 | # 70 | # @param [Array[Object]] args 71 | # the arguments for the condition 72 | # 73 | # @return [Boolean] 74 | # 75 | # @api private 76 | def check_conditions(*args) 77 | conditions.all? do |condition| 78 | condition.call(context, *args) 79 | end 80 | end 81 | 82 | # Check if this transition matches from state 83 | # 84 | # @param [Symbol] from 85 | # the from state to match against 86 | # 87 | # @example 88 | # transition = Transition.new(context, states: {:green => :red}) 89 | # transition.matches?(:green) # => true 90 | # 91 | # @return [Boolean] 92 | # Return true if match is found, false otherwise. 93 | # 94 | # @api public 95 | def matches?(from) 96 | states.keys.any? { |state| [ANY_STATE, from].include?(state) } 97 | end 98 | 99 | # Find to state for this transition given the from state 100 | # 101 | # @param [Symbol] from 102 | # the from state to check 103 | # 104 | # @example 105 | # transition = Transition.new(context, states: {:green => :red}) 106 | # transition.to_state(:green) # => :red 107 | # 108 | # @return [Symbol] 109 | # the to state 110 | # 111 | # @api public 112 | def to_state(from) 113 | states[from] || states[ANY_STATE] 114 | end 115 | 116 | # Return transition name 117 | # 118 | # @example 119 | # transition = Transition.new(context, name: :go) 120 | # transition.to_s # => "go" 121 | # 122 | # @return [String] 123 | # 124 | # @api public 125 | def to_s 126 | @name.to_s 127 | end 128 | 129 | # Return string representation 130 | # 131 | # @return [String] 132 | # 133 | # @api public 134 | def inspect 135 | transitions = @states.map { |from, to| "#{from} -> #{to}" }.join(", ") 136 | "<##{self.class} @name=#{@name}, @transitions=#{transitions}, " \ 137 | "@when=#{@conditions}>" 138 | end 139 | end # Transition 140 | end # FiniteMachine 141 | -------------------------------------------------------------------------------- /lib/finite_machine/transition_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "event_definition" 4 | require_relative "state_definition" 5 | require_relative "state_parser" 6 | require_relative "transition" 7 | 8 | module FiniteMachine 9 | # A class reponsible for building transition out of parsed states 10 | # 11 | # Used internally by {DSL} to 12 | # 13 | # @api private 14 | class TransitionBuilder 15 | # Initialize a TransitionBuilder 16 | # 17 | # @example 18 | # TransitionBuilder.new(machine, {}) 19 | # 20 | # @api public 21 | def initialize(machine, name, attributes = {}) 22 | @machine = machine 23 | @name = name 24 | @attributes = attributes 25 | 26 | @event_definition = EventDefinition.new(machine) 27 | @state_definition = StateDefinition.new(machine) 28 | end 29 | 30 | # Converts user transitions into internal {Transition} representation 31 | # 32 | # @example 33 | # transition_builder.call([:green, :yellow] => :red) 34 | # 35 | # @param [Hash[Symbol]] transitions 36 | # The transitions to extract states from 37 | # 38 | # @return [self] 39 | # 40 | # @api public 41 | def call(transitions) 42 | StateParser.parse(transitions) do |from, to| 43 | transition = Transition.new(@machine.env.target, @name, 44 | @attributes.merge(states: { from => to })) 45 | silent = @attributes.fetch(:silent, false) 46 | @machine.events_map.add(@name, transition) 47 | next unless @machine.auto_methods? 48 | 49 | unless @machine.respond_to?(@name) 50 | @event_definition.apply(@name, silent) 51 | end 52 | @state_definition.apply(from => to) 53 | end 54 | self 55 | end 56 | end # TransitionBuilder 57 | end # FiniteMachine 58 | -------------------------------------------------------------------------------- /lib/finite_machine/transition_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "threadable" 4 | 5 | module FiniteMachine 6 | # A class representing a callback transition event 7 | # 8 | # Used internally by {Observer} 9 | # 10 | # @api private 11 | class TransitionEvent 12 | # This event from state name 13 | # 14 | # @return [Object] 15 | # 16 | # @api public 17 | attr_reader :from 18 | 19 | # This event to state name 20 | # 21 | # @return [Object] 22 | # 23 | # @api public 24 | attr_reader :to 25 | 26 | # This event name 27 | # 28 | # @api public 29 | attr_reader :name 30 | 31 | # Build a transition event 32 | # 33 | # @param [String] event_name 34 | # @param [String] from 35 | # @param [String] to 36 | # 37 | # @return [self] 38 | # 39 | # @api private 40 | def initialize(event_name, from, to) 41 | @name = event_name 42 | @from = from 43 | @to = to 44 | freeze 45 | end 46 | end # TransitionEvent 47 | end # FiniteMachine 48 | -------------------------------------------------------------------------------- /lib/finite_machine/two_phase_lock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sync" 4 | 5 | module FiniteMachine 6 | # Mixin to provide lock to a {Threadable} 7 | # 8 | # @api private 9 | module TwoPhaseLock 10 | # Create synchronization lock 11 | # 12 | # @return [Sync] 13 | # 14 | # @api private 15 | def lock 16 | @lock = Sync.new 17 | end 18 | module_function :lock 19 | 20 | # Synchronize given block of code 21 | # 22 | # @param [Symbol] mode 23 | # the lock mode out of :SH, :EX, :UN 24 | # 25 | # @return [nil] 26 | # 27 | # @api private 28 | def synchronize(mode, &block) 29 | lock.synchronize(mode, &block) 30 | end 31 | module_function :synchronize 32 | end # TwoPhaseLock 33 | end # FiniteMachine 34 | -------------------------------------------------------------------------------- /lib/finite_machine/undefined_transition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | # Stand in for lack of matching transition. 5 | # 6 | # Used internally by {EventsMap} 7 | # 8 | # @api private 9 | class UndefinedTransition 10 | # Initialize an undefined transition 11 | # 12 | # @api private 13 | def initialize(name) 14 | @name = name 15 | freeze 16 | end 17 | 18 | def to_state(from) 19 | from 20 | end 21 | 22 | def ==(other) 23 | other.is_a?(UndefinedTransition) && name == other.name 24 | end 25 | 26 | protected 27 | 28 | attr_reader :name 29 | 30 | end # UndefinedTransition 31 | end # FiniteMachine 32 | -------------------------------------------------------------------------------- /lib/finite_machine/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FiniteMachine 4 | VERSION = "0.14.1" 5 | end 6 | -------------------------------------------------------------------------------- /spec/integration/system_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, 'system' do 4 | 5 | it "doesn't share state between machine callbacks" do 6 | callbacks = [] 7 | stub_const("FSM_A", Class.new(FiniteMachine::Definition) do 8 | event :init, :none => :green 9 | event :start, any_state => :green 10 | 11 | on_before do |event| 12 | callbacks << "fsmA on_before(#{event.name})" 13 | end 14 | on_enter_green do |event| 15 | target.fire 16 | callbacks << "fsmA on_enter(:green)" 17 | end 18 | once_on_enter_green do |event| 19 | callbacks << "fsmA once_on_enter(:green)" 20 | end 21 | end) 22 | 23 | stub_const("FSM_B", Class.new(FiniteMachine::Definition) do 24 | event :init, :none => :stopped 25 | event :start, :stopped => :started 26 | 27 | on_before do |event| 28 | callbacks << "fsmB on_before(#{event.name})" 29 | end 30 | on_enter_stopped do |event| 31 | callbacks << "fsmB on_enter(:stopped)" 32 | end 33 | on_enter_started do |event| 34 | callbacks << "fsmB on_enter(:started)" 35 | end 36 | end) 37 | 38 | class Backend 39 | def initialize 40 | @fsmB = FSM_B.new 41 | @fsmB.init 42 | @signal = Mutex.new 43 | end 44 | 45 | def operate 46 | @signal.unlock if @signal.locked? 47 | @worker = Thread.new do 48 | while !@signal.locked? do 49 | sleep 0.01 50 | end 51 | Thread.current.abort_on_exception = true 52 | @fsmB.start 53 | end 54 | end 55 | 56 | def stopit 57 | @signal.lock 58 | @worker.join 59 | end 60 | end 61 | 62 | class Fire 63 | def initialize 64 | @fsmA = FSM_A.new(self) 65 | 66 | @backend = Backend.new 67 | @backend.operate 68 | end 69 | 70 | def fire 71 | @backend.stopit 72 | end 73 | 74 | def operate 75 | #@fsmA.start # should trigger as well 76 | @fsmA.init 77 | end 78 | end 79 | 80 | fire = Fire.new 81 | fire.operate 82 | 83 | expect(callbacks).to match_array([ 84 | 'fsmB on_before(init)', 85 | 'fsmB on_enter(:stopped)', 86 | 'fsmA on_before(init)', 87 | 'fsmA on_enter(:green)', 88 | 'fsmA once_on_enter(:green)', 89 | 'fsmB on_before(start)', 90 | 'fsmB on_enter(:started)' 91 | ]) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/performance/benchmark_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, perf: true do 4 | include RSpec::Benchmark::Matchers 5 | 6 | class Measurement 7 | attr_reader :steps, :loops 8 | 9 | def initialize 10 | @steps = 0 11 | @loops = 0 12 | end 13 | 14 | def inc_step 15 | @steps += 1 16 | end 17 | 18 | def inc_loop 19 | @loops += 1 20 | end 21 | end 22 | 23 | it "correctly loops through events" do 24 | measurement = Measurement.new 25 | 26 | fsm = FiniteMachine.new(measurement) do 27 | initial :green 28 | 29 | event :next, :green => :yellow, 30 | :yellow => :red, 31 | :red => :green 32 | 33 | on_enter do |event| target.inc_step; true end 34 | on_enter :red do |event| target.inc_loop; true end 35 | end 36 | 37 | 100.times { fsm.next } 38 | 39 | expect(measurement.steps).to eq(100) 40 | expect(measurement.loops).to eq(100 / 3) 41 | end 42 | 43 | it "performs at least 650 ips" do 44 | fsm = FiniteMachine.new do 45 | initial :green 46 | 47 | event :next, :green => :yellow, 48 | :yellow => :red, 49 | :red => :green 50 | end 51 | 52 | expect { fsm.next }.to perform_at_least(650).ips 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV["COVERAGE"] == "true" 4 | require "simplecov" 5 | require "coveralls" 6 | 7 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ 8 | SimpleCov::Formatter::HTMLFormatter, 9 | Coveralls::SimpleCov::Formatter 10 | ]) 11 | 12 | SimpleCov.start do 13 | command_name "spec" 14 | add_filter "spec" 15 | end 16 | end 17 | 18 | require "finite_machine" 19 | require "rspec-benchmark" 20 | 21 | RSpec.configure do |config| 22 | config.run_all_when_everything_filtered = true 23 | config.filter_run :focus 24 | config.raise_errors_for_deprecations! 25 | config.mock_with :rspec do |mocks| 26 | mocks.verify_partial_doubles = true 27 | end 28 | config.disable_monkey_patching! 29 | if config.files_to_run.one? 30 | config.default_formatter = "doc" 31 | end 32 | config.order = :random 33 | end 34 | -------------------------------------------------------------------------------- /spec/unit/alias_target_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Definition, "#alias_target" do 4 | 5 | before do 6 | stub_const("Car", Class.new do 7 | def turn_reverse_lights_off 8 | @reverse_lights = false 9 | end 10 | 11 | def turn_reverse_lights_on 12 | @reverse_lights = true 13 | end 14 | 15 | def reverse_lights? 16 | @reverse_lights ||= false 17 | end 18 | end) 19 | end 20 | 21 | it "aliases target" do 22 | car = Car.new 23 | fsm = FiniteMachine.new(car, alias_target: :delorean) 24 | 25 | expect(fsm.target).to eq(car) 26 | expect { fsm.car }.to raise_error(NoMethodError) 27 | expect(fsm.delorean).to eq(car) 28 | end 29 | 30 | it "scopes the target alias to a state machine instance" do 31 | delorean = Car.new 32 | batmobile = Car.new 33 | fsm_a = FiniteMachine.new(delorean, alias_target: :delorean) 34 | fsm_b = FiniteMachine.new(batmobile, alias_target: :batmobile) 35 | 36 | expect(fsm_a.delorean).to eq(delorean) 37 | expect { fsm_a.batmobile }.to raise_error(NameError) 38 | 39 | expect(fsm_b.batmobile).to eq(batmobile) 40 | expect { fsm_b.delorean }.to raise_error(NameError) 41 | end 42 | 43 | context "when outside definition" do 44 | before do 45 | stub_const("Engine", Class.new(FiniteMachine::Definition) do 46 | initial :neutral 47 | 48 | event :forward, [:reverse, :neutral] => :one 49 | event :shift, :one => :two 50 | event :shift, :two => :one 51 | event :back, [:neutral, :one] => :reverse 52 | 53 | on_enter :reverse do |event| 54 | car.turn_reverse_lights_on 55 | end 56 | 57 | on_exit :reverse do |event| 58 | car.turn_reverse_lights_off 59 | end 60 | 61 | handle FiniteMachine::InvalidStateError do |exception| end 62 | end) 63 | end 64 | 65 | it "creates unique instances" do 66 | engine_a = Engine.new(alias_target: :car) 67 | engine_b = Engine.new(alias_target: :car) 68 | expect(engine_a).not_to be(engine_b) 69 | 70 | engine_a.forward 71 | expect(engine_a.current).to eq(:one) 72 | expect(engine_b.current).to eq(:neutral) 73 | end 74 | 75 | it "allows to create standalone machine" do 76 | car = Car.new 77 | engine = Engine.new(car, alias_target: :car) 78 | expect(engine.current).to eq(:neutral) 79 | 80 | engine.forward 81 | expect(engine.current).to eq(:one) 82 | expect(car.reverse_lights?).to be false 83 | 84 | engine.back 85 | expect(engine.current).to eq(:reverse) 86 | expect(car.reverse_lights?).to be true 87 | end 88 | end 89 | 90 | context "when target aliased inside definition" do 91 | before do 92 | stub_const("Matter", Class.new do 93 | attr_accessor :state 94 | 95 | def initialize 96 | @state = :gas 97 | end 98 | end) 99 | 100 | stub_const("Diesel", Class.new(FiniteMachine::Definition) do 101 | alias_target :matter 102 | 103 | initial :gas 104 | 105 | event :fuel, :gas => :liquid 106 | 107 | on_transition do |event| 108 | matter.state = event.to 109 | end 110 | end) 111 | end 112 | 113 | it "uses alias_target helper" do 114 | matter = Matter.new 115 | diesel = Diesel.new(matter) 116 | expect(diesel.current).to eq(:gas) 117 | expect(matter.state).to eq(:gas) 118 | 119 | diesel.fuel 120 | 121 | expect(diesel.current).to eq(:liquid) 122 | expect(matter.state).to eq(:liquid) 123 | end 124 | 125 | it "adds additional alias with alias_target helper" do 126 | matter = Matter.new 127 | diesel = Diesel.new(matter) 128 | expect(diesel.env.aliases).to eq(%i[matter]) 129 | 130 | diesel.alias_target :substance 131 | 132 | expect(diesel.env.aliases).to eq(%i[matter substance]) 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /spec/unit/async_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "async callbacks" do 4 | it "permits async callback" do 5 | called = [] 6 | fsm = FiniteMachine.new do 7 | initial :green, silent: false 8 | 9 | event :slow, :green => :yellow 10 | event :go, :yellow => :green 11 | 12 | on_enter :green, :async do |event| called << "on_enter_green" end 13 | on_before :slow, :async do |event| called << "on_before_slow" end 14 | on_exit :yellow, :async do |event| called << "on_exit_yellow" end 15 | on_after :go, :async do |event| called << "on_after_go" end 16 | end 17 | fsm.slow 18 | fsm.go 19 | sleep 0.1 20 | expect(called).to match_array([ 21 | "on_enter_green", 22 | "on_before_slow", 23 | "on_exit_yellow", 24 | "on_enter_green", 25 | "on_after_go" 26 | ]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/unit/auto_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, ":auto_methods" do 4 | it "allows turning off automatic methods generation" do 5 | fsm = FiniteMachine.new(auto_methods: false) do 6 | initial :green 7 | 8 | event :slow, :green => :yellow 9 | event :stop, :yellow => :red 10 | event :ready, :red => :yellow 11 | event :go, :yellow => :green 12 | 13 | # allows for fluid callback names 14 | once_on_enter_yellow do |event| "once_on_enter_yellow" end 15 | end 16 | 17 | expect(fsm.respond_to?(:slow)).to eq(false) 18 | expect { fsm.slow }.to raise_error(NoMethodError) 19 | expect(fsm.current).to eq(:green) 20 | 21 | fsm.trigger(:slow) 22 | expect(fsm.current).to eq(:yellow) 23 | end 24 | 25 | it "allows to use any method name without auto method generation" do 26 | fsm = FiniteMachine.new(auto_methods: false) do 27 | initial :green 28 | 29 | event :fail, :green => :red 30 | end 31 | 32 | fsm.trigger(:fail) 33 | expect(fsm.current).to eq(:red) 34 | end 35 | 36 | it "detects dangerous event names" do 37 | expect { 38 | FiniteMachine.new do 39 | event :trigger, :a => :b 40 | end 41 | }.to raise_error(FiniteMachine::AlreadyDefinedError) 42 | end 43 | end 44 | 45 | -------------------------------------------------------------------------------- /spec/unit/callable/call_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Callable, "#call" do 4 | 5 | before(:each) { 6 | stub_const("Car", Class.new do 7 | attr_reader :result 8 | 9 | def initialize 10 | @engine_on = false 11 | end 12 | 13 | def turn_engine_on 14 | @result = "turn_engine_on" 15 | @engine_on = true 16 | end 17 | 18 | def set_engine(value = :on) 19 | @result = "set_engine(#{value})" 20 | @engine = value.to_sym == :on 21 | end 22 | 23 | def turn_engine_off 24 | @result = "turn_engine_off" 25 | @engine_on = false 26 | end 27 | 28 | def engine_on? 29 | @result = "engine_on" 30 | !!@engine_on 31 | end 32 | end) 33 | } 34 | 35 | let(:called) { [] } 36 | 37 | let(:target) { Car.new } 38 | 39 | let(:instance) { described_class.new(object) } 40 | 41 | context "when string" do 42 | let(:object) { "engine_on?" } 43 | 44 | it "executes method on target" do 45 | instance.call(target) 46 | expect(target.result).to eql("engine_on") 47 | end 48 | end 49 | 50 | context "when string" do 51 | let(:object) { "set_engine(:on)" } 52 | 53 | it "executes method with arguments" do 54 | instance.call(target) 55 | expect(target.result).to eql("set_engine(on)") 56 | end 57 | end 58 | 59 | context "when string with arguments" do 60 | let(:object) { "set_engine" } 61 | 62 | it "executes method with arguments" do 63 | instance.call(target, :off) 64 | expect(target.result).to eql("set_engine(off)") 65 | end 66 | end 67 | 68 | context "when symbol" do 69 | let(:object) { :set_engine } 70 | 71 | it "executes method on target" do 72 | instance.call(target) 73 | expect(target.result).to eql("set_engine(on)") 74 | end 75 | end 76 | 77 | context "when symbol with arguments" do 78 | let(:object) { :set_engine } 79 | 80 | it "executes method on target" do 81 | instance.call(target, :off) 82 | expect(target.result).to eql("set_engine(off)") 83 | end 84 | end 85 | 86 | context "when proc without args" do 87 | let(:object) { proc { |a| called << "block_with(#{a})" } } 88 | 89 | it "passes arguments" do 90 | instance.call(target) 91 | expect(called).to eql(["block_with(#{target})"]) 92 | end 93 | end 94 | 95 | context "when proc with args" do 96 | let(:object) { proc { |a,b| called << "block_with(#{a},#{b})" } } 97 | 98 | it "passes arguments" do 99 | instance.call(target, :red) 100 | expect(called).to eql(["block_with(#{target},red)"]) 101 | end 102 | end 103 | 104 | context "when unknown" do 105 | let(:object) { Object.new } 106 | 107 | it "raises error" do 108 | expect { instance.call(target) }.to raise_error(ArgumentError) 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/unit/can_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "can?" do 4 | before(:each) { 5 | stub_const("Bug", Class.new do 6 | def pending? 7 | false 8 | end 9 | end) 10 | } 11 | 12 | it "allows to check if event can be fired" do 13 | fsm = FiniteMachine.new do 14 | initial :green 15 | 16 | event :slow, :green => :yellow 17 | event :stop, :yellow => :red 18 | event :ready, :red => :yellow 19 | event :go, :yellow => :green 20 | end 21 | 22 | expect(fsm.current).to eql(:green) 23 | 24 | expect(fsm.can?(:slow)).to be(true) 25 | expect(fsm.cannot?(:stop)).to be(true) 26 | expect(fsm.can?(:ready)).to be(false) 27 | expect(fsm.can?(:go)).to be(false) 28 | 29 | fsm.slow 30 | expect(fsm.current).to eql(:yellow) 31 | 32 | expect(fsm.can?(:slow)).to be(false) 33 | expect(fsm.can?(:stop)).to be(true) 34 | expect(fsm.can?(:ready)).to be(false) 35 | expect(fsm.can?(:go)).to be(true) 36 | 37 | fsm.stop 38 | expect(fsm.current).to eql(:red) 39 | 40 | expect(fsm.can?(:slow)).to be(false) 41 | expect(fsm.can?(:stop)).to be(false) 42 | expect(fsm.can?(:ready)).to be(true) 43 | expect(fsm.can?(:go)).to be(false) 44 | 45 | fsm.ready 46 | expect(fsm.current).to eql(:yellow) 47 | 48 | expect(fsm.can?(:slow)).to be(false) 49 | expect(fsm.can?(:stop)).to be(true) 50 | expect(fsm.can?(:ready)).to be(false) 51 | expect(fsm.can?(:go)).to be(true) 52 | end 53 | 54 | context "with conditionl transition" do 55 | it "evalutes condition with parameters" do 56 | fsm = FiniteMachine.new do 57 | initial :green 58 | 59 | event :slow, :green => :yellow 60 | event :stop, :yellow => :red, if: proc { |_, state| state } 61 | end 62 | expect(fsm.current).to eq(:green) 63 | expect(fsm.can?(:slow)).to be(true) 64 | expect(fsm.can?(:stop)).to be(false) 65 | 66 | fsm.slow 67 | expect(fsm.current).to eq(:yellow) 68 | expect(fsm.can?(:stop, false)).to be(false) 69 | expect(fsm.can?(:stop, true)).to be(true) 70 | end 71 | 72 | it "checks against target and grouped events" do 73 | bug = Bug.new 74 | fsm = FiniteMachine.new(bug) do 75 | initial :initial 76 | 77 | event :bump, :initial => :low 78 | event :bump, :low => :medium, if: :pending? 79 | event :bump, :medium => :high 80 | end 81 | expect(fsm.current).to eq(:initial) 82 | 83 | expect(fsm.can?(:bump)).to be(true) 84 | fsm.bump 85 | expect(fsm.can?(:bump)).to be(false) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/unit/cancel_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "#cancel_event" do 4 | it "cancels transition on event callback" do 5 | fsm = FiniteMachine.new do 6 | initial :green 7 | 8 | event :slow, :green => :yellow 9 | event :go, :yellow => :green 10 | 11 | on_exit :green do |event| 12 | cancel_event(event) 13 | end 14 | end 15 | 16 | expect(fsm.current).to eql(:green) 17 | fsm.slow 18 | expect(fsm.current).to eql(:green) 19 | end 20 | 21 | it "stops executing callbacks when cancelled" do 22 | called = [] 23 | 24 | fsm = FiniteMachine.new do 25 | initial :initial 26 | 27 | event :bump, initial: :low 28 | 29 | on_before do |event| 30 | called << "enter_#{event.name}_#{event.from}_#{event.to}" 31 | 32 | cancel_event(event) 33 | end 34 | 35 | on_exit :initial do |event| called << "exit_initial" end 36 | on_exit do |event| called << "exit_any" end 37 | on_enter :low do |event| called << "enter_low" end 38 | on_after :bump do |event| called << "after_#{event.name}" end 39 | on_after do |event| called << "after_any" end 40 | end 41 | 42 | fsm.bump 43 | 44 | expect(called).to eq(["enter_bump_initial_low"]) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/unit/choice_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "#choice" do 4 | before(:each) { 5 | stub_const("User", Class.new do 6 | def promo?(token = false) 7 | token == :yes 8 | end 9 | end) 10 | } 11 | 12 | it "allows for static choice based on conditional branching" do 13 | called = [] 14 | fsm = FiniteMachine.new do 15 | initial :company_form 16 | 17 | event :next, from: :company_form do 18 | choice :agreement_form, if: -> { false } 19 | choice :promo_form, if: -> { false } 20 | choice :official_form, if: -> { true } 21 | end 22 | 23 | on_exit do |event| called << "on_exit_#{event.from}" end 24 | on_enter do |event| called << "on_enter_#{event.to}" end 25 | end 26 | expect(fsm.current).to eq(:company_form) 27 | fsm.next 28 | expect(fsm.current).to eq(:official_form) 29 | expect(called).to eq([ 30 | "on_exit_company_form", 31 | "on_enter_official_form" 32 | ]) 33 | end 34 | 35 | it "allows for dynamic choice based on conditional branching" do 36 | fsm = FiniteMachine.new do 37 | initial :company_form 38 | 39 | event :next, from: :company_form do 40 | choice :agreement_form, if: proc { |_, a| a < 1 } 41 | choice :promo_form, if: proc { |_, a| a == 1 } 42 | choice :official_form, if: proc { |_, a| a > 1 } 43 | end 44 | end 45 | expect(fsm.current).to eq(:company_form) 46 | fsm.next(0) 47 | expect(fsm.current).to eq(:agreement_form) 48 | 49 | fsm.restore!(:company_form) 50 | fsm.next(1) 51 | expect(fsm.current).to eq(:promo_form) 52 | 53 | fsm.restore!(:company_form) 54 | fsm.next(2) 55 | expect(fsm.current).to eq(:official_form) 56 | end 57 | 58 | it "allows for dynamic choice based on conditional branching and target" do 59 | user = User.new 60 | fsm = FiniteMachine.new(user) do 61 | initial :company_form 62 | 63 | event :next, from: :company_form do 64 | choice :agreement_form, if: proc { |_user, token| _user.promo?(token) } 65 | choice :promo_form, unless: proc { |_user, token| _user.promo?(token) } 66 | end 67 | end 68 | expect(fsm.current).to eq(:company_form) 69 | fsm.next(:no) 70 | expect(fsm.current).to eq(:promo_form) 71 | fsm.restore!(:company_form) 72 | fsm.next(:yes) 73 | expect(fsm.current).to eq(:agreement_form) 74 | end 75 | 76 | it "chooses state when skipped if/unless" do 77 | fsm = FiniteMachine.new do 78 | initial :company_form 79 | 80 | event :next, from: :company_form do 81 | choice :agreement_form, if: -> { false } 82 | choice :promo_form 83 | choice :official_form, if: -> { true } 84 | end 85 | end 86 | expect(fsm.current).to eq(:company_form) 87 | fsm.next 88 | expect(fsm.current).to eq(:promo_form) 89 | end 90 | 91 | it "chooses default state when branching conditions don't match" do 92 | fsm = FiniteMachine.new do 93 | initial :company_form 94 | 95 | event :next, from: :company_form do 96 | choice :agreement_form, if: -> { false } 97 | choice :promo_form, if: -> { false } 98 | default :official_form 99 | end 100 | end 101 | expect(fsm.current).to eq(:company_form) 102 | fsm.next 103 | expect(fsm.current).to eq(:official_form) 104 | end 105 | 106 | it "fails to transition when no condition matches without default state" do 107 | fsm = FiniteMachine.new do 108 | initial :company_form 109 | 110 | event :next, from: :company_form do 111 | choice :agreement_form, if: -> { false } 112 | choice :promo_form, if: -> { false } 113 | end 114 | end 115 | expect(fsm.current).to eq(:company_form) 116 | fsm.next 117 | expect(fsm.current).to eq(:company_form) 118 | end 119 | 120 | it "allows to transition from multiple states to choice pseudostate" do 121 | fsm = FiniteMachine.new do 122 | initial :red 123 | 124 | event :go, from: [:yellow, :red] do 125 | choice :pink, if: -> { false } 126 | choice :green 127 | end 128 | end 129 | expect(fsm.current).to eq(:red) 130 | fsm.go 131 | expect(fsm.current).to eq(:green) 132 | fsm.restore!(:yellow) 133 | expect(fsm.current).to eq(:yellow) 134 | fsm.go 135 | expect(fsm.current).to eq(:green) 136 | end 137 | 138 | it "allows to transition from any state to choice pseudo state" do 139 | fsm = FiniteMachine.new do 140 | initial :red 141 | 142 | event :go, from: any_state do 143 | choice :pink, if: -> { false } 144 | choice :green 145 | end 146 | end 147 | expect(fsm.current).to eq(:red) 148 | fsm.go 149 | expect(fsm.current).to eq(:green) 150 | end 151 | 152 | it "groups correctly events under the same name" do 153 | fsm = FiniteMachine.new do 154 | initial :red 155 | 156 | event :next, from: :yellow, to: :green 157 | 158 | event :next, from: :red do 159 | choice :pink, if: -> { false } 160 | choice :yellow 161 | end 162 | end 163 | expect(fsm.current).to eq(:red) 164 | fsm.next 165 | expect(fsm.current).to eq(:yellow) 166 | fsm.next 167 | expect(fsm.current).to eq(:green) 168 | end 169 | 170 | it "performs matching transitions for multiple event definitions with the same name" do 171 | ticket = double(:ticket, :pending? => true, :finished? => true) 172 | fsm = FiniteMachine.new(ticket) do 173 | initial :inactive 174 | 175 | event :advance, from: [:inactive, :paused, :fulfilled] do 176 | choice :active, if: proc { |_ticket| !_ticket.pending? } 177 | end 178 | 179 | event :advance, from: [:inactive, :active, :fulfilled] do 180 | choice :paused, if: proc { |_ticket| _ticket.pending? } 181 | end 182 | 183 | event :advance, from: [:inactive, :active, :paused] do 184 | choice :fulfilled, if: proc { |_ticket| _ticket.finished? } 185 | end 186 | end 187 | expect(fsm.current).to eq(:inactive) 188 | fsm.advance 189 | expect(fsm.current).to eq(:paused) 190 | fsm.advance 191 | expect(fsm.current).to eq(:fulfilled) 192 | end 193 | 194 | it "does not transition when no matching choice for multiple event definitions" do 195 | ticket = double(:ticket, :pending? => true, :finished? => false) 196 | called = [] 197 | fsm = FiniteMachine.new(ticket) do 198 | initial :inactive 199 | 200 | event :advance, from: [:inactive, :paused, :fulfilled] do 201 | choice :active, if: proc { |_ticket| !_ticket.pending? } 202 | end 203 | 204 | event :advance, from: [:inactive, :active, :fulfilled] do 205 | choice :paused, if: proc { |_ticket| _ticket.pending? } 206 | end 207 | 208 | event :advance, from: [:inactive, :active, :paused] do 209 | choice :fulfilled, if: proc { |_ticket| _ticket.finished? } 210 | end 211 | 212 | on_before(:advance) { called << "on_before_advance" } 213 | on_after(:advance) { called << "on_after_advance" } 214 | end 215 | expect(fsm.current).to eq(:inactive) 216 | fsm.advance 217 | expect(fsm.current).to eq(:paused) 218 | fsm.advance 219 | expect(fsm.current).to eq(:paused) 220 | expect(called).to eq([ 221 | "on_before_advance", 222 | "on_after_advance", 223 | "on_before_advance", 224 | "on_after_advance" 225 | ]) 226 | end 227 | 228 | it "sets callback properties correctly" do 229 | expected = {name: :init, from: :none, to: :red, a: nil, b: nil, c: nil } 230 | 231 | callback = Proc.new { |event, a, b, c| 232 | target.expect(event.from).to target.eql(expected[:from]) 233 | target.expect(event.to).to target.eql(expected[:to]) 234 | target.expect(event.name).to target.eql(expected[:name]) 235 | target.expect(a).to target.eql(expected[:a]) 236 | target.expect(b).to target.eql(expected[:b]) 237 | target.expect(c).to target.eql(expected[:c]) 238 | } 239 | 240 | fsm = FiniteMachine.new(self) do 241 | initial :red 242 | 243 | event :next, from: :red do 244 | choice :green, if: -> { false } 245 | choice :yellow 246 | end 247 | 248 | event :next, from: :yellow do 249 | choice :green, if: -> { true } 250 | choice :yellow 251 | end 252 | 253 | event :finish, from: any_state do 254 | choice :green, if: -> { false } 255 | choice :red 256 | end 257 | 258 | # generic state callbacks 259 | on_enter(&callback) 260 | on_transition(&callback) 261 | on_exit(&callback) 262 | 263 | # generic event callbacks 264 | on_before(&callback) 265 | on_after(&callback) 266 | 267 | # state callbacks 268 | on_enter :green, &callback 269 | on_enter :yellow, &callback 270 | on_enter :red, &callback 271 | 272 | on_transition :green, &callback 273 | on_transition :yellow, &callback 274 | on_transition :red, &callback 275 | 276 | on_exit :green, &callback 277 | on_exit :yellow, &callback 278 | on_exit :red, &callback 279 | 280 | # event callbacks 281 | on_before :next, &callback 282 | on_after :next, &callback 283 | end 284 | expect(fsm.current).to eq(:red) 285 | 286 | expected = {name: :next, from: :red, to: :yellow, a: 1, b: 2, c: 3} 287 | fsm.next(1, 2, 3) 288 | 289 | expected = {name: :next, from: :yellow, to: :green, a: 4, b: 5, c: 6} 290 | fsm.next(4, 5, 6) 291 | 292 | expected = {name: :finish, from: :green, to: :red, a: 7, b: 8, c: 9} 293 | fsm.finish(7, 8, 9) 294 | end 295 | end 296 | -------------------------------------------------------------------------------- /spec/unit/define_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, ".define" do 4 | context "with block" do 5 | it "creates a state machine" do 6 | stub_const("TrafficLights", described_class.define do 7 | initial :green 8 | 9 | event :slow, :green => :yellow 10 | event :stop, :yellow => :red 11 | event :ready, :red => :yellow 12 | event :go, :yellow => :green 13 | end) 14 | 15 | fsm_a = TrafficLights.new 16 | fsm_b = TrafficLights.new 17 | 18 | expect(fsm_a.current).to eq(:green) 19 | expect(fsm_b.current).to eq(:green) 20 | 21 | fsm_a.slow 22 | expect(fsm_a.current).to eq(:yellow) 23 | expect(fsm_b.current).to eq(:green) 24 | 25 | fsm_a.stop 26 | expect(fsm_a.current).to eq(:red) 27 | expect(fsm_b.current).to eq(:green) 28 | end 29 | 30 | it "uses any_state method inside the define method block" do 31 | stub_const("TrafficLights", described_class.define do 32 | initial :green 33 | 34 | event :slow, any_state => :yellow 35 | 36 | on_enter(any_state) { |event| target << "enter_#{event.to}" } 37 | on_transition(any_state) { |event| target << "transition_#{event.to}" } 38 | on_exit(any_state) { |event| target << "exit_#{event.from}" } 39 | end) 40 | 41 | fsm = TrafficLights.new(called = []) 42 | fsm.slow 43 | 44 | expect(fsm.current).to eq(:yellow) 45 | expect(called).to eq(%w[exit_green transition_yellow enter_yellow]) 46 | end 47 | 48 | it "uses any_event method inside the define method block" do 49 | stub_const("TrafficLights", described_class.define do 50 | initial :green 51 | 52 | event :slow, :green => :yellow 53 | 54 | on_before(any_event) { |event| target << "before_#{event.name}" } 55 | on_after(any_event) { |event| target << "after_#{event.name}" } 56 | end) 57 | 58 | fsm = TrafficLights.new(called = []) 59 | fsm.slow 60 | 61 | expect(fsm.current).to eq(:yellow) 62 | expect(called).to eq(%w[before_slow after_slow]) 63 | end 64 | end 65 | 66 | context "without block" do 67 | it "creates a state machine" do 68 | called = [] 69 | stub_const("TrafficLights", described_class.define) 70 | TrafficLights.initial(:green) 71 | TrafficLights.event(:slow, :green => :yellow) 72 | TrafficLights.event(:stop, :yellow => :red) 73 | TrafficLights.event(:ready, :red => :yellow) 74 | TrafficLights.event(:go, :yellow => :green) 75 | TrafficLights.on_enter(:yellow) { called << "on_enter_yellow" } 76 | TrafficLights.handle(FiniteMachine::InvalidStateError) do 77 | called << "error_handler" 78 | end 79 | 80 | fsm = TrafficLights.new 81 | 82 | expect(fsm.current).to eq(:green) 83 | fsm.slow 84 | expect(fsm.current).to eq(:yellow) 85 | fsm.ready 86 | expect(fsm.current).to eq(:yellow) 87 | expect(called).to eq(%w[on_enter_yellow error_handler]) 88 | end 89 | 90 | it "uses any_state method outside the define method block" do 91 | stub_const("TrafficLights", described_class.define) 92 | TrafficLights.initial(:green) 93 | TrafficLights.event(:slow, TrafficLights.any_state => :yellow) 94 | TrafficLights.on_enter(TrafficLights.any_state) do |event| 95 | target << "enter_#{event.to}" 96 | end 97 | TrafficLights.on_transition(TrafficLights.any_state) do |event| 98 | target << "transition_#{event.to}" 99 | end 100 | TrafficLights.on_exit(TrafficLights.any_state) do |event| 101 | target << "exit_#{event.from}" 102 | end 103 | 104 | fsm = TrafficLights.new(called = []) 105 | fsm.slow 106 | 107 | expect(fsm.current).to eq(:yellow) 108 | expect(called).to eq(%w[exit_green transition_yellow enter_yellow]) 109 | end 110 | 111 | it "uses any_event method outside the define method block" do 112 | stub_const("TrafficLights", described_class.define) 113 | TrafficLights.initial(:green) 114 | TrafficLights.event(:slow, :green => :yellow) 115 | TrafficLights.on_before(TrafficLights.any_event) do |event| 116 | target << "before_#{event.name}" 117 | end 118 | TrafficLights.on_after(TrafficLights.any_event) do |event| 119 | target << "after_#{event.name}" 120 | end 121 | 122 | fsm = TrafficLights.new(called = []) 123 | fsm.slow 124 | 125 | expect(fsm.current).to eq(:yellow) 126 | expect(called).to eq(%w[before_slow after_slow]) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/unit/definition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Definition do 4 | before do 5 | stub_const("Engine", Class.new(described_class) do 6 | initial :neutral 7 | 8 | event :forward, %i[reverse neutral] => :one 9 | event :shift, :one => :two 10 | event :shift, :two => :one 11 | event :back, %i[neutral one] => :reverse 12 | 13 | on_enter :reverse do 14 | target.turn_reverse_lights_on 15 | end 16 | 17 | on_exit :reverse do 18 | target.turn_reverse_lights_off 19 | end 20 | 21 | handle FiniteMachine::InvalidStateError do 22 | target.turn_reverse_lights_off 23 | end 24 | end) 25 | end 26 | 27 | it "creates unique instances" do 28 | engine_a = Engine.new 29 | engine_b = Engine.new 30 | expect(engine_a).not_to be(engine_b) 31 | 32 | expect(engine_a.current).to eq(:neutral) 33 | 34 | engine_a.forward 35 | expect(engine_a.current).to eq(:one) 36 | expect(engine_b.current).to eq(:neutral) 37 | end 38 | 39 | it "creates a standalone machine" do 40 | stub_const("Car", Class.new do 41 | def turn_reverse_lights_off 42 | @reverse_lights = false 43 | end 44 | 45 | def turn_reverse_lights_on 46 | @reverse_lights = true 47 | end 48 | 49 | def reverse_lights? 50 | @reverse_lights ||= false 51 | end 52 | end) 53 | 54 | car = Car.new 55 | engine = Engine.new(car) 56 | expect(engine.current).to eq(:neutral) 57 | 58 | engine.forward 59 | expect(engine.current).to eq(:one) 60 | expect(car.reverse_lights?).to eq(false) 61 | 62 | engine.back 63 | expect(engine.current).to eq(:reverse) 64 | expect(car.reverse_lights?).to eq(true) 65 | 66 | engine.shift 67 | expect(engine.current).to eq(:reverse) 68 | expect(car.reverse_lights?).to eq(false) 69 | end 70 | 71 | it "uses any_state method inside the definition class" do 72 | stub_const("TrafficLights", Class.new(described_class) do 73 | initial :green 74 | 75 | event :slow, any_state => :yellow 76 | 77 | on_enter(any_state) { |event| target << "enter_#{event.to}" } 78 | on_transition(any_state) { |event| target << "transition_#{event.to}" } 79 | on_exit(any_state) { |event| target << "exit_#{event.from}" } 80 | end) 81 | 82 | fsm = TrafficLights.new(called = []) 83 | fsm.slow 84 | 85 | expect(fsm.current).to eq(:yellow) 86 | expect(called).to eq(%w[exit_green transition_yellow enter_yellow]) 87 | end 88 | 89 | it "uses any_event method inside the definition class" do 90 | stub_const("TrafficLights", Class.new(described_class) do 91 | initial :green 92 | 93 | event :slow, :green => :yellow 94 | 95 | on_before(any_event) { |event| target << "before_#{event.name}" } 96 | on_after(any_event) { |event| target << "after_#{event.name}" } 97 | end) 98 | 99 | fsm = TrafficLights.new(called = []) 100 | fsm.slow 101 | 102 | expect(fsm.current).to eq(:yellow) 103 | expect(called).to eq(%w[before_slow after_slow]) 104 | end 105 | 106 | it "supports definitions inheritance" do 107 | stub_const("GenericStateMachine", Class.new(described_class) do 108 | initial :red 109 | 110 | event :start, :red => :green 111 | 112 | on_enter { target << "generic" } 113 | end) 114 | 115 | stub_const("SpecificStateMachine", Class.new(GenericStateMachine) do 116 | event :stop, :green => :yellow 117 | 118 | on_enter(:yellow) { target << "specific" } 119 | end) 120 | 121 | called = [] 122 | generic_fsm = GenericStateMachine.new(called) 123 | specific_fsm = SpecificStateMachine.new(called) 124 | 125 | expect(generic_fsm.states).to match_array(%i[none red green]) 126 | expect(specific_fsm.states).to match_array(%i[none red green yellow]) 127 | 128 | expect(specific_fsm.current).to eq(:red) 129 | 130 | specific_fsm.start 131 | expect(specific_fsm.current).to eq(:green) 132 | expect(called).to eq(%w[generic]) 133 | 134 | specific_fsm.stop 135 | expect(specific_fsm.current).to eq(:yellow) 136 | expect(called).to eq(%w[generic generic specific]) 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/unit/event_names_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "#events" do 4 | it "retrieves all event names" do 5 | fsm = FiniteMachine.new do 6 | initial :green 7 | 8 | event :start, :red => :green 9 | event :stop, :green => :red 10 | end 11 | 12 | expect(fsm.current).to eql(:green) 13 | expect(fsm.events).to match_array([:init, :start, :stop]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/unit/events_map/add_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::EventsMap, "#add" do 4 | it "adds transitions" do 5 | transition = double(:transition) 6 | events_map = described_class.new 7 | 8 | events_map.add(:validated, transition) 9 | expect(events_map[:validated]).to eq([transition]) 10 | 11 | events_map.add(:validated, transition) 12 | expect(events_map[:validated]).to eq([transition, transition]) 13 | end 14 | 15 | it "allows to map add operations" do 16 | events_map = described_class.new 17 | transition = double(:transition) 18 | 19 | events_map.add(:go, transition).add(:start, transition) 20 | 21 | expect(events_map.size).to eq(2) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/unit/events_map/choice_transition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::EventsMap, "#choice_transition?" do 4 | it "checks if transition has many branches" do 5 | transition_a = double(:transition_a, matches?: true) 6 | transition_b = double(:transition_b, matches?: true) 7 | 8 | events_map = described_class.new 9 | events_map.add(:go, transition_a) 10 | events_map.add(:go, transition_b) 11 | 12 | expect(events_map.choice_transition?(:go, :green)).to eq(true) 13 | end 14 | 15 | it "checks that transition has no branches" do 16 | transition_a = double(:transition_a, matches?: false) 17 | transition_b = double(:transition_b, matches?: true) 18 | 19 | events_map = described_class.new 20 | events_map.add(:go, transition_a) 21 | events_map.add(:go, transition_b) 22 | 23 | expect(events_map.choice_transition?(:go, :green)).to eq(false) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/unit/events_map/clear_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::EventsMap, "#clear" do 4 | it "clears map events" do 5 | event = double(:event) 6 | events_map = described_class.new 7 | events_map.add(:validated, event) 8 | expect(events_map.empty?).to be(false) 9 | 10 | events_map.clear 11 | expect(events_map.empty?).to be(true) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/events_map/events_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::EventsMap, "#events" do 4 | it "has no event names" do 5 | events_map = described_class.new 6 | expect(events_map.events).to eq([]) 7 | end 8 | 9 | it "returns all event names" do 10 | events_map = described_class.new 11 | transition = double(:transition) 12 | events_map.add(:ready, transition) 13 | events_map.add(:go, transition) 14 | expect(events_map.events).to match_array([:ready, :go]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/unit/events_map/inspect_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::EventsMap, "#inspect" do 4 | it "inspects empty events map" do 5 | events_map = described_class.new 6 | expect(events_map.inspect).to eq("<#FiniteMachine::EventsMap @events_map={}>") 7 | end 8 | 9 | it "inspect events map" do 10 | transition = double(:transition) 11 | events_map = described_class.new 12 | events_map.add(:validated, transition) 13 | expect(events_map.inspect).to eq("<#FiniteMachine::EventsMap @events_map=#{{validated: [transition]}}>") 14 | end 15 | 16 | it "prints events map" do 17 | transition = double(:transition) 18 | events_map = described_class.new 19 | events_map.add(:validated, transition) 20 | expect(events_map.to_s).to eq("#{{validated: [transition]}}") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/unit/events_map/match_transition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::EventsMap, "#match_transition" do 4 | it "matches transition without conditions" do 5 | transition_a = double(:transition_a, matches?: false) 6 | transition_b = double(:transition_b, matches?: true) 7 | events_map = described_class.new 8 | 9 | events_map.add(:a, transition_a) 10 | events_map.add(:a, transition_b) 11 | 12 | expect(events_map.match_transition(:a, :green)).to eq(transition_b) 13 | end 14 | 15 | it "fails to match any transition" do 16 | events_map = described_class.new 17 | 18 | expect(events_map.match_transition(:a, :green)).to eq(nil) 19 | end 20 | 21 | it "matches transition with conditions" do 22 | transition_a = double(:transition_a, matches?: true) 23 | transition_b = double(:transition_b, matches?: true) 24 | events_map = described_class.new 25 | 26 | events_map.add(:a, transition_a) 27 | events_map.add(:a, transition_b) 28 | 29 | allow(transition_a).to receive(:check_conditions).and_return(false) 30 | allow(transition_b).to receive(:check_conditions).and_return(true) 31 | 32 | expect(events_map.match_transition_with(:a, :green, "Piotr")).to eq(transition_b) 33 | expect(transition_a).to have_received(:check_conditions).with("Piotr") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/unit/events_map/move_to_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::EventsMap, "#move_to" do 4 | it "moves to state by matching individual transition" do 5 | transition_a = double(:transition_a, matches?: false) 6 | transition_b = double(:transition_b, matches?: true) 7 | 8 | events_map = described_class.new 9 | events_map.add(:go, transition_a) 10 | events_map.add(:go, transition_b) 11 | 12 | allow(transition_b).to receive(:to_state).with(:yellow).and_return(:red) 13 | 14 | expect(events_map.move_to(:go, :yellow)).to eq(:red) 15 | expect(transition_b).to have_received(:to_state).with(:yellow) 16 | end 17 | 18 | it "moves to state by matching choice transition" do 19 | transition_a = double(:transition_a, matches?: true) 20 | transition_b = double(:transition_b, matches?: true) 21 | 22 | events_map = described_class.new 23 | events_map.add(:go, transition_a) 24 | events_map.add(:go, transition_b) 25 | 26 | allow(transition_a).to receive(:check_conditions).and_return(false) 27 | allow(transition_b).to receive(:check_conditions).and_return(true) 28 | 29 | allow(transition_b).to receive(:to_state).with(:green).and_return(:red) 30 | 31 | expect(events_map.move_to(:go, :green)).to eq(:red) 32 | expect(transition_b).to have_received(:to_state).with(:green) 33 | end 34 | 35 | it "moves to from state if no transition available" do 36 | transition_a = double(:transition_a, matches?: false) 37 | transition_b = double(:transition_b, matches?: false) 38 | 39 | events_map = described_class.new 40 | events_map.add(:go, transition_a) 41 | events_map.add(:go, transition_b) 42 | 43 | expect(events_map.move_to(:go, :green)).to eq(:green) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/unit/events_map/states_for_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::EventsMap do 4 | it "finds current states for event name" do 5 | transition = spy(:transition, states: {:red => :yellow, :yellow => :green}) 6 | events_map = described_class.new 7 | events_map.add(:start, transition) 8 | 9 | expect(events_map.states_for(:start)).to eq([:red, :yellow]) 10 | end 11 | 12 | it "fails to find any states for event name" do 13 | events_map = described_class.new 14 | 15 | expect(events_map.states_for(:start)).to eq([]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/unit/events_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "events" do 4 | it "allows for hash rocket syntax to describe transition" do 5 | fsm = FiniteMachine.new do 6 | initial :green 7 | 8 | event :slow, :green => :yellow 9 | event :stop, :yellow => :red 10 | end 11 | 12 | expect(fsm.current).to eql(:green) 13 | fsm.slow 14 | expect(fsm.current).to eql(:yellow) 15 | fsm.stop 16 | expect(fsm.current).to eql(:red) 17 | end 18 | 19 | it "allows for (:from | :to) key pairs to describe transition" do 20 | fsm = FiniteMachine.new do 21 | initial :green 22 | 23 | event :slow, from: :green, to: :yellow 24 | event :stop, from: :yellow, to: :red 25 | end 26 | 27 | expect(fsm.current).to eql(:green) 28 | fsm.slow 29 | expect(fsm.current).to eql(:yellow) 30 | fsm.stop 31 | expect(fsm.current).to eql(:red) 32 | end 33 | 34 | it "permits no-op event without 'to' transition" do 35 | fsm = FiniteMachine.new do 36 | initial :green 37 | 38 | event :noop, from: :green 39 | event :slow, from: :green, to: :yellow 40 | event :stop, from: :yellow, to: :red 41 | event :ready, from: :red, to: :yellow 42 | event :go, from: :yellow, to: :green 43 | end 44 | 45 | expect(fsm.current).to eql(:green) 46 | 47 | expect(fsm.can?(:noop)).to be true 48 | expect(fsm.can?(:slow)).to be true 49 | 50 | fsm.noop 51 | expect(fsm.current).to eql(:green) 52 | fsm.slow 53 | expect(fsm.current).to eql(:yellow) 54 | 55 | expect(fsm.cannot?(:noop)).to be true 56 | expect(fsm.cannot?(:slow)).to be true 57 | end 58 | 59 | it "permits event from any state using :from" do 60 | fsm = FiniteMachine.new do 61 | initial :green 62 | 63 | event :slow, from: :green, to: :yellow 64 | event :stop, from: :yellow, to: :red 65 | event :ready, from: :red, to: :yellow 66 | event :go, from: :yellow, to: :green 67 | event :run, from: any_state, to: :green 68 | end 69 | 70 | expect(fsm.current).to eql(:green) 71 | 72 | fsm.slow 73 | expect(fsm.current).to eql(:yellow) 74 | fsm.run 75 | expect(fsm.current).to eql(:green) 76 | 77 | fsm.slow 78 | expect(fsm.current).to eql(:yellow) 79 | fsm.stop 80 | expect(fsm.current).to eql(:red) 81 | fsm.run 82 | expect(fsm.current).to eql(:green) 83 | 84 | fsm.slow 85 | expect(fsm.current).to eql(:yellow) 86 | fsm.go 87 | expect(fsm.current).to eql(:green) 88 | fsm.run 89 | expect(fsm.current).to eql(:green) 90 | end 91 | 92 | it "permits event from any state for hash syntax" do 93 | fsm = FiniteMachine.new do 94 | initial :red 95 | 96 | event :start, :red => :yellow 97 | event :run, :yellow => :green 98 | event :stop, :green => :red 99 | event :go, any_state => :green 100 | end 101 | 102 | expect(fsm.current).to eql(:red) 103 | 104 | fsm.go 105 | expect(fsm.current).to eql(:green) 106 | fsm.stop 107 | fsm.start 108 | expect(fsm.current).to eql(:yellow) 109 | fsm.go 110 | expect(fsm.current).to eql(:green) 111 | end 112 | 113 | it "permits event from any state without 'from'" do 114 | fsm = FiniteMachine.new do 115 | initial :green 116 | 117 | event :slow, from: :green, to: :yellow 118 | event :stop, from: :yellow, to: :red 119 | event :ready, from: :red, to: :yellow 120 | event :go, from: :yellow, to: :green 121 | event :run, to: :green 122 | end 123 | 124 | expect(fsm.current).to eql(:green) 125 | 126 | fsm.slow 127 | expect(fsm.current).to eql(:yellow) 128 | fsm.run 129 | expect(fsm.current).to eql(:green) 130 | 131 | fsm.slow 132 | expect(fsm.current).to eql(:yellow) 133 | fsm.stop 134 | expect(fsm.current).to eql(:red) 135 | fsm.run 136 | expect(fsm.current).to eql(:green) 137 | 138 | fsm.slow 139 | expect(fsm.current).to eql(:yellow) 140 | fsm.go 141 | expect(fsm.current).to eql(:green) 142 | fsm.run 143 | expect(fsm.current).to eql(:green) 144 | end 145 | 146 | it "doesn't raise error on invalid transition for non-dangerous version" do 147 | called = [] 148 | fsm = FiniteMachine.new do 149 | initial :green 150 | 151 | event :stop, from: :yellow, to: :red 152 | 153 | on_before :stop do |event| called << "on_before_stop" end 154 | on_after :stop do |event| called << "on_before_stop" end 155 | end 156 | 157 | expect(fsm.current).to eq(:green) 158 | expect(fsm.stop).to eq(false) 159 | expect(fsm.current).to eq(:green) 160 | expect(called).to match_array(["on_before_stop"]) 161 | end 162 | 163 | context "for non-dangerous version" do 164 | it "doesn't raise error on invalid transition and fires callbacks" do 165 | called = [] 166 | fsm = FiniteMachine.new do 167 | initial :green 168 | 169 | event :stop, from: :yellow, to: :red 170 | 171 | on_before :stop do |event| called << "on_before_stop" end 172 | on_after :stop do |event| called << "on_before_stop" end 173 | end 174 | 175 | expect(fsm.current).to eq(:green) 176 | expect(fsm.stop).to eq(false) 177 | expect(fsm.current).to eq(:green) 178 | expect(called).to match_array(["on_before_stop"]) 179 | end 180 | 181 | it "raises error on invalid transition for dangerous version" do 182 | called = [] 183 | fsm = FiniteMachine.new do 184 | initial :green 185 | 186 | event :slow, from: :green, to: :yellow 187 | event :stop, from: :yellow, to: :red, silent: true 188 | 189 | on_before :stop do |event| called << "on_before_stop" end 190 | on_after :stop do |event| called << "on_before_stop" end 191 | end 192 | 193 | expect(fsm.current).to eql(:green) 194 | expect(fsm.stop).to eq(false) 195 | expect(called).to match_array([]) 196 | end 197 | end 198 | 199 | context "for dangerous version" do 200 | it "raises error on invalid transition without callbacks" do 201 | called = [] 202 | fsm = FiniteMachine.new do 203 | initial :green 204 | 205 | event :start, :red => :yellow, silent: true 206 | 207 | on_before :start do |event| called << "on_before_start" end 208 | on_after :start do |event| called << "on_after_start" end 209 | end 210 | 211 | expect(fsm.current).to eq(:green) 212 | expect { fsm.start! }.to raise_error(FiniteMachine::InvalidStateError) 213 | expect(called).to eq([]) 214 | expect(fsm.current).to eq(:green) 215 | end 216 | 217 | it "raises error on invalid transition with callbacks fired" do 218 | called = [] 219 | fsm = FiniteMachine.new do 220 | initial :green 221 | 222 | event :start, :red => :yellow 223 | 224 | on_before :start do |event| called << "on_before_start" end 225 | on_after :start do |event| called << "on_after_start" end 226 | end 227 | 228 | expect(fsm.current).to eq(:green) 229 | expect { fsm.start! }.to raise_error(FiniteMachine::InvalidStateError, 230 | /inappropriate current state 'green'/) 231 | expect(called).to eq(["on_before_start"]) 232 | expect(fsm.current).to eq(:green) 233 | end 234 | end 235 | 236 | context "when multiple from states" do 237 | it "allows for array from key" do 238 | fsm = FiniteMachine.new do 239 | initial :green 240 | 241 | event :slow, :green => :yellow 242 | event :stop, [:green, :yellow] => :red 243 | event :ready, :red => :yellow 244 | event :go, [:yellow, :red] => :green 245 | end 246 | 247 | expect(fsm.current).to eql(:green) 248 | 249 | expect(fsm.can?(:slow)).to be true 250 | expect(fsm.can?(:stop)).to be true 251 | expect(fsm.cannot?(:ready)).to be true 252 | expect(fsm.cannot?(:go)).to be true 253 | 254 | fsm.slow; expect(fsm.current).to eql(:yellow) 255 | fsm.stop; expect(fsm.current).to eql(:red) 256 | fsm.ready; expect(fsm.current).to eql(:yellow) 257 | fsm.go; expect(fsm.current).to eql(:green) 258 | 259 | fsm.stop; expect(fsm.current).to eql(:red) 260 | fsm.go; expect(fsm.current).to eql(:green) 261 | end 262 | 263 | it "allows for hash of states" do 264 | fsm = FiniteMachine.new do 265 | initial :green 266 | 267 | event :slow, :green => :yellow 268 | event :stop, :green => :red, :yellow => :red 269 | event :ready, :red => :yellow 270 | event :go, :yellow => :green, :red => :green 271 | end 272 | 273 | expect(fsm.current).to eql(:green) 274 | 275 | expect(fsm.can?(:slow)).to be true 276 | expect(fsm.can?(:stop)).to be true 277 | expect(fsm.cannot?(:ready)).to be true 278 | expect(fsm.cannot?(:go)).to be true 279 | 280 | fsm.slow; expect(fsm.current).to eql(:yellow) 281 | fsm.stop; expect(fsm.current).to eql(:red) 282 | fsm.ready; expect(fsm.current).to eql(:yellow) 283 | fsm.go; expect(fsm.current).to eql(:green) 284 | 285 | fsm.stop; expect(fsm.current).to eql(:red) 286 | fsm.go; expect(fsm.current).to eql(:green) 287 | end 288 | end 289 | 290 | it "groups events with the same name" do 291 | fsm = FiniteMachine.new do 292 | initial :green 293 | 294 | event :stop, :green => :yellow 295 | event :stop, :yellow => :red 296 | event :stop, :red => :pink 297 | event :cycle, [:yellow, :red, :pink] => :green 298 | end 299 | 300 | expect(fsm.current).to eql(:green) 301 | expect(fsm.can?(:stop)).to be true 302 | fsm.stop 303 | expect(fsm.current).to eql(:yellow) 304 | fsm.stop 305 | expect(fsm.current).to eql(:red) 306 | fsm.stop 307 | expect(fsm.current).to eql(:pink) 308 | fsm.cycle 309 | expect(fsm.current).to eql(:green) 310 | fsm.stop 311 | expect(fsm.current).to eql(:yellow) 312 | end 313 | 314 | it "groups transitions under one event name" do 315 | fsm = FiniteMachine.new do 316 | initial :initial 317 | 318 | event :bump, :initial => :low, 319 | :low => :medium, 320 | :medium => :high 321 | end 322 | 323 | expect(fsm.current).to eq(:initial) 324 | fsm.bump; expect(fsm.current).to eq(:low) 325 | fsm.bump; expect(fsm.current).to eq(:medium) 326 | fsm.bump; expect(fsm.current).to eq(:high) 327 | end 328 | 329 | it "returns values for events" do 330 | fsm = FiniteMachine.new do 331 | initial :neutral 332 | 333 | event :start, :neutral => :engine_on 334 | event :drive, :engine_on => :running, if: -> { return false } 335 | event :stop, any_state => :neutral 336 | 337 | on_before(:drive) { cancel_event } 338 | on_after(:stop) { } 339 | end 340 | 341 | expect(fsm.current).to eql(:neutral) 342 | expect(fsm.start).to eql(true) 343 | expect(fsm.drive).to eql(false) 344 | expect(fsm.stop).to eql(true) 345 | expect(fsm.stop).to eql(true) 346 | end 347 | 348 | it "allows for self transition events" do 349 | digits = [] 350 | callbacks = [] 351 | phone = FiniteMachine.new do 352 | initial :on_hook 353 | 354 | event :digit, :on_hook => :dialing 355 | event :digit, :dialing => :dialing 356 | event :off_hook, :dialing => :alerting 357 | 358 | on_before_digit { |event, digit| digits << digit} 359 | on_before_off_hook { |event| callbacks << "dialing #{digits.join}" } 360 | end 361 | 362 | expect(phone.current).to eq(:on_hook) 363 | phone.digit(9) 364 | expect(phone.current).to eq(:dialing) 365 | phone.digit(1) 366 | expect(phone.current).to eq(:dialing) 367 | phone.digit(1) 368 | expect(phone.current).to eq(:dialing) 369 | phone.off_hook 370 | expect(phone.current).to eq(:alerting) 371 | expect(digits).to match_array(digits) 372 | expect(callbacks).to match_array(["dialing 911"]) 373 | end 374 | 375 | it "executes event block" do 376 | fsm = FiniteMachine.new do 377 | initial :red 378 | 379 | event :start, :red => :green 380 | event :stop, :green => :red 381 | end 382 | 383 | expect(fsm.current).to eq(:red) 384 | called = [] 385 | fsm.start do |from, to| 386 | called << "execute_start_#{from}_#{to}" 387 | end 388 | expect(called).to eq(["execute_start_red_green"]) 389 | end 390 | end 391 | -------------------------------------------------------------------------------- /spec/unit/handlers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "handlers" do 4 | before(:each) { 5 | stub_const("DummyLogger", Class.new do 6 | attr_reader :result 7 | 8 | def log_error(exception) 9 | @result = "log_error(#{exception})" 10 | end 11 | 12 | def raise_error 13 | raise FiniteMachine::TransitionError 14 | end 15 | end) 16 | } 17 | 18 | it "allows to customise error handling" do 19 | called = [] 20 | fsm = FiniteMachine.new do 21 | initial :green 22 | 23 | event :slow, :green => :yellow 24 | event :stop, :yellow => :red 25 | 26 | handle FiniteMachine::InvalidStateError do |exception| 27 | called << "invalidstate" 28 | end 29 | end 30 | 31 | expect(fsm.current).to eql(:green) 32 | fsm.stop 33 | expect(fsm.current).to eql(:green) 34 | expect(called).to eql([ 35 | "invalidstate" 36 | ]) 37 | end 38 | 39 | it "allows for :with to be symbol" do 40 | logger = DummyLogger.new 41 | fsm = FiniteMachine.new(logger) do 42 | initial :green 43 | 44 | event :slow, :green => :yellow 45 | event :stop, :yellow => :red 46 | 47 | handle FiniteMachine::InvalidStateError, with: :log_error 48 | end 49 | 50 | expect(fsm.current).to eql(:green) 51 | fsm.stop 52 | expect(fsm.current).to eql(:green) 53 | expect(logger.result).to eql("log_error(FiniteMachine::InvalidStateError)") 54 | end 55 | 56 | it "allows for error type as string" do 57 | logger = DummyLogger.new 58 | called = [] 59 | fsm = FiniteMachine.new(target: logger) do 60 | initial :green 61 | 62 | event :slow, :green => :yellow 63 | event :stop, :yellow => :red 64 | 65 | on_enter_yellow do |event| 66 | raise_error 67 | end 68 | 69 | handle "InvalidStateError" do |exception| 70 | called << "invalid_state_error" 71 | end 72 | end 73 | 74 | expect(fsm.current).to eql(:green) 75 | fsm.stop 76 | expect(fsm.current).to eql(:green) 77 | expect(called).to eql(["invalid_state_error"]) 78 | end 79 | 80 | it "allows for empty block handler" do 81 | called = [] 82 | fsm = FiniteMachine.new do 83 | initial :green 84 | 85 | event :slow, :green => :yellow 86 | event :stop, :yellow => :red 87 | 88 | handle FiniteMachine::InvalidStateError do 89 | called << "invalidstate" 90 | end 91 | end 92 | 93 | expect(fsm.current).to eql(:green) 94 | fsm.stop 95 | expect(fsm.current).to eql(:green) 96 | expect(called).to eql([ 97 | "invalidstate" 98 | ]) 99 | end 100 | 101 | it "requires error handler" do 102 | expect { FiniteMachine.new do 103 | initial :green 104 | 105 | event :slow, :green => :yellow 106 | 107 | handle "UnknownErrorType" 108 | end }.to raise_error(ArgumentError, /error handler/) 109 | end 110 | 111 | it "checks handler class to be Exception" do 112 | expect { FiniteMachine.new do 113 | initial :green 114 | 115 | event :slow, :green => :yellow 116 | 117 | handle Object do end 118 | end }.to raise_error(ArgumentError, /Object isn't an Exception/) 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/unit/hook_event/any_state_or_event_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::HookEvent, "#any_state_or_event" do 4 | it "infers default name for state" do 5 | hook_event = described_class::Enter 6 | expect(described_class.any_state_or_event(hook_event)).to eq(FiniteMachine::ANY_STATE) 7 | end 8 | 9 | it "infers default name for event" do 10 | hook_event = described_class::Before 11 | expect(described_class.any_state_or_event(hook_event)).to eq(FiniteMachine::ANY_EVENT) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/hook_event/build_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::HookEvent, "#build" do 4 | it "builds action event" do 5 | hook_event = FiniteMachine::HookEvent::Before.build(:green, :go, :red) 6 | expect(hook_event.name).to eq(:go) 7 | end 8 | 9 | it "builds state event" do 10 | hook_event = FiniteMachine::HookEvent::Enter.build(:green, :go, :red) 11 | expect(hook_event.name).to eq(:green) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/hook_event/eql_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::HookEvent, "eql?" do 4 | let(:name) { :green } 5 | let(:event_name) { :go } 6 | let(:object) { described_class } 7 | 8 | subject(:hook) { object.new(name, event_name, name) } 9 | 10 | context "with the same object" do 11 | let(:other) { hook } 12 | 13 | it "equals" do 14 | expect(hook).to eql(other) 15 | end 16 | end 17 | 18 | context "with an equivalent object" do 19 | let(:other) { hook.dup } 20 | 21 | it "equals" do 22 | expect(hook).to eql(other) 23 | end 24 | end 25 | 26 | context "with an object having different name" do 27 | let(:other_name) { :red } 28 | let(:other) { object.new(other_name, event_name, other_name) } 29 | 30 | it "doesn't equal" do 31 | expect(hook).not_to eql(other) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/unit/hook_event/initialize_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::HookEvent, "#new" do 4 | it "reads event name" do 5 | hook_event = described_class.new(:green, :go, :green) 6 | expect(hook_event.name).to eql(:green) 7 | end 8 | 9 | it "reads event type" do 10 | hook_event = described_class.new(:green, :go, :green) 11 | expect(hook_event.type).to eql(FiniteMachine::HookEvent) 12 | end 13 | 14 | it "reads the from state" do 15 | hook_event = described_class.new(:green, :go, :red) 16 | expect(hook_event.from).to eql(:red) 17 | end 18 | 19 | it "freezes object" do 20 | hook_event = described_class.new(:green, :go, :green) 21 | expect(hook_event.frozen?).to eq(true) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/unit/hook_event/notify_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::HookEvent, "#notify" do 4 | it "emits event on the subscriber" do 5 | subscriber = spy(:subscriber) 6 | hook_event = described_class.new(:green, :go, :red) 7 | 8 | hook_event.notify(subscriber, 1, 2) 9 | 10 | expect(subscriber).to have_received(:emit).with(hook_event, 1, 2) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/unit/hooks/clear_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Hooks, "#clear" do 4 | it "clears all registered hooks" do 5 | hooks = described_class.new 6 | 7 | event_type = FiniteMachine::HookEvent::Before 8 | hook = -> { } 9 | hooks.register(event_type, :foo, hook) 10 | hooks.register(event_type, :bar, hook) 11 | 12 | expect(hooks.empty?).to eq(false) 13 | hooks.clear 14 | expect(hooks.empty?).to eq(true) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/unit/hooks/find_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Hooks, "#[]" do 4 | it "adds and removes a single hook" do 5 | hooks = FiniteMachine::Hooks.new 6 | expect(hooks).to be_empty 7 | 8 | yielded = [] 9 | event_type = FiniteMachine::HookEvent::Before 10 | hook = -> { } 11 | hooks.register(event_type, :foo, hook) 12 | 13 | hooks[event_type][:foo].each do |callback| 14 | yielded << callback 15 | end 16 | 17 | expect(yielded).to eq([hook]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/unit/hooks/inspect_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Hooks, "#inspect" do 4 | it "displays name and transitions" do 5 | hooks = FiniteMachine::Hooks.new 6 | hook = -> { } 7 | event = FiniteMachine::HookEvent::Enter 8 | hooks_map = {event => {yellow: [hook]}} 9 | 10 | hooks.register(event, :yellow, hook) 11 | 12 | expect(hooks.inspect).to eql("<#FiniteMachine::Hooks:0x#{hooks.object_id.to_s(16)} @hooks_map=#{hooks_map}>") 13 | end 14 | 15 | it "displays hooks content" do 16 | hooks = FiniteMachine::Hooks.new 17 | hook = -> { } 18 | event = FiniteMachine::HookEvent::Enter 19 | hooks_map = {event => {yellow: [hook]}} 20 | 21 | hooks.register(event, :yellow, hook) 22 | 23 | expect(hooks.to_s).to eql(hooks_map.to_s) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/unit/hooks/register_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Hooks, "#register" do 4 | it "adds and removes a single hook" do 5 | hooks = FiniteMachine::Hooks.new 6 | expect(hooks).to be_empty 7 | 8 | event_type = FiniteMachine::HookEvent::Before 9 | hook = -> { } 10 | 11 | hooks.register(event_type, :foo, hook) 12 | expect(hooks[event_type][:foo]).to eq([hook]) 13 | 14 | hooks.unregister(event_type, :foo, hook) 15 | expect(hooks[event_type][:foo]).to eq([]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/unit/if_unless_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, ":if, :unless" do 4 | before(:each) { 5 | stub_const("Car", Class.new do 6 | attr_accessor :engine_on 7 | 8 | def turn_engine_on 9 | @engine_on = true 10 | end 11 | 12 | def turn_engine_off 13 | @engine_on = false 14 | end 15 | 16 | def engine_on? 17 | !!@engine_on 18 | end 19 | end) 20 | 21 | stub_const("Bug", Class.new do 22 | def pending? 23 | false 24 | end 25 | end) 26 | } 27 | 28 | it "passes context to conditionals" do 29 | called = [] 30 | fsm = FiniteMachine.new do 31 | initial :red 32 | 33 | event :go, :red => :green, 34 | if: proc { |context| called << "cond_red_green(#{context})"; true} 35 | event :stop, from: any_state do 36 | choice :red, 37 | if: proc { |context| called << "cond_any_red(#{context})"; true } 38 | end 39 | end 40 | 41 | expect(fsm.current).to eq(:red) 42 | 43 | fsm.go 44 | expect(fsm.current).to eq(:green) 45 | expect(called).to eq(["cond_red_green(#{fsm})"]) 46 | 47 | fsm.stop 48 | expect(fsm.current).to eq(:red) 49 | expect(called).to match_array([ 50 | "cond_red_green(#{fsm})", 51 | "cond_any_red(#{fsm})" 52 | ]) 53 | end 54 | 55 | it "passes context & arguments to conditionals" do 56 | called = [] 57 | fsm = FiniteMachine.new do 58 | initial :red 59 | 60 | event :go, :red => :green, 61 | if: proc { |_, a| called << "cond_red_green(#{a})"; true } 62 | event :stop, from: any_state do 63 | choice :red, 64 | if: proc { |_, b| called << "cond_any_red(#{b})"; true } 65 | end 66 | end 67 | 68 | expect(fsm.current).to eq(:red) 69 | 70 | fsm.go(:foo) 71 | expect(fsm.current).to eq(:green) 72 | expect(called).to eq(["cond_red_green(foo)"]) 73 | 74 | fsm.stop(:bar) 75 | expect(fsm.current).to eq(:red) 76 | expect(called).to match_array([ 77 | "cond_red_green(foo)", 78 | "cond_any_red(bar)" 79 | ]) 80 | end 81 | 82 | it "allows to cancel event with :if option" do 83 | called = [] 84 | 85 | fsm = FiniteMachine.new do 86 | initial :green 87 | 88 | event :slow, :green => :yellow, if: -> { return false } 89 | event :stop, :yellow => :red 90 | 91 | # generic callbacks 92 | on_enter do |event| called << "on_enter" end 93 | on_transition do |event| called << "on_transition" end 94 | on_exit do |event| called << "on_exit" end 95 | 96 | # state callbacks 97 | on_enter :green do |event| called << "on_enter_green" end 98 | on_enter :yellow do |event| called << "on_enter_yellow" end 99 | 100 | on_transition :green do |event| called << "on_transition_green" end 101 | on_transition :yellow do |event| called << "on_transition_yellow" end 102 | 103 | on_exit :green do |event| called << "on_exit_green" end 104 | on_exit :yellow do |event| called << "on_exit_yellow" end 105 | end 106 | 107 | expect(fsm.current).to eql(:green) 108 | called = [] 109 | fsm.slow 110 | expect(fsm.current).to eql(:green) 111 | expect(called).to eql([]) 112 | end 113 | 114 | it "allows to cancel event with :unless option" do 115 | called = [] 116 | 117 | fsm = FiniteMachine.new do 118 | initial :green 119 | 120 | event :slow, :green => :yellow, unless: -> { true } 121 | event :stop, :yellow => :red 122 | 123 | # generic callbacks 124 | on_enter do |event| called << "on_enter" end 125 | on_transition do |event| called << "on_transition" end 126 | on_exit do |event| called << "on_exit" end 127 | 128 | # state callbacks 129 | on_enter :green do |event| called << "on_enter_green" end 130 | on_enter :yellow do |event| called << "on_enter_yellow" end 131 | 132 | on_transition :green do |event| called << "on_transition_green" end 133 | on_transition :yellow do |event| called << "on_transition_yellow" end 134 | 135 | on_exit :green do |event| called << "on_exit_green" end 136 | on_exit :yellow do |event| called << "on_exit_yellow" end 137 | end 138 | 139 | expect(fsm.current).to eql(:green) 140 | called = [] 141 | fsm.slow 142 | expect(fsm.current).to eql(:green) 143 | expect(called).to eql([]) 144 | end 145 | 146 | it "allows to combine conditionals" do 147 | conditions = [] 148 | 149 | fsm = FiniteMachine.new do 150 | initial :green 151 | 152 | event :slow, :green => :yellow, 153 | if: [ -> { conditions << "first_if"; return true }, 154 | -> { conditions << "second_if"; return true}], 155 | unless: -> { conditions << "first_unless"; return true } 156 | event :stop, :yellow => :red 157 | end 158 | 159 | expect(fsm.current).to eql(:green) 160 | fsm.slow 161 | expect(fsm.current).to eql(:green) 162 | expect(conditions).to eql([ 163 | "first_if", 164 | "second_if", 165 | "first_unless" 166 | ]) 167 | end 168 | 169 | context "when proc" do 170 | it "specifies :if and :unless" do 171 | car = Car.new 172 | 173 | fsm = FiniteMachine.new(car) do 174 | initial :neutral 175 | 176 | event :start, :neutral => :one, if: proc {|_car| _car.engine_on? } 177 | event :shift, :one => :two 178 | end 179 | car.turn_engine_off 180 | expect(car.engine_on?).to be false 181 | expect(fsm.current).to eql(:neutral) 182 | fsm.start 183 | expect(fsm.current).to eql(:neutral) 184 | 185 | car.turn_engine_on 186 | expect(car.engine_on?).to be true 187 | expect(fsm.current).to eql(:neutral) 188 | fsm.start 189 | expect(fsm.current).to eql(:one) 190 | end 191 | 192 | it "passes arguments to the scope" do 193 | car = Car.new 194 | 195 | fsm = FiniteMachine.new(car) do 196 | initial :neutral 197 | 198 | event :start, :neutral => :one, if: proc { |_car, state| 199 | _car.engine_on = state 200 | _car.engine_on? 201 | } 202 | event :shift, :one => :two 203 | end 204 | fsm.start(false) 205 | expect(fsm.current).to eql(:neutral) 206 | fsm.start(true) 207 | expect(fsm.current).to eql(:one) 208 | end 209 | end 210 | 211 | context "when symbol" do 212 | it "specifies :if and :unless" do 213 | car = Car.new 214 | 215 | fsm = FiniteMachine.new(car) do 216 | initial :neutral 217 | 218 | event :start, :neutral => :one, if: :engine_on? 219 | event :shift, :one => :two 220 | end 221 | car.turn_engine_off 222 | expect(car.engine_on?).to be false 223 | expect(fsm.current).to eql(:neutral) 224 | fsm.start 225 | expect(fsm.current).to eql(:neutral) 226 | 227 | car.turn_engine_on 228 | expect(car.engine_on?).to be true 229 | expect(fsm.current).to eql(:neutral) 230 | fsm.start 231 | expect(fsm.current).to eql(:one) 232 | end 233 | end 234 | 235 | context "when string" do 236 | it "specifies :if and :unless" do 237 | car = Car.new 238 | 239 | fsm = FiniteMachine.new(car) do 240 | initial :neutral 241 | 242 | event :start, :neutral => :one, if: "engine_on?" 243 | event :shift, :one => :two 244 | end 245 | car.turn_engine_off 246 | expect(car.engine_on?).to be false 247 | expect(fsm.current).to eql(:neutral) 248 | fsm.start 249 | expect(fsm.current).to eql(:neutral) 250 | 251 | car.turn_engine_on 252 | expect(car.engine_on?).to be true 253 | expect(fsm.current).to eql(:neutral) 254 | fsm.start 255 | expect(fsm.current).to eql(:one) 256 | end 257 | end 258 | 259 | context "when same event name" do 260 | it "preservers conditions for the same named event" do 261 | bug = Bug.new 262 | fsm = FiniteMachine.new(bug) do 263 | initial :initial 264 | 265 | event :bump, :initial => :low 266 | event :bump, :low => :medium, if: :pending? 267 | event :bump, :medium => :high 268 | end 269 | expect(fsm.current).to eq(:initial) 270 | fsm.bump 271 | expect(fsm.current).to eq(:low) 272 | fsm.bump 273 | expect(fsm.current).to eq(:low) 274 | end 275 | 276 | it "allows for static choice based on branching condition" do 277 | fsm = FiniteMachine.new do 278 | initial :company_form 279 | 280 | event :next, :company_form => :agreement_form, if: -> { false } 281 | event :next, :company_form => :promo_form, if: -> { false } 282 | event :next, :company_form => :official_form, if: -> { true } 283 | end 284 | 285 | expect(fsm.current).to eq(:company_form) 286 | fsm.next 287 | expect(fsm.current).to eq(:official_form) 288 | end 289 | 290 | it "allows for dynamic choice based on branching condition" do 291 | fsm = FiniteMachine.new do 292 | initial :company_form 293 | 294 | event :next, :company_form => :agreement_form, if: proc { |_, a| a < 1 } 295 | event :next, :company_form => :promo_form, if: proc { |_, a| a == 1 } 296 | event :next, :company_form => :official_form, if: proc { |_, a| a > 1 } 297 | end 298 | expect(fsm.current).to eq(:company_form) 299 | 300 | fsm.next(0) 301 | expect(fsm.current).to eq(:agreement_form) 302 | fsm.restore!(:company_form) 303 | expect(fsm.current).to eq(:company_form) 304 | 305 | fsm.next(1) 306 | expect(fsm.current).to eq(:promo_form) 307 | fsm.restore!(:company_form) 308 | expect(fsm.current).to eq(:company_form) 309 | 310 | fsm.next(2) 311 | expect(fsm.current).to eq(:official_form) 312 | end 313 | end 314 | end 315 | -------------------------------------------------------------------------------- /spec/unit/initial_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "initial" do 4 | 5 | before(:each) { 6 | stub_const("DummyLogger", Class.new do 7 | attr_accessor :level 8 | 9 | def initialize 10 | @level = :pending 11 | end 12 | end) 13 | } 14 | 15 | it "defaults initial state to :none" do 16 | fsm = FiniteMachine.new do 17 | event :slow, :green => :yellow 18 | event :stop, :yellow => :red 19 | end 20 | 21 | expect(fsm.current).to eql(:none) 22 | end 23 | 24 | it "requires initial state transition from :none" do 25 | fsm = FiniteMachine.new do 26 | event :init, :none => :green 27 | event :slow, :green => :yellow 28 | event :stop, :yellow => :red 29 | end 30 | 31 | expect(fsm.current).to eql(:none) 32 | fsm.init 33 | expect(fsm.current).to eql(:green) 34 | end 35 | 36 | it "allows to specify inital state" do 37 | called = [] 38 | fsm = FiniteMachine.new do 39 | initial :green 40 | 41 | event :slow, :green => :yellow 42 | event :stop, :yellow => :red 43 | 44 | on_exit :none do |event| called << "on_exit_none" end 45 | on_enter :green do |event| called << "on_enter_green" end 46 | end 47 | expect(fsm.current).to eql(:green) 48 | expect(called).to be_empty 49 | end 50 | 51 | it "allows to specify initial state through parameter" do 52 | fsm = FiniteMachine.new initial: :green do 53 | event :slow, :green => :yellow 54 | event :stop, :yellow => :red 55 | end 56 | expect(fsm.current).to eql(:green) 57 | end 58 | 59 | it "allows to specify deferred inital state" do 60 | fsm = FiniteMachine.new do 61 | initial :green, defer: true 62 | 63 | event :slow, :green => :yellow 64 | event :stop, :yellow => :red 65 | end 66 | 67 | expect(fsm.current).to eql(:none) 68 | fsm.init 69 | expect(fsm.current).to eql(:green) 70 | end 71 | 72 | it "raises error when specyfying initial without state name" do 73 | expect { 74 | FiniteMachine.new do 75 | initial defer: true 76 | 77 | event :slow, :green => :yellow 78 | event :stop, :yellow => :red 79 | end 80 | }.to raise_error(FiniteMachine::MissingInitialStateError) 81 | end 82 | 83 | it "allows to specify inital start event" do 84 | fsm = FiniteMachine.new do 85 | initial :green, event: :start 86 | 87 | event :slow, :green => :none 88 | event :stop, :yellow => :red 89 | end 90 | 91 | expect(fsm.current).to eql(:green) 92 | fsm.slow 93 | expect(fsm.current).to eql(:none) 94 | fsm.start 95 | expect(fsm.current).to eql(:green) 96 | end 97 | 98 | it "allows to specify deferred inital start event" do 99 | fsm = FiniteMachine.new do 100 | initial :green, event: :start, defer: true 101 | 102 | event :slow, :green => :yellow 103 | event :stop, :yellow => :red 104 | end 105 | 106 | expect(fsm.current).to eql(:none) 107 | fsm.start 108 | expect(fsm.current).to eql(:green) 109 | end 110 | 111 | it "evaluates initial state" do 112 | logger = DummyLogger.new 113 | fsm = FiniteMachine.new do 114 | initial logger.level 115 | 116 | event :slow, :green => :none 117 | event :stop, :yellow => :red 118 | end 119 | expect(fsm.current).to eql(:pending) 120 | end 121 | 122 | it "doesn't care about state type" do 123 | fsm = FiniteMachine.new do 124 | initial 1 125 | 126 | event :a, 1 => 2 127 | event :b, 2 => 3 128 | end 129 | expect(fsm.current).to eql(1) 130 | fsm.a 131 | expect(fsm.current).to eql(2) 132 | fsm.b 133 | expect(fsm.current).to eql(3) 134 | end 135 | 136 | it "allows to retrieve initial state" do 137 | fsm = FiniteMachine.new do 138 | initial :green 139 | 140 | event :slow, :green => :yellow 141 | event :stop, :yellow => :red 142 | end 143 | expect(fsm.current).to eq(:green) 144 | expect(fsm.initial_state).to eq(:green) 145 | fsm.slow 146 | expect(fsm.current).to eq(:yellow) 147 | expect(fsm.initial_state).to eq(:green) 148 | end 149 | 150 | it "allows to retrieve initial state for deferred" do 151 | fsm = FiniteMachine.new do 152 | initial :green, defer: true 153 | 154 | event :slow, :green => :yellow 155 | event :stop, :yellow => :red 156 | end 157 | expect(fsm.current).to eq(:none) 158 | expect(fsm.initial_state).to eq(:none) 159 | fsm.init 160 | expect(fsm.current).to eq(:green) 161 | expect(fsm.initial_state).to eq(:green) 162 | end 163 | 164 | it "allows to trigger callbacks on initial with :silent option" do 165 | called = [] 166 | fsm = FiniteMachine.new do 167 | initial :green, silent: false 168 | 169 | event :slow, :green => :yellow 170 | 171 | on_enter :green do |event| called << "on_enter_green" end 172 | end 173 | expect(fsm.current).to eq(:green) 174 | expect(called).to eq(["on_enter_green"]) 175 | end 176 | 177 | it "allows to trigger callbacks on deferred initial state" do 178 | called = [] 179 | fsm = FiniteMachine.new do 180 | initial :green, silent: false, defer: true 181 | 182 | event :slow, :green => :yellow 183 | 184 | on_enter :green do |event| called << "on_enter_green" end 185 | end 186 | expect(fsm.current).to eq(:none) 187 | fsm.init 188 | expect(called).to eq(["on_enter_green"]) 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /spec/unit/inspect_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal 2 | 3 | RSpec.describe FiniteMachine, "#inspect" do 4 | it "print useful information about state machine" do 5 | fsm = FiniteMachine.new do 6 | initial :green 7 | 8 | event :slow, :green => :yellow 9 | event :stop, :yellow => :red 10 | end 11 | inspected = fsm.inspect 12 | expect(inspected).to match(/^<#FiniteMachine::StateMachine:0x#{fsm.object_id.to_s(16)} @current=:green @states=\[.*\] @events=\[.*\] @transitions=\[.*\]>$/) 13 | 14 | event_names = eval inspected[/events=\[(.*?)\]/] 15 | states = eval inspected[/states=\[(.*?)\]/] 16 | transitions = eval inspected[/transitions=\[(.*?)\]/] 17 | 18 | expect(event_names).to match_array([:init, :slow, :stop]) 19 | expect(states).to match_array([:none, :green, :yellow, :red]) 20 | expect(transitions).to match_array([{:none => :green}, {:green => :yellow}, {:yellow => :red}]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/unit/is_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "#is?" do 4 | 5 | it "allows to check if state is reachable" do 6 | fsm = FiniteMachine.new do 7 | initial :green 8 | 9 | event :slow, :green => :yellow 10 | event :stop, :yellow => :red 11 | event :ready, :red => :yellow 12 | event :go, :yellow => :green 13 | end 14 | 15 | expect(fsm.current).to eql(:green) 16 | 17 | expect(fsm.is?(:green)).to be true 18 | expect(fsm.is?(:yellow)).to be false 19 | expect(fsm.is?([:green, :red])).to be true 20 | expect(fsm.is?([:yellow, :red])).to be false 21 | 22 | fsm.slow 23 | 24 | expect(fsm.is?(:green)).to be false 25 | expect(fsm.is?(:yellow)).to be true 26 | expect(fsm.is?([:green, :red])).to be false 27 | expect(fsm.is?([:yellow, :red])).to be true 28 | end 29 | 30 | it "defines helper methods to check current state" do 31 | fsm = FiniteMachine.new do 32 | initial :green 33 | 34 | event :slow, :green => :yellow 35 | event :stop, :yellow => :red 36 | event :ready, :red => :yellow 37 | event :go, :yellow => :green 38 | end 39 | expect(fsm.current).to eql(:green) 40 | 41 | expect(fsm.green?).to be true 42 | expect(fsm.yellow?).to be false 43 | 44 | fsm.slow 45 | 46 | expect(fsm.green?).to be false 47 | expect(fsm.yellow?).to be true 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/unit/log_transitions_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe FiniteMachine, ":log_transitions" do 2 | let(:output) { StringIO.new("", "w+")} 3 | 4 | before { FiniteMachine.logger = ::Logger.new(output) } 5 | 6 | after { FiniteMachine.logger = ::Logger.new($stderr) } 7 | 8 | it "logs transitions" do 9 | fsm = FiniteMachine.new log_transitions: true do 10 | initial :green 11 | 12 | event :slow, :green => :yellow 13 | event :stop, :yellow => :red 14 | end 15 | 16 | fsm.slow 17 | output.rewind 18 | expect(output.read).to match(/Transition: @event=slow green -> yellow/) 19 | 20 | fsm.stop(1, 2) 21 | output.rewind 22 | expect(output.read).to match(/Transition: @event=stop @with=\[1,2\] yellow -> red/) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/unit/logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Logger do 4 | let(:message) { "error" } 5 | let(:log) { spy } 6 | 7 | subject(:logger) { described_class } 8 | 9 | before { allow(FiniteMachine).to receive(:logger) { log } } 10 | 11 | it "debugs message call" do 12 | expect(log).to receive(:debug).with(message) 13 | logger.debug(message) 14 | end 15 | 16 | it "informs message call" do 17 | expect(log).to receive(:info).with(message) 18 | logger.info(message) 19 | end 20 | 21 | it "warns message call" do 22 | expect(log).to receive(:warn).with(message) 23 | logger.warn(message) 24 | end 25 | 26 | it "errors message call" do 27 | expect(log).to receive(:error).with(message) 28 | logger.error(message) 29 | end 30 | 31 | it "reports transition" do 32 | logger.report_transition(:go, :red, :green) 33 | 34 | expect(log).to have_received(:info).with("Transition: @event=go red -> green") 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/unit/message_queue_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::MessageQueue do 4 | it "dispatches all events" do 5 | event_queue = FiniteMachine::MessageQueue.new 6 | event_queue.start 7 | called = [] 8 | event1 = double(:event1, dispatch: called << "event1_dispatched") 9 | event2 = double(:event2, dispatch: called << "event2_dispatched") 10 | 11 | expect(event_queue.size).to be_zero 12 | 13 | event_queue << event1 14 | event_queue << event2 15 | event_queue.join(0.001) 16 | 17 | expect(called).to match_array(["event1_dispatched", "event2_dispatched"]) 18 | event_queue.shutdown 19 | end 20 | 21 | it "logs error" do 22 | event_queue = FiniteMachine::MessageQueue.new 23 | event_queue.start 24 | event = spy(:event) 25 | allow(event).to receive(:dispatch) { raise } 26 | expect(FiniteMachine::Logger).to receive(:error) 27 | event_queue << event 28 | event_queue.join(0.02) 29 | expect(event_queue).to be_empty 30 | end 31 | 32 | it "notifies listeners" do 33 | event_queue = FiniteMachine::MessageQueue.new 34 | event_queue.start 35 | called = [] 36 | event1 = double(:event1, dispatch: true) 37 | event2 = double(:event2, dispatch: true) 38 | event3 = double(:event3, dispatch: true) 39 | event_queue.subscribe(:listener1) { |event| called << event } 40 | event_queue << event1 41 | event_queue << event2 42 | event_queue << event3 43 | event_queue.join(0.02) 44 | event_queue.shutdown 45 | expect(called).to match_array([event1, event2, event3]) 46 | end 47 | 48 | it "allows to shutdown event queue" do 49 | event_queue = FiniteMachine::MessageQueue.new 50 | event_queue.start 51 | event1 = double(:event1, dispatch: true) 52 | event2 = double(:event2, dispatch: true) 53 | event3 = double(:event3, dispatch: true) 54 | expect(event_queue.running?).to be(true) 55 | event_queue << event1 56 | event_queue << event2 57 | event_queue.shutdown 58 | event_queue << event3 59 | expect(event_queue.running?).to be(false) 60 | event_queue.join(0.001) 61 | end 62 | 63 | it "raises an error when the message queue is already shut down" do 64 | message_queue = described_class.new 65 | message_queue.start 66 | message_queue.shutdown 67 | 68 | expect { 69 | message_queue.shutdown 70 | }.to raise_error(FiniteMachine::MessageQueueDeadError, 71 | "message queue already dead") 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/unit/new_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, ".new" do 4 | context "with block" do 5 | it "creates a state machine" do 6 | fsm = described_class.new(called = []) do 7 | initial :green 8 | 9 | event :slow, :green => :yellow 10 | event :stop, :yellow => :red 11 | event :ready, :red => :yellow 12 | event :go, :yellow => :green 13 | 14 | on_enter(:yellow) { target << "on_enter_yellow" } 15 | 16 | handle(FiniteMachine::InvalidStateError) { target << "error_handler" } 17 | end 18 | 19 | expect(fsm.current).to eq(:green) 20 | fsm.slow 21 | expect(fsm.current).to eq(:yellow) 22 | fsm.stop 23 | expect(fsm.current).to eq(:red) 24 | fsm.ready 25 | expect(fsm.current).to eq(:yellow) 26 | fsm.go 27 | expect(fsm.current).to eq(:green) 28 | fsm.stop 29 | expect(called).to eq(%w[on_enter_yellow on_enter_yellow error_handler]) 30 | end 31 | 32 | it "uses any_state method inside the new method block" do 33 | fsm = described_class.new(called = []) do 34 | initial :green 35 | 36 | event :slow, any_state => :yellow 37 | 38 | on_enter(any_state) { |event| target << "enter_#{event.to}" } 39 | on_transition(any_state) { |event| target << "transition_#{event.to}" } 40 | on_exit(any_state) { |event| target << "exit_#{event.from}" } 41 | end 42 | 43 | fsm.slow 44 | 45 | expect(fsm.current).to eq(:yellow) 46 | expect(called).to eq(%w[exit_green transition_yellow enter_yellow]) 47 | end 48 | 49 | it "uses any_event method inside the new method block" do 50 | fsm = described_class.new(called = []) do 51 | initial :green 52 | 53 | event :slow, :green => :yellow 54 | 55 | on_before(any_event) { |event| target << "before_#{event.name}" } 56 | on_after(any_event) { |event| target << "after_#{event.name}" } 57 | end 58 | 59 | fsm.slow 60 | 61 | expect(fsm.current).to eq(:yellow) 62 | expect(called).to eq(%w[before_slow after_slow]) 63 | end 64 | end 65 | 66 | context "without block" do 67 | it "creates a state machine" do 68 | fsm = described_class.new(called = []) 69 | fsm.initial(:green) 70 | fsm.event(:slow, :green => :yellow) 71 | fsm.event(:stop, :yellow => :red) 72 | fsm.event(:ready, :red => :yellow) 73 | fsm.event(:go, :yellow => :green) 74 | fsm.on_enter(:yellow) { target << "on_enter_yellow" } 75 | fsm.handle(FiniteMachine::InvalidStateError) { target << "error_handler" } 76 | 77 | fsm.init 78 | expect(fsm.current).to eq(:green) 79 | fsm.slow 80 | expect(fsm.current).to eq(:yellow) 81 | fsm.stop 82 | expect(fsm.current).to eq(:red) 83 | fsm.ready 84 | expect(fsm.current).to eq(:yellow) 85 | fsm.go 86 | expect(fsm.current).to eq(:green) 87 | fsm.stop 88 | expect(called).to eq(%w[on_enter_yellow on_enter_yellow error_handler]) 89 | end 90 | 91 | it "uses any_state method outside the new method block" do 92 | fsm = described_class.new(called = []) 93 | fsm.initial(:green) 94 | fsm.event(:slow, fsm.any_state => :yellow) 95 | fsm.on_enter(fsm.any_state) do |event| 96 | target << "enter_#{event.to}" 97 | end 98 | fsm.on_transition(fsm.any_state) do |event| 99 | target << "transition_#{event.to}" 100 | end 101 | fsm.on_exit(fsm.any_state) do |event| 102 | target << "exit_#{event.from}" 103 | end 104 | 105 | fsm.init 106 | fsm.slow 107 | 108 | expect(fsm.current).to eq(:yellow) 109 | expect(called).to eq(%w[exit_green transition_yellow enter_yellow]) 110 | end 111 | 112 | it "uses any_event method outside the new method block" do 113 | fsm = described_class.new(called = []) 114 | fsm.initial(:green) 115 | fsm.event(:slow, :green => :yellow) 116 | fsm.on_before(fsm.any_event) do |event| 117 | target << "before_#{event.name}" 118 | end 119 | fsm.on_after(fsm.any_event) do |event| 120 | target << "after_#{event.name}" 121 | end 122 | 123 | fsm.init 124 | fsm.slow 125 | 126 | expect(fsm.current).to eq(:yellow) 127 | expect(called).to eq(%w[before_slow after_slow]) 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/unit/respond_to_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "#respond_to" do 4 | 5 | subject(:fsm) { 6 | stub_const("Car", Class.new do 7 | def engine_on? 8 | true 9 | end 10 | end) 11 | 12 | FiniteMachine.new target: Car.new do 13 | initial :green 14 | 15 | event :slow, :green => :yellow 16 | end 17 | } 18 | 19 | it "knows about event name" do 20 | expect(fsm).to respond_to(:slow) 21 | end 22 | 23 | it "doesn't know about not implemented call" do 24 | expect(fsm).not_to respond_to(:not_implemented) 25 | end 26 | 27 | it "knows about event callback" do 28 | expect(fsm).to respond_to(:on_enter_slow) 29 | end 30 | 31 | it "doesn't know about target class methods" do 32 | expect(fsm).not_to respond_to(:engine_on?) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/unit/state_parser/parse_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::StateParser, "#parse" do 4 | let(:object) { described_class } 5 | 6 | context "when no attributes" do 7 | let(:attrs) { { } } 8 | 9 | it "raises error for no transitions" do 10 | expect { 11 | object.parse(attrs) 12 | }.to raise_error(FiniteMachine::NotEnoughTransitionsError, 13 | /please provide state transitions/) 14 | end 15 | end 16 | 17 | context "when :from and :to keys" do 18 | let(:attrs) { { from: :green, to: :yellow }} 19 | 20 | it "removes :from and :to keys" do 21 | expect(object.parse(attrs)).to eq({green: :yellow}) 22 | end 23 | end 24 | 25 | context "when only :from key" do 26 | let(:attrs) { { from: :green }} 27 | 28 | it "adds to state as copy of from" do 29 | expect(object.parse(attrs)).to eq({green: :green}) 30 | end 31 | end 32 | 33 | context "when only :to key" do 34 | let(:attrs) { { to: :green }} 35 | 36 | it "inserts :any from state" do 37 | expect(object.parse(attrs)).to eq({FiniteMachine::ANY_STATE => :green}) 38 | end 39 | end 40 | 41 | context "when attribuets as hash" do 42 | let(:attrs) { { green: :yellow } } 43 | 44 | it "copies attributes over" do 45 | expect(object.parse(attrs)).to eq({green: :yellow}) 46 | end 47 | end 48 | 49 | context "when array of from states" do 50 | let(:attrs) { { [:green, :red] => :yellow } } 51 | 52 | it "extracts states" do 53 | expect(object.parse(attrs)).to include({red: :yellow, green: :yellow}) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/unit/states_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "#states" do 4 | it "retrieves all available states" do 5 | fsm = FiniteMachine.new do 6 | initial :green 7 | 8 | event :slow, :green => :yellow 9 | event :stop, :yellow => :red 10 | event :ready, :red => :yellow 11 | event :go, :yellow => :green 12 | end 13 | 14 | expect(fsm.states).to match_array([:none, :green, :yellow, :red]) 15 | end 16 | 17 | it "retrieves all unique states for choice transition" do 18 | fsm = FiniteMachine.new do 19 | initial :green 20 | 21 | event :next, from: :green do 22 | choice :yellow, if: -> { false } 23 | choice :red, if: -> { true } 24 | end 25 | end 26 | expect(fsm.states).to match_array([:none, :green, :yellow, :red]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/unit/subscribers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Subscribers do 4 | let(:listener) { double } 5 | 6 | it "checks if any subscribers exist" do 7 | subscribers = described_class.new 8 | expect(subscribers.empty?).to eq(true) 9 | subscribers.subscribe(listener) 10 | expect(subscribers.empty?).to eq(false) 11 | end 12 | 13 | it "allows to subscribe multiple listeners" do 14 | subscribers = described_class.new 15 | subscribers.subscribe(listener, listener) 16 | expect(subscribers.size).to eq(2) 17 | end 18 | 19 | it "returns index for the subscriber" do 20 | subscribers = described_class.new 21 | subscribers.subscribe(listener) 22 | expect(subscribers.index(listener)).to eql(0) 23 | end 24 | 25 | it "visits all subscribed listeners for the event" do 26 | subscribers = described_class.new 27 | subscribers.subscribe(listener) 28 | event = spy(:event) 29 | subscribers.visit(event) 30 | expect(event).to have_received(:notify).with(listener) 31 | end 32 | 33 | it "resets the subscribers" do 34 | subscribers = described_class.new 35 | subscribers.subscribe(listener) 36 | expect(subscribers.empty?).to eq(false) 37 | subscribers.reset 38 | expect(subscribers.empty?).to eq(true) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/unit/target_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "#target" do 4 | it "allows to target external object" do 5 | stub_const("Car", Class.new do 6 | attr_accessor :reverse_lights 7 | 8 | def turn_reverse_lights_off 9 | @reverse_lights = false 10 | end 11 | 12 | def turn_reverse_lights_on 13 | @reverse_lights = true 14 | end 15 | 16 | def reverse_lights? 17 | @reverse_lights ||= false 18 | end 19 | 20 | def engine 21 | @engine ||= FiniteMachine.new(self) do 22 | initial :neutral 23 | 24 | event :forward, [:reverse, :neutral] => :one 25 | event :shift, :one => :two 26 | event :shift, :two => :one 27 | event :back, [:neutral, :one] => :reverse 28 | 29 | on_enter :reverse do |event| 30 | target.turn_reverse_lights_on 31 | end 32 | 33 | on_exit :reverse do |event| 34 | target.turn_reverse_lights_off 35 | end 36 | end 37 | end 38 | end) 39 | car = Car.new 40 | expect(car.reverse_lights?).to be(false) 41 | expect(car.engine.current).to eql(:neutral) 42 | car.engine.back 43 | expect(car.engine.current).to eql(:reverse) 44 | expect(car.reverse_lights?).to be(true) 45 | car.engine.forward 46 | expect(car.engine.current).to eql(:one) 47 | expect(car.reverse_lights?).to be(false) 48 | end 49 | 50 | it "propagates method call" do 51 | fsm = FiniteMachine.new do 52 | initial :green 53 | 54 | event :slow, :green => :yellow 55 | 56 | on_enter_yellow do |event| 57 | uknown_method 58 | end 59 | end 60 | expect(fsm.current).to eql(:green) 61 | expect { fsm.slow }.to raise_error(StandardError) 62 | end 63 | 64 | it "references machine methods inside callback" do 65 | called = [] 66 | fsm = FiniteMachine.new do 67 | initial :green 68 | 69 | event :slow, :green => :yellow 70 | event :stop, :yellow => :red 71 | event :ready, :red => :yellow 72 | event :go, :yellow => :green 73 | 74 | on_enter_yellow do |event| 75 | stop(:now) 76 | end 77 | 78 | on_enter_red do |event, param| 79 | called << "#{event.from} #{param}" 80 | end 81 | end 82 | 83 | expect(fsm.current).to eql(:green) 84 | fsm.slow 85 | expect(fsm.current).to eql(:red) 86 | expect(called).to eql(["yellow now"]) 87 | end 88 | 89 | it "allows context methods take precedence over machine ones" do 90 | stub_const("Car", Class.new do 91 | attr_accessor :reverse_lights 92 | attr_accessor :called 93 | 94 | def turn_reverse_lights_off 95 | @reverse_lights = false 96 | end 97 | 98 | def turn_reverse_lights_on 99 | @reverse_lights = true 100 | end 101 | 102 | def reverse_lights? 103 | @reverse_lights ||= false 104 | end 105 | 106 | def engine 107 | self.called ||= [] 108 | 109 | @engine ||= FiniteMachine.new(self) do 110 | initial :neutral 111 | 112 | event :forward, [:reverse, :neutral] => :one 113 | event :shift, :one => :two 114 | event :shift, :two => :one 115 | event :back, [:neutral, :one] => :reverse 116 | 117 | on_enter :reverse do |event| 118 | target.called << "on_enter_reverse" 119 | target.turn_reverse_lights_on 120 | forward("Piotr!") 121 | end 122 | on_before :forward do |event, name| 123 | target.called << "on_enter_forward with #{name}" 124 | end 125 | end 126 | end 127 | end) 128 | 129 | car = Car.new 130 | expect(car.reverse_lights?).to be(false) 131 | expect(car.engine.current).to eql(:neutral) 132 | car.engine.back 133 | expect(car.engine.current).to eql(:one) 134 | expect(car.called).to eql([ 135 | "on_enter_reverse", 136 | "on_enter_forward with Piotr!" 137 | ]) 138 | end 139 | 140 | it "allows to access target inside the callback" do 141 | context = double(:context) 142 | called = nil 143 | fsm = FiniteMachine.new(context) do 144 | initial :green 145 | 146 | event :slow, :green => :yellow 147 | event :stop, :yellow => :red 148 | 149 | on_enter_yellow do |event| 150 | called = target 151 | end 152 | end 153 | expect(fsm.current).to eql(:green) 154 | fsm.slow 155 | expect(called).to eq(context) 156 | end 157 | 158 | it "allows to differentiate between same named methods" do 159 | called = [] 160 | stub_const("Car", Class.new do 161 | def initialize(called) 162 | @called = called 163 | end 164 | def save 165 | @called << "car save called" 166 | end 167 | end) 168 | 169 | car = Car.new(called) 170 | fsm = FiniteMachine.new(car) do 171 | initial :unsaved 172 | 173 | event :validate, :unsaved => :valid 174 | event :save, :valid => :saved 175 | 176 | on_enter :valid do |event| 177 | target.save 178 | save 179 | end 180 | on_after :save do |event| 181 | called << "event save called" 182 | end 183 | end 184 | expect(fsm.current).to eql(:unsaved) 185 | fsm.validate 186 | expect(fsm.current).to eql(:saved) 187 | expect(called).to eq([ 188 | "car save called", 189 | "event save called" 190 | ]) 191 | end 192 | 193 | it "handles targets responding to :to_hash message" do 194 | stub_const("Serializer", Class.new do 195 | def initialize(data) 196 | @data = data 197 | end 198 | 199 | def write(new_data) 200 | @data.merge!(new_data) 201 | end 202 | 203 | def to_hash 204 | @data 205 | end 206 | alias to_h to_hash 207 | end) 208 | 209 | model = Serializer.new({a: 1, b: 2}) 210 | 211 | fsm = FiniteMachine.new(model) do 212 | initial :a 213 | 214 | event :serialize, :a => :b 215 | 216 | on_after :serialize do |event| 217 | target.write(c: 3) 218 | end 219 | end 220 | 221 | fsm.serialize 222 | 223 | expect(model.to_h).to include({a: 1, b: 2, c: 3}) 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /spec/unit/terminated_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine, "#terminated?" do 4 | 5 | it "allows to specify terminal state" do 6 | fsm = FiniteMachine.new do 7 | initial :green 8 | terminal :red 9 | 10 | event :slow, :green => :yellow 11 | event :stop, :yellow => :red 12 | end 13 | 14 | expect(fsm.current).to eql(:green) 15 | expect(fsm.terminated?).to be(false) 16 | 17 | fsm.slow 18 | expect(fsm.current).to eql(:yellow) 19 | expect(fsm.terminated?).to be(false) 20 | 21 | fsm.stop 22 | expect(fsm.current).to eql(:red) 23 | expect(fsm.terminated?).to be(true) 24 | end 25 | 26 | it "allows to specify terminal state as parameter" do 27 | fsm = FiniteMachine.new terminal: :red do 28 | initial :green 29 | 30 | event :slow, :green => :yellow 31 | event :stop, :yellow => :red 32 | end 33 | fsm.slow 34 | fsm.stop 35 | expect(fsm.terminated?).to be(true) 36 | end 37 | 38 | it "checks without terminal state" do 39 | fsm = FiniteMachine.new do 40 | initial :green 41 | 42 | event :slow, :green => :yellow 43 | event :stop, :yellow => :red 44 | end 45 | 46 | expect(fsm.current).to eql(:green) 47 | expect(fsm.terminated?).to be(false) 48 | 49 | fsm.slow 50 | expect(fsm.current).to eql(:yellow) 51 | expect(fsm.terminated?).to be(false) 52 | 53 | fsm.stop 54 | expect(fsm.current).to eql(:red) 55 | expect(fsm.terminated?).to be(false) 56 | end 57 | 58 | it "allows for multiple terminal states" do 59 | fsm = FiniteMachine.new do 60 | initial :open 61 | 62 | terminal :close, :canceled, :faulty 63 | 64 | event :resolve, :open => :close 65 | event :decline, :open => :canceled 66 | event :error, :open => :faulty 67 | end 68 | expect(fsm.current).to eql(:open) 69 | expect(fsm.terminated?).to be(false) 70 | 71 | fsm.resolve 72 | expect(fsm.current).to eql(:close) 73 | expect(fsm.terminated?).to be(true) 74 | 75 | fsm.restore!(:open) 76 | fsm.decline 77 | expect(fsm.current).to eql(:canceled) 78 | expect(fsm.terminated?).to be(true) 79 | 80 | fsm.restore!(:open) 81 | fsm.error 82 | expect(fsm.current).to eql(:faulty) 83 | expect(fsm.terminated?).to be(true) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/unit/transition/check_conditions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Transition, "#check_conditions" do 4 | it "verifies all conditions pass" do 5 | context = double(:context) 6 | exec_conditions = 0 7 | ok_condition = -> { exec_conditions += 1; return true } 8 | fail_condition = -> { exec_conditions += 1; return false } 9 | 10 | transition = described_class.new(context, :event_name, 11 | if: [ok_condition, fail_condition]) 12 | 13 | expect(transition.check_conditions).to eql(false) 14 | expect(exec_conditions).to eq(2) 15 | end 16 | 17 | it "verifies 'if' and 'unless' conditions" do 18 | context = double(:context) 19 | exec_conditions = 0 20 | ok_condition = -> { exec_conditions += 1; return true } 21 | fail_condition = -> { exec_conditions += 1; return false } 22 | 23 | transition = described_class.new(context, :event_name, 24 | if: [ok_condition], 25 | unless: [fail_condition]) 26 | 27 | expect(transition.check_conditions).to eql(true) 28 | expect(exec_conditions).to eq(2) 29 | end 30 | 31 | it "verifies condition with arguments" do 32 | context = double(:context) 33 | condition = -> (_, arg) { arg == 1 } 34 | 35 | transition = described_class.new(context, :event_name, 36 | if: [condition]) 37 | 38 | expect(transition.check_conditions(2)).to eql(false) 39 | expect(transition.check_conditions(1)).to eql(true) 40 | end 41 | 42 | it "verifies condition on target" do 43 | stub_const("Car", Class.new do 44 | def engine_on? 45 | true 46 | end 47 | end) 48 | context = Car.new 49 | condition = -> (car) { car.engine_on? } 50 | 51 | transition = described_class.new(context, :event_name, if: condition) 52 | 53 | expect(transition.check_conditions).to eql(true) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/unit/transition/inspect_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Transition, "#inspect" do 4 | let(:machine) { double(:machine) } 5 | 6 | subject(:transition) { described_class.new(machine, event_name, attrs) } 7 | 8 | context "when inspecting" do 9 | let(:event_name) { :start } 10 | let(:attrs) { { states: { :foo => :bar, :baz => :daz } } } 11 | 12 | it "displays name and transitions" do 13 | expect(transition.inspect).to eql("<#FiniteMachine::Transition @name=start, @transitions=foo -> bar, baz -> daz, @when=[]>") 14 | end 15 | end 16 | 17 | context "when converting to string" do 18 | let(:event_name) { :start } 19 | let(:attrs) { { states: { :foo => :bar } } } 20 | 21 | it "displays name and transitions" do 22 | expect(transition.to_s).to eql("start") 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/unit/transition/matches_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Transition, "#matches?" do 4 | let(:machine) { double(:machine) } 5 | 6 | it "matches from state" do 7 | states = {:green => :red} 8 | transition = described_class.new(machine, :event_name, states: states) 9 | 10 | expect(transition.matches?(:green)).to eq(true) 11 | expect(transition.matches?(:red)).to eq(false) 12 | end 13 | 14 | it "matches any state" do 15 | states = {FiniteMachine::ANY_STATE => :red} 16 | transition = described_class.new(machine, :event_name, states: states) 17 | 18 | expect(transition.matches?(:green)).to eq(true) 19 | end 20 | end 21 | 22 | -------------------------------------------------------------------------------- /spec/unit/transition/states_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Transition, "#states" do 4 | let(:machine) { double(:machine) } 5 | 6 | it "groups states with :to key only" do 7 | attrs = {states: {:any => :red}} 8 | transition = FiniteMachine::Transition.new(machine, :event_name, attrs) 9 | expect(transition.states).to eql({any: :red}) 10 | end 11 | 12 | it "groups states when from array" do 13 | attrs = {states: { :green => :red, :yellow => :red}} 14 | transition = FiniteMachine::Transition.new(machine, :event_name, attrs) 15 | expect(transition.states.keys).to match_array([:green, :yellow]) 16 | expect(transition.states.values).to eql([:red, :red]) 17 | end 18 | 19 | 20 | it "groups states when hash of states" do 21 | attrs = {states: { 22 | :initial => :low, 23 | :low => :medium, 24 | :medium => :high }} 25 | transition = FiniteMachine::Transition.new(machine, :event_name, attrs) 26 | expect(transition.states.keys).to match_array([:initial, :low, :medium]) 27 | expect(transition.states.values).to eql([:low, :medium, :high]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/unit/transition/to_state_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::Transition, "#to_state" do 4 | let(:machine) { double(:machine) } 5 | 6 | it "finds to state" do 7 | states = {:green => :red} 8 | transition = described_class.new(machine, :event_name, states: states) 9 | 10 | expect(transition.to_state(:green)).to eq(:red) 11 | end 12 | 13 | it "finds to state for transition from any state" do 14 | states = {FiniteMachine::ANY_STATE => :red} 15 | transition = described_class.new(machine, :event_name, states: states) 16 | 17 | expect(transition.to_state(:green)).to eq(:red) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/unit/trigger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::StateMachine, "#trigger" do 4 | it "triggers event manually" do 5 | called = [] 6 | fsm = FiniteMachine.new do 7 | initial :red 8 | 9 | event :start, :red => :green, if: proc { |_, name| called << name; true } 10 | event :stop, :green => :red 11 | end 12 | 13 | expect(fsm.current).to eq(:red) 14 | fsm.trigger(:start, "Piotr") 15 | expect(fsm.current).to eq(:green) 16 | expect(called).to eq(["Piotr"]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/unit/undefined_transition/eql_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FiniteMachine::UndefinedTransition, "#==" do 4 | it "is true with the same name" do 5 | expect(described_class.new(:go)).to eq(described_class.new(:go)) 6 | end 7 | 8 | it "is false with a different name" do 9 | expect(described_class.new(:go)).to_not eq(described_class.new(:other)) 10 | end 11 | 12 | it "is false with another object" do 13 | expect(described_class.new(:go)).to_not eq(:other) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /tasks/console.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Load gem inside irb console" 4 | task :console do 5 | require "irb" 6 | require "irb/completion" 7 | require_relative "../lib/finite_machine" 8 | ARGV.clear 9 | IRB.start 10 | end 11 | task c: :console 12 | -------------------------------------------------------------------------------- /tasks/coverage.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Measure code coverage" 4 | task :coverage do 5 | begin 6 | original, ENV["COVERAGE"] = ENV["COVERAGE"], "true" 7 | Rake::Task["spec"].invoke 8 | ensure 9 | ENV["COVERAGE"] = original 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /tasks/spec.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "rspec/core/rake_task" 5 | 6 | desc "Run all specs" 7 | RSpec::Core::RakeTask.new(:spec) do |task| 8 | task.pattern = "spec/{unit,integration}{,/*/**}/*_spec.rb" 9 | end 10 | 11 | namespace :spec do 12 | desc "Run unit specs" 13 | RSpec::Core::RakeTask.new(:unit) do |task| 14 | task.pattern = "spec/unit{,/*/**}/*_spec.rb" 15 | end 16 | 17 | desc "Run integration specs" 18 | RSpec::Core::RakeTask.new(:integration) do |task| 19 | task.pattern = "spec/integration{,/*/**}/*_spec.rb" 20 | end 21 | 22 | desc "Run performance specs" 23 | RSpec::Core::RakeTask.new(:perf) do |task| 24 | task.pattern = "spec/performance{,/*/**}/*_spec.rb" 25 | end 26 | end 27 | rescue LoadError 28 | %w[spec spec:unit spec:integration].each do |name| 29 | task name do 30 | warn "In order to run #{name}, do `gem install rspec`" 31 | end 32 | end 33 | end 34 | --------------------------------------------------------------------------------