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.
@@ -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 # => Runs the +save+ action after setting the state attribute
157
- # transition.perform(false) # => Only sets the state attribute
158
- # transition.perform(Time.now) # => Passes in additional arguments and runs the +save+ action
159
- # transition.perform(Time.now, false) # => Passes in additional arguments and only sets the state attribute
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 = [true, false].include?(args.last) ? args.pop : true
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
- each { |transition| transition.machine.write(object, :event_transition, transition) } if skip_after && success?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StateMachines
4
- VERSION = '0.30.0'
4
+ VERSION = '0.40.0'
5
5
  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.30.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