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; +}