servactory 3.1.0.rc3 → 3.1.0.rc4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/lib/generators/README.md +1 -1
  4. data/lib/generators/servactory/extension/USAGE +1 -1
  5. data/lib/generators/servactory/install/USAGE +2 -2
  6. data/lib/generators/servactory/rspec/USAGE +4 -4
  7. data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
  8. data/lib/generators/servactory/service/USAGE +5 -5
  9. data/lib/servactory/actions/collection.rb +21 -5
  10. data/lib/servactory/actions/stages/collection.rb +19 -1
  11. data/lib/servactory/actions/tools/rules.rb +1 -1
  12. data/lib/servactory/actions/tools/runner.rb +1 -1
  13. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
  14. data/lib/servactory/configuration/configurable.rb +1 -1
  15. data/lib/servactory/configuration/factory.rb +30 -3
  16. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +2 -1
  17. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +150 -37
  18. data/lib/servactory/context/warehouse/inputs.rb +1 -1
  19. data/lib/servactory/context/workspace/inputs.rb +2 -2
  20. data/lib/servactory/context/workspace/internals.rb +2 -2
  21. data/lib/servactory/context/workspace/outputs.rb +2 -2
  22. data/lib/servactory/context/workspace.rb +7 -7
  23. data/lib/servactory/info/builder.rb +4 -3
  24. data/lib/servactory/inputs/dsl.rb +5 -1
  25. data/lib/servactory/inputs/input.rb +1 -2
  26. data/lib/servactory/internals/dsl.rb +7 -1
  27. data/lib/servactory/maintenance/attributes/base.rb +4 -1
  28. data/lib/servactory/maintenance/attributes/collection.rb +61 -6
  29. data/lib/servactory/maintenance/options/collection.rb +46 -15
  30. data/lib/servactory/maintenance/options/option.rb +3 -12
  31. data/lib/servactory/maintenance/options/registrar.rb +2 -12
  32. data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
  33. data/lib/servactory/outputs/dsl.rb +7 -1
  34. data/lib/servactory/result.rb +21 -8
  35. data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
  36. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
  37. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -129
  38. data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
  39. data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
  40. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +110 -45
  41. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +26 -8
  42. data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
  43. data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
  44. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
  45. data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
  46. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
  47. data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
  48. data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
  49. data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
  50. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
  51. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
  52. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
  53. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
  54. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
  55. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
  56. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
  57. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
  58. data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
  59. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +29 -8
  60. data/lib/servactory/tool_kit/dynamic_options/format.rb +2 -3
  61. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +1 -1
  62. data/lib/servactory/tool_kit/dynamic_options/max.rb +1 -1
  63. data/lib/servactory/tool_kit/dynamic_options/min.rb +1 -1
  64. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +33 -5
  65. data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
  66. data/lib/servactory/tool_kit/dynamic_options/schema.rb +34 -21
  67. data/lib/servactory/tool_kit/dynamic_options/target.rb +1 -1
  68. data/lib/servactory/utils.rb +13 -3
  69. data/lib/servactory/version.rb +1 -1
  70. data/lib/servactory.rb +3 -0
  71. metadata +10 -2
@@ -35,6 +35,18 @@ module Servactory
35
35
  # .fails(CustomException, type: :base, message: "Error")
36
36
  # ```
37
37
  #
38
+ # Pass-through to the original service (spy pattern):
39
+ #
40
+ # ```ruby
41
+ # allow_service(MyService)
42
+ # .and_call_original
43
+ #
44
+ # allow_service(MyService)
45
+ # .and_wrap_original do |original, **inputs|
46
+ # original.call(**inputs)
47
+ # end
48
+ # ```
49
+ #
38
50
  # Sequential returns (first call succeeds, second fails):
39
51
  #
40
52
  # ```ruby
@@ -57,12 +69,30 @@ module Servactory
57
69
  # .with(user_id: 123)
58
70
  # ```
59
71
  #
