From 0540f94823a357087c4ac59e179feb2d238ccaed Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 12:38:37 -0700 Subject: [PATCH 01/11] Add contain_elements and have_elements variants --- src/spectator/dsl/matchers.cr | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/spectator/dsl/matchers.cr b/src/spectator/dsl/matchers.cr index a9f1030..2353ca2 100644 --- a/src/spectator/dsl/matchers.cr +++ b/src/spectator/dsl/matchers.cr @@ -459,6 +459,26 @@ module Spectator {% end %} end + # Indicates that some value or set should contain specific items. + # 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, items are compared using the underying implementation. + # In both cases, the `includes?` method is used. + # + # This is identical to `#contain`, but accepts an array (or enumerable type) instead of multiple arguments. + # + # Examples: + # ``` + # expect("foobar").to contain_elements(["foo", "bar"]) + # expect("foobar").to contain(['a', 'b']) + # expect(%i[a b c]).to contain(%i[a b]) + # ``` + macro contain_elements(expected) + %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.splat.stringify}}) + ::Spectator::Matchers::ContainMatcher.new(%test_value) + end + # Indicates that some range (or collection) should contain another value. # This is typically used on a `Range` (although any `Enumerable` works). # The `includes?` method is used. @@ -520,6 +540,29 @@ module Spectator {% end %} end + # Indicates that some value or set should contain specific items. + # This is similar to `#contain_elements`, 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_elements(["foo", "bar"]) + # expect("foobar").to have_elements(['a', 'b']) + # + # expect(%i[a b c]).to have_elements(%i[b c]) + # expect(%w[FOO BAR BAZ]).to have_elements([/FOO/, /bar/i]) + # expect([1, 2, 3, :a, :b, :c]).to have_elements([Int32, Symbol]) + # ``` + macro have_elements(expected) + %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.splat.stringify}}) + ::Spectator::Matchers::HaveMatcher.new(%test_value) + end + # Indicates that some set, such as a `Hash`, has a given key. # The `has_key?` method is used for this check. # From c72014797455bc993836e466f5d442295b490fb6 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 12:39:05 -0700 Subject: [PATCH 02/11] Bump version to 0.9.29 --- shard.yml | 2 +- src/spectator.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shard.yml b/shard.yml index f1d5883..c102009 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.9.28 +version: 0.9.29 description: | A feature-rich spec testing framework for Crystal with similarities to RSpec. diff --git a/src/spectator.cr b/src/spectator.cr index 3ceaad6..53a8a57 100644 --- a/src/spectator.cr +++ b/src/spectator.cr @@ -6,7 +6,7 @@ module Spectator extend self # Current version of the Spectator library. - VERSION = "0.9.28" + VERSION = "0.9.29" # Top-level describe method. # All specs in a file must be wrapped in this call. From 5ec7e25d576fc1f83df2986881501f6b726d1923 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 12:54:44 -0700 Subject: [PATCH 03/11] Remove splat --- src/spectator/dsl/matchers.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spectator/dsl/matchers.cr b/src/spectator/dsl/matchers.cr index 2353ca2..c234d09 100644 --- a/src/spectator/dsl/matchers.cr +++ b/src/spectator/dsl/matchers.cr @@ -475,7 +475,7 @@ module Spectator # expect(%i[a b c]).to contain(%i[a b]) # ``` macro contain_elements(expected) - %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.splat.stringify}}) + %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) ::Spectator::Matchers::ContainMatcher.new(%test_value) end @@ -559,7 +559,7 @@ module Spectator # expect([1, 2, 3, :a, :b, :c]).to have_elements([Int32, Symbol]) # ``` macro have_elements(expected) - %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.splat.stringify}}) + %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) ::Spectator::Matchers::HaveMatcher.new(%test_value) end From f465df48d4a8ac781b6138cb2f2cc8dd782ce07d Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 13:18:36 -0700 Subject: [PATCH 04/11] Fix copy/paste fail docs --- src/spectator/dsl/matchers.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spectator/dsl/matchers.cr b/src/spectator/dsl/matchers.cr index c234d09..9deb404 100644 --- a/src/spectator/dsl/matchers.cr +++ b/src/spectator/dsl/matchers.cr @@ -471,8 +471,8 @@ module Spectator # Examples: # ``` # expect("foobar").to contain_elements(["foo", "bar"]) - # expect("foobar").to contain(['a', 'b']) - # expect(%i[a b c]).to contain(%i[a b]) + # expect("foobar").to contain_elements(['a', 'b']) + # expect(%i[a b c]).to contain_elements(%i[a b]) # ``` macro contain_elements(expected) %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) From 875ca587f3344b9ceaf0bc5b1b7e3a5ed1ebad87 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 13:44:12 -0700 Subject: [PATCH 05/11] Show missing values in error output --- src/spectator/matchers/contain_matcher.cr | 81 ++++++++++++----------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/src/spectator/matchers/contain_matcher.cr b/src/spectator/matchers/contain_matcher.cr index c28c5b2..36d1843 100644 --- a/src/spectator/matchers/contain_matcher.cr +++ b/src/spectator/matchers/contain_matcher.cr @@ -1,9 +1,16 @@ -require "./value_matcher" +require "./matcher" module Spectator::Matchers # Matcher that tests whether a value, such as a `String` or `Array`, contains one or more values. # The values are checked with the `includes?` method. - struct ContainMatcher(ExpectedType) < ValueMatcher(ExpectedType) + struct ContainMatcher(ExpectedType) < Matcher + # Expected value and label. + private getter expected + + # Creates the matcher with an expected value. + def initialize(@expected : TestValue(Array(ExpectedType))) + end + # Short text about the matcher's purpose. # This explains what condition satisfies the matcher. # The description is used when the one-liner syntax is used. @@ -11,54 +18,50 @@ module Spectator::Matchers "contains #{expected.label}" end - # Checks whether the matcher is satisifed with the expression given to it. - private def match?(actual : TestExpression(T)) : Bool forall T + # Actually performs the test against the expression. + def match(actual : TestExpression(T)) : MatchData forall T actual_value = actual.value return unexpected(actual_value, actual.label) unless actual_value.responds_to?(:includes?) - expected.value.all? do |item| - actual_value.includes?(item) + actual_elements = actual_value.to_a + missing = expected.value.reject do |item| + actual_elements.includes?(item) + end + + if missing.empty? + # Contents are present. + SuccessfulMatchData.new(description) + else + # Content is missing. + FailedMatchData.new(description, "#{actual.label} does not contain #{expected.label}", + expected: expected.value.inspect, + actual: actual_elements.inspect, + missing: missing.inspect, + ) end end - # If the expectation is negated, then this method is called instead of `#match?`. - private def does_not_match?(actual : TestExpression(T)) : Bool forall T + # Performs the test against the expression, but inverted. + # A successful match with `#match` should normally fail for this method, and vice-versa. + def negated_match(actual : TestExpression(T)) : MatchData forall T actual_value = actual.value return unexpected(actual_value, actual.label) unless actual_value.responds_to?(:includes?) - !expected.value.any? do |item| - actual_value.includes?(item) + actual_elements = actual_value.to_a + missing = expected.value.reject do |item| + actual_elements.includes?(item) end - end - # Message displayed when the matcher isn't satisifed. - # - # This is only called when `#match?` returns false. - # - # The message should typically only contain the test expression labels. - # Actual values should be returned by `#values`. - private def failure_message(actual) : String - "#{actual.label} does not contain #{expected.label}" - end - - # Message displayed when the matcher isn't satisifed and is negated. - # This is essentially what would satisfy the matcher if it wasn't negated. - # - # This is only called when `#does_not_match?` returns false. - # - # The message should typically only contain the test expression labels. - # Actual values should be returned by `#values`. - private def failure_message_when_negated(actual) : String - "#{actual.label} contains #{expected.label}" - end - - # Additional information about the match failure. - # The return value is a NamedTuple with Strings for each value. - private def values(actual) - { - subset: expected.value.inspect, - superset: actual.value.inspect, - } + if missing.empty? + # Contents are identical. + FailedMatchData.new(description, "#{actual.label} contains #{expected.label}", + expected: "Not #{expected_elements.inspect}", + actual: actual_elements.inspect + ) + else + # Content differs. + SuccessfulMatchData.new(description) + end end private def unexpected(value, label) From f6fc36f60abdffeb0070f98a28cc537626e89a04 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 13:56:19 -0700 Subject: [PATCH 06/11] Bump version to 0.9.30 --- shard.yml | 2 +- src/spectator.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shard.yml b/shard.yml index c102009..b506fdf 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.9.29 +version: 0.9.30 description: | A feature-rich spec testing framework for Crystal with similarities to RSpec. diff --git a/src/spectator.cr b/src/spectator.cr index 53a8a57..e56f7ee 100644 --- a/src/spectator.cr +++ b/src/spectator.cr @@ -6,7 +6,7 @@ module Spectator extend self # Current version of the Spectator library. - VERSION = "0.9.29" + VERSION = "0.9.30" # Top-level describe method. # All specs in a file must be wrapped in this call. From de1af7178cf86e67c5721af1d64dfa48e238fcb8 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 14:11:50 -0700 Subject: [PATCH 07/11] Fix string usage with contain matcher --- src/spectator/matchers/contain_matcher.cr | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/spectator/matchers/contain_matcher.cr b/src/spectator/matchers/contain_matcher.cr index 36d1843..9bde8bb 100644 --- a/src/spectator/matchers/contain_matcher.cr +++ b/src/spectator/matchers/contain_matcher.cr @@ -8,7 +8,7 @@ module Spectator::Matchers private getter expected # Creates the matcher with an expected value. - def initialize(@expected : TestValue(Array(ExpectedType))) + def initialize(@expected : TestValue(ExpectedType)) end # Short text about the matcher's purpose. @@ -23,9 +23,8 @@ module Spectator::Matchers actual_value = actual.value return unexpected(actual_value, actual.label) unless actual_value.responds_to?(:includes?) - actual_elements = actual_value.to_a missing = expected.value.reject do |item| - actual_elements.includes?(item) + actual_value.includes?(item) end if missing.empty? @@ -35,7 +34,7 @@ module Spectator::Matchers # Content is missing. FailedMatchData.new(description, "#{actual.label} does not contain #{expected.label}", expected: expected.value.inspect, - actual: actual_elements.inspect, + actual: actual_value.inspect, missing: missing.inspect, ) end @@ -47,16 +46,15 @@ module Spectator::Matchers actual_value = actual.value return unexpected(actual_value, actual.label) unless actual_value.responds_to?(:includes?) - actual_elements = actual_value.to_a missing = expected.value.reject do |item| - actual_elements.includes?(item) + actual_value.includes?(item) end if missing.empty? # Contents are identical. FailedMatchData.new(description, "#{actual.label} contains #{expected.label}", - expected: "Not #{expected_elements.inspect}", - actual: actual_elements.inspect + expected: "Not #{expected.value.inspect}", + actual: actual_value.inspect ) else # Content differs. From a2ef0fa46ac1acf04079d0e6a14c546c6560d5de Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 14:17:05 -0700 Subject: [PATCH 08/11] Consistent comments --- src/spectator/matchers/contain_matcher.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spectator/matchers/contain_matcher.cr b/src/spectator/matchers/contain_matcher.cr index 9bde8bb..41e794e 100644 --- a/src/spectator/matchers/contain_matcher.cr +++ b/src/spectator/matchers/contain_matcher.cr @@ -51,13 +51,13 @@ module Spectator::Matchers end if missing.empty? - # Contents are identical. + # Contents are present. FailedMatchData.new(description, "#{actual.label} contains #{expected.label}", expected: "Not #{expected.value.inspect}", actual: actual_value.inspect ) else - # Content differs. + # Content is missing. SuccessfulMatchData.new(description) end end From edf8ae36df9bb4c1922936c7bd7170a9fdd9f332 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 14:19:40 -0700 Subject: [PATCH 09/11] Fix negated contain case --- src/spectator/matchers/contain_matcher.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spectator/matchers/contain_matcher.cr b/src/spectator/matchers/contain_matcher.cr index 41e794e..afc73fd 100644 --- a/src/spectator/matchers/contain_matcher.cr +++ b/src/spectator/matchers/contain_matcher.cr @@ -46,11 +46,11 @@ module Spectator::Matchers actual_value = actual.value return unexpected(actual_value, actual.label) unless actual_value.responds_to?(:includes?) - missing = expected.value.reject do |item| + satisfied = expected.value.any? do |item| actual_value.includes?(item) end - if missing.empty? + if satisfied # Contents are present. FailedMatchData.new(description, "#{actual.label} contains #{expected.label}", expected: "Not #{expected.value.inspect}", From 20caed9262ca1dbb1f1beffa1335e1e16807dfd2 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 14:38:30 -0700 Subject: [PATCH 10/11] Add contain matcher improvements to have matcher --- src/spectator/matchers/have_matcher.cr | 127 +++++++++++++++++-------- 1 file changed, 87 insertions(+), 40 deletions(-) diff --git a/src/spectator/matchers/have_matcher.cr b/src/spectator/matchers/have_matcher.cr index d67eb35..55dc009 100644 --- a/src/spectator/matchers/have_matcher.cr +++ b/src/spectator/matchers/have_matcher.cr @@ -4,7 +4,14 @@ module Spectator::Matchers # Matcher that tests whether a value, such as a `String` or `Array`, matches one or more values. # 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) + struct HaveMatcher(ExpectedType) < Matcher + # Expected value and label. + private getter expected + + # Creates the matcher with an expected value. + def initialize(@expected : TestValue(ExpectedType)) + end + # Short text about the matcher's purpose. # This explains what condition satisfies the matcher. # The description is used when the one-liner syntax is used. @@ -12,62 +19,102 @@ module Spectator::Matchers "includes #{expected.label}" end - # Checks whether the matcher is satisifed with the expression given to it. - private def match?(actual : TestExpression(T)) : Bool forall T + # Entrypoint for the matcher, forwards to the correct method for string or enumerable. + def match(actual : TestExpression(T)) : MatchData forall T if (value = actual.value).is_a?(String) - match_string?(value) + match_string(value, actual.label) else - match_enumerable?(value) + match_enumerable(value, actual.label) + end + end + + # Actually performs the test against the expression. + private def match_enumerable(actual_value, actual_label) + array = actual_value.to_a + missing = expected.value.reject do |item| + array.any? do |element| + item === element + end + end + + if missing.empty? + # Contents are present. + SuccessfulMatchData.new(description) + else + # Content is missing. + FailedMatchData.new(description, "#{actual_label} does not include #{expected.label}", + expected: expected.value.inspect, + actual: actual_value.inspect, + missing: missing.inspect, + ) end end # Checks if a `String` matches the expected values. # The `includes?` method is used for this check. - private def match_string?(value) - expected.value.all? do |item| - value.includes?(item) + private def match_string(actual_value, actual_label) + missing = expected.value.reject do |item| + actual_value.includes?(item) + end + + if missing.empty? + SuccessfulMatchData.new(description) + else + FailedMatchData.new(description, "#{actual_label} does not include #{expected.label}", + expected: expected.value.inspect, + actual: actual_value.inspect, + missing: missing.inspect, + ) end end - # Checks if an `Enumerable` matches the expected values. - # The `===` operator is used on every item. - private def match_enumerable?(value) - array = value.to_a - expected.value.all? do |item| + # Performs the test against the expression, but inverted. + # A successful match with `#match` should normally fail for this method, and vice-versa. + def negated_match(actual : TestExpression(T)) : MatchData forall T + if (value = actual.value).is_a?(String) + negated_match_string(value, actual.label) + else + negated_match_enumerable(value, actual.label) + end + end + + # Actually performs the negated test against the expression. + private def negated_match_enumerable(actual_value, actual_label) + array = actual_value.to_a + satisfied = expected.value.any? do |item| array.any? do |element| item === element end end + + if satisfied + # Contents are present. + FailedMatchData.new(description, "#{actual_label} includes #{expected.label}", + expected: "Not #{expected.value.inspect}", + actual: actual_value.inspect + ) + else + # Content is missing. + SuccessfulMatchData.new(description) + end end - # Message displayed when the matcher isn't satisifed. - # - # This is only called when `#match?` returns false. - # - # The message should typically only contain the test expression labels. - # Actual values should be returned by `#values`. - private def failure_message(actual) : String - "#{actual.label} does not include #{expected.label}" - end + # Checks if a `String` doesn't match the expected values. + # The `includes?` method is used for this check. + private def negated_match_string(actual_value, actual_label) + satisfied = expected.value.any? do |item| + actual_value.includes?(item) + end - # Message displayed when the matcher isn't satisifed and is negated. - # This is essentially what would satisfy the matcher if it wasn't negated. - # - # This is only called when `#does_not_match?` returns false. - # - # The message should typically only contain the test expression labels. - # Actual values should be returned by `#values`. - private def failure_message_when_negated(actual) : String - "#{actual.label} includes #{expected.label}" - end - - # Additional information about the match failure. - # The return value is a NamedTuple with Strings for each value. - private def values(actual) - { - subset: expected.value.inspect, - superset: actual.value.inspect, - } + if satisfied + SuccessfulMatchData.new(description) + else + FailedMatchData.new(description, "#{actual_label} does not include #{expected.label}", + expected: expected.value.inspect, + actual: actual_value.inspect, + missing: missing.inspect, + ) + end end end end From 891cd4bbf79bae2f8f13886d1189aa6ce08b90a4 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 23 Dec 2020 14:39:24 -0700 Subject: [PATCH 11/11] Change includes to has --- src/spectator/matchers/have_matcher.cr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/spectator/matchers/have_matcher.cr b/src/spectator/matchers/have_matcher.cr index 55dc009..6e535ca 100644 --- a/src/spectator/matchers/have_matcher.cr +++ b/src/spectator/matchers/have_matcher.cr @@ -16,7 +16,7 @@ module Spectator::Matchers # This explains what condition satisfies the matcher. # The description is used when the one-liner syntax is used. def description : String - "includes #{expected.label}" + "has #{expected.label}" end # Entrypoint for the matcher, forwards to the correct method for string or enumerable. @@ -42,7 +42,7 @@ module Spectator::Matchers SuccessfulMatchData.new(description) else # Content is missing. - FailedMatchData.new(description, "#{actual_label} does not include #{expected.label}", + FailedMatchData.new(description, "#{actual_label} does not have #{expected.label}", expected: expected.value.inspect, actual: actual_value.inspect, missing: missing.inspect, @@ -60,7 +60,7 @@ module Spectator::Matchers if missing.empty? SuccessfulMatchData.new(description) else - FailedMatchData.new(description, "#{actual_label} does not include #{expected.label}", + FailedMatchData.new(description, "#{actual_label} does not have #{expected.label}", expected: expected.value.inspect, actual: actual_value.inspect, missing: missing.inspect, @@ -89,7 +89,7 @@ module Spectator::Matchers if satisfied # Contents are present. - FailedMatchData.new(description, "#{actual_label} includes #{expected.label}", + FailedMatchData.new(description, "#{actual_label} has #{expected.label}", expected: "Not #{expected.value.inspect}", actual: actual_value.inspect ) @@ -109,7 +109,7 @@ module Spectator::Matchers if satisfied SuccessfulMatchData.new(description) else - FailedMatchData.new(description, "#{actual_label} does not include #{expected.label}", + FailedMatchData.new(description, "#{actual_label} does not have #{expected.label}", expected: expected.value.inspect, actual: actual_value.inspect, missing: missing.inspect,