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.