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
29 changes: 29 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,35 @@ void M()
return VerifyGeneratorOutput(source);
}

[Test]
public Task Static_Extension_Discovery_Via_Qualified_Name()
{
// Namespace.IFoo.Mock() — a fully-qualified reference parses as a member-access
// expression (not a qualified-name) in expression position. Generation must still
// trigger off the resolved symbol regardless of the syntactic shape. See issue #6298.
var source = """
using TUnit.Mocks;

namespace MyNamespace
{
public interface ITestInterface
{
string Method();
}
}

public class TestUsage
{
void M()
{
var mock = MyNamespace.ITestInterface.Mock();
}
}
""";

return VerifyGeneratorOutput(source);
}

[Test]
public Task External_Interface_With_Indexer_Static_Extension_Discovery()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// <auto-generated/>
#pragma warning disable
#nullable enable

namespace MyNamespace
{
public sealed class ITestInterfaceMock : global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface>, global::MyNamespace.ITestInterface
{
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
internal ITestInterfaceMock(global::MyNamespace.ITestInterface mockObject, global::TUnit.Mocks.MockEngine<global::MyNamespace.ITestInterface> engine)
: base(mockObject, engine) { }

string global::MyNamespace.ITestInterface.Method() => Object.Method();
}
}


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

// <auto-generated/>
#pragma warning disable
#nullable enable

namespace MyNamespace
{
file sealed class ITestInterfaceMockImpl : global::MyNamespace.ITestInterface, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject
{
private readonly global::TUnit.Mocks.MockEngine<global::MyNamespace.ITestInterface> _engine;

[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
global::TUnit.Mocks.IMock? global::TUnit.Mocks.IMockObject.MockWrapper { get; set; }

internal ITestInterfaceMockImpl(global::TUnit.Mocks.MockEngine<global::MyNamespace.ITestInterface> engine)
{
_engine = engine;
}

public string Method()
{
return _engine.HandleCallWithReturn<string>(0, "Method", global::System.Array.Empty<object?>(), "");
}

[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.");
}
}

internal static class ITestInterfaceMockFactory
{
[global::System.Runtime.CompilerServices.ModuleInitializer]
internal static void Register()
{
global::TUnit.Mocks.MockRegistry.RegisterFactory<global::MyNamespace.ITestInterface>(Create);
}

internal static global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> CreateAutoMock(global::TUnit.Mocks.MockBehavior behavior)
{
var engine = new global::TUnit.Mocks.MockEngine<global::MyNamespace.ITestInterface>(behavior);
var impl = new ITestInterfaceMockImpl(engine);
engine.Raisable = impl;
var mock = new ITestInterfaceMock(impl, engine);
return mock;
}

internal static global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs)
{
if (constructorArgs.Length > 0) throw new global::System.ArgumentException($"Interface mock 'global::MyNamespace.ITestInterface' does not support constructor arguments, but {constructorArgs.Length} were provided.");
var engine = new global::TUnit.Mocks.MockEngine<global::MyNamespace.ITestInterface>(behavior);
var impl = new ITestInterfaceMockImpl(engine);
engine.Raisable = impl;
var mock = new ITestInterfaceMock(impl, engine);
return mock;
}
}
}


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

// <auto-generated/>
#pragma warning disable
#nullable enable

