From 95764140ee9f4d908752982c02f1c653d02a662f Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 14 Jul 2022 22:01:33 -0600 Subject: [PATCH 001/120] Add spec for GitLab issue 51 https://gitlab.com/arctic-fox/spectator/-/issues/51 --- spec/issues/gitlab_issue_51_spec.cr | 107 ++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 spec/issues/gitlab_issue_51_spec.cr diff --git a/spec/issues/gitlab_issue_51_spec.cr b/spec/issues/gitlab_issue_51_spec.cr new file mode 100644 index 0000000..c809607 --- /dev/null +++ b/spec/issues/gitlab_issue_51_spec.cr @@ -0,0 +1,107 @@ +require "../spec_helper" + +private class Foo + def call(str : String) : String? + "" + end + + def alt1_call(str : String) : String? + nil + end + + def alt2_call(str : String) : String? + [str, nil].sample + end +end + +private class Bar + def call(a_foo) : Nil # Must add nil restriction here, otherwise a segfault occurs from returning the result of #alt2_call. + a_foo.call("") + a_foo.alt1_call("") + a_foo.alt2_call("") + end +end + +Spectator.describe Bar do + mock Foo, call: "", alt1_call: "", alt2_call: "" + + let(:foo) { mock(Foo) } + subject(:call) { described_class.new.call(foo) } + + describe "#call" do + it "invokes Foo#call" do + call + expect(foo).to have_received(:call) + end + + it "invokes Foo#alt1_call" do + call + expect(foo).to have_received(:alt1_call) + end + + it "invokes Foo#alt2_call" do + call + expect(foo).to have_received(:alt2_call) + end + + describe "with an explicit return of nil" do + it "should invoke Foo#call?" do + allow(foo).to receive(:call).and_return(nil) + call + expect(foo).to have_received(:call) + end + + it "invokes Foo#alt1_call" do + allow(foo).to receive(:alt1_call).and_return(nil) + call + expect(foo).to have_received(:alt1_call) + end + + it "invokes Foo#alt2_call" do + allow(foo).to receive(:alt2_call).and_return(nil) + call + expect(foo).to have_received(:alt2_call) + end + end + + describe "with returns set in before_each for all calls" do + before_each do + allow(foo).to receive(:call).and_return(nil) + allow(foo).to receive(:alt1_call).and_return(nil) + allow(foo).to receive(:alt2_call).and_return(nil) + end + + it "should invoke Foo#call?" do + call + expect(foo).to have_received(:call) + end + + it "should invoke Foo#alt1_call?" do + call + expect(foo).to have_received(:alt1_call) + end + + it "should invoke Foo#alt2_call?" do + call + expect(foo).to have_received(:alt2_call) + end + end + + describe "with returns set in before_each for alt calls only" do + before_each do + allow(foo).to receive(:alt1_call).and_return(nil) + allow(foo).to receive(:alt2_call).and_return(nil) + end + + it "invokes Foo#alt1_call" do + call + expect(foo).to have_received(:alt1_call) + end + + it "invokes Foo#alt2_call" do + call + expect(foo).to have_received(:alt2_call) + end + end + end +end From 18e9c1c35d33c043bc594e8e3fc26ab31de149a5 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 18 Jul 2022 19:47:34 -0600 Subject: [PATCH 002/120] Workaround issue with Box.unbox causing segfault Using Box.unbox on a nil value with a union type causes: Invalid memory access (signal 11) at address 0x8 Related Crystal issue: https://github.com/crystal-lang/crystal/issues/11839 Fixes: https://gitlab.com/arctic-fox/spectator/-/issues/76 --- CHANGELOG.md | 4 ++++ spec/issues/gitlab_issue_76_spec.cr | 6 ++++++ src/spectator/wrapper.cr | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 spec/issues/gitlab_issue_76_spec.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index cc33e24..16896bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Fixed +- Workaround nilable type issue with memoized value. [#76](https://gitlab.com/arctic-fox/spectator/-/issues/76) + ## [0.11.0] - 2022-07-14 ### Changed - Overhauled mock and double system. [#63](https://gitlab.com/arctic-fox/spectator/-/issues/63) diff --git a/spec/issues/gitlab_issue_76_spec.cr b/spec/issues/gitlab_issue_76_spec.cr new file mode 100644 index 0000000..3427af8 --- /dev/null +++ b/spec/issues/gitlab_issue_76_spec.cr @@ -0,0 +1,6 @@ +require "../spec_helper" + +Spectator.describe "GitLab Issue #76" do + let(:value) { nil.as(Int32?) } + specify { expect(value).to be_nil } +end diff --git a/src/spectator/wrapper.cr b/src/spectator/wrapper.cr index 5c79910..9874dec 100644 --- a/src/spectator/wrapper.cr +++ b/src/spectator/wrapper.cr @@ -19,7 +19,12 @@ module Spectator # Retrieves the previously wrapped value. # The *type* of the wrapped value must match otherwise an error will be raised. def get(type : T.class) : T forall T - Box(T).unbox(@pointer) + {% begin %} + {% if T.nilable? %} + @pointer.null? ? nil : + {% end %} + Box(T).unbox(@pointer) + {% end %} end # Retrieves the previously wrapped value. @@ -34,7 +39,12 @@ module Spectator # type = wrapper.get { Int32 } # Returns Int32 # ``` def get(& : -> T) : T forall T - Box(T).unbox(@pointer) + {% begin %} + {% if T.nilable? %} + @pointer.null? ? nil : + {% end %} + Box(T).unbox(@pointer) + {% end %} end end end From 02027cda53e5f3397c3e367d3feb00229243145e Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 18 Jul 2022 19:48:43 -0600 Subject: [PATCH 003/120] Bump version 0.11.1 --- CHANGELOG.md | 3 ++- shard.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16896bb..dbd37ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.11.1] - 2022-07-18 ### Fixed - Workaround nilable type issue with memoized value. [#76](https://gitlab.com/arctic-fox/spectator/-/issues/76) @@ -392,6 +392,7 @@ First version ready for public use. [Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...master +[0.11.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1 [0.11.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.6...v0.11.0 [0.10.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.5...v0.10.6 [0.10.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.4...v0.10.5 diff --git a/shard.yml b/shard.yml index dc55c9b..268741f 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.11.0 +version: 0.11.1 description: | A feature-rich spec testing framework for Crystal with similarities to RSpec. From 17a3ca3ac7680112068c052cbb234effeec175ba Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 7 Aug 2022 14:58:09 -0600 Subject: [PATCH 004/120] Fix https://gitlab.com/arctic-fox/spectator/-/issues/77 --- CHANGELOG.md | 6 +++++- spec/issues/gitlab_issue_77_spec.cr | 10 ++++++++++ src/spectator/dsl/matchers.cr | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 spec/issues/gitlab_issue_77_spec.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd37ac..aadf575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Fixed +- `expect_raises` with block and no arguments produces compilation error. [#77](https://gitlab.com/arctic-fox/spectator/-/issues/77) + ## [0.11.1] - 2022-07-18 ### Fixed - Workaround nilable type issue with memoized value. [#76](https://gitlab.com/arctic-fox/spectator/-/issues/76) @@ -391,7 +395,7 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...master [0.11.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1 [0.11.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.6...v0.11.0 [0.10.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.5...v0.10.6 diff --git a/spec/issues/gitlab_issue_77_spec.cr b/spec/issues/gitlab_issue_77_spec.cr new file mode 100644 index 0000000..f13c1b7 --- /dev/null +++ b/spec/issues/gitlab_issue_77_spec.cr @@ -0,0 +1,10 @@ +require "../spec_helper" + +# https://gitlab.com/arctic-fox/spectator/-/issues/77 +Spectator.describe "GitLab Issue #77" do + it "fails" do + expect_raises do + raise "Error!" + end + end +end diff --git a/src/spectator/dsl/matchers.cr b/src/spectator/dsl/matchers.cr index b42bc88..95e6c5d 100644 --- a/src/spectator/dsl/matchers.cr +++ b/src/spectator/dsl/matchers.cr @@ -790,7 +790,7 @@ module Spectator::DSL # ``` # expect_raises { raise "foobar" } # ``` - macro expect_raises + macro expect_raises(&block) expect {{block}}.to raise_error end From b5c61f900343046065b06f56051e8cc2f6ff26b1 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 7 Aug 2022 15:20:17 -0600 Subject: [PATCH 005/120] Change `-e` to use partial instead of exact match Fixes https://gitlab.com/arctic-fox/spectator/-/issues/71 Fixes https://github.com/icy-arctic-fox/spectator/issues/45 --- CHANGELOG.md | 3 +++ src/spectator/config/cli_arguments_applicator.cr | 2 +- src/spectator/name_node_filter.cr | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aadf575..6851898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `expect_raises` with block and no arguments produces compilation error. [#77](https://gitlab.com/arctic-fox/spectator/-/issues/77) +### Changed +- `-e` (`--example`) CLI option performs a partial match instead of exact match. [#71](https://gitlab.com/arctic-fox/spectator/-/issues/71) [#45](https://github.com/icy-arctic-fox/spectator/issues/45) + ## [0.11.1] - 2022-07-18 ### Fixed - Workaround nilable type issue with memoized value. [#76](https://gitlab.com/arctic-fox/spectator/-/issues/76) diff --git a/src/spectator/config/cli_arguments_applicator.cr b/src/spectator/config/cli_arguments_applicator.cr index 496fc1a..15c9f94 100644 --- a/src/spectator/config/cli_arguments_applicator.cr +++ b/src/spectator/config/cli_arguments_applicator.cr @@ -112,7 +112,7 @@ module Spectator # Adds the example filter option to the parser. private def example_option(parser, builder) parser.on("-e", "--example STRING", "Run examples whose full nested names include STRING") do |pattern| - Log.debug { "Filtering for examples named '#{pattern}' (-e '#{pattern}')" } + Log.debug { "Filtering for examples containing '#{pattern}' (-e '#{pattern}')" } filter = NameNodeFilter.new(pattern) builder.add_node_filter(filter) end diff --git a/src/spectator/name_node_filter.cr b/src/spectator/name_node_filter.cr index 6d4e64a..c404246 100644 --- a/src/spectator/name_node_filter.cr +++ b/src/spectator/name_node_filter.cr @@ -9,7 +9,7 @@ module Spectator # Checks whether the node satisfies the filter. def includes?(node) : Bool - @name == node.to_s + node.to_s.includes?(@name) end end end From d10531430c93a4e0585b8ea672a39459e3b5d15c Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 7 Aug 2022 15:23:05 -0600 Subject: [PATCH 006/120] Bump v0.11.2 --- CHANGELOG.md | 5 +++-- shard.yml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6851898..216992d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.11.2] - 2022-08-07 ### Fixed - `expect_raises` with block and no arguments produces compilation error. [#77](https://gitlab.com/arctic-fox/spectator/-/issues/77) @@ -398,7 +398,8 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...master +[0.11.2]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...v0.11.2 [0.11.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1 [0.11.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.6...v0.11.0 [0.10.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.5...v0.10.6 diff --git a/shard.yml b/shard.yml index 268741f..44dd661 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.11.1 +version: 0.11.2 description: | A feature-rich spec testing framework for Crystal with similarities to RSpec. From 027521a7bc5d3e1aa524df2c7711780c76c206eb Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 29 Aug 2022 18:00:32 -0600 Subject: [PATCH 007/120] ErrorResultBlock only needs the exception, not an ErrorResult --- .../components/error_result_block.cr | 29 +++++++++---------- src/spectator/formatting/summary.cr | 10 +++---- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/spectator/formatting/components/error_result_block.cr b/src/spectator/formatting/components/error_result_block.cr index 353c096..2f9d749 100644 --- a/src/spectator/formatting/components/error_result_block.cr +++ b/src/spectator/formatting/components/error_result_block.cr @@ -7,13 +7,13 @@ module Spectator::Formatting::Components # Displays information about an error result. struct ErrorResultBlock < ResultBlock # Creates the component. - def initialize(example : Example, index : Int32, @result : ErrorResult, subindex = 0) + def initialize(example : Example, index : Int32, @error : Exception, subindex = 0) super(example, index, subindex) end # Content displayed on the second line of the block after the label. private def subtitle - @result.error.message.try(&.each_line.first) + @error.message.try(&.each_line.first) end # Prefix for the second line of the block. @@ -24,19 +24,18 @@ module Spectator::Formatting::Components # Display error information. private def content(io) # Fetch the error and message. - error = @result.error - lines = error.message.try(&.lines) + lines = @error.message.try(&.lines) # Write the error and message if available. case - when lines.nil? then write_error_class(io, error) - when lines.size == 1 then write_error_message(io, error, lines.first) - when lines.size > 1 then write_multiline_error_message(io, error, lines) - else write_error_class(io, error) + when lines.nil? then write_error_class(io) + when lines.size == 1 then write_error_message(io, lines.first) + when lines.size > 1 then write_multiline_error_message(io, lines) + else write_error_class(io) end # Display the backtrace if it's available. - if backtrace = error.backtrace? + if backtrace = @error.backtrace? indent { write_backtrace(io, backtrace) } end @@ -44,24 +43,24 @@ module Spectator::Formatting::Components end # Display just the error type. - private def write_error_class(io, error) + private def write_error_class(io) line(io) do - io << error.class.colorize(:red) + io << @error.class.colorize(:red) end end # Display the error type and first line of the message. - private def write_error_message(io, error, message) + private def write_error_message(io, message) line(io) do - io << "#{error.class}: ".colorize(:red) + io << "#{@error.class}: ".colorize(:red) io << message end end # Display the error type and its multi-line message. - private def write_multiline_error_message(io, error, lines) + private def write_multiline_error_message(io, lines) # Use the normal formatting for the first line. - write_error_message(io, error, lines.first) + write_error_message(io, lines.first) # Display additional lines after the first. lines.skip(1).each do |entry| diff --git a/src/spectator/formatting/summary.cr b/src/spectator/formatting/summary.cr index 50a2b1b..b1ed247 100644 --- a/src/spectator/formatting/summary.cr +++ b/src/spectator/formatting/summary.cr @@ -63,15 +63,15 @@ module Spectator::Formatting # Displays one or more blocks for a failed example. # Each block is a failed expectation or error raised in the example. private def dump_failed_example(example, index) - result = example.result.as?(ErrorResult) + error = example.result.as?(ErrorResult).try(&.error) failed_expectations = example.result.expectations.select(&.failed?) block_count = failed_expectations.size - block_count += 1 if result + block_count += 1 if error # Don't use sub-index if there was only one problem. if block_count == 1 - if result - io.puts Components::ErrorResultBlock.new(example, index, result) + if error + io.puts Components::ErrorResultBlock.new(example, index, error) else io.puts Components::FailResultBlock.new(example, index, failed_expectations.first) end @@ -79,7 +79,7 @@ module Spectator::Formatting failed_expectations.each_with_index(1) do |expectation, subindex| io.puts Components::FailResultBlock.new(example, index, expectation, subindex) end - io.puts Components::ErrorResultBlock.new(example, index, result, block_count) if result + io.puts Components::ErrorResultBlock.new(example, index, error, block_count) if error end end end From 9d6d8de72f44f40f3f4e265fe4b21178231ce1a8 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 29 Aug 2022 20:53:48 -0600 Subject: [PATCH 008/120] Show error block for forced failure - `fail` Fixes https://gitlab.com/arctic-fox/spectator/-/issues/78 --- .../formatting/components/error_result_block.cr | 5 ++++- src/spectator/formatting/summary.cr | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/spectator/formatting/components/error_result_block.cr b/src/spectator/formatting/components/error_result_block.cr index 2f9d749..3eb96fc 100644 --- a/src/spectator/formatting/components/error_result_block.cr +++ b/src/spectator/formatting/components/error_result_block.cr @@ -18,7 +18,10 @@ module Spectator::Formatting::Components # Prefix for the second line of the block. private def subtitle_label - "Error: ".colorize(:red) + case @error + when ExampleFailed then "Failed: " + else "Error: " + end.colorize(:red) end # Display error information. diff --git a/src/spectator/formatting/summary.cr b/src/spectator/formatting/summary.cr index b1ed247..b866307 100644 --- a/src/spectator/formatting/summary.cr +++ b/src/spectator/formatting/summary.cr @@ -63,10 +63,17 @@ module Spectator::Formatting # Displays one or more blocks for a failed example. # Each block is a failed expectation or error raised in the example. private def dump_failed_example(example, index) - error = example.result.as?(ErrorResult).try(&.error) + # Retrieve the ultimate reason for failing. + error = example.result.as?(FailResult).try(&.error) + + # Prevent displaying duplicated output from expectation. + # Display `ExampleFailed` but not `ExpectationFailed`. + error = nil if error.responds_to?(:expectation) + + # Gather all failed expectations. failed_expectations = example.result.expectations.select(&.failed?) block_count = failed_expectations.size - block_count += 1 if error + block_count += 1 if error # Add an extra block for final error if it's significant. # Don't use sub-index if there was only one problem. if block_count == 1 From 0505f210f9a9ffde1afb85e64a012977cfc543cf Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 29 Aug 2022 20:56:26 -0600 Subject: [PATCH 009/120] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 216992d..736533c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Fixed +- Display error block (failure message and stack trace) when using `fail`. [#78](https://gitlab.com/arctic-fox/spectator/-/issues/78) + ## [0.11.2] - 2022-08-07 ### Fixed - `expect_raises` with block and no arguments produces compilation error. [#77](https://gitlab.com/arctic-fox/spectator/-/issues/77) From 7549351cce93ccd79b920a79cb0ec9b28a99d8c6 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 29 Aug 2022 21:13:58 -0600 Subject: [PATCH 010/120] Match tense of failure block component --- src/spectator/formatting/components/error_result_block.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spectator/formatting/components/error_result_block.cr b/src/spectator/formatting/components/error_result_block.cr index 3eb96fc..a24784a 100644 --- a/src/spectator/formatting/components/error_result_block.cr +++ b/src/spectator/formatting/components/error_result_block.cr @@ -19,7 +19,7 @@ module Spectator::Formatting::Components # Prefix for the second line of the block. private def subtitle_label case @error - when ExampleFailed then "Failed: " + when ExampleFailed then "Failure: " else "Error: " end.colorize(:red) end From ccdf9f124b1cce7af5a30cd2af5165be0425fc4e Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 3 Sep 2022 16:35:38 -0600 Subject: [PATCH 011/120] Add require statements and namespace Value When defining a matcher outside of the `Spectator` module (custom matcher), `Value(ExpectedType)` can't be resolved. I suspect this is a Crystal compiler bug, since a derived class should not affect lookups of parent classes like this. Require statements are added to (hopefully) ensure `Spectator::Value` is defined for the initializer. Related to https://github.com/icy-arctic-fox/spectator/issues/46 --- src/spectator/matchers/value_matcher.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/spectator/matchers/value_matcher.cr b/src/spectator/matchers/value_matcher.cr index 760578b..a88f457 100644 --- a/src/spectator/matchers/value_matcher.cr +++ b/src/spectator/matchers/value_matcher.cr @@ -1,3 +1,5 @@ +require "../expression" +require "../value" require "./standard_matcher" module Spectator::Matchers @@ -22,7 +24,7 @@ module Spectator::Matchers # Creates the value matcher. # The expected value is stored for later use. - def initialize(@expected : Value(ExpectedType)) + def initialize(@expected : ::Spectator::Value(ExpectedType)) end # Additional information about the match failure. From 079272c9de0a0a0e65f96caad7b3bd83dbd6252e Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 3 Sep 2022 16:41:03 -0600 Subject: [PATCH 012/120] Add spec for custom matchers docs Related: https://github.com/icy-arctic-fox/spectator/issues/46 --- spec/docs/custom_matchers_spec.cr | 91 +++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 spec/docs/custom_matchers_spec.cr diff --git a/spec/docs/custom_matchers_spec.cr b/spec/docs/custom_matchers_spec.cr new file mode 100644 index 0000000..183e42a --- /dev/null +++ b/spec/docs/custom_matchers_spec.cr @@ -0,0 +1,91 @@ +require "../spec_helper" + +# https://gitlab.com/arctic-fox/spectator/-/wikis/Custom-Matchers +Spectator.describe "Custom Matchers Docs" do + context "value matcher" do + # Sub-type of Matcher to suit our needs. + # Notice this is a struct. + struct MultipleOfMatcher(ExpectedType) < Spectator::Matchers::ValueMatcher(ExpectedType) + # 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. + def description : String + "is a multiple of #{expected.label}" + end + + # Checks whether the matcher is satisfied with the expression given to it. + private def match?(actual : Spectator::Expression(T)) : Bool forall T + actual.value % expected.value == 0 + end + + # Message displayed when the matcher isn't satisfied. + # The message should typically only contain the test expression labels. + private def failure_message(actual : Spectator::Expression(T)) : String forall T + "#{actual.label} is not a multiple of #{expected.label}" + end + + # Message displayed when the matcher isn't satisfied and is negated. + # This is essentially what would satisfy the matcher if it wasn't negated. + # The message should typically only contain the test expression labels. + private def failure_message_when_negated(actual : Spectator::Expression(T)) : String forall T + "#{actual.label} is a multiple of #{expected.label}" + end + end + + # The DSL portion of the matcher. + # This captures the test expression and creates an instance of the matcher. + macro be_a_multiple_of(expected) + %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}}) + MultipleOfMatcher.new(%value) + end + + specify do + expect(9).to be_a_multiple_of(3) + # or negated: + expect(5).to_not be_a_multiple_of(2) + end + + specify "failure messages" do + expect { expect(9).to be_a_multiple_of(5) }.to raise_error(Spectator::ExpectationFailed, "9 is not a multiple of 5") + expect { expect(6).to_not be_a_multiple_of(3) }.to raise_error(Spectator::ExpectationFailed, "6 is a multiple of 3") + end + end + + context "standard matcher" do + struct OddMatcher < Spectator::Matchers::StandardMatcher + def description : String + "is odd" + end + + private def match?(actual : Spectator::Expression(T)) : Bool forall T + actual.value % 2 == 1 + end + + private def failure_message(actual : Spectator::Expression(T)) : String forall T + "#{actual.label} is not odd" + end + + private def failure_message_when_negated(actual : Spectator::Expression(T)) : String forall T + "#{actual.label} is odd" + end + + private def does_not_match?(actual : Spectator::Expression(T)) : Bool forall T + actual.value % 2 == 0 + end + end + + macro be_odd + OddMatcher.new + end + + specify do + expect(9).to be_odd + expect(2).to_not be_odd + end + + specify "failure messages" do + expect { expect(2).to be_odd }.to raise_error(Spectator::ExpectationFailed, "2 is not odd") + expect { expect(3).to_not be_odd }.to raise_error(Spectator::ExpectationFailed, "3 is odd") + end + end +end From 1998edbbb2f1cc1c22013f233f7e3b2683370b20 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 3 Sep 2022 16:48:15 -0600 Subject: [PATCH 013/120] Release v0.11.3 --- CHANGELOG.md | 6 ++++-- shard.yml | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 736533c..6c1ec54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.11.3] - 2022-09-03 ### Fixed - Display error block (failure message and stack trace) when using `fail`. [#78](https://gitlab.com/arctic-fox/spectator/-/issues/78) +- Defining a custom matcher outside of the `Spectator` namespace no longer produces a compilation error. [#46](https://github.com/icy-arctic-fox/spectator/issues/46) ## [0.11.2] - 2022-08-07 ### Fixed @@ -402,7 +403,8 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...master +[0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...v0.11.3 [0.11.2]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...v0.11.2 [0.11.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1 [0.11.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.6...v0.11.0 diff --git a/shard.yml b/shard.yml index 44dd661..c30a45f 100644 --- a/shard.yml +++ b/shard.yml @@ -1,7 +1,7 @@ name: spectator -version: 0.11.2 +version: 0.11.3 description: | - A feature-rich spec testing framework for Crystal with similarities to RSpec. + Feature-rich testing framework for Crystal inspired by RSpec. authors: - Michael Miller From 4dfa5ccb6e1a21299d69f489bb442fc2e005f561 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 8 Oct 2022 14:04:02 -0600 Subject: [PATCH 014/120] Prevent defining stubs on undefined methods in LazyDouble In Crystal 1.6, a segfault would occur in the spec spec/spectator/mocks/lazy_double_spec.cr:238 I suspect this is a Crystal bug of some kind, but can't reduce it. The methods produced by `method_missing` don't have a return type including Symbol. Symbol is excluded from the union of return types (Int32 | String | Nil). The program segfaults when calling a method on the actual value, which is a symbol. It ultimately crashes when producing a failure message, which indicates the value it tested doesn't equal the expected value (a symbol of the same value). Avoid this issue by preventing stubs on undefined/untyped methods. --- CHANGELOG.md | 4 ++++ spec/spectator/mocks/lazy_double_spec.cr | 20 ++------------------ src/spectator/mocks/lazy_double.cr | 9 +++++++++ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1ec54..133aa2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Removed +- Removed support for stubbing undefined (untyped) methods in lazy doubles. Avoids possible segfault. + ## [0.11.3] - 2022-09-03 ### Fixed - Display error block (failure message and stack trace) when using `fail`. [#78](https://gitlab.com/arctic-fox/spectator/-/issues/78) diff --git a/spec/spectator/mocks/lazy_double_spec.cr b/spec/spectator/mocks/lazy_double_spec.cr index 34883dc..902922f 100644 --- a/spec/spectator/mocks/lazy_double_spec.cr +++ b/spec/spectator/mocks/lazy_double_spec.cr @@ -235,16 +235,9 @@ Spectator.describe Spectator::LazyDouble do end context "with previously undefined methods" do - it "can stub methods" do + it "raises an error" do stub = Spectator::ValueStub.new(:baz, :xyz) - dbl._spectator_define_stub(stub) - expect(dbl.baz).to eq(:xyz) - end - - it "uses a stub only if an argument constraint is met" do - stub = Spectator::ValueStub.new(:baz, :xyz, Spectator::Arguments.capture(:right)) - dbl._spectator_define_stub(stub) - expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/) + expect { dbl._spectator_define_stub(stub) }.to raise_error(/stub/) end end end @@ -258,15 +251,6 @@ Spectator.describe Spectator::LazyDouble do it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42) end - - it "raises on methods without an implementation" do - stub = Spectator::ValueStub.new(:baz, :xyz) - dbl._spectator_define_stub(stub) - expect(dbl.baz).to eq(:xyz) - - dbl._spectator_clear_stubs - expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/) - end end describe "#_spectator_calls" do diff --git a/src/spectator/mocks/lazy_double.cr b/src/spectator/mocks/lazy_double.cr index af3925d..a0158ef 100644 --- a/src/spectator/mocks/lazy_double.cr +++ b/src/spectator/mocks/lazy_double.cr @@ -26,6 +26,15 @@ module Spectator super(_spectator_double_stubs + message_stubs) end + # Defines a stub to change the behavior of a method in this double. + # + # NOTE: Defining a stub for a method not defined in the double's type raises an error. + protected def _spectator_define_stub(stub : Stub) : Nil + return super if Messages.types.has_key?(stub.method) + + raise "Can't define stub #{stub} on lazy double because it wasn't initially defined." + end + # Returns the double's name formatted for user output. private def _spectator_stubbed_name : String "#" From c1e166644963f3d637bc196e0c4d0133e521afdf Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 8 Oct 2022 14:05:53 -0600 Subject: [PATCH 015/120] Formatting --- spec/docs/custom_matchers_spec.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/docs/custom_matchers_spec.cr b/spec/docs/custom_matchers_spec.cr index 183e42a..d3ee565 100644 --- a/spec/docs/custom_matchers_spec.cr +++ b/spec/docs/custom_matchers_spec.cr @@ -75,8 +75,8 @@ Spectator.describe "Custom Matchers Docs" do end macro be_odd - OddMatcher.new - end + OddMatcher.new + end specify do expect(9).to be_odd From 422b0efa59bf82f77972223e2173c232bb685f30 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 12:33:31 -0600 Subject: [PATCH 016/120] Update test to account for fix in Crystal 1.6 Keyword arguments cannot be used as a short-hand for positional arguments (yet). https://github.com/icy-arctic-fox/spectator/issues/44 --- spec/issues/github_issue_44_spec.cr | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/spec/issues/github_issue_44_spec.cr b/spec/issues/github_issue_44_spec.cr index 0ab646d..d1b9716 100644 --- a/spec/issues/github_issue_44_spec.cr +++ b/spec/issues/github_issue_44_spec.cr @@ -9,12 +9,29 @@ Spectator.describe "GitHub Issue #44" do let(command) { "ls -l" } let(exception) { File::NotFoundError.new("File not found", file: "test.file") } - before_each do - expect(Process).to receive(:run).with(command, shell: true, output: :pipe).and_raise(exception) + context "with positional arguments" do + before_each do + pipe = Process::Redirect::Pipe + expect(Process).to receive(:run).with(command, nil, nil, false, true, pipe, pipe, pipe, nil).and_raise(exception) + end + + it "must stub Process.run" do + expect do + Process.run(command, shell: true, output: :pipe) do |_process| + end + end.to raise_error(File::NotFoundError, "File not found") + end end - skip "must stub Process.run", skip: "Method mock not applied" do - Process.run(command, shell: true, output: :pipe) do |_process| + # Original issue uses keyword arguments in place of positional arguments. + context "keyword arguments in place of positional arguments" do + before_each do + expect(Process).to receive(:run).with(command, shell: true, output: :pipe).and_raise(exception) + end + + it "must stub Process.run", skip: "Keyword arguments in place of positional arguments not supported with expect-receive" do + Process.run(command, shell: true, output: :pipe) do |_process| + end end end end From 25b99310022b8440c35af797829f9015506c79b8 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 13:38:29 -0600 Subject: [PATCH 017/120] Add ability to remove specific stubs --- src/spectator/mocks/double.cr | 5 +++++ src/spectator/mocks/mock.cr | 8 ++++++++ src/spectator/mocks/mocked.cr | 4 ++++ src/spectator/mocks/reference_mock_registry.cr | 6 ++++++ src/spectator/mocks/stubbable.cr | 3 +++ src/spectator/mocks/stubbed_type.cr | 4 ++++ src/spectator/mocks/value_mock_registry.cr | 6 ++++++ 7 files changed, 36 insertions(+) diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index 8562ca6..ad35dda 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -103,6 +103,11 @@ module Spectator @stubs.unshift(stub) end + protected def _spectator_remove_stub(stub : Stub) : Nil + Log.debug { "Removing stub #{stub} from #{_spectator_stubbed_name}" } + @stubs.delete(stub) + end + protected def _spectator_clear_stubs : Nil Log.debug { "Clearing stubs for #{_spectator_stubbed_name}" } @stubs.clear diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index 174d183..901d088 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -50,6 +50,10 @@ module Spectator end {% end %} + def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil + @_spectator_stubs.try &.delete(stub) + end + def _spectator_clear_stubs : Nil @_spectator_stubs = nil end @@ -139,6 +143,10 @@ module Spectator entry.stubs end + def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil + @@_spectator_mock_registry[self]?.try &.stubs.delete(stub) + end + def _spectator_clear_stubs : Nil @@_spectator_mock_registry.delete(self) end diff --git a/src/spectator/mocks/mocked.cr b/src/spectator/mocks/mocked.cr index be25ef0..280eef8 100644 --- a/src/spectator/mocks/mocked.cr +++ b/src/spectator/mocks/mocked.cr @@ -26,6 +26,10 @@ module Spectator _spectator_stubs.unshift(stub) end + def _spectator_remove_stub(stub : Stub) : Nil + _spectator_stubs.delete(stub) + end + def _spectator_clear_stubs : Nil _spectator_stubs.clear end diff --git a/src/spectator/mocks/reference_mock_registry.cr b/src/spectator/mocks/reference_mock_registry.cr index 20481f0..84227d1 100644 --- a/src/spectator/mocks/reference_mock_registry.cr +++ b/src/spectator/mocks/reference_mock_registry.cr @@ -25,6 +25,12 @@ module Spectator @entries[key] end + # Retrieves all stubs defined for a mocked object or nil if the object isn't mocked yet. + def []?(object : Reference) + key = Box.box(object) + @entries[key]? + end + # Retrieves all stubs defined for a mocked object. # # Yields to the block on the first retrieval. diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index aa02976..26bfea9 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -28,6 +28,9 @@ module Spectator # Defines a stub to change the behavior of a method. abstract def _spectator_define_stub(stub : Stub) : Nil + # Removes a specific, previously defined stub. + abstract def _spectator_remove_stub(stub : Stub) : Nil + # Clears all previously defined stubs. abstract def _spectator_clear_stubs : Nil diff --git a/src/spectator/mocks/stubbed_type.cr b/src/spectator/mocks/stubbed_type.cr index a5588ee..5362b84 100644 --- a/src/spectator/mocks/stubbed_type.cr +++ b/src/spectator/mocks/stubbed_type.cr @@ -20,6 +20,10 @@ module Spectator _spectator_stubs.unshift(stub) end + def _spectator_remove_stub(stub : Stub) : Nil + _spectator_stubs.delete(stub) + end + def _spectator_clear_stubs : Nil _spectator_stubs.clear end diff --git a/src/spectator/mocks/value_mock_registry.cr b/src/spectator/mocks/value_mock_registry.cr index 5763509..1efd0b0 100644 --- a/src/spectator/mocks/value_mock_registry.cr +++ b/src/spectator/mocks/value_mock_registry.cr @@ -29,6 +29,12 @@ module Spectator @entries[key] end + # Retrieves all stubs defined for a mocked object or nil if the object isn't mocked yet. + def []?(object : T) + key = value_bytes(object) + @entries[key]? + end + # Retrieves all stubs defined for a mocked object. # # Yields to the block on the first retrieval. From 5c910e5a85fa3342f7587de4d0a4e49e10c567f6 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 13:57:28 -0600 Subject: [PATCH 018/120] Clear stubs defined with `expect().to receive()` syntax after test finishes --- CHANGELOG.md | 3 +++ spec/docs/mocks_spec.cr | 4 ++-- src/spectator/expectation.cr | 24 ++++++++++++++++++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 133aa2d..94f89bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Clear stubs defined with `expect().to receive()` syntax after test finishes to prevent leakage between tests. + ### Removed - Removed support for stubbing undefined (untyped) methods in lazy doubles. Avoids possible segfault. diff --git a/spec/docs/mocks_spec.cr b/spec/docs/mocks_spec.cr index 6e164ae..5908787 100644 --- a/spec/docs/mocks_spec.cr +++ b/spec/docs/mocks_spec.cr @@ -146,9 +146,9 @@ Spectator.describe "Mocks Docs" do inst.something end - it "leaks stubs to other examples" do + it "reverts to default stub for other examples" do inst = mock(MyStruct) - expect(inst.something).to eq(7) # Previous stub was leaked. + expect(inst.something).to eq(5) # Default stub used instead of original behavior. end end end diff --git a/src/spectator/expectation.cr b/src/spectator/expectation.cr index bfc0248..8028429 100644 --- a/src/spectator/expectation.cr +++ b/src/spectator/expectation.cr @@ -160,9 +160,17 @@ module Spectator stubbable._spectator_define_stub(unconstrained_stub) end + # Apply the stub that is expected to be called. stubbable._spectator_define_stub(stub) - matcher = Matchers::ReceiveMatcher.new(stub) - to_eventually(matcher, message) + + # Check if the stub was invoked after the test completes. + Harness.current.defer do + matcher = Matchers::ReceiveMatcher.new(stub) + to(matcher, message) + ensure + # Prevent leaking stubs between tests. + stubbable._spectator_remove_stub(stub) + end end # Asserts that some criteria defined by the matcher is eventually satisfied. @@ -190,9 +198,17 @@ module Spectator stubbable._spectator_define_stub(unconstrained_stub) end + # Apply the stub that could be called in case it is. stubbable._spectator_define_stub(stub) - matcher = Matchers::ReceiveMatcher.new(stub) - to_never(matcher, message) + + # Check if the stub was invoked after the test completes. + Harness.current.defer do + matcher = Matchers::ReceiveMatcher.new(stub) + to_not(matcher, message) + ensure + # Prevent leaking stubs between tests. + stubbable._spectator_remove_stub(stub) + end end # :ditto: From e9d3f31ac30ed025090d5909ec4a5321ad74be0b Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 15:32:32 -0600 Subject: [PATCH 019/120] Use harness' cleanup instead of defer --- src/spectator/expectation.cr | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/spectator/expectation.cr b/src/spectator/expectation.cr index 8028429..780e299 100644 --- a/src/spectator/expectation.cr +++ b/src/spectator/expectation.cr @@ -164,13 +164,11 @@ module Spectator stubbable._spectator_define_stub(stub) # Check if the stub was invoked after the test completes. - Harness.current.defer do - matcher = Matchers::ReceiveMatcher.new(stub) - to(matcher, message) - ensure - # Prevent leaking stubs between tests. - stubbable._spectator_remove_stub(stub) - end + matcher = Matchers::ReceiveMatcher.new(stub) + Harness.current.defer { to(matcher, message) } + + # Prevent leaking stubs between tests. + Harness.current.cleanup { stubbable._spectator_remove_stub(stub) } end # Asserts that some criteria defined by the matcher is eventually satisfied. @@ -202,13 +200,11 @@ module Spectator stubbable._spectator_define_stub(stub) # Check if the stub was invoked after the test completes. - Harness.current.defer do - matcher = Matchers::ReceiveMatcher.new(stub) - to_not(matcher, message) - ensure - # Prevent leaking stubs between tests. - stubbable._spectator_remove_stub(stub) - end + matcher = Matchers::ReceiveMatcher.new(stub) + Harness.current.defer { to_not(matcher, message) } + + # Prevent leaking stubs between tests. + Harness.current.cleanup { stubbable._spectator_remove_stub(stub) } end # :ditto: From 2516803b0d0e2e3fea4486481cd1351ff2f5d800 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 15:33:19 -0600 Subject: [PATCH 020/120] Add spec for GitHub issue 47 https://github.com/icy-arctic-fox/spectator/issues/47 --- spec/issues/github_issue_47_spec.cr | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 spec/issues/github_issue_47_spec.cr diff --git a/spec/issues/github_issue_47_spec.cr b/spec/issues/github_issue_47_spec.cr new file mode 100644 index 0000000..3576a2d --- /dev/null +++ b/spec/issues/github_issue_47_spec.cr @@ -0,0 +1,18 @@ +require "../spec_helper" + +Spectator.describe "GitHub Issue #47" do + class Original + def foo(arg1, arg2) + # ... + end + end + + mock Original + + let(fake) { mock(Original) } + + specify do + expect(fake).to receive(:foo).with("arg1", arg2: "arg2") + fake.foo("arg1", "arg2") + end +end From 090c95b162d3299953f6648cdbb36b31a6d0eea0 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 15:48:00 -0600 Subject: [PATCH 021/120] Ensure stubs defined with allow syntax are cleared --- CHANGELOG.md | 1 + spec/spectator/mocks/allow_spec.cr | 26 ++++++++++++++++++++++++++ src/spectator/mocks/allow.cr | 2 ++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f89bf..7141cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed - Clear stubs defined with `expect().to receive()` syntax after test finishes to prevent leakage between tests. +- Ensure stubs defined with `allow().to receive()` syntax are cleared after test finishes when used inside a test (another leakage). ### Removed - Removed support for stubbing undefined (untyped) methods in lazy doubles. Avoids possible segfault. diff --git a/spec/spectator/mocks/allow_spec.cr b/spec/spectator/mocks/allow_spec.cr index 5bc16a8..090014e 100644 --- a/spec/spectator/mocks/allow_spec.cr +++ b/spec/spectator/mocks/allow_spec.cr @@ -9,5 +9,31 @@ Spectator.describe Spectator::Allow do it "applies a stub" do expect { alw.to(stub) }.to change { dbl.foo }.from(42).to(123) end + + context "leak" do + class Thing + def foo + 42 + end + end + + mock Thing + + getter(thing : Thing) { mock(Thing) } + + # Workaround type restrictions requiring a constant. + def fake + class_mock(Thing).cast(thing) + end + + specify do + expect { allow(fake).to(stub) }.to change { fake.foo }.from(42).to(123) + end + + # This example must be run after the previous (random order may break this). + it "clears the stub after the example completes" do + expect { fake.foo }.to eq(42) + end + end end end diff --git a/src/spectator/mocks/allow.cr b/src/spectator/mocks/allow.cr index dd163b2..4754690 100644 --- a/src/spectator/mocks/allow.cr +++ b/src/spectator/mocks/allow.cr @@ -1,3 +1,4 @@ +require "../harness" require "./stub" require "./stubbable" require "./stubbed_type" @@ -21,6 +22,7 @@ module Spectator # Applies a stub to the targeted stubbable object. def to(stub : Stub) : Nil @target._spectator_define_stub(stub) + Harness.current?.try &.cleanup { @target._spectator_remove_stub(stub) } end end end From 8e83edcc35d913c9ee9253a25f49eafb8eb86d42 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 16:04:07 -0600 Subject: [PATCH 022/120] Simpler conditional block inclusion --- src/spectator/dsl/mocks.cr | 2 +- src/spectator/mocks/double.cr | 2 +- src/spectator/mocks/mock.cr | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index 628fe6b..94a41f9 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -38,7 +38,7 @@ module Spectator::DSL {{null_double_type_name}}.new(@stubs) end - {% if block %}{{block.body}}{% end %} + {{block.body if block}} end {% begin %} diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index ad35dda..b45b9c8 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -61,7 +61,7 @@ module Spectator end {% end %} - {% if block %}{{block.body}}{% end %} + {{block.body if block}} end end diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index 901d088..ba72b37 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -84,7 +84,7 @@ module Spectator macro finished stub_type {{mocked_type.id}} - {% if block %}{{block.body}}{% end %} + {{block.body if block}} end end {% end %} @@ -189,7 +189,7 @@ module Spectator macro finished stub_type {{type_name.id}} - {% if block %}{{block.body}}{% end %} + {{block.body if block}} end end {% end %} From 11e227b29f5a8d68d651f21f99e6d5cd2b5049bf Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 16:24:28 -0600 Subject: [PATCH 023/120] Simplify method receiver conditional --- src/spectator/mocks/stubbable.cr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 26bfea9..061cbe1 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -118,7 +118,7 @@ module Spectator {% raise "Default stub cannot be an abstract method" if method.abstract? %} {% raise "Cannot stub method with reserved keyword as name - #{method.name}" if method.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %} - {{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{visibility.id if visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} @@ -133,7 +133,7 @@ module Spectator # This chunk of code must reconstruct the method signature exactly as it was originally. # If it doesn't match, it doesn't override the method and the stubbing won't work. %} - {{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{visibility.id if visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} @@ -214,7 +214,7 @@ module Spectator %} {% unless method.abstract? %} - {{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{visibility.id if visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} @@ -230,7 +230,7 @@ module Spectator # This chunk of code must reconstruct the method signature exactly as it was originally. # If it doesn't match, it doesn't override the method and the stubbing won't work. %} - {{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{visibility.id if visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} @@ -336,7 +336,7 @@ module Spectator meth.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) end %} - {{(method.abstract? ? :abstract_stub : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{(method.abstract? ? :abstract_stub : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} @@ -363,7 +363,7 @@ module Spectator meth.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) end %} - {{(method.abstract? ? :"abstract_stub abstract" : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{(method.abstract? ? :"abstract_stub abstract" : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} From bc0a9c03c939d2cfb7f4d7cc12be0e28ea6c3a43 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 16:47:54 -0600 Subject: [PATCH 024/120] Remove runtime compilation tests These may be readded later. Right now they're failing because the GitHub issue 44 spec changes the behavior of Process.run. The changes made by that spec shouldn't leak, but to fix correctly requires substantial changes. These runtime tests provide little value right now and slow down testing. --- .gitlab-ci.yml | 2 +- spec/helpers/.gitkeep | 0 spec/helpers/example.cr | 71 ------------------------------------ spec/helpers/example.ecr | 5 --- spec/helpers/expectation.cr | 28 -------------- spec/helpers/result.cr | 67 ---------------------------------- spec/runtime_example_spec.cr | 58 ----------------------------- spec/spec_helper.cr | 32 ---------------- 8 files changed, 1 insertion(+), 262 deletions(-) create mode 100644 spec/helpers/.gitkeep delete mode 100644 spec/helpers/example.cr delete mode 100644 spec/helpers/example.ecr delete mode 100644 spec/helpers/expectation.cr delete mode 100644 spec/helpers/result.cr delete mode 100644 spec/runtime_example_spec.cr diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b3adb42..d627d27 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,7 +13,7 @@ before_script: spec: script: - - crystal spec --error-on-warnings --junit_output=. spec/runtime_example_spec.cr spec/matchers/ spec/spectator/*.cr + - crystal spec --error-on-warnings --junit_output=. spec/matchers/ spec/spectator/*.cr artifacts: when: always paths: diff --git a/spec/helpers/.gitkeep b/spec/helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/helpers/example.cr b/spec/helpers/example.cr deleted file mode 100644 index 34034ec..0000000 --- a/spec/helpers/example.cr +++ /dev/null @@ -1,71 +0,0 @@ -require "ecr" -require "json" -require "./result" - -module Spectator::SpecHelpers - # Wrapper for compiling and running an example at runtime and getting a result. - class Example - # Creates the example. - # The *spec_helper_path* is the path to spec_helper.cr file. - # The name or ID of the example is given by *example_id*. - # Lastly, the source code for the example is given by *example_code*. - def initialize(@spec_helper_path : String, @example_id : String, @example_code : String) - end - - # Instructs the Crystal compiler to compile the test. - # Returns an instance of `JSON::Any`. - # This will be the outcome and information about the test. - # Output will be suppressed for the test. - # If an error occurs while attempting to compile and run the test, an error will be raised. - def compile - # Create a temporary file containing the test. - with_tempfile do |source_file| - args = ["run", "--no-color", source_file, "--", "--json"] - Process.run(crystal_executable, args) do |process| - JSON.parse(process.output) - rescue JSON::ParseException - raise "Compilation of example #{@example_id} failed\n\n#{process.error.gets_to_end}" - end - end - end - - # Same as `#compile`, but returns the result of the first example in the test. - # Returns a `SpectatorHelpers::Result` instance. - def result - output = compile - example = output["examples"][0] - Result.from_json_any(example) - end - - # Constructs the string representation of the example. - # This produces the Crystal source code. - # *io* is the file handle to write to. - # The *dir* is the directory of the file being written to. - # This is needed to resolve the relative path to the spec_helper.cr file. - private def write(io, dir) - spec_helper_path = Path[@spec_helper_path].relative_to(dir) # ameba:disable Lint/UselessAssign - ECR.embed(__DIR__ + "/example.ecr", io) - end - - # Creates a temporary file containing the compilable example code. - # Yields the path of the temporary file. - # Ensures the file is deleted after it is done being used. - private def with_tempfile - tempfile = File.tempfile("_#{@example_id}_spec.cr") do |file| - dir = File.dirname(file.path) - write(file, dir) - end - - begin - yield tempfile.path - ensure - tempfile.delete - end - end - - # Attempts to find the Crystal compiler on the system or raises an error. - private def crystal_executable - Process.find_executable("crystal") || raise("Could not find Crystal compiler") - end - end -end diff --git a/spec/helpers/example.ecr b/spec/helpers/example.ecr deleted file mode 100644 index 53355bf..0000000 --- a/spec/helpers/example.ecr +++ /dev/null @@ -1,5 +0,0 @@ -require "<%= spec_helper_path %>" - -Spectator.describe "<%= @example_id %>" do - <%= @example_code %> -end diff --git a/spec/helpers/expectation.cr b/spec/helpers/expectation.cr deleted file mode 100644 index fd4d84d..0000000 --- a/spec/helpers/expectation.cr +++ /dev/null @@ -1,28 +0,0 @@ -module Spectator::SpecHelpers - # Information about an `expect` call in an example. - struct Expectation - # Indicates whether the expectation passed or failed. - getter? satisfied : Bool - - # Message when the expectation failed. - # Only available when `#satisfied?` is false. - getter! message : String - - # Additional information about the expectation. - # Only available when `#satisfied?` is false. - getter! values : Hash(String, String) - - # Creates the expectation outcome. - def initialize(@satisfied, @message, @values) - end - - # Extracts the expectation information from a `JSON::Any` object. - def self.from_json_any(object : JSON::Any) - satisfied = object["satisfied"].as_bool - message = object["failure"]?.try(&.as_s?) - values = object["values"]?.try(&.as_h?) - values = values.transform_values(&.as_s) if values - new(satisfied, message, values) - end - end -end diff --git a/spec/helpers/result.cr b/spec/helpers/result.cr deleted file mode 100644 index edafd75..0000000 --- a/spec/helpers/result.cr +++ /dev/null @@ -1,67 +0,0 @@ -module Spectator::SpecHelpers - # Information about an example compiled and run at runtime. - struct Result - # Status of the example after running. - enum Outcome - Success - Failure - Error - Unknown - end - - # Full name and description of the example. - getter name : String - - # Status of the example after running. - getter outcome : Outcome - - # List of expectations ran in the example. - getter expectations : Array(Expectation) - - # Creates the result. - def initialize(@name, @outcome, @expectations) - end - - # Checks if the example was successful. - def success? - outcome.success? - end - - # :ditto: - def successful? - outcome.success? - end - - # Checks if the example failed, but did not error. - def failure? - outcome.failure? - end - - # Checks if the example encountered an error. - def error? - outcome.error? - end - - # Extracts the result information from a `JSON::Any` object. - def self.from_json_any(object : JSON::Any) - name = object["description"].as_s - outcome = parse_outcome_string(object["status"].as_s) - expectations = if (list = object["expectations"].as_a?) - list.map { |e| Expectation.from_json_any(e) } - else - [] of Expectation - end - new(name, outcome, expectations) - end - - # Converts a result string, such as "fail" to an enum value. - private def self.parse_outcome_string(string) - case string - when /pass/i then Outcome::Success - when /fail/i then Outcome::Failure - when /error/i then Outcome::Error - else Outcome::Unknown - end - end - end -end diff --git a/spec/runtime_example_spec.cr b/spec/runtime_example_spec.cr deleted file mode 100644 index 01ae9a3..0000000 --- a/spec/runtime_example_spec.cr +++ /dev/null @@ -1,58 +0,0 @@ -require "./spec_helper" - -# This is a meta test that ensures specs can be compiled and run at runtime. -# The purpose of this is to report an error if this process fails. -# Other tests will fail, but display a different name/description of the test. -# This clearly indicates that runtime testing failed. -# -# Runtime compilation is used to get output of tests as well as check syntax. -# Some specs are too complex to be ran normally. -# Additionally, this allows examples to easily check specific failure cases. -# Plus, it makes testing user-reported issues easy. -Spectator.describe "Runtime compilation", :slow, :compile do - given_example passing_example do - it "does something" do - expect(true).to be_true - end - end - - it "can compile and retrieve the result of an example" do - expect(passing_example).to be_successful - end - - it "can retrieve expectations" do - expect(passing_example.expectations).to_not be_empty - end - - given_example failing_example do - it "does something" do - expect(true).to be_false - end - - it "doesn't run" do - expect(true).to be_false - end - end - - it "detects failed examples" do - expect(failing_example).to be_failure - end - - given_example malformed_example do - it "does something" do - asdf - end - end - - it "raises on compilation errors" do - expect { malformed_example }.to raise_error(/compilation/i) - end - - given_expectation satisfied_expectation do - expect(true).to be_true - end - - it "can compile and retrieve expectations" do - expect(satisfied_expectation).to be_satisfied - end -end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 4c16fd3..e2f9578 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -15,35 +15,3 @@ end macro specify_fails(description = nil, &block) it_fails {{description}} {{block}} end - -# Defines an example ("it" block) that is lazily compiled. -# When the example is referenced with *id*, it will be compiled and the results retrieved. -# The value returned by *id* will be a `Spectator::SpecHelpers::Result`. -# This allows the test result to be inspected. -macro given_example(id, &block) - let({{id}}) do - ::Spectator::SpecHelpers::Example.new( - {{__FILE__}}, - {{id.id.stringify}}, - {{block.body.stringify}} - ).result - end -end - -# Defines an example ("it" block) that is lazily compiled. -# The "it" block must be omitted, as the block provided to this macro will be wrapped in one. -# When the expectation is referenced with *id*, it will be compiled and the result retrieved. -# The value returned by *id* will be a `Spectator::SpecHelpers::Expectation`. -# This allows an expectation to be inspected. -# Only the last expectation performed will be returned. -# An error is raised if no expectations ran. -macro given_expectation(id, &block) - let({{id}}) do - result = ::Spectator::SpecHelpers::Example.new( - {{__FILE__}}, - {{id.id.stringify}}, - {{"it do\n" + block.body.stringify + "\nend"}} - ).result - result.expectations.last || raise("No expectations found from {{id.id}}") - end -end From c6afa0adb3e2a81943b2f3d9cf862e6c83f85c85 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 16:58:56 -0600 Subject: [PATCH 025/120] Use different value than original --- spec/spectator/dsl/mocks/expect_receive_spec.cr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/spectator/dsl/mocks/expect_receive_spec.cr b/spec/spectator/dsl/mocks/expect_receive_spec.cr index 0a0242b..78db8b0 100644 --- a/spec/spectator/dsl/mocks/expect_receive_spec.cr +++ b/spec/spectator/dsl/mocks/expect_receive_spec.cr @@ -167,13 +167,13 @@ Spectator.describe "Deferred stub expectation DSL" do pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } it "matches when a message is received" do - expect(fake).to receive(:foo).and_return(42) + expect(fake).to receive(:foo).and_return(0) fake.foo(:bar) end it "returns the correct value" do - expect(fake).to receive(:foo).and_return(42) - expect(fake.foo).to eq(42) + expect(fake).to receive(:foo).and_return(0) + expect(fake.foo).to eq(0) end it "matches when a message isn't received" do @@ -181,12 +181,12 @@ Spectator.describe "Deferred stub expectation DSL" do end it "matches when a message is received with matching arguments" do - expect(fake).to receive(:foo).with(:bar).and_return(42) + expect(fake).to receive(:foo).with(:bar).and_return(0) fake.foo(:bar) end it "matches when a message without arguments is received" do - expect(fake).to_not receive(:foo).with(:bar).and_return(42) + expect(fake).to_not receive(:foo).with(:bar).and_return(0) fake.foo end @@ -195,7 +195,7 @@ Spectator.describe "Deferred stub expectation DSL" do end it "matches when a message with arguments isn't received" do - expect(fake).to_not receive(:foo).with(:baz).and_return(42) + expect(fake).to_not receive(:foo).with(:baz).and_return(0) fake.foo(:bar) end end From b3aa2d62c0524156ae5d663b833d4955690b5d77 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 16:59:39 -0600 Subject: [PATCH 026/120] Ensure stubs don't leak between examples --- .../dsl/mocks/expect_receive_spec.cr | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/spec/spectator/dsl/mocks/expect_receive_spec.cr b/spec/spectator/dsl/mocks/expect_receive_spec.cr index 78db8b0..a249ad3 100644 --- a/spec/spectator/dsl/mocks/expect_receive_spec.cr +++ b/spec/spectator/dsl/mocks/expect_receive_spec.cr @@ -14,6 +14,12 @@ Spectator.describe "Deferred stub expectation DSL" do # Ensure invocations don't leak between examples. pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" } + # Ensure stubs don't leak between examples. + pre_condition do + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) + dbl._spectator_clear_calls # Don't include previous call in results. + end + it "matches when a message is received" do expect(dbl).to receive(:foo) dbl.foo @@ -67,6 +73,12 @@ Spectator.describe "Deferred stub expectation DSL" do # Ensure invocations don't leak between examples. pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" } + # Ensure stubs don't leak between examples. + pre_condition do + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) + dbl._spectator_clear_calls # Don't include previous call in results. + end + it "matches when a message is received" do expect(dbl).to receive(:foo) dbl.foo @@ -114,6 +126,12 @@ Spectator.describe "Deferred stub expectation DSL" do # Ensure invocations don't leak between examples. pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } + # Ensure stubs don't leak between examples. + pre_condition do + expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage) + fake._spectator_clear_calls # Don't include previous call in results. + end + it "matches when a message is received" do expect(fake).to receive(:foo).and_return(42) fake.foo(:bar) @@ -166,6 +184,12 @@ Spectator.describe "Deferred stub expectation DSL" do # Ensure invocations don't leak between examples. pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } + # Ensure stubs don't leak between examples. + pre_condition do + expect(fake.foo).to eq(42) + fake._spectator_clear_calls # Don't include previous call in results. + end + it "matches when a message is received" do expect(fake).to receive(:foo).and_return(0) fake.foo(:bar) From d9082dab454143cd72ef0bc660093e5d240f8963 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 17:14:20 -0600 Subject: [PATCH 027/120] Test behavior and for leakages with allow syntax --- .../spectator/dsl/mocks/allow_receive_spec.cr | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 spec/spectator/dsl/mocks/allow_receive_spec.cr diff --git a/spec/spectator/dsl/mocks/allow_receive_spec.cr b/spec/spectator/dsl/mocks/allow_receive_spec.cr new file mode 100644 index 0000000..473c74b --- /dev/null +++ b/spec/spectator/dsl/mocks/allow_receive_spec.cr @@ -0,0 +1,188 @@ +require "../../../spec_helper" + +Spectator.describe "Allow stub DSL" do + context "with a double" do + double(:dbl) do + # Ensure the original is never called. + stub abstract def foo : Nil + stub abstract def foo(arg) : Nil + stub abstract def value : Int32 + end + + let(dbl) { double(:dbl) } + + # Ensure invocations don't leak between examples. + pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" } + + # Ensure stubs don't leak between examples. + pre_condition do + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) + end + + it "matches when a message is received" do + allow(dbl).to receive(:foo) + expect { dbl.foo }.to_not raise_error + end + + it "returns the correct value" do + allow(dbl).to receive(:value).and_return(42) + expect(dbl.value).to eq(42) + end + + it "matches when a message is received with matching arguments" do + allow(dbl).to receive(:foo).with(:bar) + expect { dbl.foo(:bar) }.to_not raise_error + end + + it "raises when a message without arguments is received" do + allow(dbl).to receive(:foo).with(:bar) + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + + it "raises when a message with different arguments is received" do + allow(dbl).to receive(:foo).with(:baz) + expect { dbl.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + end + + context "with a class double" do + double(:dbl) do + # Ensure the original is never called. + abstract_stub def self.foo : Nil + end + + abstract_stub def self.foo(arg) : Nil + end + + abstract_stub def self.value : Int32 + 42 + end + end + + let(dbl) { class_double(:dbl) } + + # Ensure invocations don't leak between examples. + pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" } + + # Ensure stubs don't leak between examples. + pre_condition do + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) + end + + it "matches when a message is received" do + allow(dbl).to receive(:foo) + expect { dbl.foo }.to_not raise_error + end + + it "returns the correct value" do + allow(dbl).to receive(:value).and_return(42) + expect(dbl.value).to eq(42) + end + + it "matches when a message is received with matching arguments" do + allow(dbl).to receive(:foo).with(:bar) + expect { dbl.foo(:bar) }.to_not raise_error + end + + it "raises when a message without arguments is received" do + allow(dbl).to receive(:foo).with(:bar) + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + + it "raises when a message with different arguments is received" do + allow(dbl).to receive(:foo).with(:baz) + expect { dbl.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + end + + context "with a mock" do + abstract class MyClass + abstract def foo : Int32 + abstract def foo(arg) : Int32 + end + + mock(MyClass) + + let(fake) { mock(MyClass) } + + # Ensure invocations don't leak between examples. + pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } + + # Ensure stubs don't leak between examples. + pre_condition do + expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage) + end + + it "matches when a message is received" do + allow(fake).to receive(:foo).and_return(42) + expect(fake.foo).to eq(42) + end + + it "returns the correct value" do + allow(fake).to receive(:foo).and_return(42) + expect(fake.foo).to eq(42) + end + + it "matches when a message is received with matching arguments" do + allow(fake).to receive(:foo).with(:bar).and_return(42) + expect(fake.foo(:bar)).to eq(42) + end + + it "raises when a message without arguments is received" do + allow(fake).to receive(:foo).with(:bar) + expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + + it "raises when a message with different arguments is received" do + allow(fake).to receive(:foo).with(:baz) + expect { fake.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + end + + context "with a class mock" do + class MyClass + def self.foo : Int32 + 42 + end + + def self.foo(arg) : Int32 + 42 + end + end + + mock(MyClass) + + let(fake) { class_mock(MyClass) } + + # Ensure invocations don't leak between examples. + pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } + + # Ensure stubs don't leak between examples. + pre_condition { expect(fake.foo).to eq(42) } + + it "matches when a message is received" do + allow(fake).to receive(:foo).and_return(0) + expect(fake.foo).to eq(0) + end + + it "returns the correct value" do + allow(fake).to receive(:foo).and_return(0) + expect(fake.foo).to eq(0) + end + + it "matches when a message is received with matching arguments" do + allow(fake).to receive(:foo).with(:bar).and_return(0) + expect(fake.foo(:bar)).to eq(0) + end + + it "calls the original when a message without arguments is received" do + allow(fake).to receive(:foo).with(:bar) + expect(fake.foo).to eq(42) + end + + it "calls the original when a message with different arguments is received" do + allow(fake).to receive(:foo).with(:baz) + expect(fake.foo(:bar)).to eq(42) + end + end +end From 70d0009db5ef37689fd6c33973219bf7a3392a0f Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 9 Oct 2022 18:23:39 -0600 Subject: [PATCH 028/120] Disable issue 47 test for now --- spec/issues/github_issue_47_spec.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/issues/github_issue_47_spec.cr b/spec/issues/github_issue_47_spec.cr index 3576a2d..8b1d3d3 100644 --- a/spec/issues/github_issue_47_spec.cr +++ b/spec/issues/github_issue_47_spec.cr @@ -11,7 +11,7 @@ Spectator.describe "GitHub Issue #47" do let(fake) { mock(Original) } - specify do + xspecify do expect(fake).to receive(:foo).with("arg1", arg2: "arg2") fake.foo("arg1", "arg2") end From e38e3ecc32567bbbbd2a4e5edef33096911f0d71 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 15:22:50 -0600 Subject: [PATCH 029/120] Initial rework of arguments to support named positionals --- spec/spectator/mocks/arguments_spec.cr | 76 +++----------- src/spectator/mocks/arguments.cr | 135 +++++++++++++++++++------ src/spectator/mocks/stubbable.cr | 18 ++-- 3 files changed, 132 insertions(+), 97 deletions(-) diff --git a/spec/spectator/mocks/arguments_spec.cr b/spec/spectator/mocks/arguments_spec.cr index 422b6c6..f51c3c6 100644 --- a/spec/spectator/mocks/arguments_spec.cr +++ b/spec/spectator/mocks/arguments_spec.cr @@ -1,26 +1,22 @@ require "../../spec_helper" Spectator.describe Spectator::Arguments do - subject(arguments) do - Spectator::Arguments.new( - args: {42, "foo"}, - kwargs: {bar: "baz", qux: 123} - ) - end + subject(arguments) { Spectator::Arguments.new({42, "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } it "stores the arguments" do - expect(arguments.args).to eq({42, "foo"}) - end - - it "stores the keyword arguments" do - expect(arguments.kwargs).to eq({bar: "baz", qux: 123}) + expect(arguments).to have_attributes( + positional: {42, "foo"}, + splat_name: :splat, + extra: {:x, :y, :z}, + kwargs: {bar: "baz", qux: 123} + ) end describe ".capture" do subject { Spectator::Arguments.capture(42, "foo", bar: "baz", qux: 123) } it "stores the arguments and keyword arguments" do - is_expected.to have_attributes(args: {42, "foo"}, kwargs: {bar: "baz", qux: 123}) + is_expected.to have_attributes(positional: {42, "foo"}, kwargs: {bar: "baz", qux: 123}) end end @@ -72,12 +68,7 @@ Spectator.describe Spectator::Arguments do end context "with different arguments" do - let(other) do - Spectator::Arguments.new( - args: {123, :foo, "bar"}, - kwargs: {opt: "foobar"} - ) - end + let(other) { Spectator::Arguments.new({123, :foo, "bar"}, nil, nil, {opt: "foobar"}) } it "returns false" do is_expected.to be_false @@ -85,12 +76,7 @@ Spectator.describe Spectator::Arguments do end context "with the same kwargs in a different order" do - let(other) do - Spectator::Arguments.new( - args: arguments.args, - kwargs: {qux: 123, bar: "baz"} - ) - end + let(other) { Spectator::Arguments.new(arguments.positional, nil, nil, {qux: 123, bar: "baz"}) } it "returns true" do is_expected.to be_true @@ -98,12 +84,7 @@ Spectator.describe Spectator::Arguments do end context "with a missing kwarg" do - let(other) do - Spectator::Arguments.new( - args: arguments.args, - kwargs: {bar: "baz"} - ) - end + let(other) { Spectator::Arguments.new(arguments.positional, nil, nil, {bar: "baz"}) } it "returns false" do is_expected.to be_false @@ -123,12 +104,7 @@ Spectator.describe Spectator::Arguments do end context "with different arguments" do - let(pattern) do - Spectator::Arguments.new( - args: {123, :foo, "bar"}, - kwargs: {opt: "foobar"} - ) - end + let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, nil, nil, {opt: "foobar"}) } it "returns false" do is_expected.to be_false @@ -136,12 +112,7 @@ Spectator.describe Spectator::Arguments do end context "with the same kwargs in a different order" do - let(pattern) do - Spectator::Arguments.new( - args: arguments.args, - kwargs: {qux: 123, bar: "baz"} - ) - end + let(pattern) { Spectator::Arguments.new(arguments.positional, nil, nil, {qux: 123, bar: "baz"}) } it "returns true" do is_expected.to be_true @@ -149,12 +120,7 @@ Spectator.describe Spectator::Arguments do end context "with a missing kwarg" do - let(pattern) do - Spectator::Arguments.new( - args: arguments.args, - kwargs: {bar: "baz"} - ) - end + let(pattern) { Spectator::Arguments.new(arguments.positional, nil, nil, {bar: "baz"}) } it "returns false" do is_expected.to be_false @@ -162,12 +128,7 @@ Spectator.describe Spectator::Arguments do end context "with matching types and regex" do - let(pattern) do - Spectator::Arguments.new( - args: {Int32, /foo/}, - kwargs: {bar: String, qux: 123} - ) - end + let(pattern) { Spectator::Arguments.new({Int32, /foo/}, nil, nil, {bar: String, qux: 123}) } it "returns true" do is_expected.to be_true @@ -175,12 +136,7 @@ Spectator.describe Spectator::Arguments do end context "with different types and regex" do - let(pattern) do - Spectator::Arguments.new( - args: {Symbol, /bar/}, - kwargs: {bar: String, qux: 42} - ) - end + let(pattern) { Spectator::Arguments.new({Symbol, /bar/}, nil, nil, {bar: String, qux: 42}) } it "returns false" do is_expected.to be_false diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index 264f0fc..db9da32 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -4,22 +4,29 @@ module Spectator # Arguments used in a method call. # # Can also be used to match arguments. - # *T* must be a `Tuple` type representing the positional arguments. - # *NT* must be a `NamedTuple` type representing the keyword arguments. - class Arguments(T, NT) < AbstractArguments + # *Positional* must be a `Tuple` or `NamedTuple` type representing the standard arguments. + # *Splat* must be a `Tuple` type representing the extra positional arguments. + # *DoubleSplat* must be a `NamedTuple` type representing extra keyword arguments. + class Arguments(Positional, Splat, DoubleSplat) < AbstractArguments # Positional arguments. - getter args : T + getter positional : Positional + + # Additional positional arguments. + getter extra : Splat # Keyword arguments. - getter kwargs : NT + getter kwargs : DoubleSplat + + # Name of the splat argument, if used. + getter splat_name : Symbol? # Creates arguments used in a method call. - def initialize(@args : T, @kwargs : NT) + def initialize(@positional : Positional, @splat_name : Symbol?, @extra : Splat, @kwargs : DoubleSplat) end - # Constructs an instance from literal arguments. - def self.capture(*args, **kwargs) : AbstractArguments - new(args, kwargs).as(AbstractArguments) + # Creates arguments used in a method call. + def self.new(positional : Positional, kwargs : DoubleSplat) + new(positional, nil, nil, kwargs) end # Instance of empty arguments. @@ -30,34 +37,80 @@ module Spectator nil.as(AbstractArguments?) end + # Captures arguments passed to a call. + def self.build(positional = Tuple.new, kwargs = NamedTuple.new) + new(positional, nil, nil, kwargs) + end + + # :ditto: + def self.build(positional : NamedTuple, splat_name : Symbol, extra : Tuple, kwargs = NamedTuple.new) + new(positional, splat_name, extra, kwargs) + end + + # Friendlier constructor for capturing arguments. + def self.capture(*args, **kwargs) + new(args, nil, nil, kwargs) + end + # Returns the positional argument at the specified index. def [](index : Int) - @args[index] + {% if Positional < NamedTuple %} + @positional.values[index] + {% else %} + @positional[index] + {% end %} end # Returns the specified named argument. def [](arg : Symbol) + {% if Positional < NamedTuple %} + return @positional[arg] if @positional.has_key?(arg) + {% end %} @kwargs[arg] end # Constructs a string representation of the arguments. def to_s(io : IO) : Nil - return io << "(no args)" if args.empty? && kwargs.empty? + return io << "(no args)" if positional.empty? && ((extra = @extra).nil? || extra.empty?) && kwargs.empty? io << '(' # Add the positional arguments. - args.each_with_index do |arg, i| - io << ", " if i > 0 - arg.inspect(io) + {% if Positional < NamedTuple %} + # Include argument names. + positional.each_with_index do |name, value, i| + io << ", " if i > 0 + io << name << ": " + value.inspect(io) + end + {% else %} + positional.each_with_index do |arg, i| + io << ", " if i > 0 + arg.inspect(io) + end + {% end %} + + # Add the splat arguments. + if (extra = @extra) && !extra.empty? + if splat = @splat_name + io << splat << ": {" + end + io << ", " unless positional.empty? + extra.each_with_index do |arg, i| + io << ", " if i > 0 + arg.inspect(io) + end + io << '}' if @splat_name + io << ", " unless kwargs.empty? end # Add the keyword arguments. - size = args.size + kwargs.size - kwargs.each_with_index(args.size) do |k, v, i| - io << ", " if 0 < i < size - io << k << ": " - v.inspect(io) + offset = positional.size + offset += extra.size if (extra = @extra) + kwargs.each_with_index(offset) do |name, value, i| + io << ", " if i > 0 + io << name << ": " + value.inspect(io) end io << ')' @@ -65,27 +118,47 @@ module Spectator # Checks if this set of arguments and another are equal. def ==(other : Arguments) - args == other.args && kwargs == other.kwargs + ordered = simplify_positional + other_ordered = other.simplify_positional + ordered == other_ordered && kwargs == other.kwargs end # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - args === other.args && named_tuples_match?(kwargs, other.kwargs) - end + {% if Positional < NamedTuple %} + if (other_positional = other.positional).is_a?(NamedTuple) + positional.each do |k, v| + return false unless other_positional.has_key?(k) + return false unless v === other_positional[k] + end + else + return false if positional.size != other_positional + positional.each_with_index do |k, v, i| + return false unless v === other_positional.unsafe_fetch(i) + end + end + {% else %} + return false unless positional === other.simplify_positional + {% end %} - # Checks if two named tuples match. - # - # Uses case equality (`===`) on every key-value pair. - # NamedTuple doesn't have a `===` operator, even though Tuple does. - private def named_tuples_match?(a : NamedTuple, b : NamedTuple) - return false if a.size != b.size + if extra = @extra + return false unless extra === other.extra + end - a.each do |k, v| - return false unless b.has_key?(k) - return false unless v === b[k] + kwargs.each do |k, v| + return false unless other.kwargs.has_key?(k) + return false unless v === other.kwargs[k] end true end + + protected def simplify_positional + if (extra = @extra) + {% if Positional < NamedTuple %}positional.values{% else %}positional{% end %} + extra + else + {% if Positional < NamedTuple %}positional.values{% else %}positional{% end %} + end + end end end diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 061cbe1..ae8d3f4 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -140,9 +140,12 @@ module Spectator ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} # Capture information about the call. - %args = ::Spectator::Arguments.capture( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.internal_name}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}{% end %} + %args = ::Spectator::Arguments.build( + ::NamedTuple.new( + {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} + ), + {% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} + {{method.double_splat}} ) %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) _spectator_record_call(%call) @@ -237,9 +240,12 @@ module Spectator ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} # Capture information about the call. - %args = ::Spectator::Arguments.capture( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.internal_name}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}{% end %} + %args = ::Spectator::Arguments.build( + ::NamedTuple.new( + {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} + ), + {% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} + {{method.double_splat}} ) %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) _spectator_record_call(%call) From 163f94287eec5e8168fdcf8355fc42b425b56e43 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 15:27:39 -0600 Subject: [PATCH 030/120] Fix Arguments to_s --- spec/spectator/mocks/arguments_spec.cr | 2 +- src/spectator/mocks/arguments.cr | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/spec/spectator/mocks/arguments_spec.cr b/spec/spectator/mocks/arguments_spec.cr index f51c3c6..5280268 100644 --- a/spec/spectator/mocks/arguments_spec.cr +++ b/spec/spectator/mocks/arguments_spec.cr @@ -44,7 +44,7 @@ Spectator.describe Spectator::Arguments do subject { arguments.to_s } it "formats the arguments" do - is_expected.to eq("(42, \"foo\", bar: \"baz\", qux: 123)") + is_expected.to eq("(42, \"foo\", *splat: {:x, :y, :z}, bar: \"baz\", qux: 123)") end context "when empty" do diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index db9da32..b50a4f0 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -92,16 +92,15 @@ module Spectator # Add the splat arguments. if (extra = @extra) && !extra.empty? - if splat = @splat_name - io << splat << ": {" - end io << ", " unless positional.empty? + if splat = @splat_name + io << '*' << splat << ": {" + end extra.each_with_index do |arg, i| io << ", " if i > 0 arg.inspect(io) end io << '}' if @splat_name - io << ", " unless kwargs.empty? end # Add the keyword arguments. From a728a037d45e0f335250beede761f16448fa35e1 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 15:37:55 -0600 Subject: [PATCH 031/120] Rename attributes --- spec/spectator/mocks/arguments_spec.cr | 14 ++-- src/spectator/mocks/arguments.cr | 97 ++++++++++++-------------- 2 files changed, 53 insertions(+), 58 deletions(-) diff --git a/spec/spectator/mocks/arguments_spec.cr b/spec/spectator/mocks/arguments_spec.cr index 5280268..d2f2d2a 100644 --- a/spec/spectator/mocks/arguments_spec.cr +++ b/spec/spectator/mocks/arguments_spec.cr @@ -5,9 +5,9 @@ Spectator.describe Spectator::Arguments do it "stores the arguments" do expect(arguments).to have_attributes( - positional: {42, "foo"}, + args: {42, "foo"}, splat_name: :splat, - extra: {:x, :y, :z}, + splat: {:x, :y, :z}, kwargs: {bar: "baz", qux: 123} ) end @@ -16,7 +16,7 @@ Spectator.describe Spectator::Arguments do subject { Spectator::Arguments.capture(42, "foo", bar: "baz", qux: 123) } it "stores the arguments and keyword arguments" do - is_expected.to have_attributes(positional: {42, "foo"}, kwargs: {bar: "baz", qux: 123}) + is_expected.to have_attributes(args: {42, "foo"}, kwargs: {bar: "baz", qux: 123}) end end @@ -76,7 +76,7 @@ Spectator.describe Spectator::Arguments do end context "with the same kwargs in a different order" do - let(other) { Spectator::Arguments.new(arguments.positional, nil, nil, {qux: 123, bar: "baz"}) } + let(other) { Spectator::Arguments.new(arguments.args, nil, nil, {qux: 123, bar: "baz"}) } it "returns true" do is_expected.to be_true @@ -84,7 +84,7 @@ Spectator.describe Spectator::Arguments do end context "with a missing kwarg" do - let(other) { Spectator::Arguments.new(arguments.positional, nil, nil, {bar: "baz"}) } + let(other) { Spectator::Arguments.new(arguments.args, nil, nil, {bar: "baz"}) } it "returns false" do is_expected.to be_false @@ -112,7 +112,7 @@ Spectator.describe Spectator::Arguments do end context "with the same kwargs in a different order" do - let(pattern) { Spectator::Arguments.new(arguments.positional, nil, nil, {qux: 123, bar: "baz"}) } + let(pattern) { Spectator::Arguments.new(arguments.args, nil, nil, {qux: 123, bar: "baz"}) } it "returns true" do is_expected.to be_true @@ -120,7 +120,7 @@ Spectator.describe Spectator::Arguments do end context "with a missing kwarg" do - let(pattern) { Spectator::Arguments.new(arguments.positional, nil, nil, {bar: "baz"}) } + let(pattern) { Spectator::Arguments.new(arguments.args, nil, nil, {bar: "baz"}) } it "returns false" do is_expected.to be_false diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index b50a4f0..a2ea853 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -4,15 +4,15 @@ module Spectator # Arguments used in a method call. # # Can also be used to match arguments. - # *Positional* must be a `Tuple` or `NamedTuple` type representing the standard arguments. + # *Args* must be a `Tuple` or `NamedTuple` type representing the standard arguments. # *Splat* must be a `Tuple` type representing the extra positional arguments. # *DoubleSplat* must be a `NamedTuple` type representing extra keyword arguments. - class Arguments(Positional, Splat, DoubleSplat) < AbstractArguments + class Arguments(Args, Splat, DoubleSplat) < AbstractArguments # Positional arguments. - getter positional : Positional + getter args : Args # Additional positional arguments. - getter extra : Splat + getter splat : Splat # Keyword arguments. getter kwargs : DoubleSplat @@ -21,12 +21,12 @@ module Spectator getter splat_name : Symbol? # Creates arguments used in a method call. - def initialize(@positional : Positional, @splat_name : Symbol?, @extra : Splat, @kwargs : DoubleSplat) + def initialize(@args : Args, @splat_name : Symbol?, @splat : Splat, @kwargs : DoubleSplat) end # Creates arguments used in a method call. - def self.new(positional : Positional, kwargs : DoubleSplat) - new(positional, nil, nil, kwargs) + def self.new(args : Args, kwargs : DoubleSplat) + new(args, nil, nil, kwargs) end # Instance of empty arguments. @@ -38,13 +38,13 @@ module Spectator end # Captures arguments passed to a call. - def self.build(positional = Tuple.new, kwargs = NamedTuple.new) - new(positional, nil, nil, kwargs) + def self.build(args = Tuple.new, kwargs = NamedTuple.new) + new(args, nil, nil, kwargs) end # :ditto: - def self.build(positional : NamedTuple, splat_name : Symbol, extra : Tuple, kwargs = NamedTuple.new) - new(positional, splat_name, extra, kwargs) + def self.build(args : NamedTuple, splat_name : Symbol, splat : Tuple, kwargs = NamedTuple.new) + new(args, splat_name, splat, kwargs) end # Friendlier constructor for capturing arguments. @@ -54,49 +54,54 @@ module Spectator # Returns the positional argument at the specified index. def [](index : Int) - {% if Positional < NamedTuple %} - @positional.values[index] - {% else %} - @positional[index] - {% end %} + positional[index] end # Returns the specified named argument. def [](arg : Symbol) - {% if Positional < NamedTuple %} - return @positional[arg] if @positional.has_key?(arg) + {% if Args < NamedTuple %} + return @args[arg] if @args.has_key?(arg) {% end %} @kwargs[arg] end + # Returns all arguments and splatted arguments as a tuple. + def positional : Tuple + if (splat = @splat) + {% if Args < NamedTuple %}args.values{% else %}args{% end %} + splat + else + {% if Args < NamedTuple %}args.values{% else %}args{% end %} + end + end + # Constructs a string representation of the arguments. def to_s(io : IO) : Nil - return io << "(no args)" if positional.empty? && ((extra = @extra).nil? || extra.empty?) && kwargs.empty? + return io << "(no args)" if args.empty? && ((splat = @splat).nil? || splat.empty?) && kwargs.empty? io << '(' # Add the positional arguments. - {% if Positional < NamedTuple %} + {% if Args < NamedTuple %} # Include argument names. - positional.each_with_index do |name, value, i| + args.each_with_index do |name, value, i| io << ", " if i > 0 io << name << ": " value.inspect(io) end {% else %} - positional.each_with_index do |arg, i| + args.each_with_index do |arg, i| io << ", " if i > 0 arg.inspect(io) end {% end %} # Add the splat arguments. - if (extra = @extra) && !extra.empty? - io << ", " unless positional.empty? - if splat = @splat_name - io << '*' << splat << ": {" + if (splat = @splat) && !splat.empty? + io << ", " unless args.empty? + if name = @splat_name + io << '*' << name << ": {" end - extra.each_with_index do |arg, i| + splat.each_with_index do |arg, i| io << ", " if i > 0 arg.inspect(io) end @@ -104,8 +109,8 @@ module Spectator end # Add the keyword arguments. - offset = positional.size - offset += extra.size if (extra = @extra) + offset = args.size + offset += splat.size if (splat = @splat) kwargs.each_with_index(offset) do |name, value, i| io << ", " if i > 0 io << name << ": " @@ -117,31 +122,29 @@ module Spectator # Checks if this set of arguments and another are equal. def ==(other : Arguments) - ordered = simplify_positional - other_ordered = other.simplify_positional - ordered == other_ordered && kwargs == other.kwargs + positional == other.positional && kwargs == other.kwargs end # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - {% if Positional < NamedTuple %} - if (other_positional = other.positional).is_a?(NamedTuple) - positional.each do |k, v| - return false unless other_positional.has_key?(k) - return false unless v === other_positional[k] + {% if Args < NamedTuple %} + if (other_args = other.args).is_a?(NamedTuple) + args.each do |k, v| + return false unless other_args.has_key?(k) + return false unless v === other_args[k] end else - return false if positional.size != other_positional - positional.each_with_index do |k, v, i| - return false unless v === other_positional.unsafe_fetch(i) + return false if args.size != other_args + args.each_with_index do |k, v, i| + return false unless v === other_args.unsafe_fetch(i) end end {% else %} - return false unless positional === other.simplify_positional + return false unless args === other.positional {% end %} - if extra = @extra - return false unless extra === other.extra + if splat = @splat + return false unless splat === other.splat end kwargs.each do |k, v| @@ -151,13 +154,5 @@ module Spectator true end - - protected def simplify_positional - if (extra = @extra) - {% if Positional < NamedTuple %}positional.values{% else %}positional{% end %} + extra - else - {% if Positional < NamedTuple %}positional.values{% else %}positional{% end %} - end - end end end From 0177a678f9eac7a3dbb936b42b2cce70ea5783b3 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 20:40:56 -0600 Subject: [PATCH 032/120] Avoid shadowing variable --- src/spectator/mocks/arguments.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index a2ea853..b033c71 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -111,9 +111,9 @@ module Spectator # Add the keyword arguments. offset = args.size offset += splat.size if (splat = @splat) - kwargs.each_with_index(offset) do |name, value, i| + kwargs.each_with_index(offset) do |key, value, i| io << ", " if i > 0 - io << name << ": " + io << key << ": " value.inspect(io) end From e2130d12d34626e0d5d6de547d7417008351f34a Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 20:42:08 -0600 Subject: [PATCH 033/120] Implement arguments case equality Implements https://github.com/icy-arctic-fox/spectator/issues/47 Some specs are failing and need to be resolved before the new feature is considered done. --- spec/issues/github_issue_47_spec.cr | 2 +- spec/spectator/mocks/arguments_spec.cr | 202 +++++++++++++++++++------ src/spectator/mocks/arguments.cr | 66 +++++--- 3 files changed, 205 insertions(+), 65 deletions(-) diff --git a/spec/issues/github_issue_47_spec.cr b/spec/issues/github_issue_47_spec.cr index 8b1d3d3..3576a2d 100644 --- a/spec/issues/github_issue_47_spec.cr +++ b/spec/issues/github_issue_47_spec.cr @@ -11,7 +11,7 @@ Spectator.describe "GitHub Issue #47" do let(fake) { mock(Original) } - xspecify do + specify do expect(fake).to receive(:foo).with("arg1", arg2: "arg2") fake.foo("arg1", "arg2") end diff --git a/spec/spectator/mocks/arguments_spec.cr b/spec/spectator/mocks/arguments_spec.cr index d2f2d2a..8b43b95 100644 --- a/spec/spectator/mocks/arguments_spec.cr +++ b/spec/spectator/mocks/arguments_spec.cr @@ -20,18 +20,61 @@ Spectator.describe Spectator::Arguments do end end - describe "#[]" do - context "with an index" do + describe "#[](index)" do + it "returns a positional argument" do + aggregate_failures do + expect(arguments[0]).to eq(42) + expect(arguments[1]).to eq("foo") + end + end + + it "returns splat arguments" do + aggregate_failures do + expect(arguments[2]).to eq(:x) + expect(arguments[3]).to eq(:y) + expect(arguments[4]).to eq(:z) + end + end + + context "with named positional arguments" do + subject(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + it "returns a positional argument" do aggregate_failures do expect(arguments[0]).to eq(42) expect(arguments[1]).to eq("foo") end end + + it "returns splat arguments" do + aggregate_failures do + expect(arguments[2]).to eq(:x) + expect(arguments[3]).to eq(:y) + expect(arguments[4]).to eq(:z) + end + end + end + end + + describe "#[](symbol)" do + it "returns a keyword argument" do + aggregate_failures do + expect(arguments[:bar]).to eq("baz") + expect(arguments[:qux]).to eq(123) + end end - context "with a symbol" do - it "returns a named argument" do + context "with named positional arguments" do + subject(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "returns a positional argument" do + aggregate_failures do + expect(arguments[:arg1]).to eq(42) + expect(arguments[:arg2]).to eq("foo") + end + end + + it "returns a keyword argument" do aggregate_failures do expect(arguments[:bar]).to eq("baz") expect(arguments[:qux]).to eq(123) @@ -62,33 +105,57 @@ Spectator.describe Spectator::Arguments do context "with equal arguments" do let(other) { arguments } - it "returns true" do - is_expected.to be_true - end + it { is_expected.to be_true } end context "with different arguments" do - let(other) { Spectator::Arguments.new({123, :foo, "bar"}, nil, nil, {opt: "foobar"}) } + let(other) { Spectator::Arguments.new({123, :foo, "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) } - it "returns false" do - is_expected.to be_false - end + it { is_expected.to be_false } end context "with the same kwargs in a different order" do - let(other) { Spectator::Arguments.new(arguments.args, nil, nil, {qux: 123, bar: "baz"}) } + let(other) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: 123, bar: "baz"}) } - it "returns true" do - is_expected.to be_true - end + it { is_expected.to be_true } end context "with a missing kwarg" do - let(other) { Spectator::Arguments.new(arguments.args, nil, nil, {bar: "baz"}) } + let(other) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz"}) } - it "returns false" do - is_expected.to be_false - end + it { is_expected.to be_false } + end + + context "with an extra kwarg" do + let(other) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz", qux: 123, extra: 0}) } + + it { is_expected.to be_false } + end + + context "with different splat arguments" do + let(other) { Spectator::Arguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) } + + it { is_expected.to be_false } + end + + context "with mixed positional tuple types" do + let(other) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, arguments.splat_name, arguments.splat, arguments.kwargs) } + + it { is_expected.to be_true } + end + + context "with mixed positional tuple types (flipped)" do + let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(other) { Spectator::Arguments.new({42, "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } + end + + context "with args spilling over into splat and mixed positional tuple types" do + let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(other) { Spectator::Arguments.new({42, "foo", :x, :y, :z}, nil, nil, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } end end @@ -98,49 +165,96 @@ Spectator.describe Spectator::Arguments do context "with equal arguments" do let(pattern) { arguments } - it "returns true" do - is_expected.to be_true - end + it { is_expected.to be_true } + end + + context "with matching arguments" do + let(pattern) { Spectator::Arguments.new({Int32, /foo/}, :splat, {Symbol, Symbol, :z}, {bar: /baz/, qux: Int32}) } + + it { is_expected.to be_true } + end + + context "with non-matching arguments" do + let(pattern) { Spectator::Arguments.new({Float64, /bar/}, :splat, {String, Int32, :x}, {bar: /foo/, qux: "123"}) } + + it { is_expected.to be_false } end context "with different arguments" do - let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, nil, nil, {opt: "foobar"}) } + let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) } - it "returns false" do - is_expected.to be_false - end + it { is_expected.to be_false } end context "with the same kwargs in a different order" do - let(pattern) { Spectator::Arguments.new(arguments.args, nil, nil, {qux: 123, bar: "baz"}) } + let(pattern) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: Int32, bar: /baz/}) } - it "returns true" do - is_expected.to be_true - end + it { is_expected.to be_true } + end + + context "with an additional kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/}) } + + it { is_expected.to be_true } end context "with a missing kwarg" do - let(pattern) { Spectator::Arguments.new(arguments.args, nil, nil, {bar: "baz"}) } + let(pattern) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/, qux: Int32, extra: 0}) } - it "returns false" do - is_expected.to be_false - end + it { is_expected.to be_false } end - context "with matching types and regex" do - let(pattern) { Spectator::Arguments.new({Int32, /foo/}, nil, nil, {bar: String, qux: 123}) } + context "with different splat arguments" do + let(pattern) { Spectator::Arguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) } - it "returns true" do - is_expected.to be_true - end + it { is_expected.to be_false } end - context "with different types and regex" do - let(pattern) { Spectator::Arguments.new({Symbol, /bar/}, nil, nil, {bar: String, qux: 42}) } + context "with matching mixed positional tuple types" do + let(pattern) { Spectator::Arguments.new({arg1: Int32, arg2: /foo/}, arguments.splat_name, arguments.splat, arguments.kwargs) } - it "returns false" do - is_expected.to be_false - end + it { is_expected.to be_true } + end + + context "with non-matching mixed positional tuple types" do + let(pattern) { Spectator::Arguments.new({arg1: Float64, arg2: /bar/}, arguments.splat_name, arguments.splat, arguments.kwargs) } + + it { is_expected.to be_false } + end + + context "with matching args spilling over into splat and mixed positional tuple types" do + let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(Int32, /foo/, Symbol, Symbol, :z, bar: /baz/, qux: Int32) } + + it { is_expected.to be_true } + end + + context "with non-matching args spilling over into splat and mixed positional tuple types" do + let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(Float64, /bar/, Symbol, String, :z, bar: /foo/, qux: Int32) } + + it { is_expected.to be_false } + end + + context "with matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(/foo/, Symbol, :y, Symbol, arg1: Int32, bar: /baz/, qux: 123) } + + it { is_expected.to be_true } + end + + context "with non-matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(5, Symbol, :z, Symbol, arg2: /foo/, bar: /baz/, qux: Int32) } + + it { is_expected.to be_false } + end + + context "with non-matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(/bar/, String, :y, Symbol, arg1: 0, bar: /foo/, qux: Float64) } + + it { is_expected.to be_false } end end end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index b033c71..965d52b 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -74,6 +74,15 @@ module Spectator end end + # Returns all named positional and keyword arguments as a named tuple. + def named : NamedTuple + {% if Args < NamedTuple %} + args.merge(kwargs) + {% else %} + kwargs + {% end %} + end + # Constructs a string representation of the arguments. def to_s(io : IO) : Nil return io << "(no args)" if args.empty? && ((splat = @splat).nil? || splat.empty?) && kwargs.empty? @@ -127,31 +136,48 @@ module Spectator # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - {% if Args < NamedTuple %} - if (other_args = other.args).is_a?(NamedTuple) - args.each do |k, v| - return false unless other_args.has_key?(k) - return false unless v === other_args[k] - end - else - return false if args.size != other_args - args.each_with_index do |k, v, i| - return false unless v === other_args.unsafe_fetch(i) - end - end - {% else %} - return false unless args === other.positional - {% end %} + self_args = args + other_args = other.args - if splat = @splat - return false unless splat === other.splat + case {self_args, other_args} + when {Tuple, Tuple} then compare(positional, other.positional, kwargs, other.kwargs) + when {Tuple, NamedTuple} then compare(kwargs, other.named, positional, other_args, other.splat) + when {NamedTuple, Tuple} then compare(positional, other.positional, kwargs, other.kwargs) + else + self_args === other_args && (!splat || splat === other.splat) && compare_named_tuples(kwargs, other.kwargs) + end + end + + private def compare(self_positional : Tuple, other_positional : Tuple, self_kwargs : NamedTuple, other_kwargs : NamedTuple) + self_positional === other_positional && compare_named_tuples(self_kwargs, other_kwargs) + end + + private def compare(self_kwargs : NamedTuple, other_named : NamedTuple, self_positional : Tuple, other_args : NamedTuple, other_splat : Tuple?) + return false unless compare_named_tuples(self_kwargs, other_named) + + i = 0 + other_args.each do |k, v2| + next if self_kwargs.has_key?(k) # Covered by named arguments. + + v1 = self_positional.fetch(i) { return false } + i += 1 + return false unless v1 === v2 end - kwargs.each do |k, v| - return false unless other.kwargs.has_key?(k) - return false unless v === other.kwargs[k] + other_splat.try &.each do |v2| + v1 = self_positional.fetch(i) { return false } + i += 1 + return false unless v1 === v2 end + i == self_positional.size + end + + private def compare_named_tuples(a : NamedTuple, b : NamedTuple) + a.each do |k, v1| + v2 = b.fetch(k) { return false } + return false unless v1 === v2 + end true end end From 39e4f8e37a00a9c3e8a1b9321c97d462ad52690c Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 21:53:24 -0600 Subject: [PATCH 034/120] Use `build` instead of `capture` for `none` --- src/spectator/mocks/arguments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index 965d52b..17567e3 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -30,7 +30,7 @@ module Spectator end # Instance of empty arguments. - class_getter none : AbstractArguments = capture + class_getter none : AbstractArguments = build # Returns unconstrained arguments. def self.any : AbstractArguments? From 8959d28b38dd03d30814b513ae144f7e154009ff Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 21:54:12 -0600 Subject: [PATCH 035/120] Cleaner call capture and logging for missing methods in doubles --- src/spectator/mocks/double.cr | 5 +++-- src/spectator/mocks/lazy_double.cr | 6 +++--- src/spectator/mocks/null_double.cr | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index b45b9c8..d099969 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -184,11 +184,12 @@ module Spectator # Handle all methods but only respond to configured messages. # Raises an `UnexpectedMessage` error for non-configures messages. macro method_missing(call) - Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" } - args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %}) + args = ::Spectator::Arguments.build({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args) _spectator_record_call(call) + Log.trace { "#{_spectator_stubbed_name} got undefined method `#{call}{% if call.block %} { ... }{% end %}`" } + raise ::Spectator::UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") nil # Necessary for compiler to infer return type as nil. Avoids runtime "can't execute ... `x` has no type errors". end diff --git a/src/spectator/mocks/lazy_double.cr b/src/spectator/mocks/lazy_double.cr index a0158ef..a53c0da 100644 --- a/src/spectator/mocks/lazy_double.cr +++ b/src/spectator/mocks/lazy_double.cr @@ -52,13 +52,13 @@ module Spectator # Handles all messages. macro method_missing(call) - Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" } - # Capture information about the call. - %args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %}) + %args = ::Spectator::Arguments.build({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) + Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } + # Attempt to find a stub that satisfies the method call and arguments. if %stub = _spectator_find_stub(%call) # Cast the stub or return value to the expected type. diff --git a/src/spectator/mocks/null_double.cr b/src/spectator/mocks/null_double.cr index 978418c..c139737 100644 --- a/src/spectator/mocks/null_double.cr +++ b/src/spectator/mocks/null_double.cr @@ -51,13 +51,13 @@ module Spectator # Handles all undefined messages. # Returns stubbed values if available, otherwise delegates to `#_spectator_abstract_stub_fallback`. macro method_missing(call) - Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" } - # Capture information about the call. - %args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %}) + %args = ::Spectator::Arguments.build({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) + Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } + self end end From c77da67341cf78278c3e3435b9f0716b65a72ef6 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 21:56:37 -0600 Subject: [PATCH 036/120] Hide splat label in certain situations Undefined double methods were reporting splat arguments, which is technically correct. But for output in these cases, it makes more sense to show the exact calling args. --- spec/spectator/mocks/arguments_spec.cr | 8 ++++++++ src/spectator/mocks/arguments.cr | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/spec/spectator/mocks/arguments_spec.cr b/spec/spectator/mocks/arguments_spec.cr index 8b43b95..cd92010 100644 --- a/spec/spectator/mocks/arguments_spec.cr +++ b/spec/spectator/mocks/arguments_spec.cr @@ -97,6 +97,14 @@ Spectator.describe Spectator::Arguments do is_expected.to eq("(no args)") end end + + context "with a splat and no arguments" do + let(arguments) { Spectator::Arguments.build(NamedTuple.new, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "omits the splat name" do + is_expected.to eq("(:x, :y, :z, bar: \"baz\", qux: 123)") + end + end end describe "#==" do diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index 17567e3..003875f 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -107,14 +107,14 @@ module Spectator # Add the splat arguments. if (splat = @splat) && !splat.empty? io << ", " unless args.empty? - if name = @splat_name - io << '*' << name << ": {" + if splat_name = !args.empty? && @splat_name + io << '*' << splat_name << ": {" end splat.each_with_index do |arg, i| io << ", " if i > 0 arg.inspect(io) end - io << '}' if @splat_name + io << '}' if splat_name end # Add the keyword arguments. From a31ffe3fa383fd50948088275a3a23d694926773 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 22:04:28 -0600 Subject: [PATCH 037/120] Fix argument capture Fix issue added by 8959d28b38dd03d30814b513ae144f7e154009ff --- src/spectator/mocks/double.cr | 2 +- src/spectator/mocks/lazy_double.cr | 2 +- src/spectator/mocks/null_double.cr | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index d099969..9ce3c5e 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -184,7 +184,7 @@ module Spectator # Handle all methods but only respond to configured messages. # Raises an `UnexpectedMessage` error for non-configures messages. macro method_missing(call) - args = ::Spectator::Arguments.build({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) + args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args) _spectator_record_call(call) diff --git a/src/spectator/mocks/lazy_double.cr b/src/spectator/mocks/lazy_double.cr index a53c0da..9a9257d 100644 --- a/src/spectator/mocks/lazy_double.cr +++ b/src/spectator/mocks/lazy_double.cr @@ -53,7 +53,7 @@ module Spectator # Handles all messages. macro method_missing(call) # Capture information about the call. - %args = ::Spectator::Arguments.build({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) + %args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) diff --git a/src/spectator/mocks/null_double.cr b/src/spectator/mocks/null_double.cr index c139737..0ddd03d 100644 --- a/src/spectator/mocks/null_double.cr +++ b/src/spectator/mocks/null_double.cr @@ -52,7 +52,7 @@ module Spectator # Returns stubbed values if available, otherwise delegates to `#_spectator_abstract_stub_fallback`. macro method_missing(call) # Capture information about the call. - %args = ::Spectator::Arguments.build({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) + %args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) From 4dacaab6dc575618eb6e8546bdbb452a54bfa771 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 22:36:20 -0600 Subject: [PATCH 038/120] Fix missing keyword arguments after splat --- spec/spectator/dsl/mocks/mock_spec.cr | 2 +- src/spectator/mocks/stubbable.cr | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/spec/spectator/dsl/mocks/mock_spec.cr b/spec/spectator/dsl/mocks/mock_spec.cr index d636826..a2b67f3 100644 --- a/spec/spectator/dsl/mocks/mock_spec.cr +++ b/spec/spectator/dsl/mocks/mock_spec.cr @@ -11,7 +11,7 @@ Spectator.describe "Mock DSL", :smoke do args[1].as(Int32), args[2].as(Int32), }, - args[3].as(Int32), + args[:kwarg].as(Int32), { x: args[:x].as(Int32), y: args[:y].as(Int32), diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index ae8d3f4..6f266b9 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -145,7 +145,9 @@ module Spectator {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ), {% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} - {{method.double_splat}} + ::NamedTuple.new( + {% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} + ).merge({{method.double_splat}}) ) %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) _spectator_record_call(%call) @@ -245,7 +247,9 @@ module Spectator {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ), {% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} - {{method.double_splat}} + ::NamedTuple.new( + {% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} + ).merge({{method.double_splat}}) ) %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) _spectator_record_call(%call) From baff1de1d86a852c7377eaa6bf9c7fed95cd1544 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 23 Oct 2022 22:37:41 -0600 Subject: [PATCH 039/120] Update changelog Implemented https://github.com/icy-arctic-fox/spectator/issues/46 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7141cc7..3a5526d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Add support for using named (keyword) arguments in place of positional arguments in stubs. [#47](https://github.com/icy-arctic-fox/spectator/issues/47) + ### Fixed - Clear stubs defined with `expect().to receive()` syntax after test finishes to prevent leakage between tests. - Ensure stubs defined with `allow().to receive()` syntax are cleared after test finishes when used inside a test (another leakage). From 24fd7d1e915da330df0ace4c84eab4d375152b1a Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 28 Oct 2022 18:14:53 -0600 Subject: [PATCH 040/120] Update Ameba --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index c30a45f..9714e38 100644 --- a/shard.yml +++ b/shard.yml @@ -13,4 +13,4 @@ license: MIT development_dependencies: ameba: github: crystal-ameba/ameba - version: ~> 1.0.0 + version: ~> 1.2.0 From 4906dfae0d6b4f634eccbc10b69a44598a6ac9ae Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 16:55:31 -0600 Subject: [PATCH 041/120] Add short before/after hook name --- src/spectator/dsl/hooks.cr | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/spectator/dsl/hooks.cr b/src/spectator/dsl/hooks.cr index 3cd4d87..6672e59 100644 --- a/src/spectator/dsl/hooks.cr +++ b/src/spectator/dsl/hooks.cr @@ -124,11 +124,21 @@ module Spectator::DSL # This means that values defined by `let` and `subject` are available. define_example_hook :before_each + # :ditto: + macro before(&block) + before_each {{block}} + end + # Defines a block of code that will be invoked after every example in the group. # The block will be run in the context of the current running example. # This means that values defined by `let` and `subject` are available. define_example_hook :after_each + # :ditto: + macro after(&block) + after_each {{block}} + end + # Defines a block of code that will be invoked around every example in the group. # The block will be run in the context of the current running example. # This means that values defined by `let` and `subject` are available. @@ -139,6 +149,11 @@ module Spectator::DSL # More code can run afterwards (in the block). define_example_hook :around_each + # :ditto: + macro around(&block) + around_each {{block}} + end + # Defines a block of code that will be invoked before every example in the group. # The block will be run in the context of the current running example. # This means that values defined by `let` and `subject` are available. From a6149b2671c3adb132ed52433a77fd90bd8de852 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 16:56:03 -0600 Subject: [PATCH 042/120] Use `before` instead of `before_each` (same for after) --- spec/matchers/receive_matcher_spec.cr | 4 ++-- spec/rspec/core/explicit_subject_spec.cr | 2 +- spec/spectator/dsl/mocks/double_spec.cr | 2 +- spec/spectator/dsl/mocks/mock_spec.cr | 2 +- spec/spectator/mocks/double_spec.cr | 10 +++++----- spec/spectator/mocks/lazy_double_spec.cr | 4 ++-- spec/spectator/mocks/mock_spec.cr | 22 +++++++++++----------- spec/spectator/mocks/null_double_spec.cr | 10 +++++----- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/spec/matchers/receive_matcher_spec.cr b/spec/matchers/receive_matcher_spec.cr index 6dfdd28..6ddbfe7 100644 --- a/spec/matchers/receive_matcher_spec.cr +++ b/spec/matchers/receive_matcher_spec.cr @@ -169,7 +169,7 @@ Spectator.describe Spectator::Matchers::ReceiveMatcher do end context "with method calls" do - before_each do + before do dbl.test_method dbl.test_method(1, "wrong", :xyz, foo: "foobarbaz") dbl.irrelevant("foo") @@ -289,7 +289,7 @@ Spectator.describe Spectator::Matchers::ReceiveMatcher do pre_condition { expect(match_data).to be_a(failed_match) } - before_each do + before do dbl.test_method dbl.test_method(1, "test", :xyz, foo: "foobarbaz") dbl.irrelevant("foo") diff --git a/spec/rspec/core/explicit_subject_spec.cr b/spec/rspec/core/explicit_subject_spec.cr index 3775b8a..075bad7 100644 --- a/spec/rspec/core/explicit_subject_spec.cr +++ b/spec/rspec/core/explicit_subject_spec.cr @@ -52,7 +52,7 @@ Spectator.describe "Explicit Subject" do describe Array(Int32) do # TODO: Multiple arguments to describe/context. subject { [] of Int32 } - before_each { subject.push(1, 2, 3) } + before { subject.push(1, 2, 3) } it "has the prescribed elements" do expect(subject).to eq([1, 2, 3]) diff --git a/spec/spectator/dsl/mocks/double_spec.cr b/spec/spectator/dsl/mocks/double_spec.cr index d3eda6d..89f652c 100644 --- a/spec/spectator/dsl/mocks/double_spec.cr +++ b/spec/spectator/dsl/mocks/double_spec.cr @@ -312,7 +312,7 @@ Spectator.describe "Double DSL", :smoke do let(override) { :override } let(dbl) { double(:context_double, override: override) } - before_each { allow(dbl).to receive(:memoize).and_return(memoize) } + before { allow(dbl).to receive(:memoize).and_return(memoize) } it "doesn't change predefined values" do expect(dbl.predefined).to eq(:predefined) diff --git a/spec/spectator/dsl/mocks/mock_spec.cr b/spec/spectator/dsl/mocks/mock_spec.cr index a2b67f3..db63dd9 100644 --- a/spec/spectator/dsl/mocks/mock_spec.cr +++ b/spec/spectator/dsl/mocks/mock_spec.cr @@ -947,7 +947,7 @@ Spectator.describe "Mock DSL", :smoke do let(override) { :override } let(fake) { mock(Dummy, override: override) } - before_each { allow(fake).to receive(:memoize).and_return(memoize) } + before { allow(fake).to receive(:memoize).and_return(memoize) } it "doesn't change predefined values" do expect(fake.predefined).to eq(:predefined) diff --git a/spec/spectator/mocks/double_spec.cr b/spec/spectator/mocks/double_spec.cr index c93a723..1b1abc9 100644 --- a/spec/spectator/mocks/double_spec.cr +++ b/spec/spectator/mocks/double_spec.cr @@ -309,7 +309,7 @@ Spectator.describe Spectator::Double do subject(dbl) { ClassDouble } let(foo_stub) { Spectator::ValueStub.new(:foo, :override) } - after_each { dbl._spectator_clear_stubs } + after { dbl._spectator_clear_stubs } it "overrides an existing method" do expect { dbl._spectator_define_stub(foo_stub) }.to change { dbl.foo }.from(:stub).to(:override) @@ -357,7 +357,7 @@ Spectator.describe Spectator::Double do end describe "._spectator_clear_stubs" do - before_each { dbl._spectator_define_stub(foo_stub) } + before { dbl._spectator_define_stub(foo_stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(:override).to(:stub) @@ -365,7 +365,7 @@ Spectator.describe Spectator::Double do end describe "._spectator_calls" do - before_each { dbl._spectator_clear_calls } + before { dbl._spectator_clear_calls } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) @@ -440,7 +440,7 @@ Spectator.describe Spectator::Double do subject(dbl) { FooBarDouble.new } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42) @@ -451,7 +451,7 @@ Spectator.describe Spectator::Double do subject(dbl) { FooBarDouble.new } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) diff --git a/spec/spectator/mocks/lazy_double_spec.cr b/spec/spectator/mocks/lazy_double_spec.cr index 902922f..c3402b5 100644 --- a/spec/spectator/mocks/lazy_double_spec.cr +++ b/spec/spectator/mocks/lazy_double_spec.cr @@ -246,7 +246,7 @@ Spectator.describe Spectator::LazyDouble do subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42) @@ -257,7 +257,7 @@ Spectator.describe Spectator::LazyDouble do subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) diff --git a/spec/spectator/mocks/mock_spec.cr b/spec/spectator/mocks/mock_spec.cr index 0c19759..40b4bf0 100644 --- a/spec/spectator/mocks/mock_spec.cr +++ b/spec/spectator/mocks/mock_spec.cr @@ -312,7 +312,7 @@ Spectator.describe Spectator::Mock do let(mock) { MockThing } let(foo_stub) { Spectator::ValueStub.new(:foo, :override) } - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "overrides an existing method" do expect { mock._spectator_define_stub(foo_stub) }.to change { mock.foo }.from(:stub).to(:override) @@ -368,7 +368,7 @@ Spectator.describe Spectator::Mock do end describe "._spectator_clear_stubs" do - before_each { mock._spectator_define_stub(foo_stub) } + before { mock._spectator_define_stub(foo_stub) } it "removes previously defined stubs" do expect { mock._spectator_clear_stubs }.to change { mock.foo }.from(:override).to(:stub) @@ -376,7 +376,7 @@ Spectator.describe Spectator::Mock do end describe "._spectator_calls" do - before_each { mock._spectator_clear_calls } + before { mock._spectator_clear_calls } # Retrieves symbolic names of methods called on a mock. def called_method_names(mock) @@ -410,7 +410,7 @@ Spectator.describe Spectator::Mock do let(mock) { MockThing.new } - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "raises a TypeCastError when using a value-based stub" do stub = Spectator::ValueStub.new(:oops, nil).as(Spectator::Stub) @@ -461,7 +461,7 @@ Spectator.describe Spectator::Mock do let(mock) { MockedClass.new } # Necessary to clear stubs to prevent leakages between tests. - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "overrides responses from methods with keyword arguments" do expect(mock.method1).to eq(123) @@ -571,8 +571,8 @@ Spectator.describe Spectator::Mock do let(mock) { MockedStruct.new } # Necessary to clear stubs to prevent leakages between tests. - after_each { mock._spectator_clear_stubs } - after_each { MockedStruct._spectator_invocations.clear } + after { mock._spectator_clear_stubs } + after { MockedStruct._spectator_invocations.clear } it "overrides responses from methods with keyword arguments" do expect(mock.method1).to eq(123) @@ -656,7 +656,7 @@ Spectator.describe Spectator::Mock do let(mock) { Thing } let(foo_stub) { Spectator::ValueStub.new(:foo, :override) } - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "overrides an existing method" do expect { mock._spectator_define_stub(foo_stub) }.to change { mock.foo }.from(:stub).to(:override) @@ -712,7 +712,7 @@ Spectator.describe Spectator::Mock do end describe "._spectator_clear_stubs" do - before_each { mock._spectator_define_stub(foo_stub) } + before { mock._spectator_define_stub(foo_stub) } it "removes previously defined stubs" do expect { mock._spectator_clear_stubs }.to change { mock.foo }.from(:override).to(:stub) @@ -720,7 +720,7 @@ Spectator.describe Spectator::Mock do end describe "._spectator_calls" do - before_each { mock._spectator_clear_calls } + before { mock._spectator_clear_calls } # Retrieves symbolic names of methods called on a mock. def called_method_names(mock) @@ -756,7 +756,7 @@ Spectator.describe Spectator::Mock do let(mock) { NoReturnThing.new } - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "raises a TypeCastError when using a value-based stub" do stub = Spectator::ValueStub.new(:oops, nil).as(Spectator::Stub) diff --git a/spec/spectator/mocks/null_double_spec.cr b/spec/spectator/mocks/null_double_spec.cr index 1aa86ca..4a8ef3a 100644 --- a/spec/spectator/mocks/null_double_spec.cr +++ b/spec/spectator/mocks/null_double_spec.cr @@ -270,7 +270,7 @@ Spectator.describe Spectator::NullDouble do subject(dbl) { ClassDouble } let(foo_stub) { Spectator::ValueStub.new(:foo, :override) } - after_each { dbl._spectator_clear_stubs } + after { dbl._spectator_clear_stubs } it "overrides an existing method" do expect { dbl._spectator_define_stub(foo_stub) }.to change { dbl.foo }.from(:stub).to(:override) @@ -318,7 +318,7 @@ Spectator.describe Spectator::NullDouble do end describe "._spectator_clear_stubs" do - before_each { dbl._spectator_define_stub(foo_stub) } + before { dbl._spectator_define_stub(foo_stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(:override).to(:stub) @@ -326,7 +326,7 @@ Spectator.describe Spectator::NullDouble do end describe "._spectator_calls" do - before_each { dbl._spectator_clear_calls } + before { dbl._spectator_clear_calls } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) @@ -401,7 +401,7 @@ Spectator.describe Spectator::NullDouble do subject(dbl) { FooBarDouble.new } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42) @@ -412,7 +412,7 @@ Spectator.describe Spectator::NullDouble do subject(dbl) { FooBarDouble.new } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) From c00d2fe4e64ced7e766e3a4052537f2ebc0d5b6d Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 16:57:06 -0600 Subject: [PATCH 043/120] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5526d..0835bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Add support for using named (keyword) arguments in place of positional arguments in stubs. [#47](https://github.com/icy-arctic-fox/spectator/issues/47) +- Add `before`, `after`, and `around` as aliases for `before_each`, `after_each`, and `around_each` respectively. ### Fixed - Clear stubs defined with `expect().to receive()` syntax after test finishes to prevent leakage between tests. From 1093571fbd0cdee822bb84e687716d5c9f08ecb6 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 20:34:52 -0600 Subject: [PATCH 044/120] Add more info to stub.to_s --- src/spectator/mocks/exception_stub.cr | 6 ++++++ src/spectator/mocks/typed_stub.cr | 6 ++++++ src/spectator/mocks/value_stub.cr | 7 +++++++ 3 files changed, 19 insertions(+) diff --git a/src/spectator/mocks/exception_stub.cr b/src/spectator/mocks/exception_stub.cr index 9aee69c..e7b6cb9 100644 --- a/src/spectator/mocks/exception_stub.cr +++ b/src/spectator/mocks/exception_stub.cr @@ -20,6 +20,12 @@ module Spectator def initialize(method : Symbol, @exception : Exception, constraint : AbstractArguments? = nil, location : Location? = nil) super(method, constraint, location) end + + # String representation of the stub, formatted as a method call. + def to_s(io : IO) : Nil + super + io << " # raises " << @exception + end end module StubModifiers diff --git a/src/spectator/mocks/typed_stub.cr b/src/spectator/mocks/typed_stub.cr index 5067215..eabbcb9 100644 --- a/src/spectator/mocks/typed_stub.cr +++ b/src/spectator/mocks/typed_stub.cr @@ -9,5 +9,11 @@ module Spectator abstract class TypedStub(T) < Stub # Invokes the stubbed implementation. abstract def call(call : MethodCall) : T + + # String representation of the stub, formatted as a method call. + def to_s(io : IO) : Nil + super + io << " : " << T + end end end diff --git a/src/spectator/mocks/value_stub.cr b/src/spectator/mocks/value_stub.cr index 464c38b..7a84d19 100644 --- a/src/spectator/mocks/value_stub.cr +++ b/src/spectator/mocks/value_stub.cr @@ -20,6 +20,13 @@ module Spectator def initialize(method : Symbol, @value : T, constraint : AbstractArguments? = nil, location : Location? = nil) super(method, constraint, location) end + + # String representation of the stub, formatted as a method call and return value. + def to_s(io : IO) : Nil + super + io << " # => " + @value.inspect(io) + end end module StubModifiers From 12eb2e935724cd51ba4292935fb628dbf000c8c7 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 20:35:43 -0600 Subject: [PATCH 045/120] Avoid printing double contents from to_s --- src/spectator/mocks/double.cr | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index 9ce3c5e..8143ba0 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -95,6 +95,12 @@ module Spectator false end + # Simplified string representation of a double. + # Avoids displaying nested content and bloating method instantiation. + def to_s(io : IO) : Nil + io << _spectator_stubbed_name + end + # Defines a stub to change the behavior of a method in this double. # # NOTE: Defining a stub for a method not defined in the double's type has no effect. From 6e7d215f691899310da56b470ff94a79bb4bcfce Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 20:56:02 -0600 Subject: [PATCH 046/120] Add type annotations to to_s and inspect --- src/spectator/abstract_expression.cr | 4 ++-- src/spectator/anything.cr | 4 ++-- src/spectator/context.cr | 4 ++-- src/spectator/error_result.cr | 2 +- src/spectator/example.cr | 6 +++--- src/spectator/example_group.cr | 2 +- src/spectator/example_group_hook.cr | 2 +- src/spectator/example_hook.cr | 2 +- src/spectator/example_procsy_hook.cr | 2 +- src/spectator/fail_result.cr | 2 +- src/spectator/formatting/components/comment.cr | 2 +- src/spectator/formatting/components/example_command.cr | 2 +- src/spectator/formatting/components/failure_command_list.cr | 2 +- src/spectator/formatting/components/profile.cr | 2 +- src/spectator/formatting/components/result_block.cr | 2 +- src/spectator/formatting/components/runtime.cr | 2 +- src/spectator/formatting/components/stats.cr | 2 +- src/spectator/formatting/components/tap_profile.cr | 2 +- src/spectator/formatting/components/totals.cr | 2 +- src/spectator/location.cr | 2 +- src/spectator/matchers/attributes_matcher.cr | 2 +- src/spectator/node.cr | 4 ++-- src/spectator/pass_result.cr | 2 +- src/spectator/pending_result.cr | 2 +- 24 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/spectator/abstract_expression.cr b/src/spectator/abstract_expression.cr index b58c1cc..6ab4cbf 100644 --- a/src/spectator/abstract_expression.cr +++ b/src/spectator/abstract_expression.cr @@ -34,7 +34,7 @@ module Spectator # Produces a string representation of the expression. # This consists of the label (if one is available) and the value. - def to_s(io) + def to_s(io : IO) : Nil if (label = @label) io << label << ": " end @@ -43,7 +43,7 @@ module Spectator # Produces a detailed string representation of the expression. # This consists of the label (if one is available) and the value. - def inspect(io) + def inspect(io : IO) : Nil if (label = @label) io << label << ": " end diff --git a/src/spectator/anything.cr b/src/spectator/anything.cr index e4d7b34..aa25e3c 100644 --- a/src/spectator/anything.cr +++ b/src/spectator/anything.cr @@ -13,12 +13,12 @@ module Spectator end # Displays "anything". - def to_s(io) + def to_s(io : IO) : Nil io << "anything" end # Displays "". - def inspect(io) + def inspect(io : IO) : Nil io << "" end end diff --git a/src/spectator/context.cr b/src/spectator/context.cr index c3213e3..7fc126b 100644 --- a/src/spectator/context.cr +++ b/src/spectator/context.cr @@ -10,12 +10,12 @@ abstract class SpectatorContext # and that the Crystal compiler instantiates a `#to_s` and/or `#inspect` for each of those types, # an explosion in method instances can be created. # The compile time is drastically reduced by using a dummy string instead. - def to_s(io) + def to_s(io : IO) : Nil io << "Context" end # :ditto: - def inspect(io) + def inspect(io : IO) : Nil io << "Context<" << self.class << '>' end end diff --git a/src/spectator/error_result.cr b/src/spectator/error_result.cr index 4babc2a..a4531fb 100644 --- a/src/spectator/error_result.cr +++ b/src/spectator/error_result.cr @@ -16,7 +16,7 @@ module Spectator end # One-word description of the result. - def to_s(io) + def to_s(io : IO) : Nil io << "error" end diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 8f99e93..06e0f19 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -191,7 +191,7 @@ module Spectator # Constructs the full name or description of the example. # This prepends names of groups this example is part of. - def to_s(io) + def to_s(io : IO) : Nil name = @name # Prefix with group's full name if the node belongs to a group. @@ -210,7 +210,7 @@ module Spectator end # Exposes information about the example useful for debugging. - def inspect(io) + def inspect(io : IO) : Nil super io << ' ' << result end @@ -286,7 +286,7 @@ module Spectator # Constructs the full name or description of the example. # This prepends names of groups this example is part of. - def to_s(io) : Nil + def to_s(io : IO) : Nil @example.to_s(io) end end diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr index 0481fc4..bb702e4 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -112,7 +112,7 @@ module Spectator # Constructs the full name or description of the example group. # This prepends names of groups this group is part of. - def to_s(io) + def to_s(io : IO) : Nil # Prefix with group's full name if the node belongs to a group. return unless parent = @group diff --git a/src/spectator/example_group_hook.cr b/src/spectator/example_group_hook.cr index bd6bac8..aee357f 100644 --- a/src/spectator/example_group_hook.cr +++ b/src/spectator/example_group_hook.cr @@ -42,7 +42,7 @@ module Spectator # Produces the string representation of the hook. # Includes the location and label if they're not nil. - def to_s(io) + def to_s(io : IO) : Nil io << "example group hook" if (label = @label) diff --git a/src/spectator/example_hook.cr b/src/spectator/example_hook.cr index edebf26..6bc77a0 100644 --- a/src/spectator/example_hook.cr +++ b/src/spectator/example_hook.cr @@ -37,7 +37,7 @@ module Spectator # Produces the string representation of the hook. # Includes the location and label if they're not nil. - def to_s(io) + def to_s(io : IO) : Nil io << "example hook" if (label = @label) diff --git a/src/spectator/example_procsy_hook.cr b/src/spectator/example_procsy_hook.cr index 8a64f17..16bc970 100644 --- a/src/spectator/example_procsy_hook.cr +++ b/src/spectator/example_procsy_hook.cr @@ -39,7 +39,7 @@ module Spectator # Produces the string representation of the hook. # Includes the location and label if they're not nil. - def to_s(io) + def to_s(io : IO) : Nil io << "example hook" if (label = @label) diff --git a/src/spectator/fail_result.cr b/src/spectator/fail_result.cr index 36ea8fb..c283a80 100644 --- a/src/spectator/fail_result.cr +++ b/src/spectator/fail_result.cr @@ -55,7 +55,7 @@ module Spectator end # One-word description of the result. - def to_s(io) + def to_s(io : IO) : Nil io << "fail" end diff --git a/src/spectator/formatting/components/comment.cr b/src/spectator/formatting/components/comment.cr index b398840..30f4293 100644 --- a/src/spectator/formatting/components/comment.cr +++ b/src/spectator/formatting/components/comment.cr @@ -16,7 +16,7 @@ module Spectator::Formatting::Components end # Writes the comment to the output. - def to_s(io) + def to_s(io : IO) : Nil io << "# " << @content end end diff --git a/src/spectator/formatting/components/example_command.cr b/src/spectator/formatting/components/example_command.cr index 8b3c0b9..b1246db 100644 --- a/src/spectator/formatting/components/example_command.cr +++ b/src/spectator/formatting/components/example_command.cr @@ -9,7 +9,7 @@ module Spectator::Formatting::Components end # Produces output for running the previously specified example. - def to_s(io) + def to_s(io : IO) : Nil io << "crystal spec " # Use location for argument if it's available, since it's simpler. diff --git a/src/spectator/formatting/components/failure_command_list.cr b/src/spectator/formatting/components/failure_command_list.cr index 7da5ed2..7eab4f5 100644 --- a/src/spectator/formatting/components/failure_command_list.cr +++ b/src/spectator/formatting/components/failure_command_list.cr @@ -10,7 +10,7 @@ module Spectator::Formatting::Components end # Produces the list of commands to run failed examples. - def to_s(io) + def to_s(io : IO) : Nil io.puts "Failed examples:" io.puts @failures.each do |failure| diff --git a/src/spectator/formatting/components/profile.cr b/src/spectator/formatting/components/profile.cr index 2a56e0d..b98d8f5 100644 --- a/src/spectator/formatting/components/profile.cr +++ b/src/spectator/formatting/components/profile.cr @@ -9,7 +9,7 @@ module Spectator::Formatting::Components end # Produces the output containing the profiling information. - def to_s(io) + def to_s(io : IO) : Nil io << "Top " io << @profile.size io << " slowest examples (" diff --git a/src/spectator/formatting/components/result_block.cr b/src/spectator/formatting/components/result_block.cr index bf4b3d2..ddd9c47 100644 --- a/src/spectator/formatting/components/result_block.cr +++ b/src/spectator/formatting/components/result_block.cr @@ -41,7 +41,7 @@ module Spectator::Formatting::Components private abstract def content(io) # Writes the component's output to the specified stream. - def to_s(io) + def to_s(io : IO) : Nil title_line(io) # Ident over to align with the spacing used by the index. indent(index_digit_count + 2) do diff --git a/src/spectator/formatting/components/runtime.cr b/src/spectator/formatting/components/runtime.cr index 9f28813..9638d93 100644 --- a/src/spectator/formatting/components/runtime.cr +++ b/src/spectator/formatting/components/runtime.cr @@ -15,7 +15,7 @@ module Spectator::Formatting::Components # #:##:## # # days #:##:## # ``` - def to_s(io) + def to_s(io : IO) : Nil millis = @span.total_milliseconds return format_micro(io, millis * 1000) if millis < 1 diff --git a/src/spectator/formatting/components/stats.cr b/src/spectator/formatting/components/stats.cr index ab76c7c..47e1063 100644 --- a/src/spectator/formatting/components/stats.cr +++ b/src/spectator/formatting/components/stats.cr @@ -11,7 +11,7 @@ module Spectator::Formatting::Components end # Displays the stats. - def to_s(io) + def to_s(io : IO) : Nil runtime(io) totals(io) if seed = @report.random_seed? diff --git a/src/spectator/formatting/components/tap_profile.cr b/src/spectator/formatting/components/tap_profile.cr index 64c36b0..4154e07 100644 --- a/src/spectator/formatting/components/tap_profile.cr +++ b/src/spectator/formatting/components/tap_profile.cr @@ -10,7 +10,7 @@ module Spectator::Formatting::Components end # Produces the output containing the profiling information. - def to_s(io) + def to_s(io : IO) : Nil io << "# Top " io << @profile.size io << " slowest examples (" diff --git a/src/spectator/formatting/components/totals.cr b/src/spectator/formatting/components/totals.cr index 941b6ee..4063cae 100644 --- a/src/spectator/formatting/components/totals.cr +++ b/src/spectator/formatting/components/totals.cr @@ -31,7 +31,7 @@ module Spectator::Formatting::Components end # Writes the counts to the output. - def to_s(io) + def to_s(io : IO) : Nil io << @examples << " examples, " << @failures << " failures" if @errors > 0 diff --git a/src/spectator/location.cr b/src/spectator/location.cr index 688fdb6..7b06eb9 100644 --- a/src/spectator/location.cr +++ b/src/spectator/location.cr @@ -59,7 +59,7 @@ module Spectator # ```text # FILE:LINE # ``` - def to_s(io) + def to_s(io : IO) : Nil io << path << ':' << line end end diff --git a/src/spectator/matchers/attributes_matcher.cr b/src/spectator/matchers/attributes_matcher.cr index 8ecd714..c66ecc4 100644 --- a/src/spectator/matchers/attributes_matcher.cr +++ b/src/spectator/matchers/attributes_matcher.cr @@ -15,7 +15,7 @@ module Spectator::Matchers extend self # Text displayed when a method is undefined. - def inspect(io) + def inspect(io : IO) : Nil io << "" end end diff --git a/src/spectator/node.cr b/src/spectator/node.cr index c5a64b6..807c8df 100644 --- a/src/spectator/node.cr +++ b/src/spectator/node.cr @@ -66,12 +66,12 @@ module Spectator # Constructs the full name or description of the node. # This prepends names of groups this node is part of. - def to_s(io) + def to_s(io : IO) : Nil display_name.to_s(io) end # Exposes information about the node useful for debugging. - def inspect(io) + def inspect(io : IO) : Nil # Full node name. io << '"' << self << '"' diff --git a/src/spectator/pass_result.cr b/src/spectator/pass_result.cr index 2b62383..20e3b04 100644 --- a/src/spectator/pass_result.cr +++ b/src/spectator/pass_result.cr @@ -24,7 +24,7 @@ module Spectator end # One-word description of the result. - def to_s(io) + def to_s(io : IO) : Nil io << "pass" end diff --git a/src/spectator/pending_result.cr b/src/spectator/pending_result.cr index 03700d9..cff38c5 100644 --- a/src/spectator/pending_result.cr +++ b/src/spectator/pending_result.cr @@ -43,7 +43,7 @@ module Spectator end # One-word description of the result. - def to_s(io) + def to_s(io : IO) : Nil io << "pending" end From 8b12262c62a49b204053a9aa07f69fca71bdd926 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 21:01:32 -0600 Subject: [PATCH 047/120] Display when to_s is called directly on the root group --- src/spectator/example_group.cr | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr index bb702e4..3ff4fcf 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -112,11 +112,15 @@ module Spectator # Constructs the full name or description of the example group. # This prepends names of groups this group is part of. - def to_s(io : IO) : Nil - # Prefix with group's full name if the node belongs to a group. - return unless parent = @group + def to_s(io : IO, *, nested = false) : Nil + unless parent = @group + # Display special string when called directly. + io << "" unless nested + return + end - parent.to_s(io) + # Prefix with group's full name if the node belongs to a group. + parent.to_s(io, nested: true) name = @name # Add padding between the node names @@ -126,7 +130,7 @@ module Spectator (parent.name?.is_a?(Symbol) && name.is_a?(String) && (name.starts_with?('#') || name.starts_with?('.'))) - super + super(io) end # Adds the specified *node* to the group. From 60b5f151f1e59eb6c7dde1ba22b7f1504f4b7e48 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 22:05:27 -0600 Subject: [PATCH 048/120] Minor improvements to log output --- src/spectator/example.cr | 8 +++++--- src/spectator/example_group.cr | 14 +++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 06e0f19..3625ded 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -103,8 +103,8 @@ module Spectator # Returns the result of the execution. # The result will also be stored in `#result`. def run : Result - Log.debug { "Running example #{self}" } - Log.warn { "Example #{self} already ran" } if @finished + Log.debug { "Running example: #{self}" } + Log.warn { "Example already ran: #{self}" } if @finished if pending? Log.debug { "Skipping example #{self} - marked pending" } @@ -142,8 +142,10 @@ module Spectator group.call_before_each(self) group.call_pre_condition(self) end + Log.trace { "Running example code for: #{self}" } @entrypoint.call(self) @finished = true + Log.trace { "Finished running example code for: #{self}" } if group = @group group.call_post_condition(self) group.call_after_each(self) @@ -212,7 +214,7 @@ module Spectator # Exposes information about the example useful for debugging. def inspect(io : IO) : Nil super - io << ' ' << result + io << " - " << result end # Creates the JSON representation of the example, diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr index 3ff4fcf..dc1fa57 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -19,14 +19,14 @@ module Spectator protected setter group : ExampleGroup? define_hook before_all : ExampleGroupHook do - Log.trace { "Processing before_all hooks for #{self}" } + Log.trace { "Processing before_all hooks for: #{self}" } @group.try &.call_before_all before_all_hooks.each &.call_once end define_hook after_all : ExampleGroupHook, :prepend do - Log.trace { "Processing after_all hooks for #{self}" } + Log.trace { "Processing after_all hooks for: #{self}" } after_all_hooks.each &.call_once if finished? if group = @group @@ -35,21 +35,21 @@ module Spectator end define_hook before_each : ExampleHook do |example| - Log.trace { "Processing before_each hooks for #{self}" } + Log.trace { "Processing before_each hooks for: #{self}" } @group.try &.call_before_each(example) before_each_hooks.each &.call(example) end define_hook after_each : ExampleHook, :prepend do |example| - Log.trace { "Processing after_each hooks for #{self}" } + Log.trace { "Processing after_each hooks for: #{self}" } after_each_hooks.each &.call(example) @group.try &.call_after_each(example) end define_hook around_each : ExampleProcsyHook do |procsy| - Log.trace { "Processing around_each hooks for #{self}" } + Log.trace { "Processing around_each hooks for: #{self}" } around_each_hooks.reverse_each { |hook| procsy = hook.wrap(procsy) } if group = @group @@ -59,14 +59,14 @@ module Spectator end define_hook pre_condition : ExampleHook do |example| - Log.trace { "Processing pre_condition hooks for #{self}" } + Log.trace { "Processing pre_condition hooks for: #{self}" } @group.try &.call_pre_condition(example) pre_condition_hooks.each &.call(example) end define_hook post_condition : ExampleHook, :prepend do |example| - Log.trace { "Processing post_condition hooks for #{self}" } + Log.trace { "Processing post_condition hooks for: #{self}" } post_condition_hooks.each &.call(example) @group.try &.call_post_condition(example) From e2cdc9e08ebd8e3faf3e3d558e5aaa140ef9f879 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 22:10:59 -0600 Subject: [PATCH 049/120] Re-enable logger after catching exit The logger is closed during at-exit hooks that get invoked by Kernel's exit method. --- CHANGELOG.md | 1 + src/spectator/system_exit.cr | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0835bcb..4b1bbed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Clear stubs defined with `expect().to receive()` syntax after test finishes to prevent leakage between tests. - Ensure stubs defined with `allow().to receive()` syntax are cleared after test finishes when used inside a test (another leakage). +- Fix crash caused when logging is enabled after running an example that attempts to exit. ### Removed - Removed support for stubbing undefined (untyped) methods in lazy doubles. Avoids possible segfault. diff --git a/src/spectator/system_exit.cr b/src/spectator/system_exit.cr index 95a2b22..d94711e 100644 --- a/src/spectator/system_exit.cr +++ b/src/spectator/system_exit.cr @@ -20,6 +20,9 @@ class Process # Replace the typically used exit method with a method that raises. # This allows tests to catch attempts to exit the application. def self.exit(status = 0) : NoReturn + # Re-enable log that is disabled from at-exit handlers. + ::Log.setup_from_env(default_level: :none) + raise ::Spectator::SystemExit.new(status: status) end end From 318e4eba898b894d270d5be311c362be99fa522c Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 4 Nov 2022 22:55:12 -0600 Subject: [PATCH 050/120] Use shorter string when stub is treated as a message --- spec/matchers/receive_matcher_spec.cr | 2 +- src/spectator/matchers/receive_matcher.cr | 16 ++++++++-------- src/spectator/mocks/stub.cr | 22 +++++++++++++++++----- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/spec/matchers/receive_matcher_spec.cr b/spec/matchers/receive_matcher_spec.cr index 6ddbfe7..b5addc5 100644 --- a/spec/matchers/receive_matcher_spec.cr +++ b/spec/matchers/receive_matcher_spec.cr @@ -296,7 +296,7 @@ Spectator.describe Spectator::Matchers::ReceiveMatcher do end it "has the expected call listed" do - is_expected.to contain({:expected, "Not #{stub}"}) + is_expected.to contain({:expected, "Not #{stub.message}"}) end it "has the list of called methods" do diff --git a/src/spectator/matchers/receive_matcher.cr b/src/spectator/matchers/receive_matcher.cr index d261db1..560cabd 100644 --- a/src/spectator/matchers/receive_matcher.cr +++ b/src/spectator/matchers/receive_matcher.cr @@ -81,7 +81,7 @@ module Spectator::Matchers # Short text about the matcher's purpose. def description : String - "received #{@stub} #{humanize_count}" + "received #{@stub.message} #{humanize_count}" end # Actually performs the test against the expression (value or block). @@ -89,10 +89,10 @@ module Spectator::Matchers stubbed = actual.value calls = relevant_calls(stubbed) if @count.includes?(calls.size) - SuccessfulMatchData.new("#{actual.label} received #{@stub} #{humanize_count}") + SuccessfulMatchData.new("#{actual.label} received #{@stub.message} #{humanize_count}") else - FailedMatchData.new("#{actual.label} received #{@stub} #{humanize_count}", - "#{actual.label} did not receive #{@stub}", values(actual).to_a) + FailedMatchData.new("#{actual.label} received #{@stub.message} #{humanize_count}", + "#{actual.label} did not receive #{@stub.message}", values(actual).to_a) end end @@ -106,9 +106,9 @@ module Spectator::Matchers stubbed = actual.value calls = relevant_calls(stubbed) if @count.includes?(calls.size) - FailedMatchData.new("#{actual.label} did not receive #{@stub}", "#{actual.label} received #{@stub}", negated_values(actual).to_a) + FailedMatchData.new("#{actual.label} did not receive #{@stub.message}", "#{actual.label} received #{@stub.message}", negated_values(actual).to_a) else - SuccessfulMatchData.new("#{actual.label} did not receive #{@stub} #{humanize_count}") + SuccessfulMatchData.new("#{actual.label} did not receive #{@stub.message} #{humanize_count}") end end @@ -120,7 +120,7 @@ module Spectator::Matchers # Additional information about the match failure. private def values(actual : Expression(T)) forall T { - expected: @stub.to_s, + expected: @stub.message, actual: method_call_list(actual.value), } end @@ -128,7 +128,7 @@ module Spectator::Matchers # Additional information about the match failure when negated. private def negated_values(actual : Expression(T)) forall T { - expected: "Not #{@stub}", + expected: "Not #{@stub.message}", actual: method_call_list(actual.value), } end diff --git a/src/spectator/mocks/stub.cr b/src/spectator/mocks/stub.cr index e28c431..606894d 100644 --- a/src/spectator/mocks/stub.cr +++ b/src/spectator/mocks/stub.cr @@ -22,6 +22,23 @@ module Spectator def initialize(@method : Symbol, @constraint : AbstractArguments? = nil, @location : Location? = nil) end + # String representation of the stub, formatted as a method call. + def message(io : IO) : Nil + io << "#" << method << (constraint || "(any args)") + end + + # String representation of the stub, formatted as a method call. + def message + String.build do |str| + message(str) + end + end + + # String representation of the stub, formatted as a method definition. + def to_s(io : IO) : Nil + message(io) + end + # Checks if a method call should receive the response from this stub. def ===(call : MethodCall) return false if method != call.method @@ -29,10 +46,5 @@ module Spectator constraint === call.arguments end - - # String representation of the stub, formatted as a method call. - def to_s(io : IO) : Nil - io << "#" << method << (constraint || "(any args)") - end end end From 015d36ea4c81eafc243cc1b620e58b2c59fee2df Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 27 Nov 2022 19:43:03 -0700 Subject: [PATCH 051/120] Work around strange cast/type checking issue For some reason, line 421 (the responds to call check) excluded the stub's call type. Luckily this line doesn't seem to be necessary anymore. Removed the unecessary quick check. The tests from spec/spectator/mocks/double_spec:88-96 were failing when they're the only tests in the file. The non-matching stub wouldn't raise. Stepping through, attempting to access the value would segfault. This is because it accessed a stub with String instead of its real Int32 type. Removing the aforementioned check fixes this. --- src/spectator/mocks/stubbable.cr | 74 +++++++++++++------------------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 6f266b9..3348be6 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -398,7 +398,7 @@ module Spectator {% end %} end - # Utility macro for casting a stub (and it's return value) to the correct type. + # Utility macro for casting a stub (and its return value) to the correct type. # # *stub* is the variable holding the stub. # *call* is the variable holding the captured method call. @@ -408,49 +408,37 @@ module Spectator # - `:raise` - raise a `TypeCastError`. # - `:no_return` - raise as no value should be returned. private macro _spectator_cast_stub_value(stub, call, type, fail_cast = :nil) - # Attempt to cast the stub to the method's return type. - # If successful, return the value of the stub. - # This is a common usage where the return type is simple and matches the stub type exactly. - if %typed = {{stub}}.as?(::Spectator::TypedStub({{type}})) - %typed.call({{call}}) - else - # The stub couldn't be easily cast to match the return type. + {% if fail_cast == :no_return %} + {{stub}}.call({{call}}) + raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a value, but it shouldn't have returned (`NoReturn`).") + {% else %} + # Get the value as-is from the stub. + # This will be compiled as a union of all known stubbed value types. + %value = {{stub}}.call({{call}}) - # Even though all stubs will have a `#call` method, the compiler doesn't seem to agree. - # Assert that it will (this should never fail). - raise TypeCastError.new("Stub has no value") unless {{stub}}.responds_to?(:call) - - {% if fail_cast == :no_return %} - {{stub}}.call({{call}}) - raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a value, but it shouldn't have returned (`NoReturn`).") - {% else %} - # Get the value as-is from the stub. - # This will be compiled as a union of all known stubbed value types. - %value = {{stub}}.call({{call}}) - - # Attempt to cast the value to the method's return type. - # If successful, which it will be in most cases, return it. - # The caller will receive a properly typed value without unions or other side-effects. - if %cast = %value.as?({{type}}) - %cast - else - {% if fail_cast == :nil %} - nil - {% elsif fail_cast == :raise %} - # The stubbed value was something else entirely and cannot be cast to the return type. - # There's something weird going on (compiler bug?) that sometimes causes this class lookup to fail. - %type = begin - %value.class.to_s - rescue - "" - end - raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%type}`, but returned type must be `#{ {{type}} }`.") - {% else %} - {% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %} - {% end %} - end - {% end %} - end + # Attempt to cast the value to the method's return type. + # If successful, which it will be in most cases, return it. + # The caller will receive a properly typed value without unions or other side-effects. + %cast = %value.as?({{type}}) + if %cast.is_a?({{type}}) + %cast + else + {% if fail_cast == :nil %} + nil + {% elsif fail_cast == :raise %} + # The stubbed value was something else entirely and cannot be cast to the return type. + # There's something weird going on (compiler bug?) that sometimes causes this class lookup to fail. + %type = begin + %value.class.to_s + rescue + "" + end + raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%type}`, but returned type must be `#{ {{type}} }`.") + {% else %} + {% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %} + {% end %} + end + {% end %} end end end From 8efd38fbdd5488208b479f97d37ec7c78f0dcb38 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 27 Nov 2022 22:26:19 -0700 Subject: [PATCH 052/120] Split Arguments class by functionality Code changes for https://github.com/icy-arctic-fox/spectator/issues/47 caused a drastic increase in compilation times. This improves compilation times by splitting concerns for arguments. In one case, arguments are used for matching. In the other, arguments are captured for comparison. The second case has been moved to a FormalArguments class. Theoretically, this reduces the complexity and combinations the compiler might be iterating. --- spec/spectator/mocks/arguments_spec.cr | 332 +++++++++--------- spec/spectator/mocks/formal_arguments_spec.cr | 325 +++++++++++++++++ src/spectator/mocks/abstract_arguments.cr | 8 + src/spectator/mocks/arguments.cr | 131 ++----- src/spectator/mocks/formal_arguments.cr | 133 +++++++ src/spectator/mocks/method_call.cr | 10 +- src/spectator/mocks/stubbable.cr | 10 +- 7 files changed, 682 insertions(+), 267 deletions(-) create mode 100644 spec/spectator/mocks/formal_arguments_spec.cr create mode 100644 src/spectator/mocks/formal_arguments.cr diff --git a/spec/spectator/mocks/arguments_spec.cr b/spec/spectator/mocks/arguments_spec.cr index cd92010..f6a09b7 100644 --- a/spec/spectator/mocks/arguments_spec.cr +++ b/spec/spectator/mocks/arguments_spec.cr @@ -1,13 +1,11 @@ require "../../spec_helper" Spectator.describe Spectator::Arguments do - subject(arguments) { Spectator::Arguments.new({42, "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + subject(arguments) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) } it "stores the arguments" do expect(arguments).to have_attributes( args: {42, "foo"}, - splat_name: :splat, - splat: {:x, :y, :z}, kwargs: {bar: "baz", qux: 123} ) end @@ -27,33 +25,6 @@ Spectator.describe Spectator::Arguments do expect(arguments[1]).to eq("foo") end end - - it "returns splat arguments" do - aggregate_failures do - expect(arguments[2]).to eq(:x) - expect(arguments[3]).to eq(:y) - expect(arguments[4]).to eq(:z) - end - end - - context "with named positional arguments" do - subject(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - - it "returns a positional argument" do - aggregate_failures do - expect(arguments[0]).to eq(42) - expect(arguments[1]).to eq("foo") - end - end - - it "returns splat arguments" do - aggregate_failures do - expect(arguments[2]).to eq(:x) - expect(arguments[3]).to eq(:y) - expect(arguments[4]).to eq(:z) - end - end - end end describe "#[](symbol)" do @@ -63,31 +34,13 @@ Spectator.describe Spectator::Arguments do expect(arguments[:qux]).to eq(123) end end - - context "with named positional arguments" do - subject(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - - it "returns a positional argument" do - aggregate_failures do - expect(arguments[:arg1]).to eq(42) - expect(arguments[:arg2]).to eq("foo") - end - end - - it "returns a keyword argument" do - aggregate_failures do - expect(arguments[:bar]).to eq("baz") - expect(arguments[:qux]).to eq(123) - end - end - end end describe "#to_s" do subject { arguments.to_s } it "formats the arguments" do - is_expected.to eq("(42, \"foo\", *splat: {:x, :y, :z}, bar: \"baz\", qux: 123)") + is_expected.to eq("(42, \"foo\", bar: \"baz\", qux: 123)") end context "when empty" do @@ -97,172 +50,235 @@ Spectator.describe Spectator::Arguments do is_expected.to eq("(no args)") end end - - context "with a splat and no arguments" do - let(arguments) { Spectator::Arguments.build(NamedTuple.new, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - - it "omits the splat name" do - is_expected.to eq("(:x, :y, :z, bar: \"baz\", qux: 123)") - end - end end describe "#==" do subject { arguments == other } - context "with equal arguments" do - let(other) { arguments } + context "with Arguments" do + context "with equal arguments" do + let(other) { arguments } - it { is_expected.to be_true } + it { is_expected.to be_true } + end + + context "with different arguments" do + let(other) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(other) { Spectator::Arguments.new(arguments.args, {qux: 123, bar: "baz"}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(other) { Spectator::Arguments.new(arguments.args, {bar: "baz"}) } + + it { is_expected.to be_false } + end + + context "with an extra kwarg" do + let(other) { Spectator::Arguments.new(arguments.args, {bar: "baz", qux: 123, extra: 0}) } + + it { is_expected.to be_false } + end end - context "with different arguments" do - let(other) { Spectator::Arguments.new({123, :foo, "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) } + context "with FormalArguments" do + context "with equal arguments" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) } - it { is_expected.to be_false } - end + it { is_expected.to be_true } + end - context "with the same kwargs in a different order" do - let(other) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: 123, bar: "baz"}) } + context "with different arguments" do + let(other) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, {opt: "foobar"}) } - it { is_expected.to be_true } - end + it { is_expected.to be_false } + end - context "with a missing kwarg" do - let(other) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz"}) } + context "with the same kwargs in a different order" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {qux: 123, bar: "baz"}) } - it { is_expected.to be_false } - end + it { is_expected.to be_true } + end - context "with an extra kwarg" do - let(other) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz", qux: 123, extra: 0}) } + context "with a missing kwarg" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz"}) } - it { is_expected.to be_false } - end + it { is_expected.to be_false } + end - context "with different splat arguments" do - let(other) { Spectator::Arguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) } + context "with an extra kwarg" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123, extra: 0}) } - it { is_expected.to be_false } - end + it { is_expected.to be_false } + end - context "with mixed positional tuple types" do - let(other) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, arguments.splat_name, arguments.splat, arguments.kwargs) } + context "with different splat arguments" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, {bar: "baz", qux: 123}) } - it { is_expected.to be_true } - end + it { is_expected.to be_false } + end - context "with mixed positional tuple types (flipped)" do - let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - let(other) { Spectator::Arguments.new({42, "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + context "with mixed positional tuple types" do + let(other) { Spectator::FormalArguments.new({arg1: 42}, :splat, {"foo"}, {bar: "baz", qux: 123}) } - it { is_expected.to be_true } - end - - context "with args spilling over into splat and mixed positional tuple types" do - let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - let(other) { Spectator::Arguments.new({42, "foo", :x, :y, :z}, nil, nil, {bar: "baz", qux: 123}) } - - it { is_expected.to be_true } + it { is_expected.to be_true } + end end end describe "#===" do subject { pattern === arguments } - context "with equal arguments" do - let(pattern) { arguments } + context "with Arguments" do + context "with equal arguments" do + let(pattern) { arguments } - it { is_expected.to be_true } + it { is_expected.to be_true } + end + + context "with matching arguments" do + let(pattern) { Spectator::Arguments.new({Int32, /foo/}, {bar: /baz/, qux: Int32}) } + + it { is_expected.to be_true } + end + + context "with non-matching arguments" do + let(pattern) { Spectator::Arguments.new({Float64, /bar/}, {bar: /foo/, qux: "123"}) } + + it { is_expected.to be_false } + end + + context "with different arguments" do + let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(pattern) { Spectator::Arguments.new(arguments.args, {qux: Int32, bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with an additional kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.args, {bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.args, {bar: /baz/, qux: Int32, extra: 0}) } + + it { is_expected.to be_false } + end end - context "with matching arguments" do - let(pattern) { Spectator::Arguments.new({Int32, /foo/}, :splat, {Symbol, Symbol, :z}, {bar: /baz/, qux: Int32}) } + context "with FormalArguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) } - it { is_expected.to be_true } - end + context "with equal arguments" do + let(pattern) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) } - context "with non-matching arguments" do - let(pattern) { Spectator::Arguments.new({Float64, /bar/}, :splat, {String, Int32, :x}, {bar: /foo/, qux: "123"}) } + it { is_expected.to be_true } + end - it { is_expected.to be_false } - end + context "with matching arguments" do + let(pattern) { Spectator::Arguments.new({Int32, /foo/}, {bar: /baz/, qux: Int32}) } - context "with different arguments" do - let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) } + it { is_expected.to be_true } + end - it { is_expected.to be_false } - end + context "with non-matching arguments" do + let(pattern) { Spectator::Arguments.new({Float64, /bar/}, {bar: /foo/, qux: "123"}) } - context "with the same kwargs in a different order" do - let(pattern) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: Int32, bar: /baz/}) } + it { is_expected.to be_false } + end - it { is_expected.to be_true } - end + context "with different arguments" do + let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) } - context "with an additional kwarg" do - let(pattern) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/}) } + it { is_expected.to be_false } + end - it { is_expected.to be_true } - end + context "with the same kwargs in a different order" do + let(pattern) { Spectator::Arguments.new(arguments.positional, {qux: Int32, bar: /baz/}) } - context "with a missing kwarg" do - let(pattern) { Spectator::Arguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/, qux: Int32, extra: 0}) } + it { is_expected.to be_true } + end - it { is_expected.to be_false } - end + context "with an additional kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.positional, {bar: /baz/}) } - context "with different splat arguments" do - let(pattern) { Spectator::Arguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) } + it { is_expected.to be_true } + end - it { is_expected.to be_false } - end + context "with a missing kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.positional, {bar: /baz/, qux: Int32, extra: 0}) } - context "with matching mixed positional tuple types" do - let(pattern) { Spectator::Arguments.new({arg1: Int32, arg2: /foo/}, arguments.splat_name, arguments.splat, arguments.kwargs) } + it { is_expected.to be_false } + end - it { is_expected.to be_true } - end + context "with different splat arguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) } + let(pattern) { Spectator::Arguments.new({Int32, /foo/, 5}, arguments.kwargs) } - context "with non-matching mixed positional tuple types" do - let(pattern) { Spectator::Arguments.new({arg1: Float64, arg2: /bar/}, arguments.splat_name, arguments.splat, arguments.kwargs) } + it { is_expected.to be_false } + end - it { is_expected.to be_false } - end + context "with matching mixed positional tuple types" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) } + let(pattern) { Spectator::Arguments.new({Int32, /foo/, 1, 2, 3}, arguments.kwargs) } - context "with matching args spilling over into splat and mixed positional tuple types" do - let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - let(pattern) { Spectator::Arguments.capture(Int32, /foo/, Symbol, Symbol, :z, bar: /baz/, qux: Int32) } + it { is_expected.to be_true } + end - it { is_expected.to be_true } - end + context "with non-matching mixed positional tuple types" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) } + let(pattern) { Spectator::Arguments.new({Float64, /bar/, 3, 2, Symbol}, arguments.kwargs) } - context "with non-matching args spilling over into splat and mixed positional tuple types" do - let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - let(pattern) { Spectator::Arguments.capture(Float64, /bar/, Symbol, String, :z, bar: /foo/, qux: Int32) } + it { is_expected.to be_false } + end - it { is_expected.to be_false } - end + context "with matching args spilling over into splat and mixed positional tuple types" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(Int32, /foo/, Symbol, Symbol, :z, bar: /baz/, qux: Int32) } - context "with matching mixed named positional and keyword arguments" do - let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - let(pattern) { Spectator::Arguments.capture(/foo/, Symbol, :y, Symbol, arg1: Int32, bar: /baz/, qux: 123) } + it { is_expected.to be_true } + end - it { is_expected.to be_true } - end + context "with non-matching args spilling over into splat and mixed positional tuple types" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(Float64, /bar/, Symbol, String, :z, bar: /foo/, qux: Int32) } - context "with non-matching mixed named positional and keyword arguments" do - let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - let(pattern) { Spectator::Arguments.capture(5, Symbol, :z, Symbol, arg2: /foo/, bar: /baz/, qux: Int32) } + it { is_expected.to be_false } + end - it { is_expected.to be_false } - end + context "with matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(/foo/, Symbol, :y, Symbol, arg1: Int32, bar: /baz/, qux: 123) } - context "with non-matching mixed named positional and keyword arguments" do - let(arguments) { Spectator::Arguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } - let(pattern) { Spectator::Arguments.capture(/bar/, String, :y, Symbol, arg1: 0, bar: /foo/, qux: Float64) } + it { is_expected.to be_true } + end - it { is_expected.to be_false } + context "with non-matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(5, Symbol, :z, Symbol, arg2: /foo/, bar: /baz/, qux: Int32) } + + it { is_expected.to be_false } + end + + context "with non-matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(/bar/, String, :y, Symbol, arg1: 0, bar: /foo/, qux: Float64) } + + it { is_expected.to be_false } + end end end end diff --git a/spec/spectator/mocks/formal_arguments_spec.cr b/spec/spectator/mocks/formal_arguments_spec.cr new file mode 100644 index 0000000..963b6eb --- /dev/null +++ b/spec/spectator/mocks/formal_arguments_spec.cr @@ -0,0 +1,325 @@ +require "../../spec_helper" + +Spectator.describe Spectator::FormalArguments do + subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "stores the arguments" do + expect(arguments).to have_attributes( + args: {arg1: 42, arg2: "foo"}, + splat_name: :splat, + splat: {:x, :y, :z}, + kwargs: {bar: "baz", qux: 123} + ) + end + + describe ".build" do + subject { Spectator::FormalArguments.build({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, {bar: "baz", qux: 123}) } + + it "stores the arguments and keyword arguments" do + is_expected.to have_attributes( + args: {arg1: 42, arg2: "foo"}, + splat_name: :splat, + splat: {1, 2, 3}, + kwargs: {bar: "baz", qux: 123} + ) + end + + context "without a splat" do + subject { Spectator::FormalArguments.build({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) } + + it "stores the arguments and keyword arguments" do + is_expected.to have_attributes( + args: {arg1: 42, arg2: "foo"}, + splat: nil, + kwargs: {bar: "baz", qux: 123} + ) + end + end + end + + describe "#[](index)" do + it "returns a positional argument" do + aggregate_failures do + expect(arguments[0]).to eq(42) + expect(arguments[1]).to eq("foo") + end + end + + it "returns splat arguments" do + aggregate_failures do + expect(arguments[2]).to eq(:x) + expect(arguments[3]).to eq(:y) + expect(arguments[4]).to eq(:z) + end + end + + context "with named positional arguments" do + subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "returns a positional argument" do + aggregate_failures do + expect(arguments[0]).to eq(42) + expect(arguments[1]).to eq("foo") + end + end + + it "returns splat arguments" do + aggregate_failures do + expect(arguments[2]).to eq(:x) + expect(arguments[3]).to eq(:y) + expect(arguments[4]).to eq(:z) + end + end + end + end + + describe "#[](symbol)" do + it "returns a keyword argument" do + aggregate_failures do + expect(arguments[:bar]).to eq("baz") + expect(arguments[:qux]).to eq(123) + end + end + + context "with named positional arguments" do + subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "returns a positional argument" do + aggregate_failures do + expect(arguments[:arg1]).to eq(42) + expect(arguments[:arg2]).to eq("foo") + end + end + + it "returns a keyword argument" do + aggregate_failures do + expect(arguments[:bar]).to eq("baz") + expect(arguments[:qux]).to eq(123) + end + end + end + end + + describe "#to_s" do + subject { arguments.to_s } + + it "formats the arguments" do + is_expected.to eq("(arg1: 42, arg2: \"foo\", *splat: {:x, :y, :z}, bar: \"baz\", qux: 123)") + end + + context "when empty" do + let(arguments) { Spectator::FormalArguments.none } + + it "returns (no args)" do + is_expected.to eq("(no args)") + end + end + + context "with a splat and no arguments" do + let(arguments) { Spectator::FormalArguments.build(NamedTuple.new, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "omits the splat name" do + is_expected.to eq("(:x, :y, :z, bar: \"baz\", qux: 123)") + end + end + end + + describe "#==" do + subject { arguments == other } + + context "with Arguments" do + context "with equal arguments" do + let(other) { Spectator::Arguments.new(arguments.positional, arguments.kwargs) } + + it { is_expected.to be_true } + end + + context "with different arguments" do + let(other) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(other) { Spectator::Arguments.new(arguments.positional, {qux: 123, bar: "baz"}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(other) { Spectator::Arguments.new(arguments.positional, {bar: "baz"}) } + + it { is_expected.to be_false } + end + + context "with an extra kwarg" do + let(other) { Spectator::Arguments.new(arguments.positional, {bar: "baz", qux: 123, extra: 0}) } + + it { is_expected.to be_false } + end + end + + context "with FormalArguments" do + context "with equal arguments" do + let(other) { arguments } + + it { is_expected.to be_true } + end + + context "with different arguments" do + let(other) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: 123, bar: "baz"}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz"}) } + + it { is_expected.to be_false } + end + + context "with an extra kwarg" do + let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz", qux: 123, extra: 0}) } + + it { is_expected.to be_false } + end + + context "with different splat arguments" do + let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) } + + it { is_expected.to be_false } + end + + context "with mixed positional tuple types" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, arguments.splat_name, arguments.splat, arguments.kwargs) } + + it { is_expected.to be_true } + end + + context "with mixed positional tuple types (flipped)" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } + end + end + end + + describe "#===" do + subject { pattern === arguments } + + context "with Arguments" do + let(arguments) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) } + + context "with equal arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } + end + + context "with matching arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/, qux: Int32}) } + + it { is_expected.to be_true } + end + + context "with non-matching arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, {bar: /foo/, qux: "123"}) } + + it { is_expected.to be_false } + end + + context "with different arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {qux: Int32, bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with an additional kwarg" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/, qux: Int32, extra: 0}) } + + it { is_expected.to be_false } + end + end + + context "with FormalArguments" do + context "with equal arguments" do + let(pattern) { arguments } + + it { is_expected.to be_true } + end + + context "with matching arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, :splat, {Symbol, Symbol, :z}, {bar: /baz/, qux: Int32}) } + + it { is_expected.to be_true } + end + + context "with non-matching arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, :splat, {String, Int32, :x}, {bar: /foo/, qux: "123"}) } + + it { is_expected.to be_false } + end + + context "with different arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: Int32, bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with an additional kwarg" do + let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/, qux: Int32, extra: 0}) } + + it { is_expected.to be_false } + end + + context "with different splat arguments" do + let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) } + + it { is_expected.to be_false } + end + + context "with matching mixed positional tuple types" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, arguments.splat_name, arguments.splat, arguments.kwargs) } + + it { is_expected.to be_true } + end + + context "with non-matching mixed positional tuple types" do + let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, arguments.splat_name, arguments.splat, arguments.kwargs) } + + it { is_expected.to be_false } + end + end + end +end diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index dacc43f..810d2fe 100644 --- a/src/spectator/mocks/abstract_arguments.cr +++ b/src/spectator/mocks/abstract_arguments.cr @@ -1,5 +1,13 @@ module Spectator # Untyped arguments to a method call (message). abstract class AbstractArguments + # Utility method for comparing two named tuples ignoring order. + private def compare_named_tuples(a : NamedTuple, b : NamedTuple) + a.each do |k, v1| + v2 = b.fetch(k) { return false } + return false unless v1 === v2 + end + true + end end end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index 003875f..f9c6e19 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -4,123 +4,68 @@ module Spectator # Arguments used in a method call. # # Can also be used to match arguments. - # *Args* must be a `Tuple` or `NamedTuple` type representing the standard arguments. - # *Splat* must be a `Tuple` type representing the extra positional arguments. - # *DoubleSplat* must be a `NamedTuple` type representing extra keyword arguments. - class Arguments(Args, Splat, DoubleSplat) < AbstractArguments + # *Args* must be a `Tuple` representing the standard arguments. + # *KWArgs* must be a `NamedTuple` type representing extra keyword arguments. + class Arguments(Args, KWArgs) < AbstractArguments # Positional arguments. getter args : Args - # Additional positional arguments. - getter splat : Splat - # Keyword arguments. - getter kwargs : DoubleSplat - - # Name of the splat argument, if used. - getter splat_name : Symbol? + getter kwargs : KWArgs # Creates arguments used in a method call. - def initialize(@args : Args, @splat_name : Symbol?, @splat : Splat, @kwargs : DoubleSplat) - end - - # Creates arguments used in a method call. - def self.new(args : Args, kwargs : DoubleSplat) - new(args, nil, nil, kwargs) + def initialize(@args : Args, @kwargs : KWArgs) + {% raise "Positional arguments (generic type Args) must be a Tuple" unless Args <= Tuple %} + {% raise "Keyword arguments (generic type KWArgs) must be a NamedTuple" unless KWArgs <= NamedTuple %} end # Instance of empty arguments. - class_getter none : AbstractArguments = build + class_getter none : AbstractArguments = capture # Returns unconstrained arguments. def self.any : AbstractArguments? nil.as(AbstractArguments?) end - # Captures arguments passed to a call. - def self.build(args = Tuple.new, kwargs = NamedTuple.new) - new(args, nil, nil, kwargs) - end - - # :ditto: - def self.build(args : NamedTuple, splat_name : Symbol, splat : Tuple, kwargs = NamedTuple.new) - new(args, splat_name, splat, kwargs) - end - # Friendlier constructor for capturing arguments. def self.capture(*args, **kwargs) - new(args, nil, nil, kwargs) + new(args, kwargs) end # Returns the positional argument at the specified index. def [](index : Int) - positional[index] + args[index] end # Returns the specified named argument. def [](arg : Symbol) - {% if Args < NamedTuple %} - return @args[arg] if @args.has_key?(arg) - {% end %} @kwargs[arg] end # Returns all arguments and splatted arguments as a tuple. def positional : Tuple - if (splat = @splat) - {% if Args < NamedTuple %}args.values{% else %}args{% end %} + splat - else - {% if Args < NamedTuple %}args.values{% else %}args{% end %} - end + args end # Returns all named positional and keyword arguments as a named tuple. def named : NamedTuple - {% if Args < NamedTuple %} - args.merge(kwargs) - {% else %} - kwargs - {% end %} + kwargs end # Constructs a string representation of the arguments. def to_s(io : IO) : Nil - return io << "(no args)" if args.empty? && ((splat = @splat).nil? || splat.empty?) && kwargs.empty? + return io << "(no args)" if args.empty? && kwargs.empty? io << '(' # Add the positional arguments. - {% if Args < NamedTuple %} - # Include argument names. - args.each_with_index do |name, value, i| - io << ", " if i > 0 - io << name << ": " - value.inspect(io) - end - {% else %} - args.each_with_index do |arg, i| - io << ", " if i > 0 - arg.inspect(io) - end - {% end %} - - # Add the splat arguments. - if (splat = @splat) && !splat.empty? - io << ", " unless args.empty? - if splat_name = !args.empty? && @splat_name - io << '*' << splat_name << ": {" - end - splat.each_with_index do |arg, i| - io << ", " if i > 0 - arg.inspect(io) - end - io << '}' if splat_name + args.each_with_index do |arg, i| + io << ", " if i > 0 + arg.inspect(io) end # Add the keyword arguments. - offset = args.size - offset += splat.size if (splat = @splat) - kwargs.each_with_index(offset) do |key, value, i| + kwargs.each_with_index(args.size) do |key, value, i| io << ", " if i > 0 io << key << ": " value.inspect(io) @@ -130,55 +75,35 @@ module Spectator end # Checks if this set of arguments and another are equal. - def ==(other : Arguments) + def ==(other : AbstractArguments) positional == other.positional && kwargs == other.kwargs end # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - self_args = args - other_args = other.args - - case {self_args, other_args} - when {Tuple, Tuple} then compare(positional, other.positional, kwargs, other.kwargs) - when {Tuple, NamedTuple} then compare(kwargs, other.named, positional, other_args, other.splat) - when {NamedTuple, Tuple} then compare(positional, other.positional, kwargs, other.kwargs) - else - self_args === other_args && (!splat || splat === other.splat) && compare_named_tuples(kwargs, other.kwargs) - end + positional === other.positional && compare_named_tuples(kwargs, other.kwargs) end - private def compare(self_positional : Tuple, other_positional : Tuple, self_kwargs : NamedTuple, other_kwargs : NamedTuple) - self_positional === other_positional && compare_named_tuples(self_kwargs, other_kwargs) - end - - private def compare(self_kwargs : NamedTuple, other_named : NamedTuple, self_positional : Tuple, other_args : NamedTuple, other_splat : Tuple?) - return false unless compare_named_tuples(self_kwargs, other_named) + # :ditto: + def ===(other : FormalArguments) + return false unless compare_named_tuples(kwargs, other.named) i = 0 - other_args.each do |k, v2| - next if self_kwargs.has_key?(k) # Covered by named arguments. + other.args.each do |k, v2| + next if kwargs.has_key?(k) # Covered by named arguments. - v1 = self_positional.fetch(i) { return false } + v1 = positional.fetch(i) { return false } i += 1 return false unless v1 === v2 end - other_splat.try &.each do |v2| - v1 = self_positional.fetch(i) { return false } + other.splat.try &.each do |v2| + v1 = positional.fetch(i) { return false } i += 1 return false unless v1 === v2 end - i == self_positional.size - end - - private def compare_named_tuples(a : NamedTuple, b : NamedTuple) - a.each do |k, v1| - v2 = b.fetch(k) { return false } - return false unless v1 === v2 - end - true + i == positional.size end end end diff --git a/src/spectator/mocks/formal_arguments.cr b/src/spectator/mocks/formal_arguments.cr new file mode 100644 index 0000000..e440214 --- /dev/null +++ b/src/spectator/mocks/formal_arguments.cr @@ -0,0 +1,133 @@ +require "./abstract_arguments" + +module Spectator + # Arguments passed into a method. + # + # *Args* must be a `NamedTuple` type representing the standard arguments. + # *Splat* must be a `Tuple` type representing the extra positional arguments. + # *DoubleSplat* must be a `NamedTuple` type representing extra keyword arguments. + class FormalArguments(Args, Splat, DoubleSplat) < AbstractArguments + # Positional arguments. + getter args : Args + + # Additional positional arguments. + getter splat : Splat + + # Keyword arguments. + getter kwargs : DoubleSplat + + # Name of the splat argument, if used. + getter splat_name : Symbol? + + # Creates arguments used in a method call. + def initialize(@args : Args, @splat_name : Symbol?, @splat : Splat, @kwargs : DoubleSplat) + {% raise "Positional arguments (generic type Args) must be a NamedTuple" unless Args <= NamedTuple %} + {% raise "Splat arguments (generic type Splat) must be a Tuple" unless Splat <= Tuple || Splat <= Nil %} + {% raise "Keyword arguments (generic type DoubleSplat) must be a NamedTuple" unless DoubleSplat <= NamedTuple %} + end + + # Creates arguments used in a method call. + def self.new(args : Args, kwargs : DoubleSplat) + new(args, nil, nil, kwargs) + end + + # Captures arguments passed to a call. + def self.build(args = NamedTuple.new, kwargs = NamedTuple.new) + new(args, nil, nil, kwargs) + end + + # :ditto: + def self.build(args : NamedTuple, splat_name : Symbol, splat : Tuple, kwargs = NamedTuple.new) + new(args, splat_name, splat, kwargs) + end + + # Instance of empty arguments. + class_getter none : AbstractArguments = build + + # Returns the positional argument at the specified index. + def [](index : Int) + positional[index] + end + + # Returns the specified named argument. + def [](arg : Symbol) + return @args[arg] if @args.has_key?(arg) + @kwargs[arg] + end + + # Returns all arguments and splatted arguments as a tuple. + def positional : Tuple + if (splat = @splat) + args.values + splat + else + args.values + end + end + + # Returns all named positional and keyword arguments as a named tuple. + def named : NamedTuple + args.merge(kwargs) + end + + # Constructs a string representation of the arguments. + def to_s(io : IO) : Nil + return io << "(no args)" if args.empty? && ((splat = @splat).nil? || splat.empty?) && kwargs.empty? + + io << '(' + + # Add the positional arguments. + {% if Args < NamedTuple %} + # Include argument names. + args.each_with_index do |name, value, i| + io << ", " if i > 0 + io << name << ": " + value.inspect(io) + end + {% else %} + args.each_with_index do |arg, i| + io << ", " if i > 0 + arg.inspect(io) + end + {% end %} + + # Add the splat arguments. + if (splat = @splat) && !splat.empty? + io << ", " unless args.empty? + if splat_name = !args.empty? && @splat_name + io << '*' << splat_name << ": {" + end + splat.each_with_index do |arg, i| + io << ", " if i > 0 + arg.inspect(io) + end + io << '}' if splat_name + end + + # Add the keyword arguments. + offset = args.size + offset += splat.size if (splat = @splat) + kwargs.each_with_index(offset) do |key, value, i| + io << ", " if i > 0 + io << key << ": " + value.inspect(io) + end + + io << ')' + end + + # Checks if this set of arguments and another are equal. + def ==(other : AbstractArguments) + positional == other.positional && kwargs == other.kwargs + end + + # Checks if another set of arguments matches this set of arguments. + def ===(other : Arguments) + positional === other.positional && compare_named_tuples(kwargs, other.kwargs) + end + + # :ditto: + def ===(other : FormalArguments) + compare_named_tuples(args, other.args) && splat === other.splat && compare_named_tuples(kwargs, other.kwargs) + end + end +end diff --git a/src/spectator/mocks/method_call.cr b/src/spectator/mocks/method_call.cr index 7094f4a..b8e973e 100644 --- a/src/spectator/mocks/method_call.cr +++ b/src/spectator/mocks/method_call.cr @@ -1,5 +1,6 @@ require "./abstract_arguments" require "./arguments" +require "./formal_arguments" module Spectator # Stores information about a call to a method. @@ -16,7 +17,14 @@ module Spectator # Creates a method call by splatting its arguments. def self.capture(method : Symbol, *args, **kwargs) - arguments = Arguments.new(args, kwargs).as(AbstractArguments) + arguments = Arguments.capture(*args, **kwargs).as(AbstractArguments) + new(method, arguments) + end + + # Creates a method call from within a method. + # Takes the same arguments as `FormalArguments.build` but with the method name first. + def self.build(method : Symbol, *args, **kwargs) + arguments = FormalArguments.build(*args, **kwargs).as(AbstractArguments) new(method, arguments) end diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 3348be6..cd7c407 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -1,5 +1,5 @@ require "../dsl/reserved" -require "./arguments" +require "./formal_arguments" require "./method_call" require "./stub" require "./typed_stub" @@ -140,7 +140,8 @@ module Spectator ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} # Capture information about the call. - %args = ::Spectator::Arguments.build( + %call = ::Spectator::MethodCall.build( + {{method.name.symbolize}}, ::NamedTuple.new( {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ), @@ -149,7 +150,6 @@ module Spectator {% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ).merge({{method.double_splat}}) ) - %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) _spectator_record_call(%call) # Attempt to find a stub that satisfies the method call and arguments. @@ -242,7 +242,8 @@ module Spectator ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} # Capture information about the call. - %args = ::Spectator::Arguments.build( + %call = ::Spectator::MethodCall.build( + {{method.name.symbolize}}, ::NamedTuple.new( {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ), @@ -251,7 +252,6 @@ module Spectator {% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ).merge({{method.double_splat}}) ) - %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) _spectator_record_call(%call) # Attempt to find a stub that satisfies the method call and arguments. From c256ef763e3c6a6523cfc41c6ee7a336e5e08c39 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 27 Nov 2022 22:27:52 -0700 Subject: [PATCH 053/120] Bump version to 0.11.4 --- CHANGELOG.md | 5 +++-- shard.yml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1bbed..88b37de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.11.4] ### Added - Add support for using named (keyword) arguments in place of positional arguments in stubs. [#47](https://github.com/icy-arctic-fox/spectator/issues/47) - Add `before`, `after`, and `around` as aliases for `before_each`, `after_each`, and `around_each` respectively. @@ -416,7 +416,8 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...master +[0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 [0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...v0.11.3 [0.11.2]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...v0.11.2 [0.11.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1 diff --git a/shard.yml b/shard.yml index 9714e38..15c9a65 100644 --- a/shard.yml +++ b/shard.yml @@ -1,12 +1,12 @@ name: spectator -version: 0.11.3 +version: 0.11.4 description: | Feature-rich testing framework for Crystal inspired by RSpec. authors: - Michael Miller -crystal: 1.5.0 +crystal: 1.6.0 license: MIT From 321c15407d285c0b7d43276c112c9542ac47bdfd Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 03:14:24 -0700 Subject: [PATCH 054/120] Add utility to test specs individually --- .gitlab-ci.yml | 5 +++++ util/test-all-individually.sh | 5 +++++ 2 files changed, 10 insertions(+) create mode 100755 util/test-all-individually.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d627d27..438c34f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -51,6 +51,11 @@ spec mocks: script: - crystal spec --error-on-warnings --junit_output=. spec/spectator/mocks/ +spec individual: + extends: spec + script: + - util/test-all-individually.sh + format: script: - shards diff --git a/util/test-all-individually.sh b/util/test-all-individually.sh new file mode 100755 index 0000000..2984a03 --- /dev/null +++ b/util/test-all-individually.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +find spec/ -type f -name \*_spec.cr -print0 | \ + xargs -0 -n1 time crystal spec --error-on-warnings -v From 2d6c8844d47bc8d2b2ac47289711700e545a717f Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 03:33:42 -0700 Subject: [PATCH 055/120] Remove `time` --- util/test-all-individually.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/test-all-individually.sh b/util/test-all-individually.sh index 2984a03..97bdd36 100755 --- a/util/test-all-individually.sh +++ b/util/test-all-individually.sh @@ -2,4 +2,4 @@ set -e find spec/ -type f -name \*_spec.cr -print0 | \ - xargs -0 -n1 time crystal spec --error-on-warnings -v + xargs -0 -n1 crystal spec --error-on-warnings -v From a585ef099656c96fd62ca70341f9e414387bf875 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 20:28:15 -0700 Subject: [PATCH 056/120] Simplify string (inspect) representation These types make heavy use of generics and combined types. Instantiating string representation methods for all possibilities is unecesssary and slows down compilation. --- src/spectator/mocks/abstract_arguments.cr | 5 +++++ src/spectator/mocks/double.cr | 13 +++++++++++++ src/spectator/mocks/method_call.cr | 8 +++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index 810d2fe..b03f161 100644 --- a/src/spectator/mocks/abstract_arguments.cr +++ b/src/spectator/mocks/abstract_arguments.cr @@ -1,6 +1,11 @@ module Spectator # Untyped arguments to a method call (message). abstract class AbstractArguments + # Use the string representation to avoid over complicating debug output. + def inspect(io : IO) : Nil + to_s(io) + end + # Utility method for comparing two named tuples ignoring order. private def compare_named_tuples(a : NamedTuple, b : NamedTuple) a.each do |k, v1| diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index 8143ba0..ad7d327 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -101,6 +101,19 @@ module Spectator io << _spectator_stubbed_name end + # :ditto: + def inspect(io : IO) : Nil + {% if anno = @type.annotation(::Spectator::StubbedName) %} + io << "#' + end + # Defines a stub to change the behavior of a method in this double. # # NOTE: Defining a stub for a method not defined in the double's type has no effect. diff --git a/src/spectator/mocks/method_call.cr b/src/spectator/mocks/method_call.cr index b8e973e..9c5fd01 100644 --- a/src/spectator/mocks/method_call.cr +++ b/src/spectator/mocks/method_call.cr @@ -30,7 +30,13 @@ module Spectator # Constructs a string containing the method name and arguments. def to_s(io : IO) : Nil - io << '#' << method << arguments + io << '#' << method + arguments.inspect(io) + end + + # :ditto: + def inspect(io : IO) : Nil + to_s(io) end end end From df10c8e75bfd583dacdec62f18f06daeaea36cd2 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 20:29:36 -0700 Subject: [PATCH 057/120] Prevent multiple redefinitions of the same method --- src/spectator/mocks/stubbable.cr | 100 ++++++++++++++++--------------- 1 file changed, 51 insertions(+), 49 deletions(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index cd7c407..ffb52f3 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -338,64 +338,66 @@ module Spectator # Redefines all methods and ones inherited from its parents and mixins to support stubs. private macro stub_type(type_name = @type) {% type = type_name.resolve - # Reverse order of ancestors (there's currently no reverse method for ArrayLiteral). - count = type.ancestors.size - ancestors = type.ancestors.map_with_index { |_, i| type.ancestors[count - i - 1] } %} - {% for ancestor in ancestors %} - {% for method in ancestor.methods.reject do |meth| - meth.name.starts_with?("_spectator") || - ::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) - end %} - {{(method.abstract? ? :abstract_stub : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}, {% end %} - {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} - ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} - super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %} - end - {% end %} + definitions = [] of Nil + scope = (type == @type ? :previous_def : :super).id - {% for method in ancestor.class.methods.reject do |meth| - meth.name.starts_with?("_spectator") || - ::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) - end %} - default_stub {{method.visibility.id if method.visibility != :public}} def self.{{method.name}}( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}, {% end %} - {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} - ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} - super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %} - end - {% end %} - {% end %} + # Add entries for methods in the target type and its class type. + [[:self.id, type.class], [nil, type]].each do |(receiver, t)| + t.methods.each do |method| + definitions << { + type: t, + method: method, + scope: scope, + receiver: receiver, + } + end + end - {% for method in type.methods.reject do |meth| - meth.name.starts_with?("_spectator") || - ::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) - end %} - {{(method.abstract? ? :"abstract_stub abstract" : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( + # Iterate through all ancestors and add their methods. + type.ancestors.each do |ancestor| + [[:self.id, ancestor.class], [nil, ancestor]].each do |(receiver, t)| + t.methods.each do |method| + # Skip methods already found to prevent redefining them multiple times. + unless definitions.any? do |d| + m = d[:method] + m.name == method.name && + m.args == method.args && + m.splat_index == method.splat_index && + m.double_splat == method.double_splat && + m.block_arg == method.block_arg + end + definitions << { + type: t, + method: method, + scope: :super.id, + receiver: receiver, + } + end + end + end + end + + definitions = definitions.reject do |definition| + name = definition[:method].name + name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.symbolize) + end %} + + {% for definition in definitions %} + {% original_type = definition[:type] + method = definition[:method] + scope = definition[:scope] + receiver = definition[:receiver] %} + # Redefinition of {{type}}{{(receiver ? "." : "#").id}}{{method.name}} + {{(method.abstract? ? "abstract_stub abstract" : "default_stub").id}} {{method.visibility.id if method.visibility != :public}} def {{"#{receiver}.".id if receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} - {% unless method.abstract? %} - {% if type == @type %}previous_def{% else %}super{% end %}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %} + {% unless method.abstract? %} + {{scope}}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %} end {% end %} {% end %} - - {% for method in type.class.methods.reject do |meth| - meth.name.starts_with?("_spectator") || - ::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) - end %} - default_stub {{method.visibility.id if method.visibility != :public}} def self.{{method.name}}( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}, {% end %} - {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} - ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} - {% if type == @type %}previous_def{% else %}super{% end %}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %} - end - {% end %} end # Utility macro for casting a stub (and its return value) to the correct type. From 5f499336acf2b20c47d7e63db17f069e0d5520ac Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 20:30:42 -0700 Subject: [PATCH 058/120] Remove individual spec runs from CI --- .gitlab-ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 438c34f..d627d27 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -51,11 +51,6 @@ spec mocks: script: - crystal spec --error-on-warnings --junit_output=. spec/spectator/mocks/ -spec individual: - extends: spec - script: - - util/test-all-individually.sh - format: script: - shards From 1f98bf9ff122e6e767418312b6a3e27d68ab3a50 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 20:32:45 -0700 Subject: [PATCH 059/120] Update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b37de..c13bd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Changed +- Simplify string representation of mock-related types. +- Remove unnecessary redefinitions of methods when adding stub functionality to a type. + ## [0.11.4] ### Added - Add support for using named (keyword) arguments in place of positional arguments in stubs. [#47](https://github.com/icy-arctic-fox/spectator/issues/47) From a967dce241daf071c6b75cd045d906ece6821ee6 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 21:24:31 -0700 Subject: [PATCH 060/120] Adjust double string representation to_s and inspect (with variants) are no longer "original implementation." --- spec/spectator/mocks/double_spec.cr | 70 ++++++++++++++++++++++-- spec/spectator/mocks/lazy_double_spec.cr | 66 +++++++++++++++++++++- spec/spectator/mocks/null_double_spec.cr | 67 ++++++++++++++++++++++- src/spectator/mocks/double.cr | 30 +++++----- src/spectator/mocks/lazy_double.cr | 6 +- src/spectator/mocks/null_double.cr | 8 +-- 6 files changed, 215 insertions(+), 32 deletions(-) diff --git a/spec/spectator/mocks/double_spec.cr b/spec/spectator/mocks/double_spec.cr index 1b1abc9..41bfad2 100644 --- a/spec/spectator/mocks/double_spec.cr +++ b/spec/spectator/mocks/double_spec.cr @@ -212,14 +212,10 @@ Spectator.describe Spectator::Double do expect(dbl.hash).to be_a(UInt64) expect(dbl.in?([42])).to be_false expect(dbl.in?(1, 2, 3)).to be_false - expect(dbl.inspect).to contain("EmptyDouble") expect(dbl.itself).to be(dbl) expect(dbl.not_nil!).to be(dbl) - expect(dbl.pretty_inspect).to contain("EmptyDouble") expect(dbl.pretty_print(pp)).to be_nil expect(dbl.tap { nil }).to be(dbl) - expect(dbl.to_s).to contain("EmptyDouble") - expect(dbl.to_s(io)).to be_nil expect(dbl.try { nil }).to be_nil expect(dbl.object_id).to be_a(UInt64) expect(dbl.same?(dbl)).to be_true @@ -469,7 +465,7 @@ Spectator.describe Spectator::Double do it "stores calls to non-stubbed methods" do expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/) - expect(called_method_names(dbl)).to eq(%i[baz]) + expect(called_method_names(dbl)).to contain(:baz) end it "stores arguments for a call" do @@ -479,4 +475,68 @@ Spectator.describe Spectator::Double do expect(call.arguments).to eq(args) end end + + describe "#to_s" do + subject(string) { dbl.to_s } + + context "with a name" do + let(dbl) { FooBarDouble.new } + + it "indicates it's a double" do + expect(string).to contain("Double") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + end + + context "without a name" do + let(dbl) { EmptyDouble.new } + + it "indicates it's a double" do + expect(string).to contain("Double") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + end + end + + describe "#inspect" do + subject(string) { dbl.inspect } + + context "with a name" do + let(dbl) { FooBarDouble.new } + + it "indicates it's a double" do + expect(string).to contain("Double") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + + context "without a name" do + let(dbl) { EmptyDouble.new } + + it "indicates it's a double" do + expect(string).to contain("Double") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + end end diff --git a/spec/spectator/mocks/lazy_double_spec.cr b/spec/spectator/mocks/lazy_double_spec.cr index c3402b5..8ea5a5d 100644 --- a/spec/spectator/mocks/lazy_double_spec.cr +++ b/spec/spectator/mocks/lazy_double_spec.cr @@ -275,7 +275,7 @@ Spectator.describe Spectator::LazyDouble do it "stores calls to non-stubbed methods" do expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/) - expect(called_method_names(dbl)).to eq(%i[baz]) + expect(called_method_names(dbl)).to contain(:baz) end it "stores arguments for a call" do @@ -285,4 +285,68 @@ Spectator.describe Spectator::LazyDouble do expect(call.arguments).to eq(args) end end + + describe "#to_s" do + subject(string) { dbl.to_s } + + context "with a name" do + let(dbl) { Spectator::LazyDouble.new("dbl-name") } + + it "indicates it's a double" do + expect(string).to contain("LazyDouble") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + end + + context "without a name" do + let(dbl) { Spectator::LazyDouble.new } + + it "contains the double type" do + expect(string).to contain("LazyDouble") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + end + end + + describe "#inspect" do + subject(string) { dbl.inspect } + + context "with a name" do + let(dbl) { Spectator::LazyDouble.new("dbl-name") } + + it "contains the double type" do + expect(string).to contain("LazyDouble") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + + context "without a name" do + let(dbl) { Spectator::LazyDouble.new } + + it "contains the double type" do + expect(string).to contain("LazyDouble") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + end end diff --git a/spec/spectator/mocks/null_double_spec.cr b/spec/spectator/mocks/null_double_spec.cr index 4a8ef3a..ad87ea9 100644 --- a/spec/spectator/mocks/null_double_spec.cr +++ b/spec/spectator/mocks/null_double_spec.cr @@ -186,12 +186,9 @@ Spectator.describe Spectator::NullDouble do expect(dbl.hash).to be_a(UInt64) expect(dbl.in?([42])).to be_false expect(dbl.in?(1, 2, 3)).to be_false - expect(dbl.inspect).to contain("EmptyDouble") expect(dbl.itself).to be(dbl) expect(dbl.not_nil!).to be(dbl) - expect(dbl.pretty_inspect).to contain("EmptyDouble") expect(dbl.tap { nil }).to be(dbl) - expect(dbl.to_s).to contain("EmptyDouble") expect(dbl.try { nil }).to be_nil expect(dbl.object_id).to be_a(UInt64) expect(dbl.same?(dbl)).to be_true @@ -439,4 +436,68 @@ Spectator.describe Spectator::NullDouble do expect(call.arguments).to eq(args) end end + + describe "#to_s" do + subject(string) { dbl.to_s } + + context "with a name" do + let(dbl) { FooBarDouble.new } + + it "indicates it's a double" do + expect(string).to contain("NullDouble") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + end + + context "without a name" do + let(dbl) { EmptyDouble.new } + + it "contains the double type" do + expect(string).to contain("NullDouble") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + end + end + + describe "#inspect" do + subject(string) { dbl.inspect } + + context "with a name" do + let(dbl) { FooBarDouble.new } + + it "contains the double type" do + expect(string).to contain("NullDouble") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + + context "without a name" do + let(dbl) { EmptyDouble.new } + + it "contains the double type" do + expect(string).to contain("NullDouble") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + end end diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index ad7d327..e5e43f4 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -98,16 +98,14 @@ module Spectator # Simplified string representation of a double. # Avoids displaying nested content and bloating method instantiation. def to_s(io : IO) : Nil - io << _spectator_stubbed_name + io << "#<" + {{@type.name(generic_args: false).stringify}} + " " + io << _spectator_stubbed_name << '>' end # :ditto: def inspect(io : IO) : Nil - {% if anno = @type.annotation(::Spectator::StubbedName) %} - io << "#" + {{(anno[0] || :Anonymous.id).stringify}} {% else %} - "#" + "Anonymous" {% end %} end private def self._spectator_stubbed_name : String {% if anno = @type.annotation(StubbedName) %} - "#" + {{(anno[0] || :Anonymous.id).stringify}} {% else %} - "#" + "Anonymous" {% end %} end @@ -188,7 +186,7 @@ module Spectator "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." end - raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") + raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}") end private def _spectator_abstract_stub_fallback(call : MethodCall, type) @@ -207,9 +205,9 @@ module Spectator call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args) _spectator_record_call(call) - Log.trace { "#{_spectator_stubbed_name} got undefined method `#{call}{% if call.block %} { ... }{% end %}`" } + Log.trace { "#{inspect} got undefined method `#{call}{% if call.block %} { ... }{% end %}`" } - raise ::Spectator::UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") + raise ::Spectator::UnexpectedMessage.new("#{inspect} received unexpected message #{call}") nil # Necessary for compiler to infer return type as nil. Avoids runtime "can't execute ... `x` has no type errors". end end diff --git a/src/spectator/mocks/lazy_double.cr b/src/spectator/mocks/lazy_double.cr index 9a9257d..75fe30c 100644 --- a/src/spectator/mocks/lazy_double.cr +++ b/src/spectator/mocks/lazy_double.cr @@ -37,13 +37,13 @@ module Spectator # Returns the double's name formatted for user output. private def _spectator_stubbed_name : String - "#" + @name || "Anonymous" end private def _spectator_stub_fallback(call : MethodCall, &) if _spectator_stub_for_method?(call.method) Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." } - raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") + raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}") else Log.trace { "Fallback for #{call} - call original" } yield @@ -57,7 +57,7 @@ module Spectator %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) - Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } + Log.trace { "#{inspect} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } # Attempt to find a stub that satisfies the method call and arguments. if %stub = _spectator_find_stub(%call) diff --git a/src/spectator/mocks/null_double.cr b/src/spectator/mocks/null_double.cr index 0ddd03d..587f4ab 100644 --- a/src/spectator/mocks/null_double.cr +++ b/src/spectator/mocks/null_double.cr @@ -26,7 +26,7 @@ module Spectator private def _spectator_abstract_stub_fallback(call : MethodCall) if _spectator_stub_for_method?(call.method) Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." } - raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") + raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}") else Log.trace { "Fallback for #{call} - return self" } self @@ -42,9 +42,9 @@ module Spectator private def _spectator_abstract_stub_fallback(call : MethodCall, type) if _spectator_stub_for_method?(call.method) Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." } - raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") + raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}") else - raise TypeCastError.new("#{_spectator_stubbed_name} received message #{call} and is attempting to return `self`, but returned type must be `#{type}`.") + raise TypeCastError.new("#{inspect} received message #{call} and is attempting to return `self`, but returned type must be `#{type}`.") end end @@ -56,7 +56,7 @@ module Spectator %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) - Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } + Log.trace { "#{inspect} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } self end From fbe877690d1e01ac887abe6022d859b728a305b8 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 22:31:22 -0700 Subject: [PATCH 061/120] Adjust call argument matching Reenable test for https://github.com/icy-arctic-fox/spectator/issues/44 and https://github.com/icy-arctic-fox/spectator/issues/47 --- spec/issues/github_issue_44_spec.cr | 11 +++++++---- src/spectator/mocks/arguments.cr | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/spec/issues/github_issue_44_spec.cr b/spec/issues/github_issue_44_spec.cr index d1b9716..da6cbf3 100644 --- a/spec/issues/github_issue_44_spec.cr +++ b/spec/issues/github_issue_44_spec.cr @@ -26,12 +26,15 @@ Spectator.describe "GitHub Issue #44" do # Original issue uses keyword arguments in place of positional arguments. context "keyword arguments in place of positional arguments" do before_each do - expect(Process).to receive(:run).with(command, shell: true, output: :pipe).and_raise(exception) + pipe = Process::Redirect::Pipe + expect(Process).to receive(:run).with(command, shell: true, output: pipe).and_raise(exception) end - it "must stub Process.run", skip: "Keyword arguments in place of positional arguments not supported with expect-receive" do - Process.run(command, shell: true, output: :pipe) do |_process| - end + it "must stub Process.run" do + expect do + Process.run(command, shell: true, output: :pipe) do |_process| + end + end.to raise_error(File::NotFoundError, "File not found") end end end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index f9c6e19..fcb74f4 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -90,9 +90,10 @@ module Spectator i = 0 other.args.each do |k, v2| + break if i >= positional.size next if kwargs.has_key?(k) # Covered by named arguments. - v1 = positional.fetch(i) { return false } + v1 = positional[i] i += 1 return false unless v1 === v2 end From 275b217c6c1238d8fb0934b733e1919e883eb7b4 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 29 Nov 2022 23:22:42 -0700 Subject: [PATCH 062/120] Allow metadata to be stored as nil --- CHANGELOG.md | 1 + src/spectator/dsl/metadata.cr | 3 +++ src/spectator/example.cr | 11 +++++----- src/spectator/example_builder.cr | 4 ++-- src/spectator/example_group.cr | 2 +- src/spectator/example_group_builder.cr | 2 +- src/spectator/example_group_iteration.cr | 2 +- .../iterative_example_group_builder.cr | 2 +- src/spectator/node.cr | 20 ++++++++++++++----- src/spectator/pending_example_builder.cr | 2 +- src/spectator/spec_builder.cr | 8 ++++---- src/spectator/tag_node_filter.cr | 4 +++- src/spectator/test_context.cr | 4 ++-- 13 files changed, 41 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c13bd55..704bc9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Simplify string representation of mock-related types. - Remove unnecessary redefinitions of methods when adding stub functionality to a type. +- Allow metadata to be stored as nil to reduce overhead when tracking nodes without tags. ## [0.11.4] ### Added diff --git a/src/spectator/dsl/metadata.cr b/src/spectator/dsl/metadata.cr index 308bcbd..04092b9 100644 --- a/src/spectator/dsl/metadata.cr +++ b/src/spectator/dsl/metadata.cr @@ -6,6 +6,9 @@ module Spectator::DSL private macro _spectator_metadata(name, source, *tags, **metadata) private def self.{{name.id}} %metadata = {{source.id}}.dup + {% unless tags.empty? && metadata.empty? %} + %metadata ||= ::Spectator::Metadata.new + {% end %} {% for k in tags %} %metadata[{{k.id.symbolize}}] = nil {% end %} diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 3625ded..67483e0 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -40,7 +40,7 @@ module Spectator # Note: The metadata will not be merged with the parent metadata. def initialize(@context : Context, @entrypoint : self ->, name : String? = nil, location : Location? = nil, - @group : ExampleGroup? = nil, metadata = Metadata.new) + @group : ExampleGroup? = nil, metadata = nil) super(name, location, metadata) # Ensure group is linked. @@ -58,7 +58,7 @@ module Spectator # Note: The metadata will not be merged with the parent metadata. def initialize(@context : Context, @entrypoint : self ->, @name_proc : Example -> String, location : Location? = nil, - @group : ExampleGroup? = nil, metadata = Metadata.new) + @group : ExampleGroup? = nil, metadata = nil) super(nil, location, metadata) # Ensure group is linked. @@ -75,7 +75,7 @@ module Spectator # A set of *metadata* can be used for filtering and modifying example behavior. # Note: The metadata will not be merged with the parent metadata. def initialize(name : String? = nil, location : Location? = nil, - @group : ExampleGroup? = nil, metadata = Metadata.new, &block : self ->) + @group : ExampleGroup? = nil, metadata = nil, &block : self ->) super(name, location, metadata) @context = NullContext.new @@ -93,9 +93,10 @@ module Spectator # A set of *metadata* can be used for filtering and modifying example behavior. # Note: The metadata will not be merged with the parent metadata. def self.pending(name : String? = nil, location : Location? = nil, - group : ExampleGroup? = nil, metadata = Metadata.new, reason = nil) + group : ExampleGroup? = nil, metadata = nil, reason = nil) # Add pending tag and reason if they don't exist. - metadata = metadata.merge({:pending => nil, :reason => reason}) { |_, v, _| v } + tags = {:pending => nil, :reason => reason} + metadata = metadata ? metadata.merge(tags) { |_, v, _| v } : tags new(name, location, group, metadata) { nil } end diff --git a/src/spectator/example_builder.cr b/src/spectator/example_builder.cr index bb640df..23398d2 100644 --- a/src/spectator/example_builder.cr +++ b/src/spectator/example_builder.cr @@ -15,7 +15,7 @@ module Spectator # The *entrypoint* indicates the proc used to invoke the test code in the example. # The *name*, *location*, and *metadata* will be applied to the `Example` produced by `#build`. def initialize(@context_builder : -> Context, @entrypoint : Example ->, - @name : String? = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new) + @name : String? = nil, @location : Location? = nil, @metadata : Metadata? = nil) end # Creates the builder. @@ -24,7 +24,7 @@ module Spectator # The *name* is an interpolated string that runs in the context of the example. # *location*, and *metadata* will be applied to the `Example` produced by `#build`. def initialize(@context_builder : -> Context, @entrypoint : Example ->, - @name : Example -> String, @location : Location? = nil, @metadata : Metadata = Metadata.new) + @name : Example -> String, @location : Location? = nil, @metadata : Metadata? = nil) end # Constructs an example with previously defined attributes and context. diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr index dc1fa57..277be3c 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -79,7 +79,7 @@ module Spectator # This group will be assigned to the parent *group* if it is provided. # A set of *metadata* can be used for filtering and modifying example behavior. def initialize(@name : Label = nil, @location : Location? = nil, - @group : ExampleGroup? = nil, @metadata : Metadata = Metadata.new) + @group : ExampleGroup? = nil, @metadata : Metadata? = nil) # Ensure group is linked. group << self if group end diff --git a/src/spectator/example_group_builder.cr b/src/spectator/example_group_builder.cr index 05c740f..207cb6e 100644 --- a/src/spectator/example_group_builder.cr +++ b/src/spectator/example_group_builder.cr @@ -28,7 +28,7 @@ module Spectator # Creates the builder. # Initially, the builder will have no children and no hooks. # The *name*, *location*, and *metadata* will be applied to the `ExampleGroup` produced by `#build`. - def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new) + def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata? = nil) end # Constructs an example group with previously defined attributes, children, and hooks. diff --git a/src/spectator/example_group_iteration.cr b/src/spectator/example_group_iteration.cr index d6576d2..0d20a29 100644 --- a/src/spectator/example_group_iteration.cr +++ b/src/spectator/example_group_iteration.cr @@ -18,7 +18,7 @@ module Spectator # This group will be assigned to the parent *group* if it is provided. # A set of *metadata* can be used for filtering and modifying example behavior. def initialize(@item : T, name : Label = nil, location : Location? = nil, - group : ExampleGroup? = nil, metadata : Metadata = Metadata.new) + group : ExampleGroup? = nil, metadata : Metadata? = nil) super(name, location, group, metadata) end end diff --git a/src/spectator/iterative_example_group_builder.cr b/src/spectator/iterative_example_group_builder.cr index 39ad549..fe67c7a 100644 --- a/src/spectator/iterative_example_group_builder.cr +++ b/src/spectator/iterative_example_group_builder.cr @@ -15,7 +15,7 @@ module Spectator # The *collection* is the set of items to create sub-nodes for. # The *iterators* is a list of optional names given to items in the collection. def initialize(@collection : Enumerable(T), name : String? = nil, @iterators : Array(String) = [] of String, - location : Location? = nil, metadata : Metadata = Metadata.new) + location : Location? = nil, metadata : Metadata? = nil) super(name, location, metadata) end diff --git a/src/spectator/node.cr b/src/spectator/node.cr index 807c8df..6a5d068 100644 --- a/src/spectator/node.cr +++ b/src/spectator/node.cr @@ -30,14 +30,16 @@ module Spectator end # User-defined tags and values used for filtering and behavior modification. - getter metadata : Metadata + def metadata : Metadata + @metadata ||= Metadata.new + end # Creates the node. # The *name* describes the purpose of the node. # It can be a `Symbol` to describe a type. # The *location* tracks where the node exists in source code. # A set of *metadata* can be used for filtering and modifying example behavior. - def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new) + def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata? = nil) end # Indicates whether the node has completed. @@ -46,17 +48,25 @@ module Spectator # Checks if the node has been marked as pending. # Pending items should be skipped during execution. def pending? - metadata.has_key?(:pending) || metadata.has_key?(:skip) + return false unless md = @metadata + + md.has_key?(:pending) || md.has_key?(:skip) end # Gets the reason the node has been marked as pending. def pending_reason - metadata[:pending]? || metadata[:skip]? || metadata[:reason]? || DEFAULT_PENDING_REASON + return DEFAULT_PENDING_REASON unless md = @metadata + + md[:pending]? || md[:skip]? || md[:reason]? || DEFAULT_PENDING_REASON end # Retrieves just the tag names applied to the node. def tags - Tags.new(metadata.keys) + if md = @metadata + Tags.new(md.keys) + else + Tags.new + end end # Non-nil name used to show the node name. diff --git a/src/spectator/pending_example_builder.cr b/src/spectator/pending_example_builder.cr index a1f0292..434efe5 100644 --- a/src/spectator/pending_example_builder.cr +++ b/src/spectator/pending_example_builder.cr @@ -11,7 +11,7 @@ module Spectator # The *name*, *location*, and *metadata* will be applied to the `Example` produced by `#build`. # A default *reason* can be given in case the user didn't provide one. def initialize(@name : String? = nil, @location : Location? = nil, - @metadata : Metadata = Metadata.new, @reason : String? = nil) + @metadata : Metadata? = nil, @reason : String? = nil) end # Constructs an example with previously defined attributes. diff --git a/src/spectator/spec_builder.cr b/src/spectator/spec_builder.cr index 265b41d..17b0284 100644 --- a/src/spectator/spec_builder.cr +++ b/src/spectator/spec_builder.cr @@ -60,7 +60,7 @@ module Spectator # # A set of *metadata* can be used for filtering and modifying example behavior. # For instance, adding a "pending" tag will mark tests as pending and skip execution. - def start_group(name, location = nil, metadata = Metadata.new) : Nil + def start_group(name, location = nil, metadata = nil) : Nil Log.trace { "Start group: #{name.inspect} @ #{location}; metadata: #{metadata}" } builder = ExampleGroupBuilder.new(name, location, metadata) @@ -86,7 +86,7 @@ module Spectator # # A set of *metadata* can be used for filtering and modifying example behavior. # For instance, adding a "pending" tag will mark tests as pending and skip execution. - def start_iterative_group(collection, name, iterator = nil, location = nil, metadata = Metadata.new) : Nil + def start_iterative_group(collection, name, iterator = nil, location = nil, metadata = nil) : Nil Log.trace { "Start iterative group: #{name} (#{typeof(collection)}) @ #{location}; metadata: #{metadata}" } builder = IterativeExampleGroupBuilder.new(collection, name, iterator, location, metadata) @@ -127,7 +127,7 @@ module Spectator # It will be yielded two arguments - the example created by this method, and the *context* argument. # The return value of the block is ignored. # It is expected that the test code runs when the block is called. - def add_example(name, location, context_builder, metadata = Metadata.new, &block : Example -> _) : Nil + def add_example(name, location, context_builder, metadata = nil, &block : Example -> _) : Nil Log.trace { "Add example: #{name} @ #{location}; metadata: #{metadata}" } current << ExampleBuilder.new(context_builder, block, name, location, metadata) end @@ -144,7 +144,7 @@ module Spectator # A set of *metadata* can be used for filtering and modifying example behavior. # For instance, adding a "pending" tag will mark the test as pending and skip execution. # A default *reason* can be given in case the user didn't provide one. - def add_pending_example(name, location, metadata = Metadata.new, reason = nil) : Nil + def add_pending_example(name, location, metadata = nil, reason = nil) : Nil Log.trace { "Add pending example: #{name} @ #{location}; metadata: #{metadata}" } current << PendingExampleBuilder.new(name, location, metadata, reason) end diff --git a/src/spectator/tag_node_filter.cr b/src/spectator/tag_node_filter.cr index d360712..0dedd59 100644 --- a/src/spectator/tag_node_filter.cr +++ b/src/spectator/tag_node_filter.cr @@ -10,7 +10,9 @@ module Spectator # Checks whether the node satisfies the filter. def includes?(node) : Bool - node.metadata.any? { |key, value| key.to_s == @tag && (!@value || value == @value) } + return false unless metadata = node.metadata + + metadata.any? { |key, value| key.to_s == @tag && (!@value || value == @value) } end end end diff --git a/src/spectator/test_context.cr b/src/spectator/test_context.cr index a68c5b9..e04fe56 100644 --- a/src/spectator/test_context.cr +++ b/src/spectator/test_context.cr @@ -34,7 +34,7 @@ class SpectatorTestContext < SpectatorContext # Initial metadata for tests. # This method should be overridden by example groups and examples. - private def self.metadata - ::Spectator::Metadata.new + private def self.metadata : ::Spectator::Metadata? + nil end end From 7ffa63718ba3e64b751351227a7b16de6e717922 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 8 Dec 2022 16:55:27 -0700 Subject: [PATCH 063/120] Use original type in redefinition comment --- src/spectator/mocks/stubbable.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index ffb52f3..4b03117 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -387,7 +387,7 @@ module Spectator method = definition[:method] scope = definition[:scope] receiver = definition[:receiver] %} - # Redefinition of {{type}}{{(receiver ? "." : "#").id}}{{method.name}} + # Redefinition of {{original_type}}{{(receiver ? "." : "#").id}}{{method.name}} {{(method.abstract? ? "abstract_stub abstract" : "default_stub").id}} {{method.visibility.id if method.visibility != :public}} def {{"#{receiver}.".id if receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} From 47a62ece789d8fe88bb05ef950078756c68dc299 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 8 Dec 2022 17:14:09 -0700 Subject: [PATCH 064/120] Add reduced test code for GitLab issue 80 https://gitlab.com/arctic-fox/spectator/-/issues/80 Note: This test only triggers a compiler bug when the file is compiled by itself. Compiling/running the entire spec suite *does not* cause the bug. --- spec/issues/gitlab_issue_80_spec.cr | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 spec/issues/gitlab_issue_80_spec.cr diff --git a/spec/issues/gitlab_issue_80_spec.cr b/spec/issues/gitlab_issue_80_spec.cr new file mode 100644 index 0000000..bdfe1ca --- /dev/null +++ b/spec/issues/gitlab_issue_80_spec.cr @@ -0,0 +1,29 @@ +require "../spec_helper" + +# https://gitlab.com/arctic-fox/spectator/-/issues/80# + +class Item +end + +class ItemUser + @item = Item.new + + def item + @item + end +end + +Spectator.describe "test1" do + it "without mock" do + item_user = ItemUser.new + item = item_user.item + item == item + end +end + +Spectator.describe "test2" do + mock Item do + end + it "without mock" do + end +end From bd44b5562e0a6e1a359294fd4013f2d50af0f460 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 9 Dec 2022 02:16:16 -0700 Subject: [PATCH 065/120] Possible fix for GitLab issue 80 Remove `is_a?` check on line 425. Replace with alternate logic that achieves the same thing. The `{{type}}` in `is_a?` was causing a compiler bug. I'm unsure of the root cause, but this works around it. --- src/spectator/mocks/stubbable.cr | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 4b03117..22c2c7a 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -422,12 +422,15 @@ module Spectator # If successful, which it will be in most cases, return it. # The caller will receive a properly typed value without unions or other side-effects. %cast = %value.as?({{type}}) - if %cast.is_a?({{type}}) + + {% if fail_cast == :nil %} %cast - else - {% if fail_cast == :nil %} - nil - {% elsif fail_cast == :raise %} + {% elsif fail_cast == :raise %} + # Check if nil was returned by the stub and if its okay to return it. + if %value.nil? && {{type}}.nilable? + # Value was nil and nil is allowed to be returned. + %cast.as({{type}}) + elsif %cast.nil? # The stubbed value was something else entirely and cannot be cast to the return type. # There's something weird going on (compiler bug?) that sometimes causes this class lookup to fail. %type = begin @@ -436,10 +439,13 @@ module Spectator "" end raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%type}`, but returned type must be `#{ {{type}} }`.") - {% else %} - {% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %} - {% end %} - end + else + # Types match and value can be returned as cast type. + %cast + end + {% else %} + {% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %} + {% end %} {% end %} end end From 2985ef5919ae43d2f3f31ec2052e8a8da5380114 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Fri, 9 Dec 2022 02:22:21 -0700 Subject: [PATCH 066/120] Remove error handling around type resolution failure This might not be necessary anymore. --- src/spectator/mocks/stubbable.cr | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 22c2c7a..9b68c68 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -432,13 +432,7 @@ module Spectator %cast.as({{type}}) elsif %cast.nil? # The stubbed value was something else entirely and cannot be cast to the return type. - # There's something weird going on (compiler bug?) that sometimes causes this class lookup to fail. - %type = begin - %value.class.to_s - rescue - "" - end - raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%type}`, but returned type must be `#{ {{type}} }`.") + raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{ {{type}} }`.") else # Types match and value can be returned as cast type. %cast From 293faccd5c781b1d113151ba6352146dce82e23a Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 13 Dec 2022 18:22:22 -0700 Subject: [PATCH 067/120] Support free variables in mocked types --- CHANGELOG.md | 3 ++ spec/issues/github_issue_48_spec.cr | 46 +++++++++++++++++++++++++++++ src/spectator/mocks/stubbable.cr | 11 ++++--- 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 spec/issues/github_issue_48_spec.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index 704bc9e..a954fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Fix macro logic to support free variables on stubbed methods. + ### Changed - Simplify string representation of mock-related types. - Remove unnecessary redefinitions of methods when adding stub functionality to a type. diff --git a/spec/issues/github_issue_48_spec.cr b/spec/issues/github_issue_48_spec.cr new file mode 100644 index 0000000..6349a82 --- /dev/null +++ b/spec/issues/github_issue_48_spec.cr @@ -0,0 +1,46 @@ +require "../spec_helper" + +Spectator.describe "GitHub Issue #48" do + class Test + def return_this(thing : T) : T forall T + thing + end + + def map(thing : T, & : T -> U) : U forall T, U + yield thing + end + + def make_nilable(thing : T) : T? forall T + thing.as(T?) + end + end + + mock Test, make_nilable: nil + + let(fake) { mock(Test) } + + it "handles free variables" do + allow(fake).to receive(:return_this).and_return("different") + expect(fake.return_this("test")).to eq("different") + end + + it "raises on type cast error with free variables" do + allow(fake).to receive(:return_this).and_return(42) + expect { fake.return_this("test") }.to raise_error(TypeCastError, /String/) + end + + it "handles free variables with a block" do + allow(fake).to receive(:map).and_return("stub") + expect(fake.map(:mapped, &.to_s)).to eq("stub") + end + + it "raises on type cast error with a block and free variables" do + allow(fake).to receive(:map).and_return(42) + expect { fake.map(:mapped, &.to_s) }.to raise_error(TypeCastError, /String/) + end + + it "handles nilable free variables" do + fake = mock(Test) + expect(fake.make_nilable("foo")).to be_nil + end +end diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 9b68c68..2e487d1 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -158,9 +158,11 @@ module Spectator # Cast the stub or return value to the expected type. # This is necessary to match the expected return type of the original method. _spectator_cast_stub_value(%stub, %call, typeof({{original}}), - {{ if method.return_type && method.return_type.resolve == NoReturn + {{ if method.return_type && method.return_type.resolve? == NoReturn :no_return - elsif method.return_type && method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil) + elsif method.return_type && + ((resolved = method.return_type.resolve?).is_a?(TypeNode) && resolved <= Nil) || + (method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve?).includes?(Nil)) :nil else :raise @@ -262,9 +264,10 @@ module Spectator {% if method.return_type %} # Return type restriction takes priority since it can be a superset of the original implementation. _spectator_cast_stub_value(%stub, %call, {{method.return_type}}, - {{ if method.return_type.resolve == NoReturn + {{ if method.return_type.resolve? == NoReturn :no_return - elsif method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil) + elsif (method.return_type.resolve?.is_a?(TypeNode) && method.return_type.resolve <= Nil) || + (method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve?).includes?(Nil)) :nil else :raise From 952e949307efee50edaa4c79e96a7ad83ce75af8 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 13 Dec 2022 22:48:21 -0700 Subject: [PATCH 068/120] Handle 'self' and some other variants in method return types --- CHANGELOG.md | 2 +- spec/issues/github_issue_48_spec.cr | 54 ++++++++++++++++++++++++++++- src/spectator/mocks/stubbable.cr | 45 +++++++++++++++++------- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a954fdd..d01c764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- Fix macro logic to support free variables on stubbed methods. +- Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) ### Changed - Simplify string representation of mock-related types. diff --git a/spec/issues/github_issue_48_spec.cr b/spec/issues/github_issue_48_spec.cr index 6349a82..d5e68fb 100644 --- a/spec/issues/github_issue_48_spec.cr +++ b/spec/issues/github_issue_48_spec.cr @@ -13,6 +13,22 @@ Spectator.describe "GitHub Issue #48" do def make_nilable(thing : T) : T? forall T thing.as(T?) end + + def itself : self + self + end + + def itself? : self? + self.as(self?) + end + + def generic(thing : T) : Array(T) forall T + Array.new(100) { thing } + end + + def union : Int32 | String + 42.as(Int32 | String) + end end mock Test, make_nilable: nil @@ -40,7 +56,43 @@ Spectator.describe "GitHub Issue #48" do end it "handles nilable free variables" do - fake = mock(Test) expect(fake.make_nilable("foo")).to be_nil end + + it "handles 'self' return type" do + not_self = mock(Test) + allow(fake).to receive(:itself).and_return(not_self) + expect(fake.itself).to be(not_self) + end + + it "raises on type cast error with 'self' return type" do + allow(fake).to receive(:itself).and_return(42) + expect { fake.itself }.to raise_error(TypeCastError, /#{class_mock(Test)}/) + end + + it "handles nilable 'self' return type" do + not_self = mock(Test) + allow(fake).to receive(:itself?).and_return(not_self) + expect(fake.itself?).to be(not_self) + end + + it "handles generic return type" do + allow(fake).to receive(:generic).and_return([42]) + expect(fake.generic(42)).to eq([42]) + end + + it "raises on type cast error with generic return type" do + allow(fake).to receive(:generic).and_return("test") + expect { fake.generic(42) }.to raise_error(TypeCastError, /Array\(Int32\)/) + end + + it "handles union return types" do + allow(fake).to receive(:union).and_return("test") + expect(fake.union).to eq("test") + end + + it "raises on type cast error with union return type" do + allow(fake).to receive(:union).and_return(:test) + expect { fake.union }.to raise_error(TypeCastError, /Symbol/) + end end diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 2e487d1..7c022cd 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -158,12 +158,24 @@ module Spectator # Cast the stub or return value to the expected type. # This is necessary to match the expected return type of the original method. _spectator_cast_stub_value(%stub, %call, typeof({{original}}), - {{ if method.return_type && method.return_type.resolve? == NoReturn - :no_return - elsif method.return_type && - ((resolved = method.return_type.resolve?).is_a?(TypeNode) && resolved <= Nil) || - (method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve?).includes?(Nil)) - :nil + {{ if rt = method.return_type + if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn + :no_return + else + # Process as an enumerable type to reduce code repetition. + rt = rt.is_a?(Union) ? rt.types : [rt] + # Check if any types are nilable. + nilable = rt.any? do |t| + # These are all macro types that have the `resolve?` method. + (t.is_a?(TypeNode) || t.is_a?(Path) || t.is_a?(Generic) || t.is_a?(MetaClass)) && + (resolved = t.resolve?).is_a?(TypeNode) && resolved <= Nil + end + if nilable + :nil + else + :raise + end + end else :raise end }}) @@ -261,16 +273,25 @@ module Spectator if %stub = _spectator_find_stub(%call) # Cast the stub or return value to the expected type. # This is necessary to match the expected return type of the original method. - {% if method.return_type %} + {% if rt = method.return_type %} # Return type restriction takes priority since it can be a superset of the original implementation. _spectator_cast_stub_value(%stub, %call, {{method.return_type}}, - {{ if method.return_type.resolve? == NoReturn + {{ if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn :no_return - elsif (method.return_type.resolve?.is_a?(TypeNode) && method.return_type.resolve <= Nil) || - (method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve?).includes?(Nil)) - :nil else - :raise + # Process as an enumerable type to reduce code repetition. + rt = rt.is_a?(Union) ? rt.types : [rt] + # Check if any types are nilable. + nilable = rt.any? do |t| + # These are all macro types that have the `resolve?` method. + (t.is_a?(TypeNode) || t.is_a?(Path) || t.is_a?(Generic) || t.is_a?(MetaClass)) && + (resolved = t.resolve?).is_a?(TypeNode) && resolved <= Nil + end + if nilable + :nil + else + :raise + end end }}) {% elsif !method.abstract? %} # The method isn't abstract, infer the type it returns without calling it. From 7e2ec4ee379364b5621cd02558a62602b0eef505 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 13 Dec 2022 22:59:42 -0700 Subject: [PATCH 069/120] Fix 0.11.4 in changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d01c764..e700a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove unnecessary redefinitions of methods when adding stub functionality to a type. - Allow metadata to be stored as nil to reduce overhead when tracking nodes without tags. -## [0.11.4] +## [0.11.4] - 2022-11-27 ### Added - Add support for using named (keyword) arguments in place of positional arguments in stubs. [#47](https://github.com/icy-arctic-fox/spectator/issues/47) - Add `before`, `after`, and `around` as aliases for `before_each`, `after_each`, and `around_each` respectively. @@ -426,7 +426,7 @@ First version ready for public use. [Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...master -[0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 +[0.11.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 [0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...v0.11.3 [0.11.2]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...v0.11.2 [0.11.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1 From b52593dbded131f6b019b3bec3d8ca20456ccf84 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 17 Dec 2022 16:39:47 -0700 Subject: [PATCH 070/120] Cleanup --- spec/issues/gitlab_issue_80_spec.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/issues/gitlab_issue_80_spec.cr b/spec/issues/gitlab_issue_80_spec.cr index bdfe1ca..9090130 100644 --- a/spec/issues/gitlab_issue_80_spec.cr +++ b/spec/issues/gitlab_issue_80_spec.cr @@ -1,6 +1,6 @@ require "../spec_helper" -# https://gitlab.com/arctic-fox/spectator/-/issues/80# +# https://gitlab.com/arctic-fox/spectator/-/issues/80 class Item end @@ -24,6 +24,7 @@ end Spectator.describe "test2" do mock Item do end + it "without mock" do end end From 65a4b8e756f36d57cd725c94aa60ca1673a83ab2 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 17 Dec 2022 16:41:22 -0700 Subject: [PATCH 071/120] Populate previous_def/super with captured block args The previous_def and super keywords do not propagate blocks. See: https://github.com/crystal-lang/crystal/issues/10399 This works around the issue by populating arguments if the method uses a block. --- spec/issues/github_issue_48_spec.cr | 27 ++++++++++++ src/spectator/mocks/stubbable.cr | 64 +++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/spec/issues/github_issue_48_spec.cr b/spec/issues/github_issue_48_spec.cr index d5e68fb..21e4664 100644 --- a/spec/issues/github_issue_48_spec.cr +++ b/spec/issues/github_issue_48_spec.cr @@ -29,6 +29,15 @@ Spectator.describe "GitHub Issue #48" do def union : Int32 | String 42.as(Int32 | String) end + + def capture(&block : -> T) forall T + block + end + + def capture(thing : T, &block : T -> T) forall T + block.call(thing) + block + end end mock Test, make_nilable: nil @@ -95,4 +104,22 @@ Spectator.describe "GitHub Issue #48" do allow(fake).to receive(:union).and_return(:test) expect { fake.union }.to raise_error(TypeCastError, /Symbol/) end + + it "handles captured blocks" do + proc = ->{} + allow(fake).to receive(:capture).and_return(proc) + expect(fake.capture { nil }).to be(proc) + end + + it "raises on type cast error with captured blocks" do + proc = ->{ 42 } + allow(fake).to receive(:capture).and_return(proc) + expect { fake.capture { "other" } }.to raise_error(TypeCastError, /Proc\(String\)/) + end + + it "handles captured blocks with arguments" do + proc = ->(x : Int32) { x * 2 } + allow(fake).to receive(:capture).and_return(proc) + expect(fake.capture(5) { 5 }).to be(proc) + end end diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 7c022cd..6ded8ee 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -126,7 +126,31 @@ module Spectator {{method.body}} end - {% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %} + {% original = "previous_def" + # Workaround for Crystal not propagating block with previous_def/super. + if method.accepts_block? + original += "(" + method.args.each_with_index do |arg, i| + original += '*' if method.splat_index == i + original += arg.name.stringify + original += ", " + end + if method.double_splat + original += method.double_splat.stringify + original += ", " + end + # If the block is captured (i.e. `&block` syntax), it must be passed along as an argument. + # Otherwise, use `yield` to forward the block. + captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0 + method.block_arg.name + else + nil + end + original += "&#{captured_block}" if captured_block + original += ")" + original += " { |*_spectator_yargs| yield *_spectator_yargs }" unless captured_block + end + original = original.id %} {% # Reconstruct the method signature. # I wish there was a better way of doing this, but there isn't (at least not that I'm aware of). @@ -241,7 +265,32 @@ module Spectator {{method.body}} end - {% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %} + {% original = "previous_def" + # Workaround for Crystal not propagating block with previous_def/super. + if method.accepts_block? + original += "(" + method.args.each_with_index do |arg, i| + original += '*' if method.splat_index == i + original += arg.name.stringify + original += ", " + end + if method.double_splat + original += method.double_splat.stringify + original += ", " + end + # If the block is captured (i.e. `&block` syntax), it must be passed along as an argument. + # Otherwise, use `yield` to forward the block. + captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0 + method.block_arg.name + else + nil + end + original += "&#{captured_block}" if captured_block + original += ")" + original += " { |*_spectator_yargs| yield *_spectator_yargs }" unless captured_block + end + original = original.id %} + {% end %} {% # Reconstruct the method signature. @@ -418,7 +467,16 @@ module Spectator {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} {% unless method.abstract? %} - {{scope}}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %} + {{scope}}{% if method.accepts_block? %}( + {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.name}}, {% end %} + {% if method.double_splat %}**{{method.double_splat}}, {% end %} + {% captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0 + method.block_arg.name + else + nil + end %} + {% if captured_block %}&{{captured_block}}{% end %} + ){% if !captured_block %} { |*%yargs| yield *%yargs }{% end %}{% end %} end {% end %} {% end %} From 9f54a9e542b46e75b74ac3989ae1b0f8b09c8043 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 17 Dec 2022 19:16:38 -0700 Subject: [PATCH 072/120] Additional handling for passing blocks --- CHANGELOG.md | 1 + src/spectator/mocks/stubbable.cr | 62 ++++++++++++++++++++++---------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e700a80..645a115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed - Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) +- Fix method stubs used on methods that capture blocks. ### Changed - Simplify string representation of mock-related types. diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 6ded8ee..9c32c47 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -130,14 +130,25 @@ module Spectator # Workaround for Crystal not propagating block with previous_def/super. if method.accepts_block? original += "(" - method.args.each_with_index do |arg, i| - original += '*' if method.splat_index == i - original += arg.name.stringify - original += ", " - end - if method.double_splat - original += method.double_splat.stringify - original += ", " + if method.splat_index + method.args.each_with_index do |arg, i| + if i == method.splat_index + original += '*' + if arg.internal_name && arg.internal_name.size > 0 + original += "#{arg.internal_name}, " + end + original += "**#{method.double_splat}, " if method.double_splat + elsif i > method.splat_index + original += "#{arg.name}: #{arg.internal_name}" + else + original += "#{arg.internal_name}, " + end + end + else + method.args.each do |arg| + original += "#{arg.internal_name}, " + end + original += "**#{method.double_splat}, " if method.double_splat end # If the block is captured (i.e. `&block` syntax), it must be passed along as an argument. # Otherwise, use `yield` to forward the block. @@ -269,14 +280,25 @@ module Spectator # Workaround for Crystal not propagating block with previous_def/super. if method.accepts_block? original += "(" - method.args.each_with_index do |arg, i| - original += '*' if method.splat_index == i - original += arg.name.stringify - original += ", " - end - if method.double_splat - original += method.double_splat.stringify - original += ", " + if method.splat_index + method.args.each_with_index do |arg, i| + if i == method.splat_index + original += '*' + if arg.internal_name && arg.internal_name.size > 0 + original += "#{arg.internal_name}, " + end + original += "**#{method.double_splat}, " if method.double_splat + elsif i > method.splat_index + original += "#{arg.name}: #{arg.internal_name}" + else + original += "#{arg.internal_name}, " + end + end + else + method.args.each do |arg| + original += "#{arg.internal_name}, " + end + original += "**#{method.double_splat}, " if method.double_splat end # If the block is captured (i.e. `&block` syntax), it must be passed along as an argument. # Otherwise, use `yield` to forward the block. @@ -467,9 +489,11 @@ module Spectator {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} {% unless method.abstract? %} - {{scope}}{% if method.accepts_block? %}( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.name}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}, {% end %} + {{scope}}{% if method.accepts_block? %}({% for arg, i in method.args %} + {% if i == method.splat_index && arg.internal_name && arg.internal_name.size > 0 %}*{{arg.internal_name}}, {% if method.double_splat %}**{{method.double_splat}}, {% end %}{% end %} + {% if method.splat_index && i > method.splat_index %}{{arg.name}}: {{arg.internal_name}}, {% end %} + {% if !method.splat_index || i < method.splat_index %}{{arg.internal_name}}, {% end %}{% end %} + {% if !method.splat_index && method.double_splat %}**{{method.double_splat}}, {% end %} {% captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0 method.block_arg.name else From 149c0e6e4b7e6ab4de0e16a84a0f45183131c634 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 17 Dec 2022 19:19:33 -0700 Subject: [PATCH 073/120] Don't use case-matching for proc arguments A proc on the left side of === calls itself passing in the right side. This causes typing issues and is easier to avoid for now. Procs arguments are compared with standard equality (==) instead of case-equality (===). --- CHANGELOG.md | 1 + src/spectator/mocks/abstract_arguments.cr | 32 ++++++++++++++++++++++- src/spectator/mocks/arguments.cr | 14 +++++++--- src/spectator/mocks/formal_arguments.cr | 4 +-- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 645a115..248f805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Simplify string representation of mock-related types. - Remove unnecessary redefinitions of methods when adding stub functionality to a type. - Allow metadata to be stored as nil to reduce overhead when tracking nodes without tags. +- Use normal equality (==) instead of case-equality (===) with proc arguments in stubs. ## [0.11.4] - 2022-11-27 ### Added diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index b03f161..b06934c 100644 --- a/src/spectator/mocks/abstract_arguments.cr +++ b/src/spectator/mocks/abstract_arguments.cr @@ -6,11 +6,41 @@ module Spectator to_s(io) end + # Utility method for comparing two tuples considering special types. + private def compare_tuples(a : Tuple, b : Tuple) + return false if a.size != b.size + + a.zip(b) do |a_value, b_value| + if a_value.is_a?(Proc) + # Using procs as argument matchers isn't supported currently. + # Compare directly instead. + return false unless a_value == b_value + else + return false unless a_value === b_value + end + end + true + end + + # Utility method for comparing two tuples considering special types. + # Supports nilable tuples (ideal for splats). + private def compare_tuples(a : Tuple?, b : Tuple?) + return false if a.nil? ^ b.nil? + + compare_tuples(a.not_nil!, b.not_nil!) + end + # Utility method for comparing two named tuples ignoring order. private def compare_named_tuples(a : NamedTuple, b : NamedTuple) a.each do |k, v1| v2 = b.fetch(k) { return false } - return false unless v1 === v2 + if v1.is_a?(Proc) + # Using procs as argument matchers isn't supported currently. + # Compare directly instead. + return false unless v1 == v2 + else + return false unless v1 === v2 + end end true end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index fcb74f4..2638e77 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -81,7 +81,7 @@ module Spectator # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - positional === other.positional && compare_named_tuples(kwargs, other.kwargs) + compare_tuples(positional, other.positional) && compare_named_tuples(kwargs, other.kwargs) end # :ditto: @@ -95,13 +95,21 @@ module Spectator v1 = positional[i] i += 1 - return false unless v1 === v2 + if v1.is_a?(Proc) + return false unless v1 == v2 + else + return false unless v1 === v2 + end end other.splat.try &.each do |v2| v1 = positional.fetch(i) { return false } i += 1 - return false unless v1 === v2 + if v1.is_a?(Proc) + return false unless v1 == v2 + else + return false unless v1 === v2 + end end i == positional.size diff --git a/src/spectator/mocks/formal_arguments.cr b/src/spectator/mocks/formal_arguments.cr index e440214..1c0ca69 100644 --- a/src/spectator/mocks/formal_arguments.cr +++ b/src/spectator/mocks/formal_arguments.cr @@ -122,12 +122,12 @@ module Spectator # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - positional === other.positional && compare_named_tuples(kwargs, other.kwargs) + compare_tuples(positional, other.positional) && compare_named_tuples(kwargs, other.kwargs) end # :ditto: def ===(other : FormalArguments) - compare_named_tuples(args, other.args) && splat === other.splat && compare_named_tuples(kwargs, other.kwargs) + compare_named_tuples(args, other.args) && compare_tuples(splat, other.splat) && compare_named_tuples(kwargs, other.kwargs) end end end From c3e7edc7003546d607ea9784ff7009d70095b090 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 17 Dec 2022 20:37:27 -0700 Subject: [PATCH 074/120] Use absolute names of types in mocked type methods Prevent possibly type name collisions. This could happen if, for instance, Array or String was redefined in the scope of the mocked type. --- src/spectator/mocks/mock.cr | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index ba72b37..a5d6128 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -50,22 +50,22 @@ module Spectator end {% end %} - def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil + def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil @_spectator_stubs.try &.delete(stub) end - def _spectator_clear_stubs : Nil + def _spectator_clear_stubs : ::Nil @_spectator_stubs = nil end - private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub + private class_getter _spectator_stubs : ::Array(::Spectator::Stub) = [] of ::Spectator::Stub - class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall + class_getter _spectator_calls : ::Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall getter _spectator_calls = [] of ::Spectator::MethodCall # Returns the mock's name formatted for user output. - private def _spectator_stubbed_name : String + private def _spectator_stubbed_name : ::String \{% if anno = @type.annotation(::Spectator::StubbedName) %} "#" \{% else %} @@ -73,7 +73,7 @@ module Spectator \{% end %} end - private def self._spectator_stubbed_name : String + private def self._spectator_stubbed_name : ::String \{% if anno = @type.annotation(::Spectator::StubbedName) %} "#" \{% else %} @@ -132,9 +132,9 @@ module Spectator {% raise "Unsupported base type #{base} for injecting mock" %} {% end %} - private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub + private class_getter _spectator_stubs : ::Array(::Spectator::Stub) = [] of ::Spectator::Stub - class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall + class_getter _spectator_calls : ::Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall private def _spectator_stubs entry = @@_spectator_mock_registry.fetch(self) do @@ -143,11 +143,11 @@ module Spectator entry.stubs end - def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil + def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil @@_spectator_mock_registry[self]?.try &.stubs.delete(stub) end - def _spectator_clear_stubs : Nil + def _spectator_clear_stubs : ::Nil @@_spectator_mock_registry.delete(self) end @@ -169,7 +169,7 @@ module Spectator end # Returns the mock's name formatted for user output. - private def _spectator_stubbed_name : String + private def _spectator_stubbed_name : ::String \{% if anno = @type.annotation(::Spectator::StubbedName) %} "#" \{% else %} @@ -178,7 +178,7 @@ module Spectator end # Returns the mock's name formatted for user output. - private def self._spectator_stubbed_name : String + private def self._spectator_stubbed_name : ::String \{% if anno = @type.annotation(::Spectator::StubbedName) %} "#" \{% else %} From 4b68b8e3de1285add515e9521a979cc0f94dd818 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 17 Dec 2022 20:56:16 -0700 Subject: [PATCH 075/120] Fix resolution issue when mocked types use custom types GitLab issue 51 is affected. https://gitlab.com/arctic-fox/spectator/-/issues/51 Private types cannot be referenced with mocks. --- CHANGELOG.md | 1 + spec/issues/gitlab_issue_51_spec.cr | 42 +++++++------- spec/spectator/mocks/mock_spec.cr | 90 +++++++++++++++++++++++++++++ src/spectator/dsl/mocks.cr | 35 ++++++----- 4 files changed, 133 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 248f805..46800cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) - Fix method stubs used on methods that capture blocks. +- Fix type name resolution for when using custom types in a mocked typed. ### Changed - Simplify string representation of mock-related types. diff --git a/spec/issues/gitlab_issue_51_spec.cr b/spec/issues/gitlab_issue_51_spec.cr index c809607..996af80 100644 --- a/spec/issues/gitlab_issue_51_spec.cr +++ b/spec/issues/gitlab_issue_51_spec.cr @@ -1,31 +1,33 @@ require "../spec_helper" -private class Foo - def call(str : String) : String? - "" +module GitLabIssue51 + class Foo + def call(str : String) : String? + "" + end + + def alt1_call(str : String) : String? + nil + end + + def alt2_call(str : String) : String? + [str, nil].sample + end end - def alt1_call(str : String) : String? - nil - end - - def alt2_call(str : String) : String? - [str, nil].sample + class Bar + def call(a_foo) : Nil # Must add nil restriction here, otherwise a segfault occurs from returning the result of #alt2_call. + a_foo.call("") + a_foo.alt1_call("") + a_foo.alt2_call("") + end end end -private class Bar - def call(a_foo) : Nil # Must add nil restriction here, otherwise a segfault occurs from returning the result of #alt2_call. - a_foo.call("") - a_foo.alt1_call("") - a_foo.alt2_call("") - end -end +Spectator.describe GitLabIssue51::Bar do + mock GitLabIssue51::Foo, call: "", alt1_call: "", alt2_call: "" -Spectator.describe Bar do - mock Foo, call: "", alt1_call: "", alt2_call: "" - - let(:foo) { mock(Foo) } + let(:foo) { mock(GitLabIssue51::Foo) } subject(:call) { described_class.new.call(foo) } describe "#call" do diff --git a/spec/spectator/mocks/mock_spec.cr b/spec/spectator/mocks/mock_spec.cr index 40b4bf0..8835183 100644 --- a/spec/spectator/mocks/mock_spec.cr +++ b/spec/spectator/mocks/mock_spec.cr @@ -29,8 +29,18 @@ Spectator.describe Spectator::Mock do @_spectator_invocations << :method3 "original" end + + def method4 : Thing + self + end + + def method5 : OtherThing + OtherThing.new + end end + class OtherThing; end + Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method1: 123) do stub def method2 :stubbed @@ -104,6 +114,20 @@ Spectator.describe Spectator::Mock do mock.method3 expect(mock._spectator_invocations).to contain_exactly(:method3) end + + it "can reference its own type" do + new_mock = MockThing.new + stub = Spectator::ValueStub.new(:method4, new_mock) + mock._spectator_define_stub(stub) + expect(mock.method4).to be(new_mock) + end + + it "can reference other types in the original namespace" do + other = OtherThing.new + stub = Spectator::ValueStub.new(:method5, other) + mock._spectator_define_stub(stub) + expect(mock.method5).to be(other) + end end context "with an abstract class" do @@ -120,8 +144,14 @@ Spectator.describe Spectator::Mock do end abstract def method4 + + abstract def method4 : Thing + + abstract def method5 : OtherThing end + class OtherThing; end + Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method2: :stubbed) do stub def method1 : Int32 # NOTE: Return type is required since one wasn't provided in the parent. 123 @@ -199,6 +229,20 @@ Spectator.describe Spectator::Mock do mock.method3 expect(mock._spectator_invocations).to contain_exactly(:method3) end + + it "can reference its own type" do + new_mock = MockThing.new + stub = Spectator::ValueStub.new(:method4, new_mock) + mock._spectator_define_stub(stub) + expect(mock.method4).to be(new_mock) + end + + it "can reference other types in the original namespace" do + other = OtherThing.new + stub = Spectator::ValueStub.new(:method5, other) + mock._spectator_define_stub(stub) + expect(mock.method5).to be(other) + end end context "with an abstract struct" do @@ -215,8 +259,14 @@ Spectator.describe Spectator::Mock do end abstract def method4 + + abstract def method4 : Thing + + abstract def method5 : OtherThing end + class OtherThing; end + Spectator::Mock.define_subtype(:struct, Thing, MockThing, :mock_name, method2: :stubbed) do stub def method1 : Int32 # NOTE: Return type is required since one wasn't provided in the parent. 123 @@ -286,6 +336,22 @@ Spectator.describe Spectator::Mock do mock.method3 expect(mock._spectator_invocations).to contain_exactly(:method3) end + + it "can reference its own type" do + mock = self.mock # FIXME: Workaround for passing by value messing with stubs. + new_mock = MockThing.new + stub = Spectator::ValueStub.new(:method4, new_mock) + mock._spectator_define_stub(stub) + expect(mock.method4).to be_a(Thing) + end + + it "can reference other types in the original namespace" do + mock = self.mock # FIXME: Workaround for passing by value messing with stubs. + other = OtherThing.new + stub = Spectator::ValueStub.new(:method5, other) + mock._spectator_define_stub(stub) + expect(mock.method5).to be(other) + end end context "class method stubs" do @@ -301,8 +367,18 @@ Spectator.describe Spectator::Mock do def self.baz(arg) yield end + + def self.thing : Thing + new + end + + def self.other : OtherThing + OtherThing.new + end end + class OtherThing; end + Spectator::Mock.define_subtype(:class, Thing, MockThing) do stub def self.foo :stub @@ -367,6 +443,20 @@ Spectator.describe Spectator::Mock do expect(restricted(mock)).to eq(:stub) end + it "can reference its own type" do + new_mock = MockThing.new + stub = Spectator::ValueStub.new(:thing, new_mock) + mock._spectator_define_stub(stub) + expect(mock.thing).to be(new_mock) + end + + it "can reference other types in the original namespace" do + other = OtherThing.new + stub = Spectator::ValueStub.new(:other, other) + mock._spectator_define_stub(stub) + expect(mock.other).to be(other) + end + describe "._spectator_clear_stubs" do before { mock._spectator_define_stub(foo_stub) } diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index 94a41f9..ab05636 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -218,24 +218,29 @@ module Spectator::DSL # end # ``` private macro def_mock(type, name = nil, **value_methods, &block) - {% # Construct a unique type name for the mock by using the number of defined types. - index = ::Spectator::DSL::Mocks::TYPES.size - mock_type_name = "Mock#{index}".id + {% resolved = type.resolve + # Construct a unique type name for the mock by using the number of defined types. + index = ::Spectator::DSL::Mocks::TYPES.size + # The type is nested under the original so that any type names from the original can be resolved. + mock_type_name = "Mock#{index}".id - # Store information about how the mock is defined and its context. - # This is important for constructing an instance of the mock later. - ::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, mock_type_name.symbolize} + # Store information about how the mock is defined and its context. + # This is important for constructing an instance of the mock later. + ::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, "::#{resolved.name}::#{mock_type_name}".id.symbolize} - resolved = type.resolve - base = if resolved.class? - :class - elsif resolved.struct? - :struct - else - :module - end %} + base = if resolved.class? + :class + elsif resolved.struct? + :struct + else + :module + end %} - ::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{**value_methods}}) {{block}} + {% begin %} + {{base.id}} ::{{resolved.name}} + ::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{**value_methods}}) {{block}} + end + {% end %} end # Instantiates a mock. From f55c60e01fcf1559cc4420353e3f6b4a6992714b Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 17 Dec 2022 21:01:22 -0700 Subject: [PATCH 076/120] Fix README spec Mocked types cannot be private. Moved to a module to prevent polluting the global namespace. --- spec/docs/readme_spec.cr | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/spec/docs/readme_spec.cr b/spec/docs/readme_spec.cr index 6024906..2ed7fd5 100644 --- a/spec/docs/readme_spec.cr +++ b/spec/docs/readme_spec.cr @@ -1,26 +1,28 @@ require "../spec_helper" -private abstract class Interface - abstract def invoke(thing) : String -end +module Readme + abstract class Interface + abstract def invoke(thing) : String + end -# Type being tested. -private class Driver - def do_something(interface : Interface, thing) - interface.invoke(thing) + # Type being tested. + class Driver + def do_something(interface : Interface, thing) + interface.invoke(thing) + end end end -Spectator.describe Driver do +Spectator.describe Readme::Driver do # Define a mock for Interface. - mock Interface + mock Readme::Interface # Define a double that the interface will use. double(:my_double, foo: 42) it "does a thing" do # Create an instance of the mock interface. - interface = mock(Interface) + interface = mock(Readme::Interface) # Indicate that `#invoke` should return "test" when called. allow(interface).to receive(:invoke).and_return("test") From e6584c9f042fd111d870a03a2924d87b8cbcfcb7 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 18 Dec 2022 11:35:43 -0700 Subject: [PATCH 077/120] Prevent comparing range arguments with non-compatible types in stubs Addresses https://github.com/icy-arctic-fox/spectator/issues/48 --- CHANGELOG.md | 1 + spec/issues/github_issue_48_spec.cr | 10 ++++++ src/spectator/mocks/abstract_arguments.cr | 38 ++++++++++++++--------- src/spectator/mocks/arguments.cr | 12 ++----- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46800cf..73ffadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) - Fix method stubs used on methods that capture blocks. - Fix type name resolution for when using custom types in a mocked typed. +- Prevent comparing range arguments with non-compatible types in stubs. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) ### Changed - Simplify string representation of mock-related types. diff --git a/spec/issues/github_issue_48_spec.cr b/spec/issues/github_issue_48_spec.cr index 21e4664..b958c1b 100644 --- a/spec/issues/github_issue_48_spec.cr +++ b/spec/issues/github_issue_48_spec.cr @@ -38,6 +38,10 @@ Spectator.describe "GitHub Issue #48" do block.call(thing) block end + + def range(r : Range) + r + end end mock Test, make_nilable: nil @@ -122,4 +126,10 @@ Spectator.describe "GitHub Issue #48" do allow(fake).to receive(:capture).and_return(proc) expect(fake.capture(5) { 5 }).to be(proc) end + + it "handles range comparisons against non-comparable types" do + range = 1..10 + allow(fake).to receive(:range).and_return(range) + expect(fake.range(1..3)).to eq(range) + end end diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index b06934c..442c1d2 100644 --- a/src/spectator/mocks/abstract_arguments.cr +++ b/src/spectator/mocks/abstract_arguments.cr @@ -11,13 +11,7 @@ module Spectator return false if a.size != b.size a.zip(b) do |a_value, b_value| - if a_value.is_a?(Proc) - # Using procs as argument matchers isn't supported currently. - # Compare directly instead. - return false unless a_value == b_value - else - return false unless a_value === b_value - end + return false unless compare_values(a_value, b_value) end true end @@ -34,15 +28,31 @@ module Spectator private def compare_named_tuples(a : NamedTuple, b : NamedTuple) a.each do |k, v1| v2 = b.fetch(k) { return false } - if v1.is_a?(Proc) - # Using procs as argument matchers isn't supported currently. - # Compare directly instead. - return false unless v1 == v2 - else - return false unless v1 === v2 - end + return false unless compare_values(v1, v2) end true end + + # Utility method for comparing two arguments considering special types. + # Some types used for case-equality don't work well with unexpected right-hand types. + # This can happen when the right side is a massive union of types. + private def compare_values(a, b) + case a + when Proc + # Using procs as argument matchers isn't supported currently. + # Compare directly instead. + a == b + when Range + # Ranges can only be matched against if their right side is comparable. + # Ensure the right side is comparable, otherwise compare directly. + if b.is_a?(Comparable(typeof(b))) + a === b + else + a == b + end + else + a === b + end + end end end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index 2638e77..f15a0ba 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -95,21 +95,13 @@ module Spectator v1 = positional[i] i += 1 - if v1.is_a?(Proc) - return false unless v1 == v2 - else - return false unless v1 === v2 - end + return false unless compare_values(v1, v2) end other.splat.try &.each do |v2| v1 = positional.fetch(i) { return false } i += 1 - if v1.is_a?(Proc) - return false unless v1 == v2 - else - return false unless v1 === v2 - end + return false unless compare_values(v1, v2) end i == positional.size From 6255cc85c44e4a5e91afaa469ae154026b07937e Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 18 Dec 2022 15:17:48 -0700 Subject: [PATCH 078/120] Handle original call reaching to another type Primary use case for this is mock modules. Allows default stubs to access more than previous_def and super. --- src/spectator/mocks/stubbable.cr | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 9c32c47..48f6ac5 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -434,7 +434,13 @@ module Spectator private macro stub_type(type_name = @type) {% type = type_name.resolve definitions = [] of Nil - scope = (type == @type ? :previous_def : :super).id + scope = if type == @type + :previous_def + elsif type.module? + type.name + else + :super + end.id # Add entries for methods in the target type and its class type. [[:self.id, type.class], [nil, type]].each do |(receiver, t)| @@ -481,15 +487,25 @@ module Spectator {% original_type = definition[:type] method = definition[:method] scope = definition[:scope] - receiver = definition[:receiver] %} - # Redefinition of {{original_type}}{{(receiver ? "." : "#").id}}{{method.name}} + receiver = definition[:receiver] + rewrite_args = method.accepts_block? + # Handle calling methods on other objects (primarily for mock modules). + if scope != :super.id && scope != :previous_def.id + if receiver == :self.id + scope = "#{scope}.#{method.name}".id + rewrite_args = true + else + scope = :super.id + end + end %} + # Redefinition of {{original_type}}{{"#".id}}{{method.name}} {{(method.abstract? ? "abstract_stub abstract" : "default_stub").id}} {{method.visibility.id if method.visibility != :public}} def {{"#{receiver}.".id if receiver}}{{method.name}}( {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} {% unless method.abstract? %} - {{scope}}{% if method.accepts_block? %}({% for arg, i in method.args %} + {{scope}}{% if rewrite_args %}({% for arg, i in method.args %} {% if i == method.splat_index && arg.internal_name && arg.internal_name.size > 0 %}*{{arg.internal_name}}, {% if method.double_splat %}**{{method.double_splat}}, {% end %}{% end %} {% if method.splat_index && i > method.splat_index %}{{arg.name}}: {{arg.internal_name}}, {% end %} {% if !method.splat_index || i < method.splat_index %}{{arg.internal_name}}, {% end %}{% end %} @@ -500,7 +516,7 @@ module Spectator nil end %} {% if captured_block %}&{{captured_block}}{% end %} - ){% if !captured_block %} { |*%yargs| yield *%yargs }{% end %}{% end %} + ){% if !captured_block && method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}{% end %} end {% end %} {% end %} From d37858305403a3339852867996e26cd4dd2293b0 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 18 Dec 2022 15:18:20 -0700 Subject: [PATCH 079/120] Support mocking modules --- spec/spectator/mocks/mock_spec.cr | 197 ++++++++++++++++++++++++++++++ src/spectator/mocks/mock.cr | 7 +- 2 files changed, 203 insertions(+), 1 deletion(-) diff --git a/spec/spectator/mocks/mock_spec.cr b/spec/spectator/mocks/mock_spec.cr index 8835183..7d04fed 100644 --- a/spec/spectator/mocks/mock_spec.cr +++ b/spec/spectator/mocks/mock_spec.cr @@ -491,6 +491,203 @@ Spectator.describe Spectator::Mock do end end + context "with a module" do + module Thing + # `extend self` cannot be used. + # The Crystal compiler doesn't report the methods as class methods when doing so. + + def self.original_method + :original + end + + def self.default_method + :original + end + + def self.stubbed_method(_value = 42) + :original + end + end + + Spectator::Mock.define_subtype(:module, Thing, MockThing) do + stub def self.stubbed_method(_value = 42) + :stubbed + end + end + + let(mock) { MockThing } + + after { mock._spectator_clear_stubs } + + it "overrides an existing method" do + stub = Spectator::ValueStub.new(:original_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override) + end + + it "doesn't affect other methods" do + stub = Spectator::ValueStub.new(:stubbed_method, :override) + expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method } + end + + it "replaces an existing default stub" do + stub = Spectator::ValueStub.new(:default_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override) + end + + it "replaces an existing stubbed method" do + stub = Spectator::ValueStub.new(:stubbed_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override) + end + + def restricted(thing : Thing.class) + thing.stubbed_method + end + + it "can be used in type restricted methods" do + expect(restricted(mock)).to eq(:stubbed) + end + + describe "._spectator_clear_stubs" do + before do + stub = Spectator::ValueStub.new(:original_method, :override) + mock._spectator_define_stub(stub) + end + + it "removes previously defined stubs" do + expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original) + end + end + + describe "._spectator_calls" do + before { mock._spectator_clear_calls } + + # Retrieves symbolic names of methods called on a mock. + def called_method_names(mock) + mock._spectator_calls.map(&.method) + end + + it "stores calls to original methods" do + expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method]) + end + + it "stores calls to default methods" do + expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method]) + end + + it "stores calls to stubbed methods" do + expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method]) + end + + it "stores multiple calls to the same stub" do + mock.stubbed_method + expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method]) + end + + it "stores arguments for a call" do + mock.stubbed_method(5) + args = Spectator::Arguments.capture(5) + call = mock._spectator_calls.first + expect(call.arguments).to eq(args) + end + end + end + + context "with a mocked module included in a class" do + module Thing + def original_method + :original + end + + def default_method + :original + end + + def stubbed_method(_value = 42) + :original + end + end + + Spectator::Mock.define_subtype(:module, Thing, MockThing, default_method: :default) do + stub def stubbed_method(_value = 42) + :stubbed + end + end + + class IncludedMock + include MockThing + end + + let(mock) { IncludedMock.new } + + it "overrides an existing method" do + stub = Spectator::ValueStub.new(:original_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override) + end + + it "doesn't affect other methods" do + stub = Spectator::ValueStub.new(:stubbed_method, :override) + expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method } + end + + it "replaces an existing default stub" do + stub = Spectator::ValueStub.new(:default_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override) + end + + it "replaces an existing stubbed method" do + stub = Spectator::ValueStub.new(:stubbed_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override) + end + + def restricted(thing : Thing.class) + thing.default_method + end + + describe "#_spectator_clear_stubs" do + before do + stub = Spectator::ValueStub.new(:original_method, :override) + mock._spectator_define_stub(stub) + end + + it "removes previously defined stubs" do + expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original) + end + end + + describe "#_spectator_calls" do + before { mock._spectator_clear_calls } + + # Retrieves symbolic names of methods called on a mock. + def called_method_names(mock) + mock._spectator_calls.map(&.method) + end + + it "stores calls to original methods" do + expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method]) + end + + it "stores calls to default methods" do + expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method]) + end + + it "stores calls to stubbed methods" do + expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method]) + end + + it "stores multiple calls to the same stub" do + mock.stubbed_method + expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method]) + end + + it "stores arguments for a call" do + mock.stubbed_method(5) + args = Spectator::Arguments.capture(5) + call = mock._spectator_calls.first + expect(call.arguments).to eq(args) + end + end + end + context "with a method that uses NoReturn" do abstract class Thing abstract def oops : NoReturn diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index a5d6128..3c0eefe 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -36,7 +36,12 @@ module Spectator macro define_subtype(base, mocked_type, type_name, name = nil, **value_methods, &block) {% begin %} {% if name %}@[::Spectator::StubbedName({{name}})]{% end %} - {{base.id}} {{type_name.id}} < {{mocked_type.id}} + {% if base.id == :module.id %} + {{base.id}} {{type_name.id}} + include {{mocked_type.id}} + {% else %} + {{base.id}} {{type_name.id}} < {{mocked_type.id}} + {% end %} include ::Spectator::Mocked extend ::Spectator::StubbedType From fa99987780369a5da0183e1354a2381b054374f9 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 18 Dec 2022 16:04:49 -0700 Subject: [PATCH 080/120] Support creating instances of mocked modules via class This is a bit of a hack. The `.new` method is added to the module, which creates an instance that includes the mocked module. No changes to the def_mock and new_mock methods are necessary. For some reason, infinite recursion occurs when calling `.new` on the class. To get around the issue for now, the internal method of allocation is used. That is, allocate + initialize. --- CHANGELOG.md | 3 + spec/spectator/dsl/mocks/mock_spec.cr | 258 ++++++++++++++++++++++++++ src/spectator/mocks/mock.cr | 23 +++ test.cr | 104 +++++++++++ 4 files changed, 388 insertions(+) create mode 100644 test.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ffadb..bf4bc1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added support for mock modules and types that include mocked modules. + ### Fixed - Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) - Fix method stubs used on methods that capture blocks. diff --git a/spec/spectator/dsl/mocks/mock_spec.cr b/spec/spectator/dsl/mocks/mock_spec.cr index db63dd9..05ef4c9 100644 --- a/spec/spectator/dsl/mocks/mock_spec.cr +++ b/spec/spectator/dsl/mocks/mock_spec.cr @@ -1027,4 +1027,262 @@ Spectator.describe "Mock DSL", :smoke do expect(fake.reference).to eq("reference") end end + + describe "mock module" do + module Dummy + # `extend self` cannot be used. + # The Crystal compiler doesn't report the methods as class methods when doing so. + + def self.abstract_method + :not_really_abstract + end + + def self.default_method + :original + end + + def self.args(arg) + arg + end + + def self.method1 + :original + end + + def self.reference + method1.to_s + end + end + + mock(Dummy) do + abstract_stub def self.abstract_method + :abstract + end + + stub def self.default_method + :default + end + end + + let(fake) { class_mock(Dummy) } + + it "raises on abstract stubs" do + expect { fake.abstract_method }.to raise_error(Spectator::UnexpectedMessage, /abstract_method/) + end + + it "can define default stubs" do + expect(fake.default_method).to eq(:default) + end + + it "can define new stubs" do + expect { allow(fake).to receive(:args).and_return(42) }.to change { fake.args(5) }.from(5).to(42) + end + + it "can override class method stubs" do + allow(fake).to receive(:method1).and_return(:override) + expect(fake.method1).to eq(:override) + end + + xit "can reference stubs", pending: "Default stub of module class methods always refer to original" do + allow(fake).to receive(:method1).and_return(:reference) + expect(fake.reference).to eq("reference") + end + end + + context "with a class including a mocked module" do + module Dummy + getter _spectator_invocations = [] of Symbol + + def method1 + @_spectator_invocations << :method1 + "original" + end + + def method2 : Symbol + @_spectator_invocations << :method2 + :original + end + + def method3(arg) + @_spectator_invocations << :method3 + arg + end + + def method4 : Symbol + @_spectator_invocations << :method4 + yield + end + + def method5 + @_spectator_invocations << :method5 + yield.to_i + end + + def method6 + @_spectator_invocations << :method6 + yield + end + + def method7(arg, *args, kwarg, **kwargs) + @_spectator_invocations << :method7 + {arg, args, kwarg, kwargs} + end + + def method8(arg, *args, kwarg, **kwargs) + @_spectator_invocations << :method8 + yield + {arg, args, kwarg, kwargs} + end + end + + # method1 stubbed via mock block + # method2 stubbed via keyword args + # method3 not stubbed (calls original) + # method4 stubbed via mock block (yields) + # method5 stubbed via keyword args (yields) + # method6 not stubbed (calls original and yields) + # method7 not stubbed (calls original) testing args + # method8 not stubbed (calls original and yields) testing args + mock(Dummy, method2: :stubbed, method5: 42) do + stub def method1 + "stubbed" + end + + stub def method4 : Symbol + yield + :block + end + end + + subject(fake) { mock(Dummy) } + + it "defines a subclass" do + expect(fake).to be_a(Dummy) + end + + it "defines stubs in the block" do + expect(fake.method1).to eq("stubbed") + end + + it "can stub methods defined in the block" do + stub = Spectator::ValueStub.new(:method1, "override") + expect { fake._spectator_define_stub(stub) }.to change { fake.method1 }.from("stubbed").to("override") + end + + it "defines stubs from keyword arguments" do + expect(fake.method2).to eq(:stubbed) + end + + it "can stub methods from keyword arguments" do + stub = Spectator::ValueStub.new(:method2, :override) + expect { fake._spectator_define_stub(stub) }.to change { fake.method2 }.from(:stubbed).to(:override) + end + + it "calls the original implementation for methods not provided a stub" do + expect(fake.method3(:xyz)).to eq(:xyz) + end + + it "can stub methods after declaration" do + stub = Spectator::ValueStub.new(:method3, :abc) + expect { fake._spectator_define_stub(stub) }.to change { fake.method3(:xyz) }.from(:xyz).to(:abc) + end + + it "defines stubs with yield in the block" do + expect(fake.method4 { :wrong }).to eq(:block) + end + + it "can stub methods with yield in the block" do + stub = Spectator::ValueStub.new(:method4, :override) + expect { fake._spectator_define_stub(stub) }.to change { fake.method4 { :wrong } }.from(:block).to(:override) + end + + it "defines stubs with yield from keyword arguments" do + expect(fake.method5 { :wrong }).to eq(42) + end + + it "can stub methods with yield from keyword arguments" do + stub = Spectator::ValueStub.new(:method5, 123) + expect { fake._spectator_define_stub(stub) }.to change { fake.method5 { "0" } }.from(42).to(123) + end + + it "can stub yielding methods after declaration" do + stub = Spectator::ValueStub.new(:method6, :abc) + expect { fake._spectator_define_stub(stub) }.to change { fake.method6 { :xyz } }.from(:xyz).to(:abc) + end + + it "handles arguments correctly" do + args1 = fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + args2 = fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + aggregate_failures do + expect(args1).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + expect(args2).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + end + end + + it "handles arguments correctly with stubs" do + stub1 = Spectator::ProcStub.new(:method7, args_proc) + stub2 = Spectator::ProcStub.new(:method8, args_proc) + fake._spectator_define_stub(stub1) + fake._spectator_define_stub(stub2) + args1 = fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + args2 = fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + aggregate_failures do + expect(args1).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + expect(args2).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + end + end + + it "compiles types without unions" do + aggregate_failures do + expect(fake.method1).to compile_as(String) + expect(fake.method2).to compile_as(Symbol) + expect(fake.method3(42)).to compile_as(Int32) + expect(fake.method4 { :foo }).to compile_as(Symbol) + expect(fake.method5 { "123" }).to compile_as(Int32) + expect(fake.method6 { "123" }).to compile_as(String) + end + end + + def restricted(thing : Dummy) + thing.method1 + end + + it "can be used in type restricted methods" do + expect(restricted(fake)).to eq("stubbed") + end + + it "does not call the original method when stubbed" do + fake.method1 + fake.method2 + fake.method3("foo") + fake.method4 { :foo } + fake.method5 { "42" } + fake.method6 { 42 } + fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + expect(fake._spectator_invocations).to contain_exactly(:method3, :method6, :method7, :method8) + end + + # Cannot test unexpected messages - will not compile due to missing methods. + + describe "deferred default stubs" do + mock(Dummy) + + let(fake2) do + mock(Dummy, + method1: "stubbed", + method3: 123, + method4: :xyz) + end + + it "uses the keyword arguments as stubs" do + aggregate_failures do + expect(fake2.method1).to eq("stubbed") + expect(fake2.method2).to eq(:original) + expect(fake2.method3(42)).to eq(123) + expect(fake2.method4 { :foo }).to eq(:xyz) + end + end + end + end end diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index 3c0eefe..248d8c8 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -39,6 +39,29 @@ module Spectator {% if base.id == :module.id %} {{base.id}} {{type_name.id}} include {{mocked_type.id}} + + # Mock class that includes the mocked module {{mocked_type.id}} + {% if name %}@[::Spectator::StubbedName({{name}})]{% end %} + private class ClassIncludingMock{{type_name.id}} + include {{type_name.id}} + end + + # Returns a mock class that includes the mocked module {{mocked_type.id}}. + def self.new(*args, **kwargs) : ClassIncludingMock{{type_name.id}} + # FIXME: Creating the instance normally with `.new` causing infinite recursion. + inst = ClassIncludingMock{{type_name.id}}.allocate + inst.initialize(*args, **kwargs) + inst + end + + # Returns a mock class that includes the mocked module {{mocked_type.id}}. + def self.new(*args, **kwargs) : ClassIncludingMock{{type_name.id}} + # FIXME: Creating the instance normally with `.new` causing infinite recursion. + inst = ClassIncludingMock{{type_name.id}}.allocate + inst.initialize(*args, **kwargs) { |*yargs| yield *yargs } + inst + end + {% else %} {{base.id}} {{type_name.id}} < {{mocked_type.id}} {% end %} diff --git a/test.cr b/test.cr new file mode 100644 index 0000000..65cb051 --- /dev/null +++ b/test.cr @@ -0,0 +1,104 @@ +require "./src/spectator" + +module Thing + def self.original_method + :original + end + + def self.default_method + :original + end + + def self.stubbed_method(_value = 42) + :original + end + + macro finished + def self.debug + {% begin %}puts "Methods: ", {{@type.methods.map &.name.stringify}} of String{% end %} + {% begin %}puts "Class Methods: ", {{@type.class.methods.map &.name.stringify}} of String{% end %} + end + end +end + +Thing.debug + +# Spectator::Mock.define_subtype(:module, Thing, MockThing, default_method: :default) do +# stub def stubbed_method(_value = 42) +# :stubbed +# end +# end + +# Spectator.describe "Mock modules" do +# let(mock) { MockThing } + +# after { mock._spectator_clear_stubs } + +# it "overrides an existing method" do +# stub = Spectator::ValueStub.new(:original_method, :override) +# expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override) +# end + +# it "doesn't affect other methods" do +# stub = Spectator::ValueStub.new(:stubbed_method, :override) +# expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method } +# end + +# it "replaces an existing default stub" do +# stub = Spectator::ValueStub.new(:default_method, :override) +# expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override) +# end + +# it "replaces an existing stubbed method" do +# stub = Spectator::ValueStub.new(:stubbed_method, :override) +# expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override) +# end + +# def restricted(thing : Thing.class) +# thing.default_method +# end + +# describe "._spectator_clear_stubs" do +# before do +# stub = Spectator::ValueStub.new(:original_method, :override) +# mock._spectator_define_stub(stub) +# end + +# it "removes previously defined stubs" do +# expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original) +# end +# end + +# describe "._spectator_calls" do +# before { mock._spectator_clear_calls } + +# # Retrieves symbolic names of methods called on a mock. +# def called_method_names(mock) +# mock._spectator_calls.map(&.method) +# end + +# it "stores calls to original methods" do +# expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method]) +# end + +# it "stores calls to default methods" do +# expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method]) +# end + +# it "stores calls to stubbed methods" do +# expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method]) +# end + +# it "stores multiple calls to the same stub" do +# mock.stubbed_method +# expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method]) +# end + +# it "stores arguments for a call" do +# mock.stubbed_method(5) +# args = Spectator::Arguments.capture(5) +# call = mock._spectator_calls.first +# expect(call.arguments).to eq(args) +# end +# end +# end From a3c55dfa4750cea0acf528e3d8eda4521dafebc4 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 18 Dec 2022 18:52:08 -0700 Subject: [PATCH 081/120] Add tests for module mocks docs --- spec/docs/mocks_spec.cr | 84 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/spec/docs/mocks_spec.cr b/spec/docs/mocks_spec.cr index 5908787..353c35e 100644 --- a/spec/docs/mocks_spec.cr +++ b/spec/docs/mocks_spec.cr @@ -123,6 +123,90 @@ Spectator.describe "Mocks Docs" do end end + context "Mock Modules" do + module MyModule + def something + # ... + end + end + + describe "#something" do + # Define a mock for MyModule. + mock MyClass + + it "does something" do + # Use mock here. + end + end + + module MyFileUtils + def self.rm_rf(path) + # ... + end + end + + mock MyFileUtils + + it "deletes all of my files" do + utils = class_mock(MyFileUtils) + allow(utils).to receive(:rm_rf) + utils.rm_rf("/") + expect(utils).to have_received(:rm_rf).with("/") + end + + module MyFileUtils2 + extend self + + def rm_rf(path) + # ... + end + end + + mock(MyFileUtils2) do + # Define a default stub for the method. + stub def self.rm_rf(path) + # ... + end + end + + it "deletes all of my files part 2" do + utils = class_mock(MyFileUtils2) + allow(utils).to receive(:rm_rf) + utils.rm_rf("/") + expect(utils).to have_received(:rm_rf).with("/") + end + + module Runnable + def run + # ... + end + end + + mock Runnable + + specify do + runnable = mock(Runnable) # or new_mock(Runnable) + runnable.run + end + + module Runnable2 + abstract def command : String + + def run_one + "Running #{command}" + end + end + + mock Runnable2, command: "ls -l" + + specify do + runnable = mock(Runnable2) + expect(runnable.run_one).to eq("Running ls -l") + runnable = mock(Runnable2, command: "echo foo") + expect(runnable.run_one).to eq("Running echo foo") + end + end + context "Injecting Mocks" do struct MyStruct def something From 8f80b10fc1d46d2f2e9d6676369b6e679f57a115 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 18 Dec 2022 19:04:50 -0700 Subject: [PATCH 082/120] Support injecting mock functionality into modules Add mock registry for a single module. --- spec/docs/mocks_spec.cr | 19 ++++++++++++ src/spectator/mocks/mock.cr | 3 +- src/spectator/mocks/mock_registry.cr | 43 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/spectator/mocks/mock_registry.cr diff --git a/spec/docs/mocks_spec.cr b/spec/docs/mocks_spec.cr index 353c35e..58c16f8 100644 --- a/spec/docs/mocks_spec.cr +++ b/spec/docs/mocks_spec.cr @@ -205,6 +205,25 @@ Spectator.describe "Mocks Docs" do runnable = mock(Runnable2, command: "echo foo") expect(runnable.run_one).to eq("Running echo foo") end + + context "Injecting Mocks" do + module MyFileUtils + def self.rm_rf(path) + true + end + end + + inject_mock MyFileUtils do + stub def self.rm_rf(path) + "Simulating deletion of #{path}" + false + end + end + + specify do + expect(MyFileUtils.rm_rf("/")).to be_false + end + end end context "Injecting Mocks" do diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index 248d8c8..87e8e23 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -1,5 +1,6 @@ require "./method_call" require "./mocked" +require "./mock_registry" require "./reference_mock_registry" require "./stub" require "./stubbed_name" @@ -157,7 +158,7 @@ module Spectator {% elsif base == :struct %} @@_spectator_mock_registry = ::Spectator::ValueMockRegistry(self).new {% else %} - {% raise "Unsupported base type #{base} for injecting mock" %} + @@_spectator_mock_registry = ::Spectator::MockRegistry.new {% end %} private class_getter _spectator_stubs : ::Array(::Spectator::Stub) = [] of ::Spectator::Stub diff --git a/src/spectator/mocks/mock_registry.cr b/src/spectator/mocks/mock_registry.cr new file mode 100644 index 0000000..29390c6 --- /dev/null +++ b/src/spectator/mocks/mock_registry.cr @@ -0,0 +1,43 @@ +require "./mock_registry_entry" +require "./stub" + +module Spectator + # Stores collections of stubs for mocked types. + # + # This type is intended for all mocked modules that have functionality "injected." + # That is, the type itself has mock functionality bolted on. + # Adding instance members should be avoided, for instance, it could mess up serialization. + class MockRegistry + @entry : MockRegistryEntry? + + # Retrieves all stubs. + def [](_object = nil) + @entry.not_nil! + end + + # Retrieves all stubs. + def []?(_object = nil) + @entry + end + + # Retrieves all stubs. + # + # Yields to the block on the first retrieval. + # This allows a mock to populate the registry with initial stubs. + def fetch(object : Reference, & : -> Array(Stub)) + entry = @entry + if entry.nil? + entry = MockRegistryEntry.new + entry.stubs = yield + @entry = entry + else + entry + end + end + + # Clears all stubs defined for a mocked object. + def delete(object : Reference) : Nil + @entry = nil + end + end +end From feaf1c601538c45a6f97bbceec720554d8575b20 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sun, 18 Dec 2022 19:15:25 -0700 Subject: [PATCH 083/120] Bump version to 0.11.5 --- CHANGELOG.md | 6 ++++-- shard.yml | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4bc1f..40813e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.11.5] - 2022-12-18 ### Added - Added support for mock modules and types that include mocked modules. @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove unnecessary redefinitions of methods when adding stub functionality to a type. - Allow metadata to be stored as nil to reduce overhead when tracking nodes without tags. - Use normal equality (==) instead of case-equality (===) with proc arguments in stubs. +- Change stub value cast logic to avoid compiler bug. [#80](https://gitlab.com/arctic-fox/spectator/-/issues/80) ## [0.11.4] - 2022-11-27 ### Added @@ -432,7 +433,8 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...master +[0.11.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...v0.11.5 [0.11.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 [0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...v0.11.3 [0.11.2]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...v0.11.2 diff --git a/shard.yml b/shard.yml index 15c9a65..a41bc90 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.11.4 +version: 0.11.5 description: | Feature-rich testing framework for Crystal inspired by RSpec. From 7620f58fb886cef889b456ca4c6e5cd2f2a69691 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 19 Dec 2022 02:31:12 -0700 Subject: [PATCH 084/120] Test file, please ignore --- .gitignore | 2 ++ test.cr | 104 ----------------------------------------------------- 2 files changed, 2 insertions(+), 104 deletions(-) delete mode 100644 test.cr diff --git a/.gitignore b/.gitignore index c4166ba..f76b510 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ # Ignore JUnit output output.xml + +/test.cr diff --git a/test.cr b/test.cr deleted file mode 100644 index 65cb051..0000000 --- a/test.cr +++ /dev/null @@ -1,104 +0,0 @@ -require "./src/spectator" - -module Thing - def self.original_method - :original - end - - def self.default_method - :original - end - - def self.stubbed_method(_value = 42) - :original - end - - macro finished - def self.debug - {% begin %}puts "Methods: ", {{@type.methods.map &.name.stringify}} of String{% end %} - {% begin %}puts "Class Methods: ", {{@type.class.methods.map &.name.stringify}} of String{% end %} - end - end -end - -Thing.debug - -# Spectator::Mock.define_subtype(:module, Thing, MockThing, default_method: :default) do -# stub def stubbed_method(_value = 42) -# :stubbed -# end -# end - -# Spectator.describe "Mock modules" do -# let(mock) { MockThing } - -# after { mock._spectator_clear_stubs } - -# it "overrides an existing method" do -# stub = Spectator::ValueStub.new(:original_method, :override) -# expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override) -# end - -# it "doesn't affect other methods" do -# stub = Spectator::ValueStub.new(:stubbed_method, :override) -# expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method } -# end - -# it "replaces an existing default stub" do -# stub = Spectator::ValueStub.new(:default_method, :override) -# expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override) -# end - -# it "replaces an existing stubbed method" do -# stub = Spectator::ValueStub.new(:stubbed_method, :override) -# expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override) -# end - -# def restricted(thing : Thing.class) -# thing.default_method -# end - -# describe "._spectator_clear_stubs" do -# before do -# stub = Spectator::ValueStub.new(:original_method, :override) -# mock._spectator_define_stub(stub) -# end - -# it "removes previously defined stubs" do -# expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original) -# end -# end - -# describe "._spectator_calls" do -# before { mock._spectator_clear_calls } - -# # Retrieves symbolic names of methods called on a mock. -# def called_method_names(mock) -# mock._spectator_calls.map(&.method) -# end - -# it "stores calls to original methods" do -# expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method]) -# end - -# it "stores calls to default methods" do -# expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method]) -# end - -# it "stores calls to stubbed methods" do -# expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method]) -# end - -# it "stores multiple calls to the same stub" do -# mock.stubbed_method -# expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method]) -# end - -# it "stores arguments for a call" do -# mock.stubbed_method(5) -# args = Spectator::Arguments.capture(5) -# call = mock._spectator_calls.first -# expect(call.arguments).to eq(args) -# end -# end -# end From 0f8c46d6efe36e34b903c05b4e0a292a96829f72 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 19 Dec 2022 21:29:21 -0700 Subject: [PATCH 085/120] Support casting types with expect statements --- CHANGELOG.md | 4 +++ spec/features/expect_type_spec.cr | 36 +++++++++++++++++++++++++ src/spectator/expectation.cr | 45 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 spec/features/expect_type_spec.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index 40813e7..9a79e5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- Added ability to cast types using the return value from expect statements with a type matcher. + ## [0.11.5] - 2022-12-18 ### Added - Added support for mock modules and types that include mocked modules. diff --git a/spec/features/expect_type_spec.cr b/spec/features/expect_type_spec.cr new file mode 100644 index 0000000..6991a8a --- /dev/null +++ b/spec/features/expect_type_spec.cr @@ -0,0 +1,36 @@ +require "../spec_helper" + +Spectator.describe "Expect Type" do + it "ensures a type is cast" do + value = 42.as(String | Int32) + expect(value).to be_a(String | Int32) + expect(value).to compile_as(String | Int32) + value = expect(value).to be_a(Int32) + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect(value).to_not respond_to(:downcase) + end + + it "ensures a type is not nil" do + value = 42.as(Int32?) + expect(value).to be_a(Int32?) + expect(value).to compile_as(Int32?) + value = expect(value).to_not be_nil + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect { value.not_nil! }.to_not raise_error(NilAssertionError) + end + + it "removes types from a union" do + value = 42.as(String | Int32) + expect(value).to be_a(String | Int32) + expect(value).to compile_as(String | Int32) + value = expect(value).to_not be_a(String) + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect(value).to_not respond_to(:downcase) + end +end diff --git a/src/spectator/expectation.cr b/src/spectator/expectation.cr index 780e299..79d8473 100644 --- a/src/spectator/expectation.cr +++ b/src/spectator/expectation.cr @@ -114,6 +114,21 @@ module Spectator report(match_data, message) end + # Asserts that some criteria defined by the matcher is satisfied. + # Allows a custom message to be used. + # Returns the expected value cast as the expected type, if the matcher is satisfied. + def to(matcher : Matchers::TypeMatcher(U), message = nil) forall U + match_data = matcher.match(@expression) + value = @expression.value + if report(match_data, message) + return value if value.is_a?(U) + + raise "Spectator bug: expected value should have cast to #{U}" + else + raise TypeCastError.new("#{@expression.label} is expected to be a #{U}, but was actually #{value.class}") + end + end + # Asserts that a method is not called before the example completes. @[AlwaysInline] def to_not(stub : Stub, message = nil) : Nil @@ -136,6 +151,36 @@ module Spectator report(match_data, message) end + # Asserts that some criteria defined by the matcher is not satisfied. + # Allows a custom message to be used. + # Returns the expected value cast without the unexpected type, if the matcher is satisfied. + def to_not(matcher : Matchers::TypeMatcher(U), message = nil) forall U + match_data = matcher.negated_match(@expression) + value = @expression.value + if report(match_data, message) + return value unless value.is_a?(U) + + raise "Spectator bug: expected value should not be #{U}" + else + raise TypeCastError.new("#{@expression.label} is not expected to be a #{U}, but was actually #{value.class}") + end + end + + # Asserts that some criteria defined by the matcher is not satisfied. + # Allows a custom message to be used. + # Returns the expected value cast as a non-nillable type, if the matcher is satisfied. + def to_not(matcher : Matchers::NilMatcher, message = nil) + match_data = matcher.negated_match(@expression) + if report(match_data, message) + value = @expression.value + return value unless value.nil? + + raise "Spectator bug: expected value should not be nil" + else + raise NilAssertionError.new("#{@expression.label} is not expected to be nil.") + end + end + # :ditto: @[AlwaysInline] def not_to(matcher, message = nil) : Nil From faff2933e6d83e087529576073accf4a3af74541 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 19 Dec 2022 22:15:53 -0700 Subject: [PATCH 086/120] Only capture splat if it has a name --- CHANGELOG.md | 3 +++ src/spectator/mocks/stubbable.cr | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a79e5e..6881601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added ability to cast types using the return value from expect statements with a type matcher. +### Fixed +- Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. + ## [0.11.5] - 2022-12-18 ### Added - Added support for mock modules and types that include mocked modules. diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 48f6ac5..119111d 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -180,7 +180,7 @@ module Spectator ::NamedTuple.new( {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ), - {% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} + {% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %} ::NamedTuple.new( {% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ).merge({{method.double_splat}}) @@ -332,7 +332,7 @@ module Spectator ::NamedTuple.new( {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ), - {% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} + {% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %} ::NamedTuple.new( {% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ).merge({{method.double_splat}}) From acf810553aa2c0c1704460c8b99260aed11e3515 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 19 Dec 2022 22:27:58 -0700 Subject: [PATCH 087/120] Use location of the 'should' keyword for their expectation --- CHANGELOG.md | 3 +++ src/spectator/should.cr | 28 ++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6881601..ddcdc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. +### Changed +- Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start. + ## [0.11.5] - 2022-12-18 ### Added - Added support for mock modules and types that include mocked modules. diff --git a/src/spectator/should.cr b/src/spectator/should.cr index eb0733f..d444c00 100644 --- a/src/spectator/should.cr +++ b/src/spectator/should.cr @@ -22,51 +22,55 @@ class Object # ``` # require "spectator/should" # ``` - def should(matcher, message = nil) + def should(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) match_data = matcher.match(actual) - expectation = ::Spectator::Expectation.new(match_data, message: message) + expectation = ::Spectator::Expectation.new(match_data, location, message) ::Spectator::Harness.current.report(expectation) end # Works the same as `#should` except the condition is inverted. # When `#should` succeeds, this method will fail, and vice-versa. - def should_not(matcher, message = nil) + def should_not(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) match_data = matcher.negated_match(actual) - expectation = ::Spectator::Expectation.new(match_data, message: message) + expectation = ::Spectator::Expectation.new(match_data, location, message) ::Spectator::Harness.current.report(expectation) end # Works the same as `#should` except that the condition check is postponed. # The expectation is checked after the example finishes and all hooks have run. - def should_eventually(matcher, message = nil) - ::Spectator::Harness.current.defer { should(matcher, message) } + def should_eventually(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) + ::Spectator::Harness.current.defer { should(matcher, message, _file: _file, _line: _line) } end # Works the same as `#should_not` except that the condition check is postponed. # The expectation is checked after the example finishes and all hooks have run. - def should_never(matcher, message = nil) - ::Spectator::Harness.current.defer { should_not(matcher, message) } + def should_never(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) + ::Spectator::Harness.current.defer { should_not(matcher, message, _file: _file, _line: _line) } end end struct Proc(*T, R) # Extension method to create an expectation for a block of code (proc). # Depending on the matcher, the proc may be executed multiple times. - def should(matcher, message = nil) + def should(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) actual = ::Spectator::Block.new(self) + location = ::Spectator::Location.new(_file, _line) match_data = matcher.match(actual) - expectation = ::Spectator::Expectation.new(match_data, message: message) + expectation = ::Spectator::Expectation.new(match_data, location, message) ::Spectator::Harness.current.report(expectation) end # Works the same as `#should` except the condition is inverted. # When `#should` succeeds, this method will fail, and vice-versa. - def should_not(matcher, message = nil) + def should_not(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) actual = ::Spectator::Block.new(self) + location = ::Spectator::Location.new(_file, _line) match_data = matcher.negated_match(actual) - expectation = ::Spectator::Expectation.new(match_data, message: message) + expectation = ::Spectator::Expectation.new(match_data, location, message) ::Spectator::Harness.current.report(expectation) end end From c4bcf54b987456b4be3377dfa33dee8af8f97032 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 19 Dec 2022 22:40:55 -0700 Subject: [PATCH 088/120] Support casting types with should statements --- CHANGELOG.md | 2 +- spec/features/expect_type_spec.cr | 92 +++++++++++++++++++++---------- src/spectator/should.cr | 51 +++++++++++++++++ 3 files changed, 115 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcdc86..72d1db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Added ability to cast types using the return value from expect statements with a type matcher. +- Added ability to cast types using the return value from expect/should statements with a type matcher. ### Fixed - Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. diff --git a/spec/features/expect_type_spec.cr b/spec/features/expect_type_spec.cr index 6991a8a..6ed9949 100644 --- a/spec/features/expect_type_spec.cr +++ b/spec/features/expect_type_spec.cr @@ -1,36 +1,70 @@ require "../spec_helper" -Spectator.describe "Expect Type" do - it "ensures a type is cast" do - value = 42.as(String | Int32) - expect(value).to be_a(String | Int32) - expect(value).to compile_as(String | Int32) - value = expect(value).to be_a(Int32) - expect(value).to eq(42) - expect(value).to be_a(Int32) - expect(value).to compile_as(Int32) - expect(value).to_not respond_to(:downcase) +Spectator.describe "Expect Type", :smoke do + context "with expect syntax" do + it "ensures a type is cast" do + value = 42.as(String | Int32) + expect(value).to be_a(String | Int32) + expect(value).to compile_as(String | Int32) + value = expect(value).to be_a(Int32) + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect(value).to_not respond_to(:downcase) + end + + it "ensures a type is not nil" do + value = 42.as(Int32?) + expect(value).to be_a(Int32?) + expect(value).to compile_as(Int32?) + value = expect(value).to_not be_nil + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect { value.not_nil! }.to_not raise_error(NilAssertionError) + end + + it "removes types from a union" do + value = 42.as(String | Int32) + expect(value).to be_a(String | Int32) + expect(value).to compile_as(String | Int32) + value = expect(value).to_not be_a(String) + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect(value).to_not respond_to(:downcase) + end end - it "ensures a type is not nil" do - value = 42.as(Int32?) - expect(value).to be_a(Int32?) - expect(value).to compile_as(Int32?) - value = expect(value).to_not be_nil - expect(value).to eq(42) - expect(value).to be_a(Int32) - expect(value).to compile_as(Int32) - expect { value.not_nil! }.to_not raise_error(NilAssertionError) - end + context "with should syntax" do + it "ensures a type is cast" do + value = 42.as(String | Int32) + value.should be_a(String | Int32) + value = value.should be_a(Int32) + value.should eq(42) + value.should be_a(Int32) + value.should compile_as(Int32) + value.should_not respond_to(:downcase) + end - it "removes types from a union" do - value = 42.as(String | Int32) - expect(value).to be_a(String | Int32) - expect(value).to compile_as(String | Int32) - value = expect(value).to_not be_a(String) - expect(value).to eq(42) - expect(value).to be_a(Int32) - expect(value).to compile_as(Int32) - expect(value).to_not respond_to(:downcase) + it "ensures a type is not nil" do + value = 42.as(Int32?) + value.should be_a(Int32?) + value = value.should_not be_nil + value.should eq(42) + value.should be_a(Int32) + value.should compile_as(Int32) + expect { value.not_nil! }.to_not raise_error(NilAssertionError) + end + + it "removes types from a union" do + value = 42.as(String | Int32) + value.should be_a(String | Int32) + value = value.should_not be_a(String) + value.should eq(42) + value.should be_a(Int32) + value.should compile_as(Int32) + value.should_not respond_to(:downcase) + end end end diff --git a/src/spectator/should.cr b/src/spectator/should.cr index d444c00..f0fe075 100644 --- a/src/spectator/should.cr +++ b/src/spectator/should.cr @@ -30,6 +30,23 @@ class Object ::Spectator::Harness.current.report(expectation) end + # Asserts that some criteria defined by the matcher is satisfied. + # Allows a custom message to be used. + # Returns the expected value cast as the expected type, if the matcher is satisfied. + def should(matcher : ::Spectator::Matchers::TypeMatcher(U), message = nil, *, _file = __FILE__, _line = __LINE__) forall U + actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) + match_data = matcher.match(actual) + expectation = ::Spectator::Expectation.new(match_data, location, message) + if ::Spectator::Harness.current.report(expectation) + return self if self.is_a?(U) + + raise "Spectator bug: expected value should have cast to #{U}" + else + raise TypeCastError.new("Expected value should be a #{U}, but was actually #{self.class}") + end + end + # Works the same as `#should` except the condition is inverted. # When `#should` succeeds, this method will fail, and vice-versa. def should_not(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) @@ -40,6 +57,40 @@ class Object ::Spectator::Harness.current.report(expectation) end + # Asserts that some criteria defined by the matcher is not satisfied. + # Allows a custom message to be used. + # Returns the expected value cast without the unexpected type, if the matcher is satisfied. + def should_not(matcher : ::Spectator::Matchers::TypeMatcher(U), message = nil, *, _file = __FILE__, _line = __LINE__) forall U + actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) + match_data = matcher.negated_match(actual) + expectation = ::Spectator::Expectation.new(match_data, location, message) + if ::Spectator::Harness.current.report(expectation) + return self unless self.is_a?(U) + + raise "Spectator bug: expected value should not be #{U}" + else + raise TypeCastError.new("Expected value is not expected to be a #{U}, but was actually #{self.class}") + end + end + + # Asserts that some criteria defined by the matcher is not satisfied. + # Allows a custom message to be used. + # Returns the expected value cast as a non-nillable type, if the matcher is satisfied. + def should_not(matcher : ::Spectator::Matchers::NilMatcher, message = nil, *, _file = __FILE__, _line = __LINE__) + actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) + match_data = matcher.negated_match(actual) + expectation = ::Spectator::Expectation.new(match_data, location, message) + if ::Spectator::Harness.current.report(expectation) + return self unless self.nil? + + raise "Spectator bug: expected value should not be nil" + else + raise NilAssertionError.new("Expected value should not be nil.") + end + end + # Works the same as `#should` except that the condition check is postponed. # The expectation is checked after the example finishes and all hooks have run. def should_eventually(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) From b8901f522afc78ec9fe055f51993cc2942f5b7c2 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 20 Dec 2022 20:11:09 -0700 Subject: [PATCH 089/120] Remove unnecessary cast --- src/spectator/example.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 67483e0..04d69c9 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -118,7 +118,7 @@ module Spectator begin @result = Harness.run do - if proc = @name_proc.as?(Proc(Example, String)) + if proc = @name_proc self.name = proc.call(self) end From 30602663fe87399e8572a90ca7464fc174fb4d31 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 20 Dec 2022 20:12:58 -0700 Subject: [PATCH 090/120] Add tests for interpolated labels The context label test intentionally fails. This functionality still needs to be implemented. --- spec/features/interpolated_label_spec.cr | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 spec/features/interpolated_label_spec.cr diff --git a/spec/features/interpolated_label_spec.cr b/spec/features/interpolated_label_spec.cr new file mode 100644 index 0000000..9c78c15 --- /dev/null +++ b/spec/features/interpolated_label_spec.cr @@ -0,0 +1,22 @@ +require "../spec_helper" + +Spectator.describe "Interpolated Label" do + let(foo) { "example" } + let(bar) { "context" } + + it "interpolates #{foo} labels" do |example| + expect(example.name).to eq("interpolates example labels") + end + + context "within a \#{bar}" do + let(foo) { "multiple" } + + it "interpolates context labels" do |example| + expect(example.group.name).to eq("within a context") + end + + it "interpolates #{foo} levels" do |example| + expect(example.name).to eq("interpolates multiple levels") + end + end +end From 8c3900adcbde9429559a35836e5726896d6513c1 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 20 Dec 2022 20:32:40 -0700 Subject: [PATCH 091/120] Add support for interpolation in context names --- CHANGELOG.md | 1 + spec/features/interpolated_label_spec.cr | 2 +- src/spectator/context.cr | 5 +++++ src/spectator/dsl/groups.cr | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d1db9..fb7b645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Added ability to cast types using the return value from expect/should statements with a type matcher. +- Added support for string interpolation in context names/labels. ### Fixed - Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. diff --git a/spec/features/interpolated_label_spec.cr b/spec/features/interpolated_label_spec.cr index 9c78c15..5506a1f 100644 --- a/spec/features/interpolated_label_spec.cr +++ b/spec/features/interpolated_label_spec.cr @@ -8,7 +8,7 @@ Spectator.describe "Interpolated Label" do expect(example.name).to eq("interpolates example labels") end - context "within a \#{bar}" do + context "within a #{bar}" do let(foo) { "multiple" } it "interpolates context labels" do |example| diff --git a/src/spectator/context.cr b/src/spectator/context.cr index 7fc126b..b9f532a 100644 --- a/src/spectator/context.cr +++ b/src/spectator/context.cr @@ -4,6 +4,11 @@ # This type is intentionally outside the `Spectator` module. # The reason for this is to prevent name collision when using the DSL to define a spec. abstract class SpectatorContext + # Evaluates the contents of a block within the scope of the context. + def eval + with self yield + end + # Produces a dummy string to represent the context as a string. # This prevents the default behavior, which normally stringifies instance variables. # Due to the sheer amount of types Spectator can create diff --git a/src/spectator/dsl/groups.cr b/src/spectator/dsl/groups.cr index 0e7b47b..f595791 100644 --- a/src/spectator/dsl/groups.cr +++ b/src/spectator/dsl/groups.cr @@ -137,7 +137,7 @@ module Spectator::DSL what.is_a?(NilLiteral) %} {{what}} {% elsif what.is_a?(StringInterpolation) %} - {% raise "String interpolation isn't supported for example group names" %} + {{@type.name}}.new.eval { {{what}} } {% else %} {{what.stringify}} {% end %} From d46698d81a444d192633bd599beb2e8bf07c53bb Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 20 Dec 2022 20:43:47 -0700 Subject: [PATCH 092/120] Use separate context for example name interpolation This simplifies some code. --- CHANGELOG.md | 1 + src/spectator/dsl/examples.cr | 4 +--- src/spectator/example.cr | 24 ------------------------ src/spectator/example_builder.cr | 11 +---------- 4 files changed, 3 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7b645..dfbe8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start. +- String interpolation for example names/labels uses a separate context than the one used by the test. ## [0.11.5] - 2022-12-18 ### Added diff --git a/src/spectator/dsl/examples.cr b/src/spectator/dsl/examples.cr index 703f2d3..4524001 100644 --- a/src/spectator/dsl/examples.cr +++ b/src/spectator/dsl/examples.cr @@ -125,9 +125,7 @@ module Spectator::DSL {% if what.is_a?(StringLiteral) || what.is_a?(NilLiteral) %} {{what}} {% elsif what.is_a?(StringInterpolation) %} - ->(example : ::Spectator::Example) do - example.with_context(\{{@type.name}}) { {{what}} } - end + {{@type.name}}.new.eval { {{what}} } {% else %} {{what.stringify}} {% end %} diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 04d69c9..0c20ccb 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -28,8 +28,6 @@ module Spectator # Is pending if the example hasn't run. getter result : Result = PendingResult.new("Example not run") - @name_proc : Proc(Example, String)? - # Creates the example. # An instance to run the test code in is given by *context*. # The *entrypoint* defines the test code (typically inside *context*). @@ -47,24 +45,6 @@ module Spectator group << self if group end - # Creates the example. - # An instance to run the test code in is given by *context*. - # The *entrypoint* defines the test code (typically inside *context*). - # The *name* describes the purpose of the example. - # It can be a proc to be evaluated in the context of the example. - # The *location* tracks where the example exists in source code. - # The example will be assigned to *group* if it is provided. - # A set of *metadata* can be used for filtering and modifying example behavior. - # Note: The metadata will not be merged with the parent metadata. - def initialize(@context : Context, @entrypoint : self ->, - @name_proc : Example -> String, location : Location? = nil, - @group : ExampleGroup? = nil, metadata = nil) - super(nil, location, metadata) - - # Ensure group is linked. - group << self if group - end - # Creates a dynamic example. # A block provided to this method will be called as-if it were the test code for the example. # The block will be given this example instance as an argument. @@ -118,10 +98,6 @@ module Spectator begin @result = Harness.run do - if proc = @name_proc - self.name = proc.call(self) - end - @group.try(&.call_before_all) if (parent = @group) parent.call_around_each(procsy).call diff --git a/src/spectator/example_builder.cr b/src/spectator/example_builder.cr index 23398d2..ae5866a 100644 --- a/src/spectator/example_builder.cr +++ b/src/spectator/example_builder.cr @@ -8,7 +8,7 @@ module Spectator # Constructs examples. # Call `#build` to produce an `Example`. class ExampleBuilder < NodeBuilder - @name : Proc(Example, String) | String? + @name : String? # Creates the builder. # A proc provided by *context_builder* is used to create a unique `Context` for each example produced by `#build`. @@ -18,15 +18,6 @@ module Spectator @name : String? = nil, @location : Location? = nil, @metadata : Metadata? = nil) end - # Creates the builder. - # A proc provided by *context_builder* is used to create a unique `Context` for each example produced by `#build`. - # The *entrypoint* indicates the proc used to invoke the test code in the example. - # The *name* is an interpolated string that runs in the context of the example. - # *location*, and *metadata* will be applied to the `Example` produced by `#build`. - def initialize(@context_builder : -> Context, @entrypoint : Example ->, - @name : Example -> String, @location : Location? = nil, @metadata : Metadata? = nil) - end - # Constructs an example with previously defined attributes and context. # The *parent* is an already constructed example group to nest the new example under. # It can be nil if the new example won't have a parent. From 4a0bfc1cb255002ecb0a92dddd8e9eb32e00e697 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 20 Dec 2022 20:52:01 -0700 Subject: [PATCH 093/120] Add smoke tag --- spec/features/interpolated_label_spec.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/interpolated_label_spec.cr b/spec/features/interpolated_label_spec.cr index 5506a1f..add8e93 100644 --- a/spec/features/interpolated_label_spec.cr +++ b/spec/features/interpolated_label_spec.cr @@ -1,6 +1,6 @@ require "../spec_helper" -Spectator.describe "Interpolated Label" do +Spectator.describe "Interpolated Label", :smoke do let(foo) { "example" } let(bar) { "context" } From 6a5e5b8f7a8d29d2688413cd3990df739b6b2de6 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 20 Dec 2022 21:40:47 -0700 Subject: [PATCH 094/120] Catch errors while evaluating node labels --- src/spectator/dsl/examples.cr | 6 +++++- src/spectator/dsl/groups.cr | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/spectator/dsl/examples.cr b/src/spectator/dsl/examples.cr index 4524001..8a08116 100644 --- a/src/spectator/dsl/examples.cr +++ b/src/spectator/dsl/examples.cr @@ -125,7 +125,11 @@ module Spectator::DSL {% if what.is_a?(StringLiteral) || what.is_a?(NilLiteral) %} {{what}} {% elsif what.is_a?(StringInterpolation) %} - {{@type.name}}.new.eval { {{what}} } + {{@type.name}}.new.eval do + {{what}} + rescue e + "" + end {% else %} {{what.stringify}} {% end %} diff --git a/src/spectator/dsl/groups.cr b/src/spectator/dsl/groups.cr index f595791..da06906 100644 --- a/src/spectator/dsl/groups.cr +++ b/src/spectator/dsl/groups.cr @@ -137,7 +137,11 @@ module Spectator::DSL what.is_a?(NilLiteral) %} {{what}} {% elsif what.is_a?(StringInterpolation) %} - {{@type.name}}.new.eval { {{what}} } + {{@type.name}}.new.eval do + {{what}} + rescue e + "" + end {% else %} {{what.stringify}} {% end %} From fd372226ab7db998d321368d7c00a5dc4dc072c3 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 21 Dec 2022 18:51:09 -0700 Subject: [PATCH 095/120] Revert "Use separate context for example name interpolation" This reverts commit d46698d81a444d192633bd599beb2e8bf07c53bb. --- CHANGELOG.md | 1 - src/spectator/dsl/examples.cr | 6 ++---- src/spectator/example.cr | 24 ++++++++++++++++++++++++ src/spectator/example_builder.cr | 11 ++++++++++- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbe8c2..fb7b645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start. -- String interpolation for example names/labels uses a separate context than the one used by the test. ## [0.11.5] - 2022-12-18 ### Added diff --git a/src/spectator/dsl/examples.cr b/src/spectator/dsl/examples.cr index 8a08116..703f2d3 100644 --- a/src/spectator/dsl/examples.cr +++ b/src/spectator/dsl/examples.cr @@ -125,10 +125,8 @@ module Spectator::DSL {% if what.is_a?(StringLiteral) || what.is_a?(NilLiteral) %} {{what}} {% elsif what.is_a?(StringInterpolation) %} - {{@type.name}}.new.eval do - {{what}} - rescue e - "" + ->(example : ::Spectator::Example) do + example.with_context(\{{@type.name}}) { {{what}} } end {% else %} {{what.stringify}} diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 0c20ccb..04d69c9 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -28,6 +28,8 @@ module Spectator # Is pending if the example hasn't run. getter result : Result = PendingResult.new("Example not run") + @name_proc : Proc(Example, String)? + # Creates the example. # An instance to run the test code in is given by *context*. # The *entrypoint* defines the test code (typically inside *context*). @@ -45,6 +47,24 @@ module Spectator group << self if group end + # Creates the example. + # An instance to run the test code in is given by *context*. + # The *entrypoint* defines the test code (typically inside *context*). + # The *name* describes the purpose of the example. + # It can be a proc to be evaluated in the context of the example. + # The *location* tracks where the example exists in source code. + # The example will be assigned to *group* if it is provided. + # A set of *metadata* can be used for filtering and modifying example behavior. + # Note: The metadata will not be merged with the parent metadata. + def initialize(@context : Context, @entrypoint : self ->, + @name_proc : Example -> String, location : Location? = nil, + @group : ExampleGroup? = nil, metadata = nil) + super(nil, location, metadata) + + # Ensure group is linked. + group << self if group + end + # Creates a dynamic example. # A block provided to this method will be called as-if it were the test code for the example. # The block will be given this example instance as an argument. @@ -98,6 +118,10 @@ module Spectator begin @result = Harness.run do + if proc = @name_proc + self.name = proc.call(self) + end + @group.try(&.call_before_all) if (parent = @group) parent.call_around_each(procsy).call diff --git a/src/spectator/example_builder.cr b/src/spectator/example_builder.cr index ae5866a..23398d2 100644 --- a/src/spectator/example_builder.cr +++ b/src/spectator/example_builder.cr @@ -8,7 +8,7 @@ module Spectator # Constructs examples. # Call `#build` to produce an `Example`. class ExampleBuilder < NodeBuilder - @name : String? + @name : Proc(Example, String) | String? # Creates the builder. # A proc provided by *context_builder* is used to create a unique `Context` for each example produced by `#build`. @@ -18,6 +18,15 @@ module Spectator @name : String? = nil, @location : Location? = nil, @metadata : Metadata? = nil) end + # Creates the builder. + # A proc provided by *context_builder* is used to create a unique `Context` for each example produced by `#build`. + # The *entrypoint* indicates the proc used to invoke the test code in the example. + # The *name* is an interpolated string that runs in the context of the example. + # *location*, and *metadata* will be applied to the `Example` produced by `#build`. + def initialize(@context_builder : -> Context, @entrypoint : Example ->, + @name : Example -> String, @location : Location? = nil, @metadata : Metadata? = nil) + end + # Constructs an example with previously defined attributes and context. # The *parent* is an already constructed example group to nest the new example under. # It can be nil if the new example won't have a parent. From abbd6ffd71973a7ce7bdea24218a9fb01974a9d2 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 23 Jan 2023 11:55:52 -0700 Subject: [PATCH 096/120] Fix splat argument expansion in method redefinition The constructed previous_def call was malformed for stub methods. Resolves the original issue in https://github.com/icy-arctic-fox/spectator/issues/49 --- spec/issues/github_issue_49_spec.cr | 6 ++++++ src/spectator/mocks/stubbable.cr | 8 +++----- 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 spec/issues/github_issue_49_spec.cr diff --git a/spec/issues/github_issue_49_spec.cr b/spec/issues/github_issue_49_spec.cr new file mode 100644 index 0000000..d6a3417 --- /dev/null +++ b/spec/issues/github_issue_49_spec.cr @@ -0,0 +1,6 @@ +require "../spec_helper" + +# https://github.com/icy-arctic-fox/spectator/issues/49 +Spectator.describe "GitHub Issue #49" do + mock File +end diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 119111d..3a14887 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -133,13 +133,12 @@ module Spectator if method.splat_index method.args.each_with_index do |arg, i| if i == method.splat_index - original += '*' if arg.internal_name && arg.internal_name.size > 0 - original += "#{arg.internal_name}, " + original += "*#{arg.internal_name}, " end original += "**#{method.double_splat}, " if method.double_splat elsif i > method.splat_index - original += "#{arg.name}: #{arg.internal_name}" + original += "#{arg.name}: #{arg.internal_name}, " else original += "#{arg.internal_name}, " end @@ -283,9 +282,8 @@ module Spectator if method.splat_index method.args.each_with_index do |arg, i| if i == method.splat_index - original += '*' if arg.internal_name && arg.internal_name.size > 0 - original += "#{arg.internal_name}, " + original += "*#{arg.internal_name}, " end original += "**#{method.double_splat}, " if method.double_splat elsif i > method.splat_index From a5e8f11e1188f2ec8acfbd7d1fba5a83833828ec Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 23 Jan 2023 16:02:30 -0700 Subject: [PATCH 097/120] Store type to reduce a bit of bloat --- src/spectator/mocks/stubbable.cr | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 3a14887..3385ad4 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -537,6 +537,7 @@ module Spectator # Get the value as-is from the stub. # This will be compiled as a union of all known stubbed value types. %value = {{stub}}.call({{call}}) + %type = {{type}} # Attempt to cast the value to the method's return type. # If successful, which it will be in most cases, return it. @@ -547,12 +548,12 @@ module Spectator %cast {% elsif fail_cast == :raise %} # Check if nil was returned by the stub and if its okay to return it. - if %value.nil? && {{type}}.nilable? + if %value.nil? && %type.nilable? # Value was nil and nil is allowed to be returned. - %cast.as({{type}}) + %type.cast(%cast) elsif %cast.nil? # The stubbed value was something else entirely and cannot be cast to the return type. - raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{ {{type}} }`.") + raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{%type}`.") else # Types match and value can be returned as cast type. %cast From cb89589155cedc89dd9faeeca864e97266284edd Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Wed, 25 Jan 2023 16:09:16 -0700 Subject: [PATCH 098/120] Compiler bug when using unsafe_as --- src/spectator/mocks/stubbable.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 3385ad4..6c5f7ad 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -550,7 +550,7 @@ module Spectator # Check if nil was returned by the stub and if its okay to return it. if %value.nil? && %type.nilable? # Value was nil and nil is allowed to be returned. - %type.cast(%cast) + %cast.unsafe_as({{type}}) elsif %cast.nil? # The stubbed value was something else entirely and cannot be cast to the return type. raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{%type}`.") From 7149ef7df572eddd3037e0e2a0ee922a66332ec5 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 26 Jan 2023 16:12:54 -0700 Subject: [PATCH 099/120] Revert "Compiler bug when using unsafe_as" This reverts commit cb89589155cedc89dd9faeeca864e97266284edd. --- src/spectator/mocks/stubbable.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index 6c5f7ad..3385ad4 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -550,7 +550,7 @@ module Spectator # Check if nil was returned by the stub and if its okay to return it. if %value.nil? && %type.nilable? # Value was nil and nil is allowed to be returned. - %cast.unsafe_as({{type}}) + %type.cast(%cast) elsif %cast.nil? # The stubbed value was something else entirely and cannot be cast to the return type. raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{%type}`.") From 528ad7257da3f01c164df619f210a8336a556ad7 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 26 Jan 2023 16:17:29 -0700 Subject: [PATCH 100/120] Disable GitHub issue 49 spec for now --- spec/issues/github_issue_49_spec.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/issues/github_issue_49_spec.cr b/spec/issues/github_issue_49_spec.cr index d6a3417..6161a57 100644 --- a/spec/issues/github_issue_49_spec.cr +++ b/spec/issues/github_issue_49_spec.cr @@ -2,5 +2,5 @@ require "../spec_helper" # https://github.com/icy-arctic-fox/spectator/issues/49 Spectator.describe "GitHub Issue #49" do - mock File + # mock File end From 24a860ea1161eff12d81cc7d5cf0c24044ac4bfe Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 26 Jan 2023 16:18:26 -0700 Subject: [PATCH 101/120] Add reference to new issue https://github.com/icy-arctic-fox/spectator/issues/51 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7b645..fe4331d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for string interpolation in context names/labels. ### Fixed -- Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. +- Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. [#51](https://github.com/icy-arctic-fox/spectator/issues/51) ### Changed - Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start. From 9ea5c261b15f4bb526aa5e1e3d1f93125f2e3168 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 26 Jan 2023 16:19:55 -0700 Subject: [PATCH 102/120] Add entry for GitHub issue 49 https://github.com/icy-arctic-fox/spectator/issues/49 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4331d..8d93c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. [#51](https://github.com/icy-arctic-fox/spectator/issues/51) +- Fix malformed method signature when using named splat with keyword arguments in mocked type. [#49](https://github.com/icy-arctic-fox/spectator/issues/49) ### Changed - Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start. From 735122a94ba9a86a7957df9e0a1b7b8cf697b32b Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 26 Jan 2023 16:21:33 -0700 Subject: [PATCH 103/120] Bump v0.11.6 --- CHANGELOG.md | 5 ++++- shard.yml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d93c8c..ffd70d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [0.11.6] - 2023-01-26 ### Added - Added ability to cast types using the return value from expect/should statements with a type matcher. - Added support for string interpolation in context names/labels. @@ -445,7 +447,8 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.6...master +[0.11.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...v0.11.6 [0.11.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...v0.11.5 [0.11.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 [0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...v0.11.3 diff --git a/shard.yml b/shard.yml index a41bc90..06ba936 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.11.5 +version: 0.11.6 description: | Feature-rich testing framework for Crystal inspired by RSpec. From 5c08427ca0440193fc304c3405f8f56c079a0773 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 26 Jan 2023 16:43:19 -0700 Subject: [PATCH 104/120] Add utility script to run nightly spec --- util/nightly.sh | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100755 util/nightly.sh diff --git a/util/nightly.sh b/util/nightly.sh new file mode 100755 index 0000000..460a839 --- /dev/null +++ b/util/nightly.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -e + +readonly image=crystallang/crystal:nightly +readonly code=/project + +docker run -it -v "$PWD:${code}" -w "${code}" "${image}" crystal spec "$@" From 726a2e1515f437027c20a61e90e423223a6961ae Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Thu, 26 Jan 2023 17:19:31 -0700 Subject: [PATCH 105/120] Add non-captured block argument Preparing for Crystal 1.8.0 https://github.com/crystal-lang/crystal/issues/8764 --- CHANGELOG.md | 1 + spec/spectator/dsl/mocks/double_spec.cr | 2 +- spec/spectator/dsl/mocks/mock_spec.cr | 44 ++++++++++---------- spec/spectator/dsl/mocks/null_double_spec.cr | 2 +- spec/spectator/mocks/double_spec.cr | 2 +- spec/spectator/mocks/mock_spec.cr | 4 +- spec/spectator/mocks/null_double_spec.cr | 2 +- src/spectator/context.cr | 2 +- src/spectator/dsl/expectations.cr | 2 +- src/spectator/error_result.cr | 2 +- src/spectator/example.cr | 6 +-- src/spectator/example_group.cr | 2 +- src/spectator/fail_result.cr | 2 +- src/spectator/formatting/components/block.cr | 4 +- src/spectator/harness.cr | 12 +++--- src/spectator/matchers/exception_matcher.cr | 2 +- src/spectator/pass_result.cr | 2 +- src/spectator/pending_result.cr | 2 +- 18 files changed, 48 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd70d3..17b7220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start. +- Add non-captured block argument in preparation for Crystal 1.8.0. ## [0.11.5] - 2022-12-18 ### Added diff --git a/spec/spectator/dsl/mocks/double_spec.cr b/spec/spectator/dsl/mocks/double_spec.cr index 89f652c..5547ae0 100644 --- a/spec/spectator/dsl/mocks/double_spec.cr +++ b/spec/spectator/dsl/mocks/double_spec.cr @@ -168,7 +168,7 @@ Spectator.describe "Double DSL", :smoke do context "methods accepting blocks" do double(:test7) do - stub def foo + stub def foo(&) yield end diff --git a/spec/spectator/dsl/mocks/mock_spec.cr b/spec/spectator/dsl/mocks/mock_spec.cr index 05ef4c9..cd57cdc 100644 --- a/spec/spectator/dsl/mocks/mock_spec.cr +++ b/spec/spectator/dsl/mocks/mock_spec.cr @@ -40,17 +40,17 @@ Spectator.describe "Mock DSL", :smoke do arg end - def method4 : Symbol + def method4(&) : Symbol @_spectator_invocations << :method4 yield end - def method5 + def method5(&) @_spectator_invocations << :method5 yield.to_i end - def method6 + def method6(&) @_spectator_invocations << :method6 yield end @@ -60,7 +60,7 @@ Spectator.describe "Mock DSL", :smoke do {arg, args, kwarg, kwargs} end - def method8(arg, *args, kwarg, **kwargs) + def method8(arg, *args, kwarg, **kwargs, &) @_spectator_invocations << :method8 yield {arg, args, kwarg, kwargs} @@ -80,7 +80,7 @@ Spectator.describe "Mock DSL", :smoke do "stubbed" end - stub def method4 : Symbol + stub def method4(&) : Symbol yield :block end @@ -258,12 +258,12 @@ Spectator.describe "Mock DSL", :smoke do # NOTE: Abstract methods that yield must have yield functionality defined in the method. # This requires that yielding methods have a default implementation. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition. - stub def method5 + stub def method5(&) yield end # NOTE: Another quirk where a default implementation must be provided because `&` is dropped. - stub def method6 : Symbol + stub def method6(&) : Symbol yield end @@ -381,12 +381,12 @@ Spectator.describe "Mock DSL", :smoke do # NOTE: Abstract methods that yield must have yield functionality defined in the method. # This requires that yielding methods have a default implementation. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition. - stub def method5 + stub def method5(&) yield end # NOTE: Another quirk where a default implementation must be provided because `&` is dropped. - stub def method6 : Symbol + stub def method6(&) : Symbol yield end end @@ -454,12 +454,12 @@ Spectator.describe "Mock DSL", :smoke do # NOTE: Abstract methods that yield must have yield functionality defined in the method. # This requires that yielding methods have a default implementation. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition. - stub def method5 + stub def method5(&) yield end # NOTE: Another quirk where a default implementation must be provided because `&` is dropped. - stub def method6 : Symbol + stub def method6(&) : Symbol yield end @@ -577,12 +577,12 @@ Spectator.describe "Mock DSL", :smoke do # NOTE: Abstract methods that yield must have yield functionality defined in the method. # This requires that yielding methods have a default implementation. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition. - stub def method5 + stub def method5(&) yield end # NOTE: Another quirk where a default implementation must be provided because `&` is dropped. - stub def method6 : Symbol + stub def method6(&) : Symbol yield end end @@ -620,11 +620,11 @@ Spectator.describe "Mock DSL", :smoke do :original end - def method3 + def method3(&) yield end - def method4 : Int32 + def method4(&) : Int32 yield.to_i end @@ -749,11 +749,11 @@ Spectator.describe "Mock DSL", :smoke do :original end - def method3 + def method3(&) yield end - def method4 : Int32 + def method4(&) : Int32 yield.to_i end @@ -1108,17 +1108,17 @@ Spectator.describe "Mock DSL", :smoke do arg end - def method4 : Symbol + def method4(&) : Symbol @_spectator_invocations << :method4 yield end - def method5 + def method5(&) @_spectator_invocations << :method5 yield.to_i end - def method6 + def method6(&) @_spectator_invocations << :method6 yield end @@ -1128,7 +1128,7 @@ Spectator.describe "Mock DSL", :smoke do {arg, args, kwarg, kwargs} end - def method8(arg, *args, kwarg, **kwargs) + def method8(arg, *args, kwarg, **kwargs, &) @_spectator_invocations << :method8 yield {arg, args, kwarg, kwargs} @@ -1148,7 +1148,7 @@ Spectator.describe "Mock DSL", :smoke do "stubbed" end - stub def method4 : Symbol + stub def method4(&) : Symbol yield :block end diff --git a/spec/spectator/dsl/mocks/null_double_spec.cr b/spec/spectator/dsl/mocks/null_double_spec.cr index 1219f50..06d35ee 100644 --- a/spec/spectator/dsl/mocks/null_double_spec.cr +++ b/spec/spectator/dsl/mocks/null_double_spec.cr @@ -156,7 +156,7 @@ Spectator.describe "Null double DSL" do context "methods accepting blocks" do double(:test7) do - stub def foo + stub def foo(&) yield end diff --git a/spec/spectator/mocks/double_spec.cr b/spec/spectator/mocks/double_spec.cr index 41bfad2..e55c549 100644 --- a/spec/spectator/mocks/double_spec.cr +++ b/spec/spectator/mocks/double_spec.cr @@ -297,7 +297,7 @@ Spectator.describe Spectator::Double do arg end - stub def self.baz(arg) + stub def self.baz(arg, &) yield end end diff --git a/spec/spectator/mocks/mock_spec.cr b/spec/spectator/mocks/mock_spec.cr index 7d04fed..3ddd0fe 100644 --- a/spec/spectator/mocks/mock_spec.cr +++ b/spec/spectator/mocks/mock_spec.cr @@ -364,7 +364,7 @@ Spectator.describe Spectator::Mock do arg end - def self.baz(arg) + def self.baz(arg, &) yield end @@ -929,7 +929,7 @@ Spectator.describe Spectator::Mock do arg end - def self.baz(arg) + def self.baz(arg, &) yield end end diff --git a/spec/spectator/mocks/null_double_spec.cr b/spec/spectator/mocks/null_double_spec.cr index ad87ea9..a6fc7d2 100644 --- a/spec/spectator/mocks/null_double_spec.cr +++ b/spec/spectator/mocks/null_double_spec.cr @@ -259,7 +259,7 @@ Spectator.describe Spectator::NullDouble do arg end - stub def self.baz(arg) + stub def self.baz(arg, &) yield end end diff --git a/src/spectator/context.cr b/src/spectator/context.cr index b9f532a..15b9335 100644 --- a/src/spectator/context.cr +++ b/src/spectator/context.cr @@ -5,7 +5,7 @@ # The reason for this is to prevent name collision when using the DSL to define a spec. abstract class SpectatorContext # Evaluates the contents of a block within the scope of the context. - def eval + def eval(&) with self yield end diff --git a/src/spectator/dsl/expectations.cr b/src/spectator/dsl/expectations.cr index a35a15c..dba2e9b 100644 --- a/src/spectator/dsl/expectations.cr +++ b/src/spectator/dsl/expectations.cr @@ -182,7 +182,7 @@ module Spectator::DSL # expect(false).to be_true # end # ``` - def aggregate_failures(label = nil) + def aggregate_failures(label = nil, &) ::Spectator::Harness.current.aggregate_failures(label) do yield end diff --git a/src/spectator/error_result.cr b/src/spectator/error_result.cr index a4531fb..f58da20 100644 --- a/src/spectator/error_result.cr +++ b/src/spectator/error_result.cr @@ -11,7 +11,7 @@ module Spectator end # Calls the `error` method on *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.error(yield self) end diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 04d69c9..e18676c 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -164,7 +164,7 @@ module Spectator # The context casted to an instance of *klass* is provided as a block argument. # # TODO: Benchmark compiler performance using this method versus client-side casting in a proc. - protected def with_context(klass) + protected def with_context(klass, &) context = klass.cast(@context) with context yield end @@ -184,7 +184,7 @@ module Spectator end # Yields this example and all parent groups. - def ascend + def ascend(&) node = self while node yield node @@ -279,7 +279,7 @@ module Spectator # The block given to this method will be executed within the test context. # # TODO: Benchmark compiler performance using this method versus client-side casting in a proc. - protected def with_context(klass) + protected def with_context(klass, &) context = @example.cast_context(klass) with context yield end diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr index 277be3c..55a3233 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -87,7 +87,7 @@ module Spectator delegate size, unsafe_fetch, to: @nodes # Yields this group and all parent groups. - def ascend + def ascend(&) group = self while group yield group diff --git a/src/spectator/fail_result.cr b/src/spectator/fail_result.cr index c283a80..082a0d2 100644 --- a/src/spectator/fail_result.cr +++ b/src/spectator/fail_result.cr @@ -24,7 +24,7 @@ module Spectator end # Calls the `failure` method on *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.fail(yield self) end diff --git a/src/spectator/formatting/components/block.cr b/src/spectator/formatting/components/block.cr index 40cd5a8..22411a7 100644 --- a/src/spectator/formatting/components/block.cr +++ b/src/spectator/formatting/components/block.cr @@ -13,7 +13,7 @@ module Spectator::Formatting::Components end # Increases the indent by the a specific *amount* for the duration of the block. - private def indent(amount = INDENT) + private def indent(amount = INDENT, &) @indent += amount yield @indent -= amount @@ -23,7 +23,7 @@ module Spectator::Formatting::Components # The contents of the line should be generated by a block provided to this method. # Ensure that _only_ one line is produced by the block, # otherwise the indent will be lost. - private def line(io) + private def line(io, &) @indent.times { io << ' ' } yield io.puts diff --git a/src/spectator/harness.cr b/src/spectator/harness.cr index 6be48b9..1f9fa09 100644 --- a/src/spectator/harness.cr +++ b/src/spectator/harness.cr @@ -43,7 +43,7 @@ module Spectator # The value of `.current` is set to the harness for the duration of the test. # It will be reset after the test regardless of the outcome. # The result of running the test code will be returned. - def self.run : Result + def self.run(&) : Result with_harness do |harness| harness.run { yield } end @@ -53,7 +53,7 @@ module Spectator # The `.current` harness is set to the new harness for the duration of the block. # `.current` is reset to the previous value (probably nil) afterwards, even if the block raises. # The result of the block is returned. - private def self.with_harness + private def self.with_harness(&) previous = @@current begin @@current = harness = new @@ -70,7 +70,7 @@ module Spectator # Runs test code and produces a result based on the outcome. # The test code should be called from within the block given to this method. - def run : Result + def run(&) : Result elapsed, error = capture { yield } elapsed2, error2 = capture { run_deferred } run_cleanup @@ -106,7 +106,7 @@ module Spectator @cleanup << block end - def aggregate_failures(label = nil) + def aggregate_failures(label = nil, &) previous = @aggregate @aggregate = aggregate = [] of Expectation begin @@ -135,7 +135,7 @@ module Spectator # Yields to run the test code and returns information about the outcome. # Returns a tuple with the elapsed time and an error if one occurred (otherwise nil). - private def capture : Tuple(Time::Span, Exception?) + private def capture(&) : Tuple(Time::Span, Exception?) error = nil elapsed = Time.measure do error = catch { yield } @@ -146,7 +146,7 @@ module Spectator # Yields to run a block of code and captures exceptions. # If the block of code raises an error, the error is caught and returned. # If the block doesn't raise an error, then nil is returned. - private def catch : Exception? + private def catch(&) : Exception? yield rescue e e diff --git a/src/spectator/matchers/exception_matcher.cr b/src/spectator/matchers/exception_matcher.cr index adec663..b26d390 100644 --- a/src/spectator/matchers/exception_matcher.cr +++ b/src/spectator/matchers/exception_matcher.cr @@ -97,7 +97,7 @@ module Spectator::Matchers # Runs a block of code and returns the exception it threw. # If no exception was thrown, *nil* is returned. - private def capture_exception + private def capture_exception(&) exception = nil begin yield diff --git a/src/spectator/pass_result.cr b/src/spectator/pass_result.cr index 20e3b04..21ed6c5 100644 --- a/src/spectator/pass_result.cr +++ b/src/spectator/pass_result.cr @@ -9,7 +9,7 @@ module Spectator end # Calls the `pass` method on *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.pass(yield self) end diff --git a/src/spectator/pending_result.cr b/src/spectator/pending_result.cr index cff38c5..57f7fd7 100644 --- a/src/spectator/pending_result.cr +++ b/src/spectator/pending_result.cr @@ -28,7 +28,7 @@ module Spectator end # Calls the `pending` method on the *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.pending(yield self) end From 9cbb5d2cf74410b5ac2f408a75b46be5708ee4fc Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 27 Mar 2023 18:37:50 -0600 Subject: [PATCH 106/120] Workaround issue using Box with union Addresses issue found relating to https://gitlab.com/arctic-fox/spectator/-/issues/81 See https://github.com/crystal-lang/crystal/issues/11839 --- CHANGELOG.md | 2 ++ src/spectator/wrapper.cr | 29 ++++++++++++++++------------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b7220..325b7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Fix memoized value (`let`) with a union type causing segfault. [#81](https://gitlab.com/arctic-fox/spectator/-/issues/81) ## [0.11.6] - 2023-01-26 ### Added diff --git a/src/spectator/wrapper.cr b/src/spectator/wrapper.cr index 9874dec..76f6f44 100644 --- a/src/spectator/wrapper.cr +++ b/src/spectator/wrapper.cr @@ -13,18 +13,13 @@ module Spectator # Creates a wrapper for the specified value. def initialize(value) - @pointer = Box.box(value) + @pointer = Value.new(value).as(Void*) end # Retrieves the previously wrapped value. # The *type* of the wrapped value must match otherwise an error will be raised. def get(type : T.class) : T forall T - {% begin %} - {% if T.nilable? %} - @pointer.null? ? nil : - {% end %} - Box(T).unbox(@pointer) - {% end %} + @pointer.unsafe_as(Value(T)).get end # Retrieves the previously wrapped value. @@ -39,12 +34,20 @@ module Spectator # type = wrapper.get { Int32 } # Returns Int32 # ``` def get(& : -> T) : T forall T - {% begin %} - {% if T.nilable? %} - @pointer.null? ? nil : - {% end %} - Box(T).unbox(@pointer) - {% end %} + @pointer.unsafe_as(Value(T)).get + end + + # Wrapper for a value. + # Similar to `Box`, but doesn't segfault on nil and unions. + private class Value(T) + # Creates the wrapper. + def initialize(@value : T) + end + + # Retrieves the value. + def get : T + @value + end end end end From 04f151fddf7ea1c6b2479eee8c8a22461bc0b9f0 Mon Sep 17 00:00:00 2001 From: Stuart Frost Date: Fri, 19 May 2023 19:39:22 +0100 Subject: [PATCH 107/120] Fix mocking example in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 973b9a9..323b4ca 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ Spectator.describe Driver do # Call the mock method. subject.do_something(interface, dbl) # Verify everything went okay. - expect(interface).to have_received(:invoke).with(thing) + expect(interface).to have_received(:invoke).with(dbl) end end ``` From 4a630b1ebf1ea4324b3c4f0230376783ce5a92a1 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Mon, 16 Oct 2023 17:34:49 -0600 Subject: [PATCH 108/120] Bump version to v0.11.7 --- CHANGELOG.md | 5 +++-- shard.yml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 325b7c5..4e23272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.11.7] - 2023-10-16 ### Fixed - Fix memoized value (`let`) with a union type causing segfault. [#81](https://gitlab.com/arctic-fox/spectator/-/issues/81) @@ -450,7 +450,8 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.6...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.7...master +[0.11.7]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.6...v0.11.7 [0.11.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...v0.11.6 [0.11.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...v0.11.5 [0.11.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 diff --git a/shard.yml b/shard.yml index 06ba936..78ab190 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.11.6 +version: 0.11.7 description: | Feature-rich testing framework for Crystal inspired by RSpec. From 5520999b6d7619985a93935faf546667f54289ad Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 27 Jan 2024 11:16:57 -0700 Subject: [PATCH 109/120] Add spec for GitHub issue 55 https://github.com/icy-arctic-fox/spectator/issues/55 --- spec/issues/github_issue_55_spec.cr | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 spec/issues/github_issue_55_spec.cr diff --git a/spec/issues/github_issue_55_spec.cr b/spec/issues/github_issue_55_spec.cr new file mode 100644 index 0000000..92c2b42 --- /dev/null +++ b/spec/issues/github_issue_55_spec.cr @@ -0,0 +1,48 @@ +require "../spec_helper" + +Spectator.describe "GitHub Issue #55" do + GROUP_NAME = "CallCenter" + + let(name) { "TimeTravel" } + let(source) { "my.time.travel.experiment" } + + class Analytics(T) + property start_time = Time.local + property end_time = Time.local + + def initialize(@brain_talker : T) + end + + def instrument(*, name, source, &) + @brain_talker.send(payload: { + :group => GROUP_NAME, + :name => name, + :source => source, + :start => start_time, + :end => end_time, + }, action: "analytics") + end + end + + double(:brain_talker, send: nil) + + let(brain_talker) { double(:brain_talker) } + let(analytics) { Analytics.new(brain_talker) } + + it "tracks the time it takes to run the block" do + analytics.start_time = expected_start_time = Time.local + expected_end_time = expected_start_time + 10.seconds + analytics.end_time = expected_end_time + 0.5.seconds # Offset to ensure non-exact match. + + analytics.instrument(name: name, source: source) do + end + + expect(brain_talker).to have_received(:send).with(payload: { + :group => GROUP_NAME, + :name => name, + :source => source, + :start => expected_start_time, + :end => be_within(1.second).of(expected_end_time), + }, action: "analytics") + end +end From b5fbc96195031cdcb2658dd0a324e4e73c0b6f9a Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 27 Jan 2024 11:17:19 -0700 Subject: [PATCH 110/120] Allow matchers to be used in case equality --- src/spectator/matchers/matcher.cr | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/spectator/matchers/matcher.cr b/src/spectator/matchers/matcher.cr index e54e55e..05adb81 100644 --- a/src/spectator/matchers/matcher.cr +++ b/src/spectator/matchers/matcher.cr @@ -1,3 +1,4 @@ +require "../value" require "./match_data" module Spectator::Matchers @@ -22,6 +23,19 @@ module Spectator::Matchers # A successful match with `#match` should normally fail for this method, and vice-versa. abstract def negated_match(actual : Expression(T)) : MatchData forall T + # Compares a matcher against a value. + # Enables composable matchers. + def ===(actual : Expression(T)) : Bool + match(actual).matched? + end + + # Compares a matcher against a value. + # Enables composable matchers. + def ===(other) : Bool + expression = Value.new(other) + match(expression).matched? + end + private def match_data_description(actual : Expression(T)) : String forall T match_data_description(actual.label) end From 556d4783bf3669cba6fab53db3ea42edba7981f4 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 27 Jan 2024 11:18:10 -0700 Subject: [PATCH 111/120] Support case equality of tuples, arrays, named tuples, and hashes in stub argument matching --- src/spectator/mocks/abstract_arguments.cr | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index 442c1d2..fd9bac9 100644 --- a/src/spectator/mocks/abstract_arguments.cr +++ b/src/spectator/mocks/abstract_arguments.cr @@ -7,7 +7,7 @@ module Spectator end # Utility method for comparing two tuples considering special types. - private def compare_tuples(a : Tuple, b : Tuple) + private def compare_tuples(a : Tuple | Array, b : Tuple | Array) return false if a.size != b.size a.zip(b) do |a_value, b_value| @@ -18,14 +18,14 @@ module Spectator # Utility method for comparing two tuples considering special types. # Supports nilable tuples (ideal for splats). - private def compare_tuples(a : Tuple?, b : Tuple?) + private def compare_tuples(a : Tuple? | Array?, b : Tuple? | Array?) return false if a.nil? ^ b.nil? compare_tuples(a.not_nil!, b.not_nil!) end # Utility method for comparing two named tuples ignoring order. - private def compare_named_tuples(a : NamedTuple, b : NamedTuple) + private def compare_named_tuples(a : NamedTuple | Hash, b : NamedTuple | Hash) a.each do |k, v1| v2 = b.fetch(k) { return false } return false unless compare_values(v1, v2) @@ -50,6 +50,18 @@ module Spectator else a == b end + when Tuple, Array + if b.is_a?(Tuple) || b.is_a?(Array) + compare_tuples(a, b) + else + a === b + end + when NamedTuple, Hash + if b.is_a?(NamedTuple) || b.is_a?(Hash) + compare_named_tuples(a, b) + else + a === b + end else a === b end From 526a998e4183e3d24621d7413d44eae91003e8be Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 27 Jan 2024 11:25:25 -0700 Subject: [PATCH 112/120] Shorten compare_values case statements --- src/spectator/mocks/abstract_arguments.cr | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index fd9bac9..4a6f75f 100644 --- a/src/spectator/mocks/abstract_arguments.cr +++ b/src/spectator/mocks/abstract_arguments.cr @@ -45,23 +45,14 @@ module Spectator when Range # Ranges can only be matched against if their right side is comparable. # Ensure the right side is comparable, otherwise compare directly. - if b.is_a?(Comparable(typeof(b))) - a === b - else - a == b - end + return a === b if b.is_a?(Comparable(typeof(b))) + a == b when Tuple, Array - if b.is_a?(Tuple) || b.is_a?(Array) - compare_tuples(a, b) - else - a === b - end + return compare_tuples(a, b) if b.is_a?(Tuple) || b.is_a?(Array) + a === b when NamedTuple, Hash - if b.is_a?(NamedTuple) || b.is_a?(Hash) - compare_named_tuples(a, b) - else - a === b - end + return compare_named_tuples(a, b) if b.is_a?(NamedTuple) || b.is_a?(Hash) + a === b else a === b end From edb20e5b2f2e57c4326bad7a0fca374342adc8c5 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 27 Jan 2024 11:25:59 -0700 Subject: [PATCH 113/120] Additional handling when comparing ranges against unexpected types --- src/spectator/matchers/range_matcher.cr | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/spectator/matchers/range_matcher.cr b/src/spectator/matchers/range_matcher.cr index 8c31810..8a9a307 100644 --- a/src/spectator/matchers/range_matcher.cr +++ b/src/spectator/matchers/range_matcher.cr @@ -29,7 +29,26 @@ module Spectator::Matchers # Checks whether the matcher is satisfied with the expression given to it. private def match?(actual : Expression(T)) : Bool forall T - expected.value.includes?(actual.value) + actual_value = actual.value + expected_value = expected.value + if expected_value.is_a?(Range) && actual_value.is_a?(Comparable) + return match_impl?(expected_value, actual_value) + end + return false unless actual_value.is_a?(Comparable(typeof(expected_value.begin))) + expected_value.includes?(actual_value) + end + + private def match_impl?(expected_value : Range(B, E), actual_value : Comparable(B)) : Bool forall B, E + expected_value.includes?(actual_value) + end + + private def match_impl?(expected_value : Range(B, E), actual_value : T) : Bool forall B, E, T + return false unless actual_value.is_a?(B) || actual_value.is_a?(Comparable(B)) + expected_value.includes?(actual_value) + end + + private def match_impl?(expected_value : Range(Number, Number), actual_value : Number) : Bool + expected_value.includes?(actual_value) end # Message displayed when the matcher isn't satisfied. From 9b1d400ee1f0edf0b979b0d113ea618b16e0deed Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 27 Jan 2024 11:29:11 -0700 Subject: [PATCH 114/120] Update CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e23272..7f7943a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- Added ability to use matchers for case equality. [#55](https://github.com/icy-arctic-fox/spectator/issues/55) +- Added support for nested case equality when checking arguments with Array, Tuple, Hash, and NamedTuple. + +### Fixed +- Fixed some issues with the `be_within` matcher when used with expected and union types. + ## [0.11.7] - 2023-10-16 ### Fixed - Fix memoized value (`let`) with a union type causing segfault. [#81](https://gitlab.com/arctic-fox/spectator/-/issues/81) From f39ceb8eba897cf634fee1922543e8a7e7125f02 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 3 Feb 2024 07:56:57 -0700 Subject: [PATCH 115/120] Release v0.12.0 --- CHANGELOG.md | 5 +++-- shard.yml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7943a..278c53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.12.0] - 2024-02-03 ### Added - Added ability to use matchers for case equality. [#55](https://github.com/icy-arctic-fox/spectator/issues/55) - Added support for nested case equality when checking arguments with Array, Tuple, Hash, and NamedTuple. @@ -458,7 +458,8 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.7...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.12.0...master +[0.12.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.7...v0.12.0 [0.11.7]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.6...v0.11.7 [0.11.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...v0.11.6 [0.11.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...v0.11.5 diff --git a/shard.yml b/shard.yml index 78ab190..559754f 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.11.7 +version: 0.12.0 description: | Feature-rich testing framework for Crystal inspired by RSpec. From 287758e6af784200a4a51a60f364211fbd1ff6c4 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 3 Feb 2024 08:05:02 -0700 Subject: [PATCH 116/120] Update README to point at v0.12.0 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 323b4ca..00b5eb9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Add this to your application's `shard.yml`: development_dependencies: spectator: gitlab: arctic-fox/spectator - version: ~> 0.11.0 + version: ~> 0.12.0 ``` Usage From 73e16862191d821951d72120110c7bdca1062b6a Mon Sep 17 00:00:00 2001 From: Grant Birkinbine Date: Sun, 11 Aug 2024 15:28:19 -0700 Subject: [PATCH 117/120] fix: `Error: expecting token 'CONST', not '::'` --- src/spectator/dsl/mocks.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index ab05636..18fe1fa 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -431,7 +431,7 @@ module Spectator::DSL # This isn't required, but new_mock() should still find this type. ::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, resolved.name.symbolize} %} - ::Spectator::Mock.inject({{base}}, ::{{resolved.name}}, {{**value_methods}}) {{block}} + ::Spectator::Mock.inject({{base}}, {{resolved.name}}, {{**value_methods}}) {{block}} end # Targets a stubbable object (such as a mock or double) for operations. From a634046a86c689835de09f4fb96a488d6657c25a Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 13 Aug 2024 18:42:22 -0600 Subject: [PATCH 118/120] Conditionally insert top-level namespace (double colon) --- src/spectator/dsl/mocks.cr | 4 ++-- src/spectator/mocks/mock.cr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index 18fe1fa..19645fc 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -226,7 +226,7 @@ module Spectator::DSL # Store information about how the mock is defined and its context. # This is important for constructing an instance of the mock later. - ::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, "::#{resolved.name}::#{mock_type_name}".id.symbolize} + ::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, "#{"::".id unless resolved.name.starts_with?("::")}#{resolved.name}::#{mock_type_name}".id.symbolize} base = if resolved.class? :class @@ -237,7 +237,7 @@ module Spectator::DSL end %} {% begin %} - {{base.id}} ::{{resolved.name}} + {{base.id}} {{"::".id unless resolved.name.starts_with?("::")}}{{resolved.name}} ::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{**value_methods}}) {{block}} end {% end %} diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index 87e8e23..d2a1fde 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -149,7 +149,7 @@ module Spectator macro inject(base, type_name, name = nil, **value_methods, &block) {% begin %} {% if name %}@[::Spectator::StubbedName({{name}})]{% end %} - {{base.id}} ::{{type_name.id}} + {{base.id}} {{"::".id unless type_name.id.starts_with?("::")}}{{type_name.id}} include ::Spectator::Mocked extend ::Spectator::StubbedType From a93908d507977db1314f71a709a88444bf49191b Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 13 Aug 2024 18:51:00 -0600 Subject: [PATCH 119/120] Fix usage of deprecated double splat syntax in macros --- src/spectator/dsl/mocks.cr | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index 19645fc..5eff1a9 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -31,7 +31,7 @@ module Spectator::DSL ::Spectator::DSL::Mocks::TYPES << {name.id.symbolize, @type.name(generic_args: false).symbolize, double_type_name.symbolize} %} # Define the plain double type. - ::Spectator::Double.define({{double_type_name}}, {{name}}, {{**value_methods}}) do + ::Spectator::Double.define({{double_type_name}}, {{name}}, {{value_methods.double_splat}}) do # Returns a new double that responds to undefined methods with itself. # See: `NullDouble` def as_null_object @@ -43,7 +43,7 @@ module Spectator::DSL {% begin %} # Define a matching null double type. - ::Spectator::NullDouble.define({{null_double_type_name}}, {{name}}, {{**value_methods}}) {{block}} + ::Spectator::NullDouble.define({{null_double_type_name}}, {{name}}, {{value_methods.double_splat}}) {{block}} {% end %} end @@ -94,9 +94,9 @@ module Spectator::DSL begin %double = {% if found_tuple %} - {{found_tuple[2].id}}.new({{**value_methods}}) + {{found_tuple[2].id}}.new({{value_methods.double_splat}}) {% else %} - ::Spectator::LazyDouble.new({{name}}, {{**value_methods}}) + ::Spectator::LazyDouble.new({{name}}, {{value_methods.double_splat}}) {% end %} ::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset }) %double @@ -176,7 +176,7 @@ module Spectator::DSL # See `#def_double`. macro double(name, **value_methods, &block) {% begin %} - {% if @def %}new_double{% else %}def_double{% end %}({{name}}, {{**value_methods}}) {{block}} + {% if @def %}new_double{% else %}def_double{% end %}({{name}}, {{value_methods.double_splat}}) {{block}} {% end %} end @@ -189,7 +189,7 @@ module Spectator::DSL # expect(dbl.foo).to eq(42) # ``` macro double(**value_methods) - ::Spectator::LazyDouble.new({{**value_methods}}) + ::Spectator::LazyDouble.new({{value_methods.double_splat}}) end # Defines a new mock type. @@ -238,7 +238,7 @@ module Spectator::DSL {% begin %} {{base.id}} {{"::".id unless resolved.name.starts_with?("::")}}{{resolved.name}} - ::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{**value_methods}}) {{block}} + ::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{value_methods.double_splat}}) {{block}} end {% end %} end @@ -321,7 +321,7 @@ module Spectator::DSL macro mock(type, **value_methods, &block) {% raise "First argument of `mock` must be a type name, not #{type}" unless type.is_a?(Path) || type.is_a?(Generic) || type.is_a?(Union) || type.is_a?(Metaclass) || type.is_a?(TypeNode) %} {% begin %} - {% if @def %}new_mock{% else %}def_mock{% end %}({{type}}, {{**value_methods}}) {{block}} + {% if @def %}new_mock{% else %}def_mock{% end %}({{type}}, {{value_methods.double_splat}}) {{block}} {% end %} end @@ -431,7 +431,7 @@ module Spectator::DSL # This isn't required, but new_mock() should still find this type. ::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, resolved.name.symbolize} %} - ::Spectator::Mock.inject({{base}}, {{resolved.name}}, {{**value_methods}}) {{block}} + ::Spectator::Mock.inject({{base}}, {{resolved.name}}, {{value_methods.double_splat}}) {{block}} end # Targets a stubbable object (such as a mock or double) for operations. From dcaa05531a3bba4855b8801b6a30bbd6c4e85cbc Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Tue, 13 Aug 2024 19:00:30 -0600 Subject: [PATCH 120/120] Release v0.12.1 --- CHANGELOG.md | 8 +++++++- shard.yml | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 278c53c..f2d8d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.1] - 2024-08-13 +### Fixed +- Fixed some global namespace issues with Crystal 1.13. [#57](https://github.com/icy-arctic-fox/spectator/pull/57) Thanks @GrantBirki ! +- Remove usage of deprecated double splat in macros. + ## [0.12.0] - 2024-02-03 ### Added - Added ability to use matchers for case equality. [#55](https://github.com/icy-arctic-fox/spectator/issues/55) @@ -458,7 +463,8 @@ This has been changed so that it compiles and raises an error at runtime with a First version ready for public use. -[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.12.0...master +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.12.1...master +[0.12.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.12.0...v0.12.1 [0.12.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.7...v0.12.0 [0.11.7]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.6...v0.11.7 [0.11.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...v0.11.6 diff --git a/shard.yml b/shard.yml index 559754f..ac8b54f 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.12.0 +version: 0.12.1 description: | Feature-rich testing framework for Crystal inspired by RSpec.