state_machine 0.1.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/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
|