servactory 3.1.0.rc2 → 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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +7 -2
  4. data/config/locales/ja.yml +134 -0
  5. data/lib/generators/README.md +2 -2
  6. data/lib/generators/servactory/extension/USAGE +1 -1
  7. data/lib/generators/servactory/install/USAGE +2 -2
  8. data/lib/generators/servactory/rspec/USAGE +4 -4
  9. data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
  10. data/lib/generators/servactory/service/USAGE +5 -5
  11. data/lib/servactory/actions/collection.rb +21 -5
  12. data/lib/servactory/actions/stages/collection.rb +19 -1
  13. data/lib/servactory/actions/tools/rules.rb +1 -1
  14. data/lib/servactory/actions/tools/runner.rb +1 -1
  15. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
  16. data/lib/servactory/configuration/config.rb +16 -0
  17. data/lib/servactory/configuration/configurable.rb +7 -4
  18. data/lib/servactory/configuration/factory.rb +30 -3
  19. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -1
  20. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +156 -29
  21. data/lib/servactory/context/warehouse/inputs.rb +1 -1
  22. data/lib/servactory/context/workspace/inputs.rb +2 -2
  23. data/lib/servactory/context/workspace/internals.rb +2 -2
  24. data/lib/servactory/context/workspace/outputs.rb +2 -2
  25. data/lib/servactory/context/workspace.rb +7 -7
  26. data/lib/servactory/info/builder.rb +4 -3
  27. data/lib/servactory/inputs/dsl.rb +5 -1
  28. data/lib/servactory/inputs/input.rb +1 -2
  29. data/lib/servactory/inputs/tools/validation.rb +10 -18
  30. data/lib/servactory/internals/dsl.rb +7 -1
  31. data/lib/servactory/maintenance/attributes/base.rb +4 -1
  32. data/lib/servactory/maintenance/attributes/collection.rb +61 -6
  33. data/lib/servactory/maintenance/options/collection.rb +71 -30
  34. data/lib/servactory/maintenance/options/option.rb +3 -12
  35. data/lib/servactory/maintenance/options/registrar.rb +3 -13
  36. data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
  37. data/lib/servactory/maintenance/validations/performer.rb +12 -23
  38. data/lib/servactory/outputs/dsl.rb +7 -1
  39. data/lib/servactory/result.rb +21 -8
  40. data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
  41. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
  42. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -125
  43. data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
  44. data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
  45. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +185 -50
  46. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +45 -10
  47. data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
  48. data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
  49. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
  50. data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
  51. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
  52. data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
  53. data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
  54. data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
  55. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
  56. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
  57. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
  58. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
  59. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
  60. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
  61. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
  62. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
  63. data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
  64. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +34 -11
  65. data/lib/servactory/tool_kit/dynamic_options/format.rb +3 -4
  66. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +3 -3
  67. data/lib/servactory/tool_kit/dynamic_options/max.rb +3 -3
  68. data/lib/servactory/tool_kit/dynamic_options/min.rb +3 -3
  69. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +35 -7
  70. data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
  71. data/lib/servactory/tool_kit/dynamic_options/schema.rb +40 -25
  72. data/lib/servactory/tool_kit/dynamic_options/target.rb +4 -4
  73. data/lib/servactory/utils.rb +13 -3
  74. data/lib/servactory/version.rb +1 -1
  75. data/lib/servactory.rb +3 -4
  76. metadata +20 -4
@@ -48,6 +48,9 @@ module Servactory
48
48
  # - **Fluent API** - chain methods for readable test assertions
49
49
  # - **Dynamic Chain Methods** - generated from submatcher registry
50
50
  # - **Mutual Exclusivity** - conflicting options replace each other
51
+ # - **Attached Checks** - every `.message` checks the option chained
52
+ # right before it and is removed when that option is replaced;
53
+ # after `.must` it becomes the expected message of the rule
51
54
  # - **Composable** - works with RSpec's compound matchers
52
55
  # - **Block Expectations** - supports `expect { }.to` syntax
53
56
  class AttributeMatcher # rubocop:disable Metrics/ClassLength
@@ -204,12 +207,39 @@ module Servactory
204
207
  submatchers << submatcher
205
208
  end
206
209
 
207
- # Removes all submatchers of the specified class.
210
+ # Adds a submatcher that checks the option of the last submatcher.
211
+ #
212
+ # Attached submatchers are kept along with each other, so every one
213
+ # of them is checked, and they are removed with the submatcher they
214
+ # are attached to.
215
+ #
216
+ # @param submatcher [Submatcher] The submatcher to attach
217
+ # @return [void]
218
+ def attach_submatcher(submatcher)
219
+ submatchers << submatcher
220
+ end
221
+
222
+ # Adds a submatcher, or attaches it to the last submatcher if its definition requires one.
223
+ #
224
+ # @param submatcher [Submatcher] The submatcher to store
225
+ # @param definition [SubmatcherDefinition] The submatcher definition
226
+ # @return [void]
227
+ def store_submatcher(submatcher, definition)
228
+ return attach_submatcher(submatcher) if definition.requires_last_submatcher
229
+
230
+ add_submatcher(submatcher)
231
+ end
232
+
233
+ # Removes all submatchers of the specified class and the submatchers attached to them.
208
234
  #
209
235
  # @param matcher_class [Class] The submatcher class to remove
210
236
  # @return [void]
211
237
  def remove_submatcher(matcher_class)
212
- submatchers.delete_if { |sm| sm.is_a?(matcher_class) }
238
+ removed = submatchers.grep(matcher_class)
239
+
240
+ submatchers.delete_if do |sm|
241
+ sm.is_a?(matcher_class) || removed.any? { |removed_sm| sm.attached_to?(removed_sm) }
242
+ end
213
243
  end
214
244
 
215
245
  private
@@ -232,24 +262,15 @@ module Servactory
232
262
  # 1. Extracts options hash if applicable
233
263
  # 2. Removes mutually exclusive submatchers
234
264
  # 3. Stores option_types if needed
235
- # 4. Builds and adds the submatcher
265
+ # 4. Builds and adds the submatcher, or attaches it to the last
266
+ # submatcher if it requires one, unless the last submatcher
267
+ # takes the arguments itself
236
268
  #
237
269
  # @param definition [SubmatcherDefinition] The submatcher definition
238
270
  # @return [void]
239
- def define_chain_method_for(definition) # rubocop:disable Metrics/MethodLength
271
+ def define_chain_method_for(definition)
240
272
  define_singleton_method(definition.chain_method) do |*args|
241
- # For methods that accept trailing options hash (like target(value, name: :option)),
242
- # we need to extract it. We use a heuristic: if the method expects options AND
243
- # the last arg is a Hash with Symbol keys that look like option keys.
244
- options = extract_options_hash(args, definition)
245
-
246
- handle_mutually_exclusive(definition)
247
-
248
- @option_types = definition.transform_args.call(args, options) if definition.stores_option_types
249
-
250
- submatcher = build_submatcher(definition, args, options)
251
- add_submatcher(submatcher)
252
- self
273
+ apply_chain_method(definition, args)
253
274
  end
254
275
 
255
276
  definition.chain_aliases.each do |alias_name|
@@ -259,6 +280,41 @@ module Servactory
259
280
  end
260
281
  end
261
282
 
283
+ # Applies a call of a chain method.
284
+ #
285
+ # @param definition [SubmatcherDefinition] The submatcher definition
286
+ # @param args [Array] The method arguments
287
+ # @return [AttributeMatcher] self, for chaining
288
+ def apply_chain_method(definition, args)
289
+ # For methods that accept trailing options hash (like target(value, name: :option)),
290
+ # we need to extract it. We use a heuristic: if the method expects options AND
291
+ # the last arg is a Hash with Symbol keys that look like option keys.
292
+ options = extract_options_hash(args, definition)
293
+
294
+ if expected_by_last_submatcher?(definition)
295
+ last_submatcher.expect_message(*definition.transform_args.call(args, options))
296
+ return self
297
+ end
298
+
299
+ handle_mutually_exclusive(definition)
300
+
301
+ @option_types = definition.transform_args.call(args, options) if definition.stores_option_types
302
+
303
+ store_submatcher(build_submatcher(definition, args, options), definition)
304
+ self
305
+ end
306
+
307
+ # Checks whether the last submatcher stores the expected message itself.
308
+ #
309
+ # `.must(:rule).message(expected)` stores the expected message in the must
310
+ # submatcher instead of adding a message submatcher.
311
+ #
312
+ # @param definition [SubmatcherDefinition] The submatcher definition
313
+ # @return [Boolean] True if the last submatcher takes the expected message
314
+ def expected_by_last_submatcher?(definition)
315
+ definition.requires_last_submatcher && last_submatcher.respond_to?(:expect_message)
316
+ end
317
+
262
318
  # Extracts trailing options hash from arguments if the definition accepts them.
