diff --git a/TUnit.Assertions.Tests/CollectionStructuralEquivalenceTests.cs b/TUnit.Assertions.Tests/CollectionStructuralEquivalenceTests.cs index 7660b6fa19..bcbf7a94ec 100644 --- a/TUnit.Assertions.Tests/CollectionStructuralEquivalenceTests.cs +++ b/TUnit.Assertions.Tests/CollectionStructuralEquivalenceTests.cs @@ -186,6 +186,59 @@ public async Task Collections_With_Custom_Comparer_Uses_Custom_Comparer() await TUnitAssert.That(listA).IsEquivalentTo(listB).Using(StringComparer.OrdinalIgnoreCase); } + + /// + /// Tests for issue #3722: IsEquivalentTo does not respect IEquatable{T} for types like Vector2 + /// + [Test] + public async Task Collections_With_Vector2_Are_Equivalent_Using_IEquatable() + { + var array = new System.Numerics.Vector2[] + { + new System.Numerics.Vector2(1, 2), + new System.Numerics.Vector2(3, 4), + new System.Numerics.Vector2(5, 6), + }; + var list = new List(array); + + await TUnitAssert.That(array).IsEquivalentTo(list); + } + + [Test] + public async Task Collections_With_Vector2_Different_Values_Are_Not_Equivalent() + { + var array = new System.Numerics.Vector2[] + { + new System.Numerics.Vector2(1, 2), + new System.Numerics.Vector2(3, 4), + }; + var list = new List + { + new System.Numerics.Vector2(1, 2), + new System.Numerics.Vector2(5, 6), + }; + + await TUnitAssert.That(array).IsNotEquivalentTo(list); + } + + [Test] + public async Task Collections_With_Uri_Are_Equivalent_Using_IEquatable() + { + var listA = new List { new Uri("http://example.com"), new Uri("http://test.com") }; + var listB = new List { new Uri("http://example.com"), new Uri("http://test.com") }; + + await TUnitAssert.That(listA).IsEquivalentTo(listB); + } + + [Test] + public async Task Collections_With_Uri_Different_Values_Are_Not_Equivalent() + { + var listA = new List { new Uri("http://example.com") }; + var listB = new List { new Uri("http://different.com") }; + + await TUnitAssert.That(listA).IsNotEquivalentTo(listB); + } + public class Message { public string? Content { get; set; } diff --git a/TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs b/TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs index c6c53162c5..ab52490c39 100644 --- a/TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs +++ b/TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs @@ -11,6 +11,7 @@ internal static class ReflectionHelper { /// /// Gets all public instance properties and fields to compare for structural equivalency. + /// Filters out indexed properties (like indexers) that require parameters. /// /// The type to get members from. /// A list of PropertyInfo and FieldInfo members. @@ -19,7 +20,17 @@ public static List GetMembersToCompare( Type type) { var members = new List(); - members.AddRange(type.GetProperties(BindingFlags.Public | BindingFlags.Instance)); + + // Filter out indexed properties (properties with parameters like this[int index]) + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var prop in properties) + { + if (prop.GetIndexParameters().Length == 0) + { + members.Add(prop); + } + } + members.AddRange(type.GetFields(BindingFlags.Public | BindingFlags.Instance)); return members; } diff --git a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs index 856a05d9e6..7715a11946 100644 --- a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs +++ b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; namespace TUnit.Assertions.Conditions.Helpers; @@ -59,6 +60,8 @@ public static void ClearCustomPrimitives() /// /// The type to check. /// True if the type should use value equality; false for structural comparison. + [UnconditionalSuppressMessage("Trimming", "IL2067", + Justification = "This method is only called from code paths that already require reflection (StructuralEqualityComparer)")] public static bool IsPrimitiveOrWellKnownType(Type type) { // Check user-defined primitives first (fast path for common case) @@ -67,18 +70,55 @@ public static bool IsPrimitiveOrWellKnownType(Type type) return true; } - return type.IsPrimitive - || type.IsEnum - || type == typeof(string) - || type == typeof(decimal) - || type == typeof(DateTime) - || type == typeof(DateTimeOffset) - || type == typeof(TimeSpan) - || type == typeof(Guid) + if (type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(TimeSpan) + || type == typeof(Guid) #if NET6_0_OR_GREATER - || type == typeof(DateOnly) - || type == typeof(TimeOnly) + || type == typeof(DateOnly) + || type == typeof(TimeOnly) #endif - ; + ) + { + return true; + } + + // Check if the type is a value type (struct) that implements IEquatable for itself + // Value types like Vector2, Matrix3x2, etc. that implement IEquatable + // should use value equality rather than structural comparison. + // We only check value types to avoid affecting records/classes that may have + // collection properties requiring structural comparison. + if (type.IsValueType && ImplementsSelfEquatable(type)) + { + return true; + } + + return false; + } + + /// + /// Checks if a type implements IEquatable{T} where T is the type itself. + /// + private static bool ImplementsSelfEquatable( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + Type type) + { + // Iterate through interfaces to find IEquatable where T is the type itself + // This approach is AOT-compatible as it doesn't use MakeGenericType + foreach (var iface in type.GetInterfaces()) + { + if (iface.IsGenericType + && iface.GetGenericTypeDefinition() == typeof(IEquatable<>) + && iface.GenericTypeArguments[0] == type) + { + return true; + } + } + + return false; } }