diff --git a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs index 2702d0f31e..26d2689bd6 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs +++ b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs @@ -81,6 +81,93 @@ void M() return VerifyGeneratorOutput(source); } + [Test] + public Task Multi_Interface_Mock_With_Secondary_Setup_Surface() + { + var source = """ + using TUnit.Mocks; + + public interface IMultiLogger + { + void Log(string message); + string LastMessage { get; } + } + + public interface IMultiDisposable + { + void Dispose(); + bool IsDisposed { get; } + } + + public class TestUsage + { + void M() + { + var mock = Mock.Of(); + } + } + """; + + return VerifyGeneratorOutput(source); + } + + [Test] + public Task Multi_Interface_Mock_With_Class_Primary_And_Explicit_Impl() + { + var source = """ + using TUnit.Mocks; + + public interface IInfra + { + string Instance { get; } + } + + public class DataContext : IInfra + { + public virtual string GetName() => "real"; + string IInfra.Instance => "real-instance"; + } + + public class TestUsage + { + void M() + { + var mock = Mock.Of(); + } + } + """; + + return VerifyGeneratorOutput(source); + } + + [Test] + public Task Multi_Interface_Mock_With_Conflicting_Member_Names() + { + var source = """ + using TUnit.Mocks; + + public interface IConflictA + { + string Tag { get; } + } + + public interface IConflictB + { + int Tag { get; } + } + + public class TestUsage + { + void M() + { + var mock = Mock.Of(); + } + } + """; + + return VerifyGeneratorOutput(source); + } + [Test] public Task Interface_With_Properties() { diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Class_Primary_And_Explicit_Impl.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Class_Primary_And_Explicit_Impl.verified.txt new file mode 100644 index 0000000000..4c6d8adb1d --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Class_Primary_And_Explicit_Impl.verified.txt @@ -0,0 +1,243 @@ +// +#pragma warning disable +#nullable enable + +file sealed class DataContext_IInfraMockImpl : global::DataContext, global::IInfra, 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; } + + [global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers] + internal DataContext_IInfraMockImpl(global::TUnit.Mocks.MockEngine engine) : base() + { + _engine = engine; + } + + public override string GetName() + { + if (_engine.TryHandleCallWithReturn(0, "GetName", global::System.Array.Empty(), "", out var __result)) + { + return __result; + } + return base.GetName(); + } + + string global::IInfra.Instance + { + get => _engine.HandleCallWithReturn(1, "get_Instance", global::System.Array.Empty(), ""); + } + + [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 DataContext_IInfraPartialMockFactory +{ + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterMultiFactory(string.Join("|", new[] { typeof(global::DataContext).FullName, typeof(global::IInfra).FullName }), Create); + } + + private static readonly int[] _secondaryMap0 = new int[] { 1 }; + + private static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + engine.RegisterSecondaryInterface(typeof(global::IInfra), _secondaryMap0); + var impl = new DataContext_IInfraMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +public static class DataContext_IInfra_MockMemberExtensions +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static int __Id(global::TUnit.Mocks.MockEngine engine, int localId) + { + if (!engine.TryGetSecondaryMemberId(typeof(global::IInfra), localId, out var memberId)) + { + throw new global::System.InvalidOperationException(engine.HasSecondaryInterface(typeof(global::IInfra)) + ? "Member #" + localId + " of 'IInfra' has no setup mapping on this mock instance — it is not part of this combo's configurable surface." + : "This mock was not created with 'IInfra' as a secondary interface. Create it with Mock.Of() to configure its members."); + } + return memberId; + } + + extension(global::TUnit.Mocks.Mock mock) + { + public global::TUnit.Mocks.PropertyMockCall Instance + { + get + { + var __engine = global::TUnit.Mocks.MockRegistry.GetEngine(mock); + return new(__engine, __Id(__engine, 0), 0, "Instance", true, false); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +file sealed class DataContextMockImpl : global::DataContext, 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; } + + [global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers] + internal DataContextMockImpl(global::TUnit.Mocks.MockEngine engine) : base() + { + _engine = engine; + } + + public override string GetName() + { + if (_engine.TryHandleCallWithReturn(0, "GetName", global::System.Array.Empty(), "", out var __result)) + { + return __result; + } + return base.GetName(); + } + + [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 DataContextPartialMockFactory +{ + [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 DataContextMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +public static class DataContext_MockMemberExtensions +{ + public static global::TUnit.Mocks.MockMethodCall GetName(this global::TUnit.Mocks.Mock mock) + { + var matchers = global::System.Array.Empty(); + return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "GetName", matchers); + } + + #if NET9_0_OR_GREATER + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void Reset(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.Reset(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyAll(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyAll(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyNoOtherCalls(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyNoOtherCalls(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetupAllProperties(this global::TUnit.Mocks.Mock 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 mock) + => global::TUnit.Mocks.Mock.GetDiagnostics(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetState(this global::TUnit.Mocks.Mock 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 mock, string stateName, global::System.Action> configure) + => global::TUnit.Mocks.Mock.InState(mock, stateName, configure); + + extension(global::TUnit.Mocks.Mock mock) + { + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public global::System.Collections.Generic.IReadOnlyList 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 ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks +{ + public static class DataContext_MockStaticExtension + { + extension(global::DataContext _) + { + public static global::TUnit.Mocks.Mock Mock() + { + return global::TUnit.Mocks.Mock.Of(); + } + + public static global::TUnit.Mocks.Mock Mock(global::TUnit.Mocks.MockBehavior behavior) + { + return global::TUnit.Mocks.Mock.Of(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Conflicting_Member_Names.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Conflicting_Member_Names.verified.txt new file mode 100644 index 0000000000..0e6cb61174 --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Conflicting_Member_Names.verified.txt @@ -0,0 +1,270 @@ +// +#pragma warning disable +#nullable enable + +file sealed class IConflictA_IConflictBMockImpl : global::IConflictA, global::IConflictB, 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 IConflictA_IConflictBMockImpl(global::TUnit.Mocks.MockEngine engine) + { + _engine = engine; + } + + public string Tag + { + get => _engine.HandleCallWithReturn(0, "get_Tag", global::System.Array.Empty(), ""); + } + + int global::IConflictB.Tag + { + get => _engine.HandleCallWithReturn(1, "get_Tag", global::System.Array.Empty(), default); + } + + [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 IConflictA_IConflictBMockFactory +{ + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterMultiFactory(string.Join("|", new[] { typeof(global::IConflictA).FullName, typeof(global::IConflictB).FullName }), Create); + } + + private static readonly int[] _secondaryMap0 = new int[] { 1 }; + + internal static global::TUnit.Mocks.Mock CreateAutoMock(global::TUnit.Mocks.MockBehavior behavior) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + engine.RegisterSecondaryInterface(typeof(global::IConflictB), _secondaryMap0); + var impl = new IConflictA_IConflictBMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } + + internal static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + if (constructorArgs.Length > 0) throw new global::System.ArgumentException($"Interface mock 'global::IConflictA' does not support constructor arguments, but {constructorArgs.Length} were provided."); + var engine = new global::TUnit.Mocks.MockEngine(behavior); + engine.RegisterSecondaryInterface(typeof(global::IConflictB), _secondaryMap0); + var impl = new IConflictA_IConflictBMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +public static class IConflictA_IConflictB_MockMemberExtensions +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static int __Id(global::TUnit.Mocks.MockEngine engine, int localId) + { + if (!engine.TryGetSecondaryMemberId(typeof(global::IConflictB), localId, out var memberId)) + { + throw new global::System.InvalidOperationException(engine.HasSecondaryInterface(typeof(global::IConflictB)) + ? "Member #" + localId + " of 'IConflictB' has no setup mapping on this mock instance — it is not part of this combo's configurable surface." + : "This mock was not created with 'IConflictB' as a secondary interface. Create it with Mock.Of() to configure its members."); + } + return memberId; + } + + extension(global::TUnit.Mocks.Mock mock) + { + public global::TUnit.Mocks.PropertyMockCall IConflictB_Tag + { + get + { + var __engine = global::TUnit.Mocks.MockRegistry.GetEngine(mock); + return new(__engine, __Id(__engine, 0), 0, "IConflictB_Tag", true, false); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +public sealed class IConflictAMock : global::TUnit.Mocks.Mock, global::IConflictA +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal IConflictAMock(global::IConflictA mockObject, global::TUnit.Mocks.MockEngine engine) + : base(mockObject, engine) { } + + string global::IConflictA.Tag { get => Object.Tag; } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +file sealed class IConflictAMockImpl : global::IConflictA, 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 IConflictAMockImpl(global::TUnit.Mocks.MockEngine engine) + { + _engine = engine; + } + + public string Tag + { + get => _engine.HandleCallWithReturn(0, "get_Tag", global::System.Array.Empty(), ""); + } + + [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 IConflictAMockFactory +{ + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterFactory(Create); + } + + internal static global::TUnit.Mocks.Mock CreateAutoMock(global::TUnit.Mocks.MockBehavior behavior) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new IConflictAMockImpl(engine); + engine.Raisable = impl; + var mock = new IConflictAMock(impl, engine); + return mock; + } + + internal static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + if (constructorArgs.Length > 0) throw new global::System.ArgumentException($"Interface mock 'global::IConflictA' does not support constructor arguments, but {constructorArgs.Length} were provided."); + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new IConflictAMockImpl(engine); + engine.Raisable = impl; + var mock = new IConflictAMock(impl, engine); + return mock; + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +public static class IConflictA_MockMemberExtensions +{ + extension(global::TUnit.Mocks.Mock mock) + { + public global::TUnit.Mocks.PropertyMockCall Tag + => new(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, 0, "Tag", true, false); + } + + #if NET9_0_OR_GREATER + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void Reset(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.Reset(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyAll(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyAll(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyNoOtherCalls(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyNoOtherCalls(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetupAllProperties(this global::TUnit.Mocks.Mock 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 mock) + => global::TUnit.Mocks.Mock.GetDiagnostics(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetState(this global::TUnit.Mocks.Mock 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 mock, string stateName, global::System.Action> configure) + => global::TUnit.Mocks.Mock.InState(mock, stateName, configure); + + extension(global::TUnit.Mocks.Mock mock) + { + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public global::System.Collections.Generic.IReadOnlyList 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 ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks +{ + public static class IConflictA_MockStaticExtension + { + extension(global::IConflictA _) + { + public static global::IConflictAMock Mock() + { + return (global::IConflictAMock)global::IConflictAMockFactory.CreateAutoMock(global::TUnit.Mocks.Mock.DefaultBehavior); + } + + public static global::IConflictAMock Mock(global::TUnit.Mocks.MockBehavior behavior) + { + return (global::IConflictAMock)global::IConflictAMockFactory.CreateAutoMock(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Secondary_Setup_Surface.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Secondary_Setup_Surface.verified.txt new file mode 100644 index 0000000000..53615d47ba --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Secondary_Setup_Surface.verified.txt @@ -0,0 +1,390 @@ +// +#pragma warning disable +#nullable enable + +file sealed class IMultiLogger_IMultiDisposableMockImpl : global::IMultiLogger, global::IMultiDisposable, 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 IMultiLogger_IMultiDisposableMockImpl(global::TUnit.Mocks.MockEngine engine) + { + _engine = engine; + } + + public void Log(string message) + { + _engine.HandleCall(0, "Log", message); + } + + public void Dispose() + { + _engine.HandleCall(2, "Dispose", global::System.Array.Empty()); + } + + public string LastMessage + { + get => _engine.HandleCallWithReturn(1, "get_LastMessage", global::System.Array.Empty(), ""); + } + + public bool IsDisposed + { + get => _engine.HandleCallWithReturn(3, "get_IsDisposed", global::System.Array.Empty(), default); + } + + [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 IMultiLogger_IMultiDisposableMockFactory +{ + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterMultiFactory(string.Join("|", new[] { typeof(global::IMultiLogger).FullName, typeof(global::IMultiDisposable).FullName }), Create); + } + + private static readonly int[] _secondaryMap0 = new int[] { 2, 3 }; + + internal static global::TUnit.Mocks.Mock CreateAutoMock(global::TUnit.Mocks.MockBehavior behavior) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + engine.RegisterSecondaryInterface(typeof(global::IMultiDisposable), _secondaryMap0); + var impl = new IMultiLogger_IMultiDisposableMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } + + internal static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + if (constructorArgs.Length > 0) throw new global::System.ArgumentException($"Interface mock 'global::IMultiLogger' does not support constructor arguments, but {constructorArgs.Length} were provided."); + var engine = new global::TUnit.Mocks.MockEngine(behavior); + engine.RegisterSecondaryInterface(typeof(global::IMultiDisposable), _secondaryMap0); + var impl = new IMultiLogger_IMultiDisposableMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +public static class IMultiLogger_IMultiDisposable_MockMemberExtensions +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static int __Id(global::TUnit.Mocks.MockEngine engine, int localId) + { + if (!engine.TryGetSecondaryMemberId(typeof(global::IMultiDisposable), localId, out var memberId)) + { + throw new global::System.InvalidOperationException(engine.HasSecondaryInterface(typeof(global::IMultiDisposable)) + ? "Member #" + localId + " of 'IMultiDisposable' has no setup mapping on this mock instance — it is not part of this combo's configurable surface." + : "This mock was not created with 'IMultiDisposable' as a secondary interface. Create it with Mock.Of() to configure its members."); + } + return memberId; + } + + public static global::TUnit.Mocks.VoidMockMethodCall Dispose(this global::TUnit.Mocks.Mock mock) + { + var matchers = global::System.Array.Empty(); + var __engine = global::TUnit.Mocks.MockRegistry.GetEngine(mock); + return new global::TUnit.Mocks.VoidMockMethodCall(__engine, __Id(__engine, 0), "Dispose", matchers); + } + + extension(global::TUnit.Mocks.Mock mock) + { + public global::TUnit.Mocks.PropertyMockCall IsDisposed + { + get + { + var __engine = global::TUnit.Mocks.MockRegistry.GetEngine(mock); + return new(__engine, __Id(__engine, 1), 0, "IsDisposed", true, false); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +public sealed class IMultiLoggerMock : global::TUnit.Mocks.Mock, global::IMultiLogger +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal IMultiLoggerMock(global::IMultiLogger mockObject, global::TUnit.Mocks.MockEngine engine) + : base(mockObject, engine) { } + + void global::IMultiLogger.Log(string message) + { + Object.Log(message); + } + + string global::IMultiLogger.LastMessage { get => Object.LastMessage; } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +file sealed class IMultiLoggerMockImpl : global::IMultiLogger, 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 IMultiLoggerMockImpl(global::TUnit.Mocks.MockEngine engine) + { + _engine = engine; + } + + public void Log(string message) + { + _engine.HandleCall(0, "Log", message); + } + + public string LastMessage + { + get => _engine.HandleCallWithReturn(1, "get_LastMessage", global::System.Array.Empty(), ""); + } + + [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 IMultiLoggerMockFactory +{ + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterFactory(Create); + } + + internal static global::TUnit.Mocks.Mock CreateAutoMock(global::TUnit.Mocks.MockBehavior behavior) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new IMultiLoggerMockImpl(engine); + engine.Raisable = impl; + var mock = new IMultiLoggerMock(impl, engine); + return mock; + } + + internal static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + if (constructorArgs.Length > 0) throw new global::System.ArgumentException($"Interface mock 'global::IMultiLogger' does not support constructor arguments, but {constructorArgs.Length} were provided."); + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new IMultiLoggerMockImpl(engine); + engine.Raisable = impl; + var mock = new IMultiLoggerMock(impl, engine); + return mock; + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +public static class IMultiLogger_MockMemberExtensions +{ + public static IMultiLogger_Log_M0_MockCall Log(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg message) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { message.Matcher }; + return new IMultiLogger_Log_M0_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Log", matchers); + } + + public static IMultiLogger_Log_M0_MockCall Log(this global::TUnit.Mocks.Mock mock, global::System.Func message) + { + global::TUnit.Mocks.Arguments.Arg __fa_message = message; + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { __fa_message.Matcher }; + return new IMultiLogger_Log_M0_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Log", matchers); + } + + extension(global::TUnit.Mocks.Mock mock) + { + public global::TUnit.Mocks.PropertyMockCall LastMessage + => new(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, 0, "LastMessage", true, false); + } + + #if NET9_0_OR_GREATER + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void Reset(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.Reset(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyAll(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyAll(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyNoOtherCalls(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyNoOtherCalls(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetupAllProperties(this global::TUnit.Mocks.Mock 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 mock) + => global::TUnit.Mocks.Mock.GetDiagnostics(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetState(this global::TUnit.Mocks.Mock 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 mock, string stateName, global::System.Action> configure) + => global::TUnit.Mocks.Mock.InState(mock, stateName, configure); + + extension(global::TUnit.Mocks.Mock mock) + { + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public global::System.Collections.Generic.IReadOnlyList 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 +} + +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +public sealed class IMultiLogger_Log_M0_MockCall : global::TUnit.Mocks.Verification.ICallVerification +{ + private readonly global::TUnit.Mocks.IMockEngineAccess _engine; + private readonly int _memberId; + private readonly string _memberName; + private readonly global::TUnit.Mocks.Arguments.IArgumentMatcher[] _matchers; + private global::TUnit.Mocks.Setup.VoidMethodSetupBuilder? _builder; + + internal IMultiLogger_Log_M0_MockCall(global::TUnit.Mocks.IMockEngineAccess engine, int memberId, string memberName, global::TUnit.Mocks.Arguments.IArgumentMatcher[] matchers) + { + _engine = engine; + _memberId = memberId; + _memberName = memberName; + _matchers = matchers; + _ = EnsureSetup(); + } + + private global::TUnit.Mocks.Setup.VoidMethodSetupBuilder EnsureSetup() + { + var existing = global::System.Threading.Volatile.Read(ref _builder); + if (existing is not null) return existing; + return EnsureSetupSlow(); + } + + [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private global::TUnit.Mocks.Setup.VoidMethodSetupBuilder EnsureSetupSlow() + { + var setup = new global::TUnit.Mocks.Setup.MethodSetup(_memberId, _matchers, _memberName); + var fresh = new global::TUnit.Mocks.Setup.VoidMethodSetupBuilder(setup); + var prev = global::System.Threading.Interlocked.CompareExchange(ref _builder, fresh, null); + if (prev is not null) return prev; + // AddSetup runs only on the CAS winner. Setup is sequential in practice, + // so a concurrent loser observing the builder before registration is benign. + _engine.AddSetup(setup); + return fresh; + } + + /// + public IMultiLogger_Log_M0_MockCall Returns() { EnsureSetup().Returns(); return this; } + /// + public IMultiLogger_Log_M0_MockCall Throws() where TException : global::System.Exception, new() { EnsureSetup().Throws(); return this; } + /// + public IMultiLogger_Log_M0_MockCall Throws(global::System.Exception exception) { EnsureSetup().Throws(exception); return this; } + /// + public IMultiLogger_Log_M0_MockCall Callback(global::System.Action callback) { EnsureSetup().Callback(callback); return this; } + /// + public IMultiLogger_Log_M0_MockCall TransitionsTo(string stateName) { EnsureSetup().TransitionsTo(stateName); return this; } + /// + public IMultiLogger_Log_M0_MockCall Then() { EnsureSetup().Then(); return this; } + + /// Execute a typed callback using the actual method parameters. + public IMultiLogger_Log_M0_MockCall Callback(global::System.Action callback) + { + EnsureSetup().Callback(callback); + return this; + } + + /// Configure a typed computed exception using the actual method parameters. + public IMultiLogger_Log_M0_MockCall Throws(global::System.Func exceptionFactory) + { + EnsureSetup().Throws(args => exceptionFactory((string)args[0]!)); + return this; + } + + // ICallVerification + /// + public void WasCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(); + /// + public void WasCalled(global::TUnit.Mocks.Times times) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times); + /// + public void WasCalled(global::TUnit.Mocks.Times times, string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times, message); + /// + public void WasCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(message); + /// + public void WasNeverCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled(); + /// + public void WasNeverCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled(message); +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks +{ + public static class IMultiLogger_MockStaticExtension + { + extension(global::IMultiLogger _) + { + public static global::IMultiLoggerMock Mock() + { + return (global::IMultiLoggerMock)global::IMultiLoggerMockFactory.CreateAutoMock(global::TUnit.Mocks.Mock.DefaultBehavior); + } + + public static global::IMultiLoggerMock Mock(global::TUnit.Mocks.MockBehavior behavior) + { + return (global::IMultiLoggerMock)global::IMultiLoggerMockFactory.CreateAutoMock(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockFactoryBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockFactoryBuilder.cs index 149523c7d9..b31b3ed800 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockFactoryBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockFactoryBuilder.cs @@ -37,11 +37,7 @@ private static void BuildInterfaceFactory(CodeWriter writer, MockTypeModel model if (model.AdditionalInterfaceNames.Length > 0) { // Register as multi-interface factory with compound key - var allTypes = new[] { model.FullyQualifiedName } - .Concat(model.AdditionalInterfaceNames) - .Select(t => $"typeof({t}).FullName"); - var keyExpr = "string.Join(\"|\", new[] { " + string.Join(", ", allTypes) + " })"; - writer.AppendLine($"global::TUnit.Mocks.MockRegistry.RegisterMultiFactory({keyExpr}, Create);"); + writer.AppendLine($"global::TUnit.Mocks.MockRegistry.RegisterMultiFactory({GetMultiKeyExpression(model)}, Create);"); } else if (model.TypeParameters.Length > 0) { @@ -61,6 +57,11 @@ private static void BuildInterfaceFactory(CodeWriter writer, MockTypeModel model } writer.AppendLine(); + if (model.AdditionalInterfaceNames.Length > 0) + { + EmitSecondaryInterfaceMapFields(writer, model); + } + { var typeParams = MockImplBuilder.GetTypeParameterList(model); var constraints = MockImplBuilder.GetConstraintClauses(model); @@ -82,6 +83,16 @@ private static void BuildInterfaceFactory(CodeWriter writer, MockTypeModel model } } + /// Compound registry key expression for multi-type mocks — must match + /// Mock.GetMultiKey ("T1.FullName|T2.FullName|..."). + private static string GetMultiKeyExpression(MockTypeModel model) + { + var allTypes = new[] { model.FullyQualifiedName } + .Concat(model.AdditionalInterfaceNames) + .Select(t => $"typeof({t}).FullName"); + return "string.Join(\"|\", new[] { " + string.Join(", ", allTypes) + " })"; + } + private static string GetOpenGeneratedTypeOfExpression(string baseName, MockTypeModel model) { if (model.TypeParameters.Length == 0) @@ -90,9 +101,46 @@ private static string GetOpenGeneratedTypeOfExpression(string baseName, MockType return $"typeof({baseName}<{new string(',', model.TypeParameters.Length - 1)}>)"; } + /// + /// Emits one static readonly field per additional-interface member-ID map, so mock creation + /// registers a shared array instead of allocating a new one each time. Call at factory class + /// level when is non-empty. + /// + private static void EmitSecondaryInterfaceMapFields(CodeWriter writer, MockTypeModel model) + { + for (int i = 0; i < model.AdditionalInterfaceNames.Length; i++) + { + var map = GetSecondaryMap(model, i); + var arrayExpr = map.Length == 0 + ? "global::System.Array.Empty()" + : $"new int[] {{ {string.Join(", ", map)} }}"; + writer.AppendLine($"private static readonly int[] _secondaryMap{i} = {arrayExpr};"); + } + writer.AppendLine(); + } + + /// + /// Registers the standalone→union member-ID map for each additional interface of a + /// multi-type mock, so the shared per-(T1, Tn) setup extensions can translate their local + /// ordinals to this combo's union IDs at runtime. No-op for single-type mocks. + /// + private static void EmitSecondaryInterfaceRegistrations(CodeWriter writer, MockTypeModel model) + { + for (int i = 0; i < model.AdditionalInterfaceNames.Length; i++) + { + writer.AppendLine($"engine.RegisterSecondaryInterface(typeof({model.AdditionalInterfaceNames[i]}), _secondaryMap{i});"); + } + } + + private static EquatableArray GetSecondaryMap(MockTypeModel model, int index) + => index < model.SecondaryMemberIdMaps.Length + ? model.SecondaryMemberIdMaps[index] + : EquatableArray.Empty; + private static void EmitCreateInterfaceMockBody(CodeWriter writer, MockTypeModel model, string mockableType, string implTypeName, string wrapperTypeName) { writer.AppendLine($"var engine = new global::TUnit.Mocks.MockEngine<{mockableType}>(behavior);"); + EmitSecondaryInterfaceRegistrations(writer, model); writer.AppendLine($"var impl = new {implTypeName}(engine);"); writer.AppendLine("engine.Raisable = impl;"); if (MockWrapperTypeBuilder.CanGenerateWrapper(model)) @@ -136,13 +184,29 @@ private static void BuildPartialFactory(CodeWriter writer, MockTypeModel model, writer.AppendLine("[global::System.Runtime.CompilerServices.ModuleInitializer]"); using (writer.Block("internal static void Register()")) { - writer.AppendLine($"global::TUnit.Mocks.MockRegistry.RegisterFactory<{model.FullyQualifiedName}>(Create);"); + if (model.AdditionalInterfaceNames.Length > 0) + { + // Multi-type partial mock (Mock.Of()): register under the + // compound key only — RegisterFactory here would clash with the + // single-type model's factory for the same class. + writer.AppendLine($"global::TUnit.Mocks.MockRegistry.RegisterMultiFactory({GetMultiKeyExpression(model)}, Create);"); + } + else + { + writer.AppendLine($"global::TUnit.Mocks.MockRegistry.RegisterFactory<{model.FullyQualifiedName}>(Create);"); + } } writer.AppendLine(); + if (model.AdditionalInterfaceNames.Length > 0) + { + EmitSecondaryInterfaceMapFields(writer, model); + } + using (writer.Block($"private static global::TUnit.Mocks.Mock<{model.FullyQualifiedName}> Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs)")) { writer.AppendLine($"var engine = new global::TUnit.Mocks.MockEngine<{model.FullyQualifiedName}>(behavior);"); + EmitSecondaryInterfaceRegistrations(writer, model); GenerateConstructorDispatch(writer, model, safeName); diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 4be7366aca..c70673c187 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -449,7 +449,13 @@ private static void BuildPartialMockImpl(CodeWriter writer, MockTypeModel model, var typeParams = GetTypeParameterList(model); var constraints = GetConstraintClauses(model); - using (writer.Block($"file sealed class {safeName}MockImpl{typeParams} : {model.FullyQualifiedName}, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject{constraints}")) + var baseTypes = model.FullyQualifiedName; + if (model.AdditionalInterfaceNames.Length > 0) + { + baseTypes += ", " + string.Join(", ", model.AdditionalInterfaceNames); + } + + using (writer.Block($"file sealed class {safeName}MockImpl{typeParams} : {baseTypes}, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject{constraints}")) { writer.AppendLine($"private readonly global::TUnit.Mocks.MockEngine<{mockableType}> _engine;"); writer.AppendLine(); @@ -459,12 +465,23 @@ private static void BuildPartialMockImpl(CodeWriter writer, MockTypeModel model, // Generate constructors that pass through to base GeneratePartialConstructors(writer, model, safeName); - // Methods — skip static abstract (they're in bridge DIMs) + // Methods — skip static abstract (they're in bridge DIMs). + // Members owned by an additional interface (OwnerTypeIndex >= 1) come from the + // interface walk, not the class walk — emit them interface-style ((re-)implementation, + // explicit when flagged), never as `override` of a base member that may be + // non-virtual or explicitly implemented. foreach (var method in model.Methods) { if (method.IsStaticAbstract) continue; writer.AppendLine(); - GeneratePartialMethod(writer, method, model); + if (method.OwnerTypeIndex > 0) + { + GenerateInterfaceMethod(writer, method, model); + } + else + { + GeneratePartialMethod(writer, method, model); + } } // Properties — skip static abstract (they're in bridge DIMs) @@ -472,7 +489,18 @@ private static void BuildPartialMockImpl(CodeWriter writer, MockTypeModel model, { if (prop.IsStaticAbstract) continue; writer.AppendLine(); - if (prop.IsIndexer) + if (prop.OwnerTypeIndex > 0) + { + if (prop.IsIndexer) + { + GenerateInterfaceIndexer(writer, prop); + } + else + { + GenerateInterfaceProperty(writer, prop, model); + } + } + else if (prop.IsIndexer) { GeneratePartialIndexer(writer, prop); } @@ -487,7 +515,14 @@ private static void BuildPartialMockImpl(CodeWriter writer, MockTypeModel model, { if (evt.IsStaticAbstract) continue; writer.AppendLine(); - GeneratePartialEvent(writer, evt); + if (evt.OwnerTypeIndex > 0) + { + GenerateEvent(writer, evt); + } + else + { + GeneratePartialEvent(writer, evt); + } } // IRaisable.RaiseEvent dispatch @@ -1666,7 +1701,7 @@ public static string GetCompositeShortSafeName(MockTypeModel model) /// Strips the global:: prefix and namespace from a fully qualified name, /// returning just the type name (sanitized for use in identifiers). /// - private static string StripNamespaceFromFqn(string fqn) + internal static string StripNamespaceFromFqn(string fqn) { var name = StripGlobalPrefix(fqn); diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 677c80bebf..eeea26a11d 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -54,7 +54,9 @@ internal static class MockMembersBuilder public static string Build(MockTypeModel model) { var writer = new CodeWriter(); - var safeName = MockImplBuilder.GetSafeName(model.FullyQualifiedName); + // For pair models (the per-(T1, Tn) secondary surface of multi-type mocks) the composite + // name appends the interface; for everything else it equals GetSafeName(FullyQualifiedName). + var safeName = MockImplBuilder.GetCompositeSafeName(model); var mockableType = MockImplBuilder.GetMockableTypeName(model); var instanceEvents = model.Events.Where(e => !e.IsStaticAbstract).ToArray(); var hasEvents = instanceEvents.Length > 0; @@ -70,6 +72,11 @@ public static string Build(MockTypeModel model) // Extension methods class using (writer.Block($"{model.Visibility} static class {safeName}_MockMemberExtensions")) { + if (model.IsSecondaryMemberSurface) + { + EmitSecondaryMemberIdResolver(writer, model, mockableType); + } + bool firstMember = true; // Pre-compute which methods need their `out` parameters kept in the extension @@ -95,7 +102,7 @@ public static string Build(MockTypeModel model) // Properties -- extension properties via C# 14 extension blocks // (skip ref struct properties — can't use PropertyMockCall) var memberProps = model.Properties - .Where(p => !p.IsIndexer && !p.IsRefStructReturn && !p.IsReturnTypeStaticAbstractInterface && (p.HasGetter || p.HasSetter) && (p.ExplicitInterfaceName is null || p.IsStaticAbstract)) + .Where(p => p.IsConfigurableSurfaceProperty && (p.ExplicitInterfaceName is null || p.IsStaticAbstract)) .ToList(); if (memberProps.Count > 0) { @@ -135,7 +142,12 @@ public static string Build(MockTypeModel model) // setup/verify extensions. When the mocked interface already declares a member of // that name, we skip the corresponding polyfill to avoid a CS0111 duplicate — the // framework operation is still reachable via the static helper (Mock.Reset(m), …). - GenerateMockControlPolyfills(writer, model, isFirstMember: firstMember); + // Secondary-member files skip them — the primary _MockMembers file already emits + // polyfills for Mock, and a duplicate would be a CS0121 at every call site. + if (!model.IsSecondaryMemberSurface) + { + GenerateMockControlPolyfills(writer, model, isFirstMember: firstMember); + } } // Generate unified sealed classes for qualifying methods @@ -154,6 +166,61 @@ public static string Build(MockTypeModel model) return writer.ToString(); } + /// + /// Emits the __Id resolver every secondary-surface extension routes its member ID + /// through: the IDs baked into the pair surface are the interface's standalone ordinals, and + /// the engine translates them to the owning combo's union IDs via the map its factory + /// registered. Mocks created without this secondary interface have no map — the resolver + /// throws instead of letting the setup silently target a member ID the impl never dispatches. + /// + private static void EmitSecondaryMemberIdResolver(CodeWriter writer, MockTypeModel model, string mockableType) + { + var ifaceFqn = model.AdditionalInterfaceNames[0]; + var ifaceDisplay = ifaceFqn.Replace("global::", ""); + var primaryDisplay = model.FullyQualifiedName.Replace("global::", ""); + + writer.AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + using (writer.Block($"internal static int __Id(global::TUnit.Mocks.MockEngine<{mockableType}> engine, int localId)")) + { + using (writer.Block($"if (!engine.TryGetSecondaryMemberId(typeof({ifaceFqn}), localId, out var memberId))")) + { + // Two distinct failure modes: the mock lacks the interface entirely (user error, + // actionable hint) vs. the interface is registered but this slot is unmapped + // (excluded member or a correlation gap — don't gaslight the user about Of<>). + writer.AppendLine($"throw new global::System.InvalidOperationException(engine.HasSecondaryInterface(typeof({ifaceFqn}))"); + writer.AppendLine($" ? \"Member #\" + localId + \" of '{ifaceDisplay}' has no setup mapping on this mock instance — it is not part of this combo's configurable surface.\""); + writer.AppendLine($" : \"This mock was not created with '{ifaceDisplay}' as a secondary interface. Create it with Mock.Of<{primaryDisplay}, {ifaceDisplay}>() to configure its members.\");"); + } + writer.AppendLine("return memberId;"); + } + writer.AppendLine(); + } + + private const string EngineExpression = "global::TUnit.Mocks.MockRegistry.GetEngine(mock)"; + + /// + /// Emits the engine lookup for an extension body and returns the expression later code should + /// use to reference it. Secondary (pair) surfaces hoist it into a local because their + /// __Id translations need the engine too — one lookup instead of one per use. + /// + private static string EmitEngineLookup(CodeWriter writer, MockTypeModel model) + { + if (!model.IsSecondaryMemberSurface) + { + return EngineExpression; + } + writer.AppendLine($"var __engine = {EngineExpression};"); + return "__engine"; + } + + /// + /// Member-ID expression for an extension body: the literal ID for normal surfaces, or a + /// runtime __Id translation (against the hoisted __engine local) for + /// secondary (pair) surfaces. + /// + private static string FormatMemberId(MockTypeModel model, int memberId) + => model.IsSecondaryMemberSurface ? $"__Id(__engine, {memberId})" : memberId.ToString(); + /// /// Emits namespace-scoped delegate types so non-span ref struct out/ref values can travel /// through OutRefContext's object? dictionary — the delegate is the reference @@ -1017,22 +1084,25 @@ private static void EmitReturnConstruction(CodeWriter writer, MockMemberModel me ? $", {MockImplBuilder.TypeArgumentsArrayLiteral(method)}" : ""; + var engineExpr = EmitEngineLookup(writer, model); + var memberIdExpr = FormatMemberId(model, method.MemberId); + if (useTypedWrapper) { var wrapperName = GetWrapperTypeName(safeName, method, model); - writer.AppendLine($"return new {wrapperName}(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers{typeArgs});"); + writer.AppendLine($"return new {wrapperName}({engineExpr}, {memberIdExpr}, \"{method.Name}\", matchers{typeArgs});"); } else if (method.IsVoid || method.IsRefStructReturn) { - writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers{typeArgs});"); + writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall({engineExpr}, {memberIdExpr}, \"{method.Name}\", matchers{typeArgs});"); } else if (method.IsReturnTypeStaticAbstractInterface) { - writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers{typeArgs});"); + writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall({engineExpr}, {memberIdExpr}, \"{method.Name}\", matchers{typeArgs});"); } else { - writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall<{setupReturnType}>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers{typeArgs});"); + writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall<{setupReturnType}>({engineExpr}, {memberIdExpr}, \"{method.Name}\", matchers{typeArgs});"); } } @@ -1263,15 +1333,28 @@ private static void GeneratePropertyExtensionBlock(CodeWriter writer, List {safePropName}"); - writer.AppendLine($" => new(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {getterMemberId}, {setterMemberId}, \"{prop.Name}\", {hasGetter}, {hasSetter});"); + if (model.IsSecondaryMemberSurface) + { + // Block-bodied so the engine is looked up once and shared by the __Id calls. + using (writer.Block($"public global::TUnit.Mocks.PropertyMockCall<{prop.ReturnType}> {safePropName}")) + using (writer.Block("get")) + { + var engineExpr = EmitEngineLookup(writer, model); + writer.AppendLine($"return new({engineExpr}, {getterMemberId}, {setterMemberId}, \"{prop.Name}\", {hasGetter}, {hasSetter});"); + } + } + else + { + writer.AppendLine($"public global::TUnit.Mocks.PropertyMockCall<{prop.ReturnType}> {safePropName}"); + writer.AppendLine($" => new({EngineExpression}, {getterMemberId}, {setterMemberId}, \"{prop.Name}\", {hasGetter}, {hasSetter});"); + } } } } @@ -1302,7 +1385,8 @@ private static void GenerateIndexerExtensionMethods(CodeWriter writer, MockMembe using (writer.Block($"public static global::TUnit.Mocks.MockMethodCall<{indexer.ReturnType}> Item{typeParams}({getterParams}){constraints}")) { writer.AppendLine($"var matchers = {matcherList};"); - writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall<{indexer.ReturnType}>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {indexer.MemberId}, \"get_Item\", matchers);"); + var engineExpr = EmitEngineLookup(writer, model); + writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall<{indexer.ReturnType}>({engineExpr}, {FormatMemberId(model, indexer.MemberId)}, \"get_Item\", matchers);"); } } @@ -1319,7 +1403,8 @@ private static void GenerateIndexerExtensionMethods(CodeWriter writer, MockMembe using (writer.Block($"public static global::TUnit.Mocks.VoidMockMethodCall SetItem{typeParams}({setterParams}){constraints}")) { writer.AppendLine($"var matchers = {setterMatcherList};"); - writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {indexer.SetterMemberId}, \"set_Item\", matchers);"); + var engineExpr = EmitEngineLookup(writer, model); + writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall({engineExpr}, {FormatMemberId(model, indexer.SetterMemberId)}, \"set_Item\", matchers);"); } } } diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index f7b05b22e2..811f3b35d1 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -18,20 +18,98 @@ internal static class MemberDiscovery /// Sentinel entry for non-mockable members (sealed, non-virtual) that block the interface loop. private static readonly (int Index, ITypeSymbol? ReturnType) NonMockableEntry = (-1, null); + /// + /// Mutable accumulation state shared by the single- and multi-type member walks. + /// Both walks run the exact same collector over the primary type, so the primary's + /// members receive identical dense member IDs in both models — the setup extensions + /// are generated from the single-type model but dispatch against the multi impl's engine, + /// so this prefix-ID invariant is load-bearing. + /// + private sealed class DiscoveryState + { + public readonly List Methods = new(); + public readonly List Properties = new(); + public readonly List Events = new(); + public readonly Dictionary SeenMethods = new(); + public readonly HashSet SeenFullMethods = new(); + public readonly Dictionary SeenProperties = new(); + public readonly HashSet SeenEvents = new(); + /// + /// Explicit interface impls collected for members blocked by a non-mockable class member, + /// keyed "interfaceFqn|memberKey". Prevents duplicate explicit impls when the same base + /// interface is reachable through multiple additional interfaces. + /// + public readonly HashSet SeenExplicitImpls = new(); + public int MemberIdCounter; + + public (EquatableArray Methods, EquatableArray Properties, EquatableArray Events) ToResult() => ( + new EquatableArray(Methods.ToImmutableArray()), + new EquatableArray(Properties.ToImmutableArray()), + new EquatableArray(Events.ToImmutableArray()) + ); + } + public static (EquatableArray Methods, EquatableArray Properties, EquatableArray Events) DiscoverMembers(ITypeSymbol typeSymbol, IAssemblySymbol? compilationAssembly, Compilation compilation) { - var methods = new List(); - var properties = new List(); - var events = new List(); + var state = new DiscoveryState(); + CollectMembers(typeSymbol, compilationAssembly, compilation, state, ownerTypeIndex: 0, primaryClassSymbol: null); + return state.ToResult(); + } - var seenMethods = new Dictionary(); - var seenFullMethods = new HashSet(); - var seenProperties = new Dictionary(); - var seenEvents = new HashSet(); + /// + /// Discovers members from multiple type symbols, merging and deduplicating across all. + /// Used for multi-interface mocks like Mock.Of<T1, T2>(). Dedup is first-wins, so members + /// shared with the primary keep OwnerTypeIndex == 0 and a single setup extension. + /// 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, Compilation compilation) + { + var state = new DiscoveryState(); + var primaryClass = typeSymbols[0].TypeKind == TypeKind.Class ? typeSymbols[0] : null; + for (int i = 0; i < typeSymbols.Length; i++) + { + CollectMembers(typeSymbols[i], compilationAssembly, compilation, state, + ownerTypeIndex: i, primaryClassSymbol: i > 0 ? primaryClass : null); + } + return state.ToResult(); + } + + /// + /// Returns true when an additional-interface member must be emitted as an explicit interface + /// implementation because the class primary already implements it (explicitly or non-virtually) — + /// explicit re-implementation is the only way the mock can intercept interface dispatch + /// (e.g. DbContext explicitly implements IInfrastructure<IServiceProvider>.Instance). + /// + private static bool RequiresExplicitImpl(INamedTypeSymbol? primaryClassSymbol, ISymbol interfaceMember) + => primaryClassSymbol is not null + && primaryClassSymbol.FindImplementationForInterfaceMember(interfaceMember) is not null; - int memberIdCounter = 0; + private static MockMemberModel Tag(MockMemberModel model, int ownerTypeIndex) + => ownerTypeIndex == 0 ? model : model with { OwnerTypeIndex = ownerTypeIndex }; + private static MockEventModel Tag(MockEventModel model, int ownerTypeIndex) + => ownerTypeIndex == 0 ? model : model with { OwnerTypeIndex = ownerTypeIndex }; + + /// + /// Collects the mockable members of one type into the shared state. + /// : 0 = the primary type, n = 1-based index into the + /// additional interfaces of a multi-type mock. + /// : non-null only when walking an additional interface + /// of a class-primary multi mock — members the class already implements are then collected as + /// explicit interface impls (interception), instead of skipped (the single-type behavior, #5673). + /// + private static void CollectMembers( + ITypeSymbol typeSymbol, + IAssemblySymbol? compilationAssembly, + Compilation compilation, + DiscoveryState state, + int ownerTypeIndex, + INamedTypeSymbol? primaryClassSymbol) + { // Collect all interfaces to scan var interfaces = typeSymbol.TypeKind == TypeKind.Interface ? new[] { typeSymbol }.Concat(typeSymbol.AllInterfaces) @@ -40,20 +118,18 @@ public static (EquatableArray Methods, EquatableArray Methods, EquatableArray.Instance). // The inherited impl satisfies the interface; the mock only needs to override // what the class walk already collected (virtual/abstract/override members). + // (Additional-interface walks never take this branch — there typeSymbol is the + // interface itself; the class-implemented members are intercepted via + // RequiresExplicitImpl below instead.) if (typeSymbol.TypeKind == TypeKind.Class && typeSymbol.FindImplementationForInterfaceMember(member) is not null) { @@ -74,25 +153,36 @@ public static (EquatableArray Methods, EquatableArray.GetEnumerator vs IEnumerable.GetEnumerator). var fullKey = GetFullMethodKey(method); - if (!seenFullMethods.Add(fullKey)) continue; // true duplicate + if (!state.SeenFullMethods.Add(fullKey)) + { + // Exact signature already seen. Normally a true duplicate — but when + // the prior sighting is a non-mockable class member (NonMockableEntry) + // and we're walking an additional interface, re-implement explicitly + // so the mock intercepts interface dispatch anyway. + if (primaryClassSymbol is null || existing.Index != NonMockableEntry.Index) continue; + if (!state.SeenExplicitImpls.Add($"{interfaceFqn}|{fullKey}")) continue; + state.Methods.Add(Tag(CreateMethodModel(method, ref state.MemberIdCounter, interfaceFqn, interfaceFqn, explicitInterfaceCanDelegate: false, compilation: compilation), ownerTypeIndex)); + break; + } // Signature collision with different return type → explicit interface impl. // Delegation is safe only if the public method's return type implements // the explicit impl's return type (e.g. IEnumerator : IEnumerator). var canDelegate = existing.ReturnType is not null && CanDelegateReturnType(existing.ReturnType, method.ReturnType); - methods.Add(CreateMethodModel(method, ref memberIdCounter, interfaceFqn, interfaceFqn, explicitInterfaceCanDelegate: canDelegate, compilation: compilation)); + state.Methods.Add(Tag(CreateMethodModel(method, ref state.MemberIdCounter, interfaceFqn, interfaceFqn, explicitInterfaceCanDelegate: canDelegate, compilation: compilation), ownerTypeIndex)); } else { - seenMethods[key] = (methods.Count, method.ReturnType); - seenFullMethods.Add(GetFullMethodKey(method)); - methods.Add(CreateMethodModel(method, ref memberIdCounter, explicitInterfaceName, interfaceFqn, compilation: compilation)); + var explicitName = RequiresExplicitImpl(primaryClassSymbol, method) ? interfaceFqn : null; + state.SeenMethods[key] = (state.Methods.Count, method.ReturnType); + state.SeenFullMethods.Add(GetFullMethodKey(method)); + state.Methods.Add(Tag(CreateMethodModel(method, ref state.MemberIdCounter, explicitName, interfaceFqn, compilation: compilation), ownerTypeIndex)); } break; } @@ -100,27 +190,47 @@ public static (EquatableArray Methods, EquatableArray Methods, EquatableArray p.Type.GetFullyQualifiedName())); var key = $"I:[{paramTypes}]"; - if (seenProperties.TryGetValue(key, out var existingIndex)) + if (state.SeenProperties.TryGetValue(key, out var existingIndex)) { if (existingIndex.HasValue) { - MergePropertyAccessors(properties, existingIndex.Value, indexer, ref memberIdCounter, compilationAssembly); + MergePropertyAccessors(state.Properties, existingIndex.Value, indexer, ref state.MemberIdCounter, compilationAssembly); + } + else if (primaryClassSymbol is not null && state.SeenExplicitImpls.Add($"{interfaceFqn}|{key}")) + { + state.Properties.Add(Tag(CreateIndexerModel(indexer, ref state.MemberIdCounter, interfaceFqn, interfaceFqn, compilationAssembly, compilation), ownerTypeIndex)); } } else { - seenProperties[key] = properties.Count; - properties.Add(CreateIndexerModel(indexer, ref memberIdCounter, explicitInterfaceName, interfaceFqn, compilationAssembly, compilation)); + var explicitName = RequiresExplicitImpl(primaryClassSymbol, indexer) ? interfaceFqn : null; + state.SeenProperties[key] = state.Properties.Count; + state.Properties.Add(Tag(CreateIndexerModel(indexer, ref state.MemberIdCounter, explicitName, interfaceFqn, compilationAssembly, compilation), ownerTypeIndex)); } break; } @@ -147,164 +262,32 @@ public static (EquatableArray Methods, EquatableArray(methods.ToImmutableArray()), - new EquatableArray(properties.ToImmutableArray()), - new EquatableArray(events.ToImmutableArray()) - ); - } - - /// - /// 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, Compilation compilation) - { - var methods = new List(); - var properties = new List(); - var events = new List(); - - var seenMethods = new Dictionary(); - var seenFullMethods = new HashSet(); - var seenProperties = new Dictionary(); - var seenEvents = new HashSet(); - - int memberIdCounter = 0; - - foreach (var typeSymbol in typeSymbols) - { - // Collect all interfaces to scan (the type itself + its inherited interfaces) - var interfaces = typeSymbol.TypeKind == TypeKind.Interface - ? new[] { typeSymbol }.Concat(typeSymbol.AllInterfaces) - : typeSymbol.AllInterfaces.AsEnumerable(); - - foreach (var iface in interfaces) - { - var interfaceFqn = iface.GetFullyQualifiedName(); - - foreach (var member in iface.GetMembers()) - { - if (member.IsStatic) - { - TryCollectStaticAbstractFromInterface(member, typeSymbol, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter, compilation); - continue; - } - - switch (member) - { - case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary: - { - var key = GetMethodKey(method); - if (seenMethods.TryGetValue(key, out var existing)) - { - var fullKey = GetFullMethodKey(method); - if (!seenFullMethods.Add(fullKey)) continue; - - var canDelegate = existing.ReturnType is not null - && CanDelegateReturnType(existing.ReturnType, method.ReturnType); - methods.Add(CreateMethodModel(method, ref memberIdCounter, - interfaceFqn, declaringInterfaceName: interfaceFqn, explicitInterfaceCanDelegate: canDelegate, compilation: compilation)); - } - else - { - seenMethods[key] = (methods.Count, method.ReturnType); - seenFullMethods.Add(GetFullMethodKey(method)); - methods.Add(CreateMethodModel(method, ref memberIdCounter, - null, declaringInterfaceName: interfaceFqn, compilation: compilation)); - } - break; - } - - case IPropertySymbol property when !property.IsIndexer: - { - var key = $"P:{property.Name}"; - if (seenProperties.TryGetValue(key, out var existingIndex)) - { - if (existingIndex.HasValue) - { - var existingProp = properties[existingIndex.Value]; - if (existingProp.ReturnType != property.Type.GetFullyQualifiedNameWithNullability()) - { - properties.Add(CreatePropertyModel(property, ref memberIdCounter, interfaceFqn, declaringInterfaceName: interfaceFqn, compilationAssembly: compilationAssembly, compilation: compilation)); - } - else - { - MergePropertyAccessors(properties, existingIndex.Value, property, ref memberIdCounter, compilationAssembly); - } - } - } - else - { - seenProperties[key] = properties.Count; - properties.Add(CreatePropertyModel(property, ref memberIdCounter, null, declaringInterfaceName: interfaceFqn, compilationAssembly: compilationAssembly, compilation: compilation)); - } - break; - } - - case IPropertySymbol indexer when indexer.IsIndexer: - { - var paramTypes = string.Join(',', indexer.Parameters.Select(p => p.Type.GetFullyQualifiedName())); - var key = $"I:[{paramTypes}]"; - if (seenProperties.TryGetValue(key, out var existingIndex)) - { - if (existingIndex.HasValue) - { - MergePropertyAccessors(properties, existingIndex.Value, indexer, ref memberIdCounter, compilationAssembly); - } - } - else - { - seenProperties[key] = properties.Count; - properties.Add(CreateIndexerModel(indexer, ref memberIdCounter, null, declaringInterfaceName: interfaceFqn, compilationAssembly: compilationAssembly, compilation: compilation)); - } - break; - } - - case IEventSymbol evt: - { - var key = $"E:{evt.Name}"; - if (!seenEvents.Add(key)) continue; - events.Add(CreateEventModel(evt, null, declaringInterfaceName: interfaceFqn)); - break; - } - } - } - } - } - - return ( - new EquatableArray(methods.ToImmutableArray()), - new EquatableArray(properties.ToImmutableArray()), - new EquatableArray(events.ToImmutableArray()) - ); } private static void ProcessClassMembers( ITypeSymbol typeSymbol, IAssemblySymbol? compilationAssembly, Compilation compilation, - List methods, - List properties, - List events, - Dictionary seenMethods, - HashSet seenFullMethods, - Dictionary seenProperties, - HashSet seenEvents, - ref int memberIdCounter) + DiscoveryState state) { + var methods = state.Methods; + var properties = state.Properties; + var events = state.Events; + var seenMethods = state.SeenMethods; + var seenFullMethods = state.SeenFullMethods; + var seenProperties = state.SeenProperties; + var seenEvents = state.SeenEvents; + ref int memberIdCounter = ref state.MemberIdCounter; + // Walk up the class hierarchy var current = typeSymbol; while (current != null && current.SpecialType != SpecialType.System_Object) @@ -1134,19 +1117,13 @@ 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, + DiscoveryState state, Compilation compilation) { if (!member.IsAbstract) return; if (!ShouldCollectStaticAbstractFromInterfaces(typeSymbol)) return; - CollectStaticAbstractMember(member, interfaceFqn, methods, properties, events, seenMethods, seenProperties, seenEvents, ref memberIdCounter, compilation); + CollectStaticAbstractMember(member, interfaceFqn, state, compilation); } /// @@ -1176,15 +1153,17 @@ private static bool IsInterfaceWithStaticAbstractMembers(ITypeSymbol type) private static void CollectStaticAbstractMember( ISymbol member, string interfaceFqn, - List methods, - List properties, - List events, - Dictionary seenMethods, - Dictionary seenProperties, - HashSet seenEvents, - ref int memberIdCounter, + DiscoveryState state, Compilation compilation) { + var methods = state.Methods; + var properties = state.Properties; + var events = state.Events; + var seenMethods = state.SeenMethods; + var seenProperties = state.SeenProperties; + var seenEvents = state.SeenEvents; + ref int memberIdCounter = ref state.MemberIdCounter; + switch (member) { case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary: diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs index a509a14928..b0a2ec5948 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs @@ -147,7 +147,17 @@ public static ImmutableArray TransformToModels(GeneratorSyntaxCon if (singleTypeModel is null) return ImmutableArray.Empty; - // Build multi-type model (generates impl + factory only) + // Transitive auto-mock factories over the primary AND additional interfaces — without + // these, members returning user interfaces reference CreateAutoMock factories that are + // never generated when only Mock.Of() appears in the assembly. + var visited = new HashSet(); + var transitiveModels = DiscoverTransitiveInterfaceTypes(namedType, visited, maxDepth: 3, compilationAssembly, compilation); + foreach (var additionalType in additionalTypes) + { + transitiveModels.AddRange(DiscoverTransitiveInterfaceTypes(additionalType, visited, maxDepth: 3, compilationAssembly, compilation)); + } + + // Build multi-type model (generates impl + factory) var allTypes = new[] { namedType }.Concat(additionalTypes).ToArray(); var (methods, properties, events) = MemberDiscovery.DiscoverMembersFromMultipleTypes(allTypes, compilationAssembly, compilation); @@ -176,11 +186,47 @@ public static ImmutableArray TransformToModels(GeneratorSyntaxCon AdditionalInterfaceNames = new EquatableArray(additionalInterfaceNames.MoveToImmutable()), Constructors = singleTypeModel.Constructors, HasStaticAbstractMembers = methods.Any(m => m.IsStaticAbstract) || properties.Any(p => p.IsStaticAbstract) || events.Any(e => e.IsStaticAbstract), - IsPublic = IsEffectivelyPublic(namedType), + // The secondary setup extensions surface additional-interface types in public + // signatures, so the whole multi model must drop to internal if ANY type is. + IsPublic = IsEffectivelyPublic(namedType) && additionalTypes.All(IsEffectivelyPublic), UseFallbackNamespace = singleTypeModel.UseFallbackNamespace }; - return ImmutableArray.Create(singleTypeModel, multiTypeModel); + // Per additional interface: compute the standalone→union member-ID map (registered on the + // engine by the factory) and build the pair model that generates the shared setup surface. + var surfaceContext = SecondarySurfaceFactory.CreateContext(multiTypeModel, singleTypeModel); + var mapsBuilder = ImmutableArray.CreateBuilder>(additionalTypes.Count); + var pairModels = new List(); + foreach (var additionalType in additionalTypes) + { + var standalone = BuildSingleTypeModel(additionalType, isPartialMock: false, compilationAssembly, compilation); + if (standalone is null) + { + mapsBuilder.Add(EquatableArray.Empty); + continue; + } + + mapsBuilder.Add(SecondarySurfaceFactory.ComputeMemberIdMap(standalone, surfaceContext)); + + var pairModel = SecondarySurfaceFactory.BuildPairModel( + standalone, singleTypeModel, additionalType.GetFullyQualifiedName(), surfaceContext); + if (pairModel is not null) + { + pairModels.Add(pairModel); + } + } + + multiTypeModel = multiTypeModel with + { + SecondaryMemberIdMaps = new EquatableArray>(mapsBuilder.MoveToImmutable()) + }; + + var resultBuilder = ImmutableArray.CreateBuilder(2 + pairModels.Count + transitiveModels.Count); + resultBuilder.Add(singleTypeModel); + resultBuilder.Add(multiTypeModel); + resultBuilder.AddRange(pairModels); + resultBuilder.AddRange(transitiveModels); + return resultBuilder.MoveToImmutable(); } /// diff --git a/TUnit.Mocks.SourceGenerator/Discovery/SecondarySurfaceFactory.cs b/TUnit.Mocks.SourceGenerator/Discovery/SecondarySurfaceFactory.cs new file mode 100644 index 0000000000..73bbd887d2 --- /dev/null +++ b/TUnit.Mocks.SourceGenerator/Discovery/SecondarySurfaceFactory.cs @@ -0,0 +1,241 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using TUnit.Mocks.SourceGenerator.Builders; +using TUnit.Mocks.SourceGenerator.Models; + +namespace TUnit.Mocks.SourceGenerator.Discovery; + +/// +/// Builds the per-(primary, additional-interface) PAIR artifacts for multi-type mocks: +/// +/// 1. — the standalone→union member-ID translation a combo's +/// factory registers on its engine. The setup extensions for an additional interface are +/// generated ONCE per (T1, Tn) pair from Tn's standalone walk (so they are identical no matter +/// how many combos contain the pair and dedupe in the pipeline), which means they carry Tn's +/// standalone member IDs — but each combo's impl dispatches its own union IDs, and the same +/// member can have a different union ID in Mock.Of<T1,T2> vs Mock.Of<T1,T2,T3>. +/// The map bridges the two at runtime. +/// +/// 2. — the pair model the members builder turns into that shared +/// extension surface (targeting Mock<T1>, member IDs = standalone ordinals). +/// +/// Both consume a built once per combo — the union lookups and the +/// primary surface index are invariant across the combo's additional interfaces. +/// +internal static class SecondarySurfaceFactory +{ + /// Per-combo invariants: union member lookups and the primary's configurable surface. + internal sealed class SurfaceContext + { + /// Union members by full signature, first-wins — so members the union + /// deduplicated against the primary resolve to the PRIMARY member's ID. + public readonly Dictionary UnionMethods = new(System.StringComparer.Ordinal); + public readonly Dictionary UnionProperties = new(System.StringComparer.Ordinal); + + public readonly HashSet PrimaryMethodSignatures = new(System.StringComparer.Ordinal); + public readonly HashSet PrimaryMethodNameParams = new(System.StringComparer.Ordinal); + public readonly HashSet PrimaryPropertySignatures = new(System.StringComparer.Ordinal); + public readonly HashSet PrimaryNames = new(System.StringComparer.Ordinal); + public readonly HashSet PrimaryEventNames = new(System.StringComparer.Ordinal); + } + + public static SurfaceContext CreateContext(MockTypeModel union, MockTypeModel primary) + { + var context = new SurfaceContext(); + + foreach (var m in union.Methods) + { + var key = GetMethodSignatureKey(GetMethodNameParamsKey(m), m); + if (!context.UnionMethods.ContainsKey(key)) context.UnionMethods.Add(key, m); + } + foreach (var p in union.Properties) + { + var key = GetPropertySignatureKey(p); + if (!context.UnionProperties.ContainsKey(key)) context.UnionProperties.Add(key, p); + } + + // The primary's configurable surface, as the members builder filters it. + foreach (var m in primary.Methods) + { + if (m.ExplicitInterfaceName is not null && !m.IsStaticAbstract) continue; + var nameParams = GetMethodNameParamsKey(m); + context.PrimaryMethodNameParams.Add(nameParams); + context.PrimaryMethodSignatures.Add(GetMethodSignatureKey(nameParams, m)); + context.PrimaryNames.Add(m.Name); + } + foreach (var p in primary.Properties) + { + if (p.ExplicitInterfaceName is not null && !p.IsStaticAbstract) continue; + // Exclusion considers ALL primary properties (an identical-signature member is + // union-deduped onto the primary slot whether or not it's configurable), but rename + // decisions only consider names the builder actually exposes as extensions. + context.PrimaryPropertySignatures.Add(GetPropertySignatureKey(p)); + if (p.IsConfigurableSurfaceProperty) context.PrimaryNames.Add(p.Name); + } + foreach (var e in primary.Events) + { + context.PrimaryNames.Add(e.Name); + context.PrimaryEventNames.Add(e.Name); + } + + return context; + } + + /// + /// Maps every member ID of (an additional interface's + /// standalone model) to the matching member's ID in the combo's union model, correlating by + /// full signature. Unmatched slots get -1. Members the union deduplicated against the primary + /// map to the PRIMARY member's ID, so a setup made through either surface configures the same + /// engine slot. + /// + public static EquatableArray ComputeMemberIdMap(MockTypeModel standalone, SurfaceContext context) + { + var maxId = -1; + foreach (var m in standalone.Methods) + { + if (m.MemberId > maxId) maxId = m.MemberId; + } + foreach (var p in standalone.Properties) + { + if (p.MemberId > maxId) maxId = p.MemberId; + if (p.HasSetter && p.SetterMemberId > maxId) maxId = p.SetterMemberId; + } + + if (maxId < 0) + { + return EquatableArray.Empty; + } + + var map = new int[maxId + 1]; + for (int i = 0; i < map.Length; i++) + { + map[i] = -1; + } + + foreach (var m in standalone.Methods) + { + if (context.UnionMethods.TryGetValue(GetMethodSignatureKey(GetMethodNameParamsKey(m), m), out var um)) + { + map[m.MemberId] = um.MemberId; + } + } + foreach (var p in standalone.Properties) + { + if (context.UnionProperties.TryGetValue(GetPropertySignatureKey(p), out var up)) + { + map[p.MemberId] = up.MemberId; + if (p.HasSetter && up.HasSetter) + { + map[p.SetterMemberId] = up.SetterMemberId; + } + } + } + + return new EquatableArray(ImmutableArray.Create(map)); + } + + /// + /// Builds the pair model generating the setup/verify extension surface for one additional + /// interface: primary identity (extensions target Mock<T1>), the interface's + /// standalone members. Members the primary surface already exposes identically are excluded + /// (their union slot IS the primary member — its extension covers both); members whose name + /// clashes with a different primary member are renamed with a short interface prefix + /// (mock.IBar_Tag) to keep call sites unambiguous. Returns null when nothing remains. + /// + public static MockTypeModel? BuildPairModel( + MockTypeModel standalone, MockTypeModel primary, string interfaceFqn, SurfaceContext context) + { + var shortName = MockImplBuilder.StripNamespaceFromFqn(interfaceFqn); + + var methods = ImmutableArray.CreateBuilder(); + foreach (var m in standalone.Methods) + { + // Static abstracts have no bridge on the multi path; explicit members are + // interface-internal shims — both excluded, same as the single-type surface. + if (m.IsStaticAbstract || m.ExplicitInterfaceName is not null) continue; + var nameParams = GetMethodNameParamsKey(m); + if (context.PrimaryMethodSignatures.Contains(GetMethodSignatureKey(nameParams, m))) continue; + // Rename changes the extension's Name only; MemberId stays the standalone ordinal and + // ComputeMemberIdMap correlates by the ORIGINAL signature key, so the map is unaffected. + methods.Add(context.PrimaryMethodNameParams.Contains(nameParams) + ? m with { Name = $"{shortName}_{m.Name}" } // same name+params, different return — would be ambiguous + : m); + } + + var properties = ImmutableArray.CreateBuilder(); + foreach (var p in standalone.Properties) + { + if (p.IsStaticAbstract || p.ExplicitInterfaceName is not null) continue; + if (context.PrimaryPropertySignatures.Contains(GetPropertySignatureKey(p))) continue; + properties.Add(!p.IsIndexer && context.PrimaryNames.Contains(p.Name) + ? p with { Name = $"{shortName}_{p.Name}" } + : p); + } + + var events = ImmutableArray.CreateBuilder(); + foreach (var e in standalone.Events) + { + if (e.IsStaticAbstract || e.ExplicitInterfaceName is not null) continue; + if (context.PrimaryEventNames.Contains(e.Name)) continue; // identical name = deduped onto the primary event + events.Add(e); + } + + if (methods.Count == 0 && properties.Count == 0 && events.Count == 0) + { + return null; + } + + return standalone with + { + FullyQualifiedName = primary.FullyQualifiedName, + OpenGenericTypeOfExpression = primary.OpenGenericTypeOfExpression, + Name = primary.Name, + Namespace = primary.Namespace, + IsInterface = primary.IsInterface, + IsAbstract = primary.IsAbstract, + IsPartialMock = false, + TypeParameters = EquatableArray.Empty, + Methods = new EquatableArray(methods.ToImmutable()), + Properties = new EquatableArray(properties.ToImmutable()), + Events = new EquatableArray(events.ToImmutable()), + AllInterfaces = primary.AllInterfaces, + AdditionalInterfaceNames = new EquatableArray(ImmutableArray.Create(interfaceFqn)), + Constructors = EquatableArray.Empty, + HasStaticAbstractMembers = false, + IsPublic = primary.IsPublic && standalone.IsPublic, + UseFallbackNamespace = primary.UseFallbackNamespace, + IsSecondaryMemberSurface = true, + SecondaryMemberIdMaps = EquatableArray>.Empty + }; + } + + private static string GetMethodNameParamsKey(MockMemberModel m) + { + var sb = new System.Text.StringBuilder(m.Name).Append('`').Append(m.TypeParameters.Length).Append('('); + for (int i = 0; i < m.Parameters.Length; i++) + { + if (i > 0) sb.Append(','); + var p = m.Parameters[i]; + sb.Append(p.Direction).Append(':').Append(p.FullyQualifiedType); + } + return sb.Append(')').ToString(); + } + + private static string GetMethodSignatureKey(string nameParamsKey, MockMemberModel m) + => $"{nameParamsKey}:{m.ReturnType}"; + + private static string GetPropertySignatureKey(MockMemberModel p) + { + if (!p.IsIndexer) + { + return $"{p.Name}:{p.ReturnType}"; + } + var sb = new System.Text.StringBuilder("this["); + for (int i = 0; i < p.Parameters.Length; i++) + { + if (i > 0) sb.Append(','); + sb.Append(p.Parameters[i].FullyQualifiedType); + } + return sb.Append("]:").Append(p.ReturnType).ToString(); + } +} diff --git a/TUnit.Mocks.SourceGenerator/MockGenerator.cs b/TUnit.Mocks.SourceGenerator/MockGenerator.cs index 8afe1db518..fee3c10ad0 100644 --- a/TUnit.Mocks.SourceGenerator/MockGenerator.cs +++ b/TUnit.Mocks.SourceGenerator/MockGenerator.cs @@ -63,7 +63,14 @@ namespace TUnit.Mocks.Generated; // Step 3: Generate source for each unique type context.RegisterSourceOutput(distinctTypes, (spc, model) => { - if (model.IsDelegateType) + if (model.IsSecondaryMemberSurface) + { + // Pair model: the shared setup/verify surface for one additional interface of a + // multi-type mock. Emitted once per (primary, interface) pair across all combos. + var secondaryMembersSource = MockMembersBuilder.Build(model); + spc.AddSource($"{GetSafeFileName(model)}_MockSecondaryMembers.g.cs", secondaryMembersSource); + } + else if (model.IsDelegateType) { // Delegate mock: generate members and delegate factory (no impl class) GenerateDelegateMock(spc, model); @@ -75,8 +82,8 @@ namespace TUnit.Mocks.Generated; } else if (model.AdditionalInterfaceNames.Length > 0) { - // Multi-interface mock: generate ONLY impl + factory - // Members/raise come from the single-type model (also emitted) + // Multi-interface mock: generate impl + factory + secondary-member setup + // extensions. Primary members/raise come from the single-type model (also emitted). GenerateMultiInterfaceMock(spc, model); } else diff --git a/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs index 9faa7f580f..5fe178145e 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs @@ -38,6 +38,12 @@ internal sealed record MockEventModel : IEquatable public string OverrideAccessModifier { get; init; } = "public"; public bool IsStaticAbstract { get; init; } + /// + /// Which type in a multi-type mock owns this event: 0 = the primary type, + /// n = 1-based index into . + /// + public int OwnerTypeIndex { get; init; } + /// /// Structured representation of raise parameters. Use this instead of parsing RaiseParameters /// by comma — which breaks for generic types like Func<int, string>. @@ -59,6 +65,7 @@ public bool Equals(MockEventModel? other) && DeclaringInterfaceName == other.DeclaringInterfaceName && OverrideAccessModifier == other.OverrideAccessModifier && IsStaticAbstract == other.IsStaticAbstract + && OwnerTypeIndex == other.OwnerTypeIndex && RaiseParameterList == other.RaiseParameterList && ObsoleteAttribute == other.ObsoleteAttribute; } @@ -75,6 +82,7 @@ public override int GetHashCode() hash = hash * 31 + (DeclaringInterfaceName?.GetHashCode() ?? 0); hash = hash * 31 + OverrideAccessModifier.GetHashCode(); hash = hash * 31 + ObsoleteAttribute.GetHashCode(); + hash = hash * 31 + OwnerTypeIndex; return hash; } } diff --git a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index a7aea7344b..5f02f057cd 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -48,6 +48,14 @@ internal sealed record MockMemberModel : IEquatable public bool IsStaticAbstract { get; init; } public string? AutoMockFactoryMethod { get; init; } + /// + /// Which type in a multi-type mock owns this member: 0 = the primary type, + /// n = 1-based index into . + /// Always 0 for single-type mocks. Members shared between the primary and an + /// additional interface keep the first occurrence (0) — dedup is first-wins. + /// + public int OwnerTypeIndex { get; init; } + /// /// True when the (unwrapped) return type is an interface that has static abstract members /// without a most specific implementation. Such types cannot be used as generic type arguments @@ -96,6 +104,16 @@ internal sealed record MockMemberModel : IEquatable /// public bool HasRefStructParams => Parameters.Any(p => p.IsRefStruct && p.Direction != ParameterDirection.Out); + /// + /// True when this property can appear as an extension property on the generated + /// Mock<T> setup surface (ref-struct and static-abstract-interface returns can't + /// flow through PropertyMockCall<T>; indexers get Item/SetItem methods instead). + /// Shared between the members builder and the secondary-surface rename logic so the two + /// definitions of "exposed property" can't drift. Derived — not part of equality. + /// + public bool IsConfigurableSurfaceProperty + => !IsIndexer && !IsRefStructReturn && !IsReturnTypeStaticAbstractInterface && (HasGetter || HasSetter); + public bool Equals(MockMemberModel? other) { if (other is null) return false; @@ -128,6 +146,7 @@ public bool Equals(MockMemberModel? other) && IsRefStructReturn == other.IsRefStructReturn && IsStaticAbstract == other.IsStaticAbstract && AutoMockFactoryMethod == other.AutoMockFactoryMethod + && OwnerTypeIndex == other.OwnerTypeIndex && IsReturnTypeStaticAbstractInterface == other.IsReturnTypeStaticAbstractInterface && SpanReturnElementType == other.SpanReturnElementType && ObsoleteAttribute == other.ObsoleteAttribute @@ -149,6 +168,7 @@ public override int GetHashCode() hash = hash * 31 + GetterAccessModifier.GetHashCode(); hash = hash * 31 + SetterAccessModifier.GetHashCode(); hash = hash * 31 + (AutoMockFactoryMethod?.GetHashCode() ?? 0); + hash = hash * 31 + OwnerTypeIndex; hash = hash * 31 + IsReturnTypeStaticAbstractInterface.GetHashCode(); hash = hash * 31 + (ExplicitInterfaceName?.GetHashCode() ?? 0); hash = hash * 31 + ExplicitInterfaceCanDelegate.GetHashCode(); diff --git a/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs index 887cc9a712..81e2124c8c 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs @@ -26,6 +26,26 @@ internal sealed record MockTypeModel : IEquatable public EquatableArray Constructors { get; init; } = EquatableArray.Empty; public bool HasStaticAbstractMembers { get; init; } + /// + /// True for the per-(primary, additional-interface) PAIR model that generates the setup/verify + /// extension surface for one additional interface of a multi-type mock. Pair models carry the + /// PRIMARY type's identity ( = T1, so extensions target + /// Mock<T1>) but the ADDITIONAL interface's standalone members + /// ( holds that single interface), whose member IDs are + /// local ordinals translated to the owning impl's union IDs at runtime via the map the factory + /// registers on the engine. Equal pair models from different combos (Mock.Of<T1,T2> and + /// Mock.Of<T1,T2,T3>) dedupe in the pipeline, so each pair surface is emitted exactly once. + /// + public bool IsSecondaryMemberSurface { get; init; } + + /// + /// On multi-type models only: one entry per element, + /// mapping that interface's standalone member IDs (the pair surface's local ordinals, + /// indexed positionally) to this combo's union member IDs (-1 = unmapped). Emitted into the + /// factory as engine.RegisterSecondaryInterface(typeof(Tn), map). + /// + public EquatableArray> SecondaryMemberIdMaps { get; init; } = EquatableArray>.Empty; + /// /// True if the mocked type's effective accessibility is public (the type itself and all /// containing types are public). When false, generated wrapper/extension types must be @@ -64,7 +84,9 @@ public bool Equals(MockTypeModel? other) && AllInterfaces.Equals(other.AllInterfaces) && AdditionalInterfaceNames.Equals(other.AdditionalInterfaceNames) && Constructors.Equals(other.Constructors) - && HasStaticAbstractMembers == other.HasStaticAbstractMembers; + && HasStaticAbstractMembers == other.HasStaticAbstractMembers + && IsSecondaryMemberSurface == other.IsSecondaryMemberSurface + && SecondaryMemberIdMaps.Equals(other.SecondaryMemberIdMaps); } public override int GetHashCode() @@ -85,6 +107,8 @@ public override int GetHashCode() hash = hash * 31 + Events.GetHashCode(); hash = hash * 31 + AdditionalInterfaceNames.GetHashCode(); hash = hash * 31 + HasStaticAbstractMembers.GetHashCode(); + hash = hash * 31 + IsSecondaryMemberSurface.GetHashCode(); + hash = hash * 31 + SecondaryMemberIdMaps.GetHashCode(); return hash; } } diff --git a/TUnit.Mocks.Tests/MultipleInterfaceSecondarySetupTests.cs b/TUnit.Mocks.Tests/MultipleInterfaceSecondarySetupTests.cs new file mode 100644 index 0000000000..e2f4ed422d --- /dev/null +++ b/TUnit.Mocks.Tests/MultipleInterfaceSecondarySetupTests.cs @@ -0,0 +1,284 @@ +using TUnit.Mocks.Exceptions; + +namespace TUnit.Mocks.Tests; + +/// +/// Types for testing setup/verify on SECONDARY interfaces of multi-type mocks (#4981). +/// +public interface IMultiHasInstance +{ + T Instance { get; } +} + +public interface IMultiConflictA +{ + string Tag { get; } +} + +public interface IMultiConflictB +{ + int Tag { get; } +} + +public interface IMultiDupPing +{ + void Ping(); +} + +public interface IMultiDupPing2 +{ + void Ping(); +} + +public interface IMultiExtra +{ + string Instance { get; } +} + +/// Models the DbContext shape from #4981: the interface member is implemented explicitly. +public class MultiServiceBase : IMultiExtra +{ + public virtual string GetName() => "real"; + string IMultiExtra.Instance => "real-instance"; +} + +/// The interface member is blocked by a non-virtual public implementation. +public class MultiServiceBlocked : IMultiExtra +{ + public string Instance => "blocked-real"; +} + +public class MultiServiceWithCtor : IMultiExtra +{ + public MultiServiceWithCtor(string seed) + { + Seed = seed; + } + + public string Seed { get; } + + string IMultiExtra.Instance => Seed; +} + +/// +/// Setup and verification on the secondary interfaces of Mock.Of<T1, T2, ...>() (#4981). +/// Secondary members surface as extensions on Mock<T1>, sharing the single engine. +/// +public class MultipleInterfaceSecondarySetupTests +{ + [Test] + public async Task Can_Setup_Secondary_Interface_Method() + { + var mock = Mock.Of(); + mock.Serialize().Returns("json"); + + var result = ((IMultiSerializable)mock.Object).Serialize(); + + await Assert.That(result).IsEqualTo("json"); + } + + [Test] + public async Task Can_Setup_Secondary_Interface_Property() + { + var mock = Mock.Of(); + mock.IsDisposed.Returns(true); + + var disposed = ((IMultiDisposable)mock.Object).IsDisposed; + + await Assert.That(disposed).IsTrue(); + } + + [Test] + public async Task Can_Verify_Secondary_Interface_Calls() + { + var mock = Mock.Of(); + + ((IMultiDisposable)mock.Object).Dispose(); + + mock.Dispose().WasCalled(Times.Once); + await Assert.That(Mock.Invocations(mock)).Count().IsEqualTo(1); + } + + [Test] + public async Task Can_Setup_Third_And_Fourth_Interface() + { + var mock = Mock.Of(); + mock.Serialize().Returns("third"); + mock.CanClone.Returns(true); + + await Assert.That(((IMultiSerializable)mock.Object).Serialize()).IsEqualTo("third"); + await Assert.That(((IMultiCloneable)mock.Object).CanClone).IsTrue(); + } + + [Test] + public async Task Same_Pair_Works_Across_Different_Combos() + { + // IMultiSerializable has a different union member layout in the 2-combo vs the 4-combo; + // the shared extension surface must resolve the right IDs for each. + var twoCombo = Mock.Of(); + var fourCombo = Mock.Of(); + twoCombo.Serialize().Returns("two"); + fourCombo.Serialize().Returns("four"); + + await Assert.That(((IMultiSerializable)twoCombo.Object).Serialize()).IsEqualTo("two"); + await Assert.That(((IMultiSerializable)fourCombo.Object).Serialize()).IsEqualTo("four"); + } + + [Test] + public async Task VerifyInOrder_Interleaves_Primary_And_Secondary_Calls() + { + var mock = Mock.Of(); + mock.Log(Any()); + + mock.Object.Log("first"); + ((IMultiDisposable)mock.Object).Dispose(); + mock.Object.Log("second"); + + Mock.VerifyInOrder(() => + { + mock.Log(Is("first")).WasCalled(); + mock.Dispose().WasCalled(); + mock.Log(Is("second")).WasCalled(); + }); + await Assert.That(Mock.Invocations(mock)).Count().IsEqualTo(3); + } + + [Test] + public async Task Strict_Mode_Throws_For_Unconfigured_Secondary_Member() + { + var mock = Mock.Of(MockBehavior.Strict); + + Assert.Throws(() => + { + ((IMultiDisposable)mock.Object).Dispose(); + }); + await Assert.That(Mock.Behavior(mock)).IsEqualTo(MockBehavior.Strict); + } + + [Test] + public async Task Reset_Clears_Secondary_Setups() + { + var mock = Mock.Of(); + mock.IsDisposed.Returns(true); + + Mock.Reset(mock); + + await Assert.That(((IMultiDisposable)mock.Object).IsDisposed).IsFalse(); + } + + [Test] + public async Task Secondary_Extension_On_Plain_Mock_Throws() + { + // IsDisposed is a Mock extension, so it compiles here — but this mock was + // not created with IMultiDisposable, so configuring it must fail loudly, not no-op. + var plain = Mock.Of(); + + var ex = Assert.Throws(() => _ = plain.IsDisposed); + await Assert.That(ex.Message).Contains("IMultiDisposable"); + } + + [Test] + public async Task Secondary_Extension_On_Wrong_Combo_Throws() + { + var mock = Mock.Of(); + + var ex = Assert.Throws(() => _ = mock.IsDisposed); + await Assert.That(ex.Message).Contains("IMultiDisposable"); + } + + [Test] + public async Task Member_Shared_With_Primary_Configures_Through_Primary_Extension() + { + // IMultiDupPing2.Ping is signature-identical to the primary's Ping, so it deduplicates + // onto the primary member: one extension, one engine slot, intercepting both casts. + var mock = Mock.Of(); + mock.Ping(); + + mock.Object.Ping(); + ((IMultiDupPing2)mock.Object).Ping(); + + mock.Ping().WasCalled(Times.Exactly(2)); + await Assert.That(Mock.Invocations(mock)).Count().IsEqualTo(2); + } + + [Test] + public async Task Conflicting_Secondary_Property_Is_Prefixed_With_Interface_Name() + { + // IMultiConflictA.Tag (string) and IMultiConflictB.Tag (int) collide by name, so the + // secondary surface exposes the prefixed IMultiConflictB_Tag. + var mock = Mock.Of(); + mock.Tag.Returns("primary"); + mock.IMultiConflictB_Tag.Returns(42); + + await Assert.That(mock.Object.Tag).IsEqualTo("primary"); + await Assert.That(((IMultiConflictB)mock.Object).Tag).IsEqualTo(42); + } + + [Test] + public async Task Class_Primary_With_Explicitly_Implemented_Interface_Member() + { + // The #4981 DbContext shape: class primary, interface member implemented explicitly. + var mock = Mock.Of(); + mock.Instance.Returns("mocked-instance"); + + var value = ((IMultiExtra)mock.Object).Instance; + + await Assert.That(value).IsEqualTo("mocked-instance"); + } + + [Test] + public async Task Class_Primary_Unconfigured_Secondary_Member_Returns_Default() + { + // The mock re-implements the interface to intercept, so the base's explicit + // implementation ("real-instance") is unreachable — unconfigured returns the smart default. + var mock = Mock.Of(); + + await Assert.That(((IMultiExtra)mock.Object).Instance).IsEmpty(); + } + + [Test] + public async Task Class_Primary_Virtual_Member_Still_Behaves_As_Partial_Mock() + { + var mock = Mock.Of(); + + // Unconfigured virtual member falls back to the base implementation... + await Assert.That(mock.Object.GetName()).IsEqualTo("real"); + + // ...and can still be configured. + mock.GetName().Returns("configured"); + await Assert.That(mock.Object.GetName()).IsEqualTo("configured"); + } + + [Test] + public async Task Class_Primary_With_NonVirtual_Blocking_Member() + { + var mock = Mock.Of(); + mock.Instance.Returns("mocked"); + + // Interface dispatch hits the mock's explicit re-implementation... + await Assert.That(((IMultiExtra)mock.Object).Instance).IsEqualTo("mocked"); + // ...while direct (non-virtual) access still reaches the real member. + await Assert.That(mock.Object.Instance).IsEqualTo("blocked-real"); + } + + [Test] + public async Task Class_Primary_With_Constructor_Args() + { + var mock = Mock.Of("seeded"); + mock.Instance.Returns("from-mock"); + + await Assert.That(mock.Object.Seed).IsEqualTo("seeded"); + await Assert.That(((IMultiExtra)mock.Object).Instance).IsEqualTo("from-mock"); + } + + [Test] + public async Task Generic_Secondary_Interface() + { + var mock = Mock.Of>(); + mock.Instance.Returns("generic-instance"); + + var value = ((IMultiHasInstance)mock.Object).Instance; + + await Assert.That(value).IsEqualTo("generic-instance"); + } +} diff --git a/TUnit.Mocks/Mock.cs b/TUnit.Mocks/Mock.cs index 5977e20146..dd68dd1001 100644 --- a/TUnit.Mocks/Mock.cs +++ b/TUnit.Mocks/Mock.cs @@ -127,17 +127,17 @@ public static Mock Of() /// Creates a mock implementing both T1 and T2 with specified behavior. public static Mock Of(MockBehavior behavior) where T1 : class where T2 : class - { - var key = GetMultiKey(typeof(T1), typeof(T2)); - if (MockRegistry.TryGetMultiFactory(key, out var factory)) - { - return (Mock)factory(behavior, Array.Empty()); - } + => Of(behavior, Array.Empty()); - throw new InvalidOperationException( - $"No multi-interface mock factory registered for types '{typeof(T1).FullName}' and '{typeof(T2).FullName}'. " + - $"Ensure the TUnit.Mocks source generator is referenced in your project."); - } + /// Creates a mock implementing both T1 and T2 using the configured default behavior, optionally passing constructor arguments when T1 is a concrete class. + public static Mock Of(params object[] constructorArgs) + where T1 : class where T2 : class + => Of(DefaultBehavior, constructorArgs); + + /// Creates a mock implementing both T1 and T2 with specified behavior, optionally passing constructor arguments when T1 is a concrete class. + public static Mock Of(MockBehavior behavior, params object[] constructorArgs) + where T1 : class where T2 : class + => OfMulti(behavior, constructorArgs, typeof(T1), typeof(T2)); /// Creates a mock implementing T1, T2, and T3 using the configured default behavior. public static Mock Of() @@ -147,17 +147,17 @@ public static Mock Of() /// Creates a mock implementing T1, T2, and T3 with specified behavior. public static Mock Of(MockBehavior behavior) where T1 : class where T2 : class where T3 : class - { - var key = GetMultiKey(typeof(T1), typeof(T2), typeof(T3)); - if (MockRegistry.TryGetMultiFactory(key, out var factory)) - { - return (Mock)factory(behavior, Array.Empty()); - } + => Of(behavior, Array.Empty()); - throw new InvalidOperationException( - $"No multi-interface mock factory registered for types '{typeof(T1).FullName}', '{typeof(T2).FullName}', and '{typeof(T3).FullName}'. " + - $"Ensure the TUnit.Mocks source generator is referenced in your project."); - } + /// Creates a mock implementing T1, T2, and T3 using the configured default behavior, optionally passing constructor arguments when T1 is a concrete class. + public static Mock Of(params object[] constructorArgs) + where T1 : class where T2 : class where T3 : class + => Of(DefaultBehavior, constructorArgs); + + /// Creates a mock implementing T1, T2, and T3 with specified behavior, optionally passing constructor arguments when T1 is a concrete class. + public static Mock Of(MockBehavior behavior, params object[] constructorArgs) + where T1 : class where T2 : class where T3 : class + => OfMulti(behavior, constructorArgs, typeof(T1), typeof(T2), typeof(T3)); /// Creates a mock implementing T1, T2, T3, and T4 using the configured default behavior. public static Mock Of() @@ -167,19 +167,33 @@ public static Mock Of() /// Creates a mock implementing T1, T2, T3, and T4 with specified behavior. public static Mock Of(MockBehavior behavior) where T1 : class where T2 : class where T3 : class where T4 : class + => Of(behavior, Array.Empty()); + + /// Creates a mock implementing T1, T2, T3, and T4 using the configured default behavior, optionally passing constructor arguments when T1 is a concrete class. + public static Mock Of(params object[] constructorArgs) + where T1 : class where T2 : class where T3 : class where T4 : class + => Of(DefaultBehavior, constructorArgs); + + /// Creates a mock implementing T1, T2, T3, and T4 with specified behavior, optionally passing constructor arguments when T1 is a concrete class. + public static Mock Of(MockBehavior behavior, params object[] constructorArgs) + where T1 : class where T2 : class where T3 : class where T4 : class + => OfMulti(behavior, constructorArgs, typeof(T1), typeof(T2), typeof(T3), typeof(T4)); + + private static Mock OfMulti(MockBehavior behavior, object[] constructorArgs, params Type[] types) + where T1 : class { - var key = GetMultiKey(typeof(T1), typeof(T2), typeof(T3), typeof(T4)); + var key = GetMultiKey(types); if (MockRegistry.TryGetMultiFactory(key, out var factory)) { - return (Mock)factory(behavior, Array.Empty()); + return (Mock)factory(behavior, constructorArgs); } throw new InvalidOperationException( - $"No multi-interface mock factory registered for types '{typeof(T1).FullName}', '{typeof(T2).FullName}', '{typeof(T3).FullName}', and '{typeof(T4).FullName}'. " + + $"No multi-interface mock factory registered for types {string.Join(", ", types.Select(t => $"'{t.FullName}'"))}. " + $"Ensure the TUnit.Mocks source generator is referenced in your project."); } - private static string GetMultiKey(params Type[] types) + private static string GetMultiKey(Type[] types) { return string.Join('|', types.Select(t => t.FullName ?? t.ToString())); } diff --git a/TUnit.Mocks/MockEngine.SecondaryInterfaces.cs b/TUnit.Mocks/MockEngine.SecondaryInterfaces.cs new file mode 100644 index 0000000000..71dc1102c3 --- /dev/null +++ b/TUnit.Mocks/MockEngine.SecondaryInterfaces.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; + +namespace TUnit.Mocks; + +public sealed partial class MockEngine where T : class +{ + // Maps each additional interface of a multi-type mock (Mock.Of) to an array + // translating the interface's standalone member ordinals (baked into the generated + // secondary setup extensions, which are shared across all combos containing the pair) + // to this impl's union member IDs. Registered once during factory construction, before + // the mock is published — read-only afterwards, so no locking is needed. + // A flat pair array beats a Dictionary here: at most 3 entries, probed by reference + // equality on cached typeof() instances, and one small allocation per mock creation. + private (Type InterfaceType, int[] Map)[]? _secondaryMemberIdMaps; + + /// + /// Registers the member-ID translation map for one additional interface of a multi-type mock. + /// Called by generated factories only. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public void RegisterSecondaryInterface(Type interfaceType, int[] memberIdMap) + { + var existing = _secondaryMemberIdMaps; + if (existing is null) + { + _secondaryMemberIdMaps = new[] { (interfaceType, memberIdMap) }; + return; + } + + var expanded = new (Type, int[])[existing.Length + 1]; + existing.CopyTo(expanded, 0); + expanded[existing.Length] = (interfaceType, memberIdMap); + _secondaryMemberIdMaps = expanded; + } + + /// + /// Resolves an additional interface's standalone member ordinal to this mock's member ID. + /// Returns false when the mock was not created with as a + /// secondary interface (or the member has no mapping). Called by generated code only. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public bool TryGetSecondaryMemberId(Type interfaceType, int localMemberId, out int memberId) + { + var maps = _secondaryMemberIdMaps; + if (maps is not null) + { + foreach (var (type, map) in maps) + { + if (ReferenceEquals(type, interfaceType)) + { + if ((uint)localMemberId < (uint)map.Length && map[localMemberId] >= 0) + { + memberId = map[localMemberId]; + return true; + } + break; + } + } + } + + memberId = -1; + return false; + } + + /// + /// True when this mock was created with as a secondary + /// interface. Used by generated __Id resolvers to distinguish "wrong mock" from + /// "registered, but this member has no mapping" when a lookup fails. Called by generated + /// code only. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public bool HasSecondaryInterface(Type interfaceType) + { + var maps = _secondaryMemberIdMaps; + if (maps is null) + { + return false; + } + foreach (var (type, _) in maps) + { + if (ReferenceEquals(type, interfaceType)) + { + return true; + } + } + return false; + } +} diff --git a/docs/docs/writing-tests/mocking/setup.md b/docs/docs/writing-tests/mocking/setup.md index 10406ec55c..ec5cfd3ebb 100644 --- a/docs/docs/writing-tests/mocking/setup.md +++ b/docs/docs/writing-tests/mocking/setup.md @@ -260,6 +260,28 @@ mock.Object.Log("test"); Supports up to 4 interfaces: `Mock.Of()`. +Members of the secondary interfaces appear directly on the mock, just like the primary's — setup, verify, and event raising all work the same way: + +```csharp +var mock = Mock.Of(); + +mock.IsDisposed.Returns(true); // IDisposable property setup +mock.Dispose().WasCalled(); // IDisposable verification +``` + +When a secondary member's name collides with a member of another interface on the mock, it is exposed with a short interface prefix instead (e.g. `mock.IDisposable_Tag`). + +The primary type can also be a concrete class — useful for types like EF Core's `DbContext` that implement infrastructure interfaces explicitly: + +```csharp +var mock = Mock.Of>(); +mock.Instance.Returns(serviceProvider); + +((IInfrastructure)mock.Object).Instance; // serviceProvider +``` + +Constructor arguments for class primaries are supported: `Mock.Of(arg1, arg2)`. + ## Setup Chaining Setup methods return chain objects that support additional behaviors: