state_machines 0.10.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +177 -2
  3. data/lib/state_machines/branch.rb +16 -15
  4. data/lib/state_machines/callback.rb +11 -12
  5. data/lib/state_machines/core.rb +1 -3
  6. data/lib/state_machines/error.rb +5 -4
  7. data/lib/state_machines/eval_helpers.rb +83 -45
  8. data/lib/state_machines/event.rb +37 -27
  9. data/lib/state_machines/event_collection.rb +4 -5
  10. data/lib/state_machines/extensions.rb +5 -5
  11. data/lib/state_machines/helper_module.rb +1 -1
  12. data/lib/state_machines/integrations/base.rb +1 -1
  13. data/lib/state_machines/integrations.rb +11 -14
  14. data/lib/state_machines/machine/action_hooks.rb +53 -0
  15. data/lib/state_machines/machine/callbacks.rb +59 -0
  16. data/lib/state_machines/machine/class_methods.rb +25 -11
  17. data/lib/state_machines/machine/configuration.rb +124 -0
  18. data/lib/state_machines/machine/event_methods.rb +59 -0
  19. data/lib/state_machines/machine/helper_generators.rb +125 -0
  20. data/lib/state_machines/machine/integration.rb +70 -0
  21. data/lib/state_machines/machine/parsing.rb +77 -0
  22. data/lib/state_machines/machine/rendering.rb +17 -0
  23. data/lib/state_machines/machine/scoping.rb +44 -0
  24. data/lib/state_machines/machine/state_methods.rb +101 -0
  25. data/lib/state_machines/machine/utilities.rb +85 -0
  26. data/lib/state_machines/machine/validation.rb +39 -0
  27. data/lib/state_machines/machine.rb +75 -618
  28. data/lib/state_machines/machine_collection.rb +21 -15
  29. data/lib/state_machines/macro_methods.rb +2 -2
  30. data/lib/state_machines/matcher.rb +6 -6
  31. data/lib/state_machines/matcher_helpers.rb +1 -1
  32. data/lib/state_machines/node_collection.rb +21 -18
  33. data/lib/state_machines/options_validator.rb +72 -0
  34. data/lib/state_machines/path.rb +5 -5
  35. data/lib/state_machines/path_collection.rb +5 -4
  36. data/lib/state_machines/state.rb +29 -11
  37. data/lib/state_machines/state_collection.rb +3 -3
  38. data/lib/state_machines/state_context.rb +9 -8
  39. data/lib/state_machines/stdio_renderer.rb +16 -16
  40. data/lib/state_machines/syntax_validator.rb +57 -0
  41. data/lib/state_machines/test_helper.rb +568 -0
  42. data/lib/state_machines/transition.rb +43 -41
  43. data/lib/state_machines/transition_collection.rb +25 -26
  44. data/lib/state_machines/version.rb +1 -1
  45. metadata +25 -10
  46. data/lib/state_machines/assertions.rb +0 -42
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ripper'
4
+
5
+ module StateMachines
6
+ # Cross-platform syntax validation for eval strings
7
+ # Supports CRuby, JRuby, TruffleRuby via pluggable backends
8
+ module SyntaxValidator
9
+ # Public API: raises SyntaxError if code is invalid
10
+ def validate!(code, filename = '(eval)')
11
+ backend.validate!(code, filename)
12
+ end
13
+ module_function :validate!
14
+
15
+ private
16
+
17
+ # Lazily pick the best backend for this platform
18
+ # Prefer RubyVM for performance on CRuby, fallback to Ripper for compatibility
19
+ def backend
20
+ @backend ||= if RubyVmBackend.available?
21
+ RubyVmBackend
22
+ else
23
+ RipperBackend
24
+ end
25
+ end
26
+ module_function :backend
27
+
28
+ # MRI backend using RubyVM::InstructionSequence
29
+ module RubyVmBackend
30
+ def available?
31
+ RUBY_ENGINE == 'ruby'
32
+ end
33
+ module_function :available?
34
+
35
+ def validate!(code, filename)
36
+ # compile will raise a SyntaxError on bad syntax
37
+ RubyVM::InstructionSequence.compile(code, filename)
38
+ true
39
+ end
40
+ module_function :validate!
41
+ end
42
+
43
+ # Universal Ruby backend via Ripper
44
+ module RipperBackend
45
+ def validate!(code, filename)
46
+ sexp = Ripper.sexp(code)
47
+ if sexp.nil?
48
+ # Ripper.sexp returns nil on a parse error, but no exception
49
+ raise SyntaxError, "syntax error in #{filename}"
50
+ end
51
+
52
+ true
53
+ end
54
+ module_function :validate!
55
+ end
56
+ end
57
+ end
@@ -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