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
2 changes: 1 addition & 1 deletion TUnit.Analyzers/DynamicTestAwaitExpressionSuppressor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public override void ReportSuppressions(SuppressionAnalysisContext context)
continue;
}

if (namedTypeSymbol.Name == "DynamicTest" || namedTypeSymbol.Name == "DynamicTest")
if (namedTypeSymbol.Name == "DynamicTest")
{
Suppress(context, diagnostic);
}
Expand Down
5 changes: 4 additions & 1 deletion TUnit.Analyzers/Extensions/CompilationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ public static class CompilationExtensions
public static bool HasImplicitConversionOrGenericParameter(this Compilation compilation, ITypeSymbol? argumentType,
ITypeSymbol? parameterType)
{
// Handle exact type matches (including when metadata differs)
if (parameterType == null && argumentType != null)
{
return false;
}
if (argumentType != null && parameterType != null &&
argumentType.ToDisplayString() == parameterType.ToDisplayString())
{
Expand Down
60 changes: 38 additions & 22 deletions TUnit.Analyzers/TestDataAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ private void Analyze(SymbolAnalysisContext context,
var typesToValidate = propertySymbol != null
? ImmutableArray.Create(propertySymbol.Type)
: parameters.Select(p => p.Type).ToImmutableArray().WithoutCancellationTokenParameter();

CheckMethodDataSource(context, attribute, testClassType, typesToValidate, propertySymbol);
}

Expand Down Expand Up @@ -556,6 +557,7 @@ private void CheckMethodDataSource(SymbolAnalysisContext context,
testParameterTypes,
out var isFunc,
out var isTuples);


if (!isFunc && unwrappedTypes.Any(x => x.SpecialType != SpecialType.System_String && x.IsReferenceType))
{
Expand All @@ -577,6 +579,18 @@ private void CheckMethodDataSource(SymbolAnalysisContext context,
// object[] can contain any types - skip compile-time type checking
return;
}

if (isTuples && unwrappedTypes.Length != testParameterTypes.Length)
{
context.ReportDiagnostic(Diagnostic.Create(
Rules.WrongArgumentTypeTestData,
attribute.GetLocation(),
string.Join(", ", unwrappedTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")),
string.Join(", ", testParameterTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")))
);
return;
}

var conversions = unwrappedTypes.ZipAll(testParameterTypes,
(argument, parameter) =>
{
Expand All @@ -597,8 +611,8 @@ private void CheckMethodDataSource(SymbolAnalysisContext context,
Diagnostic.Create(
Rules.WrongArgumentTypeTestData,
attribute.GetLocation(),
string.Join(", ", unwrappedTypes),
string.Join(", ", testParameterTypes))
string.Join(", ", unwrappedTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")),
string.Join(", ", testParameterTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")))
);
return;
}
Expand Down Expand Up @@ -634,27 +648,14 @@ private void CheckMethodDataSource(SymbolAnalysisContext context,

if (isTuples)
{
// Check if any test method parameters are tuple types when data source returns tuples
// This causes a runtime mismatch: data source provides separate arguments, but method expects tuple parameter
var tupleParameters = testParameterTypes.Where(p => p is INamedTypeSymbol { IsTupleType: true }).ToArray();
if (tupleParameters.Any())
{
context.ReportDiagnostic(Diagnostic.Create(
Rules.WrongArgumentTypeTestData,
attribute.GetLocation(),
string.Join(", ", unwrappedTypes),
string.Join(", ", testParameterTypes))
);
return;
}

if (unwrappedTypes.Length != testParameterTypes.Length)
{
context.ReportDiagnostic(Diagnostic.Create(
Rules.WrongArgumentTypeTestData,
attribute.GetLocation(),
string.Join(", ", unwrappedTypes),
string.Join(", ", testParameterTypes))
string.Join(", ", unwrappedTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")),
string.Join(", ", testParameterTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")))
);
return;
}
Expand Down Expand Up @@ -689,8 +690,8 @@ private void CheckMethodDataSource(SymbolAnalysisContext context,
Diagnostic.Create(
Rules.WrongArgumentTypeTestData,
attribute.GetLocation(),
string.Join(", ", unwrappedTypes),
string.Join(", ", testParameterTypes))
string.Join(", ", unwrappedTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")),
string.Join(", ", testParameterTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")))
);
}
}
Expand Down Expand Up @@ -777,11 +778,26 @@ private ImmutableArray<ITypeSymbol> UnwrapTypes(SymbolAnalysisContext context,
type = genericType.TypeArguments[0];
}

// Check for tuple types first before doing conversion checks
if (type is INamedTypeSymbol { IsTupleType: true } tupleType)
if (type is INamedTypeSymbol namedType && namedType.IsTupleType)
{
var tupleType = namedType;
if (testParameterTypes.Length == 1 &&
testParameterTypes[0] is INamedTypeSymbol paramTupleType &&
paramTupleType.IsTupleType &&
SymbolEqualityComparer.Default.Equals(tupleType, testParameterTypes[0]))
{
return ImmutableArray.Create(type);
}

isTuples = true;
return ImmutableArray.CreateRange(tupleType.TupleElements.Select(x => x.Type));

if (testParameterTypes.Length == 1 &&
testParameterTypes[0] is INamedTypeSymbol { IsTupleType: true })
{
return ImmutableArray.CreateRange(tupleType.TupleElements.Select(e => e.Type));
}

return ImmutableArray.CreateRange(tupleType.TupleElements.Select(e => e.Type));
}

if (testParameterTypes.Length == 1
Expand Down
112 changes: 108 additions & 4 deletions TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@
}

// Find the data source method
var dataSourceMethod = targetType.GetMembers(methodName)

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.
.OfType<IMethodSymbol>()
.FirstOrDefault();

Expand Down Expand Up @@ -1633,8 +1633,59 @@
{
writer.AppendLine("var context = global::TUnit.Core.TestContext.Current;");
}

if (parametersFromArgs.Length == 0)

// Special case: Single tuple parameter
// If we have exactly one parameter that's a tuple type, we need to handle it specially
// In source-generated mode, tuples are always unwrapped into their elements
if (parametersFromArgs.Length == 1 && parametersFromArgs[0].Type is INamedTypeSymbol { IsTupleType: true } tupleType)
{
writer.AppendLine("// Special handling for single tuple parameter");
writer.AppendLine($"if (args.Length == {tupleType.TupleElements.Length})");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine("// Arguments are unwrapped tuple elements, reconstruct the tuple");

// Build tuple reconstruction
var tupleConstruction = $"({string.Join(", ", tupleType.TupleElements.Select((_, i) => $"({tupleType.TupleElements[i].Type.GloballyQualified()})args[{i}]"))})";

var methodCallReconstructed = hasCancellationToken
? $"typedInstance.{methodName}({tupleConstruction}, context?.CancellationToken ?? System.Threading.CancellationToken.None)"
: $"typedInstance.{methodName}({tupleConstruction})";
if (isAsync)
{
writer.AppendLine($"await {methodCallReconstructed};");
}
else
{
writer.AppendLine($"{methodCallReconstructed};");
}
writer.Unindent();
writer.AppendLine("}");
writer.AppendLine("else if (args.Length == 1 && global::TUnit.Core.Helpers.DataSourceHelpers.IsTuple(args[0]))");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine("// Rare case: tuple is wrapped as a single argument");
var methodCallDirect = hasCancellationToken
? $"typedInstance.{methodName}(({tupleType.GloballyQualified()})args[0], context?.CancellationToken ?? System.Threading.CancellationToken.None)"
: $"typedInstance.{methodName}(({tupleType.GloballyQualified()})args[0])";
if (isAsync)
{
writer.AppendLine($"await {methodCallDirect};");
}
else
{
writer.AppendLine($"{methodCallDirect};");
}
writer.Unindent();
writer.AppendLine("}");
writer.AppendLine("else");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine($"throw new global::System.ArgumentException($\"Expected {tupleType.TupleElements.Length} unwrapped elements or 1 wrapped tuple, but got {{args.Length}} arguments\");");
writer.Unindent();
writer.AppendLine("}");
}
else if (parametersFromArgs.Length == 0)
{
var methodCall = hasCancellationToken
? $"typedInstance.{methodName}(context?.CancellationToken ?? System.Threading.CancellationToken.None)"
Expand Down Expand Up @@ -1730,8 +1781,61 @@
{
writer.AppendLine("var context = global::TUnit.Core.TestContext.Current;");
}

if (parametersFromArgs.Length == 0)

// Special case: Single tuple parameter (same as in TestInvoker)
// If we have exactly one parameter that's a tuple type, we need to handle it specially
// In source-generated mode, tuples are always unwrapped into their elements
if (parametersFromArgs.Length == 1 && parametersFromArgs[0].Type is INamedTypeSymbol { IsTupleType: true } singleTupleParam)
{
writer.AppendLine("// Special handling for single tuple parameter");
writer.AppendLine($"if (args.Length == {singleTupleParam.TupleElements.Length})");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine("// Arguments are unwrapped tuple elements, reconstruct the tuple");

// Build tuple reconstruction with proper casting
var tupleElements = singleTupleParam.TupleElements.Select((elem, i) =>
$"TUnit.Core.Helpers.CastHelper.Cast<{elem.Type.GloballyQualified()}>(args[{i}])").ToList();
var tupleConstruction = $"({string.Join(", ", tupleElements)})";

var methodCallReconstructed = hasCancellationToken
? $"instance.{methodName}({tupleConstruction}, cancellationToken)"
: $"instance.{methodName}({tupleConstruction})";
if (isAsync)
{
writer.AppendLine($"await {methodCallReconstructed};");
}
else
{
writer.AppendLine($"{methodCallReconstructed};");
}
writer.Unindent();
writer.AppendLine("}");
writer.AppendLine("else if (args.Length == 1 && global::TUnit.Core.Helpers.DataSourceHelpers.IsTuple(args[0]))");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine("// Rare case: tuple is wrapped as a single argument");
var methodCallDirect = hasCancellationToken
? $"instance.{methodName}(TUnit.Core.Helpers.CastHelper.Cast<{singleTupleParam.GloballyQualified()}>(args[0]), cancellationToken)"
: $"instance.{methodName}(TUnit.Core.Helpers.CastHelper.Cast<{singleTupleParam.GloballyQualified()}>(args[0]))";
if (isAsync)
{
writer.AppendLine($"await {methodCallDirect};");
}
else
{
writer.AppendLine($"{methodCallDirect};");
}
writer.Unindent();
writer.AppendLine("}");
writer.AppendLine("else");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine($"throw new global::System.ArgumentException($\"Expected {singleTupleParam.TupleElements.Length} unwrapped elements or 1 wrapped tuple, but got {{args.Length}} arguments\");");
writer.Unindent();
writer.AppendLine("}");
}
else if (parametersFromArgs.Length == 0)
{
var typedMethodCall = hasCancellationToken
? $"instance.{methodName}(cancellationToken)"
Expand Down
73 changes: 64 additions & 9 deletions TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using TUnit.Core.Enums;
Expand Down Expand Up @@ -125,7 +126,8 @@ public MethodDataSourceAttribute(
hasAnyItems = true;
yield return async () =>
{
return await Task.FromResult<object?[]?>(item.ToObjectArray());
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
return await Task.FromResult<object?[]?>(item.ToObjectArrayWithTypes(paramTypes));
};
}

Expand All @@ -149,7 +151,8 @@ public MethodDataSourceAttribute(
hasAnyItems = true;
yield return async () =>
{
return await Task.FromResult<object?[]?>(item.ToObjectArray());
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
return await Task.FromResult<object?[]?>(item.ToObjectArrayWithTypes(paramTypes));
};
}

Expand All @@ -163,7 +166,8 @@ public MethodDataSourceAttribute(
{
yield return async () =>
{
return await Task.FromResult<object?[]?>(taskResult.ToObjectArray());
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
return await Task.FromResult<object?[]?>(taskResult.ToObjectArrayWithTypes(paramTypes));
};
}
}
Expand All @@ -175,7 +179,8 @@ public MethodDataSourceAttribute(
foreach (var item in enumerable)
{
hasAnyItems = true;
yield return () => Task.FromResult<object?[]?>(item.ToObjectArray());
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
yield return () => Task.FromResult<object?[]?>(item.ToObjectArrayWithTypes(paramTypes));
}

// If the enumerable was empty, yield one empty result like NoDataSource does
Expand All @@ -186,9 +191,10 @@ public MethodDataSourceAttribute(
}
else
{
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
yield return async () =>
{
return await Task.FromResult<object?[]?>(methodResult.ToObjectArray());
return await Task.FromResult<object?[]?>(methodResult.ToObjectArrayWithTypes(paramTypes));
};
}
}
Expand All @@ -204,11 +210,60 @@ private static bool IsAsyncEnumerable([DynamicallyAccessedMembers(DynamicallyAcc
private static async IAsyncEnumerable<object?> ConvertToAsyncEnumerable(object asyncEnumerable, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var type = asyncEnumerable.GetType();
var enumeratorMethod = type.GetMethod("GetAsyncEnumerator");
var enumerator = enumeratorMethod!.Invoke(asyncEnumerable, [cancellationToken]);

// Find the IAsyncEnumerable<T> interface
var asyncEnumerableInterface = type.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>));

if (asyncEnumerableInterface is null)
{
throw new InvalidOperationException($"Type {type.Name} does not implement IAsyncEnumerable<T>");
}

// Get the GetAsyncEnumerator method from the interface
var enumeratorMethod = asyncEnumerableInterface.GetMethod("GetAsyncEnumerator");

if (enumeratorMethod is null)
{
throw new InvalidOperationException($"Could not find GetAsyncEnumerator method on interface {asyncEnumerableInterface.Name}");
}

var enumerator = enumeratorMethod.Invoke(asyncEnumerable, [cancellationToken]);

var moveNextMethod = enumerator!.GetType().GetMethod("MoveNextAsync");
var currentProperty = enumerator.GetType().GetProperty("Current");
// The enumerator might not have MoveNextAsync directly on its type,
// we need to look for it on the IAsyncEnumerator<T> interface
var enumeratorType = enumerator!.GetType();

// Find MoveNextAsync - first try the type directly, then check interfaces
var moveNextMethod = enumeratorType.GetMethod("MoveNextAsync");
if (moveNextMethod is null)
{
// Look for it on the IAsyncEnumerator<T> interface
var asyncEnumeratorInterface = enumeratorType.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IAsyncEnumerator<>));

if (asyncEnumeratorInterface != null)
{
moveNextMethod = asyncEnumeratorInterface.GetMethod("MoveNextAsync");
}
}

// Similarly for Current property
var currentProperty = enumeratorType.GetProperty("Current");
if (currentProperty is null)
{
// Look for it on the IAsyncEnumerator<T> interface
var asyncEnumeratorInterface = enumeratorType.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IAsyncEnumerator<>));

if (asyncEnumeratorInterface != null)
{
currentProperty = asyncEnumeratorInterface.GetProperty("Current");
}
}

while (true)
{
Expand Down
Loading
Loading