enum_state_machine 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rvmrc +1 -0
  4. data/Appraisals +28 -0
  5. data/CHANGELOG.md +0 -0
  6. data/Gemfile +4 -0
  7. data/LICENSE +23 -0
  8. data/README.md +4 -0
  9. data/Rakefile +43 -0
  10. data/enum_state_machine.gemspec +25 -0
  11. data/gemfiles/active_model_4.0.4.gemfile +9 -0
  12. data/gemfiles/active_model_4.0.4.gemfile.lock +51 -0
  13. data/gemfiles/active_record_4.0.4.gemfile +11 -0
  14. data/gemfiles/active_record_4.0.4.gemfile.lock +61 -0
  15. data/gemfiles/default.gemfile +7 -0
  16. data/gemfiles/default.gemfile.lock +27 -0
  17. data/gemfiles/graphviz_1.0.9.gemfile +7 -0
  18. data/gemfiles/graphviz_1.0.9.gemfile.lock +30 -0
  19. data/lib/enum_state_machine/assertions.rb +36 -0
  20. data/lib/enum_state_machine/branch.rb +225 -0
  21. data/lib/enum_state_machine/callback.rb +232 -0
  22. data/lib/enum_state_machine/core.rb +12 -0
  23. data/lib/enum_state_machine/core_ext/class/state_machine.rb +5 -0
  24. data/lib/enum_state_machine/core_ext.rb +2 -0
  25. data/lib/enum_state_machine/error.rb +13 -0
  26. data/lib/enum_state_machine/eval_helpers.rb +87 -0
  27. data/lib/enum_state_machine/event.rb +257 -0
  28. data/lib/enum_state_machine/event_collection.rb +141 -0
  29. data/lib/enum_state_machine/extensions.rb +149 -0
  30. data/lib/enum_state_machine/graph.rb +92 -0
  31. data/lib/enum_state_machine/helper_module.rb +17 -0
  32. data/lib/enum_state_machine/initializers/rails.rb +22 -0
  33. data/lib/enum_state_machine/initializers.rb +4 -0
  34. data/lib/enum_state_machine/integrations/active_model/locale.rb +11 -0
  35. data/lib/enum_state_machine/integrations/active_model/observer.rb +33 -0
  36. data/lib/enum_state_machine/integrations/active_model/observer_update.rb +42 -0
  37. data/lib/enum_state_machine/integrations/active_model/versions.rb +31 -0
  38. data/lib/enum_state_machine/integrations/active_model.rb +585 -0
  39. data/lib/enum_state_machine/integrations/active_record/locale.rb +20 -0
  40. data/lib/enum_state_machine/integrations/active_record/versions.rb +123 -0
  41. data/lib/enum_state_machine/integrations/active_record.rb +548 -0
  42. data/lib/enum_state_machine/integrations/base.rb +100 -0
  43. data/lib/enum_state_machine/integrations.rb +97 -0
  44. data/lib/enum_state_machine/machine.rb +2292 -0
  45. data/lib/enum_state_machine/machine_collection.rb +86 -0
  46. data/lib/enum_state_machine/macro_methods.rb +518 -0
  47. data/lib/enum_state_machine/matcher.rb +123 -0
  48. data/lib/enum_state_machine/matcher_helpers.rb +54 -0
  49. data/lib/enum_state_machine/node_collection.rb +222 -0
  50. data/lib/enum_state_machine/path.rb +120 -0
  51. data/lib/enum_state_machine/path_collection.rb +90 -0
  52. data/lib/enum_state_machine/state.rb +297 -0
  53. data/lib/enum_state_machine/state_collection.rb +112 -0
  54. data/lib/enum_state_machine/state_context.rb +138 -0
  55. data/lib/enum_state_machine/state_enum.rb +23 -0
  56. data/lib/enum_state_machine/transition.rb +470 -0
  57. data/lib/enum_state_machine/transition_collection.rb +245 -0
  58. data/lib/enum_state_machine/version.rb +3 -0
  59. data/lib/enum_state_machine/yard/handlers/base.rb +32 -0
  60. data/lib/enum_state_machine/yard/handlers/event.rb +25 -0
  61. data/lib/enum_state_machine/yard/handlers/machine.rb +344 -0
  62. data/lib/enum_state_machine/yard/handlers/state.rb +25 -0
  63. data/lib/enum_state_machine/yard/handlers/transition.rb +47 -0
  64. data/lib/enum_state_machine/yard/handlers.rb +12 -0
  65. data/lib/enum_state_machine/yard/templates/default/class/html/setup.rb +30 -0
  66. data/lib/enum_state_machine/yard/templates/default/class/html/state_machines.erb +12 -0
  67. data/lib/enum_state_machine/yard/templates.rb +3 -0
  68. data/lib/enum_state_machine/yard.rb +8 -0
  69. data/lib/enum_state_machine.rb +9 -0
  70. data/lib/tasks/enum_state_machine.rake +1 -0
  71. data/lib/tasks/enum_state_machine.rb +24 -0
  72. data/lib/yard-enum_state_machine.rb +2 -0
  73. data/test/files/en.yml +9 -0
  74. data/test/files/switch.rb +15 -0
  75. data/test/functional/state_machine_test.rb +1066 -0
  76. data/test/test_helper.rb +7 -0
  77. data/test/unit/assertions_test.rb +40 -0
  78. data/test/unit/branch_test.rb +969 -0
  79. data/test/unit/callback_test.rb +704 -0
  80. data/test/unit/error_test.rb +43 -0
  81. data/test/unit/eval_helpers_test.rb +270 -0
  82. data/test/unit/event_collection_test.rb +398 -0
  83. data/test/unit/event_test.rb +1196 -0
  84. data/test/unit/graph_test.rb +98 -0
  85. data/test/unit/helper_module_test.rb +17 -0
  86. data/test/unit/integrations/active_model_test.rb +1245 -0
  87. data/test/unit/integrations/active_record_test.rb +2551 -0
  88. data/test/unit/integrations/base_test.rb +104 -0
  89. data/test/unit/integrations_test.rb +71 -0
  90. data/test/unit/invalid_event_test.rb +20 -0
  91. data/test/unit/invalid_parallel_transition_test.rb +18 -0
  92. data/test/unit/invalid_transition_test.rb +115 -0
  93. data/test/unit/machine_collection_test.rb +603 -0
  94. data/test/unit/machine_test.rb +3395 -0
  95. data/test/unit/matcher_helpers_test.rb +37 -0
  96. data/test/unit/matcher_test.rb +155 -0
  97. data/test/unit/node_collection_test.rb +362 -0
  98. data/test/unit/path_collection_test.rb +266 -0
  99. data/test/unit/path_test.rb +485 -0
  100. data/test/unit/state_collection_test.rb +352 -0
  101. data/test/unit/state_context_test.rb +441 -0
  102. data/test/unit/state_enum_test.rb +50 -0
  103. data/test/unit/state_machine_test.rb +31 -0
  104. data/test/unit/state_test.rb +1101 -0
  105. data/test/unit/transition_collection_test.rb +2168 -0
  106. data/test/unit/transition_test.rb +1558 -0
  107. metadata +249 -0
