rspec-expectations 2.11.3 → 3.11.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 (152) 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 +1026 -21
  6. data/{License.txt → LICENSE.md} +5 -3
  7. data/README.md +174 -78
  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 +130 -55
  11. data/lib/rspec/expectations/fail_with.rb +17 -33
  12. data/lib/rspec/expectations/failure_aggregator.rb +212 -0
  13. data/lib/rspec/expectations/handler.rb +163 -29
  14. data/lib/rspec/expectations/minitest_integration.rb +58 -0
  15. data/lib/rspec/expectations/syntax.rb +68 -54
  16. data/lib/rspec/expectations/version.rb +1 -1
  17. data/lib/rspec/expectations.rb +59 -24
  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 +150 -20
  21. data/lib/rspec/matchers/built_in/be.rb +115 -109
  22. data/lib/rspec/matchers/built_in/be_between.rb +77 -0
  23. data/lib/rspec/matchers/built_in/be_instance_of.rb +16 -1
  24. data/lib/rspec/matchers/built_in/be_kind_of.rb +10 -1
  25. data/lib/rspec/matchers/built_in/be_within.rb +43 -17
  26. data/lib/rspec/matchers/built_in/change.rb +392 -75
  27. data/lib/rspec/matchers/built_in/compound.rb +290 -0
  28. data/lib/rspec/matchers/built_in/contain_exactly.rb +302 -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 +26 -8
  32. data/lib/rspec/matchers/built_in/eql.rb +19 -8
  33. data/lib/rspec/matchers/built_in/equal.rb +56 -19
  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 +175 -20
  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 +212 -38
  42. data/lib/rspec/matchers/built_in/respond_to.rb +155 -29
  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 +252 -98
  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 +530 -10
  50. data/lib/rspec/matchers/english_phrasing.rb +58 -0
  51. data/lib/rspec/matchers/expecteds_for_multiple_diffs.rb +82 -0
  52. data/lib/rspec/matchers/fail_matchers.rb +42 -0
  53. data/lib/rspec/matchers/generated_descriptions.rb +15 -10
  54. data/lib/rspec/matchers/matcher_delegator.rb +35 -0
  55. data/lib/rspec/matchers/matcher_protocol.rb +105 -0
  56. data/lib/rspec/matchers.rb +604 -252
  57. data.tar.gz.sig +0 -0
  58. metadata +178 -278
  59. metadata.gz.sig +0 -0
  60. data/features/README.md +0 -49
  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 -173
  64. data/features/built_in_matchers/be_within.feature +0 -46
  65. data/features/built_in_matchers/cover.feature +0 -45
  66. data/features/built_in_matchers/end_with.feature +0 -46
  67. data/features/built_in_matchers/equality.feature +0 -145
  68. data/features/built_in_matchers/exist.feature +0 -43
  69. data/features/built_in_matchers/expect_change.feature +0 -59
  70. data/features/built_in_matchers/expect_error.feature +0 -138
  71. data/features/built_in_matchers/have.feature +0 -103
  72. data/features/built_in_matchers/include.feature +0 -121
  73. data/features/built_in_matchers/match.feature +0 -50
  74. data/features/built_in_matchers/operators.feature +0 -221
  75. data/features/built_in_matchers/predicates.feature +0 -128
  76. data/features/built_in_matchers/respond_to.feature +0 -78
  77. data/features/built_in_matchers/satisfy.feature +0 -31
  78. data/features/built_in_matchers/start_with.feature +0 -46
  79. data/features/built_in_matchers/throw_symbol.feature +0 -85
  80. data/features/built_in_matchers/types.feature +0 -114
  81. data/features/built_in_matchers/yield.feature +0 -146
  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 -340
  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 -5
  92. data/features/syntax_configuration.feature +0 -68
  93. data/features/test_frameworks/test_unit.feature +0 -46
  94. data/lib/rspec/expectations/deprecation.rb +0 -38
  95. data/lib/rspec/expectations/differ.rb +0 -81
  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 -39
  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 -108
  102. data/lib/rspec/matchers/built_in/match_array.rb +0 -45
  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 -66
  106. data/lib/rspec/matchers/extensions/instance_eval_with_args.rb +0 -39
  107. data/lib/rspec/matchers/matcher.rb +0 -299
  108. data/lib/rspec/matchers/method_missing.rb +0 -12
  109. data/lib/rspec/matchers/operator_matcher.rb +0 -84
  110. data/lib/rspec/matchers/pretty.rb +0 -60
  111. data/lib/rspec-expectations.rb +0 -1
  112. data/spec/rspec/expectations/differ_spec.rb +0 -153
  113. data/spec/rspec/expectations/expectation_target_spec.rb +0 -65
  114. data/spec/rspec/expectations/extensions/kernel_spec.rb +0 -67
  115. data/spec/rspec/expectations/fail_with_spec.rb +0 -70
  116. data/spec/rspec/expectations/handler_spec.rb +0 -206
  117. data/spec/rspec/matchers/base_matcher_spec.rb +0 -60
  118. data/spec/rspec/matchers/be_close_spec.rb +0 -22
  119. data/spec/rspec/matchers/be_instance_of_spec.rb +0 -40
  120. data/spec/rspec/matchers/be_kind_of_spec.rb +0 -37
  121. data/spec/rspec/matchers/be_spec.rb +0 -452
  122. data/spec/rspec/matchers/be_within_spec.rb +0 -80
  123. data/spec/rspec/matchers/change_spec.rb +0 -528
  124. data/spec/rspec/matchers/configuration_spec.rb +0 -202
  125. data/spec/rspec/matchers/cover_spec.rb +0 -69
  126. data/spec/rspec/matchers/description_generation_spec.rb +0 -176
  127. data/spec/rspec/matchers/dsl_spec.rb +0 -57
  128. data/spec/rspec/matchers/eq_spec.rb +0 -54
  129. data/spec/rspec/matchers/eql_spec.rb +0 -41
  130. data/spec/rspec/matchers/equal_spec.rb +0 -60
  131. data/spec/rspec/matchers/exist_spec.rb +0 -110
  132. data/spec/rspec/matchers/has_spec.rb +0 -118
  133. data/spec/rspec/matchers/have_spec.rb +0 -461
  134. data/spec/rspec/matchers/include_spec.rb +0 -367
  135. data/spec/rspec/matchers/match_array_spec.rb +0 -124
  136. data/spec/rspec/matchers/match_spec.rb +0 -61
  137. data/spec/rspec/matchers/matcher_spec.rb +0 -434
  138. data/spec/rspec/matchers/matchers_spec.rb +0 -31
  139. data/spec/rspec/matchers/method_missing_spec.rb +0 -24
  140. data/spec/rspec/matchers/operator_matcher_spec.rb +0 -221
  141. data/spec/rspec/matchers/raise_error_spec.rb +0 -344
  142. data/spec/rspec/matchers/respond_to_spec.rb +0 -295
  143. data/spec/rspec/matchers/satisfy_spec.rb +0 -44
  144. data/spec/rspec/matchers/start_with_end_with_spec.rb +0 -182
  145. data/spec/rspec/matchers/throw_symbol_spec.rb +0 -116
  146. data/spec/rspec/matchers/yield_spec.rb +0 -402
  147. data/spec/spec_helper.rb +0 -27
  148. data/spec/support/classes.rb +0 -56
  149. data/spec/support/in_sub_process.rb +0 -31
  150. data/spec/support/matchers.rb +0 -22
  151. data/spec/support/ruby_version.rb +0 -10
  152. 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::ExpectedsForMultipleDiffs]
