diff --git a/README.md b/README.md index 6e98ccd..ba71627 100644 --- a/README.md +++ b/README.md @@ -302,8 +302,8 @@ Items not marked as completed may have partial implementations. - [X] Error matchers - `raise_error` - [ ] Yield matchers - `yield_control[.times]`, `yield_with_args[.times]`, `yield_with_no_args[.times]`, `yield_successive_args` - [ ] Output matchers - `output[.to_stdout|.to_stderr]` + - [X] Predicate matchers - `be_x`, `have_x` - [ ] Misc. matchers - - [ ] `exist` - [X] `match` - [ ] `satisfy` - [ ] `change[.by|.from[.to]|.to|.by_at_least|.by_at_most]` diff --git a/spec/matchers/have_predicate_matcher_spec.cr b/spec/matchers/have_predicate_matcher_spec.cr new file mode 100644 index 0000000..5b49382 --- /dev/null +++ b/spec/matchers/have_predicate_matcher_spec.cr @@ -0,0 +1,87 @@ +require "../spec_helper" + +describe Spectator::Matchers::HavePredicateMatcher do + describe "#match" do + context "returned MatchData" do + describe "#match?" do + context "with a true predicate" do + it "is true" do + value = "foo\\bar" + partial = new_partial(value) + matcher = Spectator::Matchers::HavePredicateMatcher.new({back_references: Tuple.new}, "back_references") + match_data = matcher.match(partial) + match_data.matched?.should be_true + end + end + + context "with a false predicate" do + it "is false" do + value = "foobar" + partial = new_partial(value) + matcher = Spectator::Matchers::HavePredicateMatcher.new({back_references: Tuple.new}, "back_references") + match_data = matcher.match(partial) + match_data.matched?.should be_false + end + end + end + + describe "#values" do + it "contains a key for each expected attribute" do + value = "foobar" + partial = new_partial(value) + matcher = Spectator::Matchers::HavePredicateMatcher.new({back_references: Tuple.new}, "back_references") + match_data = matcher.match(partial) + match_data_has_key?(match_data.values, :back_references).should be_true + end + + it "has the actual values" do + value = "foobar" + partial = new_partial(value) + matcher = Spectator::Matchers::HavePredicateMatcher.new({back_references: Tuple.new}, "back_references") + match_data = matcher.match(partial) + match_data_value_sans_prefix(match_data.values, :back_references)[:value].should eq(value.has_back_references?) + end + end + + describe "#message" do + it "contains the actual label" do + value = "foobar" + label = "blah" + partial = new_partial(value, label) + matcher = Spectator::Matchers::HavePredicateMatcher.new({back_references: Tuple.new}, "back_references") + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + + it "contains the expected label" do + value = "foobar" + label = "blah" + partial = new_partial(value) + matcher = Spectator::Matchers::HavePredicateMatcher.new({back_references: Tuple.new}, label) + match_data = matcher.match(partial) + match_data.message.should contain(label) + end + end + + describe "#negated_message" do + it "contains the actual label" do + value = "foobar" + label = "blah" + partial = new_partial(value, label) + matcher = Spectator::Matchers::HavePredicateMatcher.new({back_references: Tuple.new}, "back_references") + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + + it "contains the expected label" do + value = "foobar" + label = "blah" + partial = new_partial(value) + matcher = Spectator::Matchers::HavePredicateMatcher.new({back_references: Tuple.new}, label) + match_data = matcher.match(partial) + match_data.negated_message.should contain(label) + end + end + end + end +end diff --git a/spec/matchers/predicate_matcher_spec.cr b/spec/matchers/predicate_matcher_spec.cr index 5a9b2c2..031556b 100644 --- a/spec/matchers/predicate_matcher_spec.cr +++ b/spec/matchers/predicate_matcher_spec.cr @@ -8,7 +8,7 @@ describe Spectator::Matchers::PredicateMatcher do it "is true" do value = "foobar" partial = new_partial(value) - matcher = Spectator::Matchers::PredicateMatcher(NamedTuple(ascii_only: Nil)).new + matcher = Spectator::Matchers::PredicateMatcher.new({ascii_only: Tuple.new}, "ascii_only") match_data = matcher.match(partial) match_data.matched?.should be_true end @@ -18,7 +18,7 @@ describe Spectator::Matchers::PredicateMatcher do it "is false" do value = "foobar" partial = new_partial(value) - matcher = Spectator::Matchers::PredicateMatcher(NamedTuple(empty: Nil)).new + matcher = Spectator::Matchers::PredicateMatcher.new({empty: Tuple.new}, "empty") match_data = matcher.match(partial) match_data.matched?.should be_false end @@ -29,7 +29,7 @@ describe Spectator::Matchers::PredicateMatcher do it "contains a key for each expected attribute" do value = "foobar" partial = new_partial(value) - matcher = Spectator::Matchers::PredicateMatcher(NamedTuple(empty: Nil, ascii_only: Nil)).new + matcher = Spectator::Matchers::PredicateMatcher.new({empty: Tuple.new, ascii_only: Tuple.new}, "empty, ascii_only") match_data = matcher.match(partial) match_data_has_key?(match_data.values, :empty).should be_true match_data_has_key?(match_data.values, :ascii_only).should be_true @@ -38,7 +38,7 @@ describe Spectator::Matchers::PredicateMatcher do it "has the actual values" do value = "foobar" partial = new_partial(value) - matcher = Spectator::Matchers::PredicateMatcher(NamedTuple(empty: Nil, ascii_only: Nil)).new + matcher = Spectator::Matchers::PredicateMatcher.new({empty: Tuple.new, ascii_only: Tuple.new}, "empty, ascii_only") match_data = matcher.match(partial) match_data_value_sans_prefix(match_data.values, :empty)[:value].should eq(value.empty?) match_data_value_sans_prefix(match_data.values, :ascii_only)[:value].should eq(value.ascii_only?) @@ -50,17 +50,18 @@ describe Spectator::Matchers::PredicateMatcher do value = "foobar" label = "blah" partial = new_partial(value, label) - matcher = Spectator::Matchers::PredicateMatcher(NamedTuple(ascii_only: Nil)).new + matcher = Spectator::Matchers::PredicateMatcher.new({ascii_only: Tuple.new}, "ascii_only") match_data = matcher.match(partial) match_data.message.should contain(label) end - it "contains stringified form of predicate" do + it "contains the expected label" do value = "foobar" + label = "blah" partial = new_partial(value) - matcher = Spectator::Matchers::PredicateMatcher(NamedTuple(ascii_only: Nil)).new + matcher = Spectator::Matchers::PredicateMatcher.new({ascii_only: Tuple.new}, label) match_data = matcher.match(partial) - match_data.message.should contain("ascii_only") + match_data.message.should contain(label) end end @@ -69,17 +70,18 @@ describe Spectator::Matchers::PredicateMatcher do value = "foobar" label = "blah" partial = new_partial(value, label) - matcher = Spectator::Matchers::PredicateMatcher(NamedTuple(ascii_only: Nil)).new + matcher = Spectator::Matchers::PredicateMatcher.new({ascii_only: Tuple.new}, "ascii_only") match_data = matcher.match(partial) match_data.negated_message.should contain(label) end - it "contains stringified form of predicate" do + it "contains the expected label" do value = "foobar" + label = "blah" partial = new_partial(value) - matcher = Spectator::Matchers::PredicateMatcher(NamedTuple(ascii_only: Nil)).new + matcher = Spectator::Matchers::PredicateMatcher.new({ascii_only: Tuple.new}, label) match_data = matcher.match(partial) - match_data.negated_message.should contain("ascii_only") + match_data.negated_message.should contain(label) end end end diff --git a/src/spectator/dsl/matcher_dsl.cr b/src/spectator/dsl/matcher_dsl.cr index 7d14334..665e44f 100644 --- a/src/spectator/dsl/matcher_dsl.cr +++ b/src/spectator/dsl/matcher_dsl.cr @@ -561,7 +561,7 @@ module Spectator::DSL end # Used to create predicate matchers. - # Any missing method that starts with 'be_' will be handled. + # Any missing method that starts with 'be_' or 'have_' will be handled. # All other method names will be ignored and raise a compile-time error. # # This can be used to simply check a predicate method that ends in '?'. @@ -570,14 +570,37 @@ module Spectator::DSL # expect("foobar").to be_ascii_only # # Is equivalent to: # expect("foobar".ascii_only?).to be_true + # + # expect("foobar").to_not have_back_references + # # Is equivalent to: + # expect("foobar".has_back_references?).to_not be_true # ``` macro method_missing(call) {% if call.name.starts_with?("be_") %} - {% method_name = call.name[3..-1] %} # Remove be_ prefix. - ::Spectator::Matchers::PredicateMatcher(NamedTuple({{method_name}}: Nil)).new + # Remove `be_` prefix. + {% method_name = call.name[3..-1] %} + {% matcher = "PredicateMatcher" %} + {% elsif call.name.starts_with?("have_") %} + # Remove `have_` prefix. + {% method_name = call.name[5..-1] %} + {% matcher = "HavePredicateMatcher" %} {% else %} - {% raise "Undefined local variable or method '#{call}'" %} + {% raise "Undefined local variable or method '#{call}'" %} {% end %} + + descriptor = { {{method_name}}: Tuple.new({{call.args.splat}}) } + label = String::Builder.new({{method_name.stringify}}) + {% unless call.args.empty? %} + label << '(' + {% for arg, index in call.args %} + label << {{arg}} + {% if index < call.args.size - 1 %} + label << ", " + {% end %} + {% end %} + label << ')' + {% end %} + ::Spectator::Matchers::{{matcher.id}}.new(descriptor, label.to_s) end end end diff --git a/src/spectator/matchers/have_predicate_matcher.cr b/src/spectator/matchers/have_predicate_matcher.cr new file mode 100644 index 0000000..bba32ae --- /dev/null +++ b/src/spectator/matchers/have_predicate_matcher.cr @@ -0,0 +1,64 @@ +require "./value_matcher" + +module Spectator::Matchers + # Matcher that tests one or more "has" predicates + # (methods ending in '?' and starting with 'has_'). + # The `ExpectedType` type param should be a `NamedTuple`. + # Each key in the tuple is a predicate (without the '?' and 'has_' prefix) to test. + # Each value is a a `Tuple` of arguments to pass to the predicate method. + struct HavePredicateMatcher(ExpectedType) < ValueMatcher(ExpectedType) + # Determines whether the matcher is satisfied with the value given to it. + private def match?(values) + # Test each predicate and immediately return false if one is false. + {% for attribute in ExpectedType.keys %} + return false unless values[{{attribute.symbolize}}] + {% end %} + + # All checks passed if this point is reached. + true + 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) : MatchData + 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 attribute in ExpectedType.keys %} + {{attribute}}: actual.has_{{attribute}}?(*@expected[{{attribute.symbolize}}]), + {% end %} + } + {% end %} + end + + # Match data specific to this matcher. + private struct MatchData(ActualType) < MatchData + # Creates the match data. + def initialize(matched, @named_tuple : ActualType, @actual_label : String, @expected_label : String) + super(matched) + end + + # Information about the match. + getter named_tuple + + # Describes the condition that satisfies the matcher. + # This is informational and displayed to the end-user. + def message + "#{@actual_label} has #{@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 have #{@expected_label}" + end + end + end +end diff --git a/src/spectator/matchers/predicate_matcher.cr b/src/spectator/matchers/predicate_matcher.cr index 41f9f44..1abd791 100644 --- a/src/spectator/matchers/predicate_matcher.cr +++ b/src/spectator/matchers/predicate_matcher.cr @@ -4,13 +4,8 @@ module Spectator::Matchers # Matcher that tests one or more predicates (methods ending in '?'). # The `ExpectedType` type param should be a `NamedTuple`. # Each key in the tuple is a predicate (without the '?') to test. - struct PredicateMatcher(ExpectedType) < Matcher - # Textual representation of what the matcher expects. - # Constructs the label from the type parameters. - def label - {{ExpectedType.keys.splat.stringify}} - end - + # Each value is a a `Tuple` of arguments to pass to the predicate method. + struct PredicateMatcher(ExpectedType) < ValueMatcher(ExpectedType) # Determines whether the matcher is satisfied with the value given to it. private def match?(values) # Test each predicate and immediately return false if one is false. @@ -26,7 +21,7 @@ module Spectator::Matchers # `MatchData` is returned that contains information about the match. def match(partial) : MatchData values = snapshot_values(partial.actual) - MatchData.new(match?(values), values, partial.label) + MatchData.new(match?(values), values, partial.label, label) end # Captures all of the actual values. @@ -36,7 +31,7 @@ module Spectator::Matchers {% begin %} { {% for attribute in ExpectedType.keys %} - {{attribute}}: actual.{{attribute}}?, + {{attribute}}: actual.{{attribute}}?(*@expected[{{attribute.symbolize}}]), {% end %} } {% end %} @@ -45,7 +40,7 @@ module Spectator::Matchers # Match data specific to this matcher. private struct MatchData(ActualType) < MatchData # Creates the match data. - def initialize(matched, @named_tuple : ActualType, @actual_label : String) + def initialize(matched, @named_tuple : ActualType, @actual_label : String, @expected_label : String) super(matched) end @@ -55,13 +50,13 @@ module Spectator::Matchers # Describes the condition that satisfies the matcher. # This is informational and displayed to the end-user. def message - "#{@actual_label} is " + {{ActualType.keys.splat.stringify}} + "#{@actual_label} is #{@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} is not " + {{ActualType.keys.splat.stringify}} + "#{@actual_label} is not #{@expected_label}" end end end