servactory 3.1.0.rc3 → 3.1.0.rc4

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/lib/generators/README.md +1 -1
  4. data/lib/generators/servactory/extension/USAGE +1 -1
  5. data/lib/generators/servactory/install/USAGE +2 -2
  6. data/lib/generators/servactory/rspec/USAGE +4 -4
  7. data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
  8. data/lib/generators/servactory/service/USAGE +5 -5
  9. data/lib/servactory/actions/collection.rb +21 -5
  10. data/lib/servactory/actions/stages/collection.rb +19 -1
  11. data/lib/servactory/actions/tools/rules.rb +1 -1
  12. data/lib/servactory/actions/tools/runner.rb +1 -1
  13. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
  14. data/lib/servactory/configuration/configurable.rb +1 -1
  15. data/lib/servactory/configuration/factory.rb +30 -3
  16. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +2 -1
  17. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +150 -37
  18. data/lib/servactory/context/warehouse/inputs.rb +1 -1
  19. data/lib/servactory/context/workspace/inputs.rb +2 -2
  20. data/lib/servactory/context/workspace/internals.rb +2 -2
  21. data/lib/servactory/context/workspace/outputs.rb +2 -2
  22. data/lib/servactory/context/workspace.rb +7 -7
  23. data/lib/servactory/info/builder.rb +4 -3
  24. data/lib/servactory/inputs/dsl.rb +5 -1
  25. data/lib/servactory/inputs/input.rb +1 -2
  26. data/lib/servactory/internals/dsl.rb +7 -1
  27. data/lib/servactory/maintenance/attributes/base.rb +4 -1
  28. data/lib/servactory/maintenance/attributes/collection.rb +61 -6
  29. data/lib/servactory/maintenance/options/collection.rb +46 -15
  30. data/lib/servactory/maintenance/options/option.rb +3 -12
  31. data/lib/servactory/maintenance/options/registrar.rb +2 -12
  32. data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
  33. data/lib/servactory/outputs/dsl.rb +7 -1
  34. data/lib/servactory/result.rb +21 -8
  35. data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
  36. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
  37. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -129
  38. data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
  39. data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
  40. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +110 -45
  41. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +26 -8
  42. data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
  43. data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
  44. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
  45. data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
  46. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
  47. data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
  48. data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
  49. data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
  50. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
  51. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
  52. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
  53. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
  54. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
  55. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
  56. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
  57. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
  58. data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
  59. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +29 -8
  60. data/lib/servactory/tool_kit/dynamic_options/format.rb +2 -3
  61. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +1 -1
  62. data/lib/servactory/tool_kit/dynamic_options/max.rb +1 -1
  63. data/lib/servactory/tool_kit/dynamic_options/min.rb +1 -1
  64. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +33 -5
  65. data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
  66. data/lib/servactory/tool_kit/dynamic_options/schema.rb +34 -21
  67. data/lib/servactory/tool_kit/dynamic_options/target.rb +1 -1
  68. data/lib/servactory/utils.rb +13 -3
  69. data/lib/servactory/version.rb +1 -1
  70. data/lib/servactory.rb +3 -0
  71. metadata +10 -2
@@ -13,33 +13,113 @@ module Servactory
13
13
  # Validates that an attribute has the expected custom validation rules
14
14
  # defined via the `must` option. Excludes dynamic options that are
15
15
  # tested separately (consists_of, schema, be_inclusion, be_target).
16
+ # Optionally validates the messages of the rules.
16
17
  #
17
18
  # ## Usage
18
19
  #
19
20
  # ```ruby
20
21
  # it { is_expected.to have_service_input(:email).must([:be_valid_email]) }
21
22
  # it { is_expected.to have_service_input(:age).must([:be_positive, :be_adult]) }
23
+ # it { is_expected.to have_service_input(:age).must(:be_positive, be_adult: "Must be an adult") }
24
+ # it { is_expected.to have_service_input(:age).must(be_positive: /positive/, be_adult: :default) }
25
+ # it { is_expected.to have_service_input(:age).must(:be_adult).message("Must be an adult") }
22
26
  # ```
