state_machines 0.6.0 → 0.30.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 +4 -4
- data/README.md +205 -14
- data/lib/state_machines/branch.rb +20 -17
- data/lib/state_machines/callback.rb +13 -12
- data/lib/state_machines/core.rb +3 -3
- data/lib/state_machines/core_ext/class/state_machine.rb +2 -0
- data/lib/state_machines/core_ext.rb +2 -0
- data/lib/state_machines/error.rb +7 -4
- data/lib/state_machines/eval_helpers.rb +93 -26
- data/lib/state_machines/event.rb +41 -29
- data/lib/state_machines/event_collection.rb +6 -5
- data/lib/state_machines/extensions.rb +7 -5
- data/lib/state_machines/helper_module.rb +3 -1
- data/lib/state_machines/integrations/base.rb +3 -1
- data/lib/state_machines/integrations.rb +13 -14
- data/lib/state_machines/machine/action_hooks.rb +53 -0
- data/lib/state_machines/machine/callbacks.rb +59 -0
- data/lib/state_machines/machine/class_methods.rb +93 -0
- data/lib/state_machines/machine/configuration.rb +124 -0
- data/lib/state_machines/machine/event_methods.rb +59 -0
- data/lib/state_machines/machine/helper_generators.rb +125 -0
- data/lib/state_machines/machine/integration.rb +70 -0
- data/lib/state_machines/machine/parsing.rb +77 -0
- data/lib/state_machines/machine/rendering.rb +17 -0
- data/lib/state_machines/machine/scoping.rb +44 -0
- data/lib/state_machines/machine/state_methods.rb +101 -0
- data/lib/state_machines/machine/utilities.rb +85 -0
- data/lib/state_machines/machine/validation.rb +39 -0
- data/lib/state_machines/machine.rb +83 -673
- data/lib/state_machines/machine_collection.rb +23 -15
- data/lib/state_machines/macro_methods.rb +4 -2
- data/lib/state_machines/matcher.rb +8 -5
- data/lib/state_machines/matcher_helpers.rb +3 -1
- data/lib/state_machines/node_collection.rb +23 -18
- data/lib/state_machines/options_validator.rb +72 -0
- data/lib/state_machines/path.rb +7 -5
- data/lib/state_machines/path_collection.rb +7 -4
- data/lib/state_machines/state.rb +76 -47
- data/lib/state_machines/state_collection.rb +5 -3
- data/lib/state_machines/state_context.rb +11 -8
- data/lib/state_machines/stdio_renderer.rb +74 -0
- data/lib/state_machines/syntax_validator.rb +57 -0
- data/lib/state_machines/test_helper.rb +568 -0
- data/lib/state_machines/transition.rb +45 -41
- data/lib/state_machines/transition_collection.rb +27 -26
- data/lib/state_machines/version.rb +3 -1
- data/lib/state_machines.rb +4 -1
- metadata +32 -16
- data/lib/state_machines/assertions.rb +0 -40
@@ -0,0 +1,568 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
# Test helper module providing assertion methods for state machine testing
|
5
|
+
# Designed to work with Minitest, RSpec, and future testing frameworks
|
6
|
+
#
|
7
|
+
# @example Basic usage with Minitest
|
8
|
+
# class MyModelTest < Minitest::Test
|
9
|
+
# include StateMachines::TestHelper
|
10
|
+
#
|
11
|
+
# def test_initial_state
|
12
|
+
# model = MyModel.new
|
13
|
+
# assert_state(model, :state_machine_name, :initial_state)
|
14
|
+
# end
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# @example Usage with RSpec
|
18
|
+
# RSpec.describe MyModel do
|
19
|
+
# include StateMachines::TestHelper
|
20
|
+
#
|
21
|
+
# it "starts in initial state" do
|
22
|
+
# model = MyModel.new
|
23
|
+
# assert_state(model, :state_machine_name, :initial_state)
|
24
|
+
# end
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# @since 0.10.0
|
28
|
+
module TestHelper
|
29
|
+
# Assert that an object is in a specific state for a given state machine
|
30
|
+
#
|
31
|
+
# @param object [Object] The object with state machines
|
32
|
+
# @param expected_state [Symbol] The expected state
|
33
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
34
|
+
# @param message [String, nil] Custom failure message
|
35
|
+
# @return [void]
|
36
|
+
# @raise [AssertionError] If the state doesn't match
|
37
|
+
#
|
38
|
+
# @example
|
39
|
+
# user = User.new
|
40
|
+
# assert_sm_state(user, :active) # Uses default :state machine
|
41
|
+
# assert_sm_state(user, :active, machine_name: :status) # Uses :status machine
|
42
|
+
def assert_sm_state(object, expected_state, machine_name: :state, message: nil)
|
43
|
+
name_method = "#{machine_name}_name"
|
44
|
+
|
45
|
+
# Handle the case where machine_name doesn't have a corresponding _name method
|
46
|
+
unless object.respond_to?(name_method)
|
47
|
+
available_machines = begin
|
48
|
+
object.class.state_machines.keys
|
49
|
+
rescue StandardError
|
50
|
+
[]
|
51
|
+
end
|
52
|
+
raise ArgumentError, "No state machine '#{machine_name}' found. Available machines: #{available_machines.inspect}"
|
53
|
+
end
|
54
|
+
|
55
|
+
actual = object.send(name_method)
|
56
|
+
default_message = "Expected #{object.class}##{machine_name} to be #{expected_state}, but was #{actual}"
|
57
|
+
|
58
|
+
if defined?(::Minitest)
|
59
|
+
assert_equal expected_state.to_s, actual.to_s, message || default_message
|
60
|
+
elsif defined?(::RSpec)
|
61
|
+
expect(actual.to_s).to eq(expected_state.to_s), message || default_message
|
62
|
+
else
|
63
|
+
raise "Expected #{expected_state}, but got #{actual}" unless expected_state.to_s == actual.to_s
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Assert that an object can transition via a specific event
|
68
|
+
#
|
69
|
+
# @param object [Object] The object with state machines
|
70
|
+
# @param event [Symbol] The event name
|
71
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
72
|
+
# @param message [String, nil] Custom failure message
|
73
|
+
# @return [void]
|
74
|
+
# @raise [AssertionError] If the transition is not available
|
75
|
+
#
|
76
|
+
# @example
|
77
|
+
# user = User.new
|
78
|
+
# assert_sm_can_transition(user, :activate) # Uses default :state machine
|
79
|
+
# assert_sm_can_transition(user, :activate, machine_name: :status) # Uses :status machine
|
80
|
+
def assert_sm_can_transition(object, event, machine_name: :state, message: nil)
|
81
|
+
# Try different method naming patterns
|
82
|
+
possible_methods = [
|
83
|
+
"can_#{event}?", # Default state machine or non-namespaced
|
84
|
+
"can_#{event}_#{machine_name}?" # Namespaced events
|
85
|
+
]
|
86
|
+
|
87
|
+
can_method = possible_methods.find { |method| object.respond_to?(method) }
|
88
|
+
|
89
|
+
unless can_method
|
90
|
+
available_methods = object.methods.grep(/^can_.*\?$/).sort
|
91
|
+
raise ArgumentError, "No transition method found for event :#{event} on machine :#{machine_name}. Available methods: #{available_methods.first(10).inspect}"
|
92
|
+
end
|
93
|
+
|
94
|
+
default_message = "Expected to be able to trigger event :#{event} on #{machine_name}, but #{can_method} returned false"
|
95
|
+
|
96
|
+
if defined?(::Minitest)
|
97
|
+
assert object.send(can_method), message || default_message
|
98
|
+
elsif defined?(::RSpec)
|
99
|
+
expect(object.send(can_method)).to be_truthy, message || default_message
|
100
|
+
else
|
101
|
+
raise default_message unless object.send(can_method)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Assert that an object cannot transition via a specific event
|
106
|
+
#
|
107
|
+
# @param object [Object] The object with state machines
|
108
|
+
# @param event [Symbol] The event name
|
109
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
110
|
+
# @param message [String, nil] Custom failure message
|
111
|
+
# @return [void]
|
112
|
+
# @raise [AssertionError] If the transition is available
|
113
|
+
#
|
114
|
+
# @example
|
115
|
+
# user = User.new
|
116
|
+
# assert_sm_cannot_transition(user, :delete) # Uses default :state machine
|
117
|
+
# assert_sm_cannot_transition(user, :delete, machine_name: :status) # Uses :status machine
|
118
|
+
def assert_sm_cannot_transition(object, event, machine_name: :state, message: nil)
|
119
|
+
# Try different method naming patterns
|
120
|
+
possible_methods = [
|
121
|
+
"can_#{event}?", # Default state machine or non-namespaced
|
122
|
+
"can_#{event}_#{machine_name}?" # Namespaced events
|
123
|
+
]
|
124
|
+
|
125
|
+
can_method = possible_methods.find { |method| object.respond_to?(method) }
|
126
|
+
|
127
|
+
unless can_method
|
128
|
+
available_methods = object.methods.grep(/^can_.*\?$/).sort
|
129
|
+
raise ArgumentError, "No transition method found for event :#{event} on machine :#{machine_name}. Available methods: #{available_methods.first(10).inspect}"
|
130
|
+
end
|
131
|
+
|
132
|
+
default_message = "Expected not to be able to trigger event :#{event} on #{machine_name}, but #{can_method} returned true"
|
133
|
+
|
134
|
+
if defined?(::Minitest)
|
135
|
+
refute object.send(can_method), message || default_message
|
136
|
+
elsif defined?(::RSpec)
|
137
|
+
expect(object.send(can_method)).to be_falsy, message || default_message
|
138
|
+
elsif object.send(can_method)
|
139
|
+
raise default_message
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Assert that triggering an event changes the object to the expected state
|
144
|
+
#
|
145
|
+
# @param object [Object] The object with state machines
|
146
|
+
# @param event [Symbol] The event to trigger
|
147
|
+
# @param expected_state [Symbol] The expected state after transition
|
148
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
149
|
+
# @param message [String, nil] Custom failure message
|
150
|
+
# @return [void]
|
151
|
+
# @raise [AssertionError] If the transition fails or results in wrong state
|
152
|
+
#
|
153
|
+
# @example
|
154
|
+
# user = User.new
|
155
|
+
# assert_sm_transition(user, :activate, :active) # Uses default :state machine
|
156
|
+
# assert_sm_transition(user, :activate, :active, machine_name: :status) # Uses :status machine
|
157
|
+
def assert_sm_transition(object, event, expected_state, machine_name: :state, message: nil)
|
158
|
+
object.send("#{event}!")
|
159
|
+
assert_sm_state(object, expected_state, machine_name: machine_name, message: message)
|
160
|
+
end
|
161
|
+
|
162
|
+
# === Extended State Machine Assertions ===
|
163
|
+
|
164
|
+
def assert_sm_states_list(machine, expected_states, message = nil)
|
165
|
+
actual_states = machine.states.map(&:name).compact
|
166
|
+
default_message = "Expected states #{expected_states} but got #{actual_states}"
|
167
|
+
|
168
|
+
if defined?(::Minitest)
|
169
|
+
assert_equal expected_states.sort, actual_states.sort, message || default_message
|
170
|
+
elsif defined?(::RSpec)
|
171
|
+
expect(actual_states.sort).to eq(expected_states.sort), message || default_message
|
172
|
+
else
|
173
|
+
raise default_message unless expected_states.sort == actual_states.sort
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def refute_sm_state_defined(machine, state, message = nil)
|
178
|
+
state_exists = machine.states.any? { |s| s.name == state }
|
179
|
+
default_message = "Expected state #{state} to not be defined in machine"
|
180
|
+
|
181
|
+
if defined?(::Minitest)
|
182
|
+
refute state_exists, message || default_message
|
183
|
+
elsif defined?(::RSpec)
|
184
|
+
expect(state_exists).to be_falsy, message || default_message
|
185
|
+
elsif state_exists
|
186
|
+
raise default_message
|
187
|
+
end
|
188
|
+
end
|
189
|
+
alias assert_sm_state_not_defined refute_sm_state_defined
|
190
|
+
|
191
|
+
def assert_sm_initial_state(machine, expected_state, message = nil)
|
192
|
+
state_obj = machine.state(expected_state)
|
193
|
+
is_initial = state_obj&.initial?
|
194
|
+
default_message = "Expected state #{expected_state} to be the initial state"
|
195
|
+
|
196
|
+
if defined?(::Minitest)
|
197
|
+
assert is_initial, message || default_message
|
198
|
+
elsif defined?(::RSpec)
|
199
|
+
expect(is_initial).to be_truthy, message || default_message
|
200
|
+
else
|
201
|
+
raise default_message unless is_initial
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def assert_sm_final_state(machine, state, message = nil)
|
206
|
+
state_obj = machine.states[state]
|
207
|
+
is_final = state_obj&.final?
|
208
|
+
default_message = "Expected state #{state} to be final"
|
209
|
+
|
210
|
+
if defined?(::Minitest)
|
211
|
+
assert is_final, message || default_message
|
212
|
+
elsif defined?(::RSpec)
|
213
|
+
expect(is_final).to be_truthy, message || default_message
|
214
|
+
else
|
215
|
+
raise default_message unless is_final
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def assert_sm_possible_transitions(machine, from:, expected_to_states:, message: nil)
|
220
|
+
actual_transitions = machine.events.flat_map do |event|
|
221
|
+
event.branches.select { |branch| branch.known_states.include?(from) }
|
222
|
+
.map(&:to)
|
223
|
+
end.uniq
|
224
|
+
default_message = "Expected transitions from #{from} to #{expected_to_states} but got #{actual_transitions}"
|
225
|
+
|
226
|
+
if defined?(::Minitest)
|
227
|
+
assert_equal expected_to_states.sort, actual_transitions.sort, message || default_message
|
228
|
+
elsif defined?(::RSpec)
|
229
|
+
expect(actual_transitions.sort).to eq(expected_to_states.sort), message || default_message
|
230
|
+
else
|
231
|
+
raise default_message unless expected_to_states.sort == actual_transitions.sort
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def refute_sm_transition_allowed(machine, from:, to:, on:, message: nil)
|
236
|
+
event = machine.events[on]
|
237
|
+
is_allowed = event&.branches&.any? { |branch| branch.known_states.include?(from) && branch.to == to }
|
238
|
+
default_message = "Expected transition from #{from} to #{to} on #{on} to not be allowed"
|
239
|
+
|
240
|
+
if defined?(::Minitest)
|
241
|
+
refute is_allowed, message || default_message
|
242
|
+
elsif defined?(::RSpec)
|
243
|
+
expect(is_allowed).to be_falsy, message || default_message
|
244
|
+
elsif is_allowed
|
245
|
+
raise default_message
|
246
|
+
end
|
247
|
+
end
|
248
|
+
alias assert_sm_transition_not_allowed refute_sm_transition_allowed
|
249
|
+
|
250
|
+
def assert_sm_event_triggers(object, event, machine_name = :state, message = nil)
|
251
|
+
initial_state = object.send(machine_name)
|
252
|
+
object.send("#{event}!")
|
253
|
+
state_changed = initial_state != object.send(machine_name)
|
254
|
+
default_message = "Expected event #{event} to trigger state change on #{machine_name}"
|
255
|
+
|
256
|
+
if defined?(::Minitest)
|
257
|
+
assert state_changed, message || default_message
|
258
|
+
elsif defined?(::RSpec)
|
259
|
+
expect(state_changed).to be_truthy, message || default_message
|
260
|
+
else
|
261
|
+
raise default_message unless state_changed
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
def refute_sm_event_triggers(object, event, machine_name = :state, message = nil)
|
266
|
+
initial_state = object.send(machine_name)
|
267
|
+
begin
|
268
|
+
object.send("#{event}!")
|
269
|
+
state_unchanged = initial_state == object.send(machine_name)
|
270
|
+
default_message = "Expected event #{event} to not trigger state change on #{machine_name}"
|
271
|
+
|
272
|
+
if defined?(::Minitest)
|
273
|
+
assert state_unchanged, message || default_message
|
274
|
+
elsif defined?(::RSpec)
|
275
|
+
expect(state_unchanged).to be_truthy, message || default_message
|
276
|
+
else
|
277
|
+
raise default_message unless state_unchanged
|
278
|
+
end
|
279
|
+
rescue StateMachines::InvalidTransition
|
280
|
+
# Expected behavior - transition was blocked
|
281
|
+
end
|
282
|
+
end
|
283
|
+
alias assert_sm_event_not_triggers refute_sm_event_triggers
|
284
|
+
|
285
|
+
def assert_sm_event_raises_error(object, event, error_class, message = nil)
|
286
|
+
default_message = "Expected event #{event} to raise #{error_class}"
|
287
|
+
|
288
|
+
if defined?(::Minitest)
|
289
|
+
assert_raises(error_class, message || default_message) do
|
290
|
+
object.send("#{event}!")
|
291
|
+
end
|
292
|
+
elsif defined?(::RSpec)
|
293
|
+
expect { object.send("#{event}!") }.to raise_error(error_class), message || default_message
|
294
|
+
else
|
295
|
+
begin
|
296
|
+
object.send("#{event}!")
|
297
|
+
raise default_message
|
298
|
+
rescue error_class
|
299
|
+
# Expected behavior
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
def assert_sm_callback_executed(object, callback_name, message = nil)
|
305
|
+
callbacks_executed = object.instance_variable_get(:@_sm_callbacks_executed) || []
|
306
|
+
callback_was_executed = callbacks_executed.include?(callback_name)
|
307
|
+
default_message = "Expected callback #{callback_name} to be executed"
|
308
|
+
|
309
|
+
if defined?(::Minitest)
|
310
|
+
assert callback_was_executed, message || default_message
|
311
|
+
elsif defined?(::RSpec)
|
312
|
+
expect(callback_was_executed).to be_truthy, message || default_message
|
313
|
+
else
|
314
|
+
raise default_message unless callback_was_executed
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def refute_sm_callback_executed(object, callback_name, message = nil)
|
319
|
+
callbacks_executed = object.instance_variable_get(:@_sm_callbacks_executed) || []
|
320
|
+
callback_was_executed = callbacks_executed.include?(callback_name)
|
321
|
+
default_message = "Expected callback #{callback_name} to not be executed"
|
322
|
+
|
323
|
+
if defined?(::Minitest)
|
324
|
+
refute callback_was_executed, message || default_message
|
325
|
+
elsif defined?(::RSpec)
|
326
|
+
expect(callback_was_executed).to be_falsy, message || default_message
|
327
|
+
elsif callback_was_executed
|
328
|
+
raise default_message
|
329
|
+
end
|
330
|
+
end
|
331
|
+
alias assert_sm_callback_not_executed refute_sm_callback_executed
|
332
|
+
|
333
|
+
# Assert that a record's state is persisted correctly for a specific state machine
|
334
|
+
#
|
335
|
+
# @param record [Object] The record to check (should respond to reload)
|
336
|
+
# @param expected [String, Symbol] The expected persisted state
|
337
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
338
|
+
# @param message [String, nil] Custom failure message
|
339
|
+
# @return [void]
|
340
|
+
# @raise [AssertionError] If the persisted state doesn't match
|
341
|
+
#
|
342
|
+
# @example
|
343
|
+
# # Default state machine
|
344
|
+
# assert_sm_state_persisted(user, "active")
|
345
|
+
#
|
346
|
+
# # Specific state machine
|
347
|
+
# assert_sm_state_persisted(ship, "up", :shields)
|
348
|
+
# assert_sm_state_persisted(ship, "armed", :weapons)
|
349
|
+
def assert_sm_state_persisted(record, expected, machine_name = :state, message = nil)
|
350
|
+
record.reload if record.respond_to?(:reload)
|
351
|
+
actual_state = record.send(machine_name)
|
352
|
+
default_message = "Expected persisted state #{expected} for #{machine_name} but got #{actual_state}"
|
353
|
+
|
354
|
+
if defined?(::Minitest)
|
355
|
+
assert_equal expected, actual_state, message || default_message
|
356
|
+
elsif defined?(::RSpec)
|
357
|
+
expect(actual_state).to eq(expected), message || default_message
|
358
|
+
else
|
359
|
+
raise default_message unless expected == actual_state
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
# Assert that executing a block triggers one or more expected events
|
364
|
+
#
|
365
|
+
# @param object [Object] The object with state machines
|
366
|
+
# @param expected_events [Symbol, Array<Symbol>] The event(s) expected to be triggered
|
367
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
368
|
+
# @param message [String, nil] Custom failure message
|
369
|
+
# @return [void]
|
370
|
+
# @raise [AssertionError] If the expected events were not triggered
|
371
|
+
#
|
372
|
+
# @example
|
373
|
+
# # Single event
|
374
|
+
# assert_sm_triggers_event(vehicle, :crash) { vehicle.redline }
|
375
|
+
#
|
376
|
+
# # Multiple events
|
377
|
+
# assert_sm_triggers_event(vehicle, [:crash, :emergency]) { vehicle.emergency_stop }
|
378
|
+
#
|
379
|
+
# # Specific machine
|
380
|
+
# assert_sm_triggers_event(vehicle, :disable, machine_name: :alarm) { vehicle.turn_off_alarm }
|
381
|
+
def assert_sm_triggers_event(object, expected_events, machine_name: :state, message: nil)
|
382
|
+
expected_events = Array(expected_events)
|
383
|
+
triggered_events = []
|
384
|
+
|
385
|
+
# Get the state machine
|
386
|
+
machine = object.class.state_machines[machine_name]
|
387
|
+
raise ArgumentError, "No state machine found for #{machine_name}" unless machine
|
388
|
+
|
389
|
+
# Save original callbacks to restore later
|
390
|
+
machine.callbacks[:before].dup
|
391
|
+
|
392
|
+
# Add a temporary callback to track triggered events
|
393
|
+
temp_callback = machine.before_transition do |_obj, transition|
|
394
|
+
triggered_events << transition.event if transition.event
|
395
|
+
end
|
396
|
+
|
397
|
+
begin
|
398
|
+
# Execute the block
|
399
|
+
yield
|
400
|
+
|
401
|
+
# Check if expected events were triggered
|
402
|
+
missing_events = expected_events - triggered_events
|
403
|
+
extra_events = triggered_events - expected_events
|
404
|
+
|
405
|
+
unless missing_events.empty? && extra_events.empty?
|
406
|
+
default_message = "Expected events #{expected_events.inspect} to be triggered, but got #{triggered_events.inspect}"
|
407
|
+
default_message += ". Missing: #{missing_events.inspect}" if missing_events.any?
|
408
|
+
default_message += ". Extra: #{extra_events.inspect}" if extra_events.any?
|
409
|
+
|
410
|
+
if defined?(::Minitest)
|
411
|
+
assert false, message || default_message
|
412
|
+
elsif defined?(::RSpec)
|
413
|
+
raise message || default_message
|
414
|
+
else
|
415
|
+
raise default_message
|
416
|
+
end
|
417
|
+
end
|
418
|
+
ensure
|
419
|
+
# Restore original callbacks by removing the temporary one
|
420
|
+
machine.callbacks[:before].delete(temp_callback)
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
# Assert that a before_transition callback is defined with expected arguments
|
425
|
+
#
|
426
|
+
# @param machine_or_class [StateMachines::Machine, Class] The machine or class to check
|
427
|
+
# @param options [Hash] Expected callback options (on:, from:, to:, do:, if:, unless:)
|
428
|
+
# @param message [String, nil] Custom failure message
|
429
|
+
# @return [void]
|
430
|
+
# @raise [AssertionError] If the callback is not defined
|
431
|
+
#
|
432
|
+
# @example
|
433
|
+
# # Check for specific transition callback
|
434
|
+
# assert_before_transition(Vehicle, on: :crash, do: :emergency_stop)
|
435
|
+
#
|
436
|
+
# # Check with from/to states
|
437
|
+
# assert_before_transition(Vehicle.state_machine, from: :parked, to: :idling, do: :start_engine)
|
438
|
+
#
|
439
|
+
# # Check with conditions
|
440
|
+
# assert_before_transition(Vehicle, on: :ignite, if: :seatbelt_on?)
|
441
|
+
def assert_before_transition(machine_or_class, options = {}, message = nil)
|
442
|
+
_assert_transition_callback(:before, machine_or_class, options, message)
|
443
|
+
end
|
444
|
+
|
445
|
+
# Assert that an after_transition callback is defined with expected arguments
|
446
|
+
#
|
447
|
+
# @param machine_or_class [StateMachines::Machine, Class] The machine or class to check
|
448
|
+
# @param options [Hash] Expected callback options (on:, from:, to:, do:, if:, unless:)
|
449
|
+
# @param message [String, nil] Custom failure message
|
450
|
+
# @return [void]
|
451
|
+
# @raise [AssertionError] If the callback is not defined
|
452
|
+
#
|
453
|
+
# @example
|
454
|
+
# # Check for specific transition callback
|
455
|
+
# assert_after_transition(Vehicle, on: :crash, do: :tow)
|
456
|
+
#
|
457
|
+
# # Check with from/to states
|
458
|
+
# assert_after_transition(Vehicle.state_machine, from: :stalled, to: :parked, do: :log_repair)
|
459
|
+
def assert_after_transition(machine_or_class, options = {}, message = nil)
|
460
|
+
_assert_transition_callback(:after, machine_or_class, options, message)
|
461
|
+
end
|
462
|
+
|
463
|
+
# RSpec-style aliases for event triggering (for consistency with RSpec expectations)
|
464
|
+
alias expect_to_trigger_event assert_sm_triggers_event
|
465
|
+
alias have_triggered_event assert_sm_triggers_event
|
466
|
+
|
467
|
+
private
|
468
|
+
|
469
|
+
# Internal helper for checking transition callbacks
|
470
|
+
def _assert_transition_callback(callback_type, machine_or_class, options, message)
|
471
|
+
# Get the machine
|
472
|
+
machine = machine_or_class.is_a?(StateMachines::Machine) ? machine_or_class : machine_or_class.state_machine
|
473
|
+
raise ArgumentError, 'No state machine found' unless machine
|
474
|
+
|
475
|
+
callbacks = machine.callbacks[callback_type] || []
|
476
|
+
|
477
|
+
# Extract expected conditions
|
478
|
+
expected_event = options[:on]
|
479
|
+
expected_from = options[:from]
|
480
|
+
expected_to = options[:to]
|
481
|
+
expected_method = options[:do]
|
482
|
+
expected_if = options[:if]
|
483
|
+
expected_unless = options[:unless]
|
484
|
+
|
485
|
+
# Find matching callback
|
486
|
+
matching_callback = callbacks.find do |callback|
|
487
|
+
branch = callback.branch
|
488
|
+
|
489
|
+
# Check event requirement
|
490
|
+
if expected_event
|
491
|
+
event_requirement = branch.event_requirement
|
492
|
+
event_matches = if event_requirement && event_requirement.respond_to?(:values)
|
493
|
+
event_requirement.values.include?(expected_event)
|
494
|
+
else
|
495
|
+
false
|
496
|
+
end
|
497
|
+
next false unless event_matches
|
498
|
+
end
|
499
|
+
|
500
|
+
# Check state requirements (from/to)
|
501
|
+
if expected_from || expected_to
|
502
|
+
state_matches = false
|
503
|
+
branch.state_requirements.each do |req|
|
504
|
+
from_matches = !expected_from || (req[:from] && req[:from].respond_to?(:values) && req[:from].values.include?(expected_from))
|
505
|
+
to_matches = !expected_to || (req[:to] && req[:to].respond_to?(:values) && req[:to].values.include?(expected_to))
|
506
|
+
|
507
|
+
if from_matches && to_matches
|
508
|
+
state_matches = true
|
509
|
+
break
|
510
|
+
end
|
511
|
+
end
|
512
|
+
next false unless state_matches
|
513
|
+
end
|
514
|
+
|
515
|
+
# Check method requirement
|
516
|
+
if expected_method
|
517
|
+
methods = callback.instance_variable_get(:@methods) || []
|
518
|
+
method_matches = methods.any? do |method|
|
519
|
+
(method.is_a?(Symbol) && method == expected_method) ||
|
520
|
+
(method.is_a?(String) && method.to_sym == expected_method) ||
|
521
|
+
(method.respond_to?(:call) && method.respond_to?(:source_location))
|
522
|
+
end
|
523
|
+
next false unless method_matches
|
524
|
+
end
|
525
|
+
|
526
|
+
# Check if condition
|
527
|
+
if expected_if
|
528
|
+
if_condition = branch.if_condition
|
529
|
+
if_matches = (if_condition.is_a?(Symbol) && if_condition == expected_if) ||
|
530
|
+
(if_condition.is_a?(String) && if_condition.to_sym == expected_if) ||
|
531
|
+
if_condition.respond_to?(:call)
|
532
|
+
next false unless if_matches
|
533
|
+
end
|
534
|
+
|
535
|
+
# Check unless condition
|
536
|
+
if expected_unless
|
537
|
+
unless_condition = branch.unless_condition
|
538
|
+
unless_matches = (unless_condition.is_a?(Symbol) && unless_condition == expected_unless) ||
|
539
|
+
(unless_condition.is_a?(String) && unless_condition.to_sym == expected_unless) ||
|
540
|
+
unless_condition.respond_to?(:call)
|
541
|
+
next false unless unless_matches
|
542
|
+
end
|
543
|
+
|
544
|
+
true
|
545
|
+
end
|
546
|
+
|
547
|
+
return if matching_callback
|
548
|
+
|
549
|
+
expected_parts = []
|
550
|
+
expected_parts << "on: #{expected_event.inspect}" if expected_event
|
551
|
+
expected_parts << "from: #{expected_from.inspect}" if expected_from
|
552
|
+
expected_parts << "to: #{expected_to.inspect}" if expected_to
|
553
|
+
expected_parts << "do: #{expected_method.inspect}" if expected_method
|
554
|
+
expected_parts << "if: #{expected_if.inspect}" if expected_if
|
555
|
+
expected_parts << "unless: #{expected_unless.inspect}" if expected_unless
|
556
|
+
|
557
|
+
default_message = "Expected #{callback_type}_transition callback with #{expected_parts.join(', ')} to be defined, but it was not found"
|
558
|
+
|
559
|
+
if defined?(::Minitest)
|
560
|
+
assert false, message || default_message
|
561
|
+
elsif defined?(::RSpec)
|
562
|
+
raise message || default_message
|
563
|
+
else
|
564
|
+
raise default_message
|
565
|
+
end
|
566
|
+
end
|
567
|
+
end
|
568
|
+
end
|