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 +4 -4
- data/README.md +65 -1
- data/lib/state_machines/branch.rb +4 -2
- data/lib/state_machines/core.rb +1 -2
- data/lib/state_machines/event.rb +18 -5
- data/lib/state_machines/machine.rb +6 -5
- data/lib/state_machines/machine_collection.rb +3 -1
- data/lib/state_machines/node_collection.rb +3 -1
- data/lib/state_machines/options_validator.rb +72 -0
- data/lib/state_machines/path.rb +3 -1
- data/lib/state_machines/path_collection.rb +3 -1
- data/lib/state_machines/state.rb +24 -7
- data/lib/state_machines/state_context.rb +3 -1
- data/lib/state_machines/test_helper.rb +305 -0
- data/lib/state_machines/transition_collection.rb +3 -1
- data/lib/state_machines/version.rb +1 -1
- metadata +3 -2
- data/lib/state_machines/assertions.rb +0 -42
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5aa5d105c78cb53f15f42e8662dd7f8e0d0d5f8807b88dcbd8b4201f13718384
|
4
|
+
data.tar.gz: d761cedbe052c5c8829626e0875f54dce064f7156162119b4a040594b439068c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 37398c9ca5f2de7413dfb7a8a467984d0fc4d47f8367ea0747fec6d9c1eaca5a7c3439f37988c02dd8ce27622a9a9e54cf5cc0b2d84404f00f92a862c168bb23
|
7
|
+
data.tar.gz: 159022534eb3c308bc2f2c8e960f111053631d3e10adafc9ae8156c95a26165f48690c682229a577bdfecf918b335ebe5b6d57e82e095c924585ad8d11b9d1ee
|
data/README.md
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|

|
2
|
-
[](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
|
-
|
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
|
-
|
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]
|
data/lib/state_machines/core.rb
CHANGED
@@ -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'
|
data/lib/state_machines/event.rb
CHANGED
@@ -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 =
|
36
|
-
|
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 =
|
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
|
-
|
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
|
-
|
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 '
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/state_machines/path.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
32
|
+
StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :deep, :guard)
|
31
33
|
|
32
34
|
@object = object
|
33
35
|
@machine = machine
|
data/lib/state_machines/state.rb
CHANGED
@@ -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 =
|
55
|
-
|
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 =
|
61
|
-
@value =
|
62
|
-
@cache =
|
63
|
-
@matcher =
|
64
|
-
@initial =
|
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
|
-
|
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
|
-
|
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]
|
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.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
|
-
|