From 88c694fb21ef0757a93a6444122173e4dcda3d9f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:12:13 +0000 Subject: [PATCH 01/10] fix(sourcegen): generate a data source factory for [InstanceMethodDataSource] (#7225) [InstanceMethodDataSource] went through the generic attribute writer and got no Factory, so Native AOT fell back to a reflection member lookup that found no trimmed field metadata. Route it through GenerateMethodDataSourceAttribute and keep the declared attribute type so the engine still sees IAccessesInstanceData. --- .../Generators/TestMetadataGenerator.cs | 10 +- .../Bugs/7225/Tests7225.cs | 24 +++ .../Tests1821.Test.verified.txt | 5 +- .../Tests7225.Test.verified.txt | 188 ++++++++++++++++++ .../InstanceMethodDataSourceFieldTests.cs | 38 ++++ 5 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs create mode 100644 tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt create mode 100644 tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceFieldTests.cs diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index d162a33ae39..d7565de5b44 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -2544,7 +2544,10 @@ private static void GenerateDataSourceAttribute(CodeWriter writer, CompilationCo var attrName = attrClass.GloballyQualifiedNonGeneric(); - if (attrName == "global::TUnit.Core.MethodDataSourceAttribute") + // InstanceMethodDataSourceAttribute derives from MethodDataSourceAttribute and needs the + // same generated factory; without one, Native AOT falls back to a reflection lookup that + // finds no trimmed field or property metadata (#7225). + if (attrName is "global::TUnit.Core.MethodDataSourceAttribute" or "global::TUnit.Core.InstanceMethodDataSourceAttribute") { GenerateMethodDataSourceAttribute(writer, attr, typeSymbol, compilationContext.Compilation); } @@ -2916,7 +2919,10 @@ private static void GenerateMethodDataSourceAttribute(CodeWriter writer, Attribu return; } - var attrTypeName = usesTestClassInstance + // Keep a declared [InstanceMethodDataSource] as one even when its member is static, so the + // engine still sees IAccessesInstanceData as it does in reflection mode. + var isDeclaredInstanceAttribute = attr.AttributeClass?.GloballyQualifiedNonGeneric() == "global::TUnit.Core.InstanceMethodDataSourceAttribute"; + var attrTypeName = usesTestClassInstance || isDeclaredInstanceAttribute ? "global::TUnit.Core.InstanceMethodDataSourceAttribute" : "global::TUnit.Core.MethodDataSourceAttribute"; diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs new file mode 100644 index 00000000000..659a7a30690 --- /dev/null +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs @@ -0,0 +1,24 @@ +namespace TUnit.Core.SourceGenerator.Tests.Bugs._7225; + +/// +/// [InstanceMethodDataSource] gets the same generated Factory as [MethodDataSource], so Native AOT +/// does not fall back to a reflection member lookup (#7225). +/// +internal class Tests7225 : TestsBase +{ + [Test] + public Task Test() => RunTest(Path.Combine(Git.TestsDirectory.FullName, + "TUnit.TestProject", + "Bugs", + "_7225", + "InstanceMethodDataSourceFieldTests.cs"), + async generatedFiles => + { + var source = string.Join(Environment.NewLine, generatedFiles); + const string testClass = "global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests"; + await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (({testClass})instance).Rows)"); + await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (({testClass})instance).PropertyRows)"); + await Assert.That(source).Contains($"FromEnumerable(static () => {testClass}.StaticRows())"); + await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"Rows\"),"); + }); +} diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests1821.Test.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests1821.Test.verified.txt index 3e2b7b1f61e..668fdbc8909 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests1821.Test.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests1821.Test.verified.txt @@ -95,7 +95,10 @@ internal static class TUnit_TestProject_Bugs__1821_Tests__TestSource hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.InstanceMethodDataSourceAttribute("DataSource"), + new global::TUnit.Core.InstanceMethodDataSourceAttribute("DataSource") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._1821.Tests)instance).DataSource()), + }, }, classDataSources: new global::TUnit.Core.IDataSourceAttribute[] { diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt new file mode 100644 index 00000000000..6cff3e87cbb --- /dev/null +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt @@ -0,0 +1,188 @@ +// +#pragma warning disable + +#nullable enable +namespace TUnit.Generated; +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] +[global::System.CodeDom.Compiler.GeneratedCode("TUnit", "VERSION_SCRUBBED")] +internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceFieldTests__TestSource +{ + private static readonly global::TUnit.Core.ClassMetadata __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests", new global::TUnit.Core.ClassMetadata + { + Type = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests), + TypeInfo = new global::TUnit.Core.ConcreteType(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests)), + Name = "InstanceMethodDataSourceFieldTests", + Namespace = "TUnit.TestProject.Bugs._7225", + Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", "TestsBase`1"), + Parameters = global::System.Array.Empty(), + Properties = new global::TUnit.Core.PropertyMetadata[] + { + new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests).GetProperty("PropertyRows", typeof(global::System.Collections.Generic.IEnumerable)), + Type = typeof(global::System.Collections.Generic.IEnumerable), + Name = "PropertyRows", + IsStatic = false, + IsNullable = false, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests)o).PropertyRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! + } + }, + Parent = null + }); + private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests); + private static global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests __CreateInstance(global::System.Type[] typeArgs, object?[] args) + { + return new global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests(); + } + private static global::System.Threading.Tasks.ValueTask __Invoke(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) + { + try + { + switch (methodIndex) + { + case 0: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Instance_Field(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 1: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Instance_Property(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 2: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Static_Method(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + default: + throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex)); + } + } + catch (global::System.Exception ex) + { + return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex)); + } + } + private static global::System.Attribute[] __Attributes(int groupIndex) + { + switch (groupIndex) + { + case 0: + { + return + [ + new global::TUnit.Core.TestAttribute(), + new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) + ]; + } + default: + throw new global::System.ArgumentOutOfRangeException(nameof(groupIndex)); + } + } + private static readonly global::System.Func __createInstance = __CreateInstance; + private static readonly global::System.Func __invoke = __Invoke; + private static readonly global::System.Func __attributes = __Attributes; + public static readonly global::TUnit.Core.TestEntry[] Entries = new global::TUnit.Core.TestEntry[] + { + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Instance_Field", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests.Instance_Field", + filePath: "", + lineNumber: 18, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("Rows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests)instance).Rows), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests), "Instance_Field", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 0, + createAttributes: __attributes, + attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Instance_Property", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests.Instance_Property", + filePath: "", + lineNumber: 25, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("PropertyRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests)instance).PropertyRows), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests), "Instance_Property", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 1, + createAttributes: __attributes, + attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Static_Method", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests.Static_Method", + filePath: "", + lineNumber: 32, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("StaticRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromEnumerable(static () => global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests.StaticRows()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests), "Static_Method", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 2, + createAttributes: __attributes, + attributeGroupIndex: 0), + }; +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceFieldTests__TestSource = global::TUnit.Core.SourceRegistrar.RegisterEntries(static () => TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceFieldTests__TestSource.Entries); +} diff --git a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceFieldTests.cs b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceFieldTests.cs new file mode 100644 index 00000000000..9b7eff56806 --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceFieldTests.cs @@ -0,0 +1,38 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._7225; + +/// +/// [InstanceMethodDataSource] naming an instance field or property must get a generated factory, like +/// [MethodDataSource] does; otherwise Native AOT falls back to a reflection lookup that finds no member. +/// +[EngineTest(ExpectedResult.Pass)] +public class InstanceMethodDataSourceFieldTests +{ + public readonly object Rows = new[] { 53, 54 }; + + public IEnumerable PropertyRows => [55, 56]; + + public static IEnumerable StaticRows() => [57, 58]; + + [Test] + [InstanceMethodDataSource(nameof(Rows))] + public async Task Instance_Field(int value) + { + await Assert.That(value).IsBetween(53, 54); + } + + [Test] + [InstanceMethodDataSource(nameof(PropertyRows))] + public async Task Instance_Property(int value) + { + await Assert.That(value).IsBetween(55, 56); + } + + [Test] + [InstanceMethodDataSource(nameof(StaticRows))] + public async Task Static_Method(int value) + { + await Assert.That(value).IsBetween(57, 58); + } +} From de2b2a1665b37d8822794850417e622f34f8cf0f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:50:05 +0000 Subject: [PATCH 02/10] fix(sourcegen): keep runtime row expansion and private methods for [InstanceMethodDataSource] (#7225) - An object-typed property or method named by [InstanceMethodDataSource] decides its rows from the runtime value, as the attribute's reflection lookup did before it had a generated factory. - A parameterless private or protected data source method is read through a constant-name typeof(T).GetMethod lookup that Native AOT keeps, like private fields and properties, instead of a direct call the generated class cannot compile. - Share the InstanceMethodDataSourceAttribute name as a constant. --- .../Generators/TestMetadataGenerator.cs | 59 +++-- .../Bugs/7225/Tests7225.cs | 22 ++ .../Tests7225.MemberShapes.verified.txt | 241 ++++++++++++++++++ ...nstanceMethodDataSourceMemberShapeTests.cs | 48 ++++ 4 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt create mode 100644 tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index d7565de5b44..f9cfe660702 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -22,6 +22,7 @@ namespace TUnit.Core.SourceGenerator.Generators; public sealed class TestMetadataGenerator : IIncrementalGenerator { private const string GeneratedNamespace = "TUnit.Generated"; + private const string InstanceMethodDataSourceAttributeName = "global::TUnit.Core.InstanceMethodDataSourceAttribute"; private static readonly DiagnosticDescriptor GenerationErrorDescriptor = new( "TUNIT0999", @@ -2547,7 +2548,7 @@ private static void GenerateDataSourceAttribute(CodeWriter writer, CompilationCo // InstanceMethodDataSourceAttribute derives from MethodDataSourceAttribute and needs the // same generated factory; without one, Native AOT falls back to a reflection lookup that // finds no trimmed field or property metadata (#7225). - if (attrName is "global::TUnit.Core.MethodDataSourceAttribute" or "global::TUnit.Core.InstanceMethodDataSourceAttribute") + if (attrName is "global::TUnit.Core.MethodDataSourceAttribute" or InstanceMethodDataSourceAttributeName) { GenerateMethodDataSourceAttribute(writer, attr, typeSymbol, compilationContext.Compilation); } @@ -2891,7 +2892,7 @@ private static void GenerateMethodDataSourceAttribute(CodeWriter writer, Attribu if (dataSourceMember == null || (dataSourceMethod == null && dataSourceProperty == null && dataSourceField == null) - || (dataSourceMethod == null && !CanReadDataSourceMember(dataSourceMember, compilation))) + || !CanReadDataSourceMember(dataSourceMember, compilation)) { // Still generate the attribute even if method not found - it will fail at runtime with proper error // Use CodeGenerationHelpers to properly handle any generics on the attribute @@ -2921,9 +2922,9 @@ private static void GenerateMethodDataSourceAttribute(CodeWriter writer, Attribu // Keep a declared [InstanceMethodDataSource] as one even when its member is static, so the // engine still sees IAccessesInstanceData as it does in reflection mode. - var isDeclaredInstanceAttribute = attr.AttributeClass?.GloballyQualifiedNonGeneric() == "global::TUnit.Core.InstanceMethodDataSourceAttribute"; + var isDeclaredInstanceAttribute = attr.AttributeClass?.GloballyQualifiedNonGeneric() == InstanceMethodDataSourceAttributeName; var attrTypeName = usesTestClassInstance || isDeclaredInstanceAttribute - ? "global::TUnit.Core.InstanceMethodDataSourceAttribute" + ? InstanceMethodDataSourceAttributeName : "global::TUnit.Core.MethodDataSourceAttribute"; if (attr.ConstructorArguments is @@ -2983,7 +2984,7 @@ private static void GenerateMethodDataSourceAttribute(CodeWriter writer, Attribu // a per-data-source compiler-generated state machine class (#6227). Only the per-test-unique // invocation is emitted, as a static lambda. writer.Append("Factory = static dataGeneratorMetadata => "); - EmitDataSourceFactoryInvocation(writer, dataSourceMember, targetType, usesTestClassInstance, hasArguments ? argumentsProperty.Value : (TypedConstant?)null, compilation); + EmitDataSourceFactoryInvocation(writer, dataSourceMember, targetType, usesTestClassInstance, hasArguments ? argumentsProperty.Value : (TypedConstant?)null, compilation, isDeclaredInstanceAttribute); writer.AppendLine(","); writer.Unindent(); @@ -2996,7 +2997,7 @@ private static void GenerateMethodDataSourceAttribute(CodeWriter writer, Attribu /// member's declared return type (mirroring the previous inline-iterator branch order) /// and whether the member is static or instance-based. /// - private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol dataSourceMember, ITypeSymbol targetType, bool usesTestClassInstance, TypedConstant? arguments, Compilation compilation) + private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol dataSourceMember, ITypeSymbol targetType, bool usesTestClassInstance, TypedConstant? arguments, Compilation compilation, bool decidesRowsFromRuntimeValue) { var dataSourceMethod = dataSourceMember as IMethodSymbol; @@ -3075,10 +3076,11 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d // A declared type such as object does not say whether the value is one row or many. Reflection mode, and the // attribute's reflection fallback that read every field and every private or protected property before the // generator could, decide from the runtime value, so these factories hand the value to that same logic - // instead of FromValue. Accessible properties and methods keep FromValue, as their generated code always has - // (https://github.com/thomhurst/TUnit/issues/7178). - var isAccessibleMember = dataSourceMethod != null || IsAccessibleDataSourceMember(dataSourceMember, compilation); - if (kind == "Value" && (dataSourceMember is IFieldSymbol || !isAccessibleMember)) + // instead of FromValue. Accessible properties and methods of [MethodDataSource] keep FromValue, as their + // generated code always has (https://github.com/thomhurst/TUnit/issues/7178). [InstanceMethodDataSource] had no + // generated factory before #7225, so every member it names keeps the runtime-value behavior it always had. + var isAccessibleMember = IsAccessibleDataSourceMember(dataSourceMember, compilation); + if (kind == "Value" && (decidesRowsFromRuntimeValue || dataSourceMember is IFieldSymbol || !isAccessibleMember)) { helper = isStatic || !usesTestClassInstance ? "global::TUnit.Core.Helpers.DataSourceFactories.FromRuntimeValue" @@ -3088,17 +3090,24 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d if (!isAccessibleMember) { - // The generated class cannot name a private or protected field or property, so the factory reads it by - // reflection. GetField/GetProperty on a typeof() with a constant name and binding flags is understood + // The generated class cannot name a private or protected member, so the factory reads it by + // reflection. GetField/GetProperty/GetMethod on a typeof() with a constant name and binding flags is understood // by the trimmer and the AOT compiler, which keep that member, so this works under native AOT // without any trimming warning. The attribute's own reflection fallback finds the member from the // test class type at run time, which neither of them can see. - var lookup = dataSourceMember is IFieldSymbol ? "GetField" : "GetProperty"; var bindingFlags = (isStatic ? "global::System.Reflection.BindingFlags.Static" : "global::System.Reflection.BindingFlags.Instance") + " | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.DeclaredOnly"; var target = isStatic ? "null" : usesTestClassInstance ? "instance" : $"new {fullyQualifiedType}()"; + // CanReadDataSourceMember only lets a parameterless, non-generic method through, so it is looked up with no + // parameter types, which also picks it out from any overloads + var read = dataSourceMember switch + { + IFieldSymbol => $"GetField(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target})", + IPropertySymbol => $"GetProperty(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target})", + _ => $"GetMethod(\"{dataSourceMember.Name}\", {bindingFlags}, null, global::System.Type.EmptyTypes, null)!.Invoke({target}, null)" + }; var cast = kind is "Value" or "Enumerable" ? "" : $"({returnType.GloballyQualified()})"; - writer.Append($"{helper}({metadataArgument}{lambdaHeader} {cast}typeof({dataSourceMember.ContainingType.GloballyQualified()}).{lookup}(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target}))"); + writer.Append($"{helper}({metadataArgument}{lambdaHeader} {cast}typeof({dataSourceMember.ContainingType.GloballyQualified()}).{read})"); return; } @@ -3178,7 +3187,7 @@ private static bool IsShadowedOnTargetType(IMethodSymbol declaringMethod, ITypeS } /// - /// Whether the generated class can read the data source field or property directly: the member and its getter + /// Whether the generated class can read the data source member directly: the member, and a property's getter, /// are accessible from the test assembly, so not private or protected. /// private static bool IsAccessibleDataSourceMember(ISymbol member, Compilation compilation) @@ -3189,10 +3198,10 @@ private static bool IsAccessibleDataSourceMember(ISymbol member, Compilation com } /// - /// Whether a generated factory can read the data source field or property, directly or, for a private or - /// protected member, by reflection on its declaring type. That needs a declaring type that the generated class - /// can name without type parameters, a getter for a property, and a member type it can name when the factory - /// casts the value. Otherwise the attribute keeps its reflection fallback. + /// Whether a generated factory can read the data source member, directly or, for a private or protected member, + /// by reflection on its declaring type. That needs a declaring type that the generated class can name without + /// type parameters, a getter for a property, a method without parameters or type parameters, and a member type it + /// can name when the factory casts the value. Otherwise the attribute keeps its reflection fallback. /// private static bool CanReadDataSourceMember(ISymbol member, Compilation compilation) { @@ -3206,8 +3215,18 @@ private static bool CanReadDataSourceMember(ISymbol member, Compilation compilat return true; } + if (member is IMethodSymbol { Parameters.Length: > 0 } or IMethodSymbol { IsGenericMethod: true }) + { + return false; + } + // EmitDataSourceFactoryInvocation casts the value for the async helpers, which take a typed delegate - var memberType = member is IPropertySymbol property ? property.Type : ((IFieldSymbol)member).Type; + var memberType = member switch + { + IPropertySymbol property => property.Type, + IMethodSymbol method => method.ReturnType, + _ => ((IFieldSymbol)member).Type + }; var castsValue = IsAsyncEnumerable(memberType) || IsValueTask(memberType) || IsTask(memberType); return compilation.IsSymbolAccessibleWithin(member.ContainingType, compilation.Assembly) && !CodeGenerationHelpers.ContainsTypeParameter(member.ContainingType) diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs index 659a7a30690..4877851853e 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs @@ -21,4 +21,26 @@ public Task Test() => RunTest(Path.Combine(Git.TestsDirectory.FullName, await Assert.That(source).Contains($"FromEnumerable(static () => {testClass}.StaticRows())"); await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"Rows\"),"); }); + + /// + /// The factory keeps the behavior [InstanceMethodDataSource] had when it read members by reflection: an object + /// value decides its rows at run time, and a private method is looked up + /// by a constant name that Native AOT keeps, as private fields and properties are. + /// + [Test] + public Task MemberShapes() => RunTest(Path.Combine(Git.TestsDirectory.FullName, + "TUnit.TestProject", + "Bugs", + "_7225", + "InstanceMethodDataSourceMemberShapeTests.cs"), + async generatedFiles => + { + var source = string.Join(Environment.NewLine, generatedFiles); + const string testClass = "global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests"; + await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (({testClass})instance).ObjectProperty)"); + await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (({testClass})instance).ObjectMethod())"); + await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (({testClass})instance).TupleRows)"); + await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => typeof({testClass}).GetMethod(\"PrivateRows\", "); + await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"PrivateRows\"),"); + }); } diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt new file mode 100644 index 00000000000..6d79800536e --- /dev/null +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt @@ -0,0 +1,241 @@ +// +#pragma warning disable + +#nullable enable +namespace TUnit.Generated; +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] +[global::System.CodeDom.Compiler.GeneratedCode("TUnit", "VERSION_SCRUBBED")] +internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMemberShapeTests__TestSource +{ + private static readonly global::TUnit.Core.ClassMetadata __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests", new global::TUnit.Core.ClassMetadata + { + Type = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), + TypeInfo = new global::TUnit.Core.ConcreteType(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)), + Name = "InstanceMethodDataSourceMemberShapeTests", + Namespace = "TUnit.TestProject.Bugs._7225", + Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", "TestsBase`1"), + Parameters = global::System.Array.Empty(), + Properties = new global::TUnit.Core.PropertyMetadata[] + { + new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("ObjectProperty", typeof(object)), + Type = typeof(object), + Name = "ObjectProperty", + IsStatic = false, + IsNullable = false, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).ObjectProperty, + ClassMetadata = null!, + ContainingTypeMetadata = null! + }, + new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("TupleRows", typeof(global::System.Collections.Generic.IEnumerable>)), + Type = typeof(global::System.Collections.Generic.IEnumerable>), + Name = "TupleRows", + IsStatic = false, + IsNullable = false, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).TupleRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! + } + }, + Parent = null + }); + private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests); + private static global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests __CreateInstance(global::System.Type[] typeArgs, object?[] args) + { + return new global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests(); + } + private static global::System.Threading.Tasks.ValueTask __Invoke(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) + { + try + { + switch (methodIndex) + { + case 0: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Object_Property(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 1: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Object_Method(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 2: + { + // Special handling for single tuple parameter + if (args.Length == 2) + { + // Arguments are unwrapped tuple elements, reconstruct the tuple + return new global::System.Threading.Tasks.ValueTask(instance.Tuple_Single_Parameter((global::TUnit.Core.Helpers.CastHelper.Cast(args[0]), global::TUnit.Core.Helpers.CastHelper.Cast(args[1])))); + } + else if (args.Length == 1 && global::TUnit.Core.Helpers.DataSourceHelpers.IsTuple(args[0])) + { + // Rare case: tuple is wrapped as a single argument + return new global::System.Threading.Tasks.ValueTask(instance.Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + else + { + throw new global::System.ArgumentException($"Expected 2 unwrapped elements or 1 wrapped tuple, but got {args.Length} arguments"); + } + } + case 3: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Private_Method(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + default: + throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex)); + } + } + catch (global::System.Exception ex) + { + return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex)); + } + } + private static global::System.Attribute[] __Attributes(int groupIndex) + { + switch (groupIndex) + { + case 0: + { + return + [ + new global::TUnit.Core.TestAttribute(), + new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) + ]; + } + default: + throw new global::System.ArgumentOutOfRangeException(nameof(groupIndex)); + } + } + private static readonly global::System.Func __createInstance = __CreateInstance; + private static readonly global::System.Func __invoke = __Invoke; + private static readonly global::System.Func __attributes = __Attributes; + public static readonly global::TUnit.Core.TestEntry[] Entries = new global::TUnit.Core.TestEntry[] + { + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Object_Property", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Property", + filePath: "", + lineNumber: 21, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("ObjectProperty") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).ObjectProperty), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Object_Property", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 0, + createAttributes: __attributes, + attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Object_Method", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Method", + filePath: "", + lineNumber: 28, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("ObjectMethod") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).ObjectMethod()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Object_Method", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 1, + createAttributes: __attributes, + attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Tuple_Single_Parameter", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Tuple_Single_Parameter", + filePath: "", + lineNumber: 35, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("TupleRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).TupleRows), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Tuple_Single_Parameter", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.ValueTuple), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.ValueTuple)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 2, + createAttributes: __attributes, + attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Private_Method", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method", + filePath: "", + lineNumber: 42, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("PrivateRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetMethod("PrivateRows", global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.DeclaredOnly, null, global::System.Type.EmptyTypes, null)!.Invoke(instance, null)), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Private_Method", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 3, + createAttributes: __attributes, + attributeGroupIndex: 0), + }; +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMemberShapeTests__TestSource = global::TUnit.Core.SourceRegistrar.RegisterEntries(static () => TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMemberShapeTests__TestSource.Entries); +} diff --git a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs new file mode 100644 index 00000000000..3528d5b4a41 --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs @@ -0,0 +1,48 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._7225; + +/// +/// [InstanceMethodDataSource] read every member by reflection in source-gen mode before #7225, so its generated +/// factory must keep that behavior: an object value holding many rows supplies each row, a private method +/// is still callable, and a single tuple parameter still receives the whole tuple. +/// +[EngineTest(ExpectedResult.Pass)] +public class InstanceMethodDataSourceMemberShapeTests +{ + public object ObjectProperty => new[] { 61, 62 }; + + public object ObjectMethod() => new[] { 63, 64 }; + + public IEnumerable<(int, int)> TupleRows => [(1, 2), (3, 4)]; + + private IEnumerable PrivateRows() => [65, 66]; + + [Test] + [InstanceMethodDataSource(nameof(ObjectProperty))] + public async Task Object_Property(int value) + { + await Assert.That(value).IsBetween(61, 62); + } + + [Test] + [InstanceMethodDataSource(nameof(ObjectMethod))] + public async Task Object_Method(int value) + { + await Assert.That(value).IsBetween(63, 64); + } + + [Test] + [InstanceMethodDataSource(nameof(TupleRows))] + public async Task Tuple_Single_Parameter((int, int) value) + { + await Assert.That(value.Item2 - value.Item1).IsEqualTo(1); + } + + [Test] + [InstanceMethodDataSource(nameof(PrivateRows))] + public async Task Private_Method(int value) + { + await Assert.That(value).IsBetween(65, 66); + } +} From bedcaba9a397ad33e3b1d8a3d5fb5bdcf224ad0b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:14:13 +0000 Subject: [PATCH 03/10] fix(sourcegen): skip generic overloads and keep null as an empty source for [InstanceMethodDataSource] (#7225) A private parameterless data source method beside a parameterless generic overload of the same name made the generated GetMethod lookup ambiguous; the factory now filters out generic method definitions instead. A null value from an [InstanceMethodDataSource] member became one null row, so SkipIfEmpty no longer skipped the test as the reflection path does. The generated factory now reads null as an empty source. --- .../Generators/TestMetadataGenerator.cs | 24 ++++- .../Bugs/7225/Tests7225.cs | 17 +-- .../Tests1821.Test.verified.txt | 2 +- .../Tests7225.MemberShapes.verified.txt | 100 ++++++++++++++++-- .../Tests7225.Test.verified.txt | 6 +- ...nstanceMethodDataSourceMemberShapeTests.cs | 23 +++- 6 files changed, 148 insertions(+), 24 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index f9cfe660702..382202c6723 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -3088,6 +3088,13 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d metadataArgument = "dataGeneratorMetadata, "; } + // [InstanceMethodDataSource] read its member by reflection before #7225, and that path, still used in + // reflection mode, treats a null value as a source with no rows, so SkipIfEmpty skips the test. The factory + // helpers would turn null into one row holding null, so a null value becomes an empty source here. + var nullAsEmpty = decidesRowsFromRuntimeValue && kind is "Value" or "Enumerable" + ? " ?? global::System.Array.Empty()" + : ""; + if (!isAccessibleMember) { // The generated class cannot name a private or protected member, so the factory reads it by @@ -3099,15 +3106,22 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d + " | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.DeclaredOnly"; var target = isStatic ? "null" : usesTestClassInstance ? "instance" : $"new {fullyQualifiedType}()"; // CanReadDataSourceMember only lets a parameterless, non-generic method through, so it is looked up with no - // parameter types, which also picks it out from any overloads + // parameter types, which also picks it out from any overloads. A parameterless generic overload of the same + // name would match that lookup too and make it ambiguous, so then the methods are filtered instead, without + // the genericParameterCount overload of GetMethod that .NET Framework and .NET Standard 2.0 lack. + var hasGenericParameterlessOverload = dataSourceMember is IMethodSymbol + && dataSourceMember.ContainingType.GetMembers(dataSourceMember.Name) + .Any(static m => m is IMethodSymbol { IsGenericMethod: true, Parameters.Length: 0 }); var read = dataSourceMember switch { IFieldSymbol => $"GetField(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target})", IPropertySymbol => $"GetProperty(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target})", + _ when hasGenericParameterlessOverload => $"GetMethods({bindingFlags}), static m => m.Name == \"{dataSourceMember.Name}\" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 0)!.Invoke({target}, null)", _ => $"GetMethod(\"{dataSourceMember.Name}\", {bindingFlags}, null, global::System.Type.EmptyTypes, null)!.Invoke({target}, null)" }; var cast = kind is "Value" or "Enumerable" ? "" : $"({returnType.GloballyQualified()})"; - writer.Append($"{helper}({metadataArgument}{lambdaHeader} {cast}typeof({dataSourceMember.ContainingType.GloballyQualified()}).{read})"); + var find = hasGenericParameterlessOverload ? "global::System.Array.Find(" : ""; + writer.Append($"{helper}({metadataArgument}{lambdaHeader} {cast}{find}typeof({dataSourceMember.ContainingType.GloballyQualified()}).{read}{nullAsEmpty})"); return; } @@ -3147,13 +3161,15 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d writer.Append("global::System.Array.Empty()"); } writer.AppendLine(";"); - writer.AppendLine($"return {call};"); + writer.AppendLine(nullAsEmpty.Length == 0 ? $"return {call};" : $"return (object?){call}{nullAsEmpty};"); writer.Unindent(); writer.Append("})"); } else { - writer.Append($"{helper}({metadataArgument}{lambdaHeader} {call})"); + writer.Append(nullAsEmpty.Length == 0 + ? $"{helper}({metadataArgument}{lambdaHeader} {call})" + : $"{helper}({metadataArgument}{lambdaHeader} (object?){call}{nullAsEmpty})"); } } diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs index 4877851853e..14917c36e3b 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs @@ -16,15 +16,15 @@ public Task Test() => RunTest(Path.Combine(Git.TestsDirectory.FullName, { var source = string.Join(Environment.NewLine, generatedFiles); const string testClass = "global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests"; - await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (({testClass})instance).Rows)"); - await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (({testClass})instance).PropertyRows)"); - await Assert.That(source).Contains($"FromEnumerable(static () => {testClass}.StaticRows())"); + await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).Rows ?? global::System.Array.Empty())"); + await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).PropertyRows ?? global::System.Array.Empty())"); + await Assert.That(source).Contains($"FromEnumerable(static () => (object?){testClass}.StaticRows() ?? global::System.Array.Empty())"); await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"Rows\"),"); }); /// /// The factory keeps the behavior [InstanceMethodDataSource] had when it read members by reflection: an object - /// value decides its rows at run time, and a private method is looked up + /// value decides its rows at run time, a null value is an empty source, and a private method is looked up /// by a constant name that Native AOT keeps, as private fields and properties are. /// [Test] @@ -37,10 +37,13 @@ public Task MemberShapes() => RunTest(Path.Combine(Git.TestsDirectory.FullName, { var source = string.Join(Environment.NewLine, generatedFiles); const string testClass = "global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests"; - await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (({testClass})instance).ObjectProperty)"); - await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (({testClass})instance).ObjectMethod())"); - await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (({testClass})instance).TupleRows)"); + await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).ObjectProperty ?? global::System.Array.Empty())"); + await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).ObjectMethod() ?? global::System.Array.Empty())"); + await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).TupleRows ?? global::System.Array.Empty())"); await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => typeof({testClass}).GetMethod(\"PrivateRows\", "); await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"PrivateRows\"),"); + // A generic overload of the same name would make the GetMethod lookup ambiguous + await Assert.That(source).Contains($"global::System.Array.Find(typeof({testClass}).GetMethods("); + await Assert.That(source).Contains("m.Name == \"OverloadedRows\" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 0)!.Invoke(instance, null)"); }); } diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests1821.Test.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests1821.Test.verified.txt index 668fdbc8909..fb902a98ea2 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests1821.Test.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests1821.Test.verified.txt @@ -97,7 +97,7 @@ internal static class TUnit_TestProject_Bugs__1821_Tests__TestSource { new global::TUnit.Core.InstanceMethodDataSourceAttribute("DataSource") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._1821.Tests)instance).DataSource()), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._1821.Tests)instance).DataSource() ?? global::System.Array.Empty()), }, }, classDataSources: new global::TUnit.Core.IDataSourceAttribute[] diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt index 6d79800536e..7e454e30370 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt @@ -38,6 +38,17 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).TupleRows, ClassMetadata = null!, ContainingTypeMetadata = null! + }, + new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("NullRows", typeof(object)), + Type = typeof(object), + Name = "NullRows", + IsStatic = false, + IsNullable = true, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).NullRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! } }, Parent = null @@ -107,6 +118,31 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } + case 4: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Private_Method_With_Generic_Overload(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 5: + { + switch (args.Length) + { + case 1: + { + instance.Null_Source_With_SkipIfEmpty_Is_Skipped(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])); + return default(global::System.Threading.Tasks.ValueTask); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } default: throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex)); } @@ -141,13 +177,13 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Object_Property", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Property", filePath: "", - lineNumber: 21, + lineNumber: 28, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { new global::TUnit.Core.InstanceMethodDataSourceAttribute("ObjectProperty") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).ObjectProperty), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).ObjectProperty ?? global::System.Array.Empty()), }, }, returnType: typeof(global::System.Threading.Tasks.Task), @@ -165,13 +201,13 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Object_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Method", filePath: "", - lineNumber: 28, + lineNumber: 35, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { new global::TUnit.Core.InstanceMethodDataSourceAttribute("ObjectMethod") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).ObjectMethod()), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).ObjectMethod() ?? global::System.Array.Empty()), }, }, returnType: typeof(global::System.Threading.Tasks.Task), @@ -189,13 +225,13 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Tuple_Single_Parameter", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Tuple_Single_Parameter", filePath: "", - lineNumber: 35, + lineNumber: 42, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { new global::TUnit.Core.InstanceMethodDataSourceAttribute("TupleRows") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).TupleRows), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).TupleRows ?? global::System.Array.Empty()), }, }, returnType: typeof(global::System.Threading.Tasks.Task), @@ -213,13 +249,13 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Private_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method", filePath: "", - lineNumber: 42, + lineNumber: 49, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { new global::TUnit.Core.InstanceMethodDataSourceAttribute("PrivateRows") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetMethod("PrivateRows", global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.DeclaredOnly, null, global::System.Type.EmptyTypes, null)!.Invoke(instance, null)), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetMethod("PrivateRows", global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.DeclaredOnly, null, global::System.Type.EmptyTypes, null)!.Invoke(instance, null) ?? global::System.Array.Empty()), }, }, returnType: typeof(global::System.Threading.Tasks.Task), @@ -233,6 +269,54 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodIndex: 3, createAttributes: __attributes, attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Private_Method_With_Generic_Overload", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method_With_Generic_Overload", + filePath: "", + lineNumber: 56, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("OverloadedRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => global::System.Array.Find(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetMethods(global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.DeclaredOnly), static m => m.Name == "OverloadedRows" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 0)!.Invoke(instance, null) ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Private_Method_With_Generic_Overload", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 4, + createAttributes: __attributes, + attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Null_Source_With_SkipIfEmpty_Is_Skipped", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Source_With_SkipIfEmpty_Is_Skipped", + filePath: "", + lineNumber: 63, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("NullRows") + { + SkipIfEmpty = true, + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).NullRows ?? global::System.Array.Empty()), + }, +}, + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Null_Source_With_SkipIfEmpty_Is_Skipped", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 5, + createAttributes: __attributes, + attributeGroupIndex: 0), }; } internal static partial class TUnit_TestRegistration diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt index 6cff3e87cbb..137387055ef 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt @@ -118,7 +118,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceField { new global::TUnit.Core.InstanceMethodDataSourceAttribute("Rows") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests)instance).Rows), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests)instance).Rows ?? global::System.Array.Empty()), }, }, returnType: typeof(global::System.Threading.Tasks.Task), @@ -142,7 +142,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceField { new global::TUnit.Core.InstanceMethodDataSourceAttribute("PropertyRows") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests)instance).PropertyRows), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests)instance).PropertyRows ?? global::System.Array.Empty()), }, }, returnType: typeof(global::System.Threading.Tasks.Task), @@ -166,7 +166,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceField { new global::TUnit.Core.InstanceMethodDataSourceAttribute("StaticRows") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromEnumerable(static () => global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests.StaticRows()), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromEnumerable(static () => (object?)global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests.StaticRows() ?? global::System.Array.Empty()), }, }, returnType: typeof(global::System.Threading.Tasks.Task), diff --git a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs index 3528d5b4a41..2a4f768bbc5 100644 --- a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs @@ -5,7 +5,8 @@ namespace TUnit.TestProject.Bugs._7225; /// /// [InstanceMethodDataSource] read every member by reflection in source-gen mode before #7225, so its generated /// factory must keep that behavior: an object value holding many rows supplies each row, a private method -/// is still callable, and a single tuple parameter still receives the whole tuple. +/// is still callable (even beside a generic overload of the same name), a single tuple parameter still receives the +/// whole tuple, and a null value is an empty source, so SkipIfEmpty skips the test. /// [EngineTest(ExpectedResult.Pass)] public class InstanceMethodDataSourceMemberShapeTests @@ -18,6 +19,12 @@ public class InstanceMethodDataSourceMemberShapeTests private IEnumerable PrivateRows() => [65, 66]; + private IEnumerable OverloadedRows() => [67, 68]; + + private IEnumerable OverloadedRows() => []; + + public object? NullRows => null; + [Test] [InstanceMethodDataSource(nameof(ObjectProperty))] public async Task Object_Property(int value) @@ -45,4 +52,18 @@ public async Task Private_Method(int value) { await Assert.That(value).IsBetween(65, 66); } + + [Test] + [InstanceMethodDataSource(nameof(OverloadedRows))] + public async Task Private_Method_With_Generic_Overload(int value) + { + await Assert.That(value).IsBetween(67, 68); + } + + [Test] + [InstanceMethodDataSource(nameof(NullRows), SkipIfEmpty = true)] + public void Null_Source_With_SkipIfEmpty_Is_Skipped(int value) + { + throw new InvalidOperationException($"Should have been skipped, but ran with '{value}'"); + } } From 388bbb0719a623347e9dd579ed930e257ed51a1e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:39:41 +0000 Subject: [PATCH 04/10] fix(core): read a null Task or IAsyncEnumerable data source as empty (#7225) FromTask and FromAsyncEnumerable (and their instance variants) awaited or enumerated a null value and threw. The attribute's reflection path reads a null source as one with no rows, so [InstanceMethodDataSource(..., SkipIfEmpty = true)] on such a member now skips in source-gen mode as it does in reflection mode. --- .../Generators/TestMetadataGenerator.cs | 3 +- src/TUnit.Core/Helpers/DataSourceFactories.cs | 24 +++++ .../Tests7225.MemberShapes.verified.txt | 97 +++++++++++++++++-- ...nstanceMethodDataSourceMemberShapeTests.cs | 20 +++- 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 382202c6723..1fe02eece38 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -3090,7 +3090,8 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d // [InstanceMethodDataSource] read its member by reflection before #7225, and that path, still used in // reflection mode, treats a null value as a source with no rows, so SkipIfEmpty skips the test. The factory - // helpers would turn null into one row holding null, so a null value becomes an empty source here. + // helpers would turn null into one row holding null, so a null value becomes an empty source here. A null Task or + // IAsyncEnumerable needs no guard: FromTask and FromAsyncEnumerable already read it as a source with no rows. var nullAsEmpty = decidesRowsFromRuntimeValue && kind is "Value" or "Enumerable" ? " ?? global::System.Array.Empty()" : ""; diff --git a/src/TUnit.Core/Helpers/DataSourceFactories.cs b/src/TUnit.Core/Helpers/DataSourceFactories.cs index 3b7e91594fd..f8160335fc1 100644 --- a/src/TUnit.Core/Helpers/DataSourceFactories.cs +++ b/src/TUnit.Core/Helpers/DataSourceFactories.cs @@ -66,6 +66,12 @@ public static class DataSourceFactories { var result = invoke(); + // A null task is a source with no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test. + if (result is null) + { + yield break; + } + var taskResult = await result; if (taskResult is IEnumerable enumerable && !(taskResult is string)) { @@ -104,6 +110,12 @@ public static class DataSourceFactories { var result = invoke(); + // A null source has no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test. + if (result is null) + { + yield break; + } + await foreach (var item in result) { yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); @@ -143,6 +155,12 @@ public static class DataSourceFactories { var result = invoke(GetRequiredInstance(dataGeneratorMetadata)); + // A null task is a source with no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test. + if (result is null) + { + yield break; + } + var taskResult = await result; if (taskResult is IEnumerable enumerable && !(taskResult is string)) { @@ -181,6 +199,12 @@ public static class DataSourceFactories { var result = invoke(GetRequiredInstance(dataGeneratorMetadata)); + // A null source has no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test. + if (result is null) + { + yield break; + } + await foreach (var item in result) { yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt index 7e454e30370..2a9ff6fce50 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt @@ -49,6 +49,17 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).NullRows, ClassMetadata = null!, ContainingTypeMetadata = null! + }, + new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("NullAsyncRows", typeof(global::System.Collections.Generic.IAsyncEnumerable)), + Type = typeof(global::System.Collections.Generic.IAsyncEnumerable), + Name = "NullAsyncRows", + IsStatic = false, + IsNullable = false, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).NullAsyncRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! } }, Parent = null @@ -143,6 +154,32 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } + case 6: + { + switch (args.Length) + { + case 1: + { + instance.Null_Async_Source_With_SkipIfEmpty_Is_Skipped(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])); + return default(global::System.Threading.Tasks.ValueTask); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 7: + { + switch (args.Length) + { + case 1: + { + instance.Null_Task_Source_With_SkipIfEmpty_Is_Skipped(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])); + return default(global::System.Threading.Tasks.ValueTask); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } default: throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex)); } @@ -177,7 +214,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Object_Property", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Property", filePath: "", - lineNumber: 28, + lineNumber: 32, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -201,7 +238,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Object_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Method", filePath: "", - lineNumber: 35, + lineNumber: 39, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -225,7 +262,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Tuple_Single_Parameter", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Tuple_Single_Parameter", filePath: "", - lineNumber: 42, + lineNumber: 46, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -249,7 +286,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Private_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method", filePath: "", - lineNumber: 49, + lineNumber: 53, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -273,7 +310,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Private_Method_With_Generic_Overload", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method_With_Generic_Overload", filePath: "", - lineNumber: 56, + lineNumber: 60, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -297,7 +334,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Null_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 63, + lineNumber: 67, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -317,6 +354,54 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodIndex: 5, createAttributes: __attributes, attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Null_Async_Source_With_SkipIfEmpty_Is_Skipped", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Async_Source_With_SkipIfEmpty_Is_Skipped", + filePath: "", + lineNumber: 74, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("NullAsyncRows") + { + SkipIfEmpty = true, + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceAsyncEnumerable(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).NullAsyncRows), + }, +}, + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Null_Async_Source_With_SkipIfEmpty_Is_Skipped", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 6, + createAttributes: __attributes, + attributeGroupIndex: 0), + global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Null_Task_Source_With_SkipIfEmpty_Is_Skipped", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Task_Source_With_SkipIfEmpty_Is_Skipped", + filePath: "", + lineNumber: 81, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("NullTaskRows") + { + SkipIfEmpty = true, + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceTask(dataGeneratorMetadata, static instance => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).NullTaskRows()), + }, +}, + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Null_Task_Source_With_SkipIfEmpty_Is_Skipped", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(int), "value", new global::TUnit.Core.ConcreteType(typeof(int)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 7, + createAttributes: __attributes, + attributeGroupIndex: 0), }; } internal static partial class TUnit_TestRegistration diff --git a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs index 2a4f768bbc5..d1dd714b76f 100644 --- a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs @@ -6,7 +6,7 @@ namespace TUnit.TestProject.Bugs._7225; /// [InstanceMethodDataSource] read every member by reflection in source-gen mode before #7225, so its generated /// factory must keep that behavior: an object value holding many rows supplies each row, a private method /// is still callable (even beside a generic overload of the same name), a single tuple parameter still receives the -/// whole tuple, and a null value is an empty source, so SkipIfEmpty skips the test. +/// whole tuple, and a null value (including a null Task or IAsyncEnumerable) is an empty source, so SkipIfEmpty skips the test. /// [EngineTest(ExpectedResult.Pass)] public class InstanceMethodDataSourceMemberShapeTests @@ -25,6 +25,10 @@ public class InstanceMethodDataSourceMemberShapeTests public object? NullRows => null; + public IAsyncEnumerable NullAsyncRows => null!; + + public Task> NullTaskRows() => null!; + [Test] [InstanceMethodDataSource(nameof(ObjectProperty))] public async Task Object_Property(int value) @@ -66,4 +70,18 @@ public void Null_Source_With_SkipIfEmpty_Is_Skipped(int value) { throw new InvalidOperationException($"Should have been skipped, but ran with '{value}'"); } + + [Test] + [InstanceMethodDataSource(nameof(NullAsyncRows), SkipIfEmpty = true)] + public void Null_Async_Source_With_SkipIfEmpty_Is_Skipped(int value) + { + throw new InvalidOperationException($"Should have been skipped, but ran with '{value}'"); + } + + [Test] + [InstanceMethodDataSource(nameof(NullTaskRows), SkipIfEmpty = true)] + public void Null_Task_Source_With_SkipIfEmpty_Is_Skipped(int value) + { + throw new InvalidOperationException($"Should have been skipped, but ran with '{value}'"); + } } From 02b0d66c45dee5114565585f5b8a4efbff82d049 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:46:41 +0000 Subject: [PATCH 05/10] fix(sourcegen): leave a void [InstanceMethodDataSource] method to the reflection fallback (#7225) TUnit0011 only analyzes [MethodDataSource], so a void method named by [InstanceMethodDataSource] reached the generated factory and failed with CS0030. --- .../Generators/TestMetadataGenerator.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 1fe02eece38..826ae02752a 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -3217,12 +3217,14 @@ private static bool IsAccessibleDataSourceMember(ISymbol member, Compilation com /// /// Whether a generated factory can read the data source member, directly or, for a private or protected member, /// by reflection on its declaring type. That needs a declaring type that the generated class can name without - /// type parameters, a getter for a property, a method without parameters or type parameters, and a member type it + /// type parameters, a getter for a property, a method that returns a value and has no parameters or type parameters, and a member type it /// can name when the factory casts the value. Otherwise the attribute keeps its reflection fallback. /// private static bool CanReadDataSourceMember(ISymbol member, Compilation compilation) { - if (member is IPropertySymbol { GetMethod: null } or IPropertySymbol { IsIndexer: true }) + // A void method has no value to read. TUnit0011 reports it only for [MethodDataSource], so for + // [InstanceMethodDataSource] the attribute's reflection fallback reports it rather than generated code that does not compile. + if (member is IPropertySymbol { GetMethod: null } or IPropertySymbol { IsIndexer: true } or IMethodSymbol { ReturnsVoid: true }) { return false; } From 90a9df5c298a652b2521e458a94d3e924f73c6e5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:17:51 +0000 Subject: [PATCH 06/10] fix(sourcegen): rebuild a single System.Tuple parameter from unwrapped row elements (#7225) Generated data source factories unwrap a reference Tuple row into its elements, as they do a value tuple, but the generated invoker only rebuilt value tuples, so a test taking one Tuple<...> parameter failed with 'Expected exactly 1 argument'. Reflection mode keeps the row whole. This affected [MethodDataSource] already and [InstanceMethodDataSource] once it got a generated factory. --- .../Generators/TestMetadataGenerator.cs | 38 +++ .../Tests7225.MemberShapes.verified.txt | 223 +++++++++++++++--- ...nstanceMethodDataSourceMemberShapeTests.cs | 31 ++- 3 files changed, 255 insertions(+), 37 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 826ae02752a..f102e7c7aba 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -3880,6 +3880,12 @@ private static void InsertLeadingCancellationToken(List argu /// Formats the argument list for a method with one data parameter, placing each /// CancellationToken at its declared position. /// + // System.Tuple. An eight-element Tuple nests its rest in TRest, which DataSourceHelpers flattens, so it is left alone. + private static bool IsReferenceTuple(INamedTypeSymbol type) + { + return type is { Name: "Tuple", IsGenericType: true, TypeArguments.Length: >= 1 and <= 7, ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } }; + } + private static string FormatSingleArgumentCall(string argument, bool hasCancellationToken, IParameterSymbol[] parametersFromArgs) { if (!hasCancellationToken) @@ -3949,6 +3955,38 @@ private static void GenerateConcreteTestInvokerBody(CodeWriter writer, string me writer.Unindent(); writer.AppendLine("}"); } + else if (parametersFromArgs is [{ Type: INamedTypeSymbol singleReferenceTupleParam }] + && IsReferenceTuple(singleReferenceTupleParam)) + { + // A generated data source factory unwraps a System.Tuple row into its elements, as it does a value tuple, + // so rebuild it here. Reflection mode keeps the row whole because it reads the parameter types. + var arity = singleReferenceTupleParam.TypeArguments.Length; + writer.AppendLine("// Special handling for single reference tuple parameter"); + // With one element, a lone argument is either that element or the whole tuple, so check its type. + var alreadyWrapped = arity == 1 ? $" && args[0] is not {singleReferenceTupleParam.GloballyQualified()}" : ""; + writer.AppendLine($"if (args.Length == {arity.ToString(CultureInfo.InvariantCulture)}{alreadyWrapped})"); + writer.AppendLine("{"); + writer.Indent(); + var tupleElements = singleReferenceTupleParam.TypeArguments.Select((elementType, i) => + $"global::TUnit.Core.Helpers.CastHelper.Cast<{elementType.GloballyQualified()}>(args[{i.ToString(CultureInfo.InvariantCulture)}])"); + var tupleConstruction = $"new {singleReferenceTupleParam.GloballyQualified()}({string.Join(", ", tupleElements)})"; + GenerateReturnHandling(writer, $"instance.{methodName}({FormatSingleArgumentCall(tupleConstruction, hasCancellationToken, parametersFromArgs)})", returnPattern); + writer.Unindent(); + writer.AppendLine("}"); + writer.AppendLine("else if (args.Length == 1)"); + writer.AppendLine("{"); + writer.Indent(); + var methodCallDirect = $"instance.{methodName}({FormatSingleArgumentCall($"global::TUnit.Core.Helpers.CastHelper.Cast<{singleReferenceTupleParam.GloballyQualified()}>(args[0])", hasCancellationToken, parametersFromArgs)})"; + GenerateReturnHandling(writer, methodCallDirect, returnPattern); + writer.Unindent(); + writer.AppendLine("}"); + writer.AppendLine("else"); + writer.AppendLine("{"); + writer.Indent(); + writer.AppendLine($"throw new global::System.ArgumentException($\"Expected {arity.ToString(CultureInfo.InvariantCulture)} unwrapped elements or 1 wrapped tuple, but got {{args.Length}} arguments\");"); + writer.Unindent(); + writer.AppendLine("}"); + } else if (parametersFromArgs.Length == 0) { var typedMethodCall = $"instance.{methodName}({string.Join(", ", Enumerable.Repeat("cancellationToken", cancellationTokenCount))})"; diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt index 2a9ff6fce50..091277ba2b8 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt @@ -40,6 +40,28 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe ContainingTypeMetadata = null! }, new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("ReferenceTupleRows", typeof(global::System.Collections.Generic.IEnumerable>)), + Type = typeof(global::System.Collections.Generic.IEnumerable>), + Name = "ReferenceTupleRows", + IsStatic = false, + IsNullable = false, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).ReferenceTupleRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! + }, + new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("OneElementReferenceTupleRows", typeof(global::System.Collections.Generic.IEnumerable>)), + Type = typeof(global::System.Collections.Generic.IEnumerable>), + Name = "OneElementReferenceTupleRows", + IsStatic = false, + IsNullable = false, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).OneElementReferenceTupleRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! + }, + new global::TUnit.Core.PropertyMetadata { ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("NullRows", typeof(object)), Type = typeof(object), @@ -118,6 +140,54 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe } } case 3: + { + // Special handling for single reference tuple parameter + if (args.Length == 2) + { + return new global::System.Threading.Tasks.ValueTask(instance.Reference_Tuple_Single_Parameter(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]), global::TUnit.Core.Helpers.CastHelper.Cast(args[1])))); + } + else if (args.Length == 1) + { + return new global::System.Threading.Tasks.ValueTask(instance.Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + else + { + throw new global::System.ArgumentException($"Expected 2 unwrapped elements or 1 wrapped tuple, but got {args.Length} arguments"); + } + } + case 4: + { + // Special handling for single reference tuple parameter + if (args.Length == 2) + { + return new global::System.Threading.Tasks.ValueTask(instance.Static_Reference_Tuple_Single_Parameter(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]), global::TUnit.Core.Helpers.CastHelper.Cast(args[1])))); + } + else if (args.Length == 1) + { + return new global::System.Threading.Tasks.ValueTask(instance.Static_Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + else + { + throw new global::System.ArgumentException($"Expected 2 unwrapped elements or 1 wrapped tuple, but got {args.Length} arguments"); + } + } + case 5: + { + // Special handling for single reference tuple parameter + if (args.Length == 1 && args[0] is not global::System.Tuple) + { + return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Single_Parameter(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])))); + } + else if (args.Length == 1) + { + return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + else + { + throw new global::System.ArgumentException($"Expected 1 unwrapped elements or 1 wrapped tuple, but got {args.Length} arguments"); + } + } + case 6: { switch (args.Length) { @@ -129,7 +199,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 4: + case 7: { switch (args.Length) { @@ -141,7 +211,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 5: + case 8: { switch (args.Length) { @@ -154,7 +224,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 6: + case 9: { switch (args.Length) { @@ -167,7 +237,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 7: + case 10: { switch (args.Length) { @@ -208,13 +278,21 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe private static readonly global::System.Func __createInstance = __CreateInstance; private static readonly global::System.Func __invoke = __Invoke; private static readonly global::System.Func __attributes = __Attributes; - public static readonly global::TUnit.Core.TestEntry[] Entries = new global::TUnit.Core.TestEntry[] + public static readonly global::TUnit.Core.TestEntry[] Entries = __CreateEntries(); + private static global::TUnit.Core.TestEntry[] __CreateEntries() + { + var entries = new global::TUnit.Core.TestEntry[11]; + __FillEntries0(entries); + __FillEntries1(entries); + return entries; + } + private static void __FillEntries0(global::TUnit.Core.TestEntry[] entries) { - global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[0] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Object_Property", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Property", filePath: "", - lineNumber: 32, + lineNumber: 38, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -233,12 +311,12 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe invokeBody: __invoke, methodIndex: 0, createAttributes: __attributes, - attributeGroupIndex: 0), - global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + attributeGroupIndex: 0); + entries[1] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Object_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Method", filePath: "", - lineNumber: 39, + lineNumber: 45, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -257,12 +335,12 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe invokeBody: __invoke, methodIndex: 1, createAttributes: __attributes, - attributeGroupIndex: 0), - global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + attributeGroupIndex: 0); + entries[2] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Tuple_Single_Parameter", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Tuple_Single_Parameter", filePath: "", - lineNumber: 46, + lineNumber: 52, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -281,12 +359,84 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe invokeBody: __invoke, methodIndex: 2, createAttributes: __attributes, - attributeGroupIndex: 0), - global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + attributeGroupIndex: 0); + entries[3] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Reference_Tuple_Single_Parameter", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Reference_Tuple_Single_Parameter", + filePath: "", + lineNumber: 59, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("ReferenceTupleRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).ReferenceTupleRows ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Reference_Tuple_Single_Parameter", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Tuple), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Tuple)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 3, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[4] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Static_Reference_Tuple_Single_Parameter", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Static_Reference_Tuple_Single_Parameter", + filePath: "", + lineNumber: 66, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.MethodDataSourceAttribute("StaticReferenceTupleRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromEnumerable(static () => global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.StaticReferenceTupleRows()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Static_Reference_Tuple_Single_Parameter", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Tuple), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Tuple)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 4, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[5] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "One_Element_Reference_Tuple_Single_Parameter", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.One_Element_Reference_Tuple_Single_Parameter", + filePath: "", + lineNumber: 73, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("OneElementReferenceTupleRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).OneElementReferenceTupleRows ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "One_Element_Reference_Tuple_Single_Parameter", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Tuple), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Tuple)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 5, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[6] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Private_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method", filePath: "", - lineNumber: 53, + lineNumber: 80, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -303,14 +453,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 3, + methodIndex: 6, createAttributes: __attributes, - attributeGroupIndex: 0), - global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + attributeGroupIndex: 0); + entries[7] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Private_Method_With_Generic_Overload", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method_With_Generic_Overload", filePath: "", - lineNumber: 60, + lineNumber: 87, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -327,14 +477,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 4, + methodIndex: 7, createAttributes: __attributes, - attributeGroupIndex: 0), - global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + attributeGroupIndex: 0); + entries[8] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 67, + lineNumber: 94, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -351,14 +501,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 5, + methodIndex: 8, createAttributes: __attributes, - attributeGroupIndex: 0), - global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + attributeGroupIndex: 0); + entries[9] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Async_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Async_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 74, + lineNumber: 101, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -375,14 +525,17 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 6, + methodIndex: 9, createAttributes: __attributes, - attributeGroupIndex: 0), - global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + attributeGroupIndex: 0); + } + private static void __FillEntries1(global::TUnit.Core.TestEntry[] entries) + { + entries[10] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Task_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Task_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 81, + lineNumber: 108, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -399,10 +552,10 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 7, + methodIndex: 10, createAttributes: __attributes, - attributeGroupIndex: 0), - }; + attributeGroupIndex: 0); + } } internal static partial class TUnit_TestRegistration { diff --git a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs index d1dd714b76f..433ca25d81f 100644 --- a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs @@ -5,8 +5,8 @@ namespace TUnit.TestProject.Bugs._7225; /// /// [InstanceMethodDataSource] read every member by reflection in source-gen mode before #7225, so its generated /// factory must keep that behavior: an object value holding many rows supplies each row, a private method -/// is still callable (even beside a generic overload of the same name), a single tuple parameter still receives the -/// whole tuple, and a null value (including a null Task or IAsyncEnumerable) is an empty source, so SkipIfEmpty skips the test. +/// is still callable (even beside a generic overload of the same name), a single tuple parameter (value or reference tuple) +/// still receives the whole tuple, and a null value (including a null Task or IAsyncEnumerable) is an empty source, so SkipIfEmpty skips the test. /// [EngineTest(ExpectedResult.Pass)] public class InstanceMethodDataSourceMemberShapeTests @@ -17,6 +17,12 @@ public class InstanceMethodDataSourceMemberShapeTests public IEnumerable<(int, int)> TupleRows => [(1, 2), (3, 4)]; + public IEnumerable> ReferenceTupleRows => [Tuple.Create(1, 2), Tuple.Create(3, 4)]; + + public static IEnumerable> StaticReferenceTupleRows() => [Tuple.Create(5, 6)]; + + public IEnumerable> OneElementReferenceTupleRows => [Tuple.Create(7)]; + private IEnumerable PrivateRows() => [65, 66]; private IEnumerable OverloadedRows() => [67, 68]; @@ -50,6 +56,27 @@ public async Task Tuple_Single_Parameter((int, int) value) await Assert.That(value.Item2 - value.Item1).IsEqualTo(1); } + [Test] + [InstanceMethodDataSource(nameof(ReferenceTupleRows))] + public async Task Reference_Tuple_Single_Parameter(Tuple value) + { + await Assert.That(value.Item2 - value.Item1).IsEqualTo(1); + } + + [Test] + [MethodDataSource(nameof(StaticReferenceTupleRows))] + public async Task Static_Reference_Tuple_Single_Parameter(Tuple value) + { + await Assert.That(value.Item2 - value.Item1).IsEqualTo(1); + } + + [Test] + [InstanceMethodDataSource(nameof(OneElementReferenceTupleRows))] + public async Task One_Element_Reference_Tuple_Single_Parameter(Tuple value) + { + await Assert.That(value.Item1).IsEqualTo(7); + } + [Test] [InstanceMethodDataSource(nameof(PrivateRows))] public async Task Private_Method(int value) From e57d6aacbe44570fd34680e39327c748f1880f76 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:54:02 +0000 Subject: [PATCH 07/10] fix(sourcegen): fall through to the argument-count switch for a single System.Tuple parameter (#7225) Only rebuild the tuple when the row was unwrapped into its elements. A null argument, a whole tuple, and an omitted optional parameter keep the ordinary single-argument handling, as before the reference-tuple branch existed. Also move IsReferenceTuple above FormatSingleArgumentCall's doc comment and parenthesize the null-as-empty pattern. --- .../Generators/TestMetadataGenerator.cs | 76 ++++---- .../Tests7225.MemberShapes.verified.txt | 172 ++++++++++++++---- ...nstanceMethodDataSourceMemberShapeTests.cs | 16 +- 3 files changed, 189 insertions(+), 75 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index f102e7c7aba..19174046995 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -3092,7 +3092,7 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d // reflection mode, treats a null value as a source with no rows, so SkipIfEmpty skips the test. The factory // helpers would turn null into one row holding null, so a null value becomes an empty source here. A null Task or // IAsyncEnumerable needs no guard: FromTask and FromAsyncEnumerable already read it as a source with no rows. - var nullAsEmpty = decidesRowsFromRuntimeValue && kind is "Value" or "Enumerable" + var nullAsEmpty = decidesRowsFromRuntimeValue && (kind is "Value" or "Enumerable") ? " ?? global::System.Array.Empty()" : ""; @@ -3876,16 +3876,16 @@ private static void InsertLeadingCancellationToken(List argu } } - /// - /// Formats the argument list for a method with one data parameter, placing each - /// CancellationToken at its declared position. - /// // System.Tuple. An eight-element Tuple nests its rest in TRest, which DataSourceHelpers flattens, so it is left alone. private static bool IsReferenceTuple(INamedTypeSymbol type) { return type is { Name: "Tuple", IsGenericType: true, TypeArguments.Length: >= 1 and <= 7, ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } }; } + /// + /// Formats the argument list for a method with one data parameter, placing each + /// CancellationToken at its declared position. + /// private static string FormatSingleArgumentCall(string argument, bool hasCancellationToken, IParameterSymbol[] parametersFromArgs) { if (!hasCancellationToken) @@ -3955,38 +3955,6 @@ private static void GenerateConcreteTestInvokerBody(CodeWriter writer, string me writer.Unindent(); writer.AppendLine("}"); } - else if (parametersFromArgs is [{ Type: INamedTypeSymbol singleReferenceTupleParam }] - && IsReferenceTuple(singleReferenceTupleParam)) - { - // A generated data source factory unwraps a System.Tuple row into its elements, as it does a value tuple, - // so rebuild it here. Reflection mode keeps the row whole because it reads the parameter types. - var arity = singleReferenceTupleParam.TypeArguments.Length; - writer.AppendLine("// Special handling for single reference tuple parameter"); - // With one element, a lone argument is either that element or the whole tuple, so check its type. - var alreadyWrapped = arity == 1 ? $" && args[0] is not {singleReferenceTupleParam.GloballyQualified()}" : ""; - writer.AppendLine($"if (args.Length == {arity.ToString(CultureInfo.InvariantCulture)}{alreadyWrapped})"); - writer.AppendLine("{"); - writer.Indent(); - var tupleElements = singleReferenceTupleParam.TypeArguments.Select((elementType, i) => - $"global::TUnit.Core.Helpers.CastHelper.Cast<{elementType.GloballyQualified()}>(args[{i.ToString(CultureInfo.InvariantCulture)}])"); - var tupleConstruction = $"new {singleReferenceTupleParam.GloballyQualified()}({string.Join(", ", tupleElements)})"; - GenerateReturnHandling(writer, $"instance.{methodName}({FormatSingleArgumentCall(tupleConstruction, hasCancellationToken, parametersFromArgs)})", returnPattern); - writer.Unindent(); - writer.AppendLine("}"); - writer.AppendLine("else if (args.Length == 1)"); - writer.AppendLine("{"); - writer.Indent(); - var methodCallDirect = $"instance.{methodName}({FormatSingleArgumentCall($"global::TUnit.Core.Helpers.CastHelper.Cast<{singleReferenceTupleParam.GloballyQualified()}>(args[0])", hasCancellationToken, parametersFromArgs)})"; - GenerateReturnHandling(writer, methodCallDirect, returnPattern); - writer.Unindent(); - writer.AppendLine("}"); - writer.AppendLine("else"); - writer.AppendLine("{"); - writer.Indent(); - writer.AppendLine($"throw new global::System.ArgumentException($\"Expected {arity.ToString(CultureInfo.InvariantCulture)} unwrapped elements or 1 wrapped tuple, but got {{args.Length}} arguments\");"); - writer.Unindent(); - writer.AppendLine("}"); - } else if (parametersFromArgs.Length == 0) { var typedMethodCall = $"instance.{methodName}({string.Join(", ", Enumerable.Repeat("cancellationToken", cancellationTokenCount))})"; @@ -3994,6 +3962,34 @@ private static void GenerateConcreteTestInvokerBody(CodeWriter writer, string me } else { + // A generated data source factory unwraps a System.Tuple row into its elements, as it does a value tuple, + // so rebuild it here. Reflection mode keeps the row whole because it reads the parameter types. + // Any other argument shape (the whole tuple, null, or an omitted optional) falls through to the + // argument-count switch below, as it does for every other single parameter. + var singleReferenceTupleParam = parametersFromArgs is [{ Type: INamedTypeSymbol referenceTupleType }] + && IsReferenceTuple(referenceTupleType) + ? referenceTupleType + : null; + if (singleReferenceTupleParam is not null) + { + var arity = singleReferenceTupleParam.TypeArguments.Length; + writer.AppendLine("// Special handling for single reference tuple parameter"); + // With one element, a lone argument is either that element or the whole tuple (or null), so check it. + var unwrappedCheck = arity == 1 ? $" && args[0] is not null && args[0] is not {singleReferenceTupleParam.GloballyQualified()}" : ""; + writer.AppendLine($"if (args.Length == {arity.ToString(CultureInfo.InvariantCulture)}{unwrappedCheck})"); + writer.AppendLine("{"); + writer.Indent(); + var tupleElements = singleReferenceTupleParam.TypeArguments.Select((elementType, i) => + $"global::TUnit.Core.Helpers.CastHelper.Cast<{elementType.GloballyQualified()}>(args[{i.ToString(CultureInfo.InvariantCulture)}])"); + var tupleConstruction = $"new {singleReferenceTupleParam.GloballyQualified()}({string.Join(", ", tupleElements)})"; + GenerateReturnHandling(writer, $"instance.{methodName}({FormatSingleArgumentCall(tupleConstruction, hasCancellationToken, parametersFromArgs)})", returnPattern); + writer.Unindent(); + writer.AppendLine("}"); + writer.AppendLine("else"); + writer.AppendLine("{"); + writer.Indent(); + } + // A trailing array parameter (params OR a plain `T[]`) binds like a params array: // it accepts any number of remaining arguments collected into the array (issue #6120). var lastIndex = parametersFromArgs.Length - 1; @@ -4094,6 +4090,12 @@ private static void GenerateConcreteTestInvokerBody(CodeWriter writer, string me writer.Unindent(); writer.AppendLine("}"); + + if (singleReferenceTupleParam is not null) + { + writer.Unindent(); + writer.AppendLine("}"); + } } if (wrapInTryCatch) diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt index 091277ba2b8..5895b9e9659 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt @@ -146,13 +146,17 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe { return new global::System.Threading.Tasks.ValueTask(instance.Reference_Tuple_Single_Parameter(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]), global::TUnit.Core.Helpers.CastHelper.Cast(args[1])))); } - else if (args.Length == 1) - { - return new global::System.Threading.Tasks.ValueTask(instance.Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); - } else { - throw new global::System.ArgumentException($"Expected 2 unwrapped elements or 1 wrapped tuple, but got {args.Length} arguments"); + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } } } case 4: @@ -162,32 +166,84 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe { return new global::System.Threading.Tasks.ValueTask(instance.Static_Reference_Tuple_Single_Parameter(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]), global::TUnit.Core.Helpers.CastHelper.Cast(args[1])))); } - else if (args.Length == 1) - { - return new global::System.Threading.Tasks.ValueTask(instance.Static_Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); - } else { - throw new global::System.ArgumentException($"Expected 2 unwrapped elements or 1 wrapped tuple, but got {args.Length} arguments"); + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Static_Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } } } case 5: { // Special handling for single reference tuple parameter - if (args.Length == 1 && args[0] is not global::System.Tuple) + if (args.Length == 1 && args[0] is not null && args[0] is not global::System.Tuple) { return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Single_Parameter(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])))); } - else if (args.Length == 1) + else { - return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Single_Parameter(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + } + case 6: + { + // Special handling for single reference tuple parameter + if (args.Length == 1 && args[0] is not null && args[0] is not global::System.Tuple) + { + return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Null_Argument_Stays_Null(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])))); } else { - throw new global::System.ArgumentException($"Expected 1 unwrapped elements or 1 wrapped tuple, but got {args.Length} arguments"); + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Null_Argument_Stays_Null(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } } } - case 6: + case 7: + { + // Special handling for single reference tuple parameter + if (args.Length == 2) + { + return new global::System.Threading.Tasks.ValueTask(instance.Optional_Reference_Tuple_Gets_Its_Default(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]), global::TUnit.Core.Helpers.CastHelper.Cast(args[1])))); + } + else + { + switch (args.Length) + { + case 0: + { + return new global::System.Threading.Tasks.ValueTask(instance.Optional_Reference_Tuple_Gets_Its_Default(default(global::System.Tuple)!)); + } + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Optional_Reference_Tuple_Gets_Its_Default(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected between 0 and 1 arguments, but got {args.Length}"); + } + } + } + case 8: { switch (args.Length) { @@ -199,7 +255,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 7: + case 9: { switch (args.Length) { @@ -211,7 +267,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 8: + case 10: { switch (args.Length) { @@ -224,7 +280,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 9: + case 11: { switch (args.Length) { @@ -237,7 +293,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 10: + case 12: { switch (args.Length) { @@ -281,7 +337,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe public static readonly global::TUnit.Core.TestEntry[] Entries = __CreateEntries(); private static global::TUnit.Core.TestEntry[] __CreateEntries() { - var entries = new global::TUnit.Core.TestEntry[11]; + var entries = new global::TUnit.Core.TestEntry[13]; __FillEntries0(entries); __FillEntries1(entries); return entries; @@ -433,10 +489,52 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe createAttributes: __attributes, attributeGroupIndex: 0); entries[6] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "One_Element_Reference_Tuple_Null_Argument_Stays_Null", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.One_Element_Reference_Tuple_Null_Argument_Stays_Null", + filePath: "", + lineNumber: 80, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.ArgumentsAttribute(null), +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "One_Element_Reference_Tuple_Null_Argument_Stays_Null", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Tuple), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Tuple)), true) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 6, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[7] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Optional_Reference_Tuple_Gets_Its_Default", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Optional_Reference_Tuple_Gets_Its_Default", + filePath: "", + lineNumber: 87, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.ArgumentsAttribute(), +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Optional_Reference_Tuple_Gets_Its_Default", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Tuple), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Tuple)), true) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 7, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[8] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Private_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method", filePath: "", - lineNumber: 80, + lineNumber: 94, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -453,14 +551,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 6, + methodIndex: 8, createAttributes: __attributes, attributeGroupIndex: 0); - entries[7] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[9] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Private_Method_With_Generic_Overload", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method_With_Generic_Overload", filePath: "", - lineNumber: 87, + lineNumber: 101, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -477,14 +575,17 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 7, + methodIndex: 9, createAttributes: __attributes, attributeGroupIndex: 0); - entries[8] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + } + private static void __FillEntries1(global::TUnit.Core.TestEntry[] entries) + { + entries[10] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 94, + lineNumber: 108, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -501,14 +602,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 8, + methodIndex: 10, createAttributes: __attributes, attributeGroupIndex: 0); - entries[9] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[11] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Async_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Async_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 101, + lineNumber: 115, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -525,17 +626,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 9, + methodIndex: 11, createAttributes: __attributes, attributeGroupIndex: 0); - } - private static void __FillEntries1(global::TUnit.Core.TestEntry[] entries) - { - entries[10] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[12] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Task_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Task_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 108, + lineNumber: 122, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -552,7 +650,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 10, + methodIndex: 12, createAttributes: __attributes, attributeGroupIndex: 0); } diff --git a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs index 433ca25d81f..9372f91656c 100644 --- a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs @@ -6,7 +6,7 @@ namespace TUnit.TestProject.Bugs._7225; /// [InstanceMethodDataSource] read every member by reflection in source-gen mode before #7225, so its generated /// factory must keep that behavior: an object value holding many rows supplies each row, a private method /// is still callable (even beside a generic overload of the same name), a single tuple parameter (value or reference tuple) -/// still receives the whole tuple, and a null value (including a null Task or IAsyncEnumerable) is an empty source, so SkipIfEmpty skips the test. +/// still receives the whole tuple (a null or omitted optional tuple argument stays null), and a null value (including a null Task or IAsyncEnumerable) is an empty source, so SkipIfEmpty skips the test. /// [EngineTest(ExpectedResult.Pass)] public class InstanceMethodDataSourceMemberShapeTests @@ -77,6 +77,20 @@ public async Task One_Element_Reference_Tuple_Single_Parameter(Tuple value) await Assert.That(value.Item1).IsEqualTo(7); } + [Test] + [Arguments(null)] + public async Task One_Element_Reference_Tuple_Null_Argument_Stays_Null(Tuple? value) + { + await Assert.That(value).IsNull(); + } + + [Test] + [Arguments] + public async Task Optional_Reference_Tuple_Gets_Its_Default(Tuple? value = null) + { + await Assert.That(value).IsNull(); + } + [Test] [InstanceMethodDataSource(nameof(PrivateRows))] public async Task Private_Method(int value) From c4c00e49ce1722b3bcd59a6afcbcadf267a1c081 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:24:25 +0000 Subject: [PATCH 08/10] fix(core): keep a tuple row whole for a single tuple parameter of [InstanceMethodDataSource] (#7225) A one-element System.Tuple row holding null, such as Tuple.Create(null), was split into [null] by the generated factory, so the test got a null tuple. Reflection mode keeps the row whole when the test takes a single tuple. The FromInstance* helpers and new From*(DataGeneratorMetadata, ...) overloads now read the parameter types from the metadata and do the same; [InstanceMethodDataSource] passes the metadata to its static helpers. --- .../Generators/TestMetadataGenerator.cs | 5 +- src/TUnit.Core/Helpers/DataSourceFactories.cs | 200 +++++++++++------- .../Bugs/7225/Tests7225.cs | 2 +- .../Tests7225.MemberShapes.verified.txt | 171 +++++++++++---- .../Tests7225.Test.verified.txt | 2 +- ...Has_No_API_Changes.DotNet10_0.verified.txt | 24 +-- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 24 +-- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 24 +-- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 24 +-- ...nstanceMethodDataSourceMemberShapeTests.cs | 20 ++ 10 files changed, 337 insertions(+), 159 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 19174046995..76b81abc315 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -3071,7 +3071,10 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d ? $"new {fullyQualifiedType}()" : $"(({receiverType})new {fullyQualifiedType}())"; var lambdaHeader = usesTestClassInstance ? "static instance =>" : "static () =>"; - var metadataArgument = usesTestClassInstance ? "dataGeneratorMetadata, " : ""; + // The instance helpers take the metadata anyway. [InstanceMethodDataSource] read its member by reflection before + // #7225, and that path keeps a tuple row whole when the test takes a single tuple, so its static helpers get the + // metadata too: without the parameter types, a one-element tuple holding null would reach the test as null. + var metadataArgument = usesTestClassInstance || decidesRowsFromRuntimeValue ? "dataGeneratorMetadata, " : ""; // A declared type such as object does not say whether the value is one row or many. Reflection mode, and the // attribute's reflection fallback that read every field and every private or protected property before the diff --git a/src/TUnit.Core/Helpers/DataSourceFactories.cs b/src/TUnit.Core/Helpers/DataSourceFactories.cs index f8160335fc1..ccbca9f06c6 100644 --- a/src/TUnit.Core/Helpers/DataSourceFactories.cs +++ b/src/TUnit.Core/Helpers/DataSourceFactories.cs @@ -33,36 +33,81 @@ namespace TUnit.Core.Helpers; #endif public static class DataSourceFactories { -#pragma warning disable CS1998 // sync-only async iterators: deferred invocation, no await by design /// Data member declared as a single (non-enumerable, non-task) value. - public static async IAsyncEnumerable>> FromValue(Func invoke) + public static IAsyncEnumerable>> FromValue(Func invoke) + => FromValueCore(invoke, keepTupleRowsWhole: false); + + /// + /// that keeps a tuple row whole when the test takes a single tuple, + /// as the attribute's reflection path does. + /// + public static IAsyncEnumerable>> FromValue(DataGeneratorMetadata dataGeneratorMetadata, Func invoke) + => FromValueCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata)); + + /// Data member declared as a synchronous enumerable. + public static IAsyncEnumerable>> FromEnumerable(Func invoke) + => FromEnumerableCore(invoke, keepTupleRowsWhole: false); + + /// + /// that keeps a tuple row whole when the test takes a single tuple, + /// as the attribute's reflection path does. + /// + public static IAsyncEnumerable>> FromEnumerable(DataGeneratorMetadata dataGeneratorMetadata, Func invoke) + => FromEnumerableCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata)); + + /// Data member declared as Task<T>. + public static IAsyncEnumerable>> FromTask(Func> invoke) + => FromTaskCore(invoke, keepTupleRowsWhole: false); + + /// + /// that keeps a tuple row whole when the test takes a single tuple, + /// as the attribute's reflection path does. + /// + public static IAsyncEnumerable>> FromTask(DataGeneratorMetadata dataGeneratorMetadata, Func> invoke) + => FromTaskCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata)); + + /// Data member declared as ValueTask<T>. + public static IAsyncEnumerable>> FromValueTask(Func> invoke) + => FromValueTaskCore(invoke, keepTupleRowsWhole: false); + + /// + /// that keeps a tuple row whole when the test takes a single tuple, + /// as the attribute's reflection path does. + /// + public static IAsyncEnumerable>> FromValueTask(DataGeneratorMetadata dataGeneratorMetadata, Func> invoke) + => FromValueTaskCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata)); + + /// Data member declared as IAsyncEnumerable<T>. + public static IAsyncEnumerable>> FromAsyncEnumerable(Func> invoke) + => FromAsyncEnumerableCore(invoke, keepTupleRowsWhole: false); + + /// + /// that keeps a tuple row whole when the test takes a + /// single tuple, as the attribute's reflection path does. + /// + public static IAsyncEnumerable>> FromAsyncEnumerable(DataGeneratorMetadata dataGeneratorMetadata, Func> invoke) + => FromAsyncEnumerableCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata)); + +#pragma warning disable CS1998 // sync-only async iterators: deferred invocation, no await by design + private static async IAsyncEnumerable>> FromValueCore(Func invoke, bool keepTupleRowsWhole) { var result = invoke(); - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(result)); + yield return () => Task.FromResult(ToRow(result, keepTupleRowsWhole)); } - /// Data member declared as a synchronous enumerable. - public static async IAsyncEnumerable>> FromEnumerable(Func invoke) + private static async IAsyncEnumerable>> FromEnumerableCore(Func invoke, bool keepTupleRowsWhole) { var result = invoke(); - if (result is IEnumerable enumerable && !(result is string)) - { - foreach (var item in enumerable) - { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); - } - } - else + foreach (var row in RowsFromValue(result, keepTupleRowsWhole)) { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(result)); + yield return row; } } #pragma warning restore CS1998 - /// Data member declared as Task<T>. - public static async IAsyncEnumerable>> FromTask(Func> invoke) + private static async IAsyncEnumerable>> FromTaskCore(Func> invoke, bool keepTupleRowsWhole) { var result = invoke(); @@ -73,40 +118,22 @@ public static class DataSourceFactories } var taskResult = await result; - if (taskResult is IEnumerable enumerable && !(taskResult is string)) - { - foreach (var item in enumerable) - { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); - } - } - else + foreach (var row in RowsFromValue(taskResult, keepTupleRowsWhole)) { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(taskResult)); + yield return row; } } - /// Data member declared as ValueTask<T>. - public static async IAsyncEnumerable>> FromValueTask(Func> invoke) + private static async IAsyncEnumerable>> FromValueTaskCore(Func> invoke, bool keepTupleRowsWhole) { - var result = invoke(); - - var taskResult = await result; - if (taskResult is IEnumerable enumerable && !(taskResult is string)) - { - foreach (var item in enumerable) - { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); - } - } - else + var taskResult = await invoke(); + foreach (var row in RowsFromValue(taskResult, keepTupleRowsWhole)) { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(taskResult)); + yield return row; } } - /// Data member declared as IAsyncEnumerable<T>. - public static async IAsyncEnumerable>> FromAsyncEnumerable(Func> invoke) + private static async IAsyncEnumerable>> FromAsyncEnumerableCore(Func> invoke, bool keepTupleRowsWhole) { var result = invoke(); @@ -118,7 +145,7 @@ public static class DataSourceFactories await foreach (var item in result) { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); + yield return () => Task.FromResult(ToRow(item, keepTupleRowsWhole)); } } @@ -127,25 +154,20 @@ public static class DataSourceFactories public static async IAsyncEnumerable>> FromInstanceValue(DataGeneratorMetadata dataGeneratorMetadata, Func invoke) { var result = invoke(GetRequiredInstance(dataGeneratorMetadata)); + var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata); - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(result)); + yield return () => Task.FromResult(ToRow(result, keepTupleRowsWhole)); } /// Instance data member declared as a synchronous enumerable. public static async IAsyncEnumerable>> FromInstanceEnumerable(DataGeneratorMetadata dataGeneratorMetadata, Func invoke) { var result = invoke(GetRequiredInstance(dataGeneratorMetadata)); + var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata); - if (result is IEnumerable enumerable && !(result is string)) + foreach (var row in RowsFromValue(result, keepTupleRowsWhole)) { - foreach (var item in enumerable) - { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); - } - } - else - { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(result)); + yield return row; } } #pragma warning restore CS1998 @@ -154,6 +176,7 @@ public static class DataSourceFactories public static async IAsyncEnumerable>> FromInstanceTask(DataGeneratorMetadata dataGeneratorMetadata, Func> invoke) { var result = invoke(GetRequiredInstance(dataGeneratorMetadata)); + var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata); // A null task is a source with no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test. if (result is null) @@ -162,16 +185,9 @@ public static class DataSourceFactories } var taskResult = await result; - if (taskResult is IEnumerable enumerable && !(taskResult is string)) + foreach (var row in RowsFromValue(taskResult, keepTupleRowsWhole)) { - foreach (var item in enumerable) - { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); - } - } - else - { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(taskResult)); + yield return row; } } @@ -179,18 +195,12 @@ public static class DataSourceFactories public static async IAsyncEnumerable>> FromInstanceValueTask(DataGeneratorMetadata dataGeneratorMetadata, Func> invoke) { var result = invoke(GetRequiredInstance(dataGeneratorMetadata)); + var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata); var taskResult = await result; - if (taskResult is IEnumerable enumerable && !(taskResult is string)) - { - foreach (var item in enumerable) - { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); - } - } - else + foreach (var row in RowsFromValue(taskResult, keepTupleRowsWhole)) { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(taskResult)); + yield return row; } } @@ -198,6 +208,7 @@ public static class DataSourceFactories public static async IAsyncEnumerable>> FromInstanceAsyncEnumerable(DataGeneratorMetadata dataGeneratorMetadata, Func> invoke) { var result = invoke(GetRequiredInstance(dataGeneratorMetadata)); + var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata); // A null source has no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test. if (result is null) @@ -207,7 +218,7 @@ public static class DataSourceFactories await foreach (var item in result) { - yield return () => Task.FromResult(DataSourceHelpers.ToObjectArray(item)); + yield return () => Task.FromResult(ToRow(item, keepTupleRowsWhole)); } } @@ -215,7 +226,7 @@ public static class DataSourceFactories /// A data source field, or a private or protected property that the generated code reads by reflection, whose /// declared type does not say whether it holds one row or many, such as object. The value's runtime type decides, /// as it does when has no factory and reads the member itself. - /// A null value is one row holding null, as gives. + /// A null value is one row holding null, as gives. /// public static async IAsyncEnumerable>> FromRuntimeValue(DataGeneratorMetadata dataGeneratorMetadata, Func invoke) { @@ -250,6 +261,51 @@ public static class DataSourceFactories // nothing instead would become an empty row, so the test would fail for a missing argument. private static readonly Func> NullValueRow = static () => Task.FromResult([null]); + // Splits a data member's value into rows the way FromEnumerable always has: an enumerable other than a string gives + // one row per item, anything else is one row. + private static IEnumerable>> RowsFromValue(object? value, bool keepTupleRowsWhole) + { + if (value is IEnumerable enumerable && !(value is string)) + { + foreach (var item in enumerable) + { + yield return () => Task.FromResult(ToRow(item, keepTupleRowsWhole)); + } + } + else + { + yield return () => Task.FromResult(ToRow(value, keepTupleRowsWhole)); + } + } + + // When the test takes a single tuple, the attribute's reflection path (ToObjectArrayWithTypes) passes a tuple row + // as that one argument instead of splitting it into elements. Splitting would lose the difference between a null + // row and a one-element tuple holding null, such as Tuple.Create(null): both become [null]. + private static bool KeepsTupleRowsWhole(DataGeneratorMetadata dataGeneratorMetadata) + { + return dataGeneratorMetadata.MembersToGenerate is [var member] + && TupleHelper.IsTupleType(member switch + { + ParameterMetadata parameter => parameter.Type, + PropertyMetadata property => property.Type, + _ => null + }); + } + + private static object?[] ToRow(object? item, bool keepTupleRowsWhole) + { + if (keepTupleRowsWhole) + { + item = DataSourceHelpers.InvokeIfFunc(item); + if (DataSourceHelpers.IsTuple(item)) + { + return [item]; + } + } + + return DataSourceHelpers.ToObjectArray(item); + } + private static object GetRequiredInstance(DataGeneratorMetadata dataGeneratorMetadata) { return dataGeneratorMetadata.TestClassInstance diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs index 14917c36e3b..590855ee7b8 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs @@ -18,7 +18,7 @@ public Task Test() => RunTest(Path.Combine(Git.TestsDirectory.FullName, const string testClass = "global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests"; await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).Rows ?? global::System.Array.Empty())"); await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).PropertyRows ?? global::System.Array.Empty())"); - await Assert.That(source).Contains($"FromEnumerable(static () => (object?){testClass}.StaticRows() ?? global::System.Array.Empty())"); + await Assert.That(source).Contains($"FromEnumerable(dataGeneratorMetadata, static () => (object?){testClass}.StaticRows() ?? global::System.Array.Empty())"); await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"Rows\"),"); }); diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt index 5895b9e9659..135b5309ca3 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt @@ -62,6 +62,17 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe ContainingTypeMetadata = null! }, new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("OneElementReferenceTupleOfNullRows", typeof(global::System.Collections.Generic.IEnumerable>)), + Type = typeof(global::System.Collections.Generic.IEnumerable>), + Name = "OneElementReferenceTupleOfNullRows", + IsStatic = false, + IsNullable = false, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).OneElementReferenceTupleOfNullRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! + }, + new global::TUnit.Core.PropertyMetadata { ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("NullRows", typeof(object)), Type = typeof(object), @@ -200,6 +211,46 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe } } case 6: + { + // Special handling for single reference tuple parameter + if (args.Length == 1 && args[0] is not null && args[0] is not global::System.Tuple) + { + return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])))); + } + else + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + } + case 7: + { + // Special handling for single reference tuple parameter + if (args.Length == 1 && args[0] is not null && args[0] is not global::System.Tuple) + { + return new global::System.Threading.Tasks.ValueTask(instance.Static_One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple(new global::System.Tuple(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])))); + } + else + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Static_One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple(global::TUnit.Core.Helpers.CastHelper.Cast>(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + } + case 8: { // Special handling for single reference tuple parameter if (args.Length == 1 && args[0] is not null && args[0] is not global::System.Tuple) @@ -219,7 +270,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe } } } - case 7: + case 9: { // Special handling for single reference tuple parameter if (args.Length == 2) @@ -243,7 +294,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe } } } - case 8: + case 10: { switch (args.Length) { @@ -255,7 +306,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 9: + case 11: { switch (args.Length) { @@ -267,7 +318,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 10: + case 12: { switch (args.Length) { @@ -280,7 +331,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 11: + case 13: { switch (args.Length) { @@ -293,7 +344,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } - case 12: + case 14: { switch (args.Length) { @@ -337,7 +388,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe public static readonly global::TUnit.Core.TestEntry[] Entries = __CreateEntries(); private static global::TUnit.Core.TestEntry[] __CreateEntries() { - var entries = new global::TUnit.Core.TestEntry[13]; + var entries = new global::TUnit.Core.TestEntry[15]; __FillEntries0(entries); __FillEntries1(entries); return entries; @@ -348,7 +399,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Object_Property", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Property", filePath: "", - lineNumber: 38, + lineNumber: 42, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -372,7 +423,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Object_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Object_Method", filePath: "", - lineNumber: 45, + lineNumber: 49, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -396,7 +447,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Tuple_Single_Parameter", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Tuple_Single_Parameter", filePath: "", - lineNumber: 52, + lineNumber: 56, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -420,7 +471,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Reference_Tuple_Single_Parameter", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Reference_Tuple_Single_Parameter", filePath: "", - lineNumber: 59, + lineNumber: 63, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -444,7 +495,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "Static_Reference_Tuple_Single_Parameter", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Static_Reference_Tuple_Single_Parameter", filePath: "", - lineNumber: 66, + lineNumber: 70, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -468,7 +519,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodName: "One_Element_Reference_Tuple_Single_Parameter", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.One_Element_Reference_Tuple_Single_Parameter", filePath: "", - lineNumber: 73, + lineNumber: 77, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -489,10 +540,58 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe createAttributes: __attributes, attributeGroupIndex: 0); entries[6] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple", + filePath: "", + lineNumber: 84, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("OneElementReferenceTupleOfNullRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).OneElementReferenceTupleOfNullRows ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Tuple), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Tuple)), true) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 6, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[7] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Static_One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Static_One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple", + filePath: "", + lineNumber: 92, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("StaticOneElementReferenceTupleOfNullRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromEnumerable(dataGeneratorMetadata, static () => (object?)global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.StaticOneElementReferenceTupleOfNullRows() ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Static_One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Tuple), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Tuple)), true) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 7, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[8] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "One_Element_Reference_Tuple_Null_Argument_Stays_Null", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.One_Element_Reference_Tuple_Null_Argument_Stays_Null", filePath: "", - lineNumber: 80, + lineNumber: 100, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -506,14 +605,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 6, + methodIndex: 8, createAttributes: __attributes, attributeGroupIndex: 0); - entries[7] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[9] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Optional_Reference_Tuple_Gets_Its_Default", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Optional_Reference_Tuple_Gets_Its_Default", filePath: "", - lineNumber: 87, + lineNumber: 107, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -527,14 +626,17 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 7, + methodIndex: 9, createAttributes: __attributes, attributeGroupIndex: 0); - entries[8] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + } + private static void __FillEntries1(global::TUnit.Core.TestEntry[] entries) + { + entries[10] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Private_Method", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method", filePath: "", - lineNumber: 94, + lineNumber: 114, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -551,14 +653,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 8, + methodIndex: 10, createAttributes: __attributes, attributeGroupIndex: 0); - entries[9] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[11] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Private_Method_With_Generic_Overload", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Private_Method_With_Generic_Overload", filePath: "", - lineNumber: 101, + lineNumber: 121, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -575,17 +677,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 9, + methodIndex: 11, createAttributes: __attributes, attributeGroupIndex: 0); - } - private static void __FillEntries1(global::TUnit.Core.TestEntry[] entries) - { - entries[10] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[12] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 108, + lineNumber: 128, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -602,14 +701,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 10, + methodIndex: 12, createAttributes: __attributes, attributeGroupIndex: 0); - entries[11] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[13] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Async_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Async_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 115, + lineNumber: 135, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -626,14 +725,14 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 11, + methodIndex: 13, createAttributes: __attributes, attributeGroupIndex: 0); - entries[12] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + entries[14] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( methodName: "Null_Task_Source_With_SkipIfEmpty_Is_Skipped", fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Null_Task_Source_With_SkipIfEmpty_Is_Skipped", filePath: "", - lineNumber: 122, + lineNumber: 142, hasDataSource: true, testDataSources: new global::TUnit.Core.IDataSourceAttribute[] { @@ -650,7 +749,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe classMetadata: __classMetadata, createInstance: __createInstance, invokeBody: __invoke, - methodIndex: 12, + methodIndex: 14, createAttributes: __attributes, attributeGroupIndex: 0); } diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt index 137387055ef..bb9462a98b3 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.Test.verified.txt @@ -166,7 +166,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceField { new global::TUnit.Core.InstanceMethodDataSourceAttribute("StaticRows") { - Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromEnumerable(static () => (object?)global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests.StaticRows() ?? global::System.Array.Empty()), + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromEnumerable(dataGeneratorMetadata, static () => (object?)global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests.StaticRows() ?? global::System.Array.Empty()), }, }, returnType: typeof(global::System.Threading.Tasks.Task), diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 8837e50bd53..652230f1840 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -2324,30 +2324,30 @@ namespace .Helpers } public static class DataSourceFactories { - [.(typeof(..d__4))] public static .<<.>> FromAsyncEnumerable(<.> invoke) { } - [.(typeof(..d__1))] + public static .<<.>> FromAsyncEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } public static .<<.>> FromEnumerable( invoke) { } - [.(typeof(..d__9))] + public static .<<.>> FromEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } + [.(typeof(..d__19))] public static .<<.>> FromInstanceAsyncEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__6))] + [.(typeof(..d__16))] public static .<<.>> FromInstanceEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__11))] + [.(typeof(..d__21))] public static .<<.>> FromInstanceRuntimeValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__7))] + [.(typeof(..d__17))] public static .<<.>> FromInstanceTask(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__5))] + [.(typeof(..d__15))] public static .<<.>> FromInstanceValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__8))] + [.(typeof(..d__18))] public static .<<.>> FromInstanceValueTask(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__10))] + [.(typeof(..d__20))] public static .<<.>> FromRuntimeValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__2))] public static .<<.>> FromTask(<.> invoke) { } - [.(typeof(..d__0))] + public static .<<.>> FromTask(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } public static .<<.>> FromValue( invoke) { } - [.(typeof(..d__3?))] + public static .<<.>> FromValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } public static .<<.>> FromValueTask(<.> invoke) { } + public static .<<.>> FromValueTask(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } } public static class DataSourceHelpers { diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 625b61daa05..597bb3b5862 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -2324,30 +2324,30 @@ namespace .Helpers } public static class DataSourceFactories { - [.(typeof(..d__4))] public static .<<.>> FromAsyncEnumerable(<.> invoke) { } - [.(typeof(..d__1))] + public static .<<.>> FromAsyncEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } public static .<<.>> FromEnumerable( invoke) { } - [.(typeof(..d__9))] + public static .<<.>> FromEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } + [.(typeof(..d__19))] public static .<<.>> FromInstanceAsyncEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__6))] + [.(typeof(..d__16))] public static .<<.>> FromInstanceEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__11))] + [.(typeof(..d__21))] public static .<<.>> FromInstanceRuntimeValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__7))] + [.(typeof(..d__17))] public static .<<.>> FromInstanceTask(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__5))] + [.(typeof(..d__15))] public static .<<.>> FromInstanceValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__8))] + [.(typeof(..d__18))] public static .<<.>> FromInstanceValueTask(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__10))] + [.(typeof(..d__20))] public static .<<.>> FromRuntimeValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__2))] public static .<<.>> FromTask(<.> invoke) { } - [.(typeof(..d__0))] + public static .<<.>> FromTask(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } public static .<<.>> FromValue( invoke) { } - [.(typeof(..d__3?))] + public static .<<.>> FromValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } public static .<<.>> FromValueTask(<.> invoke) { } + public static .<<.>> FromValueTask(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } } public static class DataSourceHelpers { diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 4c7e9468c60..c2b2d19674d 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -2324,30 +2324,30 @@ namespace .Helpers } public static class DataSourceFactories { - [.(typeof(..d__4))] public static .<<.>> FromAsyncEnumerable(<.> invoke) { } - [.(typeof(..d__1))] + public static .<<.>> FromAsyncEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } public static .<<.>> FromEnumerable( invoke) { } - [.(typeof(..d__9))] + public static .<<.>> FromEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } + [.(typeof(..d__19))] public static .<<.>> FromInstanceAsyncEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__6))] + [.(typeof(..d__16))] public static .<<.>> FromInstanceEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__11))] + [.(typeof(..d__21))] public static .<<.>> FromInstanceRuntimeValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__7))] + [.(typeof(..d__17))] public static .<<.>> FromInstanceTask(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__5))] + [.(typeof(..d__15))] public static .<<.>> FromInstanceValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__8))] + [.(typeof(..d__18))] public static .<<.>> FromInstanceValueTask(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__10))] + [.(typeof(..d__20))] public static .<<.>> FromRuntimeValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__2))] public static .<<.>> FromTask(<.> invoke) { } - [.(typeof(..d__0))] + public static .<<.>> FromTask(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } public static .<<.>> FromValue( invoke) { } - [.(typeof(..d__3?))] + public static .<<.>> FromValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } public static .<<.>> FromValueTask(<.> invoke) { } + public static .<<.>> FromValueTask(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } } public static class DataSourceHelpers { diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index c91f0071aa5..0b2ba94dba8 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -2265,30 +2265,30 @@ namespace .Helpers } public static class DataSourceFactories { - [.(typeof(..d__4))] public static .<<.>> FromAsyncEnumerable(<.> invoke) { } - [.(typeof(..d__1))] + public static .<<.>> FromAsyncEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } public static .<<.>> FromEnumerable( invoke) { } - [.(typeof(..d__9))] + public static .<<.>> FromEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } + [.(typeof(..d__19))] public static .<<.>> FromInstanceAsyncEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__6))] + [.(typeof(..d__16))] public static .<<.>> FromInstanceEnumerable(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__11))] + [.(typeof(..d__21))] public static .<<.>> FromInstanceRuntimeValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__7))] + [.(typeof(..d__17))] public static .<<.>> FromInstanceTask(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__5))] + [.(typeof(..d__15))] public static .<<.>> FromInstanceValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__8))] + [.(typeof(..d__18))] public static .<<.>> FromInstanceValueTask(.DataGeneratorMetadata dataGeneratorMetadata, > invoke) { } - [.(typeof(..d__10))] + [.(typeof(..d__20))] public static .<<.>> FromRuntimeValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } - [.(typeof(..d__2))] public static .<<.>> FromTask(<.> invoke) { } - [.(typeof(..d__0))] + public static .<<.>> FromTask(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } public static .<<.>> FromValue( invoke) { } - [.(typeof(..d__3?))] + public static .<<.>> FromValue(.DataGeneratorMetadata dataGeneratorMetadata, invoke) { } public static .<<.>> FromValueTask(<.> invoke) { } + public static .<<.>> FromValueTask(.DataGeneratorMetadata dataGeneratorMetadata, <.> invoke) { } } public static class DataSourceHelpers { diff --git a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs index 9372f91656c..a5fbe1ee972 100644 --- a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs @@ -23,6 +23,10 @@ public class InstanceMethodDataSourceMemberShapeTests public IEnumerable> OneElementReferenceTupleRows => [Tuple.Create(7)]; + public IEnumerable> OneElementReferenceTupleOfNullRows => [Tuple.Create(null)]; + + public static IEnumerable> StaticOneElementReferenceTupleOfNullRows() => [Tuple.Create(null)]; + private IEnumerable PrivateRows() => [65, 66]; private IEnumerable OverloadedRows() => [67, 68]; @@ -77,6 +81,22 @@ public async Task One_Element_Reference_Tuple_Single_Parameter(Tuple value) await Assert.That(value.Item1).IsEqualTo(7); } + [Test] + [InstanceMethodDataSource(nameof(OneElementReferenceTupleOfNullRows))] + public async Task One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple(Tuple? value) + { + await Assert.That(value).IsNotNull(); + await Assert.That(value!.Item1).IsNull(); + } + + [Test] + [InstanceMethodDataSource(nameof(StaticOneElementReferenceTupleOfNullRows))] + public async Task Static_One_Element_Reference_Tuple_Holding_Null_Stays_A_Tuple(Tuple? value) + { + await Assert.That(value).IsNotNull(); + await Assert.That(value!.Item1).IsNull(); + } + [Test] [Arguments(null)] public async Task One_Element_Reference_Tuple_Null_Argument_Stays_Null(Tuple? value) From ca94d99a304c95fa189b746954d469a84b9f6e53 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:05:11 +0000 Subject: [PATCH 09/10] fix(sourcegen): preserve non-generic task source factories (#7225) --- .../Generators/TestMetadataGenerator.cs | 12 +- .../Bugs/7225/Tests7225.cs | 4 + .../Tests7225.MemberShapes.verified.txt | 168 +++++++++++++++++- ...nstanceMethodDataSourceMemberShapeTests.cs | 37 ++++ 4 files changed, 218 insertions(+), 3 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 76b81abc315..5fdf3b05601 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -3010,8 +3010,16 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d var isStatic = dataSourceMember.IsStatic; var fullyQualifiedType = targetType.GloballyQualified(); + // Non-generic tasks have no T for the typed task helpers. Read them through the same + // runtime-value path as reflection mode, preserving its existing row semantics. + var isNonGenericTask = returnType is INamedTypeSymbol { Arity: 0 } + && (IsTask(returnType) || IsValueTask(returnType)); string kind; - if (IsAsyncEnumerable(returnType)) + if (isNonGenericTask) + { + kind = "Value"; + } + else if (IsAsyncEnumerable(returnType)) { kind = "AsyncEnumerable"; } @@ -3083,7 +3091,7 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d // generated code always has (https://github.com/thomhurst/TUnit/issues/7178). [InstanceMethodDataSource] had no // generated factory before #7225, so every member it names keeps the runtime-value behavior it always had. var isAccessibleMember = IsAccessibleDataSourceMember(dataSourceMember, compilation); - if (kind == "Value" && (decidesRowsFromRuntimeValue || dataSourceMember is IFieldSymbol || !isAccessibleMember)) + if (kind == "Value" && (isNonGenericTask || decidesRowsFromRuntimeValue || dataSourceMember is IFieldSymbol || !isAccessibleMember)) { helper = isStatic || !usesTestClassInstance ? "global::TUnit.Core.Helpers.DataSourceFactories.FromRuntimeValue" diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs index 590855ee7b8..38e80cc1cd4 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs @@ -42,6 +42,10 @@ public Task MemberShapes() => RunTest(Path.Combine(Git.TestsDirectory.FullName, await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).TupleRows ?? global::System.Array.Empty())"); await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => typeof({testClass}).GetMethod(\"PrivateRows\", "); await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"PrivateRows\"),"); + await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).NonGenericTaskRows() ?? global::System.Array.Empty())"); + await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).NonGenericValueTaskRows ?? global::System.Array.Empty())"); + await Assert.That(source).Contains($"FromRuntimeValue(dataGeneratorMetadata, static () => (object?){testClass}.StaticNonGenericTaskRows ?? global::System.Array.Empty())"); + await Assert.That(source).Contains($"FromRuntimeValue(dataGeneratorMetadata, static () => (object?){testClass}.StaticNonGenericValueTaskRows() ?? global::System.Array.Empty())"); // A generic overload of the same name would make the GetMethod lookup ambiguous await Assert.That(source).Contains($"global::System.Array.Find(typeof({testClass}).GetMethods("); await Assert.That(source).Contains("m.Name == \"OverloadedRows\" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 0)!.Invoke(instance, null)"); diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt index 135b5309ca3..277e114ff09 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7225.MemberShapes.verified.txt @@ -93,6 +93,28 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).NullAsyncRows, ClassMetadata = null!, ContainingTypeMetadata = null! + }, + new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("NonGenericValueTaskRows", typeof(global::System.Threading.Tasks.ValueTask)), + Type = typeof(global::System.Threading.Tasks.ValueTask), + Name = "NonGenericValueTaskRows", + IsStatic = false, + IsNullable = false, + Getter = o => ((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)o).NonGenericValueTaskRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! + }, + new global::TUnit.Core.PropertyMetadata + { + ReflectionInfo = typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests).GetProperty("StaticNonGenericTaskRows", typeof(global::System.Threading.Tasks.Task)), + Type = typeof(global::System.Threading.Tasks.Task), + Name = "StaticNonGenericTaskRows", + IsStatic = true, + IsNullable = false, + Getter = _ => global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.StaticNonGenericTaskRows, + ClassMetadata = null!, + ContainingTypeMetadata = null! } }, Parent = null @@ -357,6 +379,54 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); } } + case 15: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Non_Generic_Task(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 16: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Non_Generic_ValueTask(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 17: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Static_Non_Generic_Task(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } + case 18: + { + switch (args.Length) + { + case 1: + { + return new global::System.Threading.Tasks.ValueTask(instance.Static_Non_Generic_ValueTask(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + default: + throw new global::System.ArgumentException($"Expected exactly 1 argument, but got {args.Length}"); + } + } default: throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex)); } @@ -388,7 +458,7 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe public static readonly global::TUnit.Core.TestEntry[] Entries = __CreateEntries(); private static global::TUnit.Core.TestEntry[] __CreateEntries() { - var entries = new global::TUnit.Core.TestEntry[15]; + var entries = new global::TUnit.Core.TestEntry[19]; __FillEntries0(entries); __FillEntries1(entries); return entries; @@ -752,6 +822,102 @@ internal static class TUnit_TestProject_Bugs__7225_InstanceMethodDataSourceMembe methodIndex: 14, createAttributes: __attributes, attributeGroupIndex: 0); + entries[15] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Non_Generic_Task", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Non_Generic_Task", + filePath: "", + lineNumber: 157, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("NonGenericTaskRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).NonGenericTaskRows() ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Non_Generic_Task", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new global::TUnit.Core.ConcreteType(typeof(object)), true) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 15, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[16] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Non_Generic_ValueTask", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Non_Generic_ValueTask", + filePath: "", + lineNumber: 164, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("NonGenericValueTaskRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)((global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests)instance).NonGenericValueTaskRows ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Non_Generic_ValueTask", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Threading.Tasks.ValueTask), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Threading.Tasks.ValueTask)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 16, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[17] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Static_Non_Generic_Task", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Static_Non_Generic_Task", + filePath: "", + lineNumber: 171, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("StaticNonGenericTaskRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromRuntimeValue(dataGeneratorMetadata, static () => (object?)global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.StaticNonGenericTaskRows ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Static_Non_Generic_Task", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new global::TUnit.Core.ConcreteType(typeof(object)), true) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 17, + createAttributes: __attributes, + attributeGroupIndex: 0); + entries[18] = global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata( + methodName: "Static_Non_Generic_ValueTask", + fullyQualifiedName: "TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.Static_Non_Generic_ValueTask", + filePath: "", + lineNumber: 178, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.InstanceMethodDataSourceAttribute("StaticNonGenericValueTaskRows") + { + Factory = static dataGeneratorMetadata => global::TUnit.Core.Helpers.DataSourceFactories.FromRuntimeValue(dataGeneratorMetadata, static () => (object?)global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests.StaticNonGenericValueTaskRows() ?? global::System.Array.Empty()), + }, +}, + returnType: typeof(global::System.Threading.Tasks.Task), + parameters: global::TUnit.Core.ParameterMetadataFactory.ForMethod(typeof(global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests), "Static_Non_Generic_ValueTask", false, new global::TUnit.Core.ParameterMetadata[] + { + global::TUnit.Core.ParameterMetadataFactory.Create(typeof(global::System.Threading.Tasks.ValueTask), "value", new global::TUnit.Core.ConcreteType(typeof(global::System.Threading.Tasks.ValueTask)), false) + }), + classMetadata: __classMetadata, + createInstance: __createInstance, + invokeBody: __invoke, + methodIndex: 18, + createAttributes: __attributes, + attributeGroupIndex: 0); } } internal static partial class TUnit_TestRegistration diff --git a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs index a5fbe1ee972..5bafdd79c6c 100644 --- a/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7225/InstanceMethodDataSourceMemberShapeTests.cs @@ -145,4 +145,41 @@ public void Null_Task_Source_With_SkipIfEmpty_Is_Skipped(int value) { throw new InvalidOperationException($"Should have been skipped, but ran with '{value}'"); } + + public Task NonGenericTaskRows() => Task.Delay(1); + + public ValueTask NonGenericValueTaskRows => default; + + public static Task StaticNonGenericTaskRows => Task.Delay(1); + + public static ValueTask StaticNonGenericValueTaskRows() => default; + + [Test] + [InstanceMethodDataSource(nameof(NonGenericTaskRows))] + public async Task Non_Generic_Task(object? value) + { + await Assert.That(value).IsNull(); + } + + [Test] + [InstanceMethodDataSource(nameof(NonGenericValueTaskRows))] + public async Task Non_Generic_ValueTask(ValueTask value) + { + await Assert.That(value.IsCompletedSuccessfully).IsTrue(); + } + + [Test] + [InstanceMethodDataSource(nameof(StaticNonGenericTaskRows))] + public async Task Static_Non_Generic_Task(object? value) + { + await Assert.That(value).IsNull(); + } + + [Test] + [InstanceMethodDataSource(nameof(StaticNonGenericValueTaskRows))] + public async Task Static_Non_Generic_ValueTask(ValueTask value) + { + await Assert.That(value.IsCompletedSuccessfully).IsTrue(); + } + } From edcf79075f9318d203c1babc5e3cdc96a67e4c8d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:40:02 +0000 Subject: [PATCH 10/10] refactor(sourcegen): build complete reflection reads for #7225 --- .../Generators/TestMetadataGenerator.cs | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 5fdf3b05601..c4060b95a47 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -3109,31 +3109,10 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d if (!isAccessibleMember) { - // The generated class cannot name a private or protected member, so the factory reads it by - // reflection. GetField/GetProperty/GetMethod on a typeof() with a constant name and binding flags is understood - // by the trimmer and the AOT compiler, which keep that member, so this works under native AOT - // without any trimming warning. The attribute's own reflection fallback finds the member from the - // test class type at run time, which neither of them can see. - var bindingFlags = (isStatic ? "global::System.Reflection.BindingFlags.Static" : "global::System.Reflection.BindingFlags.Instance") - + " | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.DeclaredOnly"; var target = isStatic ? "null" : usesTestClassInstance ? "instance" : $"new {fullyQualifiedType}()"; - // CanReadDataSourceMember only lets a parameterless, non-generic method through, so it is looked up with no - // parameter types, which also picks it out from any overloads. A parameterless generic overload of the same - // name would match that lookup too and make it ambiguous, so then the methods are filtered instead, without - // the genericParameterCount overload of GetMethod that .NET Framework and .NET Standard 2.0 lack. - var hasGenericParameterlessOverload = dataSourceMember is IMethodSymbol - && dataSourceMember.ContainingType.GetMembers(dataSourceMember.Name) - .Any(static m => m is IMethodSymbol { IsGenericMethod: true, Parameters.Length: 0 }); - var read = dataSourceMember switch - { - IFieldSymbol => $"GetField(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target})", - IPropertySymbol => $"GetProperty(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target})", - _ when hasGenericParameterlessOverload => $"GetMethods({bindingFlags}), static m => m.Name == \"{dataSourceMember.Name}\" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 0)!.Invoke({target}, null)", - _ => $"GetMethod(\"{dataSourceMember.Name}\", {bindingFlags}, null, global::System.Type.EmptyTypes, null)!.Invoke({target}, null)" - }; + var read = BuildReflectionDataSourceRead(dataSourceMember, target); var cast = kind is "Value" or "Enumerable" ? "" : $"({returnType.GloballyQualified()})"; - var find = hasGenericParameterlessOverload ? "global::System.Array.Find(" : ""; - writer.Append($"{helper}({metadataArgument}{lambdaHeader} {cast}{find}typeof({dataSourceMember.ContainingType.GloballyQualified()}).{read}{nullAsEmpty})"); + writer.Append($"{helper}({metadataArgument}{lambdaHeader} {cast}{read}{nullAsEmpty})"); return; } @@ -3185,6 +3164,31 @@ private static void EmitDataSourceFactoryInvocation(CodeWriter writer, ISymbol d } } + /// + /// Builds the complete reflection read for an inaccessible data source member. Constant + /// declaring types, names and binding flags let trimming and Native AOT preserve the member. + /// + private static string BuildReflectionDataSourceRead(ISymbol dataSourceMember, string target) + { + var bindingFlags = (dataSourceMember.IsStatic ? "global::System.Reflection.BindingFlags.Static" : "global::System.Reflection.BindingFlags.Instance") + + " | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.DeclaredOnly"; + // CanReadDataSourceMember only lets a parameterless, non-generic method through, so it is looked up with no + // parameter types, which also picks it out from any overloads. A parameterless generic overload of the same + // name would match that lookup too and make it ambiguous, so then the methods are filtered instead, without + // the genericParameterCount overload of GetMethod that .NET Framework and .NET Standard 2.0 lack. + var hasGenericParameterlessOverload = dataSourceMember is IMethodSymbol + && dataSourceMember.ContainingType.GetMembers(dataSourceMember.Name) + .Any(static m => m is IMethodSymbol { IsGenericMethod: true, Parameters.Length: 0 }); + var declaringType = $"typeof({dataSourceMember.ContainingType.GloballyQualified()})"; + return dataSourceMember switch + { + IFieldSymbol => $"{declaringType}.GetField(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target})", + IPropertySymbol => $"{declaringType}.GetProperty(\"{dataSourceMember.Name}\", {bindingFlags})!.GetValue({target})", + _ when hasGenericParameterlessOverload => $"global::System.Array.Find({declaringType}.GetMethods({bindingFlags}), static m => m.Name == \"{dataSourceMember.Name}\" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 0)!.Invoke({target}, null)", + _ => $"{declaringType}.GetMethod(\"{dataSourceMember.Name}\", {bindingFlags}, null, global::System.Type.EmptyTypes, null)!.Invoke({target}, null)" + }; + } + /// /// Whether a call on might not reach , a method that /// is not an override: some type from the target type down to, but excluding, the method's declaring type