diff --git a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs index 2e7891ae62..ad61c62f9c 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs +++ b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs @@ -1347,6 +1347,38 @@ void M() return VerifyGeneratorOutput(source); } + [Test] + public Task SelfEquatable_Generates_EqualsOf_GetHashCodeOf_ToStringOf() + { + // Regression for #5675: self-referential IEquatable together with + // overrides of GetHashCode/ToString must produce disambiguated extension + // helpers (EqualsOf / GetHashCodeOf / ToStringOf) on the generated mock, + // because C# overload resolution always prefers the inherited object + // instance methods over an extension named the same. + var source = """ + using System; + using TUnit.Mocks; + + public class SelfEquatableSnapshot : IEquatable + { + public virtual bool Equals(SelfEquatableSnapshot? other) => ReferenceEquals(this, other); + public override bool Equals(object? obj) => obj is SelfEquatableSnapshot s && Equals(s); + public override int GetHashCode() => 0; + public override string ToString() => "base"; + } + + public class TestUsage + { + void M() + { + var mock = Mock.Of(); + } + } + """; + + return VerifyGeneratorOutput(source); + } + [Test] public Task Interface_Inheriting_Nested_Generic_IEnumerable() { diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/SelfEquatable_Generates_EqualsOf_GetHashCodeOf_ToStringOf.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/SelfEquatable_Generates_EqualsOf_GetHashCodeOf_ToStringOf.verified.txt new file mode 100644 index 0000000000..72e6bbdcba --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/SelfEquatable_Generates_EqualsOf_GetHashCodeOf_ToStringOf.verified.txt @@ -0,0 +1,336 @@ +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + file sealed class SelfEquatableSnapshotMockImpl : global::SelfEquatableSnapshot, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject + { + private readonly global::TUnit.Mocks.MockEngine _engine; + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + global::TUnit.Mocks.IMock? global::TUnit.Mocks.IMockObject.MockWrapper { get; set; } + + internal SelfEquatableSnapshotMockImpl(global::TUnit.Mocks.MockEngine engine) : base() + { + _engine = engine; + } + + public override bool Equals(global::SelfEquatableSnapshot? other) + { + if (_engine.TryHandleCallWithReturn(0, "Equals", other, default, out var __result)) + { + return __result; + } + return base.Equals(other); + } + + public override bool Equals(object? obj) + { + if (_engine.TryHandleCallWithReturn(1, "Equals", obj, default, out var __result)) + { + return __result; + } + return base.Equals(obj); + } + + public override int GetHashCode() + { + if (_engine.TryHandleCallWithReturn(2, "GetHashCode", global::System.Array.Empty(), default, out var __result)) + { + return __result; + } + return base.GetHashCode(); + } + + public override string ToString() + { + if (_engine.TryHandleCallWithReturn(3, "ToString", global::System.Array.Empty(), "", out var __result)) + { + return __result; + } + return base.ToString(); + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public void RaiseEvent(string eventName, object? args) + { + throw new global::System.InvalidOperationException($"No event named '{eventName}' exists on this mock."); + } + } + + file static class SelfEquatableSnapshotPartialMockFactory + { + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterFactory(Create); + } + + private static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new SelfEquatableSnapshotMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + public static class SelfEquatableSnapshot_MockMemberExtensions + { + public static SelfEquatableSnapshot_Equals_M0_MockCall EqualsOf(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg other) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { other.Matcher }; + return new SelfEquatableSnapshot_Equals_M0_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Equals", matchers); + } + + public static SelfEquatableSnapshot_Equals_M0_MockCall EqualsOf(this global::TUnit.Mocks.Mock mock, global::System.Func other) + { + global::TUnit.Mocks.Arguments.Arg __fa_other = other; + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { __fa_other.Matcher }; + return new SelfEquatableSnapshot_Equals_M0_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Equals", matchers); + } + + public static SelfEquatableSnapshot_Equals_M1_MockCall EqualsOf(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg obj) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { obj.Matcher }; + return new SelfEquatableSnapshot_Equals_M1_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "Equals", matchers); + } + + public static SelfEquatableSnapshot_Equals_M1_MockCall EqualsOf(this global::TUnit.Mocks.Mock mock, global::System.Func obj) + { + global::TUnit.Mocks.Arguments.Arg __fa_obj = obj; + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { __fa_obj.Matcher }; + return new SelfEquatableSnapshot_Equals_M1_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "Equals", matchers); + } + + public static global::TUnit.Mocks.MockMethodCall GetHashCodeOf(this global::TUnit.Mocks.Mock mock) + { + var matchers = global::System.Array.Empty(); + return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 2, "GetHashCode", matchers); + } + + public static global::TUnit.Mocks.MockMethodCall ToStringOf(this global::TUnit.Mocks.Mock mock) + { + var matchers = global::System.Array.Empty(); + return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 3, "ToString", matchers); + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public sealed class SelfEquatableSnapshot_Equals_M0_MockCall : global::TUnit.Mocks.Verification.ICallVerification + { + private readonly global::TUnit.Mocks.IMockEngineAccess _engine; + private readonly int _memberId; + private readonly string _memberName; + private readonly global::TUnit.Mocks.Arguments.IArgumentMatcher[] _matchers; + private global::TUnit.Mocks.Setup.MethodSetupBuilder? _builder; + + internal SelfEquatableSnapshot_Equals_M0_MockCall(global::TUnit.Mocks.IMockEngineAccess engine, int memberId, string memberName, global::TUnit.Mocks.Arguments.IArgumentMatcher[] matchers) + { + _engine = engine; + _memberId = memberId; + _memberName = memberName; + _matchers = matchers; + } + + private global::TUnit.Mocks.Setup.MethodSetupBuilder EnsureSetup() + { + var existing = global::System.Threading.Volatile.Read(ref _builder); + if (existing is not null) return existing; + return EnsureSetupSlow(); + } + + [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private global::TUnit.Mocks.Setup.MethodSetupBuilder EnsureSetupSlow() + { + var setup = new global::TUnit.Mocks.Setup.MethodSetup(_memberId, _matchers, _memberName); + var fresh = new global::TUnit.Mocks.Setup.MethodSetupBuilder(setup); + var prev = global::System.Threading.Interlocked.CompareExchange(ref _builder, fresh, null); + if (prev is not null) return prev; + // AddSetup runs only on the CAS winner. Setup is sequential in practice, + // so a concurrent loser observing the builder before registration is benign. + _engine.AddSetup(setup); + return fresh; + } + + /// + public SelfEquatableSnapshot_Equals_M0_MockCall Returns(bool value) { EnsureSetup().Returns(value); return this; } + /// + public SelfEquatableSnapshot_Equals_M0_MockCall Returns(global::System.Func factory) { EnsureSetup().Returns(factory); return this; } + /// + public SelfEquatableSnapshot_Equals_M0_MockCall ReturnsSequentially(params bool[] values) { EnsureSetup().ReturnsSequentially(values); return this; } + /// + public SelfEquatableSnapshot_Equals_M0_MockCall Throws() where TException : global::System.Exception, new() { EnsureSetup().Throws(); return this; } + /// + public SelfEquatableSnapshot_Equals_M0_MockCall Throws(global::System.Exception exception) { EnsureSetup().Throws(exception); return this; } + /// + public SelfEquatableSnapshot_Equals_M0_MockCall Callback(global::System.Action callback) { EnsureSetup().Callback(callback); return this; } + /// + public SelfEquatableSnapshot_Equals_M0_MockCall TransitionsTo(string stateName) { EnsureSetup().TransitionsTo(stateName); return this; } + /// + public SelfEquatableSnapshot_Equals_M0_MockCall Then() { EnsureSetup().Then(); return this; } + + /// Configure a typed computed return value using the actual method parameters. + public SelfEquatableSnapshot_Equals_M0_MockCall Returns(global::System.Func factory) + { + EnsureSetup().Returns(args => factory((global::SelfEquatableSnapshot?)args[0])); + return this; + } + + /// Execute a typed callback using the actual method parameters. + public SelfEquatableSnapshot_Equals_M0_MockCall Callback(global::System.Action callback) + { + EnsureSetup().Callback(callback); + return this; + } + + /// Configure a typed computed exception using the actual method parameters. + public SelfEquatableSnapshot_Equals_M0_MockCall Throws(global::System.Func exceptionFactory) + { + EnsureSetup().Throws(args => exceptionFactory((global::SelfEquatableSnapshot?)args[0])); + return this; + } + + // ICallVerification + /// + public void WasCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(); + /// + public void WasCalled(global::TUnit.Mocks.Times times) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times); + /// + public void WasCalled(global::TUnit.Mocks.Times times, string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times, message); + /// + public void WasCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(message); + /// + public void WasNeverCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled(); + /// + public void WasNeverCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled(message); + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public sealed class SelfEquatableSnapshot_Equals_M1_MockCall : global::TUnit.Mocks.Verification.ICallVerification + { + private readonly global::TUnit.Mocks.IMockEngineAccess _engine; + private readonly int _memberId; + private readonly string _memberName; + private readonly global::TUnit.Mocks.Arguments.IArgumentMatcher[] _matchers; + private global::TUnit.Mocks.Setup.MethodSetupBuilder? _builder; + + internal SelfEquatableSnapshot_Equals_M1_MockCall(global::TUnit.Mocks.IMockEngineAccess engine, int memberId, string memberName, global::TUnit.Mocks.Arguments.IArgumentMatcher[] matchers) + { + _engine = engine; + _memberId = memberId; + _memberName = memberName; + _matchers = matchers; + } + + private global::TUnit.Mocks.Setup.MethodSetupBuilder EnsureSetup() + { + var existing = global::System.Threading.Volatile.Read(ref _builder); + if (existing is not null) return existing; + return EnsureSetupSlow(); + } + + [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private global::TUnit.Mocks.Setup.MethodSetupBuilder EnsureSetupSlow() + { + var setup = new global::TUnit.Mocks.Setup.MethodSetup(_memberId, _matchers, _memberName); + var fresh = new global::TUnit.Mocks.Setup.MethodSetupBuilder(setup); + var prev = global::System.Threading.Interlocked.CompareExchange(ref _builder, fresh, null); + if (prev is not null) return prev; + // AddSetup runs only on the CAS winner. Setup is sequential in practice, + // so a concurrent loser observing the builder before registration is benign. + _engine.AddSetup(setup); + return fresh; + } + + /// + public SelfEquatableSnapshot_Equals_M1_MockCall Returns(bool value) { EnsureSetup().Returns(value); return this; } + /// + public SelfEquatableSnapshot_Equals_M1_MockCall Returns(global::System.Func factory) { EnsureSetup().Returns(factory); return this; } + /// + public SelfEquatableSnapshot_Equals_M1_MockCall ReturnsSequentially(params bool[] values) { EnsureSetup().ReturnsSequentially(values); return this; } + /// + public SelfEquatableSnapshot_Equals_M1_MockCall Throws() where TException : global::System.Exception, new() { EnsureSetup().Throws(); return this; } + /// + public SelfEquatableSnapshot_Equals_M1_MockCall Throws(global::System.Exception exception) { EnsureSetup().Throws(exception); return this; } + /// + public SelfEquatableSnapshot_Equals_M1_MockCall Callback(global::System.Action callback) { EnsureSetup().Callback(callback); return this; } + /// + public SelfEquatableSnapshot_Equals_M1_MockCall TransitionsTo(string stateName) { EnsureSetup().TransitionsTo(stateName); return this; } + /// + public SelfEquatableSnapshot_Equals_M1_MockCall Then() { EnsureSetup().Then(); return this; } + + /// Configure a typed computed return value using the actual method parameters. + public SelfEquatableSnapshot_Equals_M1_MockCall Returns(global::System.Func factory) + { + EnsureSetup().Returns(args => factory((object?)args[0])); + return this; + } + + /// Execute a typed callback using the actual method parameters. + public SelfEquatableSnapshot_Equals_M1_MockCall Callback(global::System.Action callback) + { + EnsureSetup().Callback(callback); + return this; + } + + /// Configure a typed computed exception using the actual method parameters. + public SelfEquatableSnapshot_Equals_M1_MockCall Throws(global::System.Func exceptionFactory) + { + EnsureSetup().Throws(args => exceptionFactory((object?)args[0])); + return this; + } + + // ICallVerification + /// + public void WasCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(); + /// + public void WasCalled(global::TUnit.Mocks.Times times) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times); + /// + public void WasCalled(global::TUnit.Mocks.Times times, string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times, message); + /// + public void WasCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(message); + /// + public void WasNeverCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled(); + /// + public void WasNeverCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled(message); + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks +{ + public static class SelfEquatableSnapshot_MockStaticExtension + { + extension(global::SelfEquatableSnapshot _) + { + public static global::TUnit.Mocks.Mock Mock(global::TUnit.Mocks.MockBehavior behavior = global::TUnit.Mocks.MockBehavior.Loose) + { + return global::TUnit.Mocks.Mock.Of(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 43fce63d63..ac2d121472 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -16,10 +16,30 @@ internal static class MockMembersBuilder private const int MaxTypedParams = 8; private const int MaxFuncOverloadParams = 4; + // Two distinct shadowing problems below — different fixes because the kinds collide differently. + + // "Object" clashes with the Mock.Object PROPERTY (member-kind collision: property vs. + // generated method/property). A trailing underscore is enough since nothing on object + // is named "Object_". private static readonly HashSet MockMemberNames = new(System.StringComparer.Ordinal) { "Object", - "GetHashCode", "GetType", "ToString", "Equals" + }; + + // Equals/GetHashCode/ToString clash with inherited object INSTANCE METHODS. + // C# overload resolution always prefers an instance method on the receiver over an + // extension method, so a generated `Equals` extension is unreachable (mock.Equals(...) + // binds to object.Equals and returns bool). A trailing-underscore rename would also be + // reachable, but we use an "Of" suffix to read naturally at the call site + // (mock.EqualsOf(other).Returns(true)). + // GetType is intentionally omitted: it is non-virtual on object and therefore + // cannot be overridden or shadowed in a meaningful way, so any generated helper + // would be dead code. + private static readonly Dictionary ObjectMemberDisambiguations = new(System.StringComparer.Ordinal) + { + { "Equals", "EqualsOf" }, + { "GetHashCode", "GetHashCodeOf" }, + { "ToString", "ToStringOf" }, }; public static string Build(MockTypeModel model) @@ -137,7 +157,11 @@ private static string GetWrapperName(string safeName, MockMemberModel method) => $"{safeName}_{method.Name}_M{method.MemberId}_MockCall"; private static string GetSafeMemberName(string name) - => MockMemberNames.Contains(name) ? name + "_" : name; + { + if (ObjectMemberDisambiguations.TryGetValue(name, out var renamed)) + return renamed; + return MockMemberNames.Contains(name) ? name + "_" : name; + } private static string GetCombinedTypeParameterList(MockTypeModel model, MockMemberModel method) { diff --git a/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs b/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs index 0421541d1c..c6836dadb6 100644 --- a/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs +++ b/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs @@ -101,11 +101,20 @@ public class DoubleInterfaceExplicit : IGetIdInt, IGetIdString public virtual int OwnMember() => 0; } -// ─── T8 SKIPPED. Self-referential IEquatable — `mock.Equals(...)` resolves -// to object.Equals via extension-method dispatch rather than to the -// generator-emitted setup extension. Separate design limitation: would -// require either renaming the extension or generating a disambiguating -// helper (e.g. `mock.EqualsOf(...)`). +// ─── T8. Self-referential IEquatable — `mock.Equals(...)` would resolve to +// object.Equals via overload-resolution (extension methods can't beat instance +// methods on object). Generator emits a disambiguating `EqualsOf(...)` helper +// so the typed setup is reachable. Same disambiguation applies to GetHashCode +// and ToString. (GetType is non-virtual on object so it cannot be overridden; +// no helper is generated for it.) + +public class SelfEquatable : IEquatable +{ + public virtual bool Equals(SelfEquatable? other) => ReferenceEquals(this, other); + public override bool Equals(object? obj) => obj is SelfEquatable s && Equals(s); + public override int GetHashCode() => 0; + public override string ToString() => "base"; +} // ─── T9. Nullable value types ──────────────────────────────────────────────── @@ -334,7 +343,46 @@ public async Task T7_Two_Interfaces_Same_Name_Different_Returns() mock.OwnMember().WasCalled(Times.Once); } - // T8 test elided — see the SKIPPED note above the type declarations. + // ── T8 ── + + [Test] + public async Task T8_Self_Referential_IEquatable_Mockable() + { + var mock = SelfEquatable.Mock(); + var other = new SelfEquatable(); + var unrelated = new SelfEquatable(); + + // Setup via the disambiguated helper. Returns(...) must be reachable on the result. + mock.EqualsOf(other).Returns(true); + mock.EqualsOf(unrelated).Returns(false); + + // Direct call routes through the generated impl's Equals override to the engine. + await Assert.That(mock.Object.Equals(other)).IsTrue(); + await Assert.That(mock.Object.Equals(unrelated)).IsFalse(); + + // Interface-cast path resolves to the same underlying setup. + IEquatable asInterface = mock.Object; + await Assert.That(asInterface.Equals(other)).IsTrue(); + await Assert.That(asInterface.Equals(unrelated)).IsFalse(); + + // Verification: each setup tracks both the direct and interface-cast invocations. + mock.EqualsOf(other).WasCalled(Times.Exactly(2)); + mock.EqualsOf(unrelated).WasCalled(Times.Exactly(2)); + + var third = new SelfEquatable(); + mock.EqualsOf(third).WasNeverCalled(); + + // GetHashCodeOf / ToStringOf — same disambiguation pattern. + // GetType is not virtual on object so cannot be exercised; see class-level note. + mock.GetHashCodeOf().Returns(7); + mock.ToStringOf().Returns("mocked"); + + await Assert.That(mock.Object.GetHashCode()).IsEqualTo(7); + await Assert.That(mock.Object.ToString()).IsEqualTo("mocked"); + + mock.GetHashCodeOf().WasCalled(Times.Once); + mock.ToStringOf().WasCalled(Times.Once); + } // ── T9 ──