From 7fdb958ec01e28273bde702e69268b69be8b665c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:28:26 +0100 Subject: [PATCH 1/2] fix(mocks): handle inaccessible method signatures Keep required overrides; omit setup APIs with inaccessible types. Refs #6634 --- .../Builders/MockMembersBuilder.cs | 6 +- .../Discovery/MemberDiscovery.cs | 6 + .../Discovery/TypeAccessibility.cs | 3 + .../Models/MockMemberModel.cs | 9 ++ .../Issue6634Tests.cs | 123 +++++++++++++- ...ct_Method_Generation_Snapshot.verified.txt | 151 ++++++++++++++++++ 6 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Grpc_Style_Abstract_Method_Generation_Snapshot.verified.txt diff --git a/src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 270aef4509..ee7079dbba 100644 --- a/src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -104,7 +104,8 @@ public static string Build(MockTypeModel model) // setup/verify. Static abstract methods legitimately use ExplicitInterfaceName // for bridge interface generation and still need setup extensions. var instanceMethods = new EquatableArray( - model.Methods.Where(m => m.ExplicitInterfaceName is null || m.IsStaticAbstract).ToImmutableArray()); + model.Methods.Where(m => m.IsSignatureAccessibleFromAssembly + && (m.ExplicitInterfaceName is null || m.IsStaticAbstract)).ToImmutableArray()); var methodsWithDisambiguation = ApplyOutDisambiguation(instanceMethods); // Methods @@ -186,7 +187,8 @@ public static string Build(MockTypeModel model) /// Methods that get a typed call wrapper — the shared filter for both emission passes. private static IEnumerable WrappedMethods(MockTypeModel model, bool hasEvents) => model.Methods.Where(m => - (m.ExplicitInterfaceName is null || m.IsStaticAbstract) + m.IsSignatureAccessibleFromAssembly + && (m.ExplicitInterfaceName is null || m.IsStaticAbstract) && ShouldGenerateTypedWrapper(m, model, hasEvents)); private static void EmitOutRefSetterDelegateNamespace(CodeWriter writer, MockTypeModel model, bool hasEvents, string mockNamespace) diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index 5d73b08900..df085e79b9 100644 --- a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -748,12 +748,18 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m OverrideAccessModifier = GetOverrideAccessModifier(method, compilationAssembly), IsRefStructReturn = returnType.IsRefLikeType, AutoMockFactoryMethod = autoMockFactoryMethod, + IsSignatureAccessibleFromAssembly = IsMethodSignatureAccessibleFromAssembly(method, compilation), IsReturnTypeStaticAbstractInterface = returnTypeHasStaticAbstract, SpanReturnElementType = returnType.IsRefLikeType ? GetSpanElementType(returnType) : null, ObsoleteAttribute = GetObsoleteAttributeSyntax(method) }; } + private static bool IsMethodSignatureAccessibleFromAssembly(IMethodSymbol method, Compilation compilation) + => TypeAccessibility.IsAccessibleFromAssembly(method.ReturnType, compilation) + && method.Parameters.All(parameter => + TypeAccessibility.IsAccessibleFromAssembly(parameter.Type, compilation)); + /// /// When a property with the same name appears from multiple interfaces, merge getter/setter /// accessors so the generated class satisfies all interfaces. diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/TypeAccessibility.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/TypeAccessibility.cs index f99dd3bc88..08d0191c4e 100644 --- a/src/TUnit.Mocks.SourceGenerator/Discovery/TypeAccessibility.cs +++ b/src/TUnit.Mocks.SourceGenerator/Discovery/TypeAccessibility.cs @@ -13,6 +13,9 @@ public static bool IsAccessibleFromAssembly(ITypeSymbol type, Compilation compil { switch (type) { + case IErrorTypeSymbol: + return false; + case ITypeParameterSymbol: return true; diff --git a/src/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/src/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index b486b8e2e5..7cb334648b 100644 --- a/src/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/src/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -72,6 +72,13 @@ internal sealed record MockMemberModel : IEquatable public bool IsStaticAbstract { get; init; } public string? AutoMockFactoryMethod { get; init; } + /// + /// Whether non-derived generated code in the consumer assembly can name every type in this + /// member's signature. Inaccessible members still need an override when abstract, but cannot + /// have setup or verification extensions generated for them. + /// + public bool IsSignatureAccessibleFromAssembly { get; init; } = true; + /// /// Which type in a multi-type mock owns this member: 0 = the primary type, /// n = 1-based index into . @@ -173,6 +180,7 @@ public bool Equals(MockMemberModel? other) && IsRefStructReturn == other.IsRefStructReturn && IsStaticAbstract == other.IsStaticAbstract && AutoMockFactoryMethod == other.AutoMockFactoryMethod + && IsSignatureAccessibleFromAssembly == other.IsSignatureAccessibleFromAssembly && OwnerTypeIndex == other.OwnerTypeIndex && IsReturnTypeStaticAbstractInterface == other.IsReturnTypeStaticAbstractInterface && SpanReturnElementType == other.SpanReturnElementType @@ -195,6 +203,7 @@ public override int GetHashCode() hash = hash * 31 + GetterAccessModifier.GetHashCode(); hash = hash * 31 + SetterAccessModifier.GetHashCode(); hash = hash * 31 + (AutoMockFactoryMethod?.GetHashCode() ?? 0); + hash = hash * 31 + IsSignatureAccessibleFromAssembly.GetHashCode(); hash = hash * 31 + OwnerTypeIndex; hash = hash * 31 + IsReturnTypeStaticAbstractInterface.GetHashCode(); hash = hash * 31 + (ExplicitInterfaceName?.GetHashCode() ?? 0); diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6634Tests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6634Tests.cs index c6bf70de5e..c2764c8d75 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6634Tests.cs +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6634Tests.cs @@ -4,12 +4,29 @@ namespace TUnit.Mocks.SourceGenerator.Tests; /// /// Regression: https://github.com/thomhurst/TUnit/issues/6634 -/// Constructor discovery runs in the generated subclass context, where protected nested types are -/// accessible. Constructor models also feed a non-derived factory and static extension methods, -/// whose signatures cannot name those types unless the consumer assembly has internal access. +/// Generated subclasses can name protected nested types in constructor and member signatures. +/// Their non-derived factories and setup extensions cannot, so those public surfaces must omit +/// inaccessible signatures without dropping overrides required to instantiate abstract clients. /// public class Issue6634Tests : SnapshotTestBase { + private const string GrpcStyleExternalLibrary = """ + namespace ExternalLib; + + public abstract class GrpcClient + { + protected GrpcClient() { } + + protected abstract GrpcClient NewInstance(ClientBaseConfiguration configuration); + + protected internal class ClientBaseConfiguration { } + + public GrpcClient Clone() => NewInstance(new ClientBaseConfiguration()); + + public abstract string Call(); + } + """; + [Test] public async Task Grpc_Style_Constructor_With_Protected_Internal_State_Is_Omitted() { @@ -32,6 +49,92 @@ protected internal class ClientBaseConfiguration { } await Assert.That(generated).DoesNotContain("ClientBaseConfiguration"); } + [Test] + public async Task Grpc_Style_Abstract_Method_With_Protected_Internal_State_Is_Mockable() + { + var reference = CreateExternalAssemblyReference(GrpcStyleExternalLibrary); + var source = GrpcMockSource(); + var generated = string.Join(Environment.NewLine, RunGenerator(source, [reference])); + + await AssertNoAccessibilityErrors(source, reference); + await Assert.That(generated).Contains( + "protected override global::ExternalLib.GrpcClient NewInstance(global::ExternalLib.GrpcClient.ClientBaseConfiguration configuration)"); + await Assert.That(generated).Contains("public override string Call()"); + } + + [Test] + public Task Grpc_Style_Abstract_Method_Generation_Snapshot() + { + var reference = CreateExternalAssemblyReference(GrpcStyleExternalLibrary); + + return VerifyGeneratorOutput(GrpcMockSource(), [reference]); + } + + [Test] + public async Task Composite_Inaccessible_Method_Parameter_Is_Mockable() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public abstract class CompositeClient + { + protected CompositeClient() { } + + protected abstract CompositeClient NewInstance( + System.Collections.Generic.IReadOnlyDictionary states); + + protected internal class State { } + } + """); + var source = MockSource("ExternalLib.CompositeClient"); + + await AssertNoAccessibilityErrors(source, reference); + } + + [Test] + public async Task Inaccessible_Method_Return_Type_Is_Mockable() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public abstract class StateClient + { + protected StateClient() { } + + protected abstract State GetState(); + + protected internal class State { } + } + """); + var source = MockSource("ExternalLib.StateClient"); + + await AssertNoAccessibilityErrors(source, reference); + } + + [Test] + public async Task Protected_Method_Parameter_Is_Omitted_Even_In_Same_Assembly() + { + var source = """ + using TUnit.Mocks; + + public abstract class LocalClient + { + protected LocalClient() { } + + protected abstract LocalClient NewInstance(State state); + + protected class State { } + } + + public class Test + { + public void Run() => Mock.Of(); + } + """; + + await AssertNoAccessibilityErrors(source); + } + [Test] public async Task Inaccessible_Types_Are_Found_Inside_Arrays_And_Generic_Arguments() { @@ -264,4 +367,18 @@ public class Test public void Run() => Mock.Of<{{typeName}}>(); } """; + + private static string GrpcMockSource() => """ + using TUnit.Mocks; + + public class Test + { + public void Run() + { + var mock = Mock.Of(); + mock.Call().Returns("mocked"); + _ = mock.Object.Clone(); + } + } + """; } diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Grpc_Style_Abstract_Method_Generation_Snapshot.verified.txt b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Grpc_Style_Abstract_Method_Generation_Snapshot.verified.txt new file mode 100644 index 0000000000..7c907ec037 --- /dev/null +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Grpc_Style_Abstract_Method_Generation_Snapshot.verified.txt @@ -0,0 +1,151 @@ +// +#pragma warning disable +#nullable enable + +namespace ExternalLib +{ + file sealed class GrpcClientMockImpl : global::ExternalLib.GrpcClient, 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; } + + [global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers] + internal GrpcClientMockImpl(global::TUnit.Mocks.MockEngine engine) : base() + { + _engine = engine; + } + + protected override global::ExternalLib.GrpcClient NewInstance(global::ExternalLib.GrpcClient.ClientBaseConfiguration configuration) + { + return _engine.HandleCallWithReturn(0, "NewInstance", configuration, default!); + } + + public override string Call() + { + return _engine.HandleCallWithReturn(1, "Call", global::System.Array.Empty(), ""); + } + + [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 GrpcClientPartialMockFactory + { + [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 GrpcClientMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated +{ + public static class ExternalLib_GrpcClient_MockMemberExtensions + { + public static global::TUnit.Mocks.MockMethodCall Call(this global::TUnit.Mocks.Mock mock) + { + var matchers = global::System.Array.Empty(); + return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "Call", matchers); + } + + #if NET9_0_OR_GREATER + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void Reset(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.Reset(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyAll(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyAll(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyNoOtherCalls(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyNoOtherCalls(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetupAllProperties(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.SetupAllProperties(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static global::TUnit.Mocks.Diagnostics.MockDiagnostics GetDiagnostics(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.GetDiagnostics(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetState(this global::TUnit.Mocks.Mock mock, string? stateName) + => global::TUnit.Mocks.Mock.SetState(mock, stateName); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void InState(this global::TUnit.Mocks.Mock mock, string stateName, global::System.Action> configure) + => global::TUnit.Mocks.Mock.InState(mock, stateName, configure); + + extension(global::TUnit.Mocks.Mock mock) + { + public global::System.Collections.Generic.IReadOnlyList Invocations => global::TUnit.Mocks.Mock.Invocations(mock); + + public global::TUnit.Mocks.MockBehavior Behavior => global::TUnit.Mocks.Mock.Behavior(mock); + + public global::TUnit.Mocks.IDefaultValueProvider? DefaultValueProvider + { + get => global::TUnit.Mocks.Mock.GetDefaultValueProvider(mock); + set => global::TUnit.Mocks.Mock.SetDefaultValueProvider(mock, value); + } + } + #endif + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks +{ + public static class ExternalLib_GrpcClient_MockStaticExtension + { + extension(global::ExternalLib.GrpcClient _) + { + public static global::TUnit.Mocks.Mock Mock() + { + return global::TUnit.Mocks.Mock.Of(); + } + + public static global::TUnit.Mocks.Mock Mock(global::TUnit.Mocks.MockBehavior behavior) + { + return global::TUnit.Mocks.Mock.Of(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file From c06f3c5d5f3df24d66358892b0ab572c110df1c7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:54:25 +0100 Subject: [PATCH 2/2] fix(mocks): filter all inaccessible surfaces Keep signature filtering aligned across members and generated helpers. Refs #6634 --- .../Builders/MockImplBuilder.cs | 12 +- .../Builders/MockMembersBuilder.cs | 39 ++-- .../Discovery/MemberDiscovery.cs | 21 +- .../Models/MockEventModel.cs | 8 + .../Issue6634Tests.cs | 214 +++++++++++++++++- 5 files changed, 268 insertions(+), 26 deletions(-) diff --git a/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 56ced6565a..723f1a08b6 100644 --- a/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -1208,7 +1208,8 @@ private static void GenerateEvent(CodeWriter writer, MockEventModel evt) ? "" : string.Join(", ", evt.RaiseParameterList.Select(p => $"{p.FullyQualifiedType} {p.Name}")); var invokeArgs = string.IsNullOrEmpty(evt.InvokeArgs) ? "" : evt.InvokeArgs; - using (writer.Block($"internal void Raise_{evt.Name}({raiseParams})")) + var raiseAccessModifier = evt.IsSignatureAccessibleFromAssembly ? "internal" : "private"; + using (writer.Block($"{raiseAccessModifier} void Raise_{evt.Name}({raiseParams})")) { if (string.IsNullOrEmpty(invokeArgs)) { @@ -1242,7 +1243,8 @@ private static void GeneratePartialEvent(CodeWriter writer, MockEventModel evt) ? "" : string.Join(", ", evt.RaiseParameterList.Select(p => $"{p.FullyQualifiedType} {p.Name}")); var invokeArgs = string.IsNullOrEmpty(evt.InvokeArgs) ? "" : evt.InvokeArgs; - using (writer.Block($"internal void Raise_{evt.Name}({raiseParams})")) + var raiseAccessModifier = evt.IsSignatureAccessibleFromAssembly ? "internal" : "private"; + using (writer.Block($"{raiseAccessModifier} void Raise_{evt.Name}({raiseParams})")) { if (string.IsNullOrEmpty(invokeArgs)) { @@ -1589,9 +1591,13 @@ private static void EmitOutRefParamAssignments(CodeWriter writer, MockMemberMode /// struct out/ref params. Generic mock types and generic methods are excluded — their /// param types may reference type parameters that aren't fully bound at delegate-decl /// time and would require an allows ref struct constraint (C# 13, net9.0+ runtime). + /// Methods absent from the setup surface cannot register a setter and must not reference its + /// otherwise-unemitted delegate from the implementation. /// internal static bool SupportsClosedRefStructSetter(MockTypeModel model, MockMemberModel method) - => !method.IsGenericMethod && model.TypeParameters.Length == 0; + => method.IsSignatureAccessibleFromAssembly + && !method.IsGenericMethod + && model.TypeParameters.Length == 0; internal static string EmitArgsArrayVariable(CodeWriter writer, MockMemberModel method) { diff --git a/src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index ee7079dbba..468773c05c 100644 --- a/src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -71,7 +71,7 @@ public static string Build(MockTypeModel model) // name appends the interface; for everything else it equals GetSafeName(FullyQualifiedName). var safeName = MockImplBuilder.GetCompositeSafeName(model); var mockableType = MockImplBuilder.GetMockableTypeName(model); - var instanceEvents = model.Events.Where(e => !e.IsStaticAbstract).ToArray(); + var instanceEvents = model.Events.Where(IsConfigurableEvent).ToArray(); var hasEvents = instanceEvents.Length > 0; var mockNamespace = MockImplBuilder.GetMockNamespace(model); @@ -104,8 +104,7 @@ public static string Build(MockTypeModel model) // setup/verify. Static abstract methods legitimately use ExplicitInterfaceName // for bridge interface generation and still need setup extensions. var instanceMethods = new EquatableArray( - model.Methods.Where(m => m.IsSignatureAccessibleFromAssembly - && (m.ExplicitInterfaceName is null || m.IsStaticAbstract)).ToImmutableArray()); + model.Methods.Where(IsConfigurableMethod).ToImmutableArray()); var methodsWithDisambiguation = ApplyOutDisambiguation(instanceMethods); // Methods @@ -119,7 +118,9 @@ public static string Build(MockTypeModel model) // Properties -- extension properties via C# 14 extension blocks // (skip ref struct properties — can't use PropertyMockCall) var memberProps = model.Properties - .Where(p => p.IsConfigurableSurfaceProperty && (p.ExplicitInterfaceName is null || p.IsStaticAbstract)) + .Where(p => p.IsSignatureAccessibleFromAssembly + && p.IsConfigurableSurfaceProperty + && (p.ExplicitInterfaceName is null || p.IsStaticAbstract)) .ToList(); if (memberProps.Count > 0) { @@ -133,6 +134,7 @@ public static string Build(MockTypeModel model) // Each indexer overload (different parameter signature) gets its own pair. var indexers = model.Properties .Where(p => p.IsIndexer + && p.IsSignatureAccessibleFromAssembly && !p.IsStaticAbstract && !p.IsRefStructReturn && !p.IsReturnTypeStaticAbstractInterface) @@ -179,7 +181,7 @@ public static string Build(MockTypeModel model) // The out/ref setter delegates stay beside the mocked type: the generated impl references // them through GetGlobalMockNamespacePrefix, and they are named from the type's short name. - EmitOutRefSetterDelegateNamespace(writer, model, hasEvents, mockNamespace); + EmitOutRefSetterDelegateNamespace(writer, model, mockNamespace); return writer.ToString(); } @@ -187,14 +189,21 @@ public static string Build(MockTypeModel model) /// Methods that get a typed call wrapper — the shared filter for both emission passes. private static IEnumerable WrappedMethods(MockTypeModel model, bool hasEvents) => model.Methods.Where(m => - m.IsSignatureAccessibleFromAssembly - && (m.ExplicitInterfaceName is null || m.IsStaticAbstract) + IsConfigurableMethod(m) && ShouldGenerateTypedWrapper(m, model, hasEvents)); - private static void EmitOutRefSetterDelegateNamespace(CodeWriter writer, MockTypeModel model, bool hasEvents, string mockNamespace) + private static bool IsConfigurableMethod(MockMemberModel method) + => method.IsSignatureAccessibleFromAssembly + && (method.ExplicitInterfaceName is null || method.IsStaticAbstract); + + private static bool IsConfigurableEvent(MockEventModel evt) + => evt.IsSignatureAccessibleFromAssembly && !evt.IsStaticAbstract; + + private static void EmitOutRefSetterDelegateNamespace(CodeWriter writer, MockTypeModel model, string mockNamespace) { - var methodsNeedingDelegates = WrappedMethods(model, hasEvents) - .Where(m => MockImplBuilder.SupportsClosedRefStructSetter(model, m) + var methodsNeedingDelegates = model.Methods + .Where(m => !m.IsStaticAbstract + && MockImplBuilder.SupportsClosedRefStructSetter(model, m) && m.Parameters.Any(p => p.Direction is ParameterDirection.Out or ParameterDirection.Ref && p.IsNonSpanRefStruct)) .ToList(); @@ -1081,7 +1090,7 @@ private static (bool UseTypedWrapper, string ReturnType, string SetupReturnType) ? method.UnwrappedReturnType : method.ReturnType; - var hasEvents = model.Events.Any(e => !e.IsStaticAbstract); + var hasEvents = model.Events.Any(IsConfigurableEvent); var useTypedWrapper = ShouldGenerateTypedWrapper(method, model, hasEvents); string returnType; @@ -1225,9 +1234,8 @@ private static void EmitAnyArgsOverload(CodeWriter writer, MockMemberModel metho // Name uniqueness: same set of methods that drive extension-method emission. int sameNameCount = 0; - foreach (var m in model.Methods) + foreach (var m in model.Methods.Where(IsConfigurableMethod)) { - if (m.ExplicitInterfaceName is not null && !m.IsStaticAbstract) continue; if (m.Name == method.Name) sameNameCount++; } if (sameNameCount > 1) return; @@ -1609,7 +1617,7 @@ private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeMod var typeParams = MockImplBuilder.GetTypeParameterList(model); var constraints = MockImplBuilder.GetConstraintClauses(model); bool first = true; - foreach (var evt in model.Events.Where(e => !e.IsStaticAbstract)) + foreach (var evt in model.Events.Where(IsConfigurableEvent)) { if (!first) writer.AppendLine(); first = false; @@ -1767,10 +1775,9 @@ private static void EmitParamsAnyArgOverload(CodeWriter writer, MockMemberModel // Two same-name params methods that differ only in element type (e.g. M(params int[]) and // M(params string[])) would both produce this AnyArg-slotted signature — skip on collision. - foreach (var m in model.Methods) + foreach (var m in model.Methods.Where(IsConfigurableMethod)) { if (m.MemberId == method.MemberId || m.Name != method.Name) continue; - if (m.ExplicitInterfaceName is not null && !m.IsStaticAbstract) continue; if (m.TypeParameters.Length != method.TypeParameters.Length) continue; var mLast = m.Parameters.Length > 0 ? m.Parameters[m.Parameters.Length - 1] : null; if (mLast is null || mLast.ParamsElementType is null || mLast.Direction != ParameterDirection.In) continue; diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index df085e79b9..f9c8af504d 100644 --- a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -364,7 +364,7 @@ private static void CollectMembers( } var explicitName = RequiresExplicitImpl(primaryClassSymbol, evt) ? interfaceFqn : null; - state.Events.Add(Tag(CreateEventModel(evt, explicitName, interfaceFqn), ownerTypeIndex)); + state.Events.Add(Tag(CreateEventModel(evt, explicitName, interfaceFqn, compilation: compilation), ownerTypeIndex)); break; } } @@ -511,7 +511,7 @@ private static void ProcessClassMembers( if (evt.IsAbstract || evt.IsVirtual || evt.IsOverride) { if (!seenEvents.Add(key)) continue; - events.Add(CreateEventModel(evt, null, compilationAssembly: compilationAssembly)); + events.Add(CreateEventModel(evt, null, compilationAssembly: compilationAssembly, compilation: compilation)); } else { @@ -758,7 +758,10 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m private static bool IsMethodSignatureAccessibleFromAssembly(IMethodSymbol method, Compilation compilation) => TypeAccessibility.IsAccessibleFromAssembly(method.ReturnType, compilation) && method.Parameters.All(parameter => - TypeAccessibility.IsAccessibleFromAssembly(parameter.Type, compilation)); + TypeAccessibility.IsAccessibleFromAssembly(parameter.Type, compilation)) + && method.TypeParameters.All(typeParameter => + typeParameter.ConstraintTypes.All(constraint => + TypeAccessibility.IsAccessibleFromAssembly(constraint, compilation))); /// /// When a property with the same name appears from multiple interfaces, merge getter/setter @@ -825,6 +828,7 @@ private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref SetterAccessModifier = GetAccessorAccessModifier(property.SetMethod, overrideAccessModifier, compilationAssembly), IsRefStructReturn = property.Type.IsRefLikeType, AutoMockFactoryMethod = GetAutoMockFactoryMethod(property.Type, compilation), + IsSignatureAccessibleFromAssembly = IsPropertySignatureAccessibleFromAssembly(property, compilation), IsReturnTypeStaticAbstractInterface = IsInterfaceWithStaticAbstractMembers(property.Type), SpanReturnElementType = property.Type.IsRefLikeType ? GetSpanElementType(property.Type) : null, ObsoleteAttribute = propertyObsolete, @@ -833,6 +837,11 @@ private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref }; } + private static bool IsPropertySignatureAccessibleFromAssembly(IPropertySymbol property, Compilation compilation) + => TypeAccessibility.IsAccessibleFromAssembly(property.Type, compilation) + && property.Parameters.All(parameter => + TypeAccessibility.IsAccessibleFromAssembly(parameter.Type, compilation)); + /// Returns the [Obsolete] attribute for a single accessor, but only when the /// containing property is NOT itself marked obsolete. When the property is marked, the /// property-level emission already covers the accessor and emitting both would duplicate. @@ -950,6 +959,7 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i SetterAccessModifier = GetAccessorAccessModifier(indexer.SetMethod, overrideAccessModifier, compilationAssembly), IsRefStructReturn = indexer.Type.IsRefLikeType, AutoMockFactoryMethod = GetAutoMockFactoryMethod(indexer.Type, compilation), + IsSignatureAccessibleFromAssembly = IsPropertySignatureAccessibleFromAssembly(indexer, compilation), IsReturnTypeStaticAbstractInterface = IsInterfaceWithStaticAbstractMembers(indexer.Type), SpanReturnElementType = indexer.Type.IsRefLikeType ? GetSpanElementType(indexer.Type) : null, ObsoleteAttribute = indexerObsolete, @@ -997,7 +1007,7 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i return $"{globalPrefix}{baseName}MockFactory.CreateAutoMock<{typeArguments}>"; } - private static MockEventModel CreateEventModel(IEventSymbol evt, string? explicitInterfaceName, string? declaringInterfaceName = null, IAssemblySymbol? compilationAssembly = null) + private static MockEventModel CreateEventModel(IEventSymbol evt, string? explicitInterfaceName, string? declaringInterfaceName = null, IAssemblySymbol? compilationAssembly = null, Compilation compilation = null!) { var eventHandlerType = evt.Type.GetFullyQualifiedNameWithNullability(); @@ -1058,6 +1068,7 @@ private static MockEventModel CreateEventModel(IEventSymbol evt, string? explici ExplicitInterfaceName = explicitInterfaceName, DeclaringInterfaceName = declaringInterfaceName, OverrideAccessModifier = GetOverrideAccessModifier(evt, compilationAssembly), + IsSignatureAccessibleFromAssembly = TypeAccessibility.IsAccessibleFromAssembly(evt.Type, compilation), RaiseParameterList = raiseParameterList, ObsoleteAttribute = GetObsoleteAttributeSyntax(evt) }; @@ -1341,7 +1352,7 @@ private static void CollectStaticAbstractMember( var key = $"E:{evt.Name}"; if (!seenEvents.Add(key)) break; - var model = CreateEventModel(evt, interfaceFqn) with + var model = CreateEventModel(evt, interfaceFqn, compilation: compilation) with { IsStaticAbstract = true }; diff --git a/src/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs b/src/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs index f0152e71e3..0c723641e6 100644 --- a/src/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs +++ b/src/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs @@ -47,6 +47,12 @@ internal sealed record MockEventModel : IEquatable public string OverrideAccessModifier { get; init; } = "public"; public bool IsStaticAbstract { get; init; } + /// + /// Whether non-derived generated code in the consumer assembly can name the event handler + /// type. Inaccessible events remain implemented but get no typed raise surface. + /// + public bool IsSignatureAccessibleFromAssembly { get; init; } = true; + /// /// Which type in a multi-type mock owns this event: 0 = the primary type, /// n = 1-based index into . @@ -75,6 +81,7 @@ public bool Equals(MockEventModel? other) && AdditionalExplicitInterfaceNames.Equals(other.AdditionalExplicitInterfaceNames) && OverrideAccessModifier == other.OverrideAccessModifier && IsStaticAbstract == other.IsStaticAbstract + && IsSignatureAccessibleFromAssembly == other.IsSignatureAccessibleFromAssembly && OwnerTypeIndex == other.OwnerTypeIndex && RaiseParameterList == other.RaiseParameterList && ObsoleteAttribute == other.ObsoleteAttribute; @@ -92,6 +99,7 @@ public override int GetHashCode() hash = hash * 31 + (DeclaringInterfaceName?.GetHashCode() ?? 0); hash = hash * 31 + AdditionalExplicitInterfaceNames.GetHashCode(); hash = hash * 31 + OverrideAccessModifier.GetHashCode(); + hash = hash * 31 + IsSignatureAccessibleFromAssembly.GetHashCode(); hash = hash * 31 + ObsoleteAttribute.GetHashCode(); hash = hash * 31 + OwnerTypeIndex; return hash; diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6634Tests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6634Tests.cs index c2764c8d75..c6bafeeb8a 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6634Tests.cs +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6634Tests.cs @@ -135,6 +135,207 @@ public class Test await AssertNoAccessibilityErrors(source); } + [Test] + public async Task Inaccessible_Generic_Constraint_Is_Omitted_From_Member_Surface() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public abstract class GenericClient + { + protected GenericClient() { } + + protected abstract void Handle(T value) where T : State; + + protected internal class State { } + + public abstract void Ping(T value); + } + """); + var source = MockSource("ExternalLib.GenericClient"); + var sources = RunGenerator(source, [reference]); + var memberSurface = GetMemberSurface(sources); + + await AssertNoAccessibilityErrors(source, reference); + await Assert.That(string.Join(Environment.NewLine, sources)).Contains("protected override void Handle(T value)"); + await Assert.That(memberSurface).DoesNotContain("Handle"); + await Assert.That(memberSurface).Contains("Ping"); + } + + [Test] + public async Task Inaccessible_Overload_Does_Not_Suppress_AnyArgs_Helper() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public abstract class OverloadClient + { + protected OverloadClient() { } + + public abstract string Search(string query, int count); + + protected abstract string Search(State state, int count); + + protected internal class State { } + } + """); + var source = MockSource("ExternalLib.OverloadClient"); + var memberSurface = GetMemberSurface(RunGenerator(source, [reference])); + + await AssertNoAccessibilityErrors(source, reference); + await Assert.That(memberSurface).Contains( + "Search(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.AnyArgs _)"); + } + + [Test] + public async Task Inaccessible_Params_Overload_Does_Not_Suppress_AnyArg_Helper() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public abstract class ParamsClient + { + protected ParamsClient() { } + + public abstract int Pack(params int[] values); + + protected abstract int Pack(params State[] values); + + protected internal class State { } + } + """); + var source = MockSource("ExternalLib.ParamsClient"); + var memberSurface = GetMemberSurface(RunGenerator(source, [reference])); + + await AssertNoAccessibilityErrors(source, reference); + await Assert.That(memberSurface).Contains("global::TUnit.Mocks.Arguments.AnyArg values"); + } + + [Test] + public async Task Inaccessible_Property_Type_Is_Omitted_From_Member_Surface() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public abstract class PropertyClient + { + protected PropertyClient() { } + + protected abstract State Hidden { get; } + + public abstract string Visible { get; } + + protected internal class State { } + } + """); + var source = MockSource("ExternalLib.PropertyClient"); + var memberSurface = GetMemberSurface(RunGenerator(source, [reference])); + + await AssertNoAccessibilityErrors(source, reference); + await Assert.That(memberSurface).DoesNotContain("Hidden"); + await Assert.That(memberSurface).Contains("Visible"); + } + + [Test] + public async Task Inaccessible_Indexer_Types_Are_Omitted_From_Member_Surface() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public abstract class IndexerClient + { + protected IndexerClient() { } + + protected abstract State this[State state] { get; } + + public abstract string this[int index] { get; } + + protected internal class State { } + } + """); + var source = MockSource("ExternalLib.IndexerClient"); + var memberSurface = GetMemberSurface(RunGenerator(source, [reference])); + + await AssertNoAccessibilityErrors(source, reference); + await Assert.That(memberSurface).DoesNotContain("IndexerClient.State"); + await Assert.That(memberSurface).Contains("global::TUnit.Mocks.Arguments.Arg index"); + } + + [Test] + public async Task Inaccessible_Event_Type_Has_No_Typed_Raise_Surface() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public abstract class EventClient + { + protected EventClient() { } + + protected abstract event System.EventHandler? Hidden; + + public abstract event System.EventHandler? Visible; + + protected internal class State { } + } + """); + var source = MockSource("ExternalLib.EventClient"); + var memberSurface = GetMemberSurface(RunGenerator(source, [reference])); + + await AssertNoAccessibilityErrors(source, reference); + await Assert.That(memberSurface).DoesNotContain("RaiseHidden"); + await Assert.That(memberSurface).Contains("RaiseVisible"); + } + + [Test] + public async Task Inaccessible_Method_Emits_No_Dangling_RefStruct_Setter_Delegate() + { + var reference = CreateExternalAssemblyReference(""" + namespace ExternalLib; + + public ref struct Buffer { } + + public abstract class ReaderClient + { + protected ReaderClient() { } + + protected abstract void Read(State state, ref Buffer buffer); + + protected internal class State { } + } + """); + var source = MockSource("ExternalLib.ReaderClient"); + var generated = string.Join(Environment.NewLine, RunGenerator(source, [reference])); + + await AssertNoCompilerErrors(source, reference, "CS0051", "CS0122", "CS0246"); + await Assert.That(generated).DoesNotContain("ReaderClient_Read_M0_Buffer_RefSetter"); + } + + [Test] + public async Task Unresolved_Signature_Type_Does_Not_Abort_Generation_Or_Leak_Into_Surface() + { + var source = """ + using TUnit.Mocks; + + public abstract class ErrorClient + { + public abstract MissingType Transform(MissingType value); + } + + public class Test + { + public void Run() => Mock.Of(); + } + """; + + var (sources, diagnostics) = RunGeneratorForDiagnostics(source); + var generatorErrors = diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToList(); + var memberSurface = GetMemberSurface(sources); + + await Assert.That(generatorErrors).IsEmpty(); + await Assert.That(string.Join(Environment.NewLine, sources)).Contains("Transform"); + await Assert.That(memberSurface).DoesNotContain("Transform"); + } + [Test] public async Task Inaccessible_Types_Are_Found_Inside_Arrays_And_Generic_Arguments() { @@ -346,19 +547,28 @@ private static async Task GenerateFromExternalLibrary(string externalLib return string.Join(Environment.NewLine, RunGenerator(source, [reference])); } - private static async Task AssertNoAccessibilityErrors( + private static Task AssertNoAccessibilityErrors( string source, MetadataReference? reference = null) + => AssertNoCompilerErrors(source, reference, "CS0051", "CS0122"); + + private static async Task AssertNoCompilerErrors( + string source, + MetadataReference? reference, + params string[] diagnosticIds) { MetadataReference[]? references = reference is null ? null : [reference]; var errors = GetGeneratedCompilationErrors(source, references) - .Where(diagnostic => diagnostic.Id is "CS0051" or "CS0122") + .Where(diagnostic => diagnosticIds.Contains(diagnostic.Id, StringComparer.Ordinal)) .Select(diagnostic => diagnostic.ToString()) .ToList(); await Assert.That(errors).IsEmpty(); } + private static string GetMemberSurface(string[] sources) + => sources.Single(source => source.Contains("_MockMemberExtensions", StringComparison.Ordinal)); + private static string MockSource(string typeName) => $$""" using TUnit.Mocks;