From 372528af7018afa88b8aeeae08945fd47e2cac9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 25 Aug 2026 18:38:44 +0200 Subject: [PATCH 1/2] fix: forward wrapped access of hidden interface members to the declaring interface A property, indexer or event that hides a base interface member via `new` is emitted as an explicit interface implementation, but those implementations emitted no wrapping branch at all. On a mock created with `.Wrapping(instance)` the wrapped instance was never consulted for the hidden member: getters returned the mock default, setters only reached the registry, and event subscriptions never arrived at the instance. Let the explicit implementations take the wrapping branch and cast `MockRegistry.Wraps` to `ExplicitImplementation` when set, mirroring the method fix in #847. Casting to the mocked type instead would not compile, because the hiding member has a different type (CS0266, or CS0029 when the types are unrelated). Init-only setters stay unforwarded, since the wrapped instance is already constructed. --- Docs/pages/01-create-mocks.md | 3 + .../Sources/Sources.MockClass.cs | 38 ++- Tests/Mockolate.ExampleTests/ExampleTests.cs | 37 +++ .../TestData/IUserCache.cs | 13 + .../MockTests.cs | 288 ++++++++++++++++++ .../MockTests.WrappingInterfaceTests.cs | 223 ++++++++++++++ .../TestHelpers/IChocolateShelf.cs | 22 ++ 7 files changed, 609 insertions(+), 15 deletions(-) create mode 100644 Tests/Mockolate.ExampleTests/TestData/IUserCache.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/IChocolateShelf.cs diff --git a/Docs/pages/01-create-mocks.md b/Docs/pages/01-create-mocks.md index 7dfdbc32..2635a1b6 100644 --- a/Docs/pages/01-create-mocks.md +++ b/Docs/pages/01-create-mocks.md @@ -141,6 +141,9 @@ wrappedDispenser.Mock.Verify.Dispense(It.Is("Dark"), It.Is(5)).Once(); - Both interface and class types can be wrapped. - All public calls are forwarded to the wrapped instance. +- Members that hide a base member with `new` are forwarded to the interface that declares them, so each + interface view of the mock reaches the matching member on the wrapped instance. - You can still set up custom behavior that overrides the wrapped instance's behavior. - Protected members are not forwarded to the wrapped instance; the base class implementation is used instead. +- Init-only properties are not forwarded to the wrapped instance, since it is already constructed. - Verification works the same as with regular mocks. diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index 9d516950..d45a44b6 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -1592,8 +1592,12 @@ private static void AppendMockSubject_ImplementClass_AddEvent(StringBuilder sb, } sb.AppendLine("\t\t{"); - bool supportsWrapping = @event is { IsStatic: false, IsProtected: false, ExplicitImplementation: null, } && + bool supportsWrapping = @event is { IsStatic: false, IsProtected: false, } && !explicitInterfaceImplementation; + // An event 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 subscription + // binds to the hiding member, whose delegate type differs from the one being implemented. + string wrapsType = @event.ExplicitImplementation ?? className; bool supportsBaseForwarding = supportsWrapping && !isClassInterface && @event.UseOverride && !@event.IsAbstract; if (supportsWrapping) { @@ -1604,7 +1608,7 @@ private static void AppendMockSubject_ImplementClass_AddEvent(StringBuilder sb, sb.Append("\t\t\t\t\t").Append(mockRegistry).Append(addCall).Append(@event.GetUniqueNameString()).Append(", value.Target, value.Method);").AppendLine(); sb.Append("\t\t\t\t}").AppendLine(); sb.Append("\t\t\t\t").Append(backingFieldAccess).Append(" += value;").AppendLine(); - sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(className).Append(" wraps)").AppendLine(); + sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType).Append(" wraps)").AppendLine(); sb.Append("\t\t\t\t{").AppendLine(); sb.Append("\t\t\t\t\twraps.").Append(@event.Name).Append(" += value;").AppendLine(); sb.Append("\t\t\t\t}").AppendLine(); @@ -1624,7 +1628,7 @@ private static void AppendMockSubject_ImplementClass_AddEvent(StringBuilder sb, sb.Append("\t\t\t\t\t").Append(mockRegistry).Append(removeCall).Append(@event.GetUniqueNameString()).Append(", value.Target, value.Method);").AppendLine(); sb.Append("\t\t\t\t}").AppendLine(); sb.Append("\t\t\t\t").Append(backingFieldAccess).Append(" -= value;").AppendLine(); - sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(className).Append(" wraps)").AppendLine(); + sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType).Append(" wraps)").AppendLine(); sb.Append("\t\t\t\t{").AppendLine(); sb.Append("\t\t\t\t\twraps.").Append(@event.Name).Append(" -= value;").AppendLine(); sb.Append("\t\t\t\t}").AppendLine(); @@ -1669,6 +1673,10 @@ private static void AppendMockSubject_ImplementClass_AddProperty(StringBuilder s #pragma warning restore S107 { string mockRegistry = property.IsStatic ? "MockRegistryProvider.Value" : $"this.{mockRegistryName}"; + // A property 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 access + // binds to the hiding member instead: wrong property type (CS0266). + string wrapsType = property.ExplicitImplementation ?? className; bool useFastForProperty = useFastBuffers && !property.IsIndexer && IsFastBufferEligibleProperty(property); bool useFastForIndexer = useFastBuffers && property.IsIndexer && IsFastBufferEligibleIndexer(property); string indexerGetIdRef = property.IsIndexer @@ -1764,7 +1772,7 @@ property.IndexerParameters is not null { AppendRefStructIndexerGetterBody(sb, property, mockRegistry); } - else if (isClassInterface && !explicitInterfaceImplementation && property.ExplicitImplementation is null) + else if (isClassInterface && !explicitInterfaceImplementation) { if (property is { IsIndexer: true, IndexerParameters: not null, }) { @@ -1781,7 +1789,7 @@ property.IndexerParameters is not null property.Type, property.IndexerParameters.Value, useFastForIndexer, useFastForIndexer ? indexerGetIdRef : null, cachedBufferRef: indexerGetCachedBufferRef); - sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is not ").Append(className) + sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is not ").Append(wrapsType) .Append(' ').Append(wrapsVarName).Append(')').AppendLine(); sb.Append("\t\t\t\t{").AppendLine(); sb.Append("\t\t\t\t\treturn ").Append(setupVarName).Append(" is null") @@ -1814,7 +1822,7 @@ property.IndexerParameters is not null .AppendDefaultValueGeneratorFor(property.Type, "b.DefaultValue"); if (!property.IsStatic) { - sb.Append(", ").Append(mockRegistry).Append(".Wraps is not ").Append(className) + sb.Append(", ").Append(mockRegistry).Append(".Wraps is not ").Append(wrapsType) .Append(" wraps ? null : () => wraps.").Append(property.Name); } @@ -1828,7 +1836,7 @@ property.IndexerParameters is not null .AppendDefaultValueGeneratorFor(property.Type, $"{mockRegistry}.Behavior.DefaultValue"); if (!property.IsStatic) { - sb.Append(", ").Append(mockRegistry).Append(".Wraps is not ").Append(className) + sb.Append(", ").Append(mockRegistry).Append(".Wraps is not ").Append(wrapsType) .Append(" wraps ? null : () => wraps.").Append(property.Name); } else @@ -1865,7 +1873,7 @@ property.IndexerParameters is not null sb.Append("\t\t\t\t\t").AppendTypeOrWrapper(property.Type).Append(' ') .Append(baseResultVarName).Append(" = this.") .Append(mockRegistryName) - .Append(".Wraps is ").Append(className).Append(' ').Append(wrapsVarName).Append(" ? ") + .Append(".Wraps is ").Append(wrapsType).Append(' ').Append(wrapsVarName).Append(" ? ") .Append(wrapsVarName).Append('[') .Append(FormatIndexerParametersAsNames(property.IndexerParameters.Value)) .Append("] : base[") @@ -1905,7 +1913,7 @@ property.IndexerParameters is not null .AppendDefaultValueGeneratorFor(property.Type, "b.DefaultValue"); if (property is { IsStatic: false, } && property.Getter?.IsProtected != true) { - sb.Append(", ").Append(mockRegistry).Append(".Wraps is ").Append(className) + sb.Append(", ").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType) .Append(" wraps ? () => wraps.").Append(property.Name).Append(" : () => base.") .Append(property.Name); } @@ -1924,7 +1932,7 @@ property.IndexerParameters is not null .AppendDefaultValueGeneratorFor(property.Type, $"{mockRegistry}.Behavior.DefaultValue"); if (property is { IsStatic: false, } && property.Getter?.IsProtected != true) { - sb.Append(", ").Append(mockRegistry).Append(".Wraps is ").Append(className) + sb.Append(", ").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType) .Append(" wraps ? () => wraps.").Append(property.Name).Append(" : () => base.") .Append(property.Name); } @@ -2001,7 +2009,7 @@ property.IndexerParameters is not null { AppendRefStructIndexerSetterBody(sb, property, mockRegistry); } - else if (isClassInterface && !explicitInterfaceImplementation && property.ExplicitImplementation is null) + else if (isClassInterface && !explicitInterfaceImplementation) { if (property is { IsIndexer: true, IndexerParameters: not null, }) { @@ -2022,7 +2030,7 @@ property.IndexerParameters is not null .Append(signatureIndex).Append(");") .AppendLine(); - 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(wrapsVarName).Append(')').AppendLine(); sb.Append("\t\t\t\t{").AppendLine(); sb.Append("\t\t\t\t\t").Append(wrapsVarName).Append('[') @@ -2050,7 +2058,7 @@ property.IndexerParameters is not null if (!property.IsStatic && !property.Setter.IsInitOnly) { - 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(" wraps)").AppendLine(); sb.Append("\t\t\t\t{").AppendLine(); sb.Append("\t\t\t\t\twraps.").Append(property.Name).Append(" = value;").AppendLine(); @@ -2080,7 +2088,7 @@ property.IndexerParameters is not null sb.Append("\t\t\t\t{").AppendLine(); if (property.Setter?.IsProtected != true) { - sb.Append("\t\t\t\t\tif (this.").Append(mockRegistryName).Append(".Wraps is ").Append(className) + sb.Append("\t\t\t\t\tif (this.").Append(mockRegistryName).Append(".Wraps is ").Append(wrapsType) .Append(' ').Append(wrapsVarName).Append(')').AppendLine(); sb.Append("\t\t\t\t\t{").AppendLine(); sb.Append("\t\t\t\t\t\t").Append(wrapsVarName).Append('[') @@ -2138,7 +2146,7 @@ property.IndexerParameters is not null sb.Append("\t\t\t\t{").AppendLine(); if (property is { IsStatic: false, } && property.Setter?.IsProtected != true) { - sb.Append("\t\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(className) + sb.Append("\t\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType) .Append(" wraps)").AppendLine(); sb.Append("\t\t\t\t\t{").AppendLine(); sb.Append("\t\t\t\t\t\twraps.").Append(property.Name).Append(" = value;").AppendLine(); diff --git a/Tests/Mockolate.ExampleTests/ExampleTests.cs b/Tests/Mockolate.ExampleTests/ExampleTests.cs index 88e4b863..4e148eeb 100644 --- a/Tests/Mockolate.ExampleTests/ExampleTests.cs +++ b/Tests/Mockolate.ExampleTests/ExampleTests.cs @@ -267,4 +267,41 @@ public async Task WithOut_ShouldSupportOutParameter(bool returnValue) await That(result).IsEqualTo(returnValue); sut.Mock.Verify.TryDelete(It.Is(id), It.IsOut()).Once(); } + + [Fact] + public async Task Wrapping_HiddenMember_ShouldForwardToDeclaringInterface() + { + User alice = new(Guid.NewGuid(), "Alice"); + User bob = new(Guid.NewGuid(), "Bob"); + MyUserCache realCache = new(); + IUserCache sut = IUserCache.CreateMock().Wrapping(realCache); + + sut.Users = [alice,]; + ((IReadOnlyUserCache)sut).Users = [bob,]; + + // Each interface sees its own member on the wrapped instance. + await That(sut.Users).IsEqualTo([alice,]); + await That(((IReadOnlyUserCache)sut).Users).IsEqualTo([bob,]); + await That(realCache.CacheUsers).IsEqualTo([alice,]); + await That(realCache.ReadOnlyUsers).IsEqualTo([bob,]); + } + + private sealed class MyUserCache : IUserCache + { + public IList CacheUsers { get; private set; } = []; + + public IEnumerable ReadOnlyUsers { get; private set; } = []; + + public IList Users + { + get => CacheUsers; + set => CacheUsers = value; + } + + IEnumerable IReadOnlyUserCache.Users + { + get => ReadOnlyUsers; + set => ReadOnlyUsers = value; + } + } } diff --git a/Tests/Mockolate.ExampleTests/TestData/IUserCache.cs b/Tests/Mockolate.ExampleTests/TestData/IUserCache.cs new file mode 100644 index 00000000..2401ae2b --- /dev/null +++ b/Tests/Mockolate.ExampleTests/TestData/IUserCache.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Mockolate.ExampleTests.TestData; + +public interface IReadOnlyUserCache +{ + IEnumerable Users { get; set; } +} + +public interface IUserCache : IReadOnlyUserCache +{ + new IList Users { get; set; } +} diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs index d4fe4a26..f3d408b3 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs @@ -638,6 +638,294 @@ await That(result.Sources["Mock.ITest.g.cs"]) .Because("every level of the hierarchy must delegate to its own declaring interface"); } + [Fact] + public async Task HiddenProperty_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 Items { get; set; } + } + + public interface ITest : ITestParent + { + new IList Items { get; set; } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("CS0266: the hiding member has a narrower property type than the hidden member"); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains("this.MockRegistry.Wraps is not global::MyCode.ITestParent wraps ? null : () => wraps.Items") + .Because("the getter of the explicit implementation must read the declaring interface").And + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wraps.Items = value; + """).IgnoringNewlineStyle() + .Because("the setter of the explicit implementation must write to the declaring interface"); + } + + [Fact] + public async Task HiddenProperty_WithUnrelatedType_ShouldNotDelegateToHidingMember() + { + GeneratorResult result = Generator + .Run(""" + using System; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = ITest.CreateMock(); + } + } + + public interface ITestParent + { + string Label { get; set; } + } + + public interface ITest : ITestParent + { + new DateTime Label { get; set; } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("CS0029: the hiding member has a type unrelated to the hidden member"); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains("this.MockRegistry.Wraps is not global::MyCode.ITestParent wraps ? null : () => wraps.Label") + .And + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wraps.Label = value; + """).IgnoringNewlineStyle(); + } + + [Fact] + public async Task HiddenGetOnlyProperty_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 Items { get; } + } + + public interface ITest : ITestParent + { + new IList Items { get; } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains("this.MockRegistry.Wraps is not global::MyCode.ITestParent wraps ? null : () => wraps.Items") + .Because("a get-only hidden property must still read from the declaring interface"); + } + + [Fact] + public async Task HiddenInitOnlyProperty_ShouldOnlyDelegateGetterToDeclaringInterface() + { + 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 Items { get; init; } + } + + public interface ITest : ITestParent + { + new IList Items { get; init; } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains("this.MockRegistry.Wraps is not global::MyCode.ITestParent wraps ? null : () => wraps.Items") + .Because("the getter of the explicit implementation must read the declaring interface").And + .DoesNotContain("wraps.Items = value") + .Because("an init accessor cannot be forwarded to an already constructed instance"); + } + + [Fact] + public async Task HiddenIndexer_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 this[int index] { get; set; } + } + + public interface ITest : ITestParent + { + new IList this[int index] { get; set; } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("CS0266: the hiding indexer has a narrower value type than the hidden indexer"); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains(""" + if (this.MockRegistry.Wraps is not global::MyCode.ITestParent wraps) + """).IgnoringNewlineStyle() + .Because("the getter of the explicit implementation must read the declaring interface").And + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wraps[index] = value; + """).IgnoringNewlineStyle() + .Because("the setter of the explicit implementation must write to the declaring interface"); + } + + [Fact] + public async Task HiddenEvent_ShouldDelegateWrappingToDeclaringInterface() + { + GeneratorResult result = Generator + .Run(""" + using System; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = ITest.CreateMock(); + } + } + + public interface ITestParent + { + event EventHandler Changed; + } + + public interface ITest : ITestParent + { + new event Action Changed; + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the hiding event has a delegate type unrelated to the hidden event"); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wraps.Changed += value; + """).IgnoringNewlineStyle() + .Because("subscribing on the explicit implementation must subscribe on the declaring interface").And + .Contains(""" + if (this.MockRegistry.Wraps is global::MyCode.ITestParent wraps) + { + wraps.Changed -= value; + """).IgnoringNewlineStyle() + .Because("unsubscribing on the explicit implementation must unsubscribe on the declaring interface"); + } + + [Fact] + public async Task HiddenProperty_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 + { + object Items { get; set; } + } + + public interface ITestParent : IGrandParent + { + new IEnumerable Items { get; set; } + } + + public interface ITest : ITestParent + { + new IList Items { get; set; } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("CS0266: each hidden member has its own property type"); + await That(result.Sources["Mock.ITest.g.cs"]) + .Contains("this.MockRegistry.Wraps is not global::MyCode.ITestParent wraps ? null : () => wraps.Items") + .And + .Contains("this.MockRegistry.Wraps is not global::MyCode.IGrandParent wraps ? null : () => wraps.Items") + .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 d1ae0c9a..ed6bd84a 100644 --- a/Tests/Mockolate.Tests/MockTests.WrappingInterfaceTests.cs +++ b/Tests/Mockolate.Tests/MockTests.WrappingInterfaceTests.cs @@ -79,6 +79,24 @@ void Handler(string type, int amount) } } + [Fact] + public async Task Wrap_HiddenEvent_ShouldSubscribeOnDeclaringInterface() + { + MyChocolateShelf myShelf = new(); + IChocolateShelf wrappedShelf = IChocolateShelf.CreateMock().Wrapping(myShelf); + + int baseInvocations = 0; + int shelfInvocations = 0; + ((IChocolateShelfBase)wrappedShelf).Restocked += (_, _) => baseInvocations++; + wrappedShelf.Restocked += () => shelfInvocations++; + + myShelf.RaiseBaseRestocked(); + myShelf.RaiseShelfRestocked(); + + await That(baseInvocations).IsEqualTo(1); + await That(shelfInvocations).IsEqualTo(1); + } + [Fact] public async Task Wrap_HiddenGenericMethod_ShouldDelegateToDeclaringInterface() { @@ -91,6 +109,82 @@ public async Task Wrap_HiddenGenericMethod_ShouldDelegateToDeclaringInterface() await That(myCatalog.ReceivedCalls).IsEqualTo(["catalog", "source",]); } + [Fact] + public async Task Wrap_HiddenGetOnlyProperty_ShouldDelegateToDeclaringInterface() + { + MyChocolateShelf myShelf = new(); + IChocolateShelf wrappedShelf = IChocolateShelf.CreateMock().Wrapping(myShelf); + + await That(wrappedShelf.Featured).IsEqualTo(["Dark",]); + await That(((IChocolateShelfBase)wrappedShelf).Featured).IsEqualTo(["Praline",]); + await That(myShelf.ReceivedCalls).IsEqualTo(["get:shelf-featured", "get:base-featured",]); + } + + [Fact] + public async Task Wrap_HiddenIndexer_ShouldDelegateGetterToDeclaringInterface() + { + MyChocolateShelf myShelf = new(); + IChocolateShelf wrappedShelf = IChocolateShelf.CreateMock().Wrapping(myShelf); + + await That(wrappedShelf[1]).IsEqualTo(["Truffle",]); + await That(((IChocolateShelfBase)wrappedShelf)[1]).IsEqualTo(["Ganache",]); + await That(myShelf.ReceivedCalls).IsEqualTo(["get:shelf-item", "get:base-item",]); + } + + [Fact] + public async Task Wrap_HiddenIndexer_ShouldDelegateSetterToDeclaringInterface() + { + MyChocolateShelf myShelf = new(); + IChocolateShelf wrappedShelf = IChocolateShelf.CreateMock().Wrapping(myShelf); + + wrappedShelf[2] = ["Nougat",]; + ((IChocolateShelfBase)wrappedShelf)[2] = ["Marzipan",]; + + await That(myShelf.ReceivedCalls).IsEqualTo(["set:shelf-item", "set:base-item",]); + await That(myShelf.ShelfItems[2]).IsEqualTo(["Nougat",]); + await That(myShelf.BaseItems[2]).IsEqualTo(["Marzipan",]); + } + + [Fact] + public async Task Wrap_HiddenProperty_ShouldDelegateGetterToDeclaringInterface() + { + MyChocolateShelf myShelf = new(); + IChocolateShelf wrappedShelf = IChocolateShelf.CreateMock().Wrapping(myShelf); + + await That(wrappedShelf.Assortment).IsEqualTo(["Milk", "Dark",]); + await That(((IChocolateShelfBase)wrappedShelf).Assortment).IsEqualTo(["Praline",]); + await That(myShelf.ReceivedCalls).IsEqualTo(["get:shelf", "get:base",]); + } + + [Fact] + public async Task Wrap_HiddenProperty_ShouldDelegateSetterToDeclaringInterface() + { + MyChocolateShelf myShelf = new(); + IChocolateShelf wrappedShelf = IChocolateShelf.CreateMock().Wrapping(myShelf); + + wrappedShelf.Assortment = ["Truffle",]; + ((IChocolateShelfBase)wrappedShelf).Assortment = ["Ganache",]; + + await That(myShelf.ReceivedCalls).IsEqualTo(["set:shelf", "set:base",]); + await That(myShelf.ShelfAssortment).IsEqualTo(["Truffle",]); + await That(myShelf.BaseAssortment).IsEqualTo(["Ganache",]); + } + + [Fact] + public async Task Wrap_HiddenPropertyWithUnrelatedType_ShouldDelegateToDeclaringInterface() + { + MyChocolateShelf myShelf = new(); + IChocolateShelf wrappedShelf = IChocolateShelf.CreateMock().Wrapping(myShelf); + + wrappedShelf.Label = 7; + ((IChocolateShelfBase)wrappedShelf).Label = "Seasonal"; + + await That(wrappedShelf.Label).IsEqualTo(7); + await That(((IChocolateShelfBase)wrappedShelf).Label).IsEqualTo("Seasonal"); + await That(myShelf.ShelfLabel).IsEqualTo(7); + await That(myShelf.BaseLabel).IsEqualTo("Seasonal"); + } + [Fact] public async Task Wrap_Indexer_ShouldDelegateToWrappedInstance() { @@ -200,6 +294,135 @@ IEnumerable IChocolateSource.Get() } } + private class MyChocolateShelf : IChocolateShelf + { + private IEnumerable _baseAssortment = ["Praline",]; + private string _baseLabel = "Classic"; + private event EventHandler? _baseRestocked; + + public List ReceivedCalls { get; } = []; + + public Dictionary> ShelfItems { get; } = new() + { + { + 1, ["Truffle",] + }, + }; + + public Dictionary> BaseItems { get; } = new() + { + { + 1, ["Ganache",] + }, + }; + + public IList ShelfAssortment { get; private set; } = ["Milk", "Dark",]; + + public IEnumerable BaseAssortment => _baseAssortment; + + public int ShelfLabel { get; private set; } + + public string BaseLabel => _baseLabel; + + public IList this[int index] + { + get + { + ReceivedCalls.Add("get:shelf-item"); + return ShelfItems[index]; + } + set + { + ReceivedCalls.Add("set:shelf-item"); + ShelfItems[index] = value; + } + } + + public IList Assortment + { + get + { + ReceivedCalls.Add("get:shelf"); + return ShelfAssortment; + } + set + { + ReceivedCalls.Add("set:shelf"); + ShelfAssortment = value; + } + } + + public IList Featured + { + get + { + ReceivedCalls.Add("get:shelf-featured"); + return ["Dark",]; + } + } + + public int Label + { + get => ShelfLabel; + set => ShelfLabel = value; + } + + public event Action? Restocked; + + IEnumerable IChocolateShelfBase.this[int index] + { + get + { + ReceivedCalls.Add("get:base-item"); + return BaseItems[index]; + } + set + { + ReceivedCalls.Add("set:base-item"); + BaseItems[index] = value; + } + } + + IEnumerable IChocolateShelfBase.Assortment + { + get + { + ReceivedCalls.Add("get:base"); + return _baseAssortment; + } + set + { + ReceivedCalls.Add("set:base"); + _baseAssortment = value; + } + } + + IEnumerable IChocolateShelfBase.Featured + { + get + { + ReceivedCalls.Add("get:base-featured"); + return ["Praline",]; + } + } + + string IChocolateShelfBase.Label + { + get => _baseLabel; + set => _baseLabel = value; + } + + event EventHandler IChocolateShelfBase.Restocked + { + add => _baseRestocked += value; + remove => _baseRestocked -= value; + } + + public void RaiseBaseRestocked() => _baseRestocked?.Invoke(this, EventArgs.Empty); + + public void RaiseShelfRestocked() => Restocked?.Invoke(); + } + public delegate void MyDelegate(); } } diff --git a/Tests/Mockolate.Tests/TestHelpers/IChocolateShelf.cs b/Tests/Mockolate.Tests/TestHelpers/IChocolateShelf.cs new file mode 100644 index 00000000..6a7dab8a --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/IChocolateShelf.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Mockolate.Tests.TestHelpers; + +public interface IChocolateShelfBase +{ + IEnumerable Assortment { get; set; } + IEnumerable Featured { get; } + string Label { get; set; } + IEnumerable this[int index] { get; set; } + event EventHandler Restocked; +} + +public interface IChocolateShelf : IChocolateShelfBase +{ + new IList Assortment { get; set; } + new IList Featured { get; } + new int Label { get; set; } + new IList this[int index] { get; set; } + new event Action Restocked; +} From 00aff0cb7eff641f91ddb804e8162e8ca0dfdaa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 25 Aug 2026 21:08:34 +0200 Subject: [PATCH 2/2] fix: do not forward init-only class properties to the wrapped instance --- .../Sources/Sources.MockClass.cs | 2 +- .../MockTests.cs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index d45a44b6..dde08432 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -2144,7 +2144,7 @@ property.IndexerParameters is not null } sb.Append("\t\t\t\t{").AppendLine(); - if (property is { IsStatic: false, } && property.Setter?.IsProtected != true) + if (property is { IsStatic: false, Setter: { IsProtected: false, IsInitOnly: false, }, }) { sb.Append("\t\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType) .Append(" wraps)").AppendLine(); diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs index f3d408b3..c4ab8db5 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.cs @@ -793,6 +793,36 @@ await That(result.Sources["Mock.ITest.g.cs"]) .Because("an init accessor cannot be forwarded to an already constructed instance"); } + [Fact] + public async Task InitOnlyProperty_InClass_ShouldNotDelegateSetterToWrappedInstance() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyClass.CreateMock(); + } + } + + public class MyClass + { + public virtual int Items { get; init; } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources["Mock.MyClass.g.cs"]) + .Contains("base.Items = value;").And + .DoesNotContain("wraps.Items = value") + .Because("an init accessor cannot be forwarded to an already constructed instance"); + } + [Fact] public async Task HiddenIndexer_ShouldDelegateWrappingToDeclaringInterface() {