state_machines 0.20.0 → 0.100.4
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 +356 -18
- data/lib/state_machines/async_mode/async_event_extensions.rb +49 -0
- data/lib/state_machines/async_mode/async_events.rb +282 -0
- data/lib/state_machines/async_mode/async_machine.rb +60 -0
- data/lib/state_machines/async_mode/async_transition_collection.rb +141 -0
- data/lib/state_machines/async_mode/thread_safe_state.rb +47 -0
- data/lib/state_machines/async_mode.rb +64 -0
- data/lib/state_machines/branch.rb +74 -17
- data/lib/state_machines/callback.rb +14 -14
- data/lib/state_machines/core.rb +0 -1
- data/lib/state_machines/error.rb +5 -4
- data/lib/state_machines/eval_helpers.rb +176 -49
- data/lib/state_machines/event.rb +36 -35
- data/lib/state_machines/event_collection.rb +24 -17
- data/lib/state_machines/extensions.rb +5 -5
- data/lib/state_machines/helper_module.rb +1 -1
- data/lib/state_machines/integrations/base.rb +7 -1
- data/lib/state_machines/integrations.rb +11 -14
- data/lib/state_machines/machine/action_hooks.rb +53 -0
- data/lib/state_machines/machine/async_extensions.rb +88 -0
- data/lib/state_machines/machine/callbacks.rb +59 -0
- data/lib/state_machines/machine/class_methods.rb +27 -11
- data/lib/state_machines/machine/configuration.rb +136 -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 +74 -619
- data/lib/state_machines/machine_collection.rb +18 -14
- data/lib/state_machines/macro_methods.rb +2 -2
- data/lib/state_machines/matcher.rb +6 -6
- data/lib/state_machines/matcher_helpers.rb +1 -1
- data/lib/state_machines/node_collection.rb +18 -17
- data/lib/state_machines/path.rb +2 -4
- data/lib/state_machines/path_collection.rb +2 -3
- data/lib/state_machines/state.rb +19 -11
- data/lib/state_machines/state_collection.rb +3 -3
- data/lib/state_machines/state_context.rb +6 -7
- data/lib/state_machines/stdio_renderer.rb +16 -16
- data/lib/state_machines/syntax_validator.rb +57 -0
- data/lib/state_machines/test_helper.rb +617 -27
- data/lib/state_machines/transition.rb +269 -91
- data/lib/state_machines/transition_collection.rb +66 -35
- data/lib/state_machines/version.rb +1 -1
- metadata +30 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b5d883e6f3259de8981a9855e50d6f2d859bbd7e9e10a4cc5b4d91ae3fec65f
|
|
4
|
+
data.tar.gz: 9185125133d36eeed7ae95875a825e0e9a2e4d73b7de286c65be589965d2613d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4786a92acf6011cc49c6e9f8daa0505d7bfcd26da0d244e351830aed0972ac12656ab39df4f9cf7b35fa4892c7afa02830b7ec0b33876a8624076e60fb273f21
|
|
7
|
+
data.tar.gz: e87573373f971cea02ebba3dcca45124f49d8ca6354c34756e55fd101deab5649ee349e62b05a3abb7d0fdde9ead78075984a089f7f3070fb4b4768c7af8fcc8
|
data/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|

