diff --git a/.rspec b/.rspec index 5f16476..2333464 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1,4 @@ --color --format progress +--order random +--warnings diff --git a/.ruby-gemset b/.ruby-gemset new file mode 100644 index 0000000..0507c07 --- /dev/null +++ b/.ruby-gemset @@ -0,0 +1 @@ +finite_machine diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ac976..0532f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +0.3.0 (March 30, 2014) + +* Move development dependencies to Gemfile +* Increase test coverage to 95% +* Fix bug with event methods dynamic redefinition +* Change attr_threadsafe to accept default values +* Fix observer respond_to +* Add ability to specify callbacks on machine instance +* Add once_on type of callback +* Add off method for removing callbacks +* Add async method to state_machine for asynchronous events firing +* Fix Callable to correctly forward arguments +* Add state helpers fsm.green? to allow easily check current state + 0.2.0 (March 01, 2014) * Ensure correct transition object state diff --git a/Gemfile b/Gemfile index 8ceeb2c..063bee2 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,15 @@ source 'https://rubygems.org' -# Specify your gem's dependencies in finite_machine.gemspec gemspec + +group :development do + gem 'rake', '~> 10.1.0' + gem 'rspec', '~> 2.14.1' + gem 'yard', '~> 0.8.7' +end + +group :metrics do + gem 'coveralls', '~> 0.7.0' + gem 'simplecov', '~> 0.8.2' + gem 'yardstick', '~> 0.9.9' +end diff --git a/README.md b/README.md index ca82345..b24de65 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,16 @@ [![Gem Version](https://badge.fury.io/rb/finite_machine.png)][gem] [![Build Status](https://secure.travis-ci.org/peter-murach/finite_machine.png?branch=master)][travis] [![Code Climate](https://codeclimate.com/github/peter-murach/finite_machine.png)][codeclimate] +[![Coverage Status](https://coveralls.io/repos/peter-murach/finite_machine/badge.png)][coverage] +[![Inline docs](http://inch-pages.github.io/github/peter-murach/finite_machine.png)][inchpages] [gem]: http://badge.fury.io/rb/finite_machine [travis]: http://travis-ci.org/peter-murach/finite_machine [codeclimate]: https://codeclimate.com/github/peter-murach/finite_machine +[coverage]: https://coveralls.io/r/peter-murach/finite_machine +[inchpages]: http://inch-pages.github.io/github/peter-murach/finite_machine -A minimal finite state machine with a straightforward and intuitive syntax. You can quickly model states and add callbacks that can be triggered synchronously or asynchronously. +A minimal finite state machine with a straightforward and intuitive syntax. You can quickly model states and add callbacks that can be triggered synchronously or asynchronously. The machine is event driven with a focus on passing synchronous and asynchronous messages to trigger state transitions. ## Features @@ -18,6 +22,7 @@ A minimal finite state machine with a straightforward and intuitive syntax. You * ability to check reachable states * ability to check for terminal state * conditional transitions +* sync and async transitions * sync and async callbacks (TODO - only sync) * nested/composable states (TODO) @@ -176,6 +181,13 @@ fm.is?(:red) # => true fm.is?(:yellow) # => false ``` +Moreover, you can use helper methods to check for current state using the state name itself like so + +```ruby +fm.red? # => true +fm.yellow? # => false +``` + ### 1.5 can? and cannot? To verify whether or not an event can be fired, **FiniteMachine** provides `can?` or `cannot?` methods. `can?` checks if **FiniteMachine** can fire a given event, returning true, otherwise, it will return false. `cannot?` is simply the inverse of `can?`. @@ -246,9 +258,9 @@ method on the **FiniteMachine** instance. As a second parameter `event` accepts in the form of `:from` and `:to` hash keys or by using the state names themselves as key value pairs. ```ruby - event :start, from: :neutral, to: :first - or - event :start, :neutral => :first +event :start, from: :neutral, to: :first +or +event :start, :neutral => :first ``` Once specified, the **FiniteMachine** will create custom methods for transitioning between each state. @@ -263,18 +275,35 @@ The following methods trigger transitions for the example state machine. In order to transition to the next reachable state, simply call the event's name on the **FiniteMachine** instance. ```ruby - fm.ready - fm.current # => :yellow +fm.ready +fm.current # => :yellow ``` Furthermore, you can pass additional parameters with the method call that will be available in the triggered callback. ```ruby - fm.go('Piotr!') - fm.current # => :green +fm.go('Piotr!') +fm.current # => :green ``` -### 2.2 single event with multiple from states +### 2.2 Asynchronous transitions + +By default the transitions will be fired synchronosuly. + +```ruby +fm.ready +or +fm.sync.ready +fm.current # => :yellow +``` + +In order to fire the event transition asynchronously use the `async` scope like so + +```ruby +fm.async.ready # => executes in separate Thread +``` + +### 2.3 single event with multiple from states If an event transitions from multiple states to the same state then all the states can be grouped into an array. Altenatively, you can create separte events under the same name for each transition that needs combining. @@ -302,49 +331,49 @@ Each event takes an optional `:if` and `:unless` options which act as a predicat You can associate the `:if` and `:unless` options with a Proc object that will get called right before transition happens. Proc object gives you ability to write inline condition instead of separate method. ```ruby - fm = FiniteMachine.define do - initial :green +fm = FiniteMachine.define do + initial :green - events { - event :slow, :green => :yellow, if: -> { return false } - } - end - fm.slow # doesn't transition to :yellow state - fm.current # => :green + events { + event :slow, :green => :yellow, if: -> { return false } + } +end +fm.slow # doesn't transition to :yellow state +fm.current # => :green ``` You can also execute methods on an associated object by passing it as an argument to `target` helper. ```ruby - class Car - def turn_engine_on - @engine_on = true - end +class Car + def turn_engine_on + @engine_on = true + end - def turn_engine_off - @engine_on = false - end + def turn_engine_off + @engine_on = false + end - def engine_on? - @engine_on - end + def engine_on? + @engine_on end +end - car = Car.new - car.turn_engine_on +car = Car.new +car.turn_engine_on - fm = FiniteMachine.define do - initial :neutral +fm = FiniteMachine.define do + initial :neutral - target car + target car - events { - event :start, :neutral => :one, if: "engine_on?" - } - end + events { + event :start, :neutral => :one, if: "engine_on?" + } +end - fm.start - fm.current # => :one +fm.start +fm.current # => :one ``` ### 3.2 Using a Symbol @@ -352,15 +381,15 @@ You can also execute methods on an associated object by passing it as an argumen You can also use a symbol corresponding to the name of a method that will get called right before transition happens. ```ruby - fsm = FiniteMachine.define do - initial :neutral +fsm = FiniteMachine.define do + initial :neutral - target car + target car - events { - event :start, :neutral => :one, if: :engine_on? - } - end + events { + event :start, :neutral => :one, if: :engine_on? + } +end ``` ### 3.3 Using a String @@ -368,15 +397,15 @@ You can also use a symbol corresponding to the name of a method that will get ca Finally, it's possible to use string that will be evaluated using `eval` and needs to contain valid Ruby code. It should only be used when the string represents a short condition. ```ruby - fsm = FiniteMachine.define do - initial :neutral +fsm = FiniteMachine.define do + initial :neutral - target car + target car - events { - event :start, :neutral => :one, if: "engine_on?" - } - end + events { + event :start, :neutral => :one, if: "engine_on?" + } +end ``` ### 3.4 Combining transition conditions @@ -384,16 +413,16 @@ Finally, it's possible to use string that will be evaluated using `eval` and nee When multiple conditions define whether or not a transition should happen, an Array can be used. Furthermore, you can apply both `:if` and `:unless` to the same transition. ```ruby - fsm = FiniteMachine.define do - initial :green +fsm = FiniteMachine.define do + initial :green - events { - event :slow, :green => :yellow, - if: [ -> { return true }, -> { return true} ], - unless: -> { return true } - event :stop, :yellow => :red - } - end + events { + event :slow, :green => :yellow, + if: [ -> { return true }, -> { return true} ], + unless: -> { return true } + event :stop, :yellow => :red + } +end ``` The transition only runs when all the `:if` conditions and none of the `unless` conditions are evaluated to `true`. @@ -458,13 +487,21 @@ This method is executed after a given event or state change happens. If you prov You can further narrow down the listener to only watch state exit changes using `on_exit_state` callback. Similarly, use `on_exit_event` to only watch for event exit changes. -### 4.4 Parameters +### 4.4 once_on + +**FiniteMachine** allows you to listen on initial state change or when the event is fired first time by using the following 3 types of callbacks: + +* `once_on_enter` +* `once_on_transition` +* `once_on_exit` + +### 4.5 Parameters All callbacks get the `TransitionEvent` object with the following attributes. -* name # the event name -* from # the state transitioning from -* to # the state transitioning to +* `name # the event name` +* `from # the state transitioning from` +* `to # the state transitioning to` followed by the rest of arguments that were passed to the event method. @@ -486,7 +523,7 @@ end fm.ready(3) # => 'lights switching from red to yellow in 3 seconds' ``` -### 4.5 Same kind of callbacks +### 4.6 Same kind of callbacks You can define any number of the same kind of callback. These callbacks will be executed in the order they are specified. @@ -506,7 +543,7 @@ end fm.slow # => will invoke both callbacks ``` -### 4.6 Fluid callbacks +### 4.7 Fluid callbacks Callbacks can also be specified as full method calls. @@ -528,7 +565,7 @@ fm = FiniteMachine.define do end ``` -### 4.7 Executing methods inside callbacks +### 4.8 Executing methods inside callbacks In order to execute method from another object use `target` helper. @@ -586,13 +623,33 @@ fm.back # => Go Piotr! For more complex example see [Integration](#6-integration) section. +### 4.9 Defining callbacks + +When defining callbacks you are not limited to the `callbacks` helper. After **FiniteMachine** instance is created you can register callbacks the same way as before by calling `on` and supplying the type of notification and state/event you are interested in. + +```ruby +fm = FiniteMachine.define do + initial :red + + events { + event :ready, :red => :yellow + event :go, :yellow => :green + event :stop, :green => :red + } +end + +fm.on_enter_yellow do |event| + ... +end +``` + ## 5 Errors By default, the **FiniteMachine** will throw an exception whenever the machine is in invalid state or fails to transition. -* FiniteMachine::TransitionError -* FiniteMachine::InvalidStateError -* FiniteMachine::InvalidCallbackError +* `FiniteMachine::TransitionError` +* `FiniteMachine::InvalidStateError` +* `FiniteMachine::InvalidCallbackError` You can attach specific error handler inside the `handlers` scope by passing the name of the error and actual callback to be executed when the error happens inside the `handle` method. The `handle` receives a list of exception class or exception class names, and an option `:with` with a name of the method or a Proc object to be called to handle the error. As an alternative, you can pass a block. @@ -664,7 +721,7 @@ class Car @gears ||= FiniteMachine.define do initial :neutral - target: context + target context events { event :start, :neutral => :one @@ -705,7 +762,7 @@ class Account < ActiveRecord::Base def manage context = self - @machine ||= FiniteMachine.define do + @manage ||= FiniteMachine.define do target context initial context.state diff --git a/Rakefile b/Rakefile index b1fa6ea..d2df74e 100644 --- a/Rakefile +++ b/Rakefile @@ -1,41 +1,8 @@ -require "bundler/gem_tasks" - -begin - require 'rspec/core/rake_task' - - desc 'Run all specs' - RSpec::Core::RakeTask.new(:spec) do |task| - task.pattern = 'spec/{unit,integration}{,/*/**}/*_spec.rb' - end +# encoding: utf-8 - namespace :spec do - desc 'Run unit specs' - RSpec::Core::RakeTask.new(:unit) do |task| - task.pattern = 'spec/unit{,/*/**}/*_spec.rb' - end - - desc 'Run integration specs' - RSpec::Core::RakeTask.new(:integration) do |task| - task.pattern = 'spec/integration{,/*/**}/*_spec.rb' - end - end +require "bundler/gem_tasks" -rescue LoadError - %w[spec spec:unit spec:integration].each do |name| - task name do - $stderr.puts "In order to run #{name}, do `gem install rspec`" - end - end -end +FileList['tasks/**/*.rake'].each(&method(:import)) desc 'Run all specs' task ci: %w[ spec ] - -desc 'Load gem inside irb console' -task :console do - require 'irb' - require 'irb/completion' - require File.join(__FILE__, '../lib/finite_machine') - ARGV.clear - IRB.start -end diff --git a/finite_machine.gemspec b/finite_machine.gemspec index e2c641c..aa869f4 100644 --- a/finite_machine.gemspec +++ b/finite_machine.gemspec @@ -18,7 +18,5 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] - spec.add_development_dependency "bundler", "~> 1.3" - spec.add_development_dependency "rake" - spec.add_development_dependency "rspec" + spec.add_development_dependency "bundler", "~> 1.5" end diff --git a/lib/finite_machine.rb b/lib/finite_machine.rb index 8b755e3..0523994 100644 --- a/lib/finite_machine.rb +++ b/lib/finite_machine.rb @@ -5,9 +5,13 @@ require "finite_machine/version" require "finite_machine/threadable" +require "finite_machine/thread_context" require "finite_machine/callable" require "finite_machine/catchable" +require "finite_machine/async_proxy" +require "finite_machine/async_call" require "finite_machine/event" +require "finite_machine/event_queue" require "finite_machine/hooks" require "finite_machine/transition" require "finite_machine/dsl" @@ -59,5 +63,4 @@ module FiniteMachine def self.define(*args, &block) StateMachine.new(*args, &block) end - end # FiniteMachine diff --git a/lib/finite_machine/async_call.rb b/lib/finite_machine/async_call.rb new file mode 100644 index 0000000..19e7718 --- /dev/null +++ b/lib/finite_machine/async_call.rb @@ -0,0 +1,47 @@ +# encoding: utf-8 + +module FiniteMachine + # An asynchronouse call representation + class AsyncCall + include Threadable + + attr_threadsafe :context + + attr_threadsafe :callable + + attr_threadsafe :arguments + + attr_threadsafe :block + + # Build asynchronous call instance + # + # @param [Object] context + # @param [Callable] callable + # @param [Array] args + # @param [#call] block + # + # @example + # AsyncCall.build(self, Callable.new(:method), :a, :b) + # + # @return [self] + # + # @api public + def self.build(context, callable, *args, &block) + instance = new + instance.context = context + instance.callable = callable + instance.arguments = *args + instance.block = block + instance + end + + # Dispatch the event to the context + # + # @return [nil] + # + # @api private + def dispatch + callable.call(context, *arguments, block) + end + end # AsyncCall +end # FiniteMachine diff --git a/lib/finite_machine/async_proxy.rb b/lib/finite_machine/async_proxy.rb new file mode 100644 index 0000000..f288103 --- /dev/null +++ b/lib/finite_machine/async_proxy.rb @@ -0,0 +1,28 @@ +# encoding: utf-8 + +module FiniteMachine + # An asynchronous messages proxy + class AsyncProxy + include Threadable + include ThreadContext + + attr_threadsafe :context + + # Initialize an AsynxProxy + # + # @param [Object] context + # the context this proxy is associated with + # + # @api private + def initialize(context) + self.context = context + end + + # Delegate asynchronous event to event queue + # + # @api private + def method_missing(method_name, *args, &block) + event_queue << AsyncCall.build(context, Callable.new(method_name), *args, &block) + end + end # AsyncProxy +end # FiniteMachine diff --git a/lib/finite_machine/callable.rb b/lib/finite_machine/callable.rb index 3960cc8..61be124 100644 --- a/lib/finite_machine/callable.rb +++ b/lib/finite_machine/callable.rb @@ -1,7 +1,6 @@ # encoding: utf-8 module FiniteMachine - # A generic interface for executing strings, symbol methods or procs. class Callable @@ -21,7 +20,7 @@ def initialize(object) # # @api public def invert - lambda { |*args, &block| !self.call(*args, &block) } + lambda { |*args, &block| !call(*args, &block) } end # Execute action @@ -32,14 +31,15 @@ def invert def call(target, *args, &block) case object when Symbol - target.__send__(@object.to_sym) + target.public_send(object.to_sym, *args, &block) when String - value = eval "lambda { #{@object} }" + string = args.empty? ? "-> { #{object} }" : "-> { #{object}(*#{args}) }" + value = eval string target.instance_exec(&value) when ::Proc object.arity.zero? ? object.call : object.call(target, *args) else - raise ArgumentError, "Unknown callable #{@object}" + raise ArgumentError, "Unknown callable #{object}" end end end # Callable diff --git a/lib/finite_machine/catchable.rb b/lib/finite_machine/catchable.rb index 169b0e6..31e3db4 100644 --- a/lib/finite_machine/catchable.rb +++ b/lib/finite_machine/catchable.rb @@ -6,8 +6,8 @@ module FiniteMachine module Catchable def self.included(base) - base.class_eval do - attr_threadsafe :error_handlers + base.module_eval do + attr_threadsafe :error_handlers, default: [] end end diff --git a/lib/finite_machine/dsl.rb b/lib/finite_machine/dsl.rb index 3224d84..46f5709 100644 --- a/lib/finite_machine/dsl.rb +++ b/lib/finite_machine/dsl.rb @@ -48,7 +48,7 @@ def initialize(machine) # Define initial state # - # @params [String, Hash] value + # @param [String, Hash] value # # @api public def initial(value) @@ -58,8 +58,14 @@ def initial(value) machine.events.call(&event) end - def target(value) - machine.env.target = value + # Attach state machine to an object. This allows state machine + # to initiate events in the context of a particular object. + # + # @param [Object] object + # + # @api public + def target(object) + machine.env.target = object end # Define terminal state @@ -94,7 +100,7 @@ def handlers(&block) # Parse initial options # - # @params [String, Hash] value + # @param [String, Hash] value # # @return [Array[Symbol,String]] # @@ -124,6 +130,7 @@ def event(name, attrs = {}, &block) sync_exclusive do _transition = Transition.new(machine, attrs.merge!(name: name)) _transition.define + _transition.define_state_methods _transition.define_event end end @@ -131,15 +138,15 @@ def event(name, attrs = {}, &block) class ErrorsDSL < GenericDSL - def initialize(machine) - super(machine) - machine.error_handlers = [] - end - # Add error handler # # @param [Array] exceptions # + # @example + # handle InvalidStateError, with: :log_errors + # + # @return [Array[Exception]] + # # @api public def handle(*exceptions, &block) machine.handle(*exceptions, &block) diff --git a/lib/finite_machine/event.rb b/lib/finite_machine/event.rb index 11e3fdd..df5964b 100644 --- a/lib/finite_machine/event.rb +++ b/lib/finite_machine/event.rb @@ -29,7 +29,7 @@ def initialize(state, transition, *data, &block) def notify(subscriber, *args, &block) if subscriber.respond_to? MESSAGE - subscriber.__send__(MESSAGE, self, *args, &block) + subscriber.public_send(MESSAGE, self, *args, &block) end end diff --git a/lib/finite_machine/event_queue.rb b/lib/finite_machine/event_queue.rb new file mode 100644 index 0000000..e0fa9fd --- /dev/null +++ b/lib/finite_machine/event_queue.rb @@ -0,0 +1,123 @@ +# encoding: utf-8 + +module FiniteMachine + + # A class responsible for running asynchronous events + class EventQueue + include Enumerable + + # Initialize an event queue + # + # @example + # EventQueue.new + # + # @api public + def initialize + @queue = Queue.new + @mutex = Mutex.new + @dead = false + run + end + + # Retrieve the next event + # + # @return [AsyncCall] + # + # @api private + def next_event + @queue.pop + end + + # Add asynchronous event to the event queue + # + # @example + # event_queue << AsyncCall.build(...) + # + # @param [AsyncCall] event + # + # @return [nil] + # + # @api public + def <<(event) + @mutex.lock + begin + @queue << event + ensure + @mutex.unlock rescue nil + end + end + + # Check if there are any events to handle + # + # @example + # event_queue.empty? + # + # @api public + def empty? + @queue.empty? + end + + # Check if the event queue is alive + # + # @example + # event_queue.alive? + # + # @return [Boolean] + # + # @api public + def alive? + !@dead + end + + # Join the event queue from current thread + # + # @param [Fixnum] timeout + # + # @example + # event_queue.join + # + # @return [nil, Thread] + # + # @api public + def join(timeout) + @thread.join timeout + end + + # Shut down this event queue and clean it up + # + # @example + # event_queue.shutdown + # + # @return [Boolean] + # + # @api public + def shutdown + @mutex.lock + begin + @queue.clear + @dead = true + ensure + @mutex.unlock rescue nil + end + true + end + + private + + # Run all the events + # + # @return [Thread] + # + # @api private + def run + @thread = Thread.new do + Thread.current.abort_on_exception = true + until(@dead) do + event = next_event + Thread.exit unless event + event.dispatch + end + end + end + end # EventQueue +end # FiniteMachine diff --git a/lib/finite_machine/hooks.rb b/lib/finite_machine/hooks.rb index 1bfb0f2..fe999f7 100644 --- a/lib/finite_machine/hooks.rb +++ b/lib/finite_machine/hooks.rb @@ -10,6 +10,9 @@ class Hooks # Initialize a collection of hoooks # + # @example + # Hoosk.new(machine) + # # @api public def initialize(machine) @collection = Hash.new do |events_hash, event_type| @@ -25,13 +28,43 @@ def initialize(machine) # @param [String] name # @param [Proc] callback # + # @example + # hooks.register :enterstate, :green do ... end + # + # @return [Hash] + # # @api public def register(event_type, name, callback) @collection[event_type][name] << callback end + # Unregister callback + # + # @param [String] event_type + # @param [String] name + # @param [Proc] callback + # + # @example + # hooks.unregister :enterstate, :green do ... end + # + # @return [Hash] + # + # @api public + def unregister(event_type, name, callback) + @collection[event_type][name].shift + end + # Return all hooks matching event and state # + # @param [String] event_type + # @param [String] event_state + # @param [Event] event + # + # @example + # hooks.call(:entersate, :green, Event.new) + # + # @return [Hash] + # # @api public def call(event_type, event_state, event) @collection[event_type][event_state].each do |hook| diff --git a/lib/finite_machine/observer.rb b/lib/finite_machine/observer.rb index b2c061d..3229338 100644 --- a/lib/finite_machine/observer.rb +++ b/lib/finite_machine/observer.rb @@ -28,7 +28,7 @@ def call(&block) instance_eval(&block) end - # Register callback for a given event. + # Register callback for a given event # # @param [Symbol] event_type # @param [Symbol] name @@ -42,6 +42,17 @@ def on(event_type = ANY_EVENT, name = ANY_STATE, &callback) end end + # Unregister callback for a given event + # + # @api public + def off(event_type = ANY_EVENT, name = ANY_STATE, &callback) + sync_exclusive do + hooks.unregister event_type, name, callback + end + end + + module Once; end + def listen_on(type, *args, &callback) name = args.first events = [] @@ -67,18 +78,16 @@ def on_exit(*args, &callback) listen_on :exit, *args, &callback end - def method_missing(method_name, *args, &block) - _, event_name, callback_name = *method_name.to_s.match(/^(on_\w+?)_(\w+)$/) - if callback_names.include?(callback_name.to_sym) - send(event_name, callback_name.to_sym, *args, &block) - else - super - end + def once_on_enter(*args, &callback) + listen_on :enter, *args, &callback.extend(Once) end - def respond_to_missing?(method_name, include_private = false) - _, callback_name = *method_name.to_s.match(/^(on_\w+?)_(\w+)$/) - callback_names.include?(callback_name.to_sym) + def once_on_transition(*args, &callback) + listen_on :transition, *args, &callback.extend(Once) + end + + def once_on_exit(*args, &callback) + listen_on :exit, *args, &callback.extend(Once) end TransitionEvent = Struct.new(:from, :to, :name) do @@ -106,6 +115,7 @@ def trigger(event, *args, &block) ANY_STATE_HOOK, ANY_EVENT_HOOK].each do |event_state| hooks.call(event_type, event_state, event) do |hook| run_callback(hook, event) + off(event_type, event_state, &hook) if hook.is_a?(Once) end end end @@ -131,5 +141,37 @@ def ensure_valid_callback_name!(name) end end + # Forward the message to observer + # + # @param [String] method_name + # + # @param [Array] args + # + # @return [self] + # + # @api private + def method_missing(method_name, *args, &block) + _, event_name, callback_name = *method_name.to_s.match(/^(\w*?on_\w+?)_(\w+)$/) + if callback_names.include?(callback_name.to_sym) + public_send(event_name, :"#{callback_name}", *args, &block) + else + super + end + end + + # Test if a message can be handled by observer + # + # @param [String] method_name + # + # @param [Boolean] include_private + # + # @return [Boolean] + # + # @api private + def respond_to_missing?(method_name, include_private = false) + *_, callback_name = *method_name.to_s.match(/^(\w*?on_\w+?)_(\w+)$/) + callback_names.include?(:"#{callback_name}") + end + end # Observer end # FiniteMachine diff --git a/lib/finite_machine/state_machine.rb b/lib/finite_machine/state_machine.rb index 709fbfa..d8427a5 100644 --- a/lib/finite_machine/state_machine.rb +++ b/lib/finite_machine/state_machine.rb @@ -6,6 +6,7 @@ module FiniteMachine class StateMachine include Threadable include Catchable + include ThreadContext # Initial state, defaults to :none attr_threadsafe :initial_state @@ -51,8 +52,13 @@ def initialize(*args, &block) @dsl = DSL.new self @dsl.call(&block) if block_given? send(:"#{@dsl.initial_event}") unless @dsl.defer + self.event_queue = FiniteMachine::EventQueue.new end + # @example + # machine.subscribe(Observer.new(machine)) + # + # @api public def subscribe(*observers) @subscribers.subscribe(*observers) end @@ -70,6 +76,30 @@ def notify(event_type, _transition, *data) end end + # Help to mark the event as synchronous + # + # @example + # fsm.sync.go + # + # @return [self] + # + # @api public + alias_method :sync, :method_missing + + # Explicitly invoke event on proxy or delegate to proxy + # + # @return [AsyncProxy] + # + # @api public + def async(method_name = nil, *args, &block) + @async_proxy = AsyncProxy.new(self) + if method_name + @async_proxy.method_missing method_name, *args, &block + else + @async_proxy + end + end + # Get current state # # @return [String] @@ -79,7 +109,10 @@ def current state end - # Check if current state machtes provided state + # Check if current state matches provided state + # + # @example + # fsm.is?(:green) # => true # # @param [String, Array[String]] state # @@ -96,6 +129,9 @@ def is?(state) # Retrieve all states # + # @example + # fsm.states # => [:yellow, :green, :red] + # # @return [Array[Symbol]] # # @api public @@ -114,6 +150,9 @@ def event_names # Checks if event can be triggered # + # @example + # fsm.can?(:go) # => true + # # @param [String] event # # @return [Boolean] @@ -125,6 +164,9 @@ def can?(event) # Checks if event cannot be triggered # + # @example + # fsm.cannot?(:go) # => false + # # @param [String] event # # @return [Boolean] @@ -160,6 +202,12 @@ def valid_state?(_transition) # Performs transition # + # @param [Transition] _transition + # @param [Array] args + # + # @return [Integer] + # the status code for the transition + # # @api private def transition(_transition, *args, &block) return CANCELLED if valid_state?(_transition) @@ -191,14 +239,34 @@ def transition(_transition, *args, &block) SUCCEEDED end + # Forward the message to target, observer or self + # + # @param [String] method_name + # + # @param [Array] args + # + # @return [self] + # + # @api private def method_missing(method_name, *args, &block) if env.target.respond_to?(method_name.to_sym) - env.target.send(method_name.to_sym, *args, &block) + env.target.public_send(method_name.to_sym, *args, &block) + elsif observer.respond_to?(method_name.to_sym) + observer.public_send(method_name.to_sym, *args, &block) else super end end + # Test if a message can be handled by state machine + # + # @param [String] method_name + # + # @param [Boolean] include_private + # + # @return [Boolean] + # + # @api private def respond_to_missing?(method_name, include_private = false) env.target.respond_to?(method_name.to_sym) end diff --git a/lib/finite_machine/subscribers.rb b/lib/finite_machine/subscribers.rb index 8957f51..d306fdb 100644 --- a/lib/finite_machine/subscribers.rb +++ b/lib/finite_machine/subscribers.rb @@ -1,15 +1,18 @@ # encoding: utf-8 +require 'monitor' + module FiniteMachine # A class responsibile for storage of event subscribers class Subscribers include Enumerable + include MonitorMixin def initialize(machine) + super() @machine = machine @subscribers = [] - @mutex = Mutex.new end def each(&block) @@ -29,7 +32,7 @@ def subscribe(*observers) end def visit(event) - each { |subscriber| event.notify subscriber } + each { |subscriber| synchronize { event.notify subscriber } } end def reset diff --git a/lib/finite_machine/thread_context.rb b/lib/finite_machine/thread_context.rb new file mode 100644 index 0000000..f0d9fe4 --- /dev/null +++ b/lib/finite_machine/thread_context.rb @@ -0,0 +1,16 @@ +# encoding: utf-8 + +module FiniteMachine + + # A mixin to allow sharing of thread context + module ThreadContext + + def event_queue + Thread.current[:finite_machine_event_queue] + end + + def event_queue=(value) + Thread.current[:finite_machine_event_queue] = value + end + end # ThreadContext +end # FiniteMachine diff --git a/lib/finite_machine/threadable.rb b/lib/finite_machine/threadable.rb index 9d145ca..c1d219e 100644 --- a/lib/finite_machine/threadable.rb +++ b/lib/finite_machine/threadable.rb @@ -7,15 +7,30 @@ module Threadable module InstanceMethods @@sync = Sync.new + # Exclusive lock + # + # @return [nil] + # + # @api public def sync_exclusive(&block) @@sync.synchronize(:EX, &block) end + # Shared lock + # + # @return [nil] + # + # @api public def sync_shared(&block) @@sync.synchronize(:SH, &block) end end + # Module hook + # + # @return [nil] + # + # @api private def self.included(base) base.extend ClassMethods base.module_eval do @@ -23,17 +38,35 @@ def self.included(base) end end + private_class_method :included + module ClassMethods include InstanceMethods + # Defines threadsafe attributes for a class + # + # @example + # attr_threadable :errors, :events + # + # @example + # attr_threadable :errors, default: [] + # + # @return [nil] + # + # @api public def attr_threadsafe(*attrs) + opts = attrs.last.is_a?(::Hash) ? attrs.pop : {} + default = opts.fetch(:default, nil) attrs.flatten.each do |attr| class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 def #{attr}(*args) - if args.empty? + value = args.shift + if value + self.#{attr} = value + elsif instance_variables.include?(:@#{attr}) sync_shared { @#{attr} } - else - self.#{attr} = args.shift + elsif #{!default.nil?} + sync_shared { instance_variable_set(:@#{attr}, #{default}) } end end alias_method '#{attr}?', '#{attr}' diff --git a/lib/finite_machine/transition.rb b/lib/finite_machine/transition.rb index 5d8f258..13aad86 100644 --- a/lib/finite_machine/transition.rb +++ b/lib/finite_machine/transition.rb @@ -1,7 +1,6 @@ # encoding: utf-8 module FiniteMachine - # Class describing a transition associated with a given event class Transition include Threadable @@ -25,6 +24,9 @@ class Transition # Initialize a Transition # + # @param [StateMachine] machine + # @param [Hash] attrs + # # @api public def initialize(machine, attrs = {}) @machine = machine @@ -36,6 +38,9 @@ def initialize(machine, attrs = {}) @conditions = make_conditions end + # Reduce conditions + # + # @api private def make_conditions @if.map { |c| Callable.new(c) } + @unless.map { |c| Callable.new(c).invert } @@ -43,6 +48,8 @@ def make_conditions # Extract states from attributes # + # @param [Hash] attrs + # # @api private def parse_states(attrs) _attrs = attrs.dup @@ -67,18 +74,44 @@ def define end end + # Define helper state mehods for the transition states + # + # @api private + def define_state_methods + from.concat([to]).each { |state| define_state_method(state) } + end + + # Define state helper method + # + # @param [Symbol] state + # + # @api private + def define_state_method(state) + return if machine.respond_to?("#{state}?") + machine.send(:define_singleton_method, "#{state}?") do + machine.is?(state.to_sym) + end + end + # Define event on the machine # # @api private def define_event _transition = self - machine.class.__send__(:define_method, name) do |*args, &block| + _name = name + + machine.singleton_class.class_eval do + undef_method(_name) if method_defined?(_name) + end + machine.send(:define_singleton_method, name) do |*args, &block| transition(_transition, *args, &block) end end # Execute current transition # + # @return [nil] + # # @api private def call sync_exclusive do @@ -95,6 +128,11 @@ def to_s @name end + # Return string representation + # + # @return [String] + # + # @api public def inspect "<#{self.class} name: #{@name}, transitions: #{@from} => #{@to}, when: #{@conditions}>" end @@ -103,10 +141,16 @@ def inspect # Raise error when not enough transitions are provided # + # @param [Hash] attrs + # + # @raise [NotEnoughTransitionsError] + # if the event has not enough transition arguments + # + # @return [nil] + # # @api private def raise_not_enough_transitions(attrs) raise NotEnoughTransitionsError, "please provide state transitions for '#{attrs.inspect}'" end - end # Transition end # FiniteMachine diff --git a/lib/finite_machine/version.rb b/lib/finite_machine/version.rb index bf43364..56a87aa 100644 --- a/lib/finite_machine/version.rb +++ b/lib/finite_machine/version.rb @@ -1,5 +1,5 @@ # encoding: utf-8 module FiniteMachine - VERSION = "0.2.0" + VERSION = "0.3.0" end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3c72cf6..c417c38 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,20 @@ # encoding: utf-8 +if RUBY_VERSION > '1.9' and (ENV['COVERAGE'] || ENV['TRAVIS']) + require 'simplecov' + require 'coveralls' + + SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ + SimpleCov::Formatter::HTMLFormatter, + Coveralls::SimpleCov::Formatter + ] + + SimpleCov.start do + command_name 'spec' + add_filter 'spec' + end +end + require 'finite_machine' RSpec.configure do |config| diff --git a/spec/unit/async_events_spec.rb b/spec/unit/async_events_spec.rb new file mode 100644 index 0000000..e2c8c01 --- /dev/null +++ b/spec/unit/async_events_spec.rb @@ -0,0 +1,68 @@ +# encoding: utf-8 + +require 'spec_helper' + +describe FiniteMachine, 'async_events' do + + it 'runs events asynchronously' do + called = [] + fsm = FiniteMachine.define do + initial :green + + events { + event :slow, :green => :yellow + event :stop, :yellow => :red + event :ready, :red => :yellow + event :go, :yellow => :green + } + + callbacks { + on_enter :yellow do |event, a| called << "on_enter_yellow_#{a}" end + on_enter :red do |event, a| called << "on_enter_red_#{a}" end + } + end + + expect(fsm.current).to eql(:green) + fsm.async.slow(:foo) + fsm.event_queue.join 0.01 + expect(fsm.current).to eql(:yellow) + expect(called).to eql([ + 'on_enter_yellow_foo' + ]) + fsm.async.stop(:bar) + fsm.event_queue.join 0.01 + expect(fsm.current).to eql(:red) + expect(called).to eql([ + 'on_enter_yellow_foo', + 'on_enter_red_bar' + ]) + end + + it "ensure queue per thread" do + called = [] + fsmFoo = FiniteMachine.define do + initial :green + events { event :slow, :green => :yellow } + + callbacks { + on_enter :yellow do |event, a| called << "(foo)on_enter_yellow_#{a}" end + } + end + fsmBar = FiniteMachine.define do + initial :green + events { event :slow, :green => :yellow } + + callbacks { + on_enter :yellow do |event, a| called << "(bar)on_enter_yellow_#{a}" end + } + end + fsmFoo.slow(:foo) + fsmBar.slow(:bar) + fsmFoo.event_queue.join 0.01 + fsmBar.event_queue.join 0.01 + expect(called).to include('(foo)on_enter_yellow_foo') + expect(called).to include('(bar)on_enter_yellow_bar') + expect(fsmFoo.current).to eql(:yellow) + expect(fsmBar.current).to eql(:yellow) + end +end diff --git a/spec/unit/callable/call_spec.rb b/spec/unit/callable/call_spec.rb index ccd5989..50a204a 100644 --- a/spec/unit/callable/call_spec.rb +++ b/spec/unit/callable/call_spec.rb @@ -8,12 +8,16 @@ Car = Class.new do attr_reader :result + def initialize + @engine_on = false + end + def turn_engine_on @result = 'turn_engine_on' @engine_on = true end - def set_engine(value) + def set_engine(value = :on) @result = "set_engine(#{value})" @engine = value.to_sym == :on end @@ -54,12 +58,30 @@ def engine_on? end end + context 'when string with arguments' do + let(:object) { 'set_engine' } + + it 'executes method with arguments' do + instance.call(target, :off) + expect(target.result).to eql('set_engine(off)') + end + end + context 'when symbol' do - let(:object) { :engine_on? } + let(:object) { :set_engine } it 'executes method on target' do instance.call(target) - expect(target.result).to eql('engine_on') + expect(target.result).to eql('set_engine(on)') + end + end + + context 'when symbol with arguments' do + let(:object) { :set_engine } + + it 'executes method on target' do + instance.call(target, :off) + expect(target.result).to eql('set_engine(off)') end end diff --git a/spec/unit/callbacks_spec.rb b/spec/unit/callbacks_spec.rb index 7de2d85..5032a42 100644 --- a/spec/unit/callbacks_spec.rb +++ b/spec/unit/callbacks_spec.rb @@ -47,6 +47,8 @@ } end + expect(fsm.current).to eql(:green) + expect(called).to eql([ 'on_exit_none', 'on_exit', @@ -394,9 +396,9 @@ callbacks { # generic callbacks - on_enter &callback - on_transition &callback - on_exit &callback + on_enter(&callback) + on_transition(&callback) + on_exit(&callback) # state callbacks on_enter :green, &callback @@ -471,5 +473,109 @@ expect { fsm.slow }.to raise_error(RuntimeError) end - xit "executes callbacks with multiple 'from' transitions" + it "executes callbacks with multiple 'from' transitions" do + called = [] + fsm = FiniteMachine.define do + initial :green + + events { + event :stop, :green => :yellow + event :stop, :yellow => :red + } + + callbacks { + on_enter_stop do |event| + called << 'on_enter_stop' + end + } + end + expect(fsm.current).to eql(:green) + fsm.stop + expect(fsm.current).to eql(:yellow) + fsm.stop + expect(fsm.current).to eql(:red) + expect(called).to eql([ + 'on_enter_stop', + 'on_enter_stop' + ]) + end + + it "allows to define callbacks on machine instance" do + called = [] + fsm = FiniteMachine.define do + initial :green + + events { + event :slow, :green => :yellow + event :stop, :yellow => :red + event :ready, :red => :yellow + event :go, :yellow => :green + } + end + + fsm.on_enter_yellow do |event| + called << 'on_enter_yellow' + end + + expect(fsm.current).to eql(:green) + fsm.slow + expect(called).to eql([ + 'on_enter_yellow' + ]) + end + + it "raises error for unknown callback" do + expect { FiniteMachine.define do + initial :green + + events { + event :slow, :green => :yellow + event :stop, :yellow => :red + event :ready, :red => :yellow + event :go, :yellow => :green + } + + callbacks { + on_enter_unknown do |event| end + } + end }.to raise_error(NoMethodError) + end + + it "triggers callbacks only once" do + called = [] + fsm = FiniteMachine.define do + initial :green + + events { + event :slow, :green => :yellow + event :go, :yellow => :green + } + + callbacks { + once_on_enter_green do |event| called << 'once_on_enter_green' end + once_on_enter_yellow do |event| called << 'once_on_enter_yellow' end + + once_on_transition_green do |event| called << 'once_on_transition_green' end + once_on_transition_yellow do |event| called << 'once_on_transition_yellow' end + + once_on_exit_green do |event| called << 'once_on_exit_green' end + once_on_exit_yellow do |event| called << 'once_on_exit_yellow' end + } + end + expect(fsm.current).to eql(:green) + fsm.slow + expect(fsm.current).to eql(:yellow) + fsm.go + expect(fsm.current).to eql(:green) + fsm.slow + expect(fsm.current).to eql(:yellow) + expect(called).to eql([ + 'once_on_transition_green', + 'once_on_enter_green', + 'once_on_exit_green', + 'once_on_transition_yellow', + 'once_on_enter_yellow', + 'once_on_exit_yellow' + ]) + end end diff --git a/spec/unit/events_spec.rb b/spec/unit/events_spec.rb index cd2a85d..20a46bd 100644 --- a/spec/unit/events_spec.rb +++ b/spec/unit/events_spec.rb @@ -216,6 +216,7 @@ event :stop, :green => :yellow event :stop, :yellow => :red event :stop, :red => :pink + event :cycle, [:yellow, :red, :pink] => :green } end @@ -229,6 +230,10 @@ expect(fsm.current).to eql(:red) fsm.stop expect(fsm.current).to eql(:pink) + fsm.cycle + expect(fsm.current).to eql(:green) + fsm.stop + expect(fsm.current).to eql(:yellow) end it "returns values for events" do diff --git a/spec/unit/handlers_spec.rb b/spec/unit/handlers_spec.rb index e0020f2..521ef41 100644 --- a/spec/unit/handlers_spec.rb +++ b/spec/unit/handlers_spec.rb @@ -96,4 +96,57 @@ def raise_error expect(fsm.current).to eql(:green) expect(called).to eql(['invalid_state_error']) end + + it 'allows for empty block handler' do + called = [] + fsm = FiniteMachine.define do + initial :green + + events { + event :slow, :green => :yellow + event :stop, :yellow => :red + } + + handlers { + handle FiniteMachine::InvalidStateError do + called << 'invalidstate' + end + } + end + + expect(fsm.current).to eql(:green) + fsm.stop + expect(fsm.current).to eql(:green) + expect(called).to eql([ + 'invalidstate' + ]) + end + + it 'requires error handler' do + expect { FiniteMachine.define do + initial :green + + events { + event :slow, :green => :yellow + } + + handlers { + handle 'UnknownErrorType' + } + end }.to raise_error(ArgumentError, /error handler/) + end + + it 'checks handler class to be Exception' do + expect { FiniteMachine.define do + initial :green + + events { + event :slow, :green => :yellow + } + + handlers { + handle Object do end + } + end }.to raise_error(ArgumentError, /Object isn't an Exception/) + end end diff --git a/spec/unit/if_unless_spec.rb b/spec/unit/if_unless_spec.rb index 4988d8b..4149b6d 100644 --- a/spec/unit/if_unless_spec.rb +++ b/spec/unit/if_unless_spec.rb @@ -125,7 +125,7 @@ def engine_on? target car events { - event :start, :neutral => :one, if: proc {|car| car.engine_on? } + event :start, :neutral => :one, if: proc {|_car| _car.engine_on? } event :shift, :one => :two } end diff --git a/spec/unit/is_spec.rb b/spec/unit/is_spec.rb index 56128ed..e01c370 100644 --- a/spec/unit/is_spec.rb +++ b/spec/unit/is_spec.rb @@ -30,4 +30,26 @@ expect(fsm.is?([:green, :red])).to be_false expect(fsm.is?([:yellow, :red])).to be_true end + + it "defines helper methods to check current state" do + fsm = FiniteMachine.define do + initial :green + + events { + event :slow, :green => :yellow + event :stop, :yellow => :red + event :ready, :red => :yellow + event :go, :yellow => :green + } + end + expect(fsm.current).to eql(:green) + + expect(fsm.green?).to be_true + expect(fsm.yellow?).to be_false + + fsm.slow + + expect(fsm.green?).to be_false + expect(fsm.yellow?).to be_true + end end diff --git a/spec/unit/target_spec.rb b/spec/unit/target_spec.rb index a058a96..453da60 100644 --- a/spec/unit/target_spec.rb +++ b/spec/unit/target_spec.rb @@ -53,6 +53,23 @@ def engine expect(car.reverse_lights).to be_false end + it "propagates method call" do + fsm = FiniteMachine.define do + initial :green + events { + event :slow, :green => :yellow + } + + callbacks { + on_enter_yellow do |event| + uknown_method + end + } + end + expect(fsm.current).to eql(:green) + expect { fsm.slow }.to raise_error(StandardError) + end + it "references machine methods inside callback" do called = [] fsm = FiniteMachine.define do diff --git a/tasks/console.rake b/tasks/console.rake new file mode 100644 index 0000000..709e9c4 --- /dev/null +++ b/tasks/console.rake @@ -0,0 +1,10 @@ +# encoding: utf-8 + +desc 'Load gem inside irb console' +task :console do + require 'irb' + require 'irb/completion' + require File.join(__FILE__, '../lib/finite_machine') + ARGV.clear + IRB.start +end diff --git a/tasks/coverage.rake b/tasks/coverage.rake new file mode 100644 index 0000000..49b4ded --- /dev/null +++ b/tasks/coverage.rake @@ -0,0 +1,11 @@ +# encoding: utf-8 + +desc 'Measure code coverage' +task :coverage do + begin + original, ENV['COVERAGE'] = ENV['COVERAGE'], 'true' + Rake::Task['spec'].invoke + ensure + ENV['COVERAGE'] = original + end +end diff --git a/tasks/spec.rake b/tasks/spec.rake new file mode 100644 index 0000000..f285041 --- /dev/null +++ b/tasks/spec.rake @@ -0,0 +1,29 @@ +# encoding: utf-8 + +begin + require 'rspec/core/rake_task' + + desc 'Run all specs' + RSpec::Core::RakeTask.new(:spec) do |task| + task.pattern = 'spec/{unit,integration}{,/*/**}/*_spec.rb' + end + + namespace :spec do + desc 'Run unit specs' + RSpec::Core::RakeTask.new(:unit) do |task| + task.pattern = 'spec/unit{,/*/**}/*_spec.rb' + end + + desc 'Run integration specs' + RSpec::Core::RakeTask.new(:integration) do |task| + task.pattern = 'spec/integration{,/*/**}/*_spec.rb' + end + end + +rescue LoadError + %w[spec spec:unit spec:integration].each do |name| + task name do + $stderr.puts "In order to run #{name}, do `gem install rspec`" + end + end +end