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
22 changes: 21 additions & 1 deletion src/TUnit.Core/Models/TestModels/ParameterMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,27 @@ private InvalidOperationException CreateMissingReflectionInfoException()

internal int ReflectionInfoIndex { get; set; }

public bool IsParams => CachedIsParams ?? ReflectionInfo.IsDefined(typeof(ParamArrayAttribute), false);
public bool IsParams => CachedIsParams ?? IsParamsParameter(ReflectionInfo);

// C# 13 params collections carry ParamCollectionAttribute instead of ParamArrayAttribute. Match it
// by name because the type does not exist on every target framework.
private static bool IsParamsParameter(ParameterInfo parameter)
{
if (parameter.IsDefined(typeof(ParamArrayAttribute), false))
{
return true;
}

foreach (var attribute in parameter.GetCustomAttributesData())
{
if (attribute.AttributeType.FullName == "System.Runtime.CompilerServices.ParamCollectionAttribute")
{
return true;
}
}

return false;
}
public bool IsOptional => CachedIsOptional ?? ReflectionInfo.IsOptional;
public bool IsNullable { get; init; }

Expand Down
31 changes: 31 additions & 0 deletions src/TUnit.Engine/Helpers/DataUnwrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,17 @@ public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapWithTypesAnd
// The engine supplies CancellationToken parameters wherever they are declared, so the row
// binds to the data parameters only.
var dataParameterCount = 0;
var firstDataParameterIndex = -1;
var lastDataParameterIndex = -1;
for (var i = 0; i < expectedParameters.Length; i++)
{
if (expectedParameters[i].Type != typeof(CancellationToken))
{
if (dataParameterCount == 0)
{
firstDataParameterIndex = i;
}

dataParameterCount++;
lastDataParameterIndex = i;
}
Expand All @@ -107,6 +113,17 @@ public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapWithTypesAnd
return values;
}

// A tuple that is already an instance of the first data parameter's tuple type binds to it
// whole when every later data parameter can be omitted (optional or params).
var firstDataParameterType = expectedParameters[firstDataParameterIndex].Type;
if (dataParameterCount > 1
&& TupleHelper.IsTupleType(firstDataParameterType)
&& firstDataParameterType.IsInstanceOfType(values[0])
&& RemainingDataParametersAreOmittable(expectedParameters, firstDataParameterIndex))
{
return values;
}

var elements = DataSourceHelpers.UnwrapTupleAot(values[0]);

// A trailing array data parameter collects every remaining element. Otherwise extra
Expand All @@ -120,4 +137,18 @@ public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapWithTypesAnd
Array.Copy(elements, capped, dataParameterCount);
return capped;
}