namespace MyNamespace
{
public static class MyNamespace_ITestInterface_MockMemberExtensions
{
public static global::TUnit.Mocks.MockMethodCall<string> Method(this global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> mock)
{
var matchers = global::System.Array.Empty<global::TUnit.Mocks.Arguments.IArgumentMatcher>();
return new global::TUnit.Mocks.MockMethodCall<string>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Method", matchers);
}

#if NET9_0_OR_GREATER
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
public static void Reset(this global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> mock)
=> global::TUnit.Mocks.Mock.Reset(mock);

[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
public static void VerifyAll(this global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> mock)
=> global::TUnit.Mocks.Mock.VerifyAll(mock);

[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
public static void VerifyNoOtherCalls(this global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> mock)
=> global::TUnit.Mocks.Mock.VerifyNoOtherCalls(mock);

[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
public static void SetupAllProperties(this global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> 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<global::MyNamespace.ITestInterface> mock)
=> global::TUnit.Mocks.Mock.GetDiagnostics(mock);

[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
public static void SetState(this global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> 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<global::MyNamespace.ITestInterface> mock, string stateName, global::System.Action<global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface>> configure)
=> global::TUnit.Mocks.Mock.InState(mock, stateName, configure);

extension(global::TUnit.Mocks.Mock<global::MyNamespace.ITestInterface> mock)
{
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
public global::System.Collections.Generic.IReadOnlyList<global::TUnit.Mocks.Verification.CallRecord> Invocations => global::TUnit.Mocks.Mock.Invocations(mock);

[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
public global::TUnit.Mocks.MockBehavior Behavior => global::TUnit.Mocks.Mock.Behavior(mock);

[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
public global::TUnit.Mocks.IDefaultValueProvider? DefaultValueProvider
{
get => global::TUnit.Mocks.Mock.GetDefaultValueProvider(mock);
set => global::TUnit.Mocks.Mock.SetDefaultValueProvider(mock, value);
}
}
#endif
}
}


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

// <auto-generated/>
#pragma warning disable
#nullable enable

namespace TUnit.Mocks
{
public static class MyNamespace_ITestInterface_MockStaticExtension
{
extension(global::MyNamespace.ITestInterface _)
{
public static global::MyNamespace.ITestInterfaceMock Mock()
{
return (global::MyNamespace.ITestInterfaceMock)global::MyNamespace.ITestInterfaceMockFactory.CreateAutoMock(global::TUnit.Mocks.Mock.DefaultBehavior);
}

public static global::MyNamespace.ITestInterfaceMock Mock(global::TUnit.Mocks.MockBehavior behavior)
{
return (global::MyNamespace.ITestInterfaceMock)global::MyNamespace.ITestInterfaceMockFactory.CreateAutoMock(behavior);
}
}
}
}


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

// <auto-generated/>
#pragma warning disable
#nullable enable

namespace TUnit.Mocks.Generated;
32 changes: 17 additions & 15 deletions TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -544,25 +544,27 @@ private static bool IsEffectivelyPublic(ITypeSymbol type)
// ─── T.Mock() static extension discovery ─────────────────────────

/// <summary>
/// Syntax predicate: matches T.Mock() — a static extension invocation where the
/// left-hand side is a type name (not a variable/field access).
/// Works for both interfaces (e.g. IFoo.Mock()) and classes (e.g. MyService.Mock()).
/// Syntax predicate: matches any <c>X.Mock()</c> invocation.
/// Works for both interfaces (e.g. <c>IFoo.Mock()</c>) and classes (e.g. <c>MyService.Mock()</c>).
/// </summary>
/// <remarks>
/// This is intentionally only a cheap name gate. Whether the left-hand side is actually a
/// mockable <em>type</em> (rather than a variable, field, namespace, or other expression) is
/// decided in <see cref="TransformMockExtensionInvocation"/> off the resolved symbol — so the
/// syntactic shape of the type reference doesn't matter. A simple name (<c>IFoo.Mock()</c>) and
/// an alias parse as <see cref="IdentifierNameSyntax"/>, but a fully-qualified reference
/// (<c>Namespace.IFoo.Mock()</c>) parses as a <see cref="MemberAccessExpressionSyntax"/> in
/// expression position — gating on syntax kind here dropped that form. See issue #6298.
/// </remarks>
public static bool IsMockExtensionInvocation(SyntaxNode node, CancellationToken ct)
{
if (node is not InvocationExpressionSyntax
return node is InvocationExpressionSyntax
{
Expression: MemberAccessExpressionSyntax
{
Expression: MemberAccessExpressionSyntax
{
Name: IdentifierNameSyntax { Identifier.ValueText: "Mock" },
Expression: var lhs
}
})
return false;

// Static extension calls use a type name on the left — never a variable or member access.
// GenericNameSyntax handles IFoo<T>.Mock().
return lhs is IdentifierNameSyntax or GenericNameSyntax or QualifiedNameSyntax or AliasQualifiedNameSyntax;
Name: IdentifierNameSyntax { Identifier.ValueText: "Mock" }
}
};
}

/// <summary>
Expand Down
33 changes: 33 additions & 0 deletions TUnit.Mocks.Tests/Issue6298Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// The `using Issue6298Namespace;` below is intentional and required to compile: the generated
// mock's setup/proxy members (e.g. `mock.Method()`) are emitted into the interface's namespace.
// It does NOT weaken the regression — the thing under test is the *fully-qualified* call form
// `Issue6298Namespace.IIssue6298Interface.Mock()` at the call site (see the test method below).
using Issue6298Namespace;
using TUnit.Mocks;

// Regression for issue #6298: a fully-qualified call (Namespace.IFoo.Mock()) must generate the
// mock. The qualified form parses as member-access, not a simple name, which the discovery
// predicate previously dropped — causing CS1061. The call site uses the qualified form, so this
// won't compile unless generation triggers off that syntax form.
namespace Issue6298Namespace
{
public interface IIssue6298Interface
{
string Method();
}
}

namespace TUnit.Mocks.Tests
{
public class Issue6298Tests
{
[Test]
public async Task Qualified_Name_Reference_Generates_Mock()
{
var mock = Issue6298Namespace.IIssue6298Interface.Mock();
mock.Method().Returns("value");

await Assert.That(mock.Object.Method()).IsEqualTo("value");
}
}
}
Loading