simple_machine 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
|