23
27
  #
24
28
  # ## Comparison
25
29
  #
26
30
  # Uses set difference to compare validation names - order doesn't matter.
31
+ # Bare names and the keys of expected messages together must name
32
+ # every rule of the attribute.
33
+ #
34
+ # ## Messages
35
+ #
36
+ # `.message` chained right after `.must` naming one rule is the same
37
+ # as the keyed form for that rule. After `.must` naming several
38
+ # rules, `.message` raises ArgumentError.
39
+ #
40
+ # An expected message follows the rules of `.message`: a String is
41
+ # compared with the message exactly, a Regexp is matched against it,
42
+ # an RSpec matcher is applied to the message as defined, and
43
+ # `:default` checks that the rule defines no custom message.
44
+ #
45
+ # Without a custom message, the rule uses the default message of
46
+ # the library, which depends only on the service, the attribute and
47
+ # the rule name, so a String or Regexp is compared with it.
48
+ #
49
+ # A Proc message is called with the keyword arguments the library
50
+ # passes to it: `service:`, the attribute (`input:`, `internal:` or
51
+ # `output:`), `value:`, `code:`, `reason:` and `meta:`. Values known
52
+ # only while the service runs are `nil`. A Proc message that raises
53
+ # with them, for example because it does not accept all of them,
54
+ # does not match.
55
+ #
56
+ # Rules generated by option helpers, such as `be_in_format` of the
57
+ # `format` option or `be_greater_than_or_equal_to` of `min`, may be
58
+ # listed by name, but their messages depend on the validated value:
59
+ # expecting a message for them raises ArgumentError. Check these
60
+ # messages with `raise_error` instead.
27
61
  class MustSubmatcher < Base::Submatcher
62
+ include Concerns::OptionHelperRules
63
+ include Concerns::ProcMessage
64
+
65
+ # NOTE: Dynamic options that are also `must` but tested separately
66
+ BUILT_IN_RULE_NAMES = %i[consists_of schema be_inclusion be_target].freeze
67
+
68
+ # Splits the arguments of the `must` chain method.
69
+ #
70
+ # A trailing Hash, which Ruby passes for keyword arguments,
71
+ # maps rule names to their expected messages.
72
+ #
73
+ # @param args [Array] The arguments of the chain method
74
+ # @return [Array(Array<Symbol>, Hash{Symbol => Object})] Rule names and expected messages
75
+ def self.arguments_from(args)
76
+ expectations, names = Array(args).flatten.partition { |argument| argument.is_a?(Hash) }
77
+
78
+ [names, expectations.reduce({}, :merge)]
79
+ end
80
+
28
81
  # Creates a new must submatcher.
29
82
  #
30
83
  # @param context [Base::SubmatcherContext] The submatcher context
31
84
  # @param must_names [Array<Symbol>] Expected validation rule names
85
+ # @param message_expectations [Hash{Symbol => Object}] Expected messages by rule name
32
86
  # @return [MustSubmatcher] New submatcher instance
33
- def initialize(context, must_names)
87
+ # @raise [ArgumentError] If an expected message is of an unsupported kind,
88
+ # or belongs to a rule generated by an option helper
89
+ def initialize(context, must_names, message_expectations = {})
34
90
  super(context)
35
- @must_names = must_names
91
+ @must_names = (must_names + message_expectations.keys).uniq
92
+ @message_expectations = message_expectations.transform_values do |expected|
93
+ Base::MessageExpectation.new(expected)
94
+ end
95
+
96
+ message_expectations.each_key { |name| ensure_message_checkable!(name) }
97
+ end
98
+
99
+ # Stores the message expected by `.message` chained right after `.must`.
100
+ #
101
+ # `.must(:rule).message(expected)` is the same as `.must(rule: expected)`.
102
+ # Each rule accepts one expected message.
103
+ #
104
+ # @param expected [String, Regexp, Symbol, Object] Expected message, `:default` or an RSpec matcher
105
+ # @return [void]
106
+ # @raise [ArgumentError] If `must` does not name exactly one rule, the rule already has
107
+ # an expected message, or the message cannot be checked
108
+ def expect_message(expected)
109
+ raise ArgumentError, several_rules_error_message unless must_names.one?
110
+
111
+ name = must_names.first
112
+ raise ArgumentError, "Rule `#{name}` already has an expected message" if message_expectations.key?(name)
113
+
114
+ ensure_message_checkable!(name)
115
+ message_expectations[name] = Base::MessageExpectation.new(expected)
36
116
  end
