state_machines 0.6.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +93 -13
  3. data/lib/state_machines/branch.rb +8 -4
  4. data/lib/state_machines/callback.rb +2 -0
  5. data/lib/state_machines/core.rb +3 -2
  6. data/lib/state_machines/core_ext/class/state_machine.rb +2 -0
  7. data/lib/state_machines/core_ext.rb +2 -0
  8. data/lib/state_machines/error.rb +2 -0
  9. data/lib/state_machines/eval_helpers.rb +38 -9
  10. data/lib/state_machines/event.rb +22 -7
  11. data/lib/state_machines/event_collection.rb +2 -0
  12. data/lib/state_machines/extensions.rb +2 -0
  13. data/lib/state_machines/helper_module.rb +2 -0
  14. data/lib/state_machines/integrations/base.rb +2 -0
  15. data/lib/state_machines/integrations.rb +2 -0
  16. data/lib/state_machines/machine/class_methods.rb +79 -0
  17. data/lib/state_machines/machine.rb +21 -67
  18. data/lib/state_machines/machine_collection.rb +5 -1
  19. data/lib/state_machines/macro_methods.rb +2 -0
  20. data/lib/state_machines/matcher.rb +3 -0
  21. data/lib/state_machines/matcher_helpers.rb +2 -0
  22. data/lib/state_machines/node_collection.rb +5 -1
  23. data/lib/state_machines/options_validator.rb +72 -0
  24. data/lib/state_machines/path.rb +5 -1
  25. data/lib/state_machines/path_collection.rb +5 -1
  26. data/lib/state_machines/state.rb +71 -43
  27. data/lib/state_machines/state_collection.rb +2 -0
  28. data/lib/state_machines/state_context.rb +5 -1
  29. data/lib/state_machines/stdio_renderer.rb +74 -0
  30. data/lib/state_machines/test_helper.rb +305 -0
  31. data/lib/state_machines/transition.rb +2 -0
  32. data/lib/state_machines/transition_collection.rb +5 -1
  33. data/lib/state_machines/version.rb +3 -1
  34. data/lib/state_machines.rb +4 -1
  35. metadata +11 -9
  36. data/lib/state_machines/assertions.rb +0 -40
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'options_validator'
4
+
1
5
  module StateMachines
2
6
  # Represents a collection of state machines for a class
3
7
  class MachineCollection < Hash
@@ -20,7 +24,7 @@ module StateMachines
20
24
  # * <tt>:to</tt> - A hash to write the initialized state to instead of
21
25
  # writing to the object. Default is to write directly to the object.
22
26
  def initialize_states(object, options = {}, attributes = {})
23
- options.assert_valid_keys( :static, :dynamic, :to)
27
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :static, :dynamic, :to)
24
28
  options = {static: true, dynamic: true}.merge(options)
25
29
 
26
30
  result = yield if block_given?
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # A state machine is a model of behavior composed of states, events, and
2
4
  # transitions. This helper adds support for defining this type of
3
5
  # functionality on any Ruby class.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  # Provides a general strategy pattern for determining whether a match is found
3
5
  # for a value. The algorithm that actually determines the match depends on
@@ -33,6 +35,7 @@ module StateMachines
33
35
  def -(blacklist)
34
36
  BlacklistMatcher.new(blacklist)
35
37
  end
38
+ alias_method :except, :-
36
39
 
37
40
  # Always returns true
38
41
  def matches?(value, context = {})
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  # Provides a set of helper methods for generating matchers
3
5
  module MatcherHelpers
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'options_validator'
4
+
1
5
  module StateMachines
2
6
  # Represents a collection of nodes in a state machine, be it events or states.
3
7
  # Nodes will not differentiate between the String and Symbol versions of the
@@ -16,7 +20,7 @@ module StateMachines
16
20
  # hashed indices for in order to perform quick lookups. Default is to
17
21
  # index by the :name attribute
18
22
  def initialize(machine, options = {})
19
- options.assert_valid_keys(:index)
23
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :index)
20
24
  options = { index: :name }.merge(options)
21
25
 
22
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,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'options_validator'
4
+
1
5
  module StateMachines
2
6
  # A path represents a sequence of transitions that can be run for a particular
3
7
  # object. Paths can walk to new transitions, revealing all of the possible
@@ -20,7 +24,7 @@ module StateMachines
20
24
  # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
21
25
  # conditionals defined for each one
