From cba38d45cb2831c9f0e230bdb4e4f277462c071c Mon Sep 17 00:00:00 2001 From: Michael <51033404+theluckystrike@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:23:16 +0700 Subject: [PATCH] fix(engine): use last inferred type for repeated generic parameters Reflection mode named a repeated-type-parameter test after its last argument type but instantiated the method from its first non-assignable type. A test shown as Repeated could then run with T = string and fail its where T : struct constraint. Directly declared generic parameters now take the last argument type, matching TestGenericTypeResolver and source-generated mode. Nested inference, such as the element type of a params T[], still preserves a more specific leading type so the _7099 fixtures keep passing. --- .../Discovery/ReflectionTestDataCollector.cs | 22 ++++++++-------- tests/TUnit.Engine.Tests/Issue7121Tests.cs | 25 +++++++++++++++++++ .../Bugs/_7121/RepeatedTypeParameterTests.cs | 23 +++++++++++++++++ 3 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 tests/TUnit.Engine.Tests/Issue7121Tests.cs create mode 100644 tests/TUnit.TestProject/Bugs/_7121/RepeatedTypeParameterTests.cs diff --git a/src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index b4042de7ee9..ccf6c1149e0 100644 --- a/src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -2085,23 +2085,23 @@ public override Func /// Infers generic type mappings from parameter and argument types /// - private static void InferGenericTypeMapping(Type paramType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type argType, Dictionary typeMapping) + private static void InferGenericTypeMapping(Type paramType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type argType, Dictionary typeMapping, bool isTopLevel = false) { // Direct generic parameter if (paramType.IsGenericParameter) { - // Check if we already have a mapping for this parameter - if (typeMapping.TryGetValue(paramType, out var existingType)) + if (isTopLevel) { - // If the existing type is more specific (derived from current), keep it - if (existingType.IsAssignableFrom(argType)) - { - typeMapping[paramType] = argType; - } - // Otherwise keep the more general type + // A repeated type parameter takes the type of its last non-null declared argument, + // which is what TestGenericTypeResolver and source-generated mode infer. Keeping + // the first (or most specific) type here made the invoker use a different type + // argument from the one the test was named with. + typeMapping[paramType] = argType; } - else + else if (!typeMapping.TryGetValue(paramType, out var existingType) || existingType.IsAssignableFrom(argType)) { + // Nested inference (e.g. the element type of a params T[]) must not replace a more + // specific type already inferred from a leading T argument. typeMapping[paramType] = argType; } return; @@ -2324,7 +2324,7 @@ private static bool MatchesCovariantInterface(Type paramType, Type paramGenericD { var paramType = methodParams[j].ParameterType; var argType = arg.GetType(); - InferGenericTypeMapping(paramType, argType, typeMapping); + InferGenericTypeMapping(paramType, argType, typeMapping, isTopLevel: true); } } diff --git a/tests/TUnit.Engine.Tests/Issue7121Tests.cs b/tests/TUnit.Engine.Tests/Issue7121Tests.cs new file mode 100644 index 00000000000..c7e330430a1 --- /dev/null +++ b/tests/TUnit.Engine.Tests/Issue7121Tests.cs @@ -0,0 +1,25 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// https://github.com/thomhurst/TUnit/issues/7121: a generic test whose type parameter occurs more +/// than once must run with the same type argument reflection mode reports. +/// +public class Issue7121Tests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Repeated_Type_Parameter_Runs_With_Reported_Type() + { + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._7121/RepeatedTypeParameterTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.Results.Single().TestName.ShouldStartWith("Repeated") + ]); + } +} diff --git a/tests/TUnit.TestProject/Bugs/_7121/RepeatedTypeParameterTests.cs b/tests/TUnit.TestProject/Bugs/_7121/RepeatedTypeParameterTests.cs new file mode 100644 index 00000000000..f1a55f8dbf3 --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/_7121/RepeatedTypeParameterTests.cs @@ -0,0 +1,23 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._7121; + +[EngineTest(ExpectedResult.Pass)] +public class RepeatedTypeParameterTests +{ + public static IEnumerable> Rows() + { + yield return () => ("1", 2); + } + + // T occurs twice. Source-gen mode infers T from its last occurrence (int) and converts "1" + // through CastHelper. Reflection mode must instantiate the same type it reports. + [Test] + [MethodDataSource(nameof(Rows))] + public async Task Repeated(T a, T b) where T : struct + { + await Assert.That(typeof(T)).IsEqualTo(typeof(int)); + await Assert.That(a).IsEqualTo((T)(object)1); + await Assert.That(b).IsEqualTo((T)(object)2); + } +}