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;
}
}