From cd60fae15788a9c8019bb657874b5753cfe31ce6 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 28 Mar 2019 23:20:32 -0600 Subject: [PATCH] Add respond_to matcher --- README.md | 2 +- spec/matchers/respond_matcher_spec.cr | 123 ++++++++++++++++++++++ src/spectator/dsl/matcher_dsl.cr | 16 +++ src/spectator/matchers/respond_matcher.cr | 73 +++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 spec/matchers/respond_matcher_spec.cr create mode 100644 src/spectator/matchers/respond_matcher.cr diff --git a/README.md b/README.md index 08a50d4..b087a9b 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ Items not marked as completed may have partial implementations. - [ ] Matchers - [X] Equality matchers - `eq`, `ne`, `be ==`, `be !=` - [X] Comparison matchers - `be <`, `be <=`, `be >`, `be >=`, `be_within[.of]`, `be_close` - - [X] Type matchers - `be_a` + - [X] Type matchers - `be_a`, `respond_to` - [ ] Collection matchers - `contain`, `have`, `contain_exactly[.in_order|.in_any_order]`, `match_array[.in_order|.in_any_order]`, `start_with`, `end_with`, `be_empty`, `have_key`, `have_value`, `all`, `all_satisfy` - [X] Truthy matchers - `be`, `be_true`, `be_truthy`, `be_false`, `be_falsey`, `be_nil` - [X] Error matchers - `raise_error` diff --git a/spec/matchers/respond_matcher_spec.cr b/spec/matchers/respond_matcher_spec.cr new file mode 100644 index 0000000..3685464 --- /dev/null +++ b/spec/matchers/respond_matcher_spec.cr @@ -0,0 +1,123 @@ +require "../spec_helper" + +describe Spectator::Matchers::RespondMatcher do + describe "#match" do + context "returned MatchData" do + describe "#matched?" do + context "one method" do + context "with a responding method" do + it "is true" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil)).new + match_data = matcher.match(partial) + match_data.matched?.should be_true + end + end + + context "against a non-responding method" do + it "is false" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(downcase: Nil)).new + match_data = matcher.match(partial) + match_data.matched?.should be_false + end + end + end + + context "multiple methods" do + context "with one responding method" do + it "is false" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil, downcase: Nil)).new + match_data = matcher.match(partial) + match_data.matched?.should be_false + end + end + + context "with all responding methods" do + it "is true" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil, to_a: Nil)).new + match_data = matcher.match(partial) + match_data.matched?.should be_true + end + end + + context "with no responding methods" do + it "is false" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(downcase: Nil, upcase: Nil)).new + match_data = matcher.match(partial) + match_data.matched?.should be_false + end + end + end + end + + describe "#values" do + it "contains a key for each expected method" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil, downcase: Nil)).new + match_data = matcher.match(partial) + match_data_has_key?(match_data.values, :"responds to #size").should be_true + match_data_has_key?(match_data.values, :"responds to #downcase").should be_true + end + + it "has the actual values" do + array = %i[a b c] + partial = new_partial(array) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil, downcase: Nil)).new + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :"responds to #size")[:value].should be_true + match_data_value_sans_prefix(match_data.values, :"responds to #downcase")[:value].should be_false + end + end + + describe "#message" do + it "contains the actual label" do + value = "foobar" + label = "everything" + partial = new_partial(value, label) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil, downcase: Nil)).new + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + it "contains the method names" do + value = "foobar" + partial = new_partial(value) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil, downcase: Nil)).new + match_data = matcher.match(partial) + match_data.message.should contain("#size") + match_data.message.should contain("#downcase") + end + end + + describe "#negated_message" do + it "contains the actual label" do + value = "foobar" + label = "everything" + partial = new_partial(value, label) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil, downcase: Nil)).new + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + it "contains the method names" do + value = "foobar" + partial = new_partial(value) + matcher = Spectator::Matchers::RespondMatcher(NamedTuple(size: Nil, downcase: Nil)).new + match_data = matcher.match(partial) + match_data.message.should contain("#size") + match_data.negated_message.should contain("#downcase") + end + end + end + end +end diff --git a/src/spectator/dsl/matcher_dsl.cr b/src/spectator/dsl/matcher_dsl.cr index f081afc..6285c33 100644 --- a/src/spectator/dsl/matcher_dsl.cr +++ b/src/spectator/dsl/matcher_dsl.cr @@ -90,6 +90,22 @@ module Spectator::DSL be_a({{expected}}) end + # Indicates that some value should respond to a method call. + # One or more method names can be provided. + # + # Examples: + # ``` + # expect("foobar").to respond_to(:downcase) + # expect(%i[a b c]).to respond_to(:size, :first) + # ``` + macro respond_to(*expected) + ::Spectator::Matchers::RespondMatcher({% begin %}NamedTuple( + {% for method in expected %} + {{method.id.stringify}}: Nil, + {% end %} + ){% end %}).new + end + # Indicates that some value should be less than another. # The < operator is used for this check. # The value passed to this method is the value expected to be larger. diff --git a/src/spectator/matchers/respond_matcher.cr b/src/spectator/matchers/respond_matcher.cr new file mode 100644 index 0000000..4c0e4a0 --- /dev/null +++ b/src/spectator/matchers/respond_matcher.cr @@ -0,0 +1,73 @@ +require "./value_matcher" + +module Spectator::Matchers + # Matcher that tests that a type responds to a method call. + # The instance is tested with the `responds_to?` method. + # The `ExpectedType` type param should be a `NamedTuple`, + # with each key being the method to check and the value is ignored. + struct RespondMatcher(ExpectedType) < Matcher + # Determines whether the matcher is satisfied with the value given to it. + private def match?(actual) + # The snapshot did the hard work. + # Here just check if all values are true. + actual.values.all? + end + + # Determines whether the matcher is satisfied with the partial given to it. + # `MatchData` is returned that contains information about the match. + def match(partial) + values = snapshot_values(partial.actual) + MatchData.new(match?(values), values, partial.label, label) + end + + # Captures all of the actual values. + # A `NamedTuple` is returned, + # with each key being the attribute. + private def snapshot_values(actual) + {% begin %} + { + {% for method in ExpectedType.keys %} + {{method.stringify}}: actual.responds_to?({{method.symbolize}}), + {% end %} + } + {% end %} + end + + # Textual representation of what the matcher expects. + def label + # Prefix every method name with # and join them with commas. + {{ExpectedType.keys.map { |e| "##{e}".id }.splat.stringify}} + end + + # Match data specific to this matcher. + private struct MatchData(ActualType) < MatchData + # Creates the match data. + def initialize(matched, @actual : ActualType, @actual_label : String, @expected_label : String) + super(matched) + end + + # Information about the match. + def named_tuple + {% begin %} + { + {% for method in ActualType.keys %} + {{"responds to #" + method.stringify}}: @actual[{{method.symbolize}}], + {% end %} + } + {% end %} + end + + # Describes the condition that satisfies the matcher. + # This is informational and displayed to the end-user. + def message + "#{@actual_label} responds to #{@expected_label}" + end + + # Describes the condition that won't satsify the matcher. + # This is informational and displayed to the end-user. + def negated_message + "#{@actual_label} does not respond to #{@expected_label}" + end + end + end +end