state-fu 0.11.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 (85) hide show
  1. data/LICENSE +40 -0
  2. data/README.textile +293 -0
  3. data/Rakefile +114 -0
  4. data/lib/binding.rb +292 -0
  5. data/lib/event.rb +192 -0
  6. data/lib/executioner.rb +120 -0
  7. data/lib/hooks.rb +39 -0
  8. data/lib/interface.rb +132 -0
  9. data/lib/lathe.rb +538 -0
  10. data/lib/machine.rb +184 -0
  11. data/lib/method_factory.rb +243 -0
  12. data/lib/persistence.rb +116 -0
  13. data/lib/persistence/active_record.rb +34 -0
  14. data/lib/persistence/attribute.rb +47 -0
  15. data/lib/persistence/base.rb +100 -0
  16. data/lib/persistence/relaxdb.rb +23 -0
  17. data/lib/persistence/session.rb +7 -0
  18. data/lib/sprocket.rb +58 -0
  19. data/lib/state-fu.rb +56 -0
  20. data/lib/state.rb +48 -0
  21. data/lib/support/active_support_lite/array.rb +9 -0
  22. data/lib/support/active_support_lite/array/access.rb +60 -0
  23. data/lib/support/active_support_lite/array/conversions.rb +202 -0
  24. data/lib/support/active_support_lite/array/extract_options.rb +21 -0
  25. data/lib/support/active_support_lite/array/grouping.rb +109 -0
  26. data/lib/support/active_support_lite/array/random_access.rb +13 -0
  27. data/lib/support/active_support_lite/array/wrapper.rb +25 -0
  28. data/lib/support/active_support_lite/blank.rb +67 -0
  29. data/lib/support/active_support_lite/cattr_reader.rb +57 -0
  30. data/lib/support/active_support_lite/keys.rb +57 -0
  31. data/lib/support/active_support_lite/misc.rb +59 -0
  32. data/lib/support/active_support_lite/module.rb +1 -0
  33. data/lib/support/active_support_lite/module/delegation.rb +130 -0
  34. data/lib/support/active_support_lite/object.rb +9 -0
  35. data/lib/support/active_support_lite/string.rb +38 -0
  36. data/lib/support/active_support_lite/symbol.rb +16 -0
  37. data/lib/support/applicable.rb +41 -0
  38. data/lib/support/arrays.rb +197 -0
  39. data/lib/support/core_ext.rb +90 -0
  40. data/lib/support/exceptions.rb +106 -0
  41. data/lib/support/has_options.rb +16 -0
  42. data/lib/support/logger.rb +165 -0
  43. data/lib/support/methodical.rb +17 -0
  44. data/lib/support/no_stdout.rb +55 -0
  45. data/lib/support/plotter.rb +62 -0
  46. data/lib/support/vizier.rb +300 -0
  47. data/lib/tasks/spec_last.rake +55 -0
  48. data/lib/tasks/state_fu.rake +57 -0
  49. data/lib/transition.rb +338 -0
  50. data/lib/transition_query.rb +224 -0
  51. data/spec/custom_formatter.rb +49 -0
  52. data/spec/features/binding_and_transition_helper_mixin_spec.rb +111 -0
  53. data/spec/features/method_missing_only_once_spec.rb +28 -0
  54. data/spec/features/not_requirements_spec.rb +118 -0
  55. data/spec/features/plotter_spec.rb +97 -0
  56. data/spec/features/shared_log_spec.rb +7 -0
  57. data/spec/features/singleton_machine_spec.rb +39 -0
  58. data/spec/features/state_and_array_options_accessor_spec.rb +47 -0
  59. data/spec/features/transition_boolean_comparison_spec.rb +101 -0
  60. data/spec/helper.rb +13 -0
  61. data/spec/integration/active_record_persistence_spec.rb +202 -0
  62. data/spec/integration/binding_extension_spec.rb +41 -0
  63. data/spec/integration/class_accessor_spec.rb +117 -0
  64. data/spec/integration/event_definition_spec.rb +74 -0
  65. data/spec/integration/example_01_document_spec.rb +133 -0
  66. data/spec/integration/example_02_string_spec.rb +88 -0
  67. data/spec/integration/instance_accessor_spec.rb +97 -0
  68. data/spec/integration/lathe_extension_spec.rb +67 -0
  69. data/spec/integration/machine_duplication_spec.rb +101 -0
  70. data/spec/integration/relaxdb_persistence_spec.rb +97 -0
  71. data/spec/integration/requirement_reflection_spec.rb +270 -0
  72. data/spec/integration/state_definition_spec.rb +163 -0
  73. data/spec/integration/transition_spec.rb +1033 -0
  74. data/spec/spec.opts +9 -0
  75. data/spec/spec_helper.rb +132 -0
  76. data/spec/state_fu_spec.rb +948 -0
  77. data/spec/units/binding_spec.rb +192 -0
  78. data/spec/units/event_spec.rb +214 -0
  79. data/spec/units/exceptions_spec.rb +82 -0
  80. data/spec/units/lathe_spec.rb +570 -0
  81. data/spec/units/machine_spec.rb +229 -0
  82. data/spec/units/method_factory_spec.rb +366 -0
  83. data/spec/units/sprocket_spec.rb +69 -0
  84. data/spec/units/state_spec.rb +59 -0
  85. metadata +171 -0
