diff --git a/TUnit.Assertions.Tests/IgnoringTypeEquivalentTests.cs b/TUnit.Assertions.Tests/IgnoringTypeEquivalentTests.cs index 782385da24..af58cdc1c4 100644 --- a/TUnit.Assertions.Tests/IgnoringTypeEquivalentTests.cs +++ b/TUnit.Assertions.Tests/IgnoringTypeEquivalentTests.cs @@ -227,4 +227,124 @@ private class MyClassWithoutDates public string Name { get; set; } = string.Empty; public int Value { get; set; } } + + // Test classes for ValueType/Tuple tests + private class IgnoreMe + { + public string Message { get; set; } = string.Empty; + + public IgnoreMe() { } + public IgnoreMe(string message) => Message = message; + } + + private class ClassWithTupleProperty + { + public string Name { get; set; } = string.Empty; + public (IgnoreMe, IgnoreMe) Ignores { get; set; } + public int Value { get; set; } + } + + private class ClassWithNestedTupleProperty + { + public string Name { get; set; } = string.Empty; + public ((IgnoreMe, int), string) NestedIgnores { get; set; } + public int Value { get; set; } + } + + private class ClassWithMixedTupleProperty + { + public string Name { get; set; } = string.Empty; + public (IgnoreMe, int) MixedTuple { get; set; } + public int Value { get; set; } + } + + [Test] + public async Task IgnoringType_In_Tuple_Properties_Are_Ignored() + { + var object1 = new ClassWithTupleProperty + { + Name = "Test", + Ignores = (new IgnoreMe("foobar"), new IgnoreMe("foobar")), + Value = 123 + }; + + var object2 = new ClassWithTupleProperty + { + Name = "Test", + Ignores = (new IgnoreMe("baz"), new IgnoreMe("baz")), + Value = 123 + }; + + await TUnitAssert.That(object1) + .IsEquivalentTo(object2) + .IgnoringType(); + } + + [Test] + public async Task IgnoringType_In_Nested_Tuple_Properties_Are_Ignored() + { + var object1 = new ClassWithNestedTupleProperty + { + Name = "Test", + NestedIgnores = ((new IgnoreMe("foobar"), 1), "hello"), + Value = 123 + }; + + var object2 = new ClassWithNestedTupleProperty + { + Name = "Test", + NestedIgnores = ((new IgnoreMe("baz"), 1), "hello"), + Value = 123 + }; + + await TUnitAssert.That(object1) + .IsEquivalentTo(object2) + .IgnoringType(); + } + + [Test] + public async Task IgnoringType_In_Mixed_Tuple_Still_Compares_Non_Ignored_Parts() + { + var object1 = new ClassWithMixedTupleProperty + { + Name = "Test", + MixedTuple = (new IgnoreMe("foobar"), 42), + Value = 123 + }; + + var object2 = new ClassWithMixedTupleProperty + { + Name = "Test", + MixedTuple = (new IgnoreMe("baz"), 99), // Different int value + Value = 123 + }; + + // Should fail because the int part (42 vs 99) is different + await TUnitAssert.That(object1) + .IsNotEquivalentTo(object2) + .IgnoringType(); + } + + [Test] + public async Task IgnoringType_In_Mixed_Tuple_Passes_When_NonIgnored_Parts_Match() + { + var object1 = new ClassWithMixedTupleProperty + { + Name = "Test", + MixedTuple = (new IgnoreMe("foobar"), 42), + Value = 123 + }; + + var object2 = new ClassWithMixedTupleProperty + { + Name = "Test", + MixedTuple = (new IgnoreMe("baz"), 42), // Same int value + Value = 123 + }; + + // Should pass because the int part is the same and IgnoreMe is ignored + await TUnitAssert.That(object1) + .IsEquivalentTo(object2) + .IgnoringType(); + } } \ No newline at end of file diff --git a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs index 2f1086a1a7..13f95790b9 100644 --- a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs +++ b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs @@ -120,7 +120,8 @@ internal AssertionResult CompareObjects( var expectedType = expected.GetType(); // Handle primitive types and strings - if (TypeHelper.IsPrimitiveOrWellKnownType(actualType)) + // But don't treat generic value types as primitive if they contain ignored types + if (TypeHelper.IsPrimitiveOrWellKnownType(actualType) && !ContainsIgnoredGenericArgument(actualType)) { if (!Equals(actual, expected)) { @@ -304,6 +305,34 @@ private bool ShouldIgnoreType(Type type) return false; } + /// + /// Checks if a generic type (like ValueTuple) contains any ignored types as generic arguments. + /// This prevents tuples containing ignored types from being compared as primitive values. + /// + private bool ContainsIgnoredGenericArgument(Type type) + { + if (!type.IsGenericType || _ignoredTypes.Count == 0) + { + return false; + } + + foreach (var genericArg in type.GetGenericArguments()) + { + if (_ignoredTypes.Contains(genericArg)) + { + return true; + } + + // Recursively check nested generic types (e.g., Tuple, string>) + if (ContainsIgnoredGenericArgument(genericArg)) + { + return true; + } + } + + return false; + } + private static string FormatValue(object? value) { if (value == null)