Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions TUnit.Assertions.Tests/CollectionStructuralEquivalenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,59 @@ public async Task Collections_With_Custom_Comparer_Uses_Custom_Comparer()

await TUnitAssert.That(listA).IsEquivalentTo(listB).Using(StringComparer.OrdinalIgnoreCase);
}

/// <summary>
/// Tests for issue #3722: IsEquivalentTo does not respect IEquatable{T} for types like Vector2
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue number in the comment is incorrect. The PR description indicates this fixes issue #4081, but the comment references #3722.

Suggested change
/// Tests for issue #3722: IsEquivalentTo does not respect IEquatable{T} for types like Vector2
/// Tests for issue #4081: IsEquivalentTo does not respect IEquatable{T} for types like Vector2

Copilot uses AI. Check for mistakes.
/// </summary>
[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<System.Numerics.Vector2>(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<System.Numerics.Vector2>
{
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<Uri> { new Uri("http://example.com"), new Uri("http://test.com") };
var listB = new List<Uri> { 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<Uri> { new Uri("http://example.com") };
var listB = new List<Uri> { new Uri("http://different.com") };

await TUnitAssert.That(listA).IsNotEquivalentTo(listB);
}

public class Message
{
public string? Content { get; set; }
Expand Down
13 changes: 12 additions & 1 deletion TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal static class ReflectionHelper
{
/// <summary>
/// Gets all public instance properties and fields to compare for structural equivalency.
/// Filters out indexed properties (like indexers) that require parameters.
/// </summary>
/// <param name="type">The type to get members from.</param>
/// <returns>A list of PropertyInfo and FieldInfo members.</returns>
Expand All @@ -19,7 +20,17 @@ public static List<MemberInfo> GetMembersToCompare(
Type type)
{
var members = new List<MemberInfo>();
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);
}
}
Comment on lines +26 to +32
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.

members.AddRange(type.GetFields(BindingFlags.Public | BindingFlags.Instance));
return members;
}
Expand Down
62 changes: 51 additions & 11 deletions TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;

namespace TUnit.Assertions.Conditions.Helpers;

Expand Down Expand Up @@ -59,6 +60,8 @@ public static void ClearCustomPrimitives()
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns>True if the type should use value equality; false for structural comparison.</returns>
[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)
Expand All @@ -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<T> for itself
// Value types like Vector2, Matrix3x2, etc. that implement IEquatable<T>
// 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;
}

/// <summary>
/// Checks if a type implements IEquatable{T} where T is the type itself.
/// </summary>
private static bool ImplementsSelfEquatable(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)]
Type type)
{
// Iterate through interfaces to find IEquatable<T> 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;
}
}
Comment on lines +112 to +120
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.

return false;
}
}
Loading