private static bool RemainingDataParametersAreOmittable(ParameterMetadata[] parameters, int firstDataParameterIndex)
{
for (var i = firstDataParameterIndex + 1; i < parameters.Length; i++)
{
var parameter = parameters[i];
if (parameter.Type != typeof(CancellationToken) && !parameter.IsOptional && !parameter.IsParams)
Comment thread
thomhurst marked this conversation as resolved.
Resolved
{
return false;
}
}

return true;
}
}
31 changes: 31 additions & 0 deletions tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,37 @@ public void MyTest(int value, string value2, bool value3{{GetTimeoutCancellation
);
}

// An object[] row holding only the tuple binds whole (#7212), but a typed tuple row is spread
// before binding in source-generated mode, so TUnit0001 keeps rejecting it.
[Test]
public async Task Method_Data_Source_Is_Flagged_When_Typed_Tuple_Precedes_Optional_Parameter()
{
await Verifier.VerifyAnalyzerAsync(
"""
using System.Collections.Generic;
using TUnit.Core;

public class MyClass
{
public static IEnumerable<(int, int)> Rows()
{
yield return (25, 26);
}

[{|#0:MethodDataSource(nameof(Rows))|}]
[Test]
public void MyTest((int, int) pair, int extra = 5)
{
}
}
""",

Verifier.Diagnostic(Rules.WrongArgumentTypeTestData)
.WithLocation(0)
.WithArguments("int, int", "(int, int), int")
);
}

[Test]
[Arguments(true)]
[Arguments(false)]
Expand Down
23 changes: 23 additions & 0 deletions tests/TUnit.Engine.Tests/Issue7212Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// A tuple row binds whole to a tuple parameter followed by optional or params parameters (#7212).
/// </summary>
public class Issue7212Tests(TestMode testMode) : InvokableTestBase(testMode)
{
[Test]
public async Task Tuple_Parameter_Followed_By_Optional_Parameters_Binds_Whole()
{
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._7212/*/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(8),
result => result.ResultSummary.Counters.Passed.ShouldBe(8),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._7212;

/// <summary>
/// A row whose single value is a tuple of the first data parameter's type binds to that parameter
/// whole, and the remaining optional or params parameters take their defaults, in both
/// source-generated and reflection mode (#7212).
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class TupleParameterWithOptionalParameterTests
{
public static IEnumerable<object[]> TupleRows()
{
yield return [(25, 26)];
}

public static IEnumerable<object[]> TupleAndValueRows()
{
yield return [(25, 26), 7];
}

public static IEnumerable<((int, int), int)> NestedTupleRows()
{
yield return ((25, 26), 7);
}

[Test]
[MethodDataSource(nameof(TupleRows))]
public async Task Tuple_And_Optional((int, int) pair, int extra = 5)
{
await Assert.That(pair).IsEqualTo((25, 26));
await Assert.That(extra).IsEqualTo(5);
}

[Test]
[MethodDataSource(nameof(TupleRows))]
public async Task Tuple_And_Two_Optionals((int, int) pair, int extra = 5, string name = "default")
{
await Assert.That(pair).IsEqualTo((25, 26));
await Assert.That(extra).IsEqualTo(5);
await Assert.That(name).IsEqualTo("default");
}

[Test]
[MethodDataSource(nameof(TupleRows))]
public async Task Tuple_Optional_And_Token((int, int) pair, int extra = 5, CancellationToken token = default)
{
await Assert.That(pair).IsEqualTo((25, 26));
await Assert.That(extra).IsEqualTo(5);
await Assert.That(token.CanBeCanceled).IsTrue();
}

[Test]
[MethodDataSource(nameof(TupleRows))]
public async Task Token_Tuple_And_Optional(CancellationToken token, (int, int) pair, int extra = 5)
{
await Assert.That(pair).IsEqualTo((25, 26));
await Assert.That(extra).IsEqualTo(5);
await Assert.That(token.CanBeCanceled).IsTrue();
}

[Test]
[MethodDataSource(nameof(TupleRows))]
public async Task Tuple_And_Params((int, int) pair, params int[] rest)
{
await Assert.That(pair).IsEqualTo((25, 26));
await Assert.That(rest).IsEmpty();
}

[Test]
[MethodDataSource(nameof(TupleRows))]
public async Task Tuple_And_Params_Collection((int, int) pair, params IEnumerable<int> rest)
Comment thread
thomhurst marked this conversation as resolved.
Resolved
{
await Assert.That(pair).IsEqualTo((25, 26));
await Assert.That(rest).IsEmpty();
}

[Test]
[MethodDataSource(nameof(TupleAndValueRows))]
public async Task Tuple_And_Supplied_Optional((int, int) pair, int extra = 5)
{
await Assert.That(pair).IsEqualTo((25, 26));
await Assert.That(extra).IsEqualTo(7);
}

[Test]
[MethodDataSource(nameof(NestedTupleRows))]
public async Task Nested_Tuple_Row_Is_Spread((int, int) pair, int extra = 5)
{
await Assert.That(pair).IsEqualTo((25, 26));
await Assert.That(extra).IsEqualTo(7);
}
}
Loading