simple_machine 1.0.0
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/README.rdoc +73 -0
- data/lib/simple_machine.rb +113 -0
- data/spec/spec_simple_machine.rb +181 -0
- metadata +69 -0
data/README.rdoc
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
= SimpleMachine
|
2
|
+
|
3
|
+
SimpleMachine is module for Ruby which injects simple state machine behavior in any class that includes it. It can be defined on multiple fields in the same class
|
4
|
+
|
5
|
+
== Installation
|
6
|
+
|
7
|
+
Install it with "gem install simple_machine" then require 'simple_machine' in your project, or if you use Bundler add this to your Gemfile:
|
8
|
+
gem "simple_machine"
|
9
|
+
|
10
|
+
== Usage
|
11
|
+
|
12
|
+
After requiring 'simple_machine' inject state machine in your class like this:
|
13
|
+
|
14
|
+
require 'simple_machine'
|
15
|
+
|
16
|
+
class Phone
|
17
|
+
include SimpleMachine
|
18
|
+
implement_state_machine_for :my_state do
|
19
|
+
initial_state :off
|
20
|
+
other_states :ready, :dialing, :busy
|
21
|
+
allow_transition :turn_on, :from => :off, :to => :ready
|
22
|
+
allow_transition :dial, :from => :ready, :to => :dialing
|
23
|
+
allow_transition :hangup, :from => :dialing, :to => :ready
|
24
|
+
allow_transition :hangup, :from => :busy, :to => :ready
|
25
|
+
allow_transition :turn_off, :from => :ready, :to => :off
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
This chunk of code produces following effects:
|
31
|
+
|
32
|
+
Phone.my_state_default_state #=> :off
|
33
|
+
phone = Phone.new
|
34
|
+
phone.my_state #=> :off
|
35
|
+
phone.my_state_machine.allowed_transitions #=> [:turn_on]
|
36
|
+
phone.my_state_machine.can_dial? #=> false
|
37
|
+
phone.my_state_machine.dial #=> raises exception: 'Invalid transition #dial from 'off' state'
|
38
|
+
phone.my_state_machine.can_turn_on? #=> true
|
39
|
+
phone.my_state_machine.turn_on #=> :ready
|
40
|
+
phone.my_state #=> :ready
|
41
|
+
|
42
|
+
=== Transition Guards
|
43
|
+
|
44
|
+
You can also implement transition guards in your class like this:
|
45
|
+
|
46
|
+
class Phone
|
47
|
+
def guard_for_dial_on_my_state; false; end
|
48
|
+
end
|
49
|
+
|
50
|
+
In this case even if +dial+ is allowed transition from +ready+ state this is what will happen:
|
51
|
+
|
52
|
+
phone.my_state #=> :ready
|
53
|
+
phone.my_state_machine.allowed_transitions #=> [:hangup]
|
54
|
+
phone.my_state.machine.can_dial? #=> false
|
55
|
+
phone.my_state_machine.dial #=> raises exception: 'Unable to dial due to guard'
|
56
|
+
|
57
|
+
=== Callback
|
58
|
+
|
59
|
+
After each transition callback is invoked in parent class if it is defined:
|
60
|
+
|
61
|
+
class Phone
|
62
|
+
def after_my_state_changed
|
63
|
+
puts "New my state is '#{self.my_state}'"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
== Test
|
68
|
+
|
69
|
+
If you want to run tests you will need 'rspec' and 'mocha' gems
|
70
|
+
|
71
|
+
== Disclaimer
|
72
|
+
|
73
|
+
This library was made without any pretend to be big-enterprise-sega-mega solution for State machine, but I still hope you will find this small library useful in some cases (I already do :). If you have any issues or ideas feel free to fork the project and send a pull request.
|
@@ -0,0 +1,113 @@
|
|
1
|
+
module SimpleMachine
|
2
|
+
def self.included(klass)
|
3
|
+
klass.extend ClassMethods
|
4
|
+
end
|
5
|
+
|
6
|
+
class SimpleMachinePrototype
|
7
|
+
def initialize(owner)
|
8
|
+
@owner = owner
|
9
|
+
end
|
10
|
+
|
11
|
+
def all_states
|
12
|
+
self.class.all_states
|
13
|
+
end
|
14
|
+
def allowed_transitions
|
15
|
+
current_state = @owner.send self.class.parents_state_field_name
|
16
|
+
result = self.class.defined_transitions[current_state].collect { |hash| hash[:transition] }
|
17
|
+
result.collect do |transition|
|
18
|
+
guard_method = "guard_for_#{transition}_on_#{self.class.parents_state_field_name}".to_sym
|
19
|
+
transition if !@owner.respond_to?( guard_method ) or @owner.send(guard_method)
|
20
|
+
end.compact
|
21
|
+
end
|
22
|
+
|
23
|
+
class << self
|
24
|
+
attr_accessor :parents_state_field_name
|
25
|
+
attr_reader :all_states, :defined_transitions
|
26
|
+
attr_writer :owner_class
|
27
|
+
|
28
|
+
def initial_state(state)
|
29
|
+
@all_states = [] << state
|
30
|
+
variable = "@#{@parents_state_field_name}_default_state"
|
31
|
+
@owner_class.instance_eval { instance_variable_set(variable, state) }
|
32
|
+
end
|
33
|
+
def other_states(*other_states)
|
34
|
+
@all_states = @all_states | other_states
|
35
|
+
end
|
36
|
+
def defined_transition?(transition, from_state)
|
37
|
+
return false unless @defined_transitions.has_key? from_state
|
38
|
+
result = @defined_transitions[from_state].inject(false) { |sum, hash| sum || hash[:transition] == transition }
|
39
|
+
return result
|
40
|
+
end
|
41
|
+
def allow_transition(transition, options)
|
42
|
+
@defined_transitions ||= {}
|
43
|
+
raise "Unknown source state #{options[:from]}. Please define it first as initial_state or in other_states." unless all_states.include?(options[:from])
|
44
|
+
raise "Unknown target state #{options[:to]}. Please define it first as initial_state or in other_states." unless all_states.include?(options[:to])
|
45
|
+
raise "Already defined transition '#{transition}' from '#{options[:from]}' state" if defined_transition?(transition, options[:from])
|
46
|
+
|
47
|
+
@defined_transitions[options[:from]] = [] unless @defined_transitions.has_key?(options[:from])
|
48
|
+
@defined_transitions[options[:from]] << { :transition => transition, :target_state => options[:to] }
|
49
|
+
|
50
|
+
class_eval do
|
51
|
+
define_method "can_#{transition}?" do
|
52
|
+
allowed_transitions.include? transition
|
53
|
+
end
|
54
|
+
define_method transition do
|
55
|
+
current_state = @owner.send self.class.parents_state_field_name
|
56
|
+
if self.class.defined_transitions[current_state].inject(false) { |memo, hash| memo or hash[:transition] == transition }
|
57
|
+
raise "Unable to #{transition.to_s.gsub '_', ' '} due to guard"
|
58
|
+
else
|
59
|
+
raise "Invalid transition ##{transition.to_s.gsub '_', ' '} from '#{current_state}' state"
|
60
|
+
end unless allowed_transitions.include? transition
|
61
|
+
variable = "@#{self.class.parents_state_field_name}"
|
62
|
+
result = @owner.instance_eval { instance_variable_set variable, options[:to] }
|
63
|
+
callback_method = "after_#{self.class.parents_state_field_name}_changed".to_sym
|
64
|
+
@owner.send callback_method if @owner.respond_to? callback_method
|
65
|
+
result
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
module ClassMethods
|
73
|
+
|
74
|
+
def implement_state_machine_for(state_field_name, &block)
|
75
|
+
machine_property_name = "#{state_field_name}_machine".to_sym
|
76
|
+
default_state_field_name = "#{state_field_name}_default_state".to_sym
|
77
|
+
|
78
|
+
define_method state_field_name do
|
79
|
+
result = instance_variable_get "@#{state_field_name}"
|
80
|
+
result = instance_variable_set "@#{state_field_name}", self.class.send(default_state_field_name) unless result
|
81
|
+
result
|
82
|
+
end
|
83
|
+
define_method machine_property_name do
|
84
|
+
result = instance_variable_get "@#{machine_property_name}"
|
85
|
+
result = instance_variable_set "@#{machine_property_name}", SimpleMachine::ClassMethods.get_state_machine_class_for(self.class, state_field_name).new(self) unless result
|
86
|
+
result
|
87
|
+
end
|
88
|
+
self.class.class_eval do
|
89
|
+
define_method default_state_field_name do
|
90
|
+
instance_variable_get "@#{default_state_field_name}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
SimpleMachine::ClassMethods.get_state_machine_class_for(self, state_field_name).instance_eval &block
|
95
|
+
|
96
|
+
raise "Initial state not defined." unless send default_state_field_name
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
@state_machine_classes = {}
|
102
|
+
|
103
|
+
def self.get_state_machine_class_for(cls, state_field_name)
|
104
|
+
key = [cls.to_s.to_sym, state_field_name]
|
105
|
+
unless @state_machine_classes.has_key? key
|
106
|
+
@state_machine_classes[key] ||= SimpleMachine::SimpleMachinePrototype.clone
|
107
|
+
@state_machine_classes[key].owner_class = cls
|
108
|
+
@state_machine_classes[key].parents_state_field_name = state_field_name
|
109
|
+
end
|
110
|
+
@state_machine_classes[key]
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,181 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rspec'
|
3
|
+
require 'mocha'
|
4
|
+
require File.expand_path(File.dirname(__FILE__) + '/../lib/simple_machine')
|
5
|
+
|
6
|
+
RSpec.configure { |config| config.mock_with :mocha }
|
7
|
+
|
8
|
+
describe SimpleMachine, "for :dispatch_state field on Job, when state machine is aready defined for :other_state on Job and for :dispatch_state on Driver" do
|
9
|
+
before :all do
|
10
|
+
class Driver
|
11
|
+
include SimpleMachine
|
12
|
+
implement_state_machine_for :dispatch_state do
|
13
|
+
initial_state :waiting
|
14
|
+
other_states :assigned, :circling, :going_to_pickup_location, :on_pickup_location, :driving_passenger, :changing_zone, :going_home
|
15
|
+
allow_transition :assign, :from => :waiting, :to => :assigned
|
16
|
+
end
|
17
|
+
end
|
18
|
+
class Job
|
19
|
+
include SimpleMachine
|
20
|
+
implement_state_machine_for :other_state do
|
21
|
+
initial_state :created
|
22
|
+
other_states :deleted, :reviewed, :done, :closed
|
23
|
+
allow_transition :delete, :from => :created, :to => :deleted
|
24
|
+
allow_transition :delete, :from => :reviewed, :to => :deleted
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
context "without initial state" do
|
29
|
+
it "raises exception that initial state is undefined" do
|
30
|
+
lambda { Job.implement_state_machine_for :dispatch_state do; end }.should( raise_error do |e|
|
31
|
+
e.message.should match("Initial state not defined.")
|
32
|
+
end )
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "with only initial state :waiting" do
|
37
|
+
before :each do
|
38
|
+
Job.implement_state_machine_for :dispatch_state do
|
39
|
+
initial_state :waiting
|
40
|
+
end
|
41
|
+
end
|
42
|
+
it "defines :waiting as dispatch_state_default_state in Job" do
|
43
|
+
Job.dispatch_state_default_state.should be(:waiting)
|
44
|
+
end
|
45
|
+
it "sets dispatch_status to :waiting on new job" do
|
46
|
+
Job.new.dispatch_state.should be(:waiting)
|
47
|
+
end
|
48
|
+
|
49
|
+
describe "#dispatch_state_machine on job " do
|
50
|
+
it "#all_states contains only :waiting" do
|
51
|
+
all_states = Job.new.dispatch_state_machine.all_states
|
52
|
+
|
53
|
+
all_states.size.should == 1
|
54
|
+
all_states.should include(:waiting)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context "when initial :waiting and other :assigned, :accepted, :cancelled and :closed states are defined also" do
|
60
|
+
before :all do
|
61
|
+
Job.implement_state_machine_for :dispatch_state do
|
62
|
+
initial_state :waiting
|
63
|
+
other_states :assigned, :accepted, :cancelled, :closed
|
64
|
+
end
|
65
|
+
end
|
66
|
+
it "#dispatch_state_machine#all_states has 5 states" do
|
67
|
+
Job.new.dispatch_state_machine.all_states.should have(5).states
|
68
|
+
end
|
69
|
+
|
70
|
+
context "for defined :assign transition from :waiting to :assigned" do
|
71
|
+
before :all do
|
72
|
+
@inner_state_machine_class = Job.new.dispatch_state_machine.class
|
73
|
+
@inner_state_machine_class.allow_transition :assign, :from => :waiting, :to => :assigned
|
74
|
+
end
|
75
|
+
describe "#dispatch_state_machine#allowed_transitions" do
|
76
|
+
it "contains only :assign when in :waiting state" do
|
77
|
+
job = Job.new
|
78
|
+
|
79
|
+
job.dispatch_state_machine.should have(1).allowed_transitions
|
80
|
+
job.dispatch_state_machine.allowed_transitions.should include(:assign)
|
81
|
+
end
|
82
|
+
it "doesn't contain :assign if job#guard_for_assign_on_dispatch_state returns false" do
|
83
|
+
Job.any_instance.stubs(:guard_for_assign_on_dispatch_state).returns false
|
84
|
+
|
85
|
+
Job.new.dispatch_state_machine.should have(0).allowed_transitions
|
86
|
+
end
|
87
|
+
it "contains :assign if job#guard_for_assign_on_dispatch_state returns true" do
|
88
|
+
Job.any_instance.stubs(:guard_for_assign_on_dispatch_state).returns true
|
89
|
+
|
90
|
+
Job.new.dispatch_state_machine.allowed_transitions.should include(:assign)
|
91
|
+
end
|
92
|
+
it "raises exception if there's attempt to define again :assign transition from :waiting state" do
|
93
|
+
@inner_state_machine_class = Job.new.dispatch_state_machine.class
|
94
|
+
lambda { @inner_state_machine_class.allow_transition :assign, :from => :waiting, :to => :closed }.should( raise_error do |e|
|
95
|
+
e.message.should match("Already defined transition 'assign' from 'waiting' state")
|
96
|
+
end )
|
97
|
+
end
|
98
|
+
end
|
99
|
+
describe "assign transition" do
|
100
|
+
context "when it is allowed" do
|
101
|
+
it "returns :assigned" do
|
102
|
+
job = Job.new
|
103
|
+
job.dispatch_state_machine.assign.should be(:assigned)
|
104
|
+
end
|
105
|
+
it "sets dispatch_state to :assigned" do
|
106
|
+
job = Job.new
|
107
|
+
job.dispatch_state_machine.assign
|
108
|
+
|
109
|
+
job.dispatch_state.should be(:assigned)
|
110
|
+
end
|
111
|
+
it "calls job#after_dispatch_state_changed callback if defined" do
|
112
|
+
Job.any_instance.expects(:after_dispatch_state_changed)
|
113
|
+
Job.new.dispatch_state_machine.assign
|
114
|
+
end
|
115
|
+
end
|
116
|
+
context "when it is not valid transition from current state" do
|
117
|
+
it "raises an error" do
|
118
|
+
@inner_state_machine_class.allow_transition :reject, :from => :assigned, :to => :waiting
|
119
|
+
|
120
|
+
job = Job.new
|
121
|
+
lambda { job.dispatch_state_machine.reject }.should( raise_error do |e|
|
122
|
+
e.message.should match("Invalid transition #reject from 'waiting' state")
|
123
|
+
end )
|
124
|
+
end
|
125
|
+
end
|
126
|
+
context "when it is not valid transition due to guard method" do
|
127
|
+
it "raises an error" do
|
128
|
+
Job.any_instance.stubs(:guard_for_assign_on_dispatch_state).returns false
|
129
|
+
|
130
|
+
job = Job.new
|
131
|
+
lambda { job.dispatch_state_machine.assign }.should( raise_error do |e|
|
132
|
+
e.message.should match("Unable to assign due to guard")
|
133
|
+
end )
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
describe "transition guards" do
|
138
|
+
it "#can_assign? responds as true" do
|
139
|
+
job = Job.new
|
140
|
+
|
141
|
+
job.dispatch_state_machine.allowed_transitions.should include(:assign)
|
142
|
+
job.dispatch_state_machine.can_assign?.should be_true
|
143
|
+
end
|
144
|
+
it "responds as false for #can_go_back? if :go_back is not allowed transition but is defined" do
|
145
|
+
@inner_state_machine_class.allow_transition :go_back, :from => :assigned, :to => :waiting
|
146
|
+
|
147
|
+
job = Job.new
|
148
|
+
|
149
|
+
job.dispatch_state_machine.allowed_transitions.should_not include(:go_back)
|
150
|
+
job.dispatch_state_machine.can_go_back?.should be_false
|
151
|
+
end
|
152
|
+
it "raises an error for #can_do_something_undefined? if :do_something_undefined is not defined transition" do
|
153
|
+
lambda { Job.new.dispatch_state_machine.can_do_something_undefined? }.should( raise_error do |e|
|
154
|
+
e.message.should include("undefined method `can_do_something_undefined?'")
|
155
|
+
end )
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
context "when there are more job instances" do
|
161
|
+
before :all do
|
162
|
+
Job.implement_state_machine_for :dispatch_state do
|
163
|
+
allow_transition :accept, :from => :assigned, :to => :accepted
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
it "each instance tracks it's own flow" do
|
168
|
+
job1 = Job.new
|
169
|
+
job2 = Job.new
|
170
|
+
job3 = Job.new
|
171
|
+
|
172
|
+
job1.dispatch_state_machine.assign
|
173
|
+
job2.dispatch_state_machine.assign
|
174
|
+
job2.dispatch_state_machine.accept
|
175
|
+
|
176
|
+
job1.dispatch_state.should be(:assigned)
|
177
|
+
job2.dispatch_state.should be(:accepted)
|
178
|
+
job3.dispatch_state.should be(:waiting)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
metadata
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: simple_machine
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 23
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 1
|
8
|
+
- 0
|
9
|
+
- 0
|
10
|
+
version: 1.0.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Milan Burmaja
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2010-12-13 00:00:00 +01:00
|
19
|
+
default_executable:
|
20
|
+
dependencies: []
|
21
|
+
|
22
|
+
description:
|
23
|
+
email: burmajam@gmail.com
|
24
|
+
executables: []
|
25
|
+
|
26
|
+
extensions: []
|
27
|
+
|
28
|
+
extra_rdoc_files: []
|
29
|
+
|
30
|
+
files:
|
31
|
+
- README.rdoc
|
32
|
+
- lib/simple_machine.rb
|
33
|
+
- spec/spec_simple_machine.rb
|
34
|
+
has_rdoc: true
|
35
|
+
homepage: http://github.com/burmajam/SimpleMachine
|
36
|
+
licenses: []
|
37
|
+
|
38
|
+
post_install_message:
|
39
|
+
rdoc_options: []
|
40
|
+
|
41
|
+
require_paths:
|
42
|
+
- lib
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
44
|
+
none: false
|
45
|
+
requirements:
|
46
|
+
- - ">="
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
hash: 3
|
49
|
+
segments:
|
50
|
+
- 0
|
51
|
+
version: "0"
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
53
|
+
none: false
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
hash: 3
|
58
|
+
segments:
|
59
|
+
- 0
|
60
|
+
version: "0"
|
61
|
+
requirements: []
|
62
|
+
|
63
|
+
rubyforge_project: simple_machine
|
64
|
+
rubygems_version: 1.3.7
|
65
|
+
signing_key:
|
66
|
+
specification_version: 3
|
67
|
+
summary: SimpleMachine is module for Ruby which injects simple state machine behavior in any class that includes it
|
68
|
+
test_files: []
|
69
|
+
|