Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 53 additions & 23 deletions src/TUnit.Core/Helpers/TupleHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;

Expand Down Expand Up @@ -74,6 +75,46 @@ public static bool IsTupleType(Type? type)
}
#endif

private static readonly ConcurrentDictionary<Type, MemberInfo[]> TupleMembers = new();

#if NET
[RequiresUnreferencedCode("Tuple fallback reads tuple fields and properties through reflection.")]
#endif
private static void AppendTupleElements(object value, List<object?> elements)
{
var members = TupleMembers.GetOrAdd(value.GetType(), static type =>
{
var count = type.GetGenericArguments().Length;
var isValueTuple = type.FullName!.StartsWith("System.ValueTuple`");
var result = new MemberInfo[count];
for (var i = 0; i < count; i++)
{
var name = i == 7 ? "Rest" : $"Item{i + 1}";
result[i] = isValueTuple
? type.GetField(name)!
: (MemberInfo)type.GetProperty(name)!;
}

return result;
});

for (var i = 0; i < members.Length; i++)
{
var element = members[i] is FieldInfo field
? field.GetValue(value)
: ((PropertyInfo)members[i]).GetValue(value);
if (i == 7)
{
// Flatten only the storage Rest; a tuple in an Item slot is one element.
AppendTupleElements(element!, elements);
}
else
{
elements.Add(element);
}
}
}

/// <summary>
/// Unwraps a tuple into an array of its elements
/// </summary>
Expand All @@ -82,6 +123,16 @@ public static bool IsTupleType(Type? type)
#if NET
return UnwrapTuple(value as ITuple);
#else
return UnwrapTupleUsingReflection(value);
#endif
}

// Kept callable in modern builds so tests exercise the netstandard2.0 fallback directly.
#if NET
[RequiresUnreferencedCode("Tuple fallback reads tuple fields and properties through reflection.")]
#endif
internal static object?[] UnwrapTupleUsingReflection(object? value)
{
if (value == null)
{
return [null];
Expand All @@ -98,30 +149,9 @@ public static bool IsTupleType(Type? type)
// The AOT analyzer will warn about incompatibility at compile time
var elements = new List<object?>();

if (type.FullName?.StartsWith("System.ValueTuple`") == true)
{
// Handle ValueTuple - access via fields
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance);
foreach (var field in fields.OrderBy(f => f.Name))
{
elements.Add(field.GetValue(value));
}
}
else if (type.FullName?.StartsWith("System.Tuple`") == true)
{
// Handle Tuple - access via properties
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.Name.StartsWith("Item"))
.OrderBy(p => p.Name);

foreach (var property in properties)
{
elements.Add(property.GetValue(value));
}
}
AppendTupleElements(value, elements);

return elements.ToArray();
#endif
}

/// <summary>
Expand Down
48 changes: 46 additions & 2 deletions src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3617,6 +3617,50 @@ private static bool ReceivesNullArgument(Type genericParameter, ParameterInfo[]
return false;
}

private static readonly ConcurrentDictionary<Type, (ConstructorInfo Constructor, Type[] Arguments)> TupleConstructors = new();

// Generic inference closes (T, object) to (int, object), but a preserved (int, string)
// row is still boxed as its original invariant ValueTuple type. Rebind only incompatible
// tuples; compatible rows must retain their original identity.
private static object? CastInvocationArgument(Type parameterType, object? value)
{
var targetType = Nullable.GetUnderlyingType(parameterType) ?? parameterType;
if (value == null || targetType.IsInstanceOfType(value))
{
return value;
}

if (GetTupleElementTypes(targetType) is { } targetElements
&& GetTupleElementTypes(value.GetType()) is { } sourceElements
&& targetElements.Length == sourceElements.Length)
{
var elements = DataSourceHelpers.UnwrapTupleAot(value);
var index = 0;
return ConstructInvocationTuple(targetType, elements, ref index);
Comment thread
thomhurst marked this conversation as resolved.
Resolved
}

return CastHelper.Cast(parameterType, value);
}

