diff --git a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs index ad61c62f9c..80871657e4 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs +++ b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs @@ -535,6 +535,46 @@ void M() return VerifyGeneratorOutput(source); } + [Test] + public Task Class_Implementing_Static_Abstract_Interface() + { + // Mirrors the T15 KitchenSink shape: a class implementing an interface that has a + // static-abstract member plus an instance virtual member. The generator must NOT + // emit a MockBridge interface for class targets (CS0527 / CS0540); the class + // already provides the concrete static impl, the mock only overrides the + // instance-virtual surface. + // + // The verified snapshot for this test intentionally OMITS a `_MockBridge.g.cs` + // file section — that absence is the assertion. Class targets must not get + // bridge generation, unlike the interface-target variants in this file which + // do produce a bridge. + var source = """ + using TUnit.Mocks; + + public interface IStaticAbstractFactory + { + static abstract IStaticAbstractFactory Create(); + int InstanceValue { get; } + } + + public class StaticAbstractImpl : IStaticAbstractFactory + { + public static IStaticAbstractFactory Create() => new StaticAbstractImpl(); + public virtual int InstanceValue => 99; + } + + public class TestUsage + { + void M() + { + var mock = StaticAbstractImpl.Mock(); + } + } + """; + + return VerifyGeneratorOutput(source); + } + [Test] public Task Interface_With_Inherited_Static_Abstract_Members() { diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Class_Implementing_Static_Abstract_Interface.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Class_Implementing_Static_Abstract_Interface.verified.txt new file mode 100644 index 0000000000..c7a22fdc45 --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Class_Implementing_Static_Abstract_Interface.verified.txt @@ -0,0 +1,100 @@ +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + file sealed class StaticAbstractImplMockImpl : global::StaticAbstractImpl, 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; } + + internal StaticAbstractImplMockImpl(global::TUnit.Mocks.MockEngine engine) : base() + { + _engine = engine; + } + + public override int InstanceValue + { + get + { + if (_engine.TryHandleCallWithReturn(0, "get_InstanceValue", global::System.Array.Empty(), default, out var __result)) + { + return __result; + } + return base.InstanceValue; + } + } + + [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 StaticAbstractImplPartialMockFactory + { + [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 StaticAbstractImplMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + public static class StaticAbstractImpl_MockMemberExtensions + { + extension(global::TUnit.Mocks.Mock mock) + { + public global::TUnit.Mocks.PropertyMockCall InstanceValue + => new(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, 0, "InstanceValue", true, false); + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks +{ + public static class StaticAbstractImpl_MockStaticExtension + { + extension(global::StaticAbstractImpl _) + { + public static global::TUnit.Mocks.Mock Mock(global::TUnit.Mocks.MockBehavior behavior = global::TUnit.Mocks.MockBehavior.Loose) + { + return global::TUnit.Mocks.Mock.Of(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index ed4c32c70f..55a886e070 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -53,10 +53,7 @@ public static (EquatableArray Methods, EquatableArray Methods, EquatableArray /// Discovers members from multiple type symbols, merging and deduplicating across all. /// Used for multi-interface mocks like Mock.Of<T1, T2>(). + /// Note: the first element may be a class when invoked from + /// MockTypeDiscovery.TransformToModels with isPartialMock == true, so the + /// TypeKind guard is genuinely required. /// public static (EquatableArray Methods, EquatableArray Properties, EquatableArray Events) DiscoverMembersFromMultipleTypes(INamedTypeSymbol[] typeSymbols, IAssemblySymbol? compilationAssembly = null) @@ -199,10 +199,7 @@ public static (EquatableArray Methods, EquatableArray return null; } + /// + /// Internal predicate consumed only by . + /// Class targets already provide the concrete static impl that satisfies any static-abstract + /// interface members; emitting a bridge interface for them would produce CS0527 (class in + /// interface list) and CS0540 (explicit interface impl on a type that doesn't list the + /// interface). Centralised here so no caller can bypass the gate by accident. + /// + private static bool ShouldCollectStaticAbstractFromInterfaces(ITypeSymbol typeSymbol) + => typeSymbol.TypeKind == TypeKind.Interface; + + /// + /// Gated entry point used by every interface-member discovery loop for static members. + /// Derives the static-abstract collection flag from internally + /// so that adding a future loop cannot silently re-introduce a class-target regression + /// (CS0527 / CS0540 from a class being treated like an interface) — the only way to collect + /// a static-abstract member is through this helper. + /// + private static void TryCollectStaticAbstractFromInterface( + ISymbol member, + ITypeSymbol typeSymbol, + string interfaceFqn, + List methods, + List properties, + List events, + Dictionary seenMethods, + Dictionary seenProperties, + HashSet seenEvents, + ref int memberIdCounter) + { + if (!member.IsAbstract) return; + if (!ShouldCollectStaticAbstractFromInterfaces(typeSymbol)) return; + + CollectStaticAbstractMember(member, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter); + } + /// /// Returns true when the given type symbol is an interface that contains static abstract members /// (directly or via inherited interfaces) without a most specific implementation. diff --git a/TUnit.Mocks.SourceGenerator/MockGenerator.cs b/TUnit.Mocks.SourceGenerator/MockGenerator.cs index a600060a57..6dd3c6e98d 100644 --- a/TUnit.Mocks.SourceGenerator/MockGenerator.cs +++ b/TUnit.Mocks.SourceGenerator/MockGenerator.cs @@ -90,7 +90,10 @@ private static void GenerateSingleTypeMock(SourceProductionContext spc, MockType { var fileName = GetSafeFileName(model); - if (model.HasStaticAbstractMembers) + // Defence-in-depth: MemberDiscovery never sets HasStaticAbstractMembers for class targets + // (TypeKind guard in TryCollectStaticAbstractFromInterface), so this branch is only entered + // for interface targets. The IsInterface check makes the intent explicit. + if (model.HasStaticAbstractMembers && model.IsInterface) { var bridgeSource = MockBridgeBuilder.Build(model); spc.AddSource($"{fileName}_MockBridge.g.cs", bridgeSource); diff --git a/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs b/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs index 6c80ed6a16..2462f0a3f8 100644 --- a/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs +++ b/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs @@ -190,9 +190,21 @@ public interface IHasInIndexer string this[in int key] { get; } } -// ─── T15 SKIPPED. Mocking a class that implements a static-abstract interface -// hits the bridge builder, which treats the target as an interface ("Type -// in interface list is not an interface"). Separate generator issue. +// ─── T15. Class implementing a static-abstract interface ──────────────────── + +#if NET8_0_OR_GREATER +public interface IStaticAbstractFactory +{ + static abstract IStaticAbstractFactory Create(); + int InstanceValue { get; } +} + +public class StaticAbstractImpl : IStaticAbstractFactory +{ + public static IStaticAbstractFactory Create() => new StaticAbstractImpl(); + public virtual int InstanceValue => 99; +} +#endif // ─── T16. IAsyncEnumerable with [EnumeratorCancellation] token ────────────── @@ -541,7 +553,30 @@ public async Task T14b_Indexer_With_In_Parameter_Compiles_And_Dispatches() mock.Item(7).WasCalled(Times.Once); } - // T15 test elided — see the SKIPPED note above the type declarations. + // ── T15 (net8.0+ only — static abstract requires runtime support) ── + +#if NET8_0_OR_GREATER + [Test] + public async Task T15_Class_Implementing_Static_Abstract_Interface_Mockable() + { + // Mocking a class whose interface has static-abstract members should still work: + // the class provides the concrete static impl; the mock only needs to override + // the instance-virtual surface. No bridge interface is required for class targets. + var mock = StaticAbstractImpl.Mock(); + mock.InstanceValue.Returns(42); + + await Assert.That(mock.Object.InstanceValue).IsEqualTo(42); + mock.InstanceValue.WasCalled(Times.Once); + + // The class's concrete static impl is unaffected — direct call still works + // and returns a real instance, NOT routed through the mock engine. The static + // method's declared return type IStaticAbstractFactory itself can't be a type + // argument (CS8920), so observe the result as `object`. + object? created = StaticAbstractImpl.Create(); + await Assert.That(created).IsNotNull(); + await Assert.That(created).IsTypeOf(); + } +#endif // ── T16 ──