22
26
  def initialize(object, machine, options = {})
23
- options.assert_valid_keys(:target, :guard)
27
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :target, :guard)
24
28
 
25
29
  @object = object
26
30
  @machine = machine
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'options_validator'
4
+
1
5
  module StateMachines
2
6
  # Represents a collection of paths that are generated based on a set of
3
7
  # requirements regarding what states to start and end on
@@ -25,7 +29,7 @@ module StateMachines
25
29
  # conditionals defined for each one
26
30
  def initialize(object, machine, options = {})
27
31
  options = {deep: false, from: machine.states.match!(object).name}.merge(options)
28
- options.assert_valid_keys( :from, :to, :deep, :guard)
32
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :deep, :guard)
29
33
 
30
34
  @object = object
31
35
  @machine = machine
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'options_validator'
4
+
1
5
  module StateMachines
2
6
  # A state defines a value that an attribute can be in after being transitioned
3
7
  # 0 or more times. States can represent a value of any type in Ruby, though
@@ -8,7 +12,6 @@ module StateMachines
8
12
  # StateMachines::Machine#state for more information about how state-driven
9
13
  # behavior can be utilized.
10
14
  class State
11
-
12
15
  # The state machine for which this state is defined
13
16
  attr_reader :machine
14
17
 
@@ -31,7 +34,7 @@ module StateMachines
31
34
 
32
35
  # Whether or not this state is the initial state to use for new objects
33
36
  attr_accessor :initial
34
- alias_method :initial?, :initial
37
+ alias initial? initial
35
38
 
36
39
  # A custom lambda block for determining whether a given value matches this
37
40
  # state
@@ -50,38 +53,57 @@ module StateMachines
50
53
  # (e.g. :value => lambda {Time.now}, :if => lambda {|state| !state.nil?}).
51
54
  # By default, the configured value is matched.
52
55
  # * <tt>:human_name</tt> - The human-readable version of this state's name
53
- def initialize(machine, name, options = {}) #:nodoc:
54
- 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
55
73
 
56
74
  @machine = machine
57
75
  @name = name
58
76
  @qualified_name = name && machine.namespace ? :"#{machine.namespace}_#{name}" : name
59
- @human_name = options[:human_name] || (@name ? @name.to_s.tr('_', ' ') : 'nil')
60
- @value = options.include?(:value) ? options[:value] : name&.to_s
61
- @cache = options[:cache]
62
- @matcher = options[:if]
63
- @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
64
82
  @context = StateContext.new(self)
65
83
 
66
- if name
67
- conflicting_machines = machine.owner_class.state_machines.select { |other_name, other_machine| other_machine != machine && other_machine.states[qualified_name, :qualified_name] }
68
-
69
- # Output a warning if another machine has a conflicting qualified name
70
- # for a different attribute
71
- if (conflict = conflicting_machines.detect { |_other_name, other_machine| other_machine.attribute != machine.attribute })
72
- _name, other_machine = conflict
73
- warn "State #{qualified_name.inspect} for #{machine.name.inspect} is already defined in #{other_machine.name.inspect}"
74
- elsif conflicting_machines.empty?
75
- # Only bother adding predicates when another machine for the same
76
- # attribute hasn't already done so
77
- add_predicate
78
- end
84
+ return unless name
85
+
86
+ conflicting_machines = machine.owner_class.state_machines.select do |_other_name, other_machine|
87
+ other_machine != machine && other_machine.states[qualified_name, :qualified_name]
88
+ end
89
+
90
+ # Output a warning if another machine has a conflicting qualified name
91
+ # for a different attribute
92
+ if (conflict = conflicting_machines.detect do |_other_name, other_machine|
93
+ other_machine.attribute != machine.attribute
94
+ end)
95
+ _name, other_machine = conflict
96
+ warn "State #{qualified_name.inspect} for #{machine.name.inspect} is already defined in #{other_machine.name.inspect}"
97
+ elsif conflicting_machines.empty?
98
+ # Only bother adding predicates when another machine for the same
99
+ # attribute hasn't already done so
100
+ add_predicate
79
101
  end
80
102
  end
81
103
 
82
104
  # Creates a copy of this state, excluding the context to prevent conflicts
83
105
  # across different machines.
84
- def initialize_copy(orig) #:nodoc:
106
+ def initialize_copy(orig) # :nodoc:
85
107
  super
86
108
  @context = StateContext.new(self)
87
109
  end
