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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +7 -2
- data/config/locales/ja.yml +134 -0
- data/lib/generators/README.md +2 -2
- data/lib/generators/servactory/extension/USAGE +1 -1
- data/lib/generators/servactory/install/USAGE +2 -2
- data/lib/generators/servactory/rspec/USAGE +4 -4
- data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
- data/lib/generators/servactory/service/USAGE +5 -5
- data/lib/servactory/actions/collection.rb +21 -5
- data/lib/servactory/actions/stages/collection.rb +19 -1
- data/lib/servactory/actions/tools/rules.rb +1 -1
- data/lib/servactory/actions/tools/runner.rb +1 -1
- data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
- data/lib/servactory/configuration/config.rb +16 -0
- data/lib/servactory/configuration/configurable.rb +7 -4
- data/lib/servactory/configuration/factory.rb +30 -3
- data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -1
- data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +156 -29
- data/lib/servactory/context/warehouse/inputs.rb +1 -1
- data/lib/servactory/context/workspace/inputs.rb +2 -2
- data/lib/servactory/context/workspace/internals.rb +2 -2
- data/lib/servactory/context/workspace/outputs.rb +2 -2
- data/lib/servactory/context/workspace.rb +7 -7
- data/lib/servactory/info/builder.rb +4 -3
- data/lib/servactory/inputs/dsl.rb +5 -1
- data/lib/servactory/inputs/input.rb +1 -2
- data/lib/servactory/inputs/tools/validation.rb +10 -18
- data/lib/servactory/internals/dsl.rb +7 -1
- data/lib/servactory/maintenance/attributes/base.rb +4 -1
- data/lib/servactory/maintenance/attributes/collection.rb +61 -6
- data/lib/servactory/maintenance/options/collection.rb +71 -30
- data/lib/servactory/maintenance/options/option.rb +3 -12
- data/lib/servactory/maintenance/options/registrar.rb +3 -13
- data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
- data/lib/servactory/maintenance/validations/performer.rb +12 -23
- data/lib/servactory/outputs/dsl.rb +7 -1
- data/lib/servactory/result.rb +21 -8
- data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
- data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
- data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -125
- data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
- data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
- data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +185 -50
- data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +45 -10
- data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
- data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
- data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
- data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
- data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
- data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
- data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
- data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
- data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
- data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
- data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
- data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
- data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
- data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
- data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
- data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
- data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
- data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +34 -11
- data/lib/servactory/tool_kit/dynamic_options/format.rb +3 -4
- data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +3 -3
- data/lib/servactory/tool_kit/dynamic_options/max.rb +3 -3
- data/lib/servactory/tool_kit/dynamic_options/min.rb +3 -3
- data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +35 -7
- data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
- data/lib/servactory/tool_kit/dynamic_options/schema.rb +40 -25
- data/lib/servactory/tool_kit/dynamic_options/target.rb +4 -4
- data/lib/servactory/utils.rb +13 -3
- data/lib/servactory/version.rb +1 -1
- data/lib/servactory.rb +3 -4
- 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
|
-
#
|
|
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
|
-
|
|
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)
|
|
271
|
+
def define_chain_method_for(definition)
|
|
240
272
|
define_singleton_method(definition.chain_method) do |*args|
|
|
241
|
-
|
|
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
|