ultra-smart-sys 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.
- checksums.yaml +7 -0
- data/state_machines-0.201.0/LICENSE.txt +23 -0
- data/state_machines-0.201.0/README.md +1024 -0
- data/state_machines-0.201.0/lib/state_machines/async_mode/async_event_extensions.rb +49 -0
- data/state_machines-0.201.0/lib/state_machines/async_mode/async_events.rb +282 -0
- data/state_machines-0.201.0/lib/state_machines/async_mode/async_machine.rb +60 -0
- data/state_machines-0.201.0/lib/state_machines/async_mode/async_transition_collection.rb +136 -0
- data/state_machines-0.201.0/lib/state_machines/async_mode/thread_safe_state.rb +47 -0
- data/state_machines-0.201.0/lib/state_machines/async_mode.rb +64 -0
- data/state_machines-0.201.0/lib/state_machines/branch.rb +246 -0
- data/state_machines-0.201.0/lib/state_machines/callback.rb +223 -0
- data/state_machines-0.201.0/lib/state_machines/core.rb +43 -0
- data/state_machines-0.201.0/lib/state_machines/core_ext/class/state_machine.rb +5 -0
- data/state_machines-0.201.0/lib/state_machines/core_ext.rb +4 -0
- data/state_machines-0.201.0/lib/state_machines/error.rb +115 -0
- data/state_machines-0.201.0/lib/state_machines/eval_helpers.rb +227 -0
- data/state_machines-0.201.0/lib/state_machines/event.rb +247 -0
- data/state_machines-0.201.0/lib/state_machines/event_collection.rb +149 -0
- data/state_machines-0.201.0/lib/state_machines/extensions.rb +150 -0
- data/state_machines-0.201.0/lib/state_machines/helper_module.rb +19 -0
- data/state_machines-0.201.0/lib/state_machines/integrations/base.rb +49 -0
- data/state_machines-0.201.0/lib/state_machines/integrations.rb +111 -0
- data/state_machines-0.201.0/lib/state_machines/machine/action_hooks.rb +53 -0
- data/state_machines-0.201.0/lib/state_machines/machine/async_extensions.rb +88 -0
- data/state_machines-0.201.0/lib/state_machines/machine/callbacks.rb +333 -0
- data/state_machines-0.201.0/lib/state_machines/machine/class_methods.rb +95 -0
- data/state_machines-0.201.0/lib/state_machines/machine/configuration.rb +128 -0
- data/state_machines-0.201.0/lib/state_machines/machine/event_methods.rb +436 -0
- data/state_machines-0.201.0/lib/state_machines/machine/helper_generators.rb +125 -0
- data/state_machines-0.201.0/lib/state_machines/machine/integration.rb +92 -0
- data/state_machines-0.201.0/lib/state_machines/machine/parsing.rb +77 -0
- data/state_machines-0.201.0/lib/state_machines/machine/rendering.rb +17 -0
- data/state_machines-0.201.0/lib/state_machines/machine/scoping.rb +44 -0
- data/state_machines-0.201.0/lib/state_machines/machine/state_methods.rb +398 -0
- data/state_machines-0.201.0/lib/state_machines/machine/utilities.rb +86 -0
- data/state_machines-0.201.0/lib/state_machines/machine/validation.rb +39 -0
- data/state_machines-0.201.0/lib/state_machines/machine.rb +615 -0
- data/state_machines-0.201.0/lib/state_machines/machine_collection.rb +105 -0
- data/state_machines-0.201.0/lib/state_machines/macro_methods.rb +522 -0
- data/state_machines-0.201.0/lib/state_machines/matcher.rb +124 -0
- data/state_machines-0.201.0/lib/state_machines/matcher_helpers.rb +56 -0
- data/state_machines-0.201.0/lib/state_machines/node_collection.rb +226 -0
- data/state_machines-0.201.0/lib/state_machines/options_validator.rb +72 -0
- data/state_machines-0.201.0/lib/state_machines/path.rb +123 -0
- data/state_machines-0.201.0/lib/state_machines/path_collection.rb +91 -0
- data/state_machines-0.201.0/lib/state_machines/state.rb +314 -0
- data/state_machines-0.201.0/lib/state_machines/state_collection.rb +113 -0
- data/state_machines-0.201.0/lib/state_machines/state_context.rb +134 -0
- data/state_machines-0.201.0/lib/state_machines/stdio_renderer.rb +74 -0
- data/state_machines-0.201.0/lib/state_machines/syntax_validator.rb +54 -0
- data/state_machines-0.201.0/lib/state_machines/test_helper.rb +775 -0
- data/state_machines-0.201.0/lib/state_machines/transition.rb +593 -0
- data/state_machines-0.201.0/lib/state_machines/transition_collection.rb +310 -0
- data/state_machines-0.201.0/lib/state_machines/version.rb +5 -0
- data/state_machines-0.201.0/lib/state_machines.rb +6 -0
- data/ultra-smart-sys.gemspec +12 -0
- metadata +96 -0
|
@@ -0,0 +1,775 @@
|
|
|
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
|
+
_sm_assert_equal(expected_state.to_s, actual.to_s, message || default_message)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Assert that an object can transition via a specific event
|
|
62
|
+
#
|
|
63
|
+
# @param object [Object] The object with state machines
|
|
64
|
+
# @param event [Symbol] The event name
|
|
65
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
66
|
+
# @param message [String, nil] Custom failure message
|
|
67
|
+
# @return [void]
|
|
68
|
+
# @raise [AssertionError] If the transition is not available
|
|
69
|
+
#
|
|
70
|
+
# @example
|
|
71
|
+
# user = User.new
|
|
72
|
+
# assert_sm_can_transition(user, :activate) # Uses default :state machine
|
|
73
|
+
# assert_sm_can_transition(user, :activate, machine_name: :status) # Uses :status machine
|
|
74
|
+
def assert_sm_can_transition(object, event, machine_name: :state, message: nil)
|
|
75
|
+
can_method = _sm_find_can_method(object, event, machine_name)
|
|
76
|
+
default_message = "Expected to be able to trigger event :#{event} on #{machine_name}, but #{can_method} returned false"
|
|
77
|
+
|
|
78
|
+
_sm_assert(object.send(can_method), message || default_message)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Assert that an object cannot transition via a specific event
|
|
82
|
+
#
|
|
83
|
+
# @param object [Object] The object with state machines
|
|
84
|
+
# @param event [Symbol] The event name
|
|
85
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
86
|
+
# @param message [String, nil] Custom failure message
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [AssertionError] If the transition is available
|
|
89
|
+
#
|
|
90
|
+
# @example
|
|
91
|
+
# user = User.new
|
|
92
|
+
# assert_sm_cannot_transition(user, :delete) # Uses default :state machine
|
|
93
|
+
# assert_sm_cannot_transition(user, :delete, machine_name: :status) # Uses :status machine
|
|
94
|
+
def assert_sm_cannot_transition(object, event, machine_name: :state, message: nil)
|
|
95
|
+
can_method = _sm_find_can_method(object, event, machine_name)
|
|
96
|
+
default_message = "Expected not to be able to trigger event :#{event} on #{machine_name}, but #{can_method} returned true"
|
|
97
|
+
|
|
98
|
+
_sm_refute(object.send(can_method), message || default_message)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Assert that triggering an event changes the object to the expected state
|
|
102
|
+
#
|
|
103
|
+
# @param object [Object] The object with state machines
|
|
104
|
+
# @param event [Symbol] The event to trigger
|
|
105
|
+
# @param expected_state [Symbol] The expected state after transition
|
|
106
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
107
|
+
# @param message [String, nil] Custom failure message
|
|
108
|
+
# @return [void]
|
|
109
|
+
# @raise [AssertionError] If the transition fails or results in wrong state
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# user = User.new
|
|
113
|
+
# assert_sm_transition(user, :activate, :active) # Uses default :state machine
|
|
114
|
+
# assert_sm_transition(user, :activate, :active, machine_name: :status) # Uses :status machine
|
|
115
|
+
def assert_sm_transition(object, event, expected_state, machine_name: :state, message: nil)
|
|
116
|
+
object.send("#{event}!")
|
|
117
|
+
assert_sm_state(object, expected_state, machine_name: machine_name, message: message)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# === Extended State Machine Assertions ===
|
|
121
|
+
|
|
122
|
+
def assert_sm_states_list(machine, expected_states, message = nil)
|
|
123
|
+
actual_states = machine.states.map(&:name).compact
|
|
124
|
+
default_message = "Expected states #{expected_states} but got #{actual_states}"
|
|
125
|
+
|
|
126
|
+
_sm_assert_equal(expected_states.sort, actual_states.sort, message || default_message)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def refute_sm_state_defined(machine, state, message = nil)
|
|
130
|
+
state_exists = machine.states.any? { |s| s.name == state }
|
|
131
|
+
default_message = "Expected state #{state} to not be defined in machine"
|
|
132
|
+
|
|
133
|
+
_sm_refute(state_exists, message || default_message)
|
|
134
|
+
end
|
|
135
|
+
alias assert_sm_state_not_defined refute_sm_state_defined
|
|
136
|
+
|
|
137
|
+
def assert_sm_initial_state(machine, expected_state, message = nil)
|
|
138
|
+
state_obj = machine.state(expected_state)
|
|
139
|
+
is_initial = state_obj&.initial?
|
|
140
|
+
default_message = "Expected state #{expected_state} to be the initial state"
|
|
141
|
+
|
|
142
|
+
_sm_assert(is_initial, message || default_message)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def assert_sm_final_state(machine, state, message = nil)
|
|
146
|
+
state_obj = machine.states[state]
|
|
147
|
+
is_final = state_obj&.final?
|
|
148
|
+
default_message = "Expected state #{state} to be final"
|
|
149
|
+
|
|
150
|
+
_sm_assert(is_final, message || default_message)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def assert_sm_possible_transitions(machine, from:, expected_to_states:, message: nil)
|
|
154
|
+
actual_transitions = machine.events.flat_map do |event|
|
|
155
|
+
event.branches.select { |branch| branch.known_states.include?(from) }
|
|
156
|
+
.map(&:to)
|
|
157
|
+
end.uniq
|
|
158
|
+
default_message = "Expected transitions from #{from} to #{expected_to_states} but got #{actual_transitions}"
|
|
159
|
+
|
|
160
|
+
_sm_assert_equal(expected_to_states.sort, actual_transitions.sort, message || default_message)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def refute_sm_transition_allowed(machine, from:, to:, on:, message: nil)
|
|
164
|
+
event = machine.events[on]
|
|
165
|
+
is_allowed = event&.branches&.any? { |branch| branch.known_states.include?(from) && branch.to == to }
|
|
166
|
+
default_message = "Expected transition from #{from} to #{to} on #{on} to not be allowed"
|
|
167
|
+
|
|
168
|
+
_sm_refute(is_allowed, message || default_message)
|
|
169
|
+
end
|
|
170
|
+
alias assert_sm_transition_not_allowed refute_sm_transition_allowed
|
|
171
|
+
|
|
172
|
+
def assert_sm_event_triggers(object, event, machine_name = :state, message = nil)
|
|
173
|
+
initial_state = object.send(machine_name)
|
|
174
|
+
object.send("#{event}!")
|
|
175
|
+
state_changed = initial_state != object.send(machine_name)
|
|
176
|
+
default_message = "Expected event #{event} to trigger state change on #{machine_name}"
|
|
177
|
+
|
|
178
|
+
_sm_assert(state_changed, message || default_message)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def refute_sm_event_triggers(object, event, machine_name = :state, message = nil)
|
|
182
|
+
initial_state = object.send(machine_name)
|
|
183
|
+
begin
|
|
184
|
+
object.send("#{event}!")
|
|
185
|
+
state_unchanged = initial_state == object.send(machine_name)
|
|
186
|
+
default_message = "Expected event #{event} to not trigger state change on #{machine_name}"
|
|
187
|
+
|
|
188
|
+
_sm_assert(state_unchanged, message || default_message)
|
|
189
|
+
rescue StateMachines::InvalidTransition
|
|
190
|
+
# Expected behavior - transition was blocked
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
alias assert_sm_event_not_triggers refute_sm_event_triggers
|
|
194
|
+
|
|
195
|
+
def assert_sm_event_raises_error(object, event, error_class, message = nil)
|
|
196
|
+
default_message = "Expected event #{event} to raise #{error_class}"
|
|
197
|
+
|
|
198
|
+
if defined?(::Minitest)
|
|
199
|
+
assert_raises(error_class, message || default_message) do
|
|
200
|
+
object.send("#{event}!")
|
|
201
|
+
end
|
|
202
|
+
elsif defined?(::RSpec)
|
|
203
|
+
expect { object.send("#{event}!") }.to raise_error(error_class), message || default_message
|
|
204
|
+
else
|
|
205
|
+
begin
|
|
206
|
+
object.send("#{event}!")
|
|
207
|
+
raise default_message
|
|
208
|
+
rescue error_class
|
|
209
|
+
# Expected behavior
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def assert_sm_callback_executed(object, callback_name, message = nil)
|
|
215
|
+
callbacks_executed = object.instance_variable_get(:@_sm_callbacks_executed) || []
|
|
216
|
+
callback_was_executed = callbacks_executed.include?(callback_name)
|
|
217
|
+
default_message = "Expected callback #{callback_name} to be executed"
|
|
218
|
+
|
|
219
|
+
_sm_assert(callback_was_executed, message || default_message)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def refute_sm_callback_executed(object, callback_name, message = nil)
|
|
223
|
+
callbacks_executed = object.instance_variable_get(:@_sm_callbacks_executed) || []
|
|
224
|
+
callback_was_executed = callbacks_executed.include?(callback_name)
|
|
225
|
+
default_message = "Expected callback #{callback_name} to not be executed"
|
|
226
|
+
|
|
227
|
+
_sm_refute(callback_was_executed, message || default_message)
|
|
228
|
+
end
|
|
229
|
+
alias assert_sm_callback_not_executed refute_sm_callback_executed
|
|
230
|
+
|
|
231
|
+
# Assert that a record's state is persisted correctly for a specific state machine
|
|
232
|
+
#
|
|
233
|
+
# @param record [Object] The record to check (should respond to reload)
|
|
234
|
+
# @param expected [String, Symbol] The expected persisted state
|
|
235
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
236
|
+
# @param message [String, nil] Custom failure message
|
|
237
|
+
# @return [void]
|
|
238
|
+
# @raise [AssertionError] If the persisted state doesn't match
|
|
239
|
+
#
|
|
240
|
+
# @example
|
|
241
|
+
# # Default state machine
|
|
242
|
+
# assert_sm_state_persisted(user, "active")
|
|
243
|
+
#
|
|
244
|
+
# # Specific state machine
|
|
245
|
+
# assert_sm_state_persisted(ship, "up", :shields)
|
|
246
|
+
# assert_sm_state_persisted(ship, "armed", :weapons)
|
|
247
|
+
def assert_sm_state_persisted(record, expected, machine_name = :state, message = nil)
|
|
248
|
+
record.reload if record.respond_to?(:reload)
|
|
249
|
+
actual_state = record.send(machine_name)
|
|
250
|
+
default_message = "Expected persisted state #{expected} for #{machine_name} but got #{actual_state}"
|
|
251
|
+
|
|
252
|
+
_sm_assert_equal(expected, actual_state, message || default_message)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Assert that executing a block triggers one or more expected events
|
|
256
|
+
#
|
|
257
|
+
# @param object [Object] The object with state machines
|
|
258
|
+
# @param expected_events [Symbol, Array<Symbol>] The event(s) expected to be triggered
|
|
259
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
260
|
+
# @param message [String, nil] Custom failure message
|
|
261
|
+
# @return [void]
|
|
262
|
+
# @raise [AssertionError] If the expected events were not triggered
|
|
263
|
+
#
|
|
264
|
+
# @example
|
|
265
|
+
# # Single event
|
|
266
|
+
# assert_sm_triggers_event(vehicle, :crash) { vehicle.redline }
|
|
267
|
+
#
|
|
268
|
+
# # Multiple events
|
|
269
|
+
# assert_sm_triggers_event(vehicle, [:crash, :emergency]) { vehicle.emergency_stop }
|
|
270
|
+
#
|
|
271
|
+
# # Specific machine
|
|
272
|
+
# assert_sm_triggers_event(vehicle, :disable, machine_name: :alarm) { vehicle.turn_off_alarm }
|
|
273
|
+
def assert_sm_triggers_event(object, expected_events, machine_name: :state, message: nil)
|
|
274
|
+
expected_events = Array(expected_events)
|
|
275
|
+
triggered_events = []
|
|
276
|
+
|
|
277
|
+
# Get the state machine
|
|
278
|
+
machine = object.class.state_machines[machine_name]
|
|
279
|
+
raise ArgumentError, "No state machine found for #{machine_name}" unless machine
|
|
280
|
+
|
|
281
|
+
# Save original callbacks to restore later
|
|
282
|
+
machine.callbacks[:before].dup
|
|
283
|
+
|
|
284
|
+
# Add a temporary callback to track triggered events
|
|
285
|
+
temp_callback = machine.before_transition do |_obj, transition|
|
|
286
|
+
triggered_events << transition.event if transition.event
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
begin
|
|
290
|
+
# Execute the block
|
|
291
|
+
yield
|
|
292
|
+
|
|
293
|
+
# Check if expected events were triggered
|
|
294
|
+
missing_events = expected_events - triggered_events
|
|
295
|
+
extra_events = triggered_events - expected_events
|
|
296
|
+
|
|
297
|
+
unless missing_events.empty? && extra_events.empty?
|
|
298
|
+
default_message = "Expected events #{expected_events.inspect} to be triggered, but got #{triggered_events.inspect}"
|
|
299
|
+
default_message += ". Missing: #{missing_events.inspect}" if missing_events.any?
|
|
300
|
+
default_message += ". Extra: #{extra_events.inspect}" if extra_events.any?
|
|
301
|
+
|
|
302
|
+
_sm_flunk(message || default_message)
|
|
303
|
+
end
|
|
304
|
+
ensure
|
|
305
|
+
# Restore original callbacks by removing the temporary one
|
|
306
|
+
machine.callbacks[:before].delete(temp_callback)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Assert that a before_transition callback is defined with expected arguments
|
|
311
|
+
#
|
|
312
|
+
# @param machine_or_class [StateMachines::Machine, Class] The machine or class to check
|
|
313
|
+
# @param options [Hash] Expected callback options (on:, from:, to:, do:, if:, unless:)
|
|
314
|
+
# @param message [String, nil] Custom failure message
|
|
315
|
+
# @return [void]
|
|
316
|
+
# @raise [AssertionError] If the callback is not defined
|
|
317
|
+
#
|
|
318
|
+
# @example
|
|
319
|
+
# # Check for specific transition callback
|
|
320
|
+
# assert_before_transition(Vehicle, on: :crash, do: :emergency_stop)
|
|
321
|
+
#
|
|
322
|
+
# # Check with from/to states
|
|
323
|
+
# assert_before_transition(Vehicle.state_machine, from: :parked, to: :idling, do: :start_engine)
|
|
324
|
+
#
|
|
325
|
+
# # Check with conditions
|
|
326
|
+
# assert_before_transition(Vehicle, on: :ignite, if: :seatbelt_on?)
|
|
327
|
+
def assert_before_transition(machine_or_class, options = {}, message = nil)
|
|
328
|
+
_assert_transition_callback(:before, machine_or_class, options, message)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Assert that an after_transition callback is defined with expected arguments
|
|
332
|
+
#
|
|
333
|
+
# @param machine_or_class [StateMachines::Machine, Class] The machine or class to check
|
|
334
|
+
# @param options [Hash] Expected callback options (on:, from:, to:, do:, if:, unless:)
|
|
335
|
+
# @param message [String, nil] Custom failure message
|
|
336
|
+
# @return [void]
|
|
337
|
+
# @raise [AssertionError] If the callback is not defined
|
|
338
|
+
#
|
|
339
|
+
# @example
|
|
340
|
+
# # Check for specific transition callback
|
|
341
|
+
# assert_after_transition(Vehicle, on: :crash, do: :tow)
|
|
342
|
+
#
|
|
343
|
+
# # Check with from/to states
|
|
344
|
+
# assert_after_transition(Vehicle.state_machine, from: :stalled, to: :parked, do: :log_repair)
|
|
345
|
+
def assert_after_transition(machine_or_class, options = {}, message = nil)
|
|
346
|
+
_assert_transition_callback(:after, machine_or_class, options, message)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# === Sync Mode Assertions ===
|
|
350
|
+
|
|
351
|
+
# Assert that a state machine is operating in synchronous mode
|
|
352
|
+
#
|
|
353
|
+
# @param object [Object] The object with state machines
|
|
354
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
355
|
+
# @param message [String, nil] Custom failure message
|
|
356
|
+
# @return [void]
|
|
357
|
+
# @raise [AssertionError] If the machine has async mode enabled
|
|
358
|
+
#
|
|
359
|
+
# @example
|
|
360
|
+
# user = User.new
|
|
361
|
+
# assert_sm_sync_mode(user) # Uses default :state machine
|
|
362
|
+
# assert_sm_sync_mode(user, :status) # Uses :status machine
|
|
363
|
+
def assert_sm_sync_mode(object, machine_name = :state, message = nil)
|
|
364
|
+
machine = object.class.state_machines[machine_name]
|
|
365
|
+
raise ArgumentError, "No state machine '#{machine_name}' found" unless machine
|
|
366
|
+
|
|
367
|
+
async_enabled = machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
|
368
|
+
default_message = "Expected state machine '#{machine_name}' to be in sync mode, but async mode is enabled"
|
|
369
|
+
|
|
370
|
+
_sm_refute(async_enabled, message || default_message)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Assert that async methods are not available on a sync-only object
|
|
374
|
+
#
|
|
375
|
+
# @param object [Object] The object with state machines
|
|
376
|
+
# @param message [String, nil] Custom failure message
|
|
377
|
+
# @return [void]
|
|
378
|
+
# @raise [AssertionError] If async methods are available
|
|
379
|
+
#
|
|
380
|
+
# @example
|
|
381
|
+
# sync_only_car = Car.new # Car has no async: true machines
|
|
382
|
+
# assert_sm_no_async_methods(sync_only_car)
|
|
383
|
+
def assert_sm_no_async_methods(object, message = nil)
|
|
384
|
+
async_methods = %i[fire_event_async fire_events_async fire_event_async! async_fire_event]
|
|
385
|
+
available_async_methods = async_methods.select { |method| object.respond_to?(method) }
|
|
386
|
+
|
|
387
|
+
default_message = "Expected no async methods to be available, but found: #{available_async_methods.inspect}"
|
|
388
|
+
|
|
389
|
+
_sm_assert_empty(available_async_methods, message || default_message)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Assert that an object has no async-enabled state machines
|
|
393
|
+
#
|
|
394
|
+
# @param object [Object] The object with state machines
|
|
395
|
+
# @param message [String, nil] Custom failure message
|
|
396
|
+
# @return [void]
|
|
397
|
+
# @raise [AssertionError] If any machine has async mode enabled
|
|
398
|
+
#
|
|
399
|
+
# @example
|
|
400
|
+
# sync_only_vehicle = Vehicle.new # All machines are sync-only
|
|
401
|
+
# assert_sm_all_sync(sync_only_vehicle)
|
|
402
|
+
def assert_sm_all_sync(object, message = nil)
|
|
403
|
+
async_machines = []
|
|
404
|
+
|
|
405
|
+
object.class.state_machines.each do |name, machine|
|
|
406
|
+
if machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
|
407
|
+
async_machines << name
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
default_message = "Expected all state machines to be sync-only, but these have async enabled: #{async_machines.inspect}"
|
|
412
|
+
|
|
413
|
+
_sm_assert_empty(async_machines, message || default_message)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Assert that synchronous event execution works correctly
|
|
417
|
+
#
|
|
418
|
+
# @param object [Object] The object with state machines
|
|
419
|
+
# @param event [Symbol] The event to trigger
|
|
420
|
+
# @param expected_state [Symbol] The expected state after transition
|
|
421
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
422
|
+
# @param message [String, nil] Custom failure message
|
|
423
|
+
# @return [void]
|
|
424
|
+
# @raise [AssertionError] If sync execution fails
|
|
425
|
+
#
|
|
426
|
+
# @example
|
|
427
|
+
# car = Car.new
|
|
428
|
+
# assert_sm_sync_execution(car, :start, :running)
|
|
429
|
+
# assert_sm_sync_execution(car, :turn_on, :active, :alarm)
|
|
430
|
+
def assert_sm_sync_execution(object, event, expected_state, machine_name = :state, message = nil)
|
|
431
|
+
# Store initial state
|
|
432
|
+
initial_state = object.send(machine_name)
|
|
433
|
+
|
|
434
|
+
# Execute event synchronously
|
|
435
|
+
result = object.send("#{event}!")
|
|
436
|
+
|
|
437
|
+
# Verify immediate state change (no async delay)
|
|
438
|
+
final_state = object.send(machine_name)
|
|
439
|
+
|
|
440
|
+
# Check that transition succeeded
|
|
441
|
+
state_changed = initial_state != final_state
|
|
442
|
+
correct_final_state = final_state.to_s == expected_state.to_s
|
|
443
|
+
|
|
444
|
+
default_message = "Expected sync execution of '#{event}' to change #{machine_name} from '#{initial_state}' to '#{expected_state}', but got '#{final_state}'"
|
|
445
|
+
|
|
446
|
+
_sm_assert(result, "Event #{event} should return true on success")
|
|
447
|
+
_sm_assert(state_changed, "State should change from #{initial_state}")
|
|
448
|
+
_sm_assert(correct_final_state, message || default_message)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Assert that event execution is immediate (no async delay)
|
|
452
|
+
#
|
|
453
|
+
# @param object [Object] The object with state machines
|
|
454
|
+
# @param event [Symbol] The event to trigger
|
|
455
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
456
|
+
# @param message [String, nil] Custom failure message
|
|
457
|
+
# @return [void]
|
|
458
|
+
# @raise [AssertionError] If execution appears to be async
|
|
459
|
+
#
|
|
460
|
+
# @example
|
|
461
|
+
# car = Car.new
|
|
462
|
+
# assert_sm_immediate_execution(car, :start)
|
|
463
|
+
def assert_sm_immediate_execution(object, event, machine_name = :state, message = nil)
|
|
464
|
+
initial_state = object.send(machine_name)
|
|
465
|
+
|
|
466
|
+
# Record start time and execute
|
|
467
|
+
start_time = Time.now
|
|
468
|
+
object.send("#{event}!")
|
|
469
|
+
execution_time = Time.now - start_time
|
|
470
|
+
|
|
471
|
+
final_state = object.send(machine_name)
|
|
472
|
+
state_changed = initial_state != final_state
|
|
473
|
+
|
|
474
|
+
# Should complete very quickly (under 10ms for sync operations)
|
|
475
|
+
is_immediate = execution_time < 0.01
|
|
476
|
+
|
|
477
|
+
default_message = "Expected immediate sync execution of '#{event}', but took #{execution_time}s (likely async)"
|
|
478
|
+
|
|
479
|
+
_sm_assert(state_changed, 'Event should trigger state change')
|
|
480
|
+
_sm_assert(is_immediate, message || default_message)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# === Async Mode Assertions ===
|
|
484
|
+
|
|
485
|
+
# Assert that a state machine is operating in asynchronous mode
|
|
486
|
+
#
|
|
487
|
+
# @param object [Object] The object with state machines
|
|
488
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
|
489
|
+
# @param message [String, nil] Custom failure message
|
|
490
|
+
# @return [void]
|
|
491
|
+
# @raise [AssertionError] If the machine doesn't have async mode enabled
|
|
492
|
+
#
|
|
493
|
+
# @example
|
|
494
|
+
# drone = AutonomousDrone.new
|
|
495
|
+
# assert_sm_async_mode(drone) # Uses default :state machine
|
|
496
|
+
# assert_sm_async_mode(drone, :teleporter_status) # Uses :teleporter_status machine
|
|
497
|
+
def assert_sm_async_mode(object, machine_name = :state, message = nil)
|
|
498
|
+
machine = object.class.state_machines[machine_name]
|
|
499
|
+
raise ArgumentError, "No state machine '#{machine_name}' found" unless machine
|
|
500
|
+
|
|
501
|
+
async_enabled = machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
|
502
|
+
default_message = "Expected state machine '#{machine_name}' to have async mode enabled, but it's in sync mode"
|
|
503
|
+
|
|
504
|
+
_sm_assert(async_enabled, message || default_message)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Assert that async methods are available on an async-enabled object
|
|
508
|
+
#
|
|
509
|
+
# @param object [Object] The object with state machines
|
|
510
|
+
# @param message [String, nil] Custom failure message
|
|
511
|
+
# @return [void]
|
|
512
|
+
# @raise [AssertionError] If async methods are not available
|
|
513
|
+
#
|
|
514
|
+
# @example
|
|
515
|
+
# drone = AutonomousDrone.new # Has async: true machines
|
|
516
|
+
# assert_sm_async_methods(drone)
|
|
517
|
+
def assert_sm_async_methods(object, message = nil)
|
|
518
|
+
async_methods = %i[fire_event_async fire_events_async fire_event_async! async_fire_event]
|
|
519
|
+
available_async_methods = async_methods.select { |method| object.respond_to?(method) }
|
|
520
|
+
|
|
521
|
+
default_message = "Expected async methods to be available, but found none"
|
|
522
|
+
|
|
523
|
+
_sm_refute_empty(available_async_methods, message || default_message)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Assert that an object has async-enabled state machines
|
|
527
|
+
#
|
|
528
|
+
# @param object [Object] The object with state machines
|
|
529
|
+
# @param machine_names [Array<Symbol>] Expected async machine names
|
|
530
|
+
# @param message [String, nil] Custom failure message
|
|
531
|
+
# @return [void]
|
|
532
|
+
# @raise [AssertionError] If expected machines don't have async mode
|
|
533
|
+
#
|
|
534
|
+
# @example
|
|
535
|
+
# drone = AutonomousDrone.new
|
|
536
|
+
# assert_sm_has_async(drone, [:status, :teleporter_status, :shields])
|
|
537
|
+
def assert_sm_has_async(object, machine_names = nil, message = nil)
|
|
538
|
+
if machine_names
|
|
539
|
+
# Check specific machines
|
|
540
|
+
non_async_machines = machine_names.reject do |name|
|
|
541
|
+
machine = object.class.state_machines[name]
|
|
542
|
+
machine&.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
default_message = "Expected machines #{machine_names.inspect} to have async enabled, but these don't: #{non_async_machines.inspect}"
|
|
546
|
+
|
|
547
|
+
_sm_assert_empty(non_async_machines, message || default_message)
|
|
548
|
+
else
|
|
549
|
+
# Check that at least one machine has async
|
|
550
|
+
async_machines = object.class.state_machines.select do |name, machine|
|
|
551
|
+
machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
default_message = "Expected at least one state machine to have async enabled, but none found"
|
|
555
|
+
|
|
556
|
+
_sm_refute_empty(async_machines, message || default_message)
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Assert that individual async event methods are available
|
|
561
|
+
#
|
|
562
|
+
# @param object [Object] The object with state machines
|
|
563
|
+
# @param event [Symbol] The event name
|
|
564
|
+
# @param message [String, nil] Custom failure message
|
|
565
|
+
# @return [void]
|
|
566
|
+
# @raise [AssertionError] If async event methods are not available
|
|
567
|
+
#
|
|
568
|
+
# @example
|
|
569
|
+
# drone = AutonomousDrone.new
|
|
570
|
+
# assert_sm_async_event_methods(drone, :launch) # Checks launch_async and launch_async!
|
|
571
|
+
def assert_sm_async_event_methods(object, event, message = nil)
|
|
572
|
+
async_method = "#{event}_async".to_sym
|
|
573
|
+
async_bang_method = "#{event}_async!".to_sym
|
|
574
|
+
|
|
575
|
+
has_async = object.respond_to?(async_method)
|
|
576
|
+
has_async_bang = object.respond_to?(async_bang_method)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
_sm_assert(has_async, "Missing #{async_method} method")
|
|
580
|
+
_sm_assert(has_async_bang, "Missing #{async_bang_method} method")
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Assert that an object has thread-safe state methods when async is enabled
|
|
584
|
+
#
|
|
585
|
+
# @param object [Object] The object with state machines
|
|
586
|
+
# @param message [String, nil] Custom failure message
|
|
587
|
+
# @return [void]
|
|
588
|
+
# @raise [AssertionError] If thread-safe methods are not available
|
|
589
|
+
#
|
|
590
|
+
# @example
|
|
591
|
+
# drone = AutonomousDrone.new
|
|
592
|
+
# assert_sm_thread_safe_methods(drone)
|
|
593
|
+
def assert_sm_thread_safe_methods(object, message = nil)
|
|
594
|
+
thread_safe_methods = %i[state_machine_mutex read_state_safely write_state_safely]
|
|
595
|
+
missing_methods = thread_safe_methods.reject { |method| object.respond_to?(method) }
|
|
596
|
+
|
|
597
|
+
default_message = "Expected thread-safe methods to be available, but missing: #{missing_methods.inspect}"
|
|
598
|
+
|
|
599
|
+
_sm_assert_empty(missing_methods, message || default_message)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# RSpec-style aliases for event triggering (for consistency with RSpec expectations)
|
|
603
|
+
alias expect_to_trigger_event assert_sm_triggers_event
|
|
604
|
+
alias have_triggered_event assert_sm_triggers_event
|
|
605
|
+
|
|
606
|
+
private
|
|
607
|
+
|
|
608
|
+
# Finds the can_<event>? method for the event, trying both the default and
|
|
609
|
+
# namespaced naming patterns, and raising when neither exists
|
|
610
|
+
def _sm_find_can_method(object, event, machine_name)
|
|
611
|
+
possible_methods = [
|
|
612
|
+
"can_#{event}?", # Default state machine or non-namespaced
|
|
613
|
+
"can_#{event}_#{machine_name}?" # Namespaced events
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
can_method = possible_methods.find { |method| object.respond_to?(method) }
|
|
617
|
+
return can_method if can_method
|
|
618
|
+
|
|
619
|
+
available_methods = object.methods.grep(/^can_.*\?$/).sort
|
|
620
|
+
raise ArgumentError, "No transition method found for event :#{event} on machine :#{machine_name}. Available methods: #{available_methods.first(10).inspect}"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Framework dispatch helpers - route assertions to whichever test
|
|
624
|
+
# framework is loaded (Minitest, RSpec, or none)
|
|
625
|
+
|
|
626
|
+
def _sm_assert(condition, message)
|
|
627
|
+
if defined?(::Minitest)
|
|
628
|
+
assert condition, message
|
|
629
|
+
elsif defined?(::RSpec)
|
|
630
|
+
expect(condition).to be_truthy, message
|
|
631
|
+
else
|
|
632
|
+
raise message unless condition
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def _sm_refute(condition, message)
|
|
637
|
+
if defined?(::Minitest)
|
|
638
|
+
refute condition, message
|
|
639
|
+
elsif defined?(::RSpec)
|
|
640
|
+
expect(condition).to be_falsy, message
|
|
641
|
+
elsif condition
|
|
642
|
+
raise message
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def _sm_assert_equal(expected, actual, message)
|
|
647
|
+
if defined?(::Minitest)
|
|
648
|
+
assert_equal expected, actual, message
|
|
649
|
+
elsif defined?(::RSpec)
|
|
650
|
+
expect(actual).to eq(expected), message
|
|
651
|
+
else
|
|
652
|
+
raise message unless expected == actual
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def _sm_assert_empty(collection, message)
|
|
657
|
+
if defined?(::Minitest)
|
|
658
|
+
assert_empty collection, message
|
|
659
|
+
elsif defined?(::RSpec)
|
|
660
|
+
expect(collection).to be_empty, message
|
|
661
|
+
elsif collection.any?
|
|
662
|
+
raise message
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def _sm_refute_empty(collection, message)
|
|
667
|
+
if defined?(::Minitest)
|
|
668
|
+
refute_empty collection, message
|
|
669
|
+
elsif defined?(::RSpec)
|
|
670
|
+
expect(collection).not_to be_empty, message
|
|
671
|
+
elsif collection.empty?
|
|
672
|
+
raise message
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def _sm_flunk(message)
|
|
677
|
+
raise message unless defined?(::Minitest)
|
|
678
|
+
|
|
679
|
+
assert false, message
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Internal helper for checking transition callbacks
|
|
683
|
+
def _assert_transition_callback(callback_type, machine_or_class, options, message)
|
|
684
|
+
# Get the machine
|
|
685
|
+
machine = machine_or_class.is_a?(StateMachines::Machine) ? machine_or_class : machine_or_class.state_machine
|
|
686
|
+
raise ArgumentError, 'No state machine found' unless machine
|
|
687
|
+
|
|
688
|
+
callbacks = machine.callbacks[callback_type] || []
|
|
689
|
+
|
|
690
|
+
# Extract expected conditions
|
|
691
|
+
expected_event = options[:on]
|
|
692
|
+
expected_from = options[:from]
|
|
693
|
+
expected_to = options[:to]
|
|
694
|
+
expected_method = options[:do]
|
|
695
|
+
expected_if = options[:if]
|
|
696
|
+
expected_unless = options[:unless]
|
|
697
|
+
|
|
698
|
+
# Find matching callback
|
|
699
|
+
matching_callback = callbacks.find do |callback|
|
|
700
|
+
branch = callback.branch
|
|
701
|
+
|
|
702
|
+
# Check event requirement
|
|
703
|
+
if expected_event
|
|
704
|
+
event_requirement = branch.event_requirement
|
|
705
|
+
event_matches = if event_requirement && event_requirement.respond_to?(:values)
|
|
706
|
+
event_requirement.values.include?(expected_event)
|
|
707
|
+
else
|
|
708
|
+
false
|
|
709
|
+
end
|
|
710
|
+
next false unless event_matches
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Check state requirements (from/to)
|
|
714
|
+
if expected_from || expected_to
|
|
715
|
+
state_matches = false
|
|
716
|
+
branch.state_requirements.each do |req|
|
|
717
|
+
from_matches = !expected_from || (req[:from] && req[:from].respond_to?(:values) && req[:from].values.include?(expected_from))
|
|
718
|
+
to_matches = !expected_to || (req[:to] && req[:to].respond_to?(:values) && req[:to].values.include?(expected_to))
|
|
719
|
+
|
|
720
|
+
if from_matches && to_matches
|
|
721
|
+
state_matches = true
|
|
722
|
+
break
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
next false unless state_matches
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Check method requirement
|
|
729
|
+
if expected_method
|
|
730
|
+
methods = callback.instance_variable_get(:@methods) || []
|
|
731
|
+
method_matches = methods.any? do |method|
|
|
732
|
+
(method.is_a?(Symbol) && method == expected_method) ||
|
|
733
|
+
(method.is_a?(String) && method.to_sym == expected_method) ||
|
|
734
|
+
(method.respond_to?(:call) && method.respond_to?(:source_location))
|
|
735
|
+
end
|
|
736
|
+
next false unless method_matches
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# Check if condition
|
|
740
|
+
if expected_if
|
|
741
|
+
if_condition = branch.if_condition
|
|
742
|
+
if_matches = (if_condition.is_a?(Symbol) && if_condition == expected_if) ||
|
|
743
|
+
(if_condition.is_a?(String) && if_condition.to_sym == expected_if) ||
|
|
744
|
+
if_condition.respond_to?(:call)
|
|
745
|
+
next false unless if_matches
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# Check unless condition
|
|
749
|
+
if expected_unless
|
|
750
|
+
unless_condition = branch.unless_condition
|
|
751
|
+
unless_matches = (unless_condition.is_a?(Symbol) && unless_condition == expected_unless) ||
|
|
752
|
+
(unless_condition.is_a?(String) && unless_condition.to_sym == expected_unless) ||
|
|
753
|
+
unless_condition.respond_to?(:call)
|
|
754
|
+
next false unless unless_matches
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
true
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
return if matching_callback
|
|
761
|
+
|
|
762
|
+
expected_parts = []
|
|
763
|
+
expected_parts << "on: #{expected_event.inspect}" if expected_event
|
|
764
|
+
expected_parts << "from: #{expected_from.inspect}" if expected_from
|
|
765
|
+
expected_parts << "to: #{expected_to.inspect}" if expected_to
|
|
766
|
+
expected_parts << "do: #{expected_method.inspect}" if expected_method
|
|
767
|
+
expected_parts << "if: #{expected_if.inspect}" if expected_if
|
|
768
|
+
expected_parts << "unless: #{expected_unless.inspect}" if expected_unless
|
|
769
|
+
|
|
770
|
+
default_message = "Expected #{callback_type}_transition callback with #{expected_parts.join(', ')} to be defined, but it was not found"
|
|
771
|
+
|
|
772
|
+
_sm_flunk(message || default_message)
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
end
|