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
9 changes: 9 additions & 0 deletions Src/FluentAssertions/Common/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,13 @@ public static int CountSubstring(this string str, string substring, StringCompar

return count;
}

/// <summary>
/// Determines if the <paramref name="value"/> is longer than 8 characters or contains an <see cref="Environment.NewLine"/>.
/// </summary>
public static bool IsLongOrMultiline(this string value)
{
const int humanReadableLength = 8;
return value.Length > humanReadableLength || value.Contains(Environment.NewLine, StringComparison.Ordinal);
}
}
181 changes: 164 additions & 17 deletions Src/FluentAssertions/Primitives/StringEqualityStrategy.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Linq;
using System.Text;
using FluentAssertions.Common;
using FluentAssertions.Execution;

Expand All @@ -13,9 +15,65 @@ public StringEqualityStrategy(StringComparison comparisonMode)
this.comparisonMode = comparisonMode;
}

private bool ValidateAgainstSuperfluousWhitespace(IAssertionScope assertion, string subject, string expected)
public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, string expected)
{
return assertion
ValidateAgainstSuperfluousWhitespace(assertion, subject, expected);

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

if (indexOfMismatch == -1)
{
ValidateAgainstLengthDifferences(assertion, subject, expected);
return;
}

string locationDescription = $"at index {indexOfMismatch}";
var matchingString = subject[..indexOfMismatch];
int lineNumber = matchingString.Count(c => c == '\n');

if (lineNumber > 0)
{
var indexOfLastNewlineBeforeMismatch = matchingString.LastIndexOf('\n');
var column = matchingString.Length - indexOfLastNewlineBeforeMismatch;
locationDescription = $"on line {lineNumber + 1} and column {column} (index {indexOfMismatch})";
}

assertion.FailWith(
ExpectationDescription + "the same string{reason}, but they differ " + locationDescription + ":" +
Environment.NewLine
+ GetMismatchSegmentForLongStrings(subject, expected, indexOfMismatch) + ".");
}
else if (ValidateAgainstLengthDifferences(assertion, subject, expected))
{
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparisonMode);

if (indexOfMismatch != -1)
{
assertion.FailWith(
ExpectationDescription + "{0}{reason}, but {1} differs near " + subject.IndexedSegmentAt(indexOfMismatch) +
".",
expected, subject);
}
}
}

public string ExpectationDescription
{
get
{
string predicateDescription = IgnoreCase ? "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)))
.FailWith(ExpectationDescription + "{0}{reason}, but it misses some extra whitespace at the end.", expected)
.Then
Expand Down Expand Up @@ -55,33 +113,122 @@ private string GetMismatchSegmentForStringsOfDifferentLengths(string subject, st
return subject.IndexedSegmentAt(indexOfMismatch);
}

public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, string expected)
/// <summary>
/// Get the mismatch segment between <paramref name="expected"/> and <paramref name="subject"/> for long strings,
/// when they differ at index <paramref name="firstIndexOfMismatch"/>.
/// </summary>
private static string GetMismatchSegmentForLongStrings(string subject, string expected, int firstIndexOfMismatch)
{
int trimStart = GetStartIndexOfPhraseToShowBeforeTheMismatchingIndex(subject, firstIndexOfMismatch);
const string prefix = " \"";
const string suffix = "\"";
const char arrowDown = '\u2193';
const char arrowUp = '\u2191';

int whiteSpaceCountBeforeArrow = (firstIndexOfMismatch - trimStart) + prefix.Length;

if (trimStart > 0)
{
whiteSpaceCountBeforeArrow++;
}

var visibleText = subject[trimStart..firstIndexOfMismatch];
whiteSpaceCountBeforeArrow += visibleText.Count(c => c is '\r' or '\n');

var sb = new StringBuilder();

sb.Append(' ', whiteSpaceCountBeforeArrow).Append(arrowDown).AppendLine(" (actual)");
AppendPrefixAndEscapedPhraseToShowWithEllipsisAndSuffix(sb, prefix, subject, trimStart, suffix);
AppendPrefixAndEscapedPhraseToShowWithEllipsisAndSuffix(sb, prefix, expected, trimStart, suffix);
sb.Append(' ', whiteSpaceCountBeforeArrow).Append(arrowUp).Append(" (expected)");

return sb.ToString();
}

/// <summary>
/// Appends the <paramref name="prefix"/>, the escaped visible <paramref name="text"/> phrase decorated with ellipsis and the <paramref name="suffix"/> to the <paramref name="stringBuilder"/>.
/// </summary>
/// <remarks>When text phrase starts at <paramref name="indexOfStartingPhrase"/> and with a calculated length omits text on start or end, an ellipsis is added.</remarks>
private static void AppendPrefixAndEscapedPhraseToShowWithEllipsisAndSuffix(StringBuilder stringBuilder,
string prefix, string text, int indexOfStartingPhrase, string suffix)
{
if (!ValidateAgainstSuperfluousWhitespace(assertion, subject, expected) ||
!ValidateAgainstLengthDifferences(assertion, subject, expected))
var subjectLength = GetLengthOfPhraseToShowOrDefaultLength(text[indexOfStartingPhrase..]);
const char ellipsis = '\u2026';

stringBuilder.Append(prefix);

if (indexOfStartingPhrase > 0)
{
return;
stringBuilder.Append(ellipsis);
}

int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparisonMode);
stringBuilder.Append(text
.Substring(indexOfStartingPhrase, subjectLength)
.Replace("\r", "\\r", StringComparison.OrdinalIgnoreCase)
.Replace("\n", "\\n", StringComparison.OrdinalIgnoreCase));

if (indexOfMismatch != -1)
if (text.Length > (indexOfStartingPhrase + subjectLength))
{
assertion.FailWith(
ExpectationDescription + "{0}{reason}, but {1} differs near " + subject.IndexedSegmentAt(indexOfMismatch) + ".",
expected, subject);
stringBuilder.Append(ellipsis);
}

stringBuilder.AppendLine(suffix);
}

