From dc002d4684a481fed59c88b4916f7b0c29354d96 Mon Sep 17 00:00:00 2001 From: Travis Tilley Date: Thu, 15 Oct 2009 00:34:08 -0400 Subject: [PATCH] cleanup, some refactoring, additional tests (one modeled after a common use of AASM, from restful authentication), fire callbacks on entering initial state, whitespace --- .gitignore | 1 + Rakefile | 27 +++- lib/aasm/aasm.rb | 36 ++++-- lib/aasm/event.rb | 78 ++++++++---- .../persistence/active_record_persistence.rb | 2 +- lib/aasm/state.rb | 43 ++++--- lib/aasm/state_machine.rb | 2 +- lib/aasm/state_transition.rb | 40 ++++-- test/functional/auth_machine_test.rb | 120 ++++++++++++++++++ test/test_helper.rb | 33 +++++ test/unit/aasm_test.rb | 0 test/unit/event_test.rb | 54 ++++++++ test/unit/state_test.rb | 69 ++++++++++ test/unit/state_transition_test.rb | 75 +++++++++++ 14 files changed, 506 insertions(+), 74 deletions(-) create mode 100644 test/functional/auth_machine_test.rb create mode 100644 test/test_helper.rb create mode 100644 test/unit/aasm_test.rb create mode 100644 test/unit/event_test.rb create mode 100644 test/unit/state_test.rb create mode 100644 test/unit/state_transition_test.rb diff --git a/.gitignore b/.gitignore index aa178bb..b25ab8c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.sw? *~ .DS_Store +.idea coverage pkg rdoc diff --git a/Rakefile b/Rakefile index e38ab12..0112c7c 100644 --- a/Rakefile +++ b/Rakefile @@ -11,6 +11,7 @@ begin gem.authors = ["Scott Barron", "Scott Petersen", "Travis Tilley"] gem.email = "ttilley@gmail.com" gem.add_development_dependency "rspec" + gem.add_development_dependency "shoulda" gem.add_development_dependency 'sdoc' # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings end @@ -20,18 +21,40 @@ rescue LoadError end require 'spec/rake/spectask' +require 'rake/testtask' + +Rake::TestTask.new(:test) do |test| + test.libs << 'lib' << 'test' + test.pattern = 'test/**/*_test.rb' + test.verbose = true +end + +begin + require 'rcov/rcovtask' + Rcov::RcovTask.new(:rcov_shoulda) do |test| + test.libs << 'test' + test.pattern = 'test/**/*_test.rb' + test.verbose = true + end +rescue LoadError + task :rcov do + abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov" + end +end + Spec::Rake::SpecTask.new(:spec) do |spec| spec.libs << 'lib' << 'spec' spec.spec_files = FileList['spec/**/*_spec.rb'] spec.spec_opts = ['-cfs'] end -Spec::Rake::SpecTask.new(:rcov) do |spec| +Spec::Rake::SpecTask.new(:rcov_rspec) do |spec| spec.libs << 'lib' << 'spec' spec.pattern = 'spec/**/*_spec.rb' spec.rcov = true end +task :test => :check_dependencies task :spec => :check_dependencies begin @@ -59,7 +82,7 @@ rescue LoadError end end -task :default => :spec +task :default => :test begin require 'rake/rdoctask' diff --git a/lib/aasm/aasm.rb b/lib/aasm/aasm.rb index 16e0391..e92b887 100644 --- a/lib/aasm/aasm.rb +++ b/lib/aasm/aasm.rb @@ -84,7 +84,20 @@ module AASM @aasm_current_state = aasm_read_state end return @aasm_current_state if @aasm_current_state - aasm_determine_state_name(self.class.aasm_initial_state) + + aasm_enter_initial_state + end + + def aasm_enter_initial_state + state_name = aasm_determine_state_name(self.class.aasm_initial_state) + state = aasm_state_object_for_state(state_name) + + state.call_action(:before_enter, self) + state.call_action(:enter, self) + self.aasm_current_state = state_name + state.call_action(:after_enter, self) + + state_name end def aasm_events_for_current_state @@ -97,6 +110,7 @@ module AASM end private + def set_aasm_current_state_with_persistence(state) save_success = true if self.respond_to?(:aasm_write_state) || self.private_methods.include?('aasm_write_state') @@ -116,12 +130,12 @@ module AASM def aasm_determine_state_name(state) case state - when Symbol, String - state - when Proc - state.call(self) - else - raise NotImplementedError, "Unrecognized state-type given. Expected Symbol, String, or Proc." + when Symbol, String + state + when Proc + state.call(self) + else + raise NotImplementedError, "Unrecognized state-type given. Expected Symbol, String, or Proc." end end @@ -144,13 +158,13 @@ module AASM unless new_state_name.nil? new_state = aasm_state_object_for_state(new_state_name) - + # new before_ callbacks old_state.call_action(:before_exit, self) new_state.call_action(:before_enter, self) - + new_state.call_action(:enter, self) - + persist_successful = true if persist persist_successful = set_aasm_current_state_with_persistence(new_state_name) @@ -159,7 +173,7 @@ module AASM self.aasm_current_state = new_state_name end - if persist_successful + if persist_successful old_state.call_action(:after_exit, self) new_state.call_action(:after_enter, self) event.call_action(:after, self) diff --git a/lib/aasm/event.rb b/lib/aasm/event.rb index 249eda3..e4c2cfe 100644 --- a/lib/aasm/event.rb +++ b/lib/aasm/event.rb @@ -4,13 +4,11 @@ module AASM module SupportingClasses class Event attr_reader :name, :success, :options - + def initialize(name, options = {}, &block) @name = name - @success = options[:success] @transitions = [] - @options = options - instance_eval(&block) if block + update(options, &block) end def fire(obj, to_state=nil, *args) @@ -37,35 +35,59 @@ module AASM @transitions.select { |t| t.from == state } end - def execute_success_callback(obj, success = nil) - callback = success || @success - case(callback) - when String, Symbol - obj.send(callback) - when Proc - callback.call(obj) - when Array - callback.each{|meth|self.execute_success_callback(obj, meth)} - end - end - - def call_action(action, record) - action = @options[action] - case action - when Symbol, String - record.send(action) - when Proc - action.call(record) - when Array - action.each { |a| record.send(a) } - end - end - def all_transitions @transitions end + def call_action(action, record) + action = @options[action] + action.is_a?(Array) ? + action.each {|a| _call_action(a, record)} : + _call_action(action, record) + end + + def ==(event) + if event.is_a? Symbol + name == event + else + name == event.name + end + end + + def update(options = {}, &block) + if options.key?(:success) then + @success = options[:success] + end + if block then + instance_eval(&block) + end + @options = options + self + end + + def execute_success_callback(obj, success = nil) + callback = success || @success + case(callback) + when String, Symbol + obj.send(callback) + when Proc + callback.call(obj) + when Array + callback.each{|meth|self.execute_success_callback(obj, meth)} + end + end + private + + def _call_action(action, record) + case action + when Symbol, String + record.send(action) + when Proc + action.call(record) + end + end + def transitions(trans_opts) Array(trans_opts[:from]).each do |s| @transitions << SupportingClasses::StateTransition.new(trans_opts.merge({:from => s.to_sym})) diff --git a/lib/aasm/persistence/active_record_persistence.rb b/lib/aasm/persistence/active_record_persistence.rb index 304445d..c6d6fbf 100644 --- a/lib/aasm/persistence/active_record_persistence.rb +++ b/lib/aasm/persistence/active_record_persistence.rb @@ -155,7 +155,7 @@ module AASM # foo.aasm_state # => nil # def aasm_ensure_initial_state - send("#{self.class.aasm_column}=", self.aasm_current_state.to_s) + send("#{self.class.aasm_column}=", self.aasm_enter_initial_state.to_s) end end diff --git a/lib/aasm/state.rb b/lib/aasm/state.rb index 37766a5..655b2e9 100644 --- a/lib/aasm/state.rb +++ b/lib/aasm/state.rb @@ -4,7 +4,8 @@ module AASM attr_reader :name, :options def initialize(name, options={}) - @name, @options = name, options + @name = name + update(options) end def ==(state) @@ -17,30 +18,38 @@ module AASM def call_action(action, record) action = @options[action] - if action.is_a? Array - action.each do |a| - _call(a, record) - end - else - _call(action, record) - end + action.is_a?(Array) ? + action.each {|a| _call_action(a, record)} : + _call_action(action, record) + end + + def display_name + @display_name ||= name.to_s.gsub(/_/, ' ').capitalize end def for_select - [name.to_s.gsub(/_/, ' ').capitalize, name.to_s] + [display_name, name.to_s] end - + + def update(options = {}) + if options.key?(:display) then + @display_name = options.delete(:display) + end + @options = options + self + end + private - def _call(action, record) + + def _call_action(action, record) case action - when Symbol, String - record.send(action) - when Proc - action.call(record) - when Array - action.each { |a| record.send(a) } + when Symbol, String + record.send(action) + when Proc + action.call(record) end end + end end end diff --git a/lib/aasm/state_machine.rb b/lib/aasm/state_machine.rb index e689035..b38f5c3 100644 --- a/lib/aasm/state_machine.rb +++ b/lib/aasm/state_machine.rb @@ -15,7 +15,7 @@ module AASM attr_reader :name def initialize(name) - @name = name + @name = name @initial_state = nil @states = [] @events = {} diff --git a/lib/aasm/state_transition.rb b/lib/aasm/state_transition.rb index c823f02..8947492 100644 --- a/lib/aasm/state_transition.rb +++ b/lib/aasm/state_transition.rb @@ -2,6 +2,7 @@ module AASM module SupportingClasses class StateTransition attr_reader :from, :to, :opts + alias_method :options, :opts def initialize(opts) @from, @to, @guard, @on_transition = opts[:from], opts[:to], opts[:guard], opts[:on_transition] @@ -10,29 +11,40 @@ module AASM def perform(obj) case @guard - when Symbol, String - obj.send(@guard) - when Proc - @guard.call(obj) - else - true + when Symbol, String + obj.send(@guard) + when Proc + @guard.call(obj) + else + true end end def execute(obj, *args) - case @on_transition - when Symbol, String - obj.send(@on_transition, *args) - when Array - @on_transition.each{|m| obj.send(m, *args) } - when Proc - @on_transition.call(obj, *args) - end + @on_transition.is_a?(Array) ? + @on_transition.each {|ot| _execute(obj, ot, *args)} : + _execute(obj, @on_transition, *args) end def ==(obj) @from == obj.from && @to == obj.to end + + def from?(value) + @from == value + end + + private + + def _execute(obj, on_transition, *args) + case on_transition + when Symbol, String + obj.send(on_transition, *args) + when Proc + on_transition.call(obj, *args) + end + end + end end end diff --git a/test/functional/auth_machine_test.rb b/test/functional/auth_machine_test.rb new file mode 100644 index 0000000..f9d8ed8 --- /dev/null +++ b/test/functional/auth_machine_test.rb @@ -0,0 +1,120 @@ +require 'test_helper' + +class AuthMachine + include AASM + + attr_accessor :activation_code, :activated_at, :deleted_at + + aasm_initial_state :pending + + aasm_state :passive + aasm_state :pending, :enter => :make_activation_code + aasm_state :active, :enter => :do_activate + aasm_state :suspended + aasm_state :deleted, :enter => :do_delete, :exit => :do_undelete + + aasm_event :register do + transitions :from => :passive, :to => :pending, :guard => Proc.new {|u| u.can_register? } + end + + aasm_event :activate do + transitions :from => :pending, :to => :active + end + + aasm_event :suspend do + transitions :from => [:passive, :pending, :active], :to => :suspended + end + + aasm_event :delete do + transitions :from => [:passive, :pending, :active, :suspended], :to => :deleted + end + + aasm_event :unsuspend do + transitions :from => :suspended, :to => :active, :guard => Proc.new {|u| u.has_activated? } + transitions :from => :suspended, :to => :pending, :guard => Proc.new {|u| u.has_activation_code? } + transitions :from => :suspended, :to => :passive + end + + def initialize + # the AR backend uses a before_validate_on_create :aasm_ensure_initial_state + # lets do something similar here for testing purposes. + aasm_enter_initial_state + end + + def make_activation_code + @activation_code = 'moo' + end + + def do_activate + @activated_at = Time.now + @activation_code = nil + end + + def do_delete + @deleted_at = Time.now + end + + def do_undelete + @deleted_at = false + end + + def can_register? + true + end + + def has_activated? + !!@activated_at + end + + def has_activation_code? + !!@activation_code + end +end + +class AuthMachineTest < Test::Unit::TestCase + context 'authentication state machine' do + context 'on initialization' do + setup do + @auth = AuthMachine.new + end + + should 'be in the pending state' do + assert_equal :pending, @auth.aasm_current_state + end + + should 'have an activation code' do + assert @auth.has_activation_code? + assert_not_nil @auth.activation_code + end + end + + context 'when being unsuspended' do + should 'be active if previously activated' do + @auth = AuthMachine.new + @auth.activate! + @auth.suspend! + @auth.unsuspend! + + assert_equal :active, @auth.aasm_current_state + end + + should 'be pending if not previously activated, but an activation code is present' do + @auth = AuthMachine.new + @auth.suspend! + @auth.unsuspend! + + assert_equal :pending, @auth.aasm_current_state + end + + should 'be passive if not previously activated and there is no activation code' do + @auth = AuthMachine.new + @auth.activation_code = nil + @auth.suspend! + @auth.unsuspend! + + assert_equal :passive, @auth.aasm_current_state + end + end + + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..5dc5fe1 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,33 @@ +require 'ostruct' +require 'rubygems' + +begin + gem 'minitest' +rescue Gem::LoadError + puts 'minitest gem not found' +end + +begin + require 'minitest/autorun' + puts 'using minitest' +rescue LoadError + require 'test/unit' + puts 'using test/unit' +end + +require 'rr' +require 'shoulda' + +class Test::Unit::TestCase + include RR::Adapters::TestUnit +end + +begin + require 'ruby-debug' + Debugger.start +rescue LoadError +end + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) +$LOAD_PATH.unshift(File.dirname(__FILE__)) +require 'aasm' diff --git a/test/unit/aasm_test.rb b/test/unit/aasm_test.rb new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/event_test.rb b/test/unit/event_test.rb new file mode 100644 index 0000000..bb79573 --- /dev/null +++ b/test/unit/event_test.rb @@ -0,0 +1,54 @@ +require 'test_helper' + +class EventTest < Test::Unit::TestCase + def new_event + @event = AASM::SupportingClasses::Event.new(@name, {:success => @success}) do + transitions :to => :closed, :from => [:open, :received] + end + end + + context 'event' do + setup do + @name = :close_order + @success = :success_callback + end + + should 'set the name' do + assert_equal @name, new_event.name + end + + should 'set the success option' do + assert_equal @success, new_event.success + end + + should 'create StateTransitions' do + mock(AASM::SupportingClasses::StateTransition).new({:to => :closed, :from => :open}) + mock(AASM::SupportingClasses::StateTransition).new({:to => :closed, :from => :received}) + new_event + end + + context 'when firing' do + should 'raise an AASM::InvalidTransition error if the transitions are empty' do + event = AASM::SupportingClasses::Event.new(:event) + + obj = OpenStruct.new + obj.aasm_current_state = :open + + assert_raise AASM::InvalidTransition do + event.fire(obj) + end + end + + should 'return the state of the first matching transition it finds' do + event = AASM::SupportingClasses::Event.new(:event) do + transitions :to => :closed, :from => [:open, :received] + end + + obj = OpenStruct.new + obj.aasm_current_state = :open + + assert_equal :closed, event.fire(obj) + end + end + end +end diff --git a/test/unit/state_test.rb b/test/unit/state_test.rb new file mode 100644 index 0000000..417c952 --- /dev/null +++ b/test/unit/state_test.rb @@ -0,0 +1,69 @@ +require 'test_helper' + +class StateTest < Test::Unit::TestCase + def new_state(options={}) + AASM::SupportingClasses::State.new(@name, @options.merge(options)) + end + + context 'state' do + setup do + @name = :astate + @options = { :crazy_custom_key => 'key' } + end + + should 'set the name' do + assert_equal :astate, new_state.name + end + + should 'set the display_name from name' do + assert_equal "Astate", new_state.display_name + end + + should 'set the display_name from options' do + assert_equal "A State", new_state(:display => "A State").display_name + end + + should 'set the options and expose them as options' do + assert_equal @options, new_state.options + end + + should 'equal a symbol of the same name' do + assert_equal new_state, :astate + end + + should 'equal a state of the same name' do + assert_equal new_state, new_state + end + + should 'send a message to the record for an action if the action is present as a symbol' do + state = new_state(:entering => :foo) + mock(record = Object.new).foo + state.call_action(:entering, record) + end + + should 'send a message to the record for an action if the action is present as a string' do + state = new_state(:entering => 'foo') + mock(record = Object.new).foo + state.call_action(:entering, record) + end + + should 'call a proc with the record as its argument for an action if the action is present as a proc' do + state = new_state(:entering => Proc.new {|r| r.foobar}) + mock(record = Object.new).foobar + state.call_action(:entering, record) + end + + should 'send a message to the record for each action if the action is present as an array' do + state = new_state(:entering => [:a, :b, 'c', lambda {|r| r.foobar}]) + + record = Object.new + mock(record).a + mock(record).b + mock(record).c + mock(record).foobar + + state.call_action(:entering, record) + end + + end +end diff --git a/test/unit/state_transition_test.rb b/test/unit/state_transition_test.rb new file mode 100644 index 0000000..ed22628 --- /dev/null +++ b/test/unit/state_transition_test.rb @@ -0,0 +1,75 @@ +require 'test_helper' + +class StateTransitionTest < Test::Unit::TestCase + context 'state transition' do + setup do + @opts = {:from => 'foo', :to => 'bar', :guard => 'g'} + @st = AASM::SupportingClasses::StateTransition.new(@opts) + end + + should 'set from, to, and opts attr readers' do + assert_equal @opts[:from], @st.from + assert_equal @opts[:to], @st.to + assert_equal @opts, @st.options + end + + should 'pass equality check if from and to are the same' do + obj = OpenStruct.new + obj.from = @opts[:from] + obj.to = @opts[:to] + + assert_equal @st, obj + end + + should 'fail equality check if from is not the same' do + obj = OpenStruct.new + obj.from = 'blah' + obj.to = @opts[:to] + + assert_not_equal @st, obj + end + + should 'fail equality check if to is not the same' do + obj = OpenStruct.new + obj.from = @opts[:from] + obj.to = 'blah' + + assert_not_equal @st, obj + end + + context 'when performing guard checks' do + should 'return true if there is no guard' do + opts = {:from => 'foo', :to => 'bar'} + st = AASM::SupportingClasses::StateTransition.new(opts) + assert st.perform(nil) + end + + should 'call the method on the object if guard is a symbol' do + opts = {:from => 'foo', :to => 'bar', :guard => :test_guard} + st = AASM::SupportingClasses::StateTransition.new(opts) + + mock(obj = Object.new).test_guard + + st.perform(obj) + end + + should 'call the method on the object if guard is a string' do + opts = {:from => 'foo', :to => 'bar', :guard => 'test_guard'} + st = AASM::SupportingClasses::StateTransition.new(opts) + + mock(obj = Object.new).test_guard + + st.perform(obj) + end + + should 'call the proc passing the object if guard is a proc' do + opts = {:from => 'foo', :to => 'bar', :guard => Proc.new {|o| o.test_guard}} + st = AASM::SupportingClasses::StateTransition.new(opts) + + mock(obj = Object.new).test_guard + + st.perform(obj) + end + end + end +end