Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Jan 8, 2026

IsEquivalentTo throws "Parameter count mismatch" when comparing collections of value types like Vector2 that have indexed properties. The structural comparer was attempting to reflect over the indexer this[int] and failing.

var array = new Vector2[] { new(1, 2), new(3, 4) };
var list = new List<Vector2>(array);
await Assert.That(array).IsEquivalentTo(list);  // Was throwing

Changes

  • TypeHelper.cs: Value types implementing IEquatable<T> for themselves now use EqualityComparer<T>.Default instead of structural comparison. Limited to value types to preserve structural comparison for records/classes with collection properties.

  • ReflectionHelper.cs: Filter out indexed properties from structural comparison to prevent reflection errors on types with indexers.

  • Tests: Added test cases for Vector2 and Uri collection equivalence.

Original prompt

This section details on the original issue you should resolve

<issue_title>[Bug]: IsEquivalentTo does not respect IEquatable<T></issue_title>
<issue_description>### Description

When using
await Assert.That(array).IsEquivalentTo(array2);
on a type which implement IEquatable<T>, TUnit does not use the IEquatable<T> to check item is equal or not.

Expected Behavior

Test should pass.

Actual Behavior

Test failed.

Steps to Reproduce

[Test]
public async Task Test1()
{
    var array = new Vector2[]
    {
        new Vector2(1, 2),
        new Vector2(3, 4),
        new Vector2(5, 6),
    };
    var array2 = new List<Vector2>(array);
    
    await Assert.That(array).IsEquivalentTo(array2);
}

TUnit Version

1.5.60

.NET Version

.NET9.0

Operating System

Windows

IDE / Test Runner

JetBrains Rider

Error Output / Stack Trace

