verborghs-state_machine 0.9.4
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.
- 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
|