├── VERSION ├── test ├── unit │ ├── aasm_test.rb │ ├── event_test.rb │ ├── state_test.rb │ └── state_transition_test.rb ├── test_helper.rb └── functional │ └── auth_machine_test.rb ├── lib ├── aasm.rb └── aasm │ ├── persistence.rb │ ├── state_machine.rb │ ├── state_transition.rb │ ├── state.rb │ ├── event.rb │ ├── aasm.rb │ └── persistence │ └── active_record_persistence.rb ├── .document ├── .gitignore ├── spec ├── spec_helper.rb ├── functional │ ├── conversation_spec.rb │ └── conversation.rb └── unit │ ├── state_spec.rb │ ├── before_after_callbacks_spec.rb │ ├── state_transition_spec.rb │ ├── event_spec.rb │ ├── active_record_persistence_spec.rb │ └── aasm_spec.rb ├── LICENSE ├── Rakefile └── README.rdoc /VERSION: -------------------------------------------------------------------------------- 1 | 2.1.3 2 | -------------------------------------------------------------------------------- /test/unit/aasm_test.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/aasm.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'aasm', 'aasm') 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gemspec 2 | *.sw? 3 | *~ 4 | .DS_Store 5 | .idea 6 | coverage 7 | pkg 8 | rdoc 9 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 3 | 4 | require 'aasm' 5 | 6 | require 'spec' 7 | require 'spec/autorun' 8 | 9 | Spec::Runner.configure do |config| 10 | 11 | end 12 | -------------------------------------------------------------------------------- /spec/functional/conversation_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "..", "spec_helper") 2 | require File.join(File.dirname(__FILE__), 'conversation') 3 | 4 | describe Conversation, 'description' do 5 | it '.aasm_states should contain all of the states' do 6 | Conversation.aasm_states.should == [:needs_attention, :read, :closed, :awaiting_response, :junk] 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | require 'rubygems' 3 | 4 | begin 5 | gem 'minitest' 6 | rescue Gem::LoadError 7 | puts 'minitest gem not found' 8 | end 9 | 10 | begin 11 | require 'minitest/autorun' 12 | puts 'using minitest' 13 | rescue LoadError 14 | require 'test/unit' 15 | puts 'using test/unit' 16 | end 17 | 18 | require 'rr' 19 | require 'shoulda' 20 | 21 | class Test::Unit::TestCase 22 | include RR::Adapters::TestUnit 23 | end 24 | 25 | begin 26 | require 'ruby-debug' 27 | Debugger.start 28 | rescue LoadError 29 | end 30 | 31 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 32 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 33 | require 'aasm' 34 | -------------------------------------------------------------------------------- /lib/aasm/persistence.rb: -------------------------------------------------------------------------------- 1 | module AASM 2 | module Persistence 3 | 4 | # Checks to see this class or any of it's superclasses inherit from 5 | # ActiveRecord::Base and if so includes ActiveRecordPersistence 6 | def self.set_persistence(base) 7 | # Use a fancier auto-loading thingy, perhaps. When there are more persistence engines. 8 | hierarchy = base.ancestors.map {|klass| klass.to_s} 9 | 10 | if hierarchy.include?("ActiveRecord::Base") or hierarchy.include?("MongoMapper::EmbeddedDocument") 11 | require File.join(File.dirname(__FILE__), 'persistence', 'active_record_persistence') 12 | base.send(:include, AASM::Persistence::ActiveRecordPersistence) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/aasm/state_machine.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | 3 | module AASM 4 | class StateMachine 5 | def self.[](*args) 6 | (@machines ||= {})[args] 7 | end 8 | 9 | def self.[]=(*args) 10 | val = args.pop 11 | (@machines ||= {})[args] = val 12 | end 13 | 14 | attr_accessor :states, :events, :initial_state, :config 15 | attr_reader :name 16 | 17 | def initialize(name) 18 | @name = name 19 | @initial_state = nil 20 | @states = [] 21 | @events = {} 22 | @config = OpenStruct.new 23 | end 24 | 25 | def clone 26 | klone = super 27 | klone.states = states.clone 28 | klone.events = events.clone 29 | klone 30 | end 31 | 32 | def create_state(name, options) 33 | @states << AASM::SupportingClasses::State.new(name, options) unless @states.include?(name) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/functional/conversation.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', '..', 'lib', 'aasm') 2 | 3 | class Conversation 4 | include AASM 5 | 6 | aasm_initial_state :needs_attention 7 | 8 | aasm_state :needs_attention 9 | aasm_state :read 10 | aasm_state :closed 11 | aasm_state :awaiting_response 12 | aasm_state :junk 13 | 14 | aasm_event :new_message do 15 | end 16 | 17 | aasm_event :view do 18 | transitions :to => :read, :from => [:needs_attention] 19 | end 20 | 21 | aasm_event :reply do 22 | end 23 | 24 | aasm_event :close do 25 | transitions :to => :closed, :from => [:read, :awaiting_response] 26 | end 27 | 28 | aasm_event :junk do 29 | transitions :to => :junk, :from => [:read] 30 | end 31 | 32 | aasm_event :unjunk do 33 | end 34 | 35 | def initialize(persister) 36 | @persister = persister 37 | end 38 | 39 | 40 | private 41 | def aasm_read_state 42 | @persister.read_state 43 | end 44 | 45 | def aasm_write_state(state) 46 | @persister.write_state(state) 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 Scott Barron 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/aasm/state_transition.rb: -------------------------------------------------------------------------------- 1 | module AASM 2 | module SupportingClasses 3 | class StateTransition 4 | attr_reader :from, :to, :opts 5 | alias_method :options, :opts 6 | 7 | def initialize(opts) 8 | @from, @to, @guard, @on_transition = opts[:from], opts[:to], opts[:guard], opts[:on_transition] 9 | @opts = opts 10 | end 11 | 12 | def perform(obj) 13 | case @guard 14 | when Symbol, String 15 | obj.send(@guard) 16 | when Proc 17 | @guard.call(obj) 18 | else 19 | true 20 | end 21 | end 22 | 23 | def execute(obj, *args) 24 | @on_transition.is_a?(Array) ? 25 | @on_transition.each {|ot| _execute(obj, ot, *args)} : 26 | _execute(obj, @on_transition, *args) 27 | end 28 | 29 | def ==(obj) 30 | @from == obj.from && @to == obj.to 31 | end 32 | 33 | def from?(value) 34 | @from == value 35 | end 36 | 37 | private 38 | 39 | def _execute(obj, on_transition, *args) 40 | case on_transition 41 | when Symbol, String 42 | obj.send(on_transition, *args) 43 | when Proc 44 | on_transition.call(obj, *args) 45 | end 46 | end 47 | 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/aasm/state.rb: -------------------------------------------------------------------------------- 1 | module AASM 2 | module SupportingClasses 3 | class State 4 | attr_reader :name, :options 5 | 6 | def initialize(name, options={}) 7 | @name = name 8 | update(options) 9 | end 10 | 11 | def ==(state) 12 | if state.is_a? Symbol 13 | name == state 14 | else 15 | name == state.name 16 | end 17 | end 18 | 19 | def call_action(action, record) 20 | action = @options[action] 21 | action.is_a?(Array) ? 22 | action.each {|a| _call_action(a, record)} : 23 | _call_action(action, record) 24 | end 25 | 26 | def display_name 27 | @display_name ||= name.to_s.gsub(/_/, ' ').capitalize 28 | end 29 | 30 | def for_select 31 | [display_name, name.to_s] 32 | end 33 | 34 | def update(options = {}) 35 | if options.key?(:display) then 36 | @display_name = options.delete(:display) 37 | end 38 | @options = options 39 | self 40 | end 41 | 42 | private 43 | 44 | def _call_action(action, record) 45 | case action 46 | when Symbol, String 47 | record.send(action) 48 | when Proc 49 | action.call(record) 50 | end 51 | end 52 | 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/unit/event_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class EventTest < Test::Unit::TestCase 4 | def new_event 5 | @event = AASM::SupportingClasses::Event.new(@name, {:success => @success}) do 6 | transitions :to => :closed, :from => [:open, :received] 7 | end 8 | end 9 | 10 | context 'event' do 11 | setup do 12 | @name = :close_order 13 | @success = :success_callback 14 | end 15 | 16 | should 'set the name' do 17 | assert_equal @name, new_event.name 18 | end 19 | 20 | should 'set the success option' do 21 | assert_equal @success, new_event.success 22 | end 23 | 24 | should 'create StateTransitions' do 25 | mock(AASM::SupportingClasses::StateTransition).new({:to => :closed, :from => :open}) 26 | mock(AASM::SupportingClasses::StateTransition).new({:to => :closed, :from => :received}) 27 | new_event 28 | end 29 | 30 | context 'when firing' do 31 | should 'raise an AASM::InvalidTransition error if the transitions are empty' do 32 | event = AASM::SupportingClasses::Event.new(:event) 33 | 34 | obj = OpenStruct.new 35 | obj.aasm_current_state = :open 36 | 37 | assert_raise AASM::InvalidTransition do 38 | event.fire(obj) 39 | end 40 | end 41 | 42 | should 'return the state of the first matching transition it finds' do 43 | event = AASM::SupportingClasses::Event.new(:event) do 44 | transitions :to => :closed, :from => [:open, :received] 45 | end 46 | 47 | obj = OpenStruct.new 48 | obj.aasm_current_state = :open 49 | 50 | assert_equal :closed, event.fire(obj) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/unit/state_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | # TODO These are specs ported from original aasm 4 | describe AASM::SupportingClasses::State do 5 | before(:each) do 6 | @name = :astate 7 | @options = { :crazy_custom_key => 'key' } 8 | end 9 | 10 | def new_state(options={}) 11 | AASM::SupportingClasses::State.new(@name, @options.merge(options)) 12 | end 13 | 14 | it 'should set the name' do 15 | state = new_state 16 | 17 | state.name.should == :astate 18 | end 19 | 20 | it 'should set the options and expose them as options' do 21 | state = new_state 22 | 23 | state.options.should == @options 24 | end 25 | 26 | it 'should be equal to a symbol of the same name' do 27 | state = new_state 28 | 29 | state.should == :astate 30 | end 31 | 32 | it 'should be equal to a State of the same name' do 33 | new_state.should == new_state 34 | end 35 | 36 | it 'should send a message to the record for an action if the action is present as a symbol' do 37 | state = new_state(:entering => :foo) 38 | 39 | record = mock('record') 40 | record.should_receive(:foo) 41 | 42 | state.call_action(:entering, record) 43 | end 44 | 45 | it 'should send a message to the record for an action if the action is present as a string' do 46 | state = new_state(:entering => 'foo') 47 | 48 | record = mock('record') 49 | record.should_receive(:foo) 50 | 51 | state.call_action(:entering, record) 52 | end 53 | 54 | it 'should send a message to the record for each action' do 55 | state = new_state(:entering => [:a, :b, "c", lambda {|r| r.foobar }]) 56 | 57 | record = mock('record') 58 | record.should_receive(:a) 59 | record.should_receive(:b) 60 | record.should_receive(:c) 61 | record.should_receive(:foobar) 62 | 63 | state.call_action(:entering, record) 64 | end 65 | 66 | it 'should call a proc, passing in the record for an action if the action is present' do 67 | state = new_state(:entering => Proc.new {|r| r.foobar}) 68 | 69 | record = mock('record') 70 | record.should_receive(:foobar) 71 | 72 | state.call_action(:entering, record) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/unit/state_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class StateTest < Test::Unit::TestCase 4 | def new_state(options={}) 5 | AASM::SupportingClasses::State.new(@name, @options.merge(options)) 6 | end 7 | 8 | context 'state' do 9 | setup do 10 | @name = :astate 11 | @options = { :crazy_custom_key => 'key' } 12 | end 13 | 14 | should 'set the name' do 15 | assert_equal :astate, new_state.name 16 | end 17 | 18 | should 'set the display_name from name' do 19 | assert_equal "Astate", new_state.display_name 20 | end 21 | 22 | should 'set the display_name from options' do 23 | assert_equal "A State", new_state(:display => "A State").display_name 24 | end 25 | 26 | should 'set the options and expose them as options' do 27 | assert_equal @options, new_state.options 28 | end 29 | 30 | should 'equal a symbol of the same name' do 31 | assert_equal new_state, :astate 32 | end 33 | 34 | should 'equal a state of the same name' do 35 | assert_equal new_state, new_state 36 | end 37 | 38 | should 'send a message to the record for an action if the action is present as a symbol' do 39 | state = new_state(:entering => :foo) 40 | mock(record = Object.new).foo 41 | state.call_action(:entering, record) 42 | end 43 | 44 | should 'send a message to the record for an action if the action is present as a string' do 45 | state = new_state(:entering => 'foo') 46 | mock(record = Object.new).foo 47 | state.call_action(:entering, record) 48 | end 49 | 50 | should 'call a proc with the record as its argument for an action if the action is present as a proc' do 51 | state = new_state(:entering => Proc.new {|r| r.foobar}) 52 | mock(record = Object.new).foobar 53 | state.call_action(:entering, record) 54 | end 55 | 56 | should 'send a message to the record for each action if the action is present as an array' do 57 | state = new_state(:entering => [:a, :b, 'c', lambda {|r| r.foobar}]) 58 | 59 | record = Object.new 60 | mock(record).a 61 | mock(record).b 62 | mock(record).c 63 | mock(record).foobar 64 | 65 | state.call_action(:entering, record) 66 | end 67 | 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/unit/state_transition_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class StateTransitionTest < Test::Unit::TestCase 4 | context 'state transition' do 5 | setup do 6 | @opts = {:from => 'foo', :to => 'bar', :guard => 'g'} 7 | @st = AASM::SupportingClasses::StateTransition.new(@opts) 8 | end 9 | 10 | should 'set from, to, and opts attr readers' do 11 | assert_equal @opts[:from], @st.from 12 | assert_equal @opts[:to], @st.to 13 | assert_equal @opts, @st.options 14 | end 15 | 16 | should 'pass equality check if from and to are the same' do 17 | obj = OpenStruct.new 18 | obj.from = @opts[:from] 19 | obj.to = @opts[:to] 20 | 21 | assert_equal @st, obj 22 | end 23 | 24 | should 'fail equality check if from is not the same' do 25 | obj = OpenStruct.new 26 | obj.from = 'blah' 27 | obj.to = @opts[:to] 28 | 29 | assert_not_equal @st, obj 30 | end 31 | 32 | should 'fail equality check if to is not the same' do 33 | obj = OpenStruct.new 34 | obj.from = @opts[:from] 35 | obj.to = 'blah' 36 | 37 | assert_not_equal @st, obj 38 | end 39 | 40 | context 'when performing guard checks' do 41 | should 'return true if there is no guard' do 42 | opts = {:from => 'foo', :to => 'bar'} 43 | st = AASM::SupportingClasses::StateTransition.new(opts) 44 | assert st.perform(nil) 45 | end 46 | 47 | should 'call the method on the object if guard is a symbol' do 48 | opts = {:from => 'foo', :to => 'bar', :guard => :test_guard} 49 | st = AASM::SupportingClasses::StateTransition.new(opts) 50 | 51 | mock(obj = Object.new).test_guard 52 | 53 | st.perform(obj) 54 | end 55 | 56 | should 'call the method on the object if guard is a string' do 57 | opts = {:from => 'foo', :to => 'bar', :guard => 'test_guard'} 58 | st = AASM::SupportingClasses::StateTransition.new(opts) 59 | 60 | mock(obj = Object.new).test_guard 61 | 62 | st.perform(obj) 63 | end 64 | 65 | should 'call the proc passing the object if guard is a proc' do 66 | opts = {:from => 'foo', :to => 'bar', :guard => Proc.new {|o| o.test_guard}} 67 | st = AASM::SupportingClasses::StateTransition.new(opts) 68 | 69 | mock(obj = Object.new).test_guard 70 | 71 | st.perform(obj) 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/unit/before_after_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | class Foo2 4 | include AASM 5 | aasm_initial_state :open 6 | aasm_state :open, 7 | :before_enter => :before_enter_open, 8 | :before_exit => :before_exit_open, 9 | :after_enter => :after_enter_open, 10 | :after_exit => :after_exit_open 11 | aasm_state :closed, 12 | :before_enter => :before_enter_closed, 13 | :before_exit => :before_exit_closed, 14 | :after_enter => :after_enter_closed, 15 | :after_exit => :after_exit_closed 16 | 17 | aasm_event :close, :before => :before, :after => :after do 18 | transitions :to => :closed, :from => [:open] 19 | end 20 | 21 | aasm_event :open, :before => :before, :after => :after do 22 | transitions :to => :open, :from => :closed 23 | end 24 | 25 | def before_enter_open 26 | end 27 | def before_exit_open 28 | end 29 | def after_enter_open 30 | end 31 | def after_exit_open 32 | end 33 | 34 | def before_enter_closed 35 | end 36 | def before_exit_closed 37 | end 38 | def after_enter_closed 39 | end 40 | def after_exit_closed 41 | end 42 | 43 | def before 44 | end 45 | def after 46 | end 47 | end 48 | 49 | describe Foo2, '- new callbacks' do 50 | before(:each) do 51 | @foo = Foo2.new 52 | end 53 | 54 | it "should get close callbacks" do 55 | @foo.should_receive(:before).once.ordered 56 | @foo.should_receive(:before_exit_open).once.ordered # these should be before the state changes 57 | @foo.should_receive(:before_enter_closed).once.ordered 58 | @foo.should_receive(:aasm_write_state).once.ordered.and_return(true) # this is when the state changes 59 | @foo.should_receive(:after_exit_open).once.ordered # these should be after the state changes 60 | @foo.should_receive(:after_enter_closed).once.ordered 61 | @foo.should_receive(:after).once.ordered 62 | 63 | @foo.close! 64 | end 65 | 66 | it "should get open callbacks" do 67 | @foo.close! 68 | 69 | @foo.should_receive(:before).once.ordered 70 | @foo.should_receive(:before_exit_closed).once.ordered # these should be before the state changes 71 | @foo.should_receive(:before_enter_open).once.ordered 72 | @foo.should_receive(:aasm_write_state).once.ordered.and_return(true) # this is when the state changes 73 | @foo.should_receive(:after_exit_closed).once.ordered # these should be after the state changes 74 | @foo.should_receive(:after_enter_open).once.ordered 75 | @foo.should_receive(:after).once.ordered 76 | 77 | @foo.open! 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/unit/state_transition_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | describe AASM::SupportingClasses::StateTransition do 4 | it 'should set from, to, and opts attr readers' do 5 | opts = {:from => 'foo', :to => 'bar', :guard => 'g'} 6 | st = AASM::SupportingClasses::StateTransition.new(opts) 7 | 8 | st.from.should == opts[:from] 9 | st.to.should == opts[:to] 10 | st.opts.should == opts 11 | end 12 | 13 | it 'should pass equality check if from and to are the same' do 14 | opts = {:from => 'foo', :to => 'bar', :guard => 'g'} 15 | st = AASM::SupportingClasses::StateTransition.new(opts) 16 | 17 | obj = mock('object') 18 | obj.stub!(:from).and_return(opts[:from]) 19 | obj.stub!(:to).and_return(opts[:to]) 20 | 21 | st.should == obj 22 | end 23 | 24 | it 'should fail equality check if from are not the same' do 25 | opts = {:from => 'foo', :to => 'bar', :guard => 'g'} 26 | st = AASM::SupportingClasses::StateTransition.new(opts) 27 | 28 | obj = mock('object') 29 | obj.stub!(:from).and_return('blah') 30 | obj.stub!(:to).and_return(opts[:to]) 31 | 32 | st.should_not == obj 33 | end 34 | 35 | it 'should fail equality check if to are not the same' do 36 | opts = {:from => 'foo', :to => 'bar', :guard => 'g'} 37 | st = AASM::SupportingClasses::StateTransition.new(opts) 38 | 39 | obj = mock('object') 40 | obj.stub!(:from).and_return(opts[:from]) 41 | obj.stub!(:to).and_return('blah') 42 | 43 | st.should_not == obj 44 | end 45 | end 46 | 47 | describe AASM::SupportingClasses::StateTransition, '- when performing guard checks' do 48 | it 'should return true of there is no guard' do 49 | opts = {:from => 'foo', :to => 'bar'} 50 | st = AASM::SupportingClasses::StateTransition.new(opts) 51 | 52 | st.perform(nil).should be_true 53 | end 54 | 55 | it 'should call the method on the object if guard is a symbol' do 56 | opts = {:from => 'foo', :to => 'bar', :guard => :test} 57 | st = AASM::SupportingClasses::StateTransition.new(opts) 58 | 59 | obj = mock('object') 60 | obj.should_receive(:test) 61 | 62 | st.perform(obj) 63 | end 64 | 65 | it 'should call the method on the object if guard is a string' do 66 | opts = {:from => 'foo', :to => 'bar', :guard => 'test'} 67 | st = AASM::SupportingClasses::StateTransition.new(opts) 68 | 69 | obj = mock('object') 70 | obj.should_receive(:test) 71 | 72 | st.perform(obj) 73 | end 74 | 75 | it 'should call the proc passing the object if the guard is a proc' do 76 | opts = {:from => 'foo', :to => 'bar', :guard => Proc.new {|o| o.test}} 77 | st = AASM::SupportingClasses::StateTransition.new(opts) 78 | 79 | obj = mock('object') 80 | obj.should_receive(:test) 81 | 82 | st.perform(obj) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/aasm/event.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'state_transition') 2 | 3 | module AASM 4 | module SupportingClasses 5 | class Event 6 | attr_reader :name, :success, :options 7 | 8 | def initialize(name, options = {}, &block) 9 | @name = name 10 | @transitions = [] 11 | update(options, &block) 12 | end 13 | 14 | def fire(obj, to_state=nil, *args) 15 | transitions = @transitions.select { |t| t.from == obj.aasm_current_state } 16 | raise AASM::InvalidTransition, "Event '#{name}' cannot transition from '#{obj.aasm_current_state}'" if transitions.size == 0 17 | 18 | next_state = nil 19 | transitions.each do |transition| 20 | next if to_state and !Array(transition.to).include?(to_state) 21 | if transition.perform(obj) 22 | next_state = to_state || Array(transition.to).first 23 | transition.execute(obj, *args) 24 | break 25 | end 26 | end 27 | next_state 28 | end 29 | 30 | def transitions_from_state?(state) 31 | @transitions.any? { |t| t.from == state } 32 | end 33 | 34 | def transitions_from_state(state) 35 | @transitions.select { |t| t.from == state } 36 | end 37 | 38 | def all_transitions 39 | @transitions 40 | end 41 | 42 | def call_action(action, record) 43 | action = @options[action] 44 | action.is_a?(Array) ? 45 | action.each {|a| _call_action(a, record)} : 46 | _call_action(action, record) 47 | end 48 | 49 | def ==(event) 50 | if event.is_a? Symbol 51 | name == event 52 | else 53 | name == event.name 54 | end 55 | end 56 | 57 | def update(options = {}, &block) 58 | if options.key?(:success) then 59 | @success = options[:success] 60 | end 61 | if block then 62 | instance_eval(&block) 63 | end 64 | @options = options 65 | self 66 | end 67 | 68 | def execute_success_callback(obj, success = nil) 69 | callback = success || @success 70 | case(callback) 71 | when String, Symbol 72 | obj.send(callback) 73 | when Proc 74 | callback.call(obj) 75 | when Array 76 | callback.each{|meth|self.execute_success_callback(obj, meth)} 77 | end 78 | end 79 | 80 | private 81 | 82 | def _call_action(action, record) 83 | case action 84 | when Symbol, String 85 | record.send(action) 86 | when Proc 87 | action.call(record) 88 | end 89 | end 90 | 91 | def transitions(trans_opts) 92 | Array(trans_opts[:from]).each do |s| 93 | @transitions << SupportingClasses::StateTransition.new(trans_opts.merge({:from => s.to_sym})) 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | 4 | begin 5 | require 'jeweler' 6 | Jeweler::Tasks.new do |gem| 7 | gem.name = "aasm" 8 | gem.summary = %Q{State machine mixin for Ruby objects} 9 | gem.description = %Q{AASM is a continuation of the acts as state machine rails plugin, built for plain Ruby objects.} 10 | gem.homepage = "http://rubyist.github.com/aasm/" 11 | gem.authors = ["Scott Barron", "Scott Petersen", "Travis Tilley"] 12 | gem.email = "scott@elitists.net, ttilley@gmail.com" 13 | gem.add_development_dependency "rspec" 14 | gem.add_development_dependency "shoulda" 15 | gem.add_development_dependency 'sdoc' 16 | # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings 17 | end 18 | Jeweler::GemcutterTasks.new 19 | rescue LoadError 20 | puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler" 21 | end 22 | 23 | require 'spec/rake/spectask' 24 | require 'rake/testtask' 25 | 26 | Rake::TestTask.new(:test) do |test| 27 | test.libs << 'lib' << 'test' 28 | test.pattern = 'test/**/*_test.rb' 29 | test.verbose = true 30 | end 31 | 32 | begin 33 | require 'rcov/rcovtask' 34 | Rcov::RcovTask.new(:rcov_shoulda) do |test| 35 | test.libs << 'test' 36 | test.pattern = 'test/**/*_test.rb' 37 | test.verbose = true 38 | end 39 | rescue LoadError 40 | task :rcov do 41 | abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov" 42 | end 43 | end 44 | 45 | Spec::Rake::SpecTask.new(:spec) do |spec| 46 | spec.libs << 'lib' << 'spec' 47 | spec.spec_files = FileList['spec/**/*_spec.rb'] 48 | spec.spec_opts = ['-cfs'] 49 | end 50 | 51 | Spec::Rake::SpecTask.new(:rcov_rspec) do |spec| 52 | spec.libs << 'lib' << 'spec' 53 | spec.pattern = 'spec/**/*_spec.rb' 54 | spec.rcov = true 55 | end 56 | 57 | task :test => :check_dependencies 58 | task :spec => :check_dependencies 59 | 60 | begin 61 | require 'reek/rake_task' 62 | Reek::RakeTask.new do |t| 63 | t.fail_on_error = true 64 | t.verbose = false 65 | t.source_files = 'lib/**/*.rb' 66 | end 67 | rescue LoadError 68 | task :reek do 69 | abort "Reek is not available. In order to run reek, you must: sudo gem install reek" 70 | end 71 | end 72 | 73 | begin 74 | require 'roodi' 75 | require 'roodi_task' 76 | RoodiTask.new do |t| 77 | t.verbose = false 78 | end 79 | rescue LoadError 80 | task :roodi do 81 | abort "Roodi is not available. In order to run roodi, you must: sudo gem install roodi" 82 | end 83 | end 84 | 85 | task :default => :test 86 | 87 | begin 88 | require 'rake/rdoctask' 89 | require 'sdoc' 90 | Rake::RDocTask.new do |rdoc| 91 | if File.exist?('VERSION') 92 | version = File.read('VERSION') 93 | else 94 | version = "" 95 | end 96 | 97 | rdoc.rdoc_dir = 'rdoc' 98 | rdoc.title = "aasm #{version}" 99 | rdoc.rdoc_files.include('README*') 100 | rdoc.rdoc_files.include('lib/**/*.rb') 101 | 102 | rdoc.options << '--fmt' << 'shtml' 103 | rdoc.template = 'direct' 104 | end 105 | rescue LoadError 106 | puts "aasm makes use of the sdoc gem. Install it with: sudo gem install sdoc" 107 | end 108 | 109 | -------------------------------------------------------------------------------- /test/functional/auth_machine_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class AuthMachine 4 | include AASM 5 | 6 | attr_accessor :activation_code, :activated_at, :deleted_at 7 | 8 | aasm_initial_state :pending 9 | 10 | aasm_state :passive 11 | aasm_state :pending, :enter => :make_activation_code 12 | aasm_state :active, :enter => :do_activate 13 | aasm_state :suspended 14 | aasm_state :deleted, :enter => :do_delete, :exit => :do_undelete 15 | 16 | aasm_event :register do 17 | transitions :from => :passive, :to => :pending, :guard => Proc.new {|u| u.can_register? } 18 | end 19 | 20 | aasm_event :activate do 21 | transitions :from => :pending, :to => :active 22 | end 23 | 24 | aasm_event :suspend do 25 | transitions :from => [:passive, :pending, :active], :to => :suspended 26 | end 27 | 28 | aasm_event :delete do 29 | transitions :from => [:passive, :pending, :active, :suspended], :to => :deleted 30 | end 31 | 32 | aasm_event :unsuspend do 33 | transitions :from => :suspended, :to => :active, :guard => Proc.new {|u| u.has_activated? } 34 | transitions :from => :suspended, :to => :pending, :guard => Proc.new {|u| u.has_activation_code? } 35 | transitions :from => :suspended, :to => :passive 36 | end 37 | 38 | def initialize 39 | # the AR backend uses a before_validate_on_create :aasm_ensure_initial_state 40 | # lets do something similar here for testing purposes. 41 | aasm_enter_initial_state 42 | end 43 | 44 | def make_activation_code 45 | @activation_code = 'moo' 46 | end 47 | 48 | def do_activate 49 | @activated_at = Time.now 50 | @activation_code = nil 51 | end 52 | 53 | def do_delete 54 | @deleted_at = Time.now 55 | end 56 | 57 | def do_undelete 58 | @deleted_at = false 59 | end 60 | 61 | def can_register? 62 | true 63 | end 64 | 65 | def has_activated? 66 | !!@activated_at 67 | end 68 | 69 | def has_activation_code? 70 | !!@activation_code 71 | end 72 | end 73 | 74 | class AuthMachineTest < Test::Unit::TestCase 75 | context 'authentication state machine' do 76 | context 'on initialization' do 77 | setup do 78 | @auth = AuthMachine.new 79 | end 80 | 81 | should 'be in the pending state' do 82 | assert_equal :pending, @auth.aasm_current_state 83 | end 84 | 85 | should 'have an activation code' do 86 | assert @auth.has_activation_code? 87 | assert_not_nil @auth.activation_code 88 | end 89 | end 90 | 91 | context 'when being unsuspended' do 92 | should 'be active if previously activated' do 93 | @auth = AuthMachine.new 94 | @auth.activate! 95 | @auth.suspend! 96 | @auth.unsuspend! 97 | 98 | assert_equal :active, @auth.aasm_current_state 99 | end 100 | 101 | should 'be pending if not previously activated, but an activation code is present' do 102 | @auth = AuthMachine.new 103 | @auth.suspend! 104 | @auth.unsuspend! 105 | 106 | assert_equal :pending, @auth.aasm_current_state 107 | end 108 | 109 | should 'be passive if not previously activated and there is no activation code' do 110 | @auth = AuthMachine.new 111 | @auth.activation_code = nil 112 | @auth.suspend! 113 | @auth.unsuspend! 114 | 115 | assert_equal :passive, @auth.aasm_current_state 116 | end 117 | end 118 | 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = AASM - Ruby state machines 2 | 3 | This package contains AASM, a library for adding finite state machines to Ruby classes. 4 | 5 | AASM started as the acts_as_state_machine plugin but has evolved into a more generic library that no longer targets only ActiveRecord models. 6 | 7 | AASM has the following features: 8 | 9 | * States 10 | * Machines 11 | * Events 12 | * Transitions 13 | 14 | == New Callbacks 15 | 16 | The callback chain & order on a successful event looks like: 17 | 18 | oldstate:exit* 19 | event:before 20 | __find transition, if possible__ 21 | transition:on_transition* 22 | oldstate:before_exit 23 | newstate:before_enter 24 | newstate:enter* 25 | __update state__ 26 | event:success* 27 | oldstate:after_exit 28 | newstate:after_enter 29 | event:after 30 | obj:aasm_event_fired* 31 | 32 | (*) marks old callbacks 33 | 34 | 35 | == Download 36 | 37 | The latest AASM can currently be pulled from the git repository on github. 38 | 39 | * http://github.com/rubyist/aasm/tree/master 40 | 41 | 42 | == Installation 43 | 44 | === From gemcutter 45 | 46 | % sudo gem install gemcutter 47 | % sudo gem tumble 48 | % sudo gem install aasm 49 | 50 | === From GitHub hosted gems (only older releases are available) 51 | 52 | % sudo gem sources -a http://gems.github.com # (you only need to do this once) 53 | % sudo gem install rubyist-aasm 54 | 55 | === Building your own gems 56 | 57 | % rake gemspec 58 | % rake build 59 | % sudo gem install pkg/aasm-2.1.gem 60 | 61 | 62 | == Simple Example 63 | 64 | Here's a quick example highlighting some of the features. 65 | 66 | class Conversation 67 | include AASM 68 | 69 | aasm_initial_state :unread 70 | 71 | aasm_state :unread 72 | aasm_state :read 73 | aasm_state :closed 74 | 75 | 76 | aasm_event :view do 77 | transitions :to => :read, :from => [:unread] 78 | end 79 | 80 | aasm_event :close do 81 | transitions :to => :closed, :from => [:read, :unread] 82 | end 83 | end 84 | 85 | == A Slightly More Complex Example 86 | 87 | This example uses a few of the more complex features available. 88 | 89 | class Relationship 90 | include AASM 91 | 92 | aasm_initial_state Proc.new { |relationship| relationship.strictly_for_fun? ? :intimate : :dating } 93 | 94 | aasm_state :dating, :enter => :make_happy, :exit => :make_depressed 95 | aasm_state :intimate, :enter => :make_very_happy, :exit => :never_speak_again 96 | aasm_state :married, :enter => :give_up_intimacy, :exit => :buy_exotic_car_and_wear_a_combover 97 | 98 | aasm_event :get_intimate do 99 | transitions :to => :intimate, :from => [:dating], :guard => :drunk? 100 | end 101 | 102 | aasm_event :get_married do 103 | transitions :to => :married, :from => [:dating, :intimate], :guard => :willing_to_give_up_manhood? 104 | end 105 | 106 | def strictly_for_fun?; end 107 | def drunk?; end 108 | def willing_to_give_up_manhood?; end 109 | def make_happy; end 110 | def make_depressed; end 111 | def make_very_happy; end 112 | def never_speak_again; end 113 | def give_up_intimacy; end 114 | def buy_exotic_car_and_wear_a_combover; end 115 | end 116 | 117 | = Other Stuff 118 | 119 | Author:: Scott Barron 120 | License:: Original code Copyright 2006, 2007, 2008 by Scott Barron. 121 | Released under an MIT-style license. See the LICENSE file 122 | included in the distribution. 123 | 124 | == Warranty 125 | 126 | This software is provided "as is" and without any express or 127 | implied warranties, including, without limitation, the implied 128 | warranties of merchantibility and fitness for a particular 129 | purpose. 130 | -------------------------------------------------------------------------------- /spec/unit/event_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | describe AASM::SupportingClasses::Event do 4 | before(:each) do 5 | @name = :close_order 6 | @success = :success_callback 7 | end 8 | 9 | def new_event 10 | @event = AASM::SupportingClasses::Event.new(@name, {:success => @success}) do 11 | transitions :to => :closed, :from => [:open, :received] 12 | end 13 | end 14 | 15 | it 'should set the name' do 16 | new_event 17 | @event.name.should == @name 18 | end 19 | 20 | it 'should set the success option' do 21 | new_event 22 | @event.success.should == @success 23 | end 24 | 25 | it 'should create StateTransitions' do 26 | AASM::SupportingClasses::StateTransition.should_receive(:new).with({:to => :closed, :from => :open}) 27 | AASM::SupportingClasses::StateTransition.should_receive(:new).with({:to => :closed, :from => :received}) 28 | new_event 29 | end 30 | end 31 | 32 | describe AASM::SupportingClasses::Event, 'when firing an event' do 33 | it 'should raise an AASM::InvalidTransition error if the transitions are empty' do 34 | obj = mock('object') 35 | obj.stub!(:aasm_current_state) 36 | 37 | event = AASM::SupportingClasses::Event.new(:event) 38 | lambda { event.fire(obj) }.should raise_error(AASM::InvalidTransition) 39 | end 40 | 41 | it 'should return the state of the first matching transition it finds' do 42 | event = AASM::SupportingClasses::Event.new(:event) do 43 | transitions :to => :closed, :from => [:open, :received] 44 | end 45 | 46 | obj = mock('object') 47 | obj.stub!(:aasm_current_state).and_return(:open) 48 | 49 | event.fire(obj).should == :closed 50 | end 51 | end 52 | 53 | describe AASM::SupportingClasses::Event, 'when executing the success callback' do 54 | class ThisNameBetterNotBeInUse 55 | include AASM 56 | 57 | aasm_state :initial 58 | aasm_state :symbol 59 | aasm_state :string 60 | aasm_state :array 61 | aasm_state :proc 62 | end 63 | 64 | it "should send the success callback if it's a symbol" do 65 | ThisNameBetterNotBeInUse.instance_eval { 66 | aasm_event :with_symbol, :success => :symbol_success_callback do 67 | transitions :to => :symbol, :from => [:initial] 68 | end 69 | } 70 | 71 | model = ThisNameBetterNotBeInUse.new 72 | model.should_receive(:symbol_success_callback) 73 | model.with_symbol! 74 | end 75 | 76 | it "should send the success callback if it's a string" do 77 | ThisNameBetterNotBeInUse.instance_eval { 78 | aasm_event :with_string, :success => 'string_success_callback' do 79 | transitions :to => :string, :from => [:initial] 80 | end 81 | } 82 | 83 | model = ThisNameBetterNotBeInUse.new 84 | model.should_receive(:string_success_callback) 85 | model.with_string! 86 | end 87 | 88 | it "should call each success callback if passed an array of strings and/or symbols" do 89 | ThisNameBetterNotBeInUse.instance_eval { 90 | aasm_event :with_array, :success => [:success_callback1, 'success_callback2'] do 91 | transitions :to => :array, :from => [:initial] 92 | end 93 | } 94 | 95 | model = ThisNameBetterNotBeInUse.new 96 | model.should_receive(:success_callback1) 97 | model.should_receive(:success_callback2) 98 | model.with_array! 99 | end 100 | 101 | it "should call each success callback if passed an array of strings and/or symbols and/or procs" do 102 | ThisNameBetterNotBeInUse.instance_eval { 103 | aasm_event :with_array_including_procs, :success => [:success_callback1, 'success_callback2', lambda { |obj| obj.proc_success_callback }] do 104 | transitions :to => :array, :from => [:initial] 105 | end 106 | } 107 | 108 | model = ThisNameBetterNotBeInUse.new 109 | model.should_receive(:success_callback1) 110 | model.should_receive(:success_callback2) 111 | model.should_receive(:proc_success_callback) 112 | model.with_array_including_procs! 113 | end 114 | 115 | it "should call the success callback if it's a proc" do 116 | ThisNameBetterNotBeInUse.instance_eval { 117 | aasm_event :with_proc, :success => lambda { |obj| obj.proc_success_callback } do 118 | transitions :to => :proc, :from => [:initial] 119 | end 120 | } 121 | 122 | model = ThisNameBetterNotBeInUse.new 123 | model.should_receive(:proc_success_callback) 124 | model.with_proc! 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/aasm/aasm.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'event') 2 | require File.join(File.dirname(__FILE__), 'state') 3 | require File.join(File.dirname(__FILE__), 'state_machine') 4 | require File.join(File.dirname(__FILE__), 'persistence') 5 | 6 | module AASM 7 | class InvalidTransition < RuntimeError 8 | end 9 | 10 | class UndefinedState < RuntimeError 11 | end 12 | 13 | def self.included(base) #:nodoc: 14 | base.extend AASM::ClassMethods 15 | AASM::Persistence.set_persistence(base) 16 | unless AASM::StateMachine[base] 17 | AASM::StateMachine[base] = AASM::StateMachine.new('') 18 | end 19 | end 20 | 21 | module ClassMethods 22 | def inherited(klass) 23 | AASM::StateMachine[klass] = AASM::StateMachine[self].clone 24 | super 25 | end 26 | 27 | def aasm_initial_state(set_state=nil) 28 | if set_state 29 | AASM::StateMachine[self].initial_state = set_state 30 | else 31 | AASM::StateMachine[self].initial_state 32 | end 33 | end 34 | 35 | def aasm_initial_state=(state) 36 | AASM::StateMachine[self].initial_state = state 37 | end 38 | 39 | def aasm_state(name, options={}) 40 | sm = AASM::StateMachine[self] 41 | sm.create_state(name, options) 42 | sm.initial_state = name unless sm.initial_state 43 | 44 | define_method("#{name.to_s}?") do 45 | aasm_current_state == name 46 | end 47 | end 48 | 49 | def aasm_event(name, options = {}, &block) 50 | sm = AASM::StateMachine[self] 51 | 52 | unless sm.events.has_key?(name) 53 | sm.events[name] = AASM::SupportingClasses::Event.new(name, options, &block) 54 | end 55 | 56 | define_method("#{name.to_s}!") do |*args| 57 | aasm_fire_event(name, true, *args) 58 | end 59 | 60 | define_method("#{name.to_s}") do |*args| 61 | aasm_fire_event(name, false, *args) 62 | end 63 | end 64 | 65 | def aasm_states 66 | AASM::StateMachine[self].states 67 | end 68 | 69 | def aasm_events 70 | AASM::StateMachine[self].events 71 | end 72 | 73 | def aasm_states_for_select 74 | AASM::StateMachine[self].states.map { |state| state.for_select } 75 | end 76 | 77 | end 78 | 79 | # Instance methods 80 | def aasm_current_state 81 | return @aasm_current_state if @aasm_current_state 82 | 83 | if self.respond_to?(:aasm_read_state) || self.private_methods.include?('aasm_read_state') 84 | @aasm_current_state = aasm_read_state 85 | end 86 | return @aasm_current_state if @aasm_current_state 87 | 88 | aasm_enter_initial_state 89 | end 90 | 91 | def aasm_enter_initial_state 92 | state_name = aasm_determine_state_name(self.class.aasm_initial_state) 93 | state = aasm_state_object_for_state(state_name) 94 | 95 | state.call_action(:before_enter, self) 96 | state.call_action(:enter, self) 97 | self.aasm_current_state = state_name 98 | state.call_action(:after_enter, self) 99 | 100 | state_name 101 | end 102 | 103 | def aasm_events_for_current_state 104 | aasm_events_for_state(aasm_current_state) 105 | end 106 | 107 | def aasm_events_for_state(state) 108 | events = self.class.aasm_events.values.select {|event| event.transitions_from_state?(state) } 109 | events.map {|event| event.name} 110 | end 111 | 112 | private 113 | 114 | def set_aasm_current_state_with_persistence(state) 115 | save_success = true 116 | if self.respond_to?(:aasm_write_state) || self.private_methods.include?('aasm_write_state') 117 | save_success = aasm_write_state(state) 118 | end 119 | self.aasm_current_state = state if save_success 120 | 121 | save_success 122 | end 123 | 124 | def aasm_current_state=(state) 125 | if self.respond_to?(:aasm_write_state_without_persistence) || self.private_methods.include?('aasm_write_state_without_persistence') 126 | aasm_write_state_without_persistence(state) 127 | end 128 | @aasm_current_state = state 129 | end 130 | 131 | def aasm_determine_state_name(state) 132 | case state 133 | when Symbol, String 134 | state 135 | when Proc 136 | state.call(self) 137 | else 138 | raise NotImplementedError, "Unrecognized state-type given. Expected Symbol, String, or Proc." 139 | end 140 | end 141 | 142 | def aasm_state_object_for_state(name) 143 | obj = self.class.aasm_states.find {|s| s == name} 144 | raise AASM::UndefinedState, "State :#{name} doesn't exist" if obj.nil? 145 | obj 146 | end 147 | 148 | def aasm_fire_event(name, persist, *args) 149 | old_state = aasm_state_object_for_state(aasm_current_state) 150 | event = self.class.aasm_events[name] 151 | 152 | old_state.call_action(:exit, self) 153 | 154 | # new event before callback 155 | event.call_action(:before, self) 156 | 157 | new_state_name = event.fire(self, *args) 158 | 159 | unless new_state_name.nil? 160 | new_state = aasm_state_object_for_state(new_state_name) 161 | 162 | # new before_ callbacks 163 | old_state.call_action(:before_exit, self) 164 | new_state.call_action(:before_enter, self) 165 | 166 | new_state.call_action(:enter, self) 167 | 168 | persist_successful = true 169 | if persist 170 | persist_successful = set_aasm_current_state_with_persistence(new_state_name) 171 | event.execute_success_callback(self) if persist_successful 172 | else 173 | self.aasm_current_state = new_state_name 174 | end 175 | 176 | if persist_successful 177 | old_state.call_action(:after_exit, self) 178 | new_state.call_action(:after_enter, self) 179 | event.call_action(:after, self) 180 | 181 | self.aasm_event_fired(name, old_state.name, self.aasm_current_state) if self.respond_to?(:aasm_event_fired) 182 | else 183 | self.aasm_event_failed(name, old_state.name) if self.respond_to?(:aasm_event_failed) 184 | end 185 | 186 | persist_successful 187 | else 188 | if self.respond_to?(:aasm_event_failed) 189 | self.aasm_event_failed(name, old_state.name) 190 | end 191 | 192 | false 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/aasm/persistence/active_record_persistence.rb: -------------------------------------------------------------------------------- 1 | module AASM 2 | module Persistence 3 | module ActiveRecordPersistence 4 | # This method: 5 | # 6 | # * extends the model with ClassMethods 7 | # * includes InstanceMethods 8 | # 9 | # Unless the corresponding methods are already defined, it includes 10 | # * ReadState 11 | # * WriteState 12 | # * WriteStateWithoutPersistence 13 | # 14 | # Adds 15 | # 16 | # before_validation_on_create :aasm_ensure_initial_state 17 | # 18 | # As a result, it doesn't matter when you define your methods - the following 2 are equivalent 19 | # 20 | # class Foo < ActiveRecord::Base 21 | # def aasm_write_state(state) 22 | # "bar" 23 | # end 24 | # include AASM 25 | # end 26 | # 27 | # class Foo < ActiveRecord::Base 28 | # include AASM 29 | # def aasm_write_state(state) 30 | # "bar" 31 | # end 32 | # end 33 | # 34 | def self.included(base) 35 | base.extend AASM::Persistence::ActiveRecordPersistence::ClassMethods 36 | base.send(:include, AASM::Persistence::ActiveRecordPersistence::InstanceMethods) 37 | base.send(:include, AASM::Persistence::ActiveRecordPersistence::ReadState) unless base.method_defined?(:aasm_read_state) 38 | base.send(:include, AASM::Persistence::ActiveRecordPersistence::WriteState) unless base.method_defined?(:aasm_write_state) 39 | base.send(:include, AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence) unless base.method_defined?(:aasm_write_state_without_persistence) 40 | 41 | if base.respond_to?(:named_scope) 42 | base.extend(AASM::Persistence::ActiveRecordPersistence::NamedScopeMethods) 43 | 44 | base.class_eval do 45 | class << self 46 | unless method_defined?(:aasm_state_without_named_scope) 47 | alias_method :aasm_state_without_named_scope, :aasm_state 48 | alias_method :aasm_state, :aasm_state_with_named_scope 49 | end 50 | end 51 | end 52 | end 53 | 54 | base.before_validation_on_create :aasm_ensure_initial_state 55 | end 56 | 57 | module ClassMethods 58 | # Maps to the aasm_column in the database. Deafults to "aasm_state". You can write: 59 | # 60 | # create_table :foos do |t| 61 | # t.string :name 62 | # t.string :aasm_state 63 | # end 64 | # 65 | # class Foo < ActiveRecord::Base 66 | # include AASM 67 | # end 68 | # 69 | # OR: 70 | # 71 | # create_table :foos do |t| 72 | # t.string :name 73 | # t.string :status 74 | # end 75 | # 76 | # class Foo < ActiveRecord::Base 77 | # include AASM 78 | # aasm_column :status 79 | # end 80 | # 81 | # This method is both a getter and a setter 82 | def aasm_column(column_name=nil) 83 | if column_name 84 | AASM::StateMachine[self].config.column = column_name.to_sym 85 | # @aasm_column = column_name.to_sym 86 | else 87 | AASM::StateMachine[self].config.column ||= :aasm_state 88 | # @aasm_column ||= :aasm_state 89 | end 90 | # @aasm_column 91 | AASM::StateMachine[self].config.column 92 | end 93 | 94 | def find_in_state(number, state, *args) 95 | with_state_scope state do 96 | find(number, *args) 97 | end 98 | end 99 | 100 | def count_in_state(state, *args) 101 | with_state_scope state do 102 | count(*args) 103 | end 104 | end 105 | 106 | def calculate_in_state(state, *args) 107 | with_state_scope state do 108 | calculate(*args) 109 | end 110 | end 111 | 112 | protected 113 | def with_state_scope(state) 114 | with_scope :find => {:conditions => ["#{table_name}.#{aasm_column} = ?", state.to_s]} do 115 | yield if block_given? 116 | end 117 | end 118 | end 119 | 120 | module InstanceMethods 121 | 122 | # Returns the current aasm_state of the object. Respects reload and 123 | # any changes made to the aasm_state field directly 124 | # 125 | # Internally just calls aasm_read_state 126 | # 127 | # foo = Foo.find(1) 128 | # foo.aasm_current_state # => :pending 129 | # foo.aasm_state = "opened" 130 | # foo.aasm_current_state # => :opened 131 | # foo.close # => calls aasm_write_state_without_persistence 132 | # foo.aasm_current_state # => :closed 133 | # foo.reload 134 | # foo.aasm_current_state # => :pending 135 | # 136 | def aasm_current_state 137 | @current_state = aasm_read_state 138 | end 139 | 140 | private 141 | 142 | # Ensures that if the aasm_state column is nil and the record is new 143 | # that the initial state gets populated before validation on create 144 | # 145 | # foo = Foo.new 146 | # foo.aasm_state # => nil 147 | # foo.valid? 148 | # foo.aasm_state # => "open" (where :open is the initial state) 149 | # 150 | # 151 | # foo = Foo.find(:first) 152 | # foo.aasm_state # => 1 153 | # foo.aasm_state = nil 154 | # foo.valid? 155 | # foo.aasm_state # => nil 156 | # 157 | def aasm_ensure_initial_state 158 | send("#{self.class.aasm_column}=", self.aasm_enter_initial_state.to_s) if send(self.class.aasm_column).blank? 159 | end 160 | 161 | end 162 | 163 | module WriteStateWithoutPersistence 164 | # Writes state to the state column, but does not persist it to the database 165 | # 166 | # foo = Foo.find(1) 167 | # foo.aasm_current_state # => :opened 168 | # foo.close 169 | # foo.aasm_current_state # => :closed 170 | # Foo.find(1).aasm_current_state # => :opened 171 | # foo.save 172 | # foo.aasm_current_state # => :closed 173 | # Foo.find(1).aasm_current_state # => :closed 174 | # 175 | # NOTE: intended to be called from an event 176 | def aasm_write_state_without_persistence(state) 177 | write_attribute(self.class.aasm_column, state.to_s) 178 | end 179 | end 180 | 181 | module WriteState 182 | # Writes state to the state column and persists it to the database 183 | # using update_attribute (which bypasses validation) 184 | # 185 | # foo = Foo.find(1) 186 | # foo.aasm_current_state # => :opened 187 | # foo.close! 188 | # foo.aasm_current_state # => :closed 189 | # Foo.find(1).aasm_current_state # => :closed 190 | # 191 | # NOTE: intended to be called from an event 192 | def aasm_write_state(state) 193 | old_value = read_attribute(self.class.aasm_column) 194 | write_attribute(self.class.aasm_column, state.to_s) 195 | 196 | unless self.save(:validation => false) 197 | write_attribute(self.class.aasm_column, old_value) 198 | return false 199 | end 200 | 201 | true 202 | end 203 | end 204 | 205 | module ReadState 206 | 207 | # Returns the value of the aasm_column - called from aasm_current_state 208 | # 209 | # If it's a new record, and the aasm state column is blank it returns the initial state: 210 | # 211 | # class Foo < ActiveRecord::Base 212 | # include AASM 213 | # aasm_column :status 214 | # aasm_state :opened 215 | # aasm_state :closed 216 | # end 217 | # 218 | # foo = Foo.new 219 | # foo.current_state # => :opened 220 | # foo.close 221 | # foo.current_state # => :closed 222 | # 223 | # foo = Foo.find(1) 224 | # foo.current_state # => :opened 225 | # foo.aasm_state = nil 226 | # foo.current_state # => nil 227 | # 228 | # NOTE: intended to be called from an event 229 | # 230 | # This allows for nil aasm states - be sure to add validation to your model 231 | def aasm_read_state 232 | if new_record? 233 | send(self.class.aasm_column).blank? ? aasm_determine_state_name(self.class.aasm_initial_state) : send(self.class.aasm_column).to_sym 234 | else 235 | send(self.class.aasm_column).nil? ? nil : send(self.class.aasm_column).to_sym 236 | end 237 | end 238 | end 239 | 240 | module NamedScopeMethods 241 | def aasm_state_with_named_scope name, options = {} 242 | aasm_state_without_named_scope name, options 243 | self.named_scope name, :conditions => { "#{table_name}.#{self.aasm_column}" => name.to_s} unless self.respond_to?(name) 244 | end 245 | end 246 | end 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /spec/unit/active_record_persistence_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', '..', 'lib', 'aasm') 2 | 3 | begin 4 | require 'rubygems' 5 | require 'active_record' 6 | 7 | # A dummy class for mocking the activerecord connection class 8 | class Connection 9 | end 10 | 11 | class FooBar < ActiveRecord::Base 12 | include AASM 13 | 14 | # Fake this column for testing purposes 15 | attr_accessor :aasm_state 16 | 17 | aasm_state :open 18 | aasm_state :closed 19 | 20 | aasm_event :view do 21 | transitions :to => :read, :from => [:needs_attention] 22 | end 23 | end 24 | 25 | class Fi < ActiveRecord::Base 26 | def aasm_read_state 27 | "fi" 28 | end 29 | include AASM 30 | end 31 | 32 | class Fo < ActiveRecord::Base 33 | def aasm_write_state(state) 34 | "fo" 35 | end 36 | include AASM 37 | end 38 | 39 | class Fum < ActiveRecord::Base 40 | def aasm_write_state_without_persistence(state) 41 | "fum" 42 | end 43 | include AASM 44 | end 45 | 46 | class June < ActiveRecord::Base 47 | include AASM 48 | aasm_column :status 49 | end 50 | 51 | class Beaver < June 52 | end 53 | 54 | class Thief < ActiveRecord::Base 55 | include AASM 56 | aasm_initial_state Proc.new { |thief| thief.skilled ? :rich : :jailed } 57 | aasm_state :rich 58 | aasm_state :jailed 59 | attr_accessor :skilled, :aasm_state 60 | end 61 | 62 | describe "aasm model", :shared => true do 63 | it "should include AASM::Persistence::ActiveRecordPersistence" do 64 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence) 65 | end 66 | it "should include AASM::Persistence::ActiveRecordPersistence::InstanceMethods" do 67 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::InstanceMethods) 68 | end 69 | end 70 | 71 | describe FooBar, "class methods" do 72 | before(:each) do 73 | @klass = FooBar 74 | end 75 | it_should_behave_like "aasm model" 76 | it "should include AASM::Persistence::ActiveRecordPersistence::ReadState" do 77 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::ReadState) 78 | end 79 | it "should include AASM::Persistence::ActiveRecordPersistence::WriteState" do 80 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::WriteState) 81 | end 82 | it "should include AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence" do 83 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence) 84 | end 85 | end 86 | 87 | describe Fi, "class methods" do 88 | before(:each) do 89 | @klass = Fi 90 | end 91 | it_should_behave_like "aasm model" 92 | it "should not include AASM::Persistence::ActiveRecordPersistence::ReadState" do 93 | @klass.included_modules.should_not be_include(AASM::Persistence::ActiveRecordPersistence::ReadState) 94 | end 95 | it "should include AASM::Persistence::ActiveRecordPersistence::WriteState" do 96 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::WriteState) 97 | end 98 | it "should include AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence" do 99 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence) 100 | end 101 | end 102 | 103 | describe Fo, "class methods" do 104 | before(:each) do 105 | @klass = Fo 106 | end 107 | it_should_behave_like "aasm model" 108 | it "should include AASM::Persistence::ActiveRecordPersistence::ReadState" do 109 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::ReadState) 110 | end 111 | it "should not include AASM::Persistence::ActiveRecordPersistence::WriteState" do 112 | @klass.included_modules.should_not be_include(AASM::Persistence::ActiveRecordPersistence::WriteState) 113 | end 114 | it "should include AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence" do 115 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence) 116 | end 117 | end 118 | 119 | describe Fum, "class methods" do 120 | before(:each) do 121 | @klass = Fum 122 | end 123 | it_should_behave_like "aasm model" 124 | it "should include AASM::Persistence::ActiveRecordPersistence::ReadState" do 125 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::ReadState) 126 | end 127 | it "should include AASM::Persistence::ActiveRecordPersistence::WriteState" do 128 | @klass.included_modules.should be_include(AASM::Persistence::ActiveRecordPersistence::WriteState) 129 | end 130 | it "should not include AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence" do 131 | @klass.included_modules.should_not be_include(AASM::Persistence::ActiveRecordPersistence::WriteStateWithoutPersistence) 132 | end 133 | end 134 | 135 | describe FooBar, "instance methods" do 136 | before(:each) do 137 | connection = mock(Connection, :columns => []) 138 | FooBar.stub!(:connection).and_return(connection) 139 | end 140 | 141 | it "should respond to aasm read state when not previously defined" do 142 | FooBar.new.should respond_to(:aasm_read_state) 143 | end 144 | 145 | it "should respond to aasm write state when not previously defined" do 146 | FooBar.new.should respond_to(:aasm_write_state) 147 | end 148 | 149 | it "should respond to aasm write state without persistence when not previously defined" do 150 | FooBar.new.should respond_to(:aasm_write_state_without_persistence) 151 | end 152 | 153 | it "should return the initial state when new and the aasm field is nil" do 154 | FooBar.new.aasm_current_state.should == :open 155 | end 156 | 157 | it "should return the aasm column when new and the aasm field is not nil" do 158 | foo = FooBar.new 159 | foo.aasm_state = "closed" 160 | foo.aasm_current_state.should == :closed 161 | end 162 | 163 | it "should return the aasm column when not new and the aasm_column is not nil" do 164 | foo = FooBar.new 165 | foo.stub!(:new_record?).and_return(false) 166 | foo.aasm_state = "state" 167 | foo.aasm_current_state.should == :state 168 | end 169 | 170 | it "should allow a nil state" do 171 | foo = FooBar.new 172 | foo.stub!(:new_record?).and_return(false) 173 | foo.aasm_state = nil 174 | foo.aasm_current_state.should be_nil 175 | end 176 | 177 | it "should have aasm_ensure_initial_state" do 178 | foo = FooBar.new 179 | foo.send :aasm_ensure_initial_state 180 | end 181 | 182 | it "should call aasm_ensure_initial_state on validation before create" do 183 | foo = FooBar.new 184 | foo.should_receive(:aasm_ensure_initial_state).and_return(true) 185 | foo.valid? 186 | end 187 | 188 | it "should call aasm_ensure_initial_state on validation before create" do 189 | foo = FooBar.new 190 | foo.stub!(:new_record?).and_return(false) 191 | foo.should_not_receive(:aasm_ensure_initial_state) 192 | foo.valid? 193 | end 194 | 195 | end 196 | 197 | describe 'Beavers' do 198 | it "should have the same states as it's parent" do 199 | Beaver.aasm_states.should == June.aasm_states 200 | end 201 | 202 | it "should have the same events as it's parent" do 203 | Beaver.aasm_events.should == June.aasm_events 204 | end 205 | 206 | it "should have the same column as it's parent" do 207 | Beaver.aasm_column.should == :status 208 | end 209 | end 210 | 211 | describe AASM::Persistence::ActiveRecordPersistence::NamedScopeMethods do 212 | class NamedScopeExample < ActiveRecord::Base 213 | include AASM 214 | end 215 | 216 | context "Does not already respond_to? the scope name" do 217 | it "should add a named_scope" do 218 | NamedScopeExample.should_receive(:named_scope) 219 | NamedScopeExample.aasm_state :unknown_scope 220 | end 221 | end 222 | 223 | context "Already respond_to? the scope name" do 224 | it "should not add a named_scope" do 225 | NamedScopeExample.should_not_receive(:named_scope) 226 | NamedScopeExample.aasm_state :new 227 | end 228 | end 229 | end 230 | 231 | describe 'Thieves' do 232 | before(:each) do 233 | connection = mock(Connection, :columns => []) 234 | Thief.stub!(:connection).and_return(connection) 235 | end 236 | 237 | it 'should be rich if they\'re skilled' do 238 | Thief.new(:skilled => true).aasm_current_state.should == :rich 239 | end 240 | 241 | it 'should be jailed if they\'re unskilled' do 242 | Thief.new(:skilled => false).aasm_current_state.should == :jailed 243 | end 244 | end 245 | 246 | # TODO: figure out how to test ActiveRecord reload! without a database 247 | 248 | rescue LoadError => e 249 | if e.message == "no such file to load -- active_record" 250 | puts "You must install active record to run this spec. Install with sudo gem install activerecord" 251 | else 252 | raise 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /spec/unit/aasm_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | class Foo 4 | include AASM 5 | aasm_initial_state :open 6 | aasm_state :open, :exit => :exit 7 | aasm_state :closed, :enter => :enter 8 | 9 | aasm_event :close, :success => :success_callback do 10 | transitions :to => :closed, :from => [:open] 11 | end 12 | 13 | aasm_event :null do 14 | transitions :to => :closed, :from => [:open], :guard => :always_false 15 | end 16 | 17 | def always_false 18 | false 19 | end 20 | 21 | def success_callback 22 | end 23 | 24 | def enter 25 | end 26 | def exit 27 | end 28 | end 29 | 30 | class FooTwo < Foo 31 | include AASM 32 | aasm_state :foo 33 | end 34 | 35 | class Bar 36 | include AASM 37 | 38 | aasm_state :read 39 | aasm_state :ended 40 | 41 | aasm_event :foo do 42 | transitions :to => :ended, :from => [:read] 43 | end 44 | end 45 | 46 | class Baz < Bar 47 | end 48 | 49 | class Banker 50 | include AASM 51 | aasm_initial_state Proc.new { |banker| banker.rich? ? :retired : :selling_bad_mortgages } 52 | aasm_state :retired 53 | aasm_state :selling_bad_mortgages 54 | RICH = 1_000_000 55 | attr_accessor :balance 56 | def initialize(balance = 0); self.balance = balance; end 57 | def rich?; self.balance >= RICH; end 58 | end 59 | 60 | 61 | describe AASM, '- class level definitions' do 62 | it 'should define a class level aasm_initial_state() method on its including class' do 63 | Foo.should respond_to(:aasm_initial_state) 64 | end 65 | 66 | it 'should define a class level aasm_state() method on its including class' do 67 | Foo.should respond_to(:aasm_state) 68 | end 69 | 70 | it 'should define a class level aasm_event() method on its including class' do 71 | Foo.should respond_to(:aasm_event) 72 | end 73 | 74 | it 'should define a class level aasm_states() method on its including class' do 75 | Foo.should respond_to(:aasm_states) 76 | end 77 | 78 | it 'should define a class level aasm_states_for_select() method on its including class' do 79 | Foo.should respond_to(:aasm_states_for_select) 80 | end 81 | 82 | it 'should define a class level aasm_events() method on its including class' do 83 | Foo.should respond_to(:aasm_events) 84 | end 85 | 86 | end 87 | 88 | 89 | describe AASM, '- subclassing' do 90 | it 'should have the parent states' do 91 | Foo.aasm_states.each do |state| 92 | FooTwo.aasm_states.should include(state) 93 | end 94 | end 95 | 96 | it 'should not add the child states to the parent machine' do 97 | Foo.aasm_states.should_not include(:foo) 98 | end 99 | end 100 | 101 | 102 | describe AASM, '- aasm_states_for_select' do 103 | it "should return a select friendly array of states in the form of [['Friendly name', 'state_name']]" do 104 | Foo.aasm_states_for_select.should == [['Open', 'open'], ['Closed', 'closed']] 105 | end 106 | end 107 | 108 | describe AASM, '- instance level definitions' do 109 | before(:each) do 110 | @foo = Foo.new 111 | end 112 | 113 | it 'should define a state querying instance method on including class' do 114 | @foo.should respond_to(:open?) 115 | end 116 | 117 | it 'should define an event! inance method' do 118 | @foo.should respond_to(:close!) 119 | end 120 | end 121 | 122 | describe AASM, '- initial states' do 123 | before(:each) do 124 | @foo = Foo.new 125 | @bar = Bar.new 126 | end 127 | 128 | it 'should set the initial state' do 129 | @foo.aasm_current_state.should == :open 130 | end 131 | 132 | it '#open? should be initially true' do 133 | @foo.open?.should be_true 134 | end 135 | 136 | it '#closed? should be initially false' do 137 | @foo.closed?.should be_false 138 | end 139 | 140 | it 'should use the first state defined if no initial state is given' do 141 | @bar.aasm_current_state.should == :read 142 | end 143 | 144 | it 'should determine initial state from the Proc results' do 145 | Banker.new(Banker::RICH - 1).aasm_current_state.should == :selling_bad_mortgages 146 | Banker.new(Banker::RICH + 1).aasm_current_state.should == :retired 147 | end 148 | end 149 | 150 | describe AASM, '- event firing with persistence' do 151 | it 'should fire the Event' do 152 | foo = Foo.new 153 | 154 | Foo.aasm_events[:close].should_receive(:fire).with(foo) 155 | foo.close! 156 | end 157 | 158 | it 'should update the current state' do 159 | foo = Foo.new 160 | foo.close! 161 | 162 | foo.aasm_current_state.should == :closed 163 | end 164 | 165 | it 'should call the success callback if one was provided' do 166 | foo = Foo.new 167 | 168 | foo.should_receive(:success_callback) 169 | 170 | foo.close! 171 | end 172 | 173 | it 'should attempt to persist if aasm_write_state is defined' do 174 | foo = Foo.new 175 | 176 | def foo.aasm_write_state 177 | end 178 | 179 | foo.should_receive(:aasm_write_state) 180 | 181 | foo.close! 182 | end 183 | 184 | it 'should return true if aasm_write_state is defined and returns true' do 185 | foo = Foo.new 186 | 187 | def foo.aasm_write_state(state) 188 | true 189 | end 190 | 191 | foo.close!.should be_true 192 | end 193 | 194 | it 'should return false if aasm_write_state is defined and returns false' do 195 | foo = Foo.new 196 | 197 | def foo.aasm_write_state(state) 198 | false 199 | end 200 | 201 | foo.close!.should be_false 202 | end 203 | 204 | it "should not update the aasm_current_state if the write fails" do 205 | foo = Foo.new 206 | 207 | def foo.aasm_write_state 208 | false 209 | end 210 | 211 | foo.should_receive(:aasm_write_state) 212 | 213 | foo.close! 214 | foo.aasm_current_state.should == :open 215 | end 216 | end 217 | 218 | describe AASM, '- event firing without persistence' do 219 | it 'should fire the Event' do 220 | foo = Foo.new 221 | 222 | Foo.aasm_events[:close].should_receive(:fire).with(foo) 223 | foo.close 224 | end 225 | 226 | it 'should update the current state' do 227 | foo = Foo.new 228 | foo.close 229 | 230 | foo.aasm_current_state.should == :closed 231 | end 232 | 233 | it 'should attempt to persist if aasm_write_state is defined' do 234 | foo = Foo.new 235 | 236 | def foo.aasm_write_state 237 | end 238 | 239 | foo.should_receive(:aasm_write_state_without_persistence) 240 | 241 | foo.close 242 | end 243 | end 244 | 245 | describe AASM, '- persistence' do 246 | it 'should read the state if it has not been set and aasm_read_state is defined' do 247 | foo = Foo.new 248 | def foo.aasm_read_state 249 | end 250 | 251 | foo.should_receive(:aasm_read_state) 252 | 253 | foo.aasm_current_state 254 | end 255 | end 256 | 257 | describe AASM, '- getting events for a state' do 258 | it '#aasm_events_for_current_state should use current state' do 259 | foo = Foo.new 260 | foo.should_receive(:aasm_current_state) 261 | foo.aasm_events_for_current_state 262 | end 263 | 264 | it '#aasm_events_for_current_state should use aasm_events_for_state' do 265 | foo = Foo.new 266 | foo.stub!(:aasm_current_state).and_return(:foo) 267 | foo.should_receive(:aasm_events_for_state).with(:foo) 268 | foo.aasm_events_for_current_state 269 | end 270 | end 271 | 272 | describe AASM, '- event callbacks' do 273 | describe "with aasm_event_fired defined" do 274 | before do 275 | @foo = Foo.new 276 | def @foo.aasm_event_fired(event, from, to) 277 | end 278 | end 279 | 280 | it 'should call it for successful bang fire' do 281 | @foo.should_receive(:aasm_event_fired).with(:close, :open, :closed) 282 | @foo.close! 283 | end 284 | 285 | it 'should call it for successful non-bang fire' do 286 | @foo.should_receive(:aasm_event_fired) 287 | @foo.close 288 | end 289 | 290 | it 'should not call it for failing bang fire' do 291 | @foo.stub!(:set_aasm_current_state_with_persistence).and_return(false) 292 | @foo.should_not_receive(:aasm_event_fired) 293 | @foo.close! 294 | end 295 | end 296 | 297 | describe "with aasm_event_failed defined" do 298 | before do 299 | @foo = Foo.new 300 | def @foo.aasm_event_failed(event, from) 301 | end 302 | end 303 | 304 | it 'should call it when transition failed for bang fire' do 305 | @foo.should_receive(:aasm_event_failed).with(:null, :open) 306 | @foo.null! 307 | end 308 | 309 | it 'should call it when transition failed for non-bang fire' do 310 | @foo.should_receive(:aasm_event_failed).with(:null, :open) 311 | @foo.null 312 | end 313 | 314 | it 'should not call it if persist fails for bang fire' do 315 | @foo.stub!(:set_aasm_current_state_with_persistence).and_return(false) 316 | @foo.should_receive(:aasm_event_failed) 317 | @foo.close! 318 | end 319 | end 320 | end 321 | 322 | describe AASM, '- state actions' do 323 | it "should call enter when entering state" do 324 | foo = Foo.new 325 | foo.should_receive(:enter) 326 | 327 | foo.close 328 | end 329 | 330 | it "should call exit when exiting state" do 331 | foo = Foo.new 332 | foo.should_receive(:exit) 333 | 334 | foo.close 335 | end 336 | end 337 | 338 | 339 | describe Baz do 340 | it "should have the same states as it's parent" do 341 | Baz.aasm_states.should == Bar.aasm_states 342 | end 343 | 344 | it "should have the same events as it's parent" do 345 | Baz.aasm_events.should == Bar.aasm_events 346 | end 347 | end 348 | 349 | 350 | class ChetanPatil 351 | include AASM 352 | aasm_initial_state :sleeping 353 | aasm_state :sleeping 354 | aasm_state :showering 355 | aasm_state :working 356 | aasm_state :dating 357 | aasm_state :prettying_up 358 | 359 | aasm_event :wakeup do 360 | transitions :from => :sleeping, :to => [:showering, :working] 361 | end 362 | 363 | aasm_event :dress do 364 | transitions :from => :sleeping, :to => :working, :on_transition => :wear_clothes 365 | transitions :from => :showering, :to => [:working, :dating], :on_transition => Proc.new { |obj, *args| obj.wear_clothes(*args) } 366 | transitions :from => :showering, :to => :prettying_up, :on_transition => [:condition_hair, :fix_hair] 367 | end 368 | 369 | def wear_clothes(shirt_color, trouser_type) 370 | end 371 | 372 | def condition_hair 373 | end 374 | 375 | def fix_hair 376 | end 377 | end 378 | 379 | 380 | describe ChetanPatil do 381 | it 'should transition to specified next state (sleeping to showering)' do 382 | cp = ChetanPatil.new 383 | cp.wakeup! :showering 384 | 385 | cp.aasm_current_state.should == :showering 386 | end 387 | 388 | it 'should transition to specified next state (sleeping to working)' do 389 | cp = ChetanPatil.new 390 | cp.wakeup! :working 391 | 392 | cp.aasm_current_state.should == :working 393 | end 394 | 395 | it 'should transition to default (first or showering) state' do 396 | cp = ChetanPatil.new 397 | cp.wakeup! 398 | 399 | cp.aasm_current_state.should == :showering 400 | end 401 | 402 | it 'should transition to default state when on_transition invoked' do 403 | cp = ChetanPatil.new 404 | cp.dress!(nil, 'purple', 'dressy') 405 | 406 | cp.aasm_current_state.should == :working 407 | end 408 | 409 | it 'should call on_transition method with args' do 410 | cp = ChetanPatil.new 411 | cp.wakeup! :showering 412 | 413 | cp.should_receive(:wear_clothes).with('blue', 'jeans') 414 | cp.dress! :working, 'blue', 'jeans' 415 | end 416 | 417 | it 'should call on_transition proc' do 418 | cp = ChetanPatil.new 419 | cp.wakeup! :showering 420 | 421 | cp.should_receive(:wear_clothes).with('purple', 'slacks') 422 | cp.dress!(:dating, 'purple', 'slacks') 423 | end 424 | 425 | it 'should call on_transition with an array of methods' do 426 | cp = ChetanPatil.new 427 | cp.wakeup! :showering 428 | cp.should_receive(:condition_hair) 429 | cp.should_receive(:fix_hair) 430 | cp.dress!(:prettying_up) 431 | end 432 | end 433 | --------------------------------------------------------------------------------