commit f6a8a34f0627857c51a2d0ab3445f031189d8e07 Author: Scott Barron Date: Mon Jan 7 14:11:38 2008 -0500 Import into git diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..3189ba6 --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2006 Scott Barron + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..f4dc8ac --- /dev/null +++ b/Rakefile @@ -0,0 +1,45 @@ +require 'rake' +require 'rake/testtask' +require 'rake/rdoctask' +require 'spec/rake/spectask' + +desc 'Default: run unit tests.' +task :default => [:clean_db, :test] + +desc 'Remove the stale db file' +task :clean_db do + `rm -f #{File.dirname(__FILE__)}/test/state_machine.sqlite.db` +end + +desc 'Test the acts as state machine plugin.' +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end + +desc 'Generate documentation for the acts as state machine plugin.' +Rake::RDocTask.new(:rdoc) do |rdoc| + rdoc.rdoc_dir = 'rdoc' + rdoc.title = 'Acts As State Machine' + rdoc.options << '--line-numbers --inline-source' + rdoc.rdoc_files.include('README') + rdoc.rdoc_files.include('TODO') + rdoc.rdoc_files.include('lib/**/*.rb') +end + +desc "Run all examples with RCov" +Spec::Rake::SpecTask.new('cruise') do |t| + t.spec_files = FileList['spec/*.rb'] + t.rcov = true + t.rcov_opts = ['--exclude', 'spec'] +end + +desc "Run all examples" +Spec::Rake::SpecTask.new('spec') do |t| + t.spec_files = FileList['spec/*.rb'] + t.rcov = false + t.spec_opts = ['-cfs'] +end + +#task :default => [:cruise] diff --git a/aasm.rb b/aasm.rb new file mode 100644 index 0000000..2d02590 --- /dev/null +++ b/aasm.rb @@ -0,0 +1 @@ +require File.join(File.dirname(__FILE__), 'lib', 'aasm') diff --git a/lib/aasm.rb b/lib/aasm.rb new file mode 100644 index 0000000..89432d5 --- /dev/null +++ b/lib/aasm.rb @@ -0,0 +1,32 @@ +module AASM + def self.included(base) #:nodoc: + base.extend AASM::ClassMethods + end + + module ClassMethods + def aasm_initial_state + @aasm_initial_state + end + + def aasm_initial_state=(state) + @aasm_initial_state = state + end + alias :initial_state :aasm_initial_state= + + def state(name, options={}) + define_method("#{name.to_s}?") do + current_state == name + end + self.aasm_initial_state = name unless self.aasm_initial_state + end + + def event(name, options={}, &block) + define_method("#{name.to_s}!") do + end + end + end + + def current_state + @aasm_current_state || self.class.aasm_initial_state + end +end diff --git a/lib/event.rb b/lib/event.rb new file mode 100644 index 0000000..3c8eea5 --- /dev/null +++ b/lib/event.rb @@ -0,0 +1,32 @@ +require File.join(File.dirname(__FILE__), 'state_transition') + +module AASM + module SupportingClasses + class Event + attr_reader :name + + def initialize(name, &block) + @name = name.to_sym + @transitions = [] + instance_eval(&block) if block + end + + def next_states(from) + @transitions.select { |t| t.from == from } + end + + def fire(record) + next_states(record).each do |transition| + break true if transition.perform(record) + end + end + + private + def transitions(trans_opts) + Array(trans_opts[:from]).each do |s| + @transitions << SupportingClasses::StateTransition.new(trans_opts.merge({:from => s.to_sym})) + end + end + end + end +end diff --git a/lib/state.rb b/lib/state.rb new file mode 100644 index 0000000..e99ee6d --- /dev/null +++ b/lib/state.rb @@ -0,0 +1,29 @@ +module AASM + module SupportingClasses + class State + attr_reader :name + + def initialize(name, opts={}) + @name, @opts = name, opts + end + + def entering(record) + enteract = @opts[:enter] + record.send(:run_transition_action, enteract) if enteract + end + + def entered(record) + afteractions = @opts[:after] + return unless afteractions + Array(afteractions).each do |afteract| + record.send(:run_transition_action, afteract) + end + end + + def exited(record) + exitact = @opts[:exit] + record.send(:run_transition_action, exitact) if exitact + end + end + end +end diff --git a/lib/state_factory.rb b/lib/state_factory.rb new file mode 100644 index 0000000..f6d3242 --- /dev/null +++ b/lib/state_factory.rb @@ -0,0 +1,14 @@ +module AASM + module SupportingClasses + class StateFactory + def self.create(name, opts={}) + @states ||= {} + @states[name] ||= State.new(name, opts) + end + + def self.[](name) + @states[name] + end + end + end +end diff --git a/lib/state_transition.rb b/lib/state_transition.rb new file mode 100644 index 0000000..d2462ec --- /dev/null +++ b/lib/state_transition.rb @@ -0,0 +1,40 @@ +module AASM + module SupportingClasses + class StateTransition + attr_reader :from, :to, :opts + + def initialize(opts) + @from, @to, @guard = opts[:from], opts[:to], opts[:guard] + @opts = opts + end + + def guard(obj) + # TODO should probably not be using obj + @guard ? obj.send(:run_transition_action, @guard) : true + end + + def perform(obj) + # TODO should probably not be using obj + return false unless guard(obj) + loopback = obj.current_state == to + # TODO Maybe State should be a factory? + # State[:open] => returns same instance of State.new(:open) + next_state = StateFactory[to] + old_state = StateFactory[obj.current_state] + old_state = states[obj.current_state] + + next_state.entering(obj) unless loopback + + obj.update_attribute(obj.class.state_column, to.to_s) + + next_state.entered(obj) unless loopback + old_state.exited(obj) unless loopback + true + end + + def ==(obj) + @from == obj.from && @to == obj.to + end + end + end +end diff --git a/spec/aasm_spec.rb b/spec/aasm_spec.rb new file mode 100644 index 0000000..f81eebd --- /dev/null +++ b/spec/aasm_spec.rb @@ -0,0 +1,77 @@ +require File.join(File.dirname(__FILE__), '..', 'lib', 'aasm') +require File.join(File.dirname(__FILE__), '..', 'lib', 'state') +require File.join(File.dirname(__FILE__), '..', 'lib', 'state_factory') + +class Foo + include AASM + initial_state :open + state :open + state :closed + + event :close do + end +end + +class Bar + include AASM + state :read + state :ended +end + + +describe AASM, '- class level definitions' do + it 'should define a class level initial_state() method on its including class' do + Foo.should respond_to(:initial_state) + end + + it 'should define a class level state() method on its including class' do + Foo.should respond_to(:state) + end + + it 'should define a class level event() method on its including class' do + Foo.should respond_to(:event) + end +end + +describe AASM, '- instance level definitions' do + before(:each) do + @foo = Foo.new + end + + it 'should define a state querying instance method on including class' do + @foo.should respond_to(:open?) + end + + it 'should define an event! inance method' do + @foo.should respond_to(:close!) + end + + # TODO This isn't necessarily "in play" just yet + #it 'using the state macro should create a new State object' do + # AASM::SupportingClasses::State.should_receive(:new).with(:open, {}) + # Foo.state :open + #end +end + +describe AASM, '- initial states' do + before(:each) do + @foo = Foo.new + @bar = Bar.new + end + + it 'should set the initial state' do + @foo.current_state.should == :open + end + + it '#open? should be initially true' do + @foo.open?.should be_true + end + + it '#closed? should be initially false' do + @foo.closed?.should be_false + end + + it 'should use the first state defined if no initial state is given' do + @bar.current_state.should == :read + end +end diff --git a/spec/event_spec.rb b/spec/event_spec.rb new file mode 100644 index 0000000..eb526a3 --- /dev/null +++ b/spec/event_spec.rb @@ -0,0 +1,41 @@ +require File.join(File.dirname(__FILE__), '..', 'lib', 'event') + +describe AASM::SupportingClasses::Event do + before(:each) do + @name = :close_order + end + + def new_event + @event = AASM::SupportingClasses::Event.new(@name) do + transitions :to => :closed, :from => [:open, :received] + end + end + + it 'should set the name' do + new_event + @event.name.should == @name + end + + it 'create StateTransitions' do + AASM::SupportingClasses::StateTransition.should_receive(:new).with({:to => :closed, :from => :open}) + AASM::SupportingClasses::StateTransition.should_receive(:new).with({:to => :closed, :from => :received}) + new_event + end + + it 'should return an array of the next possible transitions for a state' do + new_event + @event.next_states(:open).size.should == 1 + @event.next_states(:received).size.should == 1 + end + + it '#fire should run #perform on each state transition' do + st = mock('StateTransition') + st.should_receive(:perform) + + new_event + + @event.stub!(:next_states).and_return([st]) + @event.fire(:closed) + end +end + diff --git a/spec/state_factory_spec.rb b/spec/state_factory_spec.rb new file mode 100644 index 0000000..4adb9da --- /dev/null +++ b/spec/state_factory_spec.rb @@ -0,0 +1,40 @@ +require File.join(File.dirname(__FILE__), '..', 'lib', 'state') +require File.join(File.dirname(__FILE__), '..', 'lib', 'state_factory') + +describe AASM::SupportingClasses::StateFactory, '- when creating a new State' do + before(:each) do + @state = :scott + @opts = {:a => 'b'} + + AASM::SupportingClasses::StateFactory.create(@state, @opts) + end + + it 'should create a new State if it has not been created yet' do + AASM::SupportingClasses::State.should_receive(:new).with(:foo, :bar => 'baz') + + AASM::SupportingClasses::StateFactory.create(:foo, :bar => 'baz') + end + + it 'should not create a new State if it has already been created' do + AASM::SupportingClasses::State.should_not_receive(:new).with(@state, @opts) + + AASM::SupportingClasses::StateFactory.create(@state, @opts) + end +end + +describe AASM::SupportingClasses::StateFactory, '- when retrieving a State via []' do + before(:each) do + @state_name = :scottb + @opts = {:a => 'b'} + + AASM::SupportingClasses::StateFactory.create(@state_name, @opts) + end + + it 'should return nil if the State was never created' do + AASM::SupportingClasses::StateFactory[:foo].should be_nil + end + + it 'should return the State' do + AASM::SupportingClasses::StateFactory[@state_name].should_not be_nil + end +end diff --git a/spec/state_spec.rb b/spec/state_spec.rb new file mode 100644 index 0000000..206c495 --- /dev/null +++ b/spec/state_spec.rb @@ -0,0 +1,69 @@ +require File.join(File.dirname(__FILE__), '..', 'lib', 'state') + + + + +# TODO These are specs ported from original aasm +describe AASM::SupportingClasses::State do + before(:each) do + @name = :astate + @options = {} + @record = mock('record') + end + + def new_state + @state = AASM::SupportingClasses::State.new(@name, @options) + end + + it 'should set the name' do + new_state + + @state.name.should == :astate + end + + it '#entering should not run_transition_action if :enter option is not passed' do + new_state + @record.should_not_receive(:run_transition_action) + + @state.entering(@record) + end + + it '#entered should not run_transition_action if :after option is not passed' do + new_state + @record.should_not_receive(:run_transition_action) + + @state.entered(@record) + end + + it '#exited should not run_transition_action if :exit option is not passed' do + new_state + @record.should_not_receive(:run_transition_action) + + @state.exited(@record) + end + + it '#entering should run_transition_action when :enter option is passed' do + @options[:enter] = true + new_state + @record.should_receive(:run_transition_action).with(true) + + @state.entering(@record) + end + + it '#entered should run_transition_action for each option when :after option is passed' do + @options[:after] = ['a', 'b'] + new_state + @record.should_receive(:run_transition_action).once.with('a') + @record.should_receive(:run_transition_action).once.with('b') + + @state.entered(@record) + end + + it '#exited should run_transition_action when :exit option is passed' do + @options[:exit] = true + new_state + @record.should_receive(:run_transition_action).with(true) + + @state.exited(@record) + end +end diff --git a/spec/state_transition_spec.rb b/spec/state_transition_spec.rb new file mode 100644 index 0000000..6846d35 --- /dev/null +++ b/spec/state_transition_spec.rb @@ -0,0 +1,13 @@ +require File.join(File.dirname(__FILE__), '..', 'lib', 'state_transition') + +describe AASM::SupportingClasses::StateTransition do + it 'should set from, to, and opts attr readers' do + opts = {:from => 'foo', :to => 'bar', :guard => 'g'} + st = AASM::SupportingClasses::StateTransition.new(opts) + + st.from.should == opts[:from] + st.to.should == opts[:to] + st.opts.should == opts + end +end +