37
117
 
38
118
  # Returns description for RSpec output.
39
119
  #
40
120
  # @return [String] Human-readable description with rule names
41
121
  def description
42
- "must: #{must_names.join(', ')}"
122
+ "must: #{must_names.map { |name| rule_description(name) }.join(', ')}"
43
123
  end
44
124
 
45
125
  protected
@@ -48,31 +128,126 @@ module Servactory
48
128
  #
49
129
  # Filters out dynamic options that are tested by other submatchers.
50
130
  #
51
- # @return [Boolean] True if must rules match (order-independent)
131
+ # @return [Boolean] True if must rules and their messages match (order-independent)
52
132
  def passes?
53
- attribute_must = attribute_data.fetch(:must)
54
- attribute_must_keys = attribute_must.keys.dup
55
-
56
- # NOTE: Dynamic options that are also `must` but tested separately
57
- attribute_must_keys.delete(:consists_of)
58
- attribute_must_keys.delete(:schema)
59
- attribute_must_keys.delete(:be_inclusion)
60
- attribute_must_keys.delete(:be_target)
133
+ @actual_must_names = actual_must_names
134
+ @message_mismatches = find_message_mismatches
61
135
 
62
- attribute_must_keys.difference(must_names).empty? &&
63
- must_names.difference(attribute_must_keys).empty?
136
+ names_match? && @message_mismatches.empty?
64
137
  end
65
138
 
66
139
  # Builds the failure message for must validation.
67
140
  #
68
- # @return [String] Simple failure message with expected rules
141
+ # @return [String] Failure message with expected vs actual rules and messages
69
142
  def build_failure_message
70
- "should #{must_names.join(', ')}"
143
+ messages = @message_mismatches.map do |name, mismatch|
144
+ "should return expected message for must rule `#{name}`:\n\n#{mismatch.indent(2)}"
145
+ end
146
+
147
+ messages.unshift(names_failure_message) unless names_match?
148
+ messages.join("\n")
71
149
  end
72
150
 
73
151
  private
74
152
 
