servactory 2.16.1 → 3.0.0.rc2

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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -9
  3. data/config/locales/de.yml +134 -0
  4. data/config/locales/en.yml +15 -0
  5. data/config/locales/es.yml +134 -0
  6. data/config/locales/fr.yml +134 -0
  7. data/config/locales/it.yml +134 -0
  8. data/config/locales/ru.yml +15 -0
  9. data/lib/generators/README.md +45 -0
  10. data/lib/generators/servactory/base.rb +82 -0
  11. data/lib/generators/servactory/extension/USAGE +54 -0
  12. data/lib/generators/servactory/extension/extension_generator.rb +41 -0
  13. data/lib/generators/servactory/extension/templates/extension.rb.tt +62 -0
  14. data/lib/generators/servactory/install/USAGE +27 -0
  15. data/lib/generators/servactory/install/install_generator.rb +94 -0
  16. data/lib/generators/servactory/{templates/services/application_service/base.rb → install/templates/application_service/base.rb.tt} +29 -19
  17. data/lib/generators/servactory/{templates/services/application_service/exceptions.rb → install/templates/application_service/exceptions.rb.tt} +1 -1
  18. data/lib/generators/servactory/{templates/services/application_service/result.rb → install/templates/application_service/result.rb.tt} +1 -1
  19. data/lib/generators/servactory/rspec/USAGE +46 -0
  20. data/lib/generators/servactory/rspec/rspec_generator.rb +95 -0
  21. data/lib/generators/servactory/rspec/templates/service_spec.rb.tt +58 -0
  22. data/lib/generators/servactory/service/USAGE +51 -0
  23. data/lib/generators/servactory/service/service_generator.rb +56 -0
  24. data/lib/generators/servactory/service/templates/service.rb.tt +22 -0
  25. data/lib/servactory/actions/collection.rb +56 -1
  26. data/lib/servactory/actions/dsl.rb +11 -11
  27. data/lib/servactory/actions/tools/rules.rb +1 -1
  28. data/lib/servactory/actions/tools/runner.rb +111 -28
  29. data/lib/servactory/base.rb +1 -7
  30. data/lib/servactory/configuration/actions/aliases/collection.rb +5 -0
  31. data/lib/servactory/configuration/actions/rescue_handlers/collection.rb +5 -0
  32. data/lib/servactory/configuration/actions/shortcuts/collection.rb +5 -0
  33. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +5 -0
  34. data/lib/servactory/configuration/config.rb +36 -0
  35. data/lib/servactory/configuration/configurable.rb +95 -0
  36. data/lib/servactory/configuration/dsl.rb +3 -27
  37. data/lib/servactory/configuration/factory.rb +20 -20
  38. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -0
  39. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +5 -0
  40. data/lib/servactory/context/warehouse/inputs.rb +2 -2
  41. data/lib/servactory/context/workspace/inputs.rb +2 -2
  42. data/lib/servactory/context/workspace/internals.rb +2 -2
  43. data/lib/servactory/context/workspace/outputs.rb +2 -2
  44. data/lib/servactory/context/workspace.rb +11 -7
  45. data/lib/servactory/dsl.rb +10 -8
  46. data/lib/servactory/maintenance/attributes/tools/validation.rb +1 -1
  47. data/lib/servactory/result.rb +2 -2
  48. data/lib/servactory/test_kit/fake_type.rb +23 -0
  49. data/lib/servactory/test_kit/result.rb +45 -0
  50. data/lib/servactory/test_kit/rspec/helpers/argument_matchers.rb +97 -0
  51. data/lib/servactory/test_kit/rspec/helpers/concerns/error_messages.rb +179 -0
  52. data/lib/servactory/test_kit/rspec/helpers/concerns/service_class_validation.rb +74 -0
  53. data/lib/servactory/test_kit/rspec/helpers/fluent.rb +110 -0
  54. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +149 -0
  55. data/lib/servactory/test_kit/rspec/helpers/legacy.rb +228 -0
  56. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +256 -0
  57. data/lib/servactory/test_kit/rspec/helpers/output_validator.rb +121 -0
  58. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +422 -0
  59. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +129 -0
  60. data/lib/servactory/test_kit/rspec/helpers.rb +51 -84
  61. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +324 -0
  62. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +133 -0
  63. data/lib/servactory/test_kit/rspec/matchers/base/submatcher_context.rb +101 -0
  64. data/lib/servactory/test_kit/rspec/matchers/base/submatcher_registry.rb +205 -0
  65. data/lib/servactory/test_kit/rspec/matchers/concerns/attribute_data_access.rb +100 -0
  66. data/lib/servactory/test_kit/rspec/matchers/concerns/error_message_builder.rb +106 -0
  67. data/lib/servactory/test_kit/rspec/matchers/concerns/value_comparison.rb +97 -0
  68. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +89 -219
  69. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +74 -166
  70. data/lib/servactory/test_kit/rspec/matchers/have_service_output_matcher.rb +238 -0
  71. data/lib/servactory/test_kit/rspec/matchers/result/be_failure_service_matcher.rb +257 -0
  72. data/lib/servactory/test_kit/rspec/matchers/result/be_success_service_matcher.rb +185 -0
  73. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/default_submatcher.rb +81 -0
  74. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/optional_submatcher.rb +62 -0
  75. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +93 -0
  76. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +271 -0
  77. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/consists_of_submatcher.rb +85 -0
  78. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/inclusion_submatcher.rb +120 -0
  79. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +115 -0
  80. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +82 -0
  81. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/schema_submatcher.rb +102 -0
  82. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +125 -0
  83. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +77 -0
  84. data/lib/servactory/test_kit/rspec/matchers.rb +126 -285
  85. data/lib/servactory/test_kit/utils/faker.rb +62 -2
  86. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +166 -0
  87. data/lib/servactory/tool_kit/dynamic_options/format.rb +195 -8
  88. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +219 -17
  89. data/lib/servactory/tool_kit/dynamic_options/max.rb +143 -0
  90. data/lib/servactory/tool_kit/dynamic_options/min.rb +143 -0
  91. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +157 -8
  92. data/lib/servactory/tool_kit/dynamic_options/must.rb +194 -0
  93. data/lib/servactory/tool_kit/dynamic_options/schema.rb +226 -2
  94. data/lib/servactory/tool_kit/dynamic_options/target.rb +248 -0
  95. data/lib/servactory/version.rb +4 -4
  96. data/lib/servactory.rb +6 -0
  97. metadata +57 -48
  98. data/lib/generators/servactory/install_generator.rb +0 -21
  99. data/lib/generators/servactory/rspec_generator.rb +0 -88
  100. data/lib/generators/servactory/service_generator.rb +0 -49
  101. data/lib/servactory/configuration/setup.rb +0 -97
  102. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/consists_of_matcher.rb +0 -68
  103. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/inclusion_matcher.rb +0 -73
  104. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/message_matcher.rb +0 -91
  105. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/must_matcher.rb +0 -72
  106. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/schema_matcher.rb +0 -92
  107. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/types_matcher.rb +0 -72
  108. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/default_matcher.rb +0 -69
  109. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/optional_matcher.rb +0 -63
  110. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/required_matcher.rb +0 -81
  111. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/valid_with_matcher.rb +0 -199
@@ -1,93 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "helpers/concerns/error_messages"
4
+ require_relative "helpers/concerns/service_class_validation"
5
+ require_relative "helpers/service_mock_config"
6
+ require_relative "helpers/mock_executor"
7
+ require_relative "helpers/input_validator"
8
+ require_relative "helpers/output_validator"
9
+ require_relative "helpers/argument_matchers"
10
+ require_relative "helpers/service_mock_builder"
11
+ require_relative "helpers/fluent"
12
+ require_relative "helpers/legacy"
13
+
3
14
  module Servactory
4
15
  module TestKit
5
16
  module Rspec
17
+ # RSpec helper methods for mocking Servactory services.
18
+ #
19
+ # ## Purpose
20
+ #
21
+ # Provides convenient helper methods for mocking Servactory service calls
22
+ # in RSpec tests. Supports both a modern fluent API and backward-compatible
23
+ # legacy methods.
24
+ #
25
+ # ## Usage
26
+ #
27
+ # Include in RSpec configuration:
28
+ #
29
+ # ```ruby
30
+ # RSpec.configure do |config|
31
+ # config.include Servactory::TestKit::Rspec::Helpers, type: :service
32
+ # end
33
+ # ```
34
+ #
35
+ # ## Available Helpers
36
+ #
37
+ # **Fluent API (recommended):**
38
+ # - `allow_service(ServiceClass)` - mock `.call` method (returns Result)
39
+ # - `allow_service!(ServiceClass)` - mock `.call!` method (raises on failure)
40
+ #
41
+ # **Backward-Compatible API:**
42
+ # - `allow_service_as_success!` / `allow_service_as_success`
43
+ # - `allow_service_as_failure!` / `allow_service_as_failure`
44
+ #
45
+ # **Argument Matchers:**
46
+ # - `including(hash)` - partial hash matching
47
+ # - `excluding(hash)` - exclusion matching
48
+ # - `any_inputs` - match any arguments
49
+ # - `no_inputs` - match no arguments
50
+ #
51
+ # @see Helpers::Fluent for fluent API documentation
52
+ # @see Helpers::Legacy for backward-compatible API documentation
53
+ # @see Helpers::ArgumentMatchers for argument matcher documentation
6
54
  module Helpers
