Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
seenMethods, seenFullMethods, seenProperties, seenEvents, ref memberIdCounter);
}

var collectStaticAbstractFromInterfaces = ShouldCollectStaticAbstractFromInterfaces(typeSymbol);

foreach (var iface in interfaces)
{
string? explicitInterfaceName = null;
Expand All @@ -53,7 +55,7 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
{
if (member.IsStatic)
{
if (member.IsAbstract)
if (member.IsAbstract && collectStaticAbstractFromInterfaces)
{
CollectStaticAbstractMember(member, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter);
}
Expand Down Expand Up @@ -191,6 +193,8 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
? new[] { typeSymbol }.Concat(typeSymbol.AllInterfaces)
: typeSymbol.AllInterfaces.AsEnumerable();

var collectStaticAbstractFromInterfaces = ShouldCollectStaticAbstractFromInterfaces(typeSymbol);

foreach (var iface in interfaces)
{
var interfaceFqn = iface.GetFullyQualifiedName();
Expand All @@ -199,7 +203,7 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
{
if (member.IsStatic)
{
if (member.IsAbstract)
if (member.IsAbstract && collectStaticAbstractFromInterfaces)
{
CollectStaticAbstractMember(member, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter);
}
Expand Down Expand Up @@ -1070,6 +1074,17 @@ private static string EscapeIdentifier(string name) =>
return null;
}

/// <summary>
/// Single source of truth for whether a member-discovery loop should collect static-abstract
/// interface members for the given target. 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). Any new collection loop over interface members
/// MUST gate static-abstract collection through this helper.
/// </summary>
private static bool ShouldCollectStaticAbstractFromInterfaces(ITypeSymbol typeSymbol)
=> typeSymbol.TypeKind != TypeKind.Class;

/// <summary>
/// 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.
Expand Down
3 changes: 2 additions & 1 deletion TUnit.Mocks.SourceGenerator/MockGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ private static void GenerateSingleTypeMock(SourceProductionContext spc, MockType
{
var fileName = GetSafeFileName(model);

if (model.HasStaticAbstractMembers)
// Defence-in-depth: MemberDiscovery already filters static-abstract collection on TypeKind, so HasStaticAbstractMembers is always false here for class targets.
if (model.HasStaticAbstractMembers && model.IsInterface)
{
var bridgeSource = MockBridgeBuilder.Build(model);
spc.AddSource($"{fileName}_MockBridge.g.cs", bridgeSource);
Expand Down
46 changes: 42 additions & 4 deletions TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,22 @@ public interface ITagLarge : ITagSmall
// ("does not implement IHasIndexer.this[int]") because the mock-impl builder
// skips indexer emission without providing a stub. Tracked as a separate
// generator gap — not in scope of the #5673 fix.
// ─── 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 ──────────────

Expand Down Expand Up @@ -433,7 +446,32 @@ public async Task T13_Derived_Interface_New_Property_Redeclaration()
await Assert.That(asSmall.Tag).IsEqualTo((short)0);
}

// T14, T15 tests elided — see the SKIPPED notes above the type declarations.
// T14 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<StaticAbstractImpl>();
}
#endif

// ── T16 ──

Expand Down
Loading