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.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.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); + } +} diff --git a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs index 856a05d9e6..3411a44a95 100644 --- a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs +++ b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs @@ -53,13 +53,45 @@ public static void ClearCustomPrimitives() CustomPrimitiveTypes.Clear(); } + /// + /// 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.PublicProperties | + System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)] + Type type) + { + // 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; + } + /// /// 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 | + 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) if (CustomPrimitiveTypes.ContainsKey(type)) @@ -67,18 +99,54 @@ 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 + // Only treat as primitive-like if: + // 1. It's a value type (struct), OR + // 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 interfaces = type.GetInterfaces(); + foreach (var iface in interfaces) + { + if (iface.IsGenericType && + iface.GetGenericTypeDefinition() == typeof(IEquatable<>) && + iface.GetGenericArguments()[0] == type) + { + // 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; + } + + break; + } + } + + return false; } }