263
319
  #
264
320
  # @param args [Array] The method arguments
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Base
8
+ # Message expected by a test for an attribute option.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # MessageExpectation compares the message defined for an attribute
13
+ # option with the message expected by a test and explains a mismatch.
14
+ # Submatchers that check messages share it, so every message
15
+ # assertion follows the same rules.
16
+ #
17
+ # ## Comparison
18
+ #
19
+ # - String - equals the message text exactly
20
+ # - Regexp - matches the message text
21
+ # - RSpec matcher - matches the message as defined, for example a Proc
22
+ # - `:default` - the option defines no custom message, so the
23
+ # library uses its default message
24
+ #
25
+ # A Proc message is built before a String or Regexp comparison.
26
+ # A Proc that raises or returns an object other than a String
27
+ # does not match.
28
+ #
29
+ # Without a custom message, a String or Regexp is compared with the
30
+ # default message only if it is known before the service runs.
31
+ # Otherwise the check fails, because the default message depends
32
+ # on values such as the given value.
33
+ #
34
+ # ## Usage
35
+ #
36
+ # ```ruby
37
+ # expectation = MessageExpectation.new(/must be/)
38
+ # expectation.mismatch_for(message) { |proc_message| proc_message.call(value: nil) }
39
+ # # => nil when the message matches, otherwise the explanation
40
+ # ```
41
+ class MessageExpectation
42
+ # Expected value meaning that the option defines no custom message
43
+ DEFAULT = :default
44
+
45
+ # @return [String, Regexp, Symbol, Object] The expected message, `:default` or an RSpec matcher
46
+ attr_reader :expected
47
+
48
+ # Creates a new message expectation.
49
+ #
50
+ # @param expected [String, Regexp, Symbol, Object] The expected message, `:default` or an RSpec matcher
51
+ # @return [MessageExpectation] New expectation instance
52
+ # @raise [ArgumentError] If the expected message is of another kind
53
+ def initialize(expected)
54
+ @expected = expected
55
+
56
+ return if default? || expected.is_a?(String) || expected.is_a?(Regexp) || matcher?
57
+
58
+ raise ArgumentError,
59
+ "Expected message must be a String, a Regexp, an RSpec matcher or :default, " \
60
+ "got #{expected.inspect}"
61
+ end
62
+
63
+ # Describes the expected message for descriptions and failure messages.
64
+ #
65
+ # @return [String] The expected message or the description of the matcher
66
+ def description
67
+ return "the default message" if default?
68
+ return expected.description if matcher? && expected.respond_to?(:description)
69
+
70
+ expected.inspect
71
+ end
72
+
73
+ # Checks the message defined for the option.
74
+ #
75
+ # @param message [String, Proc, nil] The message defined for the option
76
+ # @param default_message [String, Proc, nil] The message used when the option defines none,
77
+ # or nil if it depends on values known only while the service runs
78
+ # @yieldparam message [Proc] A Proc message to build
79
+ # @yieldreturn [Object] The built message
80
+ # @return [String, nil] Explanation of the mismatch, or nil if the message matches
81
+ def mismatch_for(message, default_message: nil, &block)
82
+ return default_mismatch_for(message) if default?
83
+ return matcher_mismatch_for(message) if matcher?
84
+ return runtime_default_explanation if message.blank? && default_message.nil?
85
+
86
+ text_mismatch_for(message.presence || default_message, &block)
87
+ end
88
+
89
+ private
90
+
91
+ # Checks whether the default message is expected.
92
+ #
93
+ # @return [Boolean] True for `:default`
94
+ def default?
95
+ expected == DEFAULT
96
+ end
97
+
98
+ # Checks that the option defines no custom message.
99
+ #
100
+ # @param message [String, Proc, nil] The message defined for the option
101
+ # @return [String, nil] Explanation of the mismatch, or nil if there is no custom message
102
+ def default_mismatch_for(message)
103
+ return if message.blank?
104
+
105
+ <<~MESSAGE
106
+ expected #{description}
107
+ got the custom message #{message.inspect}
108
+ MESSAGE
109
+ end
110
+
111
+ # Checks whether the expected message is an RSpec matcher.
112
+ #
113
+ # @return [Boolean] True for RSpec matchers
114
+ def matcher?
115
+ RSpec::Matchers.is_a_matcher?(expected)
116
+ end
117
+
118
+ # Applies the RSpec matcher to the message as defined.
119
+ #
120
+ # @param message [String, Proc, nil] The message defined for the option
121
+ # @return [String, nil] Explanation of the mismatch, or nil if the message matches
122
+ def matcher_mismatch_for(message)
123
+ return if expected.matches?(message)
124
+
125
+ diff_explanation(message)
126
+ end
127
+
128
+ # Compares the message text with the expected String or Regexp.
129
+ #
130
+ # @param message [String, Proc, nil] The message to compare
131
+ # @yieldparam message [Proc] A Proc message to build
132
+ # @return [String, nil] Explanation of the mismatch, or nil if the message matches
133
+ def text_mismatch_for(message)
134
+ text = message.is_a?(Proc) ? yield(message) : message
135
+ rescue StandardError => e
136
+ proc_error_explanation(e)
137
+ else
138
+ return "#{diff_explanation(text).chomp}, which is not a String\n" unless text.is_a?(String)
139
+
140
+ diff_explanation(text) unless text_matches?(text)
141
+ end
142
+
143
+ # Compares a message text with the expected String or Regexp.
144
+ #
145
+ # @param text [String] The message text
146
+ # @return [Boolean] True if the text matches
147
+ def text_matches?(text)
148
+ expected.is_a?(Regexp) ? expected.match?(text) : expected == text
149
+ end
150
+
151
+ # Explains a mismatch with the expected and the actual message.
152
+ #
153
+ # @param actual [Object] The actual message
154
+ # @return [String] Explanation of the mismatch
155
+ def diff_explanation(actual)
156
+ <<~MESSAGE
157
+ expected #{description}
158
+ got #{actual.inspect}
159
+ MESSAGE
160
+ end
161
+
162
+ # Explains why a String or Regexp cannot be checked without a custom message.
163
+ #
164
+ # @return [String] Explanation of the mismatch
165
+ def runtime_default_explanation
166
+ <<~MESSAGE
167
+ expected #{description}
168
+ got no custom message
169
+
170
+ The default message depends on values known only while the service runs.
171
+ Use `message(:default)` to check that no custom message is defined,
172
+ or check the error message with `raise_error`.
173
+ MESSAGE
174
+ end
175
+
176
+ # Explains an error raised while building a Proc message.
177
+ #
178
+ # @param error [StandardError] The raised error
179
+ # @return [String] Explanation of the error
180
+ def proc_error_explanation(error)
181
+ <<~MESSAGE
182
+ could not build the Proc message to compare with #{description}:
183
+ #{error.class}: #{error.message}
184
+
185
+ The Proc receives every keyword the library passes to it while the service runs,
186
+ with nil for values known only then, such as `value:`. Accept unused keywords with `**`.
187
+ MESSAGE
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -92,6 +92,14 @@ module Servactory
92
92
  "expected not to #{description}"