72
+ # Configuring the mock after the service has been called registers
73
+ # a new stub, which takes precedence over the earlier one for the calls
74
+ # it matches.
75
+ #
76
+ # A mock without `.with()` (default mock) accepts any arguments on the
77
+ # RSpec level, so it handles every later call of the stubbed method,
78
+ # including calls that mocks configured before it would match. Calls
79
+ # with inputs the service would not accept are rejected instead of
80
+ # deferred to those earlier mocks. Mocks with `.with()` configured after
81
+ # a default mock take precedence for the calls they match:
82
+ #
83
+ # ```ruby
84
+ # allow_service(MyService).succeeds(user: default_user)
85
+ # allow_service(MyService).with(user_id: 123).succeeds(user: user)
86
+ # ```
87
+ #
60
88
  # ## Features
61
89
  #
62
90
  # - **Fluent API** - chainable methods for readable test setup
63
91
  # - **Success/Failure** - configure expected result type in one method
64
92
  # - **Exception Handling** - auto-creates exceptions with type, message, meta
65
93
  # - **Input Matching** - match specific service inputs with `.with()`
94
+ # - **Pass-Through** - delegate to the original service with `.and_call_original`
95
+ # or `.and_wrap_original`
66
96
  # - **Sequential Responses** - different results for consecutive calls
67
97
  # - **Automatic Validation** - validates inputs and outputs against service definition
68
98
  #
@@ -105,7 +135,7 @@ module Servactory
105
135
  @config = ServiceMockConfig.new(service_class:)
106
136
  @config.method_type = method_type
107
137
  @sequential_configs = []
108
- @executed = false
138
+ @stub = nil
109
139
  end
110
140
 
111
141
  # ============================================================
@@ -136,7 +166,7 @@ module Servactory
136
166
  validate_outputs!(outputs_hash)
137
167
  @config.result_type = :success
138
168
  @config.outputs = outputs_hash
139
- execute_or_re_execute_mock
169
+ execute_mock
140
170
  self
141
171
  end
142
172
 
@@ -166,7 +196,7 @@ module Servactory
166
196
 
167
197
  @config.result_type = :failure
168
198
  @config.exception = build_exception(exception_class, type:, message:, meta:)
169
- execute_or_re_execute_mock
199
+ execute_mock
170
200
  self
171
201
  end
172
202
 
@@ -177,6 +207,11 @@ module Servactory
177
207
  #
178
208
  # Inputs are automatically validated against service definition.
179
209
  #
210
+ # Without `.with()`, a call matches when all required inputs are present
211
+ # and no unknown inputs are passed; optional inputs may be omitted.
212
+ # Such a mock handles every later call of the stubbed method and rejects
213
+ # invalid inputs instead of deferring to mocks configured before it.
214
+ #
180
215
  # @param inputs_hash_or_matcher [Hash, Object] Service inputs to match or RSpec matcher
181
216
  # @return [ServiceMockBuilder] self for method chaining
182
217
  #
@@ -192,8 +227,8 @@ module Servactory
192
227
  # @raise [InputValidator::ValidationError] if inputs don't match service definition
193
228
  def with(inputs_hash_or_matcher)
194
229
  validate_inputs!(inputs_hash_or_matcher)
195
- @config.argument_matcher = inputs_hash_or_matcher
196
- re_execute_mock if @executed
230
+ all_configs.each { |config| config.argument_matcher = inputs_hash_or_matcher }
231
+ update_mock_arguments
197
232
  self
198
233
  end
199
234
 
@@ -216,14 +251,21 @@ module Servactory
216
251
  validate_result_type_not_switched!(:and_call_original)
217
252
 
218
253
  @config.result_type = :call_original
219
- execute_or_re_execute_mock
254
+ execute_mock
220
255
  self
221
256
  end
222
257
 
223
258
  # Wraps the original method with custom logic.
224
- # Block receives the original method and call arguments.
259
+ # Block receives the original method and the service inputs as keywords,
260
+ # whether the service was called with keywords, a positional Hash or
261
+ # a Datory object. Input names are symbolized the way the service does it.
262
+ # A block without keyword parameters receives the call arguments as given,
263
+ # e.g. `{}` for `.call({})`.
225
264
  #
226
265
  # @yield [original, **inputs] Block wrapping the original
266
+ # @yieldparam original [Method] The original `.call` or `.call!` method
267
+ # @yieldparam inputs [Hash{Symbol => Object}] Service inputs
268
+ # @yieldreturn [Object] Value returned to the service caller
227
269
  # @return [ServiceMockBuilder] self for method chaining
228
270
  #
229
271
  # @example Modify result
@@ -232,13 +274,26 @@ module Servactory
232
274
  # # custom logic
233
275
  # result
234
276
  # end
277
+ #
278
+ # @example Modify inputs
279
+ # allow_service(S).and_wrap_original do |original, **inputs|
280
+ # original.call(**inputs, locale: "en")
281
+ # end
282
+ #
283
+ # @example Receive the call arguments as given
284
+ # allow_service(S).and_wrap_original do |original, *arguments|
285
+ # original.call(*arguments)
286
+ # end
287
+ #
288
+ # @raise [ArgumentError] if called without a block
235
289
  def and_wrap_original(&block)
290
+ validate_block_given!(:and_wrap_original, block)
236
291
  validate_not_in_sequential_mode!(:and_wrap_original)
237
292
  validate_result_type_not_switched!(:and_wrap_original)
238
293
 
239
294
  @config.result_type = :wrap_original
240
295
  @config.wrap_block = block
241
- execute_or_re_execute_mock
296
+ execute_mock
242
297
  self
243
298
  end
244
299
 
@@ -271,7 +326,7 @@ module Servactory
271
326
  @config.result_type = :success
272
327
  @config.outputs = outputs_hash
273
328
  @config.method_type = @sequential_configs.last&.method_type || :call
274
- execute_sequential_mock
329
+ execute_mock
275
330
  self
276
331
  end
277
332
 
@@ -300,7 +355,7 @@ module Servactory
300
355
  @config.result_type = :failure
301
356
  @config.exception = build_exception(exception_class, type:, message:, meta:)
302
357
  @config.method_type = @sequential_configs.last&.method_type || :call
303
- execute_sequential_mock
358
+ execute_mock
304
359
  self
305
360
  end
306
361
 
@@ -369,6 +424,21 @@ module Servactory
369
424
  "Pass-through methods are not compatible with sequential responses."
370
425
  end
371
426
 
427
+ # Validates that a block is given.
428
+ #
429
+ # @param method_name [Symbol] The method being called
430
+ # @param block [Proc, nil] The block given to the method
431
+ # @raise [ArgumentError] if block is missing
432
+ # @return [void]
433
+ def validate_block_given!(method_name, block)
434
+ return unless block.nil?
435
+
436
+ raise ArgumentError,
437
+ "Cannot call #{method_name}() without a block. " \
438
+ "Pass a block that receives the original method and the service inputs, " \
439
+ "e.g. { |original, **inputs| original.call(**inputs) }."
440
+ end
441
+
372
442
  # Validates outputs against service definition.
373
443
  #
374
444
  # @param outputs_hash [Hash] Outputs to validate
@@ -424,17 +494,6 @@ module Servactory
424
494
  # Mock Execution
425
495
  # ============================================================
426
496
 
427
- # Executes or re-executes mock depending on current state.
428
- #
429
- # @return [void]
430
- def execute_or_re_execute_mock
431
- if @executed
432
- re_execute_mock
433
- else
434
- execute_mock
435
- end
436
- end
437
-
438
497
  # Saves current config to sequential list.
439
498
  #
440
499
  # @return [void]
@@ -442,48 +501,54 @@ module Servactory
442
501
  @sequential_configs << @config.dup
443
502
  end
444
503
 
445
- # Executes the mock for the first time.
504
+ # Returns all configs in call order.
505
+ #
506
+ # @return [Array<ServiceMockConfig>] Sequential configs followed by the current config
507
+ def all_configs
508
+ @sequential_configs + [@config]
509
+ end
510
+
511
+ # Registers the mock, or reconfigures the already registered stub.
512
+ #
513
+ # A stub that has already been invoked cannot be reconfigured,
514
+ # so a new stub is registered instead.
446
515
  #
447
516
  # @return [void]
448
517
  def execute_mock
449
- return if @executed
450
-
451
- @executed = true
452
- MockExecutor.new(
453
- service_class:,
454
- configs: [@config],
455
- rspec_context: @rspec_context
456
- ).execute
518
+ @stub = mock_executor.execute(reconfigurable_stub)
457
519
  end
