diff --git a/README.md b/README.md index 060a4fe..4bd520f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ In no particular order, features that have been implemented and are planned: - [X] Equality matchers - `eq`, `ne`, `be ==`, `be !=` - [ ] Comparison matchers - `lt`, `le`, `gt`, `ge`, `be <`, `be <=`, `be >`, `be >=`, `be_within[.of]`, `be_close` - [ ] Type matchers - `be_a`, `respond_to` - - [ ] Collection matchers - `contain`, `include`, `contain_exactly[.in_order|.in_any_order]`, `match_array[.in_order|.in_any_order]`, `start_with`, `end_with`, `be_empty`, `has_key`, `has_value`, `all`, `all_satisfy` + - [ ] Collection matchers - `contain`, `have`, `contain_exactly[.in_order|.in_any_order]`, `match_array[.in_order|.in_any_order]`, `start_with`, `end_with`, `be_empty`, `has_key`, `has_value`, `all`, `all_satisfy` - [X] Truthy matchers - `be`, `be_true`, `be_truthy`, `be_false`, `be_falsey`, `be_nil` - [ ] Error matchers - `raise_error` - [ ] Yield matchers - `yield_control[.times]`, `yield_with_args[.times]`, `yield_with_no_args[.times]`, `yield_successive_args` diff --git a/src/spectator/dsl/matcher_dsl.cr b/src/spectator/dsl/matcher_dsl.cr index c99a31e..1ab1bcf 100644 --- a/src/spectator/dsl/matcher_dsl.cr +++ b/src/spectator/dsl/matcher_dsl.cr @@ -332,7 +332,7 @@ module Spectator::DSL # This is typically used on a `String` or `Array` (any `Enumerable` works). # The `expected` argument can be a `String` or `Char` # when the actual type (being comapred against) is a `String`. - # For `Enumerable` types, each item is inspected until one matches. + # For `Enumerable` types, items are compared using the underying implementation. # In both cases, the `includes?` method is used. # # Examples: @@ -344,5 +344,27 @@ module Spectator::DSL macro contain(expected) ::Spectator::Matchers::ContainMatcher.new({{expected.stringify}}, {{expected}}) end + + # Indicates that some value or set should contain another value. + # This is similar to `#contain`, but uses a different method for matching. + # Typically a `String` or `Array` (any `Enumerable` works) is checked against. + # The `expected` argument can be a `String` or `Char` + # when the actual type (being comapred against) is a `String`. + # The `includes?` method is used for this case. + # For `Enumerable` types, each item is inspected until one matches. + # The `===` operator is used for this case, which allows for equality, type, regex, and other matches. + # + # Examples: + # ``` + # expect("foobar").to have("foo") + # expect("foobar").to have('o') + # + # expect(%i[a b c]).to have(:b) + # expect(%w[FOO BAR BAZ]).to have(/bar/i) + # expect([1, 2, 3, :a, :b, :c]).to have(Int32) + # ``` + macro have(expected) + ::Spectator::Matchers::HaveMatcher.new({{expected.stringify}}, {{expected}}) + end end end diff --git a/src/spectator/matchers/have_matcher.cr b/src/spectator/matchers/have_matcher.cr new file mode 100644 index 0000000..6e83149 --- /dev/null +++ b/src/spectator/matchers/have_matcher.cr @@ -0,0 +1,46 @@ +require "./value_matcher" + +module Spectator::Matchers + # Matcher that tests whether a value, such as a `String` or `Array`, matches a value. + # For a `String`, the `includes?` method is used. + # Otherwise, it expects an `Enumerable` and iterates over each item until `===` is true. + struct HaveMatcher(ExpectedType) < ValueMatcher(ExpectedType) + # Determines whether the matcher is satisfied with the value given to it. + # True is returned if the match was successful, false otherwise. + def match?(partial) + actual = partial.actual + if actual.is_a?(String) + match_string?(actual) + else + match_enumerable?(actual) + end + end + + # Checks if a `String` matches the expected value. + # The `includes?` method is used for this check. + private def match_string?(actual) + actual.includes?(expected) + end + + # Checks if an `Enumerable` matches the expected value. + # The `===` operator is used on every item. + private def match_enumerable?(actual) + actual.each do |item| + return true if expected === item + end + false + end + + # Describes the condition that satisfies the matcher. + # This is informational and displayed to the end-user. + def message(partial) + "Expected #{partial.label} to include #{label}" + end + + # Describes the condition that won't satsify the matcher. + # This is informational and displayed to the end-user. + def negated_message(partial) + "Expected #{partial.label} to not include #{label}" + end + end +end