From b9528ad3c5379896b00772cb44faf1db0fd882d7 Mon Sep 17 00:00:00 2001 From: rick Date: Sat, 28 Jun 2008 00:55:02 -0700 Subject: initial statemachine machine and state classes --- activemodel/lib/active_model/callbacks.rb | 2 +- activemodel/lib/active_model/state_machine.rb | 43 +++ .../lib/active_model/state_machine/machine.rb | 30 ++ .../lib/active_model/state_machine/state.rb | 40 +++ activemodel/lib/active_model/validations.rb | 2 +- activemodel/test/observing_test.rb | 6 +- activemodel/test/state_machine/machine_test.rb | 28 ++ activemodel/test/state_machine/state_test.rb | 73 +++++ activemodel/test/state_machine_test.rb | 335 +++++++++++++++++++++ activemodel/test/test_helper.rb | 18 +- 10 files changed, 571 insertions(+), 6 deletions(-) create mode 100644 activemodel/lib/active_model/state_machine.rb create mode 100644 activemodel/lib/active_model/state_machine/machine.rb create mode 100644 activemodel/lib/active_model/state_machine/state.rb create mode 100644 activemodel/test/state_machine/machine_test.rb create mode 100644 activemodel/test/state_machine/state_test.rb create mode 100644 activemodel/test/state_machine_test.rb diff --git a/activemodel/lib/active_model/callbacks.rb b/activemodel/lib/active_model/callbacks.rb index 1dd20156ec..c94f76109f 100644 --- a/activemodel/lib/active_model/callbacks.rb +++ b/activemodel/lib/active_model/callbacks.rb @@ -1,4 +1,4 @@ -require 'active_model/observing' +require 'active_model/core' module ActiveModel module Callbacks diff --git a/activemodel/lib/active_model/state_machine.rb b/activemodel/lib/active_model/state_machine.rb new file mode 100644 index 0000000000..bb038f6b7a --- /dev/null +++ b/activemodel/lib/active_model/state_machine.rb @@ -0,0 +1,43 @@ +Dir[File.dirname(__FILE__) + "/state_machine/*.rb"].sort.each do |path| + filename = File.basename(path) + require "active_model/state_machine/#{filename}" +end + +module ActiveModel + module StateMachine + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + def inherited(klass) + super + klass.state_machines = state_machines + end + + def state_machines + @state_machines ||= {} + end + + def state_machines=(value) + @state_machines = value ? value.dup : nil + end + + def state_machine(name = nil, options = {}, &block) + if name.is_a?(Hash) + options = name + name = nil + end + name ||= :default + state_machines[name] ||= Machine.new(self, name) + block ? state_machines[name].update(options, &block) : state_machines[name] + end + end + + def current_state(name = nil) + sm = self.class.state_machine(name) + ivar = "@#{sm.name}_current_state" + instance_variable_get(ivar) || instance_variable_set(ivar, sm.initial_state) + end + end +end \ No newline at end of file diff --git a/activemodel/lib/active_model/state_machine/machine.rb b/activemodel/lib/active_model/state_machine/machine.rb new file mode 100644 index 0000000000..75ed8f8b65 --- /dev/null +++ b/activemodel/lib/active_model/state_machine/machine.rb @@ -0,0 +1,30 @@ +module ActiveModel + module StateMachine + class Machine + attr_accessor :initial_state, :states, :event + attr_reader :klass, :name + + def initialize(klass, name) + @klass, @name, @states, @events = klass, name, [], {} + end + + def states_for_select + states.map { |st| [st.display_name, st.name.to_s] } + end + + def state(name, options = {}) + @states << State.new(self, name, options) + end + + def initial_state + @initial_state ||= (states.first ? states.first.name : nil) + end + + def update(options = {}, &block) + @initial_state = options[:initial] + instance_eval(&block) + self + end + end + end +end \ No newline at end of file diff --git a/activemodel/lib/active_model/state_machine/state.rb b/activemodel/lib/active_model/state_machine/state.rb new file mode 100644 index 0000000000..5851a6fb79 --- /dev/null +++ b/activemodel/lib/active_model/state_machine/state.rb @@ -0,0 +1,40 @@ +module ActiveModel + module StateMachine + class State + attr_reader :name, :options + + def initialize(machine, name, options={}) + @machine, @name, @options, @display_name = machine, name, options, options.delete(:display) + machine.klass.send(:define_method, "#{name}?") do + current_state.to_s == name.to_s + end + end + + def ==(state) + if state.is_a? Symbol + name == state + else + name == state.name + end + end + + def call_action(action, record) + action = @options[action] + case action + when Symbol, String + record.send(action) + when Proc + action.call(record) + end + end + + def display_name + @display_name ||= name.to_s.gsub(/_/, ' ').capitalize + end + + def for_select + [display_name, name.to_s] + end + end + end +end diff --git a/activemodel/lib/active_model/validations.rb b/activemodel/lib/active_model/validations.rb index 6b692e6a9a..7efe9901ca 100644 --- a/activemodel/lib/active_model/validations.rb +++ b/activemodel/lib/active_model/validations.rb @@ -1,4 +1,4 @@ -require 'active_model/observing' +require 'active_model/core' module ActiveModel module Validations diff --git a/activemodel/test/observing_test.rb b/activemodel/test/observing_test.rb index 37291ae4c6..6e124de52f 100644 --- a/activemodel/test/observing_test.rb +++ b/activemodel/test/observing_test.rb @@ -1,4 +1,4 @@ -require File.join(File.dirname(__FILE__), 'test_helper') +require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper')) class ObservedModel < ActiveModel::Base class Observer @@ -20,7 +20,7 @@ end class Foo < ActiveModel::Base end -class ObservingTest < ActiveSupport::TestCase +class ObservingTest < ActiveModel::TestCase def setup ObservedModel.observers.clear end @@ -67,7 +67,7 @@ class ObservingTest < ActiveSupport::TestCase end end -class ObserverTest < ActiveSupport::TestCase +class ObserverTest < ActiveModel::TestCase def setup ObservedModel.observers = :foo_observer FooObserver.models = nil diff --git a/activemodel/test/state_machine/machine_test.rb b/activemodel/test/state_machine/machine_test.rb new file mode 100644 index 0000000000..34a4b384ce --- /dev/null +++ b/activemodel/test/state_machine/machine_test.rb @@ -0,0 +1,28 @@ +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper')) + +class MachineTestSubject + include ActiveModel::StateMachine + + state_machine do + end + + state_machine :initial => :foo do + end + + state_machine :extra, :initial => :bar do + end +end + +class StateMachineMachineTest < ActiveModel::TestCase + test "allows reuse of existing machines" do + assert_equal 2, MachineTestSubject.state_machines.size + end + + test "sets #initial_state from :initial option" do + assert_equal :bar, MachineTestSubject.state_machine(:extra).initial_state + end + + test "accesses non-default state machine" do + assert_kind_of ActiveModel::StateMachine::Machine, MachineTestSubject.state_machine(:extra) + end +end \ No newline at end of file diff --git a/activemodel/test/state_machine/state_test.rb b/activemodel/test/state_machine/state_test.rb new file mode 100644 index 0000000000..444435d271 --- /dev/null +++ b/activemodel/test/state_machine/state_test.rb @@ -0,0 +1,73 @@ +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper')) + +class StateTestSubject + include ActiveModel::StateMachine + + state_machine do + end +end + +class StateTest < ActiveModel::TestCase + def setup + @name = :astate + @options = { :crazy_custom_key => 'key' } + @machine = StateTestSubject.state_machine + end + + def new_state(options={}) + ActiveModel::StateMachine::State.new(options.delete(:machine) || @machine, @name, @options.merge(options)) + end + + test 'sets the name' do + assert_equal :astate, new_state.name + end + + test 'sets the display_name from name' do + assert_equal "Astate", new_state.display_name + end + + test 'sets the display_name from options' do + assert_equal "A State", new_state(:display => "A State").display_name + end + + test 'sets the options and expose them as options' do + assert_equal @options, new_state.options + end + + test 'equals a symbol of the same name' do + assert_equal new_state, :astate + end + + test 'equals a State of the same name' do + assert_equal new_state, new_state + end + + uses_mocha 'state actions' do + test 'should send a message to the record for an action if the action is present as a symbol' do + state = new_state(:entering => :foo) + + record = stub + record.expects(:foo) + + state.call_action(:entering, record) + end + + test 'should send a message to the record for an action if the action is present as a string' do + state = new_state(:entering => 'foo') + + record = stub + record.expects(:foo) + + state.call_action(:entering, record) + end + + test 'should call a proc, passing in the record for an action if the action is present' do + state = new_state(:entering => Proc.new {|r| r.foobar}) + + record = stub + record.expects(:foobar) + + state.call_action(:entering, record) + end + end +end \ No newline at end of file diff --git a/activemodel/test/state_machine_test.rb b/activemodel/test/state_machine_test.rb new file mode 100644 index 0000000000..e906744c77 --- /dev/null +++ b/activemodel/test/state_machine_test.rb @@ -0,0 +1,335 @@ +require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper')) + +class StateMachineSubject + include ActiveModel::StateMachine + + state_machine do + state :open, :exit => :exit + state :closed, :enter => :enter + + #event :close, :success => :success_callback do + # transitions :to => :closed, :from => [:open] + #end + # + #event :null do + # transitions :to => :closed, :from => [:open], :guard => :always_false + #end + end + + state_machine :bar do + state :read + state :ended + + #event :foo do + # transitions :to => :ended, :from => [:read] + #end + end + + def always_false + false + end + + def success_callback + end + + def enter + end + def exit + end +end + +class StateMachineSubjectSubclass < StateMachineSubject +end + +class StateMachineClassLevelTest < ActiveModel::TestCase + test 'defines a class level #state_machine method on its including class' do + assert StateMachineSubject.respond_to?(:state_machine) + end + + test 'defines a class level #state_machines method on its including class' do + assert StateMachineSubject.respond_to?(:state_machines) + end + + test 'class level #state_machine returns machine instance' do + assert_kind_of ActiveModel::StateMachine::Machine, StateMachineSubject.state_machine + end + + test 'class level #state_machine returns machine instance with given name' do + assert_kind_of ActiveModel::StateMachine::Machine, StateMachineSubject.state_machine(:default) + end + + test 'class level #state_machines returns hash of machine instances' do + assert_kind_of ActiveModel::StateMachine::Machine, StateMachineSubject.state_machines[:default] + end + + test "should return a select friendly array of states in the form of [['Friendly name', 'state_name']]" do + assert_equal [['Open', 'open'], ['Closed', 'closed']], StateMachineSubject.state_machine.states_for_select + end +end + +class StateMachineInstanceLevelTest < ActiveModel::TestCase + def setup + @foo = StateMachineSubject.new + end + + test 'defines an accessor for the current state' do + assert @foo.respond_to?(:current_state) + end + + test 'defines a state querying instance method on including class' do + assert @foo.respond_to?(:open?) + end + + #test 'should define an event! inance method' do + # assert @foo.respond_to?(:close!) + #end +end + +class StateMachineInitialStatesTest < ActiveModel::TestCase + def setup + @foo = StateMachineSubject.new + end + + test 'should set the initial state' do + assert_equal :open, @foo.current_state + end + + #test '#open? should be initially true' do + # @foo.open?.should be_true + #end + # + #test '#closed? should be initially false' do + # @foo.closed?.should be_false + #end + + test 'should use the first state defined if no initial state is given' do + assert_equal :read, @foo.current_state(:bar) + end +end +# +#describe AASM, '- event firing with persistence' do +# it 'should fire the Event' do +# foo = Foo.new +# +# Foo.aasm_events[:close].should_receive(:fire).with(foo) +# foo.close! +# end +# +# it 'should update the current state' do +# foo = Foo.new +# foo.close! +# +# foo.aasm_current_state.should == :closed +# end +# +# it 'should call the success callback if one was provided' do +# foo = Foo.new +# +# foo.should_receive(:success_callback) +# +# foo.close! +# end +# +# it 'should attempt to persist if aasm_write_state is defined' do +# foo = Foo.new +# +# def foo.aasm_write_state +# end +# +# foo.should_receive(:aasm_write_state) +# +# foo.close! +# end +#end +# +#describe AASM, '- event firing without persistence' do +# it 'should fire the Event' do +# foo = Foo.new +# +# Foo.aasm_events[:close].should_receive(:fire).with(foo) +# foo.close +# end +# +# it 'should update the current state' do +# foo = Foo.new +# foo.close +# +# foo.aasm_current_state.should == :closed +# end +# +# it 'should attempt to persist if aasm_write_state is defined' do +# foo = Foo.new +# +# def foo.aasm_write_state +# end +# +# foo.should_receive(:aasm_write_state_without_persistence) +# +# foo.close +# end +#end +# +#describe AASM, '- persistence' do +# it 'should read the state if it has not been set and aasm_read_state is defined' do +# foo = Foo.new +# def foo.aasm_read_state +# end +# +# foo.should_receive(:aasm_read_state) +# +# foo.aasm_current_state +# end +#end +# +#describe AASM, '- getting events for a state' do +# it '#aasm_events_for_current_state should use current state' do +# foo = Foo.new +# foo.should_receive(:aasm_current_state) +# foo.aasm_events_for_current_state +# end +# +# it '#aasm_events_for_current_state should use aasm_events_for_state' do +# foo = Foo.new +# foo.stub!(:aasm_current_state).and_return(:foo) +# foo.should_receive(:aasm_events_for_state).with(:foo) +# foo.aasm_events_for_current_state +# end +#end +# +#describe AASM, '- event callbacks' do +# it 'should call aasm_event_fired if defined and successful for bang fire' do +# foo = Foo.new +# def foo.aasm_event_fired(from, to) +# end +# +# foo.should_receive(:aasm_event_fired) +# +# foo.close! +# end +# +# it 'should call aasm_event_fired if defined and successful for non-bang fire' do +# foo = Foo.new +# def foo.aasm_event_fired(from, to) +# end +# +# foo.should_receive(:aasm_event_fired) +# +# foo.close +# end +# +# it 'should call aasm_event_failed if defined and transition failed for bang fire' do +# foo = Foo.new +# def foo.aasm_event_failed(event) +# end +# +# foo.should_receive(:aasm_event_failed) +# +# foo.null! +# end +# +# it 'should call aasm_event_failed if defined and transition failed for non-bang fire' do +# foo = Foo.new +# def foo.aasm_event_failed(event) +# end +# +# foo.should_receive(:aasm_event_failed) +# +# foo.null +# end +#end +# +#describe AASM, '- state actions' do +# it "should call enter when entering state" do +# foo = Foo.new +# foo.should_receive(:enter) +# +# foo.close +# end +# +# it "should call exit when exiting state" do +# foo = Foo.new +# foo.should_receive(:exit) +# +# foo.close +# end +#end +# +# +class StateMachineInheritanceTest < ActiveModel::TestCase + test "should have the same states as it's parent" do + assert_equal StateMachineSubject.state_machine.states, StateMachineSubjectSubclass.state_machine.states + end + + #test "should have the same events as it's parent" do + # StateMachineSubjectSubclass.aasm_events.should == Bar.aasm_events + #end +end +# +# +#class ChetanPatil +# include AASM +# aasm_initial_state :sleeping +# aasm_state :sleeping +# aasm_state :showering +# aasm_state :working +# aasm_state :dating +# +# aasm_event :wakeup do +# transitions :from => :sleeping, :to => [:showering, :working] +# end +# +# aasm_event :dress do +# transitions :from => :sleeping, :to => :working, :on_transition => :wear_clothes +# transitions :from => :showering, :to => [:working, :dating], :on_transition => Proc.new { |obj, *args| obj.wear_clothes(*args) } +# end +# +# def wear_clothes(shirt_color, trouser_type) +# end +#end +# +# +#describe ChetanPatil do +# it 'should transition to specified next state (sleeping to showering)' do +# cp = ChetanPatil.new +# cp.wakeup! :showering +# +# cp.aasm_current_state.should == :showering +# end +# +# it 'should transition to specified next state (sleeping to working)' do +# cp = ChetanPatil.new +# cp.wakeup! :working +# +# cp.aasm_current_state.should == :working +# end +# +# it 'should transition to default (first or showering) state' do +# cp = ChetanPatil.new +# cp.wakeup! +# +# cp.aasm_current_state.should == :showering +# end +# +# it 'should transition to default state when on_transition invoked' do +# cp = ChetanPatil.new +# cp.dress!(nil, 'purple', 'dressy') +# +# cp.aasm_current_state.should == :working +# end +# +# it 'should call on_transition method with args' do +# cp = ChetanPatil.new +# cp.wakeup! :showering +# +# cp.should_receive(:wear_clothes).with('blue', 'jeans') +# cp.dress! :working, 'blue', 'jeans' +# end +# +# it 'should call on_transition proc' do +# cp = ChetanPatil.new +# cp.wakeup! :showering +# +# cp.should_receive(:wear_clothes).with('purple', 'slacks') +# cp.dress!(:dating, 'purple', 'slacks') +# end +#end \ No newline at end of file diff --git a/activemodel/test/test_helper.rb b/activemodel/test/test_helper.rb index 8e608fa0bc..ccf93280ec 100644 --- a/activemodel/test/test_helper.rb +++ b/activemodel/test/test_helper.rb @@ -3,7 +3,8 @@ $:.unshift File.dirname(__FILE__) require 'test/unit' require 'active_model' -require 'active_support/callbacks' # needed by ActiveSupport::TestCase +require 'active_model/state_machine' +require 'active_support/callbacks' # needed by ActiveModel::TestCase require 'active_support/test_case' def uses_gem(gem_name, test_name, version = '> 0') @@ -21,3 +22,18 @@ unless defined? uses_mocha uses_gem('mocha', test_name, '>= 0.5.5', &block) end end + +begin + require 'rubygems' + require 'ruby-debug' + Debugger.start +rescue LoadError +end + +ActiveSupport::TestCase.send :include, ActiveSupport::Testing::Default + +module ActiveModel + class TestCase < ActiveSupport::TestCase + include ActiveSupport::Testing::Default + end +end \ No newline at end of file -- cgit v1.2.3