state_machines 0.10.0 → 0.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39c8073bdf33aff2e34bfc3403db686c7d6e4344f158e144cb881230b10d9597
4
- data.tar.gz: 150e14164e42addded79421e90456de2f60b4c451c743ee31a8eb2227d91896b
3
+ metadata.gz: 5aa5d105c78cb53f15f42e8662dd7f8e0d0d5f8807b88dcbd8b4201f13718384
4
+ data.tar.gz: d761cedbe052c5c8829626e0875f54dce064f7156162119b4a040594b439068c
5
5
  SHA512:
6
- metadata.gz: 34af008f9378c058a199dd05d3204df5d995466f7db126589a8614d540bb636ce08c42f38796504753be8414431623e02b30c10d772fc2d524d4a2f9f4ba58e2
7
- data.tar.gz: a64f178f0f5ce8e13139b871c3cd351570df7c665f43338201225ff27d2482fe3cb3e1619f79e01544d4f9b3341bb9e589db7a659717e4975312684b6fd0ee82
6
+ metadata.gz: 37398c9ca5f2de7413dfb7a8a467984d0fc4d47f8367ea0747fec6d9c1eaca5a7c3439f37988c02dd8ce27622a9a9e54cf5cc0b2d84404f00f92a862c168bb23
7
+ data.tar.gz: 159022534eb3c308bc2f2c8e960f111053631d3e10adafc9ae8156c95a26165f48690c682229a577bdfecf918b335ebe5b6d57e82e095c924585ad8d11b9d1ee
data/README.md CHANGED
@@ -1,5 +1,4 @@
1
1
  ![Build Status](https://github.com/state-machines/state_machines/actions/workflows/ruby.yml/badge.svg)
2
- [![Code Climate](https://codeclimate.com/github/state-machines/state_machines.svg)](https://codeclimate.com/github/state-machines/state_machines)
3
2
  # State Machines
4
3
 
5
4
  State Machines adds support for creating state machines for attributes on any Ruby class.
@@ -255,6 +254,71 @@ vehicle.state_name # => :parked
255
254
  # vehicle.state = :parked
256
255
  ```
257
256
 
257
+ ## Testing
258
+
259
+ State Machines provides a `TestHelper` module with assertion methods to make testing state machines easier and more expressive.
260
+
261
+ ### Setup
262
+
263
+ Include the test helper in your test class:
264
+
265
+ ```ruby
266
+ # For Minitest
267
+ class VehicleTest < Minitest::Test
268
+ include StateMachines::TestHelper
269
+
270
+ def test_initial_state
271
+ vehicle = Vehicle.new
272
+ assert_state vehicle, :state, :parked
273
+ end
274
+ end
275
+
276
+ # For RSpec
277
+ RSpec.describe Vehicle do
278
+ include StateMachines::TestHelper
279
+
280
+ it "starts in parked state" do
281
+ vehicle = Vehicle.new
282
+ assert_state vehicle, :state, :parked
283
+ end
284
+ end
285
+ ```
286
+
287
+ ### Available Assertions
288
+
289
+ The TestHelper provides both basic assertions and comprehensive state machine-specific assertions with `sm_` prefixes:
290
+
291
+ #### Basic Assertions
292
+
293
+ ```ruby
294
+ vehicle = Vehicle.new
295
+ assert_state vehicle, :state, :parked
296
+ assert_can_transition vehicle, :ignite
297
+ assert_cannot_transition vehicle, :shift_up
298
+ assert_transition vehicle, :ignite, :state, :idling
299
+ ```
300
+
301
+ #### Extended State Machine Assertions
302
+
303
+ ```ruby
304
+ machine = Vehicle.state_machine(:state)
305
+ vehicle = Vehicle.new
306
+
307
+ # State configuration
308
+ assert_sm_states_list machine, [:parked, :idling, :stalled]
309
+ assert_sm_initial_state machine, :parked
310
+
311
+ # Event behavior
312
+ assert_sm_event_triggers vehicle, :ignite
313
+ refute_sm_event_triggers vehicle, :shift_up
314
+ assert_sm_event_raises_error vehicle, :invalid_event, StateMachines::InvalidTransition
315
+
316
+ # Persistence (with ActiveRecord integration)
317
+ assert_sm_state_persisted record, expected: :active
318
+ ```
319
+
320
+ The test helper works with both Minitest and RSpec, automatically detecting your testing framework.
321
+
258
322
  ## Additional Topics
259
323
 
260
324
  ### Explicit vs. Implicit Event Transitions
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # Represents a set of requirements that must be met in order for a transition
5
7
  # or callback to occur. Branches verify that the event, from state, and to
@@ -114,7 +116,7 @@ module StateMachines
114
116
  # branch.match(object, :on => :ignite) # => {:to => ..., :from => ..., :on => ...}
115
117
  # branch.match(object, :on => :park) # => nil
116
118
  def match(object, query = {})
117
- query.assert_valid_keys(:from, :to, :on, :guard)
119
+ StateMachines::OptionsValidator.assert_valid_keys!(query, :from, :to, :on, :guard)
118
120
 
119
121
  if (match = match_query(query)) && matches_conditions?(object, query)
120
122
  match
@@ -131,7 +133,7 @@ module StateMachines
131
133
  # whitelist nor a blacklist option is specified, then an AllMatcher is
132
134
  # built.
133
135
  def build_matcher(options, whitelist_option, blacklist_option)
134
- options.assert_exclusive_keys(whitelist_option, blacklist_option)
136
+ StateMachines::OptionsValidator.assert_exclusive_keys!(options, whitelist_option, blacklist_option)
135
137
 
136
138
  if options.include?(whitelist_option)
137
139
  value = options[whitelist_option]
@@ -5,7 +5,6 @@
5
5
  # * StateMachines::MacroMethods which adds the state_machine DSL to your class
6
6
  # * A set of initializers for setting state_machine defaults based on the current
7
7
  # running environment (such as within Rails)
8
- require 'state_machines/assertions'
9
8
  require 'state_machines/error'
10
9
 
11
10
  require 'state_machines/extensions'
@@ -42,4 +41,4 @@ require 'state_machines/path_collection'
42
41
  require 'state_machines/machine'
43
42
  require 'state_machines/machine_collection'
44
43
 
45
- require 'state_machines/macro_methods'
44
+ require 'state_machines/macro_methods'
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # An event defines an action that transitions an attribute from one state to
5
7
  # another. The state that an attribute is transitioned to depends on the
@@ -32,13 +34,22 @@ module StateMachines
32
34
  #
33
35
  # Configuration options:
34
36
  # * <tt>:human_name</tt> - The human-readable version of this event's name
35
- def initialize(machine, name, options = {}) #:nodoc:
36
- options.assert_valid_keys(:human_name)
37
+ def initialize(machine, name, options = nil, human_name: nil, **extra_options) #:nodoc:
38
+ # Handle both old hash style and new kwargs style for backward compatibility
39
+ if options.is_a?(Hash)
40
+ # Old style: initialize(machine, name, {human_name: 'Custom Name'})
41
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :human_name)
42
+ human_name = options[:human_name]
43
+ else
44
+ # New style: initialize(machine, name, human_name: 'Custom Name')
45
+ raise ArgumentError, "Unexpected positional argument: #{options.inspect}" unless options.nil?
46
+ StateMachines::OptionsValidator.assert_valid_keys!(extra_options, :human_name) unless extra_options.empty?
47
+ end
37
48
 
38
49
  @machine = machine
39
50
  @name = name
40
51
  @qualified_name = machine.namespace ? :"#{name}_#{machine.namespace}" : name
41
- @human_name = options[:human_name] || @name.to_s.tr('_', ' ')
52
+ @human_name = human_name || @name.to_s.tr('_', ' ')
42
53
  reset
43
54
 
44
55
  # Output a warning if another event has a conflicting qualified name
@@ -91,7 +102,9 @@ module StateMachines
91
102
 
92
103
  # Only a certain subset of explicit options are allowed for transition
93
104
  # requirements
94
- options.assert_valid_keys(:from, :to, :except_from, :except_to, :if, :unless) if (options.keys - [:from, :to, :on, :except_from, :except_to, :except_on, :if, :unless]).empty?
105
+ if (options.keys - [:from, :to, :on, :except_from, :except_to, :except_on, :if, :unless]).empty?
106
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless)
107
+ end
95
108
 
96
109
  branches << branch = Branch.new(options.merge(on: name))
97
110
  @known_states |= branch.known_states
@@ -121,7 +134,7 @@ module StateMachines
121
134
  # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
122
135
  # conditionals defined for each one. Default is true.
123
136
  def transition_for(object, requirements = {})
124
- requirements.assert_valid_keys(:from, :to, :guard)
137
+ StateMachines::OptionsValidator.assert_valid_keys!(requirements, :from, :to, :guard)
125
138
  requirements[:from] = machine.states.match!(object).name unless (custom_from_state = requirements.include?(:from))
126
139
 
127
140
  branches.each do |branch|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'machine/class_methods'
3
+ require_relative 'options_validator'
4
+ require_relative 'machine/class_methods'
4
5
 
5
6
  module StateMachines
6
7
  # Represents a state machine for a particular attribute. State machines
@@ -451,7 +452,7 @@ module StateMachines
451
452
  # Creates a new state machine for the given attribute
452
453
  def initialize(owner_class, *args, &block)
453
454
  options = args.last.is_a?(Hash) ? args.pop : {}
454
- options.assert_valid_keys(:attribute, :initial, :initialize, :action, :plural, :namespace, :integration, :messages, :use_transactions)
455
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :attribute, :initial, :initialize, :action, :plural, :namespace, :integration, :messages, :use_transactions)
455
456
 
456
457
  # Find an integration that matches this machine's owner class
457
458
  if options.include?(:integration)
@@ -953,7 +954,7 @@ module StateMachines
953
954
  # options hash which contains at least <tt>:if</tt> condition support.
954
955
  def state(*names, &block)
955
956
  options = names.last.is_a?(Hash) ? names.pop : {}
956
- options.assert_valid_keys(:value, :cache, :if, :human_name)
957
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :value, :cache, :if, :human_name)
957
958
 
958
959
  # Store the context so that it can be used for / matched against any state
959
960
  # that gets added
@@ -1256,7 +1257,7 @@ module StateMachines
1256
1257
  # end
1257
1258
  def event(*names, &block)
1258
1259
  options = names.last.is_a?(Hash) ? names.pop : {}
1259
- options.assert_valid_keys(:human_name)
1260
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :human_name)
1260
1261
 
1261
1262
  # Store the context so that it can be used for / matched against any event
1262
1263
  # that gets added
@@ -1698,7 +1699,7 @@ module StateMachines
1698
1699
  def after_failure(*args, &block)
1699
1700
  options = (args.last.is_a?(Hash) ? args.pop : {})
1700
1701
  options[:do] = args if args.any?
1701
- options.assert_valid_keys(:on, :do, :if, :unless)
1702
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :on, :do, :if, :unless)
1702
1703
 
1703
1704
  add_callback(:failure, options, &block)
1704
1705
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # Represents a collection of state machines for a class
5
7
  class MachineCollection < Hash
@@ -22,7 +24,7 @@ module StateMachines
22
24
  # * <tt>:to</tt> - A hash to write the initialized state to instead of
23
25
  # writing to the object. Default is to write directly to the object.
24
26
  def initialize_states(object, options = {}, attributes = {})
25
- options.assert_valid_keys( :static, :dynamic, :to)
27
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :static, :dynamic, :to)
26
28
  options = {static: true, dynamic: true}.merge(options)
27
29
 
28
30
  result = yield if block_given?
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # Represents a collection of nodes in a state machine, be it events or states.
5
7
  # Nodes will not differentiate between the String and Symbol versions of the
@@ -18,7 +20,7 @@ module StateMachines
18
20
  # hashed indices for in order to perform quick lookups. Default is to
19
21
  # index by the :name attribute
20
22
  def initialize(machine, options = {})
21
- options.assert_valid_keys(:index)
23
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :index)
22
24
  options = { index: :name }.merge(options)
23
25
 
24
26
  @machine = machine
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ # Define the module if it doesn't exist yet
5
+ # Module for validating options without monkey-patching Hash
6
+ # Provides the same functionality as the Hash monkey patch but in a cleaner way
7
+ module OptionsValidator
8
+ class << self
9
+ # Validates that all keys in the options hash are in the list of valid keys
10
+ #
11
+ # @param options [Hash] The options hash to validate
12
+ # @param valid_keys [Array<Symbol>] List of valid key names
13
+ # @param caller_info [String] Information about the calling method for better error messages
14
+ # @raise [ArgumentError] If any invalid keys are found
15
+ def assert_valid_keys!(options, *valid_keys, caller_info: nil)
16
+ return if options.empty?
17
+
18
+ valid_keys.flatten!
19
+ invalid_keys = options.keys - valid_keys
20
+
21
+ return if invalid_keys.empty?
22
+
23
+ caller_context = caller_info ? " in #{caller_info}" : ''
24
+ raise ArgumentError, "Unknown key#{'s' if invalid_keys.length > 1}: #{invalid_keys.map(&:inspect).join(', ')}. Valid keys are: #{valid_keys.map(&:inspect).join(', ')}#{caller_context}"
25
+ end
26
+
27
+ # Validates that at most one of the exclusive keys is present in the options hash
28
+ #
29
+ # @param options [Hash] The options hash to validate
30
+ # @param exclusive_keys [Array<Symbol>] List of mutually exclusive keys
31
+ # @param caller_info [String] Information about the calling method for better error messages
32
+ # @raise [ArgumentError] If more than one exclusive key is found
33
+ def assert_exclusive_keys!(options, *exclusive_keys, caller_info: nil)
34
+ return if options.empty?
35
+
36
+ conflicting_keys = exclusive_keys & options.keys
37
+ return if conflicting_keys.length <= 1
38
+
39
+ caller_context = caller_info ? " in #{caller_info}" : ''
40
+ raise ArgumentError, "Conflicting keys: #{conflicting_keys.join(', ')}#{caller_context}"
41
+ end
42
+
43
+ # Validates options using a more convenient interface that works with both
44
+ # hash-style and kwargs-style method definitions
45
+ #
46
+ # @param valid_keys [Array<Symbol>] List of valid key names
47
+ # @param exclusive_key_groups [Array<Array<Symbol>>] Groups of mutually exclusive keys
48
+ # @param caller_info [String] Information about the calling method
49
+ # @return [Proc] A validation proc that can be called with options
50
+ def validator(valid_keys: [], exclusive_key_groups: [], caller_info: nil)
51
+ proc do |options|
52
+ assert_valid_keys!(options, *valid_keys, caller_info: caller_info) unless valid_keys.empty?
53
+
54
+ exclusive_key_groups.each do |group|
55
+ assert_exclusive_keys!(options, *group, caller_info: caller_info)
56
+ end
57
+ end
58
+ end
59
+
60
+ # Helper method for backwards compatibility - allows gradual migration
61
+ # from Hash monkey patch to this module
62
+ #
63
+ # @param options [Hash] The options to validate
64
+ # @param valid_keys [Array<Symbol>] Valid keys
65
+ # @return [Hash] The same options hash (for chaining)
66
+ def validate_and_return(options, *valid_keys)
67
+ assert_valid_keys!(options, *valid_keys)
68
+ options
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # A path represents a sequence of transitions that can be run for a particular
5
7
  # object. Paths can walk to new transitions, revealing all of the possible
@@ -22,7 +24,7 @@ module StateMachines
22
24
  # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
23
25
  # conditionals defined for each one
24
26
  def initialize(object, machine, options = {})
25
- options.assert_valid_keys(:target, :guard)
27
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :target, :guard)
26
28
 
27
29
  @object = object
28
30
  @machine = machine
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # Represents a collection of paths that are generated based on a set of
5
7
  # requirements regarding what states to start and end on
@@ -27,7 +29,7 @@ module StateMachines
27
29
  # conditionals defined for each one
28
30
  def initialize(object, machine, options = {})
29
31
  options = {deep: false, from: machine.states.match!(object).name}.merge(options)
30
- options.assert_valid_keys( :from, :to, :deep, :guard)
32
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :deep, :guard)
31
33
 
32
34
  @object = object
33
35
  @machine = machine
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # A state defines a value that an attribute can be in after being transitioned
5
7
  # 0 or more times. States can represent a value of any type in Ruby, though
@@ -51,17 +53,32 @@ module StateMachines
51
53
  # (e.g. :value => lambda {Time.now}, :if => lambda {|state| !state.nil?}).
52
54
  # By default, the configured value is matched.
53
55
  # * <tt>:human_name</tt> - The human-readable version of this state's name
54
- def initialize(machine, name, options = {}) # :nodoc:
55
- options.assert_valid_keys(:initial, :value, :cache, :if, :human_name)
56
+ def initialize(machine, name, options = nil, initial: false, value: :__not_provided__, cache: nil, if: nil, human_name: nil, **extra_options) # :nodoc:
57
+ # Handle both old hash style and new kwargs style for backward compatibility
58
+ if options.is_a?(Hash)
59
+ # Old style: initialize(machine, name, {initial: true, value: 'foo'})
60
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :initial, :value, :cache, :if, :human_name)
61
+ initial = options.fetch(:initial, false)
62
+ value = options.include?(:value) ? options[:value] : :__not_provided__
63
+ cache = options[:cache]
64
+ if_condition = options[:if]
65
+ human_name = options[:human_name]
66
+ else
67
+ # New style: initialize(machine, name, initial: true, value: 'foo')
68
+ # options parameter should be nil in this case
69
+ raise ArgumentError, "Unexpected positional argument: #{options.inspect}" unless options.nil?
70
+ StateMachines::OptionsValidator.assert_valid_keys!(extra_options, :initial, :value, :cache, :if, :human_name) unless extra_options.empty?
71
+ if_condition = binding.local_variable_get(:if) # 'if' is a keyword, need special handling
72
+ end
56
73
 
57
74
  @machine = machine
58
75
  @name = name
59
76
  @qualified_name = name && machine.namespace ? :"#{machine.namespace}_#{name}" : name
60
- @human_name = options[:human_name] || (@name ? @name.to_s.tr('_', ' ') : 'nil')
61
- @value = options.include?(:value) ? options[:value] : name&.to_s
62
- @cache = options[:cache]
63
- @matcher = options[:if]
64
- @initial = options[:initial] == true
77
+ @human_name = human_name || (@name ? @name.to_s.tr('_', ' ') : 'nil')
78
+ @value = value == :__not_provided__ ? name&.to_s : value
79
+ @cache = cache
80
+ @matcher = if_condition
81
+ @initial = initial == true
65
82
  @context = StateContext.new(self)
66
83
 
67
84
  return unless name
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # Represents a module which will get evaluated within the context of a state.
5
7
  #
@@ -88,7 +90,7 @@ module StateMachines
88
90
  # See StateMachines::Machine#transition for a description of the possible
89
91
  # configurations for defining transitions.
90
92
  def transition(options)
91
- options.assert_valid_keys(:from, :to, :on, :if, :unless)
93
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :on, :if, :unless)
92
94
  raise ArgumentError, 'Must specify :on event' unless options[:on]
93
95
  raise ArgumentError, 'Must specify either :to or :from state' unless !options[:to] ^ !options[:from]
94
96
 
@@ -0,0 +1,305 @@
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 machine_name [Symbol] The name of the state machine
33
+ # @param expected_state [Symbol] The expected 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_state(user, :status, :active)
41
+ def assert_state(object, machine_name, expected_state, message = nil)
42
+ actual = object.send("#{machine_name}_name")
43
+ default_message = "Expected #{object.class}##{machine_name} to be #{expected_state}, but was #{actual}"
44
+
45
+ if defined?(::Minitest)
46
+ assert_equal expected_state.to_s, actual.to_s, message || default_message
47
+ elsif defined?(::RSpec)
48
+ expect(actual.to_s).to eq(expected_state.to_s), message || default_message
49
+ else
50
+ raise "Expected #{expected_state}, but got #{actual}" unless expected_state.to_s == actual.to_s
51
+ end
52
+ end
53
+
54
+ # Assert that an object can transition via a specific event
55
+ #
56
+ # @param object [Object] The object with state machines
57
+ # @param event [Symbol] The event name
58
+ # @param message [String, nil] Custom failure message
59
+ # @return [void]
60
+ # @raise [AssertionError] If the transition is not available
61
+ #
62
+ # @example
63
+ # user = User.new
64
+ # assert_can_transition(user, :activate)
65
+ def assert_can_transition(object, event, message = nil)
66
+ can_method = "can_#{event}?"
67
+ default_message = "Expected to be able to trigger event :#{event}, but #{can_method} returned false"
68
+
69
+ if defined?(::Minitest)
70
+ assert object.send(can_method), message || default_message
71
+ elsif defined?(::RSpec)
72
+ expect(object.send(can_method)).to be_truthy, message || default_message
73
+ else
74
+ raise default_message unless object.send(can_method)
75
+ end
76
+ end
77
+
78
+ # Assert that an object cannot transition via a specific event
79
+ #
80
+ # @param object [Object] The object with state machines
81
+ # @param event [Symbol] The event name
82
+ # @param message [String, nil] Custom failure message
83
+ # @return [void]
84
+ # @raise [AssertionError] If the transition is available
85
+ #
86
+ # @example
87
+ # user = User.new
88
+ # assert_cannot_transition(user, :delete)
89
+ def assert_cannot_transition(object, event, message = nil)
90
+ can_method = "can_#{event}?"
91
+ default_message = "Expected not to be able to trigger event :#{event}, but #{can_method} returned true"
92
+
93
+ if defined?(::Minitest)
94
+ refute object.send(can_method), message || default_message
95
+ elsif defined?(::RSpec)
96
+ expect(object.send(can_method)).to be_falsy, message || default_message
97
+ elsif object.send(can_method)
98
+ raise default_message
99
+ end
100
+ end
101
+
102
+ # Assert that triggering an event changes the object to the expected state
103
+ #
104
+ # @param object [Object] The object with state machines
105
+ # @param event [Symbol] The event to trigger
106
+ # @param machine_name [Symbol] The name of the state machine
107
+ # @param expected_state [Symbol] The expected state after transition
108
+ # @param message [String, nil] Custom failure message
109
+ # @return [void]
110
+ # @raise [AssertionError] If the transition fails or results in wrong state
111
+ #
112
+ # @example
113
+ # user = User.new
114
+ # assert_transition(user, :activate, :status, :active)
115
+ def assert_transition(object, event, machine_name, expected_state, message = nil)
116
+ object.send("#{event}!")
117
+ assert_state(object, machine_name, expected_state, 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
+ if defined?(::Minitest)
127
+ assert_equal expected_states.sort, actual_states.sort, message || default_message
128
+ elsif defined?(::RSpec)
129
+ expect(actual_states.sort).to eq(expected_states.sort), message || default_message
130
+ else
131
+ raise default_message unless expected_states.sort == actual_states.sort
132
+ end
133
+ end
134
+
135
+ def refute_sm_state_defined(machine, state, message = nil)
136
+ state_exists = machine.states.any? { |s| s.name == state }
137
+ default_message = "Expected state #{state} to not be defined in machine"
138
+
139
+ if defined?(::Minitest)
140
+ refute state_exists, message || default_message
141
+ elsif defined?(::RSpec)
142
+ expect(state_exists).to be_falsy, message || default_message
143
+ elsif state_exists
144
+ raise default_message
145
+ end
146
+ end
147
+ alias assert_sm_state_not_defined refute_sm_state_defined
148
+
149
+ def assert_sm_initial_state(machine, expected_state, message = nil)
150
+ state_obj = machine.state(expected_state)
151
+ is_initial = state_obj&.initial?
152
+ default_message = "Expected state #{expected_state} to be the initial state"
153
+
154
+ if defined?(::Minitest)
155
+ assert is_initial, message || default_message
156
+ elsif defined?(::RSpec)
157
+ expect(is_initial).to be_truthy, message || default_message
158
+ else
159
+ raise default_message unless is_initial
160
+ end
161
+ end
162
+
163
+ def assert_sm_final_state(machine, state, message = nil)
164
+ state_obj = machine.states[state]
165
+ is_final = state_obj&.final?
166
+ default_message = "Expected state #{state} to be final"
167
+
168
+ if defined?(::Minitest)
169
+ assert is_final, message || default_message
170
+ elsif defined?(::RSpec)
171
+ expect(is_final).to be_truthy, message || default_message
172
+ else
173
+ raise default_message unless is_final
174
+ end
175
+ end
176
+
177
+ def assert_sm_possible_transitions(machine, from:, expected_to_states:, message: nil)
178
+ actual_transitions = machine.events.flat_map do |event|
179
+ event.branches.select { |branch| branch.known_states.include?(from) }
180
+ .map(&:to)
181
+ end.uniq
182
+ default_message = "Expected transitions from #{from} to #{expected_to_states} but got #{actual_transitions}"
183
+
184
+ if defined?(::Minitest)
185
+ assert_equal expected_to_states.sort, actual_transitions.sort, message || default_message
186
+ elsif defined?(::RSpec)
187
+ expect(actual_transitions.sort).to eq(expected_to_states.sort), message || default_message
188
+ else
189
+ raise default_message unless expected_to_states.sort == actual_transitions.sort
190
+ end
191
+ end
192
+
193
+ def refute_sm_transition_allowed(machine, from:, to:, on:, message: nil)
194
+ event = machine.events[on]
195
+ is_allowed = event&.branches&.any? { |branch| branch.known_states.include?(from) && branch.to == to }
196
+ default_message = "Expected transition from #{from} to #{to} on #{on} to not be allowed"
197
+
198
+ if defined?(::Minitest)
199
+ refute is_allowed, message || default_message
200
+ elsif defined?(::RSpec)
201
+ expect(is_allowed).to be_falsy, message || default_message
202
+ elsif is_allowed
203
+ raise default_message
204
+ end
205
+ end
206
+ alias assert_sm_transition_not_allowed refute_sm_transition_allowed
207
+
208
+ def assert_sm_event_triggers(object, event, message = nil)
209
+ initial_state = object.state
210
+ object.send("#{event}!")
211
+ state_changed = initial_state != object.state
212
+ default_message = "Expected event #{event} to trigger state change"
213
+
214
+ if defined?(::Minitest)
215
+ assert state_changed, message || default_message
216
+ elsif defined?(::RSpec)
217
+ expect(state_changed).to be_truthy, message || default_message
218
+ else
219
+ raise default_message unless state_changed
220
+ end
221
+ end
222
+
223
+ def refute_sm_event_triggers(object, event, message = nil)
224
+ initial_state = object.state
225
+ begin
226
+ object.send("#{event}!")
227
+ state_unchanged = initial_state == object.state
228
+ default_message = "Expected event #{event} to not trigger state change"
229
+
230
+ if defined?(::Minitest)
231
+ assert state_unchanged, message || default_message
232
+ elsif defined?(::RSpec)
233
+ expect(state_unchanged).to be_truthy, message || default_message
234
+ else
235
+ raise default_message unless state_unchanged
236
+ end
237
+ rescue StateMachines::InvalidTransition
238
+ # Expected behavior - transition was blocked
239
+ end
240
+ end
241
+ alias assert_sm_event_not_triggers refute_sm_event_triggers
242
+
243
+ def assert_sm_event_raises_error(object, event, error_class, message = nil)
244
+ default_message = "Expected event #{event} to raise #{error_class}"
245
+
246
+ if defined?(::Minitest)
247
+ assert_raises(error_class, message || default_message) do
248
+ object.send("#{event}!")
249
+ end
250
+ elsif defined?(::RSpec)
251
+ expect { object.send("#{event}!") }.to raise_error(error_class), message || default_message
252
+ else
253
+ begin
254
+ object.send("#{event}!")
255
+ raise default_message
256
+ rescue error_class
257
+ # Expected behavior
258
+ end
259
+ end
260
+ end
261
+
262
+ def assert_sm_callback_executed(object, callback_name, message = nil)
263
+ callbacks_executed = object.instance_variable_get(:@_sm_callbacks_executed) || []
264
+ callback_was_executed = callbacks_executed.include?(callback_name)
265
+ default_message = "Expected callback #{callback_name} to be executed"
266
+
267
+ if defined?(::Minitest)
268
+ assert callback_was_executed, message || default_message
269
+ elsif defined?(::RSpec)
270
+ expect(callback_was_executed).to be_truthy, message || default_message
271
+ else
272
+ raise default_message unless callback_was_executed
273
+ end
274
+ end
275
+
276
+ def refute_sm_callback_executed(object, callback_name, message = nil)
277
+ callbacks_executed = object.instance_variable_get(:@_sm_callbacks_executed) || []
278
+ callback_was_executed = callbacks_executed.include?(callback_name)
279
+ default_message = "Expected callback #{callback_name} to not be executed"
280
+
281
+ if defined?(::Minitest)
282
+ refute callback_was_executed, message || default_message
283
+ elsif defined?(::RSpec)
284
+ expect(callback_was_executed).to be_falsy, message || default_message
285
+ elsif callback_was_executed
286
+ raise default_message
287
+ end
288
+ end
289
+ alias assert_sm_callback_not_executed refute_sm_callback_executed
290
+
291
+ def assert_sm_state_persisted(record, expected:, message: nil)
292
+ record.reload if record.respond_to?(:reload)
293
+ actual_state = record.state
294
+ default_message = "Expected persisted state #{expected} but got #{actual_state}"
295
+
296
+ if defined?(::Minitest)
297
+ assert_equal expected, actual_state, message || default_message
298
+ elsif defined?(::RSpec)
299
+ expect(actual_state).to eq(expected), message || default_message
300
+ else
301
+ raise default_message unless expected == actual_state
302
+ end
303
+ end
304
+ end
305
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # Represents a collection of transitions in a state machine
5
7
  class TransitionCollection < Array
@@ -30,7 +32,7 @@ module StateMachines
30
32
  attributes = map { |transition| transition.attribute }.uniq
31
33
  fail ArgumentError, 'Cannot perform multiple transitions in parallel for the same state machine attribute' if attributes.length != length
32
34
 
33
- options.assert_valid_keys(:actions, :after, :use_transactions)
35
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :actions, :after, :use_transactions)
34
36
  options = {actions: true, after: true, use_transactions: true}.merge(options)
35
37
  @skip_actions = !options[:actions]
36
38
  @skip_after = !options[:after]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StateMachines
4
- VERSION = '0.10.0'
4
+ VERSION = '0.20.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.10.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -62,7 +62,6 @@ files:
62
62
  - LICENSE.txt
63
63
  - README.md
64
64
  - lib/state_machines.rb
65
- - lib/state_machines/assertions.rb
66
65
  - lib/state_machines/branch.rb
67
66
  - lib/state_machines/callback.rb
68
67
  - lib/state_machines/core.rb
@@ -83,12 +82,14 @@ files:
83
82
  - lib/state_machines/matcher.rb
84
83
  - lib/state_machines/matcher_helpers.rb
85
84
  - lib/state_machines/node_collection.rb
85
+ - lib/state_machines/options_validator.rb
86
86
  - lib/state_machines/path.rb
87
87
  - lib/state_machines/path_collection.rb
88
88
  - lib/state_machines/state.rb
89
89
  - lib/state_machines/state_collection.rb
90
90
  - lib/state_machines/state_context.rb
91
91
  - lib/state_machines/stdio_renderer.rb
92
+ - lib/state_machines/test_helper.rb
92
93
  - lib/state_machines/transition.rb
93
94
  - lib/state_machines/transition_collection.rb
94
95
  - lib/state_machines/version.rb
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Hash
4
- # Provides a set of helper methods for making assertions about the content
5
- # of various objects
6
-
7
- unless respond_to?(:assert_valid_keys)
8
- # Validate all keys in a hash match <tt>*valid_keys</tt>, raising ArgumentError
9
- # on a mismatch. Note that keys are NOT treated indifferently, meaning if you
10
- # use strings for keys but assert symbols as keys, this will fail.
11
- #
12
- # { name: 'Rob', years: '28' }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key: :years. Valid keys are: :name, :age"
13
- # { name: 'Rob', age: '28' }.assert_valid_keys('name', 'age') # => raises "ArgumentError: Unknown key: :name. Valid keys are: 'name', 'age'"
14
- # { name: 'Rob', age: '28' }.assert_valid_keys(:name, :age) # => passes, raises nothing
15
- # Code from ActiveSupport
16
- def assert_valid_keys(*valid_keys)
17
- valid_keys.flatten!
18
- each_key do |k|
19
- unless valid_keys.include?(k)
20
- raise ArgumentError.new("Unknown key: #{k.inspect}. Valid keys are: #{valid_keys.map(&:inspect).join(', ')}")
21
- end
22
- end
23
- end
24
- end
25
-
26
- # Validates that the given hash only includes at *most* one of a set of
27
- # exclusive keys. If more than one key is found, an ArgumentError will be
28
- # raised.
29
- #
30
- # == Examples
31
- #
32
- # options = {:only => :on, :except => :off}
33
- # options.assert_exclusive_keys(:only) # => nil
34
- # options.assert_exclusive_keys(:except) # => nil
35
- # options.assert_exclusive_keys(:only, :except) # => ArgumentError: Conflicting keys: only, except
36
- # options.assert_exclusive_keys(:only, :except, :with) # => ArgumentError: Conflicting keys: only, except
37
- def assert_exclusive_keys(*exclusive_keys)
38
- conflicting_keys = exclusive_keys & keys
39
- raise ArgumentError, "Conflicting keys: #{conflicting_keys.join(', ')}" unless conflicting_keys.length <= 1
40
- end
41
- end
42
-