state-fu 0.11.1

Sign up to get free protection for your applications and to get access to all the features.
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
+