From 59aa0060a96532d3c504a7c202841173f0fbc8af Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 15 Dec 2025 13:16:01 +0000
Subject: [PATCH 1/6] Initial plan
From 971288114634bebe164b8c57114f3971a2f3601b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 15 Dec 2025 13:26:59 +0000
Subject: [PATCH 2/6] Fix IsEquivalentTo to respect IEquatable<T>
implementation
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../IEquatableCollectionTests.cs | 163 ++++++++++++++++++
.../Conditions/Helpers/TypeHelper.cs | 47 +++--
global.json | 2 +-
3 files changed, 199 insertions(+), 13 deletions(-)
create mode 100644 TUnit.Assertions.Tests/IEquatableCollectionTests.cs
diff --git a/TUnit.Assertions.Tests/IEquatableCollectionTests.cs b/TUnit.Assertions.Tests/IEquatableCollectionTests.cs
new file mode 100644
index 0000000000..b62aacd48f
--- /dev/null
+++ b/TUnit.Assertions.Tests/IEquatableCollectionTests.cs
@@ -0,0 +1,163 @@
+using System.Numerics;
+
+namespace TUnit.Assertions.Tests;
+
+///
+/// Tests for collections containing types that implement IEquatable<T>.
+/// Verifies that IsEquivalentTo respects IEquatable implementation rather than using structural comparison.
+///
+public class IEquatableCollectionTests
+{
+ [Test]
+ public async Task Vector2_Array_IsEquivalentTo_List()
+ {
+ // Arrange - Vector2 implements IEquatable
+ var array = new Vector2[]
+ {
+ new Vector2(1, 2),
+ new Vector2(3, 4),
+ new Vector2(5, 6),
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use Vector2's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task Uri_Array_IsEquivalentTo_List()
+ {
+ // Arrange - Uri implements IEquatable
+ var array = new Uri[]
+ {
+ new Uri("https://example.com"),
+ new Uri("https://github.com"),
+ new Uri("https://stackoverflow.com"),
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use Uri's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task CultureInfo_Array_IsEquivalentTo_List()
+ {
+ // Arrange - CultureInfo implements IEquatable
+ var array = new System.Globalization.CultureInfo[]
+ {
+ new System.Globalization.CultureInfo("en-US"),
+ new System.Globalization.CultureInfo("fr-FR"),
+ new System.Globalization.CultureInfo("de-DE"),
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use CultureInfo's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task Vector3_Array_IsEquivalentTo_List()
+ {
+ // Arrange - Vector3 implements IEquatable
+ var array = new Vector3[]
+ {
+ new Vector3(1, 2, 3),
+ new Vector3(4, 5, 6),
+ new Vector3(7, 8, 9),
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use Vector3's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task DateTimeOffset_Array_IsEquivalentTo_List()
+ {
+ // Arrange - DateTimeOffset implements IEquatable
+ var array = new DateTimeOffset[]
+ {
+ new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.Zero),
+ new DateTimeOffset(2023, 6, 15, 12, 30, 0, TimeSpan.Zero),
+ new DateTimeOffset(2023, 12, 31, 23, 59, 59, TimeSpan.Zero),
+ };
+ var list = new List(array);
+
+ // Act & Assert
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task CustomEquatable_Array_IsEquivalentTo_List()
+ {
+ // Arrange - custom type implementing IEquatable
+ var array = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 2, Name = "Second" },
+ new CustomEquatable { Id = 3, Name = "Third" },
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use CustomEquatable's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task CustomEquatable_Array_NotEquivalent_DifferentValues()
+ {
+ // Arrange
+ var array1 = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 2, Name = "Second" },
+ };
+ var array2 = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 3, Name = "Third" }, // Different Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ {
+ await Assert.That(array1).IsEquivalentTo(array2);
+ });
+ }
+
+ ///
+ /// Custom type implementing IEquatable<T> for testing.
+ ///
+ public class CustomEquatable : IEquatable
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+
+ public bool Equals(CustomEquatable? other)
+ {
+ if (other is null) return false;
+ return Id == other.Id && Name == other.Name;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is CustomEquatable other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+#if NET6_0_OR_GREATER
+ return HashCode.Combine(Id, Name);
+#else
+ unchecked
+ {
+ int hash = 17;
+ hash = hash * 23 + Id.GetHashCode();
+ hash = hash * 23 + (Name?.GetHashCode() ?? 0);
+ return hash;
+ }
+#endif
+ }
+ }
+}
diff --git a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
index 856a05d9e6..bc3ee1f8eb 100644
--- a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
+++ b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
@@ -56,10 +56,14 @@ public static void ClearCustomPrimitives()
///
/// Determines if a type is a primitive or well-known immutable type that should use
/// value equality rather than structural comparison.
+ /// Types implementing IEquatable<T> are also considered primitive-like for comparison purposes.
///
/// The type to check.
/// True if the type should use value equality; false for structural comparison.
- public static bool IsPrimitiveOrWellKnownType(Type type)
+ public static bool IsPrimitiveOrWellKnownType(
+ [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
+ Type type)
{
// Check user-defined primitives first (fast path for common case)
if (CustomPrimitiveTypes.ContainsKey(type))
@@ -67,18 +71,37 @@ 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)
+ // Check if type is a well-known primitive type
+ 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 type implements IEquatable for its own type
+ // Types implementing IEquatable should use their Equals method
+ // rather than structural comparison (e.g., Vector2, Uri, CultureInfo)
+ var equatableInterface = type.GetInterface("IEquatable`1");
+ if (equatableInterface != null)
+ {
+ var genericArgs = equatableInterface.GetGenericArguments();
+ if (genericArgs.Length == 1 && genericArgs[0] == type)
+ {
+ return true;
+ }
+ }
+
+ return false;
}
}
diff --git a/global.json b/global.json
index 2139291fc8..67b0c9d008 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.101",
+ "version": "10.0.100",
"rollForward": "latestMajor",
"allowPrerelease": true
},
From cb641c15ab342b73ae6e8353543c62a58a0e844c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 15 Dec 2025 13:32:34 +0000
Subject: [PATCH 3/6] Refine IEquatable check to exclude records and preserve
structural comparison
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../Conditions/Helpers/TypeHelper.cs | 36 ++++++++++++++++---
1 file changed, 32 insertions(+), 4 deletions(-)
diff --git a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
index bc3ee1f8eb..b6b16d2f2b 100644
--- a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
+++ b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
@@ -53,6 +53,19 @@ public static void ClearCustomPrimitives()
CustomPrimitiveTypes.Clear();
}
+ ///
+ /// Checks if a type is compiler-generated (e.g., records, anonymous types).
+ ///
+ private static bool IsCompilerGeneratedType(
+ [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods)]
+ Type type)
+ {
+ // Records have a compiler-generated $ method
+ // This is a heuristic but works reliably for C# records
+ return type.GetMethod("$", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) != null;
+ }
+
///
/// Determines if a type is a primitive or well-known immutable type that should use
/// value equality rather than structural comparison.
@@ -62,7 +75,8 @@ public static void ClearCustomPrimitives()
/// True if the type should use value equality; false for structural comparison.
public static bool IsPrimitiveOrWellKnownType(
[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
- System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods)]
Type type)
{
// Check user-defined primitives first (fast path for common case)
@@ -90,15 +104,29 @@ public static bool IsPrimitiveOrWellKnownType(
}
// Check if type implements IEquatable for its own type
- // Types implementing IEquatable should use their Equals method
- // rather than structural comparison (e.g., Vector2, Uri, CultureInfo)
+ // Only treat as primitive-like if:
+ // 1. It's a value type (struct), OR
+ // 2. It's a well-known immutable reference type
+ // This ensures records and other reference types still use structural comparison
var equatableInterface = type.GetInterface("IEquatable`1");
if (equatableInterface != null)
{
var genericArgs = equatableInterface.GetGenericArguments();
if (genericArgs.Length == 1 && genericArgs[0] == type)
{
- return true;
+ // For value types, always use IEquatable
+ if (type.IsValueType)
+ {
+ return true;
+ }
+
+ // For reference types, only use IEquatable for known immutable types
+ // that are safe to compare by value (e.g., Uri, CultureInfo)
+ // Exclude records and other types that might have mutable reference fields
+ if (type.IsSealed && !IsCompilerGeneratedType(type))
+ {
+ return true;
+ }
}
}
From 18949b06f0c4618b0272bde1e2d47ea1ab885789 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 15 Dec 2025 13:39:33 +0000
Subject: [PATCH 4/6] Improve type detection based on code review feedback
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../IEquatableWorkaroundTests.cs | 107 ++++++++++++++++++
.../Conditions/Helpers/TypeHelper.cs | 37 ++++--
2 files changed, 134 insertions(+), 10 deletions(-)
create mode 100644 TUnit.Assertions.Tests/IEquatableWorkaroundTests.cs
diff --git a/TUnit.Assertions.Tests/IEquatableWorkaroundTests.cs b/TUnit.Assertions.Tests/IEquatableWorkaroundTests.cs
new file mode 100644
index 0000000000..ca4419b8c5
--- /dev/null
+++ b/TUnit.Assertions.Tests/IEquatableWorkaroundTests.cs
@@ -0,0 +1,107 @@
+using System.Numerics;
+
+namespace TUnit.Assertions.Tests;
+
+///
+/// Tests to ensure the workaround mentioned in the issue still works.
+/// Users should be able to explicitly pass EqualityComparer<T>.Default if needed.
+///
+public class IEquatableWorkaroundTests
+{
+ [Test]
+ public async Task Vector2_Array_IsEquivalentTo_WithExplicitComparer()
+ {
+ // Arrange
+ var array = new Vector2[]
+ {
+ new Vector2(1, 2),
+ new Vector2(3, 4),
+ new Vector2(5, 6),
+ };
+ var list = new List(array);
+
+ // Act & Assert - explicitly passing EqualityComparer.Default
+ await Assert.That(array).IsEquivalentTo(list, EqualityComparer.Default);
+ }
+
+ [Test]
+ public async Task CustomEquatable_Array_IsEquivalentTo_WithCustomComparer()
+ {
+ // Arrange
+ var array = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 2, Name = "Second" },
+ };
+ var list = new List(array);
+
+ // Act & Assert - using a custom comparer that only compares Id
+ await Assert.That(array).IsEquivalentTo(list, new IdOnlyComparer());
+ }
+
+ [Test]
+ public async Task CustomEquatable_Array_WithDifferentNames_EquivalentWithCustomComparer()
+ {
+ // Arrange
+ var array = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 2, Name = "Second" },
+ };
+ var list = new List
+ {
+ new CustomEquatable { Id = 1, Name = "Different" },
+ new CustomEquatable { Id = 2, Name = "Names" },
+ };
+
+ // Act & Assert - custom comparer ignores Name
+ await Assert.That(array).IsEquivalentTo(list, new IdOnlyComparer());
+ }
+
+ public class CustomEquatable : IEquatable
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+
+ public bool Equals(CustomEquatable? other)
+ {
+ if (other is null) return false;
+ return Id == other.Id && Name == other.Name;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is CustomEquatable other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+#if NET6_0_OR_GREATER
+ return HashCode.Combine(Id, Name);
+#else
+ unchecked
+ {
+ int hash = 17;
+ hash = hash * 23 + Id.GetHashCode();
+ hash = hash * 23 + (Name?.GetHashCode() ?? 0);
+ return hash;
+ }
+#endif
+ }
+ }
+
+ private class IdOnlyComparer : IEqualityComparer
+ {
+ public bool Equals(CustomEquatable? x, CustomEquatable? y)
+ {
+ if (x is null && y is null) return true;
+ if (x is null || y is null) return false;
+ return x.Id == y.Id;
+ }
+
+ public int GetHashCode(CustomEquatable obj)
+ {
+ return obj.Id.GetHashCode();
+ }
+ }
+}
diff --git a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
index b6b16d2f2b..3411a44a95 100644
--- a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
+++ b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
@@ -54,15 +54,27 @@ public static void ClearCustomPrimitives()
}
///
- /// Checks if a type is compiler-generated (e.g., records, anonymous types).
+ /// Checks if a type is a compiler-generated record type.
///
private static bool IsCompilerGeneratedType(
[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
- System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods)]
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)]
Type type)
{
- // Records have a compiler-generated $ method
- // This is a heuristic but works reliably for C# records
+ // Records have a compiler-generated EqualityContract property
+ // This is more reliable than checking for $ method
+ var equalityContract = type.GetProperty("EqualityContract",
+ System.Reflection.BindingFlags.NonPublic |
+ System.Reflection.BindingFlags.Instance);
+
+ if (equalityContract != null && equalityContract.PropertyType == typeof(Type))
+ {
+ return true;
+ }
+
+ // Also check for $ method as a fallback
return type.GetMethod("$", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) != null;
}
@@ -76,7 +88,9 @@ private static bool IsCompilerGeneratedType(
public static bool IsPrimitiveOrWellKnownType(
[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces |
- System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods)]
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)]
Type type)
{
// Check user-defined primitives first (fast path for common case)
@@ -106,13 +120,14 @@ public static bool IsPrimitiveOrWellKnownType(
// Check if type implements IEquatable for its own type
// Only treat as primitive-like if:
// 1. It's a value type (struct), OR
- // 2. It's a well-known immutable reference type
+ // 2. It's a sealed reference type that's not compiler-generated (e.g., Uri, CultureInfo)
// This ensures records and other reference types still use structural comparison
- var equatableInterface = type.GetInterface("IEquatable`1");
- if (equatableInterface != null)
+ var interfaces = type.GetInterfaces();
+ foreach (var iface in interfaces)
{
- var genericArgs = equatableInterface.GetGenericArguments();
- if (genericArgs.Length == 1 && genericArgs[0] == type)
+ if (iface.IsGenericType &&
+ iface.GetGenericTypeDefinition() == typeof(IEquatable<>) &&
+ iface.GetGenericArguments()[0] == type)
{
// For value types, always use IEquatable
if (type.IsValueType)
@@ -127,6 +142,8 @@ public static bool IsPrimitiveOrWellKnownType(
{
return true;
}
+
+ break;
}
}
From 52836580cbc6ff8438e10608787946b41dd9b8ab Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 15 Dec 2025 13:46:24 +0000
Subject: [PATCH 5/6] Add test reproducing the exact GitHub issue scenario
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../IssueReproductionTest.cs | 47 +++++++++++++++++++
1 file changed, 47 insertions(+)
create mode 100644 TUnit.Assertions.Tests/IssueReproductionTest.cs
diff --git a/TUnit.Assertions.Tests/IssueReproductionTest.cs b/TUnit.Assertions.Tests/IssueReproductionTest.cs
new file mode 100644
index 0000000000..ee8b0fc25b
--- /dev/null
+++ b/TUnit.Assertions.Tests/IssueReproductionTest.cs
@@ -0,0 +1,47 @@
+using System.Numerics;
+
+namespace TUnit.Assertions.Tests;
+
+///
+/// Reproduces the exact test case from the GitHub issue to verify the fix.
+/// This test should now pass without requiring an explicit comparer.
+///
+public class IssueReproductionTest
+{
+ ///
+ /// This is the exact test case from the GitHub issue.
+ /// Previously this would fail with "Parameter count mismatch" error.
+ /// Now it should pass because Vector2 implements IEquatable<Vector2>.
+ ///
+ [Test]
+ public async Task Test1_FromGitHubIssue()
+ {
+ var array = new Vector2[]
+ {
+ new Vector2(1, 2),
+ new Vector2(3, 4),
+ new Vector2(5, 6),
+ };
+ var array2 = new List(array);
+
+ await Assert.That(array).IsEquivalentTo(array2);
+ }
+
+ ///
+ /// Verify the workaround mentioned in the issue still works.
+ /// Users can still explicitly pass EqualityComparer if needed.
+ ///
+ [Test]
+ public async Task Test2_WithExplicitComparer_StillWorks()
+ {
+ var array = new Vector2[]
+ {
+ new Vector2(1, 2),
+ new Vector2(3, 4),
+ new Vector2(5, 6),
+ };
+ var array2 = new List(array);
+
+ await Assert.That(array).IsEquivalentTo(array2, EqualityComparer.Default);
+ }
+}
From cca93335e0b9e8df75d1da6a685c0f168bbb6bb8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 15 Dec 2025 13:47:12 +0000
Subject: [PATCH 6/6] Revert global.json change (was only needed for local
testing)
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
global.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/global.json b/global.json
index 67b0c9d008..2139291fc8 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.100",
+ "version": "10.0.101",
"rollForward": "latestMajor",
"allowPrerelease": true
},