From 94cf096ec0483e60c68f2b4eb1478a516c5ee690 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:52:23 +0100 Subject: [PATCH 1/2] fix(mocks): make generated identifier sanitization injective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SanitizeIdentifier mapped every separator to '_' and collapsed runs, so `A_B.IFoo` and `A.B.IFoo` both produced `A_B_IFoo`. That string is the AddSource hint name, and a duplicate hint name makes Roslyn drop the generated sources for both types with no diagnostic — the user only sees the downstream CS1061/CS0117 from the missing setup surface. Double literal underscores before mapping separators, so a single '_' in the result always came from a separator. The two copies of the routine (MockImplBuilder and TypeSymbolExtensions) now share one implementation in IdentifierEscaping. Only names that already contain an underscore change. The three refreshed snapshots are multi-interface mocks whose composite hint name gains a doubled underscore at the join; their contents are unchanged, only the hint-name ordering of the emitted files. Fixes #6505 --- .../Builders/MockImplBuilder.cs | 35 +-- .../Extensions/TypeSymbolExtensions.cs | 37 +-- .../IdentifierEscaping.cs | 49 ++++ .../Issue6505Tests.cs | 90 +++++++ ...ass_Primary_And_Explicit_Impl.verified.txt | 198 +++++++------- ...With_Conflicting_Member_Names.verified.txt | 210 +++++++-------- ..._With_Secondary_Setup_Surface.verified.txt | 244 +++++++++--------- 7 files changed, 467 insertions(+), 396 deletions(-) create mode 100644 tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs diff --git a/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 1c5e9057904..af0d054e02b 100644 --- a/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -1725,40 +1725,7 @@ private static string StripGlobalPrefix(string name) => name.StartsWith("global::") ? name.Substring("global::".Length) : name; private static string SanitizeIdentifier(string name) - { - name = name.Replace("global::", ""); - - var sb = new System.Text.StringBuilder(name.Length); - var lastWasUnderscore = false; - - foreach (var c in name) - { - if (c == ' ') - continue; - - if (char.IsLetterOrDigit(c) || c == '_') - { - if (c == '_') - { - if (lastWasUnderscore) - continue; - lastWasUnderscore = true; - } - else - { - lastWasUnderscore = false; - } - sb.Append(c); - } - else if (!lastWasUnderscore) - { - sb.Append('_'); - lastWasUnderscore = true; - } - } - - return sb.ToString(); - } + => IdentifierEscaping.SanitizeIdentifier(name); /// /// Root namespace for fallback-mode mock emission, used when the original namespace diff --git a/src/TUnit.Mocks.SourceGenerator/Extensions/TypeSymbolExtensions.cs b/src/TUnit.Mocks.SourceGenerator/Extensions/TypeSymbolExtensions.cs index e641c05946f..619729e4187 100644 --- a/src/TUnit.Mocks.SourceGenerator/Extensions/TypeSymbolExtensions.cs +++ b/src/TUnit.Mocks.SourceGenerator/Extensions/TypeSymbolExtensions.cs @@ -304,40 +304,5 @@ private static string StripGlobalPrefix(string name) => name.StartsWith("global::") ? name.Substring("global::".Length) : name; private static string SanitizeIdentifier(string name) - { - name = name.Replace("global::", ""); - - var sb = new StringBuilder(name.Length); - var lastWasUnderscore = false; - - foreach (var c in name) - { - if (c == ' ') - continue; - - if (char.IsLetterOrDigit(c) || c == '_') - { - if (c == '_') - { - if (lastWasUnderscore) - continue; - - lastWasUnderscore = true; - } - else - { - lastWasUnderscore = false; - } - - sb.Append(c); - } - else if (!lastWasUnderscore) - { - sb.Append('_'); - lastWasUnderscore = true; - } - } - - return sb.ToString(); - } + => IdentifierEscaping.SanitizeIdentifier(name); } diff --git a/src/TUnit.Mocks.SourceGenerator/IdentifierEscaping.cs b/src/TUnit.Mocks.SourceGenerator/IdentifierEscaping.cs index 651679acafa..fd9449eb99e 100644 --- a/src/TUnit.Mocks.SourceGenerator/IdentifierEscaping.cs +++ b/src/TUnit.Mocks.SourceGenerator/IdentifierEscaping.cs @@ -1,3 +1,4 @@ +using System.Text; using Microsoft.CodeAnalysis.CSharp; namespace TUnit.Mocks.SourceGenerator; @@ -26,4 +27,52 @@ internal static class IdentifierEscaping // interface implementation matching against the source-declared member name. internal static string EscapeIdentifier(string name) => SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ? "@" + name : name; + + /// + /// Turns a type name (fully qualified, or a bare name with generic arguments) into a single + /// C# identifier, used for generated type names and AddSource hint names. + /// + /// The mapping must be injective: a hint-name clash makes Roslyn drop the generated sources + /// for both types with no diagnostic, so the user only ever sees the downstream + /// CS1061/CS0117 from the missing surface. Every literal _ is therefore doubled before + /// separators become _, so a single underscore in the result always came from a + /// separator. Without that, A_B.IFoo and A.B.IFoo both produced + /// A_B_IFoo — see issue #6505. + /// + /// + internal static string SanitizeIdentifier(string name) + { + name = name.Replace("global::", ""); + + var sb = new StringBuilder(name.Length); + var lastWasSeparator = false; + + foreach (var c in name) + { + if (c == ' ') + { + continue; + } + + if (c == '_') + { + sb.Append("__"); + lastWasSeparator = false; + } + else if (char.IsLetterOrDigit(c)) + { + sb.Append(c); + lastWasSeparator = false; + } + else if (!lastWasSeparator) + { + // Runs of separators still collapse to one '_' ("IFoo" -> "IFoo_T_"); only the + // separator/underscore distinction has to survive. + sb.Append('_'); + lastWasSeparator = true; + } + } + + return sb.ToString(); + } } diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs new file mode 100644 index 00000000000..5ec28424667 --- /dev/null +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs @@ -0,0 +1,90 @@ +namespace TUnit.Mocks.SourceGenerator.Tests; + +/// +/// Regression: https://github.com/thomhurst/TUnit/issues/6505 +/// Generated type names and AddSource hint names come from a sanitized form of the mocked +/// type's fully qualified name. That sanitization used to map every separator to _ and +/// collapse runs, so A_B.IFoo and A.B.IFoo both produced A_B_IFoo. Roslyn +/// drops both sources on a duplicate hint name without reporting anything, so mocking both types +/// in one compilation silently generated nothing for either. +/// +public class Issue6505Tests : SnapshotTestBase +{ + private const string UnderscoreNamespaceInterface = """ + namespace A_B { public interface IFoo { void Go(); } } + """; + + private const string DottedNamespaceInterface = """ + namespace A.B { public interface IFoo { void Stop(); } } + """; + + [Test] + public async Task Types_With_Colliding_Sanitized_Names_Both_Generate() + { + var source = $$""" + using TUnit.Mocks; + + {{UnderscoreNamespaceInterface}} + {{DottedNamespaceInterface}} + + public class Test + { + public void Run() + { + var one = Mock.Of(); + var two = Mock.Of(); + } + } + """; + + var generated = RunGenerator(source); + + // Each interface declares a distinct member, so both surfaces are visible in the output. + await Assert.That(generated.Any(g => g.Contains("void Go()"))).IsTrue(); + await Assert.That(generated.Any(g => g.Contains("void Stop()"))).IsTrue(); + } + + [Test] + public async Task Mocking_Both_Colliding_Types_Drops_No_Generated_File() + { + var underscoreOnly = RunGenerator(MockOf("A_B.IFoo", UnderscoreNamespaceInterface)); + var dottedOnly = RunGenerator(MockOf("A.B.IFoo", DottedNamespaceInterface)); + var both = RunGenerator(MockOf( + "A_B.IFoo", UnderscoreNamespaceInterface, + "A.B.IFoo", DottedNamespaceInterface)); + + // The post-initialization TUnit.Mocks.Generated namespace stub is the one file all three + // runs share; everything else must survive being generated alongside the other type. + await Assert.That(both.Length).IsEqualTo(underscoreOnly.Length + dottedOnly.Length - 1); + } + + private static string MockOf(string typeName, string declaration) => $$""" + using TUnit.Mocks; + + {{declaration}} + + public class Test + { + public void Run() + { + var one = Mock.Of<{{typeName}}>(); + } + } + """; + + private static string MockOf(string firstType, string firstDeclaration, string secondType, string secondDeclaration) => $$""" + using TUnit.Mocks; + + {{firstDeclaration}} + {{secondDeclaration}} + + public class Test + { + public void Run() + { + var one = Mock.Of<{{firstType}}>(); + var two = Mock.Of<{{secondType}}>(); + } + } + """; +} diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Class_Primary_And_Explicit_Impl.verified.txt b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Class_Primary_And_Explicit_Impl.verified.txt index c11e60fb911..01067a5d7f8 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Class_Primary_And_Explicit_Impl.verified.txt +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Class_Primary_And_Explicit_Impl.verified.txt @@ -2,105 +2,6 @@ #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 - -namespace TUnit.Mocks.Generated -{ - 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; @@ -237,6 +138,105 @@ namespace TUnit.Mocks } +// ===== FILE SEPARATOR ===== + +// +#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 + +namespace TUnit.Mocks.Generated +{ + 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 ===== // diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Conflicting_Member_Names.verified.txt b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Conflicting_Member_Names.verified.txt index f3d273fb912..28ea6935f70 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Conflicting_Member_Names.verified.txt +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Conflicting_Member_Names.verified.txt @@ -2,111 +2,6 @@ #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 - -namespace TUnit.Mocks.Generated -{ - 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)] @@ -264,6 +159,111 @@ namespace TUnit.Mocks } +// ===== FILE SEPARATOR ===== + +// +#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 + +namespace TUnit.Mocks.Generated +{ + 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 ===== // diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Secondary_Setup_Surface.verified.txt b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Secondary_Setup_Surface.verified.txt index e28c75f99a6..f692751ae4f 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Secondary_Setup_Surface.verified.txt +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Interface_Mock_With_Secondary_Setup_Surface.verified.txt @@ -2,128 +2,6 @@ #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 - -namespace TUnit.Mocks.Generated -{ - 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)] @@ -384,6 +262,128 @@ namespace TUnit.Mocks } +// ===== FILE SEPARATOR ===== + +// +#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 + +namespace TUnit.Mocks.Generated +{ + 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 ===== // From f44a8fa4326b320057e82a35065c29e535638dab Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:10:33 +0100 Subject: [PATCH 2/2] fix(mocks): report TM008 when two mocked types share a generated name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: doubling literal underscores is not enough on its own. No mapping onto [A-Za-z0-9_] can be injective while both a separator and an underscore render as runs of '_' — a run of three cannot say which order it came in, so `A_.B.IFoo` and `A._B.IFoo` still meet at `A___B_IFoo`. Detect that before anything is written: the dedupe step already sees every model, so group them by generated name and flag the ones that share. Colliding types are skipped with TM008 naming both culprits and the name they share, instead of duplicate hint names aborting the generator and taking every unrelated mock in the compilation with them. Grouping keys on the secondary-surface flag (a multi-interface combo and its pair surface share a composite name by design) and ignores models that are the same target mocked in different modes. --- .../Diagnostics.cs | 21 +++++ .../GeneratedNameCollisionDetector.cs | 82 +++++++++++++++++++ .../MockGenerator.cs | 17 +++- .../Models/MockTypeModel.cs | 11 +++ .../TUnit.Mocks.SourceGenerator.csproj | 2 + .../Issue6505Tests.cs | 76 +++++++++++++++++ .../SnapshotTestBase.cs | 35 ++++++++ 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/TUnit.Mocks.SourceGenerator/Diagnostics.cs create mode 100644 src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs diff --git a/src/TUnit.Mocks.SourceGenerator/Diagnostics.cs b/src/TUnit.Mocks.SourceGenerator/Diagnostics.cs new file mode 100644 index 00000000000..c6bf7dcff5a --- /dev/null +++ b/src/TUnit.Mocks.SourceGenerator/Diagnostics.cs @@ -0,0 +1,21 @@ +using Microsoft.CodeAnalysis; + +namespace TUnit.Mocks.SourceGenerator; + +/// +/// Diagnostics reported by the generator itself. Everything the analyzer can see at a call site +/// belongs in TUnit.Mocks.Analyzers (TM001-TM007); this file is for failures only the generator +/// can observe, which is currently just whole-compilation name collisions. +/// +internal static class Diagnostics +{ + public static readonly DiagnosticDescriptor TM008_GeneratedNameCollision = new( + id: "TM008", + title: "Mocked types produce the same generated name", + messageFormat: "Cannot mock '{0}' because it produces the same generated name '{1}' as '{2}'. Rename one of the types or namespaces.", + category: "TUnit.Mocks", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Generated type and file names are derived from the mocked type's fully qualified name with separators replaced by underscores. Two types can still map to the same name when their namespaces differ only in how underscores and dots are arranged (e.g. 'A_.B.IFoo' and 'A._B.IFoo'). Emitting both would give Roslyn duplicate hint names, which discards every mock in the compilation without saying why, so generation is skipped for the colliding types and reported here instead." + ); +} diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs new file mode 100644 index 00000000000..945cd4b7902 --- /dev/null +++ b/src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs @@ -0,0 +1,82 @@ +using TUnit.Mocks.SourceGenerator.Builders; +using TUnit.Mocks.SourceGenerator.Models; + +namespace TUnit.Mocks.SourceGenerator.Discovery; + +/// +/// Flags mocked types whose generated names collide. +/// +/// Generated type and AddSource hint names come from the mocked type's fully qualified name +/// with separators mapped to _. doubles +/// literal underscores so the realistic cases stay distinct, but no mapping onto +/// [A-Za-z0-9_] can be injective while a separator and an underscore both render as runs of +/// _: a run of three cannot say whether it was underscore-then-separator or the reverse, so +/// A_.B.IFoo and A._B.IFoo still meet at A___B_IFoo. +/// +/// +/// A duplicate hint name aborts the whole generator, which costs the user every mock in the +/// compilation and reports only a CS8785 warning pointing at the generator rather than at either +/// type. Skipping the colliding types and reporting TM008 keeps the rest of the compilation's +/// mocks and names both culprits. See issue #6505. +/// +/// +internal static class GeneratedNameCollisionDetector +{ + /// + /// Returns in input order, with + /// set on every model that shares its generated name with another. + /// + internal static List Annotate(IEnumerable models) + { + var ordered = models.ToList(); + + // The name alone is not the key: a multi-interface combo and the secondary setup surface + // for the same (primary, interface) pair intentionally share a composite name and are told + // apart by the hint-name suffix, so they must not be flagged. + var groups = new Dictionary<(bool IsSecondaryMemberSurface, string Name), List>(); + + foreach (var model in ordered) + { + var key = (model.IsSecondaryMemberSurface, MockImplBuilder.GetCompositeSafeName(model)); + + if (!groups.TryGetValue(key, out var group)) + { + groups[key] = group = new List(); + } + + group.Add(model); + } + + if (groups.Count == ordered.Count) + { + return ordered; + } + + var annotated = new List(ordered.Count); + + foreach (var model in ordered) + { + var group = groups[(model.IsSecondaryMemberSurface, MockImplBuilder.GetCompositeSafeName(model))]; + + // Same target mocked in more than one mode (Mock.Of and Mock.Wrap of one type, say) + // reaches this point as separate models sharing an identity. Only distinct targets + // meeting at one name are a #6505 collision. + var others = group + .Where(other => Identity(other) != Identity(model)) + .Select(other => other.FullyQualifiedName) + .Distinct() + .ToList(); + + annotated.Add(others.Count == 0 + ? model + : model with { CollidesWith = string.Join(", ", others) }); + } + + return annotated; + } + + private static string Identity(MockTypeModel model) + => model.AdditionalInterfaceNames.Length == 0 + ? model.FullyQualifiedName + : model.FullyQualifiedName + "|" + string.Join("|", model.AdditionalInterfaceNames); +} diff --git a/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs b/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs index 9be21f689ed..7055d807dce 100644 --- a/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs +++ b/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs @@ -57,12 +57,27 @@ namespace TUnit.Mocks.Generated; foreach (var m in mockOfTypes) set.Add(m); foreach (var m in attributeTypes) set.Add(m); foreach (var m in extensionInvocations) set.Add(m); - return set; + + // Flag types that would emit the same generated names before anything is written: + // duplicate hint names abort the generator and take every mock in the compilation + // with them. See issue #6505. + return GeneratedNameCollisionDetector.Annotate(set); }); // Step 3: Generate source for each unique type context.RegisterSourceOutput(distinctTypes, (spc, model) => { + if (model.CollidesWith is not null) + { + spc.ReportDiagnostic(Diagnostic.Create( + Diagnostics.TM008_GeneratedNameCollision, + Location.None, + model.FullyQualifiedName, + MockImplBuilder.GetCompositeSafeName(model), + model.CollidesWith)); + return; + } + if (model.IsSecondaryMemberSurface) { // Pair model: the shared setup/verify surface for one additional interface of a diff --git a/src/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs b/src/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs index d15bf18020b..dd3d4e9c578 100644 --- a/src/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs +++ b/src/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs @@ -63,6 +63,15 @@ internal sealed record MockTypeModel : IEquatable /// The C# visibility keyword to emit on generated wrapper/extension types. public string Visibility => IsPublic ? "public" : "internal"; + /// + /// Set by when another mocked type in + /// the same compilation sanitizes to the same generated name (see issue #6505). Generation is + /// skipped and TM008 is reported, because emitting both would give Roslyn duplicate hint names + /// — which silently discards every mock in the compilation. Holds the other type's fully + /// qualified name (comma separated when more than one collides). + /// + public string? CollidesWith { get; init; } + /// /// True for a class target that exposes no constructor the generated impl could chain to. /// The impl derives from the target, so with every constructor private (or cross-assembly @@ -98,6 +107,7 @@ public bool Equals(MockTypeModel? other) && Constructors.Equals(other.Constructors) && HasStaticAbstractMembers == other.HasStaticAbstractMembers && IsSecondaryMemberSurface == other.IsSecondaryMemberSurface + && CollidesWith == other.CollidesWith && SecondaryMemberIdMaps.Equals(other.SecondaryMemberIdMaps); } @@ -120,6 +130,7 @@ public override int GetHashCode() hash = hash * 31 + AdditionalInterfaceNames.GetHashCode(); hash = hash * 31 + HasStaticAbstractMembers.GetHashCode(); hash = hash * 31 + IsSecondaryMemberSurface.GetHashCode(); + hash = hash * 31 + (CollidesWith?.GetHashCode() ?? 0); hash = hash * 31 + SecondaryMemberIdMaps.GetHashCode(); return hash; } diff --git a/src/TUnit.Mocks.SourceGenerator/TUnit.Mocks.SourceGenerator.csproj b/src/TUnit.Mocks.SourceGenerator/TUnit.Mocks.SourceGenerator.csproj index 6133d99340d..3b73f95f29c 100644 --- a/src/TUnit.Mocks.SourceGenerator/TUnit.Mocks.SourceGenerator.csproj +++ b/src/TUnit.Mocks.SourceGenerator/TUnit.Mocks.SourceGenerator.csproj @@ -10,6 +10,8 @@ TUnit.Mocks.SourceGenerator false false + + RS2008 diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs index 5ec28424667..5960eb31a1f 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs @@ -58,6 +58,82 @@ public async Task Mocking_Both_Colliding_Types_Drops_No_Generated_File() await Assert.That(both.Length).IsEqualTo(underscoreOnly.Length + dottedOnly.Length - 1); } + [Test] + public async Task Sanitized_Names_Keep_Separators_And_Underscores_Apart() + { + // The realistic shapes must all stay distinct; a run of underscores in the output is only + // ever a literal underscore when it is doubled. + await Assert.That(IdentifierEscaping.SanitizeIdentifier("global::A.B.IFoo")).IsEqualTo("A_B_IFoo"); + await Assert.That(IdentifierEscaping.SanitizeIdentifier("global::A_B.IFoo")).IsEqualTo("A__B_IFoo"); + await Assert.That(IdentifierEscaping.SanitizeIdentifier("global::A.B_IFoo")).IsEqualTo("A_B__IFoo"); + await Assert.That(IdentifierEscaping.SanitizeIdentifier("global::A.IFoo")).IsEqualTo("A_IFoo_A_B_"); + } + + [Test] + public async Task Names_That_Still_Collide_Are_Reported_Instead_Of_Silently_Dropped() + { + // No mapping onto [A-Za-z0-9_] can be injective while both a separator and an underscore + // render as runs of '_': three underscores cannot say which order they came in, so + // 'A_.B.IFoo' and 'A._B.IFoo' both sanitize to 'A___B_IFoo'. That must fail loudly. + var source = """ + using TUnit.Mocks; + + namespace A_.B { public interface IFoo { void Go(); } } + namespace A._B { public interface IFoo { void Stop(); } } + + public class Test + { + public void Run() + { + var one = Mock.Of(); + var two = Mock.Of(); + } + } + """; + + var (sources, diagnostics) = RunGeneratorForDiagnostics(source); + + var collisions = diagnostics.Where(d => d.Id == "TM008").ToList(); + await Assert.That(collisions).HasCount(2); + await Assert.That(collisions[0].GetMessage()).Contains("A___B_IFoo"); + // Both culprits are named, so the message says what to rename. + await Assert.That(collisions.Select(d => d.GetMessage()).Any(m => m.Contains("A_.B.IFoo"))).IsTrue(); + await Assert.That(collisions.Select(d => d.GetMessage()).Any(m => m.Contains("A._B.IFoo"))).IsTrue(); + + // Nothing was emitted for either type, but the generator did not abort. + await Assert.That(sources.Any(s => s.Contains("void Go()"))).IsFalse(); + await Assert.That(sources.Any(s => s.Contains("void Stop()"))).IsFalse(); + } + + [Test] + public async Task A_Collision_Does_Not_Stop_Other_Mocks_In_The_Compilation() + { + // The pre-#6505 behaviour was a duplicate hint name aborting the generator, which took + // every unrelated mock in the compilation with it. + var source = """ + using TUnit.Mocks; + + namespace A_.B { public interface IFoo { void Go(); } } + namespace A._B { public interface IFoo { void Stop(); } } + + public interface IUnrelated { void Untouched(); } + + public class Test + { + public void Run() + { + var one = Mock.Of(); + var two = Mock.Of(); + var three = Mock.Of(); + } + } + """; + + var (sources, _) = RunGeneratorForDiagnostics(source); + + await Assert.That(sources.Any(s => s.Contains("void Untouched()"))).IsTrue(); + } + private static string MockOf(string typeName, string declaration) => $$""" using TUnit.Mocks; diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs index b1a89487091..b3ff9ec78e3 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs @@ -90,6 +90,41 @@ protected static string[] RunGenerator( .ToArray(); } + /// + /// Runs the MockGenerator and returns its diagnostics alongside the generated files. Unlike + /// this does not throw on generator errors — use it when the + /// diagnostic is what the test is about. + /// + protected static (string[] Sources, IReadOnlyList Diagnostics) RunGeneratorForDiagnostics( + string source, + IEnumerable? additionalReferences = null) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); + + IEnumerable refs = additionalReferences is null + ? _references.Value + : _references.Value.Concat(additionalReferences); + + var compilation = CSharpCompilation.Create( + "TestAssembly", + [syntaxTree], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ).WithReferences(refs); + + var generator = new MockGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create([generator.AsSourceGenerator()], parseOptions: parseOptions); + + var runResult = driver.RunGenerators(compilation).GetRunResult(); + + var sources = runResult.GeneratedTrees + .OrderBy(t => GetGeneratedTreeSortKey(t.FilePath), StringComparer.Ordinal) + .Select(t => t.GetText().ToString()) + .ToArray(); + + return (sources, runResult.Diagnostics); + } + private static string GetGeneratedTreeSortKey(string filePath) { var normalizedPath = filePath.Replace('\\', '/');