458
520
 
459
- # Re-executes the mock after configuration changes.
521
+ # Applies the current argument matcher to the registered stub.
522
+ #
523
+ # Registers a new stub if the registered one has already been invoked.
460
524
  #
461
525
  # @return [void]
462
- def re_execute_mock
463
- return unless @executed
526
+ def update_mock_arguments
527
+ return if @stub.nil?
464
528
 
465
- if @sequential_configs.any?
466
- execute_sequential_mock
529
+ if @stub.invoked?
530
+ execute_mock
467
531
  else
468
- MockExecutor.new(
469
- service_class:,
470
- configs: [@config],
471
- rspec_context: @rspec_context
472
- ).execute
532
+ mock_executor.update_arguments(@stub)
473
533
  end
474
534
  end
475
535
 
476
- # Executes the mock with all sequential configurations.
536
+ # Returns the registered stub unless it has already been invoked.
477
537
  #
478
- # @return [void]
479
- def execute_sequential_mock
480
- all_configs = @sequential_configs + [@config]
538
+ # @return [MockExecutor::Stub, nil] Stub that can be reconfigured in place
539
+ def reconfigurable_stub
540
+ @stub unless @stub&.invoked?
541
+ end
481
542
 
543
+ # Builds an executor for the current configs.
544
+ #
545
+ # @return [MockExecutor] Executor for all configs
546
+ def mock_executor
482
547
  MockExecutor.new(
483
548
  service_class:,
484
549
  configs: all_configs,
485
550
  rspec_context: @rspec_context
486
- ).execute
551
+ )
487
552
  end
488
553
  end
489
554
  end
@@ -9,9 +9,9 @@ module Servactory
9
9
  # ## Purpose
10
10
  #
11
11
  # Holds all configuration for mocking a single service call, including
12
- # result type (success/failure), method type (call/call!), outputs,
13
- # exceptions, and argument matchers. Used by ServiceMockBuilder and
14
- # MockExecutor.
12
+ # result type (success/failure/pass-through), method type (call/call!),
13
+ # outputs, exceptions, argument matchers, and the wrap block.
14
+ # Used by ServiceMockBuilder and MockExecutor.
15
15
  #
16
16
  # ## Usage
17
17
  #
@@ -27,11 +27,12 @@ module Servactory
27
27
  # ## Attributes
28
28
  #
29
29
  # - `service_class` - The service class being mocked
30
- # - `result_type` - :success or :failure
30
+ # - `result_type` - :success, :failure, :call_original, or :wrap_original
31
31
  # - `method_type` - :call or :call!
32
32
  # - `outputs` - Hash of output values
33
33
  # - `exception` - Exception for failure mocks
34
34
  # - `argument_matcher` - RSpec argument matcher or Hash
35
+ # - `wrap_block` - Block for :wrap_original mocks
35
36
  class ServiceMockConfig
36
37
  attr_accessor :service_class,
37
38
  :result_type,
@@ -83,6 +84,13 @@ module Servactory
83
84
  result_type == :wrap_original
84
85
  end
85
86
 
87
+ # Checks if this is a pass-through mock.
88
+ #
89
+ # @return [Boolean] True if result_type is :call_original or :wrap_original
90
+ def pass_through?
91
+ call_original? || wrap_original?
92
+ end
93
+
86
94
  # Checks if this mocks the .call! method.
87
95
  #
88
96
  # @return [Boolean] True if method_type is :call!
@@ -113,17 +121,27 @@ module Servactory
113
121
 
114
122
  # Builds RSpec argument matcher from config.
115
123
  #
116
- # If no matcher specified, builds one from service input names.
124
+ # If no matcher specified, any arguments are accepted by RSpec
125
+ # and verified against the matcher from #build_inputs_matcher.
117
126
  #
118
127
  # @param rspec_context [Object] The RSpec test context
119
128
  # @return [Object] RSpec argument matcher
120
129
  def build_argument_matcher(rspec_context)
121
130
  return argument_matcher if argument_matcher.present?
122
131
 
