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 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
+