diff --git a/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs b/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs index 8cb0b1debb..1621a2f664 100644 --- a/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs +++ b/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs @@ -226,6 +226,22 @@ private static void GenerateExtensionMethod( // Skip the first parameter (AssertionContext) var additionalParams = constructor.Parameters.Skip(1).ToArray(); + // Check for RequiresUnreferencedCode attribute on the constructor first, then fall back to class-level + var constructorRequiresUnreferencedCodeAttr = constructor.GetAttributes() + .FirstOrDefault(attr => attr.AttributeClass?.Name == "RequiresUnreferencedCodeAttribute"); + + string? requiresUnreferencedCodeMessage = null; + if (constructorRequiresUnreferencedCodeAttr != null && constructorRequiresUnreferencedCodeAttr.ConstructorArguments.Length > 0) + { + // Constructor-level attribute takes precedence + requiresUnreferencedCodeMessage = constructorRequiresUnreferencedCodeAttr.ConstructorArguments[0].Value?.ToString(); + } + else if (!string.IsNullOrEmpty(data.RequiresUnreferencedCodeMessage)) + { + // Fall back to class-level attribute + requiresUnreferencedCodeMessage = data.RequiresUnreferencedCodeMessage; + } + // Build generic type parameters string // Use the assertion class's own type parameters if it has them var genericParams = new List(); @@ -315,10 +331,10 @@ private static void GenerateExtensionMethod( sourceBuilder.AppendLine($" /// Extension method for {assertionType.Name}."); sourceBuilder.AppendLine(" /// "); - // Add RequiresUnreferencedCode attribute if present - if (!string.IsNullOrEmpty(data.RequiresUnreferencedCodeMessage)) + // Add RequiresUnreferencedCode attribute if present (from constructor or class level) + if (!string.IsNullOrEmpty(requiresUnreferencedCodeMessage)) { - var escapedMessage = data.RequiresUnreferencedCodeMessage!.Replace("\"", "\\\""); + var escapedMessage = requiresUnreferencedCodeMessage!.Replace("\"", "\\\""); sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"{escapedMessage}\")]"); } diff --git a/TUnit.Assertions/Conditions/CollectionComparerBasedAssertion.cs b/TUnit.Assertions/Conditions/CollectionComparerBasedAssertion.cs index 5a2f78fec1..cd168e5bba 100644 --- a/TUnit.Assertions/Conditions/CollectionComparerBasedAssertion.cs +++ b/TUnit.Assertions/Conditions/CollectionComparerBasedAssertion.cs @@ -1,4 +1,3 @@ -using System.Collections; using TUnit.Assertions.Core; using TUnit.Assertions.Sources; @@ -13,7 +12,7 @@ namespace TUnit.Assertions.Conditions; public abstract class CollectionComparerBasedAssertion : CollectionAssertionBase where TCollection : IEnumerable { - private IEqualityComparer? _comparer; + protected IEqualityComparer? Comparer; protected CollectionComparerBasedAssertion(AssertionContext context) : base(context) @@ -26,7 +25,7 @@ protected CollectionComparerBasedAssertion(AssertionContext context /// protected void SetComparer(IEqualityComparer comparer) { - _comparer = comparer; + Comparer = comparer; Context.ExpressionBuilder.Append($".Using({comparer.GetType().Name})"); } @@ -36,14 +35,6 @@ protected void SetComparer(IEqualityComparer comparer) /// protected IEqualityComparer GetComparer() { - return _comparer ?? EqualityComparer.Default; - } - - /// - /// Checks if a custom comparer has been specified. - /// - protected bool HasCustomComparer() - { - return _comparer != null; + return Comparer ?? EqualityComparer.Default; } } diff --git a/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs b/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs index 6eaf69bf8b..7592c54f0e 100644 --- a/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs +++ b/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs @@ -14,21 +14,19 @@ namespace TUnit.Assertions.Conditions; /// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains. /// [AssertionExtension("IsEquivalentTo")] -[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")] public class IsEquivalentToAssertion : CollectionComparerBasedAssertion where TCollection : IEnumerable { private readonly IEnumerable _expected; private readonly CollectionOrdering _ordering; + [RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")] public IsEquivalentToAssertion( AssertionContext context, IEnumerable expected, CollectionOrdering ordering = CollectionOrdering.Any) - : base(context) + : this(context, expected, StructuralEqualityComparer.Instance, ordering) { - _expected = expected ?? throw new ArgumentNullException(nameof(expected)); - _ordering = ordering; } public IsEquivalentToAssertion( @@ -40,7 +38,7 @@ public IsEquivalentToAssertion( { _expected = expected ?? throw new ArgumentNullException(nameof(expected)); _ordering = ordering; - SetComparer(comparer); + Comparer = comparer; } public IsEquivalentToAssertion Using(IEqualityComparer comparer) @@ -49,7 +47,6 @@ public IsEquivalentToAssertion Using(IEqualityComparer CheckAsync(EvaluationMetadata metadata) { var value = metadata.Value; @@ -60,7 +57,7 @@ protected override Task CheckAsync(EvaluationMetadata.Instance; + var comparer = GetComparer(); var result = CollectionEquivalencyChecker.AreEquivalent( value, diff --git a/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs b/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs index 5fbb18d0d7..e32548337f 100644 --- a/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs +++ b/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs @@ -13,21 +13,31 @@ namespace TUnit.Assertions.Conditions; /// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains. /// [AssertionExtension("IsNotEquivalentTo")] -[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")] public class NotEquivalentToAssertion : CollectionComparerBasedAssertion where TCollection : IEnumerable { private readonly IEnumerable _notExpected; private readonly CollectionOrdering _ordering; + [RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")] public NotEquivalentToAssertion( AssertionContext context, IEnumerable notExpected, CollectionOrdering ordering = CollectionOrdering.Any) + : this(context, notExpected, StructuralEqualityComparer.Instance, ordering) + { + } + + public NotEquivalentToAssertion( + AssertionContext context, + IEnumerable notExpected, + IEqualityComparer comparer, + CollectionOrdering ordering = CollectionOrdering.Any) : base(context) { _notExpected = notExpected ?? throw new ArgumentNullException(nameof(notExpected)); _ordering = ordering; + Comparer = comparer; } public NotEquivalentToAssertion Using(IEqualityComparer comparer) @@ -36,7 +46,6 @@ public NotEquivalentToAssertion Using(IEqualityComparer CheckAsync(EvaluationMetadata metadata) { var value = metadata.Value; @@ -47,7 +56,7 @@ protected override Task CheckAsync(EvaluationMetadata.Instance; + var comparer = GetComparer(); var result = CollectionEquivalencyChecker.AreEquivalent( value, 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 35bcf00a2e..9714184f79 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 @@ -388,9 +388,9 @@ namespace .Conditions public abstract class CollectionComparerBasedAssertion : . where TCollection : . { + protected .? Comparer; protected CollectionComparerBasedAssertion(. context) { } protected . GetComparer() { } - protected bool HasCustomComparer() { } protected void SetComparer(. comparer) { } } [.("Contains")] @@ -817,15 +817,14 @@ namespace .Conditions protected override string GetExpectation() { } public . Using(. comparer) { } } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] [.("IsEquivalentTo")] public class IsEquivalentToAssertion : . where TCollection : . { + [.("Collection equivalency uses structural comparison for complex objects, which requ" + + "ires reflection and is not compatible with AOT")] public IsEquivalentToAssertion(. context, . expected, . ordering = 0) { } public IsEquivalentToAssertion(. context, . expected, . comparer, . ordering = 0) { } - [.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")] protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } @@ -919,14 +918,14 @@ namespace .Conditions public . IgnoringType( type) { } public . IgnoringType() { } } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] [.("IsNotEquivalentTo")] public class NotEquivalentToAssertion : . where TCollection : . { + [.("Collection equivalency uses structural comparison for complex objects, which requ" + + "ires reflection and is not compatible with AOT")] public NotEquivalentToAssertion(. context, . notExpected, . ordering = 0) { } - [.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")] + public NotEquivalentToAssertion(. context, . notExpected, . comparer, . ordering = 0) { } protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } @@ -3085,8 +3084,6 @@ namespace .Extensions "ires reflection and is not compatible with AOT")] public static . IsEquivalentTo(this . source, . expected, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] public static . IsEquivalentTo(this . source, . expected, . comparer, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } } @@ -3184,6 +3181,8 @@ namespace .Extensions "ires reflection and is not compatible with AOT")] public static . IsNotEquivalentTo(this . source, . notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } + public static . IsNotEquivalentTo(this . source, . notExpected, . comparer, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) + where TCollection : . { } } public static class NotSameReferenceAssertionExtensions { 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 060668995b..665faeb994 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 @@ -385,9 +385,9 @@ namespace .Conditions public abstract class CollectionComparerBasedAssertion : . where TCollection : . { + protected .? Comparer; protected CollectionComparerBasedAssertion(. context) { } protected . GetComparer() { } - protected bool HasCustomComparer() { } protected void SetComparer(. comparer) { } } [.("Contains")] @@ -814,15 +814,14 @@ namespace .Conditions protected override string GetExpectation() { } public . Using(. comparer) { } } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] [.("IsEquivalentTo")] public class IsEquivalentToAssertion : . where TCollection : . { + [.("Collection equivalency uses structural comparison for complex objects, which requ" + + "ires reflection and is not compatible with AOT")] public IsEquivalentToAssertion(. context, . expected, . ordering = 0) { } public IsEquivalentToAssertion(. context, . expected, . comparer, . ordering = 0) { } - [.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")] protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } @@ -916,14 +915,14 @@ namespace .Conditions public . IgnoringType( type) { } public . IgnoringType() { } } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] [.("IsNotEquivalentTo")] public class NotEquivalentToAssertion : . where TCollection : . { + [.("Collection equivalency uses structural comparison for complex objects, which requ" + + "ires reflection and is not compatible with AOT")] public NotEquivalentToAssertion(. context, . notExpected, . ordering = 0) { } - [.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")] + public NotEquivalentToAssertion(. context, . notExpected, . comparer, . ordering = 0) { } protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } @@ -3068,8 +3067,6 @@ namespace .Extensions "ires reflection and is not compatible with AOT")] public static . IsEquivalentTo(this . source, . expected, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] public static . IsEquivalentTo(this . source, . expected, . comparer, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } } @@ -3166,6 +3163,8 @@ namespace .Extensions "ires reflection and is not compatible with AOT")] public static . IsNotEquivalentTo(this . source, . notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } + public static . IsNotEquivalentTo(this . source, . notExpected, . comparer, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) + where TCollection : . { } } public static class NotSameReferenceAssertionExtensions { 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 634cbf0e26..c8665a0b6a 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 @@ -388,9 +388,9 @@ namespace .Conditions public abstract class CollectionComparerBasedAssertion : . where TCollection : . { + protected .? Comparer; protected CollectionComparerBasedAssertion(. context) { } protected . GetComparer() { } - protected bool HasCustomComparer() { } protected void SetComparer(. comparer) { } } [.("Contains")] @@ -817,15 +817,14 @@ namespace .Conditions protected override string GetExpectation() { } public . Using(. comparer) { } } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] [.("IsEquivalentTo")] public class IsEquivalentToAssertion : . where TCollection : . { + [.("Collection equivalency uses structural comparison for complex objects, which requ" + + "ires reflection and is not compatible with AOT")] public IsEquivalentToAssertion(. context, . expected, . ordering = 0) { } public IsEquivalentToAssertion(. context, . expected, . comparer, . ordering = 0) { } - [.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")] protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } @@ -919,14 +918,14 @@ namespace .Conditions public . IgnoringType( type) { } public . IgnoringType() { } } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] [.("IsNotEquivalentTo")] public class NotEquivalentToAssertion : . where TCollection : . { + [.("Collection equivalency uses structural comparison for complex objects, which requ" + + "ires reflection and is not compatible with AOT")] public NotEquivalentToAssertion(. context, . notExpected, . ordering = 0) { } - [.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")] + public NotEquivalentToAssertion(. context, . notExpected, . comparer, . ordering = 0) { } protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } @@ -3085,8 +3084,6 @@ namespace .Extensions "ires reflection and is not compatible with AOT")] public static . IsEquivalentTo(this . source, . expected, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } - [.("Collection equivalency uses structural comparison for complex objects, which requ" + - "ires reflection and is not compatible with AOT")] public static . IsEquivalentTo(this . source, . expected, . comparer, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } } @@ -3184,6 +3181,8 @@ namespace .Extensions "ires reflection and is not compatible with AOT")] public static . IsNotEquivalentTo(this . source, . notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } + public static . IsNotEquivalentTo(this . source, . notExpected, . comparer, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) + where TCollection : . { } } public static class NotSameReferenceAssertionExtensions { 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 03c58549c0..bd832a6fca 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 @@ -381,9 +381,9 @@ namespace .Conditions public abstract class CollectionComparerBasedAssertion : . where TCollection : . { + protected .? Comparer; protected CollectionComparerBasedAssertion(. context) { } protected . GetComparer() { } - protected bool HasCustomComparer() { } protected void SetComparer(. comparer) { } } [.("Contains")] @@ -893,6 +893,7 @@ namespace .Conditions where TCollection : . { public NotEquivalentToAssertion(. context, . notExpected, . ordering = 0) { } + public NotEquivalentToAssertion(. context, . notExpected, . comparer, . ordering = 0) { } protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } @@ -2881,6 +2882,8 @@ namespace .Extensions { public static . IsNotEquivalentTo(this . source, . notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null) where TCollection : . { } + public static . IsNotEquivalentTo(this . source, . notExpected, . comparer, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) + where TCollection : . { } } public static class NotSameReferenceAssertionExtensions { diff --git a/tools/tunit-nuget-tester/TUnit.NugetTester/TUnit.NugetTester/AotCompatibilityTests.cs b/tools/tunit-nuget-tester/TUnit.NugetTester/TUnit.NugetTester/AotCompatibilityTests.cs index 9702c05d5e..454de33ccf 100644 --- a/tools/tunit-nuget-tester/TUnit.NugetTester/TUnit.NugetTester/AotCompatibilityTests.cs +++ b/tools/tunit-nuget-tester/TUnit.NugetTester/TUnit.NugetTester/AotCompatibilityTests.cs @@ -1,3 +1,5 @@ +using TUnit.Assertions.Enums; + namespace TUnit.NugetTester; /// @@ -48,4 +50,32 @@ public async Task PropertyInjection_ShouldNotTriggerAotWarnings() await Assert.That(InjectedProperty).IsNotNull(); await Assert.That(InjectedProperty).IsEqualTo("test value"); } + + /// + /// Tests for issue #3851 - IsEquivalentTo with custom comparer should be AOT-compatible + /// When a custom comparer is provided, no reflection is used, so the method should not + /// have RequiresUnreferencedCode attribute and should be safe for AOT. + /// + [Test] + public async Task IsEquivalentTo_WithCustomComparer_ShouldNotTriggerAotWarnings() + { + // This test verifies that using IsEquivalentTo with a custom comparer doesn't trigger IL2026/IL3050 + // The custom comparer path doesn't use StructuralEqualityComparer which requires reflection + var list1 = new List { 1, 2, 3 }; + var list2 = new List { 3, 2, 1 }; + + // Using explicit comparer - should be AOT-safe + await Assert.That(list1).IsEquivalentTo(list2, EqualityComparer.Default); + } + + [Test] + public async Task IsEquivalentTo_WithCustomComparer_OrderMatching_ShouldNotTriggerAotWarnings() + { + // Verify that custom comparer works with both ordering modes + var list1 = new List { "a", "b", "c" }; + var list2 = new List { "a", "b", "c" }; + + // Using custom comparer with order matching - should be AOT-safe + await Assert.That(list1).IsEquivalentTo(list2, StringComparer.OrdinalIgnoreCase, CollectionOrdering.Matching); + } }