Parameter count mismatch.
   at System.Reflection.MethodBaseInvoker.ThrowTargetParameterCountException()
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.PropertyInfo.GetValue(Object obj)
   at TUnit.Assertions.Conditions.Helpers.ReflectionHelper.GetMemberValue(Object obj, MemberInfo member)
   at TUnit.Assertions.Conditions.Helpers.StructuralEqualityComparer`1.CompareStructurally(Object x, Object y, HashSet`1 visited)
   at TUnit.Assertions.Conditions.Helpers.StructuralEqualityComparer`1.Equals(T x, T y)
   at TUnit.Assertions.Conditions.Helpers.CollectionEquivalencyChecker.CheckUnorderedEquivalenceLinear[TItem](List`1 actualList, List`1 expectedList, IEqualityComparer`1 comparer)
   at TUnit.Assertions.Conditions.Helpers.CollectionEquivalencyChecker.CheckUnorderedEquivalence[TItem](List`1 actualList, List`1 expectedList, IEqualityComparer`1 comparer)
   at TUnit.Assertions.Conditions.Helpers.CollectionEquivalencyChecker.AreEquivalent[TItem](IEnumerable`1 actual, IEnumerable`1 expected, CollectionOrdering ordering, IEqualityComparer`1 comparer)
   at TUnit.Assertions.Conditions.IsEquivalentToAssertion`2.CheckAsync(EvaluationMetadata`1 metadata)
   at TUnit.Assertions.Core.Assertion`1.ExecuteCoreAsync()
   at TUnit.Assertions.Core.Assertion`1.AssertAsync()
   at LightProto.AssemblyLevelTests.AssemblyPriorityTests.Test() in D:\repos\LightProto\tests\LightProto.AssemblyLevelTests\AssemblyPriorityTests.cs:line 62
   at TUnit.Core.TestMetadata`1.<>c__DisplayClass13_0.<<get_CreateExecutableTestFactory>b__2>d.MoveNext()
--- End of stack trace from previous location ---
   at TUnit.Core.ExecutableTest.InvokeTestAsync(Object instance, CancellationToken cancellationToken)
   at TUnit.Engine.TestExecutor.ExecuteTestAsync(AbstractExecutableTest executableTest, CancellationToken cancellationToken)
   at TUnit.Engine.TestExecutor.ExecuteAsync(AbstractExecutableTest executableTest, TestInitializer testInitializer, CancellationToken cancellationToken)
   at TUnit.Engine.TestExecutor.ExecuteAsync(AbstractExecutableTest executableTest, TestInitializer testInitializer, CancellationToken cancellationToken)
   at TUnit.Engine.Services.TestExecution.TestCoordinator.<>c__DisplayClass12_0.<<ExecuteTestInternalAsync>b__1>d.MoveNext()
--- End of stack trace from previous location ---
   at TUnit.Engine.Services.TestExecution.TestCoordinator.<>c__DisplayClass12_0.<<ExecuteTestInternalAsync>b__1>d.MoveNext()
--- End of stack trace from previous location ---
   at TUnit.Engine.Helpers.TimeoutHelper.ExecuteWithTimeoutAsync(Func`2 taskFactory, Nullable`1 timeout, CancellationToken cancellationToken, String timeoutMessage)
   at TUnit.Engine.Services.TestExecution.TestCoordinator.<>c__DisplayClass12_0.<<ExecuteTestInternalAsync>b__0>d.MoveNext()
--- End of stack trace from previous location ---
   at TUnit.Engine.Services.TestExecution.RetryHelper.ExecuteWithRetry(TestContext testContext, Func`1 action)
   at TUnit.Engine.Services.TestExecution.RetryHelper.ExecuteWithRetry(TestContext testContext, Func`1 action)
   at TUnit.Engine.Services.TestExecution.TestCoordinator.ExecuteTestInternalAsync(AbstractExecutableTest test, CancellationToken cancellationToken)

Additional Context

If I change
await Assert.That(array).IsEquivalentTo(array2);
to
await Assert.That(array).IsEquivalentTo(array2,EqualityComparer<Vector2>.Default);
test will pass.

There are a lot of type (Uri,CultureInfo,Vector3,Matrix3x2,etc...) has this issue, not just Vector2.

Additionally, IComparable<T> should be considered as well.

IDE-Specific Issue?

  • I've confirmed this issue occurs when running via dotnet test or dotnet run, not just in my IDE</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI changed the title [WIP] Fix IsEquivalentTo to respect IEquatable<T> Fix IsEquivalentTo to respect IEquatable<T> for value types like Vector2 Jan 8, 2026
Copilot AI requested a review from thomhurst January 8, 2026 12:59
@thomhurst
Copy link
Owner

Summary

Fixes IsEquivalentTo assertion to respect IEquatable for value types like Vector2 that have indexed properties.

Critical Issues

None found ✅

Suggestions

1. AOT Annotation Missing on ImplementsSelfEquatable

Location: TUnit.Assertions/Conditions/Helpers/TypeHelper.cs:97

The new ImplementsSelfEquatable method calls type.GetInterfaces() which requires reflection. It should have the [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] annotation on its parameter for AOT compatibility (per TUnit rule #5).

The existing UnconditionalSuppressMessage on line 63 of IsPrimitiveOrWellKnownType may not propagate properly to the called method.

2. Test Comment Has Wrong Issue Number

Location: TUnit.Assertions.Tests/CollectionStructuralEquivalenceTests.cs:191

The comment references issue #3722 but the PR description says this fixes #4081. The comment should be updated for consistency.

Technical Analysis

The changes correctly address the root cause:

  1. ReflectionHelper.cs: Filtering out indexed properties prevents the Parameter count mismatch error when reflection tries to invoke indexers without parameters ✅

  2. TypeHelper.cs: Detecting value types with self-referencing IEquatable and treating them as primitives is a sound approach. Limiting this to value types (not classes/records) preserves structural comparison for complex types with collection properties ✅

  3. Tests: Good coverage of both Vector2 (struct with indexer) and Uri (class without indexer but with IEquatable) ✅

The fix aligns with TUnit principles:

  • Modern C# with proper null handling ✅
  • No blocking on async ✅
  • Performance-conscious (doesn't change hot paths significantly) ✅

Verdict

💬 COMMENT - Excellent fix overall. The AOT annotation is a minor improvement that would make the code more robust for Native AOT scenarios per TUnit mandatory rules.

@thomhurst thomhurst marked this pull request as ready for review January 8, 2026 18:29
Copilot AI review requested due to automatic review settings January 8, 2026 18:29
@thomhurst thomhurst merged commit fbc4e2d into main Jan 8, 2026
11 of 13 checks passed
@thomhurst thomhurst deleted the copilot/fix-isequivalentto-bug-again branch January 8, 2026 18:29
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a bug where IsEquivalentTo throws "Parameter count mismatch" when comparing collections of types with indexed properties, such as Vector2 or Uri. The issue occurred because the structural equality comparer attempted to reflect over indexer properties that require parameters.

Key changes:

  • Value types implementing IEquatable<T> now use EqualityComparer<T>.Default instead of structural comparison
  • Indexed properties are filtered out during structural comparison to prevent reflection errors
  • Test coverage added for Vector2 (value type) and Uri (reference type) scenarios

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
TUnit.Assertions/Conditions/Helpers/TypeHelper.cs Added logic to detect value types implementing IEquatable for themselves and route them to use EqualityComparer.Default instead of structural comparison. Includes proper AOT/trimming annotations.
TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs Modified GetMembersToCompare to filter out indexed properties (properties with parameters) that would cause reflection errors during structural comparison.
TUnit.Assertions.Tests/CollectionStructuralEquivalenceTests.cs Added comprehensive test cases for Vector2 and Uri collection equivalence, covering both positive (equivalent) and negative (not equivalent) scenarios.

}

/// <summary>
/// Tests for issue #3722: IsEquivalentTo does not respect IEquatable{T} for types like Vector2
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue number in the comment is incorrect. The PR description indicates this fixes issue #4081, but the comment references #3722.

Suggested change
/// Tests for issue #3722: IsEquivalentTo does not respect IEquatable{T} for types like Vector2
/// Tests for issue #4081: IsEquivalentTo does not respect IEquatable{T} for types like Vector2

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +32
foreach (var prop in properties)
{
if (prop.GetIndexParameters().Length == 0)
{
members.Add(prop);
}
}
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +120
foreach (var iface in type.GetInterfaces())
{
if (iface.IsGenericType
&& iface.GetGenericTypeDefinition() == typeof(IEquatable<>)
&& iface.GenericTypeArguments[0] == type)
{
return true;
}
}
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: IsEquivalentTo does not respect IEquatable<T>

2 participants