davidlee-state-fu 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. data/LICENSE +40 -0
  2. data/README.textile +174 -0
  3. data/Rakefile +87 -0
  4. data/lib/no_stdout.rb +32 -0
  5. data/lib/state-fu.rb +93 -0
  6. data/lib/state_fu/binding.rb +262 -0
  7. data/lib/state_fu/core_ext.rb +23 -0
  8. data/lib/state_fu/event.rb +98 -0
  9. data/lib/state_fu/exceptions.rb +42 -0
  10. data/lib/state_fu/fu_space.rb +50 -0
  11. data/lib/state_fu/helper.rb +189 -0
  12. data/lib/state_fu/hooks.rb +28 -0
  13. data/lib/state_fu/interface.rb +139 -0
  14. data/lib/state_fu/lathe.rb +247 -0
  15. data/lib/state_fu/logger.rb +10 -0
  16. data/lib/state_fu/machine.rb +159 -0
  17. data/lib/state_fu/method_factory.rb +95 -0
  18. data/lib/state_fu/persistence/active_record.rb +27 -0
  19. data/lib/state_fu/persistence/attribute.rb +46 -0
  20. data/lib/state_fu/persistence/base.rb +98 -0
  21. data/lib/state_fu/persistence/session.rb +7 -0
  22. data/lib/state_fu/persistence.rb +50 -0
  23. data/lib/state_fu/sprocket.rb +27 -0
  24. data/lib/state_fu/state.rb +45 -0
  25. data/lib/state_fu/transition.rb +213 -0
  26. data/spec/helper.rb +86 -0
  27. data/spec/integration/active_record_persistence_spec.rb +189 -0
  28. data/spec/integration/class_accessor_spec.rb +127 -0
  29. data/spec/integration/event_definition_spec.rb +74 -0
  30. data/spec/integration/ex_machine_for_accounts_spec.rb +79 -0
  31. data/spec/integration/example_01_document_spec.rb +127 -0
  32. data/spec/integration/example_02_string_spec.rb +87 -0
  33. data/spec/integration/instance_accessor_spec.rb +100 -0
  34. data/spec/integration/machine_duplication_spec.rb +95 -0
  35. data/spec/integration/requirement_reflection_spec.rb +201 -0
  36. data/spec/integration/sanity_spec.rb +31 -0
  37. data/spec/integration/state_definition_spec.rb +177 -0
  38. data/spec/integration/transition_spec.rb +1060 -0
  39. data/spec/spec.opts +7 -0
  40. data/spec/units/binding_spec.rb +145 -0
  41. data/spec/units/event_spec.rb +232 -0
  42. data/spec/units/exceptions_spec.rb +75 -0
  43. data/spec/units/fu_space_spec.rb +95 -0
  44. data/spec/units/lathe_spec.rb +567 -0
  45. data/spec/units/machine_spec.rb +237 -0
  46. data/spec/units/method_factory_spec.rb +359 -0
  47. data/spec/units/sprocket_spec.rb +71 -0
  48. data/spec/units/state_spec.rb +50 -0
  49. metadata +122 -0