75
- attr_reader :must_names
153
+ attr_reader :must_names, :message_expectations
154
+
155
+ # Ensures that the message of a rule does not depend on the validated value.
156
+ #
157
+ # @param name [Symbol] Rule name
158
+ # @return [void]
159
+ # @raise [ArgumentError] If an option helper generated the rule
160
+ def ensure_message_checkable!(name)
161
+ return if BUILT_IN_RULE_NAMES.include?(name) || !option_helper_rule?(name)
162
+
163
+ raise ArgumentError,
164
+ "The message of must rule `#{name}` cannot be checked: an option helper generated the rule, " \
165
+ "and its message depends on the validated value. List the rule by name, as in " \
166
+ "`must(:#{name})`, and check the error message with `raise_error`."
167
+ end
168
+
169
+ # Builds the error message for `.message` after `.must` naming several rules.
170
+ #
171
+ # @return [String] Error message
172
+ def several_rules_error_message
173
+ return "`message` after `must` needs a rule name, e.g. `must(:rule).message(...)`." if must_names.empty?
174
+
175
+ keyed = must_names.map { |name| "#{name}: ..." }.join(", ")
176
+
177
+ "`message` after `must` needs exactly one rule, got #{must_names.size}. " \
178
+ "Use the keyed form to expect a message for each rule, as in `must(#{keyed})`."
179
+ end
180
+
181
+ # Describes a rule name with its expected message.
182
+ #
183
+ # @param name [Symbol] Rule name
184
+ # @return [String] Rule description
185
+ def rule_description(name)
186
+ expectation = message_expectations[name]
187
+ return name.to_s if expectation.nil?
188
+
189
+ "#{name} with message #{expectation.description}"
190
+ end
191
+
192
+ # Returns the attribute's must rules.
193
+ #
194
+ # @return [Hash{Symbol => Hash, Proc}] Rules by name
195
+ def attribute_must
196
+ attribute_data[:must] || {}
197
+ end
198
+
199
+ # Returns the names of the attribute's rules checked by this submatcher.
200
+ #
201
+ # @return [Array<Symbol>] Rule names
202
+ def actual_must_names
203
+ attribute_must.keys - BUILT_IN_RULE_NAMES
204
+ end
205
+
206
+ # Compares the expected rule names with the attribute's rule names.
207
+ #
208
+ # @return [Boolean] True if both name the same rules
209
+ def names_match?
210
+ @actual_must_names.difference(must_names).empty? &&
211
+ must_names.difference(@actual_must_names).empty?
212
+ end
213
+
214
+ # Builds the failure message for mismatching rule names.
215
+ #
216
+ # @return [String] Failure message with expected vs actual rules
217
+ def names_failure_message
218
+ <<~MESSAGE
219
+ should #{must_names.join(', ')}
220
+
221
+ expected must rules: #{build_list_message(must_names)}
222
+ got must rules: #{build_list_message(@actual_must_names)}
223
+ MESSAGE
224
+ end
225
+
226
+ # Compares the messages of the attribute's rules with the expected messages.
227
+ #
228
+ # Rules missing from the attribute are reported by the name comparison.
229
+ #
230
+ # @return [Hash{Symbol => String}] Explanations of the mismatches by rule name
231
+ def find_message_mismatches
232
+ message_expectations.filter_map do |name, expectation|
233
+ next unless attribute_must.key?(name)
234
+
235
+ mismatch = expectation.mismatch_for(
236
+ rule_message(attribute_must.fetch(name)),
237
+ default_message: Servactory::Maintenance::Validations::Translator::Must.default_message
238
+ ) { |message| call_proc_message(message, :must, code: name) }
239
+
240
+ [name, mismatch] unless mismatch.nil?
241
+ end.to_h
242
+ end
243
+
244
+ # Returns the custom message of a rule.
245
+ #
246
+ # @param rule [Hash, Proc] The rule in advanced or simple mode
247
+ # @return [String, Proc, nil] The custom message
248
+ def rule_message(rule)
249
+ rule.is_a?(Hash) ? rule[:message] : nil
250
+ end
76
251
  end
77
252
  end
78
253
  end
@@ -29,6 +29,9 @@ module Servactory
29
29
  # Key for the target values within the option
30
30
  OPTION_BODY_KEY = :in
31
31
 
32
+ # @return [Symbol] The name of the target option
33
+ attr_reader :option_name
34
+
32
35
  # Creates a new target submatcher.
33
36
  #
34
37
  # @param context [Base::SubmatcherContext] The submatcher context
@@ -77,8 +80,7 @@ module Servactory
77
80
 
78
81
  private
79
82
 
80
- attr_reader :option_name,
81
- :values
83
+ attr_reader :values
82
84
 
83
85
  # Formats values for human-readable description.
84
86
  #
@@ -19,6 +19,7 @@ module Servactory
19
19
  # it { is_expected.to have_service_input(:user_id).type(Integer) }
20
20
  # it { is_expected.to have_service_input(:data).types(String, Hash) }
21
21
  # it { is_expected.to have_service_internal(:result).type(Array) }
22
+ # it { is_expected.to have_service_input(:user_id).type(Integer).message("User ID must be an Integer") }
22
23
  # ```
23
24
  #
24
25
  # ## Comparison
@@ -26,6 +27,11 @@ module Servactory
26
27
  # Types are compared by name, sorted alphabetically. Order of types
27
28
  # in the definition doesn't matter - only the set of types must match.
28
29
  class TypesSubmatcher < Base::Submatcher
