diff --git a/TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs b/TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs index 948163d8cc..45046abf86 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs @@ -439,7 +439,11 @@ private AttributeSyntax TransformTestCaseAttribute(AttributeSyntax attribute) return attribute; } - var newArgs = new List(); + // Separate positional and named arguments to ensure correct ordering: + // Positional args → ExpectedResult → Named properties (DisplayName, Skip, Categories) + var positionalArgs = new List(); + var namedArgs = new List(); + var categories = new List(); ExpressionSyntax? expectedValue = null; foreach (var arg in attribute.ArgumentList.Arguments) @@ -453,7 +457,7 @@ private AttributeSyntax TransformTestCaseAttribute(AttributeSyntax attribute) else if (namedProperty == null) { // Positional argument - keep it - newArgs.Add(arg); + positionalArgs.Add(arg); } else if (namedProperty == "Ignore" || namedProperty == "IgnoreReason") { @@ -462,27 +466,64 @@ private AttributeSyntax TransformTestCaseAttribute(AttributeSyntax attribute) SyntaxFactory.NameEquals(SyntaxFactory.IdentifierName("Skip")), null, arg.Expression); - newArgs.Add(skipArg); + namedArgs.Add(skipArg); } - else if (namedProperty is "TestName" or "Category" or "Description" or "Author" or "Explicit" or "ExplicitReason") + else if (namedProperty == "TestName") + { + // Map NUnit's TestName to TUnit's DisplayName inline on [Arguments] + var displayNameArg = SyntaxFactory.AttributeArgument( + SyntaxFactory.NameEquals(SyntaxFactory.IdentifierName("DisplayName")), + null, + arg.Expression); + namedArgs.Add(displayNameArg); + } + else if (namedProperty == "Category") + { + // Collect categories to create a Categories array + categories.Add(arg.Expression); + } + else if (namedProperty is "Description" or "Author" or "Explicit" or "ExplicitReason") { // These properties are converted to separate TUnit attributes by NUnitTestCasePropertyRewriter: - // TestName → [DisplayName], Category → [Category], Description/Author → [Property], Explicit → [Explicit] + // Description/Author → [Property], Explicit → [Explicit] // Skip them here - they don't belong in the [Arguments] attribute } // Other named arguments are preserved as-is (they might be TUnit-compatible) else { - newArgs.Add(arg); + namedArgs.Add(arg); } } - // Add expected value as last positional argument + // Build final argument list in correct order: + // 1. Positional arguments + // 2. ExpectedResult (as positional argument) + // 3. Named properties (DisplayName, Skip, Categories) + var newArgs = new List(positionalArgs); + + // Add expected value as last positional argument (before named properties) if (expectedValue != null) { newArgs.Add(SyntaxFactory.AttributeArgument(expectedValue)); } + // Add named arguments + newArgs.AddRange(namedArgs); + + // Add Categories array if any categories were found + if (categories.Count > 0) + { + var categoriesArray = SyntaxFactory.CollectionExpression( + SyntaxFactory.SeparatedList( + categories.Select(c => (CollectionElementSyntax)SyntaxFactory.ExpressionElement(c)))); + + var categoriesArg = SyntaxFactory.AttributeArgument( + SyntaxFactory.NameEquals(SyntaxFactory.IdentifierName("Categories")), + null, + categoriesArray); + newArgs.Add(categoriesArg); + } + var newAttribute = attribute.WithArgumentList( SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(newArgs))); diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs index 479dd6e152..a36e812241 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs @@ -84,6 +84,7 @@ protected override bool IsFrameworkAttribute(string attributeName) private AttributeArgumentListSyntax ConvertTestCaseArguments(AttributeArgumentListSyntax argumentList) { var newArgs = new List(); + var categories = new List(); foreach (var arg in argumentList.Arguments) { @@ -103,10 +104,24 @@ private AttributeArgumentListSyntax ConvertTestCaseArguments(AttributeArgumentLi arg.Expression); newArgs.Add(skipArg); } - else if (namedProperty is "TestName" or "Category" or "Description" or "Author" or "Explicit" or "ExplicitReason") + else if (namedProperty == "TestName") + { + // Map NUnit's TestName to TUnit's DisplayName inline on [Arguments] + var displayNameArg = SyntaxFactory.AttributeArgument( + SyntaxFactory.NameEquals(SyntaxFactory.IdentifierName("DisplayName")), + null, + arg.Expression); + newArgs.Add(displayNameArg); + } + else if (namedProperty == "Category") + { + // Collect categories to create a Categories array + categories.Add(arg.Expression); + } + else if (namedProperty is "Description" or "Author" or "Explicit" or "ExplicitReason") { // These properties are converted to separate TUnit attributes by NUnitTestCasePropertyRewriter: - // TestName → [DisplayName], Category → [Category], Description/Author → [Property], Explicit → [Explicit] + // Description/Author → [Property], Explicit → [Explicit] // Skip them here - they don't belong in the [Arguments] attribute } else if (namedProperty == "ExpectedResult") @@ -121,6 +136,20 @@ private AttributeArgumentListSyntax ConvertTestCaseArguments(AttributeArgumentLi } } + // Add Categories array if any categories were found + if (categories.Count > 0) + { + var categoriesArray = SyntaxFactory.CollectionExpression( + SyntaxFactory.SeparatedList( + categories.Select(c => (CollectionElementSyntax)SyntaxFactory.ExpressionElement(c)))); + + var categoriesArg = SyntaxFactory.AttributeArgument( + SyntaxFactory.NameEquals(SyntaxFactory.IdentifierName("Categories")), + null, + categoriesArray); + newArgs.Add(categoriesArg); + } + return SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(newArgs)); } diff --git a/TUnit.Analyzers.CodeFixers/NUnitTestCasePropertyRewriter.cs b/TUnit.Analyzers.CodeFixers/NUnitTestCasePropertyRewriter.cs index efa4242936..9ee760bbf8 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitTestCasePropertyRewriter.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitTestCasePropertyRewriter.cs @@ -7,7 +7,9 @@ namespace TUnit.Analyzers.CodeFixers; /// /// Extracts NUnit TestCase properties and converts them to TUnit attributes. -/// Maps: TestName → DisplayName, Category → Category, Description/Author → Property, Explicit → Explicit +/// Maps: Description/Author → Property, Explicit → Explicit +/// Note: TestName → DisplayName and Category → Categories are now handled inline on [Arguments] +/// by NUnitAttributeRewriter, so we don't generate separate attributes for those. /// public class NUnitTestCasePropertyRewriter : CSharpSyntaxRewriter { @@ -88,21 +90,8 @@ private TestCaseProperties ExtractProperties(List testCaseAttri switch (propertyName) { - case "TestName": - var testNameValue = GetStringValue(arg.Expression); - if (testNameValue != null) - { - properties.TestNames.Add(testNameValue); - } - break; - - case "Category": - var categoryValue = GetStringValue(arg.Expression); - if (categoryValue != null) - { - properties.Categories.Add(categoryValue); - } - break; + // Note: TestName and Category are now handled inline on [Arguments] by NUnitAttributeRewriter + // so we don't need to extract them here anymore. case "Description": var descValue = GetStringValue(arg.Expression); @@ -168,29 +157,8 @@ private SyntaxList GeneratePropertyAttributes( // Get indentation from existing attributes var indentation = GetIndentation(leadingTrivia); - // DisplayName from TestName (use first if multiple, or try to create pattern) - if (properties.TestNames.Count > 0) - { - var displayNameAttr = CreateDisplayNameAttribute(properties.TestNames); - if (displayNameAttr != null) - { - newLists.Add(CreateAttributeList(displayNameAttr, indentation)); - } - } - - // Category - add all unique categories - foreach (var category in properties.Categories.Distinct()) - { - var categoryAttr = SyntaxFactory.Attribute( - SyntaxFactory.IdentifierName("Category"), - SyntaxFactory.AttributeArgumentList( - SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.AttributeArgument( - SyntaxFactory.LiteralExpression( - SyntaxKind.StringLiteralExpression, - SyntaxFactory.Literal(category)))))); - newLists.Add(CreateAttributeList(categoryAttr, indentation)); - } + // Note: TestName → DisplayName and Category → Categories are now handled inline on [Arguments] + // by NUnitAttributeRewriter.ConvertTestCaseArguments, so we don't generate separate attributes here. // Description - use Property attribute if (properties.Descriptions.Count > 0) @@ -226,27 +194,6 @@ private SyntaxList GeneratePropertyAttributes( return SyntaxFactory.List(newLists); } - private AttributeSyntax? CreateDisplayNameAttribute(HashSet testNames) - { - // Only generate DisplayName if there's exactly one unique TestName - // Multiple different TestNames cannot be represented by a single DisplayName attribute - if (testNames.Count != 1) - { - return null; - } - - var displayName = testNames.First(); - - return SyntaxFactory.Attribute( - SyntaxFactory.IdentifierName("DisplayName"), - SyntaxFactory.AttributeArgumentList( - SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.AttributeArgument( - SyntaxFactory.LiteralExpression( - SyntaxKind.StringLiteralExpression, - SyntaxFactory.Literal(displayName)))))); - } - private AttributeSyntax CreatePropertyAttribute(string name, string value) { return SyntaxFactory.Attribute( @@ -288,8 +235,7 @@ private SyntaxTrivia GetIndentation(SyntaxTriviaList triviaList) private class TestCaseProperties { - public HashSet TestNames { get; } = new(); - public HashSet Categories { get; } = new(); + // Note: TestNames and Categories are now handled inline on [Arguments] by NUnitAttributeRewriter public HashSet Descriptions { get; } = new(); public HashSet Authors { get; } = new(); public bool IsExplicit { get; set; } diff --git a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs index de75e09671..38ed20b5f3 100644 --- a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs @@ -1079,7 +1079,7 @@ public async Task AdditionTest(int a, int b, int expected) [Test] public async Task NUnit_TestCase_TestName_Converted_To_DisplayName() { - // When multiple TestCases have the SAME TestName, it generates DisplayName + // TestName is now converted to inline DisplayName on each [Arguments] await CodeFixer.VerifyCodeFixAsync( """ using NUnit.Framework; @@ -1105,9 +1105,8 @@ public void MyTest(int value) public class MyClass { [Test] - [Arguments(1)] - [Arguments(2)] - [DisplayName("Test values")] + [Arguments(1, DisplayName = "Test values")] + [Arguments(2, DisplayName = "Test values")] public async Task MyTest(int value) { await Assert.That(value > 0).IsTrue(); @@ -1119,10 +1118,9 @@ public async Task MyTest(int value) } [Test] - public async Task NUnit_TestCase_Different_TestNames_Not_Converted() + public async Task NUnit_TestCase_Different_TestNames_Converted_Inline() { - // When multiple TestCases have DIFFERENT TestNames, no DisplayName is generated - // (user must manually adjust since a single DisplayName can't represent multiple values) + // Each TestCase's TestName is now converted to inline DisplayName on each [Arguments] await CodeFixer.VerifyCodeFixAsync( """ using NUnit.Framework; @@ -1148,8 +1146,8 @@ public void MyTest(int value) public class MyClass { [Test] - [Arguments(1)] - [Arguments(2)] + [Arguments(1, DisplayName = "Addition of one")] + [Arguments(2, DisplayName = "Addition of two")] public async Task MyTest(int value) { await Assert.That(value > 0).IsTrue(); @@ -1161,8 +1159,9 @@ public async Task MyTest(int value) } [Test] - public async Task NUnit_TestCase_Category_Converted_To_Category() + public async Task NUnit_TestCase_Category_Converted_To_Categories_Inline() { + // Category is now converted to inline Categories array on each [Arguments] await CodeFixer.VerifyCodeFixAsync( """ using NUnit.Framework; @@ -1188,10 +1187,8 @@ public void MyTest(int value) public class MyClass { [Test] - [Arguments(1)] - [Arguments(2)] - [Category("Unit")] - [Category("Integration")] + [Arguments(1, Categories = ["Unit"])] + [Arguments(2, Categories = ["Integration"])] public async Task MyTest(int value) { await Assert.That(value > 0).IsTrue(); @@ -1362,6 +1359,7 @@ public async Task MyTest(int value) [Test] public async Task NUnit_TestCase_MultipleProperties_All_Converted() { + // TestName and Category are inline on [Arguments], Description/Author become separate [Property] attributes await CodeFixer.VerifyCodeFixAsync( """ using NUnit.Framework; @@ -1386,9 +1384,7 @@ public void MyTest(int value) public class MyClass { [Test] - [Arguments(1)] - [DisplayName("Test One")] - [Category("Unit")] + [Arguments(1, DisplayName = "Test One", Categories = ["Unit"])] [Property("Description", "First test")] [Property("Author", "Jane")] public async Task MyTest(int value) @@ -1404,6 +1400,7 @@ public async Task MyTest(int value) [Test] public async Task NUnit_TestCase_WithExpectedResult_And_Properties_Converted() { + // TestName and Category are inline on [Arguments], ExpectedResult becomes extra parameter await CodeFixer.VerifyCodeFixAsync( """ using NUnit.Framework; @@ -1425,9 +1422,7 @@ public class MyClass public class MyClass { [Test] - [Arguments(2, 3, 5)] - [DisplayName("Add small numbers")] - [Category("Math")] + [Arguments(2, 3, 5, DisplayName = "Add small numbers", Categories = ["Math"])] public async Task Add(int a, int b, int expected) { await Assert.That(a + b).IsEqualTo(expected); @@ -1438,6 +1433,171 @@ public async Task Add(int a, int b, int expected) ); } + [Test] + public async Task NUnit_TestCase_Ignore_Converted_To_Skip_Inline() + { + // Ignore is converted to inline Skip on each [Arguments] + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [TestCase(1, Ignore = "Needs fixing")] + [TestCase(2, Ignore = "Also broken")] + public void MyTest(int value) + { + Assert.That(value > 0, Is.True); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + [Arguments(1, Skip = "Needs fixing")] + [Arguments(2, Skip = "Also broken")] + public async Task MyTest(int value) + { + await Assert.That(value > 0).IsTrue(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_TestCase_IgnoreReason_Converted_To_Skip_Inline() + { + // IgnoreReason is converted to inline Skip on each [Arguments] + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [TestCase(1, IgnoreReason = "Under development")] + public void MyTest(int value) + { + Assert.That(value > 0, Is.True); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + [Arguments(1, Skip = "Under development")] + public async Task MyTest(int value) + { + await Assert.That(value > 0).IsTrue(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_TestCase_AllInlineProperties_Converted() + { + // Test with all inline properties: DisplayName, Skip, and Categories + // Note: The order of named properties in output follows the order in the source + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [TestCase(1, TestName = "Test One", Category = "Unit", Ignore = "Temporarily disabled")] + [TestCase(2, TestName = "Test Two", Category = "Integration", Ignore = "WIP")] + public void MyTest(int value) + { + Assert.That(value > 0, Is.True); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + [Arguments(1, DisplayName = "Test One", Skip = "Temporarily disabled", Categories = ["Unit"])] + [Arguments(2, DisplayName = "Test Two", Skip = "WIP", Categories = ["Integration"])] + public async Task MyTest(int value) + { + await Assert.That(value > 0).IsTrue(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_TestCase_AllProperties_Comprehensive_Converted() + { + // Comprehensive test with ALL properties - inline (TestName→DisplayName, Category→Categories, Ignore→Skip) + // and separate attributes (Description→Property, Author→Property, Explicit→Explicit, ExplicitReason→Explicit+Property) + // Note: The order of named properties in output follows the order in the source + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [TestCase(1, TestName = "Full featured test", Category = "Comprehensive", Ignore = "Testing migration", Description = "A complete test case", Author = "Developer")] + public void MyTest(int value) + { + Assert.That(value > 0, Is.True); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + [Arguments(1, DisplayName = "Full featured test", Skip = "Testing migration", Categories = ["Comprehensive"])] + [Property("Description", "A complete test case")] + [Property("Author", "Developer")] + public async Task MyTest(int value) + { + await Assert.That(value > 0).IsTrue(); + } + } + """, + ConfigureNUnitTest + ); + } + private static void ConfigureNUnitTest(Verifier.Test test) { test.TestState.AdditionalReferences.Add(typeof(NUnit.Framework.TestAttribute).Assembly);