diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b7220..f2d8d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,22 @@ 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.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) +- 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) ## [0.11.6] - 2023-01-26 ### Added @@ -448,7 +463,10 @@ 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.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 [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/README.md b/README.md index 973b9a9..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 @@ -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 ``` diff --git a/shard.yml b/shard.yml index 06ba936..ac8b54f 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.11.6 +version: 0.12.1 description: | Feature-rich testing framework for Crystal inspired by RSpec. 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 diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index ab05636..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. @@ -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,8 +237,8 @@ module Spectator::DSL end %} {% begin %} - {{base.id}} ::{{resolved.name}} - ::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{**value_methods}}) {{block}} + {{base.id}} {{"::".id unless resolved.name.starts_with?("::")}}{{resolved.name}} + ::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. 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 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. diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index 442c1d2..4a6f75f 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) @@ -45,11 +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 + return compare_tuples(a, b) if b.is_a?(Tuple) || b.is_a?(Array) + a === b + when NamedTuple, Hash + return compare_named_tuples(a, b) if b.is_a?(NamedTuple) || b.is_a?(Hash) + a === b else a === b 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 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