diff --git a/src/spectator/dsl/matcher_dsl.cr b/src/spectator/dsl/matcher_dsl.cr index 14be169..eba6098 100644 --- a/src/spectator/dsl/matcher_dsl.cr +++ b/src/spectator/dsl/matcher_dsl.cr @@ -305,5 +305,27 @@ module Spectator::DSL macro start_with(expected) ::Spectator::Matchers::StartWithMatcher.new({{expected.stringify}}, {{expected}}) end + + # Indicates that some value or set should end with another value. + # This is typically used on a `String` or `Array` (any `Indexable` works). + # The `expected` argument can be a `String`, `Char`, or `Regex` + # when the actual type (being comapred against) is a `String`. + # For `Indexable` types, only the last item is inspected. + # It is compared with the `===` operator, + # so that values, types, regular expressions, and others can be tested. + # + # Examples: + # ``` + # expect("foobar").to end_with("bar") + # expect("foobar").to end_with('r') + # expect("FOOBAR").to end_with(/bar/i) + # + # expect(%i[a b c]).to end_with(:c) + # expect(%i[a b c]).to end_with(Symbol) + # expect(%w[foo bar]).to end_with(/bar/) + # ``` + macro end_with(expected) + ::Spectator::Matchers::EndWithMatcher.new({{expected.stringify}}, {{expected}}) + end end end diff --git a/src/spectator/matchers/end_with_matcher.cr b/src/spectator/matchers/end_with_matcher.cr new file mode 100644 index 0000000..f65d900 --- /dev/null +++ b/src/spectator/matchers/end_with_matcher.cr @@ -0,0 +1,72 @@ +require "./value_matcher" + +module Spectator::Matchers + # Matcher that tests whether a value, such as a `String` or `Array`, ends with a value. + # The `ends_with?` method is used if it's defined on the actual type. + # Otherwise, it is treated as an `Indexable` and the `last` value is compared against. + struct EndWithMatcher(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 + compare_method(actual, &.eval(actual, expected)) + end + + # Describes the condition that satisfies the matcher. + # This is informational and displayed to the end-user. + def message(partial) + method_string = compare_method(partial.actual, &.to_s) + "Expected #{partial.label} to end with #{label} (using #{method_string})" + end + + # Describes the condition that won't satsify the matcher. + # This is informational and displayed to the end-user. + def negated_message(partial) + method_string = compare_method(partial.actual, &.to_s) + "Expected #{partial.label} to not end with #{label} (using #{method_string})" + end + + # Returns the method that should be used for comparison. + # Call `eval(actual, expected)` on the returned value. + private macro compare_method(actual, &block) + # If the actual type defines `ends_with?`, + # then use that for the comparison. + # Otherwise, treat the actual type as an `Indexable`, + # and retrieve the last value to compare with. + # FIXME: Is there a better way to do this? + if {{actual}}.responds_to?(:starts_with?) + {{block.args.first}} = EndsWithCompareMethod.new + {{block.body}} + else + {{block.args.first}} = IndexableCompareMethod.new + {{block.body}} + end + end + + # Comparison method for types that define the `ends_with?` method. + private struct EndsWithCompareMethod + # Evaluates the condition to determine whether the matcher is satisfied. + def eval(actual, expected) + actual.ends_with?(expected) + end + + # String representation for end-user output. + def to_s + "#starts_with?" + end + end + + # Comparison method for `Indexable` types. + private struct IndexableCompareMethod + # Evaluates the condition to determine whether the matcher is satisfied. + def eval(actual, expected) + expected === actual.last + end + + # String representation for end-user output. + def to_s + "expected === actual.last" + end + end + end +end