data/spec/spec.opts ADDED
@@ -0,0 +1,9 @@
1
+ --colour
2
+ --loadby
3
+ mtime
4
+ --reverse
5
+ --debugger
6
+ --require
7
+ spec/custom_formatter.rb
8
+ --format
9
+ progress
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env ruby
2
+ thisdir = File.expand_path(File.dirname(__FILE__))
3
+
4
+ # ensure we require state-fu from lib, not gems
5
+ $LOAD_PATH.unshift( "#{thisdir}/../lib" )
6
+ require 'state-fu'
7
+ require 'support/no_stdout'
8
+ require 'fileutils'
9
+ require 'rubygems'
10
+ require 'spec'
11
+
12
+ # record the log output on each run
13
+ LOGFILE = File.join('log', 'spec.log') unless Object.const_defined?('LOGFILE')
14
+ FileUtils.rm LOGFILE if File.exists?(LOGFILE)
15
+ StateFu::Logger.level = Logger::INFO
16
+ StateFu::Logger.logger = Logger.new(LOGFILE)
17
+
18
+ module MySpecHelper
19
+ include NoStdout
20
+
21
+ def prepare_active_record( options={}, &migration )
22
+ if skip_slow_specs?
23
+ skip_slow_specs and return false
24
+ end
25
+
26
+ begin
27
+ require 'activesupport'
28
+ require 'active_record'
29
+ require 'sqlite3'
30
+ rescue LoadError => e
31
+ pending "skipping specifications due to load error: #{e}"
32
+ return false
33
+ end
34
+
35
+ options.symbolize_keys!
36
+ options.assert_valid_keys( :db_config, :migration_name, :hidden )
37
+
38
+ # connect ActiveRecord
39
+ db_config = options.delete(:db_config) || {
40
+ :adapter => 'sqlite3',
41
+ :database => ':memory:'
42
+ }
43
+ ActiveRecord::Base.establish_connection( db_config )
44
+
45
+ return unless block_given?
46
+
47
+ # prepare the migration
48
+ migration_class_name =
49
+ options.delete(:migration_name) || 'BeforeSpecMigration'
50
+ make_pristine_class( migration_class_name, ActiveRecord::Migration )
51
+ migration_class = migration_class_name.constantize
52
+ migration_class.class_eval( &migration )
53
+
54
+ # run the migration without spewing crap everywhere
55
+ if options.delete(:hidden) != false
56
+ no_stdout { migration_class.migrate( :up ) }
57
+ else
58
+ migration_class.migrate( :up )
59
+ end
60
+ end
61
+
62
+ def skip_slow_specs?
63
+ !!ENV['SKIP_SLOW_SPECS']
64
+ end
65
+
66
+ def skip_slow_specs
67
+ pending('Skipping slow specs - run $ rake all if you want them')
68
+ end
69
+
70
+ def skip_unless_relaxdb
71
+ unless Object.const_defined?( 'RelaxDB' )
72
+ pending('Skipping specs because you do not have the relaxdb gem (paulcarey-relaxdb) installed ...')
73
+ end
74
+ end
75
+
76
+ def prepare_relaxdb( options={} )
77
+ if skip_slow_specs?
78
+ return false
79
+ end
80
+ begin
81
+ require 'relaxdb'
82
+ if Object.const_defined?( "RelaxDB" )
83
+ RelaxDB.configure :host => "localhost", :port => 5984, :design_doc => "spec_doc"
84
+ RelaxDB.delete_db "relaxdb_spec" rescue "ok"
85
+ RelaxDB.use_db "relaxdb_spec"
86
+ RelaxDB.enable_view_creation
87
+ end
88
+ rescue LoadError => e
89
+ # pending "skipping specifications due to load error: #{e}"
90
+ return false
91
+ end
92
+ begin
93
+ RelaxDB.replicate_db "relaxdb_spec_base", "relaxdb_spec"
94
+ RelaxDB.enable_view_creation
95
+ rescue => e
96
+ puts "\n===== Run rake create_base_db before the first spec run ====="
97
+ puts
98
+ exit!
99
+ end
100
+ #
101
+ end
102
+
103
+ def make_pristine_class(class_name, superklass=Object, &block)
104
+ @class_names ||= []
105
+ @class_names << class_name
106
+ klass = Class.new( superklass )
107
+ klass.send( :include, StateFu )
108
+ Object.send(:remove_const, class_name ) if Object.const_defined?( class_name )
109
+ Object.const_set(class_name, klass)
110
+ klass.class_eval &block if block_given?
111
+ end
112
+
113
+ def reset!
114
+ @class_names ||= []
115
+ @class_names.each do |class_name|
116
+ Object.send(:remove_const, class_name ) if Object.const_defined?( class_name )
117
+ end
118
+ @class_names = []
119
+ end
120
+
121
+ def set_method_arity( object, method_name, needed_arity = 1 )
122
+ raise caller.first.inspect
123
+ a = Proc.new {}
124
+ stub( a ).arity() { needed_arity }
125
+ stub( object ).method( anything ) { |x| object.send(x) }
126
+ stub( object ).method( method_name ) { a }
127
+ end
128
+ end
129
+
130
+ Spec::Runner.configure do |config|
131
+ config.include MySpecHelper
132
+ end
@@ -0,0 +1,948 @@
1
+ require File.join(File.dirname(__FILE__), 'spec_helper')
2
+
3
+ #
4
+ # Door
5
+ #
6
+
7
+ describe "A door which opens and shuts:" do
8
+ before :all do
9
+
10
+ # class Door
11
+ make_pristine_class('Door') do
12
+ include StateFu
13
+
14
+ attr_accessor :locked
15
+
16
+ def shut
17
+ "I don't know how to shut!"
18
+ end
19
+
20
+ def locked?
21
+ !!locked
22
+ end
23
+
24
+ def method_missing(method_name, *args, &block)
25
+ raise NoMethodError.new("I'm just a door!" )
26
+ end
27
+
28
+ machine do
29
+ event :shut, :transitions_from => :open, :to => :closed
30
+ event :slam, :transitions_from => :open, :to => :closed
31
+ event :open, :transitions_from => :closed, :to => :open,
32
+ :requires => :not_locked?,
33
+ :message => "Sorry, it's locked."
34
+ end
35
+ end
36
+ end # before
37
+
38
+ describe "Door's state machine" do
39
+ it "have two states, :open and :closed" do
40
+ Door.machine.states.names.should == [:open, :closed]
41
+ end
42
+
43
+ it "have two events, :shut and :open" do
44
+ Door.machine.events.names.should == [:shut, :slam, :open]
45
+ end
46
+
47
+ it "have an initial state of :open, the first state defined" do
48
+ Door.machine.initial_state.name.should == :open
49
+ end
50
+
51
+ it "have an initial state of :open, the first state defined" do
52
+ Door.machine.initial_state.name.should == :open
53
+ end
54
+
55
+ it "have a requirement :not_locked? for the :open event" do
56
+ Door.machine.events[:open].requirements.should == [:not_locked?]
57
+ end
58
+
59
+ it "have a requirement_message 'Sorry, it's locked.' for :not_locked?" do
60
+ Door.machine.requirement_messages[:not_locked?].should == "Sorry, it's locked."
61
+ end
62
+
63
+ it "can reflect on event origin and target states" do
64
+ event = Door.machine.events[:open]
65
+ event.origin_names.should == [:closed]
66
+ event.target_names.should == [:open]
67
+ event.simple?.should == true # because there's only one possible target
68
+ event.origin.should == :closed # because there's only one origin
69
+ event.target.should == :open # because there's only one target
70
+ event.machine.should == Door.machine
71
+ end
72
+
73
+ end
74
+
75
+ describe "door" do
76
+ before do
77
+ @door = Door.new
78
+ end
79
+
80
+ describe "magic event methods" do
81
+
82
+ it "doesn't normally have a method #shut!" do
83
+ @door.respond_to?(:shut!).should == false
84
+ end
85
+
86
+ it "will define #shut! when method_missing is called for the first time" do
87
+ begin
88
+ @door.play_the_ukelele
89
+ rescue NoMethodError
90
+ end
91
+ @door.respond_to?(:shut!).should == true
92
+ end
93
+
94
+ it "will keep any existing methods when method_missing is triggered" do
95
+ @door.respond_to?(:shut).should == true
96
+ @door.respond_to?(:can_shut?).should == false
97
+ @door.shut.should == "I don't know how to shut!"
98
+ @door.can_shut?.should == true # triggers method_missing
99
+ @door.respond_to?(:shut).should == true
100
+ @door.respond_to?(:can_shut?).should == true # new methods defined
101
+ @door.shut.should == "I don't know how to shut!" # old method retained
102
+ end
103
+
104
+ it "gets a set of new methods when any magic method is called" do
105
+ @door.respond_to?(:shut).should == true # already defined
106
+ @door.respond_to?(:open).should == false
107
+ @door.respond_to?(:can_shut?).should == false
108
+ @door.respond_to?(:can_open?).should == false
109
+ @door.respond_to?(:shut!).should == false
110
+ @door.respond_to?(:open!).should == false
111
+ @door.can_shut?.should == true # call one of them (triggers method_missing)
112
+ @door.respond_to?(:open).should == false # a private method: Kernel#open
113
+ @door.respond_to?(:can_shut?).should == true # but these are all newly defined public methods
114
+ @door.respond_to?(:can_open?).should == true
115
+ @door.respond_to?(:shut!).should == true
116
+ @door.respond_to?(:open!).should == true
117
+ end
118
+
119
+ it "retains any previously defined method_missing" do
120
+ begin
121
+ @door.hug_me
122
+ rescue NoMethodError => e
123
+ e.message.should == "I'm just a door!"
124
+ end
125
+ end
126
+
127
+ describe "for :slam - " do
128
+ describe "#slam" do
129
+ it "should return an unfired StateFu::Transition" do
130
+ t = @door.slam
131
+ t.should be_kind_of(StateFu::Transition)
132
+ t.fired?.should == false
133
+ end
134
+ end
135
+
136
+ describe "#can_slam?" do
137
+ it "should be true if the transition is valid for the current state" do
138
+ @door.current_state.should == :open
139
+ @door.can_slam?.should == true
140
+ end
141
+
142
+ it "should be false when the transition has unmet requirements" do
143
+ Door.machine.events[:slam].lathe do
144
+ requires :some_impossible_condition do
145
+ false
146
+ end
147
+ end
148
+ @door.can_slam?.should == false
149
+ end
150
+
151
+ it "should be nil when the transition is invalid for the current state" do
152
+ @door.shut!
153
+ @door.current_state.should == :closed
154
+ @door.can_slam?.should == nil
155
+ end
156
+ end
157
+ end
158
+
159
+ end # magic event methods
160
+
161
+ describe "magic state methods" do
162
+ it "should be defined for each state by method_missing voodoo" do
163
+ @door.should_not respond_to(:closed?)
164
+ @door.should_not respond_to(:open?)
165
+ @door.open?.should == true
166
+ @door.should respond_to(:closed?)
167
+ @door.should respond_to(:open?)
168
+ end
169
+
170
+ describe "for :closed - " do
171
+ describe "#closed?" do
172
+ it "should be true when the current_state is :closed" do
173
+ @door.current_state.should == :open
174
+ @door.closed?.should == false
175
+ @door.shut!
176
+ @door.closed?.should == true
177
+ end
178
+ end
179
+ end
180
+ end # magic state methods
181
+
182
+ it "#can_shut? when the current state is open" do
183
+ @door.current_state.should == :open
184
+ # @door.state_fu.valid_transitions.map(&:destination).inspect
185
+ @door.can_shut?.should == true
186
+ @door.can_open?.should == nil # not a valid transition from this state -> nil
187
+ end
188
+
189
+ it "transitions from :open to :closed on #shut!" do
190
+ @door.current_state.should == :open
191
+ shut_result = @door.shut!
192
+ shut_result.should be_true
193
+ shut_result.should be_kind_of(StateFu::Transition)
194
+ shut_result.should be_complete
195
+ @door.current_state.should == :closed
196
+ end
197
+
198
+ it "raises a StateFu::IllegalTransition if #shut! is called when already :closed" do
199
+ @door.current_state.should == :open
200
+ @door.shut!.should be_true
201
+ @door.current_state.should == :closed
202
+ lambda do
203
+ t = @door.shut!
204
+ t.origin.should == :open
205
+ end.should raise_error(StateFu::IllegalTransition)
206
+ end
207
+
208
+ it "raises StateFu::RequirementError if #open! is called when it is locked" do
209
+ @door.shut!
210
+ @door.locked = true
211
+ lambda { @door.open! }.should raise_error(StateFu::RequirementError)
212
+ end
213
+
214
+ it "tells you why it won't open if you ask nicely" do
215
+ @door.shut!
216
+ @door.locked = true
217
+ @door.locked?.should be_true
218
+
219
+ transition = @door.state_fu.transition :open
220
+ transition.requirement_errors.should == {:not_locked? => "Sorry, it's locked."}
221
+ end
222
+
223
+ it "gives you information about the requirement errors if you rescue the RequirementError" do
224
+ @door.shut!
225
+ @door.locked = true
226
+ @door.locked?.should be_true
227
+ begin
228
+ @door.open!
229
+ rescue StateFu::RequirementError => e
230
+ e.to_a.should == ["Sorry, it's locked."]
231
+ e.to_h.should == {:not_locked? => "Sorry, it's locked."}
232
+ e.to_enum.should be_kind_of(Enumerable::Enumerator)
233
+ e.should_not be_empty
234
+ e.length.should == 1
235
+ e.each do |requirement, message|
236
+ requirement.should == :not_locked?
237
+ message.should == "Sorry, it's locked."
238
+ end
239
+ end
240
+ end
241
+
242
+ describe "Transition objects" do
243
+
244
+ # TODO refactor me
245
+ def should_be_an_unfired_transition_with_the_event_slam_from_open_to_closed(transition)
246
+ transition.should be_kind_of(StateFu::Transition)
247
+ transition.fired?.should == false
248
+ transition.current_state.should == :open
249
+ transition.event.should == :slam
250
+ transition.origin.should == :open
251
+ transition.target.should == :closed
252
+ end
253
+
254
+ it "returns a Transition on #slam" do
255
+ @door.slam do |transition|
256
+ transition.should be_kind_of(StateFu::Transition)
257
+ transition.fired?.should == false
258
+ transition.current_state.should == :open
259
+ transition.event.should == :slam
260
+ transition.origin.should == :open
261
+ transition.target.should == :closed
262
+ end
263
+ end
264
+
265
+ it "returns a Transition on #state_fu.slam" do
266
+ transition = @door.state_fu.slam
267
+ should_be_an_unfired_transition_with_the_event_slam_from_open_to_closed( transition )
268
+ end
269
+
270
+ it "returns a Transition on #state_fu.transition :slam" do
271
+ transition = @door.state_fu.transition :slam
272
+ should_be_an_unfired_transition_with_the_event_slam_from_open_to_closed( transition )
273
+ end
274
+
275
+ it "returns a Transition on #state_fu.transition [:slam, :closed]" do
276
+ transition = @door.state_fu.transition [:slam, :closed]
277
+ should_be_an_unfired_transition_with_the_event_slam_from_open_to_closed( transition )
278
+ end
279
+
280
+ it "changes the door's state when you #fire! the transition" do
281
+ transition = @door.slam
282
+ transition.fire!
283
+ transition.fired?.should == true
284
+ transition.complete?.should == true
285
+ @door.current_state.should == :closed
286
+ end
287
+
288
+ it "can tell you its #origin and #target states" do
289
+ transition = @door.state_fu.transition :shut
290
+ transition.origin.should be_kind_of(StateFu::State)
291
+ transition.target.should be_kind_of(StateFu::State)
292
+ transition.origin.should == :open
293
+ transition.target.should == :closed
294
+ end
295
+
296
+ it "can give you information about any requirement errors" do
297
+ @door.shut!
298
+ @door.locked = true
299
+ transition = @door.state_fu.transition :open
300
+ transition.valid?.should == false
301
+ transition.unmet_requirements.should == [:not_locked?]
302
+ transition.unmet_requirement_messages.should == ["Sorry, it's locked."]
303
+ transition.requirement_errors.should == {:not_locked? => "Sorry, it's locked."}
304
+ transition.first_unmet_requirement.should == :not_locked?
305
+ transition.first_unmet_requirement_message.should == "Sorry, it's locked."
306
+ end
307
+ end
308
+
309
+ # TODO save this for later ...............
310
+ describe "#state_fu_binding" do
311
+ it "be a StateFu::Binding" do
312
+ @door.state_fu_binding.should be_kind_of StateFu::Binding
313
+ end
314
+
315
+ it "have a current_state which is initially :open" do
316
+ @door.state_fu_binding.current_state.should == :open
317
+ end
318
+
319
+ it "have two events, :shut and :slam" do
320
+ @door.state_fu_binding.events.should == [:shut, :slam]
321
+ end
322
+
323
+ it "have a list of #valid_transitions" do
324
+ @door.state_fu_binding.valid_transitions.should be_kind_of(StateFu::TransitionQuery)
325
+ @door.state_fu_binding.valid_transitions.length.should == 2
326
+ transition = @door.state_fu_binding.valid_transitions.first
327
+ transition.event.name.should == :shut
328
+ transition.origin.name.should == :open
329
+ transition.target.name.should == :closed
330
+ transition = @door.state_fu_binding.valid_transitions.last
331
+ transition.event.name.should == :slam
332
+ transition.origin.name.should == :open
333
+ transition.target.name.should == :closed
334
+ end
335
+ end
336
+
337
+ describe "#state_fu" do
338
+ it "be the same as door#state_fu_binding" do
339
+ @door.state_fu.should == @door.state_fu_binding
340
+ end
341
+ end
342
+
343
+ describe "#stfu" do
344
+ it "be the same as door#state_fu_binding" do
345
+ @door.stfu.should == @door.state_fu_binding
346
+ end
347
+ end
348
+
349
+ describe "#fu" do
350
+ it "be the same as door#state_fu_binding" do
351
+ @door.fu.should == @door.state_fu_binding
352
+ end
353
+ end
354
+
355
+ end
356
+ end
357
+
358
+ #
359
+ # Heart
360
+ #
361
+
362
+ describe "a simple machine, a heart which beats:" do
363
+
364
+ before :all do
365
+ make_pristine_class('Heart') do
366
+ include StateFu
367
+
368
+ def heartbeats
369
+ @heartbeats ||= []
370
+ end
371
+
372
+ machine do
373
+ cycle :state => :beating, :on => :beat do
374
+ causes(:heartbeat) { heartbeats << :thumpthump }
375
+ end
376
+ event :stop, :from => { :beating => :stopped }
377
+ end
378
+ end
379
+ end # before
380
+
381
+ describe "the machine" do
382
+ it "have two states, :beating and :stopped" do
383
+ Heart.machine.states.names.should == [:beating,:stopped]
384
+ end
385
+
386
+ it "have two events, :beat and :stop" do
387
+ Heart.machine.events.names.should == [:beat, :stop]
388
+ end
389
+
390
+ it "have an initial state of :beating" do
391
+ Heart.machine.initial_state.name.should == :beating
392
+ end
393
+ end
394
+
395
+ describe "it" do
396
+ before do
397
+ @heart = Heart.new
398
+ end
399
+
400
+ it "cause a heartbeat on heart#beat!" do
401
+ @heart.heartbeats.should == []
402
+ @heart.beat!.should be_true
403
+ @heart.heartbeats.should == [:thumpthump]
404
+ end
405
+
406
+ it "raise an IllegalTransition if it tries to beat after it's stopped" do
407
+ @heart.stop!
408
+ @heart.current_state.should == :stopped
409
+ lambda { @heart.beat! }.should raise_error(StateFu::IllegalTransition)
410
+ end
411
+
412
+ it "transition to :stopped on #next!" do
413
+ @heart.current_state.should == :beating
414
+ @heart.state_fu.transitions.not_cyclic.length.should == 1
415
+ @heart.state_fu.next_transition.should_not == nil
416
+ @heart.state_fu.next_state.should_not == nil
417
+ @heart.next_state!
418
+ @heart.current_state.should == :stopped
419
+ end
420
+
421
+ it "transition to :stopped on #next_state!" do
422
+ @heart.current_state.should == :beating
423
+ @heart.next_state!
424
+ @heart.current_state.should == :stopped
425
+ end
426
+
427
+ it "transition to :stopped on #next_transition!" do
428
+ @heart.current_state.should == :beating
429
+ @heart.next_state!
430
+ @heart.current_state.should == :stopped
431
+ end
432
+
433
+ end
434
+ end
435
+
436
+ #
437
+ # Traffic Lights
438
+ #
439
+
440
+ describe "a simple machine, a set of traffic lights:" do
441
+ before :all do
442
+
443
+ make_pristine_class('TrafficLights') do
444
+ include StateFu
445
+ attr_reader :photos
446
+
447
+ def initialize
448
+ @photos = []
449
+ end
450
+
451
+ def red_light_camera
452
+ @photos << :click
453
+ end
454
+
455
+ machine do
456
+ state :go, :colour => :green
457
+ state :caution, :colour => :amber
458
+ state :stop, :colour => :red do
459
+ on_entry :red_light_camera
460
+ end
461
+
462
+ connect_states :go, :caution, :stop, :go
463
+ end
464
+ end
465
+ end # before
466
+
467
+ describe "the machine:" do
468
+ it "have three states, :go, :caution, and :stop" do
469
+ TrafficLights.machine.states.names.should == [:go, :caution, :stop]
470
+ end
471
+
472
+ it "have three events :go_to_caution, :caution_to_stop, and :stop_to_go" do
473
+ TrafficLights.machine.events.names.should == [:go_to_caution, :caution_to_stop, :stop_to_go]
474
+ end
475
+
476
+ it "have an initial_state of :go" do
477
+ TrafficLights.machine.initial_state.name.should == :go
478
+ end
479
+
480
+ describe "the states' options" do
481
+ it "have an appropriate colour" do
482
+ TrafficLights.machine.states[:go] [:colour].should == :green
483
+ TrafficLights.machine.states[:caution][:colour].should == :amber
484
+ TrafficLights.machine.states[:stop] [:colour].should == :red
485
+ end
486
+ end
487
+ end
488
+
489
+ describe "it" do
490
+ before do
491
+ @lights = TrafficLights.new
492
+ end
493
+
494
+ it "transition from :go to :caution on #go_to_caution!" do
495
+ @lights.current_state.should == :go
496
+ @lights.go_to_caution!
497
+ @lights.current_state.should == :caution
498
+ end
499
+
500
+ it "transition from :go to :caution on #next!" do
501
+ @lights.current_state.should == :go
502
+ @lights.next!
503
+ @lights.current_state.should == :caution
504
+ end
505
+
506
+ it "transition from :go to :caution on #next_state!" do
507
+ @lights.current_state.should == :go
508
+ @lights.next_state!
509
+ @lights.current_state.should == :caution
510
+ end
511
+
512
+ it "transition from :go to :caution on #fire_next_transition!" do
513
+ @lights.current_state.should == :go
514
+ @lights.fire_next_transition!
515
+ @lights.current_state.should == :caution
516
+ end
517
+
518
+ describe "when entering the :stop state" do
519
+ it "fire :red_light_camera" do
520
+ @lights.next!
521
+ @lights.photos.should be_empty
522
+ @lights.next!
523
+ @lights.current_state.should == :stop
524
+ @lights.photos.length.should == 1
525
+ end
526
+ end
527
+ end
528
+ end
529
+
530
+ #
531
+ # Recorder
532
+ #
533
+ describe "arguments given to different method signatures" do
534
+ before :all do
535
+ make_pristine_class('Recorder') do
536
+ include StateFu
537
+ attr_accessor :received
538
+
539
+ def initialize
540
+ @received = {}
541
+ end
542
+
543
+ # arguments passed to methods / procs:
544
+ # these method signatures get a transition
545
+ def a1(t) received[:a1] = [t] end
546
+ def b1(t=nil) received[:b1] = [t] end
547
+ def c1(*t) received[:c1] = [t] end
548
+
549
+ # these method signatures get a transition and a list of arguments
550
+ def a2(t,a) received[:a2] = [t,a] end
551
+ def b2(t,a=nil) received[:b2] = [t,a] end
552
+ def c2(t,*a) received[:c2] = [t,a] end
553
+
554
+ # these method signatures get a transition, a list of arguments,
555
+ # and the object which owns the machine
556
+ def a3(t,a,o) received[:a3] = [t,a,o] end
557
+ def b3(t,a,o=nil) received[:b3] = [t,a,o] end
558
+ def c3(t,a,*o) received[:c3] = [t,a,o] end
559
+
560
+ machine do
561
+ cycle :state => :observing, :on => :observe do
562
+ trigger :a1, :b1, :c1, :a2, :b2, :c2, :a3, :b3, :c3
563
+ end
564
+ end
565
+
566
+ end
567
+ end # before
568
+
569
+ describe "the machine" do
570
+ it "have an event :observe which is a #cycle?" do
571
+ Recorder.machine.events[:observe].cycle?.should be_true
572
+ end
573
+
574
+ it "have a list of execute hooks" do
575
+ Recorder.machine.events[:observe].hooks[:execute].should == [:a1, :b1, :c1, :a2, :b2, :c2, :a3, :b3, :c3]
576
+ end
577
+ end
578
+
579
+ describe "it" do
580
+ before do
581
+ @recorder = Recorder.new
582
+ end
583
+
584
+ it "fire a transition on #observe!" do
585
+ t = @recorder.observe!
586
+ results = @recorder.received
587
+ t.should be_kind_of(StateFu::Transition)
588
+ t.should be_complete
589
+ end
590
+
591
+ describe "observing method calls on #observe!" do
592
+ before do
593
+ @t = @recorder.observe!
594
+ @results = @recorder.received
595
+ end
596
+
597
+ it "call the event's :execute hooks on #observe!" do
598
+ @results.keys.should =~ [:a1, :b1, :c1, :a2, :b2, :c2, :a3, :b3, :c3]
599
+ end
600
+
601
+ describe "methods which expect one argument" do
602
+ it "receive a StateFu::Transition" do
603
+ @results[:a1].should == [@t]
604
+ @results[:b1].should == [@t]
605
+ @results[:c1].should == [[@t]]
606
+ end
607
+ end
608
+
609
+ describe "methods which expect two arguments" do
610
+ it "receive a StateFu::Transition and an argument list" do
611
+ @results[:a2].should == [@t, @t.args]
612
+ @results[:b2].should == [@t, @t.args]
613
+ @results[:c2].should == [@t, [@t.args]]
614
+ end
615
+ end
616
+
617
+ describe "methods which expect three arguments" do
618
+ it "receive a StateFu::Transition, an argument list and the recorder object" do
619
+ @results[:a3].should == [@t, @t.args, @recorder]
620
+ @results[:b3].should == [@t, @t.args, @recorder]
621
+ @results[:c3].should == [@t, @t.args, [@recorder]]
622
+ end
623
+ end
624
+ end
625
+ end
626
+ end
627
+
628
+ #
629
+ # Pokies
630
+ #
631
+
632
+ describe "sitting at a poker machine" do
633
+
634
+ before :all do
635
+ make_pristine_class('PokerMachine') do
636
+
637
+ attr_accessor :silly_noises_inflicted
638
+
639
+ def insert_coins n
640
+ @credits = n * PokerMachine::CREDITS_PER_COIN
641
+ end
642
+
643
+ # sets coins to 0 and returns what it was
644
+ def refund_coins
645
+ (self.credits, x = 0, self.credits / PokerMachine::CREDITS_PER_COIN)[1]
646
+ end
647
+
648
+ def play_a_silly_noise
649
+ @silly_noises_inflicted << [:silly_noise]
650
+ end
651
+
652
+ # an array with the accessors (StateFu::Bindings)
653
+ # for each of the wheels' state machines, for convenience
654
+ def wheels
655
+ [wheel_one, wheel_two, wheel_three]
656
+ end
657
+
658
+ def wheels_spinning?
659
+ wheels.any?(&:spinning?)
660
+ end
661
+
662
+ def display
663
+ wheels.map(&:current_state_name)
664
+ end
665
+
666
+ def wait
667
+ while wheels_spinning?
668
+ spin_wheels!
669
+ end
670
+ stop_spinning!
671
+ end
672
+
673
+ PokerMachine::CREDITS_TO_PLAY = 5
674
+ PokerMachine::CREDITS_PER_COIN = 5
675
+
676
+ attr_accessor :credits
677
+
678
+ def initialize
679
+ @credits = 0
680
+ @silly_noises_inflicted = []
681
+ end
682
+
683
+ machine do
684
+ # adds a hook to the machine's global after slot
685
+ after_everything :play_a_silly_noise
686
+
687
+ # Define helper methods with 'proc' or its alias 'define'. This is
688
+ # implicit when you supply a block and a symbol for an event or state
689
+ # hook, a requirement, or a requirement failure message.
690
+ #
691
+ # Named procs are "machine-local": they are available in any other
692
+ # block evaluated by StateFu for a given machine, but are not defined
693
+ # on the stateful class itself.
694
+ #
695
+ # Use them to extend the state machine DSL without cluttering up your
696
+ # classes themselves.
697
+ #
698
+ # If you want a method which spans multiple machines (eg 'wheels',
699
+ # above) or which is available to your object in any context, define
700
+ # it as a standard method. You will then be able to access it in any
701
+ # of your state machines.
702
+ named_proc(:wheel_states) { wheels.map(&:current_state) }
703
+ named_proc(:wheels_stopped?) do
704
+ !wheels.any?(&:spinning?)
705
+ end
706
+
707
+ state :ready do
708
+
709
+ event :pull_lever, :transitions_to => :spinning do
710
+ # The execution context always provides handy access to all the
711
+ # methods of the PokerMachine instance - however, constants must
712
+ # still be qualified.
713
+ requires(:enough_credits) { self.credits >= PokerMachine::CREDITS_TO_PLAY }
714
+ triggers(:deduct_credits) { self.credits -= PokerMachine::CREDITS_TO_PLAY }
715
+ triggers(:spin_wheels) { [wheel_one, wheel_two,wheel_three].each(&:start!) }
716
+ # if we enable this line, the machine will #wait automatically
717
+ # so that merely pulling the lever causes it to return to the ready state:
718
+ #
719
+ # after :wait
720
+ end # :pull_lever event
721
+ end # :ready state
722
+
723
+ state :spinning do
724
+ cycle :spin_wheels do
725
+ # executes after the transition has been accepted
726
+ after do
727
+ wheels.each do |wheel|
728
+ if wheel.spinning?
729
+ wheel.spin!
730
+ end
731
+ end
732
+ end # execute
733
+ end # :spinning state
734
+
735
+ event :stop_spinning, :to => :ready do
736
+ requires :wheels_stopped?
737
+ execute :payout do
738
+ if wheel_states == wheel_states.uniq
739
+ self.credits += wheel_states.first[:value]
740
+ end
741
+ end
742
+ end # :stop_spinning event
743
+ end # spinning state
744
+ end # default machine
745
+
746
+ [:one, :two, :three].each do |wheel|
747
+ machine "wheel_#{wheel}" do
748
+
749
+ state :bomb, :value => -5
750
+ state :cherry, :value => 5
751
+ state :smiley, :value => 10
752
+ state :gold, :value => 15
753
+
754
+ state :spinning do
755
+ cycle :spin do
756
+ execute do
757
+ silly_noises_inflicted << :spinning_noise
758
+ end
759
+ after do
760
+ if rand(3) == 0
761
+ # we use binding.stop! rather than self.stop! here
762
+ # to disambiguate which machine we're sending the event to.
763
+ #
764
+ # .binding yields a StateFu::Binding, which has all the same
765
+ # magic methods as @pokie, but is explicitly for one machine,
766
+ # and one @pokie.
767
+ #
768
+ # @pokie.stop! would always cause the same wheel to stop
769
+ # (the first one, becuase it was defined first, and automatically
770
+ # defined methods never clobber any pre-existing methods) -
771
+ # which isn't what we want here.
772
+ binding.stop!([:bomb, :cherry, :smiley, :gold].rand)
773
+ end
774
+ end
775
+ end
776
+ end
777
+
778
+ initial_state states.except(:spinning).rand
779
+
780
+ event :start, :from => states.except(:spinning), :to => :spinning
781
+ event :stop, :from => :spinning, :to => states.except(:spinning)
782
+
783
+ end # machine :cell_#{cell}
784
+ end # each cell
785
+ end # PokerMachine
786
+ end # before
787
+
788
+ describe "the state machine" do
789
+ end
790
+
791
+ before :each do
792
+ @pokie = PokerMachine.new
793
+ end
794
+
795
+ # just a sanity check for method_missing
796
+ it "doesn't talk to you" do
797
+ lambda { @pokie.talk_to_me }.should raise_error(NoMethodError)
798
+ end
799
+
800
+ it "you need credits to pull the lever" do
801
+ @pokie.state_fu!
802
+ @pokie.credits.should == 0
803
+ @pokie.state_fu!
804
+ @pokie.can_pull_lever?.should == false
805
+ lambda { @pokie.pull_lever! }.should raise_error(StateFu::RequirementError)
806
+ end
807
+
808
+ it "has three wheels" do
809
+ @pokie.wheels.length.should == 3
810
+ end
811
+
812
+ it "displays three icons" do
813
+ @pokie.display.should be_kind_of(Array)
814
+ @pokie.display.map(&:class).should == [Symbol, Symbol, Symbol]
815
+ (@pokie.display - [:bomb, :cherry, :smiley, :gold]).should be_empty
816
+ end
817
+
818
+ describe "putting in 20 coins" do
819
+ before do
820
+ @pokie.insert_coins(20)
821
+ end
822
+
823
+ it "gives you 100 credits" do
824
+ @pokie.credits.should == 100
825
+ end
826
+
827
+ describe "then pulling the lever" do
828
+
829
+ it "spins the icons" do
830
+ @pokie.pull_lever!
831
+ @pokie.display.should == [:spinning, :spinning, :spinning]
832
+ end
833
+
834
+ it "takes away credits" do
835
+ credits_before_pulling_lever = @pokie.credits
836
+ @pokie.pull_lever!
837
+ @pokie.credits.should == credits_before_pulling_lever - PokerMachine::CREDITS_TO_PLAY
838
+ end
839
+
840
+ it "makes a silly noise" do
841
+ lambda { @pokie.pull_lever! }.should change(@pokie.silly_noises_inflicted, :length)
842
+ end
843
+
844
+ it "wont let you pull it again while it's still spinning" do
845
+ @pokie.pull_lever!
846
+ @pokie.spinning?.should be_true
847
+ @pokie.can_pull_lever?.should == nil
848
+ lambda{ @pokie.pull_lever! }.should raise_error(StateFu::IllegalTransition)
849
+ end
850
+
851
+ it "makes a spinning sound while you wait" do
852
+ @pokie.pull_lever!
853
+ noises_before = @pokie.silly_noises_inflicted
854
+ @pokie.wait
855
+ (@pokie.silly_noises_inflicted).should include(:spinning_noise)
856
+ end
857
+
858
+ it "it stops spinning after a little #wait" do
859
+ @pokie.pull_lever!
860
+ @pokie.wait
861
+ @pokie.spinning?.should be_false
862
+ end
863
+
864
+ it "gives you more credits if all the icons are the same" do
865
+ @pokie.pull_lever!
866
+ @pokie.wheel_one.stop! :smiley
867
+ @pokie.wheel_two.stop! :smiley
868
+ @pokie.wheel_three.stop! :smiley
869
+ @pokie.wait
870
+ @pokie.credits.should == 105
871
+ end
872
+ end
873
+ end
874
+ end
875
+
876
+ describe "Chameleon" do
877
+
878
+ before do
879
+ make_pristine_class('Chameleon') do
880
+
881
+ machine :location do
882
+ initial_state :outside
883
+
884
+ event :go_outside, :from => {:inside => :outside}
885
+ event :go_inside, :from => {:outside => :inside}
886
+ end
887
+
888
+ machine :skin do
889
+ initial_state :green
890
+
891
+ states :plaid, :paisley, :tartan, :location => :indoors
892
+ states :bark, :pebbles, :foliage, :location => :outdoors
893
+
894
+ define :change_according_to_surroundings? do |transition|
895
+ if transition.cycle?
896
+ false
897
+ else
898
+ case transition.target[:location]
899
+ when :indoors
900
+ inside?
901
+ when :outdoors
902
+ outside?
903
+ else
904
+ true
905
+ end
906
+ end
907
+ end
908
+
909
+ event :camoflage, :from => states.all, :to => states.all do
910
+ requires :change_according_to_surroundings?, :message => lambda { |t| "It's no good looking like #{t.target.name} when you're #{t.object.location.current_state_name}!"}
911
+ end
912
+
913
+ end
914
+ end # Chameleon
915
+ end # before
916
+
917
+ describe "changing its skin" do
918
+ before do
919
+ @chameleon = Chameleon.new
920
+ end
921
+
922
+ it "should change its skin according to its surroundings" do
923
+ @chameleon.current_state(:location).should == :outside
924
+ @chameleon.current_state(:skin).should == :green
925
+
926
+ @chameleon.outside?.should == true
927
+
928
+ @chameleon.skin.valid_transitions.targets.names.should == [:bark, :pebbles, :foliage]
929
+ @chameleon.camoflage!(:bark)
930
+ @chameleon.skin.should == :bark
931
+
932
+ @chameleon.go_inside!
933
+ @chameleon.skin.valid_transitions.targets.names.should == [:green, :plaid, :paisley, :tartan]
934
+
935
+ @chameleon.camoflage!(:tartan)
936
+ @chameleon.skin.should == :tartan
937
+
938
+ @chameleon.camoflage!(:green)
939
+ @chameleon.skin.should == :green
940
+
941
+ lambda { @chameleon.camoflage!(:bark) }.should raise_error(StateFu::RequirementError)
942
+ @chameleon.camoflage(:bark).error_messages.should == ["It's no good looking like bark when you're inside!"]
943
+ end
944
+
945
+ end
946
+ end
947
+
948
+