From 19d52ff02bfb5632d73abb250046c6ee06ab2760 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 12 Jun 2019 15:35:33 -0600 Subject: [PATCH] Add unordered array matcher --- spec/matchers/array_matcher_spec.cr | 23 + spec/matchers/unordered_array_matcher_spec.cr | 474 ++++++++++++++++++ src/spectator/matchers/array_matcher.cr | 12 + .../matchers/unordered_array_matcher.cr | 114 +++++ 4 files changed, 623 insertions(+) create mode 100644 spec/matchers/unordered_array_matcher_spec.cr create mode 100644 src/spectator/matchers/unordered_array_matcher.cr diff --git a/spec/matchers/array_matcher_spec.cr b/spec/matchers/array_matcher_spec.cr index 5f89e7a..9376ccf 100644 --- a/spec/matchers/array_matcher_spec.cr +++ b/spec/matchers/array_matcher_spec.cr @@ -1,6 +1,29 @@ require "../spec_helper" describe Spectator::Matchers::ArrayMatcher do + describe "#in_any_order" do + it "returns an unordered matcher" do + array = %i[x y z] + matcher = Spectator::Matchers::ArrayMatcher.new(array) + matcher.in_any_order.should be_a(Spectator::Matchers::UnorderedArrayMatcher(Symbol)) + end + + it "maintains the expected array" do + array = %i[x y z] + matcher = Spectator::Matchers::ArrayMatcher.new(array) + unordered_matcher = matcher.in_any_order + unordered_matcher.expected.should eq(array) + end + + it "maintains the expected label" do + array = %i[x y z] + label = "some_array" + matcher = Spectator::Matchers::ArrayMatcher.new(array, label) + unordered_matcher = matcher.in_any_order + unordered_matcher.label.should eq(label) + end + end + describe "#match" do context "returned MatchData" do context "with identical arrays" do diff --git a/spec/matchers/unordered_array_matcher_spec.cr b/spec/matchers/unordered_array_matcher_spec.cr new file mode 100644 index 0000000..5cea6f6 --- /dev/null +++ b/spec/matchers/unordered_array_matcher_spec.cr @@ -0,0 +1,474 @@ +require "../spec_helper" + +describe Spectator::Matchers::UnorderedArrayMatcher do + describe "#match" do + context "returned MatchData" do + context "with identical arrays" do + describe "#matched?" do + it "is true" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array) + match_data = matcher.match(partial) + match_data.matched?.should be_true + end + end + + describe "#values" do + context "expected" do + it "is the expected array" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array) + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :expected)[:value].should eq(array) + end + end + + context "actual" do + it "is the actual array" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array) + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :actual)[:value].should eq(array) + end + end + end + + describe "#message" do + it "contains the actual label" do + array = %i[a b c] + label = "everything" + partial = new_partial(array, label) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + it "contains the expected label" do + array = %i[a b c] + label = "everything" + partial = new_partial(array) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array, label) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + context "when expected label is omitted" do + it "contains stringified form of expected array" do + array1 = %i[a b c] + array2 = [1, 2, 3] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.message.should contain(array2.to_s) + end + end + end + + describe "#negated_message" do + it "contains the actual label" do + array = %i[a b c] + label = "everything" + partial = new_partial(array, label) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + it "contains the expected label" do + array = %i[a b c] + label = "everything" + partial = new_partial(array) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array, label) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + context "when expected label is omitted" do + it "contains stringified form of expected array" do + array1 = %i[a b c] + array2 = [1, 2, 3] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.negated_message.should contain(array2.to_s) + end + end + end + end + + context "with identical unordered arrays" do + describe "#matched?" do + it "is true" do + array1 = %i[a b c] + array2 = %i[c a b] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.matched?.should be_true + end + end + + describe "#values" do + context "expected" do + it "is the expected array" do + array1 = %i[a b c] + array2 = %i[c a b] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :expected)[:value].should eq(array2) + end + end + + context "actual" do + it "is the actual array" do + array1 = %i[a b c] + array2 = %i[c a b] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :actual)[:value].should eq(array1) + end + end + end + + describe "#message" do + it "contains the actual label" do + array1 = %i[a b c] + array2 = %i[c a b] + label = "everything" + partial = new_partial(array1, label) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + it "contains the expected label" do + array1 = %i[a b c] + array2 = %i[c a b] + label = "everything" + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2, label) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + context "when expected label is omitted" do + it "contains stringified form of expected array" do + array1 = %i[a b c] + array2 = %i[c a b] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.message.should contain(array2.to_s) + end + end + end + + describe "#negated_message" do + it "contains the actual label" do + array1 = %i[a b c] + array2 = %i[c a b] + label = "everything" + partial = new_partial(array1, label) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + it "contains the expected label" do + array1 = %i[a b c] + array2 = %i[c a b] + label = "everything" + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2, label) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + context "when expected label is omitted" do + it "contains stringified form of expected array" do + array1 = %i[a b c] + array2 = %i[c a b] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.negated_message.should contain(array2.to_s) + end + end + end + end + + context "with arrays differing in size" do + describe "#matched?" do + it "is false" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.matched?.should be_false + end + end + + describe "#values" do + context "expected" do + it "is the expected array" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :expected)[:value].should eq(array2) + end + end + + context "actual" do + it "is the actual array" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :actual)[:value].should eq(array1) + end + end + + context "missing" do + it "is the missing items" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + missing = match_data_value_sans_prefix(match_data.values, :missing)[:value].as(typeof(array2)) + missing.should contain(:f) + end + end + + context "extra" do + it "is the extra items" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + extra = match_data_value_sans_prefix(match_data.values, :extra)[:value].as(typeof(array1)) + extra.should contain(:b) + extra.should contain(:d) + end + end + end + + describe "#message" do + it "contains the actual label" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + label = "everything" + partial = new_partial(array1, label) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + it "contains the expected label" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + label = "everything" + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2, label) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + context "when expected label is omitted" do + it "contains stringified form of expected array" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.message.should contain(array2.to_s) + end + end + end + + describe "#negated_message" do + it "contains the actual label" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + label = "everything" + partial = new_partial(array1, label) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + it "contains the expected label" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + label = "everything" + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2, label) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + context "when expected label is omitted" do + it "contains stringified form of expected array" do + array1 = %i[a b c d e] + array2 = %i[a c e f] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.negated_message.should contain(array2.to_s) + end + end + end + end + + context "with arrays differing in content" do + describe "#matched?" do + it "is false" do + array1 = %i[a b c] + array2 = %i[x y z] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.matched?.should be_false + end + end + + describe "#values" do + context "expected" do + it "is the expected array" do + array1 = %i[a b c] + array2 = %i[x y z] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :expected)[:value].should eq(array2) + end + end + + context "actual" do + it "is the actual array" do + array1 = %i[a b c] + array2 = %i[x y z] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :actual)[:value].should eq(array1) + end + end + + context "missing" do + it "is the missing items" do + array1 = %i[a b c] + array2 = %i[x y z] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + missing = match_data_value_sans_prefix(match_data.values, :missing)[:value].as(typeof(array2)) + missing.should contain(:x) + missing.should contain(:y) + missing.should contain(:z) + end + end + + context "extra" do + it "is the extra items" do + array1 = %i[a b c] + array2 = %i[x y z] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + extra = match_data_value_sans_prefix(match_data.values, :extra)[:value].as(typeof(array1)) + extra.should contain(:a) + extra.should contain(:b) + extra.should contain(:c) + end + end + end + + describe "#message" do + it "contains the actual label" do + array1 = %i[a b c] + array2 = %i[x y z] + label = "everything" + partial = new_partial(array1, label) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + it "contains the expected label" do + array1 = %i[a b c] + array2 = %i[x y z] + label = "everything" + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2, label) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + context "when expected label is omitted" do + it "contains stringified form of expected array" do + array1 = %i[a b c] + array2 = %i[x y z] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.message.should contain(array2.to_s) + end + end + end + + describe "#negated_message" do + it "mentions content" do + array1 = %i[a b c] + array2 = %i[x y z] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.negated_message.should contain("content") + end + + it "contains the actual label" do + array1 = %i[a b c] + array2 = %i[x y z] + label = "everything" + partial = new_partial(array1, label) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + it "contains the expected label" do + array1 = %i[a b c] + array2 = %i[x y z] + label = "everything" + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2, label) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + context "when expected label is omitted" do + it "contains stringified form of expected array" do + array1 = %i[a b c] + array2 = %i[x y z] + partial = new_partial(array1) + matcher = Spectator::Matchers::UnorderedArrayMatcher.new(array2) + match_data = matcher.match(partial) + match_data.negated_message.should contain(array2.to_s) + end + end + end + end + end + end +end diff --git a/src/spectator/matchers/array_matcher.cr b/src/spectator/matchers/array_matcher.cr index 03a3b23..834d8c8 100644 --- a/src/spectator/matchers/array_matcher.cr +++ b/src/spectator/matchers/array_matcher.cr @@ -1,4 +1,5 @@ require "./value_matcher" +require "./unordered_array_matcher" module Spectator::Matchers # Matcher for checking that the contents of one array (or similar type) @@ -35,6 +36,17 @@ module Spectator::Matchers super end + # Returns a matcher that uses the same expected array, but allows unordered items. + def in_any_order + UnorderedArrayMatcher.new(@expected, @label) + end + + # Returns self. + # Exists for syntax to ensure in-order matching is performed. + def in_order + self + end + # Common functionality for all match data for this matcher. private abstract struct CommonMatchData(ExpectedType, ActualType) < MatchData # Creates the match data. diff --git a/src/spectator/matchers/unordered_array_matcher.cr b/src/spectator/matchers/unordered_array_matcher.cr new file mode 100644 index 0000000..c58443a --- /dev/null +++ b/src/spectator/matchers/unordered_array_matcher.cr @@ -0,0 +1,114 @@ +require "./value_matcher" + +module Spectator::Matchers + # Matcher for checking that the contents of one array (or similar type) + # has the exact same contents as another, but in any order. + struct UnorderedArrayMatcher(ExpectedType) < ValueMatcher(Array(ExpectedType)) + # Determines whether the matcher is satisfied with the partial given to it. + # `MatchData` is returned that contains information about the match. + def match(partial) + actual = partial.actual.to_a + missing, extra = array_diff(expected, actual) + + values = ExpectedActual.new(expected, label, actual, partial.label) + if missing.empty? && extra.empty? + IdenticalMatchData.new(values) + else + ContentMatchData.new(values, missing, extra) + end + end + + # Finds the difference of two unordered arrays. + # Returns a tuple of arrays - missing from *actual* and extra in *actual*. + private def array_diff(expected, actual) + extra = actual.dup + missing = [] of ExpectedType + + # TODO: OPTIMIZE + expected.each do |item| + index = extra.index(item) + if index + extra.delete_at(index) + else + missing << item + end + end + + {missing, extra} + end + + # Creates the value matcher. + # The label should be a string representation of the expectation. + # The expected value is stored for later use. + def initialize(expected : Array(ExpectedType), label : String) + super + end + + # Creates the value matcher. + # The label is generated by calling `#to_s` on the expected value. + # The expected value is stored for later use. + def initialize(expected : Array(ExpectedType)) + super + end + + # Common functionality for all match data for this matcher. + private abstract struct CommonMatchData(ExpectedType, ActualType) < MatchData + # Creates the match data. + def initialize(matched, @values : ExpectedActual(Array(ExpectedType), Array(ActualType))) + super(matched) + end + + # Basic information about the match. + def named_tuple + { + expected: NegatableMatchDataValue.new(@values.expected), + actual: @values.actual, + } + end + + # Describes the condition that satisfies the matcher. + # This is informational and displayed to the end-user. + def message + "#{@values.actual_label} contains #{@values.expected_label} (unordered)" + end + end + + # Match data specific to this matcher. + # This type is used when the actual value matches the expected value. + private struct IdenticalMatchData(ExpectedType, ActualType) < CommonMatchData(ExpectedType, ActualType) + # Creates the match data. + def initialize(values : ExpectedActual(Array(ExpectedType), Array(ActualType))) + super(true, values) + end + + # Describes the condition that won't satsify the matcher. + # This is informational and displayed to the end-user. + def negated_message + "#{@values.actual_label} does not contain #{@values.expected_label} (unordered)" + end + end + + # Match data specific to this matcher. + # This type is used when the actual contents differs from the expected contents. + private struct ContentMatchData(ExpectedType, ActualType) < CommonMatchData(ExpectedType, ActualType) + # Creates the match data. + def initialize(values : ExpectedActual(Array(ExpectedType), Array(ActualType)), @missing : Array(ExpectedType), @extra : Array(ActualType)) + super(false, values) + end + + # Information about the match. + def named_tuple + super.merge({ + missing: @missing, + extra: @extra, + }) + end + + # Describes the condition that won't satsify the matcher. + # This is informational and displayed to the end-user. + def negated_message + "#{@values.actual_label} does not contain #{@values.expected_label} (content differs)" + end + end + end +end