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/lib/transition.rb ADDED
@@ -0,0 +1,338 @@
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
+ # TODO - make transition evaluate as true if accepted, false if failed, or nil unless fired
10
+
11
+ class Transition
12
+ include Applicable
13
+ include HasOptions
14
+
15
+ attr_reader :binding,
16
+ :machine,
17
+ :origin,
18
+ :target,
19
+ :event,
20
+ :args,
21
+ :errors,
22
+ :object,
23
+ :current_hook_slot,
24
+ :current_hook
25
+
26
+ alias_method :arguments, :args
27
+
28
+ def initialize( binding, event, target=nil, *argument_list, &block )
29
+ # ensure we have an Event
30
+ event = binding.machine.events[event] if event.is_a?(Symbol)
31
+ raise( UnknownTarget.new(self, "Not an event: #{event} #{self.inspect}" )) unless event.is_a? Event
32
+
33
+ @binding = binding
34
+ @machine = binding.machine
35
+ @object = binding.object
36
+ @origin = binding.current_state
37
+
38
+ # ensure we have a target
39
+ target = find_event_target( event, target ) || raise( UnknownTarget.new(self, "target cannot be determined: #{target.inspect} #{self.inspect}"))
40
+
41
+ @target = target
42
+ @event = event
43
+ @errors = []
44
+
45
+ if event.target_for_origin(origin) == target
46
+ # it's a "sequence"
47
+ # which is a hacky way of emulating simpler state machines with
48
+ # state-local events - and in which case, the targets & origins are
49
+ # valid. Quite likely this notion will be removed in time.
50
+ else
51
+ # ensure target is valid for the event
52
+ unless event.targets.include? target
53
+ raise IllegalTransition.new self, "Illegal target #{target} for #{event}"
54
+ end
55
+
56
+ # ensure current_state is a valid origin for the event
57
+ unless event.origins.include? origin
58
+ raise IllegalTransition.new( self, "Illegal event #{event.name} for current state #{binding.state_name}" )
59
+ end
60
+ end
61
+
62
+ machine.inject_helpers_into( self )
63
+ self.args = argument_list
64
+ apply!(argument_list, &block )
65
+ end
66
+
67
+
68
+ def valid?(*args)
69
+ self.args = args unless args.empty?
70
+ requirements_met?(true, true) # revalidate; exit on first failure
71
+ end
72
+
73
+ def args=(args)
74
+ @args = args.extend(TransitionArgsArray).init(self)
75
+ apply!(args) if args.last.is_a?(Hash) unless options.nil?
76
+ end
77
+
78
+ def with(*args)
79
+ self.args = args unless args.empty?
80
+ self
81
+ end
82
+
83
+ def with?(*args)
84
+ valid?
85
+ end
86
+ alias_method :valid_with?, :with?
87
+
88
+ #
89
+ # Requirements
90
+ #
91
+
92
+ def requirements
93
+ origin.exit_requirements + target.entry_requirements + event.requirements
94
+ end
95
+
96
+ def unmet_requirements(revalidate=false, fail_fast=false)
97
+ if revalidate
98
+ @unmet_requirements = nil
99
+ else
100
+ return @unmet_requirements if @unmet_requirements
101
+ end
102
+ result = [requirements].flatten.uniq.inject([]) do |unmet, requirement|
103
+ unmet << requirement unless evaluate(requirement)
104
+ break(unmet) if fail_fast && !unmet.empty?
105
+ unmet
106
+ end
107
+ raise self.inspect if result.nil?
108
+ # don't cache result if it might
109
+ @unmet_requirements = result unless (fail_fast && unmet_requirements.length != 0)
110
+ result
111
+ end
112
+
113
+ def first_unmet_requirement(revalidate=false)
114
+ unmet_requirements(revalidate, fail_fast=true)[0]
115
+ end
116
+
117
+ def unmet_requirement_messages(revalidate=false, fail_fast=false) # TODO
118
+ unmet_requirements(revalidate, fail_fast).map do |requirement|
119
+ evaluate_requirement_message(requirement, revalidate)
120
+ end.extend MessageArray
121
+ end
122
+ alias_method :error_messages, :unmet_requirement_messages
123
+
124
+ # return a hash of requirement_name => evaluated message
125
+ def requirement_errors(revalidate=false, fail_fast=false)
126
+ unmet_requirements(revalidate, fail_fast).
127
+ map { |requirement| [requirement, evaluate_requirement_message(requirement)]}.
128
+ to_h
129
+ end
130
+
131
+ def first_unmet_requirement_message(revalidate=false)
132
+ evaluate_requirement_message(first_unmet_requirement(revalidate), revalidate)
133
+ end
134
+
135
+ # raise a RequirementError unless all requirements are met.
136
+ def check_requirements!(revalidate=false, fail_fast=true) # TODO
137
+ raise RequirementError.new( self, unmet_requirement_messages.inspect ) unless requirements_met?(revalidate, fail_fast)
138
+ end
139
+
140
+ def requirements_met?(revalidate=false, fail_fast=false) # TODO
141
+ unmet_requirements(revalidate, fail_fast).empty?
142
+ end
143
+
144
+ #
145
+ # Hooks
146
+ #
147
+ def hooks_for(element, slot)
148
+ send(element).hooks[slot]
149
+ end
150
+
151
+ def hooks
152
+ StateFu::Hooks::ALL_HOOKS.map do |owner, slot|
153
+ [ [owner, slot], send(owner).hooks[slot] ]
154
+ end
155
+ end
156
+
157
+ def run_hook hook
158
+ evaluate hook
159
+ end
160
+
161
+ #
162
+ # Halt a transition mid-execution
163
+ #
164
+
165
+ # halt a transition with a message
166
+ # can be used to back out of a transition inside eg a state entry hook
167
+ def halt! message
168
+ raise TransitionHalted.new( self, message )
169
+ end
170
+
171
+ #
172
+ # Fire!
173
+ #
174
+
175
+ # actually fire the transition
176
+ def fire!(*arguments) # block?
177
+ raise TransitionAlreadyFired.new(self) if fired?
178
+ self.args = arguments unless arguments.empty?
179
+
180
+ check_requirements!
181
+ @fired = true
182
+ begin
183
+ # duplicated: see #hooks method
184
+ StateFu::Hooks::ALL_HOOKS.map do |owner, slot|
185
+ [ [owner, slot], send(owner).hooks[slot] ]
186
+ end.each do |address, hooks|
187
+ Logger.info("running #{address.inspect} hooks for #{object.class} #{object}")
188
+ owner,slot = *address
189
+ hooks.each do |hook|
190
+ Logger.info("running hook #{hooks} for #{object.class} #{object}")
191
+ @current_hook_slot = address
192
+ @current_hook = hook
193
+ run_hook hook
194
+ end
195
+ if slot == :entry
196
+ @accepted = true
197
+ @binding.persister.current_state = @target
198
+ Logger.info("State is now :#{@target.name} for #{object.class} #{object}")
199
+ end
200
+ end
201
+ # transition complete
202
+ @current_hook_slot = nil
203
+ @current_hook = nil
204
+ rescue TransitionHalted => e
205
+ Logger.info("Transition halted for #{object.class} #{object}: #{e.inspect}")
206
+ @errors << e
207
+ end
208
+ self
209
+ end
210
+
211
+ #
212
+ # It can pretend it's a hash; so the transition makes a good argument to be
213
+ # passed to methods.
214
+ #
215
+ include Enumerable
216
+
217
+ def each *a, &b
218
+ options.each *a, &b
219
+ end
220
+
221
+ def halted?
222
+ !@errors.empty?
223
+ end
224
+
225
+ def fired?
226
+ !!@fired
227
+ end
228
+
229
+ def accepted?
230
+ !!@accepted
231
+ end
232
+ alias_method :complete?, :accepted?
233
+
234
+ def current_state
235
+ binding.current_state
236
+ end
237
+
238
+ # a little convenience for debugging / display
239
+ def destination
240
+ [event, target].map(&:to_sym)
241
+ end
242
+
243
+ #
244
+ # give as many choices as possible
245
+ #
246
+
247
+ alias_method :obj, :object
248
+ alias_method :instance, :object
249
+ alias_method :model, :object
250
+ alias_method :instance, :object
251
+
252
+ alias_method :destination, :target
253
+ alias_method :final_state, :target
254
+ alias_method :to, :target
255
+
256
+ alias_method :original_state, :origin
257
+ alias_method :initial_state, :origin
258
+ alias_method :from, :origin
259
+
260
+ def cycle?
261
+ origin == target
262
+ end
263
+
264
+ # an accepted transition == true
265
+ # an unaccepted transition == false
266
+ # same for === (for case equality)
267
+ def == other
268
+ case other
269
+ when true
270
+ accepted?
271
+ when false
272
+ !accepted?
273
+ when State, Symbol
274
+ current_state == other.to_sym
275
+ when Transition
276
+ inspect == other.inspect
277
+ else
278
+ super( other )
279
+ end
280
+ end
281
+
282
+ # display nice and short
283
+ def inspect
284
+ s = self.to_s
285
+ s = s[0,s.length-1]
286
+ s << " event=#{event.to_sym.inspect}" if event
287
+ s << " origin=#{origin.to_sym.inspect}" if origin
288
+ s << " target=#{target.to_sym.inspect}" if target
289
+ s << " args=#{args.inspect}" if args
290
+ s << " options=#{options.inspect}" if options
291
+ s << ">"
292
+ s
293
+ end
294
+
295
+ private
296
+
297
+ def executioner
298
+ @executioner ||= Executioner.new( self ) do |ex|
299
+ machine.inject_helpers_into( ex )
300
+ machine.inject_methods_into( ex )
301
+ end
302
+ end
303
+
304
+ def evaluate(method_name_or_proc)
305
+ executioner.evaluate(method_name_or_proc)
306
+ end
307
+
308
+ def evaluate_requirement_message( name, revalidate=false)
309
+ @requirement_messages ||= {}
310
+ name = name.to_sym
311
+ return @requirement_messages[name] if @requirement_messages[name] && !revalidate
312
+ msg = machine.requirement_messages[name.to_sym]
313
+ result = case msg
314
+ when String
315
+ msg
316
+ when Symbol, Proc
317
+ evaluate msg
318
+ else
319
+ name
320
+ end
321
+ @requirement_messages[name] = result
322
+ end
323
+
324
+ def find_event_target( evt, tgt )
325
+ case tgt
326
+ when StateFu::State
327
+ tgt
328
+ when Symbol
329
+ binding && binding.machine.states[ tgt ]
330
+ when NilClass
331
+ evt.respond_to?(:target) && evt.target
332
+ else
333
+ raise ArgumentError.new( "#{tgt.class} is not a Symbol, StateFu::State or nil (#{evt})" )
334
+ end
335
+ end
336
+
337
+ end
338
+ end
@@ -0,0 +1,224 @@
1
+ module StateFu
2
+ class TransitionQuery
3
+ attr_accessor :binding, :options, :result, :args, :block
4
+
5
+ def initialize(binding, options={})
6
+ defaults = { :valid => true, :cyclic => nil }
7
+ @options = defaults.merge(options).symbolize_keys
8
+ @binding = binding
9
+ end
10
+
11
+ include Enumerable
12
+
13
+ def each *a, &b
14
+ result.each *a, &b
15
+ end
16
+
17
+ # calling result() will cause the set of transitions to be calculated -
18
+ # the cat will then be either dead or alive; until then it's a litte from
19
+ # column A, a little from column B.
20
+ def method_missing(method_name, *args, &block)
21
+ if result.respond_to?(method_name, true)
22
+ result.__send__(method_name, *args, &block)
23
+ else
24
+ super(method_name, *args, &block)
25
+ end
26
+ end
27
+
28
+ # prepare the query with arguments / block
29
+ # so that they can be applied to the transition once one is selected
30
+ def with(*args, &block)
31
+ @args = args
32
+ @block = block if block_given?
33
+ self
34
+ end
35
+
36
+ # build a list of possible transition destinations ([event, target])
37
+ # without actually constructing any transition objects
38
+ # def all_destinations
39
+ # binding.events.inject([]){ |arr, evt| arr += evt.targets.map{|tgt| [evt,tgt] }; arr}.uniq
40
+ # end
41
+ #
42
+ # def all_destination_names
43
+ # all_destinations.map {|tuple| tuple.map(&:to_sym) }
44
+ # end
45
+
46
+ #
47
+ # Chainable Filters
48
+ #
49
+
50
+ def cyclic
51
+ @options.merge! :cyclic => true
52
+ self
53
+ end
54
+
55
+ def not_cyclic
56
+ @options.merge! :cyclic => false
57
+ self
58
+ end
59
+
60
+ def valid
61
+ @options.merge! :valid => true
62
+ self
63
+ end
64
+
65
+ def not_valid
66
+ @options.merge! :valid => false
67
+ self
68
+ end
69
+ alias_method :invalid, :not_valid
70
+
71
+ def to state
72
+ @options.merge! :target => state
73
+ self
74
+ end
75
+
76
+ def for_event event
77
+ @options.merge! :event => event
78
+ self
79
+ end
80
+
81
+ def simple
82
+ @options.merge! :simple => true
83
+ self
84
+ end
85
+
86
+ #
87
+ # Means to an outcome
88
+ #
89
+
90
+ # find a transition by event and optionally (optional if it can be inferred) target.
91
+ def find(destination=nil, &block)
92
+ # use the prepared event & target, and block, if none are supplied
93
+ event, target = destination.nil? ? [options[:event], options[:target]] : parse_destination(destination)
94
+ block ||= @block
95
+ returning Transition.new(binding, event, target, &block) do |transition|
96
+ if @args
97
+ transition.args = @args
98
+ end
99
+ end
100
+ end
101
+
102
+ def singular
103
+ result.first if result.length == 1
104
+ end
105
+
106
+ def singular?
107
+ !!singular
108
+ end
109
+
110
+ def next
111
+ @options[:cyclic] ||= false
112
+ singular
113
+ end
114
+
115
+ def next_state
116
+ @options[:cyclic] ||= false
117
+ if result.map(&:target).uniq.length == 1
118
+ result.first.target
119
+ end
120
+ end
121
+
122
+ def next_event
123
+ @options[:cyclic] ||= false
124
+ if result.map(&:event).uniq.length == 1
125
+ result.first.event
126
+ end
127
+ end
128
+
129
+ def events
130
+ map {|t| t.event }.extend EventArray
131
+ end
132
+
133
+ def targets
134
+ map {|t| t.target }.extend StateArray
135
+ end
136
+
137
+ private
138
+
139
+ # extend result with this to provide a few conveniences
140
+ module Result
141
+ def states
142
+ map(&:target).uniq.extend StateArray
143
+ end
144
+ alias_method :targets, :states
145
+ alias_method :next_states, :states
146
+
147
+ def events
148
+ map(&:event).uniq.extend EventArray
149
+ end
150
+ end # Result
151
+
152
+ # looks a little complex because of all the places that previously set
153
+ # options can filter the set of transitions - but all it's doing is
154
+ # looping over each event, and each event's possible targets, and building
155
+ # a list of transitions.
156
+
157
+ def result
158
+ @result = binding.events.select do |e|
159
+ case options[:cyclic]
160
+ when true
161
+ e.cycle?
162
+ when false
163
+ !e.cycle?
164
+ else
165
+ true
166
+ end
167
+ end.map do |event|
168
+ next if options[:event] and event != options[:event]
169
+ returning [] do |ts|
170
+
171
+ # TODO hmm ... "sequences" ... undecided on these. see Event / Lathe for more detail
172
+ if options[:sequences]
173
+ if target = event.target_for_origin(current_state)
174
+ ts << binding.transition([event,target], *args) unless options[:cyclic]
175
+ end
176
+ end
177
+
178
+ # build a list of transitions from the possible events and their targets
179
+ if event.targets
180
+ next unless event.target if options[:simple]
181
+ event.targets.flatten.each do |target|
182
+ next if options[:target] and target != options[:target]
183
+ t = Transition.new(binding, event, target, *args)
184
+ ts << t if (t.valid? or !options[:valid])
185
+ end
186
+ end
187
+
188
+ end
189
+ end.flatten.extend(Result)
190
+
191
+ if @args || @block
192
+ @result.each do |t|
193
+ t.apply!( &@block) if @block
194
+ t.args = @args if @args
195
+ end
196
+ end
197
+
198
+ @result
199
+ end # result
200
+
201
+ # sanitizes / extracts destination for find.
202
+ #
203
+ # takes a single, simple (one target only) event,
204
+ # or an array of [event, target],
205
+ # or one of the above with symbols in place of the objects themselves.
206
+ def parse_destination(destination)
207
+ event, target = destination
208
+
209
+ unless event.is_a?(Event)
210
+ event = binding.machine.events[event]
211
+ end
212
+
213
+ unless target.is_a?(State)
214
+ target = binding.machine.states[target] rescue nil
215
+ end
216
+
217
+ raise ArgumentError.new( [event,target].inspect ) unless
218
+ [[Event, State],[Event, NilClass]].include?( [event,target].map(&:class) )
219
+ [event, target]
220
+ end # parse_destination
221
+
222
+ end
223
+ end
224
+