diff --git a/TUnit.Assertions.Tests/Bugs/Tests4358.cs b/TUnit.Assertions.Tests/Bugs/Tests4358.cs new file mode 100644 index 0000000000..64f40ab8f6 --- /dev/null +++ b/TUnit.Assertions.Tests/Bugs/Tests4358.cs @@ -0,0 +1,788 @@ +namespace TUnit.Assertions.Tests.Bugs; + +/// +/// Tests for issue #4358: IsEquivalentTo broken for value types +/// ValueTuples and structs containing reference type properties were incorrectly +/// compared using Equals() instead of structural comparison. +/// +public class Tests4358 +{ + #region Test Types + + public record Thing(string Name, int[] Numbers); + + public record Person(string Name, int Age, List Tags); + + public class NonRecordClass + { + public string Value { get; set; } = string.Empty; + public int[] Data { get; set; } = []; + } + + public struct StructWithReferenceProperties + { + public string Name { get; set; } + public int[] Numbers { get; set; } + public List Tags { get; set; } + } + + public struct StructWithEquatable : IEquatable + { + public string Id { get; set; } + public int[] Data { get; set; } + + // This Equals only compares Id, NOT Data - demonstrating why structural comparison is needed + public bool Equals(StructWithEquatable other) => Id == other.Id; + public override bool Equals(object? obj) => obj is StructWithEquatable other && Equals(other); + public override int GetHashCode() => Id?.GetHashCode() ?? 0; + } + + public struct NestedStruct + { + public string Name { get; set; } + public StructWithReferenceProperties Inner { get; set; } + } + + public struct StructWithNullableReference + { + public string? Name { get; set; } + public int[]? Numbers { get; set; } + } + + #endregion + + #region Original Bug Reproduction Tests + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithEquivalentRecords_ShouldSucceed() + { + // Arrange - Two structurally equivalent records (different instances, same content) + var foo = new Thing("Foo", [1, 2, 3]); + var bar = new Thing("Foo", [1, 2, 3]); + + // Act & Assert - Should pass because records have equivalent content + // This test verifies that ValueTuples use structural comparison, not Equals() + await Assert.That((foo, bar)).IsEquivalentTo((bar, foo)); + } + + [Test] + public async Task IsEquivalentTo_Record_WithArrayProperty_ShouldSucceed() + { + // Arrange - Two records with identical array content but different array instances + var foo = new Thing("Foo", [1, 2, 3]); + var bar = new Thing("Foo", [1, 2, 3]); + + // Act & Assert - Records with equivalent content should be considered equivalent + await Assert.That(foo).IsEquivalentTo(bar); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithDifferentRecords_ShouldFail() + { + // Arrange - Two records with different content + var foo = new Thing("Foo", [1, 2, 3]); + var bar = new Thing("Bar", [4, 5, 6]); + + // Act & Assert - Should fail because records have different content + var exception = await Assert.ThrowsAsync( + async () => await Assert.That((foo, bar)).IsEquivalentTo((bar, foo))); + + await Assert.That(exception).IsNotNull(); + } + + #endregion + + #region ValueTuple Arity Tests + + [Test] + public async Task IsEquivalentTo_ValueTuple2_WithEquivalentContent_ShouldSucceed() + { + var tuple1 = (new Thing("A", [1]), new Thing("B", [2])); + var tuple2 = (new Thing("A", [1]), new Thing("B", [2])); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple3_WithEquivalentContent_ShouldSucceed() + { + var tuple1 = (new Thing("A", [1]), new Thing("B", [2]), new Thing("C", [3])); + var tuple2 = (new Thing("A", [1]), new Thing("B", [2]), new Thing("C", [3])); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple4_WithEquivalentContent_ShouldSucceed() + { + var tuple1 = (new Thing("A", [1]), new Thing("B", [2]), new Thing("C", [3]), new Thing("D", [4])); + var tuple2 = (new Thing("A", [1]), new Thing("B", [2]), new Thing("C", [3]), new Thing("D", [4])); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple5_WithEquivalentContent_ShouldSucceed() + { + var tuple1 = ("a", "b", "c", "d", new int[] { 1, 2, 3 }); + var tuple2 = ("a", "b", "c", "d", new int[] { 1, 2, 3 }); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple6_WithEquivalentContent_ShouldSucceed() + { + var tuple1 = (1, 2, 3, 4, 5, new string[] { "a", "b" }); + var tuple2 = (1, 2, 3, 4, 5, new string[] { "a", "b" }); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple7_WithEquivalentContent_ShouldSucceed() + { + var tuple1 = (1, 2, 3, 4, 5, 6, new List { 7, 8, 9 }); + var tuple2 = (1, 2, 3, 4, 5, 6, new List { 7, 8, 9 }); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + #endregion + + #region Nested ValueTuple Tests + + [Test] + public async Task IsEquivalentTo_NestedValueTuple_WithEquivalentContent_ShouldSucceed() + { + var foo = new Thing("Foo", [1, 2, 3]); + var bar = new Thing("Bar", [4, 5, 6]); + + var tuple1 = ((foo, bar), "test"); + var tuple2 = ((new Thing("Foo", [1, 2, 3]), new Thing("Bar", [4, 5, 6])), "test"); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_DeeplyNestedValueTuple_ShouldSucceed() + { + var tuple1 = (((1, new int[] { 2, 3 }), "inner"), "outer"); + var tuple2 = (((1, new int[] { 2, 3 }), "inner"), "outer"); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_DeeplyNestedValueTuple_DifferentArrayContent_ShouldFail() + { + var tuple1 = (((1, new int[] { 2, 3 }), "inner"), "outer"); + var tuple2 = (((1, new int[] { 2, 99 }), "inner"), "outer"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(tuple1).IsEquivalentTo(tuple2)); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task IsEquivalentTo_TripleNestedValueTuple_ShouldSucceed() + { + var innermost = (new Thing("Deep", [1, 2, 3]), 42); + var middle = (innermost, "middle"); + var outer1 = (middle, new int[] { 10, 20 }); + + var innermost2 = (new Thing("Deep", [1, 2, 3]), 42); + var middle2 = (innermost2, "middle"); + var outer2 = (middle2, new int[] { 10, 20 }); + + await Assert.That(outer1).IsEquivalentTo(outer2); + } + + #endregion + + #region ValueTuple with Primitives Tests + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithPrimitives_ShouldSucceed() + { + var tuple1 = (42, "hello", 3.14); + var tuple2 = (42, "hello", 3.14); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithPrimitives_DifferentValues_ShouldFail() + { + var tuple1 = (42, "hello", 3.14); + var tuple2 = (99, "world", 2.71); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(tuple1).IsEquivalentTo(tuple2)); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithMixedPrimitivesAndArrays_ShouldSucceed() + { + var tuple1 = (42, new int[] { 1, 2, 3 }, "test", 3.14); + var tuple2 = (42, new int[] { 1, 2, 3 }, "test", 3.14); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithDateTime_ShouldSucceed() + { + var date = new DateTime(2024, 1, 15, 10, 30, 0); + var tuple1 = (date, new int[] { 1, 2, 3 }); + var tuple2 = (new DateTime(2024, 1, 15, 10, 30, 0), new int[] { 1, 2, 3 }); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithGuid_ShouldSucceed() + { + var guid = Guid.Parse("12345678-1234-1234-1234-123456789012"); + var tuple1 = (guid, new string[] { "a", "b" }); + var tuple2 = (Guid.Parse("12345678-1234-1234-1234-123456789012"), new string[] { "a", "b" }); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + #endregion + + #region Struct with Reference Properties Tests + + [Test] + public async Task IsEquivalentTo_StructWithReferenceProperties_SameContent_ShouldSucceed() + { + var struct1 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [1, 2, 3], + Tags = ["a", "b", "c"] + }; + + var struct2 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [1, 2, 3], + Tags = ["a", "b", "c"] + }; + + await Assert.That(struct1).IsEquivalentTo(struct2); + } + + [Test] + public async Task IsEquivalentTo_StructWithReferenceProperties_DifferentArrayContent_ShouldFail() + { + var struct1 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [1, 2, 3], + Tags = ["a", "b", "c"] + }; + + var struct2 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [1, 2, 99], + Tags = ["a", "b", "c"] + }; + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(struct1).IsEquivalentTo(struct2)); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task IsEquivalentTo_StructWithReferenceProperties_DifferentListContent_ShouldFail() + { + var struct1 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [1, 2, 3], + Tags = ["a", "b", "c"] + }; + + var struct2 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [1, 2, 3], + Tags = ["a", "b", "different"] + }; + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(struct1).IsEquivalentTo(struct2)); + + await Assert.That(exception).IsNotNull(); + } + + #endregion + + #region Struct Implementing IEquatable Tests + + [Test] + public async Task IsEquivalentTo_StructWithEquatable_ComparesStructurally_NotByEquals() + { + // This struct's Equals() only compares Id, ignoring Data + // IsEquivalentTo should compare ALL fields structurally + var struct1 = new StructWithEquatable + { + Id = "same-id", + Data = [1, 2, 3] + }; + + var struct2 = new StructWithEquatable + { + Id = "same-id", + Data = [1, 2, 3] + }; + + // Should succeed because structural comparison finds all fields equal + await Assert.That(struct1).IsEquivalentTo(struct2); + } + + [Test] + public async Task IsEquivalentTo_StructWithEquatable_DifferentData_SameId_ShouldFail() + { + // This struct's Equals() would return true (same Id), but + // IsEquivalentTo should fail because Data is different + var struct1 = new StructWithEquatable + { + Id = "same-id", + Data = [1, 2, 3] + }; + + var struct2 = new StructWithEquatable + { + Id = "same-id", + Data = [4, 5, 6] // Different data! + }; + + // Should fail because structural comparison finds Data different + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(struct1).IsEquivalentTo(struct2)); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task IsEquivalentTo_StructWithEquatable_InTuple_ComparesStructurally() + { + var struct1 = new StructWithEquatable { Id = "id1", Data = [1, 2, 3] }; + var struct2 = new StructWithEquatable { Id = "id1", Data = [1, 2, 3] }; + + var tuple1 = (struct1, "extra"); + var tuple2 = (struct2, "extra"); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_StructWithEquatable_InTuple_DifferentData_ShouldFail() + { + var struct1 = new StructWithEquatable { Id = "id1", Data = [1, 2, 3] }; + var struct2 = new StructWithEquatable { Id = "id1", Data = [9, 9, 9] }; // Different! + + var tuple1 = (struct1, "extra"); + var tuple2 = (struct2, "extra"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(tuple1).IsEquivalentTo(tuple2)); + + await Assert.That(exception).IsNotNull(); + } + + #endregion + + #region Nested Struct Tests + + [Test] + public async Task IsEquivalentTo_NestedStruct_WithEquivalentContent_ShouldSucceed() + { + var struct1 = new NestedStruct + { + Name = "Outer", + Inner = new StructWithReferenceProperties + { + Name = "Inner", + Numbers = [1, 2, 3], + Tags = ["a", "b"] + } + }; + + var struct2 = new NestedStruct + { + Name = "Outer", + Inner = new StructWithReferenceProperties + { + Name = "Inner", + Numbers = [1, 2, 3], + Tags = ["a", "b"] + } + }; + + await Assert.That(struct1).IsEquivalentTo(struct2); + } + + [Test] + public async Task IsEquivalentTo_NestedStruct_DifferentInnerArray_ShouldFail() + { + var struct1 = new NestedStruct + { + Name = "Outer", + Inner = new StructWithReferenceProperties + { + Name = "Inner", + Numbers = [1, 2, 3], + Tags = ["a", "b"] + } + }; + + var struct2 = new NestedStruct + { + Name = "Outer", + Inner = new StructWithReferenceProperties + { + Name = "Inner", + Numbers = [1, 2, 99], // Different! + Tags = ["a", "b"] + } + }; + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(struct1).IsEquivalentTo(struct2)); + + await Assert.That(exception).IsNotNull(); + } + + #endregion + + #region Nullable Reference in Struct Tests + + [Test] + public async Task IsEquivalentTo_StructWithNullableReference_BothNull_ShouldSucceed() + { + var struct1 = new StructWithNullableReference + { + Name = null, + Numbers = null + }; + + var struct2 = new StructWithNullableReference + { + Name = null, + Numbers = null + }; + + await Assert.That(struct1).IsEquivalentTo(struct2); + } + + [Test] + public async Task IsEquivalentTo_StructWithNullableReference_BothPopulated_ShouldSucceed() + { + var struct1 = new StructWithNullableReference + { + Name = "Test", + Numbers = [1, 2, 3] + }; + + var struct2 = new StructWithNullableReference + { + Name = "Test", + Numbers = [1, 2, 3] + }; + + await Assert.That(struct1).IsEquivalentTo(struct2); + } + + [Test] + public async Task IsEquivalentTo_StructWithNullableReference_OneNull_ShouldFail() + { + var struct1 = new StructWithNullableReference + { + Name = "Test", + Numbers = [1, 2, 3] + }; + + var struct2 = new StructWithNullableReference + { + Name = "Test", + Numbers = null + }; + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(struct1).IsEquivalentTo(struct2)); + + await Assert.That(exception).IsNotNull(); + } + + #endregion + + #region ValueTuple with Non-Record Class Tests + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithNonRecordClass_ShouldSucceed() + { + var obj1 = new NonRecordClass { Value = "test", Data = [1, 2, 3] }; + var obj2 = new NonRecordClass { Value = "test", Data = [1, 2, 3] }; + + var tuple1 = (obj1, 42); + var tuple2 = (obj2, 42); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithNonRecordClass_DifferentData_ShouldFail() + { + var obj1 = new NonRecordClass { Value = "test", Data = [1, 2, 3] }; + var obj2 = new NonRecordClass { Value = "test", Data = [4, 5, 6] }; + + var tuple1 = (obj1, 42); + var tuple2 = (obj2, 42); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(tuple1).IsEquivalentTo(tuple2)); + + await Assert.That(exception).IsNotNull(); + } + + #endregion + + #region Empty Collection Tests + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithEmptyArrays_ShouldSucceed() + { + var tuple1 = (Array.Empty(), "test"); + var tuple2 = (Array.Empty(), "test"); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_WithEmptyLists_ShouldSucceed() + { + var tuple1 = (new List(), 42); + var tuple2 = (new List(), 42); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_EmptyVsNonEmpty_ShouldFail() + { + var tuple1 = (Array.Empty(), "test"); + var tuple2 = (new int[] { 1 }, "test"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(tuple1).IsEquivalentTo(tuple2)); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task IsEquivalentTo_Struct_WithEmptyCollections_ShouldSucceed() + { + var struct1 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [], + Tags = [] + }; + + var struct2 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [], + Tags = [] + }; + + await Assert.That(struct1).IsEquivalentTo(struct2); + } + + #endregion + + #region Complex Mixed Scenarios + + [Test] + public async Task IsEquivalentTo_ComplexNestedStructure_ShouldSucceed() + { + var person1 = new Person("John", 30, ["developer", "musician"]); + var thing1 = new Thing("Widget", [100, 200, 300]); + + var tuple1 = ((person1, thing1), new List { 1, 2, 3 }, "metadata"); + + var person2 = new Person("John", 30, ["developer", "musician"]); + var thing2 = new Thing("Widget", [100, 200, 300]); + + var tuple2 = ((person2, thing2), new List { 1, 2, 3 }, "metadata"); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + [Test] + public async Task IsEquivalentTo_TupleOfStructs_WithDifferentArraySizes_ShouldFail() + { + var struct1 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [1, 2, 3], + Tags = ["a"] + }; + + var struct2 = new StructWithReferenceProperties + { + Name = "Test", + Numbers = [1, 2, 3, 4, 5], // More elements + Tags = ["a"] + }; + + var tuple1 = (struct1, "extra"); + var tuple2 = (struct2, "extra"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(tuple1).IsEquivalentTo(tuple2)); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task IsEquivalentTo_ListOfTuples_WithEquivalentContent_ShouldSucceed() + { + var list1 = new List<(Thing, int)> + { + (new Thing("A", [1, 2]), 10), + (new Thing("B", [3, 4]), 20) + }; + + var list2 = new List<(Thing, int)> + { + (new Thing("A", [1, 2]), 10), + (new Thing("B", [3, 4]), 20) + }; + + await Assert.That(list1).IsEquivalentTo(list2); + } + + [Test] + public async Task IsEquivalentTo_ArrayOfStructs_WithEquivalentContent_ShouldSucceed() + { + var array1 = new StructWithReferenceProperties[] + { + new() { Name = "First", Numbers = [1], Tags = ["a"] }, + new() { Name = "Second", Numbers = [2], Tags = ["b"] } + }; + + var array2 = new StructWithReferenceProperties[] + { + new() { Name = "First", Numbers = [1], Tags = ["a"] }, + new() { Name = "Second", Numbers = [2], Tags = ["b"] } + }; + + await Assert.That(array1).IsEquivalentTo(array2); + } + + [Test] + public async Task IsEquivalentTo_DictionaryInTuple_WithEquivalentContent_ShouldSucceed() + { + var dict1 = new Dictionary + { + ["key1"] = [1, 2, 3], + ["key2"] = [4, 5, 6] + }; + + var dict2 = new Dictionary + { + ["key1"] = [1, 2, 3], + ["key2"] = [4, 5, 6] + }; + + var tuple1 = (dict1, "metadata"); + var tuple2 = (dict2, "metadata"); + + await Assert.That(tuple1).IsEquivalentTo(tuple2); + } + + #endregion + + #region ValueTuple Same Order Same Content Tests + + [Test] + public async Task IsEquivalentTo_ValueTuple_SameOrderSameContent_ShouldSucceed() + { + var foo1 = new Thing("Foo", [1, 2, 3]); + var bar1 = new Thing("Bar", [4, 5, 6]); + var foo2 = new Thing("Foo", [1, 2, 3]); + var bar2 = new Thing("Bar", [4, 5, 6]); + + await Assert.That((foo1, bar1)).IsEquivalentTo((foo2, bar2)); + } + + [Test] + public async Task IsEquivalentTo_ValueTuple_SwappedElements_DifferentContent_ShouldFail() + { + var foo = new Thing("Foo", [1, 2, 3]); + var bar = new Thing("Bar", [4, 5, 6]); + + // (foo, bar) vs (bar, foo) - different because foo != bar + var exception = await Assert.ThrowsAsync( + async () => await Assert.That((foo, bar)).IsEquivalentTo((bar, foo))); + + await Assert.That(exception).IsNotNull(); + } + + #endregion + + #region IsNotEquivalentTo Tests + + [Test] + public async Task IsNotEquivalentTo_ValueTuple_DifferentContent_ShouldSucceed() + { + var tuple1 = (new Thing("A", [1, 2, 3]), 42); + var tuple2 = (new Thing("B", [4, 5, 6]), 42); + + await Assert.That(tuple1).IsNotEquivalentTo(tuple2); + } + + [Test] + public async Task IsNotEquivalentTo_Struct_DifferentContent_ShouldSucceed() + { + var struct1 = new StructWithReferenceProperties + { + Name = "Test1", + Numbers = [1, 2, 3], + Tags = ["a"] + }; + + var struct2 = new StructWithReferenceProperties + { + Name = "Test2", + Numbers = [4, 5, 6], + Tags = ["b"] + }; + + await Assert.That(struct1).IsNotEquivalentTo(struct2); + } + + [Test] + public async Task IsNotEquivalentTo_ValueTuple_SameContent_ShouldFail() + { + var tuple1 = (new Thing("A", [1, 2, 3]), 42); + var tuple2 = (new Thing("A", [1, 2, 3]), 42); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(tuple1).IsNotEquivalentTo(tuple2)); + + await Assert.That(exception).IsNotNull(); + } + + #endregion +} diff --git a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs index 7715a11946..cec67d319d 100644 --- a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs +++ b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs @@ -87,38 +87,12 @@ public static bool IsPrimitiveOrWellKnownType(Type type) 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; - } - } - + // Note: We intentionally do NOT treat value types implementing IEquatable as primitives. + // IsEquivalentTo should always perform structural comparison, comparing each field/property. + // Using Equals() for structs could miss structural differences when they contain reference + // types (e.g., ValueTuple containing records with array properties - issue #4358). + // For primitives like Vector2, structural comparison of X/Y yields the same result anyway. + // Users who want Equals() behavior should use IsEqualTo() instead. return false; } }