From 60360b4093105af0a6822106d23de795e99edc19 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:40 +0100 Subject: [PATCH 1/2] fix(mocks): report generator failures --- .../Diagnostics.cs | 12 +- .../MockGenerator.cs | 197 +++++++++++++----- .../Models/MockGenerationRequest.cs | 40 ++++ .../MockGeneratorDiagnosticTests.cs | 49 +++++ .../SnapshotTestBase.cs | 5 +- 5 files changed, 243 insertions(+), 60 deletions(-) create mode 100644 src/TUnit.Mocks.SourceGenerator/Models/MockGenerationRequest.cs create mode 100644 tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorDiagnosticTests.cs diff --git a/src/TUnit.Mocks.SourceGenerator/Diagnostics.cs b/src/TUnit.Mocks.SourceGenerator/Diagnostics.cs index c6bf7dcff5..23bc6fc84d 100644 --- a/src/TUnit.Mocks.SourceGenerator/Diagnostics.cs +++ b/src/TUnit.Mocks.SourceGenerator/Diagnostics.cs @@ -5,7 +5,7 @@ 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. +/// can observe, such as whole-compilation name collisions and unexpected generation failures. /// internal static class Diagnostics { @@ -18,4 +18,14 @@ internal static class Diagnostics 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." ); + + public static readonly DiagnosticDescriptor TM009_GenerationFailed = new( + id: "TM009", + title: "Mock generation failed", + messageFormat: "Failed to generate mock for '{0}': {1}: {2}", + category: "TUnit.Mocks", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "An unexpected exception prevented TUnit.Mocks from generating a requested mock. The exception type and message identify the failing generator path." + ); } diff --git a/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs b/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs index 7055d807dc..e575ecb519 100644 --- a/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs +++ b/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using TUnit.Mocks.SourceGenerator.Builders; using TUnit.Mocks.SourceGenerator.Discovery; @@ -8,6 +9,17 @@ namespace TUnit.Mocks.SourceGenerator; [Generator(LanguageNames.CSharp)] public class MockGenerator : IIncrementalGenerator { + private readonly Action _emitSources; + + public MockGenerator() : this(EmitSources) + { + } + + internal MockGenerator(Action emitSources) + { + _emitSources = emitSources; + } + public void Initialize(IncrementalGeneratorInitializationContext context) { // Always emit the TUnit.Mocks.Generated namespace so that global usings never fail @@ -26,23 +38,29 @@ namespace TUnit.Mocks.Generated; var mockTypes = context.SyntaxProvider .CreateSyntaxProvider( predicate: MockTypeDiscovery.IsMockOfInvocation, - transform: MockTypeDiscovery.TransformToModels) - .SelectMany((models, _) => models); + transform: static (ctx, ct) => CreateRequests( + MockTypeDiscovery.TransformToModels(ctx, ct), + ctx.Node.GetLocation())) + .SelectMany((requests, _) => requests); // Step 1b: Find all [assembly: GenerateMock(typeof(T))] attributes var attributeTypes = context.SyntaxProvider .ForAttributeWithMetadataName( "TUnit.Mocks.GenerateMockAttribute", predicate: static (node, _) => true, - transform: MockTypeDiscovery.TransformGenerateMockAttribute) - .SelectMany((models, _) => models); + transform: static (ctx, ct) => CreateRequests( + MockTypeDiscovery.TransformGenerateMockAttribute(ctx, ct), + GetAttributeLocation(ctx, ct))) + .SelectMany((requests, _) => requests); // Step 1c: Find all IFoo.Mock() static extension invocations var extensionTypes = context.SyntaxProvider .CreateSyntaxProvider( predicate: MockTypeDiscovery.IsMockExtensionInvocation, - transform: MockTypeDiscovery.TransformMockExtensionInvocation) - .SelectMany((models, _) => models); + transform: static (ctx, ct) => CreateRequests( + MockTypeDiscovery.TransformMockExtensionInvocation(ctx, ct), + ctx.Node.GetLocation())) + .SelectMany((requests, _) => requests); // Step 2: Merge all sources and deduplicate var distinctTypes = mockTypes @@ -52,69 +70,134 @@ namespace TUnit.Mocks.Generated; .SelectMany((pair, _) => { var (mockOfAndAttribute, extensionInvocations) = pair; - var (mockOfTypes, attributeTypes) = mockOfAndAttribute; + var (mockOfRequests, attributeRequests) = mockOfAndAttribute; var set = new HashSet(); - foreach (var m in mockOfTypes) set.Add(m); - foreach (var m in attributeTypes) set.Add(m); - foreach (var m in extensionInvocations) set.Add(m); + var models = new List(); + var locations = new List(); + + AddDistinctRequests(mockOfRequests, set, models, locations); + AddDistinctRequests(attributeRequests, set, models, locations); + AddDistinctRequests(extensionInvocations, set, models, locations); // 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); + var annotatedModels = GeneratedNameCollisionDetector.Annotate(models); + return annotatedModels.Select((model, index) => + new MockGenerationRequest(model, locations[index])); }); // Step 3: Generate source for each unique type - context.RegisterSourceOutput(distinctTypes, (spc, model) => + context.RegisterSourceOutput(distinctTypes, GenerateMockSafely); + } + + private void GenerateMockSafely(SourceProductionContext spc, MockGenerationRequest request) + { + try { - if (model.CollidesWith is not null) - { - spc.ReportDiagnostic(Diagnostic.Create( - Diagnostics.TM008_GeneratedNameCollision, - Location.None, - model.FullyQualifiedName, - MockImplBuilder.GetCompositeSafeName(model), - model.CollidesWith)); - return; - } + _emitSources(spc, request.Model); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + spc.ReportDiagnostic(Diagnostic.Create( + Diagnostics.TM009_GenerationFailed, + request.SourceLocation.ToLocation(), + request.Model.FullyQualifiedName, + exception.GetType().Name, + exception.Message)); + } + } - 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); - } - else if (model.LacksAccessibleConstructor) - { - // Unsubclassable class (every constructor private / cross-assembly internal). - // Emit only the static Mock() entry point so the call site still binds and the - // TM006 analyzer diagnostic is the single error the user sees, instead of a - // CS1729 pointing into generated code. See issue #6493. - GenerateUnconstructableClassStub(spc, model); - } - else if (model.IsWrapMock) - { - // Wrap mock: generate wrap impl, wrap factory, plus members - GenerateWrapMock(spc, model); - } - else if (model.AdditionalInterfaceNames.Length > 0) - { - // 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 + private static ImmutableArray CreateRequests( + ImmutableArray models, + Location location) + { + if (models.IsDefaultOrEmpty) + { + return ImmutableArray.Empty; + } + + var sourceLocation = MockSourceLocation.From(location); + var requests = ImmutableArray.CreateBuilder(models.Length); + foreach (var model in models) + { + requests.Add(new MockGenerationRequest(model, sourceLocation)); + } + + return requests.MoveToImmutable(); + } + + private static Location GetAttributeLocation(GeneratorAttributeSyntaxContext context, CancellationToken ct) + => context.Attributes[0].ApplicationSyntaxReference?.GetSyntax(ct).GetLocation() + ?? context.TargetNode.GetLocation(); + + private static void AddDistinctRequests( + ImmutableArray requests, + HashSet set, + List models, + List locations) + { + foreach (var request in requests) + { + if (!set.Add(request.Model)) { - // Single-type mock: generate everything - GenerateSingleTypeMock(spc, model); + continue; } - }); + + models.Add(request.Model); + locations.Add(request.SourceLocation); + } + } + + internal static void EmitSources(SourceProductionContext spc, MockTypeModel 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 + // 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); + } + else if (model.LacksAccessibleConstructor) + { + // Unsubclassable class (every constructor private / cross-assembly internal). + // Emit only the static Mock() entry point so the call site still binds and the + // TM006 analyzer diagnostic is the single error the user sees, instead of a + // CS1729 pointing into generated code. See issue #6493. + GenerateUnconstructableClassStub(spc, model); + } + else if (model.IsWrapMock) + { + // Wrap mock: generate wrap impl, wrap factory, plus members + GenerateWrapMock(spc, model); + } + else if (model.AdditionalInterfaceNames.Length > 0) + { + // 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 + { + // Single-type mock: generate everything + GenerateSingleTypeMock(spc, model); + } } private static void GenerateSingleTypeMock(SourceProductionContext spc, MockTypeModel model) diff --git a/src/TUnit.Mocks.SourceGenerator/Models/MockGenerationRequest.cs b/src/TUnit.Mocks.SourceGenerator/Models/MockGenerationRequest.cs new file mode 100644 index 0000000000..1a2e989109 --- /dev/null +++ b/src/TUnit.Mocks.SourceGenerator/Models/MockGenerationRequest.cs @@ -0,0 +1,40 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace TUnit.Mocks.SourceGenerator.Models; + +/// +/// Pairs an equatable mock model with the source request that triggered it. Location data stays +/// primitive so incremental state never retains a syntax tree. +/// +internal sealed record MockGenerationRequest(MockTypeModel Model, MockSourceLocation SourceLocation); + +internal readonly record struct MockSourceLocation( + string FilePath, + int SpanStart, + int SpanLength, + int StartLine, + int StartCharacter, + int EndLine, + int EndCharacter) +{ + public static MockSourceLocation From(Location location) + { + var lineSpan = location.GetLineSpan(); + return new MockSourceLocation( + lineSpan.Path, + location.SourceSpan.Start, + location.SourceSpan.Length, + lineSpan.StartLinePosition.Line, + lineSpan.StartLinePosition.Character, + lineSpan.EndLinePosition.Line, + lineSpan.EndLinePosition.Character); + } + + public Location ToLocation() => Location.Create( + FilePath, + new TextSpan(SpanStart, SpanLength), + new LinePositionSpan( + new LinePosition(StartLine, StartCharacter), + new LinePosition(EndLine, EndCharacter))); +} diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorDiagnosticTests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorDiagnosticTests.cs new file mode 100644 index 0000000000..6d0eb5efa5 --- /dev/null +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorDiagnosticTests.cs @@ -0,0 +1,49 @@ +using Microsoft.CodeAnalysis; +using TUnit.Mocks.SourceGenerator.Models; + +namespace TUnit.Mocks.SourceGenerator.Tests; + +public class MockGeneratorDiagnosticTests : SnapshotTestBase +{ + [Test] + public async Task Unexpected_Generation_Failure_Reports_Diagnostic_And_Does_Not_Stop_Other_Mocks() + { + var source = """ + using TUnit.Mocks; + + [assembly: GenerateMock(typeof(IBroken))] + + public interface IBroken { void Break(); } + public interface IHealthy { void Run(); } + + public class Usage + { + public void Create() => _ = Mock.Of(); + } + """; + + var generator = new MockGenerator(EmitWithInjectedFailure); + var (sources, diagnostics) = RunGeneratorForDiagnostics(source, generator: generator); + + var diagnostic = diagnostics.Single(d => d.Id == "TM009"); + await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Error); + await Assert.That(diagnostic.GetMessage()).Contains("global::IBroken"); + await Assert.That(diagnostic.GetMessage()).Contains("InvalidOperationException"); + await Assert.That(diagnostic.GetMessage()).Contains("Injected generation failure"); + await Assert.That(diagnostic.Location.Kind).IsEqualTo(LocationKind.ExternalFile); + await Assert.That(diagnostic.Location.GetLineSpan().StartLinePosition.Line).IsEqualTo(2); + await Assert.That(sources.Any(s => s.Contains("IHealthyMock", StringComparison.Ordinal))).IsTrue(); + } + + private static void EmitWithInjectedFailure( + SourceProductionContext context, + MockTypeModel model) + { + if (model.FullyQualifiedName == "global::IBroken") + { + throw new InvalidOperationException("Injected generation failure"); + } + + MockGenerator.EmitSources(context, model); + } +} diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs index b3ff9ec78e..99eb4a14c3 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs @@ -97,7 +97,8 @@ protected static string[] RunGenerator( /// protected static (string[] Sources, IReadOnlyList Diagnostics) RunGeneratorForDiagnostics( string source, - IEnumerable? additionalReferences = null) + IEnumerable? additionalReferences = null, + IIncrementalGenerator? generator = null) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); @@ -112,7 +113,7 @@ protected static (string[] Sources, IReadOnlyList Diagnostics) RunGe options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ).WithReferences(refs); - var generator = new MockGenerator(); + generator ??= new MockGenerator(); GeneratorDriver driver = CSharpGeneratorDriver.Create([generator.AsSourceGenerator()], parseOptions: parseOptions); var runResult = driver.RunGenerators(compilation).GetRunResult(); From 5050a275dfc783c28c5c6391e9cccae0cdff65de Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:28:47 +0100 Subject: [PATCH 2/2] fix(mocks): preserve diagnostic requests --- .../GeneratedNameCollisionDetector.cs | 31 ++++++++++--------- .../Discovery/MockTypeDiscovery.cs | 19 +++++++++--- .../MockGenerator.cs | 27 +++++----------- .../Models/MockGenerationRequest.cs | 2 +- .../MockGeneratorDiagnosticTests.cs | 27 ++++++++++++++++ 5 files changed, 68 insertions(+), 38 deletions(-) diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs index 945cd4b790..98e975fa3d 100644 --- a/src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs +++ b/src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs @@ -23,28 +23,30 @@ namespace TUnit.Mocks.SourceGenerator.Discovery; internal static class GeneratedNameCollisionDetector { /// - /// Returns in input order, with - /// set on every model that shares its generated name with another. + /// Returns in input order, with + /// set on every model that shares its generated name + /// with another. Each model remains paired with its original request location. /// - internal static List Annotate(IEnumerable models) + internal static List Annotate(IEnumerable requests) { - var ordered = models.ToList(); + var ordered = requests.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>(); + var groups = new Dictionary<(bool IsSecondaryMemberSurface, string Name), List>(); - foreach (var model in ordered) + foreach (var request in ordered) { + var model = request.Model; var key = (model.IsSecondaryMemberSurface, MockImplBuilder.GetCompositeSafeName(model)); if (!groups.TryGetValue(key, out var group)) { - groups[key] = group = new List(); + groups[key] = group = new List(); } - group.Add(model); + group.Add(request); } if (groups.Count == ordered.Count) @@ -52,24 +54,25 @@ internal static List Annotate(IEnumerable models) return ordered; } - var annotated = new List(ordered.Count); + var annotated = new List(ordered.Count); - foreach (var model in ordered) + foreach (var request in ordered) { + var model = request.Model; 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) + .Where(other => Identity(other.Model) != Identity(model)) + .Select(other => other.Model.FullyQualifiedName) .Distinct() .ToList(); annotated.Add(others.Count == 0 - ? model - : model with { CollidesWith = string.Join(", ", others) }); + ? request + : request with { Model = model with { CollidesWith = string.Join(", ", others) } }); } return annotated; diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs index ebb72f35ba..ef0d701dd2 100644 --- a/src/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs +++ b/src/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs @@ -624,9 +624,9 @@ public static ImmutableArray TransformMockExtensionInvocation( /// /// Semantic transform for [assembly: GenerateMock(typeof(T))]. - /// Extracts the type argument and builds a . + /// Extracts the type argument and pairs each model with its attribute location. /// - public static ImmutableArray TransformGenerateMockAttribute( + public static ImmutableArray TransformGenerateMockAttribute( GeneratorAttributeSyntaxContext context, CancellationToken ct) { // The target symbol for an assembly attribute is the assembly itself @@ -653,13 +653,24 @@ public static ImmutableArray TransformGenerateMockAttribute( if (namedType.IsValueType) continue; - return BuildModelWithTransitiveDependencies( + var models = BuildModelWithTransitiveDependencies( NormalizeSingleMockType(namedType), isPartialMock: namedType.TypeKind == TypeKind.Class, compilationAssembly, compilation); + + var location = attr.ApplicationSyntaxReference?.GetSyntax(ct).GetLocation() + ?? context.TargetNode.GetLocation(); + var sourceLocation = MockSourceLocation.From(location); + var requests = ImmutableArray.CreateBuilder(models.Length); + foreach (var model in models) + { + requests.Add(new MockGenerationRequest(model, sourceLocation)); + } + + return requests.MoveToImmutable(); } - return ImmutableArray.Empty; + return ImmutableArray.Empty; } } diff --git a/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs b/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs index e575ecb519..b45a4c52ca 100644 --- a/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs +++ b/src/TUnit.Mocks.SourceGenerator/MockGenerator.cs @@ -48,9 +48,7 @@ namespace TUnit.Mocks.Generated; .ForAttributeWithMetadataName( "TUnit.Mocks.GenerateMockAttribute", predicate: static (node, _) => true, - transform: static (ctx, ct) => CreateRequests( - MockTypeDiscovery.TransformGenerateMockAttribute(ctx, ct), - GetAttributeLocation(ctx, ct))) + transform: MockTypeDiscovery.TransformGenerateMockAttribute) .SelectMany((requests, _) => requests); // Step 1c: Find all IFoo.Mock() static extension invocations @@ -72,19 +70,16 @@ namespace TUnit.Mocks.Generated; var (mockOfAndAttribute, extensionInvocations) = pair; var (mockOfRequests, attributeRequests) = mockOfAndAttribute; var set = new HashSet(); - var models = new List(); - var locations = new List(); + var requests = new List(); - AddDistinctRequests(mockOfRequests, set, models, locations); - AddDistinctRequests(attributeRequests, set, models, locations); - AddDistinctRequests(extensionInvocations, set, models, locations); + AddDistinctRequests(mockOfRequests, set, requests); + AddDistinctRequests(attributeRequests, set, requests); + AddDistinctRequests(extensionInvocations, set, requests); // 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. - var annotatedModels = GeneratedNameCollisionDetector.Annotate(models); - return annotatedModels.Select((model, index) => - new MockGenerationRequest(model, locations[index])); + return GeneratedNameCollisionDetector.Annotate(requests); }); // Step 3: Generate source for each unique type @@ -127,15 +122,10 @@ private static ImmutableArray CreateRequests( return requests.MoveToImmutable(); } - private static Location GetAttributeLocation(GeneratorAttributeSyntaxContext context, CancellationToken ct) - => context.Attributes[0].ApplicationSyntaxReference?.GetSyntax(ct).GetLocation() - ?? context.TargetNode.GetLocation(); - private static void AddDistinctRequests( ImmutableArray requests, HashSet set, - List models, - List locations) + List distinctRequests) { foreach (var request in requests) { @@ -144,8 +134,7 @@ private static void AddDistinctRequests( continue; } - models.Add(request.Model); - locations.Add(request.SourceLocation); + distinctRequests.Add(request); } } diff --git a/src/TUnit.Mocks.SourceGenerator/Models/MockGenerationRequest.cs b/src/TUnit.Mocks.SourceGenerator/Models/MockGenerationRequest.cs index 1a2e989109..342874883f 100644 --- a/src/TUnit.Mocks.SourceGenerator/Models/MockGenerationRequest.cs +++ b/src/TUnit.Mocks.SourceGenerator/Models/MockGenerationRequest.cs @@ -7,7 +7,7 @@ namespace TUnit.Mocks.SourceGenerator.Models; /// Pairs an equatable mock model with the source request that triggered it. Location data stays /// primitive so incremental state never retains a syntax tree. /// -internal sealed record MockGenerationRequest(MockTypeModel Model, MockSourceLocation SourceLocation); +internal readonly record struct MockGenerationRequest(MockTypeModel Model, MockSourceLocation SourceLocation); internal readonly record struct MockSourceLocation( string FilePath, diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorDiagnosticTests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorDiagnosticTests.cs index 6d0eb5efa5..05dc96097e 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorDiagnosticTests.cs +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorDiagnosticTests.cs @@ -32,15 +32,42 @@ public class Usage await Assert.That(diagnostic.GetMessage()).Contains("Injected generation failure"); await Assert.That(diagnostic.Location.Kind).IsEqualTo(LocationKind.ExternalFile); await Assert.That(diagnostic.Location.GetLineSpan().StartLinePosition.Line).IsEqualTo(2); + await Assert.That(sources.Any(s => s.Contains("IBrokenPartialSource", StringComparison.Ordinal))).IsTrue(); await Assert.That(sources.Any(s => s.Contains("IHealthyMock", StringComparison.Ordinal))).IsTrue(); } + [Test] + public async Task Attribute_Failure_Uses_The_Matching_Attribute_Location() + { + var source = """ + using TUnit.Mocks; + + [assembly: GenerateMock(typeof(string)), GenerateMock(typeof(IBroken))] + + public interface IBroken { void Break(); } + """; + + var generator = new MockGenerator(EmitWithInjectedFailure); + var (_, diagnostics) = RunGeneratorForDiagnostics(source, generator: generator); + + var diagnostic = diagnostics.Single(d => d.Id == "TM009"); + var expectedStart = source.IndexOf("GenerateMock(typeof(IBroken))", StringComparison.Ordinal); + + await Assert.That(diagnostic.Location.SourceSpan.Start).IsEqualTo(expectedStart); + } + private static void EmitWithInjectedFailure( SourceProductionContext context, MockTypeModel model) { if (model.FullyQualifiedName == "global::IBroken") { + context.AddSource("IBroken_Partial.g.cs", """ + // + namespace TUnit.Mocks.Generated; + + internal sealed class IBrokenPartialSource; + """); throw new InvalidOperationException("Injected generation failure"); }