steady_state 0.0.1

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.
@@ -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