diff --git a/src/spectator/dsl/matchers.cr b/src/spectator/dsl/matchers.cr index 4eb83e8..784426a 100644 --- a/src/spectator/dsl/matchers.cr +++ b/src/spectator/dsl/matchers.cr @@ -520,22 +520,23 @@ module Spectator have_value({{expected}}) end - # Indicates that some set should contain some values in exact order. + # Indicates that some set should contain some values in any order. # # Example: # ``` - # expect([1, 2, 3]).to contain_exactly(1, 2, 3) + # expect([1, 2, 3]).to contain_exactly(3, 2, 1) # ``` macro contain_exactly(*expected) %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) ::Spectator::Matchers::ArrayMatcher.new(%test_value) end - # Indicates that some set should contain the same values in exact order as another set. + # Indicates that some set should contain the same values in any order as another set. + # This is the same as `#contain_exactly`, but takes an array as an argument. # # Example: # ``` - # expect([1, 2, 3]).to match_array([1, 2, 3]) + # expect([1, 2, 3]).to match_array([3, 2, 1]) # ``` macro match_array(expected) %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) diff --git a/src/spectator/matchers/array_matcher.cr b/src/spectator/matchers/array_matcher.cr index 8144db5..92fb01a 100644 --- a/src/spectator/matchers/array_matcher.cr +++ b/src/spectator/matchers/array_matcher.cr @@ -5,7 +5,7 @@ require "./unordered_array_matcher" module Spectator::Matchers # Matcher for checking that the contents of one array (or similar type) - # has the exact same contents as another and in the same order. + # has the exact same contents as another but may be in any order. struct ArrayMatcher(ExpectedType) < Matcher # Expected value and label. private getter expected @@ -25,15 +25,19 @@ module Spectator::Matchers def match(actual : TestExpression(T)) : MatchData forall T actual_elements = actual.value.to_a expected_elements = expected.value.to_a - index = compare_arrays(expected_elements, actual_elements) + missing, extra = compare_arrays(expected_elements, actual_elements) - case index - when Int # Content differs. - failed_content_mismatch(expected_elements, actual_elements, index, actual.label) - when true # Contents are identical. + if missing.empty? && extra.empty? + # Contents are identical. SuccessfulMatchData.new(description) - else # Size differs. - failed_size_mismatch(expected_elements, actual_elements, actual.label) + else + # Content differs. + FailedMatchData.new(description, "#{actual.label} does not contain exactly #{expected.label}", + expected: expected_elements.inspect, + actual: actual_elements.inspect, + missing: missing.empty? ? "None" : missing.inspect, + extra: extra.empty? ? "None" : extra.inspect + ) end end @@ -42,13 +46,16 @@ module Spectator::Matchers def negated_match(actual : TestExpression(T)) : MatchData forall T actual_elements = actual.value.to_a expected_elements = expected.value.to_a + missing, extra = compare_arrays(expected_elements, actual_elements) - case compare_arrays(expected_elements, actual_elements) - when Int # Contents differ. - SuccessfulMatchData.new(description) - when true # Contents are identical. - failed_content_identical(expected_elements, actual_elements, actual.label) - else # Size differs. + if missing.empty? && extra.empty? + # Contents are identical. + FailedMatchData.new(description, "#{actual.label} contains exactly #{expected.label}", + expected: "Not #{expected_elements.inspect}", + actual: actual_elements.inspect + ) + else + # Content differs. SuccessfulMatchData.new(description) end end @@ -65,49 +72,41 @@ module Spectator::Matchers UnorderedArrayMatcher.new(expected) end - # Compares two arrays to determine whether they contain the same elements, and in the same order. - # If the arrays are the same, then `true` is returned. - # If they are different, `false` or an integer is returned. - # `false` is returned when the sizes of the arrays don't match. - # An integer is returned, that is the index of the mismatched elements in the arrays. + # Compares two arrays to determine whether they contain the same elements, but in any order. + # A tuple of two arrays is returned. + # The first array is the missing elements (present in expected, missing in actual). + # The second array array is the extra elements (not present in expected, present in actual). private def compare_arrays(expected_elements, actual_elements) - if expected_elements.size == actual_elements.size - index = 0 - expected_elements.zip(actual_elements) do |expected_element, actual_element| - return index unless expected_element == actual_element - index += 1 + # Produce hashes where the array elements are the keys, and the values are the number of occurances. + expected_hash = expected_elements.group_by(&.itself).map { |k, v| {k, v.size} }.to_h + actual_hash = actual_elements.group_by(&.itself).map { |k, v| {k, v.size} }.to_h + + { + hash_count_difference(expected_hash, actual_hash), + hash_count_difference(actual_hash, expected_hash), + } + end + + # Expects two hashes, with values as counts for keys. + # Produces an array of differences with elements repeated if needed. + private def hash_count_difference(first, second) + # Subtract the number of occurances from the other array. + # A duplicate hash is used here because the original can't be modified, + # since it there's a two-way comparison. + # + # Then reject elements that have zero (or less) occurances. + # Lastly, expand to the correct number of elements. + first.map do |element, count| + if second_count = second[element]? + {element, count - second_count} + else + {element, count} end - true - else - false - end - end - - # Produces match data for a failure when the array sizes differ. - private def failed_size_mismatch(expected_elements, actual_elements, actual_label) - FailedMatchData.new(description, "#{actual_label} does not contain exactly #{expected.label} (size mismatch)", - expected: expected_elements.inspect, - actual: actual_elements.inspect, - "expected size": expected_elements.size.to_s, - "actual size": actual_elements.size.to_s - ) - end - - # Produces match data for a failure when the array content is mismatched. - private def failed_content_mismatch(expected_elements, actual_elements, index, actual_label) - FailedMatchData.new(description, "#{actual_label} does not contain exactly #{expected.label} (element mismatch)", - expected: expected_elements[index].inspect, - actual: actual_elements[index].inspect, - index: index.to_s - ) - end - - # Produces match data for a failure when the arrays are identical, but they shouldn't be (negation). - private def failed_content_identical(expected_elements, actual_elements, actual_label) - FailedMatchData.new(description, "#{actual_label} contains exactly #{expected.label}", - expected: "Not #{expected_elements.inspect}", - actual: actual_elements.inspect - ) + end.reject do |(_, count)| + count <= 0 + end.map do |(element, count)| + Array.new(count, element) + end.flatten end end end