30
+ # Option name in attribute data
31
+ OPTION_NAME = :type
32
+ # Key for the types within the option
33
+ OPTION_BODY_KEY = :is
34
+
29
35
  # Creates a new types submatcher.
30
36
  #
31
37
  # @param context [Base::SubmatcherContext] The submatcher context
@@ -44,8 +44,12 @@
44
44
  require_relative "matchers/concerns/attribute_data_access"
45
45
  require_relative "matchers/concerns/error_message_builder"
46
46
  require_relative "matchers/concerns/value_comparison"
47
+ require_relative "matchers/concerns/option_helper_rules"
48
+ require_relative "matchers/concerns/proc_message"
49
+ require_relative "matchers/concerns/required_message"
47
50
 
48
51
  # Base classes (loaded after concerns)
52
+ require_relative "matchers/base/message_expectation"
49
53
  require_relative "matchers/base/submatcher_context"
50
54
  require_relative "matchers/base/submatcher"
51
55
  require_relative "matchers/base/submatcher_registry"
@@ -89,6 +93,12 @@ module Servactory
89
93
  # expect(MyService).to have_service_input(:user_id)
90
94
  # .type(Integer)
91
95
  # .required
96
+ #
97
+ # @example With messages
98
+ # expect(MyService).to have_service_input(:age)
99
+ # .type(Integer).message("Age must be an Integer")
100
+ # .required(/is required/)
101
+ # .must(:be_adult, be_positive: :default)
92
102
  def have_service_input(input_name) # rubocop:disable Naming/PredicatePrefix
93
103
  HaveServiceInputMatcher.new(described_class, input_name)
94
104
  end
@@ -101,6 +111,11 @@ module Servactory
101
111
  # @example
102
112
  # expect(MyService).to have_service_internal(:processed_data)
103
113
  # .type(Hash)
114
+ #
115
+ # @example With messages
116
+ # expect(MyService).to have_service_internal(:total)
117
+ # .type(Integer).message(:default)
118
+ # .must(:be_positive).message("Total must be positive")
104
119
  def have_service_internal(internal_name) # rubocop:disable Naming/PredicatePrefix
105
120
  HaveServiceInternalMatcher.new(described_class, internal_name)
106
121
  end
@@ -90,7 +90,7 @@ module Servactory
90
90
  # @param option_name [Symbol] The option name (default: :consists_of)
91
91
  # @param collection_mode_class_names [Servactory::Configuration::CollectionMode::ClassNamesCollection]
92
92
  # Valid collection types
93
- # @return [Servactory::Maintenance::Attributes::OptionHelper]
93
+ # @return [Servactory::Maintenance::Options::Helper]
94
94
  def self.use(option_name = :consists_of, collection_mode_class_names:)
95
95
  instance = new(option_name, :type, false)
96
96
  instance.assign(collection_mode_class_names)
@@ -162,7 +162,7 @@ module Servactory
162
162
  # @param option [WorkOption] Type configuration
163
163
  # @return [Boolean, Array] true if valid, or [false, reason]
164
164
  def validate_for!(attribute:, values:, option:)
165
- consists_of_types = Array(option.value).uniq
165
+ consists_of_types = Array(option.value)
166
166
 
167
167
  # Check presence requirements.
168
168
  return [false, :required] if fails_presence_validation?(attribute:, values:, consists_of_types:)
@@ -217,8 +217,7 @@ module Servactory
217
217
  service_class_name: service.class_name,
218
218
  input_name: input.name,
219
219
  option_name:,
220
- expected_type: Array(option_value).uniq.join(", "),
221
- given_type: given_type_for(values: value, option_value:)
220
+ **type_names_for(attribute: input, values: value, option_value:, reason:)
222
221
  )
223
222
  end
224
223
 
@@ -240,8 +239,7 @@ module Servactory
240
239
  service_class_name: service.class_name,
241
240
  internal_name: internal.name,
242
241
  option_name:,
243
- expected_type: Array(option_value).uniq.join(", "),
244
- given_type: given_type_for(values: value, option_value:)
242
+ **type_names_for(attribute: internal, values: value, option_value:, reason:)
245
243
  )
246
244
  end