@@ -96,7 +118,7 @@ module StateMachines
96
118
  # Any objects in a final state will remain so forever given the current
97
119
  # machine's definition.
98
120
  def final?
99
- !machine.events.any? do |event|
121
+ machine.events.none? do |event|
100
122
  event.branches.any? do |branch|
101
123
  branch.state_requirements.any? do |requirement|
102
124
  requirement[:from].matches?(name) && !requirement[:to].matches?(name, from: name)
@@ -126,7 +148,7 @@ module StateMachines
126
148
  # description or just the internal name
127
149
  def description(options = {})
128
150
  label = options[:human_name] ? human_name : name
129
- description = label ? label.to_s : label.inspect
151
+ description = +(label ? label.to_s : label.inspect)
130
152
  description << " (#{@value.is_a?(Proc) ? '*' : @value.inspect})" unless name.to_s == @value.to_s
131
153
  description
132
154
  end
@@ -186,19 +208,19 @@ module StateMachines
186
208
  # Evaluate the method definitions and track which ones were added
187
209
  old_methods = context_methods
188
210
  context.class_eval(&block)
189
- new_methods = context_methods.to_a.select { |(name, method)| old_methods[name] != method }
211
+ new_methods = context_methods.to_a.reject { |(name, method)| old_methods[name] == method }
190
212
 
191
213
  # Alias new methods so that the only execute when the object is in this state
192
214
  new_methods.each do |(method_name, _method)|
193
215
  context_name = context_name_for(method_name)
194
- context.class_eval <<-end_eval, __FILE__, __LINE__ + 1
216
+ context.class_eval <<-END_EVAL, __FILE__, __LINE__ + 1
195
217
  alias_method :"#{context_name}", :#{method_name}
196
218
  def #{method_name}(*args, &block)