93
93
  end
94
94
 
95
+ # Checks whether this submatcher checks the option of the given submatcher.
96
+ #
97
+ # @param submatcher [Submatcher] The submatcher that may precede this one in the chain
98
+ # @return [Boolean] True if this submatcher was chained right after the given one
99
+ def attached_to?(submatcher)
100
+ context.last_submatcher.equal?(submatcher)
101
+ end
102
+
95
103
  # Returns a human-readable description of what this submatcher validates.
96
104
  #
97
105
  # @abstract Subclasses must implement this method
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Concerns
8
+ # Concern for recognizing must rules generated by option helpers.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # Option helpers expand into `must` rules: dynamic options such as
13
+ # `format`, `min`, `max`, `multiple_of` and `target`, subclasses of
14
+ # `Servactory::ToolKit::DynamicOptions::Must`, and custom helpers
15
+ # registered with `*_option_helpers`. The messages of these rules
16
+ # depend on the validated value, so they cannot be checked before
17
+ # the service runs.
18
+ #
19
+ # ## Detection
20
+ #
21
+ # A rule is generated by an option helper when:
22
+ # - its condition was built by a `DynamicOptions::Must` instance, or
23
+ # - it is a rule of a helper registered for the attribute type,
24
+ # compared by equality and by the source location of its condition
25
+ #
26
+ # ## Usage
27
+ #
28
+ # ```ruby
29
+ # class MustSubmatcher < Base::Submatcher
30
+ # include Concerns::OptionHelperRules
31
+ #
32
+ # def initialize(context, names)
33
+ # super(context)
34
+ # raise ArgumentError if names.any? { |name| option_helper_rule?(name) }
35
+ # end
36
+ # end
37
+ # ```
38
+ module OptionHelperRules
39
+ # Includes InstanceMethods in the including class.
40
+ #
41
+ # @param base [Class] The class including this concern
42
+ # @return [void]
43
+ def self.included(base)
44
+ base.include(InstanceMethods)
45
+ end
46
+
47
+ # Instance methods added by this concern.
48
+ module InstanceMethods
49
+ # Checks whether a must rule of the attribute was generated by an option helper.
50
+ #
51
+ # @param name [Symbol] The rule name
52
+ # @return [Boolean] True if an option helper generated the rule
53
+ def option_helper_rule?(name)
54
+ rule = (attribute_data[:must] || {})[name]
55
+ return false if rule.nil?
56
+
57
+ dynamic_option_rule?(rule) || option_helper_rules.any? { |helper_rule| same_rule?(helper_rule, rule) }
58
+ end
59
+
60
+ # Returns the name of the must rule the option helper of an option generates.
61
+ #
62
+ # The library passes it as `code:` to the Proc message of the option.
63
+ #
64
+ # @param option_name [Symbol] The option name, such as `inclusion`
65
+ # @return [Symbol, nil] The rule name, such as `be_inclusion`, or nil if no helper generates one
66
+ def option_helper_rule_name(option_name)
67
+ helpers = described_class.config.public_send(:"#{attribute_type}_option_helpers")
68
+ helper = helpers.find_by(name: option_name)
69
+ return if helper.nil?
70
+
71
+ rules = helper_option(helper)
72
+ rules[:must].keys.first if rules.is_a?(Hash) && rules[:must].is_a?(Hash)
73
+ end
74
+
75
+ private
76
+
77
+ # Returns the condition of a rule in advanced or simple mode.
78
+ #
79
+ # @param rule [Hash, Proc] The rule
80
+ # @return [Object] The condition
81
+ def rule_condition(rule)
82
+ rule.is_a?(Hash) ? rule[:is] : rule
83
+ end
84
+
85
+ # Checks whether a `DynamicOptions::Must` instance built the rule's condition.
86
+ #
87
+ # @param rule [Hash, Proc] The rule
88
+ # @return [Boolean] True for rules of dynamic options
89
+ def dynamic_option_rule?(rule)
90
+ condition = rule_condition(rule)
91
+ return false unless condition.is_a?(Proc)
92
+
93
+ condition.binding.receiver.is_a?(Servactory::ToolKit::DynamicOptions::Must)
94
+ rescue StandardError
95
+ false
96
+ end
97
+
98
+ # Compares a rule with a rule generated by an option helper.
99
+ #
100
+ # @param helper_rule [Hash, Proc] The rule generated by an option helper
101
+ # @param rule [Hash, Proc] The attribute's rule
102
+ # @return [Boolean] True if both are the same rule
103
+ def same_rule?(helper_rule, rule)
104
+ return true if helper_rule == rule
105
+
106
+ location = condition_location(rule)
107
+ !location.nil? && location == condition_location(helper_rule)
108
+ end
109
+
110
+ # Returns the source location of a rule's condition.
111
+ #
112
+ # @param rule [Hash, Proc] The rule
113
+ # @return [Array(String, Integer), nil] The source location
114
+ def condition_location(rule)
115
+ condition = rule_condition(rule)
116
+ condition.source_location if condition.is_a?(Proc)
117
+ end
118
+
119
+ # Returns the must rules of the option helpers registered for the attribute type.
120
+ #
121
+ # @return [Array<Hash, Proc>] Rules generated by option helpers
122
+ def option_helper_rules
123
+ @option_helper_rules ||=
124
+ described_class.config.public_send(:"#{attribute_type}_option_helpers").flat_map do |helper|
125
+ rules = helper_option(helper)
126
+ rules.is_a?(Hash) && rules[:must].is_a?(Hash) ? rules[:must].values : []
127
+ end
128
+ end
129
+
130
+ # Expands an option helper the way the library does for the attribute.
131
+ #
132
+ # @param helper [Servactory::Maintenance::Options::Helper] The option helper
133
+ # @return [Object] The options the helper expands into
134
+ def helper_option(helper)
135
+ return helper.equivalent unless helper.equivalent.is_a?(Proc)
136
+
137
+ values = attribute_data.fetch(:actor).options[helper.name].deep_dup
138
+ values.is_a?(Hash) ? helper.equivalent.call(**values) : helper.equivalent.call(values)
139
+ rescue StandardError
140
+ nil
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Concerns
8
+ # Concern for calling Proc messages the way the library calls them.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # ProcMessage calls a Proc message of an attribute option with the
13
+ # complete set of keyword arguments the library passes to it while
14
+ # the service runs, so every submatcher builds Proc messages the same
15
+ # way. A Proc that would raise while the service runs, for example
16
+ # because it does not accept every keyword, raises here too.
17
+ #
18
+ # ## Keyword Arguments
19
+ #
20
+ # Every Proc message receives `service:`, the attribute (`input:`,
21
+ # `internal:` or `output:`) and `value:`, plus the keywords of its
22
+ # option:
23
+ #
24
+ # - `:required` - no other keywords
25
+ # - `:type` - `expected_type:`, `given_type:`
26
+ # - `:must` - `code:`, `reason:`, `meta:`
27
+ # - `:dynamic_option` (`consists_of`, `inclusion`, `target`) -
28
+ # `code:`, `reason:`, `option_name:`, `option_value:`
29
+ # - `:schema` - `code:`, `reason:`, `option_name:`, `option_value:`,
30
+ # `key_name:`, `expected_type:`, `given_type:`
31
+ #
32
+ # The library passes the same keywords for every failure reason of an
33
+ # option, for example `key_name:` is passed as `nil` when a `schema`
34
+ # attribute is not Hash-compatible. `reason:` itself is `nil` unless
35
+ # given, because the reason of a failure is known only while the
36
+ # service runs.
37
+ #
38
+ # Keywords without a given value, such as `value:`, are `nil`.
39
+ #
40
+ # ## Usage
41
+ #
42
+ # ```ruby
43
+ # class MySubmatcher < Base::Submatcher
44
+ # include Concerns::ProcMessage
45
+ #
46
+ # def passes?
47
+ # call_proc_message(message, :type, expected_type: "Integer") == "Must be an Integer"
48
+ # end
49
+ # end
50
+ # ```
51
+ module ProcMessage
52
+ # Keywords the library passes to Proc messages by option, besides
53
+ # `service:`, the attribute and `value:`.
54
+ OPTION_KEYWORDS = {
55
+ required: [].freeze,
56
+ type: %i[expected_type given_type].freeze,
57
+ must: %i[code reason meta].freeze,
58
+ dynamic_option: %i[code reason option_name option_value].freeze,
59
+ schema: %i[code reason option_name option_value key_name expected_type given_type].freeze
60
+ }.freeze
61
+
62
+ # Includes InstanceMethods in the including class.
63
+ #
64
+ # @param base [Class] The class including this concern
65
+ # @return [void]
66
+ def self.included(base)
67
+ base.include(InstanceMethods)
68
+ end
69
+
70
+ # Instance methods added by this concern.
71
+ module InstanceMethods
72
+ # Calls a Proc message with the keyword arguments the library passes to it.
73
+ #
74
+ # @param message [Proc] The message of the option
75
+ # @param option [Symbol] The option, a key of OPTION_KEYWORDS
76
+ # @param values [Hash{Symbol => Object}] Known values of the keywords
77
+ # @return [Object] The message built by the Proc
78
+ # @raise [ArgumentError] If a value is given for a keyword the library does not pass
79
+ def call_proc_message(message, option, **values)
80
+ message.call(**proc_message_arguments(option, **values))
81
+ end
82
+
83
+ # Builds the keyword arguments the library passes to a Proc message.
84
+ #
85
+ # @param option [Symbol] The option, a key of OPTION_KEYWORDS
86
+ # @param values [Hash{Symbol => Object}] Known values of the keywords
87
+ # @return [Hash{Symbol => Object}] Keyword arguments, `nil` for keywords without a given value
88
+ # @raise [ArgumentError] If a value is given for a keyword the library does not pass
89
+ def proc_message_arguments(option, **values)
90
+ arguments = {
91
+ service: proc_message_service_info,
92
+ attribute_type => attribute_data.fetch(:actor),
93
+ value: nil,
94
+ **OPTION_KEYWORDS.fetch(option).to_h { |keyword| [keyword, nil] }
95
+ }
96
+
97
+ unknown = values.keys - arguments.keys
98
+ raise ArgumentError, "Unknown keywords for #{option} messages: #{unknown.join(', ')}" if unknown.any?
99
+
100
+ arguments.merge(values)
101
+ end
102
+
103
+ private
104
+
105
+ # Returns the service information passed to Proc messages.
106
+ #
107
+ # @return [Object] The service information
108
+ def proc_message_service_info
109
+ @proc_message_service_info ||= described_class.send(:new).send(:servactory_service_info)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Concerns
8
+ # Concern for building the message of the `required` option of an input.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # RequiredMessage builds the message the library uses for a missing
13
+ # required value, so submatchers that check it build it the same way.
14
+ #
15
+ # ## Usage
16
+ #
17
+ # ```ruby
18
+ # class MySubmatcher < Base::Submatcher
19
+ # include Concerns::RequiredMessage
20
+ #
21
+ # def passes?
22
+ # required_message == "Name is mandatory"
23
+ # end
24
+ # end
25
+ # ```
26
+ #
27
+ # ## Methods Provided
28
+ #
29
+ # - `required_custom_message` - the custom message as defined
30
+ # - `default_required_message` - the default message of the library
31
+ # - `call_required_message` - builds a Proc message
32
+ # - `required_message` - the message used for a missing value
33
+ module RequiredMessage
34
+ # Includes ProcMessage and InstanceMethods in the including class.
35
+ #
36
+ # @param base [Class] The class including this concern
37
+ # @return [void]
38
+ def self.included(base)
39
+ base.include(ProcMessage)
40
+ base.include(InstanceMethods)
41
+ end
42
+
43
+ # Instance methods added by this concern.
44
+ module InstanceMethods
45
+ # Returns the custom message from `required: { message: }`.
46
+ #
47
+ # @return [String, Proc, nil] The custom message as defined
48
+ def required_custom_message
49
+ attribute_data.fetch(:required).fetch(:message)
50
+ end
51
+
52
+ # Generates the default I18n message for required validation.
53
+ #
54
+ # @return [String] Localized default required message
55
+ def default_required_message
56
+ I18n.t(
57
+ "#{i18n_root_key}.inputs.validations.required.default_error.default",
58
+ service_class_name: described_class.name,
59
+ input_name: attribute_name
60
+ )
61
+ end
62
+
63
+ # Calls a Proc message with the keyword arguments the library passes to it.
64
+ #
65
+ # `value:` is `nil`, as for a missing value.
66
+ #
67
+ # @param message [Proc] The custom message
68
+ # @return [Object] The message built by the Proc
69
+ def call_required_message(message)
70
+ call_proc_message(message, :required)
71
+ end
72
+
73
+ # Builds the message the library uses for a missing required value.
74
+ #
75
+ # Uses the custom message if provided, otherwise the default message.
76
+ #
77
+ # @return [Object] The message, a String unless a Proc message returns another object
78
+ def required_message
79
+ message = required_custom_message
80
+
81
+ return default_required_message if message.blank?
82
+ return message unless message.is_a?(Proc)
83
+
84
+ call_required_message(message)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end