Allow more string comparisons - #4899
Conversation
1. Types that can be implicit cast to string 2. Types that implement IEquatable<string>
74ce7f9 to
51618d8
Compare
| // Alternatively we could fall back to pre 4.3 EqualConstraint behavior | ||
| // But if the actual value cannot be convert to a string nor can be compared to one | ||
| // we should fail the test. | ||
| return new EqualConstraint(_expected).ApplyTo(actual); |
There was a problem hiding this comment.
I'm a little conflicted here myself but I'm tempted to say we fall back to a non-generic implementation here just in case there's something we're not considering. @OsirisTerje @jnm2 what are your thoughts?
There was a problem hiding this comment.
I would say fallback to the non-generic. I believe we might be struggling with this over time, and then that fallback might "save the day".
|
Thanks @manfred-brands this looks great and my attempts to think up edge cases not covered have been futile. I think it looks pretty solid. I noticed you were asking for thoughts on how to handle the "last" case and thought it might be good to hear from @OsirisTerje or possibly @jnm2 if either are free |
|
I used code from this branch to get my test suite running. |
|
If all repros work with these changes, I am fine with them. It also looks like we have got these cases into the test suite, which is awesome. #4902 also look like something we should add to the test suite here. I do wonder though that we treat the expected and the actual different. The expected is being used as a parameter to the construction of the constraint, and that is when the implicit operator is being called. That applies to both classes and structs. The actual however, is only passed into the generic ApplyTo method, and thus the implicit operator is not being called. So we are comparing apples to bananas, which fails. If we did have a class containing the actual value, with overloads matching what the constraint class have, the implicit operator would be called and we would be able to compare, comparing apples to apples as we should. I don't mean that we should look into something like this, at least not now, but just for thoughts. |
I agree. If you can make |
|
Suggest we push this out as a 4.3.1 when ready. PS. I ran the 4.3.0 through some large solutions we have before the actual release, no faults detected. One day after release and things starts trickling in. Just show the importance of getting different people from different places and different organizations to check things out :-) Thanks @moshekar @smdn @alkampfergit |
Yes. We have raised this before the decoupling of the
I tried adding an overload If I also change However that is a binary breaking change: Removing a method. It also broke 2 of our own NUnit tests. It also triggered a possibly bug in the NUnit2026 rules of the analyzer complaining about non-matching types.
It won't. The way generics work is that an instance of type Neither can I hard-cast in case that triggers an operator. Even though
The functionality was already available on the generic overload, but that resulted in that function having modifiers that don't make sense. Such as: Assert.That(2 + 2, Is.EqualTo(4).IgnoreCase);
Assert.That("NUnit", Is.EqualTo("nunit").Within(1e-3));The extra overloads put into NUnit 4.3.0 means that both of those non-appropriate modifiers now give compile time errors. |
You are correct. I tried a simple method like that and there are cases the compiler can't infer the type. I incorrectly assumed the first argument type will be chosen but that's not the case and it tries to infer from both arguments. public static bool IsEqualTo<TActual>(TActual? actual, object? expected)
{
if (actual is null)
{
return expected is null;
}
if (expected is not null && actual is string str)
{
// add special string functionality here...
return str.Equals(expected);
}
return actual.Equals(expected);
} |
To prevent calling overloads using implicit operators we need a generic fall back. As we can only have 1 generic method we have to write out the Numeric overload into its 11 separate types.
|
@OsirisTerje I made the change for the fallback to There are actually two separate issues but combining them gives us a proper resolution:
However, replacing the generic for Numerics with individual overloads, allows us to create an I have updated the branch with the latest, which I think gives a better solution. RedisValue redisResult = "42";
Assert.That(redisResult, Is.EqualTo("42")); |
Good catch :) RedisValue redisValue = "42";
Assert.That(redisValue.Equals("42"));
Assert.That(redisValue.Equals(42)); |
It's a good argument
but since we haven't supported that earlier, we don't need to add this to this hotfix. It could be raised as a separate enhancement issue though. Comments @stevenaw @alkampfergit @smdn ? |
|
While I expected a simple comparison like the behaviour of Assert.That(redisResult, Is.EqualTo("42")); // expect to passInstead of using overloads or behavioural enhancements of Assert.That(redisResult, Is.EqualTo("42"));
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Warning NUnitXXX: 'Ambiguity comparison. Do you expect a comparison between `RedisValue` and `string`? Or expect implicit conversions?'
// If the intention is clear, no warning is given.
Assert.That(redisResult.ToString(), Is.EqualTo("42"));
Assert.That((string)redisResult, Is.EqualTo("42"));
Assert.That(redisResult, Is.EqualTo<string>("42"));
Assert.That(redisResult, Is.EqualTo<int>(42));MyLong l1 = new(1);
Assert.That(l1, Is.EqualTo(1));
~~~~~~~~~~~~~~~~~ Warning NUnitXXX: 'Ambiguity comparison. Do you expect a comparison between `MyLong` and `int`? Or expect implicit conversions?'
// No warning is given
Assert.That(l1, Is.EqualTo(l1));
Assert.That(l1, Is.EqualTo<int>(1)); // performs implicit conversion (calls IConvertible.ToInt32() like with NUnit 4.3.0) |
|
It seems that the changes in commit a12d6ab caused my test code to have build error NUnit2021. [Test]
public void Test()
{
MyLong l0 = new(0);
Assert.That(l0, Is.EqualTo((MyLong)0));
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ error NUnit2021: The EqualTo constraint always fails as the actual and the expected value cannot be equal (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2021.md)
}I will report again when the situation can be clearly reproduced. |
I can reproduce it by adding an explicit cast operator to the MyLong class you previously supplied. I suspect that the analyzer doesn't deal with the generic |
@manfred-brands , after sleeping on it I got an idea how to forbid IgnoreCase during compile type while still managing the overload selection: If you wrap the string with a custom wrapper that itself has implicit conversion from string - it works :) To do this, replace the public record struct StrictString(string Value)
{
public static implicit operator StrictString(string Value) => new(Value);
} |
|
@moshekar Thanks, you remined me that I needed the However, So with the current state of the branch only actual Do your [Test]
public void RedisValueString()
{
// Arrange
RedisValue value = "42";
// Act & Assert
using (Assert.EnterMultipleScope())
{
Assert.That(value, Is.EqualTo(value), "RedisValue should support comparisons with itself.");
Assert.That(value, Is.EqualTo<int>(42), "Is.EqualTo<int>(42) -> EqualConstraint");
Assert.That(value, Is.EqualTo(42), "Is.EqualTo(42) -> EqualNumericConstraint");
Assert.That(value, Is.EqualTo("42"), "Is.EqualTo(\"42\") -> EqualStringConstraint");
}
}Fail with: |
Yes, in my asserts implementation all these pass: RedisValue rv = 42;
Assert.That(rv, Is.EqualTo(rv)); // object? overload
Assert.That(rv, Is.EqualTo(42)); // object? overload
Assert.That(rv, Is.EqualTo("42")); // StrictString overload
rv = "42";
Assert.That(rv, Is.EqualTo(rv)); // object? overload
Assert.That(rv, Is.EqualTo(42)); // object? overload
Assert.That(rv, Is.EqualTo("42")); // StrictString overloadI didn't add a generic EqualTo. In my implementation, all non-strings goes to the return Equals(actual, Expected);and I just let the compiler/runtime do all their automatic conversions for me. |
Also only allow conversions on supported NumericTypes For everything else drop back to pre 4.3 behaviour
42d7f7c to
bd45e7c
Compare
|
@moshekar The standard You would think that Equality is commutative, but if you changed your code to call
Order matters when calling Assert.That(Equals(value, 42), Is.True, "RedisValue.Equals(42)");
Assert.That(Equals(42, value), Is.False, "42.Equals(RedisValue)");Same for Assert.That(value, Is.EqualTo(42), "value, Is.EqualTo(42) -> EqualNumericConstraint -> Fallback to EqualConstraint");
Assert.That(42, Is.EqualTo(value), "42, Is.EqualTo(value) -> EqualConstraint");According to the documentation:
Therefore |
Of course :) I just wanted to reproduce the new issues in a basic implementation to exclude other factors.
Since But I don't see any problem with that as this is an expected behaviour. |
You are saying: |
|
@OsirisTerje @stevenaw Can we merge this PR and get a new version out? The PR fixes both issues:
|
|
@manfred-brands I had been thinking of asking the same 🙂 On my end, I'll do a final pass as a review within an hour or so. |
stevenaw
left a comment
There was a problem hiding this comment.
Thanks @manfred-brands one question from me. It might be good to let @OsirisTerje review too as I feel I've been a bit more arms-length on this one
| /// </summary> | ||
| #pragma warning disable CS3024 // Constraint type is not CLS-compliant | ||
| public class EqualNumericConstraint<T> : EqualNumericWithoutUsingConstraint<T>, IEqualWithUsingConstraint<T> | ||
| where T : unmanaged, IConvertible, IEquatable<T> |
There was a problem hiding this comment.
question: I understand why we added the overloads on Is.EqualTo(), but can you remind me why we needed to go back to struct here?
Related question about the need to make the constructor for this internal... Now that we've shipped a public constructor, is there a harm in letting people target it? I feel like the could also open some extensibility doors.
For example, a ComplexNumberEqualConstraint which internally composes two separate EqualNumericConstraint instances.
There was a problem hiding this comment.
why we needed to go back to
structhere?
Unconstraint generics resulted in Possible null reference compile time error, see build
I could have used notnull, but the only supported numeric types are value types and maybe the compiler can do something more knowing that.
Related question about the need to make the constructor for this
internal... Now that we've shipped a public constructor, is there a harm in letting people target it? I feel like the could also open some extensibility doors.
Initially I made it internal to prevent it being called with unsupported types.
As I have now added a Guard protecting it that is less of an issue, so if you prefer to have it public, that is fine by me.
|
@stevenaw I added a commit restoring the |
Isn't that why the parameters are named Actual (the result we check) and Expected (what value we expect it to be)? Thank you @manfred-brands , @OsirisTerje and @stevenaw for all the work and cooperation on this issue. |
|
@manfred-brands Agree, merge. When you have merged I can start pushing out a 4.3.1 with this one. |
|
@OsirisTerje I cannot merge without someone approving the PR. |
|
Approved |
|
Thanks for contributing this fix, and the deep research into method binding to find the right approach @manfred-brands |
|
Thanks @stevenaw |
|
Thank you for fixing this issue! @manfred-brands @OsirisTerje If it is treated as outside the scope of this PR, that is fine. |
|
@smdn Could you create an issue for that in the nunit.analyzers project, I'll get that fixed. |
|
@OsirisTerje when the new fix will be available? The last version in nuget is 4.3.0. UPDATE: Now it's updated. Thanks! |
|
@smdn We will release an new version of the analyzers today with @manfred-brands fix |
|
@manfred-brands @mikkelbu @OsirisTerje |



Fixes #4898 for strings:
Fixes #4898 for types implementing both
IEquatableandIConvertible. The latter path is only chosen if the actual type is neither a primitive nor decimal. OtherwiseIEquatable.Equalsis called.