public string ExpectationDescription
/// <summary>
/// Calculates the start index of the visible segment from <paramref name="value"/> when highlighting the difference at <paramref name="indexOfFirstMismatch"/>.
/// </summary>
/// <remarks>
/// Either keep the last 10 characters before <paramref name="indexOfFirstMismatch"/> or a word begin (separated by whitespace) between 15 and 5 characters before <paramref name="indexOfFirstMismatch"/>.
/// </remarks>
private static int GetStartIndexOfPhraseToShowBeforeTheMismatchingIndex(string value, int indexOfFirstMismatch)
{
get
const int defaultCharactersToKeep = 10;
const int minCharactersToKeep = 5;
const int maxCharactersToKeep = 15;
const int lengthOfWhitespace = 1;
const int phraseLengthToCheckForWordBoundary = (maxCharactersToKeep - minCharactersToKeep) + lengthOfWhitespace;

if (indexOfFirstMismatch <= defaultCharactersToKeep)
{
string predicateDescription = IgnoreCase ? "be equivalent to" : "be";
return "Expected {context:string} to " + predicateDescription + " ";
return 0;
}

var indexToStartSearchingForWordBoundary = Math.Max(indexOfFirstMismatch - (maxCharactersToKeep + lengthOfWhitespace), 0);

var indexOfWordBoundary = value
.IndexOf(' ', indexToStartSearchingForWordBoundary, phraseLengthToCheckForWordBoundary) -
indexToStartSearchingForWordBoundary;

if (indexOfWordBoundary >= 0)
{
return indexToStartSearchingForWordBoundary + indexOfWordBoundary + lengthOfWhitespace;
}

return indexOfFirstMismatch - defaultCharactersToKeep;
}

private bool IgnoreCase
=> comparisonMode == StringComparison.OrdinalIgnoreCase;
/// <summary>
/// Calculates how many characters to keep in <paramref name="value"/>.
/// </summary>
/// <remarks>
/// If a word end is found between 15 and 25 characters, use this word end, otherwise keep 20 characters.
/// </remarks>
private static int GetLengthOfPhraseToShowOrDefaultLength(string value)
{
const int defaultLength = 20;
const int minLength = 15;
const int maxLength = 25;
const int lengthOfWhitespace = 1;

var indexOfWordBoundary = value
.LastIndexOf(' ', Math.Min(maxLength + lengthOfWhitespace, value.Length) - 1);

if (indexOfWordBoundary >= minLength)
{
return indexOfWordBoundary;
}

return Math.Min(defaultLength, value.Length);
}
}
11 changes: 2 additions & 9 deletions Src/FluentAssertions/Primitives/StringValidator.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
using System;
using FluentAssertions.Common;
using FluentAssertions.Execution;

namespace FluentAssertions.Primitives;

internal class StringValidator
{
private const int HumanReadableLength = 8;

private readonly IStringComparisonStrategy comparisonStrategy;
private IAssertionScope assertion;

Expand All @@ -28,7 +26,7 @@ public void Validate(string subject, string expected)
return;
}

if (IsLongOrMultiline(expected) || IsLongOrMultiline(subject))
if (expected.IsLongOrMultiline() || subject.IsLongOrMultiline())
{
assertion = assertion.UsingLineBreaks;
}
Expand All @@ -46,9 +44,4 @@ private bool ValidateAgainstNulls(string subject, string expected)
assertion.FailWith(comparisonStrategy.ExpectationDescription + "{0}{reason}, but found {1}.", expected, subject);
return false;
}

