Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
40 changes: 40 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// <auto-generated/>
#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<global::StaticAbstractImpl> _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<global::StaticAbstractImpl> engine) : base()
{
_engine = engine;
}

public override int InstanceValue
{
get
{
if (_engine.TryHandleCallWithReturn<int>(0, "get_InstanceValue", global::System.Array.Empty<object?>(), 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<global::StaticAbstractImpl>(Create);
}

private static global::TUnit.Mocks.Mock<global::StaticAbstractImpl> Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs)
{
var engine = new global::TUnit.Mocks.MockEngine<global::StaticAbstractImpl>(behavior);
var impl = new StaticAbstractImplMockImpl(engine);
engine.Raisable = impl;
var mock = new global::TUnit.Mocks.Mock<global::StaticAbstractImpl>(impl, engine);
return mock;
}
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#nullable enable

namespace TUnit.Mocks.Generated
{
public static class StaticAbstractImpl_MockMemberExtensions
{
extension(global::TUnit.Mocks.Mock<global::StaticAbstractImpl> mock)
{
public global::TUnit.Mocks.PropertyMockCall<int> InstanceValue
=> new(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, 0, "InstanceValue", true, false);
}
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#nullable enable

namespace TUnit.Mocks
{
public static class StaticAbstractImpl_MockStaticExtension
{
extension(global::StaticAbstractImpl _)
{
public static global::TUnit.Mocks.Mock<global::StaticAbstractImpl> Mock(global::TUnit.Mocks.MockBehavior behavior = global::TUnit.Mocks.MockBehavior.Loose)
{
return global::TUnit.Mocks.Mock.Of<global::StaticAbstractImpl>(behavior);
}
}
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#nullable enable

namespace TUnit.Mocks.Generated;
48 changes: 40 additions & 8 deletions TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,7 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
{
if (member.IsStatic)
{
if (member.IsAbstract)
{
CollectStaticAbstractMember(member, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter);
}
TryCollectStaticAbstractFromInterface(member, typeSymbol, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter);
continue;
}

Expand Down Expand Up @@ -169,6 +166,9 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
/// <summary>
/// Discovers members from multiple type symbols, merging and deduplicating across all.
/// Used for multi-interface mocks like Mock.Of&lt;T1, T2&gt;().
/// Note: the first element may be a class when invoked from
/// <c>MockTypeDiscovery.TransformToModels</c> with <c>isPartialMock == true</c>, so the
/// <see cref="TryCollectStaticAbstractFromInterface"/> TypeKind guard is genuinely required.
/// </summary>
public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMemberModel> Properties, EquatableArray<MockEventModel> Events)
DiscoverMembersFromMultipleTypes(INamedTypeSymbol[] typeSymbols, IAssemblySymbol? compilationAssembly = null)
Expand Down Expand Up @@ -199,10 +199,7 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
{
if (member.IsStatic)
{
if (member.IsAbstract)
{
CollectStaticAbstractMember(member, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter);
}
TryCollectStaticAbstractFromInterface(member, typeSymbol, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter);
continue;
}

Expand Down Expand Up @@ -1070,6 +1067,41 @@ private static string EscapeIdentifier(string name) =>
return null;
}

/// <summary>
/// Internal predicate consumed only by <see cref="TryCollectStaticAbstractFromInterface"/>.
/// 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.
/// </summary>
private static bool ShouldCollectStaticAbstractFromInterfaces(ITypeSymbol typeSymbol)
=> typeSymbol.TypeKind == TypeKind.Interface;

/// <summary>
/// Gated entry point used by every interface-member discovery loop for static members.
/// Derives the static-abstract collection flag from <paramref name="typeSymbol"/> 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.
/// </summary>
private static void TryCollectStaticAbstractFromInterface(
ISymbol member,
ITypeSymbol typeSymbol,
string interfaceFqn,
List<MockMemberModel> methods,
List<MockMemberModel> properties,
List<MockEventModel> events,
Dictionary<string, (int Index, ITypeSymbol? ReturnType)> seenMethods,
Dictionary<string, int?> seenProperties,
HashSet<string> seenEvents,
ref int memberIdCounter)
{
if (!member.IsAbstract) return;
if (!ShouldCollectStaticAbstractFromInterfaces(typeSymbol)) return;

CollectStaticAbstractMember(member, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter);
}

/// <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
5 changes: 4 additions & 1 deletion TUnit.Mocks.SourceGenerator/MockGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
43 changes: 39 additions & 4 deletions TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────

Expand Down Expand Up @@ -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<StaticAbstractImpl>();
}
#endif

// ── T16 ──

Expand Down
Loading