spree-state_machine 2.0.0.beta1
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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.travis.yml +12 -0
- data/.yardopts +5 -0
- data/CHANGELOG.md +502 -0
- data/Gemfile +3 -0
- data/LICENSE +20 -0
- data/README.md +1246 -0
- data/Rakefile +20 -0
- data/examples/AutoShop_state.png +0 -0
- data/examples/Car_state.png +0 -0
- data/examples/Gemfile +5 -0
- data/examples/Gemfile.lock +14 -0
- data/examples/TrafficLight_state.png +0 -0
- data/examples/Vehicle_state.png +0 -0
- data/examples/auto_shop.rb +13 -0
- data/examples/car.rb +21 -0
- data/examples/doc/AutoShop.html +2856 -0
- data/examples/doc/AutoShop_state.png +0 -0
- data/examples/doc/Car.html +919 -0
- data/examples/doc/Car_state.png +0 -0
- data/examples/doc/TrafficLight.html +2230 -0
- data/examples/doc/TrafficLight_state.png +0 -0
- data/examples/doc/Vehicle.html +7921 -0
- data/examples/doc/Vehicle_state.png +0 -0
- data/examples/doc/_index.html +136 -0
- data/examples/doc/class_list.html +47 -0
- data/examples/doc/css/common.css +1 -0
- data/examples/doc/css/full_list.css +55 -0
- data/examples/doc/css/style.css +322 -0
- data/examples/doc/file_list.html +46 -0
- data/examples/doc/frames.html +13 -0
- data/examples/doc/index.html +136 -0
- data/examples/doc/js/app.js +205 -0
- data/examples/doc/js/full_list.js +173 -0
- data/examples/doc/js/jquery.js +16 -0
- data/examples/doc/method_list.html +734 -0
- data/examples/doc/top-level-namespace.html +105 -0
- data/examples/merb-rest/controller.rb +51 -0
- data/examples/merb-rest/model.rb +28 -0
- data/examples/merb-rest/view_edit.html.erb +24 -0
- data/examples/merb-rest/view_index.html.erb +23 -0
- data/examples/merb-rest/view_new.html.erb +13 -0
- data/examples/merb-rest/view_show.html.erb +17 -0
- data/examples/rails-rest/controller.rb +43 -0
- data/examples/rails-rest/migration.rb +7 -0
- data/examples/rails-rest/model.rb +23 -0
- data/examples/rails-rest/view__form.html.erb +34 -0
- data/examples/rails-rest/view_edit.html.erb +6 -0
- data/examples/rails-rest/view_index.html.erb +25 -0
- data/examples/rails-rest/view_new.html.erb +5 -0
- data/examples/rails-rest/view_show.html.erb +19 -0
- data/examples/traffic_light.rb +9 -0
- data/examples/vehicle.rb +33 -0
- data/lib/state_machine/assertions.rb +36 -0
- data/lib/state_machine/branch.rb +225 -0
- data/lib/state_machine/callback.rb +236 -0
- data/lib/state_machine/core.rb +7 -0
- data/lib/state_machine/core_ext/class/state_machine.rb +5 -0
- data/lib/state_machine/core_ext.rb +2 -0
- data/lib/state_machine/error.rb +13 -0
- data/lib/state_machine/eval_helpers.rb +87 -0
- data/lib/state_machine/event.rb +257 -0
- data/lib/state_machine/event_collection.rb +141 -0
- data/lib/state_machine/extensions.rb +149 -0
- data/lib/state_machine/graph.rb +92 -0
- data/lib/state_machine/helper_module.rb +17 -0
- data/lib/state_machine/initializers/rails.rb +25 -0
- data/lib/state_machine/initializers.rb +4 -0
- data/lib/state_machine/integrations/active_model/locale.rb +11 -0
- data/lib/state_machine/integrations/active_model/observer.rb +33 -0
- data/lib/state_machine/integrations/active_model/observer_update.rb +42 -0
- data/lib/state_machine/integrations/active_model/versions.rb +31 -0
- data/lib/state_machine/integrations/active_model.rb +585 -0
- data/lib/state_machine/integrations/active_record/locale.rb +20 -0
- data/lib/state_machine/integrations/active_record/versions.rb +123 -0
- data/lib/state_machine/integrations/active_record.rb +525 -0
- data/lib/state_machine/integrations/base.rb +100 -0
- data/lib/state_machine/integrations.rb +121 -0
- data/lib/state_machine/machine.rb +2287 -0
- data/lib/state_machine/machine_collection.rb +74 -0
- data/lib/state_machine/macro_methods.rb +522 -0
- data/lib/state_machine/matcher.rb +123 -0
- data/lib/state_machine/matcher_helpers.rb +54 -0
- data/lib/state_machine/node_collection.rb +222 -0
- data/lib/state_machine/path.rb +120 -0
- data/lib/state_machine/path_collection.rb +90 -0
- data/lib/state_machine/state.rb +297 -0
- data/lib/state_machine/state_collection.rb +112 -0
- data/lib/state_machine/state_context.rb +138 -0
- data/lib/state_machine/transition.rb +470 -0
- data/lib/state_machine/transition_collection.rb +245 -0
- data/lib/state_machine/version.rb +3 -0
- data/lib/state_machine/yard/handlers/base.rb +32 -0
- data/lib/state_machine/yard/handlers/event.rb +25 -0
- data/lib/state_machine/yard/handlers/machine.rb +344 -0
- data/lib/state_machine/yard/handlers/state.rb +25 -0
- data/lib/state_machine/yard/handlers/transition.rb +47 -0
- data/lib/state_machine/yard/handlers.rb +12 -0
- data/lib/state_machine/yard/templates/default/class/html/setup.rb +30 -0
- data/lib/state_machine/yard/templates/default/class/html/state_machines.erb +12 -0
- data/lib/state_machine/yard/templates.rb +3 -0
- data/lib/state_machine/yard.rb +8 -0
- data/lib/state_machine.rb +8 -0
- data/lib/yard-state_machine.rb +2 -0
- data/state_machine.gemspec +22 -0
- data/test/files/en.yml +17 -0
- data/test/files/switch.rb +15 -0
- data/test/functional/state_machine_test.rb +1066 -0
- data/test/test_helper.rb +7 -0
- data/test/unit/assertions_test.rb +40 -0
- data/test/unit/branch_test.rb +969 -0
- data/test/unit/callback_test.rb +704 -0
- data/test/unit/error_test.rb +43 -0
- data/test/unit/eval_helpers_test.rb +270 -0
- data/test/unit/event_collection_test.rb +398 -0
- data/test/unit/event_test.rb +1196 -0
- data/test/unit/graph_test.rb +98 -0
- data/test/unit/helper_module_test.rb +17 -0
- data/test/unit/integrations/active_model_test.rb +1245 -0
- data/test/unit/integrations/active_record_test.rb +2551 -0
- data/test/unit/integrations/base_test.rb +104 -0
- data/test/unit/integrations_test.rb +71 -0
- data/test/unit/invalid_event_test.rb +20 -0
- data/test/unit/invalid_parallel_transition_test.rb +18 -0
- data/test/unit/invalid_transition_test.rb +115 -0
- data/test/unit/machine_collection_test.rb +603 -0
- data/test/unit/machine_test.rb +3395 -0
- data/test/unit/matcher_helpers_test.rb +37 -0
- data/test/unit/matcher_test.rb +155 -0
- data/test/unit/node_collection_test.rb +362 -0
- data/test/unit/path_collection_test.rb +266 -0
- data/test/unit/path_test.rb +485 -0
- data/test/unit/state_collection_test.rb +352 -0
- data/test/unit/state_context_test.rb +441 -0
- data/test/unit/state_machine_test.rb +31 -0
- data/test/unit/state_test.rb +1101 -0
- data/test/unit/transition_collection_test.rb +2168 -0
- data/test/unit/transition_test.rb +1558 -0
- metadata +264 -0
@@ -0,0 +1,297 @@
|
|
1
|
+
require 'state_machine/assertions'
|
2
|
+
require 'state_machine/state_context'
|
3
|
+
|
4
|
+
module StateMachine
|
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
|
+
# StateMachine::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 = StateMachine::State.new(machine, :parked, :value => 1, :initial => true)
|
271
|
+
# state # => #<StateMachine::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 'state_machine/node_collection'
|
2
|
+
|
3
|
+
module StateMachine
|
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) # => #<StateMachine::State name=:parked value="parked" initial=true>
|
49
|
+
#
|
50
|
+
# vehicle.state = 'idling'
|
51
|
+
# states.match(vehicle) # => #<StateMachine::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) # => #<StateMachine::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 'state_machine/assertions'
|
2
|
+
require 'state_machine/eval_helpers'
|
3
|
+
|
4
|
+
module StateMachine
|
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 = StateMachine::Machine.new(Vehicle)
|
48
|
+
# context = StateMachine::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 StateMachine::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
|