Skip to content

Commit bb50da5

Browse files
authored
Refactor StringValidator to avoid inheritance (fluentassertions#2296)
1 parent b0f8abb commit bb50da5

10 files changed

+285
-287
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using FluentAssertions.Execution;
2+
3+
namespace FluentAssertions.Primitives;
4+
5+
/// <summary>
6+
/// The strategy used for comparing two <see langword="string" />s.
7+
/// </summary>
8+
internal interface IStringComparisonStrategy
9+
{
10+
/// <summary>
11+
/// The prefix for the message when the assertion fails.
12+
/// </summary>
13+
string ExpectationDescription { get; }
14+
15+
/// <summary>
16+
/// Asserts that the <paramref name="subject"/> matches the <paramref name="expected"/> value.
17+
/// </summary>
18+
void ValidateAgainstMismatch(IAssertionScope assertion, string subject, string expected);
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
using FluentAssertions.Execution;
3+
4+
namespace FluentAssertions.Primitives;
5+
6+
internal class NegatedStringStartStrategy : IStringComparisonStrategy
7+
{
8+
private readonly StringComparison stringComparison;
9+
10+
public NegatedStringStartStrategy(StringComparison stringComparison)
11+
{
12+
this.stringComparison = stringComparison;
13+
}
14+
15+
public string ExpectationDescription
16+
{
17+
get
18+
{
19+
string predicateDescription = IgnoreCase ? "start with equivalent of" : "start with";
20+
return "Expected {context:string} that does not " + predicateDescription + " ";
21+
}
22+
}
23+
24+
private bool IgnoreCase
25+
=> stringComparison == StringComparison.OrdinalIgnoreCase;
26+
27+
public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, string expected)
28+
{
29+
assertion
30+
.ForCondition(!subject.StartsWith(expected, stringComparison))
31+
.FailWith(ExpectationDescription + "{0}{reason}, but found {1}.", expected, subject);
32+
}
33+
}

Src/FluentAssertions/Primitives/NegatedStringStartValidator.cs

Lines changed: 0 additions & 43 deletions
This file was deleted.

Src/FluentAssertions/Primitives/StringAssertions.cs

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ public StringAssertions(string value)
5353
/// </param>
5454
public AndConstraint<TAssertions> Be(string expected, string because = "", params object[] becauseArgs)
5555
{
56-
var stringEqualityValidator =
57-
new StringEqualityValidator(Subject, expected, StringComparison.Ordinal, because, becauseArgs);
56+
var stringEqualityValidator = new StringValidator(
57+
new StringEqualityStrategy(StringComparison.Ordinal),
58+
because, becauseArgs);
5859

59-
stringEqualityValidator.Validate();
60+
stringEqualityValidator.Validate(Subject, expected);
6061

6162
return new AndConstraint<TAssertions>((TAssertions)this);
6263
}
@@ -112,10 +113,11 @@ public AndConstraint<TAssertions> BeOneOf(IEnumerable<string> validValues, strin
112113
public AndConstraint<TAssertions> BeEquivalentTo(string expected, string because = "",
113114
params object[] becauseArgs)
114115
{
115-
var expectation = new StringEqualityValidator(
116-
Subject, expected, StringComparison.OrdinalIgnoreCase, because, becauseArgs);
116+
var expectation = new StringValidator(
117+
new StringEqualityStrategy(StringComparison.OrdinalIgnoreCase),
118+
because, becauseArgs);
117119

118-
expectation.Validate();
120+
expectation.Validate(Subject, expected);
119121

120122
return new AndConstraint<TAssertions>((TAssertions)this);
121123
}
@@ -218,8 +220,11 @@ public AndConstraint<TAssertions> Match(string wildcardPattern, string because =
218220
Guard.ThrowIfArgumentIsEmpty(wildcardPattern, nameof(wildcardPattern),
219221
"Cannot match string against an empty string. Provide a wildcard pattern or use the BeEmpty method.");
220222

221-
var stringWildcardMatchingValidator = new StringWildcardMatchingValidator(Subject, wildcardPattern, because, becauseArgs);
222-
stringWildcardMatchingValidator.Validate();
223+
var stringWildcardMatchingValidator = new StringValidator(
224+
new StringWildcardMatchingStrategy(),
225+
because, becauseArgs);
226+
227+
stringWildcardMatchingValidator.Validate(Subject, wildcardPattern);
223228

224229
return new AndConstraint<TAssertions>((TAssertions)this);
225230
}
@@ -267,10 +272,14 @@ public AndConstraint<TAssertions> NotMatch(string wildcardPattern, string becaus
267272
Guard.ThrowIfArgumentIsEmpty(wildcardPattern, nameof(wildcardPattern),
268273
"Cannot match string against an empty string. Provide a wildcard pattern or use the NotBeEmpty method.");
269274

270-
new StringWildcardMatchingValidator(Subject, wildcardPattern, because, becauseArgs)
271-
{
272-
Negate = true
273-
}.Validate();
275+
var stringWildcardMatchingValidator = new StringValidator(
276+
new StringWildcardMatchingStrategy
277+
{
278+
Negate = true
279+
},
280+
because, becauseArgs);
281+
282+
stringWildcardMatchingValidator.Validate(Subject, wildcardPattern);
274283

275284
return new AndConstraint<TAssertions>((TAssertions)this);
276285
}
@@ -319,13 +328,15 @@ public AndConstraint<TAssertions> MatchEquivalentOf(string wildcardPattern, stri
319328
Guard.ThrowIfArgumentIsEmpty(wildcardPattern, nameof(wildcardPattern),
320329
"Cannot match string against an empty string. Provide a wildcard pattern or use the BeEmpty method.");
321330

322-
var validator = new StringWildcardMatchingValidator(Subject, wildcardPattern, because, becauseArgs)
323-
{
324-
IgnoreCase = true,
325-
IgnoreNewLineDifferences = true
326-
};
331+
var stringWildcardMatchingValidator = new StringValidator(
332+
new StringWildcardMatchingStrategy
333+
{
334+
IgnoreCase = true,
335+
IgnoreNewLineDifferences = true
336+
},
337+
because, becauseArgs);
327338

328-
validator.Validate();
339+
stringWildcardMatchingValidator.Validate(Subject, wildcardPattern);
329340

330341
return new AndConstraint<TAssertions>((TAssertions)this);
331342
}
@@ -374,14 +385,16 @@ public AndConstraint<TAssertions> NotMatchEquivalentOf(string wildcardPattern, s
374385
Guard.ThrowIfArgumentIsEmpty(wildcardPattern, nameof(wildcardPattern),
375386
"Cannot match string against an empty string. Provide a wildcard pattern or use the NotBeEmpty method.");
376387

377-
var validator = new StringWildcardMatchingValidator(Subject, wildcardPattern, because, becauseArgs)
378-
{
379-
IgnoreCase = true,
380-
IgnoreNewLineDifferences = true,
381-
Negate = true
382-
};
388+
var stringWildcardMatchingValidator = new StringValidator(
389+
new StringWildcardMatchingStrategy
390+
{
391+
IgnoreCase = true,
392+
IgnoreNewLineDifferences = true,
393+
Negate = true
394+
},
395+
because, becauseArgs);
383396

384-
validator.Validate();
397+
stringWildcardMatchingValidator.Validate(Subject, wildcardPattern);
385398

386399
return new AndConstraint<TAssertions>((TAssertions)this);
387400
}
@@ -661,8 +674,11 @@ public AndConstraint<TAssertions> StartWith(string expected, string because = ""
661674
{
662675
Guard.ThrowIfArgumentIsNull(expected, nameof(expected), "Cannot compare start of string with <null>.");
663676

664-
var stringStartValidator = new StringStartValidator(Subject, expected, StringComparison.Ordinal, because, becauseArgs);
665-
stringStartValidator.Validate();
677+
var stringStartValidator = new StringValidator(
678+
new StringStartStrategy(StringComparison.Ordinal),
679+
because, becauseArgs);
680+
681+
stringStartValidator.Validate(Subject, expected);
666682

667683
return new AndConstraint<TAssertions>((TAssertions)this);
668684
}
@@ -684,10 +700,11 @@ public AndConstraint<TAssertions> NotStartWith(string unexpected, string because
684700
{
685701
Guard.ThrowIfArgumentIsNull(unexpected, nameof(unexpected), "Cannot compare start of string with <null>.");
686702

687-
var negatedStringStartValidator =
688-
new NegatedStringStartValidator(Subject, unexpected, StringComparison.Ordinal, because, becauseArgs);
703+
var negatedStringStartValidator = new StringValidator(
704+
new NegatedStringStartStrategy(StringComparison.Ordinal),
705+
because, becauseArgs);
689706

690-
negatedStringStartValidator.Validate();
707+
negatedStringStartValidator.Validate(Subject, unexpected);
691708

692709
return new AndConstraint<TAssertions>((TAssertions)this);
693710
}
@@ -710,10 +727,11 @@ public AndConstraint<TAssertions> StartWithEquivalentOf(string expected, string
710727
{
711728
Guard.ThrowIfArgumentIsNull(expected, nameof(expected), "Cannot compare string start equivalence with <null>.");
712729

713-
var stringStartValidator =
714-
new StringStartValidator(Subject, expected, StringComparison.OrdinalIgnoreCase, because, becauseArgs);
730+
var stringStartValidator = new StringValidator(
731+
new StringStartStrategy(StringComparison.OrdinalIgnoreCase),
732+
because, becauseArgs);
715733

716-
stringStartValidator.Validate();
734+
stringStartValidator.Validate(Subject, expected);
717735

718736
return new AndConstraint<TAssertions>((TAssertions)this);
719737
}
@@ -736,10 +754,11 @@ public AndConstraint<TAssertions> NotStartWithEquivalentOf(string unexpected, st
736754
{
737755
Guard.ThrowIfArgumentIsNull(unexpected, nameof(unexpected), "Cannot compare start of string with <null>.");
738756

739-
var negatedStringStartValidator =
740-
new NegatedStringStartValidator(Subject, unexpected, StringComparison.OrdinalIgnoreCase, because, becauseArgs);
757+
var negatedStringStartValidator = new StringValidator(
758+
new NegatedStringStartStrategy(StringComparison.OrdinalIgnoreCase),
759+
because, becauseArgs);
741760

742-
negatedStringStartValidator.Validate();
761+
negatedStringStartValidator.Validate(Subject, unexpected);
743762

744763
return new AndConstraint<TAssertions>((TAssertions)this);
745764
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System;
2+
using FluentAssertions.Common;
3+
using FluentAssertions.Execution;
4+
5+
namespace FluentAssertions.Primitives;
6+
7+
internal class StringEqualityStrategy : IStringComparisonStrategy
8+
{
9+
private readonly StringComparison comparisonMode;
10+
11+
public StringEqualityStrategy(StringComparison comparisonMode)
12+
{
13+
this.comparisonMode = comparisonMode;
14+
}
15+
16+
private bool ValidateAgainstSuperfluousWhitespace(IAssertionScope assertion, string subject, string expected)
17+
{
18+
return assertion
19+
.ForCondition(!(expected.Length > subject.Length && expected.TrimEnd().Equals(subject, comparisonMode)))
20+
.FailWith(ExpectationDescription + "{0}{reason}, but it misses some extra whitespace at the end.", expected)
21+
.Then
22+
.ForCondition(!(subject.Length > expected.Length && subject.TrimEnd().Equals(expected, comparisonMode)))
23+
.FailWith(ExpectationDescription + "{0}{reason}, but it has unexpected whitespace at the end.", expected);
24+
}
25+
26+
private bool ValidateAgainstLengthDifferences(IAssertionScope assertion, string subject, string expected)
27+
{
28+
return assertion
29+
.ForCondition(subject.Length == expected.Length)
30+
.FailWith(() =>
31+
{
32+
string mismatchSegment = GetMismatchSegmentForStringsOfDifferentLengths(subject, expected);
33+
34+
string message = ExpectationDescription +
35+
"{0} with a length of {1}{reason}, but {2} has a length of {3}, differs near " + mismatchSegment + ".";
36+
37+
return new FailReason(message, expected, expected.Length, subject, subject.Length);
38+
});
39+
}
40+
41+
private string GetMismatchSegmentForStringsOfDifferentLengths(string subject, string expected)
42+
{
43+
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparisonMode);
44+
45+
// If there is no difference it means that expected starts with subject and subject is shorter than expected
46+
if (indexOfMismatch == -1)
47+
{
48+
// Subject is shorter so we point at its last character.
49+
// We would like to point at next character as it is the real
50+
// index of first mismatch, but we need to point at character existing in
51+
// subject, so the next best thing is the last subject character.
52+
indexOfMismatch = Math.Max(0, subject.Length - 1);
53+
}
54+
55+
return subject.IndexedSegmentAt(indexOfMismatch);
56+
}
57+
58+
public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, string expected)
59+
{
60+
if (!ValidateAgainstSuperfluousWhitespace(assertion, subject, expected) ||
61+
!ValidateAgainstLengthDifferences(assertion, subject, expected))
62+
{
63+
return;
64+
}
65+
66+
int indexOfMismatch = subject.IndexOfFirstMismatch(expected, comparisonMode);
67+
68+
if (indexOfMismatch != -1)
69+
{
70+
assertion.FailWith(
71+
ExpectationDescription + "{0}{reason}, but {1} differs near " + subject.IndexedSegmentAt(indexOfMismatch) + ".",
72+
expected, subject);
73+
}
74+
}
75+
76+
public string ExpectationDescription
77+
{
78+
get
79+
{
80+
string predicateDescription = IgnoreCase ? "be equivalent to" : "be";
81+
return "Expected {context:string} to " + predicateDescription + " ";
82+
}
83+
}
84+
85+
private bool IgnoreCase
86+
=> comparisonMode == StringComparison.OrdinalIgnoreCase;
87+
}

0 commit comments

Comments
 (0)