steady_state 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b6ac44424729680f31b79e25aa8b110323aee6ec97824f19ac03f54921bc2213
4
+ data.tar.gz: 77f39b19b580b0930864cf8a867a9e41ae4fc4900356a65123e83359729110f6
5
+ SHA512:
6
+ metadata.gz: 35ac9ceff1d5997e97b3a40840629b5665d26175e491e8ae28df4dd3fa99a64b4aa419122e60a3fc8372e9a6ccc0afa8c07136ea067eca9662d47d65f4099a58
7
+ data.tar.gz: e5394a77afb844a74d72f020f22fb343ec1d2d3bea2cdc98a1a0d2db2c6e3a28d0b85cd930b47a4221946830d40f0c52acc9f217d9262337f7c3b4f692678fc0
@@ -0,0 +1,259 @@
1
+ # SteadyState
2
+
3
+ > A minimalist approach to managing object state, perhaps best described as an "enum with guard rails." Designed to work with `ActiveRecord` and `ActiveModel` classes, or anywhere where Rails validations are used.
4
+
5
+ ## Overview
6
+
7
+ SteadyState takes idea of a [Finite State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) and cuts out everything but the most basic declaration of states and transitions (i.e. a directed graph). It then uses ActiveModel validations to enforce these transition rules, and plays nicely with other `validates`/`validate` declarations on your model.
8
+
9
+ All of the features one might expect of a Finite State Machine—take, for example, named events, conditional rules, transition hooks, and event callbacks—can then be implemented using existing methods like `valid?`, `errors`, `after_save`, and so on. This approach is effective in contexts that already rely on these methods for control flow (e.g. a Rails controller).
10
+
11
+ Both `ActiveRecord` and `ActiveModel` classes are supported, as well as any class adhering to the `ActiveModel::Validations` APIs.
12
+
13
+ ## Installation
14
+
15
+ Add this to your Gemfile:
16
+
17
+ ```
18
+ gem 'steady_state'
19
+ ```
20
+
21
+ ## Getting Started
22
+
23
+ To enable stateful behavior on an attribute or column, call `steady_state` with the name of the attribute, and define the states as strings, like so:
24
+
25
+ ```ruby
26
+ class Material
27
+ include ActiveModel::Model
28
+ include SteadyState
29
+
30
+ attr_accessor :state
31
+
32
+ steady_state :state do
33
+ state 'solid', default: true
34
+ state 'liquid', from: 'solid'
35
+ state 'gas', from: 'liquid'
36
+ state 'plasma', from: 'gas'
37
+ end
38
+ end
39
+ ```
40
+
41
+ The `:from` option specifies the state transition rules, i.e. which state(s) a given state is allowed to transition from. It may accept either a single state, or a list of states:
42
+
43
+ ```ruby
44
+ state 'cancelled', from: %w(step-1 step-2)
45
+ ```
46
+
47
+ The `:default` option defines the state that your object will start in if no other state is provided:
48
+
49
+ ```ruby
50
+ material = Material.new
51
+ material.state # => 'solid'
52
+ ```
53
+
54
+ You may always instantiate a new object in any state, regardless of the default:
55
+
56
+ ```ruby
57
+ material = Material.new(state: 'liquid')
58
+ material.state # => 'liquid'
59
+ ```
60
+
61
+ A class may have any number of these `steady_state` declarations, one per stateful attribute.
62
+
63
+ ### Moving Between States
64
+
65
+ After your object has been instantiated (or loaded from a database via your ORM), the transitional validations and rules begin to take effect. To change the state, simply use the attribute's setter (e.g. `state=`), and then call `valid?` to see if the change will be accepted:
66
+
67
+ ```ruby
68
+ material.state.solid? # => true
69
+ material.state = 'liquid'
70
+ material.state # => 'liquid'
71
+ material.valid? # => true
72
+ ```
73
+
74
+ If the change is not valid, a validation error will be added to the object:
75
+
76
+ ```ruby
77
+ material.state.liquid? # => true
78
+ material.state = 'solid'
79
+ material.state # => 'solid'
80
+ material.valid? # => false
81
+ material.errors[:state] # => ['is invalid']
82
+ ```
83
+
84
+ #### A Deliberate Design Choice
85
+
86
+ Notice that even when the rules are violated, the state attribute does not revert to the previous state, nor is an exception raised inside of the setter. This is a deliberate design decision.
87
+
88
+ Compare this behavior to, say, a numericality validation:
89
+
90
+ ```ruby
91
+ validates :amount, numericality: { greater_than: 0 }
92
+
93
+ model = MyModel.new(amount: -100)
94
+ model.amount # => -100
95
+ model.valid? # false
96
+ model.errors[:amount] # => ['must be greater than 0']
97
+ ```
98
+
99
+ In keeping with the general pattern of `ActiveModel::Validations`, we rely on an object's _current state in memory_ to determine whether or not it is valid. For both the `state` and `amount` fields, the attribute is allowed to hold an invalid value, resulting in a validation error on the object.
100
+
101
+ #### Custom Validations
102
+
103
+ In addition to the built-in transitional validations, you can define your own validations that take effect only when the object enters a specific state:
104
+
105
+ ```ruby
106
+ validates :container, absence: true, if: :solid?
107
+ validates :container, presence: true, if: :liquid?
108
+ ```
109
+
110
+ With such a validation in place, a state change will not be valid unless the related validation rules are resolved at the same time:
111
+
112
+ ```ruby
113
+ object.update!(state: 'liquid') # !! ActiveRecord::RecordInvalid
114
+ object.update!(state: 'liquid', container: Cup.new) # 🎉
115
+ ```
116
+
117
+ With these tools, you can define rich sets of state-aware rules about your object, and then rely simply on built-in methods like `valid?` and `errors` to determine if an operation violates these rules.
118
+
119
+ ### Bringing it All Together
120
+
121
+ Commonly, state transition events are expected to have names, like "melt" and "evaporate," and other such _action verbs_. SteadyState has no such expectation, and will not define any named events for you.
122
+
123
+ If you need them, we encourage you to define these transitions using plain ol' Ruby methods, like so:
124
+
125
+ ```ruby
126
+ def melt
127
+ self.state = 'liquid'
128
+ valid? # will return `false` if state transition is invalid
129
+ end
130
+
131
+ def melt!
132
+ self.state = 'liquid'
133
+ validate! # will raise an exception if state transition is invalid
134
+ end
135
+ ```
136
+
137
+ If you are using ActiveRecord, you can rely on built-in persistence methods to construct your state transition operations:
138
+
139
+ ```ruby
140
+ def melt
141
+ update(state: 'liquid')
142
+ end
143
+
144
+ def melt!
145
+ update!(state: 'liquid')
146
+ end
147
+ ```
148
+
149
+ For complex persistence operations, we encourage the use of existing persistence helpers like `transaction` and `with_lock`, to provide atomicity and avoid race conditions:
150
+
151
+ ```ruby
152
+ def melt
153
+ with_lock do
154
+ if update(state: 'liquid', melted_at: Time.zone.now)
155
+ owner.update!(melt_count: owner.lock!.melt_count + 1)
156
+ Delayed::Job.enqueue MeltNotificationJob.new(self)
157
+ true
158
+ else
159
+ false
160
+ end
161
+ end
162
+ end
163
+ ```
164
+
165
+ Here is an example Rails controller making use of this new `melt` method:
166
+
167
+
168
+ ```ruby
169
+ class MaterialsController < ApplicationController
170
+ def melt
171
+ @material = Material.find(params[:id])
172
+ if @material.melt
173
+ redirect_to material_path(@material)
174
+ else
175
+ render :edit
176
+ end
177
+ end
178
+ end
179
+ ```
180
+
181
+ With the ability to define your states, apply transitional validations, and persist state changes, you should have everything you need to start using SteadyState inside of your application.
182
+
183
+ ## Addional Features & Configuration
184
+
185
+ ### Predicates
186
+
187
+ Predicate methods (or "Huh methods") are automatically defined for each state:
188
+
189
+ ```ruby
190
+ material = Material.new
191
+ material.solid? # => true
192
+ material.liquid? # => false
193
+ ```
194
+
195
+ You can disable these if, for instance, they conflict with other methods:
196
+
197
+ ```ruby
198
+ steady_state :status, predicates: false do
199
+ # ...
200
+ end
201
+ ```
202
+
203
+ Either way, predicate methods are always available on the value itself:
204
+
205
+ ```ruby
206
+ material.status.solid? # => true
207
+ material.status.liquid? # => false
208
+ ```
209
+
210
+ ### Scopes
211
+
212
+ On ActiveRecord objects, scopes are automatically defined for each state:
213
+
214
+ ```ruby
215
+ Material.solid # => query for 'solid' records
216
+ Material.liquid # => query for 'liquid' records
217
+ ```
218
+
219
+ These can be disabled as well:
220
+
221
+ ```ruby
222
+ steady_state :step, scopes: false do
223
+ # ...
224
+ end
225
+ ```
226
+
227
+ ### Next and Previous States
228
+
229
+ The `may_become?` method can be used to see if setting the state to a particular value would be allowed (ignoring all other validations):
230
+
231
+ ```ruby
232
+ material.state.may_become?('gas') #=> true
233
+ material.state.may_become?('solid') #=> false
234
+ ```
235
+
236
+ To get a list of all of the valid state transitions, use the `next_values` method:
237
+
238
+ ```ruby
239
+ material.state.next_values # => ['gas']
240
+ ```
241
+
242
+ As it stands, state history is not preserved, but it is still possible to get a list of all possible previous states using the `previous_values` method:
243
+
244
+ ```ruby
245
+ material.state.previous_values # => ['solid']
246
+ ```
247
+
248
+ ## How to Contribute
249
+
250
+ We would love for you to contribute! Anything that benefits the majority of `steady_state` users—from a documentation fix to an entirely new feature—is encouraged.
251
+
252
+ Before diving in, check our issue tracker and consider creating a new issue to get early feedback on your proposed change.
253
+
254
+ #### Suggested Workflow
255
+
256
+ * Fork the project and create a new branch for your contribution.
257
+ * Write your contribution (and any applicable test coverage).
258
+ * Make sure all tests pass (bundle exec rake).
259
+ * Submit a pull request.
@@ -0,0 +1,16 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ Bundler::GemHelper.install_tasks
8
+
9
+ require 'rubocop/rake_task'
10
+ RuboCop::RakeTask.new
11
+
12
+ require 'rspec/core'
13
+ require 'rspec/core/rake_task'
14
+ RSpec::Core::RakeTask.new(:spec)
15
+
16
+ task default: %i(rubocop spec)
@@ -0,0 +1,16 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'active_model'
4
+ require 'steady_state/attribute'
5
+
6
+ module SteadyState
7
+ extend ActiveSupport::Concern
8
+
9
+ def self.active_record?(klass)
10
+ defined?(ActiveRecord::Base) && klass < ActiveRecord::Base
11
+ end
12
+
13
+ included do
14
+ include Attribute
15
+ end
16
+ end
@@ -0,0 +1,58 @@
1
+ require 'steady_state/attribute/state'
2
+ require 'steady_state/attribute/state_machine'
3
+ require 'steady_state/attribute/transition_validator'
4
+
5
+ module SteadyState
6
+ module Attribute
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ cattr_reader :state_machines do
11
+ Hash.new { |h, k| h[k] = StateMachine.new }
12
+ end
13
+ end
14
+
15
+ class_methods do
16
+ def steady_state(attr_name, predicates: true, scopes: SteadyState.active_record?(self), &block) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/LineLength
17
+ overrides = Module.new do
18
+ define_method :"validate_#{attr_name}_transition_to" do |next_value|
19
+ if public_send(attr_name).may_become?(next_value)
20
+ remove_instance_variable("@last_valid_#{attr_name}") if instance_variable_defined?("@last_valid_#{attr_name}")
21
+ elsif !instance_variable_defined?("@last_valid_#{attr_name}")
22
+ instance_variable_set("@last_valid_#{attr_name}", public_send(attr_name))
23
+ end
24
+ end
25
+
26
+ define_method :"#{attr_name}=" do |value|
27
+ unless instance_variable_defined?("@#{attr_name}_state_initialized")
28
+ instance_variable_set("@#{attr_name}_state_initialized", true)
29
+ end
30
+ public_send(:"validate_#{attr_name}_transition_to", value) if public_send(attr_name).present?
31
+ super(value)
32
+ end
33
+
34
+ define_method :"#{attr_name}" do |*args, &blk|
35
+ unless instance_variable_defined?("@#{attr_name}_state_initialized")
36
+ public_send(:"#{attr_name}=", state_machines[attr_name].start) if super(*args, &blk).blank?
37
+ instance_variable_set("@#{attr_name}_state_initialized", true)
38
+ end
39
+ last_valid_value = instance_variable_get("@last_valid_#{attr_name}") if instance_variable_defined?("@last_valid_#{attr_name}")
40
+ state_machines[attr_name].new_state super(*args, &blk), last_valid_value
41
+ end
42
+ end
43
+ prepend overrides
44
+
45
+ state_machines[attr_name].instance_eval(&block)
46
+ delegate(*state_machines[attr_name].predicates, to: attr_name, allow_nil: true) if predicates
47
+ if scopes
48
+ state_machines[attr_name].states.each do |state|
49
+ scope state.to_sym, -> { where(attr_name.to_sym => state) }
50
+ end
51
+ end
52
+
53
+ validates :"#{attr_name}", 'steady_state/attribute/transition' => true,
54
+ inclusion: { in: state_machines[attr_name].states }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,25 @@
1
+ module SteadyState
2
+ module Attribute
3
+ class State < SimpleDelegator
4
+ attr_accessor :state_machine, :last_valid_value
5
+
6
+ def initialize(state_machine, current_value, last_valid_value)
7
+ self.state_machine = state_machine
8
+ self.last_valid_value = last_valid_value
9
+ super(current_value&.inquiry)
10
+ end
11
+
12
+ def may_become?(new_value)
13
+ next_values.include?(new_value)
14
+ end
15
+
16
+ def next_values
17
+ @next_values ||= state_machine.transitions[last_valid_value || self]
18
+ end
19
+
20
+ def previous_values
21
+ @previous_values ||= state_machine.transitions.select { |_, v| v.include?(last_valid_value || self) }.keys
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ module SteadyState
2
+ module Attribute
3
+ class StateMachine
4
+ attr_accessor :start
5
+
6
+ def state(state, default: false, from: [])
7
+ states << state
8
+ self.start = state if default
9
+ [from].flatten(1).each do |from_state|
10
+ transitions[from_state] << state
11
+ end
12
+ end
13
+
14
+ def new_state(value, last_valid_value)
15
+ State.new(self, value, last_valid_value) unless value.nil?
16
+ end
17
+
18
+ def states
19
+ @states ||= []
20
+ end
21
+
22
+ def predicates
23
+ states.map { |state| :"#{state.parameterize.underscore}?" }
24
+ end
25
+
26
+ def transitions
27
+ @transitions ||= Hash.new { |h, k| h[k] = [] }.with_indifferent_access
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,9 @@
1
+ module SteadyState
2
+ module Attribute
3
+ class TransitionValidator < ActiveModel::EachValidator
4
+ def validate_each(obj, attr_name, _value)
5
+ obj.errors.add(attr_name, :invalid) if obj.instance_variable_defined?("@last_valid_#{attr_name}")
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ module SteadyState
2
+ VERSION = '0.0.1'.freeze
3
+ end
@@ -0,0 +1 @@
1
+ require 'steady_state'
@@ -0,0 +1,370 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe SteadyState::Attribute do
4
+ let(:steady_state_class) do
5
+ Class.new do
6
+ include ActiveModel::Model
7
+ include SteadyState
8
+
9
+ def self.model_name
10
+ ActiveModel::Name.new(self, nil, 'steady_state_class')
11
+ end
12
+ end
13
+ end
14
+ subject { steady_state_class.new }
15
+
16
+ shared_examples 'a basic state machine' do
17
+ it 'starts on initial state' do
18
+ expect(subject.state).to eq 'solid'
19
+ end
20
+
21
+ it 'allows initialization to the initial state' do
22
+ expect(steady_state_class.new(state: 'solid')).to be_valid
23
+ end
24
+
25
+ it 'allows initialization to other states' do
26
+ expect(steady_state_class.new(state: 'plasma')).to be_valid
27
+ end
28
+
29
+ it 'adds validation errors when initializing to an invalid state' do
30
+ object = steady_state_class.new(state: 'banana')
31
+ expect(object).not_to be_valid
32
+ expect(object.errors[:state]).to match_array(['is not included in the list'])
33
+ end
34
+
35
+ it 'allows valid transitions' do
36
+ expect(subject.state.may_become?('liquid')).to eq true
37
+ expect(subject.state.next_values).to match_array(['liquid'])
38
+ expect(subject.state.previous_values).to match_array([])
39
+ expect { subject.state = 'liquid' }.to change { subject.state }.from('solid').to('liquid')
40
+ expect(subject).to be_valid
41
+
42
+ expect(subject.state.may_become?('gas')).to eq true
43
+ expect(subject.state.next_values).to match_array(['gas'])
44
+ expect(subject.state.previous_values).to match_array(['solid'])
45
+ expect { subject.state = 'gas' }.to change { subject.state }.from('liquid').to('gas')
46
+ expect(subject).to be_valid
47
+
48
+ expect(subject.state.may_become?('plasma')).to eq true
49
+ expect(subject.state.next_values).to match_array(['plasma'])
50
+ expect(subject.state.previous_values).to match_array(['liquid'])
51
+ expect { subject.state = 'plasma' }.to change { subject.state }.from('gas').to('plasma')
52
+ expect(subject).to be_valid
53
+ expect(subject.state.next_values).to be_empty
54
+ expect(subject.state.previous_values).to match_array(['gas'])
55
+ end
56
+
57
+ it 'adds validation errors for invalid transitions' do
58
+ expect(subject.state.may_become?('gas')).to eq false
59
+ expect { subject.state = 'gas' }.to change { subject.state }.from('solid').to('gas')
60
+ expect(subject).not_to be_valid
61
+ expect(subject.errors[:state]).to match_array(['is invalid'])
62
+ expect(subject.state.next_values).to match_array(['liquid'])
63
+ expect(subject.state.previous_values).to match_array([])
64
+
65
+ expect(subject.state.may_become?('plasma')).to eq false
66
+ expect { subject.state = 'plasma' }.to change { subject.state }.from('gas').to('plasma')
67
+ expect(subject).not_to be_valid
68
+ expect(subject.errors[:state]).to match_array(['is invalid'])
69
+ expect(subject.state.next_values).to match_array(['liquid'])
70
+ expect(subject.state.previous_values).to match_array([])
71
+
72
+ expect(subject.state.may_become?('solid')).to eq false
73
+ expect { subject.state = 'solid' }.to change { subject.state }.from('plasma').to('solid')
74
+ expect(subject).not_to be_valid
75
+ expect(subject.errors[:state]).to match_array(['is invalid'])
76
+ expect(subject.state.next_values).to match_array(['liquid'])
77
+ expect(subject.state.previous_values).to match_array([])
78
+ end
79
+ end
80
+
81
+ context 'with a single field and nothing fancy' do
82
+ before do
83
+ steady_state_class.module_eval do
84
+ attr_accessor :state
85
+
86
+ steady_state :state do
87
+ state 'solid', default: true
88
+ state 'liquid', from: 'solid'
89
+ state 'gas', from: 'liquid'
90
+ state 'plasma', from: 'gas'
91
+ end
92
+ end
93
+ end
94
+
95
+ it_behaves_like 'a basic state machine'
96
+
97
+ context 'with inheritance' do
98
+ let(:subclass) do
99
+ Class.new(steady_state_class) do
100
+ def initialize
101
+ # I do my own thing.
102
+ end
103
+ end
104
+ end
105
+ subject { subclass.new }
106
+
107
+ it_behaves_like 'a basic state machine'
108
+ end
109
+
110
+ context 'with an existing state value' do
111
+ before do
112
+ steady_state_class.module_eval do
113
+ def state
114
+ @state ||= 'liquid'
115
+ end
116
+ end
117
+ end
118
+
119
+ it 'starts on existing state' do
120
+ expect(subject.state).to eq 'liquid'
121
+ end
122
+
123
+ it 'does not allow initialization to an invalid next state' do
124
+ object = steady_state_class.new(state: 'solid')
125
+ expect(object).not_to be_valid
126
+ expect(object.errors[:state]).to match_array(['is invalid'])
127
+ end
128
+
129
+ it 'allows initialization to a valid next state' do
130
+ expect(steady_state_class.new(state: 'gas')).to be_valid
131
+ end
132
+
133
+ it 'adds validation errors when initializing to an invalid state' do
134
+ object = steady_state_class.new(state: 'banana')
135
+ expect(object).not_to be_valid
136
+ expect(object.errors[:state]).to match_array(['is invalid', 'is not included in the list'])
137
+ end
138
+
139
+ it 'allows valid transitions' do
140
+ expect(subject).to be_valid
141
+ expect(subject.state.may_become?('gas')).to eq true
142
+ expect(subject.state.next_values).to match_array(['gas'])
143
+ expect(subject.state.previous_values).to match_array(['solid'])
144
+ expect { subject.state = 'gas' }.to change { subject.state }.from('liquid').to('gas')
145
+ expect(subject).to be_valid
146
+
147
+ expect(subject.state.may_become?('plasma')).to eq true
148
+ expect(subject.state.next_values).to match_array(['plasma'])
149
+ expect(subject.state.previous_values).to match_array(['liquid'])
150
+ expect { subject.state = 'plasma' }.to change { subject.state }.from('gas').to('plasma')
151
+ expect(subject).to be_valid
152
+ expect(subject.state.next_values).to be_empty
153
+ expect(subject.state.previous_values).to match_array(['gas'])
154
+ end
155
+
156
+ it 'adds validation errors for invalid transitions' do
157
+ expect(subject.state.may_become?('plasma')).to eq false
158
+ expect { subject.state = 'plasma' }.to change { subject.state }.from('liquid').to('plasma')
159
+ expect(subject).not_to be_valid
160
+ expect(subject.errors[:state]).to match_array(['is invalid'])
161
+ expect(subject.state.next_values).to match_array(['gas'])
162
+ expect(subject.state.previous_values).to match_array(['solid'])
163
+
164
+ expect(subject.state.may_become?('solid')).to eq false
165
+ expect { subject.state = 'solid' }.to change { subject.state }.from('plasma').to('solid')
166
+ expect(subject).not_to be_valid
167
+ expect(subject.errors[:state]).to match_array(['is invalid'])
168
+ expect(subject.state.next_values).to match_array(['gas'])
169
+ expect(subject.state.previous_values).to match_array(['solid'])
170
+ end
171
+ end
172
+ end
173
+
174
+ context 'with a field reachable by multiple states' do
175
+ before do
176
+ steady_state_class.module_eval do
177
+ attr_accessor :step
178
+
179
+ steady_state :step do
180
+ state 'step-1', default: true
181
+ state 'step-2', from: 'step-1'
182
+ state 'cancelled', from: %w(step-1 step-2)
183
+ end
184
+ end
185
+ end
186
+
187
+ it 'allows transition from first state' do
188
+ expect(subject.step.may_become?('step-1')).to eq false
189
+ expect(subject.step.may_become?('step-2')).to eq true
190
+ expect(subject.step.may_become?('cancelled')).to eq true
191
+ expect(subject.step.next_values).to match_array(['cancelled', 'step-2'])
192
+ expect(subject.step.previous_values).to match_array([])
193
+ expect { subject.step = 'cancelled' }.to change { subject.step }.from('step-1').to('cancelled')
194
+ expect(subject.step.next_values).to match_array([])
195
+ expect(subject.step.previous_values).to match_array(['step-1', 'step-2'])
196
+ expect(subject).to be_valid
197
+ end
198
+
199
+ it 'allows transition from second state' do
200
+ expect(subject.step.may_become?('step-1')).to eq false
201
+ expect(subject.step.may_become?('step-2')).to eq true
202
+ expect(subject.step.may_become?('cancelled')).to eq true
203
+ expect(subject.step.next_values).to match_array(['cancelled', 'step-2'])
204
+ expect(subject.step.previous_values).to match_array([])
205
+ expect { subject.step = 'step-2' }.to change { subject.step }.from('step-1').to('step-2')
206
+ expect(subject).to be_valid
207
+
208
+ expect(subject.step.may_become?('step-1')).to eq false
209
+ expect(subject.step.may_become?('step-2')).to eq false
210
+ expect(subject.step.may_become?('cancelled')).to eq true
211
+ expect(subject.step.next_values).to match_array(['cancelled'])
212
+ expect(subject.step.previous_values).to match_array(['step-1'])
213
+ expect { subject.step = 'cancelled' }.to change { subject.step }.from('step-2').to('cancelled')
214
+ expect(subject.step.next_values).to match_array([])
215
+ expect(subject.step.previous_values).to match_array(['step-1', 'step-2'])
216
+ expect(subject).to be_valid
217
+ end
218
+ end
219
+
220
+ context 'with the predicates option' do
221
+ before do
222
+ options = opts
223
+ steady_state_class.module_eval do
224
+ attr_accessor :door
225
+
226
+ steady_state :door, options do
227
+ state 'open', default: true
228
+ state 'closed', from: 'open'
229
+ state 'locked', from: 'closed'
230
+ end
231
+ end
232
+ end
233
+
234
+ context 'default' do
235
+ let(:opts) { {} }
236
+
237
+ it 'defines a predicate method for each state' do
238
+ expect(subject).to respond_to(:open?)
239
+ expect(subject).to respond_to(:closed?)
240
+ expect(subject).to respond_to(:locked?)
241
+
242
+ expect(subject.open?).to eq true
243
+ expect(subject.closed?).to eq false
244
+ expect(subject.locked?).to eq false
245
+
246
+ subject.door = 'closed'
247
+ expect(subject.open?).to eq false
248
+ expect(subject.closed?).to eq true
249
+ expect(subject.locked?).to eq false
250
+
251
+ subject.door = 'locked'
252
+ expect(subject.open?).to eq false
253
+ expect(subject.closed?).to eq false
254
+ expect(subject.locked?).to eq true
255
+ end
256
+ end
257
+
258
+ context 'enabled' do
259
+ let(:opts) { { predicates: true } }
260
+
261
+ it 'defines a predicate method for each state' do
262
+ expect(subject).to respond_to(:open?)
263
+ expect(subject).to respond_to(:closed?)
264
+ expect(subject).to respond_to(:locked?)
265
+
266
+ expect(subject.open?).to eq true
267
+ expect(subject.closed?).to eq false
268
+ expect(subject.locked?).to eq false
269
+
270
+ subject.door = 'closed'
271
+ expect(subject.open?).to eq false
272
+ expect(subject.closed?).to eq true
273
+ expect(subject.locked?).to eq false
274
+
275
+ subject.door = 'locked'
276
+ expect(subject.open?).to eq false
277
+ expect(subject.closed?).to eq false
278
+ expect(subject.locked?).to eq true
279
+ end
280
+ end
281
+
282
+ context 'disabled' do
283
+ let(:opts) { { predicates: false } }
284
+
285
+ it 'does not define predicate methods' do
286
+ expect(subject).not_to respond_to(:open?)
287
+ expect(subject).not_to respond_to(:closed?)
288
+ expect(subject).not_to respond_to(:locked?)
289
+ end
290
+ end
291
+ end
292
+
293
+ context 'with the scopes option' do
294
+ let(:query_object) { double(where: []) } # rubocop:disable RSpec/VerifiedDoubles
295
+
296
+ before do
297
+ options = opts
298
+ steady_state_class.module_eval do
299
+ attr_accessor :car
300
+
301
+ def self.defined_scopes
302
+ @defined_scopes ||= {}
303
+ end
304
+
305
+ def self.scope(name, callable)
306
+ defined_scopes[name] ||= callable
307
+ end
308
+
309
+ steady_state :car, options do
310
+ state 'driving', default: true
311
+ state 'stopped', from: 'driving'
312
+ state 'parked', from: 'stopped'
313
+ end
314
+ end
315
+ end
316
+
317
+ context 'default' do
318
+ let(:opts) { {} }
319
+
320
+ it 'does not define scope methods' do
321
+ expect(steady_state_class.defined_scopes.keys).to eq []
322
+ end
323
+
324
+ context 'on an ActiveRecord' do
325
+ let(:steady_state_class) do
326
+ stub_const('ActiveRecord::Base', Class.new)
327
+
328
+ Class.new(ActiveRecord::Base) do
329
+ include ActiveModel::Model
330
+ include SteadyState
331
+ end
332
+ end
333
+
334
+ it 'defines a scope for each state' do
335
+ expect(steady_state_class.defined_scopes.keys).to eq %i(driving stopped parked)
336
+
337
+ expect(query_object).to receive(:where).with(car: 'driving')
338
+ query_object.instance_exec(&steady_state_class.defined_scopes[:driving])
339
+ expect(query_object).to receive(:where).with(car: 'stopped')
340
+ query_object.instance_exec(&steady_state_class.defined_scopes[:stopped])
341
+ expect(query_object).to receive(:where).with(car: 'parked')
342
+ query_object.instance_exec(&steady_state_class.defined_scopes[:parked])
343
+ end
344
+ end
345
+ end
346
+
347
+ context 'enabled' do
348
+ let(:opts) { { scopes: true } }
349
+
350
+ it 'defines a scope for each state' do
351
+ expect(steady_state_class.defined_scopes.keys).to eq %i(driving stopped parked)
352
+
353
+ expect(query_object).to receive(:where).with(car: 'driving')
354
+ query_object.instance_exec(&steady_state_class.defined_scopes[:driving])
355
+ expect(query_object).to receive(:where).with(car: 'stopped')
356
+ query_object.instance_exec(&steady_state_class.defined_scopes[:stopped])
357
+ expect(query_object).to receive(:where).with(car: 'parked')
358
+ query_object.instance_exec(&steady_state_class.defined_scopes[:parked])
359
+ end
360
+ end
361
+
362
+ context 'disabled' do
363
+ let(:opts) { { scopes: false } }
364
+
365
+ it 'does not define scope methods' do
366
+ expect(steady_state_class.defined_scopes.keys).to eq []
367
+ end
368
+ end
369
+ end
370
+ end
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: steady_state
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Griffith
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-10-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '4.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '4.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '4.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-betterment
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: |
84
+ A minimalist approach to managing object state,
85
+ perhaps best described as "an enum with guard rails."
86
+ Designed to work with `ActiveRecord` and `ActiveModel` classes,
87
+ or anywhere where Rails validations are used.
88
+ email:
89
+ - nathan@betterment.com
90
+ executables: []
91
+ extensions: []
92
+ extra_rdoc_files: []
93
+ files:
94
+ - README.md
95
+ - Rakefile
96
+ - lib/steady_state.rb
97
+ - lib/steady_state/attribute.rb
98
+ - lib/steady_state/attribute/state.rb
99
+ - lib/steady_state/attribute/state_machine.rb
100
+ - lib/steady_state/attribute/transition_validator.rb
101
+ - lib/steady_state/version.rb
102
+ - spec/spec_helper.rb
103
+ - spec/steady_state/attribute_spec.rb
104
+ homepage:
105
+ licenses: []
106
+ metadata: {}
107
+ post_install_message:
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubyforge_project:
123
+ rubygems_version: 2.7.7
124
+ signing_key:
125
+ specification_version: 4
126
+ summary: Minimalist state management via "an enum with guard rails"
127
+ test_files:
128
+ - spec/spec_helper.rb
129
+ - spec/steady_state/attribute_spec.rb