@@ -0,0 +1,50 @@
1
+ module StateFu
2
+ module Persistence
3
+ DEFAULT_FIELD_NAME_SUFFIX = '_field'
4
+
5
+ def self.prepare_class( klass )
6
+ return if ( klass.instance_methods + klass.private_methods + klass.protected_methods ).map(&:to_sym).include?( :method_missing_before_state_fu )
7
+ alias_method :method_missing_before_state_fu, :method_missing
8
+ klass.class_eval do
9
+ def method_missing( method_name, *args, &block )
10
+ args.unshift method_name
11
+ if @state_fu_initialized
12
+ method_missing_before_state_fu( *args, &block )
13
+ else
14
+ state_fu!
15
+ if respond_to?(method_name)
16
+ send( *args, &block )
17
+ else
18
+ method_missing_before_state_fu( *args, &block )
19
+ end
20
+ end
21
+ end # method_missing
22
+ end # class_eval
23
+ end # prepare_class
24
+
25
+
26
+ def self.active_record_column?( klass, field_name )
27
+ Object.const_defined?("ActiveRecord") &&
28
+ ::ActiveRecord.const_defined?("Base") &&
29
+ klass.ancestors.include?( ::ActiveRecord::Base ) &&
30
+ klass.columns.map(&:name).include?( field_name.to_s )
31
+ end
32
+
33
+ def self.for( binding, field_name )
34
+ if active_record_column?( binding.object.class, field_name )
35
+ self::ActiveRecord.new( binding, field_name )
36
+ else
37
+ self::Attribute.new( binding, field_name )
38
+ end
39
+ end
40
+
41
+ def self.prepare_field( klass, field_name )
42
+ if active_record_column?( klass, field_name )
43
+ self::ActiveRecord.prepare_field( klass, field_name )
44
+ else
45
+ self::Attribute.prepare_field( klass, field_name )
46
+ end
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,27 @@
1
+ module StateFu
2
+ class Sprocket # Abstract Superclass of State & Event
3
+ include StateFu::Helper # define apply!
4
+
5
+ attr_reader :machine, :name, :options, :hooks
6
+
7
+ def initialize(machine, name, options={})
8
+ @machine = machine
9
+ @name = name.to_sym
10
+ @options = options.symbolize_keys!
11
+ @hooks = StateFu::Hooks.for( self )
12
+ end
13
+
14
+ # sneaky way to make some comparisons / duck typing a bit cleaner
15
+ alias_method :to_sym, :name
16
+
17
+ def add_hook slot, name, value
18
+ @hooks[slot.to_sym] << [name.to_sym, value]
19
+ end
20
+
21
+ def lathe(options={}, &block)
22
+ StateFu::Lathe.new( machine, self, options, &block )
23
+ end
24
+
25
+ end
26
+ end
27
+
@@ -0,0 +1,45 @@
1
+ module StateFu
2
+ class State < StateFu::Sprocket
3
+
4
+ attr_reader :entry_requirements, :exit_requirements
5
+
6
+ def initialize(machine, name, options={})
7
+ @entry_requirements = [].extend ArrayWithSymbolAccessor
8
+ @exit_requirements = [].extend ArrayWithSymbolAccessor
9
+ super( machine, name, options )
10
+ end
11
+
12
+ def events
13
+ machine.events.from(self)
14
+ end
15
+
16
+ #
17
+ # Proxy methods to StateFu::Lathe
18
+ #
19
+ # TODO - build something meta to build these proxy events
20
+ def event( name, options={}, &block )
21
+ if block_given?
22
+ lathe.event( name, options, &block )
23
+ else
24
+ lathe.event( name, options )
25
+ end
26
+ end
27
+
28
+ def enterable_by?( binding )
29
+ entry_requirements.reject do |r|
30
+ res = binding.evaluate_requirement( r )
31
+ end.empty?
32
+ end
33
+
34
+ def exitable_by?( binding )
35
+ exit_requirements.reject do |r|
36
+ binding.evaluate_requirement( r )
37
+ end.empty?
38
+ end
39
+
40
+ # allows @obj.state_fu.state === :new
41
+ def === other
42
+ self.to_sym === other.to_sym
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,213 @@
1
+ module StateFu
2
+
3
+ # A 'context' class, created when an event is fired, or needs to be
4
+ # validated.
5
+ #
6
+ # This is what gets yielded to event hooks; it also gets attached
7
+ # to any TransitionHalted exceptions raised.
8
+
9
+ class Transition
10
+ include StateFu::Helper
11
+ include ContextualEval
12
+ attr_reader( :binding,
13
+ :machine,
14
+ :origin,
15
+ :target,
16
+ :event,
17
+ :args,
18
+ :errors,
19
+ :object,
20
+ :options,
21
+ :current_hook_slot,
22
+ :current_hook )
23
+
24
+ attr_accessor :test_only, :args, :options
25
+
26
+ def initialize( binding, event, target=nil, *args, &block )
27
+ # ensure event is a StateFu::Event
28
+ if event.is_a?( Symbol ) && e = binding.machine.events[ event ]
29
+ event = e
30
+ end
31
+ raise( ArgumentError, "Not an event: #{event}" ) unless event.is_a?( StateFu::Event )
32
+
33
+ # infer target if necessary
34
+ case target
35
+ when StateFu::State # good
36
+ when Symbol
37
+ target = binding.machine.states[ target ] ||
38
+ raise( ArgumentError, "target cannot be determined: #{target.inspect}" )
39
+ when NilClass
40
+ unless target = event.target
41
+ raise( ArgumentError, "target cannot be determined: #{target.inspect}" )
42
+ end
43
+ else
44
+ raise ArgumentError.new( target.inspect )
45
+ end
46
+
47
+ # ensure target is valid for the event
48
+ unless event.targets.include?( target )
49
+ raise( StateFu::InvalidTransition.new( binding, event, binding.current_state, target,
50
+ "Illegal target #{target} for #{event}" ))
51
+ end
52
+
53
+ # ensure current_state is a valid origin for the event
54
+ unless event.origins.include?( binding.current_state )
55
+ raise( StateFu::InvalidTransition.new( binding, event, binding.current_state, target,
56
+ "Illegal event #{event.name} for current state #{binding.state_name}" ))
57
+ end
58
+
59
+ @options = args.extract_options!.symbolize_keys!
60
+ @binding = binding
61
+ @machine = binding.machine
62
+ @object = binding.object
63
+ @origin = binding.current_state
64
+ @target = target
65
+ @event = event
66
+ @args = args
67
+ @errors = []
68
+ @testing = @options.delete( :test_only )
69
+
70
+ machine.inject_helpers_into( self )
71
+
72
+ # do stuff with the transition in a block, if you like
73
+ apply!( &block ) if block_given?
74
+ end
75
+
76
+ def requirements
77
+ origin.exit_requirements + target.entry_requirements + event.requirements
78
+ end
79
+
80
+ def unmet_requirements
81
+ requirements.reject do |requirement|
82
+ binding.evaluate_requirement( requirement )
83
+ end
84
+ end
85
+
86
+ def unmet_requirement_messages
87
+ unmet_requirements.map do |r|
88
+ binding.evaluate_requirement_message(r, self )
89
+ end
90
+ end
91
+
92
+ def check_requirements!
93
+ raise RequirementError.new( unmet_requirements.inspect ) unless requirements_met?
94
+ end
95
+
96
+ def requirements_met?
97
+ unmet_requirements.empty?
98
+ end
99
+ alias_method :valid?, :requirements_met?
100
+
101
+ def hooks_for( element, slot )
102
+ send(element).hooks[slot]
103
+ end
104
+
105
+ def hooks()
106
+ StateFu::Hooks::ALL_HOOKS.map do |owner, slot|
107
+ [ [owner, slot], send( owner ).hooks[ slot ] ]
108
+ end
109
+ end
110
+
111
+ def current_state
112
+ if accepted?
113
+ :accepted
114
+ else
115
+ current_hook.state rescue :unfired
116
+ end
117
+ end
118
+
119
+ def run_hook( hook )
120
+ evaluate_named_proc_or_method( hook )
121
+ end
122
+
123
+ def halt!( message )
124
+ raise TransitionHalted.new( self, message )
125
+ end
126
+
127
+ def fire!
128
+ return false if fired? # no infinite loops please
129
+ check_requirements!
130
+ @fired = true
131
+ begin
132
+ StateFu::Hooks::ALL_HOOKS.map do |owner, slot|
133
+ [ [owner, slot], send( owner ).hooks[ slot ] ]
134
+ end.each do |address, hooks|
135
+ owner,slot = *address
136
+ hooks.each do |hook|
137
+ @current_hook_slot = address
138
+ @current_hook = hook
139
+ run_hook( hook )
140
+ end
141
+ if slot == :entry
142
+ @accepted = true
143
+ @binding.persister.current_state = @target
144
+ end
145
+ end
146
+ # transition complete
147
+ @current_hook_slot = nil
148
+ @current_hook = nil
149
+ rescue TransitionHalted => e
150
+ @errors << e
151
+ end
152
+ return accepted?
153
+ end
154
+
155
+ def halted?
156
+ !@errors.empty?
157
+ end
158
+
159
+ def fired?
160
+ !!@fired
161
+ end
162
+
163
+ def testing?
164
+ !!@test_only
165
+ end
166
+
167
+ def live?
168
+ !testing?
169
+ end
170
+
171
+ def accepted?
172
+ !!@accepted
173
+ end
174
+
175
+ #
176
+ # Try to give as many options (chances) as possible
177
+ #
178
+
179
+ alias_method :obj, :object
180
+ alias_method :instance, :object
181
+ alias_method :model, :object
182
+ alias_method :instance, :object
183
+
184
+ alias_method :destination, :target
185
+ alias_method :final_state, :target
186
+ alias_method :to, :target
187
+
188
+ alias_method :original_state, :origin
189
+ alias_method :initial_state, :origin
190
+ alias_method :from, :origin
191
+
192
+ alias_method :om, :binding
193
+ alias_method :stateful, :binding
194
+ alias_method :binding, :binding
195
+ alias_method :present, :binding
196
+
197
+ alias_method :workflow, :machine
198
+
199
+ alias_method :write? , :live?
200
+ alias_method :destructive?, :live?
201
+ alias_method :real?, :live?
202
+ alias_method :really?, :live?
203
+ alias_method :seriously?, :live?
204
+
205
+ alias_method :test?, :testing?
206
+ alias_method :test_only?, :testing?
207
+ alias_method :read_only?, :testing?
208
+ alias_method :only_pretend?, :testing?
209
+ alias_method :pretend?, :testing?
210
+ alias_method :dry_run?, :testing?
211
+
212
+ end
213
+ end
data/spec/helper.rb ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env ruby
2
+ thisdir = File.expand_path(File.dirname(__FILE__))
3
+ $: << thisdir << "#{thisdir}/../lib"
4
+
5
+ require 'rubygems'
6
+
7
+ {"rr" => "rr", "spec" => "rspec" }.each do |lib, gem_name|
8
+ begin
9
+ require lib
10
+ rescue LoadError => e
11
+ STDERR.puts "The '#{gem_name}' gem is required to run StateFu's specs. Please install it by running (as root):\ngem install #{gem_name}\n\n"
12
+ exit 1;
13
+ end
14
+ end
15
+
16
+ require 'state-fu'
17
+ require File.join( thisdir, '..' , 'lib', 'no_stdout' )
18
+
19
+ Spec::Runner.configure do |config|
20
+ config.mock_with :rr
21
+ end
22
+
23
+ module MySpecHelper
24
+ include NoStdout
25
+
26
+ def prepare_active_record( options={}, &migration )
27
+ begin
28
+ require 'active_record'
29
+ rescue MissingSourceFile => e
30
+ pending "skipping specifications due to load error: #{e}"
31
+ return false
32
+ end
33
+
34
+ options.symbolize_keys!
35
+ options.assert_valid_keys( :db_config, :migration_name, :hidden )
36
+
37
+ # connect ActiveRecord
38
+ db_config = options.delete(:db_config) || {
39
+ :adapter => 'sqlite3',
40
+ :database => ':memory:'
41
+ }
42
+ ActiveRecord::Base.establish_connection( db_config )
43
+
44
+ return unless block_given?
45
+
46
+ # prepare the migration
47
+ migration_class_name =
48
+ options.delete(:migration_name) || 'BeforeSpecMigration'
49
+ make_pristine_class( migration_class_name, ActiveRecord::Migration )
50
+ migration_class = migration_class_name.constantize
51
+ migration_class.class_eval( &migration )
52
+
53
+ # run the migration without spewing crap everywhere
54
+ if options.delete(:hidden) != false
55
+ no_stdout { migration_class.migrate( :up ) }
56
+ else
57
+ migration_class.migrate( :up )
58
+ end
59
+ end
60
+
61
+ def make_pristine_class(class_name, superklass=Object, reset_first = false)
62
+ reset! if reset_first
63
+ @class_names ||= []
64
+ @class_names << class_name
65
+ klass = Class.new( superklass )
66
+ klass.send( :include, StateFu )
67
+ Object.send(:remove_const, class_name ) if Object.const_defined?( class_name )
68
+ Object.const_set(class_name, klass)
69
+ end
70
+
71
+ def reset!
72
+ @class_names ||= []
73
+ @class_names.each do |class_name|
74
+ Object.send(:remove_const, class_name ) if Object.const_defined?( class_name )
75
+ end
76
+ @class_names = []
77
+ StateFu::FuSpace.reset!
78
+ end
79
+
80
+ def set_method_arity( object, method_name, needed_arity = 1 )
81
+ a = Object.new
82
+ stub( a ).arity() { needed_arity }
83
+ stub( object ).method(method_name) { a }
84
+ end
85
+
86
+ end
@@ -0,0 +1,189 @@
1
+ require File.expand_path("#{File.dirname(__FILE__)}/../helper")
2
+
3
+ describe "an ActiveRecord model with StateFu included:" do
4
+
5
+ include MySpecHelper
6
+
7
+ before(:each) do
8
+ reset!
9
+ prepare_active_record() do
10
+ def self.up
11
+ create_table :example_records do |t|
12
+ t.string :name, :null => false
13
+ t.string :state_fu_field, :null => false
14
+ t.string :description
15
+ t.string :status
16
+ t.timestamps
17
+ end
18
+ end
19
+ end
20
+
21
+ # class ExampleRecord < ActiveRecord::Base
22
+ make_pristine_class( 'ExampleRecord', ActiveRecord::Base )
23
+ ExampleRecord.class_eval do
24
+ validates_presence_of :name
25
+ end
26
+ # end class ExampleRecord
27
+ end
28
+
29
+ it "should be a subclass of ActiveRecord::Base" do
30
+ ExampleRecord.superclass.should == ActiveRecord::Base
31
+ end
32
+
33
+ describe "when the default machine is defined with no field_name specified" do
34
+ before do
35
+ ExampleRecord.class_eval do
36
+ machine do
37
+ state :initial do
38
+ event( :change, :to => :final ) { after :save! }
39
+ end
40
+ end
41
+ end
42
+ @ex = ExampleRecord.new( :name => "exemplar" )
43
+ end # before
44
+
45
+ it "should have an active_record string column 'state_fu_field' " do
46
+ col = ExampleRecord.columns.detect {|c| c.name == "state_fu_field" }
47
+ col.type.should == :string
48
+ end
49
+
50
+ describe "calling :save! via an event's after hook" do
51
+ it "should save the record with the new state persisted via the DB" do
52
+ @ex.change!
53
+ @ex.state_fu.name.should == :final
54
+ @ex.state_fu_field.should == 'final'
55
+ @ex.reload
56
+ @ex.state_fu_field.should == 'final'
57
+ @ex.state_fu.name.should == :final
58
+ end
59
+ end
60
+
61
+ describe "StateFu::Persistence.active_record_column?" do
62
+ it "should return true for ExampleRecord, :state_fu_field" do
63
+ StateFu::Persistence.active_record_column?( ExampleRecord, :state_fu_field ).should == true
64
+ end
65
+
66
+ it "should return true for ExampleRecord, :status" do
67
+ StateFu::Persistence.active_record_column?( ExampleRecord, :status ).should == true
68
+ end
69
+
70
+ it "should return false for ExampleRecord, :not_a_column" do
71
+ StateFu::Persistence.active_record_column?( ExampleRecord, :not_a_column ).should == false
72
+ end
73
+ it "should not clobber activerecord accessors" do
74
+ @ex.noodle! rescue nil
75
+ # lambda { @ex.description }.should_not raise_error()
76
+ @ex.description.should be_nil
77
+ @ex.description= 'foo'
78
+ @ex.description.should == 'foo'
79
+ end
80
+
81
+ it "should have an active_record string column 'state_fu_field' " do
82
+ col = ExampleRecord.columns.detect {|c| c.name == "state_fu_field" }
83
+ col.type.should == :string
84
+ end
85
+ end
86
+
87
+ it "should have an active_record persister with the default field_name 'state_fu_field' " do
88
+ @ex.state_fu
89
+ @ex.state_fu.should be_kind_of( StateFu::Binding )
90
+ @ex.state_fu.persister.should be_kind_of( StateFu::Persistence::ActiveRecord )
91
+ @ex.state_fu.persister.field_name.should == :state_fu_field
92
+ end
93
+
94
+
95
+ # this ensures state_fu initializes the field before create to
96
+ # satisfy the not null constraint
97
+ describe "automagic state_fu! before_save filter and validations" do
98
+
99
+ it "should call state_fu! before a record is created" do
100
+ @ex.should be_new_record
101
+ mock.proxy( @ex ).state_fu!.at_least( 1 ) { }
102
+ @ex.save!
103
+ end
104
+
105
+ it "should call state_fu! before a record is updated" do
106
+ @ex.should be_new_record
107
+ mock.proxy( @ex ).state_fu!.at_least( 1 ) { }
108
+ @ex.save!
109
+ end
110
+
111
+ it "should fail to save if state_fu! does not instantiate the binding before create" do
112
+ pending "is this still relevant?"
113
+ mock( @ex ).state_fu!.at_least( 1 ) { }
114
+ lambda { @ex.save! }.should raise_error( ActiveRecord::StatementInvalid )
115
+ @ex.state_fu_field.should == nil
116
+ end
117
+
118
+ it "should create a record given only a name, with the field set to the initial state" do
119
+ ex = ExampleRecord.new( :name => "exemplar" )
120
+ ex.should be_valid
121
+ ex.state_fu_field.should == nil
122
+ ex.save!
123
+ ex.should_not be_new_record
124
+ ex.state_fu_field.should == 'initial'
125
+ ex.state_fu.state.name.should == :initial
126
+ end
127
+
128
+ it "should update the field after a transition is completed" do
129
+ ex = ExampleRecord.create!( :name => "exemplar" )
130
+ ex.state_fu.state.name.should == :initial
131
+ ex.state_fu_field.should == 'initial'
132
+ t = ex.state_fu.fire!( :change )
133
+ t.should be_accepted
134
+ ex.state_fu.state.name.should == :final
135
+ ex.state_fu_field.should == 'final'
136
+ ex.attributes['state_fu_field'].should == 'final'
137
+ ex.save!
138
+ end
139
+
140
+ describe "a saved record whose state is not the default" do
141
+ before do
142
+ @r = ExampleRecord.create!( :name => "exemplar" )
143
+ @r.change!
144
+ @r.state_fu_field.should == 'final'
145
+ @r.save!
146
+ end
147
+
148
+ it "should be reconstituted with the correct state" do
149
+ r = ExampleRecord.find( @r.id )
150
+ r.state_fu.should be_kind_of( StateFu::Binding )
151
+ r.state_fu.current_state.should be_kind_of( StateFu::State )
152
+ r.state_fu.current_state.should == ExampleRecord.machine.states[:final]
153
+ end
154
+ end # saved record after transition
155
+
156
+ describe "when a second machine named :status is defined with :field_name => 'status' " do
157
+ before do
158
+ ExampleRecord.machine(:status, :field_name => 'status') do
159
+ event( :go, :from => :initial, :to => :final )
160
+ end
161
+ @ex = ExampleRecord.new()
162
+ end
163
+
164
+ it "should have a binding for .status" do
165
+ @ex.status.should be_kind_of( StateFu::Binding )
166
+ end
167
+
168
+ it "should have an ActiveRecord persister with the field_name :status" do
169
+ @ex.status.persister.should be_kind_of( StateFu::Persistence::ActiveRecord )
170
+ @ex.status.persister.field_name.should == :status
171
+ end
172
+
173
+ it "should have a value of nil for the status field before state_fu is called" do
174
+ @ex.read_attribute('status').should be_nil
175
+ end
176
+
177
+ it "should have the ActiveRecord setter method .status=" do
178
+ @ex.status= 'damp'
179
+ @ex.read_attribute(:status).should == 'damp'
180
+ end
181
+
182
+ it "should raise StateFu::InvalidState if the status field is set to a bad value and .status is called" do
183
+ @ex.status= 'damp'
184
+ lambda { @ex.status }.should raise_error( StateFu::InvalidStateName )
185
+ end
186
+ end
187
+ end # second machine
188
+ end # with before_create filter
189
+ end # default machine