From 3945b96c4e9ed805d9a5e3df43c2a7bb5cb4d3a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 25 Aug 2026 16:32:07 +0200 Subject: [PATCH] fix: delegate wrapped calls of hidden interface members to the declaring interface A method that hides a base interface member via `new` is emitted as an explicit interface implementation, but its wrapping delegation cast `MockRegistry.Wraps` to the mocked type instead of the declaring interface. The delegated call therefore bound to the hiding member: - same constraints, different return type: wrong member invoked silently - hiding member adds a constraint: CS0452 - hiding member narrows a constraint: CS0453 - three-level hierarchy: CS0266 Cast to `method.ExplicitImplementation` when set, mirroring how events already treat explicitly implemented members. --- .../Sources/Sources.MockClass.cs | 6 +- .../MockTests.cs | 168 ++++++++++++++++++ .../MockTests.WrappingInterfaceTests.cs | 29 +++ .../TestHelpers/IChocolateCatalog.cs | 13 ++ 4 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 Tests/Mockolate.Tests/TestHelpers/IChocolateCatalog.cs diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index 3448b692..9d516950 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -2332,6 +2332,10 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb, string wpc = Helpers.GetUniqueLocalVariableName("wpc", method.Parameters); string wraps = Helpers.GetUniqueLocalVariableName("wraps", method.Parameters); bool supportsWrapping = !explicitInterfaceImplementation && method is { IsStatic: false, IsProtected: false, }; + // A method that hides a base member (`new`) is emitted as an explicit interface implementation. + // The wrapped instance must be cast to the declaring interface, otherwise the delegated call + // binds to the hiding member instead: wrong return type (CS0266) or missing constraints (CS0452/CS0453). + string wrapsType = method.ExplicitImplementation ?? className; bool isAbstractOrInterface = isClassInterface || method.IsAbstract; StringBuilder sb2 = new(); @@ -2452,7 +2456,7 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb, if (supportsWrapping) { - sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(className) + sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType) .Append(' ').Append(wraps).Append(')').AppendLine(); sb.Append("\t\t\t\t{").AppendLine(); if (method.ReturnType != Type.Void) diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs index 4087f049..d4fe4a26 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs @@ -470,6 +470,174 @@ await That(result.Sources["Mock.MyClassWithSealedProperties.g.cs"]) .DoesNotContain("override int MyProperty"); } + [Fact] + public async Task HiddenGenericMethod_ShouldDelegateWrappingToDeclaringInterface() + { + GeneratorResult result = Generator + .Run(""" + using System.Collections.Generic; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = ITest.CreateMock(); + } + } + + public interface ITestParent + { + IEnumerable Get() where T : notnull; + } + + public interface ITest : ITestParent + { + new IList Get() where T : notnull; + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wrappedResult = wraps.Get(); + """).IgnoringNewlineStyle() + .Because( + "the explicit implementation of the hidden member must delegate to the declaring interface, not to the hiding member"); + } + + [Fact] + public async Task HiddenGenericMethod_WithAdditionalConstraints_ShouldNotDelegateToHidingMember() + { + GeneratorResult result = Generator + .Run(""" + using System.Collections.Generic; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = ITest.CreateMock(); + } + } + + public interface ITestParent + { + IEnumerable Get(); + } + + public interface ITest : ITestParent + { + new IList Get() where T : class, new(); + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("CS0452: the hiding member requires a reference type, the hidden member does not"); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wrappedResult = wraps.Get(); + """).IgnoringNewlineStyle(); + } + + [Fact] + public async Task HiddenGenericMethod_WithNarrowedConstraints_ShouldNotDelegateToHidingMember() + { + GeneratorResult result = Generator + .Run(""" + using System.Collections.Generic; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = ITest.CreateMock(); + } + } + + public interface ITestParent + { + IEnumerable Get() where T : notnull; + } + + public interface ITest : ITestParent + { + new IEnumerable Get() where T : struct; + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("CS0453: the hiding member requires a non-nullable value type, the hidden member does not"); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wrappedResult = wraps.Get(); + """).IgnoringNewlineStyle(); + } + + [Fact] + public async Task HiddenGenericMethod_InDeepHierarchy_ShouldNotDelegateToHidingMember() + { + GeneratorResult result = Generator + .Run(""" + using System.Collections.Generic; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = ITest.CreateMock(); + } + } + + public interface IGrandParent + { + T Get(int id) where T : notnull; + } + + public interface ITestParent : IGrandParent + { + new IEnumerable Get(int id) where T : notnull; + } + + public interface ITest : ITestParent + { + new IList Get(int id) where T : notnull; + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("CS0266: each hidden member has its own return type"); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wrappedResult = wraps.Get(id); + """).IgnoringNewlineStyle().And + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.IGrandParent wraps) + { + wrappedResult = wraps.Get(id); + """).IgnoringNewlineStyle() + .Because("every level of the hierarchy must delegate to its own declaring interface"); + } + [Fact] public async Task MembersWithReservedNames_ShouldPrefixAtSymbol() { diff --git a/Tests/Mockolate.Tests/MockTests.WrappingInterfaceTests.cs b/Tests/Mockolate.Tests/MockTests.WrappingInterfaceTests.cs index 336c65a5..d1ae0c9a 100644 --- a/Tests/Mockolate.Tests/MockTests.WrappingInterfaceTests.cs +++ b/Tests/Mockolate.Tests/MockTests.WrappingInterfaceTests.cs @@ -79,6 +79,18 @@ void Handler(string type, int amount) } } + [Fact] + public async Task Wrap_HiddenGenericMethod_ShouldDelegateToDeclaringInterface() + { + MyChocolateCatalog myCatalog = new(); + IChocolateCatalog wrappedCatalog = IChocolateCatalog.CreateMock().Wrapping(myCatalog); + + _ = wrappedCatalog.Get(); + _ = ((IChocolateSource)wrappedCatalog).Get(); + + await That(myCatalog.ReceivedCalls).IsEqualTo(["catalog", "source",]); + } + [Fact] public async Task Wrap_Indexer_ShouldDelegateToWrappedInstance() { @@ -171,6 +183,23 @@ public bool Dispense(string type, int amount) public event ChocolateDispensedDelegate? ChocolateDispensed; } + private class MyChocolateCatalog : IChocolateCatalog + { + public List ReceivedCalls { get; } = []; + + public IList Get() where T : notnull + { + ReceivedCalls.Add("catalog"); + return []; + } + + IEnumerable IChocolateSource.Get() + { + ReceivedCalls.Add("source"); + return []; + } + } + public delegate void MyDelegate(); } } diff --git a/Tests/Mockolate.Tests/TestHelpers/IChocolateCatalog.cs b/Tests/Mockolate.Tests/TestHelpers/IChocolateCatalog.cs new file mode 100644 index 00000000..d40ae4b7 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/IChocolateCatalog.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Mockolate.Tests.TestHelpers; + +public interface IChocolateSource +{ + IEnumerable Get() where T : notnull; +} + +public interface IChocolateCatalog : IChocolateSource +{ + new IList Get() where T : notnull; +}