|
|
2
|
+
|
|
2
3
|
# State Machines
|
|
3
4
|
|
|
4
5
|
State Machines adds support for creating state machines for attributes on any Ruby class.
|
|
@@ -9,15 +10,21 @@ State Machines adds support for creating state machines for attributes on any Ru
|
|
|
9
10
|
|
|
10
11
|
Add this line to your application's Gemfile:
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
```ruby
|
|
14
|
+
gem 'state_machines'
|
|
15
|
+
```
|
|
13
16
|
|
|
14
17
|
And then execute:
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
```sh
|
|
20
|
+
bundle
|
|
21
|
+
```
|
|
17
22
|
|
|
18
23
|
Or install it yourself as:
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
```sh
|
|
26
|
+
gem install state_machines
|
|
27
|
+
```
|
|
21
28
|
|
|
22
29
|
## Usage
|
|
23
30
|
|
|
@@ -29,6 +36,8 @@ Below is an example of many of the features offered by this plugin, including:
|
|
|
29
36
|
* Namespaced states
|
|
30
37
|
* Transition callbacks
|
|
31
38
|
* Conditional transitions
|
|
39
|
+
* Coordinated state management guards
|
|
40
|
+
* Asynchronous state machines (async: true)
|
|
32
41
|
* State-driven instance behavior
|
|
33
42
|
* Customized state values
|
|
34
43
|
* Parallel events
|
|
@@ -38,11 +47,11 @@ Class definition:
|
|
|
38
47
|
|
|
39
48
|
```ruby
|
|
40
49
|
class Vehicle
|
|
41
|
-
attr_accessor :seatbelt_on, :time_used, :auto_shop_busy
|
|
50
|
+
attr_accessor :seatbelt_on, :time_used, :auto_shop_busy, :parking_meter_number
|
|
42
51
|
|
|
43
52
|
state_machine :state, initial: :parked do
|
|
44
53
|
before_transition parked: any - :parked, do: :put_on_seatbelt
|
|
45
|
-
|
|
54
|
+
|
|
46
55
|
after_transition on: :crash, do: :tow
|
|
47
56
|
after_transition on: :repair, do: :fix
|
|
48
57
|
after_transition any => :parked do |vehicle, transition|
|
|
@@ -61,6 +70,18 @@ class Vehicle
|
|
|
61
70
|
transition [:idling, :first_gear] => :parked
|
|
62
71
|
end
|
|
63
72
|
|
|
73
|
+
before_transition on: :park do |vehicle, transition|
|
|
74
|
+
# If using Rails:
|
|
75
|
+
# options = transition.args.extract_options!
|
|
76
|
+
|
|
77
|
+
options = transition.args.last.is_a?(Hash) ? transition.args.pop : {}
|
|
78
|
+
meter_number = options[:meter_number]
|
|
79
|
+
|
|
80
|
+
unless meter_number.nil?
|
|
81
|
+
vehicle.parking_meter_number = meter_number
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
64
85
|
event :ignite do
|
|
65
86
|
transition stalled: same, parked: :idling
|
|
66
87
|
end
|
|
@@ -130,6 +151,7 @@ class Vehicle
|
|
|
130
151
|
@seatbelt_on = false
|
|
131
152
|
@time_used = 0
|
|
132
153
|
@auto_shop_busy = true
|
|
154
|
+
@parking_meter_number = nil
|
|
133
155
|
super() # NOTE: This *must* be called, otherwise states won't get initialized
|
|
134
156
|
end
|
|
135
157
|
|
|
@@ -200,6 +222,11 @@ vehicle.park! # => StateMachines:InvalidTransition: Cannot tra
|
|
|
200
222
|
vehicle.state?(:parked) # => false
|
|
201
223
|
vehicle.state?(:invalid) # => IndexError: :invalid is an invalid name
|
|
202
224
|
|
|
225
|
+
# Transition callbacks can receive arguments
|
|
226
|
+
vehicle.park(meter_number: '12345') # => true
|
|
227
|
+
vehicle.parked? # => true
|
|
228
|
+
vehicle.parking_meter_number # => "12345"
|
|
229
|
+
|
|
203
230
|
# Namespaced machines have uniquely-generated methods
|
|
204
231
|
vehicle.alarm_state # => 1
|
|
205
232
|
vehicle.alarm_state_name # => :active
|
|
@@ -220,6 +247,206 @@ vehicle.alarm_state_name # => :active
|
|
|
220
247
|
|
|
221
248
|
vehicle.fire_events!(:ignite, :enable_alarm) # => StateMachines:InvalidParallelTransition: Cannot run events in parallel: ignite, enable_alarm
|
|
222
249
|
|
|
250
|
+
# Coordinated State Management
|
|
251
|
+
|
|
252
|
+
State machines can coordinate with each other using state guards, allowing transitions to depend on the state of other state machines within the same object. This enables complex system modeling where components are interdependent.
|
|
253
|
+
|
|
254
|
+
## State Guard Options
|
|
255
|
+
|
|
256
|
+
### Single State Guards
|
|
257
|
+
|
|
258
|
+
* `:if_state` - Transition only if another state machine is in a specific state.
|
|
259
|
+
* `:unless_state` - Transition only if another state machine is NOT in a specific state.
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
class TorpedoSystem
|
|
263
|
+
state_machine :bay_doors, initial: :closed do
|
|
264
|
+
event :open do
|
|
265
|
+
transition closed: :open
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
event :close do
|
|
269
|
+
transition open: :closed
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
state_machine :torpedo_status, initial: :loaded do
|
|
274
|
+
event :fire_torpedo do
|
|
275
|
+
# Can only fire torpedo if bay doors are open
|
|
276
|
+
transition loaded: :fired, if_state: { bay_doors: :open }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
event :reload do
|
|
280
|
+
# Can only reload if bay doors are closed (for safety)
|
|
281
|
+
transition fired: :loaded, unless_state: { bay_doors: :open }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
system = TorpedoSystem.new
|
|
287
|
+
system.fire_torpedo # => false (bay doors are closed)
|
|
288
|
+
|
|
289
|
+
system.open_bay_doors!
|
|
290
|
+
system.fire_torpedo # => true (bay doors are now open)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Multiple State Guards
|
|
294
|
+
|
|
295
|
+
* `:if_all_states` - Transition only if ALL specified state machines are in their respective states.
|
|
296
|
+
* `:unless_all_states` - Transition only if NOT ALL specified state machines are in their respective states.
|
|
297
|
+
* `:if_any_state` - Transition only if ANY of the specified state machines are in their respective states.
|
|
298
|
+
* `:unless_any_state` - Transition only if NONE of the specified state machines are in their respective states.
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
class StarshipBridge
|
|
302
|
+
state_machine :shields, initial: :down
|
|
303
|
+
state_machine :weapons, initial: :offline
|
|
304
|
+
state_machine :warp_core, initial: :stable
|
|
305
|
+
|
|
306
|
+
state_machine :alert_status, initial: :green do
|
|
307
|
+
event :red_alert do
|
|
308
|
+
# Red alert if ANY critical system needs attention
|
|
309
|
+
transition green: :red, if_any_state: { warp_core: :critical, shields: :down }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
event :battle_stations do
|
|
313
|
+
# Battle stations only if ALL combat systems are ready
|
|
314
|
+
transition green: :battle, if_all_states: { shields: :up, weapons: :armed }
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Error Handling
|
|
321
|
+
|
|
322
|
+
State guards provide comprehensive error checking:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
# Referencing a non-existent state machine
|
|
326
|
+
event :invalid, if_state: { nonexistent_machine: :some_state }
|
|
327
|
+
# => ArgumentError: State machine 'nonexistent_machine' is not defined for StarshipBridge
|
|
328
|
+
|
|
329
|
+
# Referencing a non-existent state
|
|
330
|
+
event :another_invalid, if_state: { shields: :nonexistent_state }
|
|
331
|
+
# => ArgumentError: State 'nonexistent_state' is not defined in state machine 'shields'
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
# Asynchronous State Machines
|
|
335
|
+
|
|
336
|
+
State machines can operate asynchronously for high-performance applications. This is ideal for I/O-bound tasks, such as in web servers or other concurrent environments, where you don't want a long-running state transition (like one involving a network call) to block the entire thread.
|
|
337
|
+
|
|
338
|
+
This feature is powered by the [async](https://github.com/socketry/async) gem and uses `concurrent-ruby` for enterprise-grade thread safety.
|
|
339
|
+
|
|
340
|
+
## Platform Compatibility
|
|
341
|
+
|
|
342
|
+
**Supported Platforms:**
|
|
343
|
+
* MRI Ruby (CRuby) 3.2+
|
|
344
|
+
* Other Ruby engines with full Fiber scheduler support
|
|
345
|
+
|
|
346
|
+
**Unsupported Platforms:**
|
|
347
|
+
* JRuby - Falls back to synchronous mode with warnings
|
|
348
|
+
* TruffleRuby - Falls back to synchronous mode with warnings
|
|
349
|
+
|
|
350
|
+
## Basic Async Usage
|
|
351
|
+
|
|
352
|
+
Enable async mode by adding `async: true` to your state machine declaration:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
class AutonomousDrone < StarfleetShip
|
|
356
|
+
# Async-enabled state machine for autonomous operation
|
|
357
|
+
state_machine :status, async: true, initial: :docked do
|
|
358
|
+
event :launch do
|
|
359
|
+
transition docked: :flying
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
event :land do
|
|
363
|
+
transition flying: :docked
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Mixed configuration: some machines async, others sync
|
|
368
|
+
state_machine :teleporter_status, async: true, initial: :offline do
|
|
369
|
+
event :power_up do
|
|
370
|
+
transition offline: :charging
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
event :teleport do
|
|
374
|
+
transition ready: :teleporting
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Weapons remain synchronous for safety
|
|
379
|
+
state_machine :weapons, initial: :disarmed do
|
|
380
|
+
event :arm do
|
|
381
|
+
transition disarmed: :armed
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Async Event Methods
|
|
388
|
+
|
|
389
|
+
Async-enabled machines automatically generate async versions of event methods:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
drone = AutonomousDrone.new
|
|
393
|
+
|
|
394
|
+
# Within an Async context
|
|
395
|
+
Async do
|
|
396
|
+
# Async event firing - returns Async::Task
|
|
397
|
+
task = drone.launch_async
|
|
398
|
+
result = task.wait # => true
|
|
399
|
+
|
|
400
|
+
# Bang methods for strict error handling
|
|
401
|
+
drone.power_up_async! # => Async::Task (raises on failure)
|
|
402
|
+
|
|
403
|
+
# Generic async event firing
|
|
404
|
+
drone.fire_event_async(:teleport) # => Async::Task
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Outside Async context - raises error
|
|
408
|
+
drone.launch_async # => RuntimeError: launch_async must be called within an Async context
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Thread Safety
|
|
412
|
+
|
|
413
|
+
Async state machines use enterprise-grade thread safety with `concurrent-ruby`:
|
|
414
|
+
|
|
415
|
+
```ruby
|
|
416
|
+
# Concurrent operations are automatically thread-safe
|
|
417
|
+
threads = []
|
|
418
|
+
10.times do
|
|
419
|
+
threads << Thread.new do
|
|
420
|
+
Async do
|
|
421
|
+
drone.launch_async.wait
|
|
422
|
+
drone.land_async.wait
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
threads.each(&:join)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
## Performance Considerations
|
|
430
|
+
|
|
431
|
+
* **Thread Safety**: Uses `Concurrent::ReentrantReadWriteLock` for optimal read/write performance.
|
|
432
|
+
* **Memory**: Each async-enabled object gets its own mutex (lazy-loaded).
|
|
433
|
+
* **Marshalling**: Objects with async state machines can be serialized (mutex excluded/recreated).
|
|
434
|
+
* **Mixed Mode**: You can mix async and sync state machines in the same class.
|
|
435
|
+
|
|
436
|
+
## Dependencies
|
|
437
|
+
|
|
438
|
+
Async functionality requires:
|
|
439
|
+
|
|
440
|
+
```ruby
|
|
441
|
+
# Gemfile (automatically scoped to MRI Ruby)
|
|
442
|
+
platform :ruby do
|
|
443
|
+
gem 'async', '>= 2.25.0'
|
|
444
|
+
gem 'concurrent-ruby', '>= 1.3.5'
|
|
445
|
+
end
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
*Note: These gems are only installed on supported platforms. JRuby/TruffleRuby won't attempt installation.*
|
|
449
|
+
|
|
223
450
|
# Human-friendly names can be accessed for states/events
|
|
224
451
|
Vehicle.human_state_name(:first_gear) # => "first gear"
|
|
225
452
|
Vehicle.human_alarm_state_name(:active) # => "active"
|
|
@@ -256,30 +483,36 @@ vehicle.state_name # => :parked
|
|
|
256
483
|
|
|
257
484
|
## Testing
|
|
258
485
|
|
|
259
|
-
State Machines provides
|
|
486
|
+
State Machines provides an optional `TestHelper` module with assertion methods to make testing state machines easier and more expressive.
|
|
487
|
+
|
|
488
|
+
**Note: TestHelper is not required by default** - you must explicitly require it in your test files.
|
|
260
489
|
|
|
261
490
|
### Setup
|
|
262
491
|
|
|
263
|
-
|
|
492
|
+
First, require the test helper module, then include it in your test class:
|
|
264
493
|
|
|
265
494
|
```ruby
|
|
266
495
|
# For Minitest
|
|
496
|
+
require 'state_machines/test_helper'
|
|
497
|
+
|
|
267
498
|
class VehicleTest < Minitest::Test
|
|
268
499
|
include StateMachines::TestHelper
|
|
269
|
-
|
|
500
|
+
|
|
270
501
|
def test_initial_state
|
|
271
502
|
vehicle = Vehicle.new
|
|
272
|
-
|
|
503
|
+
assert_sm_state vehicle, :parked
|
|
273
504
|
end
|
|
274
505
|
end
|
|
275
506
|
|
|
276
|
-
# For RSpec
|
|
507
|
+
# For RSpec
|
|
508
|
+
require 'state_machines/test_helper'
|
|
509
|
+
|
|
277
510
|
RSpec.describe Vehicle do
|
|
278
511
|
include StateMachines::TestHelper
|
|
279
|
-
|
|
512
|
+
|
|
280
513
|
it "starts in parked state" do
|
|
281
514
|
vehicle = Vehicle.new
|
|
282
|
-
|
|
515
|
+
assert_sm_state vehicle, :parked
|
|
283
516
|
end
|
|
284
517
|
end
|
|
285
518
|
```
|
|
@@ -292,10 +525,17 @@ The TestHelper provides both basic assertions and comprehensive state machine-sp
|
|
|
292
525
|
|
|
293
526
|
```ruby
|
|
294
527
|
vehicle = Vehicle.new
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
528
|
+
|
|
529
|
+
# New standardized API (all methods prefixed with assert_sm_)
|
|
530
|
+
assert_sm_state(vehicle, :parked) # Uses default :state machine
|
|
531
|
+
assert_sm_state(vehicle, :parked, machine_name: :status) # Specify machine explicitly
|
|
532
|
+
assert_sm_can_transition(vehicle, :ignite) # Test transition capability
|
|
533
|
+
assert_sm_cannot_transition(vehicle, :shift_up) # Test transition restriction
|
|
534
|
+
assert_sm_transition(vehicle, :ignite, :idling) # Test actual transition
|
|
535
|
+
|
|
536
|
+
# Multi-FSM examples
|
|
537
|
+
assert_sm_state(vehicle, :inactive, machine_name: :insurance_state) # Test insurance state
|
|
538
|
+
assert_sm_can_transition(vehicle, :buy_insurance, machine_name: :insurance_state)
|
|
299
539
|
```
|
|
300
540
|
|
|
301
541
|
#### Extended State Machine Assertions
|
|
@@ -308,7 +548,7 @@ vehicle = Vehicle.new
|
|
|
308
548
|
assert_sm_states_list machine, [:parked, :idling, :stalled]
|
|
309
549
|
assert_sm_initial_state machine, :parked
|
|
310
550
|
|
|
311
|
-
# Event behavior
|
|
551
|
+
# Event behavior
|
|
312
552
|
assert_sm_event_triggers vehicle, :ignite
|
|
313
553
|
refute_sm_event_triggers vehicle, :shift_up
|
|
314
554
|
assert_sm_event_raises_error vehicle, :invalid_event, StateMachines::InvalidTransition
|
|
@@ -317,8 +557,106 @@ assert_sm_event_raises_error vehicle, :invalid_event, StateMachines::InvalidTran
|
|
|
317
557
|
assert_sm_state_persisted record, expected: :active
|
|
318
558
|
```
|
|
319
559
|
|
|
560
|
+
#### Indirect Event Testing
|
|
561
|
+
|
|
562
|
+
Test that methods trigger state machine events indirectly:
|
|
563
|
+
|
|
564
|
+
```ruby
|
|
565
|
+
# Minitest style
|
|
566
|
+
vehicle = Vehicle.new
|
|
567
|
+
vehicle.ignite # Put in idling state
|
|
568
|
+
|
|
569
|
+
# Test that a custom method triggers a specific event
|
|
570
|
+
assert_sm_triggers_event(vehicle, :crash) do
|
|
571
|
+
vehicle.redline # Custom method that calls crash! internally
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Test multiple events
|
|
575
|
+
assert_sm_triggers_event(vehicle, [:crash, :emergency]) do
|
|
576
|
+
vehicle.emergency_stop
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Test on specific state machine (multi-FSM support)
|
|
580
|
+
assert_sm_triggers_event(vehicle, :disable, machine_name: :alarm) do
|
|
581
|
+
vehicle.turn_off_alarm
|
|
582
|
+
end
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
# RSpec style (coming soon with proper matcher support)
|
|
587
|
+
RSpec.describe Vehicle do
|
|
588
|
+
include StateMachines::TestHelper
|
|
589
|
+
|
|
590
|
+
it "triggers crash when redlining" do
|
|
591
|
+
vehicle = Vehicle.new
|
|
592
|
+
vehicle.ignite
|
|
593
|
+
|
|
594
|
+
expect_to_trigger_event(vehicle, :crash) do
|
|
595
|
+
vehicle.redline
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
#### Callback Definition Testing (TDD Support)
|
|
602
|
+
|
|
603
|
+
Verify that callbacks are properly defined in your state machine:
|
|
604
|
+
|
|
605
|
+
```ruby
|
|
606
|
+
# Test after_transition callbacks
|
|
607
|
+
assert_after_transition(Vehicle, on: :crash, do: :tow)
|
|
608
|
+
assert_after_transition(Vehicle, from: :stalled, to: :parked, do: :log_repair)
|
|
609
|
+
|
|
610
|
+
# Test before_transition callbacks
|
|
611
|
+
assert_before_transition(Vehicle, from: :parked, do: :put_on_seatbelt)
|
|
612
|
+
assert_before_transition(Vehicle, on: :ignite, if: :seatbelt_on?)
|
|
613
|
+
|
|
614
|
+
# Works with machine instances too
|
|
615
|
+
machine = Vehicle.state_machine(:state)
|
|
616
|
+
assert_after_transition(machine, on: :crash, do: :tow)
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
#### Multiple State Machine Support
|
|
620
|
+
|
|
621
|
+
The TestHelper fully supports objects with multiple state machines:
|
|
622
|
+
|
|
623
|
+
```ruby
|
|
624
|
+
# Example: StarfleetShip with 3 state machines
|
|
625
|
+
ship = StarfleetShip.new
|
|
626
|
+
|
|
627
|
+
# Test states on different machines
|
|
628
|
+
assert_sm_state(ship, :docked, machine_name: :status) # Main ship status
|
|
629
|
+
assert_sm_state(ship, :down, machine_name: :shields) # Shield system
|
|
630
|
+
assert_sm_state(ship, :standby, machine_name: :weapons) # Weapons system
|
|
631
|
+
|
|
632
|
+
# Test transitions on specific machines
|
|
633
|
+
assert_sm_transition(ship, :undock, :impulse, machine_name: :status)
|
|
634
|
+
assert_sm_transition(ship, :raise_shields, :up, machine_name: :shields)
|
|
635
|
+
assert_sm_transition(ship, :arm_weapons, :armed, machine_name: :weapons)
|
|
636
|
+
|
|
637
|
+
# Test event triggering across multiple machines
|
|
638
|
+
assert_sm_triggers_event(ship, :red_alert, machine_name: :status) do
|
|
639
|
+
ship.engage_combat_mode # Custom method affecting multiple systems
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
assert_sm_triggers_event(ship, :raise_shields, machine_name: :shields) do
|
|
643
|
+
ship.engage_combat_mode
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Test callback definitions on specific machines
|
|
647
|
+
shields_machine = StarfleetShip.state_machine(:shields)
|
|
648
|
+
assert_before_transition(shields_machine, from: :down, to: :up, do: :power_up_shields)
|
|
649
|
+
|
|
650
|
+
# Test persistence across multiple machines
|
|
651
|
+
assert_sm_state_persisted(ship, "impulse", :status)
|
|
652
|
+
assert_sm_state_persisted(ship, "up", :shields)
|
|
653
|
+
assert_sm_state_persisted(ship, "armed", :weapons)
|
|
654
|
+
```
|
|
655
|
+
|
|
320
656
|
The test helper works with both Minitest and RSpec, automatically detecting your testing framework.
|
|
321
657
|
|
|
658
|
+
**Note:** All methods use consistent keyword arguments with `machine_name:` as the last parameter, making the API intuitive and Grep-friendly.
|
|
659
|
+
|
|
322
660
|
## Additional Topics
|
|
323
661
|
|
|
324
662
|
### Explicit vs. Implicit Event Transitions
|
|
@@ -679,7 +1017,7 @@ For RSpec testing, use the custom RSpec matchers:
|
|
|
679
1017
|
|
|
680
1018
|
## Contributing
|
|
681
1019
|
|
|
682
|
-
1. Fork it ( https://github.com/state-machines/state_machines/fork )
|
|
1020
|
+
1. Fork it ( <https://github.com/state-machines/state_machines/fork> )
|
|
683
1021
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
684
1022
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
685
1023
|
4. Push to the branch (`git push origin my-new-feature`)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StateMachines
|
|
4
|
+
module AsyncMode
|
|
5
|
+
# Extensions to Event class for async bang methods
|
|
6
|
+
module AsyncEventExtensions
|
|
7
|
+
# Generate async bang methods for events when async mode is enabled
|
|
8
|
+
def define_helper(scope, method, *args, &block)
|
|
9
|
+
result = super
|
|
10
|
+
|
|
11
|
+
# If this is an async-enabled machine and we're defining an event method
|
|
12
|
+
if scope == :instance && method !~ /_async[!]?$/ && machine.async_mode_enabled?
|
|
13
|
+
qualified_name = method.to_s
|
|
14
|
+
|
|
15
|
+
# Create async version that returns a task
|
|
16
|
+
machine.define_helper(scope, "#{qualified_name}_async") do |machine, object, *method_args, **kwargs|
|
|
17
|
+
# Find the machine that has this event
|
|
18
|
+
target_machine = object.class.state_machines.values.find { |m| m.events[name] }
|
|
19
|
+
|
|
20
|
+
unless defined?(::Async::Task) && ::Async::Task.current?
|
|
21
|
+
raise RuntimeError, "#{qualified_name}_async must be called within an Async context"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Async do
|
|
25
|
+
target_machine.events[name].fire(object, *method_args, **kwargs)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Create async bang version that raises exceptions when awaited
|
|
30
|
+
machine.define_helper(scope, "#{qualified_name}_async!") do |machine, object, *method_args, **kwargs|
|
|
31
|
+
# Find the machine that has this event
|
|
32
|
+
target_machine = object.class.state_machines.values.find { |m| m.events[name] }
|
|
33
|
+
|
|
34
|
+
unless defined?(::Async::Task) && ::Async::Task.current?
|
|
35
|
+
raise RuntimeError, "#{qualified_name}_async! must be called within an Async context"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Async do
|
|
39
|
+
# Use fire method which will raise exceptions on invalid transitions
|
|
40
|
+
target_machine.events[name].fire(object, *method_args, **kwargs) || raise(StateMachines::InvalidTransition.new(object, target_machine, name))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|