Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
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
14 changes: 14 additions & 0 deletions 14 Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ public bool IsCyclicReference(object expectation)

public ITraceWriter TraceWriter { get; set; }

/// <summary>
/// This method ensures that tracing starts with a fresh state when invoked.
/// </summary>
internal void ResetTracing()
{
// SMELL: We need to ensure that if tracing is enabled using the built-in internal writer,
// we start with a fresh instance of InternalTraceWriter. We can't add extend ITraceWriter
// as that would be a breaking change.
if (TraceWriter is InternalTraceWriter)
{
TraceWriter = new InternalTraceWriter();
}
}

public override string ToString()
{
return Invariant($"{{Path=\"{CurrentNode.Description}\"}}");
Expand Down
2 changes: 2 additions & 0 deletions 2 Src/FluentAssertions/Equivalency/EquivalencyValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public class EquivalencyValidator : IEquivalencyValidator

public void AssertEquality(Comparands comparands, EquivalencyValidationContext context)
{
context.ResetTracing();

using var scope = new AssertionScope();

scope.AssumeSingleCaller();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ public IMember Match(IMember expectedMember, object subject, INode parent, IEqui
if (subjectMember is null)
{
Execute.Assertion.FailWith(
$"Expectation has {expectedMember.Description} that the other object does not have.");
"Expectation has {0} that the other object does not have.", expectedMember.Description.AsNonFormattable());
}
else if (options.IgnoreNonBrowsableOnSubject && !subjectMember.IsBrowsable)
{
Execute.Assertion.FailWith(
$"Expectation has {expectedMember.Description} that is non-browsable in the other object, and non-browsable " +
"members on the subject are ignored with the current configuration");
"Expectation has {0} that is non-browsable in the other object, and non-browsable " +
"members on the subject are ignored with the current configuration", expectedMember.Description.AsNonFormattable());
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ public TSelf ComparingByValue(Type type)
/// </summary>
public TSelf WithTracing(ITraceWriter writer = null)
{
TraceWriter = writer ?? new StringBuilderTraceWriter();
TraceWriter = writer ?? new InternalTraceWriter();
return (TSelf)this;
}

Expand Down
9 changes: 9 additions & 0 deletions 9 Src/FluentAssertions/Equivalency/Steps/AssertionResultSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public string[] SelectClosestMatchFor(object key = null)
}

KeyValuePair<object, string[]>[] bestResultSets = GetBestResultSets();
if (bestResultSets.Length == 0)
{
return [];
}

KeyValuePair<object, string[]> bestMatch = Array.Find(bestResultSets, r => r.Key.Equals(key));

Expand All @@ -50,6 +54,11 @@ public string[] SelectClosestMatchFor(object key = null)

private KeyValuePair<object, string[]>[] GetBestResultSets()
{
if (set.Values.Count == 0)
{
return [];
}

int fewestFailures = set.Values.Min(r => r.Length);
return set.Where(r => r.Value.Length == fewestFailures).ToArray();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ private bool ExecuteAssertion(Comparands comparands, IEquivalencyValidationConte
bool subjectIsValidType =
AssertionScope.Current
.ForCondition(subjectIsNull || comparands.Subject.GetType().IsSameOrInherits(typeof(TSubject)))
.FailWith("Expected " + context.CurrentNode.Description + " from subject to be a {0}{reason}, but found a {1}.",
.FailWith("Expected {0} from subject to be a {1}{reason}, but found a {2}.",
context.CurrentNode.Description.AsNonFormattable(),
typeof(TSubject), comparands.Subject?.GetType());

bool expectationIsNull = comparands.Expectation is null;
Expand All @@ -80,7 +81,8 @@ private bool ExecuteAssertion(Comparands comparands, IEquivalencyValidationConte
AssertionScope.Current
.ForCondition(expectationIsNull || comparands.Expectation.GetType().IsSameOrInherits(typeof(TSubject)))
.FailWith(
"Expected " + context.CurrentNode.Description + " from expectation to be a {0}{reason}, but found a {1}.",
"Expected {0} from expectation to be a {1}{reason}, but found a {2}.",
context.CurrentNode.Description.AsNonFormattable(),
typeof(TSubject), comparands.Expectation?.GetType());

if (subjectIsValidType && expectationIsValidType)
Expand Down
11 changes: 7 additions & 4 deletions 11 Src/FluentAssertions/Equivalency/Steps/EnumEqualityStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationCon
string expectationName = GetDisplayNameForEnumComparison(comparands.Expectation, expectationsUnderlyingValue);

return new FailReason(
$"Expected {{context:enum}} to be equivalent to {expectationName}{{reason}}, but found {{0}}.",
"Expected {context:enum} to be equivalent to {0}{reason}, but found {1}.",
expectationName.AsNonFormattable(),
comparands.Subject);
});

Expand Down Expand Up @@ -65,7 +66,8 @@ private static void HandleByValue(Comparands comparands, Reason reason)
string expectationName = GetDisplayNameForEnumComparison(comparands.Expectation, expectationsUnderlyingValue);

return new FailReason(
$"Expected {{context:enum}} to equal {expectationName} by value{{reason}}, but found {subjectsName}.");
"Expected {context:enum} to equal {0} by value{reason}, but found {1}.",
expectationName.AsNonFormattable(), subjectsName.AsNonFormattable());
});
}

