From 9d29869cdd7f9fd5556348b39d09c5a6aaf4cbff Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:15:11 +0100 Subject: [PATCH 1/3] fix(sourcegen): pair [GenerateGenericTest] on a generic method with each class source of a generic class For a generic method of a generic class, the generator treated type arguments from a method-only source ([GenerateGenericTest] on the method, or a method [Arguments] row) as the class's type arguments and left the method open. The generated invoker then named the method type parameter (CS0246) or could not infer it (CS0411). The generator now pairs each class source with each method source, as reflection mode does when it closes the method on each concrete class: class rows with the method's [GenerateGenericTest] types, and a class-level [GenerateGenericTest] with method rows. Type arguments that cover only the class or only the method are no longer emitted. Fixes #7117 --- .../Generators/TestMetadataGenerator.cs | 64 +++++++++- ...eGenericTestOnMethodOfGenericClassTests.cs | 110 ++++++++++++++++++ tests/TUnit.Engine.Tests/Issue7117Tests.cs | 30 +++++ ...eGenericTestOnMethodOfGenericClassTests.cs | 72 ++++++++++++ 4 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs create mode 100644 tests/TUnit.Engine.Tests/Issue7117Tests.cs create mode 100644 tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index c56c76268b9..9e09458e79f 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -842,6 +842,15 @@ void TryAddInstantiation( AttributeData? methodRow = null, bool fromGenerateGenericTest = false) { + // A generic method of a generic class needs both the class and the method type arguments. + // A source that supplies only one of them (a method row or [GenerateGenericTest] without a + // class source) would emit an entry that names the open type parameters, which does not + // compile (#7117). Those sources are paired with each class source further down instead. + if (classTypeArgCount > 0 && methodTypeArgCount > 0 && typeArguments.Length != classTypeArgCount + methodTypeArgCount) + { + return; + } + var classTypeArgs = classTypeArgCount > 0 ? typeArguments.Take(classTypeArgCount).ToArray() : Array.Empty(); var methodTypeArgs = methodTypeArgCount > 0 ? typeArguments.Skip(classTypeArgCount).ToArray() : Array.Empty(); @@ -1238,8 +1247,61 @@ a.AttributeClass is { } attributeClass .ToList() : []; - if (testMethod is { IsGenericType: true, IsGenericMethod: true } && classGenericTestAttrs.Count > 0 && methodGenericTestAttrs.Count > 0) + if (testMethod is { IsGenericType: true, IsGenericMethod: true }) { + // A generic method of a generic class pairs every class source with every method + // source (#7117), as reflection mode closes the method on each concrete class. Class + // rows paired with method rows were added above. Rows come before [GenerateGenericTest] + // types, so a type that a row infers runs only that row, while any other type runs + // every method data source. + foreach (var classGenericTest in classGenericTestAttrs) + { + var classIsValid = ValidateClassTypeConstraints(testMethod.TypeSymbol, classGenericTest.TypeArgs!, compilation); + foreach (var methodArgAttr in methodArgumentsAttributes) + { + var methodTypes = InferTypesFromArgumentsAttribute(testMethod.MethodSymbol, methodArgAttr, compilation); + if (methodTypes is { Length: > 0 }) + { + TryAddInstantiation(classGenericTest.TypeArgs!.Concat(methodTypes).ToArray(), methodArgAttr, + reportMethodConstraintViolation: true, + reportClassViolationAs: ClassViolationSource.GenerateGenericTest, + violationSource: classIsValid ? null : classGenericTest.Attribute); + } + } + } + + foreach (var classAttr in classArgumentsAttributes) + { + var classTypes = InferTypesFromClassArgumentsAttribute(testMethod.TypeSymbol, classAttr, compilation); + if (classTypes is not { Length: > 0 }) + { + continue; + } + + var classIsValid = ValidateClassTypeConstraints(testMethod.TypeSymbol, classTypes, compilation); + foreach (var methodGenericTest in methodGenericTestAttrs) + { + var countBefore = results.Count; + TryAddInstantiation(classTypes.Concat(methodGenericTest.TypeArgs!).ToArray(), classAttr, + reportMethodConstraintViolation: true, + reportClassViolationAs: ClassViolationSource.ClassArguments, + violationSource: classIsValid ? methodGenericTest.Attribute : classAttr, + isClassRow: true); + + // The class row scopes the entry to itself; the [GenerateGenericTest] type runs + // every method data source, as it does on a non-generic class. + if (results.Count > countBefore + && results[results.Count - 1] is { MethodConstraintViolations: null } added + && typedDataSourceBucket is not null) + { + foreach (var dataSource in typedDataSourceBucket) + { + added.AddRow(dataSource, isClassRow: false); + } + } + } + } + // Cartesian product of class and method type args foreach (var classGenericTest in classGenericTestAttrs) { diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs new file mode 100644 index 00000000000..129240fabcc --- /dev/null +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs @@ -0,0 +1,110 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TUnit.Core.SourceGenerator.Generators; + +namespace TUnit.Core.SourceGenerator.Tests.Bugs._7117; + +/// +/// Regression test for https://github.com/thomhurst/TUnit/issues/7117. +/// +/// [GenerateGenericTest] on a generic method of a generic class whose type arguments come from +/// a class-level data source (not a class-level [GenerateGenericTest]) used to emit generated +/// code that referenced the open method type parameter, which fails with CS0246. The snapshot +/// harness only inspects generator diagnostics, so this test compiles the generated output. +/// +internal class GenerateGenericTestOnMethodOfGenericClassTests +{ + private const string Source = + """ + global using global::System; + global using global::System.Collections.Generic; + global using global::System.Linq; + global using global::System.Threading.Tasks; + global using global::TUnit.Core; + + namespace MyTests; + + [Arguments(1)] + public class GenericClass(T value) + { + [Test] + [GenerateGenericTest(typeof(int))] + [Arguments(1)] + public void Run(TMethod arg) { } + + [Test] + [GenerateGenericTest(typeof(string))] + public void NoMethodRows() { } + + [Test] + [GenerateGenericTest(typeof(long))] + [Arguments(1)] + public void UsesBoth(T classArg) { } + } + + [Arguments("a")] + [Arguments(2)] + public class TwoClassRows(T value) + { + [Test] + [GenerateGenericTest(typeof(int))] + [GenerateGenericTest(typeof(string))] + [Arguments(1)] + public void Run(TMethod arg) { } + } + + [GenerateGenericTest(typeof(string))] + public class ClassGenerateGenericTest + { + [Test] + [GenerateGenericTest(typeof(int))] + [Arguments(1)] + public void Run(TMethod arg) { } + } + + public class MethodRowInfersClass + { + [Test] + [GenerateGenericTest(typeof(int))] + [Arguments("x", 1)] + public void Run(T classValue, TMethod arg) { } + } + """; + + [Test] + public async Task Generated_code_compiles() + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + var compilation = CSharpCompilation.Create( + "Issue7117", + [CSharpSyntaxTree.ParseText(Source, parseOptions)], + ReferencesHelper.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new TestMetadataGenerator().AsSourceGenerator()], + parseOptions: parseOptions); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + + var errors = output.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToArray(); + + var generated = string.Join( + Environment.NewLine + "----" + Environment.NewLine, + output.SyntaxTrees.Skip(1).Select(t => t.GetText().ToString())); + + await Assert.That(errors) + .IsEmpty() + .Because($"generated code must compile. Errors:{Environment.NewLine}" + + string.Join(Environment.NewLine, errors.Select(e => e.ToString())) + + Environment.NewLine + generated); + + // Both the class and the method are closed over their own type arguments. + await Assert.That(generated).Contains(".Run("); + await Assert.That(generated).Contains(".NoMethodRows("); + await Assert.That(generated).Contains(".UsesBoth("); + await Assert.That(generated).DoesNotContain(""); + } +} diff --git a/tests/TUnit.Engine.Tests/Issue7117Tests.cs b/tests/TUnit.Engine.Tests/Issue7117Tests.cs new file mode 100644 index 00000000000..ee7edb857dc --- /dev/null +++ b/tests/TUnit.Engine.Tests/Issue7117Tests.cs @@ -0,0 +1,30 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// https://github.com/thomhurst/TUnit/issues/7117: [GenerateGenericTest] on a generic method of a +/// generic class closes the method on every class the class sources produce, in both modes. +/// +public class Issue7117Tests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + [Arguments("MethodGenerateGenericTestOnClassRow", new[] { "Run(2)" })] + [Arguments("MethodGenerateGenericTestWithClassRows", new[] { "Run", "Run" })] + [Arguments("ClassGenerateGenericTestWithMethodRow", new[] { "Run(1)" })] + [Arguments("MethodGenerateGenericTestTypeNotInferredByRow", new[] { "Run(2)", "Run(2)" })] + public async Task Generic_Method_Of_Generic_Class_Runs_Each_Instantiation(string className, string[] expectedTestNames) + { + await RunTestsWithFilter( + $"/*/TUnit.TestProject.Bugs._7117/{className}*/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(expectedTestNames.Length), + result => result.ResultSummary.Counters.Passed.ShouldBe(expectedTestNames.Length), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.Results.Select(x => x.TestName).OrderBy(x => x, StringComparer.Ordinal) + .ShouldBe(expectedTestNames.OrderBy(x => x, StringComparer.Ordinal)) + ]); + } +} diff --git a/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs b/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs new file mode 100644 index 00000000000..ca1ff264fb2 --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs @@ -0,0 +1,72 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._7117; + +// [GenerateGenericTest] on a generic method of a generic class closes the method on every class the +// class sources produce, in both modes (#7117). + +// The issue's shape: the class row and the method row infer int, which [GenerateGenericTest] also lists. +[EngineTest(ExpectedResult.Pass)] +[Arguments(1)] +public class MethodGenerateGenericTestOnClassRow(T value) +{ + [Test] + [GenerateGenericTest(typeof(int))] + [Arguments(2)] + public async Task Run(TMethod arg) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(int)); + await Assert.That(typeof(TMethod)).IsEqualTo(typeof(int)); + await Assert.That(value).IsEqualTo((T)(object)1); + await Assert.That(arg).IsEqualTo((TMethod)(object)2); + } +} + +// Each class row closes the class; the method's [GenerateGenericTest] type runs on each of them. +[EngineTest(ExpectedResult.Pass)] +[Arguments("a")] +[Arguments(2)] +public class MethodGenerateGenericTestWithClassRows(T value) +{ + [Test] + [GenerateGenericTest(typeof(bool))] + public async Task Run() + { + await Assert.That(typeof(TMethod)).IsEqualTo(typeof(bool)); + await Assert.That(value is "a" or 2).IsTrue(); + await Assert.That(typeof(T)).IsEqualTo(value!.GetType()); + } +} + +// A class-level [GenerateGenericTest] with a method row that infers the method's type argument. +[EngineTest(ExpectedResult.Pass)] +[GenerateGenericTest(typeof(string))] +public class ClassGenerateGenericTestWithMethodRow +{ + [Test] + [Arguments(1)] + public async Task Run(TMethod arg) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(string)); + await Assert.That(typeof(TMethod)).IsEqualTo(typeof(int)); + await Assert.That(arg).IsEqualTo((TMethod)(object)1); + } +} + +// A [GenerateGenericTest] type that no method row infers runs every method row, as on a non-generic +// class: Run(2) from the row and Run(2) from the attribute. +[EngineTest(ExpectedResult.Pass)] +[Arguments(1)] +public class MethodGenerateGenericTestTypeNotInferredByRow(T value) +{ + [Test] + [GenerateGenericTest(typeof(long))] + [Arguments(2)] + public async Task Run(TMethod arg) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(int)); + await Assert.That(value).IsEqualTo((T)(object)1); + await Assert.That(typeof(TMethod) == typeof(int) || typeof(TMethod) == typeof(long)).IsTrue(); + await Assert.That(Convert.ToInt64(arg)).IsEqualTo(2L); + } +} From 5e9976d275321bbd3a4ca9e50dd99a1641fdef70 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:21:39 +0100 Subject: [PATCH 2/3] fix(sourcegen): pair method data sources with each class source of a generic class Inferred method type arguments from typed data sources, [MethodDataSource] and IInfersType on a generic method of a generic class are now paired with each class row and class-level [GenerateGenericTest], as reflection mode does. The previous commit stopped them being emitted as class type arguments; without this they would no longer be discovered in source-gen mode. --- .../Generators/TestMetadataGenerator.cs | 107 +++++++++++++----- ...eGenericTestOnMethodOfGenericClassTests.cs | 21 ++++ tests/TUnit.Engine.Tests/Issue7117Tests.cs | 2 + ...eGenericTestOnMethodOfGenericClassTests.cs | 43 +++++++ 4 files changed, 142 insertions(+), 31 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 9e09458e79f..bbde711a6d5 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -1015,6 +1015,30 @@ void TryAddCombinedArgumentsInstantiation(ITypeSymbol[] classTypes, ITypeSymbol[ methodRow: methodAttr); } + // A class row paired with method type arguments that no single method data source inferred: + // a method [GenerateGenericTest] type (methodSource) or IInfersType (null). The class row + // scopes the entry to itself, and every method data source runs on it, as on a non-generic + // class (#7117). + void TryAddClassRowInstantiationWithEveryMethodSource(ITypeSymbol[] classTypes, ITypeSymbol[] combinedTypes, AttributeData classAttr, AttributeData? methodSource, List? methodDataSources) + { + var countBefore = results.Count; + TryAddInstantiation(combinedTypes, classAttr, + reportMethodConstraintViolation: true, + reportClassViolationAs: ClassViolationSource.ClassArguments, + violationSource: methodSource is not null && ValidateClassTypeConstraints(testMethod.TypeSymbol, classTypes, compilation) ? methodSource : classAttr, + isClassRow: true); + + if (results.Count > countBefore + && results[results.Count - 1] is { MethodConstraintViolations: null } added + && methodDataSources is not null) + { + foreach (var dataSource in methodDataSources) + { + added.AddRow(dataSource, isClassRow: false); + } + } + } + // Single-pass classification of method attributes into named buckets, replacing the // repeated `.Where(...).ToArray()` scans that previously walked MethodAttributes 6-8 times. List? methodArgumentsBucket = null; @@ -1250,23 +1274,49 @@ a.AttributeClass is { } attributeClass if (testMethod is { IsGenericType: true, IsGenericMethod: true }) { // A generic method of a generic class pairs every class source with every method - // source (#7117), as reflection mode closes the method on each concrete class. Class - // rows paired with method rows were added above. Rows come before [GenerateGenericTest] - // types, so a type that a row infers runs only that row, while any other type runs - // every method data source. + // source (#7117), as reflection mode closes the method on each concrete class. Method + // data sources come first, in reflection mode's order, so a type that one of them + // infers runs only that source. Any other [GenerateGenericTest] type runs every method + // data source. A null source (IInfersType) also runs every method data source. + var methodSources = new List<(ITypeSymbol[] Types, AttributeData? Source)>(); + foreach (var methodArgAttr in methodArgumentsAttributes) + { + if (InferTypesFromArgumentsAttribute(testMethod.MethodSymbol, methodArgAttr, compilation) is { } types && types.Length == methodTypeArgCount) + { + methodSources.Add((types, methodArgAttr)); + } + } + + foreach (var dataSourceAttr in typedDataSourceBucket ?? Enumerable.Empty()) + { + if (InferTypesFromDataSourceAttribute(testMethod.MethodSymbol, dataSourceAttr) is { } types && types.Length == methodTypeArgCount) + { + methodSources.Add((types, dataSourceAttr)); + } + } + + if (InferTypesFromTypeInferringAttributes(testMethod.MethodSymbol) is { } inferringTypes && inferringTypes.Length == methodTypeArgCount) + { + methodSources.Add((inferringTypes, null)); + } + + foreach (var mdsAttr in methodDataSourceBucket ?? Enumerable.Empty()) + { + if (InferTypesFromMethodDataSource(testMethod, mdsAttr) is { } types && types.Length == methodTypeArgCount) + { + methodSources.Add((types, mdsAttr)); + } + } + foreach (var classGenericTest in classGenericTestAttrs) { var classIsValid = ValidateClassTypeConstraints(testMethod.TypeSymbol, classGenericTest.TypeArgs!, compilation); - foreach (var methodArgAttr in methodArgumentsAttributes) + foreach (var (methodTypes, source) in methodSources) { - var methodTypes = InferTypesFromArgumentsAttribute(testMethod.MethodSymbol, methodArgAttr, compilation); - if (methodTypes is { Length: > 0 }) - { - TryAddInstantiation(classGenericTest.TypeArgs!.Concat(methodTypes).ToArray(), methodArgAttr, - reportMethodConstraintViolation: true, - reportClassViolationAs: ClassViolationSource.GenerateGenericTest, - violationSource: classIsValid ? null : classGenericTest.Attribute); - } + TryAddInstantiation(classGenericTest.TypeArgs!.Concat(methodTypes).ToArray(), source, + reportMethodConstraintViolation: true, + reportClassViolationAs: ClassViolationSource.GenerateGenericTest, + violationSource: classIsValid ? null : classGenericTest.Attribute); } } @@ -1278,28 +1328,23 @@ a.AttributeClass is { } attributeClass continue; } - var classIsValid = ValidateClassTypeConstraints(testMethod.TypeSymbol, classTypes, compilation); - foreach (var methodGenericTest in methodGenericTestAttrs) + // Method rows were already paired with this class row above; they join their entries. + foreach (var (methodTypes, source) in methodSources) { - var countBefore = results.Count; - TryAddInstantiation(classTypes.Concat(methodGenericTest.TypeArgs!).ToArray(), classAttr, - reportMethodConstraintViolation: true, - reportClassViolationAs: ClassViolationSource.ClassArguments, - violationSource: classIsValid ? methodGenericTest.Attribute : classAttr, - isClassRow: true); - - // The class row scopes the entry to itself; the [GenerateGenericTest] type runs - // every method data source, as it does on a non-generic class. - if (results.Count > countBefore - && results[results.Count - 1] is { MethodConstraintViolations: null } added - && typedDataSourceBucket is not null) + if (source is not null) { - foreach (var dataSource in typedDataSourceBucket) - { - added.AddRow(dataSource, isClassRow: false); - } + TryAddCombinedArgumentsInstantiation(classTypes, classTypes.Concat(methodTypes).ToArray(), classAttr, source); + } + else + { + TryAddClassRowInstantiationWithEveryMethodSource(classTypes, classTypes.Concat(methodTypes).ToArray(), classAttr, methodSource: null, typedDataSourceBucket); } } + + foreach (var methodGenericTest in methodGenericTestAttrs) + { + TryAddClassRowInstantiationWithEveryMethodSource(classTypes, classTypes.Concat(methodGenericTest.TypeArgs!).ToArray(), classAttr, methodGenericTest.Attribute, typedDataSourceBucket); + } } // Cartesian product of class and method type args diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs index 129240fabcc..2902b864657 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs @@ -62,6 +62,26 @@ public class ClassGenerateGenericTest public void Run(TMethod arg) { } } + [Arguments(1)] + public class ClassRowMethodDataSource(T value) + { + public static IEnumerable> Rows() { yield return () => ("a", 1); } + + [Test] + [MethodDataSource(nameof(Rows))] + public void FromMethodDataSource(TMethod arg, int count) { } + } + + [GenerateGenericTest(typeof(long))] + public class ClassGenerateGenericTestMethodDataSource + { + public static IEnumerable> Rows() { yield return () => ("a", 1); } + + [Test] + [MethodDataSource(nameof(Rows))] + public void FromMethodDataSource(TMethod arg, int count) { } + } + public class MethodRowInfersClass { [Test] @@ -105,6 +125,7 @@ await Assert.That(errors) await Assert.That(generated).Contains(".Run("); await Assert.That(generated).Contains(".NoMethodRows("); await Assert.That(generated).Contains(".UsesBoth("); + await Assert.That(generated).Contains(".FromMethodDataSource("); await Assert.That(generated).DoesNotContain(""); } } diff --git a/tests/TUnit.Engine.Tests/Issue7117Tests.cs b/tests/TUnit.Engine.Tests/Issue7117Tests.cs index ee7edb857dc..18159956433 100644 --- a/tests/TUnit.Engine.Tests/Issue7117Tests.cs +++ b/tests/TUnit.Engine.Tests/Issue7117Tests.cs @@ -14,6 +14,8 @@ public class Issue7117Tests(TestMode testMode) : InvokableTestBase(testMode) [Arguments("MethodGenerateGenericTestWithClassRows", new[] { "Run", "Run" })] [Arguments("ClassGenerateGenericTestWithMethodRow", new[] { "Run(1)" })] [Arguments("MethodGenerateGenericTestTypeNotInferredByRow", new[] { "Run(2)", "Run(2)" })] + [Arguments("MethodDataSourceOnClassRow", new[] { "Run(a, 1)" })] + [Arguments("MethodDataSourceOnClassGenerateGenericTest", new[] { "Run(a, 1)" })] public async Task Generic_Method_Of_Generic_Class_Runs_Each_Instantiation(string className, string[] expectedTestNames) { await RunTestsWithFilter( diff --git a/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs b/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs index ca1ff264fb2..273bd330fcb 100644 --- a/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs @@ -70,3 +70,46 @@ public async Task Run(TMethod arg) await Assert.That(Convert.ToInt64(arg)).IsEqualTo(2L); } } + +// A method data source that infers the method's type argument runs on each class a class row closes. +[EngineTest(ExpectedResult.Pass)] +[Arguments(1)] +public class MethodDataSourceOnClassRow(T value) +{ + public static IEnumerable> Rows() + { + yield return () => ("a", 1); + } + + [Test] + [MethodDataSource(nameof(Rows))] + public async Task Run(TMethod arg, int count) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(int)); + await Assert.That(value).IsEqualTo((T)(object)1); + await Assert.That(count).IsEqualTo(1); + await Assert.That(typeof(TMethod)).IsEqualTo(typeof(string)); + await Assert.That(arg).IsEqualTo((TMethod)(object)"a"); + } +} + +// The same, on each class a class-level [GenerateGenericTest] closes. +[EngineTest(ExpectedResult.Pass)] +[GenerateGenericTest(typeof(long))] +public class MethodDataSourceOnClassGenerateGenericTest +{ + public static IEnumerable> Rows() + { + yield return () => ("a", 1); + } + + [Test] + [MethodDataSource(nameof(Rows))] + public async Task Run(TMethod arg, int count) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(long)); + await Assert.That(count).IsEqualTo(1); + await Assert.That(typeof(TMethod)).IsEqualTo(typeof(string)); + await Assert.That(arg).IsEqualTo((TMethod)(object)"a"); + } +} From b1a7aac00fa892dbe102884bacd4fdb68a7fb85d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:54:34 +0100 Subject: [PATCH 3/3] fix(sourcegen): close inferred method types over the class and keep per-row constraint failures - Substitute class type parameters in method type arguments inferred from a [MethodDataSource] of the open class (IEnumerable>), so the generated call names the closed type instead of T (CS0246). - A method constraint violation from a [MethodDataSource] paired with a class row now fails once per row it yields, and one from IInfersType once per row of every method data source, as in reflection mode. - Drop the typed data source pairing: InferTypesFromDataSourceAttribute never infers method types on a generic class, matching reflection mode, so the loop was dead code. - Add TestProject shapes and engine/generator tests for these cases. --- .../Generators/TestMetadataGenerator.cs | 52 ++++++++---- ...eGenericTestOnMethodOfGenericClassTests.cs | 11 +++ tests/TUnit.Engine.Tests/Issue7117Tests.cs | 20 +++++ ...eGenericTestOnMethodOfGenericClassTests.cs | 82 +++++++++++++++++++ 4 files changed, 149 insertions(+), 16 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index bbde711a6d5..b68b967f4e0 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -840,7 +840,9 @@ void TryAddInstantiation( AttributeData? violationSource = null, bool isClassRow = false, AttributeData? methodRow = null, - bool fromGenerateGenericTest = false) + bool fromGenerateGenericTest = false, + AttributeData? methodViolationRowSource = null, + bool methodViolationAllMethodDataSources = false) { // A generic method of a generic class needs both the class and the method type arguments. // A source that supplies only one of them (a method row or [GenerateGenericTest] without a @@ -937,8 +939,10 @@ void TryAddInstantiation( violation = new MethodConstraintViolation( methodConstraintViolation, // [GenerateGenericTest] (the violation source) has no rows: it reports one failure. - RowSource: violationSource is null ? specificAttr : null, - AllMethodDataSources: specificAttr is null && violationSource is null); + // A method data source paired with a class row reports each of its own rows + // (methodViolationRowSource), and IInfersType each row of every method data source. + RowSource: methodViolationRowSource ?? (violationSource is null ? specificAttr : null), + AllMethodDataSources: methodViolationAllMethodDataSources || (specificAttr is null && violationSource is null)); // All violations of one concrete class share one entry, so that each failing row gets // its own data source index, and so its own test id. @@ -1005,6 +1009,8 @@ void TryAddInstantiation( // A class row combined with a generic method row. A class violation is reported once per // class row; a method violation once per method row, as reflection mode reports it when // the row's inferred method type arguments fail to resolve. + // A method data source other than an [Arguments] row (such as [MethodDataSource]) may yield + // several rows, so a method violation is reported once per row it yields instead. void TryAddCombinedArgumentsInstantiation(ITypeSymbol[] classTypes, ITypeSymbol[] combinedTypes, AttributeData classAttr, AttributeData methodAttr) { TryAddInstantiation(combinedTypes, classAttr, @@ -1012,7 +1018,8 @@ void TryAddCombinedArgumentsInstantiation(ITypeSymbol[] classTypes, ITypeSymbol[ reportClassViolationAs: ClassViolationSource.ClassArguments, violationSource: ValidateClassTypeConstraints(testMethod.TypeSymbol, classTypes, compilation) ? methodAttr : classAttr, isClassRow: true, - methodRow: methodAttr); + methodRow: methodAttr, + methodViolationRowSource: methodAttr.AttributeClass?.Name == "ArgumentsAttribute" ? null : methodAttr); } // A class row paired with method type arguments that no single method data source inferred: @@ -1026,7 +1033,8 @@ void TryAddClassRowInstantiationWithEveryMethodSource(ITypeSymbol[] classTypes, reportMethodConstraintViolation: true, reportClassViolationAs: ClassViolationSource.ClassArguments, violationSource: methodSource is not null && ValidateClassTypeConstraints(testMethod.TypeSymbol, classTypes, compilation) ? methodSource : classAttr, - isClassRow: true); + isClassRow: true, + methodViolationAllMethodDataSources: methodSource is null); if (results.Count > countBefore && results[results.Count - 1] is { MethodConstraintViolations: null } added @@ -1039,6 +1047,22 @@ void TryAddClassRowInstantiationWithEveryMethodSource(ITypeSymbol[] classTypes, } } + // The class and method type arguments of a generic method of a generic class. A method type + // argument inferred from a member of the open class (a [MethodDataSource] returning + // IEnumerable>) may name a class type parameter, which is replaced by this class + // type argument, as reflection mode reads the member on the closed class (#7117). + ITypeSymbol[] CloseOverClass(ITypeSymbol[] classTypes, ITypeSymbol[] methodTypes) + { + var combined = new ITypeSymbol[classTypes.Length + methodTypes.Length]; + Array.Copy(classTypes, combined, classTypes.Length); + for (var i = 0; i < methodTypes.Length; i++) + { + combined[classTypes.Length + i] = SubstituteTypeParameters(methodTypes[i], testMethod.TypeSymbol.TypeParameters, classTypes, compilation); + } + + return combined; + } + // Single-pass classification of method attributes into named buckets, replacing the // repeated `.Where(...).ToArray()` scans that previously walked MethodAttributes 6-8 times. List? methodArgumentsBucket = null; @@ -1287,14 +1311,10 @@ a.AttributeClass is { } attributeClass } } - foreach (var dataSourceAttr in typedDataSourceBucket ?? Enumerable.Empty()) - { - if (InferTypesFromDataSourceAttribute(testMethod.MethodSymbol, dataSourceAttr) is { } types && types.Length == methodTypeArgCount) - { - methodSources.Add((types, dataSourceAttr)); - } - } - + // A typed data source (DataSourceGeneratorAttribute) infers no method type arguments + // on a generic class in either mode (InferTypesFromDataSourceAttribute here, and + // ReflectionTestDataCollector.InferMethodInstantiationsFromDataSources), so it is not a + // method source: it runs on every instantiation like any other method data source. if (InferTypesFromTypeInferringAttributes(testMethod.MethodSymbol) is { } inferringTypes && inferringTypes.Length == methodTypeArgCount) { methodSources.Add((inferringTypes, null)); @@ -1313,7 +1333,7 @@ a.AttributeClass is { } attributeClass var classIsValid = ValidateClassTypeConstraints(testMethod.TypeSymbol, classGenericTest.TypeArgs!, compilation); foreach (var (methodTypes, source) in methodSources) { - TryAddInstantiation(classGenericTest.TypeArgs!.Concat(methodTypes).ToArray(), source, + TryAddInstantiation(CloseOverClass(classGenericTest.TypeArgs!, methodTypes), source, reportMethodConstraintViolation: true, reportClassViolationAs: ClassViolationSource.GenerateGenericTest, violationSource: classIsValid ? null : classGenericTest.Attribute); @@ -1333,11 +1353,11 @@ a.AttributeClass is { } attributeClass { if (source is not null) { - TryAddCombinedArgumentsInstantiation(classTypes, classTypes.Concat(methodTypes).ToArray(), classAttr, source); + TryAddCombinedArgumentsInstantiation(classTypes, CloseOverClass(classTypes, methodTypes), classAttr, source); } else { - TryAddClassRowInstantiationWithEveryMethodSource(classTypes, classTypes.Concat(methodTypes).ToArray(), classAttr, methodSource: null, typedDataSourceBucket); + TryAddClassRowInstantiationWithEveryMethodSource(classTypes, CloseOverClass(classTypes, methodTypes), classAttr, methodSource: null, typedDataSourceBucket); } } diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs index 2902b864657..898ead5808a 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7117/GenerateGenericTestOnMethodOfGenericClassTests.cs @@ -82,6 +82,16 @@ public class ClassGenerateGenericTestMethodDataSource public void FromMethodDataSource(TMethod arg, int count) { } } + [GenerateGenericTest(typeof(long))] + public class ClassTypedMethodDataSource + { + public static IEnumerable> Rows() { yield return () => (default(T)!, 1); } + + [Test] + [MethodDataSource(nameof(Rows))] + public void FromClassTypedSource(TMethod arg, int count) { } + } + public class MethodRowInfersClass { [Test] @@ -126,6 +136,7 @@ await Assert.That(errors) await Assert.That(generated).Contains(".NoMethodRows("); await Assert.That(generated).Contains(".UsesBoth("); await Assert.That(generated).Contains(".FromMethodDataSource("); + await Assert.That(generated).Contains(".FromClassTypedSource("); await Assert.That(generated).DoesNotContain(""); } } diff --git a/tests/TUnit.Engine.Tests/Issue7117Tests.cs b/tests/TUnit.Engine.Tests/Issue7117Tests.cs index 18159956433..dd71f5f3826 100644 --- a/tests/TUnit.Engine.Tests/Issue7117Tests.cs +++ b/tests/TUnit.Engine.Tests/Issue7117Tests.cs @@ -16,6 +16,8 @@ public class Issue7117Tests(TestMode testMode) : InvokableTestBase(testMode) [Arguments("MethodGenerateGenericTestTypeNotInferredByRow", new[] { "Run(2)", "Run(2)" })] [Arguments("MethodDataSourceOnClassRow", new[] { "Run(a, 1)" })] [Arguments("MethodDataSourceOnClassGenerateGenericTest", new[] { "Run(a, 1)" })] + [Arguments("ClassTypedMethodDataSourceOnClassGenerateGenericTest", new[] { "Run(0, 1)" })] + [Arguments("TypedDataSourceOnClassGenerateGenericTest", new[] { "Run(42)" })] public async Task Generic_Method_Of_Generic_Class_Runs_Each_Instantiation(string className, string[] expectedTestNames) { await RunTestsWithFilter( @@ -29,4 +31,22 @@ await RunTestsWithFilter( .ShouldBe(expectedTestNames.OrderBy(x => x, StringComparer.Ordinal)) ]); } + + // A method constraint violation paired with a class row fails once per row of the method source + // ([MethodDataSource] rows, or every method data source for IInfersType), not once in total. + [Test] + [Arguments("MethodDataSourceConstraintViolationOnClassRow")] + [Arguments("MatrixConstraintViolationOnClassRow")] + public async Task Method_Constraint_Violation_On_Class_Row_Fails_Once_Per_Row(string className) + { + await RunTestsWithFilter( + $"/*/TUnit.TestProject.Bugs._7117/{className}/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Failed"), + result => result.ResultSummary.Counters.Total.ShouldBe(2), + result => result.ResultSummary.Counters.Failed.ShouldBe(2), + result => result.Results.ShouldAllBe(x => x.TestName == "Run"), + result => result.Results.ShouldAllBe(x => x.Output!.ErrorInfo!.Message!.Contains("TMethod")) + ]); + } } diff --git a/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs b/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs index 273bd330fcb..6f8db7b91ae 100644 --- a/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7117/GenerateGenericTestOnMethodOfGenericClassTests.cs @@ -113,3 +113,85 @@ public async Task Run(TMethod arg, int count) await Assert.That(arg).IsEqualTo((TMethod)(object)"a"); } } + +// A method data source of the open class whose rows name the class type parameter infers the method +// type argument from the closed class: Run on GenerateGenericTest's long, not Run. +[EngineTest(ExpectedResult.Pass)] +[GenerateGenericTest(typeof(long))] +public class ClassTypedMethodDataSourceOnClassGenerateGenericTest +{ + public static IEnumerable> Rows() + { + yield return () => (default(T)!, 1); + } + + [Test] + [MethodDataSource(nameof(Rows))] + public async Task Run(TMethod arg, int count) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(long)); + await Assert.That(typeof(TMethod)).IsEqualTo(typeof(long)); + await Assert.That(count).IsEqualTo(1); + } +} + +// A typed data source infers no method type argument on a generic class, in either mode: it runs on the +// method [GenerateGenericTest] type, Run(42), and adds no Run. +[EngineTest(ExpectedResult.Pass)] +[GenerateGenericTest(typeof(string))] +public class TypedDataSourceOnClassGenerateGenericTest +{ + [Test] + [GenerateGenericTest(typeof(long))] + [IntSource7117] + public async Task Run(TMethod arg) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(string)); + await Assert.That(typeof(TMethod)).IsEqualTo(typeof(long)); + await Assert.That(Convert.ToInt64(arg)).IsEqualTo(42L); + } +} + +// A method data source whose inferred type breaks the method's constraint, paired with a class row: +// one failed test per row the source yields, as in reflection mode. No [EngineTest]: it fails on purpose. +[Arguments(1)] +public class MethodDataSourceConstraintViolationOnClassRow(T value) +{ + public static IEnumerable> Rows() + { + yield return () => ("a", 1); + yield return () => ("b", 2); + } + + [Test] +#pragma warning disable TUnit0001 // the analyzer rejects string for TMethod; the test checks how discovery reports it + [MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task Run(TMethod arg, int count) where TMethod : struct + { + _ = value; + await Assert.That(arg).IsNotEqualTo(default(TMethod)); + } +} + +// IInfersType whose type breaks the method's constraint, paired with a class row: one failed test +// per row of every method data source, as in reflection mode. No [EngineTest]: it fails on purpose. +[Arguments(1)] +public class MatrixConstraintViolationOnClassRow(T value) +{ + [Test] + [MatrixDataSource] + public async Task Run([Matrix("a", "b")] TMethod arg) where TMethod : struct + { + _ = value; + await Assert.That(arg).IsNotEqualTo(default(TMethod)); + } +} + +public class IntSource7117 : DataSourceGeneratorAttribute +{ + protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata metadata) + { + yield return () => 42; + } +}