7
- def allow_service_as_success!(service_class, with: nil, &block)
8
- allow_service!(service_class, :as_success, with:, &block)
9
- end
10
-
11
- def allow_service_as_success(service_class, with: nil, &block)
12
- allow_service(service_class, :as_success, with:, &block)
13
- end
14
-
15
- def allow_service_as_failure!(service_class, with: nil, &block)
16
- allow_service!(service_class, :as_failure, with:, &block)
17
- end
18
-
19
- def allow_service_as_failure(service_class, with: nil, &block)
20
- allow_service(service_class, :as_failure, with:, &block)
21
- end
22
-
23
- ########################################################################
24
-
25
- def allow_service!(service_class, result_type, with: nil, &block)
26
- allow_servactory(service_class, :call!, result_type, with:, &block)
27
- end
28
-
29
- def allow_service(service_class, result_type, with: nil, &block)
30
- allow_servactory(service_class, :call, result_type, with:, &block)
31
- end
32
-
33
- ########################################################################
34
-
35
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
36
- def allow_servactory(service_class, method_call, result_type, with: nil)
37
- method_call = method_call.to_sym
38
- result_type = result_type.to_sym
39
-
40
- unless %i[call! call].include?(method_call)
41
- raise ArgumentError, "Invalid value for `method_call`. Must be `:call!` or `:call`."
42
- end
43
-
44
- unless %i[as_success as_failure].include?(result_type)
45
- raise ArgumentError, "Invalid value for `result_type`. Must be `:as_success` or `:as_failure`."
46
- end
47
-
48
- as_success = result_type == :as_success
49
- with_bang = method_call == :call!
50
-
51
- if block_given? && !yield.is_a?(Hash) && as_success
52
- raise ArgumentError, "Invalid value for block. Must be a Hash with attributes."
53
- end
54
-
55
- and_return_or_raise = with_bang && !as_success ? :and_raise : :and_return
56
-
57
- result = if block_given?
58
- if yield.is_a?(Hash)
59
- yield
60
- else
61
- { exception: yield }
62
- end
63
- else
64
- {}
65
- end
66
-
67
- result[:service_class] = service_class
68
-
69
- allow(service_class).to(
70
- receive(method_call)
71
- .with(
72
- if with.present?
73
- with
74
- elsif (input_names = service_class.info.inputs.keys).present?
75
- input_names.to_h { |input_name| [input_name, anything] }
76
- else
77
- no_args
78
- end
79
- )
80
- .public_send(
81
- and_return_or_raise,
82
- if as_success || !with_bang
83
- Servactory::TestKit::Result.public_send(result_type, **result)
84
- else
85
- result.fetch(:exception)
86
- end
87
- )
88
- )
89
- end
90
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
55
+ include Helpers::ArgumentMatchers
56
+ include Helpers::Fluent
57
+ include Helpers::Legacy
91
58
  end
92
59
  end
93
60
  end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Base
8
+ # Base class for RSpec matchers that validate Servactory attribute definitions.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # AttributeMatcher provides the foundation for testing service attribute
13
+ # definitions (inputs, internals, outputs) in Servactory services. It implements
14
+ # the RSpec matcher protocol with fluent API for chaining validation rules.
15
+ #
16
+ # ## Usage
17
+ #
18
+ # Subclasses define specific attribute type matchers:
19
+ #
20
+ # ```ruby
21
+ # class HaveServiceInputMatcher < Base::AttributeMatcher
22
+ # for_attribute_type :input
23
+ #
24
+ # register_submatcher :required,
25
+ # class_name: "Input::RequiredSubmatcher",
26
+ # mutually_exclusive_with: [:optional]
27
+ # end
28
+ # ```
29
+ #
30
+ # In RSpec tests:
31
+ #
32
+ # ```ruby
33
+ # expect(MyService).to have_service_input(:name)
34
+ # .type(String)
35
+ # .required
36
+ # .inclusion(%w[admin user])
37
+ # ```
38
+ #
39
+ # ## Architecture
40
+ #
41
+ # Works with:
42
+ # - SubmatcherRegistry - provides DSL for registering submatchers
43
+ # - SubmatcherContext - carries context data to submatchers
44
+ # - Submatcher - base class for individual validation rules
45
+ #
46
+ # ## Features
47
+ #
48
+ # - **Fluent API** - chain methods for readable test assertions
49
+ # - **Dynamic Chain Methods** - generated from submatcher registry
50
+ # - **Mutual Exclusivity** - conflicting options replace each other
51
+ # - **Composable** - works with RSpec's compound matchers
52
+ # - **Block Expectations** - supports `expect { }.to` syntax
53
+ class AttributeMatcher # rubocop:disable Metrics/ClassLength
54
+ include SubmatcherRegistry
55
+ include RSpec::Matchers::Composable
56
+
57
+ class << self
58
+ # @return [Symbol] The attribute type this matcher validates (:input, :internal, :output)
59
+ attr_reader :attribute_type
60
+
61
+ # Sets the attribute type for this matcher class.
62
+ #
63
+ # @param type [Symbol] The attribute type (:input, :internal, :output)
64
+ # @return [void]
65
+ def for_attribute_type(type)
66
+ @attribute_type = type
67
+ end
68
+ end
69
+
70
+ # @return [Class] The Servactory service class being tested
71
+ attr_reader :described_class
72
+
73
+ # @return [Symbol] The name of the attribute being validated
74
+ attr_reader :attribute_name
75
+
76
+ # @return [Array, nil] Type classes passed to the types chain method
77
+ attr_reader :option_types
78
+
79
+ # Creates a new attribute matcher instance.
80
+ #
81
+ # @param described_class [Class] The Servactory service class to test
82
+ # @param attribute_name [Symbol] The name of the attribute to validate
83
+ def initialize(described_class, attribute_name)
84
+ @described_class = described_class
85
+ @attribute_name = attribute_name
86
+ @submatchers = []
87
+ @option_types = nil
88
+ @last_submatcher = nil
89
+
90
+ build_chain_methods_from_registry
91
+ end
92
+
93
+ # Indicates this matcher supports block expectations.
94
+ #
95
+ # Required by RSpec for matchers used with `expect { }.to` syntax.
96
+ #
97
+ # @return [Boolean] Always returns true
98
+ def supports_block_expectations?
99
+ true
100
+ end
101
+
102
+ # Checks if all submatchers pass for the given subject.
103
+ #
104
+ # @param subject [Object] The RSpec subject (typically nil for service class matchers)
105
+ # @return [Boolean] True if all submatchers pass, false otherwise
106
+ def matches?(subject)
107
+ @subject = subject
108
+ failing_submatchers.empty?
109
+ end
110
+
111
+ # Builds a human-readable description of what this matcher validates.
112
+ #
113
+ # @return [String] Description including attribute name and all submatcher descriptions
114
+ def description
115
+ submatcher_descriptions = submatchers.map(&:description).join(", ")
116
+ "#{attribute_name} with #{submatcher_descriptions}"
117
+ end
118
+
119
+ # Builds the failure message when the matcher does not pass.
120
+ #
121
+ # @return [String] Explanation of what was expected and what was missing
122
+ def failure_message
123
+ "Expected #{expectation}, which #{missing_options}"
124
+ end
125
+
126
+ # Builds the failure message for negated expectations.
127
+ #
128
+ # @return [String] Explanation for negated expectation failure
129
+ def failure_message_when_negated
130
+ "Did not expect #{expectation} with specified options"
131
+ end
132
+
133
+ protected
134
+
135
+ # @return [Array<Submatcher>] Collection of registered submatchers
136
+ attr_reader :submatchers
137
+
138
+ # @return [Object] The RSpec subject passed to matches?
139
+ attr_reader :subject
140
+
141
+ # @return [Submatcher, nil] The most recently added submatcher
142
+ attr_reader :last_submatcher
143
+
144
+ # Returns the attribute type from the class-level setting.
145
+ #
146
+ # @return [Symbol] The attribute type (:input, :internal, :output)
147
+ def attribute_type
148
+ self.class.attribute_type
149
+ end
150
+
151
+ # Returns the pluralized attribute type for accessing service info.
152
+ #
153
+ # @return [Symbol] Pluralized attribute type (:inputs, :internals, :outputs)
154
+ def attribute_type_plural
155
+ @attribute_type_plural ||= attribute_type.to_s.pluralize.to_sym
156
+ end
157
+
158
+ # Fetches attribute definition data from the service class.
159
+ #
160
+ # @return [Hash] The attribute definition hash from service info
161
+ def attribute_data
162
+ @attribute_data ||=
163
+ described_class
164
+ .info
165
+ .public_send(attribute_type_plural)
166
+ .fetch(attribute_name)
167
+ end
168
+
169
+ # Returns the i18n root key configured for the service.
170
+ #
171
+ # @return [String, nil] The i18n root key for error messages
172
+ def i18n_root_key
173
+ @i18n_root_key ||= described_class.config.i18n_root_key
174
+ end
175
+
176
+ # Builds the expectation string for failure messages.
177
+ #
178
+ # @return [String] Human-readable expectation description
179
+ def expectation
180
+ "#{described_class.name} to have a service #{attribute_type} attribute named #{attribute_name}"
181
+ end
182
+
183
+ # Finds submatchers that did not pass validation.
184
+ #
185
+ # @return [Array<Submatcher>] Submatchers where matches? returned false
186
+ def failing_submatchers
187
+ @failing_submatchers ||= submatchers.reject { |m| m.matches?(subject) }
188
+ end
189
+
190
+ # Collects missing option messages from failing submatchers.
191
+ #
192
+ # @return [String] Comma-separated list of missing option descriptions
193
+ def missing_options
194
+ failing_submatchers.map(&:missing_option).select(&:present?).join(", ")
195
+ end
196
+
197
+ # Adds a submatcher to the collection, replacing any of the same class.
198
+ #
199
+ # @param submatcher [Submatcher] The submatcher to add
200
+ # @return [void]
201
+ def add_submatcher(submatcher)
202
+ remove_submatcher(submatcher.class)
203
+ @last_submatcher = submatcher
204
+ submatchers << submatcher
205
+ end
206
+
207
+ # Removes all submatchers of the specified class.
208
+ #
209
+ # @param matcher_class [Class] The submatcher class to remove
210
+ # @return [void]
211
+ def remove_submatcher(matcher_class)
212
+ submatchers.delete_if { |sm| sm.is_a?(matcher_class) }
213
+ end
214
+
215
+ private
216
+
217
+ # Dynamically defines chain methods from registered submatcher definitions.
218
+ #
219
+ # Called during initialization to create fluent API methods like
220
+ # `.type()`, `.required()`, `.optional()`, etc.
221
+ #
222
+ # @return [void]
223
+ def build_chain_methods_from_registry
224
+ self.class.submatcher_definitions.each_value do |definition|
225
+ define_chain_method_for(definition)
226
+ end
227
+ end
228
+
229
+ # Defines a chain method and its aliases for a submatcher definition.
230
+ #
231
+ # Creates a singleton method that:
232
+ # 1. Extracts options hash if applicable
233
+ # 2. Removes mutually exclusive submatchers
234
+ # 3. Stores option_types if needed
235
+ # 4. Builds and adds the submatcher
236
+ #
237
+ # @param definition [SubmatcherDefinition] The submatcher definition
238
+ # @return [void]
239
+ def define_chain_method_for(definition) # rubocop:disable Metrics/MethodLength
240
+ 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
253
+ end
254
+
255
+ definition.chain_aliases.each do |alias_name|
256
+ define_singleton_method(alias_name) do |*args|
257
+ public_send(definition.chain_method, *args)
258
+ end
259
+ end
260
+ end
261
+
262
+ # Extracts trailing options hash from arguments if the definition accepts them.
263
+ #
264
+ # @param args [Array] The method arguments
265
+ # @param definition [SubmatcherDefinition] The submatcher definition
266
+ # @return [Hash] Extracted options hash or empty hash
267
+ def extract_options_hash(args, definition)
268
+ # Only extract trailing options hash if the definition explicitly accepts them.
269
+ return {} unless definition.accepts_trailing_options
270
+ return {} unless args.last.is_a?(Hash)
271
+ return {} if args.last.is_a?(Class)
272
+
273
+ args.pop || {}
274
+ end
275
+
276
+ # Removes submatchers that are mutually exclusive with the current one.
277
+ #
278
+ # @param definition [SubmatcherDefinition] The current submatcher definition
279
+ # @return [void]
280
+ def handle_mutually_exclusive(definition)
281
+ definition.mutually_exclusive_with.each do |exclusive_name|
282
+ exclusive_def = self.class.submatcher_definitions[exclusive_name]
283
+ next unless exclusive_def
284
+
285
+ submatcher_class = resolve_submatcher_class(exclusive_def.class_name)
286
+ remove_submatcher(submatcher_class)
287
+ end
288
+ end
289
+
290
+ # Builds a submatcher instance with context and transformed arguments.
291
+ #
292
+ # @param definition [SubmatcherDefinition] The submatcher definition
293
+ # @param args [Array] Original method arguments
294
+ # @param options [Hash] Extracted options hash
295
+ # @return [Submatcher] The constructed submatcher instance
296
+ def build_submatcher(definition, args, options) # rubocop:disable Metrics/MethodLength
297
+ context = SubmatcherContext.new(
298
+ described_class:,
299
+ attribute_type:,
300
+ attribute_name:,
301
+ attribute_data:,
302
+ option_types: definition.requires_option_types ? option_types : nil,
303
+ last_submatcher: definition.requires_last_submatcher ? last_submatcher : nil,
304
+ i18n_root_key:
305
+ )
306
+
307
+ transformed_args = definition.transform_args.call(args, options)
308
+ submatcher_class = resolve_submatcher_class(definition.class_name)
309
+ submatcher_class.new(context, *transformed_args)
310
+ end
311
+
312
+ # Resolves submatcher class name to actual class constant.
313
+ #
314
+ # @param class_name [String] Relative class name (e.g., "Input::RequiredSubmatcher")
315
+ # @return [Class] The submatcher class
316
+ def resolve_submatcher_class(class_name)
317
+ "Servactory::TestKit::Rspec::Matchers::Submatchers::#{class_name}".constantize
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Base
8
+ # Abstract base class for individual validation rules in attribute matchers.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # Submatcher provides the foundation for specific validation checks like
13
+ # type validation, required/optional status, default values, etc.
14
+ # Each submatcher validates one aspect of a service attribute definition.
15
+ #
16
+ # ## Usage
17
+ #
18
+ # Create a subclass and implement required abstract methods:
19
+ #
20
+ # ```ruby
21
+ # class RequiredSubmatcher < Base::Submatcher
22
+ # def description
23
+ # "required"
24
+ # end
25
+ #
26
+ # protected
27
+ #
28
+ # def passes?
29
+ # fetch_option(:required) == true
30
+ # end
31
+ #
32
+ # def build_failure_message
33
+ # "expected input to be required, but it was optional"
34
+ # end
35
+ # end
36
+ # ```
37
+ #
38
+ # ## Abstract Methods
39
+ #
40
+ # Subclasses must implement:
41
+ # - `description` - human-readable description for test output
42
+ # - `passes?` - validation logic returning boolean
43
+ # - `build_failure_message` - explanation when validation fails
44
+ #
45
+ # ## Architecture
46
+ #
47
+ # Works with:
48
+ # - SubmatcherContext - provides attribute data and metadata
49
+ # - AttributeMatcher - manages collection of submatchers
50
+ # - Concerns - provides helper methods for data access and comparison
51
+ class Submatcher
52
+ include RSpec::Matchers::Composable
53
+ include Concerns::AttributeDataAccess
54
+ include Concerns::ErrorMessageBuilder
55
+ include Concerns::ValueComparison
56
+
57
+ # @return [String] Failure message when validation does not pass
58
+ attr_reader :missing_option
59
+
60
+ # Creates a new submatcher instance.
61
+ #
62
+ # @param context [SubmatcherContext] Context with attribute data and metadata
63
+ def initialize(context)
64
+ @context = context
65
+ @missing_option = ""
66
+ end
67
+
68
+ # Checks if this submatcher's validation passes.
69
+ #
70
+ # @param _subject [Object] RSpec subject (unused, kept for interface compatibility)
71
+ # @return [Boolean] True if validation passes
72
+ def matches?(_subject)
73
+ if passes?
74
+ true
75
+ else
76
+ @missing_option = build_failure_message
77
+ false
78
+ end
79
+ end
80
+
81
+ # Returns the failure message for RSpec output.
82
+ #
83
+ # @return [String] The failure message
84
+ def failure_message
85
+ @missing_option
86
+ end
87
+
88
+ # Returns the failure message for negated expectations.
89
+ #
90
+ # @return [String] The negated failure message
91
+ def failure_message_when_negated
92
+ "expected not to #{description}"
93
+ end
94
+
95
+ # Returns a human-readable description of what this submatcher validates.
96
+ #
97
+ # @abstract Subclasses must implement this method
98
+ # @return [String] Description for test output
99
+ # @raise [NotImplementedError] If not implemented by subclass
100
+ def description
101
+ raise NotImplementedError, "#{self.class} must implement #description"
102
+ end
103
+
104
+ protected
105
+
106
+ # Performs the actual validation logic.
107
+ #
108
+ # @abstract Subclasses must implement this method
109
+ # @return [Boolean] True if validation passes
110
+ # @raise [NotImplementedError] If not implemented by subclass
111
+ def passes?
112
+ raise NotImplementedError, "#{self.class} must implement #passes?"
113
+ end
114
+
115
+ # Builds a descriptive failure message when validation fails.
116
+ #
117
+ # @abstract Subclasses must implement this method
118
+ # @return [String] Explanation of why validation failed
119
+ # @raise [NotImplementedError] If not implemented by subclass
120
+ def build_failure_message
121
+ raise NotImplementedError, "#{self.class} must implement #build_failure_message"
122
+ end
123
+
124
+ private
125
+
126
+ # @return [SubmatcherContext] The context with attribute data
127
+ attr_reader :context
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end