Skip to content

Commit aea9506

Browse files
authored
Unmark AOT safe IsEquivalentTo overload (#3854)
* feat: enhance AOT compatibility by adding RequiresUnreferencedCode attribute to assertion classes and introducing tests for custom comparer scenarios * feat: update AOT compatibility messages and remove virtual modifiers from GetErrorOutput and GetStandardOutput methods * feat: update GetErrorOutput and GetStandardOutput methods to virtual and add TestBuildContext class * feat: refactor collection assertion classes to expose Comparer property and streamline comparer handling
1 parent f79dbff commit aea9506

9 files changed

Lines changed: 96 additions & 53 deletions

TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,22 @@ private static void GenerateExtensionMethod(
226226
// Skip the first parameter (AssertionContext<T>)
227227
var additionalParams = constructor.Parameters.Skip(1).ToArray();
228228

229+
// Check for RequiresUnreferencedCode attribute on the constructor first, then fall back to class-level
230+
var constructorRequiresUnreferencedCodeAttr = constructor.GetAttributes()
231+
.FirstOrDefault(attr => attr.AttributeClass?.Name == "RequiresUnreferencedCodeAttribute");
232+
233+
string? requiresUnreferencedCodeMessage = null;
234+
if (constructorRequiresUnreferencedCodeAttr != null && constructorRequiresUnreferencedCodeAttr.ConstructorArguments.Length > 0)
235+
{
236+
// Constructor-level attribute takes precedence
237+
requiresUnreferencedCodeMessage = constructorRequiresUnreferencedCodeAttr.ConstructorArguments[0].Value?.ToString();
238+
}
239+
else if (!string.IsNullOrEmpty(data.RequiresUnreferencedCodeMessage))
240+
{
241+
// Fall back to class-level attribute
242+
requiresUnreferencedCodeMessage = data.RequiresUnreferencedCodeMessage;
243+
}
244+
229245
// Build generic type parameters string
230246
// Use the assertion class's own type parameters if it has them
231247
var genericParams = new List<string>();
@@ -315,10 +331,10 @@ private static void GenerateExtensionMethod(
315331
sourceBuilder.AppendLine($" /// Extension method for {assertionType.Name}.");
316332
sourceBuilder.AppendLine(" /// </summary>");
317333

318-
// Add RequiresUnreferencedCode attribute if present
319-
if (!string.IsNullOrEmpty(data.RequiresUnreferencedCodeMessage))
334+
// Add RequiresUnreferencedCode attribute if present (from constructor or class level)
335+
if (!string.IsNullOrEmpty(requiresUnreferencedCodeMessage))
320336
{
321-
var escapedMessage = data.RequiresUnreferencedCodeMessage!.Replace("\"", "\\\"");
337+
var escapedMessage = requiresUnreferencedCodeMessage!.Replace("\"", "\\\"");
322338
sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"{escapedMessage}\")]");
323339
}
324340

TUnit.Assertions/Conditions/CollectionComparerBasedAssertion.cs

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Collections;
21
using TUnit.Assertions.Core;
32
using TUnit.Assertions.Sources;
43

@@ -13,7 +12,7 @@ namespace TUnit.Assertions.Conditions;
1312
public abstract class CollectionComparerBasedAssertion<TCollection, TItem> : CollectionAssertionBase<TCollection, TItem>
1413
where TCollection : IEnumerable<TItem>
1514
{
16-
private IEqualityComparer<TItem>? _comparer;
15+
protected IEqualityComparer<TItem>? Comparer;
1716

1817
protected CollectionComparerBasedAssertion(AssertionContext<TCollection> context)
1918
: base(context)
@@ -26,7 +25,7 @@ protected CollectionComparerBasedAssertion(AssertionContext<TCollection> context
2625
/// </summary>
2726
protected void SetComparer(IEqualityComparer<TItem> comparer)
2827
{
29-
_comparer = comparer;
28+
Comparer = comparer;
3029
Context.ExpressionBuilder.Append($".Using({comparer.GetType().Name})");
3130
}
3231

@@ -36,14 +35,6 @@ protected void SetComparer(IEqualityComparer<TItem> comparer)
3635
/// </summary>
3736
protected IEqualityComparer<TItem> GetComparer()
3837
{
39-
return _comparer ?? EqualityComparer<TItem>.Default;
40-
}
41-
42-
/// <summary>
43-
/// Checks if a custom comparer has been specified.
44-
/// </summary>
45-
protected bool HasCustomComparer()
46-
{
47-
return _comparer != null;
38+
return Comparer ?? EqualityComparer<TItem>.Default;
4839
}
4940
}

TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,19 @@ namespace TUnit.Assertions.Conditions;
1414
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
1515
/// </summary>
1616
[AssertionExtension("IsEquivalentTo")]
17-
[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
1817
public class IsEquivalentToAssertion<TCollection, TItem> : CollectionComparerBasedAssertion<TCollection, TItem>
1918
where TCollection : IEnumerable<TItem>
2019
{
2120
private readonly IEnumerable<TItem> _expected;
2221
private readonly CollectionOrdering _ordering;
2322

23+
[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
2424
public IsEquivalentToAssertion(
2525
AssertionContext<TCollection> context,
2626
IEnumerable<TItem> expected,
2727
CollectionOrdering ordering = CollectionOrdering.Any)
28-
: base(context)
28+
: this(context, expected, StructuralEqualityComparer<TItem>.Instance, ordering)
2929
{
30-
_expected = expected ?? throw new ArgumentNullException(nameof(expected));
31-
_ordering = ordering;
3230
}
3331

3432
public IsEquivalentToAssertion(
@@ -40,7 +38,7 @@ public IsEquivalentToAssertion(
4038
{
4139
_expected = expected ?? throw new ArgumentNullException(nameof(expected));
4240
_ordering = ordering;
43-
SetComparer(comparer);
41+
Comparer = comparer;
4442
}
4543

4644
public IsEquivalentToAssertion<TCollection, TItem> Using(IEqualityComparer<TItem> comparer)
@@ -49,7 +47,6 @@ public IsEquivalentToAssertion<TCollection, TItem> Using(IEqualityComparer<TItem
4947
return this;
5048
}
5149

52-
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Collection equivalency uses structural comparison which requires reflection")]
5350
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
5451
{
5552
var value = metadata.Value;
@@ -60,7 +57,7 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollecti
6057
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}"));
6158
}
6259

63-
var comparer = HasCustomComparer() ? GetComparer() : StructuralEqualityComparer<TItem>.Instance;
60+
var comparer = GetComparer();
6461

6562
var result = CollectionEquivalencyChecker.AreEquivalent(
6663
value,

TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,31 @@ namespace TUnit.Assertions.Conditions;
1313
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
1414
/// </summary>
1515
[AssertionExtension("IsNotEquivalentTo")]
16-
[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
1716
public class NotEquivalentToAssertion<TCollection, TItem> : CollectionComparerBasedAssertion<TCollection, TItem>
1817
where TCollection : IEnumerable<TItem>
1918
{
2019
private readonly IEnumerable<TItem> _notExpected;
2120
private readonly CollectionOrdering _ordering;
2221

22+
[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
2323
public NotEquivalentToAssertion(
2424
AssertionContext<TCollection> context,
2525
IEnumerable<TItem> notExpected,
2626
CollectionOrdering ordering = CollectionOrdering.Any)
27+
: this(context, notExpected, StructuralEqualityComparer<TItem>.Instance, ordering)
28+
{
29+
}
30+
31+
public NotEquivalentToAssertion(
32+
AssertionContext<TCollection> context,
33+
IEnumerable<TItem> notExpected,
34+
IEqualityComparer<TItem> comparer,
35+
CollectionOrdering ordering = CollectionOrdering.Any)
2736
: base(context)
2837
{
2938
_notExpected = notExpected ?? throw new ArgumentNullException(nameof(notExpected));
3039
_ordering = ordering;
40+
Comparer = comparer;
3141
}
3242

3343
public NotEquivalentToAssertion<TCollection, TItem> Using(IEqualityComparer<TItem> comparer)
@@ -36,7 +46,6 @@ public NotEquivalentToAssertion<TCollection, TItem> Using(IEqualityComparer<TIte
3646
return this;
3747
}
3848

39-
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Collection equivalency uses structural comparison which requires reflection")]
4049
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
4150
{
4251
var value = metadata.Value;
@@ -47,7 +56,7 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollecti
4756
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}"));
4857
}
4958

50-
var comparer = HasCustomComparer() ? GetComparer() : StructuralEqualityComparer<TItem>.Instance;
59+
var comparer = GetComparer();
5160

5261
var result = CollectionEquivalencyChecker.AreEquivalent(
5362
value,

TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -388,9 +388,9 @@ namespace .Conditions
388388
public abstract class CollectionComparerBasedAssertion<TCollection, TItem> : .<TCollection, TItem>
389389
where TCollection : .<TItem>
390390
{
391+
protected .<TItem>? Comparer;
391392
protected CollectionComparerBasedAssertion(.<TCollection> context) { }
392393
protected .<TItem> GetComparer() { }
393-
protected bool HasCustomComparer() { }
394394
protected void SetComparer(.<TItem> comparer) { }
395395
}
396396
[.("Contains")]
@@ -817,15 +817,14 @@ namespace .Conditions
817817
protected override string GetExpectation() { }
818818
public .<TValue> Using(.<TValue> comparer) { }
819819
}
820-
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
821-
"ires reflection and is not compatible with AOT")]
822820
[.("IsEquivalentTo")]
823821
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection, TItem>
824822
where TCollection : .<TItem>
825823
{
824+
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
825+
"ires reflection and is not compatible with AOT")]
826826
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 0) { }
827827
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, .<TItem> comparer, . ordering = 0) { }
828-
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
829828
protected override .<.> CheckAsync(.<TCollection> metadata) { }
830829
protected override string GetExpectation() { }
831830
public .<TCollection, TItem> Using(.<TItem> comparer) { }
@@ -919,14 +918,14 @@ namespace .Conditions
919918
public .<TValue> IgnoringType( type) { }
920919
public .<TValue> IgnoringType<TIgnore>() { }
921920
}
922-
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
923-
"ires reflection and is not compatible with AOT")]
924921
[.("IsNotEquivalentTo")]
925922
public class NotEquivalentToAssertion<TCollection, TItem> : .<TCollection, TItem>
926923
where TCollection : .<TItem>
927924
{
925+
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
926+
"ires reflection and is not compatible with AOT")]
928927
public NotEquivalentToAssertion(.<TCollection> context, .<TItem> notExpected, . ordering = 0) { }
929-
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
928+
public NotEquivalentToAssertion(.<TCollection> context, .<TItem> notExpected, .<TItem> comparer, . ordering = 0) { }
930929
protected override .<.> CheckAsync(.<TCollection> metadata) { }
931930
protected override string GetExpectation() { }
932931
public .<TCollection, TItem> Using(.<TItem> comparer) { }
@@ -3085,8 +3084,6 @@ namespace .Extensions
30853084
"ires reflection and is not compatible with AOT")]
30863085
public static .<TCollection, TItem> IsEquivalentTo<TCollection, TItem>(this .<TCollection> source, .<TItem> expected, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("ordering")] string? orderingExpression = null)
30873086
where TCollection : .<TItem> { }
3088-
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
3089-
"ires reflection and is not compatible with AOT")]
30903087
public static .<TCollection, TItem> IsEquivalentTo<TCollection, TItem>(this .<TCollection> source, .<TItem> expected, .<TItem> comparer, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null)
30913088
where TCollection : .<TItem> { }
30923089
}
@@ -3184,6 +3181,8 @@ namespace .Extensions
31843181
"ires reflection and is not compatible with AOT")]
31853182
public static .<TCollection, TItem> IsNotEquivalentTo<TCollection, TItem>(this .<TCollection> source, .<TItem> notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null)
31863183
where TCollection : .<TItem> { }
3184+
public static .<TCollection, TItem> IsNotEquivalentTo<TCollection, TItem>(this .<TCollection> source, .<TItem> notExpected, .<TItem> comparer, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null)
3185+
where TCollection : .<TItem> { }
31873186
}
31883187
public static class NotSameReferenceAssertionExtensions
31893188
{

TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,9 @@ namespace .Conditions
385385
public abstract class CollectionComparerBasedAssertion<TCollection, TItem> : .<TCollection, TItem>
386386
where TCollection : .<TItem>
387387
{
388+
protected .<TItem>? Comparer;
388389
protected CollectionComparerBasedAssertion(.<TCollection> context) { }
389390
protected .<TItem> GetComparer() { }
390-
protected bool HasCustomComparer() { }
391391
protected void SetComparer(.<TItem> comparer) { }
392392
}
393393
[.("Contains")]
@@ -814,15 +814,14 @@ namespace .Conditions
814814
protected override string GetExpectation() { }
815815
public .<TValue> Using(.<TValue> comparer) { }
816816
}
817-
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
818-
"ires reflection and is not compatible with AOT")]
819817
[.("IsEquivalentTo")]
820818
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection, TItem>
821819
where TCollection : .<TItem>
822820
{
821+
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
822+
"ires reflection and is not compatible with AOT")]
823823
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 0) { }
824824
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, .<TItem> comparer, . ordering = 0) { }
825-
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
826825
protected override .<.> CheckAsync(.<TCollection> metadata) { }
827826
protected override string GetExpectation() { }
828827
public .<TCollection, TItem> Using(.<TItem> comparer) { }
@@ -916,14 +915,14 @@ namespace .Conditions
916915
public .<TValue> IgnoringType( type) { }
917916
public .<TValue> IgnoringType<TIgnore>() { }
918917
}
919-
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
920-
"ires reflection and is not compatible with AOT")]
921918
[.("IsNotEquivalentTo")]
922919
public class NotEquivalentToAssertion<TCollection, TItem> : .<TCollection, TItem>
923920
where TCollection : .<TItem>
924921
{
922+
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
923+
"ires reflection and is not compatible with AOT")]
925924
public NotEquivalentToAssertion(.<TCollection> context, .<TItem> notExpected, . ordering = 0) { }
926-
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
925+
public NotEquivalentToAssertion(.<TCollection> context, .<TItem> notExpected, .<TItem> comparer, . ordering = 0) { }
927926
protected override .<.> CheckAsync(.<TCollection> metadata) { }
928927
protected override string GetExpectation() { }
929928
public .<TCollection, TItem> Using(.<TItem> comparer) { }
@@ -3068,8 +3067,6 @@ namespace .Extensions
30683067
"ires reflection and is not compatible with AOT")]
30693068
public static .<TCollection, TItem> IsEquivalentTo<TCollection, TItem>(this .<TCollection> source, .<TItem> expected, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("ordering")] string? orderingExpression = null)
30703069
where TCollection : .<TItem> { }
3071-
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
3072-
"ires reflection and is not compatible with AOT")]
30733070
public static .<TCollection, TItem> IsEquivalentTo<TCollection, TItem>(this .<TCollection> source, .<TItem> expected, .<TItem> comparer, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null)
30743071
where TCollection : .<TItem> { }
30753072
}
@@ -3166,6 +3163,8 @@ namespace .Extensions
31663163
"ires reflection and is not compatible with AOT")]
31673164
public static .<TCollection, TItem> IsNotEquivalentTo<TCollection, TItem>(this .<TCollection> source, .<TItem> notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null)
31683165
where TCollection : .<TItem> { }
3166+
public static .<TCollection, TItem> IsNotEquivalentTo<TCollection, TItem>(this .<TCollection> source, .<TItem> notExpected, .<TItem> comparer, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null)
3167+
where TCollection : .<TItem> { }
31693168
}
31703169
public static class NotSameReferenceAssertionExtensions
31713170
{

0 commit comments

Comments
 (0)