state_machine 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +101 -0
- data/MIT-LICENSE +20 -0
- data/README +97 -0
- data/Rakefile +79 -0
- data/init.rb +1 -0
- data/lib/state_machine.rb +92 -0
- data/lib/state_machine/event.rb +127 -0
- data/lib/state_machine/machine.rb +141 -0
- data/lib/state_machine/transition.rb +75 -0
- data/test/app_root/app/models/auto_shop.rb +34 -0
- data/test/app_root/app/models/car.rb +19 -0
- data/test/app_root/app/models/highway.rb +3 -0
- data/test/app_root/app/models/motorcycle.rb +3 -0
- data/test/app_root/app/models/switch.rb +12 -0
- data/test/app_root/app/models/toggle_switch.rb +2 -0
- data/test/app_root/app/models/vehicle.rb +71 -0
- data/test/app_root/db/migrate/001_create_switches.rb +12 -0
- data/test/app_root/db/migrate/002_create_auto_shops.rb +13 -0
- data/test/app_root/db/migrate/003_create_highways.rb +11 -0
- data/test/app_root/db/migrate/004_create_vehicles.rb +16 -0
- data/test/factory.rb +61 -0
- data/test/functional/state_machine_test.rb +443 -0
- data/test/test_helper.rb +13 -0
- data/test/unit/event_test.rb +234 -0
- data/test/unit/machine_test.rb +152 -0
- data/test/unit/state_machine_test.rb +102 -0
- data/test/unit/transition_test.rb +298 -0
- metadata +91 -0
@@ -0,0 +1,141 @@
|
|
1
|
+
require 'state_machine/event'
|
2
|
+
|
3
|
+
module PluginAWeek #:nodoc:
|
4
|
+
module StateMachine
|
5
|
+
# Represents a state machine for a particular attribute
|
6
|
+
#
|
7
|
+
# == State callbacks
|
8
|
+
#
|
9
|
+
# These callbacks are invoked in the following order:
|
10
|
+
# 1. before_exit (old state)
|
11
|
+
# 2. before_enter (new state)
|
12
|
+
# 3. after_exit (old state)
|
13
|
+
# 4. after_enter (new state)
|
14
|
+
class Machine
|
15
|
+
# The events that trigger transitions
|
16
|
+
attr_reader :events
|
17
|
+
|
18
|
+
# The attribute for which the state machine is being defined
|
19
|
+
attr_accessor :attribute
|
20
|
+
|
21
|
+
# The initial state that the machine will be in
|
22
|
+
attr_reader :initial_state
|
23
|
+
|
24
|
+
# The class that the attribute belongs to
|
25
|
+
attr_reader :owner_class
|
26
|
+
|
27
|
+
# Creates a new state machine for the given attribute
|
28
|
+
#
|
29
|
+
# Configuration options:
|
30
|
+
# * +initial+ - The initial value to set the attribute to
|
31
|
+
#
|
32
|
+
# == Scopes
|
33
|
+
#
|
34
|
+
# This will automatically created a named scope called with_#{attribute}
|
35
|
+
# that will find all records that have the attribute set to a given value.
|
36
|
+
# For example,
|
37
|
+
#
|
38
|
+
# Switch.with_state('on') # => Finds all switches where the state is on
|
39
|
+
# Switch.with_states('on', 'off') # => Finds all switches where the state is either on or off
|
40
|
+
def initialize(owner_class, attribute, options = {})
|
41
|
+
options.assert_valid_keys(:initial)
|
42
|
+
|
43
|
+
@owner_class = owner_class
|
44
|
+
@attribute = attribute.to_s
|
45
|
+
@initial_state = options[:initial]
|
46
|
+
@events = {}
|
47
|
+
|
48
|
+
add_named_scopes
|
49
|
+
end
|
50
|
+
|
51
|
+
# Gets the initial state of the machine for the given record. The record
|
52
|
+
# is only used if a dynamic initial state is being used
|
53
|
+
def initial_state(record)
|
54
|
+
@initial_state.is_a?(Proc) ? @initial_state.call(record) : @initial_state
|
55
|
+
end
|
56
|
+
|
57
|
+
# Gets the initial state without processing it against a particular record
|
58
|
+
def initial_state_without_processing
|
59
|
+
@initial_state
|
60
|
+
end
|
61
|
+
|
62
|
+
# Defines an event of the system. This can take an optional hash that
|
63
|
+
# defines callbacks which will be invoked when the object enters/exits
|
64
|
+
# the event.
|
65
|
+
#
|
66
|
+
# Configuration options:
|
67
|
+
# * +before+ - Invoked before the event has been executed
|
68
|
+
# * +after+ - Invoked after the event has been executed
|
69
|
+
#
|
70
|
+
# == Callback order
|
71
|
+
#
|
72
|
+
# These callbacks are invoked in the following order:
|
73
|
+
# 1. before
|
74
|
+
# 2. after
|
75
|
+
#
|
76
|
+
# == Instance methods
|
77
|
+
#
|
78
|
+
# The following instance methods are generated when a new event is defined
|
79
|
+
# (the "park" event is used as an example):
|
80
|
+
# * <tt>park!(*args)</tt> - Fires the "park" event, transitioning from the current state to the next valid state. This takes an optional +args+ list which is passed to the event callbacks.
|
81
|
+
#
|
82
|
+
# == Defining transitions
|
83
|
+
#
|
84
|
+
# +event+ requires a block which allows you to define the possible
|
85
|
+
# transitions that can happen as a result of that event. For example,
|
86
|
+
#
|
87
|
+
# event :park do
|
88
|
+
# transition :to => 'parked', :from => 'idle'
|
89
|
+
# end
|
90
|
+
#
|
91
|
+
# event :first_gear do
|
92
|
+
# transition :to => 'first_gear', :from => 'parked', :if => :seatbelt_on?
|
93
|
+
# end
|
94
|
+
#
|
95
|
+
# See PluginAWeek::StateMachine::Event#transition for more information on
|
96
|
+
# the possible options that can be passed in.
|
97
|
+
#
|
98
|
+
# == Example
|
99
|
+
#
|
100
|
+
# class Car < ActiveRecord::Base
|
101
|
+
# state_machine(:state, :initial => 'parked') do
|
102
|
+
# event :park, :after => :release_seatbelt do
|
103
|
+
# transition :to => 'parked', :from => %w(first_gear reverse)
|
104
|
+
# end
|
105
|
+
# ...
|
106
|
+
# end
|
107
|
+
# end
|
108
|
+
def event(name, options = {}, &block)
|
109
|
+
name = name.to_s
|
110
|
+
event = events[name] = Event.new(self, name, options)
|
111
|
+
event.instance_eval(&block)
|
112
|
+
event
|
113
|
+
end
|
114
|
+
|
115
|
+
# Define state callbacks
|
116
|
+
%w(before_exit before_enter after_exit after_enter).each do |callback|
|
117
|
+
module_eval <<-end_eval
|
118
|
+
def #{callback}(state, callback)
|
119
|
+
callback_name = "#{callback}_\#{attribute}_\#{state}"
|
120
|
+
owner_class.define_callbacks(callback_name)
|
121
|
+
owner_class.send(callback_name, callback)
|
122
|
+
end
|
123
|
+
end_eval
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
def add_named_scopes
|
128
|
+
unless owner_class.respond_to?("with_#{attribute}")
|
129
|
+
# How do you alias named scopes? (doesn't work completely with a simple alias/alias_method)
|
130
|
+
%W(with_#{attribute} with_#{attribute.pluralize}).each do |scope_name|
|
131
|
+
owner_class.class_eval <<-end_eos
|
132
|
+
named_scope :#{scope_name}, Proc.new {|*values| {
|
133
|
+
:conditions => {:#{attribute} => values.flatten}
|
134
|
+
}}
|
135
|
+
end_eos
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module PluginAWeek #:nodoc:
|
2
|
+
module StateMachine
|
3
|
+
# A transition indicates a state change and is described by a condition
|
4
|
+
# that would need to be fulfilled to enable the transition. Transitions
|
5
|
+
# consist of:
|
6
|
+
# * The starting state
|
7
|
+
# * The ending state
|
8
|
+
# * A guard to check if the transition is allowed
|
9
|
+
class Transition
|
10
|
+
# The state from which the transition is being made
|
11
|
+
attr_reader :from_state
|
12
|
+
|
13
|
+
# The state to which the transition is being made
|
14
|
+
attr_reader :to_state
|
15
|
+
|
16
|
+
# The event that caused the transition
|
17
|
+
attr_reader :event
|
18
|
+
|
19
|
+
delegate :machine,
|
20
|
+
:to => :event
|
21
|
+
|
22
|
+
def initialize(event, from_state, to_state) #:nodoc:
|
23
|
+
@event = event
|
24
|
+
@from_state = from_state
|
25
|
+
@to_state = to_state
|
26
|
+
@loopback = from_state == to_state
|
27
|
+
end
|
28
|
+
|
29
|
+
# Whether or not this is a loopback transition (i.e. from and to state are the same)
|
30
|
+
def loopback?(state = from_state)
|
31
|
+
state == to_state
|
32
|
+
end
|
33
|
+
|
34
|
+
# Determines whether or not this transition can be performed on the given
|
35
|
+
# states
|
36
|
+
def can_perform_on?(record)
|
37
|
+
!from_state || from_state == record.send(machine.attribute)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Runs the actual transition and any actions associated with entering
|
41
|
+
# and exiting the states
|
42
|
+
def perform(record, *args)
|
43
|
+
state = record.send(machine.attribute)
|
44
|
+
|
45
|
+
invoke_before_callbacks(state, record) != false &&
|
46
|
+
update_state(state, record) &&
|
47
|
+
invoke_after_callbacks(state, record) != false
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
def update_state(from_state, record)
|
52
|
+
loopback?(from_state) ? true : record.update_attribute(machine.attribute, to_state)
|
53
|
+
end
|
54
|
+
|
55
|
+
def invoke_before_callbacks(from_state, record)
|
56
|
+
# Start leaving the last state and start entering the next state
|
57
|
+
loopback?(from_state) || invoke_callbacks(:before_exit, from_state, record) && invoke_callbacks(:before_enter, to_state, record)
|
58
|
+
end
|
59
|
+
|
60
|
+
def invoke_after_callbacks(from_state, record)
|
61
|
+
# Start leaving the last state and start entering the next state
|
62
|
+
loopback?(from_state) || invoke_callbacks(:after_exit, from_state, record) && invoke_callbacks(:after_enter, to_state, record)
|
63
|
+
end
|
64
|
+
|
65
|
+
def invoke_callbacks(type, state, record)
|
66
|
+
kind = "#{type}_#{machine.attribute}_#{state}"
|
67
|
+
if record.class.respond_to?("#{kind}_callback_chain")
|
68
|
+
record.run_callbacks(kind) {|result, record| result == false}
|
69
|
+
else
|
70
|
+
true
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class AutoShop < ActiveRecord::Base
|
2
|
+
state_machine :state, :initial => 'available' do
|
3
|
+
after_exit 'available', :increment_customers
|
4
|
+
after_exit 'busy', :decrement_customers
|
5
|
+
|
6
|
+
event :tow_vehicle do
|
7
|
+
transition :to => 'busy', :from => 'available'
|
8
|
+
end
|
9
|
+
|
10
|
+
event :fix_vehicle do
|
11
|
+
transition :to => 'available', :from => 'busy'
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Is the Auto Shop available for new customers?
|
16
|
+
def available?
|
17
|
+
state == 'available'
|
18
|
+
end
|
19
|
+
|
20
|
+
# Is the Auto Shop currently not taking new customers?
|
21
|
+
def busy?
|
22
|
+
state == 'busy'
|
23
|
+
end
|
24
|
+
|
25
|
+
# Increments the number of customers in service
|
26
|
+
def increment_customers
|
27
|
+
update_attribute(:num_customers, num_customers + 1)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Decrements the number of customers in service
|
31
|
+
def decrement_customers
|
32
|
+
update_attribute(:num_customers, num_customers - 1)
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class Car < Vehicle
|
2
|
+
state_machine :state do
|
3
|
+
event :reverse do
|
4
|
+
transition :to => 'backing_up', :from => %w(parked idling first_gear)
|
5
|
+
end
|
6
|
+
|
7
|
+
event :park do
|
8
|
+
transition :to => 'parked', :from => 'backing_up'
|
9
|
+
end
|
10
|
+
|
11
|
+
event :idle do
|
12
|
+
transition :to => 'idling', :from => 'backing_up'
|
13
|
+
end
|
14
|
+
|
15
|
+
event :shift_up do
|
16
|
+
transition :to => 'first_gear', :from => 'backing_up'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
class Vehicle < ActiveRecord::Base
|
2
|
+
belongs_to :auto_shop
|
3
|
+
belongs_to :highway
|
4
|
+
|
5
|
+
attr_accessor :force_idle
|
6
|
+
|
7
|
+
# Defines the state machine for the state of the vehicle
|
8
|
+
state_machine :state, :initial => Proc.new {|vehicle| vehicle.force_idle ? 'idling' : 'parked'} do
|
9
|
+
before_exit 'parked', :put_on_seatbelt
|
10
|
+
after_enter 'parked', Proc.new {|vehicle| vehicle.update_attribute(:seatbelt_on, false)}
|
11
|
+
before_enter 'stalled', :increase_insurance_premium
|
12
|
+
|
13
|
+
event :park do
|
14
|
+
transition :to => 'parked', :from => %w(idling first_gear)
|
15
|
+
end
|
16
|
+
|
17
|
+
event :ignite do
|
18
|
+
transition :to => 'stalled', :from => 'stalled'
|
19
|
+
transition :to => 'idling', :from => 'parked'
|
20
|
+
end
|
21
|
+
|
22
|
+
event :idle do
|
23
|
+
transition :to => 'idling', :from => 'first_gear'
|
24
|
+
end
|
25
|
+
|
26
|
+
event :shift_up do
|
27
|
+
transition :to => 'first_gear', :from => 'idling'
|
28
|
+
transition :to => 'second_gear', :from => 'first_gear'
|
29
|
+
transition :to => 'third_gear', :from => 'second_gear'
|
30
|
+
end
|
31
|
+
|
32
|
+
event :shift_down do
|
33
|
+
transition :to => 'second_gear', :from => 'third_gear'
|
34
|
+
transition :to => 'first_gear', :from => 'second_gear'
|
35
|
+
end
|
36
|
+
|
37
|
+
event :crash, :after => :tow! do
|
38
|
+
transition :to => 'stalled', :from => %w(first_gear second_gear third_gear), :if => Proc.new {|vehicle| vehicle.auto_shop.available?}
|
39
|
+
end
|
40
|
+
|
41
|
+
event :repair, :after => :fix! do
|
42
|
+
transition :to => 'parked', :from => 'stalled', :if => :auto_shop_busy?
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Tows the vehicle to the auto shop
|
47
|
+
def tow!
|
48
|
+
auto_shop.tow_vehicle!
|
49
|
+
end
|
50
|
+
|
51
|
+
# Fixes the vehicle; it will no longer be in the auto shop
|
52
|
+
def fix!
|
53
|
+
auto_shop.fix_vehicle!
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
# Safety first! Puts on our seatbelt
|
58
|
+
def put_on_seatbelt
|
59
|
+
self.seatbelt_on = true
|
60
|
+
end
|
61
|
+
|
62
|
+
# We crashed! Increase the insurance premium on the vehicle
|
63
|
+
def increase_insurance_premium
|
64
|
+
update_attribute(:insurance_premium, self.insurance_premium + 100)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Is the auto shop currently servicing another customer?
|
68
|
+
def auto_shop_busy?
|
69
|
+
auto_shop.busy?
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class CreateAutoShops < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :auto_shops do |t|
|
4
|
+
t.string :name, :null => false
|
5
|
+
t.integer :num_customers, :null => false
|
6
|
+
t.string :state, :null => false
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.down
|
11
|
+
drop_table :auto_shops
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class CreateVehicles < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :vehicles do |t|
|
4
|
+
t.references :highway, :null => false
|
5
|
+
t.references :auto_shop, :null => false
|
6
|
+
t.boolean :seatbelt_on, :null => false
|
7
|
+
t.integer :insurance_premium, :null => false
|
8
|
+
t.string :state, :null => false
|
9
|
+
t.string :type
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.down
|
14
|
+
drop_table :vehicles
|
15
|
+
end
|
16
|
+
end
|
data/test/factory.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
module Factory
|
2
|
+
# Build actions for the class
|
3
|
+
def self.build(klass, &block)
|
4
|
+
name = klass.to_s.underscore
|
5
|
+
define_method("#{name}_attributes", block)
|
6
|
+
|
7
|
+
module_eval <<-end_eval
|
8
|
+
def valid_#{name}_attributes(attributes = {})
|
9
|
+
#{name}_attributes(attributes)
|
10
|
+
attributes
|
11
|
+
end
|
12
|
+
|
13
|
+
def new_#{name}(attributes = {})
|
14
|
+
#{klass}.new(valid_#{name}_attributes(attributes))
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_#{name}(*args)
|
18
|
+
record = new_#{name}(*args)
|
19
|
+
record.save!
|
20
|
+
record.reload
|
21
|
+
record
|
22
|
+
end
|
23
|
+
end_eval
|
24
|
+
end
|
25
|
+
|
26
|
+
build AutoShop do |attributes|
|
27
|
+
attributes.reverse_merge!(
|
28
|
+
:name => "Joe's Auto Body",
|
29
|
+
:num_customers => 0
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
build Car do |attributes|
|
34
|
+
attributes[:highway] = create_highway unless attributes.include?(:highway)
|
35
|
+
attributes[:auto_shop] = create_auto_shop unless attributes.include?(:auto_shop)
|
36
|
+
attributes.reverse_merge!(
|
37
|
+
:seatbelt_on => false,
|
38
|
+
:insurance_premium => 50
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
build Highway do |attributes|
|
43
|
+
attributes.reverse_merge!(
|
44
|
+
:name => 'Route 66'
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
build Motorcycle do |attributes|
|
49
|
+
valid_car_attributes(attributes)
|
50
|
+
end
|
51
|
+
|
52
|
+
build Switch do |attributes|
|
53
|
+
attributes.reverse_merge!(
|
54
|
+
:state => 'off'
|
55
|
+
)
|
56
|
+
end
|
57
|
+
|
58
|
+
build Vehicle do |attributes|
|
59
|
+
valid_car_attributes(attributes)
|
60
|
+
end
|
61
|
+
end
|