diff --git a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs index 0ab90543..a197dee3 100644 --- a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs +++ b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs @@ -466,10 +466,11 @@ private static bool IsMockable(ITypeSymbol typeSymbol, IAssemblySymbol sourceAss return false; } - if (FindInaccessibleRequiredMember(typeSymbol, sourceAssembly) is { } inaccessibleMember) + if (FindInaccessibleRequiredMember(typeSymbol, sourceAssembly) is { } inaccessible) { - reason = - $"the member '{inaccessibleMember.ToDisplayString()}' must be implemented, but it is not accessible from this assembly"; + reason = inaccessible.InaccessibleType is { } inaccessibleType + ? $"the member '{inaccessible.Member.ToDisplayString()}' must be implemented, but its signature uses the type '{inaccessibleType.ToDisplayString()}', which is not accessible from this assembly" + : $"the member '{inaccessible.Member.ToDisplayString()}' must be implemented, but it is not accessible from this assembly"; return false; } @@ -477,7 +478,8 @@ private static bool IsMockable(ITypeSymbol typeSymbol, IAssemblySymbol sourceAss return true; } - private static ISymbol? FindInaccessibleRequiredMember(ITypeSymbol type, IAssemblySymbol sourceAssembly) + private static (ISymbol Member, ITypeSymbol? InaccessibleType)? FindInaccessibleRequiredMember(ITypeSymbol type, + IAssemblySymbol sourceAssembly) { HashSet filledSlots = new(StringComparer.Ordinal); @@ -559,17 +561,99 @@ private static IEnumerable EnumerateFilledSlots(ISymbol member) } } - private static ISymbol? FindInaccessibleMember(ISymbol member, IAssemblySymbol sourceAssembly) - => member switch - { - IMethodSymbol { MethodKind: MethodKind.Ordinary, IsAbstract: true, } method - => IsAccessibleFrom(method, sourceAssembly) ? null : method, - IPropertySymbol { IsAbstract: true, } property - => FindInaccessibleAccessor(property, sourceAssembly), - IEventSymbol { IsAbstract: true, } @event - => IsAccessibleFrom(@event, sourceAssembly) ? null : @event, - _ => null, - }; + private static (ISymbol Member, ITypeSymbol? InaccessibleType)? FindInaccessibleMember(ISymbol member, + IAssemblySymbol sourceAssembly) + { + switch (member) + { + case IMethodSymbol { MethodKind: MethodKind.Ordinary, IsAbstract: true, } method: + return !IsAccessibleFrom(method, sourceAssembly) + ? (method, null) + : Combine(method, FindInaccessibleSignatureType(method, sourceAssembly)); + case IPropertySymbol { IsAbstract: true, } property: + return FindInaccessibleAccessor(property, sourceAssembly) is { } accessor + ? (accessor, null) + : Combine(property, FindInaccessibleSignatureType(property, sourceAssembly)); + case IEventSymbol { IsAbstract: true, } @event: + return !IsAccessibleFrom(@event, sourceAssembly) + ? (@event, null) + : Combine(@event, FindInaccessibleSignatureType(@event, sourceAssembly)); + default: + return null; + } + + static (ISymbol Member, ITypeSymbol? InaccessibleType)? Combine(ISymbol member, ITypeSymbol? inaccessibleType) + => inaccessibleType is null ? null : (member, inaccessibleType); + } + + /// + /// The first type named in 's signature that the mock cannot restate, + /// or when the whole signature is reachable. Mirrors + /// Helpers.HasAccessibleSignature in the source generator; keep both in sync. + /// + private static ITypeSymbol? FindInaccessibleSignatureType(ISymbol member, IAssemblySymbol sourceAssembly) + { + switch (member) + { + case IMethodSymbol method: + return FirstInaccessible([ + method.ReturnType, + ..method.Parameters.Select(parameter => parameter.Type), + ..method.TypeParameters.SelectMany(typeParameter => typeParameter.ConstraintTypes), + ]); + case IPropertySymbol property: + return FirstInaccessible([ + property.Type, ..property.Parameters.Select(parameter => parameter.Type), + ]); + case IEventSymbol @event: + return FirstInaccessible([@event.Type,]); + default: + return null; + } + + ITypeSymbol? FirstInaccessible(IEnumerable types) + => types.FirstOrDefault(type => !IsTypeAccessibleFrom(type, sourceAssembly)); + } + + /// + /// Mirror of Helpers.IsAccessibleFrom: every type in the containing chain and every + /// composed type must be public, or internal/protected internal with access granted. The + /// half never counts, because the surfaces naming the type do not + /// derive from the mocked type. + /// + private static bool IsTypeAccessibleFrom(ITypeSymbol type, IAssemblySymbol sourceAssembly) + { + switch (type) + { + case IArrayTypeSymbol array: + return IsTypeAccessibleFrom(array.ElementType, sourceAssembly); + case IPointerTypeSymbol pointer: + return IsTypeAccessibleFrom(pointer.PointedAtType, sourceAssembly); + case INamedTypeSymbol named: + for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType) + { + if (!IsDeclarationAccessible(t) || + !t.TypeArguments.All(argument => IsTypeAccessibleFrom(argument, sourceAssembly))) + { + return false; + } + } + + return true; + default: + return true; + } + + bool IsDeclarationAccessible(INamedTypeSymbol candidate) + => candidate.DeclaredAccessibility switch + { + Accessibility.Public => true, + Accessibility.Internal or Accessibility.ProtectedOrInternal => + SymbolEqualityComparer.Default.Equals(candidate.ContainingAssembly, sourceAssembly) || + candidate.ContainingAssembly?.GivesAccessTo(sourceAssembly) == true, + _ => false, + }; + } private static ISymbol? FindInaccessibleAccessor(IPropertySymbol property, IAssemblySymbol sourceAssembly) { diff --git a/Source/Mockolate.SourceGenerators/Entities/Class.cs b/Source/Mockolate.SourceGenerators/Entities/Class.cs index 3711ebaa..b2d048eb 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Class.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Class.cs @@ -193,10 +193,13 @@ bool ShouldIncludeMember(ISymbol member) if (IsInterface || member.IsAbstract) { + // An abstract member is kept even when it cannot be restated; + // `ComputeHasInaccessibleRequiredMember` then rejects the whole type. return true; } - return Helpers.IsOverridableFrom(member, _sourceAssembly); + return Helpers.IsOverridableFrom(member, _sourceAssembly) && + Helpers.HasAccessibleSignature(member, _sourceAssembly); } } @@ -257,7 +260,8 @@ private int ComputeSurfaceHash() /// /// True when a member the mock is still obliged to implement is invisible to the mock's - /// assembly, leaving no valid code the generator could emit for it. + /// assembly - either the member itself, or a type named in its signature - leaving no valid code + /// the generator could emit for it. /// /// /// Deliberately reads the filtered // @@ -280,7 +284,8 @@ private bool ComputeHasInaccessibleRequiredMember(List filledPropertie /// /// True when fills a base slot (by or by /// explicit interface implementation) that the mock must leave alone entirely, because the base - /// declaration or one of its accessors is invisible to . + /// declaration, one of its accessors, or a type in its signature is invisible to + /// . /// private static bool FillsInaccessibleBaseSlot(ISymbol member, IAssemblySymbol? sourceAssembly) { @@ -305,6 +310,7 @@ private static bool FillsInaccessibleBaseSlot(ISymbol member, IAssemblySymbol? s IAssemblySymbol? sourceAssembly) => member is IPropertySymbol { IsAbstract: false, OverriddenProperty: { } slot, } && Helpers.IsOverridableFrom(slot, sourceAssembly) && + Helpers.HasAccessibleSignature(slot, sourceAssembly) && HasUnreachableAccessor(slot, sourceAssembly) ? slot : null; @@ -359,6 +365,7 @@ private static IEnumerable EnumerateFilledSlots(ISymbol member) private static bool IsSlotReachable(ISymbol slot, IAssemblySymbol? sourceAssembly) => Helpers.IsOverridableFrom(slot, sourceAssembly) && + Helpers.HasAccessibleSignature(slot, sourceAssembly) && (slot is not IPropertySymbol property || !HasUnreachableAccessor(property, sourceAssembly)); private static bool HasUnreachableAccessor(IPropertySymbol property, IAssemblySymbol? sourceAssembly) diff --git a/Source/Mockolate.SourceGenerators/Entities/Event.cs b/Source/Mockolate.SourceGenerators/Entities/Event.cs index bb84c982..93a5253b 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Event.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Event.cs @@ -12,7 +12,8 @@ public Event(IEventSymbol eventSymbol, IMethodSymbol delegateInvokeMethod, List< OverrideAccessibility = Helpers.ResolveOverrideVisibility( Accessibility, eventSymbol.ContainingAssembly, sourceAssembly); UseOverride = eventSymbol.IsVirtual || eventSymbol.IsAbstract; - IsOverridableFromMock = Helpers.IsOverridableFrom(eventSymbol, sourceAssembly); + IsOverridableFromMock = Helpers.IsOverridableFrom(eventSymbol, sourceAssembly) && + Helpers.HasAccessibleSignature(eventSymbol, sourceAssembly); IsAbstract = eventSymbol.IsAbstract; Name = Helpers.EscapeIfKeyword(eventSymbol.ExplicitInterfaceImplementations.Length > 0 ? eventSymbol.ExplicitInterfaceImplementations[0].Name : eventSymbol.Name); Type = Type.From(eventSymbol.Type); diff --git a/Source/Mockolate.SourceGenerators/Entities/Method.cs b/Source/Mockolate.SourceGenerators/Entities/Method.cs index 67afd3b4..41c7cc54 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Method.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Method.cs @@ -12,7 +12,8 @@ public Method(IMethodSymbol methodSymbol, List? alreadyDefinedMethods, I OverrideAccessibility = Helpers.ResolveOverrideVisibility( Accessibility, methodSymbol.ContainingAssembly, sourceAssembly); UseOverride = methodSymbol.IsVirtual || methodSymbol.IsAbstract; - IsOverridableFromMock = Helpers.IsOverridableFrom(methodSymbol, sourceAssembly); + IsOverridableFromMock = Helpers.IsOverridableFrom(methodSymbol, sourceAssembly) && + Helpers.HasAccessibleSignature(methodSymbol, sourceAssembly); IsAbstract = methodSymbol.IsAbstract; IsStatic = methodSymbol.IsStatic; IsInitOnly = methodSymbol.IsInitOnly; diff --git a/Source/Mockolate.SourceGenerators/Entities/MockClass.cs b/Source/Mockolate.SourceGenerators/Entities/MockClass.cs index 77df75ff..b8393be4 100644 --- a/Source/Mockolate.SourceGenerators/Entities/MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Entities/MockClass.cs @@ -24,6 +24,10 @@ public MockClass(ITypeSymbol[] types, IAssemblySymbol sourceAssembly) : base(typ .Where(x => x.DeclaredAccessibility == Accessibility.Protected || x.DeclaredAccessibility == Accessibility.ProtectedOrInternal || x.DeclaredAccessibility == Accessibility.Public) + // Parameter types are named verbatim in `MockExtensionsForXXX`, which does not derive + // from the mocked type: a type reachable only through inheritance would cause CS0122 + // there and CS0051 on the generated `public` constructor. + .Where(x => x.Parameters.All(p => Helpers.IsAccessibleFrom(p.Type, sourceAssembly))) .Select(x => new Method(x, null, sourceAssembly)).ToArray()); if (namedTypeSymbol.DelegateInvokeMethod is not null) { diff --git a/Source/Mockolate.SourceGenerators/Entities/Property.cs b/Source/Mockolate.SourceGenerators/Entities/Property.cs index ef9ccf88..1e535767 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Property.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Property.cs @@ -42,7 +42,8 @@ public Property(IPropertySymbol propertySymbol, List? alreadyDefinedPr bool setterOverridable = propertySymbol.SetMethod is not { } setterSymbol || Helpers.IsOverridableFrom(setterSymbol, sourceAssembly); IsOverridableFromMock = getterOverridable && setterOverridable && - Helpers.IsOverridableFrom(propertySymbol, sourceAssembly); + Helpers.IsOverridableFrom(propertySymbol, sourceAssembly) && + Helpers.HasAccessibleSignature(propertySymbol, sourceAssembly); Getter = propertySymbol.GetMethod is { } getter && getterOverridable ? new Method(getter, null, sourceAssembly) diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index 3bfff6ce..621a971e 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -121,6 +121,69 @@ public static string ResolveOverrideVisibility(Accessibility accessibility, _ => "private", }; + /// + /// Conservative visibility test for a type the generator names verbatim (attribute names, + /// constructor parameter types, member signatures): every type in the containing chain and + /// every composed type (array element, pointed-at type, type argument) must be public, or + /// internal/protected internal with access granted. The protected half never counts, + /// because the surfaces naming the type do not derive from the mocked type. + /// + public static bool IsAccessibleFrom(ITypeSymbol type, IAssemblySymbol? sourceAssembly) + { + switch (type) + { + case IArrayTypeSymbol array: + return IsAccessibleFrom(array.ElementType, sourceAssembly); + case IPointerTypeSymbol pointer: + return IsAccessibleFrom(pointer.PointedAtType, sourceAssembly); + case INamedTypeSymbol named: + // For `Outer.Inner` the arguments sit on the containing type, so + // `named.TypeArguments` alone would miss them. + for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType) + { + if (!IsDeclarationAccessible(t) || + !t.TypeArguments.All(argument => IsAccessibleFrom(argument, sourceAssembly))) + { + return false; + } + } + + return true; + default: + return true; + } + + bool IsDeclarationAccessible(INamedTypeSymbol candidate) + => candidate.DeclaredAccessibility switch + { + Accessibility.Public => true, + Accessibility.Internal or Accessibility.ProtectedOrInternal => + sourceAssembly is null || HasInternalAccess(candidate.ContainingAssembly, sourceAssembly), + _ => false, + }; + } + + /// + /// True when every type named in 's signature (return/member type, + /// parameter types, generic constraint types) is accessible per . + /// The mock restates them on surfaces that do not derive from the mocked type + /// (IMockSetupForXXX, IMockVerifyForXXX, MockExtensionsForXXX), so a + /// nested type causes CS0122 there even though the + /// inside the mock class itself would compile. + /// + public static bool HasAccessibleSignature(ISymbol member, IAssemblySymbol? sourceAssembly) + => member switch + { + IMethodSymbol method => IsAccessibleFrom(method.ReturnType, sourceAssembly) && + method.Parameters.All(p => IsAccessibleFrom(p.Type, sourceAssembly)) && + method.TypeParameters.All(p => p.ConstraintTypes + .All(c => IsAccessibleFrom(c, sourceAssembly))), + IPropertySymbol property => IsAccessibleFrom(property.Type, sourceAssembly) && + property.Parameters.All(p => IsAccessibleFrom(p.Type, sourceAssembly)), + IEventSymbol @event => IsAccessibleFrom(@event.Type, sourceAssembly), + _ => true, + }; + private static bool HasInternalAccess(IAssemblySymbol? containingAssembly, IAssemblySymbol? sourceAssembly) { if (sourceAssembly is null || containingAssembly is null) @@ -263,6 +326,8 @@ public bool HasReservedName(string candidate) { public EquatableArray? ToAttributeArray(IAssemblySymbol? sourceAssembly = null) { + // The attribute name is emitted verbatim, so an attribute class invisible to the mock's + // assembly would cause CS0122. Drop it instead of emitting uncompilable code. Attribute[] consideredAttributes = attributes .Where(x => x.AttributeClass is not null && !IsCompilerEmittedAttribute(x.AttributeClass) @@ -292,43 +357,6 @@ static bool IsCompilerEmittedAttribute(INamedTypeSymbol attribute) or "AsyncStateMachineAttribute" or "IteratorStateMachineAttribute" or "AsyncIteratorStateMachineAttribute"; } - - // The attribute name is emitted verbatim into the generated code (e.g. - // `[global::Azure.Core.CallerShouldAudit(...)]`). If the attribute class — or any of its - // containing types — is not visible to the generated mock assembly, referencing it causes - // CS0122. Drop the attribute instead of producing uncompilable output. - // - // Conservative rule: a type is accessible only if its whole containing chain is either - // Public, or Internal/ProtectedOrInternal with InternalsVisibleTo granted (or the - // same assembly). Private/Protected/ProtectedAndInternal nested types and - // ProtectedOrInternal across assemblies without IVT are treated as inaccessible — the - // `protected` half would require knowing the derivation relationship to the declaring - // type, which we don't verify here. - static bool IsAccessibleFrom(INamedTypeSymbol attribute, IAssemblySymbol? sourceAssembly) - { - for (INamedTypeSymbol? t = attribute; t is not null; t = t.ContainingType) - { - switch (t.DeclaredAccessibility) - { - case Accessibility.Public: - continue; - case Accessibility.Internal: - case Accessibility.ProtectedOrInternal: - if (sourceAssembly is null || - SymbolEqualityComparer.Default.Equals(t.ContainingAssembly, sourceAssembly) || - t.ContainingAssembly.GivesAccessTo(sourceAssembly)) - { - continue; - } - - return false; - default: - return false; - } - } - - return true; - } } } diff --git a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs index b8eaa33d..cd7c08a9 100644 --- a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs +++ b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs @@ -151,6 +151,65 @@ public async Task WhenProtectedInternalAbstractMember_ShouldNotBeFlagged() => aw MockCreation, ExternalType("abstract class", "protected internal abstract void MyMember();")); + [Theory] + [InlineData("protected abstract void MyMember(MyConfiguration configuration);", + "Ext.MyExternalType.MyMember(Ext.MyExternalType.MyConfiguration)", "Ext.MyExternalType.MyConfiguration")] + [InlineData("protected abstract MyConfiguration MyMember();", "Ext.MyExternalType.MyMember()", + "Ext.MyExternalType.MyConfiguration")] + [InlineData("protected abstract MyConfiguration MyMember { get; set; }", "Ext.MyExternalType.MyMember", + "Ext.MyExternalType.MyConfiguration")] + [InlineData("protected abstract void MyMember() where T : MyConfiguration;", + "Ext.MyExternalType.MyMember()", "Ext.MyExternalType.MyConfiguration")] + [InlineData("protected abstract System.Collections.Generic.List MyMember();", + "Ext.MyExternalType.MyMember()", "System.Collections.Generic.List")] + public async Task WhenAbstractMemberSignatureUsesAnInaccessibleType_ShouldBeFlagged( + string member, string reportedMember, string reportedType) => await Verifier + .VerifyAnalyzerWithReferencedProjectAsync( + MockCreation, + ExternalType("abstract class", $$""" + {{member}} + protected internal class MyConfiguration { } + """), + Verifier.Diagnostic(Rules.MockabilityRule) + .WithLocation(0) + .WithArguments("Ext.MyExternalType", + $"the member '{reportedMember}' must be implemented, but its signature uses the type '{reportedType}', which is not accessible from this assembly") + ); + + [Fact] + public async Task WhenAbstractMemberSignatureUsesATypeVisibleToTheMockAssembly_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerWithReferencedProjectAsync( + MockCreation, + ExternalType("abstract class", """ + protected abstract void MyMember(MyConfiguration configuration); + protected internal class MyConfiguration { } + """, internalsVisibleToTestProject: true)); + + [Theory] + [InlineData("protected abstract void MyMember(MyConfiguration configuration);", + "protected override void MyMember(MyConfiguration configuration) { }")] + [InlineData("protected abstract MyConfiguration MyMember { get; set; }", + "protected override MyConfiguration MyMember { get; set; }")] + public async Task WhenAbstractMemberWithInaccessibleSignatureIsAlreadyOverridden_ShouldNotBeFlagged( + string baseMember, string derivedOverride) => await Verifier + .VerifyAnalyzerWithReferencedProjectAsync( + MockCreation, + $$""" + namespace Ext + { + public abstract class MyBaseType + { + {{baseMember}} + protected internal class MyConfiguration { } + } + + public class MyExternalType : MyBaseType + { + {{derivedOverride}} + } + } + """); + [Theory] [InlineData("private protected abstract void MyMember();", "Ext.MyExternalType.MyMember()")] [InlineData("private protected abstract int MyMember { get; set; }", "Ext.MyExternalType.MyMember.get")] diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs index dc61c7e1..b3f11f07 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs @@ -581,6 +581,350 @@ await That(result.Sources["Mock.MyService.g.cs"]) "Action does not collide with the setup action type (Action)"); } + [Fact] + public async Task WhenConstructorParameterTypeIsProtectedInternalNestedType_ShouldEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(new MyService.Configuration()); + } + } + + public class MyService + { + protected MyService(Configuration configuration) { } + protected internal class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .Contains("CreateMock(global::MyCode.MyService.Configuration configuration)") + .IgnoringNewlineStyle() + .Because("the internal half of `protected internal` is visible within the declaring assembly"); + } + + [Fact] + public async Task WhenConstructorParameterTypeIsProtectedNestedType_ShouldNotEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + public MyService() { } + protected MyService(Configuration configuration) { } + protected class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .Contains("public MyService(global::Mockolate.MockRegistry mockRegistry)").IgnoringNewlineStyle().And + .DoesNotContain("Configuration") + .Because( + "a protected nested type is only reachable through inheritance, so naming it on the generated public constructor (CS0051) or in MockExtensionsForMyService (CS0122) would not compile"); + } + + [Fact] + public async Task WhenConstructorParameterTypeIsProtectedNestedTypeArgument_ShouldNotEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using System.Collections.Generic; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + public MyService() { } + protected MyService(List configurations) { } + protected MyService(Configuration[] configurations) { } + protected class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .DoesNotContain("Configuration") + .Because("an inaccessible type is equally unusable when composed into an array or a type argument"); + } + + [Fact] + public async Task WhenOnlyConstructorHasProtectedNestedParameterType_ShouldNotGenerateMock() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + protected MyService(Configuration configuration) { } + protected class Configuration { } + } + """); + + await That(result.Sources).DoesNotContainKey("Mock.MyService.g.cs") + .Because("no constructor remains that the generated mock could invoke"); + } + + [Fact] + public async Task WhenConstructorParameterTypeNestsInProtectedTypeArgumentOfContainingType_ShouldNotEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class Wrapper + { + public class Inner { } + } + + public class MyService + { + public MyService() { } + protected MyService(Wrapper.Inner inner) { } + protected class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .DoesNotContain("Configuration") + .Because( + "`Wrapper.Inner` carries the type argument on its containing type, so checking only the innermost type's arguments would let the inaccessible type through"); + } + + [Fact] + public async Task WhenConstructorParameterTypeIsInternalInSameAssembly_ShouldEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(new Configuration()); + } + } + + internal class Configuration { } + + internal class MyService + { + protected MyService(Configuration configuration) { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .Contains("CreateMock(global::MyCode.Configuration configuration)") + .IgnoringNewlineStyle() + .Because("the generated mock lives in the same assembly, so an internal parameter type is nameable"); + } + + [Fact] + public async Task WhenVirtualMemberSignatureUsesProtectedNestedType_ShouldNotMockMember() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + protected virtual void Consume(Configuration configuration) { } + protected virtual Configuration Produce() => new Configuration(); + protected virtual Configuration Current { get; set; } + protected virtual void Constrained() where T : Configuration { } + protected class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .DoesNotContain("Configuration").And + .DoesNotContain("Consume").And + .DoesNotContain("Produce").And + .DoesNotContain("Current").And + .DoesNotContain("Constrained") + .Because( + "the setup and verify surfaces restate the signature verbatim but do not derive from the mocked type, so a protected nested type would cause CS0122 there"); + } + + [Fact] + public async Task WhenVirtualEventTypeUsesProtectedNestedType_ShouldNotMockEvent() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + protected virtual event Handler Changed; + protected void Fire() => Changed?.Invoke(); + protected delegate void Handler(); + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .DoesNotContain("Changed").And + .DoesNotContain("Handler") + .Because("the raise surface names the delegate type verbatim"); + } + + [Fact] + public async Task WhenAbstractMemberSignatureUsesProtectedNestedType_ShouldNotGenerateMock() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public abstract class MyService + { + protected abstract void Consume(Configuration configuration); + protected class Configuration { } + } + """); + + await That(result.Sources).DoesNotContainKey("Mock.MyService.g.cs") + .Because("the member must be implemented, but the mock cannot restate its signature"); + } + + [Theory] + [InlineData("protected abstract void Consume(Configuration configuration);", + "protected override void Consume(Configuration configuration) { }")] + [InlineData("protected abstract Configuration Current { get; set; }", + "protected override Configuration Current { get; set; }")] + [InlineData("protected abstract event Handler Changed;", + "protected override event Handler Changed; protected void Fire() => Changed?.Invoke();")] + public async Task WhenAbstractMemberWithInaccessibleSignatureIsAlreadyOverridden_ShouldGenerateMock( + string baseMember, string derivedOverride) + { + GeneratorResult result = Generator + .Run($$""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = Derived.CreateMock(); + } + } + + public abstract class Base + { + {{baseMember}} + protected class Configuration { } + protected delegate void Handler(); + } + + public class Derived : Base + { + {{derivedOverride}} + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.Derived.g.cs") + .Because("`Derived` already fills the slot, so restating the signature is not the mock's obligation"); + await That(result.Sources["Mock.Derived.g.cs"]) + .DoesNotContain("Configuration").And + .DoesNotContain("Handler"); + } + [Fact] public async Task WhenConstructorsDifferOnlyByNullableValueType_ShouldEmitBothTypedOverloads() { diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs index 81dddfb0..11dfe843 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs @@ -6,6 +6,17 @@ public sealed partial class MockTests { public sealed class CrossAssemblyTests { + private const string CreateMockForClientBase = """ + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) => _ = Ext.ClientBase.CreateMock(); + } + """; + private const string CreateMockForMyBaseClass = """ using Mockolate; @@ -91,6 +102,90 @@ await That(result.Sources["Mock.MyBaseClass.g.cs"]) .DoesNotContain("protected internal set"); } + [Fact] + public async Task ProtectedInternalNestedConstructorParameter_WithInternalsVisibleTo_ShouldEmitConstructor() + { + MetadataReference external = CompileClientBaseAssembly(grantsInternalsVisibleTo: true); + + GeneratorResult result = Generator.RunWithReferences(CreateMockForClientBase, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.ClientBase.g.cs"); + await That(result.Sources["Mock.ClientBase.g.cs"]) + .Contains("CreateMock(global::Ext.ClientBase.ClientBaseConfiguration configuration)") + .Because("InternalsVisibleTo makes the internal half of `protected internal` visible"); + } + + [Fact] + public async Task ProtectedInternalNestedConstructorParameter_WithoutInternalsVisibleTo_ShouldNotEmitConstructor() + { + MetadataReference external = CompileClientBaseAssembly(grantsInternalsVisibleTo: false); + + GeneratorResult result = Generator.RunWithReferences(CreateMockForClientBase, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.ClientBase.g.cs"); + await That(result.Sources["Mock.ClientBase.g.cs"]) + .Contains("public ClientBase(global::Mockolate.MockRegistry mockRegistry)").And + .DoesNotContain("ClientBaseConfiguration") + .Because( + "`protected internal` degrades to `protected` outside the declaring assembly, so the type is only reachable through inheritance and cannot be named on the generated public constructor (CS0051) or in MockExtensionsForClientBase (CS0122)"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ProtectedInternalNestedMemberSignature_ShouldFollowInternalsVisibleTo( + bool grantsInternalsVisibleTo) + { + MetadataReference external = CompileClientBaseAssembly(grantsInternalsVisibleTo); + + GeneratorResult result = Generator.RunWithReferences(CreateMockForClientBase, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.ClientBase.g.cs"); + if (grantsInternalsVisibleTo) + { + await That(result.Sources["Mock.ClientBase.g.cs"]) + .Contains("ApplyOptions") + .Because("InternalsVisibleTo makes the internal half of `protected internal` visible"); + } + else + { + await That(result.Sources["Mock.ClientBase.g.cs"]) + .DoesNotContain("ApplyOptions") + .Because( + "the setup and verify surfaces would have to name `ClientBaseConfiguration`, which degrades to `protected` outside the declaring assembly (CS0122)"); + } + } + + [Fact] + public async Task AttributeWithInaccessibleTypeArgument_ShouldNotBeEmitted() + { + MetadataReference external = ExternalAssembly.Compile(""" + namespace Ext; + + internal class Secret { } + + public class MyMarkerAttribute : System.Attribute { } + + public class ClientBase + { + [MyMarkerAttribute] + public virtual void Send() { } + } + """); + + GeneratorResult result = Generator.RunWithReferences(CreateMockForClientBase, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources["Mock.ClientBase.g.cs"]) + .Contains("Send").And + .DoesNotContain("MyMarkerAttribute") + .Because( + "the attribute name is emitted verbatim, so an inaccessible type argument makes it unusable even though the attribute class itself is public"); + } + [Fact] public async Task PublicMembers_ShouldBeMockedAcrossAssemblyBoundary() { @@ -508,6 +603,63 @@ await That(result.Sources).DoesNotContainKey("Mock.MyExternalType.g.cs") "an `abstract override` re-declaration continues the slot without filling it, so the member is still the mock's obligation"); } + [Fact] + public async Task PartlyReachableSlot_WithInaccessibleSignatureType_ShouldSkipMember() + { + MetadataReference external = ExternalAssembly.Compile(""" + namespace Ext; + + public abstract class Base + { + protected abstract Configuration Current { get; private protected set; } + protected class Configuration { } + } + + public class Derived : Base + { + protected override Configuration Current { get; private protected set; } + } + """); + + GeneratorResult result = Generator.RunWithReferences(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) => _ = Ext.Derived.CreateMock(); + } + """, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.Derived.g.cs"); + await That(result.Sources["Mock.Derived.g.cs"]) + .DoesNotContain("Configuration").And + .DoesNotContain("Current") + .Because( + "the reachable getter could be restated in the mock class, but the setup and verify surfaces would have to name `Configuration`, which is only reachable through inheritance (CS0122)"); + } + + private static MetadataReference CompileClientBaseAssembly(bool grantsInternalsVisibleTo) + { + string internalsVisibleTo = grantsInternalsVisibleTo + ? """[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("TestAssembly")]""" + : ""; + return ExternalAssembly.Compile($$""" + {{internalsVisibleTo}} + namespace Ext; + + public class ClientBase + { + protected ClientBase() { } + protected ClientBase(ClientBaseConfiguration configuration) { } + protected virtual void ApplyOptions(ClientBaseConfiguration configuration) { } + protected internal class ClientBaseConfiguration { } + } + """); + } + private static MetadataReference CompileMyExternalTypeAssembly(string typeKeyword, string member, bool grantsInternalsVisibleTo) {