diff --git a/TUnit.Assertions.Tests/Bugs/Tests1600.cs b/TUnit.Assertions.Tests/Bugs/Tests1600.cs index e85824749b..0a8b4850ec 100644 --- a/TUnit.Assertions.Tests/Bugs/Tests1600.cs +++ b/TUnit.Assertions.Tests/Bugs/Tests1600.cs @@ -22,6 +22,39 @@ public async Task Custom_Comparer() await Assert.That(array1).IsEquivalentTo(array2).Using(new MyModelComparer()); } + [Test] + public async Task Custom_Predicate() + { + MyModel[] array1 = [new(), new(), new()]; + MyModel[] array2 = [new(), new(), new()]; + + // Using a lambda predicate instead of implementing IEqualityComparer + await Assert.That(array1).IsEquivalentTo(array2).Using((x, y) => true); + } + + [Test] + public async Task Custom_Predicate_With_Property_Comparison() + { + var users1 = new[] { new User("Alice", 30), new User("Bob", 25) }; + var users2 = new[] { new User("Bob", 25), new User("Alice", 30) }; + + // Elements have different order but are equivalent by name and age + await Assert.That(users1) + .IsEquivalentTo(users2) + .Using((u1, u2) => u1?.Name == u2?.Name && u1?.Age == u2?.Age); + } + + [Test] + public async Task Custom_Predicate_Not_Equivalent() + { + var users1 = new[] { new User("Alice", 30), new User("Bob", 25) }; + var users2 = new[] { new User("Charlie", 35), new User("Diana", 28) }; + + await Assert.That(users1) + .IsNotEquivalentTo(users2) + .Using((u1, u2) => u1?.Name == u2?.Name && u1?.Age == u2?.Age); + } + public class MyModel { public string Id { get; } = Guid.NewGuid().ToString(); @@ -39,4 +72,6 @@ public int GetHashCode(MyModel obj) return 1; } } + + public record User(string Name, int Age); } diff --git a/TUnit.Assertions/Conditions/DictionaryAssertions.cs b/TUnit.Assertions/Conditions/DictionaryAssertions.cs index e177b11609..de92090645 100644 --- a/TUnit.Assertions/Conditions/DictionaryAssertions.cs +++ b/TUnit.Assertions/Conditions/DictionaryAssertions.cs @@ -1,4 +1,5 @@ using System.Text; +using TUnit.Assertions.Conditions.Helpers; using TUnit.Assertions.Core; namespace TUnit.Assertions.Conditions; @@ -29,6 +30,12 @@ public DictionaryContainsKeyAssertion Using(IEquality return new DictionaryContainsKeyAssertion(Context, _expectedKey, comparer); } + public DictionaryContainsKeyAssertion Using(Func equalityPredicate) + { + return new DictionaryContainsKeyAssertion( + Context, _expectedKey, new FuncEqualityComparer(equalityPredicate)); + } + protected override Task CheckAsync(EvaluationMetadata metadata) { var value = metadata.Value; diff --git a/TUnit.Assertions/Conditions/Helpers/FuncEqualityComparer.cs b/TUnit.Assertions/Conditions/Helpers/FuncEqualityComparer.cs new file mode 100644 index 0000000000..aeb1307a7b --- /dev/null +++ b/TUnit.Assertions/Conditions/Helpers/FuncEqualityComparer.cs @@ -0,0 +1,25 @@ +namespace TUnit.Assertions.Conditions.Helpers; + +/// +/// An IEqualityComparer implementation that uses a custom Func for equality comparison. +/// This allows users to pass lambda predicates to assertion methods like Using(). +/// +/// The type of objects to compare. +internal sealed class FuncEqualityComparer : IEqualityComparer +{ + private readonly Func _equals; + + public FuncEqualityComparer(Func equals) + { + _equals = equals ?? throw new ArgumentNullException(nameof(equals)); + } + + public bool Equals(T? x, T? y) => _equals(x, y); + + // Return a constant hash code to force linear search in collection equivalency. + // This is intentional because: + // 1. We cannot derive a meaningful hash function from an equality predicate + // 2. CollectionEquivalencyChecker already uses O(n²) linear search for custom comparers + // 3. This matches the expected behavior for all custom IEqualityComparer implementations + public int GetHashCode(T obj) => 0; +} diff --git a/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs b/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs index 7592c54f0e..18f2fa8ae7 100644 --- a/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs +++ b/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs @@ -47,6 +47,12 @@ public IsEquivalentToAssertion Using(IEqualityComparer Using(Func equalityPredicate) + { + SetComparer(new FuncEqualityComparer(equalityPredicate)); + return this; + } + protected override Task CheckAsync(EvaluationMetadata metadata) { var value = metadata.Value; diff --git a/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs b/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs index e32548337f..41a6d3d880 100644 --- a/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs +++ b/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs @@ -46,6 +46,12 @@ public NotEquivalentToAssertion Using(IEqualityComparer Using(Func equalityPredicate) + { + SetComparer(new FuncEqualityComparer(equalityPredicate)); + return this; + } + protected override Task CheckAsync(EvaluationMetadata metadata) { var value = metadata.Value; diff --git a/TUnit.Assertions/Conditions/PredicateAssertions.cs b/TUnit.Assertions/Conditions/PredicateAssertions.cs index 744a257181..4d43ee668d 100644 --- a/TUnit.Assertions/Conditions/PredicateAssertions.cs +++ b/TUnit.Assertions/Conditions/PredicateAssertions.cs @@ -1,5 +1,6 @@ using System.Text; using TUnit.Assertions.Attributes; +using TUnit.Assertions.Conditions.Helpers; using TUnit.Assertions.Core; namespace TUnit.Assertions.Conditions; @@ -66,6 +67,12 @@ public IsEquatableOrEqualToAssertion Using(IEqualityComparer com return this; } + public IsEquatableOrEqualToAssertion Using(Func equalityPredicate) + { + SetComparer(new FuncEqualityComparer(equalityPredicate)); + return this; + } + protected override Task CheckAsync(EvaluationMetadata metadata) { var value = metadata.Value; diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 41743ad69d..193caf076a 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -596,6 +596,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class DictionaryDoesNotContainKeyAssertion : . where TDictionary : . @@ -840,6 +841,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } [.("IsEquivalentTo")] public class IsEquivalentToAssertion : . @@ -852,6 +854,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class IsNotAssignableToAssertion : . { @@ -953,6 +956,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class NotNullAssertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 90595a8661..cdf708e41b 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -591,6 +591,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class DictionaryDoesNotContainKeyAssertion : . where TDictionary : . @@ -835,6 +836,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } [.("IsEquivalentTo")] public class IsEquivalentToAssertion : . @@ -847,6 +849,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class IsNotAssignableToAssertion : . { @@ -948,6 +951,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class NotNullAssertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 32e1ddc218..de701714b4 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -596,6 +596,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class DictionaryDoesNotContainKeyAssertion : . where TDictionary : . @@ -840,6 +841,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } [.("IsEquivalentTo")] public class IsEquivalentToAssertion : . @@ -852,6 +854,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class IsNotAssignableToAssertion : . { @@ -953,6 +956,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class NotNullAssertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 0ceb47da88..702517902b 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -575,6 +575,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class DictionaryDoesNotContainKeyAssertion : . where TDictionary : . @@ -810,6 +811,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } [.("IsEquivalentTo")] public class IsEquivalentToAssertion : . @@ -820,6 +822,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class IsNotAssignableToAssertion : . { @@ -919,6 +922,7 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } public . Using(. comparer) { } + public . Using( equalityPredicate) { } } public class NotNullAssertion : . {