stately 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/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ .bundle/
2
+ log/*.log
3
+ pkg/
4
+ rdoc/
5
+ .DS_Store
6
+ .yardoc/
7
+ doc/
8
+ test/dummy/db/*.sqlite3
9
+ test/dummy/log/*.log
10
+ test/dummy/tmp/
11
+ test/dummy/.sass-cache
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format progress
2
+ --color
data/.yardopts ADDED
@@ -0,0 +1,4 @@
1
+ lib/**/*.rb
2
+ -
3
+ README.md
4
+ MIT-LICENSE
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'http://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,28 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ stately (0.0.1)
5
+
6
+ GEM
7
+ remote: http://rubygems.org/
8
+ specs:
9
+ diff-lcs (1.1.3)
10
+ redcarpet (2.2.2)
11
+ rspec (2.11.0)
12
+ rspec-core (~> 2.11.0)
13
+ rspec-expectations (~> 2.11.0)
14
+ rspec-mocks (~> 2.11.0)
15
+ rspec-core (2.11.1)
16
+ rspec-expectations (2.11.3)
17
+ diff-lcs (~> 1.1.3)
18
+ rspec-mocks (2.11.3)
19
+ yard (0.8.3)
20
+
21
+ PLATFORMS
22
+ ruby
23
+
24
+ DEPENDENCIES
25
+ redcarpet (~> 2.2.2)
26
+ rspec (~> 2.0)
27
+ stately!
28
+ yard (~> 0.8.3)
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2012 Ryan Twomey
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # Stately
2
+
3
+ An elegant state machine for your ruby objects.
4
+
5
+ ![A stately fellow.](https://dl.dropbox.com/u/2754528/exquisite_cat.jpg "A stately fellow.")
6
+
7
+ ## Making a stately start
8
+
9
+ Stately is a state machine for ruby objects, with an elegant, easy-to-read DSL. Here's an example showing off what Stately can do:
10
+
11
+ Class Order do
12
+ stately start: :processing do
13
+ state :completed do
14
+ prevent_from :refunded
15
+
16
+ before_transition from: :processing, do: :calculate_total
17
+ after_transition do: :email_receipt
18
+
19
+ validate :validates_credit_card
20
+ end
21
+
22
+ state :invalid do
23
+ prevent_from :completed, :refunded
24
+ end
25
+
26
+ state :refunded do
27
+ allow_from :completed
28
+
29
+ after_transition do: :email_receipt
30
+ end
31
+ end
32
+
33
+ Stately tries hard not to surprise you. When you transition to a new state, you're responsible for taking whatever actions that means using `before_transition` and `after_transition`. Stately also has no dependencies on things like DataMapper or ActiveModel, so it will never surprise you with an implicit `save` after transitioning states.
34
+
35
+ ## Getting started
36
+
37
+ Either install locally:
38
+
39
+ gem install stately
40
+
41
+ or add it to your Gemfile:
42
+
43
+ gem stately
44
+
45
+ Be sure to run `bundle install` afterwards.
46
+
47
+ The first step is to add the following to your object:
48
+
49
+ stately start: :initial_state, attr: :my_state_attr do
50
+ # ...
51
+ end
52
+
53
+ This sets up Stately to look for an attribute named `my_state_attr`, and initially set it to `initial_state`. If you omit `attr: :my_state_attr`, Stately will automatically look for an attribute named `state`.
54
+
55
+ ## Defining a state
56
+
57
+ States make up the core of Stately and define two things: the name of the state (i.e. "completed"), and a verb as the name of the method to call to begin a transition into that state (i.e. "complete"). Stately has support for some common state/verb combinations, but you can always use your own:
58
+
59
+ Class Order
60
+ stately start: :processing do
61
+ state :my_state, action: transition_to_my_state
62
+ end
63
+ end
64
+
65
+ order = Order.new
66
+ order.transition_to_my_state
67
+
68
+ ## Transitions
69
+
70
+ A "transition" is the process of moving from one state to another. You can define legal transitions using `allow_from` and `prevent_from`:
71
+
72
+ state :completed do
73
+ allow_from :processing
74
+ prevent_from :refunded
75
+ end
76
+
77
+ In the above example, if you try to transition to `completed` (by calling `complete` on the object) from `refunded`, you'll see a `Stately::InvalidTransition` is raised. By default, all transitions are allowed.
78
+
79
+ ## Validations
80
+
81
+ While transitioning from one state to another, you can define validations to be run. If any validation returns `false`, the transition is halted.
82
+
83
+ state :completed do
84
+ validate :validates_amount
85
+ validate :validates_credit_card
86
+ end
87
+
88
+ Each validation is also called in order, so first `validates_amount` will be called, and if it doesn't return `false`, then `validates_credit_card` will be called and checked.
89
+
90
+ ## Callbacks
91
+
92
+ Callbacks can be defined to run either before or after a transition occurs. A `before_transition` is run after validations are checked, but before the `state_attr` has been written to with the new state. An `after_transition` is called after the `state_attr` has been written to.
93
+
94
+ If you're using Stately with a database, you'll almost always want an `after_transition` that calls `save` or the equivalent.
95
+
96
+ state :completed do
97
+ before_transition from: :processing, do: :before_completed
98
+ before_transition from: :invalid, do: :cleanup_invalid
99
+ after_transition do: :after_completed
100
+ end
101
+
102
+ A callback can include an optional `from` state name, which is only called when transitioning from the named state. Omitting it means the callback is always called.
103
+
104
+ Additionally, each callback is executed in the order in which it's defined.
105
+
106
+ ## Requirements
107
+
108
+ Stately requires Ruby 1.9+. If you'd like to contribute to Stately, you'll need Rspec 2.0+.
109
+
110
+ ## License
111
+
112
+ Stately is Copyright © 2012 Ryan Twomey. It is free software, and may be redistributed under the terms specified in the MIT-LICENSE file.
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require 'bundler'
4
+ require 'rspec/core/rake_task'
5
+
6
+ Bundler::GemHelper.install_tasks
7
+
8
+ begin
9
+ require 'rdoc/task'
10
+ rescue LoadError
11
+ require 'rdoc/rdoc'
12
+ require 'rake/rdoctask'
13
+ RDoc::Task = Rake::RDocTask
14
+ end
15
+
16
+ RDoc::Task.new(:rdoc) do |rdoc|
17
+ rdoc.rdoc_dir = 'rdoc'
18
+ rdoc.title = 'Stately'
19
+ rdoc.options << '--line-numbers'
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+ namespace :spec do
24
+ desc 'Run unit specs'
25
+ RSpec::Core::RakeTask.new('unit') do |t|
26
+ t.pattern = 'spec/unit/**/*_spec.rb'
27
+ end
28
+
29
+ desc 'Run functional specs'
30
+ RSpec::Core::RakeTask.new('functional') do |t|
31
+ t.pattern = 'spec/functional/**/*_spec.rb'
32
+ end
33
+ end
34
+
35
+ desc 'Run unit and functional specs'
36
+ task :spec => ['spec:unit', 'spec:functional']
37
+
38
+ task :default => :spec
@@ -0,0 +1,5 @@
1
+ # Includes Stately on Ruby's Object.
2
+
3
+ Object.class_eval do
4
+ include Stately
5
+ end
@@ -0,0 +1,26 @@
1
+ module Stately
2
+ # A Stately::Machine is a container for Stately::States.
3
+ class Machine
4
+ attr_reader :start, :state_attr, :states
5
+
6
+ # Sets up a new instance of Stately::Machine
7
+ def initialize(attr_name, start)
8
+ @state_attr = attr_name
9
+ @start = start
10
+ @states = [State.new(@start)]
11
+ end
12
+
13
+ # Define a new Stately::State and add it to this Stately::Machine.
14
+ #
15
+ # @param [String] name The name of the state. This is also stored in the instance object's
16
+ # state attribute.
17
+ # @param [Hash] opts Optionally, a method name can be defined as this state's action, if it
18
+ # can't be inferred from the name.
19
+ def state(name, opts={}, &block)
20
+ @states.delete_if { |s| s.name == name }
21
+
22
+ action = opts ? opts[:action] : nil
23
+ @states << State.new(name, action, &block)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,96 @@
1
+ module Stately
2
+ # A Stately::State object contains the configuration and other information about a defined
3
+ # state.
4
+ #
5
+ # It's made up of a name (which is saved to the parent instance's state attribute), the
6
+ # name of an action (which is a method called to transition into this state), and a DSL to
7
+ # define allowed transitions, callbacks, and validations.
8
+
9
+ class State
10
+ attr_reader :action, :name
11
+ attr_reader :allow_from_states, :prevent_from_states
12
+ attr_reader :after_transitions, :before_transitions, :validations
13
+
14
+ # Sets up and returns a new Stately::State object.
15
+ #
16
+ # @param [String] name The name of the state
17
+ # @param [String] action The method name that's called to transition to this state. Some method
18
+ # names can be inferred based on the state's name.
19
+ def initialize(name, action=nil, &block)
20
+ @action = (action || guess_action_for(name)).to_s
21
+ @name = name
22
+
23
+ @allow_from_states = []
24
+ @prevent_from_states = []
25
+
26
+ @before_transitions = []
27
+ @after_transitions = []
28
+ @validations = []
29
+
30
+ if block_given?
31
+ configuration = StateConfigurator.new(&block)
32
+
33
+ @allow_from_states = configuration.allow_from_states || []
34
+ @prevent_from_states = configuration.prevent_from_states || []
35
+
36
+ @after_transitions = configuration.after_transitions || []
37
+ @before_transitions = configuration.before_transitions || []
38
+ @validations = configuration.validations || []
39
+ end
40
+ end
41
+
42
+ # @return [String] The state name as a string
43
+ def to_s
44
+ @name.to_s
45
+ end
46
+
47
+ # @return [Symbol] The state name as a string
48
+ def to_sym
49
+ @name.to_sym
50
+ end
51
+
52
+ private
53
+
54
+ ACTIONS = { completed: :complete, converting: :convert, invalid: :invalidate,
55
+ preparing: :prepare, processing: :process, refunded: :refund, reticulating: :reticulate,
56
+ saving: :save, searching: :search, started: :start, stopped: :stop }
57
+
58
+ def guess_action_for(name)
59
+ ACTIONS[name.to_sym]
60
+ end
61
+
62
+ class StateConfigurator
63
+ attr_reader :after_transitions, :before_transitions, :validations
64
+ attr_reader :allow_from_states, :prevent_from_states
65
+
66
+ def initialize(&block)
67
+ instance_eval(&block)
68
+ end
69
+
70
+ def allow_from(*states)
71
+ @allow_from_states ||= []
72
+ @allow_from_states |= states.map(&:to_sym)
73
+ end
74
+
75
+ def before_transition(options={})
76
+ @before_transitions ||= []
77
+ @before_transitions << options
78
+ end
79
+
80
+ def after_transition(options={})
81
+ @after_transitions ||= []
82
+ @after_transitions << options
83
+ end
84
+
85
+ def prevent_from(*states)
86
+ @prevent_from_states ||= []
87
+ @prevent_from_states |= states.map(&:to_sym)
88
+ end
89
+
90
+ def validate(options={})
91
+ @validations ||= []
92
+ @validations << options
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,3 @@
1
+ module Stately
2
+ VERSION = '0.1.0'
3
+ end
data/lib/stately.rb ADDED
@@ -0,0 +1,204 @@
1
+ require 'stately/machine'
2
+ require 'stately/state'
3
+
4
+ module Stately
5
+ # An InvalidTransition is an error that is raised when attempting to transition from a state
6
+ # that's not allowable, based on the Stately::State DSL definitions allow_from and prevent_from.
7
+ class InvalidTransition < StandardError
8
+ end
9
+
10
+ # Define a new Stately state machine.
11
+ #
12
+ # As an example, let's say you have an Order object and you'd like an elegant state machine for
13
+ # it. Here's one way you might set it up:
14
+ #
15
+ # Class Order do
16
+ # stately start: :processing do
17
+ # state :completed do
18
+ # prevent_from :refunded
19
+ #
20
+ # before_transition from: :processing, do: :calculate_total
21
+ # after_transition do: :email_receipt
22
+ #
23
+ # validate :validates_credit_card
24
+ # end
25
+ #
26
+ # state :invalid do
27
+ # prevent_from :completed, :refunded
28
+ # end
29
+ #
30
+ # state :refunded do
31
+ # allow_from :completed
32
+ #
33
+ # after_transition do: :email_receipt
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # This example is doing quite a few things, paraphrased as:
39
+ #
40
+ # * It sets up a new state machine using the default state attribute on Order to store the
41
+ # current state. It also indicates the initial state should be :processing.
42
+ # * It defines three states: :completed, :refunded, and :invalid
43
+ # * Order can transition to the completed state from all but the refunded state. Similar
44
+ # definitions are setup for the other two states.
45
+ # * Callbacks are setup using before_transition and after_transition
46
+ # * Validations are added. If a validation fails, it prevents the transition.
47
+ #
48
+ # Stately tries hard not to surprise you. In a typical Stately implementation, you'll always have
49
+ # an after_transition, primarily to call save (or whatever the equivalent is to store the
50
+ # instance's current state).
51
+ def stately(*opts, &block)
52
+ options = opts.last.is_a?(Hash) ? opts.last : {}
53
+ options[:attr] ||= :state
54
+
55
+ self.stately_machine = Stately::Machine.new(options[:attr], options[:start])
56
+ self.stately_machine.instance_eval(&block) if block_given?
57
+
58
+ include Stately::InstanceMethods
59
+ end
60
+
61
+ # Get the current Stately::Machine object
62
+ def self.stately_machine
63
+ @@stately_machine
64
+ end
65
+
66
+ # Get the current Stately::Machine object
67
+ def stately_machine
68
+ @@stately_machine
69
+ end
70
+
71
+ # Set the current Stately::Machine object
72
+ def self.stately_machine=(obj)
73
+ @@stately_machine = obj
74
+ end
75
+
76
+ # Set the current Stately::Machine object
77
+ def stately_machine=(obj)
78
+ @@stately_machine = obj
79
+ end
80
+
81
+ module InstanceMethods
82
+ # Sets up an object with Stately. The DSL is parsed and the Stately::Machine is initialized.
83
+ #
84
+ # When an object is first initialized, Stately automatically sets the state attribute to the
85
+ # start state.
86
+ #
87
+ # Additionally, a method is defined for each of the state's actions. These methods are used to
88
+ # transition between states. If you have a state named 'completed', Stately will infer the
89
+ # action to be 'complete' and define a method named 'complete'. You can then call 'complete' on
90
+ # the object to transition into the completed state.
91
+
92
+ def InstanceMethods.included(klass)
93
+ klass.class_eval do
94
+ alias_method :init_instance, :initialize
95
+ def initialize(*args)
96
+ init_instance(*args)
97
+ initialize_stately
98
+ end
99
+
100
+ stately_machine.states.each do |state|
101
+ define_method(state.action) do
102
+ transition_to(state)
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # @return [Array<String>] a list of state names.
109
+ def states
110
+ stately_machine.states.map(&:name)
111
+ end
112
+
113
+ private
114
+
115
+ def allowed_state_transition?(to_state)
116
+ if current_state == to_state.to_s
117
+ raise InvalidTransition,
118
+ "Prevented transition from #{current_state} to #{state.to_s}."
119
+ end
120
+
121
+ allowed_from_states(to_state).include?(current_state.to_sym)
122
+ end
123
+
124
+ def allowed_from_states(state)
125
+ if state.allow_from_states.empty?
126
+ stately_machine.states.map(&:to_sym) - state.prevent_from_states
127
+ else
128
+ state.allow_from_states
129
+ end
130
+ end
131
+
132
+ def current_state
133
+ (self.send(stately_machine.state_attr) || stately_machine.start).to_s
134
+ end
135
+
136
+ def eligible_callback?(callback)
137
+ if (callback.has_key?(:from) && callback[:from].to_s == current_state) ||
138
+ (!callback.has_key?(:from))
139
+ true
140
+ else
141
+ false
142
+ end
143
+ end
144
+
145
+ def initialize_stately
146
+ set_initial_state
147
+ end
148
+
149
+ def run_before_transition_callbacks(state)
150
+ state.before_transitions.each do |callback|
151
+ if eligible_callback?(callback)
152
+ self.send callback[:do]
153
+ end
154
+ end
155
+ end
156
+
157
+ def run_after_transition_callbacks(state)
158
+ state.after_transitions.each do |callback|
159
+ self.send callback[:do]
160
+ end
161
+ end
162
+
163
+ def state_named(state_name)
164
+ stately_machine.states.find { |s| s.to_s == state_name.to_s }
165
+ end
166
+
167
+ def transition_to(state_name)
168
+ state = state_named(state_name)
169
+
170
+ if valid_transition_to?(state)
171
+ run_before_transition_callbacks(state)
172
+ write_attribute(stately_machine.state_attr, state.to_s)
173
+ run_after_transition_callbacks(state)
174
+ end
175
+ end
176
+
177
+ def set_initial_state
178
+ write_attribute(stately_machine.state_attr, stately_machine.start.to_s)
179
+ end
180
+
181
+ def write_attribute(attr, val)
182
+ send("#{attr}=", val)
183
+ end
184
+
185
+ def valid_transition_to?(state)
186
+ if allowed_state_transition?(state)
187
+ if state.validations.nil? || state.validations.empty?
188
+ true
189
+ else
190
+ results = state.validations.collect do |validation|
191
+ self.send validation
192
+ end
193
+
194
+ results.detect { |r| r == false }.nil?
195
+ end
196
+ else
197
+ raise InvalidTransition,
198
+ "Prevented transition from #{current_state} to #{state.to_s}."
199
+ end
200
+ end
201
+ end
202
+ end
203
+
204
+ require 'stately/core_ext'
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :stately do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,305 @@
1
+ require 'ostruct'
2
+ require 'spec_helper'
3
+
4
+ describe Stately do
5
+ before do
6
+ @order_class = Class.new(OpenStruct) do
7
+ stately start: :processing do
8
+ state :completed do
9
+ prevent_from :refunded
10
+
11
+ before_transition from: :processing, do: :before_completed
12
+ before_transition from: :invalid, do: :cleanup_invalid
13
+ after_transition do: :after_completed
14
+
15
+ validate :validates_amount
16
+ validate :validates_credit_card
17
+ end
18
+
19
+ state :invalid do
20
+ prevent_from :completed, :refunded
21
+ end
22
+
23
+ state :processing do
24
+ prevent_from :completed, :invalid, :refunded
25
+ end
26
+
27
+ state :refunded do
28
+ allow_from :completed
29
+
30
+ before_transition from: :completed, do: :before_refunded
31
+ after_transition from: :completed, do: :after_refunded
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def before_completed
38
+ self.serial_number = Time.now.usec
39
+ end
40
+
41
+ def after_completed
42
+ end
43
+
44
+ def before_refunded
45
+ self.refunded_reason = 'Overcharged'
46
+ end
47
+
48
+ def after_refunded
49
+ end
50
+
51
+ def cleanup_invalid
52
+ self.serial_number = nil
53
+ end
54
+
55
+ def validates_amount
56
+ amount > 0.0 && amount < 100.0
57
+ end
58
+
59
+ def validates_credit_card
60
+ self.cc_number == 123
61
+ end
62
+ end
63
+ end
64
+
65
+ def self.should_call_callbacks_on_complete(order)
66
+ @order = order
67
+
68
+ describe 'callbacks' do
69
+ it 'calls callbacks in order' do
70
+ @order.should_receive(:before_completed).ordered
71
+ @order.should_receive(:after_completed).ordered
72
+ @order.should_not_receive :cleanup_invalid
73
+
74
+ @order.complete
75
+ end
76
+
77
+ it 'sets serial_number' do
78
+ @order.serial_number.should be_nil
79
+ @order.complete
80
+ @order.serial_number.should_not be_nil
81
+ end
82
+ end
83
+ end
84
+
85
+ def self.should_call_validations_on_complete(order)
86
+ @order = order
87
+
88
+ describe 'validations' do
89
+ it 'calls validations in order' do
90
+ @order.should_receive(:validates_amount).ordered
91
+ @order.should_receive(:validates_credit_card).ordered
92
+
93
+ @order.complete
94
+ end
95
+
96
+ describe 'return values' do
97
+ before do
98
+ @order.stub :validates_amount => false
99
+ end
100
+
101
+ it 'should halt on false' do
102
+ @order.should_receive :validates_amount
103
+ @order.should_receive :validates_credit_card
104
+ @order.should_not_receive :before_completed
105
+ @order.should_not_receive :after_completed
106
+ @order.should_not_receive :cleanup_invalid
107
+
108
+ current_state = @order.state
109
+ @order.complete
110
+ @order.state.should == current_state
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ def self.should_prevent_transition(from, to, action)
117
+ before do
118
+ @order = @order_class.new(amount: 99, cc_number: 123)
119
+ @order.state = from
120
+ end
121
+
122
+ it 'should be prevented' do
123
+ lambda { @order.send(action) }.should raise_error(Stately::InvalidTransition,
124
+ "Prevented transition from #{from} to #{to}.")
125
+ end
126
+ end
127
+
128
+ def self.should_set_state(new_state, order, action)
129
+ @order = order
130
+
131
+ describe 'on success' do
132
+ before do
133
+ @order.send action
134
+ end
135
+
136
+ it 'sets state' do
137
+ @order.state.should == new_state
138
+ end
139
+ end
140
+ end
141
+
142
+ describe 'initial state' do
143
+ before do
144
+ @order = @order_class.new(amount: 99, cc_number: 123)
145
+ end
146
+
147
+ it 'creates actions for each state' do
148
+ @order_class.method_defined?(:complete).should be_true
149
+ @order_class.method_defined?(:process).should be_true
150
+ @order_class.method_defined?(:refund).should be_true
151
+ end
152
+
153
+ it 'finds all states' do
154
+ @order.states.should == [:completed, :invalid, :processing, :refunded]
155
+ end
156
+
157
+ it 'sets initial state to processing' do
158
+ @order.state.should == 'processing'
159
+ end
160
+ end
161
+
162
+ describe '#process' do
163
+ describe 'from processing' do
164
+ should_prevent_transition('processing', 'processing', :process)
165
+ end
166
+
167
+ describe 'from completed' do
168
+ should_prevent_transition('completed', 'processing', :process)
169
+ end
170
+
171
+ describe 'from invalid' do
172
+ should_prevent_transition('invalid', 'processing', :process)
173
+ end
174
+
175
+ describe 'from refunded' do
176
+ should_prevent_transition('refunded', 'processing', :process)
177
+ end
178
+ end
179
+
180
+ describe '#complete' do
181
+ before do
182
+ @order = @order_class.new(amount: 99, cc_number: 123)
183
+ end
184
+
185
+ describe 'from processing' do
186
+ should_call_validations_on_complete(@order)
187
+
188
+ describe 'callbacks' do
189
+ it 'calls callbacks in order' do
190
+ @order.should_receive(:before_completed).ordered
191
+ @order.should_receive(:after_completed).ordered
192
+ @order.should_not_receive :cleanup_invalid
193
+
194
+ @order.complete
195
+ end
196
+
197
+ it 'sets serial_number' do
198
+ @order.serial_number.should be_nil
199
+ @order.complete
200
+ @order.serial_number.should_not be_nil
201
+ end
202
+ end
203
+
204
+ should_set_state('completed', @order, :complete)
205
+ end
206
+
207
+ describe 'from completed' do
208
+ should_prevent_transition('completed', 'completed', :complete)
209
+ end
210
+
211
+ describe 'from invalid' do
212
+ before do
213
+ @order.serial_number = Time.now.usec
214
+ @order.state = 'invalid'
215
+ end
216
+
217
+ should_call_validations_on_complete(@order)
218
+
219
+ describe 'callbacks' do
220
+ it 'calls callbacks in order' do
221
+ @order.should_receive(:cleanup_invalid).ordered
222
+ @order.should_receive(:after_completed).ordered
223
+ @order.should_not_receive :before_completed
224
+
225
+ @order.complete
226
+ end
227
+
228
+ it 'sets serial_number to nil' do
229
+ @order.serial_number.should_not be_nil
230
+ @order.complete
231
+ @order.serial_number.should be_nil
232
+ end
233
+ end
234
+
235
+ should_set_state('completed', @order, :complete)
236
+ end
237
+
238
+ describe 'from refunded' do
239
+ should_prevent_transition('refunded', 'completed', :complete)
240
+ end
241
+ end
242
+
243
+ describe '#invalidate' do
244
+ describe 'from processing' do
245
+ before do
246
+ @order = @order_class.new(amount: 99, cc_number: 123)
247
+ @order.invalidate
248
+ end
249
+
250
+ it 'sets state' do
251
+ @order.state.should == 'invalid'
252
+ end
253
+ end
254
+
255
+ describe 'from completed' do
256
+ should_prevent_transition('completed', 'invalid', :invalidate)
257
+ end
258
+
259
+ describe 'from invalid' do
260
+ should_prevent_transition('invalid', 'invalid', :invalidate)
261
+ end
262
+
263
+ describe 'from refunded' do
264
+ should_prevent_transition('refunded', 'invalid', :invalidate)
265
+ end
266
+ end
267
+
268
+ describe '#refund' do
269
+ describe 'from processing' do
270
+ should_prevent_transition('processing', 'refunded', :refund)
271
+ end
272
+
273
+ describe 'from completed' do
274
+ before do
275
+ @order = @order_class.new(amount: 99, cc_number: 123)
276
+ @order.state = 'completed'
277
+ end
278
+
279
+ describe 'callbacks' do
280
+ it 'calls callbacks in order' do
281
+ @order.should_receive(:before_refunded).ordered
282
+ @order.should_receive(:after_refunded).ordered
283
+
284
+ @order.refund
285
+ end
286
+
287
+ it 'sets refunded_reason' do
288
+ @order.refunded_reason.should be_nil
289
+ @order.refund
290
+ @order.refunded_reason.should_not be_nil
291
+ end
292
+ end
293
+
294
+ should_set_state('refunded', @order, :refund)
295
+ end
296
+
297
+ describe 'from invalid' do
298
+ should_prevent_transition('invalid', 'refunded', :refund)
299
+ end
300
+
301
+ describe 'from refunded' do
302
+ should_prevent_transition('refunded', 'refunded', :refund)
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,5 @@
1
+ $LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib')
2
+ $LOAD_PATH << File.join(File.dirname(__FILE__))
3
+
4
+ require 'rspec'
5
+ require 'stately'
@@ -0,0 +1,119 @@
1
+ require 'spec_helper'
2
+
3
+ describe Stately::Machine do
4
+ before do
5
+ @machine = Stately::Machine.new(:state, :processing)
6
+ end
7
+
8
+ describe 'initialize' do
9
+ it 'sets initial vars' do
10
+ @machine.start.should == :processing
11
+ @machine.state_attr.should == :state
12
+ @machine.states.map(&:to_s).should == ['processing']
13
+ end
14
+
15
+ it 'guesses the initial action' do
16
+ @machine.states.first.action.should == 'process'
17
+ end
18
+ end
19
+
20
+ describe '#state' do
21
+ describe 'with name only' do
22
+ describe 'of a new state' do
23
+ before do
24
+ @machine.state(:completed)
25
+ end
26
+
27
+ it 'adds a new state' do
28
+ @machine.states.map(&:to_s).should == ['processing', 'completed']
29
+ end
30
+ end
31
+
32
+ describe 'of a previously defined state' do
33
+ before do
34
+ @machine.state(:processing)
35
+ end
36
+
37
+ it "doesn't add a new state" do
38
+ @machine.states.map(&:to_s).should == ['processing']
39
+ end
40
+ end
41
+ end
42
+
43
+ describe 'with name and action' do
44
+ describe 'of a new state' do
45
+ before do
46
+ @machine.state(:new_state, action: :transition_to_new_state)
47
+ end
48
+
49
+ it 'adds a new state' do
50
+ @machine.states.map(&:to_s).should == ['processing', 'new_state']
51
+ end
52
+
53
+ it 'adds the correct action to the new state' do
54
+ @machine.states.last.action.should == 'transition_to_new_state'
55
+ end
56
+ end
57
+
58
+ describe 'of a previously defined state' do
59
+ before do
60
+ @machine.state(:processing, action: :transition_to_processing)
61
+ end
62
+
63
+ it "doesn't add a new state" do
64
+ @machine.states.map(&:to_s).should == ['processing']
65
+ end
66
+
67
+ it 'adds the correct action to the existing state' do
68
+ @machine.states.first.action.should == 'transition_to_processing'
69
+ end
70
+ end
71
+ end
72
+
73
+ describe 'with name, action, and block' do
74
+ describe 'of a new state' do
75
+ before do
76
+ @machine.state(:new_state, action: :transition_to_new_state) do
77
+ allow_from :completed
78
+ end
79
+
80
+ @new_state = @machine.states.last
81
+ end
82
+
83
+ it 'adds a new state' do
84
+ @machine.states.map(&:to_s).should == ['processing', 'new_state']
85
+ end
86
+
87
+ it 'adds the correct action to the new state' do
88
+ @new_state.action.should == 'transition_to_new_state'
89
+ end
90
+
91
+ it 'includes the allow_from param' do
92
+ @new_state.allow_from_states.should == [:completed]
93
+ end
94
+ end
95
+
96
+ describe 'of a previously defined state' do
97
+ before do
98
+ @machine.state(:processing, action: :transition_to_processing) do
99
+ allow_from :completed
100
+ end
101
+
102
+ @new_state = @machine.states.last
103
+ end
104
+
105
+ it "doesn't add a new state" do
106
+ @machine.states.map(&:to_s).should == ['processing']
107
+ end
108
+
109
+ it 'adds the correct action to the new state' do
110
+ @new_state.action.should == 'transition_to_processing'
111
+ end
112
+
113
+ it 'includes the allow_from param' do
114
+ @new_state.allow_from_states.should == [:completed]
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,101 @@
1
+ require 'spec_helper'
2
+
3
+ describe Stately::State do
4
+ describe 'initialize' do
5
+ describe 'with a block given' do
6
+ describe 'new' do
7
+ before do
8
+ @state = Stately::State.new(:invalid, nil) do
9
+ allow_from :completed
10
+ prevent_from :completed, :refunded
11
+
12
+ before_transition do: :prepare
13
+ before_transition from: :processing, do: :before_completed
14
+ after_transition do: :cleanup
15
+ after_transition from: :processing, do: :after_processing
16
+
17
+ validate :validates_amount
18
+ validate :validates_credit_card
19
+ end
20
+ end
21
+
22
+ it 'should set initial values' do
23
+ @state.name.should == :invalid
24
+
25
+ @state.allow_from_states.should == [:completed]
26
+ @state.prevent_from_states.should == [:completed, :refunded]
27
+
28
+ @state.before_transitions.should == [{do: :prepare}, {from: :processing,
29
+ do: :before_completed}]
30
+ @state.after_transitions.should == [{do: :cleanup}, {from: :processing,
31
+ do: :after_processing}]
32
+ @state.validations.should == [:validates_amount, :validates_credit_card]
33
+ end
34
+ end
35
+ end
36
+
37
+ describe 'without a block given' do
38
+ describe 'new' do
39
+ before do
40
+ @state = Stately::State.new(:test_state)
41
+ end
42
+
43
+ it 'should set initial values' do
44
+ @state.name.should == :test_state
45
+
46
+ @state.allow_from_states.should == []
47
+ @state.prevent_from_states.should == []
48
+
49
+ @state.before_transitions.should == []
50
+ @state.after_transitions.should == []
51
+ @state.validations.should == []
52
+ end
53
+ end
54
+
55
+ describe 'with a given action' do
56
+ before do
57
+ @state = Stately::State.new(:test_state, :test_action)
58
+ end
59
+
60
+ it 'should set the given action name' do
61
+ @state.action.should == 'test_action'
62
+ end
63
+ end
64
+
65
+ describe 'without a given action' do
66
+ before do
67
+ @actions = { completed: :complete, converting: :convert, invalid: :invalidate,
68
+ preparing: :prepare, processing: :process, refunded: :refund, reticulating: :reticulate,
69
+ saving: :save, searching: :search, started: :start, stopped: :stop }
70
+ end
71
+
72
+ it 'should set the correct action verb' do
73
+ @actions.map do |state_name, action_name|
74
+ state = Stately::State.new(state_name)
75
+ state.action.should == action_name.to_s
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ describe '#to_s' do
83
+ before do
84
+ @state = Stately::State.new(:test_state)
85
+ end
86
+
87
+ it 'should return a string' do
88
+ @state.to_s.should == 'test_state'
89
+ end
90
+ end
91
+
92
+ describe '#to_sym' do
93
+ before do
94
+ @state = Stately::State.new('test_state')
95
+ end
96
+
97
+ it 'should return a symbol' do
98
+ @state.to_sym.should == :test_state
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,58 @@
1
+ require 'ostruct'
2
+ require 'spec_helper'
3
+
4
+ describe Stately::InstanceMethods do
5
+ before do
6
+ @test_class = Class.new(Object) do
7
+ attr_accessor :state
8
+
9
+ stately start: :processing do
10
+ state :completed
11
+ end
12
+ end
13
+
14
+ @object = @test_class.new
15
+ end
16
+
17
+ describe 'initialize' do
18
+ it 'creates a new Stately::Machine' do
19
+ @object.stately_machine.class.should == Stately::Machine
20
+ @object.stately_machine.should == @test_class.stately_machine
21
+ end
22
+
23
+ it 'sets initial state' do
24
+ @object.state.should == 'processing'
25
+ end
26
+ end
27
+
28
+ describe '#states' do
29
+ it 'returns known state names in order' do
30
+ @object.states.should == [:processing, :completed]
31
+ end
32
+ end
33
+
34
+ describe 'actions' do
35
+ it 'defines action methods' do
36
+ @test_class.method_defined?(:complete).should be_true
37
+ @test_class.method_defined?(:process).should be_true
38
+ end
39
+ end
40
+
41
+ describe 'stately_machine' do
42
+ it 'defines a class-level accessor called stately_machine' do
43
+ @test_class.respond_to?(:stately_machine).should be_true
44
+ end
45
+
46
+ it 'defines an instance-level accessor called stately_machine' do
47
+ @test_class.method_defined?(:stately_machine).should be_true
48
+ end
49
+
50
+ it 'defines a class-level setter called stately_machine=' do
51
+ @test_class.respond_to?(:stately_machine=).should be_true
52
+ end
53
+
54
+ it 'defines an instance-level setter called stately_machine=' do
55
+ @test_class.method_defined?(:stately_machine=).should be_true
56
+ end
57
+ end
58
+ end
data/stately.gemspec ADDED
@@ -0,0 +1,22 @@
1
+ $LOAD_PATH << File.expand_path('../lib', __FILE__)
2
+ require 'stately/version'
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = 'stately'
6
+ s.version = Stately::VERSION
7
+ s.authors = ['Ryan Twomey']
8
+ s.email = ['rtwomey@gmail.com']
9
+ s.homepage = 'http://github.com/rtwomey/stately'
10
+ s.summary = 'A simple, elegant state machine for Ruby'
11
+ s.description = 'Add an elegant state machine to your ruby objects with a simple DSL'
12
+
13
+ s.files = `git ls-files`.split("\n")
14
+ s.test_files = `git ls-files -- {spec}/*`.split("\n")
15
+
16
+ s.add_development_dependency 'redcarpet', '~> 2.2.2'
17
+ s.add_development_dependency 'rspec', '~> 2.0'
18
+ s.add_development_dependency 'yard', '~> 0.8.3'
19
+
20
+ s.required_ruby_version = Gem::Requirement.new('>= 1.9.2')
21
+ s.require_paths = ['lib']
22
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stately
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Ryan Twomey
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-11-03 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: redcarpet
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 2.2.2
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 2.2.2
30
+ - !ruby/object:Gem::Dependency
31
+ name: rspec
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: '2.0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: '2.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: yard
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ~>
52
+ - !ruby/object:Gem::Version
53
+ version: 0.8.3
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ version: 0.8.3
62
+ description: Add an elegant state machine to your ruby objects with a simple DSL
63
+ email:
64
+ - rtwomey@gmail.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - .gitignore
70
+ - .rspec
71
+ - .yardopts
72
+ - Gemfile
73
+ - Gemfile.lock
74
+ - MIT-LICENSE
75
+ - README.md
76
+ - Rakefile
77
+ - lib/stately.rb
78
+ - lib/stately/core_ext.rb
79
+ - lib/stately/machine.rb
80
+ - lib/stately/state.rb
81
+ - lib/stately/version.rb
82
+ - lib/tasks/stately_tasks.rake
83
+ - spec/functional/stately_spec.rb
84
+ - spec/spec_helper.rb
85
+ - spec/unit/stately/machine_spec.rb
86
+ - spec/unit/stately/state_spec.rb
87
+ - spec/unit/stately_spec.rb
88
+ - stately.gemspec
89
+ homepage: http://github.com/rtwomey/stately
90
+ licenses: []
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ! '>='
99
+ - !ruby/object:Gem::Version
100
+ version: 1.9.2
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ none: false
103
+ requirements:
104
+ - - ! '>='
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubyforge_project:
109
+ rubygems_version: 1.8.24
110
+ signing_key:
111
+ specification_version: 3
112
+ summary: A simple, elegant state machine for Ruby
113
+ test_files: []
114
+ has_rdoc: