diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..bfa5319 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,97 @@ +AllCops: + TargetRubyVersion: 2.3 + # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop + # to ignore them, so only the ones explicitly set in this file are enabled. + DisabledByDefault: true + Exclude: + - '**/bin/*' + - '**/db/**/*' + - '**/templates/**/*' + - '**/vendor/**/*' + +# Prefer &&/|| over and/or. +Style/AndOr: + Enabled: true + +# Do not use braces for hash literals when they are the last argument of a +# method call. +Style/BracesAroundHashParameters: + Enabled: true + +# Align `when` with `case`. +Style/CaseIndentation: + Enabled: true + +# No extra empty lines. +Style/EmptyLines: + Enabled: true + +# In a regular class definition, no empty lines around the body. +Style/EmptyLinesAroundClassBody: + Enabled: true + +# In a regular module definition, no empty lines around the body. +Style/EmptyLinesAroundModuleBody: + Enabled: true + +# Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. +Style/HashSyntax: + Enabled: true + +# Method definitions after `private` or `protected` isolated calls need one +# extra level of indentation. +Style/IndentationConsistency: + Enabled: true + EnforcedStyle: rails + +# Two spaces, no tabs (for indentation). +Style/IndentationWidth: + Enabled: true + +# Defining a method with parameters needs parentheses. +Style/MethodDefParentheses: + Enabled: true + +# Use `foo {}` not `foo{}`. +Style/SpaceBeforeBlockBraces: + Enabled: true + +# Use `foo { bar }` not `foo {bar}`. +Style/SpaceInsideBlockBraces: + Enabled: true + +# Use `{ a: 1 }` not `{a:1}`. +Style/SpaceInsideHashLiteralBraces: + Enabled: true + +# Check quotes usage according to lint rule below. +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +# Detect hard tabs, no hard tabs. +Style/Tab: + Enabled: true + +# Blank lines should not have any spaces. +Style/TrailingBlankLines: + Enabled: true + +# No trailing whitespace. +Style/TrailingWhitespace: + Enabled: true + +# Use quotes for string literals when they are enough. +Style/UnneededPercentQ: + Enabled: true + +# Align `end` with the matching keyword or starting expression except for +# assignments, where it should be aligned with the LHS. +Lint/EndAlignment: + Enabled: true + EnforcedStyleAlignWith: variable + +# Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. +Lint/RequireParentheses: + Enabled: true + diff --git a/.travis.yml b/.travis.yml index df2241c..1dca372 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,4 @@ language: ruby env: LC_ALL="en_US.UTF-8" LANG="en_US.UTF-8" install: bundle install --jobs=1 --retry=3 rvm: - - ruby - - rbx - - jruby - + - 2.4.1 diff --git a/Gemfile b/Gemfile index 06618ce..3be9c3c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,2 @@ -source 'https://rubygems.org' +source "https://rubygems.org" gemspec - diff --git a/README.md b/README.md index c09f739..53e29db 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Lines of Code](http://img.shields.io/badge/lines_of_code-60-brightgreen.svg?style=flat)](http://blog.codinghorror.com/the-best-code-is-no-code-at-all/) +[![Lines of Code](http://img.shields.io/badge/lines_of_code-130-brightgreen.svg?style=flat)](http://blog.codinghorror.com/the-best-code-is-no-code-at-all/) [![Code Status](http://img.shields.io/codeclimate/github/hopsoft/state_jacket.svg?style=flat)](https://codeclimate.com/github/hopsoft/state_jacket) [![Dependency Status](http://img.shields.io/gemnasium/hopsoft/state_jacket.svg?style=flat)](https://gemnasium.com/hopsoft/state_jacket) [![Build Status](http://img.shields.io/travis/hopsoft/state_jacket.svg?style=flat)](https://travis-ci.org/hopsoft/state_jacket) @@ -7,132 +7,86 @@ # StateJacket -## An Intuitive [State Transition System](http://en.wikipedia.org/wiki/State_transition_system) +## An Intuitive [State Transition System](http://en.wikipedia.org/wiki/State_transition_system) & [State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) -[State machines](http://en.wikipedia.org/wiki/Finite-state_machine) are awesome -but can be pretty daunting as a system grows. -Keeping states, transitions, & events straight can be tricky. -StateJacket simplifies things by isolating the management of states & transitions. -Events are left out, making it much easier to reason about what states exist -and how they transition to other states. +StateJacket provides an intuitive approach to building complex state machines +by isolating the concerns of the state transition system & state machine. -*The examples below are somewhat contrived, but should clearly illustrate usage.* - -## The Basics - -#### Install +## Install ```sh gem install state_jacket ``` -#### Define states & transitions for a simple [turnstyle](http://en.wikipedia.org/wiki/Finite-state_machine#Example:_a_turnstile). - -![Turnstyle](https://raw.github.com/hopsoft/state_jacket/master/doc/turnstyle.png) - -```ruby -require "state_jacket" - -states = StateJacket::Catalog.new -states.add :open => [:closed, :error] -states.add :closed => [:open, :error] -states.add :error -states.lock - -states.inspect # => {:open=>[:closed, :error], :closed=>[:open, :error], :error=>nil} -states.transitioners # => [:open, :closed] -states.terminators # => [:error] - -states.can_transition? :open => :closed # => true -states.can_transition? :closed => :open # => true -states.can_transition? :error => :open # => false -states.can_transition? :error => :closed # => false -``` - -## Next Steps +## Example -Lets model something a bit more complex. +Let's define states & transitions (i.e. the state transition system) & a state machine for a [turnstyle](http://en.wikipedia.org/wiki/Finite-state_machine#Example:_a_turnstile). -#### Define states & transitions for a phone call. +![Turnstyle](https://raw.github.com/hopsoft/state_jacket/master/doc/turnstyle.png) -![Phone Call](https://raw.github.com/hopsoft/state_jacket/master/doc/phone-call.png) +### State Transition System ```ruby -require "state_jacket" - -states = StateJacket::Catalog.new -states.add :idle => [:dialing] -states.add :dialing => [:idle, :connecting] -states.add :connecting => [:idle, :busy, :connected] -states.add :busy => [:idle] -states.add :connected => [:idle] -states.lock - -states.transitioners # => [:idle, :dialing, :connecting, :busy, :connected] -states.terminators # => [] - -states.can_transition? :idle => :dialing # => true -states.can_transition? :dialing => [:idle, :connecting] # => true -states.can_transition? :connecting => [:idle, :busy, :connected] # => true -states.can_transition? :busy => :idle # => true -states.can_transition? :connected => :idle # => true -states.can_transition? :idle => [:dialing, :connected] # => false +system = StateJacket::StateTransitionSystem.new +system.add :opened => [:closed, :errored] +system.add :closed => [:opened, :errored] +system.lock # prevent further changes + +system.to_h.inspect # => {"opened"=>["closed", "errored"], "closed"=>["opened", "errored"], "errored"=>nil} +system.transitioners # => ["opened", "closed"] +system.terminators # => ["errored"] + +system.can_transition? :opened => :closed # => true +system.can_transition? :closed => :opened # => true +system.can_transition? :errored => :opened # => false +system.can_transition? :errored => :closed # => false ``` -## Deep Cuts +### State Machine -Lets add state awareness and behavior to another class. -We'll reuse the turnstyle states from the example from above. +Define the events that trigger transitions defined by the state transition system (i.e. the state machine). ```ruby -require "state_jacket" - -class Turnstyle - attr_reader :states, :current_state - - def initialize - @states = StateJacket::Catalog.new - @states.add :open => [:closed, :error] - @states.add :closed => [:open, :error] - @states.add :error - @states.lock - @current_state = :closed - end +machine = StateJacket::StateMachine.new(system, state: "closed") +machine.on :open, :closed => :opened +machine.on :close, :opened => :closed +machine.lock # prevent further changes + +machine.to_h.inspect # => {"open"=>[{"closed"=>"opened"}], "close"=>[{"opened"=>"closed"}]} +machine.events # => ["open", "close"] + +machine.state # => "closed" +machine.is_event? :open # => true +machine.is_event? :close # => true +machine.is_event? :other # => false + +machine.can_trigger? :open # => true +machine.can_trigger? :close # => false + +machine.state # => "closed" +machine.trigger :open # => "opened" +machine.state # => "opened" + +# you can also pass a block when triggering events +machine.trigger :close do |from_state, to_state| + # custom logic can be placed here + from_state # => "opened" + to_state # => "closed" +end - def open - if states.can_transition? current_state => :open - @current_state = :open - else - raise "Can't transition from #{@current_state} to :open" - end - end +machine.state # => "closed" - def close - if states.can_transition? current_state => :closed - @current_state = :closed - else - raise "Can't transition from #{@current_state} to :closed" - end - end +# this is a noop because can_trigger?(:close) is false +machine.trigger :close # => nil + +machine.state # => "closed" - def break - @current_state = :error +begin + machine.trigger :open do |from_state, to_state| + raise # the transition isn't performed if an error occurs in the block end +rescue end -# example usage -turnstyle = Turnstyle.new -turnstyle.current_state # => :closed -turnstyle.open -turnstyle.current_state # => :open -turnstyle.close -turnstyle.current_state # => :closed -turnstyle.close # => RuntimeError: Can't transition from closed to :closed -turnstyle.open -turnstyle.current_state # => :open -turnstyle.open # => RuntimeError: Can't transition from open to :open -turnstyle.break -turnstyle.open # => RuntimeError: Can't transition from error to :open -turnstyle.close # => RuntimeError: Can't transition from error to :closed +machine.state # => "closed" ``` - diff --git a/Rakefile b/Rakefile index dd45fee..c0703fa 100644 --- a/Rakefile +++ b/Rakefile @@ -1,9 +1,13 @@ require "bundler/gem_tasks" -task :default => [:test] +task default: [:test] + +desc "Runs rubocop." +task :rubocop do + exec "bundle exec rubocop -c .rubocop.yml" +end desc "Runs the test suite." task :test do exec "bundle exec pry-test --disable-pry" end - diff --git a/lib/state_jacket.rb b/lib/state_jacket.rb index 82199ba..c7d3eca 100644 --- a/lib/state_jacket.rb +++ b/lib/state_jacket.rb @@ -1,3 +1,6 @@ -require "state_jacket/version" -require "state_jacket/catalog" +require_relative "./state_jacket/version" +require_relative "./state_jacket/state_transition_system" +require_relative "./state_jacket/state_machine" +module StateJacket +end diff --git a/lib/state_jacket/catalog.rb b/lib/state_jacket/catalog.rb deleted file mode 100644 index 88be78c..0000000 --- a/lib/state_jacket/catalog.rb +++ /dev/null @@ -1,72 +0,0 @@ -require "delegate" - -module StateJacket - - # A simple class that allows users to intuitively define states and transitions. - class Catalog < SimpleDelegator - def initialize - @inner_hash = {} - super inner_hash - end - - def add(state) - if state.is_a?(Hash) - self[state.keys.first.to_s] = state.values.first.map(&:to_s) - else - self[state.to_s] = nil - end - end - - def can_transition?(from_to) - from = from_to.keys.first.to_s - to = from_to.values.first - to = [to] unless to.is_a?(Array) - to = to.map(&:to_s) - transitions = self[from] || [] - (to & transitions).length == to.length - end - - def transitioners - keys.select do |state| - self[state] != nil - end - end - - def transitioner?(state) - transitioners.include?(state.to_s) - end - - def terminators - keys.select do |state| - self[state] == nil - end - end - - def terminator?(state) - terminators.include?(state.to_s) - end - - def lock - values.flatten.each do |value| - next if value.nil? - if !keys.include?(value) - raise "Invalid StateJacket::Catalog! [#{value}] is not a first class state." - end - end - inner_hash.freeze - values.each { |value| value.freeze unless value.nil? } - end - - def supports_state?(state) - keys.include?(state.to_s) - end - - protected - - attr_reader :inner_hash - - end - -end - - diff --git a/lib/state_jacket/state_machine.rb b/lib/state_jacket/state_machine.rb new file mode 100644 index 0000000..875ee4b --- /dev/null +++ b/lib/state_jacket/state_machine.rb @@ -0,0 +1,73 @@ +module StateJacket + class StateMachine + attr_reader :state + + def initialize(transition_system, state:) + transition_system.lock + raise ArgumentError.new("illegal state") unless transition_system.is_state?(state) + @transition_system = transition_system + @state = state.to_s + @triggers = {} + end + + def to_h + triggers.dup + end + + def events + triggers.keys + end + + def on(event, transitions={}) + raise "events cannot be added after locking" if is_locked? + raise ArgumentError.new("event has already been added") if is_event?(event) + transitions.each do |from, to| + raise ArgumentError.new("illegal transition") unless transition_system.can_transition?(from => to) + triggers[event.to_s] ||= [] + triggers[event.to_s] << { from.to_s => to.to_s } + triggers[event.to_s].uniq! + end + end + + def trigger(event) + raise "must be locked before triggering events" unless is_locked? + raise ArgumentError.new("event not defined") unless is_event?(event) + transition = transition_for(event) + return nil unless transition + from = @state + to = transition.values.first + raise "current state doesn't match transition state" unless from == transition.keys.first + yield from, to if block_given? + @state = to + end + + def lock + return true if is_locked? + triggers.freeze + triggers.values.map(&:freeze) + triggers.values.freeze + @locked = true + end + + def is_locked? + !!@locked + end + + def is_event?(event) + triggers.has_key? event.to_s + end + + def can_trigger?(event) + return false unless is_locked? + !!transition_for(event) + end + + private + + attr_reader :transition_system, :triggers + + def transition_for(event) + triggers[event.to_s].find { |entry| entry.keys.first == state } + end + end +end diff --git a/lib/state_jacket/state_transition_system.rb b/lib/state_jacket/state_transition_system.rb new file mode 100644 index 0000000..2a68752 --- /dev/null +++ b/lib/state_jacket/state_transition_system.rb @@ -0,0 +1,75 @@ +module StateJacket + class StateTransitionSystem + def initialize + @transitions = {} + end + + def to_h + transitions.dup + end + + def add(state) + raise "states cannot be added after locking" if is_locked? + if state.is_a?(Hash) + from = state.keys.first.to_s + transitions[from] = make_states(state.values.first) + else + transitions[state.to_s] = nil + end + end + + def lock + return true if is_locked? + transitions.freeze + transitions.values.each { |value| value.freeze unless value.nil? } + @locked = true + end + + def is_locked? + !!@locked + end + + def can_transition?(from_to) + raise ArgumentError.new("from_to should contain a single transition") unless from_to.size == 1 + from = from_to.keys.first.to_s + to = make_states(from_to.values.first) + allowed_states = transitions[from] || [] + (to & allowed_states).length == to.length + end + + def states + transitions.keys + end + + def transitioners + transitions.keys.select { |state| transitions[state] != nil } + end + + def terminators + transitions.keys.select { |state| transitions[state] == nil } + end + + def is_state?(state) + transitions.keys.include?(state.to_s) + end + + def is_terminator?(state) + terminators.include?(state.to_s) + end + + def is_transitioner?(state) + transitioners.include?(state.to_s) + end + + private + + attr_reader :transitions + + def make_states(values) + values = [values.to_s] unless values.respond_to?(:map) + values = values.map(&:to_s) + values.each { |value| transitions[value] ||= nil } unless transitions.frozen? + values + end + end +end diff --git a/lib/state_jacket/version.rb b/lib/state_jacket/version.rb index 42c4407..a470264 100644 --- a/lib/state_jacket/version.rb +++ b/lib/state_jacket/version.rb @@ -1,3 +1,3 @@ module StateJacket - VERSION = "0.1.1" + VERSION = "1.0.0" end diff --git a/state_jacket.gemspec b/state_jacket.gemspec index 43ce7bf..fed3ed8 100644 --- a/state_jacket.gemspec +++ b/state_jacket.gemspec @@ -7,8 +7,7 @@ Gem::Specification.new do |gem| gem.version = StateJacket::VERSION gem.authors = ["Nathan Hopkins"] gem.email = ["natehop@gmail.com"] - gem.summary = "Intuitively define state machine like states and transitions." - gem.description = "Intuitively define state machine like states and transitions." + gem.summary = "A simple & intuitive state machine" gem.homepage = "https://github.com/hopsoft/state_jacket" gem.files = Dir["lib/**/*.rb", "[A-Z]*"] @@ -18,7 +17,6 @@ Gem::Specification.new do |gem| gem.add_development_dependency "rake" gem.add_development_dependency "pry" gem.add_development_dependency "pry-test" - #gem.add_development_dependency "pry-stack_explorer" - #gem.add_development_dependency "pry-rescue" + gem.add_development_dependency "rubocop" gem.add_development_dependency "coveralls" end diff --git a/test/catalog_test.rb b/test/catalog_test.rb deleted file mode 100644 index 2031186..0000000 --- a/test/catalog_test.rb +++ /dev/null @@ -1,131 +0,0 @@ -require "pry-test" -require "coveralls" -Coveralls.wear! -SimpleCov.command_name "pry-test" -require_relative "../lib/state_jacket/catalog" - -class CatalogTest < PryTest::Test - before do - @catalog = StateJacket::Catalog.new - end - - test "add state" do - @catalog.add :start - assert @catalog.has_key?("start") - end - - test "terminators" do - @catalog.add :start => [:finish] - @catalog.add :finish - @catalog.lock - assert @catalog.terminators == ["finish"] - end - - test "terminator" do - @catalog.add :start => [:finish] - @catalog.add :finish - @catalog.lock - assert @catalog.terminator?(:finish) - end - - test "transitioners" do - @catalog.add :start => [:finish] - @catalog.add :finish - @catalog.lock - assert @catalog.transitioners == ["start"] - end - - test "transitioner" do - @catalog.add :start => [:finish] - @catalog.add :finish - @catalog.lock - assert @catalog.transitioner?(:start) - end - - test "can transition" do - @catalog.add :start => [:finish] - @catalog.add :finish - @catalog.lock - assert @catalog.can_transition?(:start => :finish) - end - - test "supports state" do - @catalog.add :start => [:finish] - @catalog.add :finish - @catalog.lock - assert @catalog.supports_state?(:start) - assert @catalog.supports_state?(:finish) - end - - test "lock failure" do - @catalog.add :start => [:finish] - begin - @catalog.lock - rescue Exception => e - end - assert e.message.start_with?("Invalid StateJacket::Catalog!") - end - - test "lock success" do - @catalog.add :start => [:finish] - @catalog.add :finish - begin - @catalog.lock - rescue Exception => e - end - assert e.nil? - end - - test "symbol state" do - @catalog.add :start => [:finish] - @catalog.add :finish - assert @catalog.keys.include?("start") - assert @catalog.can_transition?(:start => :finish) - end - - test "string state" do - @catalog.add "start" => ["finish"] - @catalog.add "finish" - assert @catalog.keys.include?("start") - assert @catalog.can_transition?("start" => "finish") - end - - test "number state" do - @catalog.add 1 => [2] - @catalog.add 2 - assert @catalog.keys.include?("1") - assert @catalog.can_transition?(1 => 2) - end - - test "turnstyle example" do - @catalog.add :open => [:closed, :error] - @catalog.add :closed => [:open, :error] - @catalog.add :error - @catalog.lock - assert @catalog.transitioners == ["open", "closed"] - assert @catalog.terminators == ["error"] - assert @catalog.can_transition?(:open => :closed) - assert @catalog.can_transition?(:closed => :open) - assert @catalog.can_transition?(:error => :open) == false - assert @catalog.can_transition?(:error => :closed) == false - end - - test "phone call example" do - @catalog = StateJacket::Catalog.new - @catalog.add :idle => [:dialing] - @catalog.add :dialing => [:idle, :connecting] - @catalog.add :connecting => [:idle, :busy, :connected] - @catalog.add :busy => [:idle] - @catalog.add :connected => [:idle] - @catalog.lock - assert @catalog.transitioners == ["idle", "dialing", "connecting", "busy", "connected"] - assert @catalog.terminators == [] - assert @catalog.can_transition?(:idle => :dialing) - assert @catalog.can_transition?(:dialing => [:idle, :connecting]) - assert @catalog.can_transition?(:connecting => [:idle, :busy, :connected]) - assert @catalog.can_transition?(:busy => :idle) - assert @catalog.can_transition?(:connected => :idle) - assert @catalog.can_transition?(:idle => [:dialing, :connected]) == false - end - -end diff --git a/test/state_machine_test.rb b/test/state_machine_test.rb new file mode 100644 index 0000000..99317cc --- /dev/null +++ b/test/state_machine_test.rb @@ -0,0 +1,171 @@ +require_relative "./test_helper" + +class StateMachineTest < PryTest::Test + before do + @transitions = StateJacket::StateTransitionSystem.new + end + + test "new raises with invalid state" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + begin + StateJacket::StateMachine.new(@transitions, state: :foo) + rescue ArgumentError => e + end + assert e + end + + test "new assigns state" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + machine = StateJacket::StateMachine.new(@transitions, state: :opened) + assert machine.state == "opened" + end + + test "new locks the jacket" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + StateJacket::StateMachine.new(@transitions, state: :closed) + assert @transitions.is_locked? + end + + test "creating an event that has an illegal transition fails" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + begin + machine.on :reopen, errored: :open + rescue StandardError => e + end + assert e + end + + test "to_h" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + assert machine.to_h == {"open"=>[{"closed"=>"opened"}], "close"=>[{"opened"=>"closed"}]} + end + + test "lock prevents future mutations" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + assert machine.lock + assert machine.is_locked? + begin + machine.on :error, closed: :opened + rescue StandardError => e + end + assert e + end + + test "can't trigger events unless locked" do + @transitions.add opened: [:closed] + @transitions.add closed: [:opened] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + begin + machine.trigger :open + rescue StandardError => e + end + assert e + end + + test "trigger event sets matching state" do + @transitions.add opened: [:closed] + @transitions.add closed: [:opened] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + machine.lock + machine.trigger :open + assert machine.state == "opened" + machine.trigger :close + assert machine.state == "closed" + end + + test "trigger noop" do + @transitions.add opened: [:closed] + @transitions.add closed: [:opened] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + machine.lock + assert machine.trigger(:open) == "opened" + assert machine.trigger(:open).nil? + end + + test "trigger event sets matching state with block" do + @transitions.add opened: [:closed] + @transitions.add closed: [:opened] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + machine.lock + machine.trigger(:open) { |from, to| "consumer logic goes here..." } + assert machine.state == "opened" + machine.trigger(:close) { |from, to| "consumer logic goes here..." } + assert machine.state == "closed" + end + + test "trigger event does not set state if error in block" do + @transitions.add opened: [:closed] + @transitions.add closed: [:opened] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + machine.lock + machine.trigger(:open) { |from, to| raise } rescue nil + assert machine.state == "closed" + end + + test "trigger event passes from/to states to block" do + @transitions.add opened: [:closed] + @transitions.add closed: [:opened] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + machine.lock + states = { from: nil, to: nil } + machine.trigger :open do |from, to| + states[:from] = from + states[:to] = to + end + assert states == { from: "closed", to: "opened" } + end + + test "can_trigger? false unless locked" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + assert !machine.can_trigger?(:open) + end + + test "can_trigger?" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + machine.lock + assert machine.can_trigger?(:open) + end + + test "can_trigger? false" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + machine = StateJacket::StateMachine.new(@transitions, state: :closed) + machine.on :open, closed: :opened + machine.on :close, opened: :closed + machine.lock + assert !machine.can_trigger?(:close) + end +end diff --git a/test/state_transition_sytem_test.rb b/test/state_transition_sytem_test.rb new file mode 100644 index 0000000..e254ba3 --- /dev/null +++ b/test/state_transition_sytem_test.rb @@ -0,0 +1,123 @@ +require_relative "./test_helper" + +class StateJacketTest < PryTest::Test + before do + @transitions = StateJacket::StateTransitionSystem.new + end + + test "add state" do + @transitions.add :started + assert @transitions.to_h.has_key?("started") + end + + test "terminators" do + @transitions.add started: [:finished] + @transitions.lock + assert @transitions.terminators == ["finished"] + end + + test "is_terminator?" do + @transitions.add started: [:finished] + @transitions.lock + assert @transitions.is_terminator?(:finished) + end + + test "transitioners" do + @transitions.add started: [:finished] + @transitions.lock + assert @transitions.transitioners == ["started"] + end + + test "is_transitioner?" do + @transitions.add started: [:finished] + @transitions.lock + assert @transitions.is_transitioner?(:started) + end + + test "can_transition?" do + @transitions.add started: [:finished] + @transitions.lock + assert @transitions.can_transition?(started: :finished) + end + + test "is_state?" do + @transitions.add started: [:finished] + @transitions.lock + assert @transitions.is_state?(:started) + assert @transitions.is_state?(:finished) + end + + test "lock success" do + @transitions.add started: [:finished] + begin + @transitions.lock + rescue Exception => e + end + assert e.nil? + end + + test "states" do + @transitions.add started: [:finished] + @transitions.lock + assert @transitions.states == %w(finished started) + end + + test "symbol state" do + @transitions.add started: [:finished] + assert @transitions.to_h.keys.include?("started") + assert @transitions.can_transition?(started: :finished) + end + + test "string state" do + @transitions.add "started" => ["finished"] + assert @transitions.to_h.keys.include?("started") + assert @transitions.can_transition?("started" => "finished") + end + + test "number state" do + @transitions.add 1 => [2] + assert @transitions.to_h.keys.include?("1") + assert @transitions.can_transition?(1 => 2) + end + + test "turnstyle example" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + @transitions.lock + assert @transitions.transitioners.sort == ["closed", "opened"] + assert @transitions.terminators == ["errored"] + assert @transitions.can_transition?(opened: :closed) + assert @transitions.can_transition?(closed: :opened) + assert @transitions.can_transition?(errored: :opened) == false + assert @transitions.can_transition?(errored: :closeded) == false + end + + test "phone call example" do + @transitions = StateJacket::StateTransitionSystem.new + @transitions.add idle: [:dialing] + @transitions.add dialing: [:idle, :connecting] + @transitions.add connecting: [:idle, :busy, :connected] + @transitions.add busy: [:idle] + @transitions.add connected: [:idle] + @transitions.lock + assert @transitions.transitioners.sort == ["busy", "connected", "connecting", "dialing", "idle"] + assert @transitions.terminators == [] + assert @transitions.can_transition?(idle: :dialing) + assert @transitions.can_transition?(dialing: [:idle, :connecting]) + assert @transitions.can_transition?(connecting: [:idle, :busy, :connected]) + assert @transitions.can_transition?(busy: :idle) + assert @transitions.can_transition?(connected: :idle) + assert @transitions.can_transition?(idle: [:dialing, :connected]) == false + end + + test "to_h" do + @transitions.add opened: [:closed, :errored] + @transitions.add closed: [:opened, :errored] + @transitions.lock + assert @transitions.to_h == { + "closed" => ["opened", "errored"], + "errored" => nil, + "opened" => ["closed", "errored"] + } + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..1efdde4 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,5 @@ +require "pry-test" +require "coveralls" +Coveralls.wear! +SimpleCov.command_name "pry-test" +require_relative "../lib/state_jacket"