rspec-expectations 2.14.0 → 3.13.0

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 (155) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/.document +1 -1
  4. data/.yardopts +1 -1
  5. data/Changelog.md +976 -25
  6. data/{License.txt → LICENSE.md} +5 -3
  7. data/README.md +162 -26
  8. data/lib/rspec/expectations/block_snippet_extractor.rb +253 -0
  9. data/lib/rspec/expectations/configuration.rb +230 -0
  10. data/lib/rspec/expectations/expectation_target.rb +127 -51
  11. data/lib/rspec/expectations/fail_with.rb +17 -57
  12. data/lib/rspec/expectations/failure_aggregator.rb +229 -0
  13. data/lib/rspec/expectations/handler.rb +146 -32
  14. data/lib/rspec/expectations/minitest_integration.rb +58 -0
  15. data/lib/rspec/expectations/syntax.rb +68 -100
  16. data/lib/rspec/expectations/version.rb +1 -1
  17. data/lib/rspec/expectations.rb +58 -23
  18. data/lib/rspec/matchers/aliased_matcher.rb +116 -0
  19. data/lib/rspec/matchers/built_in/all.rb +86 -0
  20. data/lib/rspec/matchers/built_in/base_matcher.rb +191 -20
  21. data/lib/rspec/matchers/built_in/be.rb +114 -114
  22. data/lib/rspec/matchers/built_in/be_between.rb +77 -0
  23. data/lib/rspec/matchers/built_in/be_instance_of.rb +15 -4
  24. data/lib/rspec/matchers/built_in/be_kind_of.rb +10 -1
  25. data/lib/rspec/matchers/built_in/be_within.rb +35 -18
  26. data/lib/rspec/matchers/built_in/change.rb +389 -80
  27. data/lib/rspec/matchers/built_in/compound.rb +290 -0
  28. data/lib/rspec/matchers/built_in/contain_exactly.rb +310 -0
  29. data/lib/rspec/matchers/built_in/count_expectation.rb +169 -0
  30. data/lib/rspec/matchers/built_in/cover.rb +3 -0
  31. data/lib/rspec/matchers/built_in/eq.rb +30 -8
  32. data/lib/rspec/matchers/built_in/eql.rb +23 -8
  33. data/lib/rspec/matchers/built_in/equal.rb +55 -22
  34. data/lib/rspec/matchers/built_in/exist.rb +74 -10
  35. data/lib/rspec/matchers/built_in/has.rb +141 -22
  36. data/lib/rspec/matchers/built_in/have_attributes.rb +114 -0
  37. data/lib/rspec/matchers/built_in/include.rb +184 -32
  38. data/lib/rspec/matchers/built_in/match.rb +95 -1
  39. data/lib/rspec/matchers/built_in/operators.rb +128 -0
  40. data/lib/rspec/matchers/built_in/output.rb +207 -0
  41. data/lib/rspec/matchers/built_in/raise_error.rb +192 -44
  42. data/lib/rspec/matchers/built_in/respond_to.rb +154 -28
  43. data/lib/rspec/matchers/built_in/satisfy.rb +39 -9
  44. data/lib/rspec/matchers/built_in/start_or_end_with.rb +94 -0
  45. data/lib/rspec/matchers/built_in/throw_symbol.rb +58 -14
  46. data/lib/rspec/matchers/built_in/yield.rb +240 -161
  47. data/lib/rspec/matchers/built_in.rb +47 -33
  48. data/lib/rspec/matchers/composable.rb +171 -0
  49. data/lib/rspec/matchers/dsl.rb +531 -10
  50. data/lib/rspec/matchers/english_phrasing.rb +58 -0
  51. data/lib/rspec/matchers/fail_matchers.rb +42 -0
  52. data/lib/rspec/matchers/generated_descriptions.rb +14 -8
  53. data/lib/rspec/matchers/matcher_delegator.rb +61 -0
  54. data/lib/rspec/matchers/matcher_protocol.rb +105 -0
  55. data/lib/rspec/matchers/multi_matcher_diff.rb +82 -0
  56. data/lib/rspec/matchers.rb +520 -173
  57. data.tar.gz.sig +0 -0
  58. metadata +141 -242
  59. metadata.gz.sig +2 -0
  60. data/features/README.md +0 -48
  61. data/features/Upgrade.md +0 -53
  62. data/features/built_in_matchers/README.md +0 -90
  63. data/features/built_in_matchers/be.feature +0 -175
  64. data/features/built_in_matchers/be_within.feature +0 -48
  65. data/features/built_in_matchers/cover.feature +0 -47
  66. data/features/built_in_matchers/end_with.feature +0 -48
  67. data/features/built_in_matchers/equality.feature +0 -139
  68. data/features/built_in_matchers/exist.feature +0 -45
  69. data/features/built_in_matchers/expect_change.feature +0 -59
  70. data/features/built_in_matchers/expect_error.feature +0 -144
  71. data/features/built_in_matchers/have.feature +0 -109
  72. data/features/built_in_matchers/include.feature +0 -174
  73. data/features/built_in_matchers/match.feature +0 -52
  74. data/features/built_in_matchers/operators.feature +0 -227
  75. data/features/built_in_matchers/predicates.feature +0 -137
  76. data/features/built_in_matchers/respond_to.feature +0 -84
  77. data/features/built_in_matchers/satisfy.feature +0 -33
  78. data/features/built_in_matchers/start_with.feature +0 -48
  79. data/features/built_in_matchers/throw_symbol.feature +0 -91
  80. data/features/built_in_matchers/types.feature +0 -116
  81. data/features/built_in_matchers/yield.feature +0 -161
  82. data/features/custom_matchers/access_running_example.feature +0 -53
  83. data/features/custom_matchers/define_diffable_matcher.feature +0 -27
  84. data/features/custom_matchers/define_matcher.feature +0 -368
  85. data/features/custom_matchers/define_matcher_outside_rspec.feature +0 -38
  86. data/features/custom_matchers/define_matcher_with_fluent_interface.feature +0 -24
  87. data/features/customized_message.feature +0 -22
  88. data/features/diffing.feature +0 -85
  89. data/features/implicit_docstrings.feature +0 -52
  90. data/features/step_definitions/additional_cli_steps.rb +0 -22
  91. data/features/support/env.rb +0 -14
  92. data/features/syntax_configuration.feature +0 -71
  93. data/features/test_frameworks/test_unit.feature +0 -44
  94. data/lib/rspec/expectations/deprecation.rb +0 -17
  95. data/lib/rspec/expectations/differ.rb +0 -133
  96. data/lib/rspec/expectations/errors.rb +0 -9
  97. data/lib/rspec/expectations/extensions/array.rb +0 -9
  98. data/lib/rspec/expectations/extensions/object.rb +0 -29
  99. data/lib/rspec/expectations/extensions.rb +0 -2
  100. data/lib/rspec/matchers/be_close.rb +0 -9
  101. data/lib/rspec/matchers/built_in/have.rb +0 -124
  102. data/lib/rspec/matchers/built_in/match_array.rb +0 -51
  103. data/lib/rspec/matchers/built_in/start_and_end_with.rb +0 -48
  104. data/lib/rspec/matchers/compatibility.rb +0 -14
  105. data/lib/rspec/matchers/configuration.rb +0 -108
  106. data/lib/rspec/matchers/extensions/instance_eval_with_args.rb +0 -39
  107. data/lib/rspec/matchers/matcher.rb +0 -300
  108. data/lib/rspec/matchers/method_missing.rb +0 -12
  109. data/lib/rspec/matchers/operator_matcher.rb +0 -109
  110. data/lib/rspec/matchers/pretty.rb +0 -70
  111. data/lib/rspec/matchers/test_unit_integration.rb +0 -11
  112. data/lib/rspec-expectations.rb +0 -1
  113. data/spec/rspec/expectations/differ_spec.rb +0 -192
  114. data/spec/rspec/expectations/expectation_target_spec.rb +0 -82
  115. data/spec/rspec/expectations/extensions/kernel_spec.rb +0 -67
  116. data/spec/rspec/expectations/fail_with_spec.rb +0 -114
  117. data/spec/rspec/expectations/handler_spec.rb +0 -227
  118. data/spec/rspec/expectations/syntax_spec.rb +0 -139
  119. data/spec/rspec/matchers/base_matcher_spec.rb +0 -62
  120. data/spec/rspec/matchers/be_close_spec.rb +0 -22
  121. data/spec/rspec/matchers/be_instance_of_spec.rb +0 -63
  122. data/spec/rspec/matchers/be_kind_of_spec.rb +0 -41
  123. data/spec/rspec/matchers/be_spec.rb +0 -516
  124. data/spec/rspec/matchers/be_within_spec.rb +0 -137
  125. data/spec/rspec/matchers/change_spec.rb +0 -553
  126. data/spec/rspec/matchers/configuration_spec.rb +0 -206
  127. data/spec/rspec/matchers/cover_spec.rb +0 -69
  128. data/spec/rspec/matchers/description_generation_spec.rb +0 -190
  129. data/spec/rspec/matchers/dsl_spec.rb +0 -57
  130. data/spec/rspec/matchers/eq_spec.rb +0 -60
  131. data/spec/rspec/matchers/eql_spec.rb +0 -41
  132. data/spec/rspec/matchers/equal_spec.rb +0 -78
  133. data/spec/rspec/matchers/exist_spec.rb +0 -124
  134. data/spec/rspec/matchers/has_spec.rb +0 -122
  135. data/spec/rspec/matchers/have_spec.rb +0 -455
  136. data/spec/rspec/matchers/include_matcher_integration_spec.rb +0 -30
  137. data/spec/rspec/matchers/include_spec.rb +0 -531
  138. data/spec/rspec/matchers/match_array_spec.rb +0 -194
  139. data/spec/rspec/matchers/match_spec.rb +0 -61
  140. data/spec/rspec/matchers/matcher_spec.rb +0 -471
  141. data/spec/rspec/matchers/matchers_spec.rb +0 -37
  142. data/spec/rspec/matchers/method_missing_spec.rb +0 -28
  143. data/spec/rspec/matchers/operator_matcher_spec.rb +0 -223
  144. data/spec/rspec/matchers/raise_error_spec.rb +0 -485
  145. data/spec/rspec/matchers/respond_to_spec.rb +0 -292
  146. data/spec/rspec/matchers/satisfy_spec.rb +0 -44
  147. data/spec/rspec/matchers/start_with_end_with_spec.rb +0 -186
  148. data/spec/rspec/matchers/throw_symbol_spec.rb +0 -116
  149. data/spec/rspec/matchers/yield_spec.rb +0 -514
  150. data/spec/spec_helper.rb +0 -54
  151. data/spec/support/classes.rb +0 -56
  152. data/spec/support/in_sub_process.rb +0 -38
  153. data/spec/support/matchers.rb +0 -22
  154. data/spec/support/ruby_version.rb +0 -10
  155. data/spec/support/shared_examples.rb +0 -13