55
+ def expected
56
+ return nil unless evaluator
57
+ ::RSpec::Matchers::ExpectedsForMultipleDiffs.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. Prehaps 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,302 @@
1
+ module RSpec
2
+ module Matchers
3
+ module BuiltIn
4
+ # rubocop:disable 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
+ private
35
+
36
+ def generate_failure_message
37
+ message = expected_collection_line
38
+ message += actual_collection_line
39
+ message += missing_elements_line unless missing_items.empty?
40
+ message += extra_elements_line unless extra_items.empty?
41
+ message
42
+ end
43
+
44
+ def expected_collection_line
45
+ message_line('expected collection contained', expected, true)
46
+ end
47
+
48
+ def actual_collection_line
49
+ message_line('actual collection contained', actual)
50
+ end
51
+
52
+ def missing_elements_line
53
+ message_line('the missing elements were', missing_items, true)
54
+ end
55
+
56
+ def extra_elements_line
57
+ message_line('the extra elements were', extra_items)
58
+ end
59
+
60
+ def describe_collection(collection, surface_descriptions=false)
61
+ if surface_descriptions
62
+ "#{description_of(safe_sort(surface_descriptions_in collection))}\n"
63
+ else
64
+ "#{description_of(safe_sort(collection))}\n"
65
+ end
66
+ end
67
+
68
+ def message_line(prefix, collection, surface_descriptions=false)
69
+ "%-32s%s" % [prefix + ':',
70
+ describe_collection(collection, surface_descriptions)]
71
+ end
72
+
73
+ def match(_expected, _actual)
74
+ return false unless convert_actual_to_an_array
75
+ match_when_sorted? || (extra_items.empty? && missing_items.empty?)
76
+ end
77
+
78
+ # This cannot always work (e.g. when dealing with unsortable items,
79
+ # or matchers as expected items), but it's practically free compared to
80
+ # the slowness of the full matching algorithm, and in common cases this
81
+ # works, so it's worth a try.
82
+ def match_when_sorted?
83
+ values_match?(safe_sort(expected), safe_sort(actual))
84
+ end
85
+
86
+ def convert_actual_to_an_array
87
+ if actual.respond_to?(:to_ary)
88
+ @actual = actual.to_ary
89
+ elsif actual.respond_to?(:to_a) && !to_a_disallowed?(actual)
90
+ @actual = actual.to_a
91
+ else
92
+ false
93
+ end
94
+ end
95
+
96
+ def safe_sort(array)
97
+ array.sort
98
+ rescue Support::AllExceptionsExceptOnesWeMustNotRescue
99
+ array
100
+ end
101
+
102
+ if RUBY_VERSION == "1.8.7"
103
+ def to_a_disallowed?(object)
104
+ case object
105
+ when NilClass, String then true
106
+ else Kernel == RSpec::Support.method_handle_for(object, :to_a).owner
107
+ end
108
+ end
109
+ else
110
+ def to_a_disallowed?(object)
111
+ NilClass === object
112
+ end
113
+ end
114
+
115
+ def missing_items
116
+ @missing_items ||= best_solution.unmatched_expected_indexes.map do |index|
117
+ expected[index]
118
+ end
119
+ end
120
+
121
+ def extra_items
122
+ @extra_items ||= best_solution.unmatched_actual_indexes.map do |index|
123
+ actual[index]
124
+ end
125
+ end
126
+
127
+ def best_solution
128
+ @best_solution ||= pairings_maximizer.find_best_solution
129
+ end
130
+
131
+ def pairings_maximizer
132
+ @pairings_maximizer ||= begin
133
+ expected_matches = Hash[Array.new(expected.size) { |i| [i, []] }]
134
+ actual_matches = Hash[Array.new(actual.size) { |i| [i, []] }]
135
+
136
+ expected.each_with_index do |e, ei|
137
+ actual.each_with_index do |a, ai|
138
+ next unless values_match?(e, a)
139
+
140
+ expected_matches[ei] << ai
141
+ actual_matches[ai] << ei
142
+ end
143
+ end
144
+
145
+ PairingsMaximizer.new(expected_matches, actual_matches)
146
+ end
147
+ end
148
+
149
+ # Once we started supporting composing matchers, the algorithm for this matcher got
150
+ # much more complicated. Consider this expression:
151
+ #
152
+ # expect(["fool", "food"]).to contain_exactly(/foo/, /fool/)
153
+ #
154
+ # This should pass (because we can pair /fool/ with "fool" and /foo/ with "food"), but
155
+ # the original algorithm used by this matcher would pair the first elements it could
156
+ # (/foo/ with "fool"), which would leave /fool/ and "food" unmatched. When we have
157
+ # an expected element which is a matcher that matches a superset of actual items
158
+ # compared to another expected element matcher, we need to consider every possible pairing.
159
+ #
160
+ # This class is designed to maximize the number of actual/expected pairings -- or,
161
+ # conversely, to minimize the number of unpaired items. It's essentially a brute
162
+ # force solution, but with a few heuristics applied to reduce the size of the
163
+ # problem space:
164
+ #
165
+ # * Any items which match none of the items in the other list are immediately
166
+ # placed into the `unmatched_expected_indexes` or `unmatched_actual_indexes` array.
167
+ # The extra items and missing items in the matcher failure message are derived
168
+ # from these arrays.
169
+ # * Any items which reciprocally match only each other are paired up and not
170
+ # considered further.
171
+ #
172
+ # What's left is only the items which match multiple items from the other list
173
+ # (or vice versa). From here, it performs a brute-force depth-first search,
174
+ # looking for a solution which pairs all elements in both lists, or, barring that,
175
+ # that produces the fewest unmatched items.
176
+ #
177
+ # @private
178
+ class PairingsMaximizer
179
+ # @private
180
+ Solution = Struct.new(:unmatched_expected_indexes, :unmatched_actual_indexes,
181
+ :indeterminate_expected_indexes, :indeterminate_actual_indexes) do
182
+ def worse_than?(other)
183
+ unmatched_item_count > other.unmatched_item_count
184
+ end
185
+
186
+ def candidate?
187
+ indeterminate_expected_indexes.empty? &&
188
+ indeterminate_actual_indexes.empty?
189
+ end
190
+
191
+ def ideal?
192
+ candidate? && (
193
+ unmatched_expected_indexes.empty? ||
194
+ unmatched_actual_indexes.empty?
195
+ )
196
+ end
197
+
198
+ def unmatched_item_count
199
+ unmatched_expected_indexes.count + unmatched_actual_indexes.count
200
+ end
201
+
202
+ def +(derived_candidate_solution)
203
+ self.class.new(
204
+ unmatched_expected_indexes + derived_candidate_solution.unmatched_expected_indexes,
205
+ unmatched_actual_indexes + derived_candidate_solution.unmatched_actual_indexes,
206
+ # Ignore the indeterminate indexes: by the time we get here,
207
+ # we've dealt with all indeterminates.
208
+ [], []
209
+ )
210
+ end
211
+ end
212
+
213
+ attr_reader :expected_to_actual_matched_indexes, :actual_to_expected_matched_indexes, :solution
214
+
215
+ def initialize(expected_to_actual_matched_indexes, actual_to_expected_matched_indexes)
216
+ @expected_to_actual_matched_indexes = expected_to_actual_matched_indexes
217
+ @actual_to_expected_matched_indexes = actual_to_expected_matched_indexes
218
+
219
+ unmatched_expected_indexes, indeterminate_expected_indexes =
220
+ categorize_indexes(expected_to_actual_matched_indexes, actual_to_expected_matched_indexes)
221
+
222
+ unmatched_actual_indexes, indeterminate_actual_indexes =
223
+ categorize_indexes(actual_to_expected_matched_indexes, expected_to_actual_matched_indexes)
224
+
225
+ @solution = Solution.new(unmatched_expected_indexes, unmatched_actual_indexes,
226
+ indeterminate_expected_indexes, indeterminate_actual_indexes)
227
+ end
228
+
229
+ def find_best_solution
230
+ return solution if solution.candidate?
231
+ best_solution_so_far = NullSolution
232
+
233
+ expected_index = solution.indeterminate_expected_indexes.first
234
+ actuals = expected_to_actual_matched_indexes[expected_index]
235
+
236
+ actuals.each do |actual_index|
237
+ solution = best_solution_for_pairing(expected_index, actual_index)
238
+ return solution if solution.ideal?
239
+ best_solution_so_far = solution if best_solution_so_far.worse_than?(solution)
240
+ end
241
+
242
+ best_solution_so_far
243
+ end
244
+
245
+ private
246
+
247
+ # @private
248
+ # Starting solution that is worse than any other real solution.
249
+ NullSolution = Class.new do
250
+ def self.worse_than?(_other)
251
+ true
252
+ end
253
+ end
254
+
255
+ def categorize_indexes(indexes_to_categorize, other_indexes)
256
+ unmatched = []
257
+ indeterminate = []
258
+
259
+ indexes_to_categorize.each_pair do |index, matches|
260
+ if matches.empty?
261
+ unmatched << index
262
+ elsif !reciprocal_single_match?(matches, index, other_indexes)
263
+ indeterminate << index
264
+ end
265
+ end
266
+
267
+ return unmatched, indeterminate
268
+ end
269
+
270
+ def reciprocal_single_match?(matches, index, other_list)
271
+ return false unless matches.one?
272
+ other_list[matches.first] == [index]
273
+ end
274
+
275
+ def best_solution_for_pairing(expected_index, actual_index)
276
+ modified_expecteds = apply_pairing_to(
277
+ solution.indeterminate_expected_indexes,
278
+ expected_to_actual_matched_indexes, actual_index)
279
+
280
+ modified_expecteds.delete(expected_index)
281
+
282
+ modified_actuals = apply_pairing_to(
283
+ solution.indeterminate_actual_indexes,
284
+ actual_to_expected_matched_indexes, expected_index)
285
+
286
+ modified_actuals.delete(actual_index)
287
+
288
+ solution + self.class.new(modified_expecteds, modified_actuals).find_best_solution
289
+ end
290
+
291
+ def apply_pairing_to(indeterminates, original_matches, other_list_index)
292
+ indeterminates.inject({}) do |accum, index|
293
+ accum[index] = original_matches[index] - [other_list_index]
294
+ accum
295
+ end
296
+ end
297
+ end
298
+ end
299
+ # rubocop:enable ClassLength
300
+ end
301
+ end
302
+ end