123
- input_names = service_class.info.inputs.keys
124
- return rspec_context.no_args if input_names.empty?
132
+ rspec_context.any_args
133
+ end
134
+
135
+ # Builds matcher of service inputs used when no argument matcher is specified.
136
+ #
137
+ # Required inputs must be present, optional inputs may be omitted,
138
+ # and unknown inputs are rejected.
139
+ #
140
+ # @return [ServiceInputsMatcher, nil] Inputs matcher, or nil if an argument matcher is specified
141
+ def build_inputs_matcher
142
+ return if argument_matcher.present?
125
143
 
126
- input_names.to_h { |input_name| [input_name, rspec_context.anything] }
144
+ ServiceInputsMatcher.new(service_class.info.inputs)
127
145
  end
128
146
 
129
147
  # Creates a deep copy of this config.
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Helpers
7
+ # Adapts blocks given to `and_wrap_original` to the arguments of service calls.
8
+ #
9
+ # ## Purpose
10
+ #
11
+ # Services accept inputs as keywords, as a positional Hash or as
12
+ # a Datory object. A block accepting keywords receives the inputs as
13
+ # keywords in each case, converted with Servactory::Utils.adapt the way
14
+ # the service converts them, so String keys and HashWithIndifferentAccess
15
+ # arrive as Symbol keys. Any other block receives the arguments as given,
16
+ # the way RSpec's `and_wrap_original` passes them, so a call with an
17
+ # empty positional Hash passes `{}` to it.
18
+ #
19
+ # The adaptation is chosen once from the block parameters.
20
+ #
21
+ # ## Usage
22
+ #
23
+ # Used internally by MockExecutor:
24
+ #
25
+ # ```ruby
26
+ # delegate = WrapBlockAdapter.adapt(wrap_block)
27
+ #
28
+ # delegate.call(original, *arguments, &block)
29
+ # ```
30
+ class WrapBlockAdapter
31
+ KEYWORD_PARAMETER_TYPES = %i[key keyreq keyrest].freeze
32
+
33
+ private_constant :KEYWORD_PARAMETER_TYPES
34
+
35
+ class << self
36
+ # Adapts a wrap block to the arguments of service calls.
37
+ #
38
+ # @param wrap_block [Proc] Block given to and_wrap_original
39
+ # @return [Proc] Callable receiving the original method and the call arguments
40
+ def adapt(wrap_block)
41
+ return wrap_block unless accepts_keywords?(wrap_block)
42
+
43
+ with_keyword_inputs(wrap_block)
44
+ end
45
+
46
+ private
47
+
48
+ # Checks whether a block declares keyword parameters.
49
+ #
50
+ # @param wrap_block [Proc] Block given to and_wrap_original
51
+ # @return [Boolean] True if the block accepts keywords
52
+ def accepts_keywords?(wrap_block)
53
+ wrap_block.parameters.any? { |type, _name| KEYWORD_PARAMETER_TYPES.include?(type) }
54
+ end
55
+
56
+ # Adapts a block to receive service inputs as keywords.
57
+ #
58
+ # @param wrap_block [Proc] Block accepting keywords
59
+ # @return [Proc] Callable passing the inputs of a call to the block as Symbol keywords
60
+ def with_keyword_inputs(wrap_block)
61
+ lambda do |original, *arguments, &block|
62
+ if arguments.one? && Servactory::Utils.adaptable?(arguments.first)
63
+ wrap_block.call(original, **Servactory::Utils.adapt(arguments.first), &block)
64
+ else
65
+ wrap_block.call(original, *arguments, &block)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -2,7 +2,11 @@
2
2
 
3
3
  require_relative "helpers/concerns/error_messages"
4
4
  require_relative "helpers/concerns/service_class_validation"
5
+ require_relative "helpers/service_inputs_matcher"
6
+ require_relative "helpers/service_inputs_guard"
5
7
  require_relative "helpers/service_mock_config"
8
+ require_relative "helpers/exception_validator"
9
+ require_relative "helpers/wrap_block_adapter"
6
10
  require_relative "helpers/mock_executor"
7
11
  require_relative "helpers/input_validator"
8
12
  require_relative "helpers/output_validator"
@@ -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