verborghs-state_machine 0.9.4
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.rdoc +360 -0
- data/LICENSE +20 -0
- data/README.rdoc +635 -0
- data/Rakefile +77 -0
- data/examples/AutoShop_state.png +0 -0
- data/examples/Car_state.png +0 -0
- data/examples/TrafficLight_state.png +0 -0
- data/examples/Vehicle_state.png +0 -0
- data/examples/auto_shop.rb +11 -0
- data/examples/car.rb +19 -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 +11 -0
- data/examples/rails-rest/model.rb +23 -0
- data/examples/rails-rest/view_edit.html.erb +25 -0
- data/examples/rails-rest/view_index.html.erb +23 -0
- data/examples/rails-rest/view_new.html.erb +14 -0
- data/examples/rails-rest/view_show.html.erb +17 -0
- data/examples/traffic_light.rb +7 -0
- data/examples/vehicle.rb +31 -0
- data/init.rb +1 -0
- data/lib/state_machine/assertions.rb +36 -0
- data/lib/state_machine/callback.rb +241 -0
- data/lib/state_machine/condition_proxy.rb +106 -0
- data/lib/state_machine/eval_helpers.rb +83 -0
- data/lib/state_machine/event.rb +267 -0
- data/lib/state_machine/event_collection.rb +122 -0
- data/lib/state_machine/extensions.rb +149 -0
- data/lib/state_machine/guard.rb +230 -0
- data/lib/state_machine/initializers/merb.rb +1 -0
- data/lib/state_machine/initializers/rails.rb +5 -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 +45 -0
- data/lib/state_machine/integrations/active_model.rb +445 -0
- data/lib/state_machine/integrations/active_record/locale.rb +20 -0
- data/lib/state_machine/integrations/active_record.rb +522 -0
- data/lib/state_machine/integrations/data_mapper/observer.rb +175 -0
- data/lib/state_machine/integrations/data_mapper.rb +379 -0
- data/lib/state_machine/integrations/mongo_mapper.rb +309 -0
- data/lib/state_machine/integrations/sequel.rb +356 -0
- data/lib/state_machine/integrations.rb +83 -0
- data/lib/state_machine/machine.rb +1645 -0
- data/lib/state_machine/machine_collection.rb +64 -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 +152 -0
- data/lib/state_machine/state.rb +260 -0
- data/lib/state_machine/state_collection.rb +112 -0
- data/lib/state_machine/transition.rb +399 -0
- data/lib/state_machine/transition_collection.rb +244 -0
- data/lib/state_machine.rb +421 -0
- data/lib/tasks/state_machine.rake +1 -0
- data/lib/tasks/state_machine.rb +27 -0
- data/test/files/en.yml +9 -0
- data/test/files/switch.rb +11 -0
- data/test/functional/state_machine_test.rb +980 -0
- data/test/test_helper.rb +4 -0
- data/test/unit/assertions_test.rb +40 -0
- data/test/unit/callback_test.rb +728 -0
- data/test/unit/condition_proxy_test.rb +328 -0
- data/test/unit/eval_helpers_test.rb +222 -0
- data/test/unit/event_collection_test.rb +324 -0
- data/test/unit/event_test.rb +795 -0
- data/test/unit/guard_test.rb +909 -0
- data/test/unit/integrations/active_model_test.rb +956 -0
- data/test/unit/integrations/active_record_test.rb +1918 -0
- data/test/unit/integrations/data_mapper_test.rb +1814 -0
- data/test/unit/integrations/mongo_mapper_test.rb +1382 -0
- data/test/unit/integrations/sequel_test.rb +1492 -0
- data/test/unit/integrations_test.rb +50 -0
- data/test/unit/invalid_event_test.rb +7 -0
- data/test/unit/invalid_transition_test.rb +7 -0
- data/test/unit/machine_collection_test.rb +565 -0
- data/test/unit/machine_test.rb +2349 -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 +207 -0
- data/test/unit/state_collection_test.rb +280 -0
- data/test/unit/state_machine_test.rb +31 -0
- data/test/unit/state_test.rb +848 -0
- data/test/unit/transition_collection_test.rb +2098 -0
- data/test/unit/transition_test.rb +1384 -0
- metadata +176 -0
@@ -0,0 +1,64 @@
|
|
1
|
+
module StateMachine
|
2
|
+
# Represents a collection of state machines for a class
|
3
|
+
class MachineCollection < Hash
|
4
|
+
# Initializes the state of each machine in the given object. Initial
|
5
|
+
# values are only set if the machine's attribute doesn't already exist
|
6
|
+
# (which must mean the defaults are being skipped)
|
7
|
+
def initialize_states(object, options = {})
|
8
|
+
if ignore = options[:ignore]
|
9
|
+
ignore = ignore.map {|attribute| attribute.to_sym}
|
10
|
+
end
|
11
|
+
|
12
|
+
each_value do |machine|
|
13
|
+
if (!ignore || !ignore.include?(machine.attribute)) && (!options.include?(:dynamic) || machine.dynamic_initial_state? == options[:dynamic])
|
14
|
+
value = machine.read(object, :state)
|
15
|
+
machine.initialize_state(object) if ignore || value.nil? || value.respond_to?(:empty?) && value.empty?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Runs one or more events in parallel on the given object. See
|
21
|
+
# StateMachine::InstanceMethods#fire_events for more information.
|
22
|
+
def fire_events(object, *events)
|
23
|
+
run_action = [true, false].include?(events.last) ? events.pop : true
|
24
|
+
|
25
|
+
# Generate the transitions to run for each event
|
26
|
+
transitions = events.collect do |event_name|
|
27
|
+
# Find the actual event being run
|
28
|
+
event = nil
|
29
|
+
detect {|name, machine| event = machine.events[event_name, :qualified_name]}
|
30
|
+
|
31
|
+
raise InvalidEvent, "#{event_name.inspect} is an unknown state machine event" unless event
|
32
|
+
|
33
|
+
# Get the transition that will be performed for the event
|
34
|
+
unless transition = event.transition_for(object)
|
35
|
+
machine = event.machine
|
36
|
+
machine.invalidate(object, :state, :invalid_transition, [[:event, event.human_name]])
|
37
|
+
end
|
38
|
+
|
39
|
+
transition
|
40
|
+
end.compact
|
41
|
+
|
42
|
+
# Run the events in parallel only if valid transitions were found for
|
43
|
+
# all of them
|
44
|
+
if events.length == transitions.length
|
45
|
+
TransitionCollection.new(transitions, :actions => run_action).perform
|
46
|
+
else
|
47
|
+
false
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Builds the collection of transitions for all event attributes defined on
|
52
|
+
# the given object. This will only include events whose machine actions
|
53
|
+
# match the one specified.
|
54
|
+
#
|
55
|
+
# These should only be fired as a result of the action being run.
|
56
|
+
def transitions(object, action, options = {})
|
57
|
+
transitions = map do |name, machine|
|
58
|
+
machine.events.attribute_transition_for(object, true) if machine.action == action
|
59
|
+
end
|
60
|
+
|
61
|
+
AttributeTransitionCollection.new(transitions.compact, options)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -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,152 @@
|
|
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
|
+
class NodeCollection
|
6
|
+
include Enumerable
|
7
|
+
include Assertions
|
8
|
+
|
9
|
+
# The machine associated with the nodes
|
10
|
+
attr_reader :machine
|
11
|
+
|
12
|
+
# Creates a new collection of nodes for the given state machine. By default,
|
13
|
+
# the collection is empty.
|
14
|
+
#
|
15
|
+
# Configuration options:
|
16
|
+
# * <tt>:index</tt> - One or more attributes to automatically generate
|
17
|
+
# hashed indices for in order to perform quick lookups. Default is to
|
18
|
+
# index by the :name attribute
|
19
|
+
def initialize(machine, options = {})
|
20
|
+
assert_valid_keys(options, :index)
|
21
|
+
options = {:index => :name}.merge(options)
|
22
|
+
|
23
|
+
@machine = machine
|
24
|
+
@nodes = []
|
25
|
+
@indices = Array(options[:index]).inject({}) {|indices, attribute| indices[attribute] = {}; indices}
|
26
|
+
@default_index = Array(options[:index]).first
|
27
|
+
end
|
28
|
+
|
29
|
+
# Creates a copy of this collection such that modifications don't affect
|
30
|
+
# the original collection
|
31
|
+
def initialize_copy(orig) #:nodoc:
|
32
|
+
super
|
33
|
+
|
34
|
+
nodes = @nodes
|
35
|
+
@nodes = []
|
36
|
+
@indices = @indices.inject({}) {|indices, (name, index)| indices[name] = {}; indices}
|
37
|
+
nodes.each {|node| self << node.dup}
|
38
|
+
end
|
39
|
+
|
40
|
+
# Changes the current machine associated with the collection. In turn, this
|
41
|
+
# will change the state machine associated with each node in the collection.
|
42
|
+
def machine=(new_machine)
|
43
|
+
@machine = new_machine
|
44
|
+
each {|node| node.machine = new_machine}
|
45
|
+
end
|
46
|
+
|
47
|
+
# Gets the number of nodes in this collection
|
48
|
+
def length
|
49
|
+
@nodes.length
|
50
|
+
end
|
51
|
+
|
52
|
+
# Gets the set of unique keys for the given index
|
53
|
+
def keys(index_name = @default_index)
|
54
|
+
index(index_name).keys
|
55
|
+
end
|
56
|
+
|
57
|
+
# Adds a new node to the collection. By doing so, this will also add it to
|
58
|
+
# the configured indices.
|
59
|
+
def <<(node)
|
60
|
+
@nodes << node
|
61
|
+
@indices.each {|attribute, index| index[value(node, attribute)] = node}
|
62
|
+
self
|
63
|
+
end
|
64
|
+
|
65
|
+
# Updates the indexed keys for the given node. If the node's attribute
|
66
|
+
# has changed since it was added to the collection, the old indexed keys
|
67
|
+
# will be replaced with the updated ones.
|
68
|
+
def update(node)
|
69
|
+
@indices.each do |attribute, index|
|
70
|
+
old_key = RUBY_VERSION < '1.9' ? index.index(node) : index.key(node)
|
71
|
+
new_key = value(node, attribute)
|
72
|
+
|
73
|
+
# Only replace the key if it's changed
|
74
|
+
if old_key != new_key
|
75
|
+
index.delete(old_key)
|
76
|
+
index[new_key] = node
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Calls the block once for each element in self, passing that element as a
|
82
|
+
# parameter.
|
83
|
+
#
|
84
|
+
# states = StateMachine::NodeCollection.new
|
85
|
+
# states << StateMachine::State.new(machine, :parked)
|
86
|
+
# states << StateMachine::State.new(machine, :idling)
|
87
|
+
# states.each {|state| puts state.name, ' -- '}
|
88
|
+
#
|
89
|
+
# ...produces:
|
90
|
+
#
|
91
|
+
# parked -- idling --
|
92
|
+
def each
|
93
|
+
@nodes.each {|node| yield node}
|
94
|
+
self
|
95
|
+
end
|
96
|
+
|
97
|
+
# Gets the node at the given index.
|
98
|
+
#
|
99
|
+
# states = StateMachine::NodeCollection.new
|
100
|
+
# states << StateMachine::State.new(machine, :parked)
|
101
|
+
# states << StateMachine::State.new(machine, :idling)
|
102
|
+
#
|
103
|
+
# states.at(0).name # => :parked
|
104
|
+
# states.at(1).name # => :idling
|
105
|
+
def at(index)
|
106
|
+
@nodes[index]
|
107
|
+
end
|
108
|
+
|
109
|
+
# Gets the node indexed by the given key. By default, this will look up the
|
110
|
+
# key in the first index configured for the collection. A custom index can
|
111
|
+
# be specified like so:
|
112
|
+
#
|
113
|
+
# collection['parked', :value]
|
114
|
+
#
|
115
|
+
# The above will look up the "parked" key in a hash indexed by each node's
|
116
|
+
# +value+ attribute.
|
117
|
+
#
|
118
|
+
# If the key cannot be found, then nil will be returned.
|
119
|
+
def [](key, index_name = @default_index)
|
120
|
+
index(index_name)[key]
|
121
|
+
end
|
122
|
+
|
123
|
+
# Gets the node indexed by the given key. By default, this will look up the
|
124
|
+
# key in the first index configured for the collection. A custom index can
|
125
|
+
# be specified like so:
|
126
|
+
#
|
127
|
+
# collection['parked', :value]
|
128
|
+
#
|
129
|
+
# The above will look up the "parked" key in a hash indexed by each node's
|
130
|
+
# +value+ attribute.
|
131
|
+
#
|
132
|
+
# If the key cannot be found, then an IndexError exception will be raised:
|
133
|
+
#
|
134
|
+
# collection['invalid', :value] # => IndexError: "invalid" is an invalid value
|
135
|
+
def fetch(key, index_name = @default_index)
|
136
|
+
self[key, index_name] || raise(IndexError, "#{key.inspect} is an invalid #{index_name}")
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
# Gets the given index. If the index does not exist, then an ArgumentError
|
141
|
+
# is raised.
|
142
|
+
def index(name)
|
143
|
+
raise ArgumentError, 'No indices configured' unless @indices.any?
|
144
|
+
@indices[name] || raise(ArgumentError, "Invalid index: #{name.inspect}")
|
145
|
+
end
|
146
|
+
|
147
|
+
# Gets the value for the given attribute on the node
|
148
|
+
def value(node, attribute)
|
149
|
+
node.send(attribute)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,260 @@
|
|
1
|
+
require 'state_machine/assertions'
|
2
|
+
require 'state_machine/condition_proxy'
|
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
|
+
# Tracks all of the methods that have been defined for the machine's owner
|
45
|
+
# class when objects are in this state.
|
46
|
+
#
|
47
|
+
# Maps :method_name => UnboundMethod
|
48
|
+
attr_reader :methods
|
49
|
+
|
50
|
+
# Creates a new state within the context of the given machine.
|
51
|
+
#
|
52
|
+
# Configuration options:
|
53
|
+
# * <tt>:initial</tt> - Whether this state is the beginning state for the
|
54
|
+
# machine. Default is false.
|
55
|
+
# * <tt>:value</tt> - The value to store when an object transitions to this
|
56
|
+
# state. Default is the name (stringified).
|
57
|
+
# * <tt>:cache</tt> - If a dynamic value (via a lambda block) is being used,
|
58
|
+
# then setting this to true will cache the evaluated result
|
59
|
+
# * <tt>:if</tt> - Determines whether a value matches this state
|
60
|
+
# (e.g. :value => lambda {Time.now}, :if => lambda {|state| !state.nil?}).
|
61
|
+
# By default, the configured value is matched.
|
62
|
+
# * <tt>:human_name</tt> - The human-readable version of this state's name
|
63
|
+
def initialize(machine, name, options = {}) #:nodoc:
|
64
|
+
assert_valid_keys(options, :initial, :value, :cache, :if, :human_name)
|
65
|
+
|
66
|
+
@machine = machine
|
67
|
+
@name = name
|
68
|
+
@qualified_name = name && machine.namespace ? :"#{machine.namespace}_#{name}" : name
|
69
|
+
@human_name = options[:human_name] || (@name ? @name.to_s.tr('_', ' ') : 'nil')
|
70
|
+
@value = options.include?(:value) ? options[:value] : name && name.to_s
|
71
|
+
@cache = options[:cache]
|
72
|
+
@matcher = options[:if]
|
73
|
+
@methods = {}
|
74
|
+
@initial = options[:initial] == true
|
75
|
+
|
76
|
+
add_predicate
|
77
|
+
end
|
78
|
+
|
79
|
+
# Creates a copy of this state in addition to the list of associated
|
80
|
+
# methods to prevent conflicts across different states.
|
81
|
+
def initialize_copy(orig) #:nodoc:
|
82
|
+
super
|
83
|
+
@methods = methods.dup
|
84
|
+
end
|
85
|
+
|
86
|
+
# Determines whether there are any states that can be transitioned to from
|
87
|
+
# this state. If there are none, then this state is considered *final*.
|
88
|
+
# Any objects in a final state will remain so forever given the current
|
89
|
+
# machine's definition.
|
90
|
+
def final?
|
91
|
+
!machine.events.any? do |event|
|
92
|
+
event.guards.any? do |guard|
|
93
|
+
guard.state_requirements.any? do |requirement|
|
94
|
+
requirement[:from].matches?(name) && !requirement[:to].matches?(name, :from => name)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Transforms the state name into a more human-readable format, such as
|
101
|
+
# "first gear" instead of "first_gear"
|
102
|
+
def human_name(klass = @machine.owner_class)
|
103
|
+
@human_name.is_a?(Proc) ? @human_name.call(self, klass) : @human_name
|
104
|
+
end
|
105
|
+
|
106
|
+
# Generates a human-readable description of this state's name / value:
|
107
|
+
#
|
108
|
+
# For example,
|
109
|
+
#
|
110
|
+
# State.new(machine, :parked).description # => "parked"
|
111
|
+
# State.new(machine, :parked, :value => :parked).description # => "parked"
|
112
|
+
# State.new(machine, :parked, :value => nil).description # => "parked (nil)"
|
113
|
+
# State.new(machine, :parked, :value => 1).description # => "parked (1)"
|
114
|
+
# State.new(machine, :parked, :value => lambda {Time.now}).description # => "parked (*)
|
115
|
+
def description
|
116
|
+
description = name ? name.to_s : name.inspect
|
117
|
+
description << " (#{@value.is_a?(Proc) ? '*' : @value.inspect})" unless name.to_s == @value.to_s
|
118
|
+
description
|
119
|
+
end
|
120
|
+
|
121
|
+
# The value that represents this state. This will optionally evaluate the
|
122
|
+
# original block if it's a lambda block. Otherwise, the static value is
|
123
|
+
# returned.
|
124
|
+
#
|
125
|
+
# For example,
|
126
|
+
#
|
127
|
+
# State.new(machine, :parked, :value => 1).value # => 1
|
128
|
+
# State.new(machine, :parked, :value => lambda {Time.now}).value # => Tue Jan 01 00:00:00 UTC 2008
|
129
|
+
# State.new(machine, :parked, :value => lambda {Time.now}).value(false) # => <Proc:0xb6ea7ca0@...>
|
130
|
+
def value(eval = true)
|
131
|
+
if @value.is_a?(Proc) && eval
|
132
|
+
if cache_value?
|
133
|
+
@value = @value.call
|
134
|
+
machine.states.update(self)
|
135
|
+
@value
|
136
|
+
else
|
137
|
+
@value.call
|
138
|
+
end
|
139
|
+
else
|
140
|
+
@value
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Determines whether this state matches the given value. If no matcher is
|
145
|
+
# configured, then this will check whether the values are equivalent.
|
146
|
+
# Otherwise, the matcher will determine the result.
|
147
|
+
#
|
148
|
+
# For example,
|
149
|
+
#
|
150
|
+
# # Without a matcher
|
151
|
+
# state = State.new(machine, :parked, :value => 1)
|
152
|
+
# state.matches?(1) # => true
|
153
|
+
# state.matches?(2) # => false
|
154
|
+
#
|
155
|
+
# # With a matcher
|
156
|
+
# state = State.new(machine, :parked, :value => lambda {Time.now}, :if => lambda {|value| !value.nil?})
|
157
|
+
# state.matches?(nil) # => false
|
158
|
+
# state.matches?(Time.now) # => true
|
159
|
+
def matches?(other_value)
|
160
|
+
matcher ? matcher.call(other_value) : other_value == value
|
161
|
+
end
|
162
|
+
|
163
|
+
# Defines a context for the state which will be enabled on instances of
|
164
|
+
# the owner class when the machine is in this state.
|
165
|
+
#
|
166
|
+
# This can be called multiple times. Each time a new context is created,
|
167
|
+
# a new module will be included in the owner class.
|
168
|
+
def context(&block)
|
169
|
+
owner_class = machine.owner_class
|
170
|
+
machine_name = machine.name
|
171
|
+
name = self.name
|
172
|
+
|
173
|
+
# Evaluate the method definitions
|
174
|
+
context = ConditionProxy.new(owner_class, lambda {|object| object.class.state_machine(machine_name).states.matches?(object, name)})
|
175
|
+
context.class_eval(&block)
|
176
|
+
context.instance_methods.each do |method|
|
177
|
+
methods[method.to_sym] = context.instance_method(method)
|
178
|
+
|
179
|
+
# Calls the method defined by the current state of the machine
|
180
|
+
context.class_eval <<-end_eval, __FILE__, __LINE__
|
181
|
+
def #{method}(*args, &block)
|
182
|
+
self.class.state_machine(#{machine_name.inspect}).states.match!(self).call(self, #{method.inspect}, lambda {super}, *args, &block)
|
183
|
+
end
|
184
|
+
end_eval
|
185
|
+
end
|
186
|
+
|
187
|
+
# Include the context so that it can be bound to the owner class (the
|
188
|
+
# context is considered an ancestor, so it's allowed to be bound)
|
189
|
+
owner_class.class_eval { include context }
|
190
|
+
|
191
|
+
context
|
192
|
+
end
|
193
|
+
|
194
|
+
# Calls a method defined in this state's context on the given object. All
|
195
|
+
# arguments and any block will be passed into the method defined.
|
196
|
+
#
|
197
|
+
# If the method has never been defined for this state, then a NoMethodError
|
198
|
+
# will be raised.
|
199
|
+
def call(object, method, method_missing = nil, *args, &block)
|
200
|
+
if context_method = methods[method.to_sym]
|
201
|
+
# Method is defined by the state: proxy it through
|
202
|
+
context_method.bind(object).call(*args, &block)
|
203
|
+
else
|
204
|
+
# Dispatch to the superclass since this state doesn't handle the method
|
205
|
+
method_missing.call if method_missing
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
# Draws a representation of this state on the given machine. This will
|
210
|
+
# create a new node on the graph with the following properties:
|
211
|
+
# * +label+ - The human-friendly description of the state.
|
212
|
+
# * +width+ - The width of the node. Always 1.
|
213
|
+
# * +height+ - The height of the node. Always 1.
|
214
|
+
# * +shape+ - The actual shape of the node. If the state is a final
|
215
|
+
# state, then "doublecircle", otherwise "ellipse".
|
216
|
+
#
|
217
|
+
# The actual node generated on the graph will be returned.
|
218
|
+
def draw(graph)
|
219
|
+
node = graph.add_node(name ? name.to_s : 'nil',
|
220
|
+
:label => description,
|
221
|
+
:width => '1',
|
222
|
+
:height => '1',
|
223
|
+
:shape => final? ? 'doublecircle' : 'ellipse'
|
224
|
+
)
|
225
|
+
|
226
|
+
# Add open arrow for initial state
|
227
|
+
graph.add_edge(graph.add_node('starting_state', :shape => 'point'), node) if initial?
|
228
|
+
|
229
|
+
node
|
230
|
+
end
|
231
|
+
|
232
|
+
# Generates a nicely formatted description of this state's contents.
|
233
|
+
#
|
234
|
+
# For example,
|
235
|
+
#
|
236
|
+
# state = StateMachine::State.new(machine, :parked, :value => 1, :initial => true)
|
237
|
+
# state # => #<StateMachine::State name=:parked value=1 initial=true context=[]>
|
238
|
+
def inspect
|
239
|
+
attributes = [[:name, name], [:value, @value], [:initial, initial?], [:context, methods.keys]]
|
240
|
+
"#<#{self.class} #{attributes.map {|attr, value| "#{attr}=#{value.inspect}"} * ' '}>"
|
241
|
+
end
|
242
|
+
|
243
|
+
private
|
244
|
+
# Should the value be cached after it's evaluated for the first time?
|
245
|
+
def cache_value?
|
246
|
+
@cache
|
247
|
+
end
|
248
|
+
|
249
|
+
# Adds a predicate method to the owner class so long as a name has
|
250
|
+
# actually been configured for the state
|
251
|
+
def add_predicate
|
252
|
+
return unless name
|
253
|
+
|
254
|
+
# Checks whether the current value matches this state
|
255
|
+
machine.define_instance_method("#{qualified_name}?") do |machine, object|
|
256
|
+
machine.states.matches?(object, name)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|