Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions TUnit.Assertions.Tests/Bugs/Tests3367.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using TUnit.Assertions.Enums;

namespace TUnit.Assertions.Tests.Bugs;

public class Tests3367
{
/// <summary>
/// Custom comparer for doubles with tolerance.
/// Note: This comparer intentionally does NOT implement GetHashCode correctly
/// for tolerance-based equality, which is extremely difficult to do correctly.
/// TUnit should handle this gracefully.
/// </summary>
public class DoubleComparer(double tolerance) : IEqualityComparer<double>
{
private readonly double _tolerance = tolerance;

public bool Equals(double x, double y) => Math.Abs(x - y) <= _tolerance;

public int GetHashCode(double obj) => obj.GetHashCode();
}

[Test]
public async Task IsEquivalentTo_WithCustomComparer_SingleElement_ShouldSucceed()
{
// Arrange
var comparer = new DoubleComparer(0.0001);
double value1 = 0.29999999999999999;
double value2 = 0.30000000000000004;

// Act & Assert - single element comparison works
await TUnitAssert.That(comparer.Equals(value1, value2)).IsTrue();
}

[Test]
public async Task IsEquivalentTo_WithCustomComparer_Array_ShouldSucceed()
{
// Arrange
var comparer = new DoubleComparer(0.0001);
double[] array1 = [0.1, 0.2, 0.29999999999999999];
double[] array2 = [0.1, 0.2, 0.30000000000000004];

// Act & Assert - array comparison should work with custom comparer
await TUnitAssert.That(array1).IsEquivalentTo(array2).Using(comparer);
}

[Test]
public async Task IsEquivalentTo_WithCustomComparer_Array_DifferentOrder_ShouldSucceed()
{
// Arrange
var comparer = new DoubleComparer(0.0001);
double[] array1 = [0.1, 0.29999999999999999, 0.2];
double[] array2 = [0.2, 0.1, 0.30000000000000004];

// Act & Assert - should work regardless of order
await TUnitAssert.That(array1).IsEquivalentTo(array2, CollectionOrdering.Any).Using(comparer);
}

[Test]
public async Task IsEquivalentTo_WithCustomComparer_Array_NotEquivalent_ShouldFail()
{
// Arrange
var comparer = new DoubleComparer(0.0001);
double[] array1 = [0.1, 0.2, 0.3];
double[] array2 = [0.1, 0.2, 0.5]; // 0.5 is not within tolerance of 0.3

// Act & Assert - should fail when values are not within tolerance
var exception = await TUnitAssert.ThrowsAsync<TUnitAssertionException>(
async () => await TUnitAssert.That(array1).IsEquivalentTo(array2).Using(comparer));

await TUnitAssert.That(exception).IsNotNull();
}

[Test]
public async Task IsEquivalentTo_WithCustomComparer_DuplicateValues_ShouldSucceed()
{
// Arrange
var comparer = new DoubleComparer(0.0001);
double[] array1 = [0.1, 0.2, 0.2, 0.3];
double[] array2 = [0.1, 0.20000000000000001, 0.19999999999999999, 0.30000000000000004];

// Act & Assert - should handle duplicates correctly
await TUnitAssert.That(array1).IsEquivalentTo(array2, CollectionOrdering.Any).Using(comparer);
}
}
2 changes: 1 addition & 1 deletion TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public async Task Different_Dictionaries_Are_Equivalent_With_Different_Ordered_K
{ "A", "A" },
};

await TUnitAssert.That(dict1).IsEquivalentTo(dict2);
await TUnitAssert.That(dict1).IsEquivalentTo(dict2, CollectionOrdering.Any);
}

[Test]
Expand Down
47 changes: 45 additions & 2 deletions TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class IsEquivalentToAssertion<TCollection, TItem> : Assertion<TCollection
public IsEquivalentToAssertion(
AssertionContext<TCollection> context,
IEnumerable<TItem> expected,
CollectionOrdering ordering = CollectionOrdering.Any)
CollectionOrdering ordering = CollectionOrdering.Matching)
: base(context)
{
_expected = expected ?? throw new ArgumentNullException(nameof(expected));
Expand Down Expand Up @@ -82,7 +82,50 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollecti
}

// Otherwise, use frequency map for unordered comparison (CollectionOrdering.Any)
// Build a frequency map of actual items - O(n)

// When using a custom comparer, we use a linear search approach because:
// 1. Custom comparers (especially tolerance-based ones for floating-point) often cannot
// implement GetHashCode correctly (equal items MUST have same hash code)
// 2. Dictionary lookups rely on both GetHashCode and Equals, which fails with broken hash codes
// 3. Linear search is more forgiving and aligns with user expectations for custom comparers
var isCustomComparer = _comparer != null;

if (isCustomComparer)
{
// Use linear search for custom comparers - O(n²) but correct
var remainingActual = new List<TItem>(actualList);

foreach (var expectedItem in expectedList)
{
var foundIndex = -1;
for (int i = 0; i < remainingActual.Count; i++)
{
var actualItem = remainingActual[i];

bool areEqual = expectedItem == null && actualItem == null ||
expectedItem != null && actualItem != null && comparer.Equals(expectedItem, actualItem);

if (areEqual)
{
foundIndex = i;
break;
}
}

if (foundIndex == -1)
{
return Task.FromResult(AssertionResult.Failed(
$"collection does not contain expected item: {expectedItem}"));
}

remainingActual.RemoveAt(foundIndex);
}

return Task.FromResult(AssertionResult.Passed);
}

// Use efficient Dictionary-based frequency map for default comparer - O(n)
// Build a frequency map of actual items
// Track null count separately to avoid Dictionary<TKey> notnull constraint
int nullCount = 0;
#pragma warning disable CS8714 // Nullability of type argument doesn't match 'notnull' constraint - we handle nulls separately
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,7 @@ namespace .Conditions
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection>
where TCollection : .<TItem>
{
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 0) { }
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 1) { }
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,7 @@ namespace .Conditions
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection>
where TCollection : .<TItem>
{
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 0) { }
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 1) { }
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,7 @@ namespace .Conditions
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection>
where TCollection : .<TItem>
{
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 0) { }
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 1) { }
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ namespace .Conditions
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection>
where TCollection : .<TItem>
{
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 0) { }
public IsEquivalentToAssertion(.<TCollection> context, .<TItem> expected, . ordering = 1) { }
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
Expand Down
Loading