state_machines-rspec 0.4.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.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +27 -0
- data/Gemfile +8 -0
- data/Guardfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +76 -0
- data/Rakefile +6 -0
- data/lib/matchers/events/handle_event.rb +35 -0
- data/lib/matchers/events/matcher.rb +71 -0
- data/lib/matchers/events/reject_event.rb +35 -0
- data/lib/matchers/states/have_state.rb +46 -0
- data/lib/matchers/states/matcher.rb +56 -0
- data/lib/matchers/states/reject_state.rb +34 -0
- data/lib/matchers/transitions/transition_from.rb +83 -0
- data/lib/state_machines_rspec.rb +12 -0
- data/lib/state_machines_rspec/state_machines_introspector.rb +74 -0
- data/lib/state_machines_rspec/version.rb +3 -0
- data/spec/integration/integration_spec.rb +332 -0
- data/spec/integration/models/vehicle.rb +119 -0
- data/spec/matchers/events/handle_event_spec.rb +133 -0
- data/spec/matchers/events/reject_event_spec.rb +133 -0
- data/spec/matchers/states/have_state_spec.rb +136 -0
- data/spec/matchers/states/reject_state_spec.rb +97 -0
- data/spec/matchers/transitions/transition_from_spec.rb +177 -0
- data/spec/spec_helper.rb +12 -0
- data/state_machines-rspec.gemspec +32 -0
- metadata +237 -0
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'matchers/states/matcher'
|
2
|
+
|
3
|
+
module StateMachinesRspec
|
4
|
+
module Matchers
|
5
|
+
def reject_states(state, *states)
|
6
|
+
RejectStateMatcher.new(states.unshift(state))
|
7
|
+
end
|
8
|
+
alias_method :reject_state, :reject_states
|
9
|
+
|
10
|
+
class RejectStateMatcher < StateMachinesRspec::Matchers::States::Matcher
|
11
|
+
def matches_states?(states)
|
12
|
+
no_defined_states?
|
13
|
+
end
|
14
|
+
|
15
|
+
def description
|
16
|
+
message = super
|
17
|
+
message << " on #{state_machine_scope.inspect}" if state_machine_scope
|
18
|
+
"not have #{message}"
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def no_defined_states?
|
24
|
+
defined_states = @introspector.defined_states(@states)
|
25
|
+
unless defined_states.empty?
|
26
|
+
@failure_message = "Did not expect #{@introspector.state_machine_attribute} " +
|
27
|
+
"to allow states: #{defined_states.join(', ')}"
|
28
|
+
end
|
29
|
+
|
30
|
+
defined_states.empty?
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'active_support/core_ext/array/extract_options'
|
2
|
+
|
3
|
+
module StateMachinesRspec
|
4
|
+
module Matchers
|
5
|
+
def transition_from(*values)
|
6
|
+
HandleTransitionFromMatcher.new(*values)
|
7
|
+
end
|
8
|
+
|
9
|
+
alias transitions_from transition_from
|
10
|
+
|
11
|
+
class HandleTransitionFromMatcher
|
12
|
+
attr_reader :failure_message, :options, :from_states, :from_state,
|
13
|
+
:state_machine_scope
|
14
|
+
|
15
|
+
def initialize(*values)
|
16
|
+
@options = values.extract_options!
|
17
|
+
@state_machine_scope = @options.fetch(:on, nil)
|
18
|
+
@from_states = values
|
19
|
+
end
|
20
|
+
|
21
|
+
def matches?(subject)
|
22
|
+
@subject = subject
|
23
|
+
@introspector = StateMachinesIntrospector.new(@subject, state_machine_scope)
|
24
|
+
from_states.each do |from_state|
|
25
|
+
@from_state = from_state
|
26
|
+
enter_from_state
|
27
|
+
return false unless valid_transition?
|
28
|
+
break if @failure_message
|
29
|
+
end
|
30
|
+
@failure_message.nil?
|
31
|
+
end
|
32
|
+
|
33
|
+
def description
|
34
|
+
message = "transition state to :#{options[:to_state]} from "
|
35
|
+
message << from_states.map{ |state| ":#{state}" }.join(', ')
|
36
|
+
message << " on event :#{options[:on_event]}"
|
37
|
+
message << " on #{state_machine_scope.inspect}" if state_machine_scope
|
38
|
+
message
|
39
|
+
end
|
40
|
+
|
41
|
+
def valid_transition?
|
42
|
+
valid_transition = @introspector.valid_transition?(event, to_state)
|
43
|
+
unless valid_transition
|
44
|
+
@failure_message = 'Expected to be able to transition state from: ' \
|
45
|
+
"#{from_state} to: #{to_state}, on_event: #{event}"
|
46
|
+
end
|
47
|
+
|
48
|
+
valid_transition
|
49
|
+
end
|
50
|
+
|
51
|
+
def event
|
52
|
+
@event ||=
|
53
|
+
unless event = options[:on_event]
|
54
|
+
raise StateMachinesIntrospectorError, 'Option :on_event cannot be nil'
|
55
|
+
end
|
56
|
+
unless @introspector.event_defined?(event)
|
57
|
+
raise StateMachinesIntrospectorError, "#{@subject.class} does not define event :#{event}"
|
58
|
+
end
|
59
|
+
event
|
60
|
+
end
|
61
|
+
|
62
|
+
def to_state
|
63
|
+
@to_state ||=
|
64
|
+
unless state_name = options[:to_state]
|
65
|
+
raise StateMachinesIntrospectorError, 'Option :to_state cannot be nil'
|
66
|
+
end
|
67
|
+
unless state = @introspector.state(state_name)
|
68
|
+
raise StateMachinesIntrospectorError, "#{@subject.class} does not define state: #{state_name}"
|
69
|
+
end
|
70
|
+
state.value
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def enter_from_state
|
76
|
+
unless state = @introspector.state(from_state)
|
77
|
+
raise StateMachinesIntrospectorError, "#{@subject.class} does not define state: #{from_state}"
|
78
|
+
end
|
79
|
+
@subject.send("#{@introspector.state_machine_attribute}=", state.value)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'state_machines_rspec/version'
|
2
|
+
require 'state_machines_rspec/state_machines_introspector'
|
3
|
+
require 'matchers/events/handle_event'
|
4
|
+
require 'matchers/events/reject_event'
|
5
|
+
require 'matchers/states/have_state'
|
6
|
+
require 'matchers/states/reject_state'
|
7
|
+
require 'matchers/transitions/transition_from'
|
8
|
+
|
9
|
+
module StateMachinesRspec
|
10
|
+
module Matchers
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
class StateMachinesIntrospector
|
2
|
+
def initialize(subject, state_machine_name=nil)
|
3
|
+
@subject = subject
|
4
|
+
@state_machine_name = state_machine_name
|
5
|
+
end
|
6
|
+
|
7
|
+
def state_machine_attribute
|
8
|
+
state_machine.attribute
|
9
|
+
end
|
10
|
+
|
11
|
+
def current_state_value
|
12
|
+
@subject.send(state_machine_attribute)
|
13
|
+
end
|
14
|
+
|
15
|
+
def state(name)
|
16
|
+
state_machine.states.find { |s| s.name == name }
|
17
|
+
end
|
18
|
+
|
19
|
+
def undefined_states(states)
|
20
|
+
states.reject { |s| state_defined? s }
|
21
|
+
end
|
22
|
+
|
23
|
+
def defined_states(states)
|
24
|
+
states.select { |s| state_defined? s }
|
25
|
+
end
|
26
|
+
|
27
|
+
def undefined_events(events)
|
28
|
+
events.reject { |e| event_defined? e }
|
29
|
+
end
|
30
|
+
|
31
|
+
def valid_events(events)
|
32
|
+
events.select { |e| valid_event? e }
|
33
|
+
end
|
34
|
+
|
35
|
+
def invalid_events(events)
|
36
|
+
events.reject { |e| valid_event? e }
|
37
|
+
end
|
38
|
+
|
39
|
+
def event_defined?(event)
|
40
|
+
@subject.respond_to? "can_#{event}?"
|
41
|
+
end
|
42
|
+
|
43
|
+
def valid_transition?(event, to_state)
|
44
|
+
@subject.send(event)
|
45
|
+
current_state_value == to_state
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def state_machine
|
51
|
+
if @state_machine_name
|
52
|
+
unless machine = @subject.class.state_machines[@state_machine_name]
|
53
|
+
raise StateMachinesIntrospectorError,
|
54
|
+
"#{@subject.class} does not have a state machine defined " \
|
55
|
+
"on #{@state_machine_name}"
|
56
|
+
end
|
57
|
+
else
|
58
|
+
machine = @subject.class.state_machine
|
59
|
+
end
|
60
|
+
|
61
|
+
machine
|
62
|
+
end
|
63
|
+
|
64
|
+
def state_defined?(state_name)
|
65
|
+
state(state_name)
|
66
|
+
end
|
67
|
+
|
68
|
+
def valid_event?(event)
|
69
|
+
@subject.send("can_#{event}?")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
class StateMachinesIntrospectorError < StandardError
|
74
|
+
end
|
@@ -0,0 +1,332 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'integration/models/vehicle'
|
3
|
+
|
4
|
+
describe Vehicle do
|
5
|
+
let(:vehicle) { Vehicle.new }
|
6
|
+
subject { vehicle }
|
7
|
+
|
8
|
+
its(:passed_inspection?) { is_expected.to be_falsey }
|
9
|
+
|
10
|
+
shared_examples 'crashable' do
|
11
|
+
describe 'crash' do
|
12
|
+
context 'having passed inspection' do
|
13
|
+
before { allow(vehicle).to receive_messages(:passed_inspection => true) }
|
14
|
+
pending 'keeps running' do
|
15
|
+
initial_state = vehicle.state
|
16
|
+
vehicle.crash!
|
17
|
+
|
18
|
+
expect(vehicle.state).to eq initial_state
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
context 'not having passed inspection' do
|
23
|
+
before { allow(vehicle).to receive_messages(:passed_inspection => false) }
|
24
|
+
it 'stalls' do
|
25
|
+
vehicle.crash!
|
26
|
+
expect(vehicle.state).to eq :stalled.to_s
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
shared_examples 'speedless' do
|
33
|
+
it 'does not respond to speed' do
|
34
|
+
expect { vehicle.speed }.to raise_error StateMachines::InvalidContext
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe '#initialize' do
|
39
|
+
its(:seatbelt_on) { is_expected.to be_falsey }
|
40
|
+
its(:time_used) { is_expected.to eq 0 }
|
41
|
+
its(:auto_shop_busy) { is_expected.to be_truthy }
|
42
|
+
end
|
43
|
+
|
44
|
+
describe '#put_on_seatbelt' do
|
45
|
+
it 'sets seatbelt_on to true' do
|
46
|
+
vehicle.seatbelt_on = false
|
47
|
+
vehicle.put_on_seatbelt
|
48
|
+
|
49
|
+
expect(vehicle.seatbelt_on).to be_truthy
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe 'state machines' do
|
54
|
+
it { is_expected.to have_states :parked, :idling, :stalled, :first_gear,
|
55
|
+
:second_gear, :third_gear }
|
56
|
+
it { is_expected.to reject_state :flying }
|
57
|
+
|
58
|
+
it { is_expected.to handle_event :ignite, when: :parked }
|
59
|
+
it { is_expected.to reject_events :park, :idle, :shift_up,
|
60
|
+
:shift_down, :crash, :repair,
|
61
|
+
when: :parked }
|
62
|
+
|
63
|
+
it { is_expected.to handle_events :park, :shift_up, :crash, when: :idling }
|
64
|
+
it { is_expected.to reject_events :ignite, :idle, :shift_down, :repair,
|
65
|
+
when: :idling }
|
66
|
+
|
67
|
+
it { is_expected.to handle_events :ignite, :repair, when: :stalled }
|
68
|
+
it { is_expected.to reject_events :park, :idle, :shift_up, :shift_down, :crash,
|
69
|
+
when: :stalled }
|
70
|
+
|
71
|
+
it { is_expected.to handle_events :park, :idle, :shift_up, :crash,
|
72
|
+
when: :first_gear }
|
73
|
+
it { is_expected.to reject_events :ignite, :shift_down, :repair,
|
74
|
+
when: :first_gear }
|
75
|
+
|
76
|
+
it { is_expected.to handle_events :shift_up, :shift_down, :crash,
|
77
|
+
when: :second_gear }
|
78
|
+
it { is_expected.to reject_events :park, :ignite, :idle, :repair,
|
79
|
+
when: :second_gear }
|
80
|
+
|
81
|
+
it { is_expected.to handle_events :shift_down, :crash, when: :third_gear }
|
82
|
+
it { is_expected.to reject_events :park, :ignite, :idle, :shift_up, :repair,
|
83
|
+
when: :third_gear }
|
84
|
+
|
85
|
+
it { is_expected.to transition_from :idling, :first_gear, to_state: :parked, on_event: :park }
|
86
|
+
it { is_expected.to transition_from :idling, to_state: :parked, on_event: :park }
|
87
|
+
|
88
|
+
it 'has an initial state of "parked"' do
|
89
|
+
expect(vehicle).to be_parked
|
90
|
+
end
|
91
|
+
|
92
|
+
it 'has an initial alarm state of "active"' do
|
93
|
+
expect(vehicle.alarm_active?).to be_truthy
|
94
|
+
end
|
95
|
+
|
96
|
+
describe 'around transitions' do
|
97
|
+
it 'updates the time used' do
|
98
|
+
expect(vehicle).to receive(:time_used=).with(0)
|
99
|
+
Timecop.freeze { vehicle.ignite! }
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
context 'when parked' do
|
104
|
+
before { vehicle.state = :parked.to_s }
|
105
|
+
|
106
|
+
its(:speed) { is_expected.to be_zero }
|
107
|
+
it { is_expected.not_to be_moving }
|
108
|
+
|
109
|
+
describe 'before transitions' do
|
110
|
+
it 'puts on a seatbelt' do
|
111
|
+
expect(vehicle).to receive :put_on_seatbelt
|
112
|
+
vehicle.ignite!
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
describe 'ignite' do
|
117
|
+
it 'should transition to idling' do
|
118
|
+
vehicle.ignite!
|
119
|
+
expect(vehicle).to be_idling
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
context 'when transitioning to parked' do
|
125
|
+
before { vehicle.state = :idling.to_s }
|
126
|
+
it 'removes seatbelts' do
|
127
|
+
expect(vehicle).to receive(:seatbelt_on=).with(false)
|
128
|
+
vehicle.park!
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
context 'when idling' do
|
133
|
+
before { vehicle.state = :idling.to_s }
|
134
|
+
|
135
|
+
its(:speed) { is_expected.to eq 10 }
|
136
|
+
it { is_expected.not_to be_moving }
|
137
|
+
|
138
|
+
describe 'park' do
|
139
|
+
it 'should transition to a parked state' do
|
140
|
+
vehicle.park!
|
141
|
+
expect(vehicle).to be_parked
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
describe 'shift up' do
|
146
|
+
it 'should shift into first gear' do
|
147
|
+
vehicle.shift_up!
|
148
|
+
expect(vehicle).to be_first_gear
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
it_behaves_like 'crashable'
|
153
|
+
end
|
154
|
+
|
155
|
+
context 'when stalled' do
|
156
|
+
before { vehicle.state = :stalled.to_s }
|
157
|
+
|
158
|
+
it { is_expected.not_to be_moving }
|
159
|
+
it_behaves_like 'speedless'
|
160
|
+
|
161
|
+
describe 'ignite' do
|
162
|
+
it 'remains stalled' do
|
163
|
+
vehicle.ignite!
|
164
|
+
expect(vehicle).to be_stalled
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
describe 'repair' do
|
169
|
+
context 'the auto shop is busy' do
|
170
|
+
before { allow(vehicle).to receive_messages(:auto_shop_busy => true) }
|
171
|
+
it 'remains stalled' do
|
172
|
+
vehicle.repair!
|
173
|
+
expect(vehicle).to be_stalled
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
context 'the auto shop is not busy' do
|
178
|
+
before { allow(vehicle).to receive_messages(:auto_shop_busy => false) }
|
179
|
+
it 'is parked' do
|
180
|
+
vehicle.repair!
|
181
|
+
expect(vehicle).to be_parked
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
context 'when in first gear' do
|
188
|
+
before { vehicle.state = :first_gear.to_s }
|
189
|
+
|
190
|
+
its(:speed) { is_expected.to eq 10 }
|
191
|
+
it { is_expected.to be_moving }
|
192
|
+
|
193
|
+
describe 'park' do
|
194
|
+
it 'parks' do
|
195
|
+
vehicle.park!
|
196
|
+
expect(vehicle).to be_parked
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
describe 'idle' do
|
201
|
+
it 'idles' do
|
202
|
+
vehicle.idle!
|
203
|
+
expect(vehicle).to be_idling
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
describe 'shift up' do
|
208
|
+
it 'shift into second gear' do
|
209
|
+
vehicle.shift_up!
|
210
|
+
expect(vehicle).to be_second_gear
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
it_behaves_like 'crashable'
|
215
|
+
end
|
216
|
+
|
217
|
+
context 'when in second gear' do
|
218
|
+
before { vehicle.state = :second_gear.to_s }
|
219
|
+
|
220
|
+
it { is_expected.to be_moving }
|
221
|
+
it_behaves_like 'speedless'
|
222
|
+
|
223
|
+
describe 'shift up' do
|
224
|
+
it 'shifts into third gear' do
|
225
|
+
vehicle.shift_up!
|
226
|
+
expect(vehicle).to be_third_gear
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
describe 'shift down' do
|
231
|
+
it 'shifts back into first gear' do
|
232
|
+
vehicle.shift_down!
|
233
|
+
expect(vehicle).to be_first_gear
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
it_behaves_like 'crashable'
|
238
|
+
end
|
239
|
+
|
240
|
+
context 'when in third gear' do
|
241
|
+
before { vehicle.state = :third_gear.to_s }
|
242
|
+
|
243
|
+
it { is_expected.to be_moving }
|
244
|
+
it_behaves_like 'speedless'
|
245
|
+
|
246
|
+
describe 'shift down' do
|
247
|
+
it 'shifts back into second gear' do
|
248
|
+
vehicle.shift_down!
|
249
|
+
expect(vehicle).to be_second_gear
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
it_behaves_like 'crashable'
|
254
|
+
end
|
255
|
+
|
256
|
+
context 'on ignition' do
|
257
|
+
context 'when it fails' do
|
258
|
+
before { allow(vehicle).to receive_messages(:ignite => false) }
|
259
|
+
pending 'logs the failure' do
|
260
|
+
expect(vehicle).to receive(:log_start_failure)
|
261
|
+
vehicle.ignite
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
context 'on a crash' do
|
267
|
+
before { vehicle.state = :third_gear.to_s }
|
268
|
+
it 'gets towed' do
|
269
|
+
expect(vehicle).to receive(:tow)
|
270
|
+
vehicle.crash!
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
context 'upon being repaired' do
|
275
|
+
before { vehicle.state = :stalled.to_s }
|
276
|
+
it 'gets fixed' do
|
277
|
+
expect(vehicle).to receive(:fix)
|
278
|
+
vehicle.repair!
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
describe 'alarm state machines' do
|
284
|
+
it { is_expected.to have_state :active, on: :alarm_state, value: 1 }
|
285
|
+
it { is_expected.to have_state :off, on: :alarm_state, value: 0 }
|
286
|
+
it { is_expected.to reject_states :broken, :ringing, on: :alarm_state }
|
287
|
+
|
288
|
+
it { is_expected.to handle_events :enable_alarm, :disable_alarm,
|
289
|
+
when: :active, on: :alarm_state }
|
290
|
+
it { is_expected.to handle_events :enable_alarm, :disable_alarm,
|
291
|
+
when: :off, on: :alarm_state }
|
292
|
+
|
293
|
+
it { is_expected.to transition_from :active, to_state: :off, on_event: :disable_alarm, on: :alarm_state }
|
294
|
+
|
295
|
+
it 'has an initial state of activated' do
|
296
|
+
expect(vehicle.alarm_active?).to be_truthy
|
297
|
+
end
|
298
|
+
|
299
|
+
context 'when active' do
|
300
|
+
describe 'enable' do
|
301
|
+
it 'becomes active' do
|
302
|
+
vehicle.enable_alarm!
|
303
|
+
expect(vehicle.alarm_active?).to be_truthy
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
describe 'disable' do
|
308
|
+
it 'turns the alarm off' do
|
309
|
+
vehicle.disable_alarm!
|
310
|
+
expect(vehicle.alarm_off?).to be_truthy
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
context 'when off' do
|
316
|
+
before { vehicle.alarm_state = 0 }
|
317
|
+
describe 'enable' do
|
318
|
+
it 'becomes active' do
|
319
|
+
vehicle.enable_alarm!
|
320
|
+
expect(vehicle.alarm_active?).to be_truthy
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
describe 'disable' do
|
325
|
+
it 'turns the alarm off' do
|
326
|
+
vehicle.disable_alarm!
|
327
|
+
expect(vehicle.alarm_off?).to be_truthy
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|