247
245
 
@@ -263,11 +261,34 @@ module Servactory
263
261
  service_class_name: service.class_name,
264
262
  output_name: output.name,
265
263
  option_name:,
266
- expected_type: Array(option_value).uniq.join(", "),
267
- given_type: given_type_for(values: value, option_value:)
264
+ **type_names_for(attribute: output, values: value, option_value:, reason:)
268
265
  )
269
266
  end
270
267
 
268
+ # Builds the expected and given type names for a failure message.
269
+ #
270
+ # For `:wrong_type` the attribute itself is not of a collection type,
271
+ # so the configured collection types are expected and the declared
272
+ # attribute types are given. For other reasons the element types
273
+ # are compared.
274
+ #
275
+ # @param attribute [Object] The attribute being validated
276
+ # @param values [Object] Failed value
277
+ # @param option_value [Object] Expected element types
278
+ # @param reason [Symbol] Failure reason
279
+ # @return [Hash] Hash with :expected_type and :given_type keys
280
+ def type_names_for(attribute:, values:, option_value:, reason:)
281
+ if reason == :wrong_type
282
+ expected_type = @collection_mode_class_names.to_a.join(", ")
283
+ given_type = attribute.types.join(", ")
284
+ else
285
+ expected_type = Array(option_value).uniq.join(", ")
286
+ given_type = given_type_for(values:, option_value:)
287
+ end
288
+
289
+ { expected_type:, given_type: }
290
+ end
291
+
271
292
  # Extracts type names of elements that don't match expected types.
272
293
  #
273
294
  # @param values [Array, nil] Collection elements
@@ -179,7 +179,7 @@ module Servactory
179
179
  #
180
180
  # @param option_name [Symbol] The option name (default: :format)
181
181
  # @param formats [Hash] Custom format definitions to merge with defaults
182
- # @return [Servactory::Maintenance::Attributes::OptionHelper]
182
+ # @return [Servactory::Maintenance::Options::Helper]
183
183
  def self.use(option_name = :format, formats: {})
184
184
  instance = new(option_name)
185
185
  instance.assign(formats)
@@ -232,7 +232,7 @@ module Servactory
232
232
  # @param internal [Object, nil] Internal attribute if applicable
233
233
  # @param output [Object, nil] Output attribute if applicable
234
234
  # @return [Boolean, Array] true if valid, or [false, reason]
235
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
235
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
236
236
  def common_condition_with(value:, option:, input: nil, internal: nil, output: nil)
237
237
  option_value = option.value&.to_sym
238
238
 
@@ -269,7 +269,6 @@ module Servactory
269
269
  # Run validator callback.
270
270
  option.properties.fetch(:validator, format_options.fetch(:validator)).call(value:)
271
271
  end
272
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
273
272
 
274
273
  ########################################################################
275
274
 
@@ -87,7 +87,7 @@ module Servactory
87
87
  # Creates an Inclusion validator instance.
88
88
  #
89
89
  # @param option_name [Symbol] The option name (default: :inclusion)
90
- # @return [Servactory::Maintenance::Attributes::OptionHelper]
90
+ # @return [Servactory::Maintenance::Options::Helper]
91
91
  def self.use(option_name = :inclusion)
92
92
  instance = new(option_name, :in)
93
93
  instance.must(:be_inclusion)
@@ -97,7 +97,7 @@ module Servactory
97
97
  # Creates a Max validator instance.
98
98
  #
99
99
  # @param option_name [Symbol] The option name (default: :max)
100
- # @return [Servactory::Maintenance::Attributes::OptionHelper]
100
+ # @return [Servactory::Maintenance::Options::Helper]
101
101
  def self.use(option_name = :max)
102
102
  new(option_name).must(:be_less_than_or_equal_to)
103
103
  end
@@ -97,7 +97,7 @@ module Servactory
97
97
  # Creates a Min validator instance.
98
98
  #
99
99
  # @param option_name [Symbol] The option name (default: :min)
100
- # @return [Servactory::Maintenance::Attributes::OptionHelper]
100
+ # @return [Servactory::Maintenance::Options::Helper]
101
101
  def self.use(option_name = :min)
