diff --git a/src/spectator/assertion_failed.cr b/src/spectator/assertion_failed.cr index 4e0b136b..365587b5 100644 --- a/src/spectator/assertion_failed.cr +++ b/src/spectator/assertion_failed.cr @@ -1,22 +1,20 @@ require "./error" require "./core/location_range" -require "./matchers/match_data" +require "./matchers/match_failure" module Spectator # Error raised when an assertion fails. # This typically occurs when a matcher isn't satisfied and it's expectation isn't met. class AssertionFailed < Error - getter match_data : Matchers::MatchData? + getter match_failure : Matchers::MatchFailure? getter message : String? do - @match_data.try &.to_s + @match_failure.try &.to_s end - getter location : Core::LocationRange? do - @match_data.try &.location - end + getter location : Core::LocationRange? - def initialize(@match_data : Matchers::MatchData) + def initialize(@match_failure : Matchers::MatchFailure, @location : Core::LocationRange? = nil) super(nil) end diff --git a/src/spectator/matchers/built_in/have_attributes_matcher.cr b/src/spectator/matchers/built_in/have_attributes_matcher.cr new file mode 100644 index 00000000..f4366c76 --- /dev/null +++ b/src/spectator/matchers/built_in/have_attributes_matcher.cr @@ -0,0 +1,51 @@ +module Spectator::Matchers::BuiltIn + class HaveAttributesMatcher(Attributes) + private struct Attribute(T) + getter expected_value : T + getter expected_string : String + getter actual_string : String? + getter? matched : Bool + + def initialize(@expected_value : T, @matched : Bool, @actual_string : String?) + @expected_string = @expected_value.inspect + end + + def self.missing(expected_value) : self + new(expected_value, false, nil) + end + end + + def initialize(attributes : Attributes) + {% begin %} + # @attributes = NamedTuple.new({% for key in Attributes %} + # {{key.stringify}}: 42, # Attribute.missing(attributes[{{key.symbolize}}]), + # {% end %}) + {% debug %} + {% end %} + end + + def matches?(actual_value) + @attributes = capture_attributes(actual_value) + @attributes.all? &.matched? + end + + private def capture_attributes(actual_value) + {% for key in Attributes %} + %expected{key} = @attributes[{{key.stringify}}].expected_value + %attribute{key} = if actual_value.responds_to?({{key.symbolize}}) + value = actual_value.{{key.symbolize}} + Attribute.new(%expected{key}, value == %expected{key}, value.inspect) + else + Attribute.missing(%expected{key}) + end + {% end %} + + NamedTuple.new({% for key in Attributes %} + {{key.stringify}}: %attribute{key}, + {% end %}) + end + + def failure_message(actual_value) + end + end +end diff --git a/src/spectator/matchers/expect.cr b/src/spectator/matchers/expect.cr index 47676ab0..70eee512 100644 --- a/src/spectator/matchers/expect.cr +++ b/src/spectator/matchers/expect.cr @@ -1,5 +1,6 @@ require "../core/location_range" require "./matcher" +require "./built_in/be_a_matcher" require "./built_in/raise_error_matcher" module Spectator::Matchers @@ -11,28 +12,48 @@ module Spectator::Matchers source_file = __FILE__, source_line = __LINE__, source_end_line = __END_LINE__) : Nil + return unless failure = Matcher.match(matcher, @actual_value, failure_message) location = Core::LocationRange.new(source_file, source_line, source_end_line) - match_data = Matcher.match(matcher, @actual_value, - failure_message: failure_message, - location: location) - match_data.try_raise + failure.raise(location) + end + + def to(matcher : BuiltIn::BeAMatcher(U), failure_message : String? = nil, *, + source_file = __FILE__, + source_line = __LINE__, + source_end_line = __END_LINE__) : U forall U + if failure = Matcher.match(matcher, @actual_value, failure_message) + location = Core::LocationRange.new(source_file, source_line, source_end_line) + failure.raise(location) + else + return actual_value if (actual_value = @actual_value).is_a?(U) + raise FrameworkError.new("Bug: Expected #{@actual_value} to be a #{U}") + end end def not_to(matcher, failure_message : String? = nil, *, source_file = __FILE__, source_line = __LINE__, source_end_line = __END_LINE__) : Nil + return unless failure = Matcher.match_negated(matcher, @actual_value, failure_message) location = Core::LocationRange.new(source_file, source_line, source_end_line) - match_data = Matcher.match_negated(matcher, @actual_value, - failure_message: failure_message, - location: location) - match_data.try_raise + failure.raise(location) + end + + def not_to(matcher : BuiltIn::BeNilMatcher, failure_message : String? = nil, *, + source_file = __FILE__, + source_line = __LINE__, + source_end_line = __END_LINE__) + if failure = Matcher.match_negated(matcher, @actual_value, failure_message) + location = Core::LocationRange.new(source_file, source_line, source_end_line) + failure.raise(location) + end + @actual_value.not_nil!("Bug: Expected #{@actual_value} to not be nil") end def to_not(matcher, failure_message : String? = nil, *, source_file = __FILE__, source_line = __LINE__, - source_end_line = __END_LINE__) : Nil + source_end_line = __END_LINE__) not_to(matcher, failure_message, source_file: source_file, source_line: source_line, @@ -48,40 +69,35 @@ module Spectator::Matchers source_file = __FILE__, source_line = __LINE__, source_end_line = __END_LINE__) : Nil + return unless failure = Matcher.match_block(matcher, @block, failure_message) location = Core::LocationRange.new(source_file, source_line, source_end_line) - match_data = Matcher.match_block(matcher, @block, - failure_message: failure_message, - location: location) - match_data.try_raise + failure.raise(location) end def to(matcher : BuiltIn::RaiseErrorMatcher, failure_message : String? = nil, *, source_file = __FILE__, source_line = __LINE__, source_end_line = __END_LINE__) : Exception - location = Core::LocationRange.new(source_file, source_line, source_end_line) - match_data = Matcher.match_block(matcher, @block, - failure_message: failure_message, - location: location) - match_data.try_raise - matcher.rescued_error.not_nil!("BUG: Error should have been captured") + if failure = Matcher.match_block(matcher, @block, failure_message) + location = Core::LocationRange.new(source_file, source_line, source_end_line) + failure.raise(location) + end + matcher.rescued_error.not_nil!("Bug: Rescued error should have been captured by matcher") end def not_to(matcher, failure_message : String? = nil, *, source_file = __FILE__, source_line = __LINE__, source_end_line = __END_LINE__) : Nil + return unless failure = Matcher.match_negated_block(matcher, @block, failure_message) location = Core::LocationRange.new(source_file, source_line, source_end_line) - match_data = Matcher.match_block_negated(matcher, @block, - failure_message: failure_message, - location: location) - match_data.try_raise + failure.raise(location) end def to_not(matcher, failure_message : String? = nil, *, source_file = __FILE__, source_line = __LINE__, - source_end_line = __END_LINE__) : Nil + source_end_line = __END_LINE__) not_to(matcher, failure_message, source_file: source_file, source_line: source_line, diff --git a/src/spectator/matchers/formatting.cr b/src/spectator/matchers/formatting.cr index fd6ebe47..66cc9578 100644 --- a/src/spectator/matchers/formatting.cr +++ b/src/spectator/matchers/formatting.cr @@ -2,32 +2,20 @@ require "../formatters/printer" module Spectator::Matchers module Formatting - def description_of(value) - # TODO: Actually format the value. - value.inspect - end + struct DescriptionOf(T) + def initialize(@value : T) + end - def description_of(matchable : Matchable) - matchable.description + def apply(printer : FormattingPrinter) + end end - end - - struct FormattingPrinter - forward_missing_to @printer - def initialize(@printer : Formatters::Printer) - end - - def description_of(value) : Nil - @printer.value(value) - end - - def description_of(value : T.class) : Nil forall T - @printer.type(value) + def description_of(value) + DescriptionOf.new(value) end - def description_of(matchable : Matchable) : Nil - @printer << matchable.description + def description_of(matchable : Matchable) + matchable.description end end end diff --git a/src/spectator/matchers/match_data.cr b/src/spectator/matchers/match_data.cr deleted file mode 100644 index 8d42e27d..00000000 --- a/src/spectator/matchers/match_data.cr +++ /dev/null @@ -1,98 +0,0 @@ -require "../core/location_range" -require "../formatters/plain_printer" -require "../formatters/printer" -require "./formatting" - -module Spectator::Matchers - abstract struct MatchData - getter location : Core::LocationRange? - - def initialize(@location : Core::LocationRange? = nil) - end - - abstract def passed? : Bool - - def failed? : Bool - !passing? - end - - abstract def try_raise : Nil - - abstract def format(printer : FormattingPrinter) : Nil - - def format(printer : Formatters::Printer) : Nil - format(FormattingPrinter.new(printer)) - end - end - - struct PassedMatchData < MatchData - def passed? : Bool - true - end - - def to_s(io : IO) : Nil - io << "Passed" - end - - def try_raise : Nil - # ... - end - - def format(printer : FormattingPrinter) : Nil - printer << "Passed" - end - end - - struct FailedMatchData < MatchData - getter message : String do - to_s - end - - @proc : (FormattingPrinter ->)? - - def initialize(@message : String, location : Core::LocationRange? = nil) - super(location) - end - - def initialize(location : Core::LocationRange? = nil, &block : FormattingPrinter ->) - super(location) - @proc = block - end - - def passed? : Bool - false - end - - def try_raise : Nil - raise AssertionFailed.new(self) - end - - def format(printer : FormattingPrinter) : Nil - if proc = @proc - proc.call(printer) - elsif message = @message - printer << message - else - printer << "Failed" - end - end - - def to_s(io : IO) : Nil - printer = Formatters::PlainPrinter.new(io) - printer = FormattingPrinter.new(printer) - format(printer) - end - end - - def self.passed(location : Core::LocationRange? = nil) : MatchData - PassedMatchData.new(location) - end - - def self.failed(message : String, location : Core::LocationRange? = nil) : MatchData - FailedMatchData.new(message, location) - end - - def self.failed(location : Core::LocationRange? = nil, &block : FormattingPrinter ->) : MatchData - FailedMatchData.new(location, &block) - end -end diff --git a/src/spectator/matchers/match_failure.cr b/src/spectator/matchers/match_failure.cr new file mode 100644 index 00000000..37993a2a --- /dev/null +++ b/src/spectator/matchers/match_failure.cr @@ -0,0 +1,35 @@ +require "../assertion_failed" +require "../formatters/plain_printer" +require "../formatters/printer" + +module Spectator::Matchers + alias FailureMessagePrinter = Formatters::Printer -> + + struct MatchFailure + @failure_message : String | FailureMessagePrinter + + def initialize(failure_message : String) + @failure_message = failure_message + end + + def initialize(&block : FailureMessagePrinter) + @failure_message = block + end + + def raise(location) + raise AssertionFailed.new(self, location) + end + + def print_failure_message(printer : Formatters::Printer) : Nil + case failure_message = @failure_message + in FailureMessagePrinter then failure_message.call(printer) + in String then printer << failure_message + end + end + + def to_s(io : IO) : Nil + printer = Formatters::PlainPrinter.new(io) + print_failure_message(printer) + end + end +end diff --git a/src/spectator/matchers/matchable.cr b/src/spectator/matchers/matchable.cr index 11019f4d..6edf2276 100644 --- a/src/spectator/matchers/matchable.cr +++ b/src/spectator/matchers/matchable.cr @@ -1,12 +1,16 @@ require "../formatters/printer" require "../framework_error" require "./formatting" -require "./printable" +require "./match_failure" module Spectator::Matchers module Matchable include Formatting + getter matcher_name : String do + self.class.name.split("::").last.rchop("Matcher").underscore + end + abstract def description abstract def matches?(actual_value) @@ -15,10 +19,6 @@ module Spectator::Matchers matches?(yield) end - getter matcher_name : String do - self.class.name.split("::").last.rchop("Matcher").underscore - end - def does_not_match?(actual_value) !matches?(actual_value) end @@ -27,26 +27,62 @@ module Spectator::Matchers does_not_match?(yield) end + def print_failure_message(printer : Formatters::Printer, actual_value) : Nil + printer << "Expected " << description_of(actual_value) << " to " << description + end + + def print_failure_message(printer : Formatters::Printer, &) : Nil + print_failure_message(printer, yield) + end + def failure_message(actual_value) - "Expected #{description_of actual_value} to #{description}" + String.build do |io| + printer = Formatters::PlainPrinter.new(io) + print_failure_message(printer, actual_value) + end end def failure_message(&) failure_message(yield) end + def print_negated_failure_message(printer : Formatters::Printer, actual_value) : Nil + printer << "Expected " << description_of(actual_value) << " not to " << description + end + + def print_negated_failure_message(printer : Formatters::Printer, &) : Nil + print_negated_failure_message(printer, yield) + end + def negated_failure_message(actual_value) - "Expected #{description_of actual_value} not to #{description}" + String.build do |io| + printer = Formatters::PlainPrinter.new(io) + print_negated_failure_message(printer, actual_value) + end end def negated_failure_message(&) negated_failure_message(yield) end - def =~(other) + def ===(other) matches?(other) end + def match(actual_value) : MatchFailure? + return unless matches?(actual_value) + MatchFailure.new do |printer| + print_failure_message(printer, actual_value) + end + end + + def match_negated(actual_value) : MatchFailure? + return unless does_not_match?(actual_value) + MatchFailure.new do |printer| + print_negated_failure_message(printer, actual_value) + end + end + macro disable_negation private def no_negation! raise ::Spectator::FrameworkError.new("Matcher `#{matcher_name}` does not support negated matching.") @@ -60,6 +96,14 @@ module Spectator::Matchers no_negation! end + def print_negated_failure_message(printer : Formatters::Printer, actual_value) : Nil + no_negation! + end + + def print_negated_failure_message(printer : Formatters::Printer, &) : Nil + no_negation! + end + def negated_failure_message(actual_value) no_negation! end @@ -86,25 +130,21 @@ module Spectator::Matchers !matches?(&block) end - def failure_message(actual_value) + def print_failure_message(printer : Formatters::Printer, actual_value) : Nil no_block! end - def negated_failure_message(actual_value) + def failure_message(actual_value) no_block! end - def failure_message(printer : ::Spectator::Matchers::FormattingPrinter, actual_value) : Nil + def print_negated_failure_message(printer : Formatters::Printer, actual_value) : Nil no_block! end - def negated_failure_message(printer : ::Spectator::Matchers::FormattingPrinter, actual_value) : Nil + def negated_failure_message(actual_value) no_block! end end - - macro print_messages - include ::Spectator::Matchers::Printable - end end end diff --git a/src/spectator/matchers/matcher.cr b/src/spectator/matchers/matcher.cr index 293e614c..754d2a72 100644 --- a/src/spectator/matchers/matcher.cr +++ b/src/spectator/matchers/matcher.cr @@ -1,122 +1,121 @@ -require "../core/location_range" require "../framework_error" -require "./match_data" -require "./printable" module Spectator::Matchers module Matcher - extend self - - def match(matcher, actual_value, *, - failure_message : String? = nil, - location : Core::LocationRange? = nil) : MatchData - if matcher.responds_to?(:matches?) && matcher.responds_to?(:failure_message) - if matcher.matches?(actual_value) - Matchers.passed(location) - else - if failure_message - Matchers.failed(failure_message, location) - elsif matcher.is_a?(Printable) - Matchers.failed(location) do |printer| - matcher.failure_message(printer, actual_value) - end - else - failure_message = matcher.failure_message(actual_value).to_s - Matchers.failed(failure_message, location) + def self.match(matcher, actual_value, failure_message : String? = nil) : MatchFailure? + return matcher.match(actual_value) if matcher.responds_to?(:match) + + if matcher.responds_to?(:matches?) + return if matcher.matches?(actual_value) + + return MatchFailure.new(failure_message) if failure_message + + if matcher.responds_to?(:print_failure_message) + return MatchFailure.new do |printer| + matcher.print_failure_message(printer, actual_value) end end - else + + if matcher.responds_to?(:failure_message) + message = matcher.failure_message(actual_value).to_s + return MatchFailure.new(message) + end + # TODO: Add more information, such as missing methods and suggestions. - raise FrameworkError.new("Matcher #{matcher.class} does not support matching.") + raise FrameworkError.new("Matcher #{matcher.class} must implement `#failure_message` or `#print_failure_message`.") end + + # TODO: Add more information, such as missing methods and suggestions. + raise FrameworkError.new("Object #{matcher} does not support matching.") end - def match_negated(matcher, actual_value, *, - failure_message : String? = nil, - location : Core::LocationRange? = nil) : MatchData - if matcher.responds_to?(:negated_failure_message) - passed = if matcher.responds_to?(:does_not_match?) - matcher.does_not_match?(actual_value) - elsif matcher.responds_to?(:matches?) - !matcher.matches?(actual_value) - else - # TODO: Add more information, such as missing methods and suggestions. - raise FrameworkError.new("Matcher #{matcher.class} does not support negated matching.") - end - if passed - Matchers.passed(location) - else - if failure_message - Matchers.failed(failure_message, location) - elsif matcher.is_a?(Printable) - Matchers.failed(location) do |printer| - matcher.negated_failure_message(printer, actual_value) - end - else - failure_message = matcher.negated_failure_message(actual_value).to_s - Matchers.failed(failure_message, location) - end - end - else + def self.match_negated(matcher, actual_value, failure_message : String? = nil) : MatchFailure? + return matcher.match_negated(actual_value) if matcher.responds_to?(:match_negated) + + # Check if negated matching is supported. + unless failure_message || + matcher.responds_to?(:print_negated_failure_message) || + matcher.responds_to?(:negated_failure_message) # TODO: Add more information, such as missing methods and suggestions. - raise FrameworkError.new("Matcher #{matcher.class} does not support negated matching.") + raise FrameworkError.new("Matcher #{matcher.class} does not support negated matching. Implement `#negated_failure_message` or `#print_negated_failure_message`.") + end + + return if matcher.responds_to?(:does_not_match?) && matcher.does_not_match?(actual_value) + return if matcher.responds_to?(:matches?) && !matcher.matches?(actual_value) + + return MatchFailure.new(failure_message) if failure_message + + if matcher.responds_to?(:print_negated_failure_message) + return MatchFailure.new do |printer| + matcher.print_negated_failure_message(printer, actual_value) + end end + + if matcher.responds_to?(:negated_failure_message) + message = matcher.negated_failure_message(actual_value).to_s + return MatchFailure.new(message) + end + + # TODO: Add more information, such as missing methods and suggestions. + raise FrameworkError.new("Object #{matcher} does not support matching.") end - def match_block(matcher, block, *, - failure_message : String? = nil, - location : Core::LocationRange? = nil) : MatchData - if matcher.responds_to?(:matches?) && matcher.responds_to?(:failure_message) - if matcher.matches?(&block) - Matchers.passed(location) - else - if failure_message - Matchers.failed(failure_message, location) - elsif matcher.is_a?(Printable) - Matchers.failed(location) do |printer| - matcher.failure_message(printer, &block) - end - else - failure_message = matcher.failure_message(&block).to_s - Matchers.failed(failure_message, location) + def self.match_block(matcher, block, failure_message : String? = nil) : MatchFailure? + return matcher.match(&block) if matcher.responds_to?(:match) + + if matcher.responds_to?(:matches?) + return if matcher.matches?(&block) + + return MatchFailure.new(failure_message) if failure_message + + if matcher.responds_to?(:print_failure_message) + return MatchFailure.new do |printer| + matcher.print_failure_message(printer, &block) end end - else + + if matcher.responds_to?(:failure_message) + message = matcher.failure_message(&block).to_s + return MatchFailure.new(message) + end + # TODO: Add more information, such as missing methods and suggestions. - raise FrameworkError.new("Matcher #{matcher.class} does not support matching with a block.") + raise FrameworkError.new("Matcher #{matcher.class} must implement `#failure_message` or `#print_failure_message`.") end + + # TODO: Add more information, such as missing methods and suggestions. + raise FrameworkError.new("Object #{matcher} does not support matching.") end - def match_block_negated(matcher, block, *, - failure_message : String? = nil, - location : Core::LocationRange? = nil) : MatchData - if matcher.responds_to?(:negated_failure_message) - passed = if matcher.responds_to?(:does_not_match?) - matcher.does_not_match?(&block) - elsif matcher.responds_to?(:matches?) - !matcher.matches?(&block) - else - # TODO: Add more information, such as missing methods and suggestions. - raise FrameworkError.new("Matcher #{matcher.class} does not support negated matching with a block.") - end - if passed - Matchers.passed(location) - else - if failure_message - Matchers.failed(failure_message, location) - elsif matcher.is_a?(Printable) - Matchers.failed(location) do |printer| - matcher.negated_failure_message(printer, &block) - end - else - failure_message = matcher.negated_failure_message(&block).to_s - Matchers.failed(failure_message, location) - end - end - else + def self.match_negated_block(matcher, block, failure_message : String? = nil) : MatchFailure? + return matcher.match_negated(&block) if matcher.responds_to?(:match_negated) + + # Check if negated matching is supported. + unless failure_message || + matcher.responds_to?(:print_negated_failure_message) || + matcher.responds_to?(:negated_failure_message) # TODO: Add more information, such as missing methods and suggestions. - raise FrameworkError.new("Matcher #{matcher.class} does not support negated matching with a block.") + raise FrameworkError.new("Matcher #{matcher.class} does not support negated matching. Implement `#negated_failure_message` or `#print_negated_failure_message`.") + end + + return if matcher.responds_to?(:does_not_match?) && matcher.does_not_match?(&block) + return if matcher.responds_to?(:matches?) && !matcher.matches?(&block) + + return MatchFailure.new(failure_message) if failure_message + + if matcher.responds_to?(:print_negated_failure_message) + return MatchFailure.new do |printer| + matcher.print_negated_failure_message(printer, &block) + end end + + if matcher.responds_to?(:negated_failure_message) + message = matcher.negated_failure_message(&block).to_s + return MatchFailure.new(message) + end + + # TODO: Add more information, such as missing methods and suggestions. + raise FrameworkError.new("Object #{matcher} does not support matching.") end end end diff --git a/src/spectator/matchers/matcher_interface.cr b/src/spectator/matchers/matcher_interface.cr new file mode 100644 index 00000000..191a5f80 --- /dev/null +++ b/src/spectator/matchers/matcher_interface.cr @@ -0,0 +1,36 @@ +require "../formatters/printer" +require "./match_failure" + +module Spectator::Matchers + module Matcher + abstract def description + + abstract def match(actual_value) : MatchFailure? + + abstract def match_negated(actual_value) : MatchFailure? + + abstract def matches?(actual_value) + + abstract def matches?(&) + + abstract def does_not_match?(actual_value) + + abstract def does_not_match?(&) + + abstract def failure_message(actual_value) + + abstract def failure_message(&) + + abstract def print_failure_message(printer : Formatters::Printer, actual_value) : Nil + + abstract def print_failure_message(printer : Formatters::Printer, &) : Nil + + abstract def negated_failure_message(actual_value) + + abstract def negated_failure_message(&) + + abstract def print_negated_failure_message(printer : Formatters::Printer, actual_value) : Nil + + abstract def print_negated_failure_message(printer : Formatters::Printer, &) : Nil + end +end