spree-state_machine 2.0.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- 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,123 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
|
3
|
+
module StateMachine
|
4
|
+
# Provides a general strategy pattern for determining whether a match is found
|
5
|
+
# for a value. The algorithm that actually determines the match depends on
|
6
|
+
# the matcher in use.
|
7
|
+
class Matcher
|
8
|
+
# The list of values against which queries are matched
|
9
|
+
attr_reader :values
|
10
|
+
|
11
|
+
# Creates a new matcher for querying against the given set of values
|
12
|
+
def initialize(values = [])
|
13
|
+
@values = values.is_a?(Array) ? values : [values]
|
14
|
+
end
|
15
|
+
|
16
|
+
# Generates a subset of values that exists in both the set of values being
|
17
|
+
# filtered and the values configured for the matcher
|
18
|
+
def filter(values)
|
19
|
+
self.values & values
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Matches any given value. Since there is no configuration for this type of
|
24
|
+
# matcher, it must be used as a singleton.
|
25
|
+
class AllMatcher < Matcher
|
26
|
+
include Singleton
|
27
|
+
|
28
|
+
# Generates a blacklist matcher based on the given set of values
|
29
|
+
#
|
30
|
+
# == Examples
|
31
|
+
#
|
32
|
+
# matcher = StateMachine::AllMatcher.instance - [:parked, :idling]
|
33
|
+
# matcher.matches?(:parked) # => false
|
34
|
+
# matcher.matches?(:first_gear) # => true
|
35
|
+
def -(blacklist)
|
36
|
+
BlacklistMatcher.new(blacklist)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Always returns true
|
40
|
+
def matches?(value, context = {})
|
41
|
+
true
|
42
|
+
end
|
43
|
+
|
44
|
+
# Always returns the given set of values
|
45
|
+
def filter(values)
|
46
|
+
values
|
47
|
+
end
|
48
|
+
|
49
|
+
# A human-readable description of this matcher. Always "all".
|
50
|
+
def description
|
51
|
+
'all'
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Matches a specific set of values
|
56
|
+
class WhitelistMatcher < Matcher
|
57
|
+
# Checks whether the given value exists within the whitelist configured
|
58
|
+
# for this matcher.
|
59
|
+
#
|
60
|
+
# == Examples
|
61
|
+
#
|
62
|
+
# matcher = StateMachine::WhitelistMatcher.new([:parked, :idling])
|
63
|
+
# matcher.matches?(:parked) # => true
|
64
|
+
# matcher.matches?(:first_gear) # => false
|
65
|
+
def matches?(value, context = {})
|
66
|
+
values.include?(value)
|
67
|
+
end
|
68
|
+
|
69
|
+
# A human-readable description of this matcher
|
70
|
+
def description
|
71
|
+
values.length == 1 ? values.first.inspect : values.inspect
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Matches everything but a specific set of values
|
76
|
+
class BlacklistMatcher < Matcher
|
77
|
+
# Checks whether the given value exists outside the blacklist configured
|
78
|
+
# for this matcher.
|
79
|
+
#
|
80
|
+
# == Examples
|
81
|
+
#
|
82
|
+
# matcher = StateMachine::BlacklistMatcher.new([:parked, :idling])
|
83
|
+
# matcher.matches?(:parked) # => false
|
84
|
+
# matcher.matches?(:first_gear) # => true
|
85
|
+
def matches?(value, context = {})
|
86
|
+
!values.include?(value)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Finds all values that are *not* within the blacklist configured for this
|
90
|
+
# matcher
|
91
|
+
def filter(values)
|
92
|
+
values - self.values
|
93
|
+
end
|
94
|
+
|
95
|
+
# A human-readable description of this matcher
|
96
|
+
def description
|
97
|
+
"all - #{values.length == 1 ? values.first.inspect : values.inspect}"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Matches a loopback of two values within a context. Since there is no
|
102
|
+
# configuration for this type of matcher, it must be used as a singleton.
|
103
|
+
class LoopbackMatcher < Matcher
|
104
|
+
include Singleton
|
105
|
+
|
106
|
+
# Checks whether the given value matches what the value originally was.
|
107
|
+
# This value should be defined in the context.
|
108
|
+
#
|
109
|
+
# == Examples
|
110
|
+
#
|
111
|
+
# matcher = StateMachine::LoopbackMatcher.instance
|
112
|
+
# matcher.matches?(:parked, :from => :parked) # => true
|
113
|
+
# matcher.matches?(:parked, :from => :idling) # => false
|
114
|
+
def matches?(value, context)
|
115
|
+
context[:from] == value
|
116
|
+
end
|
117
|
+
|
118
|
+
# A human-readable description of this matcher. Always "same".
|
119
|
+
def description
|
120
|
+
'same'
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module StateMachine
|
2
|
+
# Provides a set of helper methods for generating matchers
|
3
|
+
module MatcherHelpers
|
4
|
+
# Represents a state that matches all known states in a machine.
|
5
|
+
#
|
6
|
+
# == Examples
|
7
|
+
#
|
8
|
+
# class Vehicle
|
9
|
+
# state_machine do
|
10
|
+
# before_transition any => :parked, :do => lambda {...}
|
11
|
+
# before_transition all - :parked => all - :idling, :do => lambda {}
|
12
|
+
#
|
13
|
+
# event :park
|
14
|
+
# transition all => :parked
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# event :crash
|
18
|
+
# transition all - :parked => :stalled
|
19
|
+
# end
|
20
|
+
# end
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
# In the above example, +all+ will match the following states since they
|
24
|
+
# are known:
|
25
|
+
# * +parked+
|
26
|
+
# * +stalled+
|
27
|
+
# * +idling+
|
28
|
+
def all
|
29
|
+
AllMatcher.instance
|
30
|
+
end
|
31
|
+
alias_method :any, :all
|
32
|
+
|
33
|
+
# Represents a state that matches the original +from+ state. This is useful
|
34
|
+
# for defining transitions which are loopbacks.
|
35
|
+
#
|
36
|
+
# == Examples
|
37
|
+
#
|
38
|
+
# class Vehicle
|
39
|
+
# state_machine do
|
40
|
+
# event :ignite
|
41
|
+
# transition [:idling, :first_gear] => same
|
42
|
+
# end
|
43
|
+
# end
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
# In the above example, +same+ will match whichever the from state is. In
|
47
|
+
# the case of the +ignite+ event, it is essential the same as the following:
|
48
|
+
#
|
49
|
+
# transition :parked => :parked, :first_gear => :first_gear
|
50
|
+
def same
|
51
|
+
LoopbackMatcher.instance
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
require 'state_machine/assertions'
|
2
|
+
|
3
|
+
module StateMachine
|
4
|
+
# Represents a collection of nodes in a state machine, be it events or states.
|
5
|
+
# Nodes will not differentiate between the String and Symbol versions of the
|
6
|
+
# values being indexed.
|
7
|
+
class NodeCollection
|
8
|
+
include Enumerable
|
9
|
+
include Assertions
|
10
|
+
|
11
|
+
# The machine associated with the nodes
|
12
|
+
attr_reader :machine
|
13
|
+
|
14
|
+
# Creates a new collection of nodes for the given state machine. By default,
|
15
|
+
# the collection is empty.
|
16
|
+
#
|
17
|
+
# Configuration options:
|
18
|
+
# * <tt>:index</tt> - One or more attributes to automatically generate
|
19
|
+
# hashed indices for in order to perform quick lookups. Default is to
|
20
|
+
# index by the :name attribute
|
21
|
+
def initialize(machine, options = {})
|
22
|
+
assert_valid_keys(options, :index)
|
23
|
+
options = {:index => :name}.merge(options)
|
24
|
+
|
25
|
+
@machine = machine
|
26
|
+
@nodes = []
|
27
|
+
@index_names = Array(options[:index])
|
28
|
+
@indices = @index_names.inject({}) do |indices, name|
|
29
|
+
indices[name] = {}
|
30
|
+
indices[:"#{name}_to_s"] = {}
|
31
|
+
indices[:"#{name}_to_sym"] = {}
|
32
|
+
indices
|
33
|
+
end
|
34
|
+
@default_index = Array(options[:index]).first
|
35
|
+
@contexts = []
|
36
|
+
end
|
37
|
+
|
38
|
+
# Creates a copy of this collection such that modifications don't affect
|
39
|
+
# the original collection
|
40
|
+
def initialize_copy(orig) #:nodoc:
|
41
|
+
super
|
42
|
+
|
43
|
+
nodes = @nodes
|
44
|
+
contexts = @contexts
|
45
|
+
@nodes = []
|
46
|
+
@contexts = []
|
47
|
+
@indices = @indices.inject({}) {|indices, (name, *)| indices[name] = {}; indices}
|
48
|
+
|
49
|
+
# Add nodes *prior* to copying over the contexts so that they don't get
|
50
|
+
# evaluated multiple times
|
51
|
+
concat(nodes.map {|n| n.dup})
|
52
|
+
@contexts = contexts.dup
|
53
|
+
end
|
54
|
+
|
55
|
+
# Changes the current machine associated with the collection. In turn, this
|
56
|
+
# will change the state machine associated with each node in the collection.
|
57
|
+
def machine=(new_machine)
|
58
|
+
@machine = new_machine
|
59
|
+
each {|node| node.machine = new_machine}
|
60
|
+
end
|
61
|
+
|
62
|
+
# Gets the number of nodes in this collection
|
63
|
+
def length
|
64
|
+
@nodes.length
|
65
|
+
end
|
66
|
+
|
67
|
+
# Gets the set of unique keys for the given index
|
68
|
+
def keys(index_name = @default_index)
|
69
|
+
index(index_name).keys
|
70
|
+
end
|
71
|
+
|
72
|
+
# Tracks a context that should be evaluated for any nodes that get added
|
73
|
+
# which match the given set of nodes. Matchers can be used so that the
|
74
|
+
# context can get added once and evaluated after multiple adds.
|
75
|
+
def context(nodes, &block)
|
76
|
+
nodes = nodes.first.is_a?(Matcher) ? nodes.first : WhitelistMatcher.new(nodes)
|
77
|
+
@contexts << context = {:nodes => nodes, :block => block}
|
78
|
+
|
79
|
+
# Evaluate the new context for existing nodes
|
80
|
+
each {|node| eval_context(context, node)}
|
81
|
+
|
82
|
+
context
|
83
|
+
end
|
84
|
+
|
85
|
+
# Adds a new node to the collection. By doing so, this will also add it to
|
86
|
+
# the configured indices. This will also evaluate any existings contexts
|
87
|
+
# that match the new node.
|
88
|
+
def <<(node)
|
89
|
+
@nodes << node
|
90
|
+
@index_names.each {|name| add_to_index(name, value(node, name), node)}
|
91
|
+
@contexts.each {|context| eval_context(context, node)}
|
92
|
+
self
|
93
|
+
end
|
94
|
+
|
95
|
+
# Appends a group of nodes to the collection
|
96
|
+
def concat(nodes)
|
97
|
+
nodes.each {|node| self << node}
|
98
|
+
end
|
99
|
+
|
100
|
+
# Updates the indexed keys for the given node. If the node's attribute
|
101
|
+
# has changed since it was added to the collection, the old indexed keys
|
102
|
+
# will be replaced with the updated ones.
|
103
|
+
def update(node)
|
104
|
+
@index_names.each {|name| update_index(name, node)}
|
105
|
+
end
|
106
|
+
|
107
|
+
# Calls the block once for each element in self, passing that element as a
|
108
|
+
# parameter.
|
109
|
+
#
|
110
|
+
# states = StateMachine::NodeCollection.new
|
111
|
+
# states << StateMachine::State.new(machine, :parked)
|
112
|
+
# states << StateMachine::State.new(machine, :idling)
|
113
|
+
# states.each {|state| puts state.name, ' -- '}
|
114
|
+
#
|
115
|
+
# ...produces:
|
116
|
+
#
|
117
|
+
# parked -- idling --
|
118
|
+
def each
|
119
|
+
@nodes.each {|node| yield node}
|
120
|
+
self
|
121
|
+
end
|
122
|
+
|
123
|
+
# Gets the node at the given index.
|
124
|
+
#
|
125
|
+
# states = StateMachine::NodeCollection.new
|
126
|
+
# states << StateMachine::State.new(machine, :parked)
|
127
|
+
# states << StateMachine::State.new(machine, :idling)
|
128
|
+
#
|
129
|
+
# states.at(0).name # => :parked
|
130
|
+
# states.at(1).name # => :idling
|
131
|
+
def at(index)
|
132
|
+
@nodes[index]
|
133
|
+
end
|
134
|
+
|
135
|
+
# Gets the node indexed by the given key. By default, this will look up the
|
136
|
+
# key in the first index configured for the collection. A custom index can
|
137
|
+
# be specified like so:
|
138
|
+
#
|
139
|
+
# collection['parked', :value]
|
140
|
+
#
|
141
|
+
# The above will look up the "parked" key in a hash indexed by each node's
|
142
|
+
# +value+ attribute.
|
143
|
+
#
|
144
|
+
# If the key cannot be found, then nil will be returned.
|
145
|
+
def [](key, index_name = @default_index)
|
146
|
+
self.index(index_name)[key] ||
|
147
|
+
self.index(:"#{index_name}_to_s")[key.to_s] ||
|
148
|
+
to_sym?(key) && self.index(:"#{index_name}_to_sym")[:"#{key}"] ||
|
149
|
+
nil
|
150
|
+
end
|
151
|
+
|
152
|
+
# Gets the node indexed by the given key. By default, this will look up the
|
153
|
+
# key in the first index configured for the collection. A custom index can
|
154
|
+
# be specified like so:
|
155
|
+
#
|
156
|
+
# collection['parked', :value]
|
157
|
+
#
|
158
|
+
# The above will look up the "parked" key in a hash indexed by each node's
|
159
|
+
# +value+ attribute.
|
160
|
+
#
|
161
|
+
# If the key cannot be found, then an IndexError exception will be raised:
|
162
|
+
#
|
163
|
+
# collection['invalid', :value] # => IndexError: "invalid" is an invalid value
|
164
|
+
def fetch(key, index_name = @default_index)
|
165
|
+
self[key, index_name] || raise(IndexError, "#{key.inspect} is an invalid #{index_name}")
|
166
|
+
end
|
167
|
+
|
168
|
+
protected
|
169
|
+
# Gets the given index. If the index does not exist, then an ArgumentError
|
170
|
+
# is raised.
|
171
|
+
def index(name)
|
172
|
+
raise ArgumentError, 'No indices configured' unless @indices.any?
|
173
|
+
@indices[name] || raise(ArgumentError, "Invalid index: #{name.inspect}")
|
174
|
+
end
|
175
|
+
|
176
|
+
# Gets the value for the given attribute on the node
|
177
|
+
def value(node, attribute)
|
178
|
+
node.send(attribute)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Adds the given key / node combination to an index, including the string
|
182
|
+
# and symbol versions of the index
|
183
|
+
def add_to_index(name, key, node)
|
184
|
+
index(name)[key] = node
|
185
|
+
index(:"#{name}_to_s")[key.to_s] = node
|
186
|
+
index(:"#{name}_to_sym")[:"#{key}"] = node if to_sym?(key)
|
187
|
+
end
|
188
|
+
|
189
|
+
# Removes the given key from an index, including the string and symbol
|
190
|
+
# versions of the index
|
191
|
+
def remove_from_index(name, key)
|
192
|
+
index(name).delete(key)
|
193
|
+
index(:"#{name}_to_s").delete(key.to_s)
|
194
|
+
index(:"#{name}_to_sym").delete(:"#{key}") if to_sym?(key)
|
195
|
+
end
|
196
|
+
|
197
|
+
# Updates the node for the given index, including the string and symbol
|
198
|
+
# versions of the index
|
199
|
+
def update_index(name, node)
|
200
|
+
index = self.index(name)
|
201
|
+
old_key = RUBY_VERSION < '1.9' ? index.index(node) : index.key(node)
|
202
|
+
new_key = value(node, name)
|
203
|
+
|
204
|
+
# Only replace the key if it's changed
|
205
|
+
if old_key != new_key
|
206
|
+
remove_from_index(name, old_key)
|
207
|
+
add_to_index(name, new_key, node)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# Determines whether the given value can be converted to a symbol
|
212
|
+
def to_sym?(value)
|
213
|
+
"#{value}" != ''
|
214
|
+
end
|
215
|
+
|
216
|
+
# Evaluates the given context for a particular node. This will only
|
217
|
+
# evaluate the context if the node matches.
|
218
|
+
def eval_context(context, node)
|
219
|
+
node.context(&context[:block]) if context[:nodes].matches?(node.name)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module StateMachine
|
2
|
+
# A path represents a sequence of transitions that can be run for a particular
|
3
|
+
# object. Paths can walk to new transitions, revealing all of the possible
|
4
|
+
# branches that can be encountered in the object's state machine.
|
5
|
+
class Path < Array
|
6
|
+
include Assertions
|
7
|
+
|
8
|
+
# The object whose state machine is being walked
|
9
|
+
attr_reader :object
|
10
|
+
|
11
|
+
# The state machine this path is walking
|
12
|
+
attr_reader :machine
|
13
|
+
|
14
|
+
# Creates a new transition path for the given object. Initially this is an
|
15
|
+
# empty path. In order to start walking the path, it must be populated with
|
16
|
+
# an initial transition.
|
17
|
+
#
|
18
|
+
# Configuration options:
|
19
|
+
# * <tt>:target</tt> - The target state to end the path on
|
20
|
+
# * <tt>:guard</tt> - Whether to guard transitions with the if/unless
|
21
|
+
# conditionals defined for each one
|
22
|
+
def initialize(object, machine, options = {})
|
23
|
+
assert_valid_keys(options, :target, :guard)
|
24
|
+
|
25
|
+
@object = object
|
26
|
+
@machine = machine
|
27
|
+
@target = options[:target]
|
28
|
+
@guard = options[:guard]
|
29
|
+
end
|
30
|
+
|
31
|
+
def initialize_copy(orig) #:nodoc:
|
32
|
+
super
|
33
|
+
@transitions = nil
|
34
|
+
end
|
35
|
+
|
36
|
+
# The initial state name for this path
|
37
|
+
def from_name
|
38
|
+
first && first.from_name
|
39
|
+
end
|
40
|
+
|
41
|
+
# Lists all of the from states that can be reached through this path.
|
42
|
+
#
|
43
|
+
# For example,
|
44
|
+
#
|
45
|
+
# path.to_states # => [:parked, :idling, :first_gear, ...]
|
46
|
+
def from_states
|
47
|
+
map {|transition| transition.from_name}.uniq
|
48
|
+
end
|
49
|
+
|
50
|
+
# The end state name for this path. If a target state was specified for
|
51
|
+
# the path, then that will be returned if the path is complete.
|
52
|
+
def to_name
|
53
|
+
last && last.to_name
|
54
|
+
end
|
55
|
+
|
56
|
+
# Lists all of the to states that can be reached through this path.
|
57
|
+
#
|
58
|
+
# For example,
|
59
|
+
#
|
60
|
+
# path.to_states # => [:parked, :idling, :first_gear, ...]
|
61
|
+
def to_states
|
62
|
+
map {|transition| transition.to_name}.uniq
|
63
|
+
end
|
64
|
+
|
65
|
+
# Lists all of the events that can be fired through this path.
|
66
|
+
#
|
67
|
+
# For example,
|
68
|
+
#
|
69
|
+
# path.events # => [:park, :ignite, :shift_up, ...]
|
70
|
+
def events
|
71
|
+
map {|transition| transition.event}.uniq
|
72
|
+
end
|
73
|
+
|
74
|
+
# Walks down the next transitions at the end of this path. This will only
|
75
|
+
# walk down paths that are considered valid.
|
76
|
+
def walk
|
77
|
+
transitions.each {|transition| yield dup.push(transition)}
|
78
|
+
end
|
79
|
+
|
80
|
+
# Determines whether or not this path has completed. A path is considered
|
81
|
+
# complete when one of the following conditions is met:
|
82
|
+
# * The last transition in the path ends on the target state
|
83
|
+
# * There are no more transitions remaining to walk and there is no target
|
84
|
+
# state
|
85
|
+
def complete?
|
86
|
+
!empty? && (@target ? to_name == @target : transitions.empty?)
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
# Calculates the number of times the given state has been walked to
|
91
|
+
def times_walked_to(state)
|
92
|
+
select {|transition| transition.to_name == state}.length
|
93
|
+
end
|
94
|
+
|
95
|
+
# Determines whether the given transition has been recently walked down in
|
96
|
+
# this path. If a target is configured for this path, then this will only
|
97
|
+
# look at transitions walked down since the target was last reached.
|
98
|
+
def recently_walked?(transition)
|
99
|
+
transitions = self
|
100
|
+
if @target && @target != to_name && target_transition = detect {|t| t.to_name == @target}
|
101
|
+
transitions = transitions[index(target_transition) + 1..-1]
|
102
|
+
end
|
103
|
+
transitions.include?(transition)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Determines whether it's possible to walk to the given transition from
|
107
|
+
# the current path. A transition can be walked to if:
|
108
|
+
# * It has not been recently walked and
|
109
|
+
# * If a target is specified, it has not been walked to twice yet
|
110
|
+
def can_walk_to?(transition)
|
111
|
+
!recently_walked?(transition) && (!@target || times_walked_to(@target) < 2)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Get the next set of transitions that can be walked to starting from the
|
115
|
+
# end of this path
|
116
|
+
def transitions
|
117
|
+
@transitions ||= empty? ? [] : machine.events.transitions_for(object, :from => to_name, :guard => @guard).select {|transition| can_walk_to?(transition)}
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'state_machine/path'
|
2
|
+
|
3
|
+
module StateMachine
|
4
|
+
# Represents a collection of paths that are generated based on a set of
|
5
|
+
# requirements regarding what states to start and end on
|
6
|
+
class PathCollection < Array
|
7
|
+
include Assertions
|
8
|
+
|
9
|
+
# The object whose state machine is being walked
|
10
|
+
attr_reader :object
|
11
|
+
|
12
|
+
# The state machine these path are walking
|
13
|
+
attr_reader :machine
|
14
|
+
|
15
|
+
# The initial state to start each path from
|
16
|
+
attr_reader :from_name
|
17
|
+
|
18
|
+
# The target state for each path
|
19
|
+
attr_reader :to_name
|
20
|
+
|
21
|
+
# Creates a new collection of paths with the given requirements.
|
22
|
+
#
|
23
|
+
# Configuration options:
|
24
|
+
# * <tt>:from</tt> - The initial state to start from
|
25
|
+
# * <tt>:to</tt> - The target end state
|
26
|
+
# * <tt>:deep</tt> - Whether to enable deep searches for the target state.
|
27
|
+
# * <tt>:guard</tt> - Whether to guard transitions with the if/unless
|
28
|
+
# conditionals defined for each one
|
29
|
+
def initialize(object, machine, options = {})
|
30
|
+
options = {:deep => false, :from => machine.states.match!(object).name}.merge(options)
|
31
|
+
assert_valid_keys(options, :from, :to, :deep, :guard)
|
32
|
+
|
33
|
+
@object = object
|
34
|
+
@machine = machine
|
35
|
+
@from_name = machine.states.fetch(options[:from]).name
|
36
|
+
@to_name = options[:to] && machine.states.fetch(options[:to]).name
|
37
|
+
@guard = options[:guard]
|
38
|
+
@deep = options[:deep]
|
39
|
+
|
40
|
+
initial_paths.each {|path| walk(path)}
|
41
|
+
end
|
42
|
+
|
43
|
+
# Lists all of the states that can be transitioned from through the paths in
|
44
|
+
# this collection.
|
45
|
+
#
|
46
|
+
# For example,
|
47
|
+
#
|
48
|
+
# paths.from_states # => [:parked, :idling, :first_gear, ...]
|
49
|
+
def from_states
|
50
|
+
map {|path| path.from_states}.flatten.uniq
|
51
|
+
end
|
52
|
+
|
53
|
+
# Lists all of the states that can be transitioned to through the paths in
|
54
|
+
# this collection.
|
55
|
+
#
|
56
|
+
# For example,
|
57
|
+
#
|
58
|
+
# paths.to_states # => [:idling, :first_gear, :second_gear, ...]
|
59
|
+
def to_states
|
60
|
+
map {|path| path.to_states}.flatten.uniq
|
61
|
+
end
|
62
|
+
|
63
|
+
# Lists all of the events that can be fired through the paths in this
|
64
|
+
# collection.
|
65
|
+
#
|
66
|
+
# For example,
|
67
|
+
#
|
68
|
+
# paths.events # => [:park, :ignite, :shift_up, ...]
|
69
|
+
def events
|
70
|
+
map {|path| path.events}.flatten.uniq
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
# Gets the initial set of paths to walk
|
75
|
+
def initial_paths
|
76
|
+
machine.events.transitions_for(object, :from => from_name, :guard => @guard).map do |transition|
|
77
|
+
path = Path.new(object, machine, :target => to_name, :guard => @guard)
|
78
|
+
path << transition
|
79
|
+
path
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Walks down the given path. Each new path that matches the configured
|
84
|
+
# requirements will be added to this collection.
|
85
|
+
def walk(path)
|
86
|
+
self << path if path.complete?
|
87
|
+
path.walk {|next_path| walk(next_path)} unless to_name && path.complete? && !@deep
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|