@@ -0,0 +1,290 @@
1
+ module RSpec
2
+ module Matchers
3
+ module BuiltIn
4
+ # @api private
5
+ # Base class for `and` and `or` compound matchers.
6
+ class Compound < BaseMatcher
7
+ # @private
8
+ attr_reader :matcher_1, :matcher_2, :evaluator
9
+
10
+ def initialize(matcher_1, matcher_2)
11
+ @matcher_1 = matcher_1
12
+ @matcher_2 = matcher_2
13
+ end
14
+
15
+ # @private
16
+ def does_not_match?(_actual)
17
+ raise NotImplementedError, "`expect(...).not_to matcher.#{conjunction} matcher` " \
18
+ "is not supported, since it creates a bit of an ambiguity. Instead, define negated versions " \
19
+ "of whatever matchers you wish to negate with `RSpec::Matchers.define_negated_matcher` and " \
20
+ "use `expect(...).to matcher.#{conjunction} matcher`."
21
+ end
22
+
23
+ # @api private
24
+ # @return [String]
25
+ def description
26
+ "#{matcher_1.description} #{conjunction} #{matcher_2.description}"
27
+ end
28
+
29
+ # @api private
30
+ def supports_block_expectations?
31
+ matcher_supports_block_expectations?(matcher_1) &&
32
+ matcher_supports_block_expectations?(matcher_2)
33
+ end
34
+
35
+ # @api private
36
+ def supports_value_expectations?
37
+ matcher_supports_value_expectations?(matcher_1) &&
38
+ matcher_supports_value_expectations?(matcher_2)
39
+ end
40
+
41
+ # @api private
42
+ def expects_call_stack_jump?
43
+ NestedEvaluator.matcher_expects_call_stack_jump?(matcher_1) ||
44
+ NestedEvaluator.matcher_expects_call_stack_jump?(matcher_2)
45
+ end
46
+
47
+ # @api private
48
+ # @return [Boolean]
49
+ def diffable?
50
+ matcher_is_diffable?(matcher_1) || matcher_is_diffable?(matcher_2)
51
+ end
52
+
53
+ # @api private
54
+ # @return [RSpec::Matchers::MultiMatcherDiff]
55
+ def expected
56
+ return nil unless evaluator
57
+ ::RSpec::Matchers::MultiMatcherDiff.for_many_matchers(diffable_matcher_list)
58
+ end
59
+
60
+ protected
61
+
62
+ def diffable_matcher_list
63
+ list = []
64
+ list.concat(diffable_matcher_list_for(matcher_1)) unless matcher_1_matches?
65
+ list.concat(diffable_matcher_list_for(matcher_2)) unless matcher_2_matches?
66
+ list
67
+ end
68
+
69
+ private
70
+
71
+ def initialize_copy(other)
72
+ @matcher_1 = @matcher_1.clone
73
+ @matcher_2 = @matcher_2.clone
74
+ super
75
+ end
76
+
77
+ def match(_expected, actual)
78
+ evaluator_klass = if supports_block_expectations? && Proc === actual
79
+ NestedEvaluator
80
+ else
81
+ SequentialEvaluator
82
+ end
83
+
84
+ @evaluator = evaluator_klass.new(actual, matcher_1, matcher_2)
85
+ end
86
+
87
+ def indent_multiline_message(message)
88
+ message.lines.map do |line|
89
+ line =~ /\S/ ? ' ' + line : line
90
+ end.join
91
+ end
92
+
93
+ def compound_failure_message
94
+ "#{indent_multiline_message(matcher_1.failure_message.sub(/\n+\z/, ''))}" \
95
+ "\n\n...#{conjunction}:" \
96
+ "\n\n#{indent_multiline_message(matcher_2.failure_message.sub(/\A\n+/, ''))}"
97
+ end
98
+
99
+ def matcher_1_matches?
100
+ evaluator.matcher_matches?(matcher_1)
101
+ end
102
+
103
+ def matcher_2_matches?
104
+ evaluator.matcher_matches?(matcher_2)
105
+ end
106
+
107
+ def matcher_supports_block_expectations?(matcher)
108
+ matcher.supports_block_expectations?
109
+ rescue NoMethodError
110
+ false
111
+ end
112
+
113
+ def matcher_supports_value_expectations?(matcher)
114
+ matcher.supports_value_expectations?
115
+ rescue NoMethodError
116
+ true
117
+ end
118
+
119
+ def matcher_is_diffable?(matcher)
120
+ matcher.diffable?
121
+ rescue NoMethodError
122
+ false
123
+ end
124
+
125
+ def diffable_matcher_list_for(matcher)
126
+ return [] unless matcher_is_diffable?(matcher)
127
+ return matcher.diffable_matcher_list if Compound === matcher
128
+ [matcher]
129
+ end
130
+
131
+ # For value expectations, we can evaluate the matchers sequentially.
132
+ class SequentialEvaluator
133
+ def initialize(actual, *)
134
+ @actual = actual
135
+ end
136
+
137
+ def matcher_matches?(matcher)
138
+ matcher.matches?(@actual)
139
+ end
140
+ end
141
+
142
+ # Normally, we evaluate the matching sequentially. For an expression like
143
+ # `expect(x).to foo.and bar`, this becomes:
144
+ #
145
+ # expect(x).to foo
146
+ # expect(x).to bar
147
+ #
148
+ # For block expectations, we need to nest them instead, so that
149
+ # `expect { x }.to foo.and bar` becomes:
150
+ #
151
+ # expect {
152
+ # expect { x }.to foo
153
+ # }.to bar
154
+ #
155
+ # This is necessary so that the `expect` block is only executed once.
156
+ class NestedEvaluator
157
+ def initialize(actual, matcher_1, matcher_2)
158
+ @actual = actual
159
+ @matcher_1 = matcher_1
160
+ @matcher_2 = matcher_2
161
+ @match_results = {}
162
+
163
+ inner, outer = order_block_matchers
164
+
165
+ @match_results[outer] = outer.matches?(Proc.new do |*args|
166
+ @match_results[inner] = inner.matches?(inner_matcher_block(args))
167
+ end)
168
+ end
169
+
170
+ def matcher_matches?(matcher)
171
+ @match_results.fetch(matcher) do
172
+ raise ArgumentError, "Your #{matcher.description} has no match " \
173
+ "results, this can occur when an unexpected call stack or " \
174
+ "local jump occurs. Perhaps one of your matchers needs to " \
175
+ "declare `expects_call_stack_jump?` as `true`?"
176
+ end
177
+ end
178
+
179
+ private
180
+
181
+ # Some block matchers (such as `yield_xyz`) pass args to the `expect` block.
182
+ # When such a matcher is used as the outer matcher, we need to forward the
183
+ # the args on to the `expect` block.
184
+ def inner_matcher_block(outer_args)
185
+ return @actual if outer_args.empty?
186
+
187
+ Proc.new do |*inner_args|
188
+ unless inner_args.empty?
189
+ raise ArgumentError, "(#{@matcher_1.description}) and " \
190
+ "(#{@matcher_2.description}) cannot be combined in a compound expectation " \
191
+ "since both matchers pass arguments to the block."
192
+ end
193
+
194
+ @actual.call(*outer_args)
195
+ end
196
+ end
197
+
198
+ # For a matcher like `raise_error` or `throw_symbol`, where the block will jump
199
+ # up the call stack, we need to order things so that it is the inner matcher.
200
+ # For example, we need it to be this:
201
+ #
202
+ # expect {
203
+ # expect {
204
+ # x += 1
205
+ # raise "boom"
206
+ # }.to raise_error("boom")
207
+ # }.to change { x }.by(1)
208
+ #
209
+ # ...rather than:
210
+ #
211
+ # expect {
212
+ # expect {
213
+ # x += 1
214
+ # raise "boom"
215
+ # }.to change { x }.by(1)
216
+ # }.to raise_error("boom")
217
+ #
218
+ # In the latter case, the after-block logic in the `change` matcher would never
219
+ # get executed because the `raise "boom"` line would jump to the `rescue` in the
220
+ # `raise_error` logic, so only the former case will work properly.
221
+ #
222
+ # This method figures out which matcher should be the inner matcher and which
223
+ # should be the outer matcher.
224
+ def order_block_matchers
225
+ return @matcher_1, @matcher_2 unless self.class.matcher_expects_call_stack_jump?(@matcher_2)
226
+ return @matcher_2, @matcher_1 unless self.class.matcher_expects_call_stack_jump?(@matcher_1)
227
+
228
+ raise ArgumentError, "(#{@matcher_1.description}) and " \
229
+ "(#{@matcher_2.description}) cannot be combined in a compound expectation " \
230
+ "because they both expect a call stack jump."
231
+ end
232
+
233
+ def self.matcher_expects_call_stack_jump?(matcher)
234
+ matcher.expects_call_stack_jump?
235
+ rescue NoMethodError
236
+ false
237
+ end
238
+ end
239
+
240
+ # @api public
241
+ # Matcher used to represent a compound `and` expectation.
242
+ class And < self
243
+ # @api private
244
+ # @return [String]
245
+ def failure_message
246
+ if matcher_1_matches?
247
+ matcher_2.failure_message
248
+ elsif matcher_2_matches?
249
+ matcher_1.failure_message
250
+ else
251
+ compound_failure_message
252
+ end
253
+ end
254
+
255
+ private
256
+
257
+ def match(*)
258
+ super
259
+ matcher_1_matches? && matcher_2_matches?
260
+ end
261
+
262
+ def conjunction
263
+ "and"
264
+ end
265
+ end
266
+
267
+ # @api public
268
+ # Matcher used to represent a compound `or` expectation.
269
+ class Or < self
270
+ # @api private
271
+ # @return [String]
272
+ def failure_message
273
+ compound_failure_message
274
+ end
275
+
276
+ private
277
+
278
+ def match(*)
279
+ super
280
+ matcher_1_matches? || matcher_2_matches?
281
+ end
282
+
283
+ def conjunction
284
+ "or"
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,310 @@
1
+ module RSpec
2
+ module Matchers
3
+ module BuiltIn
4
+ # rubocop:disable Metrics/ClassLength
5
+ # @api private
6
+ # Provides the implementation for `contain_exactly` and `match_array`.
7
+ # Not intended to be instantiated directly.
8
+ class ContainExactly < BaseMatcher
9
+ # @api private
10
+ # @return [String]
11
+ def failure_message
12
+ if Array === actual
13
+ generate_failure_message
14
+ else
15
+ "expected a collection that can be converted to an array with " \
16
+ "`#to_ary` or `#to_a`, but got #{actual_formatted}"
17
+ end
18
+ end
19
+
20
+ # @api private
21
+ # @return [String]
22
+ def failure_message_when_negated
23
+ list = EnglishPhrasing.list(surface_descriptions_in(expected))
24
+ "expected #{actual_formatted} not to contain exactly#{list}"
25
+ end
26
+
27
+ # @api private
28
+ # @return [String]
29
+ def description
30
+ list = EnglishPhrasing.list(surface_descriptions_in(expected))
31
+ "contain exactly#{list}"
32
+ end
33
+
34
+ def matches?(actual)
35
+ @pairings_maximizer = nil
36
+ @best_solution = nil
37
+ @extra_items = nil
38
+ @missing_items = nil
39
+ super(actual)
40
+ end
41
+
42
+ private
43
+
44
+ def generate_failure_message
45
+ message = expected_collection_line
46
+ message += actual_collection_line
47
+ message += missing_elements_line unless missing_items.empty?
48
+ message += extra_elements_line unless extra_items.empty?
49
+ message
50
+ end
51
+
52
+ def expected_collection_line
53
+ message_line('expected collection contained', expected, true)
54
+ end
55
+
56
+ def actual_collection_line
57
+ message_line('actual collection contained', actual)
58
+ end
59
+
60
+ def missing_elements_line
61
+ message_line('the missing elements were', missing_items, true)
62
+ end
63
+
64
+ def extra_elements_line
65
+ message_line('the extra elements were', extra_items)
66
+ end
67
+
68
+ def describe_collection(collection, surface_descriptions=false)
69
+ if surface_descriptions
70
+ "#{description_of(safe_sort(surface_descriptions_in collection))}\n"
71
+ else
72
+ "#{description_of(safe_sort(collection))}\n"
73
+ end
74
+ end
75
+
76
+ def message_line(prefix, collection, surface_descriptions=false)
77
+ "%-32s%s" % [prefix + ':',
78
+ describe_collection(collection, surface_descriptions)]
79
+ end
80
+
81
+ def match(_expected, _actual)
82
+ return false unless convert_actual_to_an_array
83
+ match_when_sorted? || (extra_items.empty? && missing_items.empty?)
84
+ end
85
+
86
+ # This cannot always work (e.g. when dealing with unsortable items,
87
+ # or matchers as expected items), but it's practically free compared to
88
+ # the slowness of the full matching algorithm, and in common cases this
89
+ # works, so it's worth a try.
90
+ def match_when_sorted?
91
+ values_match?(safe_sort(expected), safe_sort(actual))
92
+ end
93
+
94
+ def convert_actual_to_an_array
95
+ if actual.respond_to?(:to_ary)
96
+ @actual = actual.to_ary
97
+ elsif actual.respond_to?(:to_a) && !to_a_disallowed?(actual)
98
+ @actual = actual.to_a
99
+ else
100
+ false
101
+ end
102
+ end
103
+
104
+ def safe_sort(array)
105
+ array.sort
106
+ rescue Support::AllExceptionsExceptOnesWeMustNotRescue
107
+ array
108
+ end
109
+
110
+ if RUBY_VERSION == "1.8.7"
111
+ def to_a_disallowed?(object)
112
+ case object
113
+ when NilClass, String then true
114
+ else Kernel == RSpec::Support.method_handle_for(object, :to_a).owner
115
+ end
116
+ end
117
+ else
118
+ def to_a_disallowed?(object)
119
+ NilClass === object
120
+ end
121
+ end
122
+
123
+ def missing_items
124
+ @missing_items ||= best_solution.unmatched_expected_indexes.map do |index|
125
+ expected[index]
126
+ end
127
+ end
128
+
129
+ def extra_items
130
+ @extra_items ||= best_solution.unmatched_actual_indexes.map do |index|
131
+ actual[index]
132
+ end
133
+ end
134
+
135
+ def best_solution
136
+ @best_solution ||= pairings_maximizer.find_best_solution
137
+ end
138
+
139
+ def pairings_maximizer
140
+ @pairings_maximizer ||= begin
141
+ expected_matches = Hash[Array.new(expected.size) { |i| [i, []] }]
142
+ actual_matches = Hash[Array.new(actual.size) { |i| [i, []] }]
143
+
144
+ expected.each_with_index do |e, ei|
145
+ actual.each_with_index do |a, ai|
146
+ next unless values_match?(e, a)
147
+
148
+ expected_matches[ei] << ai
149
+ actual_matches[ai] << ei
150
+ end
151
+ end
152
+
153
+ PairingsMaximizer.new(expected_matches, actual_matches)
154
+ end
155
+ end
156
+
157
+ # Once we started supporting composing matchers, the algorithm for this matcher got
158
+ # much more complicated. Consider this expression:
159
+ #
160
+ # expect(["fool", "food"]).to contain_exactly(/foo/, /fool/)
161
+ #
162
+ # This should pass (because we can pair /fool/ with "fool" and /foo/ with "food"), but
163
+ # the original algorithm used by this matcher would pair the first elements it could
164
+ # (/foo/ with "fool"), which would leave /fool/ and "food" unmatched. When we have
165
+ # an expected element which is a matcher that matches a superset of actual items
166
+ # compared to another expected element matcher, we need to consider every possible pairing.
167
+ #
168
+ # This class is designed to maximize the number of actual/expected pairings -- or,
169
+ # conversely, to minimize the number of unpaired items. It's essentially a brute
170
+ # force solution, but with a few heuristics applied to reduce the size of the
171
+ # problem space:
172
+ #
173
+ # * Any items which match none of the items in the other list are immediately
174
+ # placed into the `unmatched_expected_indexes` or `unmatched_actual_indexes` array.
175
+ # The extra items and missing items in the matcher failure message are derived
176
+ # from these arrays.
177
+ # * Any items which reciprocally match only each other are paired up and not
178
+ # considered further.
179
+ #
180
+ # What's left is only the items which match multiple items from the other list
181
+ # (or vice versa). From here, it performs a brute-force depth-first search,
182
+ # looking for a solution which pairs all elements in both lists, or, barring that,
183
+ # that produces the fewest unmatched items.
184
+ #
185
+ # @private
186
+ class PairingsMaximizer
187
+ # @private
188
+ Solution = Struct.new(:unmatched_expected_indexes, :unmatched_actual_indexes,
189
+ :indeterminate_expected_indexes, :indeterminate_actual_indexes) do
190
+ def worse_than?(other)
191
+ unmatched_item_count > other.unmatched_item_count
192
+ end
193
+
194
+ def candidate?
195
+ indeterminate_expected_indexes.empty? &&
196
+ indeterminate_actual_indexes.empty?
197
+ end
198
+
199
+ def ideal?
200
+ candidate? && (
201
+ unmatched_expected_indexes.empty? ||
202
+ unmatched_actual_indexes.empty?
203
+ )
204
+ end
205
+
206
+ def unmatched_item_count
207
+ unmatched_expected_indexes.count + unmatched_actual_indexes.count
208
+ end
209
+
210
+ def +(derived_candidate_solution)
211
+ self.class.new(
212
+ unmatched_expected_indexes + derived_candidate_solution.unmatched_expected_indexes,
213
+ unmatched_actual_indexes + derived_candidate_solution.unmatched_actual_indexes,
214
+ # Ignore the indeterminate indexes: by the time we get here,
215
+ # we've dealt with all indeterminates.
216
+ [], []
217
+ )
218
+ end
219
+ end
220
+
221
+ attr_reader :expected_to_actual_matched_indexes, :actual_to_expected_matched_indexes, :solution
222
+
223
+ def initialize(expected_to_actual_matched_indexes, actual_to_expected_matched_indexes)
224
+ @expected_to_actual_matched_indexes = expected_to_actual_matched_indexes
225
+ @actual_to_expected_matched_indexes = actual_to_expected_matched_indexes
226
+
227
+ unmatched_expected_indexes, indeterminate_expected_indexes =
228
+ categorize_indexes(expected_to_actual_matched_indexes, actual_to_expected_matched_indexes)
229
+
230
+ unmatched_actual_indexes, indeterminate_actual_indexes =
231
+ categorize_indexes(actual_to_expected_matched_indexes, expected_to_actual_matched_indexes)
232
+
233
+ @solution = Solution.new(unmatched_expected_indexes, unmatched_actual_indexes,
234
+ indeterminate_expected_indexes, indeterminate_actual_indexes)
235
+ end
236
+
237
+ def find_best_solution
238
+ return solution if solution.candidate?
239
+ best_solution_so_far = NullSolution
240
+
241
+ expected_index = solution.indeterminate_expected_indexes.first
242
+ actuals = expected_to_actual_matched_indexes[expected_index]
243
+
244
+ actuals.each do |actual_index|
245
+ solution = best_solution_for_pairing(expected_index, actual_index)
246
+ return solution if solution.ideal?
247
+ best_solution_so_far = solution if best_solution_so_far.worse_than?(solution)
248
+ end
249
+
250
+ best_solution_so_far
251
+ end
252
+
253
+ private
254
+
255
+ # @private
256
+ # Starting solution that is worse than any other real solution.
257
+ NullSolution = Class.new do
258
+ def self.worse_than?(_other)
259
+ true
260
+ end
261
+ end
262
+
263
+ def categorize_indexes(indexes_to_categorize, other_indexes)
264
+ unmatched = []
265
+ indeterminate = []
266
+
267
+ indexes_to_categorize.each_pair do |index, matches|
268
+ if matches.empty?
269
+ unmatched << index
270
+ elsif !reciprocal_single_match?(matches, index, other_indexes)
271
+ indeterminate << index
272
+ end
273
+ end
274
+
275
+ return unmatched, indeterminate
276
+ end
277
+
278
+ def reciprocal_single_match?(matches, index, other_list)
279
+ return false unless matches.one?
280
+ other_list[matches.first] == [index]
281
+ end
282
+
283
+ def best_solution_for_pairing(expected_index, actual_index)
284
+ modified_expecteds = apply_pairing_to(
285
+ solution.indeterminate_expected_indexes,
286
+ expected_to_actual_matched_indexes, actual_index)
287
+
288
+ modified_expecteds.delete(expected_index)
289
+
290
+ modified_actuals = apply_pairing_to(
291
+ solution.indeterminate_actual_indexes,
292
+ actual_to_expected_matched_indexes, expected_index)
293
+
294
+ modified_actuals.delete(actual_index)
295
+
296
+ solution + self.class.new(modified_expecteds, modified_actuals).find_best_solution
297
+ end
298
+
299
+ def apply_pairing_to(indeterminates, original_matches, other_list_index)
300
+ indeterminates.inject({}) do |accum, index|
301
+ accum[index] = original_matches[index] - [other_list_index]
302
+ accum
303
+ end
304
+ end
305
+ end
306
+ end
307
+ # rubocop:enable Metrics/ClassLength
308
+ end
309
+ end
310
+ end