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