From 0d41155ca542da658f7d199408e0d80ba3d035e4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:34:42 +0000 Subject: [PATCH 1/7] feat: add NUnit FileAssert, DirectoryAssert, and ExpectedException support - Add FileAssert conversion (Exists, DoesNotExist, AreEqual, AreNotEqual) - Add DirectoryAssert conversion (Exists, DoesNotExist, AreEqual, AreNotEqual) - Add ExpectedException attribute conversion to Assert.ThrowsAsync() - Automatically add System.IO using when File/Directory classes are used - Support async lambdas in ExpectedException conversion when await expressions exist Co-Authored-By: Claude Opus 4.5 --- .../NUnitMigrationCodeFixProvider.cs | 388 +++++++++++++++++- .../NUnitMigrationAnalyzerTests.cs | 279 +++++++++++++ .../Migrators/Base/MigrationHelpers.cs | 26 ++ 3 files changed, 687 insertions(+), 6 deletions(-) diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs index da216b04de..a2835bc719 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs @@ -46,6 +46,10 @@ protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(Compi var expectedResultRewriter = new NUnitExpectedResultRewriter(semanticModel); compilationUnit = (CompilationUnitSyntax)expectedResultRewriter.Visit(compilationUnit); + // Handle [ExpectedException] attribute conversion + var expectedExceptionRewriter = new NUnitExpectedExceptionRewriter(); + compilationUnit = (CompilationUnitSyntax)expectedExceptionRewriter.Visit(compilationUnit); + return compilationUnit; } @@ -74,7 +78,9 @@ protected override bool IsFrameworkAttribute(string attributeName) // Parameter-level data attributes (converted to Matrix/MatrixRange) "Values" or "Range" or "ValueSource" or // Combinatorial strategy attributes - "Sequential" or "Combinatorial" => true, + "Sequential" or "Combinatorial" or + // Exception handling attribute (converted to Assert.ThrowsAsync) + "ExpectedException" => true, _ => false }; } @@ -517,11 +523,25 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName) protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation) { + // Handle FileAssert - check BEFORE IsFrameworkAssertion since FileAssert is a separate class + if (invocation.Expression is MemberAccessExpressionSyntax fileAccess && + fileAccess.Expression is IdentifierNameSyntax { Identifier.Text: "FileAssert" }) + { + return ConvertFileAssertion(invocation, fileAccess.Name.Identifier.Text); + } + + // Handle DirectoryAssert - check BEFORE IsFrameworkAssertion since DirectoryAssert is a separate class + if (invocation.Expression is MemberAccessExpressionSyntax directoryAccess && + directoryAccess.Expression is IdentifierNameSyntax { Identifier.Text: "DirectoryAssert" }) + { + return ConvertDirectoryAssertion(invocation, directoryAccess.Name.Identifier.Text); + } + if (!IsFrameworkAssertion(invocation)) { return null; } - + // Handle Assert.That(value, constraint) if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && memberAccess.Name.Identifier.Text == "That" && @@ -529,14 +549,14 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName) { return ConvertAssertThat(invocation); } - + // Handle classic assertions like Assert.AreEqual, ClassicAssert.AreEqual, etc. if (invocation.Expression is MemberAccessExpressionSyntax classicMemberAccess && classicMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Assert" or "ClassicAssert" }) { return ConvertClassicAssertion(invocation, classicMemberAccess.Name.Identifier.Text); } - + return null; } @@ -1419,6 +1439,196 @@ private ExpressionSyntax CreateSkipAssertion(SeparatedSyntaxList return skipInvocation; } + + private ExpressionSyntax? ConvertFileAssertion(InvocationExpressionSyntax invocation, string methodName) + { + var arguments = invocation.ArgumentList.Arguments; + + // FileAssert.Exists(path) -> Assert.That(File.Exists(path)).IsTrue() + // FileAssert.DoesNotExist(path) -> Assert.That(File.Exists(path)).IsFalse() + // FileAssert.AreEqual(expected, actual) -> Assert.That(File.ReadAllBytes(actual)).IsEquivalentTo(File.ReadAllBytes(expected)) + // FileAssert.AreNotEqual(expected, actual) -> Assert.That(File.ReadAllBytes(actual)).IsNotEquivalentTo(File.ReadAllBytes(expected)) + + return methodName switch + { + "Exists" when arguments.Count >= 1 => CreateFileExistsAssertion(arguments[0].Expression, isNegated: false), + "DoesNotExist" when arguments.Count >= 1 => CreateFileExistsAssertion(arguments[0].Expression, isNegated: true), + "AreEqual" when arguments.Count >= 2 => CreateFileAreEqualAssertion(arguments[0].Expression, arguments[1].Expression, isNegated: false), + "AreNotEqual" when arguments.Count >= 2 => CreateFileAreEqualAssertion(arguments[0].Expression, arguments[1].Expression, isNegated: true), + _ => null + }; + } + + private ExpressionSyntax CreateFileExistsAssertion(ExpressionSyntax pathOrFileInfo, bool isNegated) + { + // Create: File.Exists(path) or fileInfo.Exists + ExpressionSyntax existsCheck; + + // If it's a string literal or string-like expression, use File.Exists(path) + // If it's a FileInfo, use fileInfo.Exists + if (pathOrFileInfo is LiteralExpressionSyntax || + pathOrFileInfo.ToString().EndsWith("Path", StringComparison.OrdinalIgnoreCase)) + { + // File.Exists(path) + existsCheck = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("File"), + SyntaxFactory.IdentifierName("Exists")), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(pathOrFileInfo)))); + } + else + { + // Assume it's a FileInfo - use .Exists property + existsCheck = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + pathOrFileInfo, + SyntaxFactory.IdentifierName("Exists")); + } + + var assertionMethod = isNegated ? "IsFalse" : "IsTrue"; + return CreateTUnitAssertion(assertionMethod, existsCheck); + } + + private ExpressionSyntax CreateFileAreEqualAssertion(ExpressionSyntax expected, ExpressionSyntax actual, bool isNegated) + { + // Create: File.ReadAllBytes(expected) and File.ReadAllBytes(actual) + // Then: Assert.That(actualBytes).IsEquivalentTo(expectedBytes) + ExpressionSyntax expectedBytes = CreateFileReadAllBytes(expected); + ExpressionSyntax actualBytes = CreateFileReadAllBytes(actual); + + var assertionMethod = isNegated ? "IsNotEquivalentTo" : "IsEquivalentTo"; + return CreateTUnitAssertion(assertionMethod, actualBytes, SyntaxFactory.Argument(expectedBytes)); + } + + private static ExpressionSyntax CreateFileReadAllBytes(ExpressionSyntax pathOrFileInfo) + { + // If it's a FileInfo, use fileInfo.FullName + ExpressionSyntax path; + if (pathOrFileInfo is LiteralExpressionSyntax || + pathOrFileInfo.ToString().EndsWith("Path", StringComparison.OrdinalIgnoreCase)) + { + path = pathOrFileInfo; + } + else + { + // Assume it's a FileInfo - use .FullName property + path = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + pathOrFileInfo, + SyntaxFactory.IdentifierName("FullName")); + } + + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("File"), + SyntaxFactory.IdentifierName("ReadAllBytes")), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(path)))); + } + + private ExpressionSyntax? ConvertDirectoryAssertion(InvocationExpressionSyntax invocation, string methodName) + { + var arguments = invocation.ArgumentList.Arguments; + + // DirectoryAssert.Exists(path) -> Assert.That(Directory.Exists(path)).IsTrue() + // DirectoryAssert.DoesNotExist(path) -> Assert.That(Directory.Exists(path)).IsFalse() + // DirectoryAssert.AreEqual(expected, actual) -> Assert.That(Directory.GetFiles(actual)).IsEquivalentTo(Directory.GetFiles(expected)) + // DirectoryAssert.AreNotEqual(expected, actual) -> Assert.That(Directory.GetFiles(actual)).IsNotEquivalentTo(Directory.GetFiles(expected)) + + return methodName switch + { + "Exists" when arguments.Count >= 1 => CreateDirectoryExistsAssertion(arguments[0].Expression, isNegated: false), + "DoesNotExist" when arguments.Count >= 1 => CreateDirectoryExistsAssertion(arguments[0].Expression, isNegated: true), + "AreEqual" when arguments.Count >= 2 => CreateDirectoryAreEqualAssertion(arguments[0].Expression, arguments[1].Expression, isNegated: false), + "AreNotEqual" when arguments.Count >= 2 => CreateDirectoryAreEqualAssertion(arguments[0].Expression, arguments[1].Expression, isNegated: true), + _ => null + }; + } + + private ExpressionSyntax CreateDirectoryExistsAssertion(ExpressionSyntax pathOrDirectoryInfo, bool isNegated) + { + // Create: Directory.Exists(path) or directoryInfo.Exists + ExpressionSyntax existsCheck; + + // If it's a string literal or string-like expression, use Directory.Exists(path) + // If it's a DirectoryInfo, use directoryInfo.Exists + if (pathOrDirectoryInfo is LiteralExpressionSyntax || + pathOrDirectoryInfo.ToString().EndsWith("Path", StringComparison.OrdinalIgnoreCase)) + { + // Directory.Exists(path) + existsCheck = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("Directory"), + SyntaxFactory.IdentifierName("Exists")), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(pathOrDirectoryInfo)))); + } + else + { + // Assume it's a DirectoryInfo - use .Exists property + existsCheck = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + pathOrDirectoryInfo, + SyntaxFactory.IdentifierName("Exists")); + } + + var assertionMethod = isNegated ? "IsFalse" : "IsTrue"; + return CreateTUnitAssertion(assertionMethod, existsCheck); + } + + private ExpressionSyntax CreateDirectoryAreEqualAssertion(ExpressionSyntax expected, ExpressionSyntax actual, bool isNegated) + { + // Create: Directory.GetFiles(expected, "*", SearchOption.AllDirectories) and same for actual + // This compares directory contents + // Note: NUnit's DirectoryAssert.AreEqual is complex - it compares directory contents recursively + // We'll use a simpler approach: compare file lists + ExpressionSyntax expectedFiles = CreateDirectoryGetFiles(expected); + ExpressionSyntax actualFiles = CreateDirectoryGetFiles(actual); + + var assertionMethod = isNegated ? "IsNotEquivalentTo" : "IsEquivalentTo"; + return CreateTUnitAssertion(assertionMethod, actualFiles, SyntaxFactory.Argument(expectedFiles)); + } + + private static ExpressionSyntax CreateDirectoryGetFiles(ExpressionSyntax pathOrDirectoryInfo) + { + // If it's a DirectoryInfo, use directoryInfo.FullName + ExpressionSyntax path; + if (pathOrDirectoryInfo is LiteralExpressionSyntax || + pathOrDirectoryInfo.ToString().EndsWith("Path", StringComparison.OrdinalIgnoreCase)) + { + path = pathOrDirectoryInfo; + } + else + { + // Assume it's a DirectoryInfo - use .FullName property + path = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + pathOrDirectoryInfo, + SyntaxFactory.IdentifierName("FullName")); + } + + // Directory.GetFiles(path, "*", SearchOption.AllDirectories) + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("Directory"), + SyntaxFactory.IdentifierName("GetFiles")), + SyntaxFactory.ArgumentList( + SyntaxFactory.SeparatedList(new[] + { + SyntaxFactory.Argument(path), + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal("*"))), + SyntaxFactory.Argument( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("SearchOption"), + SyntaxFactory.IdentifierName("AllDirectories"))) + }))); + } } public class NUnitBaseTypeRewriter : CSharpSyntaxRewriter @@ -1440,12 +1650,178 @@ public class NUnitLifecycleRewriter : CSharpSyntaxRewriter var hasLifecycleAttribute = node.AttributeLists .SelectMany(al => al.Attributes) .Any(a => a.Name.ToString() is "Before" or "After"); - + if (hasLifecycleAttribute && !node.Modifiers.Any(SyntaxKind.PublicKeyword)) { return node.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); } - + return base.VisitMethodDeclaration(node); } +} + +/// +/// Handles NUnit [ExpectedException(typeof(T))] attribute conversion by: +/// 1. Removing the attribute +/// 2. Wrapping the method body in Assert.ThrowsAsync<T>() +/// +public class NUnitExpectedExceptionRewriter : CSharpSyntaxRewriter +{ + public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) + { + // Find [ExpectedException] attribute + var expectedExceptionAttr = node.AttributeLists + .SelectMany(al => al.Attributes) + .FirstOrDefault(a => MigrationHelpers.GetAttributeName(a) == "ExpectedException"); + + if (expectedExceptionAttr == null) + { + return base.VisitMethodDeclaration(node); + } + + // Extract the exception type from typeof(ExceptionType) + var exceptionType = ExtractExceptionType(expectedExceptionAttr); + if (exceptionType == null) + { + // Can't extract exception type, leave as-is with a TODO comment + return node; + } + + // Remove the [ExpectedException] attribute + var newAttributeLists = RemoveExpectedExceptionAttribute(node.AttributeLists); + + // Wrap the method body in Assert.ThrowsAsync() + var newBody = WrapBodyInThrowsAsync(node.Body, node.ExpressionBody, exceptionType); + + if (newBody == null) + { + return node.WithAttributeLists(SyntaxFactory.List(newAttributeLists)); + } + + return node + .WithAttributeLists(SyntaxFactory.List(newAttributeLists)) + .WithBody(newBody) + .WithExpressionBody(null) + .WithSemicolonToken(default); + } + + private static TypeSyntax? ExtractExceptionType(AttributeSyntax attribute) + { + if (attribute.ArgumentList == null || attribute.ArgumentList.Arguments.Count == 0) + { + return null; + } + + var firstArg = attribute.ArgumentList.Arguments[0].Expression; + + // Handle typeof(ExceptionType) + if (firstArg is TypeOfExpressionSyntax typeOfExpr) + { + return typeOfExpr.Type; + } + + return null; + } + + private static List RemoveExpectedExceptionAttribute(SyntaxList attributeLists) + { + var result = new List(); + + foreach (var attrList in attributeLists) + { + var remainingAttrs = attrList.Attributes + .Where(a => MigrationHelpers.GetAttributeName(a) != "ExpectedException") + .ToList(); + + if (remainingAttrs.Count > 0) + { + result.Add(attrList.WithAttributes(SyntaxFactory.SeparatedList(remainingAttrs))); + } + } + + return result; + } + + private static BlockSyntax? WrapBodyInThrowsAsync(BlockSyntax? body, ArrowExpressionClauseSyntax? expressionBody, TypeSyntax exceptionType) + { + StatementSyntax[] originalStatements; + + if (body != null) + { + originalStatements = body.Statements.ToArray(); + } + else if (expressionBody != null) + { + // Convert expression body to a statement + originalStatements = [SyntaxFactory.ExpressionStatement(expressionBody.Expression)]; + } + else + { + return null; + } + + // Check if the original statements contain any await expressions + var hasAwait = originalStatements.Any(s => s.DescendantNodes().OfType().Any()); + + // Create: await Assert.ThrowsAsync(() => { original statements }); + // or: await Assert.ThrowsAsync(async () => { original statements }); if async + // Add extra indentation for statements inside the lambda block (4 more spaces) + var indentedStatements = originalStatements.Select(s => + { + var existingTrivia = s.GetLeadingTrivia(); + var newTrivia = existingTrivia.Add(SyntaxFactory.Whitespace(" ")); + return s.WithLeadingTrivia(newTrivia); + }).ToArray(); + var lambdaBody = SyntaxFactory.Block(indentedStatements) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken) + .WithLeadingTrivia(SyntaxFactory.Whitespace(" ")) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken) + .WithLeadingTrivia(SyntaxFactory.Whitespace(" "))); + ParenthesizedLambdaExpressionSyntax lambda; + + lambda = SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(), + lambdaBody + ).WithArrowToken(SyntaxFactory.Token(SyntaxKind.EqualsGreaterThanToken) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)); + + if (hasAwait) + { + // Need async lambda for await expressions + lambda = lambda.WithAsyncKeyword(SyntaxFactory.Token(SyntaxKind.AsyncKeyword) + .WithTrailingTrivia(SyntaxFactory.Space)); + } + + var throwsAsyncCall = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("Assert"), + SyntaxFactory.GenericName("ThrowsAsync") + .WithTypeArgumentList( + SyntaxFactory.TypeArgumentList( + SyntaxFactory.SingletonSeparatedList(exceptionType.WithoutTrivia()) + ) + ) + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(lambda) + ) + ) + ); + + var awaitExpression = SyntaxFactory.AwaitExpression(throwsAsyncCall); + + var newStatement = SyntaxFactory.ExpressionStatement(awaitExpression) + .WithLeadingTrivia(SyntaxFactory.TriviaList( + SyntaxFactory.Whitespace(" "))) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); + + return SyntaxFactory.Block(newStatement) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken) + .WithLeadingTrivia(SyntaxFactory.Whitespace(" "))); + } } \ No newline at end of file diff --git a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs index f479882801..6976588ec0 100644 --- a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs @@ -3249,6 +3249,285 @@ public async Task TestMethod([Matrix(1, 2)] int a, [Matrix("x", "y")] string b) ); } + [Test] + public async Task NUnit_FileAssert_Exists_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void TestMethod() + { + FileAssert.Exists("test.txt"); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.IO; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.That(File.Exists("test.txt")).IsTrue(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_FileAssert_DoesNotExist_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void TestMethod() + { + FileAssert.DoesNotExist("test.txt"); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.IO; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.That(File.Exists("test.txt")).IsFalse(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_FileAssert_AreEqual_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void TestMethod() + { + FileAssert.AreEqual("expected.txt", "actual.txt"); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.IO; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.That(File.ReadAllBytes("actual.txt")).IsEquivalentTo(File.ReadAllBytes("expected.txt")); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_DirectoryAssert_Exists_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void TestMethod() + { + DirectoryAssert.Exists("testDir"); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.IO; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.That(Directory.Exists("testDir")).IsTrue(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_DirectoryAssert_DoesNotExist_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void TestMethod() + { + DirectoryAssert.DoesNotExist("testDir"); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.IO; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.That(Directory.Exists("testDir")).IsFalse(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_ExpectedException_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using System; + using NUnit.Framework; + + public class MyClass + { + {|#0:[Test]|} + [ExpectedException(typeof(InvalidOperationException))] + public void TestMethod() + { + throw new InvalidOperationException(); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.ThrowsAsync(() => + { + throw new InvalidOperationException(); + }); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_ExpectedException_With_Async_Code_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using System; + using System.Threading.Tasks; + using NUnit.Framework; + + public class MyClass + { + {|#0:[Test]|} + [ExpectedException(typeof(InvalidOperationException))] + public async Task TestMethod() + { + await Task.Delay(1); + throw new InvalidOperationException(); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.ThrowsAsync(async () => + { + await Task.Delay(1); + throw new InvalidOperationException(); + }); + } + } + """, + ConfigureNUnitTest + ); + } + private static void ConfigureNUnitTest(Verifier.Test test) { test.TestState.AdditionalReferences.Add(typeof(NUnit.Framework.TestAttribute).Assembly); diff --git a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs index 28ee89cdb6..8846f63884 100644 --- a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs +++ b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs @@ -231,6 +231,9 @@ public static CompilationUnitSyntax AddTUnitUsings(CompilationUnitSyntax compila // First add System.Threading.Tasks if needed compilationUnit = AddSystemThreadingTasksUsing(compilationUnit); + // Add System.IO if File. or Directory. is used (from FileAssert/DirectoryAssert conversion) + compilationUnit = AddSystemIOUsing(compilationUnit); + var existingUsings = compilationUnit.Usings.ToList(); if (!existingUsings.Any(u => u.Name?.ToString() == "TUnit.Core")) @@ -256,4 +259,27 @@ public static CompilationUnitSyntax AddTUnitUsings(CompilationUnitSyntax compila return compilationUnit.WithUsings(SyntaxFactory.List(existingUsings)); } + + /// + /// Adds System.IO using directive if the code contains File. or Directory. references. + /// This is needed when FileAssert or DirectoryAssert is converted to use File/Directory classes. + /// + public static CompilationUnitSyntax AddSystemIOUsing(CompilationUnitSyntax compilationUnit) + { + var existingUsings = compilationUnit.Usings.ToList(); + + // Check if code contains File. or Directory. member access + bool hasFileOrDirectoryCode = compilationUnit.DescendantNodes() + .OfType() + .Any(m => m.Expression is IdentifierNameSyntax { Identifier.Text: "File" or "Directory" }); + + if (hasFileOrDirectoryCode && !existingUsings.Any(u => u.Name?.ToString() == "System.IO")) + { + var ioUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.IO")); + existingUsings.Insert(0, ioUsing); // Insert at beginning to keep System.* namespaces together + return compilationUnit.WithUsings(SyntaxFactory.List(existingUsings)); + } + + return compilationUnit; + } } \ No newline at end of file From 18eeea006ee6c5a877513b4ae40cd7bfb5daca74 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:49:36 +0000 Subject: [PATCH 2/7] fix: add TODO comment warning about DirectoryAssert.AreEqual semantic difference Address review feedback: NUnit's DirectoryAssert.AreEqual compares both file paths AND file contents recursively. The TUnit migration only compares file paths. Add a TODO comment to warn users about this limitation. Co-Authored-By: Claude Opus 4.5 --- .../NUnitMigrationCodeFixProvider.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs index a2835bc719..f8f38910da 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs @@ -1590,7 +1590,13 @@ private ExpressionSyntax CreateDirectoryAreEqualAssertion(ExpressionSyntax expec ExpressionSyntax actualFiles = CreateDirectoryGetFiles(actual); var assertionMethod = isNegated ? "IsNotEquivalentTo" : "IsEquivalentTo"; - return CreateTUnitAssertion(assertionMethod, actualFiles, SyntaxFactory.Argument(expectedFiles)); + var assertion = CreateTUnitAssertion(assertionMethod, actualFiles, SyntaxFactory.Argument(expectedFiles)); + + // Add a TODO comment warning about the semantic difference + return assertion.WithLeadingTrivia( + SyntaxFactory.Comment("// TODO: TUnit migration - This only compares file paths, not file contents. NUnit's DirectoryAssert.AreEqual also compared contents."), + SyntaxFactory.EndOfLine("\n"), + SyntaxFactory.Whitespace(" ")); } private static ExpressionSyntax CreateDirectoryGetFiles(ExpressionSyntax pathOrDirectoryInfo) From 0ed12b8cdbcac419d05e1fded5f711d9d32983fa Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:04:36 +0000 Subject: [PATCH 3/7] feat: add FileInfo/DirectoryInfo assertions and simplify migration - Add FileInfo.HasSameContentAs(other) assertion for binary file comparison - Add DirectoryInfo.HasSameStructureAs(other) for path structure comparison - Add DirectoryInfo.IsEquivalentTo(other) for full directory equivalence - Add DirectoryInfo.IsNotEquivalentTo(other) negated version - Simplify NUnit FileAssert/DirectoryAssert migration to use new assertions - Update AddSystemIOUsing to detect FileInfo/DirectoryInfo object creation Co-Authored-By: Claude Opus 4.5 --- .../NUnitMigrationCodeFixProvider.cs | 104 +++---- .../NUnitMigrationAnalyzerTests.cs | 40 ++- .../Migrators/Base/MigrationHelpers.cs | 14 +- .../Conditions/FileSystemAssertions.cs | 271 ++++++++++++++++++ 4 files changed, 375 insertions(+), 54 deletions(-) diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs index f8f38910da..c5636347f3 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs @@ -1493,13 +1493,42 @@ private ExpressionSyntax CreateFileExistsAssertion(ExpressionSyntax pathOrFileIn private ExpressionSyntax CreateFileAreEqualAssertion(ExpressionSyntax expected, ExpressionSyntax actual, bool isNegated) { - // Create: File.ReadAllBytes(expected) and File.ReadAllBytes(actual) - // Then: Assert.That(actualBytes).IsEquivalentTo(expectedBytes) - ExpressionSyntax expectedBytes = CreateFileReadAllBytes(expected); - ExpressionSyntax actualBytes = CreateFileReadAllBytes(actual); + // NUnit's FileAssert.AreEqual compares file contents + // Generate: Assert.That(new FileInfo(actual)).HasSameContentAs(new FileInfo(expected)) - var assertionMethod = isNegated ? "IsNotEquivalentTo" : "IsEquivalentTo"; - return CreateTUnitAssertion(assertionMethod, actualBytes, SyntaxFactory.Argument(expectedBytes)); + var actualFileInfo = CreateFileInfoExpression(actual); + var expectedFileInfo = CreateFileInfoExpression(expected); + + // Note: HasSameContentAs doesn't have a negated version in TUnit.Assertions yet + // For now, use IsEquivalentTo on byte arrays for negated case + if (isNegated) + { + // Fall back to byte comparison for the negated case + var actualBytes = CreateFileReadAllBytes(actual); + var expectedBytes = CreateFileReadAllBytes(expected); + return CreateTUnitAssertion("IsNotEquivalentTo", actualBytes, SyntaxFactory.Argument(expectedBytes)); + } + + return CreateTUnitAssertion("HasSameContentAs", actualFileInfo, SyntaxFactory.Argument(expectedFileInfo)); + } + + private static ExpressionSyntax CreateFileInfoExpression(ExpressionSyntax pathOrFileInfo) + { + // If it's already a FileInfo (not a string path), use it directly + // If the expression is a string literal or looks like a path variable, wrap it + if (pathOrFileInfo is LiteralExpressionSyntax || + pathOrFileInfo.ToString().EndsWith("Path", StringComparison.OrdinalIgnoreCase) || + pathOrFileInfo.ToString().Contains("path", StringComparison.OrdinalIgnoreCase)) + { + // Create: new FileInfo(path) + return SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName("FileInfo")) + .WithArgumentList(SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(pathOrFileInfo)))); + } + + // Assume it's already a FileInfo or can be used as-is + return pathOrFileInfo; } private static ExpressionSyntax CreateFileReadAllBytes(ExpressionSyntax pathOrFileInfo) @@ -1582,59 +1611,36 @@ private ExpressionSyntax CreateDirectoryExistsAssertion(ExpressionSyntax pathOrD private ExpressionSyntax CreateDirectoryAreEqualAssertion(ExpressionSyntax expected, ExpressionSyntax actual, bool isNegated) { - // Create: Directory.GetFiles(expected, "*", SearchOption.AllDirectories) and same for actual - // This compares directory contents - // Note: NUnit's DirectoryAssert.AreEqual is complex - it compares directory contents recursively - // We'll use a simpler approach: compare file lists - ExpressionSyntax expectedFiles = CreateDirectoryGetFiles(expected); - ExpressionSyntax actualFiles = CreateDirectoryGetFiles(actual); + // NUnit's DirectoryAssert.AreEqual compares both file structure AND file contents + // Generate: Assert.That(new DirectoryInfo(actual)).IsEquivalentTo(new DirectoryInfo(expected)) - var assertionMethod = isNegated ? "IsNotEquivalentTo" : "IsEquivalentTo"; - var assertion = CreateTUnitAssertion(assertionMethod, actualFiles, SyntaxFactory.Argument(expectedFiles)); + var actualDirectoryInfo = CreateDirectoryInfoExpression(actual); + var expectedDirectoryInfo = CreateDirectoryInfoExpression(expected); - // Add a TODO comment warning about the semantic difference - return assertion.WithLeadingTrivia( - SyntaxFactory.Comment("// TODO: TUnit migration - This only compares file paths, not file contents. NUnit's DirectoryAssert.AreEqual also compared contents."), - SyntaxFactory.EndOfLine("\n"), - SyntaxFactory.Whitespace(" ")); + var assertionMethod = isNegated ? "IsNotEquivalentTo" : "IsEquivalentTo"; + return CreateTUnitAssertion(assertionMethod, actualDirectoryInfo, SyntaxFactory.Argument(expectedDirectoryInfo)); } - private static ExpressionSyntax CreateDirectoryGetFiles(ExpressionSyntax pathOrDirectoryInfo) + private static ExpressionSyntax CreateDirectoryInfoExpression(ExpressionSyntax pathOrDirectoryInfo) { - // If it's a DirectoryInfo, use directoryInfo.FullName - ExpressionSyntax path; + // If it's already a DirectoryInfo (not a string path), use it directly + // We can't easily determine the type at syntax level, so we wrap in new DirectoryInfo() for string paths + // If the expression is a string literal or looks like a path variable, wrap it if (pathOrDirectoryInfo is LiteralExpressionSyntax || - pathOrDirectoryInfo.ToString().EndsWith("Path", StringComparison.OrdinalIgnoreCase)) + pathOrDirectoryInfo.ToString().EndsWith("Path", StringComparison.OrdinalIgnoreCase) || + pathOrDirectoryInfo.ToString().Contains("path", StringComparison.OrdinalIgnoreCase)) { - path = pathOrDirectoryInfo; - } - else - { - // Assume it's a DirectoryInfo - use .FullName property - path = SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - pathOrDirectoryInfo, - SyntaxFactory.IdentifierName("FullName")); + // Create: new DirectoryInfo(path) + return SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName("DirectoryInfo")) + .WithArgumentList(SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(pathOrDirectoryInfo)))); } - // Directory.GetFiles(path, "*", SearchOption.AllDirectories) - return SyntaxFactory.InvocationExpression( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - SyntaxFactory.IdentifierName("Directory"), - SyntaxFactory.IdentifierName("GetFiles")), - SyntaxFactory.ArgumentList( - SyntaxFactory.SeparatedList(new[] - { - SyntaxFactory.Argument(path), - SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal("*"))), - SyntaxFactory.Argument( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - SyntaxFactory.IdentifierName("SearchOption"), - SyntaxFactory.IdentifierName("AllDirectories"))) - }))); + // Assume it's already a DirectoryInfo or can be used as-is + return pathOrDirectoryInfo; } + } public class NUnitBaseTypeRewriter : CSharpSyntaxRewriter diff --git a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs index 6976588ec0..db8d9bccf8 100644 --- a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs @@ -3355,7 +3355,7 @@ public class MyClass [Test] public async Task TestMethod() { - await Assert.That(File.ReadAllBytes("actual.txt")).IsEquivalentTo(File.ReadAllBytes("expected.txt")); + await Assert.That(new FileInfo("actual.txt")).HasSameContentAs(new FileInfo("expected.txt")); } } """, @@ -3439,6 +3439,44 @@ public async Task TestMethod() ); } + [Test] + public async Task NUnit_DirectoryAssert_AreEqual_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void TestMethod() + { + DirectoryAssert.AreEqual("expectedDir", "actualDir"); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.IO; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.That(new DirectoryInfo("actualDir")).IsEquivalentTo(new DirectoryInfo("expectedDir")); + } + } + """, + ConfigureNUnitTest + ); + } + [Test] public async Task NUnit_ExpectedException_Converted() { diff --git a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs index 8846f63884..6c6bf05070 100644 --- a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs +++ b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs @@ -261,19 +261,25 @@ public static CompilationUnitSyntax AddTUnitUsings(CompilationUnitSyntax compila } /// - /// Adds System.IO using directive if the code contains File. or Directory. references. - /// This is needed when FileAssert or DirectoryAssert is converted to use File/Directory classes. + /// Adds System.IO using directive if the code contains File, Directory, FileInfo, or DirectoryInfo references. + /// This is needed when FileAssert or DirectoryAssert is converted to use System.IO classes. /// public static CompilationUnitSyntax AddSystemIOUsing(CompilationUnitSyntax compilationUnit) { var existingUsings = compilationUnit.Usings.ToList(); // Check if code contains File. or Directory. member access - bool hasFileOrDirectoryCode = compilationUnit.DescendantNodes() + bool hasFileOrDirectoryMemberAccess = compilationUnit.DescendantNodes() .OfType() .Any(m => m.Expression is IdentifierNameSyntax { Identifier.Text: "File" or "Directory" }); - if (hasFileOrDirectoryCode && !existingUsings.Any(u => u.Name?.ToString() == "System.IO")) + // Check if code contains new FileInfo(...) or new DirectoryInfo(...) object creation + bool hasFileInfoOrDirectoryInfoCreation = compilationUnit.DescendantNodes() + .OfType() + .Any(o => o.Type is IdentifierNameSyntax { Identifier.Text: "FileInfo" or "DirectoryInfo" }); + + if ((hasFileOrDirectoryMemberAccess || hasFileInfoOrDirectoryInfoCreation) && + !existingUsings.Any(u => u.Name?.ToString() == "System.IO")) { var ioUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.IO")); existingUsings.Insert(0, ioUsing); // Insert at beginning to keep System.* namespaces together diff --git a/TUnit.Assertions/Conditions/FileSystemAssertions.cs b/TUnit.Assertions/Conditions/FileSystemAssertions.cs index 6a3812e118..a186fa3419 100644 --- a/TUnit.Assertions/Conditions/FileSystemAssertions.cs +++ b/TUnit.Assertions/Conditions/FileSystemAssertions.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Text; using TUnit.Assertions.Attributes; using TUnit.Assertions.Core; @@ -174,3 +175,273 @@ protected override Task CheckAsync(EvaluationMetadata protected override string GetExpectation() => "to not be executable"; } + +/// +/// File and directory comparison assertions using [GenerateAssertion] for simpler code. +/// +public static partial class FileSystemComparisonAssertions +{ + /// + /// Asserts that a file has the same binary content as another file. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [GenerateAssertion(ExpectationMessage = "to have same content as {expected}")] + public static AssertionResult HasSameContentAs(this FileInfo value, FileInfo expected) + { + if (value == null) + { + return AssertionResult.Failed("actual file was null"); + } + + if (expected == null) + { + return AssertionResult.Failed("expected file was null"); + } + + value.Refresh(); + expected.Refresh(); + + if (!value.Exists) + { + return AssertionResult.Failed($"actual file '{value.FullName}' does not exist"); + } + + if (!expected.Exists) + { + return AssertionResult.Failed($"expected file '{expected.FullName}' does not exist"); + } + + if (value.Length != expected.Length) + { + return AssertionResult.Failed($"file sizes differ: actual {value.Length} bytes, expected {expected.Length} bytes"); + } + + var actualBytes = File.ReadAllBytes(value.FullName); + var expectedBytes = File.ReadAllBytes(expected.FullName); + + for (int i = 0; i < actualBytes.Length; i++) + { + if (actualBytes[i] != expectedBytes[i]) + { + return AssertionResult.Failed($"files differ at byte position {i}: actual 0x{actualBytes[i]:X2}, expected 0x{expectedBytes[i]:X2}"); + } + } + + return AssertionResult.Passed; + } + + /// + /// Asserts that a directory has the same structure (file paths) as another directory. + /// Does not compare file contents, only the relative paths of files and subdirectories. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [GenerateAssertion(ExpectationMessage = "to have same structure as {expected}")] + public static AssertionResult HasSameStructureAs(this DirectoryInfo value, DirectoryInfo expected) + { + if (value == null) + { + return AssertionResult.Failed("actual directory was null"); + } + + if (expected == null) + { + return AssertionResult.Failed("expected directory was null"); + } + + value.Refresh(); + expected.Refresh(); + + if (!value.Exists) + { + return AssertionResult.Failed($"actual directory '{value.FullName}' does not exist"); + } + + if (!expected.Exists) + { + return AssertionResult.Failed($"expected directory '{expected.FullName}' does not exist"); + } + + var actualPaths = value.EnumerateFileSystemInfos("*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(value.FullName, f.FullName)) + .OrderBy(p => p) + .ToList(); + var expectedPaths = expected.EnumerateFileSystemInfos("*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(expected.FullName, f.FullName)) + .OrderBy(p => p) + .ToList(); + + var missingInActual = expectedPaths.Except(actualPaths).ToList(); + var extraInActual = actualPaths.Except(expectedPaths).ToList(); + + if (missingInActual.Count > 0 || extraInActual.Count > 0) + { + var message = new StringBuilder("directory structures differ:"); + if (missingInActual.Count > 0) + { + message.Append($" missing: [{string.Join(", ", missingInActual.Take(5))}]"); + if (missingInActual.Count > 5) + { + message.Append($" (+{missingInActual.Count - 5} more)"); + } + } + if (extraInActual.Count > 0) + { + message.Append($" extra: [{string.Join(", ", extraInActual.Take(5))}]"); + if (extraInActual.Count > 5) + { + message.Append($" (+{extraInActual.Count - 5} more)"); + } + } + return AssertionResult.Failed(message.ToString()); + } + + return AssertionResult.Passed; + } + + /// + /// Asserts that a directory is equivalent to another directory. + /// Compares both the directory structure (file paths) AND file contents. + /// This is equivalent to NUnit's DirectoryAssert.AreEqual. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [GenerateAssertion(ExpectationMessage = "to be equivalent to {expected}")] + public static AssertionResult IsEquivalentTo(this DirectoryInfo value, DirectoryInfo expected) + { + if (value == null) + { + return AssertionResult.Failed("actual directory was null"); + } + + if (expected == null) + { + return AssertionResult.Failed("expected directory was null"); + } + + value.Refresh(); + expected.Refresh(); + + if (!value.Exists) + { + return AssertionResult.Failed($"actual directory '{value.FullName}' does not exist"); + } + + if (!expected.Exists) + { + return AssertionResult.Failed($"expected directory '{expected.FullName}' does not exist"); + } + + // First check structure + var actualFiles = value.EnumerateFiles("*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(value.FullName, f.FullName)) + .OrderBy(p => p) + .ToList(); + var expectedFiles = expected.EnumerateFiles("*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(expected.FullName, f.FullName)) + .OrderBy(p => p) + .ToList(); + + var missingFiles = expectedFiles.Except(actualFiles).ToList(); + var extraFiles = actualFiles.Except(expectedFiles).ToList(); + + if (missingFiles.Count > 0 || extraFiles.Count > 0) + { + var message = new StringBuilder("directory structures differ:"); + if (missingFiles.Count > 0) + { + message.Append($" missing files: [{string.Join(", ", missingFiles.Take(5))}]"); + if (missingFiles.Count > 5) + { + message.Append($" (+{missingFiles.Count - 5} more)"); + } + } + if (extraFiles.Count > 0) + { + message.Append($" extra files: [{string.Join(", ", extraFiles.Take(5))}]"); + if (extraFiles.Count > 5) + { + message.Append($" (+{extraFiles.Count - 5} more)"); + } + } + return AssertionResult.Failed(message.ToString()); + } + + // Now compare file contents + foreach (var relativePath in actualFiles) + { + var actualFilePath = Path.Combine(value.FullName, relativePath); + var expectedFilePath = Path.Combine(expected.FullName, relativePath); + + var actualBytes = File.ReadAllBytes(actualFilePath); + var expectedBytes = File.ReadAllBytes(expectedFilePath); + + if (actualBytes.Length != expectedBytes.Length) + { + return AssertionResult.Failed($"file '{relativePath}' sizes differ: actual {actualBytes.Length} bytes, expected {expectedBytes.Length} bytes"); + } + + for (int i = 0; i < actualBytes.Length; i++) + { + if (actualBytes[i] != expectedBytes[i]) + { + return AssertionResult.Failed($"file '{relativePath}' content differs at byte position {i}"); + } + } + } + + return AssertionResult.Passed; + } + + /// + /// Asserts that a directory is NOT equivalent to another directory. + /// The opposite of IsEquivalentTo - passes if structure OR content differs. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [GenerateAssertion(ExpectationMessage = "to not be equivalent to {expected}")] + public static AssertionResult IsNotEquivalentTo(this DirectoryInfo value, DirectoryInfo expected) + { + if (value == null || expected == null) + { + return AssertionResult.Passed; // null is not equivalent to anything + } + + value.Refresh(); + expected.Refresh(); + + if (!value.Exists || !expected.Exists) + { + return AssertionResult.Passed; // non-existent directories are not equivalent + } + + // Check structure + var actualFiles = value.EnumerateFiles("*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(value.FullName, f.FullName)) + .OrderBy(p => p) + .ToList(); + var expectedFiles = expected.EnumerateFiles("*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(expected.FullName, f.FullName)) + .OrderBy(p => p) + .ToList(); + + if (!actualFiles.SequenceEqual(expectedFiles)) + { + return AssertionResult.Passed; // Different structure + } + + // Check file contents + foreach (var relativePath in actualFiles) + { + var actualFilePath = Path.Combine(value.FullName, relativePath); + var expectedFilePath = Path.Combine(expected.FullName, relativePath); + + var actualBytes = File.ReadAllBytes(actualFilePath); + var expectedBytes = File.ReadAllBytes(expectedFilePath); + + if (!actualBytes.SequenceEqual(expectedBytes)) + { + return AssertionResult.Passed; // Different content + } + } + + return AssertionResult.Failed("directories are equivalent"); + } +} From 9cfaad846ce7c823d5f9f8127f012405a241f60f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:12:36 +0000 Subject: [PATCH 4/7] feat: add DoesNotHaveSameContentAs for FileInfo assertion - Add FileInfo.DoesNotHaveSameContentAs(other) assertion for negated file comparison - Update NUnit migration to use DoesNotHaveSameContentAs for FileAssert.AreNotEqual - Add test for FileAssert.AreNotEqual migration Co-Authored-By: Claude Opus 4.5 --- .../NUnitMigrationCodeFixProvider.cs | 14 ++----- .../NUnitMigrationAnalyzerTests.cs | 38 ++++++++++++++++++ .../Conditions/FileSystemAssertions.cs | 39 +++++++++++++++++++ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs index c5636347f3..817a4d9baf 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs @@ -1495,21 +1495,13 @@ private ExpressionSyntax CreateFileAreEqualAssertion(ExpressionSyntax expected, { // NUnit's FileAssert.AreEqual compares file contents // Generate: Assert.That(new FileInfo(actual)).HasSameContentAs(new FileInfo(expected)) + // Or for negated: Assert.That(new FileInfo(actual)).DoesNotHaveSameContentAs(new FileInfo(expected)) var actualFileInfo = CreateFileInfoExpression(actual); var expectedFileInfo = CreateFileInfoExpression(expected); - // Note: HasSameContentAs doesn't have a negated version in TUnit.Assertions yet - // For now, use IsEquivalentTo on byte arrays for negated case - if (isNegated) - { - // Fall back to byte comparison for the negated case - var actualBytes = CreateFileReadAllBytes(actual); - var expectedBytes = CreateFileReadAllBytes(expected); - return CreateTUnitAssertion("IsNotEquivalentTo", actualBytes, SyntaxFactory.Argument(expectedBytes)); - } - - return CreateTUnitAssertion("HasSameContentAs", actualFileInfo, SyntaxFactory.Argument(expectedFileInfo)); + var assertionMethod = isNegated ? "DoesNotHaveSameContentAs" : "HasSameContentAs"; + return CreateTUnitAssertion(assertionMethod, actualFileInfo, SyntaxFactory.Argument(expectedFileInfo)); } private static ExpressionSyntax CreateFileInfoExpression(ExpressionSyntax pathOrFileInfo) diff --git a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs index db8d9bccf8..fa0b2312aa 100644 --- a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs @@ -3363,6 +3363,44 @@ public async Task TestMethod() ); } + [Test] + public async Task NUnit_FileAssert_AreNotEqual_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void TestMethod() + { + FileAssert.AreNotEqual("expected.txt", "actual.txt"); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System.IO; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public async Task TestMethod() + { + await Assert.That(new FileInfo("actual.txt")).DoesNotHaveSameContentAs(new FileInfo("expected.txt")); + } + } + """, + ConfigureNUnitTest + ); + } + [Test] public async Task NUnit_DirectoryAssert_Exists_Converted() { diff --git a/TUnit.Assertions/Conditions/FileSystemAssertions.cs b/TUnit.Assertions/Conditions/FileSystemAssertions.cs index a186fa3419..c702142539 100644 --- a/TUnit.Assertions/Conditions/FileSystemAssertions.cs +++ b/TUnit.Assertions/Conditions/FileSystemAssertions.cs @@ -230,6 +230,45 @@ public static AssertionResult HasSameContentAs(this FileInfo value, FileInfo exp return AssertionResult.Passed; } + /// + /// Asserts that a file does NOT have the same binary content as another file. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [GenerateAssertion(ExpectationMessage = "to not have same content as {expected}")] + public static AssertionResult DoesNotHaveSameContentAs(this FileInfo value, FileInfo expected) + { + if (value == null || expected == null) + { + return AssertionResult.Passed; // null is not the same as anything + } + + value.Refresh(); + expected.Refresh(); + + if (!value.Exists || !expected.Exists) + { + return AssertionResult.Passed; // non-existent files are not the same + } + + if (value.Length != expected.Length) + { + return AssertionResult.Passed; // different sizes means different content + } + + var actualBytes = File.ReadAllBytes(value.FullName); + var expectedBytes = File.ReadAllBytes(expected.FullName); + + for (int i = 0; i < actualBytes.Length; i++) + { + if (actualBytes[i] != expectedBytes[i]) + { + return AssertionResult.Passed; // found a difference + } + } + + return AssertionResult.Failed("files have identical content"); + } + /// /// Asserts that a directory has the same structure (file paths) as another directory. /// Does not compare file contents, only the relative paths of files and subdirectories. From 6f05273757b4c3158c591945013792d2ffb7a9eb Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:19:15 +0000 Subject: [PATCH 5/7] fix: add Path.GetRelativePath polyfill for netstandard2.0 Path.GetRelativePath is not available in netstandard2.0, so use a Uri-based implementation as a polyfill for that target framework. Co-Authored-By: Claude Opus 4.5 --- .../Conditions/FileSystemAssertions.cs | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/TUnit.Assertions/Conditions/FileSystemAssertions.cs b/TUnit.Assertions/Conditions/FileSystemAssertions.cs index c702142539..cebd42532c 100644 --- a/TUnit.Assertions/Conditions/FileSystemAssertions.cs +++ b/TUnit.Assertions/Conditions/FileSystemAssertions.cs @@ -301,11 +301,11 @@ public static AssertionResult HasSameStructureAs(this DirectoryInfo value, Direc } var actualPaths = value.EnumerateFileSystemInfos("*", SearchOption.AllDirectories) - .Select(f => Path.GetRelativePath(value.FullName, f.FullName)) + .Select(f => GetRelativePath(value.FullName, f.FullName)) .OrderBy(p => p) .ToList(); var expectedPaths = expected.EnumerateFileSystemInfos("*", SearchOption.AllDirectories) - .Select(f => Path.GetRelativePath(expected.FullName, f.FullName)) + .Select(f => GetRelativePath(expected.FullName, f.FullName)) .OrderBy(p => p) .ToList(); @@ -371,11 +371,11 @@ public static AssertionResult IsEquivalentTo(this DirectoryInfo value, Directory // First check structure var actualFiles = value.EnumerateFiles("*", SearchOption.AllDirectories) - .Select(f => Path.GetRelativePath(value.FullName, f.FullName)) + .Select(f => GetRelativePath(value.FullName, f.FullName)) .OrderBy(p => p) .ToList(); var expectedFiles = expected.EnumerateFiles("*", SearchOption.AllDirectories) - .Select(f => Path.GetRelativePath(expected.FullName, f.FullName)) + .Select(f => GetRelativePath(expected.FullName, f.FullName)) .OrderBy(p => p) .ToList(); @@ -453,11 +453,11 @@ public static AssertionResult IsNotEquivalentTo(this DirectoryInfo value, Direct // Check structure var actualFiles = value.EnumerateFiles("*", SearchOption.AllDirectories) - .Select(f => Path.GetRelativePath(value.FullName, f.FullName)) + .Select(f => GetRelativePath(value.FullName, f.FullName)) .OrderBy(p => p) .ToList(); var expectedFiles = expected.EnumerateFiles("*", SearchOption.AllDirectories) - .Select(f => Path.GetRelativePath(expected.FullName, f.FullName)) + .Select(f => GetRelativePath(expected.FullName, f.FullName)) .OrderBy(p => p) .ToList(); @@ -483,4 +483,44 @@ public static AssertionResult IsNotEquivalentTo(this DirectoryInfo value, Direct return AssertionResult.Failed("directories are equivalent"); } + + /// + /// Gets a relative path from one path to another. This is a polyfill for Path.GetRelativePath + /// which is not available in netstandard2.0. + /// + private static string GetRelativePath(string relativeTo, string path) + { +#if NETSTANDARD2_0 + // Normalize paths + relativeTo = Path.GetFullPath(relativeTo); + path = Path.GetFullPath(path); + + // Ensure relativeTo ends with directory separator for proper comparison + if (!relativeTo.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !relativeTo.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + relativeTo += Path.DirectorySeparatorChar; + } + + var relativeToUri = new Uri(relativeTo); + var pathUri = new Uri(path); + + if (relativeToUri.Scheme != pathUri.Scheme) + { + return path; + } + + var relativeUri = relativeToUri.MakeRelativeUri(pathUri); + var relativePath = Uri.UnescapeDataString(relativeUri.ToString()); + + if (string.Equals(pathUri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + { + relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + } + + return relativePath; +#else + return GetRelativePath(relativeTo, path); +#endif + } } From 7184c5b489615292022c11ff95fad9033c64d571 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:43:25 +0000 Subject: [PATCH 6/7] chore: update public API snapshots for new FileInfo/DirectoryInfo assertions Add new public API entries for: - FileInfo.DoesNotHaveSameContentAs - FileInfo.HasSameContentAs - DirectoryInfo.HasSameStructureAs - DirectoryInfo.IsEquivalentTo - DirectoryInfo.IsNotEquivalentTo Co-Authored-By: Claude Opus 4.5 --- ...Has_No_API_Changes.DotNet10_0.verified.txt | 51 +++++++++++++++++++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 51 +++++++++++++++++++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 51 +++++++++++++++++++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 51 +++++++++++++++++++ 4 files changed, 204 insertions(+) diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 5daa46f3f4..3939c57d70 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1121,6 +1121,19 @@ namespace .Conditions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public static class FileSystemComparisonAssertions + { + [.(ExpectationMessage="to not have same content as {expected}")] + public static . DoesNotHaveSameContentAs(this .FileInfo value, .FileInfo expected) { } + [.(ExpectationMessage="to have same content as {expected}")] + public static . HasSameContentAs(this .FileInfo value, .FileInfo expected) { } + [.(ExpectationMessage="to have same structure as {expected}")] + public static . HasSameStructureAs(this .DirectoryInfo value, .DirectoryInfo expected) { } + [.(ExpectationMessage="to be equivalent to {expected}")] + public static . IsEquivalentTo(this .DirectoryInfo value, .DirectoryInfo expected) { } + [.(ExpectationMessage="to not be equivalent to {expected}")] + public static . IsNotEquivalentTo(this .DirectoryInfo value, .DirectoryInfo expected) { } + } [.("IsEqualTo", OverloadResolutionPriority=2)] public class FloatEqualsAssertion : . { @@ -3229,12 +3242,24 @@ namespace .Extensions protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_HasSameStructureAs_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_HasSameStructureAs_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsEmpty_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsEmpty_Assertion(.<.DirectoryInfo> context) { } protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_IsEquivalentTo_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_IsEquivalentTo_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsHidden_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsHidden_Assertion(.<.DirectoryInfo> context) { } @@ -3247,6 +3272,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_IsNotEquivalentTo_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_IsNotEquivalentTo_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsNotHidden_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsNotHidden_Assertion(.<.DirectoryInfo> context) { } @@ -3519,6 +3550,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class FileInfo_DoesNotHaveSameContentAs_FileInfo_Assertion : .<.FileInfo> + { + public FileInfo_DoesNotHaveSameContentAs_FileInfo_Assertion(.<.FileInfo> context, .FileInfo expected) { } + protected override .<.> CheckAsync(.<.FileInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class FileInfo_HasExtension_Assertion : .<.FileInfo> { public FileInfo_HasExtension_Assertion(.<.FileInfo> context) { } @@ -3531,6 +3568,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class FileInfo_HasSameContentAs_FileInfo_Assertion : .<.FileInfo> + { + public FileInfo_HasSameContentAs_FileInfo_Assertion(.<.FileInfo> context, .FileInfo expected) { } + protected override .<.> CheckAsync(.<.FileInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class FileInfo_IsArchived_Assertion : .<.FileInfo> { public FileInfo_IsArchived_Assertion(.<.FileInfo> context) { } @@ -3575,6 +3618,14 @@ namespace .Extensions { public static . IsNotSystem(this .<.FileInfo> source) { } } + public static class FileSystemComparisonAssertions + { + public static ._DoesNotHaveSameContentAs_FileInfo_Assertion DoesNotHaveSameContentAs(this .<.FileInfo> source, .FileInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasSameContentAs_FileInfo_Assertion HasSameContentAs(this .<.FileInfo> source, .FileInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasSameStructureAs_DirectoryInfo_Assertion HasSameStructureAs(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsEquivalentTo_DirectoryInfo_Assertion IsEquivalentTo(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotEquivalentTo_DirectoryInfo_Assertion IsNotEquivalentTo(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + } public static class FloatAssertions { public static ._IsNotZero_Assertion IsNotZero(this . source) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 7763d3aba8..7c1db3dab2 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1106,6 +1106,19 @@ namespace .Conditions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public static class FileSystemComparisonAssertions + { + [.(ExpectationMessage="to not have same content as {expected}")] + public static . DoesNotHaveSameContentAs(this .FileInfo value, .FileInfo expected) { } + [.(ExpectationMessage="to have same content as {expected}")] + public static . HasSameContentAs(this .FileInfo value, .FileInfo expected) { } + [.(ExpectationMessage="to have same structure as {expected}")] + public static . HasSameStructureAs(this .DirectoryInfo value, .DirectoryInfo expected) { } + [.(ExpectationMessage="to be equivalent to {expected}")] + public static . IsEquivalentTo(this .DirectoryInfo value, .DirectoryInfo expected) { } + [.(ExpectationMessage="to not be equivalent to {expected}")] + public static . IsNotEquivalentTo(this .DirectoryInfo value, .DirectoryInfo expected) { } + } [.("IsEqualTo", OverloadResolutionPriority=2)] public class FloatEqualsAssertion : . { @@ -3199,12 +3212,24 @@ namespace .Extensions protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_HasSameStructureAs_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_HasSameStructureAs_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsEmpty_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsEmpty_Assertion(.<.DirectoryInfo> context) { } protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_IsEquivalentTo_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_IsEquivalentTo_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsHidden_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsHidden_Assertion(.<.DirectoryInfo> context) { } @@ -3217,6 +3242,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_IsNotEquivalentTo_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_IsNotEquivalentTo_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsNotHidden_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsNotHidden_Assertion(.<.DirectoryInfo> context) { } @@ -3488,6 +3519,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class FileInfo_DoesNotHaveSameContentAs_FileInfo_Assertion : .<.FileInfo> + { + public FileInfo_DoesNotHaveSameContentAs_FileInfo_Assertion(.<.FileInfo> context, .FileInfo expected) { } + protected override .<.> CheckAsync(.<.FileInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class FileInfo_HasExtension_Assertion : .<.FileInfo> { public FileInfo_HasExtension_Assertion(.<.FileInfo> context) { } @@ -3500,6 +3537,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class FileInfo_HasSameContentAs_FileInfo_Assertion : .<.FileInfo> + { + public FileInfo_HasSameContentAs_FileInfo_Assertion(.<.FileInfo> context, .FileInfo expected) { } + protected override .<.> CheckAsync(.<.FileInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class FileInfo_IsArchived_Assertion : .<.FileInfo> { public FileInfo_IsArchived_Assertion(.<.FileInfo> context) { } @@ -3544,6 +3587,14 @@ namespace .Extensions { public static . IsNotSystem(this .<.FileInfo> source) { } } + public static class FileSystemComparisonAssertions + { + public static ._DoesNotHaveSameContentAs_FileInfo_Assertion DoesNotHaveSameContentAs(this .<.FileInfo> source, .FileInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasSameContentAs_FileInfo_Assertion HasSameContentAs(this .<.FileInfo> source, .FileInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasSameStructureAs_DirectoryInfo_Assertion HasSameStructureAs(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsEquivalentTo_DirectoryInfo_Assertion IsEquivalentTo(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotEquivalentTo_DirectoryInfo_Assertion IsNotEquivalentTo(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + } public static class FloatAssertions { public static ._IsNotZero_Assertion IsNotZero(this . source) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 3ec036ff23..0dfc614f00 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1121,6 +1121,19 @@ namespace .Conditions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public static class FileSystemComparisonAssertions + { + [.(ExpectationMessage="to not have same content as {expected}")] + public static . DoesNotHaveSameContentAs(this .FileInfo value, .FileInfo expected) { } + [.(ExpectationMessage="to have same content as {expected}")] + public static . HasSameContentAs(this .FileInfo value, .FileInfo expected) { } + [.(ExpectationMessage="to have same structure as {expected}")] + public static . HasSameStructureAs(this .DirectoryInfo value, .DirectoryInfo expected) { } + [.(ExpectationMessage="to be equivalent to {expected}")] + public static . IsEquivalentTo(this .DirectoryInfo value, .DirectoryInfo expected) { } + [.(ExpectationMessage="to not be equivalent to {expected}")] + public static . IsNotEquivalentTo(this .DirectoryInfo value, .DirectoryInfo expected) { } + } [.("IsEqualTo", OverloadResolutionPriority=2)] public class FloatEqualsAssertion : . { @@ -3229,12 +3242,24 @@ namespace .Extensions protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_HasSameStructureAs_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_HasSameStructureAs_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsEmpty_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsEmpty_Assertion(.<.DirectoryInfo> context) { } protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_IsEquivalentTo_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_IsEquivalentTo_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsHidden_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsHidden_Assertion(.<.DirectoryInfo> context) { } @@ -3247,6 +3272,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_IsNotEquivalentTo_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_IsNotEquivalentTo_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsNotHidden_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsNotHidden_Assertion(.<.DirectoryInfo> context) { } @@ -3519,6 +3550,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class FileInfo_DoesNotHaveSameContentAs_FileInfo_Assertion : .<.FileInfo> + { + public FileInfo_DoesNotHaveSameContentAs_FileInfo_Assertion(.<.FileInfo> context, .FileInfo expected) { } + protected override .<.> CheckAsync(.<.FileInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class FileInfo_HasExtension_Assertion : .<.FileInfo> { public FileInfo_HasExtension_Assertion(.<.FileInfo> context) { } @@ -3531,6 +3568,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class FileInfo_HasSameContentAs_FileInfo_Assertion : .<.FileInfo> + { + public FileInfo_HasSameContentAs_FileInfo_Assertion(.<.FileInfo> context, .FileInfo expected) { } + protected override .<.> CheckAsync(.<.FileInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class FileInfo_IsArchived_Assertion : .<.FileInfo> { public FileInfo_IsArchived_Assertion(.<.FileInfo> context) { } @@ -3575,6 +3618,14 @@ namespace .Extensions { public static . IsNotSystem(this .<.FileInfo> source) { } } + public static class FileSystemComparisonAssertions + { + public static ._DoesNotHaveSameContentAs_FileInfo_Assertion DoesNotHaveSameContentAs(this .<.FileInfo> source, .FileInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasSameContentAs_FileInfo_Assertion HasSameContentAs(this .<.FileInfo> source, .FileInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasSameStructureAs_DirectoryInfo_Assertion HasSameStructureAs(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsEquivalentTo_DirectoryInfo_Assertion IsEquivalentTo(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotEquivalentTo_DirectoryInfo_Assertion IsNotEquivalentTo(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + } public static class FloatAssertions { public static ._IsNotZero_Assertion IsNotZero(this . source) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index bdf527be20..cec513b140 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -955,6 +955,19 @@ namespace .Conditions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public static class FileSystemComparisonAssertions + { + [.(ExpectationMessage="to not have same content as {expected}")] + public static . DoesNotHaveSameContentAs(this .FileInfo value, .FileInfo expected) { } + [.(ExpectationMessage="to have same content as {expected}")] + public static . HasSameContentAs(this .FileInfo value, .FileInfo expected) { } + [.(ExpectationMessage="to have same structure as {expected}")] + public static . HasSameStructureAs(this .DirectoryInfo value, .DirectoryInfo expected) { } + [.(ExpectationMessage="to be equivalent to {expected}")] + public static . IsEquivalentTo(this .DirectoryInfo value, .DirectoryInfo expected) { } + [.(ExpectationMessage="to not be equivalent to {expected}")] + public static . IsNotEquivalentTo(this .DirectoryInfo value, .DirectoryInfo expected) { } + } [.("IsEqualTo", OverloadResolutionPriority=2)] public class FloatEqualsAssertion : . { @@ -2858,12 +2871,24 @@ namespace .Extensions protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_HasSameStructureAs_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_HasSameStructureAs_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsEmpty_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsEmpty_Assertion(.<.DirectoryInfo> context) { } protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_IsEquivalentTo_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_IsEquivalentTo_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsHidden_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsHidden_Assertion(.<.DirectoryInfo> context) { } @@ -2876,6 +2901,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class DirectoryInfo_IsNotEquivalentTo_DirectoryInfo_Assertion : .<.DirectoryInfo> + { + public DirectoryInfo_IsNotEquivalentTo_DirectoryInfo_Assertion(.<.DirectoryInfo> context, .DirectoryInfo expected) { } + protected override .<.> CheckAsync(.<.DirectoryInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class DirectoryInfo_IsNotHidden_Assertion : .<.DirectoryInfo> { public DirectoryInfo_IsNotHidden_Assertion(.<.DirectoryInfo> context) { } @@ -3119,6 +3150,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class FileInfo_DoesNotHaveSameContentAs_FileInfo_Assertion : .<.FileInfo> + { + public FileInfo_DoesNotHaveSameContentAs_FileInfo_Assertion(.<.FileInfo> context, .FileInfo expected) { } + protected override .<.> CheckAsync(.<.FileInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class FileInfo_HasExtension_Assertion : .<.FileInfo> { public FileInfo_HasExtension_Assertion(.<.FileInfo> context) { } @@ -3131,6 +3168,12 @@ namespace .Extensions protected override .<.> CheckAsync(.<.FileInfo> metadata) { } protected override string GetExpectation() { } } + public sealed class FileInfo_HasSameContentAs_FileInfo_Assertion : .<.FileInfo> + { + public FileInfo_HasSameContentAs_FileInfo_Assertion(.<.FileInfo> context, .FileInfo expected) { } + protected override .<.> CheckAsync(.<.FileInfo> metadata) { } + protected override string GetExpectation() { } + } public sealed class FileInfo_IsArchived_Assertion : .<.FileInfo> { public FileInfo_IsArchived_Assertion(.<.FileInfo> context) { } @@ -3175,6 +3218,14 @@ namespace .Extensions { public static . IsNotSystem(this .<.FileInfo> source) { } } + public static class FileSystemComparisonAssertions + { + public static ._DoesNotHaveSameContentAs_FileInfo_Assertion DoesNotHaveSameContentAs(this .<.FileInfo> source, .FileInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasSameContentAs_FileInfo_Assertion HasSameContentAs(this .<.FileInfo> source, .FileInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasSameStructureAs_DirectoryInfo_Assertion HasSameStructureAs(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsEquivalentTo_DirectoryInfo_Assertion IsEquivalentTo(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotEquivalentTo_DirectoryInfo_Assertion IsNotEquivalentTo(this .<.DirectoryInfo> source, .DirectoryInfo expected, [.("expected")] string? expectedExpression = null) { } + } public static class FloatAssertions { public static ._IsNotZero_Assertion IsNotZero(this . source) { } From 4ce827cb6dd3c454a93c52262a96a6189261d943 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:45:04 +0000 Subject: [PATCH 7/7] fix: resolve infinite recursion in GetRelativePath on .NET 6+ The #else branch was calling GetRelativePath recursively instead of Path.GetRelativePath, which would cause a StackOverflowException on non-netstandard2.0 targets. Co-Authored-By: Claude Opus 4.5 --- TUnit.Assertions/Conditions/FileSystemAssertions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TUnit.Assertions/Conditions/FileSystemAssertions.cs b/TUnit.Assertions/Conditions/FileSystemAssertions.cs index cebd42532c..5cda2945d2 100644 --- a/TUnit.Assertions/Conditions/FileSystemAssertions.cs +++ b/TUnit.Assertions/Conditions/FileSystemAssertions.cs @@ -520,7 +520,7 @@ private static string GetRelativePath(string relativeTo, string path) return relativePath; #else - return GetRelativePath(relativeTo, path); + return Path.GetRelativePath(relativeTo, path); #endif } }