antw-simple_state 0.2.1
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/LICENSE +19 -0
- data/README.markdown +224 -0
- data/Rakefile +58 -0
- data/VERSION.yml +4 -0
- data/lib/simple_state.rb +14 -0
- data/lib/simple_state/builder.rb +150 -0
- data/lib/simple_state/mixins.rb +65 -0
- data/spec/builder_spec.rb +71 -0
- data/spec/event_methods_spec.rb +227 -0
- data/spec/mixins_spec.rb +47 -0
- data/spec/predicate_methods_spec.rb +40 -0
- data/spec/simple_state_spec.rb +10 -0
- data/spec/spec_helper.rb +16 -0
- metadata +72 -0
data/LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2009 Anthony Williams
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
11
|
+
copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
19
|
+
SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,224 @@
|
|
1
|
+
# SimpleState
|
2
|
+
|
3
|
+
Yet another state machine library for Ruby.
|
4
|
+
|
5
|
+
## Why _another_ state machine library?
|
6
|
+
|
7
|
+
There are several existing implementations of state machines in Ruby, notably
|
8
|
+
[pluginaweek/state_machine][pluginaweek], [rubyist/aasm][rubyist] and
|
9
|
+
[ryan-allen/workflow][ryanallen]. However, all felt rather heavy and
|
10
|
+
cumbersome, when all I really needed was a lightweight means for setting the
|
11
|
+
state of a class instance, and transitioning from one state to another.
|
12
|
+
|
13
|
+
There is no explicit support for adding your own code to customise
|
14
|
+
transitions, nor is there any support for callbacks or event guards (although
|
15
|
+
you can still do similar things fairly trivially). It's called **Simple**State
|
16
|
+
for a reason! The library adds some helper methods to your class, keeps track
|
17
|
+
of the valid states, makes sure that a transition is permitted, and that's
|
18
|
+
about it.
|
19
|
+
|
20
|
+
## Why use SimpleState?
|
21
|
+
|
22
|
+
<ul style="margin-top: 1em">
|
23
|
+
<li>Lightweight.</li>
|
24
|
+
<li>method_missing isn't used. ;)</li>
|
25
|
+
<li>No dependencies.</li>
|
26
|
+
<li>No extensions to core classes.</li>
|
27
|
+
<li>Tested on Ruby 1.8.6 (p383), 1.8.7 (p174), 1.9.1 (p243), and JRuby
|
28
|
+
1.3.1.</li>
|
29
|
+
<li>Uses an API similar to Workflow, which I find to be more logical than
|
30
|
+
that in the acts_as_state_machine family.</li>
|
31
|
+
</ul>
|
32
|
+
|
33
|
+
## Why use something else?
|
34
|
+
|
35
|
+
<ul style="margin-top: 1em">
|
36
|
+
<li>The three libraries mentioned above make available, as part of their
|
37
|
+
DSL, a means of customising events/transitions with your own code.
|
38
|
+
SimpleState makes no such provision, however you can mimic the behaviour
|
39
|
+
quite easily as documented in example 3, below.</li>
|
40
|
+
<li>Similarly, some other libraries provide the ability to add guard
|
41
|
+
conditions -- a condition which must be satisfied before a transition
|
42
|
+
can take place. SimpleState also does explicitly support this, however it
|
43
|
+
is possible by adapting example 3.
|
44
|
+
<li>SimpleState forces you to use an attribute called `state` - other
|
45
|
+
libraries let you choose whatever name you want.</li>
|
46
|
+
<li>Uses a class variable to keep track of transitions - doesn't lend itself
|
47
|
+
all that well to subclassing your state machines.</li>
|
48
|
+
</ul>
|
49
|
+
|
50
|
+
If SimpleState's limitations are too much for you, then you are probably
|
51
|
+
better off choosing one of the other libraries instead.
|
52
|
+
|
53
|
+
## Examples
|
54
|
+
|
55
|
+
### Example 1: Basic usage
|
56
|
+
|
57
|
+
require 'rubygems'
|
58
|
+
require 'simple_state'
|
59
|
+
|
60
|
+
class SimpleStateMachine
|
61
|
+
extend SimpleState # Adds state_machine method to this class.
|
62
|
+
|
63
|
+
state_machine do
|
64
|
+
state :not_started do
|
65
|
+
event :start, :transitions_to => :started
|
66
|
+
end
|
67
|
+
|
68
|
+
state :started do
|
69
|
+
event :finish, :transitions_to => :finished
|
70
|
+
event :cancel, :transitions_to => :cancelled
|
71
|
+
end
|
72
|
+
|
73
|
+
state :finished
|
74
|
+
state :cancelled
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
SimpleState makes one assumption: that the first call to `state` in the
|
79
|
+
`state_machine` block is the default state; every instance of
|
80
|
+
SimpleStateMachine will begin with the state `:not_started`.
|
81
|
+
|
82
|
+
_Note: if you define `#initialize` in your class, you should ensure that you
|
83
|
+
call `super` or the default state won't get set._
|
84
|
+
|
85
|
+
The above example declares four states: `not_started`, `started`, `finished`
|
86
|
+
and `cancelled`. If your instance is in the `not_started` state it may
|
87
|
+
transition to the `started` state by calling `SimpleStateMachine#start!`. Once
|
88
|
+
`started` it can then transition to `finished` or `cancelled` using
|
89
|
+
`SimpleStateMachine#finish!` and `SimpleStateMachine#cancel!`.
|
90
|
+
|
91
|
+
Along with the bang methods for changing an instance's state, there are
|
92
|
+
predicate methods which will return true or false depending on the current
|
93
|
+
state of the instance.
|
94
|
+
|
95
|
+
instance = SimpleStateMachine.new # Initial state will be :not_started
|
96
|
+
|
97
|
+
instance.not_started? # => true
|
98
|
+
instance.started? # => false
|
99
|
+
instance.finished? # => false
|
100
|
+
instance.cancelled? # => false
|
101
|
+
|
102
|
+
instance.start!
|
103
|
+
|
104
|
+
instance.not_started? # => false
|
105
|
+
instance.started? # => true
|
106
|
+
instance.finished? # => false
|
107
|
+
instance.cancelled? # => false
|
108
|
+
|
109
|
+
# etc...
|
110
|
+
|
111
|
+
### Example 2: Events in multiple states
|
112
|
+
|
113
|
+
It is possible for the same event to be used in multiple states:
|
114
|
+
|
115
|
+
state :not_started do
|
116
|
+
event :start, :transitions_to => :started
|
117
|
+
event :cancel, :transitions_to => :cancelled # <--
|
118
|
+
end
|
119
|
+
|
120
|
+
state :started do
|
121
|
+
event :finish, :transitions_to => :finished
|
122
|
+
event :cancel, :transitions_to => :cancelled # <--
|
123
|
+
end
|
124
|
+
|
125
|
+
... or for the event to do something different depending on the object's
|
126
|
+
current state:
|
127
|
+
|
128
|
+
state :not_started do
|
129
|
+
event :start, :transitions_to => :started
|
130
|
+
event :cancel, :transitions_to => :cancelled_before_start # <--
|
131
|
+
end
|
132
|
+
|
133
|
+
state :started do
|
134
|
+
event :finish, :transitions_to => :finished
|
135
|
+
event :cancel, :transitions_to => :cancelled # <--
|
136
|
+
end
|
137
|
+
|
138
|
+
state :finished
|
139
|
+
state :cancelled
|
140
|
+
state :cancelled_before_start
|
141
|
+
|
142
|
+
### Example 3: Customising event transitions
|
143
|
+
|
144
|
+
If the built in event methods aren't sufficient and you need to do extra stuff
|
145
|
+
to your class during a particular event, you can simply override the method;
|
146
|
+
the original method is available via `super`:
|
147
|
+
|
148
|
+
class OverriddenEvent
|
149
|
+
extend SimpleState
|
150
|
+
|
151
|
+
state_machine do
|
152
|
+
state :start do
|
153
|
+
event :start, :transitions_to => :started
|
154
|
+
end
|
155
|
+
|
156
|
+
state :started
|
157
|
+
end
|
158
|
+
|
159
|
+
def start!
|
160
|
+
puts "Before super() : state=#{self.state}"
|
161
|
+
ret = super
|
162
|
+
puts "After super() : state=#{self.state}"
|
163
|
+
ret
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
OverriddenEvent.new.start!
|
168
|
+
# => Before super() : state=start
|
169
|
+
# => After super() : state=finished
|
170
|
+
# => :started
|
171
|
+
|
172
|
+
If the event transition isn't valid, super will simply return false, otherwise
|
173
|
+
it will return the symbol representing the new state.
|
174
|
+
|
175
|
+
def start!
|
176
|
+
if new_state = super
|
177
|
+
puts "Started! The new state is #{self.state}"
|
178
|
+
else
|
179
|
+
puts "Could not start!"
|
180
|
+
end
|
181
|
+
|
182
|
+
new_state
|
183
|
+
end
|
184
|
+
|
185
|
+
machine = OverriddenEvent.new
|
186
|
+
machine.start!
|
187
|
+
=> Started! The new state is finished
|
188
|
+
=> :started
|
189
|
+
|
190
|
+
machine.start!
|
191
|
+
=> Could not start!
|
192
|
+
=> false
|
193
|
+
|
194
|
+
If you need to know whether a transition will be permitted before you call
|
195
|
+
super(), SimpleState provides `#event_permitted?`, expecting you to provide a
|
196
|
+
symbol representing the event.
|
197
|
+
|
198
|
+
machine.event_permitted?(:start)
|
199
|
+
# => true|false
|
200
|
+
|
201
|
+
This also provides an easy means for creating guard conditions:
|
202
|
+
|
203
|
+
def start!
|
204
|
+
if event_permitted?(:start) && SomeExternalService.can_start?(self)
|
205
|
+
super
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
## ORM Integration
|
210
|
+
|
211
|
+
SimpleState should play nicely with your ORM of choice. When an object's state
|
212
|
+
is set, `YourObject#state=` is called with a symbol representing the state.
|
213
|
+
Simply add a string/enum property called `state` to your DataMapper class, or
|
214
|
+
a `state` field to your ActiveRecord database and things should be fine. I
|
215
|
+
confess to having no familiarity with Sequel, but I don't foresee any
|
216
|
+
difficulty there either.
|
217
|
+
|
218
|
+
## License
|
219
|
+
|
220
|
+
SimpleState is released under the MIT License; see LICENSE for details.
|
221
|
+
|
222
|
+
[pluginaweek]: http://github.com/pluginaweek/state_machine (pluginaweek's state_machine library)
|
223
|
+
[rubyist]: http://github.com/rubyist/aasm (rubyist's aasm library)
|
224
|
+
[ryanallen]: http://github.com/ryan-allen/workflow (ryan-allen's Workflow library)
|
data/Rakefile
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'spec/rake/spectask'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "simple_state"
|
8
|
+
gem.platform = Gem::Platform::RUBY
|
9
|
+
gem.summary = 'A *very simple* state machine implementation.'
|
10
|
+
gem.description = gem.summary
|
11
|
+
gem.email = "anthony@ninecraft.com"
|
12
|
+
gem.homepage = "http://github.com/antw/simple_state"
|
13
|
+
gem.authors = ["Anthony Williams"]
|
14
|
+
|
15
|
+
gem.extra_rdoc_files = %w(README.markdown LICENSE)
|
16
|
+
|
17
|
+
gem.files = %w(LICENSE README.markdown Rakefile VERSION.yml) +
|
18
|
+
Dir.glob("{lib,spec}/**/*")
|
19
|
+
end
|
20
|
+
rescue LoadError
|
21
|
+
puts "Jeweler not available. Install it with: sudo gem install " \
|
22
|
+
"technicalpickles-jeweler -s http://gems.github.com"
|
23
|
+
end
|
24
|
+
|
25
|
+
# rDoc =======================================================================
|
26
|
+
|
27
|
+
require 'rake/rdoctask'
|
28
|
+
Rake::RDocTask.new do |rdoc|
|
29
|
+
rdoc.rdoc_dir = 'rdoc'
|
30
|
+
rdoc.title = 'simple_state'
|
31
|
+
rdoc.options << '--line-numbers' << '--inline-source'
|
32
|
+
rdoc.rdoc_files.include('README*')
|
33
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
34
|
+
end
|
35
|
+
|
36
|
+
# rSpec & rcov ===============================================================
|
37
|
+
|
38
|
+
desc "Run all examples (or a specific spec with TASK=xxxx)"
|
39
|
+
Spec::Rake::SpecTask.new('spec') do |t|
|
40
|
+
t.spec_opts = ["-c -f s"]
|
41
|
+
t.spec_files = begin
|
42
|
+
if ENV["TASK"]
|
43
|
+
ENV["TASK"].split(',').map { |task| "spec/**/#{task}_spec.rb" }
|
44
|
+
else
|
45
|
+
FileList['spec/**/*_spec.rb']
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
desc "Run all examples with RCov"
|
51
|
+
Spec::Rake::SpecTask.new('spec:rcov') do |t|
|
52
|
+
t.spec_files = FileList['spec/**/*.rb']
|
53
|
+
t.spec_opts = ['-c -f s']
|
54
|
+
t.rcov = true
|
55
|
+
t.rcov_opts = ['--exclude', 'spec']
|
56
|
+
end
|
57
|
+
|
58
|
+
task :default => :spec
|
data/VERSION.yml
ADDED
data/lib/simple_state.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
module SimpleState
|
2
|
+
class Error < StandardError; end
|
3
|
+
class ArgumentError < Error; end
|
4
|
+
|
5
|
+
##
|
6
|
+
# Sets up a state machine on the current class.
|
7
|
+
#
|
8
|
+
def state_machine(&blk)
|
9
|
+
Builder.new(self).build(&blk)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
require 'simple_state/builder'
|
14
|
+
require 'simple_state/mixins'
|
@@ -0,0 +1,150 @@
|
|
1
|
+
module SimpleState
|
2
|
+
##
|
3
|
+
# Responsible for taking a state machine block and building the methods.
|
4
|
+
#
|
5
|
+
# The builder is run whenever you call +state_machine+ on a class and does
|
6
|
+
# a number of things.
|
7
|
+
#
|
8
|
+
# * Firstly, it adds a :state reader if one is not defined, and a
|
9
|
+
# _private_ :state writer.
|
10
|
+
#
|
11
|
+
# * It adds a +states+ method to the class, used for easily accessing
|
12
|
+
# the list of states for the class, and the events belonging to each
|
13
|
+
# state (and the state that the event transitions to).
|
14
|
+
#
|
15
|
+
# * Four internal methods +initial_state+, +initial_state=+,
|
16
|
+
# +_determine_new_state+ and +_valid_transition+ which are used
|
17
|
+
# internally by SimpleState for aiding the transition from one state to
|
18
|
+
# another.
|
19
|
+
#
|
20
|
+
class Builder
|
21
|
+
def initialize(klass)
|
22
|
+
@klass = klass
|
23
|
+
end
|
24
|
+
|
25
|
+
##
|
26
|
+
# Trigger for building the state machine methods.
|
27
|
+
#
|
28
|
+
def build(&blk)
|
29
|
+
@klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
30
|
+
include ::SimpleState::Mixins
|
31
|
+
RUBY
|
32
|
+
|
33
|
+
# Create an anonymous module which will be added to the state machine
|
34
|
+
# class's inheritance chain.
|
35
|
+
mod = @mod = Module.new
|
36
|
+
mod.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
37
|
+
def self.inspect
|
38
|
+
"SimpleState::#{@klass}AnonMixin"
|
39
|
+
end
|
40
|
+
|
41
|
+
# Handles the change of state.
|
42
|
+
# @api private
|
43
|
+
def _change_state_using_event!(event)
|
44
|
+
self.state = self.class._determine_new_state(self.state, event)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Returns if the passed event is permitted with the instance in it's
|
48
|
+
# current state.
|
49
|
+
# @api public
|
50
|
+
def event_permitted?(event)
|
51
|
+
self.class._event_permitted?(self.state, event)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns true if the given symbol matches the current state.
|
55
|
+
# @api public
|
56
|
+
def in_state?(state)
|
57
|
+
self.state == state
|
58
|
+
end
|
59
|
+
RUBY
|
60
|
+
|
61
|
+
# Declare the state machine rules.
|
62
|
+
instance_eval(&blk)
|
63
|
+
|
64
|
+
# Insert the anonymous module.
|
65
|
+
@klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
66
|
+
include mod
|
67
|
+
RUBY
|
68
|
+
end
|
69
|
+
|
70
|
+
##
|
71
|
+
# Defines a new state.
|
72
|
+
#
|
73
|
+
# @param [Symbol] name
|
74
|
+
# The name of the state.
|
75
|
+
# @param [Block] &blk
|
76
|
+
# An optional block for defining transitions for the state. If no block
|
77
|
+
# is given, the state will be an end-point.
|
78
|
+
#
|
79
|
+
def state(name, &blk)
|
80
|
+
@klass.states[name] = []
|
81
|
+
@klass.initial_state ||= name
|
82
|
+
|
83
|
+
@mod.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
84
|
+
def #{name}? # def prepared?
|
85
|
+
in_state?(:#{name}) # self.state == :prepared
|
86
|
+
end # end
|
87
|
+
RUBY
|
88
|
+
|
89
|
+
# Define transitions for this state.
|
90
|
+
StateBuilder.new(@klass, @mod, name).build(&blk) if blk
|
91
|
+
end
|
92
|
+
|
93
|
+
##
|
94
|
+
# Responsible for building events for a given state.
|
95
|
+
#
|
96
|
+
class StateBuilder
|
97
|
+
def initialize(klass, mod, state)
|
98
|
+
@klass, @module, @state = klass, mod, state
|
99
|
+
end
|
100
|
+
|
101
|
+
##
|
102
|
+
# Specialises a state by defining events.
|
103
|
+
#
|
104
|
+
# @param [Block] &blk
|
105
|
+
# An block for defining transitions for the state.
|
106
|
+
#
|
107
|
+
def build(&blk)
|
108
|
+
instance_eval(&blk)
|
109
|
+
end
|
110
|
+
|
111
|
+
##
|
112
|
+
# Defines an event and transition.
|
113
|
+
#
|
114
|
+
# @param [Symbol] event_name A name for this event.
|
115
|
+
# @param [Hash] opts An options hash for customising the event.
|
116
|
+
#
|
117
|
+
def event(event, opts = {})
|
118
|
+
unless opts[:transitions_to].kind_of?(Symbol)
|
119
|
+
raise ArgumentError, 'You must declare a :transitions_to state ' \
|
120
|
+
'when defining events'
|
121
|
+
end
|
122
|
+
|
123
|
+
# Keep track of valid transitions for this state.
|
124
|
+
@klass.states[@state].push([event, opts[:transitions_to]])
|
125
|
+
|
126
|
+
unless @module.method_defined?(:"#{event}!")
|
127
|
+
# Example:
|
128
|
+
#
|
129
|
+
# def process!
|
130
|
+
# if event_permitted?(:process)
|
131
|
+
# _change_state_using_event!(:process)
|
132
|
+
# else
|
133
|
+
# false
|
134
|
+
# end
|
135
|
+
# end
|
136
|
+
@module.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
137
|
+
def #{event}!
|
138
|
+
if event_permitted?(:#{event})
|
139
|
+
_change_state_using_event!(:#{event})
|
140
|
+
else
|
141
|
+
false
|
142
|
+
end
|
143
|
+
end
|
144
|
+
RUBY
|
145
|
+
end
|
146
|
+
end # def event
|
147
|
+
end # StateBuilder
|
148
|
+
|
149
|
+
end # Builder
|
150
|
+
end # SimpleState
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module SimpleState
|
2
|
+
module Mixins
|
3
|
+
def self.included(klass)
|
4
|
+
klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
5
|
+
attr_reader :state unless method_defined?(:state)
|
6
|
+
@@states = {}
|
7
|
+
@@initial_state = nil
|
8
|
+
|
9
|
+
unless method_defined?(:state=)
|
10
|
+
attr_writer :state
|
11
|
+
private :state=
|
12
|
+
end
|
13
|
+
|
14
|
+
extend Singleton
|
15
|
+
include Instance
|
16
|
+
RUBY
|
17
|
+
end
|
18
|
+
|
19
|
+
##
|
20
|
+
# Defines singleton methods which are mixed in to a class when
|
21
|
+
# state_machine is called.
|
22
|
+
#
|
23
|
+
module Singleton
|
24
|
+
# @api private
|
25
|
+
def states
|
26
|
+
class_variable_get(:@@states)
|
27
|
+
end
|
28
|
+
|
29
|
+
# @api public
|
30
|
+
def initial_state=(state)
|
31
|
+
class_variable_set(:@@initial_state, state)
|
32
|
+
end
|
33
|
+
|
34
|
+
# @api public
|
35
|
+
def initial_state
|
36
|
+
class_variable_get(:@@initial_state)
|
37
|
+
end
|
38
|
+
|
39
|
+
# @api private
|
40
|
+
def _determine_new_state(current, to)
|
41
|
+
states[current] && (t = states[current].assoc(to)) && t.last
|
42
|
+
end
|
43
|
+
|
44
|
+
# @api private
|
45
|
+
def _event_permitted?(current, to)
|
46
|
+
states[current] and not states[current].assoc(to).nil?
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
##
|
51
|
+
# Defines instance methods which are mixed in to a class when
|
52
|
+
# state_machine is called.
|
53
|
+
#
|
54
|
+
module Instance
|
55
|
+
##
|
56
|
+
# Set the initial value for the state machine after calling the original
|
57
|
+
# initialize method.
|
58
|
+
#
|
59
|
+
def initialize(*args, &blk)
|
60
|
+
super
|
61
|
+
self.state = self.class.initial_state
|
62
|
+
end
|
63
|
+
end # Instance
|
64
|
+
end # Mixins
|
65
|
+
end # SimpleState
|
@@ -0,0 +1,71 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper.rb')
|
2
|
+
|
3
|
+
# Basic State Machine ========================================================
|
4
|
+
|
5
|
+
describe SimpleState::Builder do
|
6
|
+
before(:each) do
|
7
|
+
@c = state_class do
|
8
|
+
state :prepared
|
9
|
+
state :processed
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'should add a private state writer' do
|
14
|
+
@c.private_methods.map { |m| m.to_sym }.should include(:state=)
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'should add a state reader' do
|
18
|
+
@c.methods.map { |m| m.to_sym }.should include(:state)
|
19
|
+
end
|
20
|
+
|
21
|
+
describe 'when defining states with no events' do
|
22
|
+
it 'should create a predicate' do
|
23
|
+
methods = @c.methods.map { |m| m.to_sym }
|
24
|
+
methods.should include(:prepared?)
|
25
|
+
methods.should include(:processed?)
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'should add the state to register' do
|
29
|
+
@c.class.states.keys.should include(:prepared)
|
30
|
+
@c.class.states.keys.should include(:processed)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# State Machine with Events ==================================================
|
36
|
+
|
37
|
+
describe 'when defining a state with an event' do
|
38
|
+
before(:each) do
|
39
|
+
@evented_state = state_class do
|
40
|
+
state :prepared do
|
41
|
+
event :process, :transitions_to => :processed
|
42
|
+
event :processing_failed, :transitions_to => :failed
|
43
|
+
end
|
44
|
+
|
45
|
+
state :processed
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'should add a bang method for the transition' do
|
50
|
+
@evented_state.methods.map { |m| m.to_sym }.should \
|
51
|
+
include(:process!)
|
52
|
+
@evented_state.methods.map { |m| m.to_sym }.should \
|
53
|
+
include(:processing_failed!)
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'should add the state to register' do
|
57
|
+
@evented_state.class.states.keys.should include(:prepared)
|
58
|
+
@evented_state.class.states.keys.should include(:processed)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'should raise an argument error if no :transitions_to is provided' do
|
62
|
+
lambda {
|
63
|
+
Class.new do
|
64
|
+
extend SimpleState
|
65
|
+
state_machine do
|
66
|
+
state(:prepared) { event :process }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
}.should raise_error(SimpleState::ArgumentError)
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,227 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper.rb')
|
2
|
+
|
3
|
+
describe 'Generated event methods' do
|
4
|
+
|
5
|
+
it 'should permit the use of super when overriding them' do
|
6
|
+
@c = Class.new do
|
7
|
+
attr_reader :called
|
8
|
+
|
9
|
+
extend SimpleState
|
10
|
+
|
11
|
+
state_machine do
|
12
|
+
state :begin do
|
13
|
+
event :go, :transitions_to => :finished
|
14
|
+
end
|
15
|
+
|
16
|
+
state :finished
|
17
|
+
end
|
18
|
+
|
19
|
+
def go!
|
20
|
+
@called = true
|
21
|
+
super()
|
22
|
+
end
|
23
|
+
end.new
|
24
|
+
|
25
|
+
@c.should be_begin
|
26
|
+
lambda { @c.go! }.should_not raise_error(NoMethodError)
|
27
|
+
@c.should be_finished
|
28
|
+
@c.called.should be_true
|
29
|
+
end
|
30
|
+
|
31
|
+
# Event Validation =========================================================
|
32
|
+
|
33
|
+
describe 'when the transition is valid' do
|
34
|
+
before(:each) do
|
35
|
+
@c = state_class do
|
36
|
+
state :begin do
|
37
|
+
event :go, :transitions_to => :state_one
|
38
|
+
end
|
39
|
+
|
40
|
+
state :state_one
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'should return the new state' do
|
45
|
+
@c.go!.should == :state_one
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'should transition the instance to the new state' do
|
49
|
+
@c.go!
|
50
|
+
@c.should be_state_one
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
describe 'when the transition is not valid' do
|
55
|
+
before(:each) do
|
56
|
+
@c = state_class do
|
57
|
+
state :begin do
|
58
|
+
event :go, :transitions_to => :state_one
|
59
|
+
end
|
60
|
+
|
61
|
+
state :state_one do
|
62
|
+
event :go_again, :transitions_to => :state_two
|
63
|
+
end
|
64
|
+
|
65
|
+
state :state_two
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'should return false' do
|
70
|
+
@c.go_again!.should be_false
|
71
|
+
end
|
72
|
+
|
73
|
+
it "should not change the instance's state" do
|
74
|
+
@c.go_again!
|
75
|
+
@c.should be_begin
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Multiple Paths Definition ================================================
|
80
|
+
|
81
|
+
describe 'when mulitple states share the same event' do
|
82
|
+
before(:each) do
|
83
|
+
@path = state_class do
|
84
|
+
state :begin do
|
85
|
+
event :s1, :transitions_to => :state_one
|
86
|
+
event :s2, :transitions_to => :state_two
|
87
|
+
end
|
88
|
+
|
89
|
+
state :state_one do
|
90
|
+
event :go, :transitions_to => :state_three
|
91
|
+
end
|
92
|
+
|
93
|
+
state :state_two do
|
94
|
+
event :go, :transitions_to => :state_four
|
95
|
+
end
|
96
|
+
|
97
|
+
state :state_three
|
98
|
+
state :state_four
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
it 'should transition to state_three if currently in state_one' do
|
103
|
+
@path.s1!
|
104
|
+
@path.go!
|
105
|
+
@path.should be_state_three
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'should transition to state_four if current in state_two' do
|
109
|
+
@path.s2!
|
110
|
+
@path.go!
|
111
|
+
@path.should be_state_four
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
116
|
+
|
117
|
+
# Test full workflow =========================================================
|
118
|
+
# This tests all the possible transition permutations of a state machine.
|
119
|
+
|
120
|
+
describe 'Generated event methods (integration)' do
|
121
|
+
before(:each) do
|
122
|
+
@c = state_class do
|
123
|
+
state :prepared do
|
124
|
+
event :requires_decompress, :transitions_to => :requires_decompress
|
125
|
+
event :invalid_extension, :transitions_to => :halted
|
126
|
+
event :processed, :transitions_to => :processed
|
127
|
+
event :processing_failed, :transitions_to => :halted
|
128
|
+
end
|
129
|
+
|
130
|
+
state :processed do
|
131
|
+
event :stored, :transitions_to => :stored
|
132
|
+
event :store_failed, :transitions_to => :halted
|
133
|
+
end
|
134
|
+
|
135
|
+
state :requires_decompress do
|
136
|
+
event :decompressed, :transitions_to => :complete
|
137
|
+
event :decompress_failed, :transitions_to => :halted
|
138
|
+
end
|
139
|
+
|
140
|
+
state :stored do
|
141
|
+
event :cleaned, :transitions_to => :complete
|
142
|
+
end
|
143
|
+
|
144
|
+
state :halted do
|
145
|
+
event :cleaned, :transitions_to => :failed
|
146
|
+
end
|
147
|
+
|
148
|
+
state :failed
|
149
|
+
state :complete
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
it 'should successfully change the state to complete via the ' \
|
154
|
+
'intermediate states' do
|
155
|
+
|
156
|
+
# begin -> processed -> stored -> complete
|
157
|
+
|
158
|
+
@c.should be_prepared
|
159
|
+
|
160
|
+
@c.processed!.should == :processed
|
161
|
+
@c.should be_processed
|
162
|
+
|
163
|
+
@c.stored!.should == :stored
|
164
|
+
@c.should be_stored
|
165
|
+
|
166
|
+
@c.cleaned!.should == :complete
|
167
|
+
@c.should be_complete
|
168
|
+
end
|
169
|
+
|
170
|
+
it 'should successfully change the state to complete via successful ' \
|
171
|
+
'decompress' do
|
172
|
+
|
173
|
+
# begin -> requires_decompress -> complete
|
174
|
+
|
175
|
+
@c.requires_decompress!.should == :requires_decompress
|
176
|
+
@c.should be_requires_decompress
|
177
|
+
|
178
|
+
@c.decompressed!.should == :complete
|
179
|
+
@c.should be_complete
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'should successfully change the state to failed via failed decompress' do
|
183
|
+
# begins -> requires_decompress -> halted -> failed
|
184
|
+
|
185
|
+
@c.requires_decompress!.should == :requires_decompress
|
186
|
+
@c.should be_requires_decompress
|
187
|
+
|
188
|
+
@c.decompress_failed!.should == :halted
|
189
|
+
@c.should be_halted
|
190
|
+
|
191
|
+
@c.cleaned!.should == :failed
|
192
|
+
@c.should be_failed
|
193
|
+
end
|
194
|
+
|
195
|
+
it 'should successfully change the state to failed via invalid extension' do
|
196
|
+
# begins -> halted -> failed
|
197
|
+
|
198
|
+
@c.invalid_extension!.should == :halted
|
199
|
+
@c.should be_halted
|
200
|
+
|
201
|
+
@c.cleaned!.should == :failed
|
202
|
+
@c.should be_failed
|
203
|
+
end
|
204
|
+
|
205
|
+
it 'should successfully change the state to failed via failed processing' do
|
206
|
+
# begins -> halted -> failed
|
207
|
+
|
208
|
+
@c.processing_failed!.should == :halted
|
209
|
+
@c.should be_halted
|
210
|
+
|
211
|
+
@c.cleaned!.should == :failed
|
212
|
+
@c.should be_failed
|
213
|
+
end
|
214
|
+
|
215
|
+
it 'should successfully change the state to failed via failed storage' do
|
216
|
+
# begins -> processed -> halted -> failed
|
217
|
+
|
218
|
+
@c.processed!.should == :processed
|
219
|
+
@c.should be_processed
|
220
|
+
|
221
|
+
@c.store_failed!.should == :halted
|
222
|
+
@c.should be_halted
|
223
|
+
|
224
|
+
@c.cleaned!.should == :failed
|
225
|
+
@c.should be_failed
|
226
|
+
end
|
227
|
+
end
|
data/spec/mixins_spec.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper.rb')
|
2
|
+
|
3
|
+
describe SimpleState::Mixins::Instance do
|
4
|
+
describe '#initialize' do
|
5
|
+
it 'should set the initial state' do
|
6
|
+
c = state_class do
|
7
|
+
state :begin
|
8
|
+
state :finish
|
9
|
+
end
|
10
|
+
|
11
|
+
c.state.should == :begin
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'should call the original #initialize' do
|
15
|
+
parent = Class.new do
|
16
|
+
attr_reader :called
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@called = true
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
child = Class.new(parent) do
|
24
|
+
extend SimpleState
|
25
|
+
state_machine do
|
26
|
+
state :begin
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
child.new.called.should be_true
|
31
|
+
child.new.state.should == :begin
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should have separate state machines for each class' do
|
35
|
+
class_one = state_class do
|
36
|
+
state :one
|
37
|
+
end
|
38
|
+
|
39
|
+
class_two = state_class do
|
40
|
+
state :two
|
41
|
+
end
|
42
|
+
|
43
|
+
class_one.class.states.keys.should == [:one]
|
44
|
+
class_two.class.states.keys.should == [:two]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper.rb')
|
2
|
+
|
3
|
+
describe SimpleState, 'generated predicate methods' do
|
4
|
+
before(:each) do
|
5
|
+
@predicate_test = state_class do
|
6
|
+
state :state_one
|
7
|
+
state :state_two
|
8
|
+
state :state_three
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'should return true if the current state matches the predicate' do
|
13
|
+
@predicate_test.should be_state_one
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'should return false if the current state does not match the predicate' do
|
17
|
+
@predicate_test.should_not be_state_two
|
18
|
+
@predicate_test.should_not be_state_three
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'should permit the use of super when overriding them' do
|
22
|
+
@c = Class.new do
|
23
|
+
attr_reader :called
|
24
|
+
|
25
|
+
extend SimpleState
|
26
|
+
|
27
|
+
state_machine do
|
28
|
+
state :begin
|
29
|
+
end
|
30
|
+
|
31
|
+
def begin?
|
32
|
+
@called = true
|
33
|
+
super()
|
34
|
+
end
|
35
|
+
end.new
|
36
|
+
|
37
|
+
lambda { @c.begin? }.should_not raise_error(NoMethodError)
|
38
|
+
@c.called.should be_true
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper.rb')
|
2
|
+
|
3
|
+
describe "SimpleState" do
|
4
|
+
it "should add a state_machine method to the class" do
|
5
|
+
Class.new { extend SimpleState }.methods.map do |m|
|
6
|
+
# Ruby 1.9 compat.
|
7
|
+
m.to_sym
|
8
|
+
end.should include(:state_machine)
|
9
|
+
end
|
10
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'spec'
|
3
|
+
|
4
|
+
# $LOAD_PATH.unshift(File.dirname(__FILE__))
|
5
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
6
|
+
require 'simple_state'
|
7
|
+
|
8
|
+
##
|
9
|
+
# Creates an anonymous class which uses SimpleState.
|
10
|
+
#
|
11
|
+
def state_class(&blk)
|
12
|
+
Class.new do
|
13
|
+
extend SimpleState
|
14
|
+
state_machine(&blk)
|
15
|
+
end.new
|
16
|
+
end
|
metadata
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: antw-simple_state
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Anthony Williams
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-08-21 00:00:00 -07:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: A *very simple* state machine implementation.
|
17
|
+
email: anthony@ninecraft.com
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- LICENSE
|
24
|
+
- README.markdown
|
25
|
+
files:
|
26
|
+
- LICENSE
|
27
|
+
- README.markdown
|
28
|
+
- Rakefile
|
29
|
+
- VERSION.yml
|
30
|
+
- lib/simple_state.rb
|
31
|
+
- lib/simple_state/builder.rb
|
32
|
+
- lib/simple_state/mixins.rb
|
33
|
+
- spec/builder_spec.rb
|
34
|
+
- spec/event_methods_spec.rb
|
35
|
+
- spec/mixins_spec.rb
|
36
|
+
- spec/predicate_methods_spec.rb
|
37
|
+
- spec/simple_state_spec.rb
|
38
|
+
- spec/spec_helper.rb
|
39
|
+
has_rdoc: false
|
40
|
+
homepage: http://github.com/antw/simple_state
|
41
|
+
licenses:
|
42
|
+
post_install_message:
|
43
|
+
rdoc_options:
|
44
|
+
- --charset=UTF-8
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
requirements:
|
49
|
+
- - ">="
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: "0"
|
52
|
+
version:
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: "0"
|
58
|
+
version:
|
59
|
+
requirements: []
|
60
|
+
|
61
|
+
rubyforge_project:
|
62
|
+
rubygems_version: 1.3.5
|
63
|
+
signing_key:
|
64
|
+
specification_version: 3
|
65
|
+
summary: A *very simple* state machine implementation.
|
66
|
+
test_files:
|
67
|
+
- spec/builder_spec.rb
|
68
|
+
- spec/event_methods_spec.rb
|
69
|
+
- spec/mixins_spec.rb
|
70
|
+
- spec/predicate_methods_spec.rb
|
71
|
+
- spec/simple_state_spec.rb
|
72
|
+
- spec/spec_helper.rb
|