102
102
  new(option_name).must(:be_greater_than_or_equal_to)
103
103
  end
@@ -93,14 +93,22 @@ module Servactory
93
93
  # ## Important Notes
94
94
  #
95
95
  # - Divisor must be a non-zero Numeric
96
- # - Uses epsilon comparison for floating point precision
96
+ # - Integer, Rational and BigDecimal operands are checked exactly
97
+ # - Float operands are accepted when the remainder is within an epsilon
98
+ # tolerance of zero or of the divisor, scaled by the absolute value
99
+ # of the checked value
97
100
  # - Returns false for non-numeric values
98
101
  # - Provides specific error messages for blank and zero divisors
99
102
  class MultipleOf < Must
103
+ # Relative tolerance for Float remainders, scaled by the absolute
104
+ # value of the checked value.
105
+ FLOAT_TOLERANCE = Float::EPSILON * 2
106
+ private_constant :FLOAT_TOLERANCE
107
+
100
108
  # Creates a MultipleOf validator instance.
101
109
  #
102
110
  # @param option_name [Symbol] The option name (default: :multiple_of)
103
- # @return [Servactory::Maintenance::Attributes::OptionHelper]
111
+ # @return [Servactory::Maintenance::Options::Helper]
104
112
  def self.use(option_name = :multiple_of)
105
113
  new(option_name).must(:be_multiple_of)
106
114
  end
@@ -148,14 +156,34 @@ module Servactory
148
156
  return false unless option.value.is_a?(Numeric)
149
157
  return false if option.value.zero?
150
158
 
151
- # Calculate remainder with epsilon tolerance for floats.
152
- remainder = value % option.value
153
- remainder.zero? || remainder.abs < Float::EPSILON * [value.abs, option.value.abs].max
159
+ multiple_of?(value, option.value)
154
160
  else
155
161
  false
156
162
  end
157
163
  end
158
164
 
165
+ # Checks whether value is a multiple of divisor.
166
+ #
167
+ # Exact for Integer, Rational and BigDecimal operands. When either
168
+ # operand is a Float, the remainder of the absolute operands counts
169
+ # as a multiple when it is within the tolerance of zero or of the
170
+ # divisor, with the tolerance scaled by the absolute value of the
171
+ # checked value.
172
+ #
173
+ # @param value [Numeric] Value to check
174
+ # @param divisor [Numeric] Non-zero divisor
175
+ # @return [Boolean] true if value is multiple of divisor
176
+ def multiple_of?(value, divisor)
177
+ return (value % divisor).zero? unless value.is_a?(Float) || divisor.is_a?(Float)
178
+
179
+ remainder = value.abs % divisor.abs
180
+ return true if remainder.zero?
181
+ return false unless remainder.finite? && divisor.finite?
182
+
183
+ tolerance = FLOAT_TOLERANCE * value.abs
184
+ remainder <= tolerance || divisor.abs - remainder <= tolerance
185
+ end
186
+
159
187
  ########################################################################
160
188
 
161
189
  # Generates error message for input validation failure.
@@ -152,7 +152,7 @@ module Servactory
152
152
  @body_fallback = body_fallback
153
153
  end
154
154
 
155
- # Creates an OptionHelper for registration with Servactory.
155
+ # Creates an option helper for registration with Servactory.
156
156
  #
157
157
  # @param name [Symbol] Internal validation name
158
158
  # @return [Servactory::Maintenance::Options::Helper]
@@ -221,7 +221,7 @@ module Servactory
221
221
  #
222
222
  # @param option [WorkOption] Parsed option data
223
223
  # @return [Proc] Lambda that generates error message
224
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
224
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
225
225
  def must_content_message_with(option)
226
226
  is_option_message_present = option.message.present?
227
227
  is_option_message_proc = option.message.is_a?(Proc) if is_option_message_present
@@ -274,7 +274,6 @@ module Servactory
274
274
  end
275
275
  end
276
276
  end
277
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
278
277
 
279
278
  ########################################################################
280
279