private static object ConstructInvocationTuple(Type tupleType, object?[] elements, ref int index)
{
var shape = TupleConstructors.GetOrAdd(tupleType, static type =>
{
var arguments = type.GetGenericArguments();
return (type.GetConstructor(arguments)!, arguments);
});
var arguments = new object?[shape.Arguments.Length];
for (var i = 0; i < arguments.Length; i++)
{
// Both Tuple and ValueTuple store elements beyond seven in a nested Rest tuple.
arguments[i] = i == 7
? ConstructInvocationTuple(shape.Arguments[i], elements, ref index)
: CastInvocationArgument(shape.Arguments[i], elements[index++]);
}

return shape.Constructor.Invoke(arguments);
}

/// <summary>
/// Creates a reflection-based test invoker with proper AOT attribution
/// </summary>
Expand Down Expand Up @@ -3779,7 +3823,7 @@ private static bool ReceivesNullArgument(Type genericParameter, ParameterInfo[]
else
{
// Otherwise use CastHelper for conversions
castedArgs[i] = CastHelper.Cast(paramType, arg);
castedArgs[i] = CastInvocationArgument(paramType, arg);
}
}

Expand Down Expand Up @@ -3914,7 +3958,7 @@ private static bool ReceivesNullArgument(Type genericParameter, ParameterInfo[]
else
{
// Otherwise use CastHelper for conversions
castedArgs[i] = CastHelper.Cast(paramType, arg);
castedArgs[i] = CastInvocationArgument(paramType, arg);
}
}

Expand Down
43 changes: 43 additions & 0 deletions tests/TUnit.TestProject/Bugs/_7482/GenericTupleBindingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._7482;

[EngineTest(ExpectedResult.Pass)]
public class GenericTupleBindingTests
{
public static (int, string) Row() => (1, "text");

[Test, MethodDataSource(nameof(Row))]
public async Task Widened<T>((T, object) row)
{
await Assert.That(typeof(T)).IsEqualTo(typeof(int));
await Assert.That(row.Item1).IsEqualTo((T)(object)1);
await Assert.That(row.Item2).IsEqualTo("text");
}

[Test, MethodDataSource(nameof(Row))]
public async Task Compatible<T>((T, string) row)
{
await Assert.That(typeof(T)).IsEqualTo(typeof(int));
await Assert.That(row.Item1).IsEqualTo((T)(object)1);
await Assert.That(row.Item2).IsEqualTo("text");
}

public static (int, string, int, int, int, int, int, string) LongRow() => (1, "two", 3, 4, 5, 6, 7, "eight");

[Test, MethodDataSource(nameof(LongRow))]
public async Task Long<T>((T, object, int, int, int, int, int, object) row)
{
await Assert.That(typeof(T)).IsEqualTo(typeof(int));
await Assert.That(row).IsEqualTo(((T)(object)1, (object)"two", 3, 4, 5, 6, 7, (object)"eight"));
}

[Test, MethodDataSource(nameof(Row))]
public async Task WithToken<T>(CancellationToken token, (T, object) row)
{
await Assert.That(typeof(T)).IsEqualTo(typeof(int));
await Assert.That(row.Item1).IsEqualTo((T)(object)1);
await Assert.That(row.Item2).IsEqualTo("text");
await Assert.That(token.CanBeCanceled).IsTrue();
}
}
21 changes: 21 additions & 0 deletions tests/TUnit.UnitTests/DataSourceHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ namespace TUnit.UnitTests;
/// </summary>
public class DataSourceHelpersTests
{
[Test]
public async Task UnwrapTuple_LongValueTuple_FlattensMultipleRestLevels()
{
object value = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, (15, 16));
var elements = TupleHelper.UnwrapTupleUsingReflection(value);

await Assert.That(elements.Length).IsEqualTo(15);
await Assert.That(elements[7]).IsEqualTo((object)8);
await Assert.That(elements[14]).IsEqualTo((object)(15, 16));
}

[Test]
public async Task UnwrapTuple_LongReferenceTuple_FlattensRest()
{
object value = Tuple.Create(1, 2, 3, 4, 5, 6, 7, "eight");
var elements = TupleHelper.UnwrapTupleUsingReflection(value);

await Assert.That(elements.Length).IsEqualTo(8);
await Assert.That(elements[7]).IsEqualTo((object)"eight");
}

[Test]
public async Task HandleTupleValue_Tuple_ReturnsFactoryPerElement()
{
Expand Down
Loading