Expand All @@ -86,7 +88,8 @@ private static void HandleByName(Comparands comparands, Reason reason)
string expectationName = GetDisplayNameForEnumComparison(comparands.Expectation, expectationsUnderlyingValue);

return new FailReason(
$"Expected {{context:enum}} to equal {expectationName} by name{{reason}}, but found {subjectsName}.");
"Expected {context:enum} to equal {0} by name{reason}, but found {1}.",
expectationName.AsNonFormattable(), subjectsName.AsNonFormattable());
});
}

Expand All @@ -100,7 +103,7 @@ private static string GetDisplayNameForEnumComparison(object o, decimal? v)
string typePart = o.GetType().Name;
string namePart = o.ToString().Replace(", ", "|", StringComparison.Ordinal);
string valuePart = v.Value.ToString(CultureInfo.InvariantCulture);
return $"{typePart}.{namePart} {{{{value: {valuePart}}}}}";
return $"{typePart}.{namePart} {{value: {valuePart}}}";
}

private static decimal? ExtractDecimal(object o)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ private static bool ValidateAgainstNulls(Comparands comparands, INode currentNod
if (onlyOneNull)
{
AssertionScope.Current.FailWith(
$"Expected {currentNode.Description} to be {{0}}{{reason}}, but found {{1}}.", expected, subject);
"Expected {0} to be {1}{reason}, but found {2}.", currentNode.Description.AsNonFormattable(), expected, subject);

return false;
}
Expand All @@ -61,7 +61,7 @@ private static bool ValidateSubjectIsString(Comparands comparands, INode current

return
AssertionScope.Current
.FailWith($"Expected {currentNode} to be {{0}}, but found {{1}}.",
.FailWith("Expected {0} to be {1}, but found {2}.", currentNode.AsNonFormattable(),
comparands.RuntimeType, comparands.Subject.GetType());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace FluentAssertions.Equivalency.Tracing;

internal sealed class InternalTraceWriter : StringBuilderTraceWriter;
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ private void WriteLine(string trace)
{
foreach (string traceLine in trace.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries))
{
builder.Append(new string(' ', depth * 2)).AppendLine(traceLine);
builder.Append(' ', depth * 2).AppendLine(traceLine);
}
}

Expand Down
24 changes: 24 additions & 0 deletions 24 Src/FluentAssertions/Execution/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace FluentAssertions.Execution;

internal static class StringExtensions
{
/// <summary>
/// Can be used
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static WithoutFormattingWrapper AsNonFormattable(this string value)
{
return new WithoutFormattingWrapper(value);
}

/// <summary>
/// Can be used
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static WithoutFormattingWrapper AsNonFormattable(this object value)
{
return new WithoutFormattingWrapper(value?.ToString());
}
}
11 changes: 11 additions & 0 deletions 11 Src/FluentAssertions/Execution/WithoutFormattingWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using FluentAssertions.Formatting;

namespace FluentAssertions.Execution;

/// <summary>
/// Wrapper to tell the <see cref="Formatter"/> not to apply any value formatters on this string.
/// </summary>
internal class WithoutFormattingWrapper(string value)
{
public override string ToString() => value;
}
1 change: 1 addition & 0 deletions 1 Src/FluentAssertions/Formatting/Formatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static class Formatter

private static readonly List<IValueFormatter> DefaultFormatters = new()
{
new PassthroughValueFormatter(),
new XmlReaderValueFormatter(),
new XmlNodeFormatter(),
new AttributeBasedFormatter(),
Expand Down
17 changes: 17 additions & 0 deletions 17 Src/FluentAssertions/Formatting/PassthroughValueFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using FluentAssertions.Execution;

namespace FluentAssertions.Formatting;

/// <summary>
/// Ensures that any value wrapped in a <see cref="WithoutFormattingWrapper"/>
/// is passed through as-is.
/// </summary>
internal class PassthroughValueFormatter : IValueFormatter
{
public bool CanHandle(object value) => value is WithoutFormattingWrapper;

public void Format(object value, FormattedObjectGraph formattedGraph, FormattingContext context, FormatChild formatChild)
{
formattedGraph.AddFragment(((WithoutFormattingWrapper)value).ToString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ private static Expression ReduceConstantSubExpressions(Expression expression)
{
return new ConstantSubExpressionReductionVisitor().Visit(expression);
}
catch (InvalidOperationException)
catch (Exception e) when (e is InvalidOperationException or NotSupportedException)
{
// Fallback if we make an invalid rewrite of the expression.
return expression;
Expand Down
4 changes: 2 additions & 2 deletions 4 Src/FluentAssertions/Primitives/StringEqualityStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public void ValidateAgainstMismatch(IAssertionScope assertion, string subject, s

assertion.FailWith(
ExpectationDescription + "the same string{reason}, but they differ " + locationDescription + ":" +
Environment.NewLine
+ GetMismatchSegmentForLongStrings(subject, expected, indexOfMismatch) + ".");
Environment.NewLine + "{0}.",
GetMismatchSegmentForLongStrings(subject, expected, indexOfMismatch).AsNonFormattable());
}
else if (ValidateAgainstLengthDifferences(assertion, subject, expected))
{
Expand Down
5 changes: 1 addition & 4 deletions 5 Src/FluentAssertions/Specialized/ExceptionAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,7 @@ public static void Execute(IEnumerable<string> messages, string expectation, str

foreach (string failure in results.SelectClosestMatchFor())
{
string replacedCurlyBraces =
failure.EscapePlaceholders();

AssertionScope.Current.FailWith(replacedCurlyBraces);
AssertionScope.Current.FailWith("{0}", failure.AsNonFormattable());
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion 2 Tests/FSharp.Specs/FSharp.Specs.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>6.0</LangVersion>
<LangVersion>8.0</LangVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
26 changes: 26 additions & 0 deletions 26 Tests/FluentAssertions.Specs/ConfigurationSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Threading.Tasks;
using FluentAssertions.Common;
using Xunit;
using Xunit.Sdk;

namespace FluentAssertions.Specs;

Expand Down Expand Up @@ -29,6 +30,31 @@ public void When_concurrently_accessing_current_Configuration_no_exception_shoul
// Assert
act.Should().NotThrow();
}

[Fact]
public void Tracing_must_be_safe_when_executed_concurrently()
{
try
{
// Arrange
AssertionOptions.AssertEquivalencyUsing(e => e.WithTracing());

Parallel.For(1, 10_000, (_, _) =>
{
try
{
new { A = "a" }.Should().BeEquivalentTo(new { A = "b" });
}
catch (XunitException)
{
}
});
}
finally
{
AssertionOptions.AssertEquivalencyUsing(_ => new());
}
}
}

// Due to tests that call Configuration.Current
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -511,4 +511,37 @@ public void Message_should_contain_the_reason_as_defined_with_arguments()
act.Should().Throw<XunitException>()
.WithMessage("Expected because reasons");
}

[Theory]
[InlineData("{0}{0}", "\"foo\"\"foo\"")]
[InlineData("{{0}}{0}", "{0}\"foo\"")]
[InlineData("{0}{{0}}", "\"foo\"{0}")]
[InlineData("{{{0}}}{0}", "{\"foo\"}\"foo\"")]
[InlineData("{0}{{{0}}}", "\"foo\"{\"foo\"}")]
public void Can_handle_escaped_braces(string format, string expected)
{
Execute.Assertion
.Invoking(e => e.FailWith(format, "foo"))
.Should().Throw<XunitException>().WithMessage(expected);
}

[Theory]
[InlineData("{")]
[InlineData("}")]
[InlineData("{}")]
[InlineData("{0}")]
[InlineData("{{0}}")]
public void Can_handle_more_braces_in_dictionary_keys(string key)
{
// Arrange
var subject = new Dictionary<string, string> { [key] = "" };
var expectation = new Dictionary<string, string> { [key] = null };

// Act
var act = () => expectation.Should().BeEquivalentTo(subject);

// Assert
act.Should().Throw<XunitException>()
.WithMessage($"Expected expectation[{key}] to be \"\", but found <null>.*");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,36 @@ public void When_condition_contains_extension_method_then_extension_method_must_
result.Should().Be("a.TextIsNotBlank() AndAlso (a.Number >= 0) AndAlso (a.Number <= 1000)");
}

#pragma warning disable RCS1196 // Do not call Contains as extension method. This is to exercise first-class spans
[Fact]
public void When_condition_contains_linq_extension_method_then_extension_method_must_be_formatted()
{
// Arrange
var allowed = new[] { 1, 2, 3 };

// Act
string result = Format<int>(a => allowed.Contains(a));
string result = Format<int>(a => Enumerable.Contains(allowed, a));

// Assert
result.Should().Be("value(System.Int32[]).Contains(a)");
}

#if NET6_0_OR_GREATER
[Fact]
public void Methods_using_ReadOnlySpan_can_be_formatted()
{
// Arrange
int[] allowed = [1, 2, 3];

// Act
string result = Format<int>(a => MemoryExtensions.Contains(allowed, a));

// Assert
result.Should().Match("*.Contains(a)");
}
#endif
#pragma warning restore RCS1196

[Fact]
public void Formatting_a_lifted_binary_operator()
{
Expand Down
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.