@@ -0,0 +1,297 @@
1
+ require 'enum_state_machine/assertions'
2
+ require 'enum_state_machine/state_context'
3
+
4
+ module EnumStateMachine
5
+ # A state defines a value that an attribute can be in after being transitioned
6
+ # 0 or more times. States can represent a value of any type in Ruby, though
7
+ # the most common (and default) type is String.
8
+ #
9
+ # In addition to defining the machine's value, a state can also define a
10
+ # behavioral context for an object when that object is in the state. See
11
+ # EnumStateMachine::Machine#state for more information about how state-driven
12
+ # behavior can be utilized.
13
+ class State
14
+ include Assertions
15
+
16
+ # The state machine for which this state is defined
17
+ attr_accessor :machine
18
+
19
+ # The unique identifier for the state used in event and callback definitions
20
+ attr_reader :name
21
+
22
+ # The fully-qualified identifier for the state, scoped by the machine's
23
+ # namespace
24
+ attr_reader :qualified_name
25
+
26
+ # The human-readable name for the state
27
+ attr_writer :human_name
28
+
29
+ # The value that is written to a machine's attribute when an object
30
+ # transitions into this state
31
+ attr_writer :value
32
+
33
+ # Whether this state's value should be cached after being evaluated
34
+ attr_accessor :cache
35
+
36
+ # Whether or not this state is the initial state to use for new objects
37
+ attr_accessor :initial
38
+ alias_method :initial?, :initial
39
+
40
+ # A custom lambda block for determining whether a given value matches this
41
+ # state
42
+ attr_accessor :matcher
43
+
44
+ # Creates a new state within the context of the given machine.
45
+ #
46
+ # Configuration options:
47
+ # * <tt>:initial</tt> - Whether this state is the beginning state for the
48
+ # machine. Default is false.
49
+ # * <tt>:value</tt> - The value to store when an object transitions to this
50
+ # state. Default is the name (stringified).
51
+ # * <tt>:cache</tt> - If a dynamic value (via a lambda block) is being used,
52
+ # then setting this to true will cache the evaluated result
53
+ # * <tt>:if</tt> - Determines whether a value matches this state
54
+ # (e.g. :value => lambda {Time.now}, :if => lambda {|state| !state.nil?}).
55
+ # By default, the configured value is matched.
56
+ # * <tt>:human_name</tt> - The human-readable version of this state's name
57
+ def initialize(machine, name, options = {}) #:nodoc:
58
+ assert_valid_keys(options, :initial, :value, :cache, :if, :human_name)
59
+
60
+ @machine = machine
61
+ @name = name
62
+ @qualified_name = name && machine.namespace ? :"#{machine.namespace}_#{name}" : name
63
+ @human_name = options[:human_name] || (@name ? @name.to_s.tr('_', ' ') : 'nil')
64
+ @value = options.include?(:value) ? options[:value] : name && name.to_s
65
+ @cache = options[:cache]
66
+ @matcher = options[:if]
67
+ @initial = options[:initial] == true
68
+ @context = StateContext.new(self)
69
+
70
+ if name
71
+ conflicting_machines = machine.owner_class.state_machines.select {|other_name, other_machine| other_machine != machine && other_machine.states[qualified_name, :qualified_name]}
72
+
73
+ # Output a warning if another machine has a conflicting qualified name
74
+ # for a different attribute
75
+ if conflict = conflicting_machines.detect {|other_name, other_machine| other_machine.attribute != machine.attribute}
76
+ name, other_machine = conflict
77
+ warn "State #{qualified_name.inspect} for #{machine.name.inspect} is already defined in #{other_machine.name.inspect}"
78
+ elsif conflicting_machines.empty?
79
+ # Only bother adding predicates when another machine for the same
80
+ # attribute hasn't already done so
81
+ add_predicate
82
+ end
83
+ end
84
+ end
85
+
86
+ # Creates a copy of this state, excluding the context to prevent conflicts
87
+ # across different machines.
88
+ def initialize_copy(orig) #:nodoc:
89
+ super
90
+ @context = StateContext.new(self)
91
+ end
92
+
93
+ # Determines whether there are any states that can be transitioned to from
94
+ # this state. If there are none, then this state is considered *final*.
95
+ # Any objects in a final state will remain so forever given the current
96
+ # machine's definition.
97
+ def final?
98
+ !machine.events.any? do |event|
99
+ event.branches.any? do |branch|
100
+ branch.state_requirements.any? do |requirement|
101
+ requirement[:from].matches?(name) && !requirement[:to].matches?(name, :from => name)
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ # Transforms the state name into a more human-readable format, such as
108
+ # "first gear" instead of "first_gear"
109
+ def human_name(klass = @machine.owner_class)
110
+ @human_name.is_a?(Proc) ? @human_name.call(self, klass) : @human_name
111
+ end
112
+
113
+ # Generates a human-readable description of this state's name / value:
114
+ #
115
+ # For example,
116
+ #
117
+ # State.new(machine, :parked).description # => "parked"
118
+ # State.new(machine, :parked, :value => :parked).description # => "parked"
119
+ # State.new(machine, :parked, :value => nil).description # => "parked (nil)"
120
+ # State.new(machine, :parked, :value => 1).description # => "parked (1)"
121
+ # State.new(machine, :parked, :value => lambda {Time.now}).description # => "parked (*)
122
+ #
123
+ # Configuration options:
124
+ # * <tt>:human_name</tt> - Whether to use this state's human name in the
125
+ # description or just the internal name
126
+ def description(options = {})
127
+ label = options[:human_name] ? human_name : name
128
+ description = label ? label.to_s : label.inspect
129
+ description << " (#{@value.is_a?(Proc) ? '*' : @value.inspect})" unless name.to_s == @value.to_s
130
+ description
131
+ end
132
+
133
+ # The value that represents this state. This will optionally evaluate the
134
+ # original block if it's a lambda block. Otherwise, the static value is
135
+ # returned.
136
+ #
137
+ # For example,
138
+ #
139
+ # State.new(machine, :parked, :value => 1).value # => 1
140
+ # State.new(machine, :parked, :value => lambda {Time.now}).value # => Tue Jan 01 00:00:00 UTC 2008
141
+ # State.new(machine, :parked, :value => lambda {Time.now}).value(false) # => <Proc:0xb6ea7ca0@...>
142
+ def value(eval = true)
143
+ if @value.is_a?(Proc) && eval
144
+ if cache_value?
145
+ @value = @value.call
146
+ machine.states.update(self)
147
+ @value
148
+ else
149
+ @value.call
150
+ end
151
+ else
152
+ @value
153
+ end
154
+ end
155
+
156
+ # Determines whether this state matches the given value. If no matcher is
157
+ # configured, then this will check whether the values are equivalent.
158
+ # Otherwise, the matcher will determine the result.
159
+ #
160
+ # For example,
161
+ #
162
+ # # Without a matcher
163
+ # state = State.new(machine, :parked, :value => 1)
164
+ # state.matches?(1) # => true
165
+ # state.matches?(2) # => false
166
+ #
167
+ # # With a matcher
168
+ # state = State.new(machine, :parked, :value => lambda {Time.now}, :if => lambda {|value| !value.nil?})
169
+ # state.matches?(nil) # => false
170
+ # state.matches?(Time.now) # => true
171
+ def matches?(other_value)
172
+ matcher ? matcher.call(other_value) : other_value == value
173
+ end
174
+
175
+ # Defines a context for the state which will be enabled on instances of
176
+ # the owner class when the machine is in this state.
177
+ #
178
+ # This can be called multiple times. Each time a new context is created,
179
+ # a new module will be included in the owner class.
180
+ def context(&block)
181
+ # Include the context
182
+ context = @context
183
+ machine.owner_class.class_eval { include context }
184
+
185
+ # Evaluate the method definitions and track which ones were added
186
+ old_methods = context_methods
187
+ context.class_eval(&block)
188
+ new_methods = context_methods.to_a.select {|(name, method)| old_methods[name] != method}
189
+
190
+ # Alias new methods so that the only execute when the object is in this state
191
+ new_methods.each do |(method_name, method)|
192
+ context_name = context_name_for(method_name)
193
+ context.class_eval <<-end_eval, __FILE__, __LINE__ + 1
194
+ alias_method :"#{context_name}", :#{method_name}
195
+ def #{method_name}(*args, &block)
196
+ state = self.class.state_machine(#{machine.name.inspect}).states.fetch(#{name.inspect})
197
+ options = {:method_missing => lambda {super(*args, &block)}, :method_name => #{method_name.inspect}}
198
+ state.call(self, :"#{context_name}", *(args + [options]), &block)
199
+ end
200
+ end_eval
201
+ end
202
+
203
+ true
204
+ end
205
+
206
+ # The list of methods that have been defined in this state's context
207
+ def context_methods
208
+ @context.instance_methods.inject({}) do |methods, name|
209
+ methods.merge(name.to_sym => @context.instance_method(name))
210
+ end
211
+ end
212
+
213
+ # Calls a method defined in this state's context on the given object. All
214
+ # arguments and any block will be passed into the method defined.
215
+ #
216
+ # If the method has never been defined for this state, then a NoMethodError
217
+ # will be raised.
218
+ def call(object, method, *args, &block)
219
+ options = args.last.is_a?(Hash) ? args.pop : {}
220
+ options = {:method_name => method}.merge(options)
221
+ state = machine.states.match!(object)
222
+
223
+ if state == self && object.respond_to?(method)
224
+ object.send(method, *args, &block)
225
+ elsif method_missing = options[:method_missing]
226
+ # Dispatch to the superclass since the object either isn't in this state
227
+ # or this state doesn't handle the method
228
+ begin
229
+ method_missing.call
230
+ rescue NoMethodError => ex
231
+ if ex.name.to_s == options[:method_name].to_s && ex.args == args
232
+ # No valid context for this method
233
+ raise InvalidContext.new(object, "State #{state.name.inspect} for #{machine.name.inspect} is not a valid context for calling ##{options[:method_name]}")
234
+ else
235
+ raise
236
+ end
237
+ end
238
+ end
239
+ end
240
+
241
+ # Draws a representation of this state on the given machine. This will
242
+ # create a new node on the graph with the following properties:
243
+ # * +label+ - The human-friendly description of the state.
244
+ # * +width+ - The width of the node. Always 1.
245
+ # * +height+ - The height of the node. Always 1.
246
+ # * +shape+ - The actual shape of the node. If the state is a final
247
+ # state, then "doublecircle", otherwise "ellipse".
248
+ #
249
+ # Configuration options:
250
+ # * <tt>:human_name</tt> - Whether to use the state's human name for the
251
+ # node's label that gets drawn on the graph
252
+ def draw(graph, options = {})
253
+ node = graph.add_nodes(name ? name.to_s : 'nil',
254
+ :label => description(options),
255
+ :width => '1',
256
+ :height => '1',
257
+ :shape => final? ? 'doublecircle' : 'ellipse'
258
+ )
259
+
260
+ # Add open arrow for initial state
261
+ graph.add_edges(graph.add_nodes('starting_state', :shape => 'point'), node) if initial?
262
+
263
+ true
264
+ end
265
+
266
+ # Generates a nicely formatted description of this state's contents.
267
+ #
268
+ # For example,
269
+ #
270
+ # state = EnumStateMachine::State.new(machine, :parked, :value => 1, :initial => true)
271
+ # state # => #<EnumStateMachine::State name=:parked value=1 initial=true context=[]>
272
+ def inspect
273
+ attributes = [[:name, name], [:value, @value], [:initial, initial?]]
274
+ "#<#{self.class} #{attributes.map {|attr, value| "#{attr}=#{value.inspect}"} * ' '}>"
275
+ end
276
+
277
+ private
278
+ # Should the value be cached after it's evaluated for the first time?
279
+ def cache_value?
280
+ @cache
281
+ end
282
+
283
+ # Adds a predicate method to the owner class so long as a name has
284
+ # actually been configured for the state
285
+ def add_predicate
286
+ # Checks whether the current value matches this state
287
+ machine.define_helper(:instance, "#{qualified_name}?") do |machine, object|
288
+ machine.states.matches?(object, name)
289
+ end
290
+ end
291
+
292
+ # Generates the name of the method containing the actual implementation
293
+ def context_name_for(method)
294
+ :"__#{machine.name}_#{name}_#{method}_#{@context.object_id}__"
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,112 @@
1
+ require 'enum_state_machine/node_collection'
2
+
3
+ module EnumStateMachine
4
+ # Represents a collection of states in a state machine
5
+ class StateCollection < NodeCollection
6
+ def initialize(machine) #:nodoc:
7
+ super(machine, :index => [:name, :qualified_name, :value])
8
+ end
9
+
10
+ # Determines whether the given object is in a specific state. If the
11
+ # object's current value doesn't match the state, then this will return
12
+ # false, otherwise true. If the given state is unknown, then an IndexError
13
+ # will be raised.
14
+ #
15
+ # == Examples
16
+ #
17
+ # class Vehicle
18
+ # state_machine :initial => :parked do
19
+ # other_states :idling
20
+ # end
21
+ # end
22
+ #
23
+ # states = Vehicle.state_machine.states
24
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
25
+ #
26
+ # states.matches?(vehicle, :parked) # => true
27
+ # states.matches?(vehicle, :idling) # => false
28
+ # states.matches?(vehicle, :invalid) # => IndexError: :invalid is an invalid key for :name index
29
+ def matches?(object, name)
30
+ fetch(name).matches?(machine.read(object, :state))
31
+ end
32
+
33
+ # Determines the current state of the given object as configured by this
34
+ # state machine. This will attempt to find a known state that matches
35
+ # the value of the attribute on the object.
36
+ #
37
+ # == Examples
38
+ #
39
+ # class Vehicle
40
+ # state_machine :initial => :parked do
41
+ # other_states :idling
42
+ # end
43
+ # end
44
+ #
45
+ # states = Vehicle.state_machine.states
46
+ #
47
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
48
+ # states.match(vehicle) # => #<EnumStateMachine::State name=:parked value="parked" initial=true>
49
+ #
50
+ # vehicle.state = 'idling'
51
+ # states.match(vehicle) # => #<EnumStateMachine::State name=:idling value="idling" initial=true>
52
+ #
53
+ # vehicle.state = 'invalid'
54
+ # states.match(vehicle) # => nil
55
+ def match(object)
56
+ value = machine.read(object, :state)
57
+ self[value, :value] || detect {|state| state.matches?(value)}
58
+ end
59
+
60
+ # Determines the current state of the given object as configured by this
61
+ # state machine. If no state is found, then an ArgumentError will be
62
+ # raised.
63
+ #
64
+ # == Examples
65
+ #
66
+ # class Vehicle
67
+ # state_machine :initial => :parked do
68
+ # other_states :idling
69
+ # end
70
+ # end
71
+ #
72
+ # states = Vehicle.state_machine.states
73
+ #
74
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
75
+ # states.match!(vehicle) # => #<EnumStateMachine::State name=:parked value="parked" initial=true>
76
+ #
77
+ # vehicle.state = 'invalid'
78
+ # states.match!(vehicle) # => ArgumentError: "invalid" is not a known state value
79
+ def match!(object)
80
+ match(object) || raise(ArgumentError, "#{machine.read(object, :state).inspect} is not a known #{machine.name} value")
81
+ end
82
+
83
+ # Gets the order in which states should be displayed based on where they
84
+ # were first referenced. This will order states in the following priority:
85
+ #
86
+ # 1. Initial state
87
+ # 2. Event transitions (:from, :except_from, :to, :except_to options)
88
+ # 3. States with behaviors
89
+ # 4. States referenced via +state+ or +other_states+
90
+ # 5. States referenced in callbacks
91
+ #
92
+ # This order will determine how the GraphViz visualizations are rendered.
93
+ def by_priority
94
+ order = select {|state| state.initial}.map {|state| state.name}
95
+
96
+ machine.events.each {|event| order += event.known_states}
97
+ order += select {|state| state.context_methods.any?}.map {|state| state.name}
98
+ order += keys(:name) - machine.callbacks.values.flatten.map {|callback| callback.known_states}.flatten
99
+ order += keys(:name)
100
+
101
+ order.uniq!
102
+ order.map! {|name| self[name]}
103
+ order
104
+ end
105
+
106
+ private
107
+ # Gets the value for the given attribute on the node
108
+ def value(node, attribute)
109
+ attribute == :value ? node.value(false) : super
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,138 @@
1
+ require 'enum_state_machine/assertions'
2
+ require 'enum_state_machine/eval_helpers'
3
+
4
+ module EnumStateMachine
5
+ # A method was called in an invalid state context
6
+ class InvalidContext < Error
7
+ end
8
+
9
+ # Represents a module which will get evaluated within the context of a state.
10
+ #
11
+ # Class-level methods are proxied to the owner class, injecting a custom
12
+ # <tt>:if</tt> condition along with method. This assumes that the method has
13
+ # support for a set of configuration options, including <tt>:if</tt>. This
14
+ # condition will check that the object's state matches this context's state.
15
+ #
16
+ # Instance-level methods are used to define state-driven behavior on the
17
+ # state's owner class.
18
+ #
19
+ # == Examples
20
+ #
21
+ # class Vehicle
22
+ # class << self
23
+ # attr_accessor :validations
24
+ #
25
+ # def validate(options, &block)
26
+ # validations << options
27
+ # end
28
+ # end
29
+ #
30
+ # self.validations = []
31
+ # attr_accessor :state, :simulate
32
+ #
33
+ # def moving?
34
+ # self.class.validations.all? {|validation| validation[:if].call(self)}
35
+ # end
36
+ # end
37
+ #
38
+ # In the above class, a simple set of validation behaviors have been defined.
39
+ # Each validation consists of a configuration like so:
40
+ #
41
+ # Vehicle.validate :unless => :simulate
42
+ # Vehicle.validate :if => lambda {|vehicle| ...}
43
+ #
44
+ # In order to scope validations to a particular state context, the class-level
45
+ # +validate+ method can be invoked like so:
46
+ #
47
+ # machine = EnumStateMachine::Machine.new(Vehicle)
48
+ # context = EnumStateMachine::StateContext.new(machine.state(:first_gear))
49
+ # context.validate(:unless => :simulate)
50
+ #
51
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7ce491c @simulate=nil, @state=nil>
52
+ # vehicle.moving? # => false
53
+ #
54
+ # vehicle.state = 'first_gear'
55
+ # vehicle.moving? # => true
56
+ #
57
+ # vehicle.simulate = true
58
+ # vehicle.moving? # => false
59
+ class StateContext < Module
60
+ include Assertions
61
+ include EvalHelpers
62
+
63
+ # The state machine for which this context's state is defined
64
+ attr_reader :machine
65
+
66
+ # The state that must be present in an object for this context to be active
67
+ attr_reader :state
68
+
69
+ # Creates a new context for the given state
70
+ def initialize(state)
71
+ @state = state
72
+ @machine = state.machine
73
+
74
+ state_name = state.name
75
+ machine_name = machine.name
76
+ @condition = lambda {|object| object.class.state_machine(machine_name).states.matches?(object, state_name)}
77
+ end
78
+
79
+ # Creates a new transition that determines what to change the current state
80
+ # to when an event fires from this state.
81
+ #
82
+ # Since this transition is being defined within a state context, you do
83
+ # *not* need to specify the <tt>:from</tt> option for the transition. For
84
+ # example:
85
+ #
86
+ # state_machine do
87
+ # state :parked do
88
+ # transition :to => :idling, :on => [:ignite, :shift_up] # Transitions to :idling
89
+ # transition :from => [:idling, :parked], :on => :park, :unless => :seatbelt_on? # Transitions to :parked if seatbelt is off
90
+ # end
91
+ # end
92
+ #
93
+ # See EnumStateMachine::Machine#transition for a description of the possible
94
+ # configurations for defining transitions.
95
+ def transition(options)
96
+ assert_valid_keys(options, :from, :to, :on, :if, :unless)
97
+ raise ArgumentError, 'Must specify :on event' unless options[:on]
98
+ raise ArgumentError, 'Must specify either :to or :from state' unless !options[:to] ^ !options[:from]
99
+
100
+ machine.transition(options.merge(options[:to] ? {:from => state.name} : {:to => state.name}))
101
+ end
102
+
103
+ # Hooks in condition-merging to methods that don't exist in this module
104
+ def method_missing(*args, &block)
105
+ # Get the configuration
106
+ if args.last.is_a?(Hash)
107
+ options = args.last
108
+ else
109
+ args << options = {}
110
+ end
111
+
112
+ # Get any existing condition that may need to be merged
113
+ if_condition = options.delete(:if)
114
+ unless_condition = options.delete(:unless)
115
+
116
+ # Provide scope access to configuration in case the block is evaluated
117
+ # within the object instance
118
+ proxy = self
119
+ proxy_condition = @condition
120
+
121
+ # Replace the configuration condition with the one configured for this
122
+ # proxy, merging together any existing conditions
123
+ options[:if] = lambda do |*condition_args|
124
+ # Block may be executed within the context of the actual object, so
125
+ # it'll either be the first argument or the executing context
126
+ object = condition_args.first || self
127
+
128
+ proxy.evaluate_method(object, proxy_condition) &&
129
+ Array(if_condition).all? {|condition| proxy.evaluate_method(object, condition)} &&
130
+ !Array(unless_condition).any? {|condition| proxy.evaluate_method(object, condition)}
131
+ end
132
+
133
+ # Evaluate the method on the owner class with the condition proxied
134
+ # through
135
+ machine.owner_class.send(*args, &block)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,23 @@
1
+ require 'power_enum'
2
+
3
+ module EnumStateMachine
4
+ module StateEnum
5
+ def self.included(base)
6
+ base.extend ClassMethods if defined?(PowerEnum)
7
+ end
8
+
9
+ module ClassMethods
10
+ def has_state_enum(state_attr, enum_attr, enum_opts = {})
11
+ has_enumerated enum_attr, enum_opts
12
+
13
+ define_method "#{state_attr}" do
14
+ public_send("#{enum_attr}").to_s
15
+ end
16
+
17
+ define_method "#{state_attr}=" do |value|
18
+ public_send("#{enum_attr}=", value)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end