197
219
  state = self.class.state_machine(#{machine.name.inspect}).states.fetch(#{name.inspect})
198
220
  options = {:method_missing => lambda {super(*args, &block)}, :method_name => #{method_name.inspect}}
199
221
  state.call(self, :"#{context_name}", *(args + [options]), &block)
200
222
  end
201
- end_eval
223
+ END_EVAL
202
224
  end
203
225
 
204
226
  true
@@ -218,29 +240,28 @@ module StateMachines
218
240
  # will be raised.
219
241
  def call(object, method, *args, &block)
220
242
  options = args.last.is_a?(Hash) ? args.pop : {}
221
- options = {method_name: method}.merge(options)
243
+ options = { method_name: method }.merge(options)
222
244
  state = machine.states.match!(object)
223
245
 
224
246
  if state == self && object.respond_to?(method)
225
247
  object.send(method, *args, &block)
226
- elsif method_missing = options[:method_missing]
248
+ elsif (method_missing = options[:method_missing])
227
249
  # Dispatch to the superclass since the object either isn't in this state
228
250
  # or this state doesn't handle the method
229
251
  begin
230
252
  method_missing.call
231
- rescue NoMethodError => ex
232
- if ex.name.to_s == options[:method_name].to_s && ex.args == args
233
- # No valid context for this method
234
- raise InvalidContext.new(object, "State #{state.name.inspect} for #{machine.name.inspect} is not a valid context for calling ##{options[:method_name]}")
235
- else
236
- raise
237
- end
253
+ rescue NoMethodError => e
254
+ raise unless e.name.to_s == options[:method_name].to_s && e.args == args
255
+
256
+ # No valid context for this method
257
+ raise InvalidContext.new(object,
258
+ "State #{state.name.inspect} for #{machine.name.inspect} is not a valid context for calling ##{options[:method_name]}")
238
259
  end
239
260
  end
240
261
  end
241
262
 
242
- def draw(graph, options = {})
243
- fail NotImplementedError
263
+ def draw(graph, options = {}, io = $stdout)
264
+ machine.renderer.draw_state(self, graph, options, io)
244
265
  end
245
266
 
246
267
  # Generates a nicely formatted description of this state's contents.
@@ -254,7 +275,7 @@ module StateMachines
254
275
  "#<#{self.class} #{attributes.map { |attr, value| "#{attr}=#{value.inspect}" } * ' '}>"
255
276
  end
256
277
 
257
- private
278
+ private
258
279
 
259
280
  # Should the value be cached after it's evaluated for the first time?
260
281
  def cache_value?
@@ -264,9 +285,16 @@ module StateMachines
264
285
  # Adds a predicate method to the owner class so long as a name has
265
286
  # actually been configured for the state
266
287
  def add_predicate
267
- # Checks whether the current value matches this state
268
- machine.define_helper(:instance, "#{qualified_name}?") do |machine, object|
269
- machine.states.matches?(object, name)
288
+ predicate_method = "#{qualified_name}?"
289
+
290
+ if machine.send(:owner_class_ancestor_has_method?, :instance, predicate_method)
291
+ warn "Instance method #{predicate_method.inspect} is already defined in #{machine.owner_class.ancestors.first.inspect}, use generic helper instead or set StateMachines::Machine.ignore_method_conflicts = true."
292
+ elsif machine.send(:owner_class_has_method?, :instance, predicate_method)
293
+ warn "Instance method #{predicate_method.inspect} is already defined in #{machine.owner_class.inspect}, use generic helper instead or set StateMachines::Machine.ignore_method_conflicts = true."
294
+ else
295
+ machine.define_helper(:instance, predicate_method) do |machine, object|
296
+ machine.states.matches?(object, name)
297
+ end
270
298
  end
271
299
  end
272
300
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  # Represents a collection of states in a state machine
3
5
  class StateCollection < NodeCollection
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'options_validator'
4
+
1
5
  module StateMachines
2
6
  # Represents a module which will get evaluated within the context of a state.
3
7
  #
@@ -86,7 +90,7 @@ module StateMachines
86
90
  # See StateMachines::Machine#transition for a description of the possible
87
91
  # configurations for defining transitions.
88
92
  def transition(options)
89
- options.assert_valid_keys(:from, :to, :on, :if, :unless)
93
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :on, :if, :unless)
90
94
  raise ArgumentError, 'Must specify :on event' unless options[:on]
91
95
  raise ArgumentError, 'Must specify either :to or :from state' unless !options[:to] ^ !options[:from]
92
96
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module STDIORenderer
5
+ module_function def draw_machine(machine, io: $stdout)
6
+ draw_class(machine: machine, io: io)
7
+ draw_states(machine: machine, io: io)
8
+ draw_events(machine: machine, io: io)
9
+ end
10
+
11
+ module_function def draw_class(machine:, io: $stdout)
12
+ io.puts "Class: #{machine.owner_class.name}"
13
+ end
14
+
15
+ module_function def draw_states(machine:, io: $stdout)
16
+ io.puts " States:"
17
+ if machine.states.to_a.empty?
18
+ io.puts " - None"
19
+ else
20
+ machine.states.each do |state|
21
+ io.puts " - #{state.name}"
22
+ end
23
+ end
24
+ end
25
+
26
+ module_function def draw_event(event, graph, options: {}, io: $stdout)
27
+ io = io || options[:io] || $stdout
28
+ io.puts " Event: #{event.name}"
29
+ end
30
+
31
+ module_function def draw_branch(branch, graph, event, options: {}, io: $stdout)
32
+ io = io || options[:io] || $stdout
33
+ io.puts " Branch: #{branch.inspect}"
34
+ end
35
+
36
+ module_function def draw_state(state, graph, options: {}, io: $stdout)
37
+ io = io || options[:io] || $stdout
38
+ io.puts " State: #{state.name}"
39
+ end
40
+
41
+ module_function def draw_events(machine:, io: $stdout)
42
+ io.puts " Events:"
43
+ if machine.events.to_a.empty?
44
+ io.puts " - None"
45
+ else
46
+ machine.events.each do |event|
47
+ io.puts " - #{event.name}"
48
+ event.branches.each do |branch|
49
+ branch.state_requirements.each do |requirement|
50
+ out = +" - "
51
+ out << "#{draw_requirement(requirement[:from])} => #{draw_requirement(requirement[:to])}"
52
+ out << " IF #{branch.if_condition}" if branch.if_condition
53
+ out << " UNLESS #{branch.unless_condition}" if branch.unless_condition
54
+ io.puts out
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ module_function def draw_requirement(requirement)
62
+ case requirement
63
+ when StateMachines::BlacklistMatcher
64
+ "ALL EXCEPT #{requirement.values.join(', ')}"
65
+ when StateMachines::AllMatcher
66
+ "ALL"
67
+ when StateMachines::LoopbackMatcher
68
+ "SAME"
69
+ else
70
+ requirement.values.join(', ')
71
+ end
72
+ end
73
+ end
74
+ end