Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow IEqualityComparer in string (Not)BeEquivalentTo assertion #2372

Closed
14 changes: 4 additions & 10 deletions Src/FluentAssertions/Common/StringExtensions.cs
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using FluentAssertions.Formatting;
Expand All @@ -9,15 +10,13 @@ internal static class StringExtensions
{
/// <summary>
/// Finds the first index at which the <paramref name="value"/> does not match the <paramref name="expected"/>
/// string anymore, accounting for the specified <paramref name="stringComparison"/>.
/// string anymore, accounting for the specified <paramref name="comparer"/>.
/// </summary>
public static int IndexOfFirstMismatch(this string value, string expected, StringComparison stringComparison)
public static int IndexOfFirstMismatch(this string value, string expected, IEqualityComparer<string> comparer)
{
Func<char, char, bool> comparer = GetCharComparer(stringComparison);

for (int index = 0; index < value.Length; index++)
{
if (index >= expected.Length || !comparer(value[index], expected[index]))
if (index >= expected.Length || !comparer.Equals(value[index..(index + 1)], expected[index..(index + 1)]))
{
return index;
}
Expand All @@ -26,11 +25,6 @@ public static int IndexOfFirstMismatch(this string value, string expected, Strin
return -1;
dennisdoomen marked this conversation as resolved.
Show resolved Hide resolved
}

private static Func<char, char, bool> GetCharComparer(StringComparison stringComparison) =>
stringComparison == StringComparison.Ordinal
? (x, y) => x == y
: (x, y) => char.ToUpperInvariant(x) == char.ToUpperInvariant(y);

/// <summary>
/// Gets the quoted three characters at the specified index of a string, including the index itself.
/// </summary>
Expand Down
41 changes: 35 additions & 6 deletions Src/FluentAssertions/Primitives/StringAssertions.cs
Expand Up @@ -54,7 +54,7 @@ public StringAssertions(string value)
public AndConstraint<TAssertions> Be(string expected, string because = "", params object[] becauseArgs)
{
var stringEqualityValidator = new StringValidator(
new StringEqualityStrategy(StringComparison.Ordinal),
new StringEqualityStrategy(StringComparer.Ordinal),
because, becauseArgs);

stringEqualityValidator.Validate(Subject, expected);
Expand Down Expand Up @@ -110,11 +110,40 @@ public AndConstraint<TAssertions> BeOneOf(IEnumerable<string> validValues, strin
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public AndConstraint<TAssertions> BeEquivalentTo(string expected, string because = "",
params object[] becauseArgs)
public AndConstraint<TAssertions> BeEquivalentTo(string expected,
vbreuss marked this conversation as resolved.
Show resolved Hide resolved
string because = "", params object[] becauseArgs)
{
var expectation = new StringValidator(
new StringEqualityStrategy(StringComparer.OrdinalIgnoreCase),
because, becauseArgs);

expectation.Validate(Subject, expected);

return new AndConstraint<TAssertions>((TAssertions)this);
}

/// <summary>
/// Asserts that a string is exactly the same as another string, using the provided <paramref name="comparer"/>.
/// </summary>
/// <param name="expected">
/// The string that the subject is expected to be equivalent to.
/// </param>
/// <param name="comparer">
/// The string equality comparer.
/// </param>
/// <param name="because">
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </param>
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public AndConstraint<TAssertions> BeEquivalentTo(string expected,
IEqualityComparer<string> comparer,
string because = "", params object[] becauseArgs)
{
var expectation = new StringValidator(
new StringEqualityStrategy(StringComparison.OrdinalIgnoreCase),
new StringEqualityStrategy(comparer),
because, becauseArgs);

expectation.Validate(Subject, expected);
Expand Down Expand Up @@ -675,7 +704,7 @@ public AndConstraint<TAssertions> StartWith(string expected, string because = ""
Guard.ThrowIfArgumentIsNull(expected, nameof(expected), "Cannot compare start of string with <null>.");

var stringStartValidator = new StringValidator(
new StringStartStrategy(StringComparison.Ordinal),
new StringStartStrategy(StringComparer.Ordinal),
because, becauseArgs);

stringStartValidator.Validate(Subject, expected);
Expand Down Expand Up @@ -728,7 +757,7 @@ public AndConstraint<TAssertions> NotStartWith(string unexpected, string because
Guard.ThrowIfArgumentIsNull(expected, nameof(expected), "Cannot compare string start equivalence with <null>.");

var stringStartValidator = new StringValidator(
new StringStartStrategy(StringComparison.OrdinalIgnoreCase),
new StringStartStrategy(StringComparer.OrdinalIgnoreCase),
because, becauseArgs);

stringStartValidator.Validate(Subject, expected);
Expand Down
24 changes: 12 additions & 12 deletions Src/FluentAssertions/Primitives/StringEqualityStrategy.cs
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using FluentAssertions.Common;
Expand All @@ -8,11 +9,13 @@ namespace FluentAssertions.Primitives;

internal class StringEqualityStrategy : IStringComparisonStrategy
{
private readonly StringComparison comparisonMode;
private readonly IEqualityComparer<string> comparer;
private readonly bool ignoringCase;

public StringEqualityStrategy(StringComparison comparisonMode)
public StringEqualityStrategy(IEqualityComparer<string> comparer)
{
this.comparisonMode = comparisonMode;
this.comparer = comparer;
ignoringCase = comparer.Equals("A", "a");
}

public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, string expected)
Expand All @@ -21,7 +24,7 @@ public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, s

if (expected.IsLongOrMultiline() || subject.IsLongOrMultiline())
{
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparisonMode);
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparer);

if (indexOfMismatch == -1)
{
Expand All @@ -47,7 +50,7 @@ public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, s
}
else if (ValidateAgainstLengthDifferences(assertion, subject, expected))
{
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparisonMode);
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparer);

if (indexOfMismatch != -1)
{
Expand All @@ -63,21 +66,18 @@ public string ExpectationDescription
{
get
{
string predicateDescription = IgnoreCase ? "be equivalent to" : "be";
string predicateDescription = ignoringCase ? "be equivalent to" : "be";
return "Expected {context:string} to " + predicateDescription + " ";
}
}

private bool IgnoreCase
=> comparisonMode == StringComparison.OrdinalIgnoreCase;

private void ValidateAgainstSuperfluousWhitespace(IAssertionScope assertion, string subject, string expected)
{
assertion
.ForCondition(!(expected.Length > subject.Length && expected.TrimEnd().Equals(subject, comparisonMode)))
.ForCondition(!(expected.Length > subject.Length && comparer.Equals(expected.TrimEnd(), subject)))
.FailWith(ExpectationDescription + "{0}{reason}, but it misses some extra whitespace at the end.", expected)
.Then
.ForCondition(!(subject.Length > expected.Length && subject.TrimEnd().Equals(expected, comparisonMode)))
.ForCondition(!(subject.Length > expected.Length && comparer.Equals(subject.TrimEnd(), expected)))
.FailWith(ExpectationDescription + "{0}{reason}, but it has unexpected whitespace at the end.", expected);
}

Expand All @@ -98,7 +98,7 @@ private bool ValidateAgainstLengthDifferences(IAssertionScope assertion, string

private string GetMismatchSegmentForStringsOfDifferentLengths(string subject, string expected)
{
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparisonMode);
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparer);

// If there is no difference it means that expected starts with subject and subject is shorter than expected
if (indexOfMismatch == -1)
Expand Down
21 changes: 10 additions & 11 deletions Src/FluentAssertions/Primitives/StringStartStrategy.cs
@@ -1,30 +1,29 @@
using System;
using System.Collections.Generic;
using FluentAssertions.Common;
using FluentAssertions.Execution;

namespace FluentAssertions.Primitives;

internal class StringStartStrategy : IStringComparisonStrategy
{
private readonly StringComparison stringComparison;
private readonly IEqualityComparer<string> comparer;
private readonly bool ignoringCase;

public StringStartStrategy(StringComparison stringComparison)
public StringStartStrategy(IEqualityComparer<string> comparer)
{
this.stringComparison = stringComparison;
this.comparer = comparer;
ignoringCase = comparer.Equals("A", "a");
}

public string ExpectationDescription
{
get
{
string predicateDescription = IgnoreCase ? "start with equivalent of" : "start with";
string predicateDescription = ignoringCase ? "start with equivalent of" : "start with";
return "Expected {context:string} to " + predicateDescription + " ";
}
}

private bool IgnoreCase
=> stringComparison == StringComparison.OrdinalIgnoreCase;

public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, string expected)
{
if (!assertion
Expand All @@ -34,13 +33,13 @@ public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, s
return;
}

if (subject.StartsWith(expected, stringComparison))
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparer);

if (indexOfMismatch < 0 || indexOfMismatch >= expected.Length)
{
return;
}

int indexOfMismatch = subject.IndexOfFirstMismatch(expected, stringComparison);

assertion.FailWith(
ExpectationDescription + "{0}{reason}, but {1} differs near " + subject.IndexedSegmentAt(indexOfMismatch) +
".",
Expand Down
Expand Up @@ -2013,6 +2013,7 @@ namespace FluentAssertions.Primitives
public FluentAssertions.AndConstraint<TAssertions> Be(string expected, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEmpty(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEquivalentTo(string expected, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEquivalentTo(string expected, System.Collections.Generic.IEqualityComparer<string> comparer, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeLowerCased(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeNullOrEmpty(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeNullOrWhiteSpace(string because = "", params object[] becauseArgs) { }
Expand Down
Expand Up @@ -2097,6 +2097,7 @@ namespace FluentAssertions.Primitives
public FluentAssertions.AndConstraint<TAssertions> Be(string expected, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEmpty(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEquivalentTo(string expected, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEquivalentTo(string expected, System.Collections.Generic.IEqualityComparer<string> comparer, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeLowerCased(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeNullOrEmpty(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeNullOrWhiteSpace(string because = "", params object[] becauseArgs) { }
Expand Down
Expand Up @@ -1964,6 +1964,7 @@ namespace FluentAssertions.Primitives
public FluentAssertions.AndConstraint<TAssertions> Be(string expected, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEmpty(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEquivalentTo(string expected, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEquivalentTo(string expected, System.Collections.Generic.IEqualityComparer<string> comparer, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeLowerCased(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeNullOrEmpty(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeNullOrWhiteSpace(string because = "", params object[] becauseArgs) { }
Expand Down
Expand Up @@ -2013,6 +2013,7 @@ namespace FluentAssertions.Primitives
public FluentAssertions.AndConstraint<TAssertions> Be(string expected, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEmpty(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEquivalentTo(string expected, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeEquivalentTo(string expected, System.Collections.Generic.IEqualityComparer<string> comparer, string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeLowerCased(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeNullOrEmpty(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<TAssertions> BeNullOrWhiteSpace(string because = "", params object[] becauseArgs) { }
Expand Down
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Xunit;
using Xunit.Sdk;
Expand All @@ -12,6 +13,33 @@ public partial class StringAssertionSpecs
{
public class BeEquivalentTo
{
[Fact]
public void Succeed_for_different_strings_using_custom_matching_comparer()
{
// Arrange
var comparer = new MatchingEqualityComparer();
string actual = "test A";
string expect = "test B";

// Act / Assert
actual.Should().BeEquivalentTo(expect, comparer);
}

[Fact]
public void Fail_for_same_strings_using_custom_not_matching_comparer()
{
// Arrange
var comparer = new NotMatchingEqualityComparer();
string actual = "foo";
string expect = "foo";

// Act
Action act = () => actual.Should().BeEquivalentTo(expect, comparer);

// Assert
act.Should().Throw<XunitException>();
}

[Fact]
public void When_strings_are_the_same_while_ignoring_case_it_should_not_throw()
{
Expand Down Expand Up @@ -104,6 +132,32 @@ public void
act.Should().Throw<XunitException>().WithMessage(
"Expected string to be equivalent to \"abc\" because I say so, but it has unexpected whitespace at the end.");
}

private sealed class MatchingEqualityComparer : IEqualityComparer<string>
{
public bool Equals(string x, string y)
{
return true;
}

public int GetHashCode(string obj)
{
return obj.GetHashCode();
}
}

private sealed class NotMatchingEqualityComparer : IEqualityComparer<string>
{
public bool Equals(string x, string y)
{
return false;
}

public int GetHashCode(string obj)
{
return obj.GetHashCode();
}
}
}

public class NotBeEquivalentTo
Expand Down
Expand Up @@ -26,6 +26,20 @@ public void When_asserting_string_starts_with_the_same_value_it_should_not_throw
action.Should().NotThrow();
}

[Fact]
public void When_expected_string_is_the_same_value_it_should_not_throw()
{
// Arrange
string value = "ABC";

// Act
Action action = () =>
value.Should().StartWith(value);

// Assert
action.Should().NotThrow();
vbreuss marked this conversation as resolved.
Show resolved Hide resolved
}

[Fact]
public void When_string_does_not_start_with_expected_phrase_it_should_throw()
{
Expand Down
1 change: 1 addition & 0 deletions docs/_pages/releases.md
Expand Up @@ -13,6 +13,7 @@ sidebar:

### Improvements
* Improve failure message for string assertions when checking for equality - [#2307](https://github.com/fluentassertions/fluentassertions/pull/2307)
* Allow `IEqualityComparer` in string `BeEquivalentTo` assertion - [#2372](https://github.com/fluentassertions/fluentassertions/pull/2372)
vbreuss marked this conversation as resolved.
Show resolved Hide resolved

### Fixes
* Fixed formatting error when checking nullable `DateTimeOffset` with
Expand Down