From 6a94afcc120040021e26616fb7a50153aa8f20aa Mon Sep 17 00:00:00 2001 From: Aaron Pfeifer Date: Sun, 7 Sep 2008 00:43:51 -0400 Subject: [PATCH] MAJOR REWRITE! Replace all before/after_exit/enter/loopback callback hooks and :before/:after options for events with before_transition/after_transition callbacks Fix state machines in subclasses not knowing what states/events/transitions were defined by superclasses Don't use callbacks for performing transitions Add support for can_#{event}? for checking whether an event can be fired based on the current state of the record No longer allow additional arguments to be passed into event actions --- CHANGELOG.rdoc | 9 + README.rdoc | 78 ++- lib/state_machine.rb | 69 +-- lib/state_machine/event.rb | 164 ++---- lib/state_machine/machine.rb | 385 ++++++++++--- lib/state_machine/transition.rb | 244 ++++++--- test/app_root/app/models/auto_shop.rb | 4 +- test/app_root/app/models/switch_observer.rb | 20 + test/app_root/app/models/vehicle.rb | 20 +- test/app_root/config/environment.rb | 7 + test/factory.rb | 6 + test/functional/state_machine_test.rb | 23 +- test/unit/event_test.rb | 231 ++------ test/unit/machine_test.rb | 184 ++++++- test/unit/state_machine_test.rb | 68 ++- test/unit/transition_test.rb | 574 ++++++++++++-------- 16 files changed, 1242 insertions(+), 844 deletions(-) create mode 100644 test/app_root/app/models/switch_observer.rb create mode 100644 test/app_root/config/environment.rb diff --git a/CHANGELOG.rdoc b/CHANGELOG.rdoc index 5c033847..399c0926 100644 --- a/CHANGELOG.rdoc +++ b/CHANGELOG.rdoc @@ -1,5 +1,14 @@ == master +* No longer allow additional arguments to be passed into event actions +* Add support for can_#{event}? for checking whether an event can be fired based on the current state of the record +* Don't use callbacks for performing transitions +* Fix state machines in subclasses not knowing what states/events/transitions were defined by superclasses +* Replace all before/after_exit/enter/loopback callback hooks and :before/:after options for events with before_transition/after_transition callbacks, e.g. + + before_transition :from => 'parked', :do => :lock_doors # was before_exit :parked, :lock_doors + after_transition :on => 'ignite', :do => :turn_on_radio # was event :ignite, :after => :turn_on_radio do + * Always save when an event is fired even if it results in a loopback [Jürgen Strobel] * Ensure initial state callbacks are invoked in the proper order when an event is fired on a new record * Add before_loopback and after_loopback hooks [Jürgen Strobel] diff --git a/README.rdoc b/README.rdoc index 6fe1e012..92f55937 100644 --- a/README.rdoc +++ b/README.rdoc @@ -40,14 +40,15 @@ making it so simple you don't even need to know what a state machine is :) Below is an example of many of the features offered by this plugin, including * Initial states -* State callbacks -* Event callbacks +* Transition callbacks * Conditional transitions class Vehicle < ActiveRecord::Base state_machine :state, :initial => 'idling' do - before_exit 'parked', :put_on_seatbelt - after_enter 'parked', Proc.new {|vehicle| vehicle.update_attribute(:seatbelt_on, false)} + before_transition :from => %w(parked idling), :do => :put_on_seatbelt + after_transition :to => 'parked', :do => lambda {|vehicle| vehicle.update_attribute(:seatbelt_on, false)} + after_transition :on => 'crash', :do => :tow! + after_transition :on => 'repair', :do => :fix! event :park do transition :to => 'parked', :from => %w(idling first_gear) @@ -73,19 +74,21 @@ Below is an example of many of the features offered by this plugin, including transition :to => 'first_gear', :from => 'second_gear' end - event :crash, :after => :tow! do + event :crash do transition :to => 'stalled', :from => %w(first_gear second_gear third_gear), :unless => :auto_shop_busy? end - event :repair, :after => :fix! do + event :repair do transition :to => 'parked', :from => 'stalled', :if => :auto_shop_busy? end end def tow! + # do something here end def fix! + # do something here end def auto_shop_busy? @@ -96,16 +99,62 @@ Below is an example of many of the features offered by this plugin, including Using the above model as an example, you can interact with the state machine like so: - vehicle = Vehicle.create # => # - vehicle.ignite # => true - vehicle # => # - vehicle.shift_up # => true - vehicle # => # - vehicle.shift_up # => true - vehicle # => # + vehicle = Vehicle.create # => # + vehicle.ignite # => true + vehicle # => # + vehicle.shift_up # => true + vehicle # => # + vehicle.shift_up # => true + vehicle # => # # The bang (!) operator can raise exceptions if the event fails - vehicle.park! # => PluginAWeek::StateMachine::InvalidTransition: Cannot transition via :park from "second_gear" + vehicle.park! # => PluginAWeek::StateMachine::InvalidTransition: Cannot transition via :park from "second_gear" + +=== With enumerations + +Using the acts_as_enumeration[http://github.com/pluginaweek/acts_as_enumeration] plugin +states can be transparently stored using record ids in the database like so: + + class VehicleState < ActiveRecord::Base + acts_as_enumeration + + create :id => 1, :name => 'parked' + create :id => 2, :name => 'idling' + create :id => 3, :name => 'first_gear' + ... + end + + class Vehicle < ActiveRecord::Base + belongs_to :state, :class_name => 'VehicleState' + + state_machine :state, :initial => 'idling' do + ... + + event :park do + transition :to => 'parked', :from => %w(idling first_gear) + end + + event :ignite do + transition :to => 'stalled', :from => 'stalled' + transition :to => 'idling', :from => 'parked' + end + end + + ... + end + +Notice in the above example, the state machine definition remains *exactly* the +same. However, when interacting with the records, the actual state will be +stored using the identifiers defined for the enumeration: + + vehicle = Vehicle.create # => # + vehicle.ignite # => true + vehicle # => # + vehicle.shift_up # => true + vehicle # => # + +This allows states to take on more complex functionality other than just being +a string value. == Tools @@ -130,3 +179,4 @@ To run against a specific version of Rails: == References * Scott Barron - acts_as_state_machine[http://elitists.textdriven.com/svn/plugins/acts_as_state_machine] +* acts_as_enumeration[http://github.com/pluginaweek/acts_as_enumeration] diff --git a/lib/state_machine.rb b/lib/state_machine.rb index 13c2ee0a..d8ff9b0a 100644 --- a/lib/state_machine.rb +++ b/lib/state_machine.rb @@ -1,9 +1,9 @@ require 'state_machine/machine' module PluginAWeek #:nodoc: - # A state machine is a model of behavior composed of states, transitions, - # and events. This helper adds support for defining this type of - # functionality within your ActiveRecord models. + # A state machine is a model of behavior composed of states, events, and + # transitions. This helper adds support for defining this type of + # functionality within ActiveRecord models. module StateMachine def self.included(base) #:nodoc: base.class_eval do @@ -19,10 +19,10 @@ module MacroMethods # * +initial+ - The initial value of the attribute. This can either be the actual value or a Proc for dynamic initial states. # # This also requires a block which will be used to actually configure the - # events and transitions for the state machine. *Note* that this block will - # be executed within the context of the state machine. As a result, you will - # not be able to access any class methods on the model unless you refer to - # them directly (i.e. specifying the class name). + # events and transitions for the state machine. *Note* that this block + # will be executed within the context of the state machine. As a result, + # you will not be able to access any class methods on the model unless you + # refer to them directly (i.e. specifying the class name). # # For examples on the types of configured state machines and blocks, see # the section below. @@ -61,7 +61,7 @@ module MacroMethods # With a dynamic initial state: # # class Switch < ActiveRecord::Base - # state_machine :status, :initial => Proc.new {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do + # state_machine :status, :initial => lambda {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do # ... # end # end @@ -74,39 +74,28 @@ module MacroMethods # == Defining callbacks # # Within the +state_machine+ block, you can also define callbacks for - # particular states. These states are enumerated in the PluginAWeek::StateMachine::Machine - # documentation. Below are examples of defining the various types of callbacks: - # - # class Switch < ActiveRecord::Base - # state_machine do - # before_exit :off, :alert_homeowner - # before_enter :on, Proc.new {|switch| ...} - # before_loopback :on, :display_warning - # - # after_exit :off, :on, :play_sound - # after_enter :off, :on, :play_sound - # after_loopback :on, Proc.new {|switch| ...} - # - # ... - # end + # particular states. For more information about defining these callbacks, + # see PluginAWeek::StateMachine::Machine#before_transition and + # PluginAWeek::StateMachine::Machine#after_transition. def state_machine(*args, &block) unless included_modules.include?(PluginAWeek::StateMachine::InstanceMethods) write_inheritable_attribute :state_machines, {} class_inheritable_reader :state_machines - after_create :run_initial_state_machine_actions - include PluginAWeek::StateMachine::InstanceMethods end options = args.extract_options! attribute = args.any? ? args.first.to_s : 'state' - options[:initial] = state_machines[attribute].initial_state_without_processing if !options.include?(:initial) && state_machines[attribute] - # This will create a new machine for subclasses as well so that the owner_class and - # initial state can be overridden - machine = state_machines[attribute] = PluginAWeek::StateMachine::Machine.new(self, attribute, options) + # Creates the state machine for this class. If a superclass has already + # defined the machine, then a copy of it will be used with its context + # changed to this class. If no machine has been defined before for the + # attribute, a new one will be created. + original = state_machines[attribute] + machine = state_machines[attribute] = original ? original.within_context(self, options) : PluginAWeek::StateMachine::Machine.new(self, attribute, options) machine.instance_eval(&block) if block + machine end end @@ -122,31 +111,15 @@ def self.included(base) #:nodoc: def initialize_with_state_machine(attributes = nil) initialize_without_state_machine(attributes) - attribute_keys = (attributes || {}).keys.map!(&:to_s) - # Set the initial value of each state machine as long as the value wasn't - # included in the attribute hash passed in + # included in the initial attributes + attributes = (attributes || {}).stringify_keys self.class.state_machines.each do |attribute, machine| - unless attribute_keys.include?(attribute) - send("#{attribute}=", machine.initial_state(self)) - end + send("#{attribute}=", machine.initial_state(self)) unless attributes.include?(attribute) end yield self if block_given? end - - # Records the transition for the record going into its initial state - def run_initial_state_machine_actions - # Make sure that these initial actions are only invoked once - unless @processed_initial_state_machine_actions - @processed_initial_state_machine_actions = true - - self.class.state_machines.each do |attribute, machine| - callback = "after_enter_#{attribute}_#{self[attribute]}" - run_callbacks(callback) if self[attribute] && self.class.respond_to?(callback) - end - end - end end end end diff --git a/lib/state_machine/event.rb b/lib/state_machine/event.rb index f0adb221..1e9ee45b 100644 --- a/lib/state_machine/event.rb +++ b/lib/state_machine/event.rb @@ -6,7 +6,7 @@ module StateMachine # another class Event # The state machine for which this event is defined - attr_reader :machine + attr_accessor :machine # The name of the action that fires the event attr_reader :name @@ -17,22 +17,20 @@ class Event delegate :owner_class, :to => :machine - # Creates a new event within the context of the given machine. - # - # Configuration options: - # * +before+ - Callbacks to invoke before the event is fired - # * +after+ - Callbacks to invoke after the event is fired - def initialize(machine, name, options = {}) - options.assert_valid_keys(:before, :after) - + # Creates a new event within the context of the given machine + def initialize(machine, name) @machine = machine @name = name - @options = options.stringify_keys @transitions = [] - add_transition_actions - add_transition_callbacks - add_event_callbacks + add_actions + end + + # Creates a copy of this event in addition to the list of associated + # transitions to prevent conflicts across different events. + def initialize_copy(orig) #:nodoc: + super + @transitions = @transitions.dup end # Creates a new transition to the specified state. @@ -41,7 +39,7 @@ def initialize(machine, name, options = {}) # * +to+ - The state that being transitioned to # * +from+ - A state or array of states that can be transitioned from. If not specified, then the transition can occur for *any* from state # * +except_from+ - A state or array of states that *cannot* be transitioned from. - # * +if+ - Specifies a method, proc or string to call to determine if the validation should occur (e.g. :if => :moving?, or :if => Proc.new {|car| car.speed > 60}). The method, proc or string should return or evaluate to a true or false value. + # * +if+ - Specifies a method, proc or string to call to determine if the transition should occur (e.g. :if => :moving?, or :if => Proc.new {|car| car.speed > 60}). The method, proc or string should return or evaluate to a true or false value. # * +unless+ - Specifies a method, proc or string to call to determine if the transition should not occur (e.g. :unless => :stopped?, or :unless => Proc.new {|car| car.speed <= 60}). The method, proc or string should return or evaluate to a true or false value. # # == Examples @@ -52,134 +50,66 @@ def initialize(machine, name, options = {}) # transition :to => 'parked', :from => 'first_gear', :if => :moving? # transition :to => 'parked', :from => 'first_gear', :unless => :stopped? # transition :to => 'parked', :except_from => 'parked' - def transition(options = {}) - # Slice out the callback options - options.symbolize_keys! - callback_options = {:if => options.delete(:if), :unless => options.delete(:unless)} - - transition = Transition.new(self, options) - - # Add the callback to the model. If the callback fails, then the next - # available callback for the event will run until one is successful. - callback = Proc.new {|record, *args| try_transition(transition, false, record, *args)} - owner_class.send("transition_on_#{name}", callback, callback_options) - - # Add the callback! to the model similar to above - callback = Proc.new {|record, *args| try_transition(transition, true, record, *args)} - owner_class.send("transition_bang_on_#{name}", callback, callback_options) - - transitions << transition + def transition(options) + transitions << transition = Transition.new(self, options) transition end + # Determines whether any transitions can be performed for this event based + # on the current state of the given record. + # + # If the event can't be fired, then this will return false, otherwise true. + def can_fire?(record) + transitions.any? {|transition| transition.can_perform?(record)} + end + # Attempts to perform one of the event's transitions for the given record. # Any additional arguments will be passed to the event's callbacks. - def fire(record, *args) - fire_with_optional_bang(record, false, *args) || false + def fire(record) + run(record, false) || false end # Attempts to perform one of the event's transitions for the given record. # If the transition cannot be made, then a PluginAWeek::StateMachine::InvalidTransition # error will be raised. - def fire!(record, *args) - fire_with_optional_bang(record, true, *args) || raise(PluginAWeek::StateMachine::InvalidTransition, "Cannot transition via :#{name} from #{record.send(machine.attribute).inspect}") + def fire!(record) + run(record, true) || raise(PluginAWeek::StateMachine::InvalidTransition, "Cannot transition via :#{name} from \"#{record.send(machine.attribute)}\"") end private - # Fires the event - def fire_with_optional_bang(record, bang, *args) - record.class.transaction do - invoke_transition_callbacks(record, bang, *args) || raise(ActiveRecord::Rollback) - end - end - # Add the various instance methods that can transition the record using # the current event - def add_transition_actions + def add_actions + attribute = machine.attribute name = self.name - owner_class = self.owner_class - machine = self.machine owner_class.class_eval do - # Fires the event, returning true/false - define_method(name) do |*args| - owner_class.state_machines[machine.attribute].events[name].fire(self, *args) - end - - # Fires the event, raising an exception if it fails - define_method("#{name}!") do |*args| - owner_class.state_machines[machine.attribute].events[name].fire!(self, *args) - end + define_method(name) {self.class.state_machines[attribute].events[name].fire(self)} + define_method("#{name}!") {self.class.state_machines[attribute].events[name].fire!(self)} + define_method("can_#{name}?") {self.class.state_machines[attribute].events[name].can_fire?(self)} end end - # Defines callbacks for invoking transitions when this event is performed - def add_transition_callbacks - %W(transition transition_bang).each do |callback_name| - callback_name = "#{callback_name}_on_#{name}" - owner_class.define_callbacks(callback_name) - end - end - - # Adds the before/after callbacks for when the event is performed - def add_event_callbacks - %w(before after).each do |type| - callback_name = "#{type}_#{name}" - owner_class.define_callbacks(callback_name) - - # Add each defined callback - Array(@options[type]).each {|callback| owner_class.send(callback_name, callback)} - end - end - - # Attempts to perform the given transition. If it can't be performed based - # on the state of the given record, then the transition will be skipped - # and the next available one will be tried. + # Attempts to find a transition that can be performed for this event. # - # If +bang+ is specified, then perform! will be called on the transition. - # Otherwise, the default +perform+ will be invoked. - def try_transition(transition, bang, record, *args) - if transition.can_perform_on?(record) - # If the record hasn't been saved yet, then make sure we run any - # initial actions for the state it's currently in - record.run_initial_state_machine_actions if record.new_record? - - # Now that the state machine has been initialized properly, proceed - # normally to the callback chain - return false if invoke_event_callbacks(:before, record, *args) == false - result = bang ? transition.perform!(record, *args) : transition.perform(record, *args) - invoke_event_callbacks(:after, record, *args) - result - else - # Indicate that the transition cannot be performed - :skip - end - end - - # Invokes a particulary type of callback for the event - def invoke_event_callbacks(type, record, *args) - args = [record] + args + # +bang+ indicates whether +perform+ or perform! will be + # invoked on transitions. + def run(record, bang) + result = false - record.class.send("#{type}_#{name}_callback_chain").each do |callback| - result = callback.call(*args) - break result if result == false + record.class.transaction do + transitions.each do |transition| + if transition.can_perform?(record) + result = bang ? transition.perform!(record) : transition.perform(record) + break + end + end + + # Rollback any changes if the transition failed + raise ActiveRecord::Rollback unless result end - end - - # Invokes the callbacks for each transition in order to find one that - # completes successfully. - # - # +bang+ indicates whether perform or perform! will be invoked on the - # transitions in the callback chain - def invoke_transition_callbacks(record, bang, *args) - args = [record] + args - callback_chain = "transition#{'_bang' if bang}_on_#{name}_callback_chain" - result = record.class.send(callback_chain).each do |callback| - result = callback.call(*args) - break result if [true, false].include?(result) - end - result == true + result end end end diff --git a/lib/state_machine/machine.rb b/lib/state_machine/machine.rb index d5e98868..1a529e44 100644 --- a/lib/state_machine/machine.rb +++ b/lib/state_machine/machine.rb @@ -7,67 +7,102 @@ module StateMachine # how the state changes after a particular event is fired. # # A state machine may not necessarily know all of the possible states for - # an object since they can be any arbitrary value. + # an object since they can be any arbitrary value. As a result, anything + # that relies on a list of all possible states should keep in mind that if + # a state has not been referenced *anywhere* in the state machine definition, + # then it will *not* be a known state. # # == Callbacks # - # Callbacks are supported for hooking into event calls and state transitions. - # The order in which these callbacks are invoked is shown below: - # * (1) before_exit (from state) - # * (2) before_enter (to state) - # * (3) before (event) - # * (-) update state - # * (4) after_exit (from state) - # * (5) after_enter (to state) - # * (6) after (event) - # - # If the event causes a loopback (i.e. to and from state are the same), then - # the callback chain is slightly different: - # - # * (1) before_loopback (to/from state) - # * (2) before (event) - # * (-) update state - # * (3) after_loopback (to/from state) - # * (4) after (event) - # - # One last *important* note about callbacks is that the after_enter callback - # will be invoked for the initial state when a record is saved (assuming that - # the initial state is set). So if an event is fired on an unsaved record, - # the callback order will be: - # - # * (1) after_enter (initial state) - # * (2) before_exit (from/initial state) - # * (3) before_enter (to state) - # * (4) before (event) - # * (-) update state - # * (5) after_exit (from/initial state) - # * (6) after_enter (to state) - # * (7) after (event) - # - # == Cancelling callbacks - # - # If a before_* callback returns +false+, all the later callbacks - # and associated event are cancelled. If an after_* callback returns - # false, all the later callbacks are cancelled. Callbacks are run in the - # order in which they are defined. - # - # Note that if a before_* callback fails and the bang version of an - # event was invoked, an exception will be raised instaed of returning false. + # Callbacks are supported for hooking before and after every possible + # transition in the machine. Each callback is invoked in the order in which + # it was defined. See PluginAWeek::StateMachine::Machine#before_transition + # and PluginAWeek::StateMachine::Machine#after_transition for documentation + # on how to define new callbacks. + # + # === Cancelling callbacks + # + # If a +before+ callback returns +false+, all the later callbacks and + # associated transition are cancelled. If an +after+ callback returns false, + # the later callbacks are cancelled, but the transition is still successful. + # This is the same behavior as exposed by ActiveRecord's callback support. + # + # *Note* that if a +before+ callback fails and the bang version of an event + # was invoked, an exception will be raised instead of returning false. + # + # == Observers + # + # ActiveRecord observers can also hook into state machines in addition to + # the conventional before_save, after_save, etc. behaviors. The following + # types of behaviors can be observed: + # * events (e.g. before_park/after_park, before_ignite/after_ignite) + # * transitions (before_transition/after_transition) + # + # Each method takes a set of parameters that provides additional information + # about the transition that caused the observer to be notified. Below are + # examples of defining observers for the following state machine: + # + # class Vehicle < ActiveRecord::Base + # state_machine do + # event :park do + # transition :to => 'parked', :from => 'idling' + # end + # ... + # end + # ... + # end + # + # Event behaviors: + # + # class VehicleObserver < ActiveRecord::Observer + # def before_park(vehicle, from_state, to_state) + # logger.info "Vehicle #{vehicle.id} instructed to park... state is: #{from_state}, state will be: #{to_state}" + # end + # + # def after_park(vehicle, from_state, to_state) + # logger.info "Vehicle #{vehicle.id} instructed to park... state was: #{from_state}, state is: #{to_state}" + # end + # end + # + # Transition behaviors: + # + # class VehicleObserver < ActiveRecord::Observer + # def before_transition(vehicle, attribute, event, from_state, to_state) + # logger.info "Vehicle #{vehicle.id} instructed to #{event}... #{attribute} is: #{from_state}, #{attribute} will be: #{to_state}" + # end + # + # def after_transition(vehicle, attribute, event, from_state, to_state) + # logger.info "Vehicle #{vehicle.id} instructed to #{event}... #{attribute} was: #{from_state}, #{attribute} is: #{to_state}" + # end + # end + # + # One common callback is to record transitions for all models in the system + # for audit/debugging purposes. Below is an example of an observer that can + # easily automate this process for all models: + # + # class StateMachineObserver < ActiveRecord::Observer + # observe Vehicle, Switch, AutoShop + # + # def before_transition(record, attribute, event, from_state, to_state) + # transition = StateTransition.build(:record => record, :attribute => attribute, :event => event, :from_state => from_state, :to_state => to_state) + # transition.save # Will cancel rollback/cancel transition if this fails + # end + # end class Machine - # The events that trigger transitions - attr_reader :events - - # A list of the states defined in the transitions of all of the events - attr_reader :states + # The class that the machine is defined for + attr_reader :owner_class # The attribute for which the state machine is being defined - attr_accessor :attribute + attr_reader :attribute - # The initial state that the machine will be in + # The initial state that the machine will be in when a record is created attr_reader :initial_state - # The class that the attribute belongs to - attr_reader :owner_class + # A list of the states defined in the transitions of all of the events + attr_reader :states + + # The events that trigger transitions + attr_reader :events # Creates a new state machine for the given attribute # @@ -82,43 +117,91 @@ class Machine # # Switch.with_state('on') # => Finds all switches where the state is on # Switch.with_states('on', 'off') # => Finds all switches where the state is either on or off + # + # *Note* that if class methods already exist with those names (i.e. "with_state" + # or "with_states"), then a scope will not be defined for that name. def initialize(owner_class, attribute = 'state', options = {}) - options.assert_valid_keys(:initial) + set_context(owner_class, options) - @owner_class = owner_class @attribute = attribute.to_s - @initial_state = options[:initial] - @events = {} @states = [] + @events = {} + add_transition_callbacks add_named_scopes end - # Gets the initial state of the machine for the given record. The record - # is only used if a dynamic initial state was configured. - def initial_state(record) - @initial_state.is_a?(Proc) ? @initial_state.call(record) : @initial_state + # Creates a copy of this machine in addition to copies of each associated + # event, so that the list of transitions for each event don't conflict + # with different machines + def initialize_copy(orig) #:nodoc: + super + + @states = @states.dup + @events = @events.inject({}) do |events, (name, event)| + event = event.dup + event.machine = self + events[name] = event + events + end end - # Gets the initial state without processing it against a particular record - def initial_state_without_processing - @initial_state + # Creates a copy of this machine within the context of the given class. + # This should be used for inheritance support of state machines. + def within_context(owner_class, options = {}) #:nodoc: + machine = dup + machine.set_context(owner_class, options) + machine end - # Defines an event of the system. This can take an optional hash that - # defines callbacks which will be invoked before and after the event is - # invoked on the object. + # Changes the context of this machine to the given class so that new + # events and transitions are created in the proper context. + def set_context(owner_class, options = {}) #:nodoc: + options.assert_valid_keys(:initial) + + @owner_class = owner_class + @initial_state = options[:initial] if options[:initial] + end + + # Gets the initial state of the machine for the given record. If a record + # is specified a and a dynamic initial state was configured for the machine, + # then that record will be passed into the proc to help determine the actual + # value of the initial state. # - # Configuration options: - # * +before+ - One or more callbacks that will be invoked before the event has been fired - # * +after+ - One or more callbacks that will be invoked after the event has been fired + # == Examples + # + # With normal initial state: + # + # class Vehicle < ActiveRecord::Base + # state_machine :initial => 'parked' do + # ... + # end + # end + # + # Vehicle.state_machines['state'].initial_state(@vehicle) # => "parked" + # + # With dynamic initial state: + # + # class Vehicle < ActiveRecord::Base + # state_machine :initial => lambda {|vehicle| vehicle.force_idle ? 'idling' : 'parked'} do + # ... + # end + # end + # + # Vehicle.state_machines['state'].initial_state(@vehicle) # => "idling" + def initial_state(record) + @initial_state.is_a?(Proc) ? @initial_state.call(record) : @initial_state + end + + # Defines an event of the system # # == Instance methods # # The following instance methods are generated when a new event is defined # (the "park" event is used as an example): - # * park(*args) - Fires the "park" event, transitioning from the current state to the next valid state. This takes an optional list of arguments which are passed to the event callbacks. - # * park!(*args) - Fires the "park" event, transitioning from the current state to the next valid state. This takes an optional list of arguments which are passed to the event callbacks. If the transition cannot happen (for validation, database, etc. reasons), then an error will be raised + # * park - Fires the "park" event, transitioning from the current state to the next valid state. + # * park! - Fires the "park" event, transitioning from the current state to the next valid state. If the transition cannot happen (for validation, database, etc. reasons), then an error will be raised. + # * can_park? - Checks whether the "park" event can be fired given the current state of the record. # # == Defining transitions # @@ -142,7 +225,7 @@ def initial_state_without_processing # # class Car < ActiveRecord::Base # def self.safe_states - # %w(parked idling first_gear) + # %w(parked idling stalled) # end # # state_machine :state do @@ -162,40 +245,166 @@ def initial_state_without_processing # ... # end # end - def event(name, options = {}, &block) + def event(name, &block) name = name.to_s - event = events[name] = Event.new(self, name, options) + event = events[name] ||= Event.new(self, name) event.instance_eval(&block) - # Record the states + # Record the states so that the machine can keep a list of all known + # states that have been defined event.transitions.each do |transition| - @states |= ([transition.to_state] + transition.from_states) + @states |= [transition.options[:to]] + Array(transition.options[:from]) + Array(transition.options[:except_from]) + @states.sort! end event end - # Define state callbacks - %w(before_exit before_enter before_loopback after_exit after_enter after_loopback).each do |callback_type| - define_method(callback_type) {|state, callback| add_callback(callback_type, state, callback)} + # Creates a callback that will be invoked *before* a transition has been + # performed, so long as the given configuration options match the transition. + # Each part of the transition (to state, from state, and event) must match + # in order for the callback to get invoked. + # + # Configuration options: + # * +to+ - One or more states being transitioned to. If none are specified, then all states will match. + # * +from+ - One or more states being transitioned from. If none are specified, then all states will match. + # * +on+ - One or more events that fired the transition. If none are specified, then all events will match. + # * +except_to+ - One more states *not* being transitioned to + # * +except_from+ - One or more states *not* being transitioned from + # * +except_on+ - One or more events that *did not* fire the transition + # * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string. + # * +if+ - A method, proc or string to call to determine if the callback should occur (e.g. :if => :allow_callbacks, or :if => lambda {|user| user.signup_step > 2}). The method, proc or string should return or evaluate to a true or false value. + # * +unless+ - A method, proc or string to call to determine if the callback should not occur (e.g. :unless => :skip_callbacks, or :unless => lambda {|user| user.signup_step <= 2}). The method, proc or string should return or evaluate to a true or false value. + # + # The +except+ group of options (+except_to+, +exception_from+, and + # +except_on+) acts as the +unless+ equivalent of their counterparts (+to+, + # +from+, and +on+, respectively) + # + # == The callback + # + # When defining additional configuration options, callbacks must be defined + # in the :do option like so: + # + # class Vehicle < ActiveRecord::Base + # state_machine do + # before_transition :to => 'parked', :do => :set_alarm + # ... + # end + # end + # + # == Examples + # + # Below is an example of a model with one state machine and various types + # of +before+ transitions defined for it: + # + # class Vehicle < ActiveRecord::Base + # state_machine do + # # Before all transitions + # before_transition :update_dashboard + # + # # Before specific transition: + # before_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt + # + # # With conditional callback: + # before_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on? + # + # # Using :except counterparts: + # before_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard + # ... + # end + # end + # + # As can be seen, any number of transitions can be created using various + # combinations of configuration options. + def before_transition(options = {}) + add_transition_callback(:before, options) + end + + # Creates a callback that will be invoked *after* a transition has been + # performed, so long as the given configuration options match the transition. + # Each part of the transition (to state, from state, and event) must match + # in order for the callback to get invoked. + # + # Configuration options: + # * +to+ - One or more states being transitioned to. If none are specified, then all states will match. + # * +from+ - One or more states being transitioned from. If none are specified, then all states will match. + # * +on+ - One or more events that fired the transition. If none are specified, then all events will match. + # * +except_to+ - One more states *not* being transitioned to + # * +except_from+ - One or more states *not* being transitioned from + # * +except_on+ - One or more events that *did not* fire the transition + # * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string. + # * +if+ - A method, proc or string to call to determine if the callback should occur (e.g. :if => :allow_callbacks, or :if => lambda {|user| user.signup_step > 2}). The method, proc or string should return or evaluate to a true or false value. + # * +unless+ - A method, proc or string to call to determine if the callback should not occur (e.g. :unless => :skip_callbacks, or :unless => lambda {|user| user.signup_step <= 2}). The method, proc or string should return or evaluate to a true or false value. + # + # The +except+ group of options (+except_to+, +exception_from+, and + # +except_on+) acts as the +unless+ equivalent of their counterparts (+to+, + # +from+, and +on+, respectively) + # + # == The callback + # + # When defining additional configuration options, callbacks must be defined + # in the :do option like so: + # + # class Vehicle < ActiveRecord::Base + # state_machine do + # after_transition :to => 'parked', :do => :set_alarm + # ... + # end + # end + # + # == Examples + # + # Below is an example of a model with one state machine and various types + # of +after+ transitions defined for it: + # + # class Vehicle < ActiveRecord::Base + # state_machine do + # # After all transitions + # after_transition :update_dashboard + # + # # After specific transition: + # after_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt + # + # # With conditional callback: + # after_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on? + # + # # Using :except counterparts: + # after_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard + # ... + # end + # end + # + # As can be seen, any number of transitions can be created using various + # combinations of configuration options. + def after_transition(options = {}) + add_transition_callback(:after, options) end private # Adds the given callback to the callback chain during a state transition - def add_callback(type, state, callback) - callback_name = "#{type}_#{attribute}_#{state}" - owner_class.define_callbacks(callback_name) - owner_class.send(callback_name, callback) + def add_transition_callback(type, options) + options = {:do => options} unless options.is_a?(Hash) + options.assert_valid_keys(:to, :from, :on, :except_to, :except_from, :except_on, :do, :if, :unless) + + # The actual callback (defined in the :do option) must be defined + raise ArgumentError, ':do callback must be specified' unless options[:do] + + # Create the callback + owner_class.send("#{type}_transition_#{attribute}", options.delete(:do), options) + end + + # Add before/after callbacks for when the attribute transitions to a + # different value + def add_transition_callbacks + %w(before after).each {|type| owner_class.define_callbacks("#{type}_transition_#{attribute}") } end # Add named scopes for finding records with a particular value or values # for the attribute def add_named_scopes - [attribute, attribute.pluralize].each do |name| - unless owner_class.respond_to?("with_#{name}") - name = "with_#{name}" - owner_class.named_scope name.to_sym, Proc.new {|*values| {:conditions => {attribute => values.flatten}}} - end + [attribute, attribute.pluralize].uniq.each do |name| + name = "with_#{name}" + owner_class.named_scope name.to_sym, lambda {|*values| {:conditions => {attribute => values.flatten}}} unless owner_class.respond_to?(name) end end end diff --git a/lib/state_machine/transition.rb b/lib/state_machine/transition.rb index d4335d0b..d662d5c1 100644 --- a/lib/state_machine/transition.rb +++ b/lib/state_machine/transition.rb @@ -4,22 +4,19 @@ module StateMachine class InvalidTransition < StandardError end - # A transition indicates a state change and is described by a condition + # A transition represents a state change and is described by a condition # that would need to be fulfilled to enable the transition. Transitions # consist of: - # * The starting state(s) - # * The ending state - # * A guard to check if the transition is allowed + # * An event + # * One or more starting states + # * An ending state class Transition - # The state to which the transition is being made - attr_reader :to_state - - # The states from which the transition can be made - attr_reader :from_states - # The event that caused the transition attr_reader :event + # The configuration for this transition + attr_reader :options + delegate :machine, :to => :event @@ -29,95 +26,200 @@ class Transition # * +to+ - The state being transitioned to # * +from+ - One or more states being transitioned from. Default is nil (can transition from any state) # * +except_from+ - One or more states that *can't* be transitioned from. + # * +if+ - Specifies a method, proc or string to call to determine if the transition should occur (e.g. :if => :moving?, or :if => Proc.new {|car| car.speed > 60}). The method, proc or string should return or evaluate to a true or false value. + # * +unless+ - Specifies a method, proc or string to call to determine if the transition should not occur (e.g. :unless => :stopped?, or :unless => Proc.new {|car| car.speed <= 60}). The method, proc or string should return or evaluate to a true or false value. def initialize(event, options) #:nodoc: @event = event + @options = options + @options.symbolize_keys! - options.assert_valid_keys(:to, :from, :except_from) + options.assert_valid_keys(:to, :from, :except_from, :if, :unless) raise ArgumentError, ':to state must be specified' unless options.include?(:to) - - # Get the states involved in the transition - @to_state = options[:to] - @from_states = Array(options[:from] || options[:except_from]) - - # Should we be matching the from states? - @require_match = !options[:from].nil? end - # Whether or not this is a loopback transition (i.e. from and to state are the same) - def loopback?(from_state) - from_state == to_state + # Determines whether the given query options match the machine state that + # this transition describes. Since transitions have no way of telling + # what the *current* from state is in this context (may be called before + # or after a transition has occurred), it must be provided. + # + # Query options: + # * +to+ - One or more states being transitioned to. If none are specified, then this will always match. + # * +from+ - One or more states being transitioned from. If none are specified, then this will always match. + # * +on+ - One or more events that fired the transition. If none are specified, then this will always match. + # * +except_to+ - One more states *not* being transitioned to + # * +except_from+ - One or more states *not* being transitioned from + # * +except_on+ - One or more events that *did not* fire the transition. + # + # *Note* that if the given from state is not an actual valid state for this + # transition, then an ArgumentError will be raised. + # + # == Examples + # + # event = PluginAWeek::StateMachine::Event.new(machine, 'ignite') + # transition = PluginAWeek::StateMachine::Transition.new(event, :to => 'idling', :from => 'parked') + # + # # Successful + # transition.matches?('parked') # => true + # transition.matches?('parked', :from => 'parked') # => true + # transition.matches?('parked', :to => 'idling') # => true + # transition.matches?('parked', :on => 'ignite') # => true + # transition.matches?('parked', :from => 'parked', :to => 'idling') # => true + # transition.matches?('parked', :from => 'parked', :to => 'idling', :on => 'ignite') # => true + # + # # Unsuccessful + # transition.matches?('idling') # => ArgumentError: "idling" is not a valid from state for transition + # transition.matches?('parked', :from => 'idling') # => false + # transition.matches?('parked', :to => 'first_gear') # => false + # transition.matches?('parked', :on => 'park') # => false + # transition.matches?('parked', :from => 'parked', :to => 'first_gear') # => false + # transition.matches?('parked', :from => 'parked', :to => 'idling', :on => 'park') # => false + def matches?(from_state, query = {}) + raise ArgumentError, "\"#{from_state}\" is not a valid from state for transition" unless valid_from_state?(from_state) + + # Ensure that from state, to state, and event match the query + query.blank? || + find_match(from_state, query[:from], query[:except_from]) && + find_match(@options[:to], query[:to], query[:except_to]) && + find_match(event.name, query[:on], query[:except_on]) end - # Determines whether or not this transition can be performed on the given - # record. The transition can be performed if the record's state matches - # one of the states that are valid in this transition. - def can_perform_on?(record) - from_states.empty? || from_states.include?(record.send(machine.attribute)) == @require_match + # Determines whether this transition can be performed on the given record. + # This checks two things: + # 1. Does the from state match what's configured for this transition + # 2. If so, do the conditional :if/:unless options for the transition + # allow the transition to be performed? + # + # If both of those two checks pass, then this transition can be performed + # by subsequently calling +perform+/perform! + def can_perform?(record) + if valid_from_state?(record.send(machine.attribute)) + # Verify that the conditional evaluates to true for the record + if @options[:if] + evaluate_method(@options[:if], record) + elsif @options[:unless] + !evaluate_method(@options[:unless], record) + else + true + end + else + false + end end - # Runs the actual transition and any callbacks associated with entering - # and exiting the states. Any additional arguments are passed to the - # callbacks. + # Runs the actual transition and any before/after callbacks associated + # with the transition. Additional arguments are passed to the callbacks. # - # *Note* that the caller should check can_perform_on? before calling - # perform. This will *not* check whether transition should be performed. - def perform(record, *args) - perform_with_optional_bang(record, false, *args) + # *Note* that the caller should check matches? before being + # called. This will *not* check whether transition should be performed. + def perform(record) + run(record, false) end - # Runs the actual transition and any callbacks associated with entering - # and exiting the states. Any errors during validation or saving will be - # raised. If any +before+ callbacks fail, a PluginAWeek::StateMachine::InvalidTransition - # error will be raised. - def perform!(record, *args) - perform_with_optional_bang(record, true, *args) || raise(PluginAWeek::StateMachine::InvalidTransition, "Cannot transition via :#{event.name} from #{record.send(machine.attribute).inspect} to #{to_state.inspect}") + # Runs the actual transition and any before/after callbacks associated + # with the transition. Additional arguments are passed to the callbacks. + # + # Any errors during validation or saving will be raised. If any +before+ + # callbacks fail, a PluginAWeek::StateMachine::InvalidTransition error + # will be raised. + def perform!(record) + run(record, true) || raise(PluginAWeek::StateMachine::InvalidTransition, "Could not transition via :#{event.name} from #{record.send(machine.attribute).inspect} to #{@options[:to].inspect}") end private - # Performs the transition - def perform_with_optional_bang(record, bang, *args) - state = record.send(machine.attribute) + # Determines whether the given from state matches what was configured + # for this transition + def valid_from_state?(from_state) + find_match(from_state, @options[:from], @options[:except_from]) + end + + # Attempts to find the given value in either a whitelist of values or + # a blacklist of values. The whitelist will always be used first if it + # is specified. If neither lists are specified, then this will always + # find a match. + def find_match(value, whitelist, blacklist) + if whitelist + Array(whitelist).include?(value) + elsif blacklist + !Array(blacklist).include?(value) + else + true + end + end + + # Evaluates a method for conditionally determining whether this + # transition is allowed to be performed on the given record. This is + # copied from ActiveSupport::Calllbacks::Callback since it has not been + # extracted into a separate, reusable method. + def evaluate_method(method, record) + case method + when Symbol + record.send(method) + when String + eval(method, record.instance_eval {binding}) + when Proc, Method + method.call(record) + else + raise ArgumentError, 'Transition conditionals must be a symbol denoting the method to call, a string to be evaluated, or a block to be invoked' + end + end + + # Performs the actual transition, invoking before/after callbacks in the + # process. If either the before callbacks fail or the actual save fails, + # then this transition will fail. + def run(record, bang) + from_state = record.send(machine.attribute) + + # Stop the transition if any before callbacks fail + return false if invoke_callbacks(record, :before, from_state) == false + result = update_state(record, bang) + + # Always invoke after callbacks regardless of whether the update failed + invoke_callbacks(record, :after, from_state) - return false if invoke_before_callbacks(state, record) == false - result = update_state(state, bang, record) - invoke_after_callbacks(state, record) result end - # Updates the record's attribute to the state represented by this transition - # Even if the transition is a loopback, the record will still be saved - def update_state(from_state, bang, record) - record.send("#{machine.attribute}=", to_state) + # Updates the record's attribute to the state represented by this + # transition. Even if the transition is a loopback, the record will + # still be saved. + def update_state(record, bang) + record.send("#{machine.attribute}=", @options[:to]) bang ? record.save! : record.save end - def invoke_before_callbacks(from_state, record) - # Start leaving the last state and start entering the next state - if loopback?(from_state) - invoke_callbacks(:before_loopback, from_state, record) - else - invoke_callbacks(:before_exit, from_state, record) && invoke_callbacks(:before_enter, to_state, record) - end - end - - def invoke_after_callbacks(from_state, record) - # Start leaving the last state and start entering the next state - if loopback?(from_state) - invoke_callbacks(:after_loopback, from_state, record) + # Runs the callbacks of the given type for this transition + def invoke_callbacks(record, type, from_state) + # Transition callback + kind = "#{type}_transition_#{machine.attribute}" + + result = if record.class.respond_to?("#{kind}_callback_chain") + record.class.send("#{kind}_callback_chain").all? do |callback| + # false indicates that the remaining callbacks should be skipped + !matches?(from_state, callback.options) || callback.call(record) != false + end else - invoke_callbacks(:after_exit, from_state, record) - invoke_callbacks(:after_enter, to_state, record) + # No callbacks defined for attribute: always successful + true end - true + # Notify observers + notify("#{type}_#{event.name}", record, from_state, @options[:to]) + notify("#{type}_transition", record, machine.attribute, event.name, from_state, @options[:to]) + + result end - def invoke_callbacks(type, state, record) - kind = "#{type}_#{machine.attribute}_#{state}" - if record.class.respond_to?("#{kind}_callback_chain") - record.run_callbacks(kind) {|result, record| result == false} - else - true + # Sends a notification to all observers of the record's class + def notify(method, record, *args) + # This technique of notifying observers is much less than ideal. + # Unfortunately, ActiveRecord only allows the record to be passed into + # Observer methods. As a result, it's not possible to pass in the + # from state, to state, and other contextual information for the + # transition. + record.class.class_eval do + @observer_peers.dup.each do |observer| + observer.send(method, record, *args) if observer.respond_to?(method) + end if defined?(@observer_peers) end end end diff --git a/test/app_root/app/models/auto_shop.rb b/test/app_root/app/models/auto_shop.rb index b9ca429d..403d3ce0 100644 --- a/test/app_root/app/models/auto_shop.rb +++ b/test/app_root/app/models/auto_shop.rb @@ -1,7 +1,7 @@ class AutoShop < ActiveRecord::Base state_machine :state, :initial => 'available' do - after_exit 'available', :increment_customers - after_exit 'busy', :decrement_customers + after_transition :from => 'available', :do => :increment_customers + after_transition :from => 'busy', :do => :decrement_customers event :tow_vehicle do transition :to => 'busy', :from => 'available' diff --git a/test/app_root/app/models/switch_observer.rb b/test/app_root/app/models/switch_observer.rb new file mode 100644 index 00000000..6ad89b43 --- /dev/null +++ b/test/app_root/app/models/switch_observer.rb @@ -0,0 +1,20 @@ +class SwitchObserver < ActiveRecord::Observer + cattr_accessor :notifications + self.notifications = [] + + def before_turn_on(switch, from_state, to_state) + notifications << ['before_turn_on', switch, from_state, to_state] + end + + def after_turn_on(switch, from_state, to_state) + notifications << ['after_turn_on', switch, from_state, to_state] + end + + def before_transition(switch, attribute, event, from_state, to_state) + notifications << ['before_transition', switch, attribute, event, from_state, to_state] + end + + def after_transition(switch, attribute, event, from_state, to_state) + notifications << ['after_transition', switch, attribute, event, from_state, to_state] + end +end diff --git a/test/app_root/app/models/vehicle.rb b/test/app_root/app/models/vehicle.rb index 469d870b..a0208c95 100644 --- a/test/app_root/app/models/vehicle.rb +++ b/test/app_root/app/models/vehicle.rb @@ -5,15 +5,17 @@ class Vehicle < ActiveRecord::Base attr_accessor :force_idle attr_accessor :callbacks - # Defines the state machine for the state of the vehicle + # Defines the state machine for the state of the vehicled state_machine :state, :initial => Proc.new {|vehicle| vehicle.force_idle ? 'idling' : 'parked'} do - before_exit 'parked', :put_on_seatbelt - after_enter 'parked', Proc.new {|vehicle| vehicle.update_attribute(:seatbelt_on, false)} - before_enter 'stalled', :increase_insurance_premium + before_transition :from => 'parked', :do => :put_on_seatbelt + before_transition :to => 'stalled', :do => :increase_insurance_premium + after_transition :to => 'parked', :do => lambda {|vehicle| vehicle.update_attribute(:seatbelt_on, false)} + after_transition :on => 'crash', :do => :tow! + after_transition :on => 'repair', :do => :fix! # Callback tracking for initial state callbacks - after_enter 'parked', Proc.new {|vehicle| (vehicle.callbacks ||= []) << 'before_enter_parked'} - before_enter 'idling', Proc.new {|vehicle| (vehicle.callbacks ||= []) << 'before_enter_idling'} + after_transition :to => 'parked', :do => lambda {|vehicle| (vehicle.callbacks ||= []) << 'before_enter_parked'} + before_transition :to => 'idling', :do => lambda {|vehicle| (vehicle.callbacks ||= []) << 'before_enter_idling'} event :park do transition :to => 'parked', :from => %w(idling first_gear) @@ -39,11 +41,11 @@ class Vehicle < ActiveRecord::Base transition :to => 'first_gear', :from => 'second_gear' end - event :crash, :after => :tow! do - transition :to => 'stalled', :from => %w(first_gear second_gear third_gear), :if => Proc.new {|vehicle| vehicle.auto_shop.available?} + event :crash do + transition :to => 'stalled', :from => %w(first_gear second_gear third_gear), :if => lambda {|vehicle| vehicle.auto_shop.available?} end - event :repair, :after => :fix! do + event :repair do transition :to => 'parked', :from => 'stalled', :if => :auto_shop_busy? end end diff --git a/test/app_root/config/environment.rb b/test/app_root/config/environment.rb new file mode 100644 index 00000000..b1b1f0d6 --- /dev/null +++ b/test/app_root/config/environment.rb @@ -0,0 +1,7 @@ +require 'config/boot' + +Rails::Initializer.run do |config| + config.cache_classes = false + config.whiny_nils = true + config.active_record.observers = :switch_observer +end diff --git a/test/factory.rb b/test/factory.rb index f33105d1..be49652b 100644 --- a/test/factory.rb +++ b/test/factory.rb @@ -56,6 +56,12 @@ def create_record(model, *args) ) end + build ToggleSwitch do |attributes| + attributes.reverse_merge!( + :state => 'off' + ) + end + build Vehicle do |attributes| attributes[:highway] = create_highway unless attributes.include?(:highway) attributes[:auto_shop] = create_auto_shop unless attributes.include?(:auto_shop) diff --git a/test/functional/state_machine_test.rb b/test/functional/state_machine_test.rb index 760ab453..12251aee 100644 --- a/test/functional/state_machine_test.rb +++ b/test/functional/state_machine_test.rb @@ -19,10 +19,18 @@ def test_should_be_in_parked_state assert_equal 'parked', @vehicle.state end + def test_should_not_be_able_to_park + assert !@vehicle.can_park? + end + def test_should_not_allow_park assert !@vehicle.park end + def test_should_be_able_to_ignite + assert @vehicle.can_ignite? + end + def test_should_allow_ignite assert @vehicle.ignite assert_equal 'idling', @vehicle.state @@ -52,21 +60,6 @@ def test_should_not_allow_crash def test_should_not_allow_repair assert !@vehicle.repair end - - def test_should_invoke_initial_state_and_event_callbacks - @vehicle.ignite - assert_equal %w(before_enter_parked before_enter_idling), @vehicle.callbacks - end -end - -class VehicleAfterBeingCreatedTest < Test::Unit::TestCase - def setup - @vehicle = create_vehicle - end - - def test_should_invoke_initial_state_callbacks - assert_equal %w(before_enter_parked), @vehicle.callbacks - end end class VehicleParkedTest < Test::Unit::TestCase diff --git a/test/unit/event_test.rb b/test/unit/event_test.rb index c64f685f..e8f5ff2e 100644 --- a/test/unit/event_test.rb +++ b/test/unit/event_test.rb @@ -2,8 +2,10 @@ class EventTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') + + @switch = new_switch end def test_should_have_a_machine @@ -19,43 +21,23 @@ def test_should_not_have_any_transitions end def test_should_define_an_event_action_on_the_owner_class - switch = new_switch - assert switch.respond_to?(:turn_on) + assert @switch.respond_to?(:turn_on) end def test_should_define_an_event_bang_action_on_the_owner_class - switch = new_switch - assert switch.respond_to?(:turn_on!) - end - - def test_should_define_transition_callbacks - assert Switch.respond_to?(:transition_on_turn_on) - end - - def test_should_define_transition_bang_callbacks - assert Switch.respond_to?(:transition_bang_on_turn_on) - end - - def test_should_define_before_event_callbacks - assert Switch.respond_to?(:before_turn_on) + assert @switch.respond_to?(:turn_on!) end - def test_should_define_after_event_callbacks - assert Switch.respond_to?(:after_turn_on) - end -end - -class EventWithInvalidOptionsTest < Test::Unit::TestCase - def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + def test_should_define_an_event_predicate_on_the_owner_class + assert @switch.respond_to?(:can_turn_on?) end - def test_should_raise_exception + def test_should_raise_exception_if_invalid_option_specified assert_raise(ArgumentError) {PluginAWeek::StateMachine::Event.new(@machine, 'turn_on', :invalid => true)} end end -class EventWithTransitionsTest < Test::Unit::TestCase +class EventDefiningTransitionsTest < Test::Unit::TestCase def setup @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @@ -73,7 +55,7 @@ def test_should_not_raise_exception_if_from_option_not_specified assert_nothing_raised {@event.transition(:to => 'on')} end - def test_should_allow_transitioning_without_a_state + def test_should_allow_transitioning_without_a_from_state assert @event.transition(:to => 'on') end @@ -86,25 +68,34 @@ def test_should_allow_transitioning_from_multiple_states end def test_should_have_transitions - @event.transition(:to => 'on') - assert @event.transitions.any? + transition = @event.transition(:to => 'on') + assert_equal [transition], @event.transitions + end +end + +class EventAfterBeingCopiedTest < Test::Unit::TestCase + def setup + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') + @copied_event = @event.dup end - def teardown - Switch.class_eval do - @transition_on_turn_on_callbacks = nil - @transition_bang_on_turn_on_callbacks = nil - end + def test_should_not_have_the_same_collection_of_transitions + assert_not_same @copied_event.transitions, @event.transitions end end -class EventAfterBeingFiredWithNoTransitionsTest < Test::Unit::TestCase +class EventWithoutTransitionsTest < Test::Unit::TestCase def setup @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @switch = create_switch(:state => 'off') end + def test_should_not_be_able_to_fire + assert !@event.can_fire?(@switch) + end + def test_should_not_fire assert !@event.fire(@switch) end @@ -119,7 +110,7 @@ def test_should_raise_exception_during_fire! end end -class EventAfterBeingFiredWithTransitionsTest < Test::Unit::TestCase +class EventWithTransitionsTest < Test::Unit::TestCase def setup @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @@ -127,6 +118,10 @@ def setup @switch = create_switch(:state => 'off') end + def test_should_not_be_able_to_fire_if_no_transitions_are_matched + assert !@event.can_fire?(@switch) + end + def test_should_not_fire_if_no_transitions_are_matched assert !@event.fire(@switch) assert_equal 'off', @switch.state @@ -137,7 +132,12 @@ def test_should_raise_exception_if_no_transitions_are_matched_during_fire! assert_equal 'off', @switch.state end - def test_should_fire_if_transition_with_no_from_state_is_matched + def test_should_be_able_to_fire_if_transition_is_matched + @event.transition :to => 'on' + assert @event.can_fire?(@switch) + end + + def test_should_fire_if_transition_is_matched @event.transition :to => 'on' assert @event.fire(@switch) assert_equal 'on', @switch.state @@ -190,81 +190,6 @@ def test_should_not_raise_exception_if_transition_is_matched_during_fire! assert @event.fire!(@switch) assert_equal 'on', @switch.state end - - def teardown - Switch.class_eval do - @transition_on_turn_on_callbacks = nil - @transition_bang_on_turn_on_callbacks = nil - end - end -end - -class EventAfterBeingFiredWithConditionalTransitionsTest < Test::Unit::TestCase - def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') - @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') - @switch = create_switch(:state => 'off') - end - - def test_should_fire_if_if_is_true - @event.transition :to => 'on', :from => 'off', :if => Proc.new {true} - assert @event.fire(@switch) - end - - def test_should_not_fire_if_if_is_false - @event.transition :to => 'on', :from => 'off', :if => Proc.new {false} - assert !@event.fire(@switch) - end - - def test_should_fire_if_unless_is_false - @event.transition :to => 'on', :from => 'off', :unless => Proc.new {false} - assert @event.fire(@switch) - end - - def test_should_not_fire_if_unless_is_true - @event.transition :to => 'on', :from => 'off', :unless => Proc.new {true} - assert !@event.fire(@switch) - end - - def test_should_pass_in_record_as_argument - @event.transition :to => 'on', :from => 'off', :if => Proc.new {|record, value| !record.nil?} - assert @event.fire(@switch) - end - - def test_should_pass_in_value_as_argument - @event.transition :to => 'on', :from => 'off', :if => Proc.new {|record, value| value == 1} - assert @event.fire(@switch, 1) - end - - def test_should_fire_if_method_evaluates_to_true - @switch.data = true - @event.transition :to => 'on', :from => 'off', :if => :data - assert @event.fire(@switch) - end - - def test_should_not_fire_if_method_evaluates_to_false - @switch.data = false - @event.transition :to => 'on', :from => 'off', :if => :data - assert !@event.fire(@switch) - end - - def test_should_raise_exception_if_no_transitions_are_matched - assert_raise(PluginAWeek::StateMachine::InvalidTransition) {@event.fire!(@switch, 1)} - assert_equal 'off', @switch.state - end - - def test_should_not_raise_exception_if_transition_is_matched - @event.transition :to => 'on', :from => 'off', :if => Proc.new {true} - assert @event.fire!(@switch) - assert_equal 'on', @switch.state - end - - def teardown - Switch.class_eval do - @transition_on_turn_on_callbacks = nil - @transition_bang_on_turn_on_callbacks = nil - end - end end class EventWithinTransactionTest < Test::Unit::TestCase @@ -274,11 +199,11 @@ def setup @event.transition :to => 'on', :from => 'off' @switch = create_switch(:state => 'off') - Switch.define_callbacks :before_exit_state_off + Switch.define_callbacks :before_transition_state end def test_should_save_all_records_within_transaction_if_performed - Switch.before_exit_state_off Proc.new {|record| Switch.create(:state => 'pending'); true} + Switch.before_transition_state lambda {|record| Switch.create(:state => 'pending'); true}, :from => 'off' assert @event.fire(@switch) assert_equal 'on', @switch.state assert_equal 'pending', Switch.find(:all).last.state @@ -286,7 +211,7 @@ def test_should_save_all_records_within_transaction_if_performed uses_transaction :test_should_rollback_all_records_within_transaction_if_not_performed def test_should_rollback_all_records_within_transaction_if_not_performed - Switch.before_exit_state_off Proc.new {|record| Switch.create(:state => 'pending'); false} + Switch.before_transition_state lambda {|record| Switch.create(:state => 'pending'); false}, :from => 'off' assert !@event.fire(@switch) assert_equal 1, Switch.count ensure @@ -295,7 +220,7 @@ def test_should_rollback_all_records_within_transaction_if_not_performed uses_transaction :test_should_rollback_all_records_within_transaction_if_not_performed! def test_should_rollback_all_records_within_transaction_if_not_performed! - Switch.before_exit_state_off Proc.new {|record| Switch.create(:state => 'pending'); false} + Switch.before_transition_state lambda {|record| Switch.create(:state => 'pending'); false}, :from => 'off' assert_raise(PluginAWeek::StateMachine::InvalidTransition) {@event.fire!(@switch)} assert_equal 1, Switch.count ensure @@ -304,77 +229,7 @@ def test_should_rollback_all_records_within_transaction_if_not_performed! def teardown Switch.class_eval do - @transition_on_turn_on_callbacks = nil - @transition_bang_on_turn_on_callbacks = nil - @before_exit_state_off_callbacks = nil - end - end -end - -class EventWithCallbacksTest < Test::Unit::TestCase - def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') - @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') - @event.transition :from => 'off', :to => 'on' - @record = create_switch(:state => 'off') - - Switch.define_callbacks :before_turn_on, :after_turn_on - end - - def test_should_not_perform_if_before_callback_fails - Switch.before_turn_on Proc.new {|record| false} - Switch.after_turn_on Proc.new {|record| record.callbacks << 'after'; true} - - assert !@event.fire(@record) - assert_equal [], @record.callbacks - end - - def test_should_raise_exception_if_before_callback_fails_during_perform! - Switch.before_turn_on Proc.new {|record| false} - Switch.after_turn_on Proc.new {|record| record.callbacks << 'after'; true} - - assert_raise(PluginAWeek::StateMachine::InvalidTransition) {@event.fire!(@record)} - assert_equal [], @record.callbacks - end - - def test_should_perform_if_after_callback_fails - Switch.before_turn_on Proc.new {|record| record.callbacks << 'before'; true} - Switch.after_turn_on Proc.new {|record| false} - - assert @event.fire(@record) - assert_equal %w(before), @record.callbacks - end - - def test_should_not_raise_exception_if_after_callback_fails_during_perform! - Switch.before_turn_on Proc.new {|record| record.callbacks << 'before'; true} - Switch.after_turn_on Proc.new {|record| false} - - assert @event.fire!(@record) - assert_equal %w(before), @record.callbacks - end - - def test_should_perform_if_all_callbacks_are_successful - Switch.before_turn_on Proc.new {|record| record.callbacks << 'before'; true} - Switch.after_turn_on Proc.new {|record| record.callbacks << 'after'; true} - - assert @event.fire(@record) - assert_equal %w(before after), @record.callbacks - end - - def test_should_pass_additional_arguments_to_callbacks - Switch.before_turn_on Proc.new {|record, value| record.callbacks << "before-#{value}"; true} - Switch.after_turn_on Proc.new {|record, value| record.callbacks << "after-#{value}"; true} - - assert @event.fire(@record, 'light') - assert_equal %w(before-light after-light), @record.callbacks - end - - def teardown - Switch.class_eval do - @before_turn_on_callbacks = nil - @after_turn_on_callbacks = nil - @transition_on_turn_on_callbacks = nil - @transition_bang_on_turn_on_callbacks = nil + @before_transition_state_callbacks = nil end end end diff --git a/test/unit/machine_test.rb b/test/unit/machine_test.rb index 0bb21256..3e4bb40c 100644 --- a/test/unit/machine_test.rb +++ b/test/unit/machine_test.rb @@ -26,12 +26,6 @@ def test_should_not_have_any_states end end -class MachineWithInvalidOptionsTest < Test::Unit::TestCase - def test_should_throw_an_exception - assert_raise(ArgumentError) {PluginAWeek::StateMachine::Machine.new(Switch, 'state', :invalid => true)} - end -end - class MachineWithInitialStateTest < Test::Unit::TestCase def setup @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') @@ -40,15 +34,11 @@ def setup def test_should_have_an_initial_state assert_equal 'off', @machine.initial_state(new_switch) end - - def test_should_have_an_initial_state_without_processing - assert_equal 'off', @machine.initial_state_without_processing - end end class MachineWithDynamicInitialStateTest < Test::Unit::TestCase def setup - @initial_state = Proc.new {|switch| switch.initial_state} + @initial_state = lambda {|switch| switch.initial_state} @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => @initial_state) end @@ -56,10 +46,6 @@ def test_should_use_the_record_for_determining_the_initial_state assert_equal 'off', @machine.initial_state(new_switch(:initial_state => 'off')) assert_equal 'on', @machine.initial_state(new_switch(:initial_state => 'on')) end - - def test_should_have_an_initial_state_without_processing - assert_equal @initial_state, @machine.initial_state_without_processing - end end class MachineTest < Test::Unit::TestCase @@ -80,6 +66,74 @@ def test_should_define_a_pluralized_named_scope_for_the_attribute assert_equal [on, off], Switch.with_states('on', 'off') end + + def test_should_raise_exception_if_invalid_option_specified + assert_raise(ArgumentError) {PluginAWeek::StateMachine::Machine.new(Switch, 'state', :invalid => true)} + end + + def test_should_symbolize_attribute + machine = PluginAWeek::StateMachine::Machine.new(Switch, :state) + assert_equal 'state', machine.attribute + end +end + +class MachineAfterBeingCopiedTest < Test::Unit::TestCase + def setup + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') + @machine.event(:turn_on) {} + + @copied_machine = @machine.dup + end + + def test_should_not_have_the_same_collection_of_states + assert_not_same @copied_machine.states, @machine.states + end + + def test_should_not_have_the_same_collection_of_events + assert_not_same @copied_machine.events, @machine.events + end + + def test_should_copy_each_event + assert_not_same @copied_machine.events['turn_on'], @machine.events['turn_on'] + end + + def test_should_update_machine_for_each_event + assert_equal @copied_machine, @copied_machine.events['turn_on'].machine + end + + def test_should_not_update_machine_for_original_event + assert_equal @machine, @machine.events['turn_on'].machine + end +end + +class MachineAfterChangingContextTest < Test::Unit::TestCase + def setup + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') + end + + def test_should_create_copy_of_machine + new_machine = @machine.within_context(ToggleSwitch) + assert_not_same @machine, new_machine + end + + def test_should_update_owner_clas + new_machine = @machine.within_context(ToggleSwitch) + assert_equal ToggleSwitch, new_machine.owner_class + end + + def test_should_update_initial_state + new_machine = @machine.within_context(ToggleSwitch, :initial => 'off') + assert_equal 'off', new_machine.initial_state(new_switch) + end + + def test_should_not_update_initial_state_if_not_provided + new_machine = @machine.within_context(ToggleSwitch) + assert_nil new_machine.initial_state(new_switch) + end + + def test_raise_exception_if_invalid_option_specified + assert_raise(ArgumentError) {@machine.within_context(ToggleSwitch, :invalid => true)} + end end class MachineWithConflictingNamedScopesTest < Test::Unit::TestCase @@ -131,6 +185,18 @@ def test_should_have_events end end +class MachineWithExistingEventTest < Test::Unit::TestCase + def setup + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') + @event = @machine.event(:turn_on) {} + @same_event = @machine.event(:turn_on) {} + end + + def test_should_not_create_new_event + assert_same @event, @same_event + end +end + class MachineWithEventsAndTransitionsTest < Test::Unit::TestCase def setup @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @@ -144,19 +210,22 @@ def test_should_have_events assert_equal %w(turn_on), @machine.events.keys end - def test_should_have_states - assert_equal %w(on off error unknown), @machine.states + def test_should_track_states_defined_in_event_transitions + assert_equal %w(error off on unknown), @machine.states + end + + def test_should_not_duplicate_states_defined_in_multiple_event_transitions + @machine.event :turn_off do + transition :to => 'off', :from => 'on' + end + + assert_equal %w(error off on unknown), @machine.states end end -class MachineWithStateCallbacksTest < Test::Unit::TestCase +class MachineWithTransitionCallbacksTest < Test::Unit::TestCase def setup @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') - @machine.before_exit 'off', Proc.new {|switch, value| switch.callbacks << 'before_exit'} - @machine.before_enter 'on', Proc.new {|switch, value| switch.callbacks << 'before_enter'} - @machine.after_exit 'off', Proc.new {|switch, value| switch.callbacks << 'after_exit'} - @machine.after_enter 'on', Proc.new {|switch, value| switch.callbacks << 'after_enter'} - @event = @machine.event :turn_on do transition :to => 'on', :from => 'off' end @@ -164,19 +233,74 @@ def setup @switch = create_switch(:state => 'off') end + def test_should_raise_exception_if_invalid_option_specified + assert_raise(ArgumentError) {@machine.before_transition :invalid => true} + end + + def test_should_raise_exception_if_do_option_not_specified + assert_raise(ArgumentError) {@machine.before_transition :to => 'on'} + end + def test_should_invoke_callbacks_during_transition + @machine.before_transition lambda {|switch| switch.callbacks << 'before'} + @machine.after_transition lambda {|switch| switch.callbacks << 'after'} + + @event.fire(@switch) + assert_equal %w(before after), @switch.callbacks + end + + def test_should_support_from_query + @machine.before_transition :from => 'off', :do => lambda {|switch| switch.callbacks << 'off'} + @machine.before_transition :from => 'on', :do => lambda {|switch| switch.callbacks << 'on'} + + @event.fire(@switch) + assert_equal %w(off), @switch.callbacks + end + + def test_should_support_except_from_query + @machine.before_transition :except_from => 'off', :do => lambda {|switch| switch.callbacks << 'off'} + @machine.before_transition :except_from => 'on', :do => lambda {|switch| switch.callbacks << 'on'} + + @event.fire(@switch) + assert_equal %w(on), @switch.callbacks + end + + def test_should_support_to_query + @machine.before_transition :to => 'off', :do => lambda {|switch| switch.callbacks << 'off'} + @machine.before_transition :to => 'on', :do => lambda {|switch| switch.callbacks << 'on'} + + @event.fire(@switch) + assert_equal %w(on), @switch.callbacks + end + + def test_should_support_except_to_query + @machine.before_transition :except_to => 'off', :do => lambda {|switch| switch.callbacks << 'off'} + @machine.before_transition :except_to => 'on', :do => lambda {|switch| switch.callbacks << 'on'} + + @event.fire(@switch) + assert_equal %w(off), @switch.callbacks + end + + def test_should_support_on_query + @machine.before_transition :on => 'turn_off', :do => lambda {|switch| switch.callbacks << 'turn_off'} + @machine.before_transition :on => 'turn_on', :do => lambda {|switch| switch.callbacks << 'turn_on'} + + @event.fire(@switch) + assert_equal %w(turn_on), @switch.callbacks + end + + def test_should_support_except_on_query + @machine.before_transition :except_on => 'turn_off', :do => lambda {|switch| switch.callbacks << 'turn_off'} + @machine.before_transition :except_on => 'turn_on', :do => lambda {|switch| switch.callbacks << 'turn_on'} + @event.fire(@switch) - assert_equal %w(before_exit before_enter after_exit after_enter), @switch.callbacks + assert_equal %w(turn_off), @switch.callbacks end def teardown Switch.class_eval do - @transition_on_turn_on_callbacks = nil - @transition_bang_on_turn_on_callbacks = nil - @before_exit_state_off_callbacks = nil - @before_enter_state_on_callbacks = nil - @after_exit_state_off_callbacks = nil - @after_enter_state_on_callbacks = nil + @before_transition_state_callbacks = nil + @after_transition_state_callbacks = nil end end end diff --git a/test/unit/state_machine_test.rb b/test/unit/state_machine_test.rb index 7c610d7c..94bfb328 100644 --- a/test/unit/state_machine_test.rb +++ b/test/unit/state_machine_test.rb @@ -72,45 +72,55 @@ def teardown end end -class StateMachineAfterCreatedTest < Test::Unit::TestCase +class StateMachineWithSubclassTest < Test::Unit::TestCase def setup - machine = Switch.state_machine(:state, :initial => 'off') + @machine = Switch.state_machine(:state, :initial => 'on') do + event :turn_on do + transition :to => 'on', :from => 'off' + end + end - machine.before_exit 'off', Proc.new {|switch, value| switch.callbacks << 'before_exit'} - machine.before_enter 'off', Proc.new {|switch, value| switch.callbacks << 'before_enter'} - machine.after_exit 'off', Proc.new {|switch, value| switch.callbacks << 'after_exit'} - machine.after_enter 'off', Proc.new {|switch, value| switch.callbacks << 'after_enter'} + # Need to add this since the state machine isn't defined directly within the + # class + ToggleSwitch.write_inheritable_attribute :state_machines, {'state' => @machine} - @switch = create_switch + @new_machine = ToggleSwitch.state_machine(:state, :initial => 'off') do + event :turn_on do + transition :to => 'off', :from => 'on' + end + + event :replace do + transition :to => 'under_repair', :from => 'off' + end + end end - def test_should_invoke_after_enter_callbacks_for_initial_state - assert_equal %w(after_enter), @switch.callbacks + def test_should_not_have_the_same_machine_as_the_superclass + assert_not_same @machine, @new_machine end - def teardown - Switch.write_inheritable_attribute(:state_machines, {}) - - Switch.class_eval do - @transition_on_turn_on_callbacks = nil - @transition_bang_on_turn_on_callbacks = nil - @before_exit_state_off_callbacks = nil - @before_enter_state_on_callbacks = nil - @after_exit_state_off_callbacks = nil - @after_enter_state_on_callbacks = nil - end + def test_should_use_new_initial_state + assert_equal 'off', @new_machine.initial_state(new_switch) end -end - -class StateMachineWithSubclassTest < Test::Unit::TestCase - def setup - Switch.state_machine(:state, :initial => 'on') - ToggleSwitch.state_machine(:state, :initial => 'off') + + def test_should_not_change_original_initial_state + assert_equal 'on', @machine.initial_state(new_switch) + end + + def test_should_define_new_events_on_subclass + assert new_toggle_switch.respond_to?(:replace) + end + + def test_should_not_define_new_events_on_superclass + assert !new_switch.respond_to?(:replace) + end + + def test_should_define_new_transitions_on_subclass + assert_equal 2, @new_machine.events['turn_on'].transitions.length end - def test_should_be_able_to_override_initial_state - assert_equal 'on', Switch.new.state - assert_equal 'off', ToggleSwitch.new.state + def test_should_not_define_new_transitions_on_superclass + assert_equal 1, @machine.events['turn_on'].transitions.length end def teardown diff --git a/test/unit/transition_test.rb b/test/unit/transition_test.rb index a8fbc7e7..d9f1defa 100644 --- a/test/unit/transition_test.rb +++ b/test/unit/transition_test.rb @@ -2,33 +2,126 @@ class TransitionTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on') end - def test_should_not_have_any_from_states - assert @transition.from_states.empty? + def test_should_have_an_event + assert_not_nil @transition.event end - def test_should_not_be_a_loopback_if_from_state_is_different - assert !@transition.loopback?('off') + def test_should_have_options + assert_not_nil @transition.options end - def test_should_have_a_to_state - assert_equal 'on', @transition.to_state + def test_should_match_any_from_state + assert @transition.matches?('off') + assert @transition.matches?('on') end - def test_should_be_loopback_if_from_state_is_same - assert @transition.loopback?('on') + def test_should_match_empty_query + assert @transition.matches?('off', {}) + end + + def test_should_match_if_from_state_included + assert @transition.matches?('off', :from => 'off') + end + + def test_should_not_match_if_from_state_not_included + assert !@transition.matches?('off', :from => 'on') + end + + def test_should_allow_matching_of_multiple_from_states + assert @transition.matches?('off', :from => %w(on off)) + end + + def test_should_match_if_except_from_state_not_included + assert @transition.matches?('off', :except_from => 'on') + end + + def test_should_not_match_if_except_from_state_included + assert !@transition.matches?('off', :except_from => 'off') + end + + def test_should_allow_matching_of_multiple_except_from_states + assert @transition.matches?('off', :except_from => %w(on maybe)) + end + + def test_should_match_if_to_state_included + assert @transition.matches?('off', :to => 'on') + end + + def test_should_not_match_if_to_state_not_included + assert !@transition.matches?('off', :to => 'off') + end + + def test_should_allow_matching_of_multiple_to_states + assert @transition.matches?('off', :to => %w(on off)) + end + + def test_should_match_if_except_to_state_not_included + assert @transition.matches?('off', :except_to => 'off') + end + + def test_should_not_match_if_except_to_state_included + assert !@transition.matches?('off', :except_to => 'on') + end + + def test_should_allow_matching_of_multiple_except_to_states + assert @transition.matches?('off', :except_to => %w(off maybe)) + end + + def test_should_match_if_on_event_included + assert @transition.matches?('off', :on => 'turn_on') + end + + def test_should_not_match_if_on_event_not_included + assert !@transition.matches?('off', :on => 'turn_off') + end + + def test_should_allow_matching_of_multiple_on_events + assert @transition.matches?('off', :on => %w(turn_off turn_on)) + end + + def test_should_match_if_except_on_event_not_included + assert @transition.matches?('off', :except_on => 'turn_off') + end + + def test_should_not_match_if_except_on_event_included + assert !@transition.matches?('off', :except_on => 'turn_on') + end + + def test_should_allow_matching_of_multiple_except_on_events + assert @transition.matches?('off', :except_on => %w(turn_off not_sure)) + end + + def test_should_match_if_from_state_and_to_state_match + assert @transition.matches?('off', :from => 'off', :to => 'on') + end + + def test_should_not_match_if_from_state_matches_but_not_to_state + assert !@transition.matches?('off', :from => 'off', :to => 'off') + end + + def test_should_not_match_if_to_state_matches_but_not_from_state + assert !@transition.matches?('off', :from => 'on', :to => 'on') + end + + def test_should_match_if_from_state_to_state_and_on_event_match + assert @transition.matches?('off', :from => 'off', :to => 'on', :on => 'turn_on') + end + + def test_should_not_match_if_from_state_and_to_state_match_but_not_on_event + assert !@transition.matches?('off', :from => 'off', :to => 'on', :on => 'turn_off') end def test_should_be_able_to_perform_on_all_states record = new_switch(:state => 'off') - assert @transition.can_perform_on?(record) + assert @transition.can_perform?(record) record = new_switch(:state => 'on') - assert @transition.can_perform_on?(record) + assert @transition.can_perform?(record) end def test_should_perform_for_all_states @@ -76,16 +169,61 @@ def test_should_raise_exception_if_to_option_not_specified end end +class TransitionWithConditionalTest < Test::Unit::TestCase + def setup + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') + @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') + @switch = create_switch(:state => 'off') + end + + def test_should_be_able_to_perform_if_if_is_true + transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :if => lambda {true}) + assert transition.can_perform?(@switch) + end + + def test_should_not_be_able_to_perform_if_if_is_false + transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :if => lambda {false}) + assert !transition.can_perform?(@switch) + end + + def test_should_be_able_to_perform_if_unless_is_false + transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :unless => lambda {false}) + assert transition.can_perform?(@switch) + end + + def test_should_not_be_able_to_perform_if_unless_is_true + transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :unless => lambda {true}) + assert !transition.can_perform?(@switch) + end + + def test_should_pass_in_record_as_argument + transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :if => lambda {|record| !record.nil?}) + assert transition.can_perform?(@switch) + end + + def test_should_be_able_to_perform_if_method_evaluates_to_true + @switch.data = true + transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :if => :data) + assert transition.can_perform?(@switch) + end + + def test_should_not_be_able_to_perform_if_method_evaluates_to_false + @switch.data = false + transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :if => :data) + assert !transition.can_perform?(@switch) + end +end + class TransitionWithLoopbackTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'on') end def test_should_be_able_to_perform record = new_switch(:state => 'on') - assert @transition.can_perform_on?(record) + assert @transition.can_perform?(record) end def test_should_perform_for_valid_from_state @@ -96,23 +234,19 @@ def test_should_perform_for_valid_from_state class TransitionWithFromStateTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'off') end - def test_should_have_a_from_state - assert_equal ['off'], @transition.from_states - end - def test_should_not_be_able_to_perform_if_record_state_is_not_from_state record = new_switch(:state => 'on') - assert !@transition.can_perform_on?(record) + assert !@transition.can_perform?(record) end def test_should_be_able_to_perform_if_record_state_is_from_state record = new_switch(:state => 'off') - assert @transition.can_perform_on?(record) + assert @transition.can_perform?(record) end def test_should_perform_for_valid_from_state @@ -123,26 +257,22 @@ def test_should_perform_for_valid_from_state class TransitionWithMultipleFromStatesTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => %w(off on)) end - def test_should_have_multiple_from_states - assert_equal ['off', 'on'], @transition.from_states - end - def test_should_not_be_able_to_perform_if_record_state_is_not_from_state record = new_switch(:state => 'unknown') - assert !@transition.can_perform_on?(record) + assert !@transition.can_perform?(record) end def test_should_be_able_to_perform_if_record_state_is_any_from_state record = new_switch(:state => 'off') - assert @transition.can_perform_on?(record) + assert @transition.can_perform?(record) record = new_switch(:state => 'on') - assert @transition.can_perform_on?(record) + assert @transition.can_perform?(record) end def test_should_perform_for_any_valid_from_state @@ -156,23 +286,19 @@ def test_should_perform_for_any_valid_from_state class TransitionWithMismatchedFromStatesRequiredTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :except_from => 'on') end - def test_should_have_a_from_state - assert_equal ['on'], @transition.from_states - end - def test_should_be_able_to_perform_if_record_state_is_not_from_state record = new_switch(:state => 'off') - assert @transition.can_perform_on?(record) + assert @transition.can_perform?(record) end def test_should_not_be_able_to_perform_if_record_state_is_from_state record = new_switch(:state => 'on') - assert !@transition.can_perform_on?(record) + assert !@transition.can_perform?(record) end def test_should_perform_for_valid_from_state @@ -182,13 +308,13 @@ def test_should_perform_for_valid_from_state def test_should_not_perform_for_invalid_from_state record = new_switch(:state => 'on') - assert !@transition.can_perform_on?(record) + assert !@transition.can_perform?(record) end end class TransitionAfterBeingPerformedTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'off') @@ -202,13 +328,13 @@ def test_should_update_the_state_to_the_to_state end def test_should_no_longer_be_able_to_perform_on_the_record - assert !@transition.can_perform_on?(@record) + assert !@transition.can_perform?(@record) end end class TransitionWithLoopbackAfterBeingPerformedTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'on') @@ -227,299 +353,281 @@ def test_should_save_the_record end def test_should_still_be_able_to_perform_on_the_record - assert @transition.can_perform_on?(@record) + assert @transition.can_perform?(@record) end end class TransitionWithCallbacksTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'off') @record = create_switch(:state => 'off') - Switch.define_callbacks :before_exit_state_off, :before_enter_state_on, :before_loopback_state_on, :after_exit_state_off, :after_enter_state_on, :after_loopback_state_on + Switch.define_callbacks :before_transition_state, :after_transition_state end - def test_should_not_perform_if_before_exit_callback_fails - Switch.before_exit_state_off Proc.new {|record| false} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} + def test_should_include_record_in_callback + Switch.before_transition_state lambda {|record| record == @record} - assert !@transition.perform(@record) - assert_equal [], @record.callbacks - end - - def test_should_raise_exception_if_before_exit_callback_fails_during_perform! - Switch.before_exit_state_off Proc.new {|record| false} - - assert_raise(PluginAWeek::StateMachine::InvalidTransition) {@transition.perform!(@record)} + assert @transition.perform(@record) end - def test_should_not_perform_if_before_enter_callback_fails - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| false} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} + def test_should_not_perform_if_before_callback_fails + Switch.before_transition_state lambda {|record| false} + Switch.after_transition_state lambda {|record| record.callbacks << 'after'; true} assert !@transition.perform(@record) - assert_equal %w(before_exit), @record.callbacks + assert_equal [], @record.callbacks end - def test_should_raise_exception_if_after_enter_callback_fails_during_perform! - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| false} + def test_should_raise_exception_if_before_callback_fails_during_perform! + Switch.before_transition_state lambda {|record| false} assert_raise(PluginAWeek::StateMachine::InvalidTransition) {@transition.perform!(@record)} end - def test_should_perform_if_after_exit_callback_fails - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| false} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} + def test_should_perform_if_after_callback_fails + Switch.before_transition_state lambda {|record| record.callbacks << 'before'; true} + Switch.after_transition_state lambda {|record| false} assert @transition.perform(@record) - assert_equal %w(before_exit before_enter after_enter), @record.callbacks + assert_equal %w(before), @record.callbacks end - def test_should_not_raise_exception_if_after_exit_callback_fails_during_perform! - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| false} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} + def test_should_not_raise_exception_if_after_callback_fails_during_perform! + Switch.before_transition_state lambda {|record| record.callbacks << 'before'; true} + Switch.after_transition_state lambda {|record| false} assert @transition.perform!(@record) end - def test_should_perform_if_after_enter_callback_fails - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| false} + def test_should_perform_if_all_callbacks_are_successful + Switch.before_transition_state lambda {|record| record.callbacks << 'before'; true} + Switch.after_transition_state lambda {|record| record.callbacks << 'after'; true} assert @transition.perform(@record) - assert_equal %w(before_exit before_enter after_exit), @record.callbacks + assert_equal %w(before after), @record.callbacks end - def test_should_not_raise_exception_if_after_enter_callback_fails_during_perform! - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| false} + def test_should_stop_before_callbacks_if_any_fail + Switch.before_transition_state lambda {|record| false} + Switch.before_transition_state lambda {|record| record.callbacks << 'before_2'; true} - assert @transition.perform!(@record) + assert !@transition.perform(@record) + assert_equal [], @record.callbacks end - def test_should_perform_if_all_callbacks_are_successful - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.before_loopback_state_on Proc.new {|record| record.callbacks << 'before_loopback'; true} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} - Switch.after_loopback_state_on Proc.new {|record| record.callbacks << 'after_loopback'; true} + def test_should_stop_after_callbacks_if_any_fail + Switch.after_transition_state lambda {|record| false} + Switch.after_transition_state lambda {|record| record.callbacks << 'after_2'; true} assert @transition.perform(@record) - assert_equal %w(before_exit before_enter after_exit after_enter), @record.callbacks + assert_equal [], @record.callbacks end - def test_should_stop_before_exit_callbacks_if_any_fail - Switch.before_exit_state_off Proc.new {|record| false} - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} + def teardown + Switch.class_eval do + @before_transition_state_callbacks = nil + @after_transition_state_callbacks = nil + end + end +end + +class TransitionWithCallbackConditionalsTest < Test::Unit::TestCase + def setup + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') + @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') + @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'off') + @record = create_switch(:state => 'off') + @invoked = false - assert !@transition.perform(@record) - assert_equal [], @record.callbacks + Switch.define_callbacks :before_transition_state, :after_transition_state end - def test_should_stop_before_enter_callbacks_if_any_fail - Switch.before_enter_state_on Proc.new {|record| false} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - - assert !@transition.perform(@record) - assert_equal [], @record.callbacks + def test_should_invoke_callback_if_if_is_true + Switch.before_transition_state lambda {|record| @invoked = true}, :if => lambda {true} + @transition.perform(@record) + assert @invoked end - def test_should_stop_after_exit_callbacks_if_any_fail - Switch.after_exit_state_off Proc.new {|record| false} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - - assert @transition.perform(@record) - assert_equal [], @record.callbacks + def test_should_not_invoke_callback_if_if_is_false + Switch.before_transition_state lambda {|record| @invoked = true}, :if => lambda {false} + @transition.perform(@record) + assert !@invoked end - def test_should_stop_after_enter_callbacks_if_any_fail - Switch.after_enter_state_on Proc.new {|record| false} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} - - assert @transition.perform(@record) - assert_equal [], @record.callbacks + def test_should_invoke_callback_if_unless_is_false + Switch.before_transition_state lambda {|record| @invoked = true}, :unless => lambda {false} + @transition.perform(@record) + assert @invoked + end + + def test_should_not_invoke_callback_if_unless_is_true + Switch.before_transition_state lambda {|record| @invoked = true}, :unless => lambda {true} + @transition.perform(@record) + assert !@invoked end def teardown Switch.class_eval do - @before_exit_state_off_callbacks = nil - @before_enter_state_on_callbacks = nil - @before_loopback_state_on_callbacks = nil - @after_exit_state_off_callbacks = nil - @after_enter_state_on_callbacks = nil - @after_loopback_state_on_callbacks = nil + @before_transition_state_callbacks = nil + @after_transition_state_callbacks = nil end end end -class TransitionWithoutFromStateAndCallbacksTest < Test::Unit::TestCase +class TransitionWithCallbackQueryTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') - @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on') + @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'off') @record = create_switch(:state => 'off') - Switch.define_callbacks :before_exit_state_off, :before_enter_state_on, :after_exit_state_off, :after_enter_state_on + Switch.define_callbacks :before_transition_state, :after_transition_state end - def test_should_not_perform_if_before_exit_callback_fails - Switch.before_exit_state_off Proc.new {|record| false} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} - - assert !@transition.perform(@record) - assert_equal [], @record.callbacks + def test_should_invoke_callback_if_from_state_included + Switch.before_transition_state lambda {|record| @invoked = true}, :from => 'off' + @transition.perform(@record) + assert @invoked end - def test_should_not_perform_if_before_enter_callback_fails - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| false} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} - - assert !@transition.perform(@record) - assert_equal %w(before_exit), @record.callbacks + def test_should_not_invoke_callback_if_from_state_not_included + Switch.before_transition_state lambda {|record| @invoked = true}, :from => 'on' + @transition.perform(@record) + assert !@invoked end - def test_should_perform_if_after_exit_callback_fails - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| false} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} - - assert @transition.perform(@record) - assert_equal %w(before_exit before_enter after_enter), @record.callbacks + def test_should_invoke_callback_if_except_from_state_not_included + Switch.before_transition_state lambda {|record| @invoked = true}, :except_from => 'on' + @transition.perform(@record) + assert @invoked end - def test_should_perform_if_after_enter_callback_fails - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| false} - - assert @transition.perform(@record) - assert_equal %w(before_exit before_enter after_exit), @record.callbacks + def test_should_not_invoke_callback_if_except_from_state_included + Switch.before_transition_state lambda {|record| @invoked = true}, :except_from => 'off' + @transition.perform(@record) + assert !@invoked end - def test_should_perform_if_all_callbacks_are_successful - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} - - assert @transition.perform(@record) - assert_equal %w(before_exit before_enter after_exit after_enter), @record.callbacks + def test_should_invoke_callback_if_to_state_included + Switch.before_transition_state lambda {|record| @invoked = true}, :to => 'on' + @transition.perform(@record) + assert @invoked end - def test_should_stop_before_exit_callbacks_if_any_fail - Switch.before_exit_state_off Proc.new {|record| false} - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - - assert !@transition.perform(@record) - assert_equal [], @record.callbacks + def test_should_not_invoke_callback_if_to_state_not_included + Switch.before_transition_state lambda {|record| @invoked = true}, :to => 'off' + @transition.perform(@record) + assert !@invoked end - def test_should_stop_before_enter_callbacks_if_any_fail - Switch.before_enter_state_on Proc.new {|record| false} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - - assert !@transition.perform(@record) - assert_equal [], @record.callbacks + def test_should_invoke_callback_if_except_to_state_not_included + Switch.before_transition_state lambda {|record| @invoked = true}, :except_to => 'off' + @transition.perform(@record) + assert @invoked end - def test_should_stop_after_exit_callbacks_if_any_fail - Switch.after_exit_state_off Proc.new {|record| false} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - - assert @transition.perform(@record) - assert_equal [], @record.callbacks + def test_should_not_invoke_callback_if_except_to_state_included + Switch.before_transition_state lambda {|record| @invoked = true}, :except_to => 'on' + @transition.perform(@record) + assert !@invoked end - def test_should_stop_after_enter_callbacks_if_any_fail - Switch.after_enter_state_on Proc.new {|record| false} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} - - assert @transition.perform(@record) - assert_equal [], @record.callbacks + def test_should_invoke_callback_if_on_event_included + Switch.before_transition_state lambda {|record| @invoked = true}, :on => 'turn_on' + @transition.perform(@record) + assert @invoked + end + + def test_should_not_invoke_callback_if_on_event_not_included + Switch.before_transition_state lambda {|record| @invoked = true}, :on => 'turn_off' + @transition.perform(@record) + assert !@invoked + end + + def test_should_invoke_callback_if_except_on_event_not_included + Switch.before_transition_state lambda {|record| @invoked = true}, :except_on => 'turn_off' + @transition.perform(@record) + assert @invoked + end + + def test_should_not_invoke_callback_if_except_on_event_included + Switch.before_transition_state lambda {|record| @invoked = true}, :except_on => 'turn_on' + @transition.perform(@record) + assert !@invoked + end + + def test_should_skip_callbacks_that_do_not_match + Switch.before_transition_state lambda {|record| false}, :from => 'on' + Switch.before_transition_state lambda {|record| @invoked = true}, :from => 'off' + @transition.perform(@record) + assert @invoked end def teardown Switch.class_eval do - @before_exit_state_off_callbacks = nil - @before_enter_state_on_callbacks = nil - @after_exit_state_off_callbacks = nil - @after_enter_state_on_callbacks = nil + @before_transition_state_callbacks = nil + @after_transition_state_callbacks = nil end end end -class TransitionWithLoopbackAndCallbacksTest < Test::Unit::TestCase +class TransitionWithObserversTest < Test::Unit::TestCase def setup - @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state', :initial => 'off') + @machine = PluginAWeek::StateMachine::Machine.new(Switch, 'state') @event = PluginAWeek::StateMachine::Event.new(@machine, 'turn_on') - @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'on') - @record = create_switch(:state => 'on') - - Switch.define_callbacks :before_exit_state_off, :before_enter_state_on, :before_loopback_state_on, :after_exit_state_off, :after_enter_state_on, :after_loopback_state_on - Switch.before_exit_state_off Proc.new {|record| record.callbacks << 'before_exit'; true} - Switch.before_enter_state_on Proc.new {|record| record.callbacks << 'before_enter'; true} - Switch.before_loopback_state_on Proc.new {|record| record.callbacks << 'before_loopback'; true} - Switch.after_exit_state_off Proc.new {|record| record.callbacks << 'after_exit'; true} - Switch.after_enter_state_on Proc.new {|record| record.callbacks << 'after_enter'; true} - Switch.after_loopback_state_on Proc.new {|record| record.callbacks << 'after_loopback'; true} + @transition = PluginAWeek::StateMachine::Transition.new(@event, :to => 'on', :from => 'off') + @record = create_switch(:state => 'off') - assert @transition.perform(@record) - end - - def test_should_not_run_before_exit_callbacks - assert !@record.callbacks.include?('before_exit') - end - - def test_should_not_run_before_enter_callbacks - assert !@record.callbacks.include?('before_enter') + Switch.define_callbacks :before_transition_state, :after_transition_state + SwitchObserver.notifications = [] end - def test_should_run_before_loopback_callbacks - assert @record.callbacks.include?('before_loopback') - end - - def test_should_not_run_after_exit_callbacks - assert !@record.callbacks.include?('after_exit') + def test_should_notify_all_callbacks_if_successful + @transition.perform(@record) + + expected = [ + ['before_turn_on', @record, 'off', 'on'], + ['before_transition', @record, 'state', 'turn_on', 'off', 'on'], + ['after_turn_on', @record, 'off', 'on'], + ['after_transition', @record, 'state', 'turn_on', 'off', 'on'] + ] + + assert_equal expected, SwitchObserver.notifications end - def test_should_not_run_after_enter_callbacks - assert !@record.callbacks.include?('after_enter') + def test_should_notify_before_callbacks_if_before_callback_fails + Switch.before_transition_state lambda {|record| false} + @transition.perform(@record) + + expected = [ + ['before_turn_on', @record, 'off', 'on'], + ['before_transition', @record, 'state', 'turn_on', 'off', 'on'] + ] + + assert_equal expected, SwitchObserver.notifications end - def test_should_run_after_loopback_callbacks - assert @record.callbacks.include?('after_loopback') + def test_should_notify_before_and_after_callbacks_if_after_callback_fails + Switch.after_transition_state lambda {|record| false} + @transition.perform(@record) + + expected = [ + ['before_turn_on', @record, 'off', 'on'], + ['before_transition', @record, 'state', 'turn_on', 'off', 'on'], + ['after_turn_on', @record, 'off', 'on'], + ['after_transition', @record, 'state', 'turn_on', 'off', 'on'] + ] + + assert_equal expected, SwitchObserver.notifications end def teardown Switch.class_eval do - @before_exit_state_off_callbacks = nil - @before_enter_state_on_callbacks = nil - @before_loopback_state_on_callbacks = nil - @after_exit_state_off_callbacks = nil - @after_enter_state_on_callbacks = nil - @after_loopback_state_on_callbacks = nil + @before_transition_state_callbacks = nil + @after_transition_state_callbacks = nil end end end