diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs index 9dd2aa3d5b..3983091e31 100644 --- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs +++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs @@ -483,9 +483,15 @@ public class InvocationReplacement : ConversionTarget public class TheoryDataConversion : ConversionTarget { /// - /// The element type(s) from TheoryData<T> (e.g., "TimeSpan" from TheoryData<TimeSpan>) + /// The individual type arguments from TheoryData (e.g., ["TimeSpan"] or ["string", "int"]). /// - public required string ElementType { get; init; } + public required IReadOnlyList ElementTypes { get; init; } + + /// + /// Whether the TheoryData has multiple type arguments (e.g., TheoryData<string, int>). + /// When true, initializer expressions { val1, val2 } must be converted to tuple expressions (val1, val2). + /// + public bool IsMultiType => ElementTypes.Count > 1; /// /// Annotation for the GenericName (TheoryData<T>) type syntax to convert to IEnumerable<T> diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs index d9bb94f30c..2d0d1488ad 100644 --- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs +++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs @@ -229,9 +229,12 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) if (objectCreation?.Initializer != null) { - // Build array type: T[] + // Build the element type: T for single, (T1, T2) for multi + var elementTypeSyntax = BuildElementType(conversion.ElementTypes); + + // Build array type: T[] or (T1, T2)[] var arrayType = SyntaxFactory.ArrayType( - SyntaxFactory.ParseTypeName(conversion.ElementType), + elementTypeSyntax, SyntaxFactory.SingletonList( SyntaxFactory.ArrayRankSpecifier( SyntaxFactory.SingletonSeparatedList( @@ -241,21 +244,40 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) ) ).WithoutTrailingTrivia(); - // Get the open brace token and ensure it has proper newline trivia + // Get the open brace token and ensure it has proper newline trivia. + // When the brace is on a separate line (e.g., new Type\n{), the newline + // may be trailing trivia of the preceding token, not leading trivia of {. + // Preserve existing whitespace indentation when adding the newline. var openBrace = objectCreation.Initializer.OpenBraceToken; if (!openBrace.LeadingTrivia.Any(t => t.IsKind(SyntaxKind.EndOfLineTrivia))) { - // Add newline and proper indentation before the brace + var existingWhitespace = openBrace.LeadingTrivia + .LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia)); + + var whitespace = existingWhitespace.IsKind(SyntaxKind.WhitespaceTrivia) + ? existingWhitespace + : SyntaxFactory.Whitespace(" "); + openBrace = openBrace.WithLeadingTrivia( SyntaxFactory.EndOfLine("\n"), - SyntaxFactory.Whitespace(" ")); + whitespace); + } + + // For multi-type TheoryData, convert complex initializer expressions + // { val1, val2 } to tuple expressions (val1, val2) + var expressions = objectCreation.Initializer.Expressions; + if (conversion.IsMultiType) + { + expressions = SyntaxFactory.SeparatedList( + expressions.Select(expr => ConvertToTupleExpression(expr)), + expressions.GetSeparators()); } // Create array initializer from the collection initializer var newInitializer = SyntaxFactory.InitializerExpression( SyntaxKind.ArrayInitializerExpression, openBrace, - objectCreation.Initializer.Expressions, + expressions, objectCreation.Initializer.CloseBraceToken); // Build the array creation expression @@ -273,7 +295,7 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) } } - // Then, transform the type declaration from TheoryData to IEnumerable + // Then, transform the type declaration from TheoryData to IEnumerable or IEnumerable<(T1, T2)> if (conversion.TypeAnnotation != null) { var genericName = currentRoot.DescendantNodes() @@ -282,10 +304,13 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) if (genericName != null) { + var elementTypeSyntax = BuildElementType(conversion.ElementTypes); + var typeArgList = SyntaxFactory.TypeArgumentList( + SyntaxFactory.SingletonSeparatedList(elementTypeSyntax)); + var enumerableType = SyntaxFactory.GenericName( SyntaxFactory.Identifier("IEnumerable"), - SyntaxFactory.TypeArgumentList( - SyntaxFactory.SeparatedList(genericName.TypeArgumentList.Arguments))) + typeArgList) .WithLeadingTrivia(genericName.GetLeadingTrivia()) .WithTrailingTrivia(genericName.GetTrailingTrivia()); @@ -308,6 +333,47 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) return currentRoot; } + /// + /// Builds the element TypeSyntax from the stored type argument strings. + /// Single type: returns the type directly (e.g., "TimeSpan" → TimeSpan). + /// Multi type: returns a tuple type (e.g., ["string", "int"] → (string, int)). + /// + private static TypeSyntax BuildElementType(IReadOnlyList elementTypes) + { + if (elementTypes.Count == 1) + { + return SyntaxFactory.ParseTypeName(elementTypes[0]); + } + + return SyntaxFactory.TupleType( + SyntaxFactory.SeparatedList( + elementTypes.Select(t => + SyntaxFactory.TupleElement(SyntaxFactory.ParseTypeName(t))))); + } + + /// + /// Converts a complex initializer expression { val1, val2 } to a tuple expression (val1, val2). + /// For simple expressions (single-value), returns the expression unchanged. + /// + private static ExpressionSyntax ConvertToTupleExpression(ExpressionSyntax expression) + { + if (expression is not InitializerExpressionSyntax initializer + || !initializer.IsKind(SyntaxKind.ComplexElementInitializerExpression)) + { + return expression; + } + + var arguments = initializer.Expressions.Select( + expr => SyntaxFactory.Argument(expr.WithoutLeadingTrivia().WithoutTrailingTrivia())); + + var tupleExpression = SyntaxFactory.TupleExpression( + SyntaxFactory.SeparatedList(arguments)) + .WithLeadingTrivia(initializer.GetLeadingTrivia()) + .WithTrailingTrivia(initializer.GetTrailingTrivia()); + + return tupleExpression; + } + private CompilationUnitSyntax TransformAssertions(CompilationUnitSyntax root) { var currentRoot = root; diff --git a/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs b/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs index 9a13310ca0..68c50ac78b 100644 --- a/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs +++ b/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs @@ -1833,11 +1833,11 @@ protected override CompilationUnitSyntax AnalyzeTheoryData(CompilationUnitSyntax { try { - // Get the type argument - var typeArg = originalGeneric.TypeArgumentList.Arguments.FirstOrDefault(); - if (typeArg == null) continue; + // Get the type arguments + var typeArgs = originalGeneric.TypeArgumentList.Arguments; + if (typeArgs.Count == 0) continue; - var elementType = typeArg.ToString(); + var elementTypes = typeArgs.Select(t => t.ToString()).ToList(); // Create annotations for both the type and the object creation var typeAnnotation = new SyntaxAnnotation("TUnitMigration", Guid.NewGuid().ToString()); @@ -1845,7 +1845,7 @@ protected override CompilationUnitSyntax AnalyzeTheoryData(CompilationUnitSyntax var conversion = new TheoryDataConversion { - ElementType = elementType, + ElementTypes = elementTypes, TypeAnnotation = typeAnnotation, CreationAnnotation = creationAnnotation, OriginalText = originalGeneric.ToString() diff --git a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs index dcd7d31399..7b6a603d63 100644 --- a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs @@ -563,6 +563,148 @@ public class MyClass ); } + [Test] + public async Task TheoryData_MultiType_Is_Flagged() + { + await Verifier + .VerifyAnalyzerAsync( + """ + {|#0:using System; + + public class MyClass + { + public static TheoryData MyData + { + get + { + var data = new TheoryData + { + { "a", 1 }, + { "b", 2 } + }; + return data; + } + } + }|} + """, + ConfigureXUnitTest, + Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0) + ); + } + + [Test] + public async Task TheoryData_MultiType_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using Xunit; + + public class MyClass + { + public static readonly TheoryData Items = new() + { + { "a", 1 }, + { "b", 2 } + }; + }|} + """, + Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0), + """ + + public class MyClass + { + public static readonly IEnumerable<(string, int)> Items = new (string, int)[] + { + ("a", 1), + ("b", 2) + }; + } + """, + ConfigureXUnitTest + ); + } + + [Test] + public async Task TheoryData_MultiType_PropertyGetter_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using Xunit; + + public class MyClass + { + public static TheoryData MyData + { + get + { + var data = new TheoryData + { + { "a", 1 }, + { "b", 2 } + }; + return data; + } + } + }|} + """, + Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0), + """ + + public class MyClass + { + public static IEnumerable<(string, int)> MyData + { + get + { + var data = new (string, int)[] + { + ("a", 1), + ("b", 2) + }; + return data; + } + } + } + """, + ConfigureXUnitTest + ); + } + + [Test] + public async Task TheoryData_ThreeTypes_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using Xunit; + + public class MyClass + { + public static readonly TheoryData Items = new() + { + { "a", 1, true }, + { "b", 2, false } + }; + }|} + """, + Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0), + """ + + public class MyClass + { + public static readonly IEnumerable<(string, int, bool)> Items = new (string, int, bool)[] + { + ("a", 1, true), + ("b", 2, false) + }; + } + """, + ConfigureXUnitTest + ); + } + [Test] public async Task ITestOutputHelper_Is_Flagged() {