state_machines 0.30.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +30 -5
- 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 +21 -7
- data/lib/state_machines/callback.rb +3 -2
- data/lib/state_machines/eval_helpers.rb +123 -32
- data/lib/state_machines/event.rb +18 -14
- data/lib/state_machines/event_collection.rb +21 -13
- data/lib/state_machines/machine/async_extensions.rb +88 -0
- data/lib/state_machines/machine/class_methods.rb +4 -0
- data/lib/state_machines/machine/configuration.rb +11 -1
- data/lib/state_machines/machine.rb +3 -2
- data/lib/state_machines/state.rb +14 -7
- data/lib/state_machines/test_helper.rb +328 -0
- data/lib/state_machines/transition.rb +15 -5
- data/lib/state_machines/transition_collection.rb +15 -1
- data/lib/state_machines/version.rb +1 -1
- metadata +8 -1
@@ -460,6 +460,334 @@ module StateMachines
|
|
460
460
|
_assert_transition_callback(:after, machine_or_class, options, message)
|
461
461
|
end
|
462
462
|
|
463
|
+
# === Sync Mode Assertions ===
|
464
|
+
|
465
|
+
# Assert that a state machine is operating in synchronous mode
|
466
|
+
#
|
467
|
+
# @param object [Object] The object with state machines
|
468
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
469
|
+
# @param message [String, nil] Custom failure message
|
470
|
+
# @return [void]
|
471
|
+
# @raise [AssertionError] If the machine has async mode enabled
|
472
|
+
#
|
473
|
+
# @example
|
474
|
+
# user = User.new
|
475
|
+
# assert_sm_sync_mode(user) # Uses default :state machine
|
476
|
+
# assert_sm_sync_mode(user, :status) # Uses :status machine
|
477
|
+
def assert_sm_sync_mode(object, machine_name = :state, message = nil)
|
478
|
+
machine = object.class.state_machines[machine_name]
|
479
|
+
raise ArgumentError, "No state machine '#{machine_name}' found" unless machine
|
480
|
+
|
481
|
+
async_enabled = machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
482
|
+
default_message = "Expected state machine '#{machine_name}' to be in sync mode, but async mode is enabled"
|
483
|
+
|
484
|
+
if defined?(::Minitest)
|
485
|
+
refute async_enabled, message || default_message
|
486
|
+
elsif defined?(::RSpec)
|
487
|
+
expect(async_enabled).to be_falsy, message || default_message
|
488
|
+
elsif async_enabled
|
489
|
+
raise default_message
|
490
|
+
end
|
491
|
+
end
|
492
|
+
|
493
|
+
# Assert that async methods are not available on a sync-only object
|
494
|
+
#
|
495
|
+
# @param object [Object] The object with state machines
|
496
|
+
# @param message [String, nil] Custom failure message
|
497
|
+
# @return [void]
|
498
|
+
# @raise [AssertionError] If async methods are available
|
499
|
+
#
|
500
|
+
# @example
|
501
|
+
# sync_only_car = Car.new # Car has no async: true machines
|
502
|
+
# assert_sm_no_async_methods(sync_only_car)
|
503
|
+
def assert_sm_no_async_methods(object, message = nil)
|
504
|
+
async_methods = %i[fire_event_async fire_events_async fire_event_async! async_fire_event]
|
505
|
+
available_async_methods = async_methods.select { |method| object.respond_to?(method) }
|
506
|
+
|
507
|
+
default_message = "Expected no async methods to be available, but found: #{available_async_methods.inspect}"
|
508
|
+
|
509
|
+
if defined?(::Minitest)
|
510
|
+
assert_empty available_async_methods, message || default_message
|
511
|
+
elsif defined?(::RSpec)
|
512
|
+
expect(available_async_methods).to be_empty, message || default_message
|
513
|
+
elsif available_async_methods.any?
|
514
|
+
raise default_message
|
515
|
+
end
|
516
|
+
end
|
517
|
+
|
518
|
+
# Assert that an object has no async-enabled state machines
|
519
|
+
#
|
520
|
+
# @param object [Object] The object with state machines
|
521
|
+
# @param message [String, nil] Custom failure message
|
522
|
+
# @return [void]
|
523
|
+
# @raise [AssertionError] If any machine has async mode enabled
|
524
|
+
#
|
525
|
+
# @example
|
526
|
+
# sync_only_vehicle = Vehicle.new # All machines are sync-only
|
527
|
+
# assert_sm_all_sync(sync_only_vehicle)
|
528
|
+
def assert_sm_all_sync(object, message = nil)
|
529
|
+
async_machines = []
|
530
|
+
|
531
|
+
object.class.state_machines.each do |name, machine|
|
532
|
+
if machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
533
|
+
async_machines << name
|
534
|
+
end
|
535
|
+
end
|
536
|
+
|
537
|
+
default_message = "Expected all state machines to be sync-only, but these have async enabled: #{async_machines.inspect}"
|
538
|
+
|
539
|
+
if defined?(::Minitest)
|
540
|
+
assert_empty async_machines, message || default_message
|
541
|
+
elsif defined?(::RSpec)
|
542
|
+
expect(async_machines).to be_empty, message || default_message
|
543
|
+
elsif async_machines.any?
|
544
|
+
raise default_message
|
545
|
+
end
|
546
|
+
end
|
547
|
+
|
548
|
+
# Assert that synchronous event execution works correctly
|
549
|
+
#
|
550
|
+
# @param object [Object] The object with state machines
|
551
|
+
# @param event [Symbol] The event to trigger
|
552
|
+
# @param expected_state [Symbol] The expected state after transition
|
553
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
554
|
+
# @param message [String, nil] Custom failure message
|
555
|
+
# @return [void]
|
556
|
+
# @raise [AssertionError] If sync execution fails
|
557
|
+
#
|
558
|
+
# @example
|
559
|
+
# car = Car.new
|
560
|
+
# assert_sm_sync_execution(car, :start, :running)
|
561
|
+
# assert_sm_sync_execution(car, :turn_on, :active, :alarm)
|
562
|
+
def assert_sm_sync_execution(object, event, expected_state, machine_name = :state, message = nil)
|
563
|
+
# Store initial state
|
564
|
+
initial_state = object.send(machine_name)
|
565
|
+
|
566
|
+
# Execute event synchronously
|
567
|
+
result = object.send("#{event}!")
|
568
|
+
|
569
|
+
# Verify immediate state change (no async delay)
|
570
|
+
final_state = object.send(machine_name)
|
571
|
+
|
572
|
+
# Check that transition succeeded
|
573
|
+
state_changed = initial_state != final_state
|
574
|
+
correct_final_state = final_state.to_s == expected_state.to_s
|
575
|
+
|
576
|
+
default_message = "Expected sync execution of '#{event}' to change #{machine_name} from '#{initial_state}' to '#{expected_state}', but got '#{final_state}'"
|
577
|
+
|
578
|
+
if defined?(::Minitest)
|
579
|
+
assert result, "Event #{event} should return true on success"
|
580
|
+
assert state_changed, "State should change from #{initial_state}"
|
581
|
+
assert correct_final_state, message || default_message
|
582
|
+
elsif defined?(::RSpec)
|
583
|
+
expect(result).to be_truthy, "Event #{event} should return true on success"
|
584
|
+
expect(state_changed).to be_truthy, "State should change from #{initial_state}"
|
585
|
+
expect(correct_final_state).to be_truthy, message || default_message
|
586
|
+
else
|
587
|
+
raise "Event #{event} should return true on success" unless result
|
588
|
+
raise "State should change from #{initial_state}" unless state_changed
|
589
|
+
raise default_message unless correct_final_state
|
590
|
+
end
|
591
|
+
end
|
592
|
+
|
593
|
+
# Assert that event execution is immediate (no async delay)
|
594
|
+
#
|
595
|
+
# @param object [Object] The object with state machines
|
596
|
+
# @param event [Symbol] The event to trigger
|
597
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
598
|
+
# @param message [String, nil] Custom failure message
|
599
|
+
# @return [void]
|
600
|
+
# @raise [AssertionError] If execution appears to be async
|
601
|
+
#
|
602
|
+
# @example
|
603
|
+
# car = Car.new
|
604
|
+
# assert_sm_immediate_execution(car, :start)
|
605
|
+
def assert_sm_immediate_execution(object, event, machine_name = :state, message = nil)
|
606
|
+
initial_state = object.send(machine_name)
|
607
|
+
|
608
|
+
# Record start time and execute
|
609
|
+
start_time = Time.now
|
610
|
+
object.send("#{event}!")
|
611
|
+
execution_time = Time.now - start_time
|
612
|
+
|
613
|
+
final_state = object.send(machine_name)
|
614
|
+
state_changed = initial_state != final_state
|
615
|
+
|
616
|
+
# Should complete very quickly (under 10ms for sync operations)
|
617
|
+
is_immediate = execution_time < 0.01
|
618
|
+
|
619
|
+
default_message = "Expected immediate sync execution of '#{event}', but took #{execution_time}s (likely async)"
|
620
|
+
|
621
|
+
if defined?(::Minitest)
|
622
|
+
assert state_changed, "Event should trigger state change"
|
623
|
+
assert is_immediate, message || default_message
|
624
|
+
elsif defined?(::RSpec)
|
625
|
+
expect(state_changed).to be_truthy, "Event should trigger state change"
|
626
|
+
expect(is_immediate).to be_truthy, message || default_message
|
627
|
+
else
|
628
|
+
raise "Event should trigger state change" unless state_changed
|
629
|
+
raise default_message unless is_immediate
|
630
|
+
end
|
631
|
+
end
|
632
|
+
|
633
|
+
# === Async Mode Assertions ===
|
634
|
+
|
635
|
+
# Assert that a state machine is operating in asynchronous mode
|
636
|
+
#
|
637
|
+
# @param object [Object] The object with state machines
|
638
|
+
# @param machine_name [Symbol] The name of the state machine (defaults to :state)
|
639
|
+
# @param message [String, nil] Custom failure message
|
640
|
+
# @return [void]
|
641
|
+
# @raise [AssertionError] If the machine doesn't have async mode enabled
|
642
|
+
#
|
643
|
+
# @example
|
644
|
+
# drone = AutonomousDrone.new
|
645
|
+
# assert_sm_async_mode(drone) # Uses default :state machine
|
646
|
+
# assert_sm_async_mode(drone, :teleporter_status) # Uses :teleporter_status machine
|
647
|
+
def assert_sm_async_mode(object, machine_name = :state, message = nil)
|
648
|
+
machine = object.class.state_machines[machine_name]
|
649
|
+
raise ArgumentError, "No state machine '#{machine_name}' found" unless machine
|
650
|
+
|
651
|
+
async_enabled = machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
652
|
+
default_message = "Expected state machine '#{machine_name}' to have async mode enabled, but it's in sync mode"
|
653
|
+
|
654
|
+
if defined?(::Minitest)
|
655
|
+
assert async_enabled, message || default_message
|
656
|
+
elsif defined?(::RSpec)
|
657
|
+
expect(async_enabled).to be_truthy, message || default_message
|
658
|
+
else
|
659
|
+
raise default_message unless async_enabled
|
660
|
+
end
|
661
|
+
end
|
662
|
+
|
663
|
+
# Assert that async methods are available on an async-enabled object
|
664
|
+
#
|
665
|
+
# @param object [Object] The object with state machines
|
666
|
+
# @param message [String, nil] Custom failure message
|
667
|
+
# @return [void]
|
668
|
+
# @raise [AssertionError] If async methods are not available
|
669
|
+
#
|
670
|
+
# @example
|
671
|
+
# drone = AutonomousDrone.new # Has async: true machines
|
672
|
+
# assert_sm_async_methods(drone)
|
673
|
+
def assert_sm_async_methods(object, message = nil)
|
674
|
+
async_methods = %i[fire_event_async fire_events_async fire_event_async! async_fire_event]
|
675
|
+
available_async_methods = async_methods.select { |method| object.respond_to?(method) }
|
676
|
+
|
677
|
+
default_message = "Expected async methods to be available, but found none"
|
678
|
+
|
679
|
+
if defined?(::Minitest)
|
680
|
+
refute_empty available_async_methods, message || default_message
|
681
|
+
elsif defined?(::RSpec)
|
682
|
+
expect(available_async_methods).not_to be_empty, message || default_message
|
683
|
+
elsif available_async_methods.empty?
|
684
|
+
raise default_message
|
685
|
+
end
|
686
|
+
end
|
687
|
+
|
688
|
+
# Assert that an object has async-enabled state machines
|
689
|
+
#
|
690
|
+
# @param object [Object] The object with state machines
|
691
|
+
# @param machine_names [Array<Symbol>] Expected async machine names
|
692
|
+
# @param message [String, nil] Custom failure message
|
693
|
+
# @return [void]
|
694
|
+
# @raise [AssertionError] If expected machines don't have async mode
|
695
|
+
#
|
696
|
+
# @example
|
697
|
+
# drone = AutonomousDrone.new
|
698
|
+
# assert_sm_has_async(drone, [:status, :teleporter_status, :shields])
|
699
|
+
def assert_sm_has_async(object, machine_names = nil, message = nil)
|
700
|
+
if machine_names
|
701
|
+
# Check specific machines
|
702
|
+
non_async_machines = machine_names.reject do |name|
|
703
|
+
machine = object.class.state_machines[name]
|
704
|
+
machine&.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
705
|
+
end
|
706
|
+
|
707
|
+
default_message = "Expected machines #{machine_names.inspect} to have async enabled, but these don't: #{non_async_machines.inspect}"
|
708
|
+
|
709
|
+
if defined?(::Minitest)
|
710
|
+
assert_empty non_async_machines, message || default_message
|
711
|
+
elsif defined?(::RSpec)
|
712
|
+
expect(non_async_machines).to be_empty, message || default_message
|
713
|
+
elsif non_async_machines.any?
|
714
|
+
raise default_message
|
715
|
+
end
|
716
|
+
else
|
717
|
+
# Check that at least one machine has async
|
718
|
+
async_machines = object.class.state_machines.select do |name, machine|
|
719
|
+
machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
|
720
|
+
end
|
721
|
+
|
722
|
+
default_message = "Expected at least one state machine to have async enabled, but none found"
|
723
|
+
|
724
|
+
if defined?(::Minitest)
|
725
|
+
refute_empty async_machines, message || default_message
|
726
|
+
elsif defined?(::RSpec)
|
727
|
+
expect(async_machines).not_to be_empty, message || default_message
|
728
|
+
elsif async_machines.empty?
|
729
|
+
raise default_message
|
730
|
+
end
|
731
|
+
end
|
732
|
+
end
|
733
|
+
|
734
|
+
# Assert that individual async event methods are available
|
735
|
+
#
|
736
|
+
# @param object [Object] The object with state machines
|
737
|
+
# @param event [Symbol] The event name
|
738
|
+
# @param message [String, nil] Custom failure message
|
739
|
+
# @return [void]
|
740
|
+
# @raise [AssertionError] If async event methods are not available
|
741
|
+
#
|
742
|
+
# @example
|
743
|
+
# drone = AutonomousDrone.new
|
744
|
+
# assert_sm_async_event_methods(drone, :launch) # Checks launch_async and launch_async!
|
745
|
+
def assert_sm_async_event_methods(object, event, message = nil)
|
746
|
+
async_method = "#{event}_async".to_sym
|
747
|
+
async_bang_method = "#{event}_async!".to_sym
|
748
|
+
|
749
|
+
has_async = object.respond_to?(async_method)
|
750
|
+
has_async_bang = object.respond_to?(async_bang_method)
|
751
|
+
|
752
|
+
default_message = "Expected async event methods #{async_method} and #{async_bang_method} to be available for event :#{event}"
|
753
|
+
|
754
|
+
if defined?(::Minitest)
|
755
|
+
assert has_async, "Missing #{async_method} method"
|
756
|
+
assert has_async_bang, "Missing #{async_bang_method} method"
|
757
|
+
elsif defined?(::RSpec)
|
758
|
+
expect(has_async).to be_truthy, "Missing #{async_method} method"
|
759
|
+
expect(has_async_bang).to be_truthy, "Missing #{async_bang_method} method"
|
760
|
+
else
|
761
|
+
raise "Missing #{async_method} method" unless has_async
|
762
|
+
raise "Missing #{async_bang_method} method" unless has_async_bang
|
763
|
+
end
|
764
|
+
end
|
765
|
+
|
766
|
+
# Assert that an object has thread-safe state methods when async is enabled
|
767
|
+
#
|
768
|
+
# @param object [Object] The object with state machines
|
769
|
+
# @param message [String, nil] Custom failure message
|
770
|
+
# @return [void]
|
771
|
+
# @raise [AssertionError] If thread-safe methods are not available
|
772
|
+
#
|
773
|
+
# @example
|
774
|
+
# drone = AutonomousDrone.new
|
775
|
+
# assert_sm_thread_safe_methods(drone)
|
776
|
+
def assert_sm_thread_safe_methods(object, message = nil)
|
777
|
+
thread_safe_methods = %i[state_machine_mutex read_state_safely write_state_safely]
|
778
|
+
missing_methods = thread_safe_methods.reject { |method| object.respond_to?(method) }
|
779
|
+
|
780
|
+
default_message = "Expected thread-safe methods to be available, but missing: #{missing_methods.inspect}"
|
781
|
+
|
782
|
+
if defined?(::Minitest)
|
783
|
+
assert_empty missing_methods, message || default_message
|
784
|
+
elsif defined?(::RSpec)
|
785
|
+
expect(missing_methods).to be_empty, message || default_message
|
786
|
+
elsif missing_methods.any?
|
787
|
+
raise default_message
|
788
|
+
end
|
789
|
+
end
|
790
|
+
|
463
791
|
# RSpec-style aliases for event triggering (for consistency with RSpec expectations)
|
464
792
|
alias expect_to_trigger_event assert_sm_triggers_event
|
465
793
|
alias have_triggered_event assert_sm_triggers_event
|
@@ -153,12 +153,22 @@ module StateMachines
|
|
153
153
|
#
|
154
154
|
# vehicle = Vehicle.new
|
155
155
|
# transition = StateMachines::Transition.new(vehicle, machine, :ignite, :parked, :idling)
|
156
|
-
# transition.perform
|
157
|
-
# transition.perform(false)
|
158
|
-
# transition.perform(
|
159
|
-
# transition.perform(Time.now
|
156
|
+
# transition.perform # => Runs the +save+ action after setting the state attribute
|
157
|
+
# transition.perform(false) # => Only sets the state attribute
|
158
|
+
# transition.perform(run_action: false) # => Only sets the state attribute
|
159
|
+
# transition.perform(Time.now) # => Passes in additional arguments and runs the +save+ action
|
160
|
+
# transition.perform(Time.now, false) # => Passes in additional arguments and only sets the state attribute
|
161
|
+
# transition.perform(Time.now, run_action: false) # => Passes in additional arguments and only sets the state attribute
|
160
162
|
def perform(*args)
|
161
|
-
run_action =
|
163
|
+
run_action = case args.last
|
164
|
+
in true | false
|
165
|
+
args.pop
|
166
|
+
in { run_action: }
|
167
|
+
args.last.delete(:run_action)
|
168
|
+
else
|
169
|
+
true
|
170
|
+
end
|
171
|
+
|
162
172
|
self.args = args
|
163
173
|
|
164
174
|
# Run the transition
|
@@ -209,6 +209,11 @@ module StateMachines
|
|
209
209
|
transition.machine.write(object, :event, nil)
|
210
210
|
transition.machine.write(object, :event_transition, nil)
|
211
211
|
end
|
212
|
+
|
213
|
+
# Clear stored transitions hash for new cycle (issue #91)
|
214
|
+
if !empty? && (obj = first.object)
|
215
|
+
obj.instance_variable_set(:@_state_machine_event_transitions, nil)
|
216
|
+
end
|
212
217
|
|
213
218
|
# Rollback only if exceptions occur during before callbacks
|
214
219
|
begin
|
@@ -222,7 +227,16 @@ module StateMachines
|
|
222
227
|
# Persists transitions on the object if partial transition was successful.
|
223
228
|
# This allows us to reference them later to complete the transition with
|
224
229
|
# after callbacks.
|
225
|
-
|
230
|
+
if skip_after && success?
|
231
|
+
each { |transition| transition.machine.write(object, :event_transition, transition) }
|
232
|
+
|
233
|
+
# Store transitions in a hash by machine name to avoid overwriting (issue #91)
|
234
|
+
if !empty?
|
235
|
+
transitions_by_machine = object.instance_variable_get(:@_state_machine_event_transitions) || {}
|
236
|
+
each { |transition| transitions_by_machine[transition.machine.name] = transition }
|
237
|
+
object.instance_variable_set(:@_state_machine_event_transitions, transitions_by_machine)
|
238
|
+
end
|
239
|
+
end
|
226
240
|
else
|
227
241
|
super
|
228
242
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: state_machines
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.40.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Abdelkader Boudih
|
@@ -62,6 +62,12 @@ files:
|
|
62
62
|
- LICENSE.txt
|
63
63
|
- README.md
|
64
64
|
- lib/state_machines.rb
|
65
|
+
- lib/state_machines/async_mode.rb
|
66
|
+
- lib/state_machines/async_mode/async_event_extensions.rb
|
67
|
+
- lib/state_machines/async_mode/async_events.rb
|
68
|
+
- lib/state_machines/async_mode/async_machine.rb
|
69
|
+
- lib/state_machines/async_mode/async_transition_collection.rb
|
70
|
+
- lib/state_machines/async_mode/thread_safe_state.rb
|
65
71
|
- lib/state_machines/branch.rb
|
66
72
|
- lib/state_machines/callback.rb
|
67
73
|
- lib/state_machines/core.rb
|
@@ -77,6 +83,7 @@ files:
|
|
77
83
|
- lib/state_machines/integrations/base.rb
|
78
84
|
- lib/state_machines/machine.rb
|
79
85
|
- lib/state_machines/machine/action_hooks.rb
|
86
|
+
- lib/state_machines/machine/async_extensions.rb
|
80
87
|
- lib/state_machines/machine/callbacks.rb
|
81
88
|
- lib/state_machines/machine/class_methods.rb
|
82
89
|
- lib/state_machines/machine/configuration.rb
|