private static bool IsLongOrMultiline(string value)
{
return value.Length > HumanReadableLength || value.Contains(Environment.NewLine, StringComparison.Ordinal);
}
}
4 changes: 2 additions & 2 deletions Tests/FluentAssertions.Equivalency.Specs/BasicSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ public void When_treating_a_value_type_in_a_collection_as_a_complex_type_it_shou
options => options.ComparingByMembers<ClassWithValueSemanticsOnSingleProperty>());

// Assert
act.Should().Throw<XunitException>().WithMessage("*NestedProperty*OtherValue*SomeValue*");
act.Should().Throw<XunitException>().WithMessage("*NestedProperty*SomeValue*OtherValue*");
}

[Fact]
Expand All @@ -237,7 +237,7 @@ public void When_treating_a_value_type_as_a_complex_type_it_should_compare_them_
options => options.ComparingByMembers<ClassWithValueSemanticsOnSingleProperty>());

// Assert
act.Should().Throw<XunitException>().WithMessage("*NestedProperty*OtherValue*SomeValue*");
act.Should().Throw<XunitException>().WithMessage("*NestedProperty*SomeValue*OtherValue*");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ public void Allow_ignoring_cyclic_references_in_value_types_compared_by_members(

// Assert
act.Should().Throw<XunitException>()
.WithMessage("*subject.Next.Title*Second*SecondDifferent*")
.WithMessage("*subject.Next.Title*SecondDifferent*Second*")
.Which.Message.Should().NotContain("maximum recursion depth was reached");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ public void When_deeply_nested_properties_do_not_have_all_equal_values_it_should
act
.Should().Throw<XunitException>()
.WithMessage(
"Expected*Level.Level.Text*to be *A wrong text value*but*\"Level2\"*length*");
"Expected*Level.Level.Text*to be *\"Level2\"*A wrong text value*");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1295,7 +1295,7 @@ public void When_a_property_is_internal_and_it_should_be_included_it_should_fail

// Assert
act.Should().Throw<XunitException>()
.WithMessage("*InternalProperty*also internal*internal*ProtectedInternalProperty*");
.WithMessage("*InternalProperty*internal*also internal*ProtectedInternalProperty*");
}

private class ClassWithInternalProperty
Expand Down Expand Up @@ -1351,7 +1351,7 @@ public void When_a_field_is_internal_and_it_should_be_included_it_should_fail_th
Action act = () => actual.Should().BeEquivalentTo(expected, options => options.IncludingInternalFields());

// Assert
act.Should().Throw<XunitException>().WithMessage("*InternalField*also internal*internal*ProtectedInternalField*");
act.Should().Throw<XunitException>().WithMessage("*InternalField*internal*also internal*ProtectedInternalField*");
}

private class ClassWithInternalField
Expand Down
2 changes: 1 addition & 1 deletion Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net47;net6.0;netcoreapp2.0;netcoreapp2.1;netcoreapp3.1</TargetFrameworks>
Expand Down
2 changes: 1 addition & 1 deletion Tests/FluentAssertions.Specs/Formatting/FormatterSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public void When_the_subject_or_expectation_contains_reserved_symbols_it_should_
Action act = () => result.Should().Be(expectedJson);

// Assert
act.Should().Throw<XunitException>().WithMessage("*near*index 37*");
act.Should().Throw<XunitException>().WithMessage("*at*index 37*");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ public void When_object_is_of_the_expected_type_it_should_cast_the_returned_obje
Action act = () => someObject.Should().BeOfType<Exception>().Which.Message.Should().Be("Other Message");

// Assert
act.Should().Throw<XunitException>().WithMessage("*Expected*Other*Actual*");
act.Should().Throw<XunitException>().WithMessage("*Expected*Actual*Other*");
}

[Fact]
Expand Down Expand Up @@ -906,7 +906,7 @@ public void When_to_the_expected_type_it_should_cast_the_returned_object_for_cha
Action act = () => someObject.Should().BeAssignableTo<Exception>().Which.Message.Should().Be("Other Message");

// Assert
act.Should().Throw<XunitException>().WithMessage("*Expected*Other*Actual*");
act.Should().Throw<XunitException>().WithMessage("*Expected*Actual*Other*");
}

[Fact]
Expand Down Expand Up @@ -1117,7 +1117,7 @@ public void
.Which.Message.Should().Be("Other Message");

// Assert
act.Should().Throw<XunitException>().WithMessage("*Expected*Other*Actual*");
act.Should().Throw<XunitException>().WithMessage("*Expected*Actual*Other*");
}

[Fact]
Expand Down
Loading