diff --git a/Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs b/Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs index 5ebbe576b1..4a259de619 100644 --- a/Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs +++ b/Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs @@ -80,6 +80,20 @@ public bool IsCyclicReference(object expectation) public ITraceWriter TraceWriter { get; set; } + /// + /// This method ensures that tracing starts with a fresh state when invoked. + /// + 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}\"}}"); diff --git a/Src/FluentAssertions/Equivalency/EquivalencyValidator.cs b/Src/FluentAssertions/Equivalency/EquivalencyValidator.cs index 230952a4aa..eb9d77dd21 100644 --- a/Src/FluentAssertions/Equivalency/EquivalencyValidator.cs +++ b/Src/FluentAssertions/Equivalency/EquivalencyValidator.cs @@ -13,6 +13,8 @@ public class EquivalencyValidator : IEquivalencyValidator public void AssertEquality(Comparands comparands, EquivalencyValidationContext context) { + context.ResetTracing(); + using var scope = new AssertionScope(); scope.AssumeSingleCaller(); diff --git a/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs b/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs index 52c2cacff2..ce7806e085 100644 --- a/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs +++ b/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs @@ -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 { diff --git a/Src/FluentAssertions/Equivalency/SelfReferenceEquivalencyAssertionOptions.cs b/Src/FluentAssertions/Equivalency/SelfReferenceEquivalencyAssertionOptions.cs index 3246847d7b..d33e97d240 100644 --- a/Src/FluentAssertions/Equivalency/SelfReferenceEquivalencyAssertionOptions.cs +++ b/Src/FluentAssertions/Equivalency/SelfReferenceEquivalencyAssertionOptions.cs @@ -643,7 +643,7 @@ public TSelf ComparingByValue(Type type) /// public TSelf WithTracing(ITraceWriter writer = null) { - TraceWriter = writer ?? new StringBuilderTraceWriter(); + TraceWriter = writer ?? new InternalTraceWriter(); return (TSelf)this; } diff --git a/Src/FluentAssertions/Equivalency/Steps/AssertionResultSet.cs b/Src/FluentAssertions/Equivalency/Steps/AssertionResultSet.cs index f7bae2311d..273da55f43 100644 --- a/Src/FluentAssertions/Equivalency/Steps/AssertionResultSet.cs +++ b/Src/FluentAssertions/Equivalency/Steps/AssertionResultSet.cs @@ -37,6 +37,10 @@ public string[] SelectClosestMatchFor(object key = null) } KeyValuePair[] bestResultSets = GetBestResultSets(); + if (bestResultSets.Length == 0) + { + return []; + } KeyValuePair bestMatch = Array.Find(bestResultSets, r => r.Key.Equals(key)); @@ -50,6 +54,11 @@ public string[] SelectClosestMatchFor(object key = null) private KeyValuePair[] GetBestResultSets() { + if (set.Values.Count == 0) + { + return []; + } + int fewestFailures = set.Values.Min(r => r.Length); return set.Where(r => r.Value.Length == fewestFailures).ToArray(); } diff --git a/Src/FluentAssertions/Equivalency/Steps/AssertionRuleEquivalencyStep.cs b/Src/FluentAssertions/Equivalency/Steps/AssertionRuleEquivalencyStep.cs index 4c3609bccd..9cdde8f9e1 100644 --- a/Src/FluentAssertions/Equivalency/Steps/AssertionRuleEquivalencyStep.cs +++ b/Src/FluentAssertions/Equivalency/Steps/AssertionRuleEquivalencyStep.cs @@ -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; @@ -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) diff --git a/Src/FluentAssertions/Equivalency/Steps/EnumEqualityStep.cs b/Src/FluentAssertions/Equivalency/Steps/EnumEqualityStep.cs index 274f6a7c3e..326176fc07 100644 --- a/Src/FluentAssertions/Equivalency/Steps/EnumEqualityStep.cs +++ b/Src/FluentAssertions/Equivalency/Steps/EnumEqualityStep.cs @@ -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); }); @@ -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()); }); } @@ -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()); }); } @@ -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) diff --git a/Src/FluentAssertions/Equivalency/Steps/StringEqualityEquivalencyStep.cs b/Src/FluentAssertions/Equivalency/Steps/StringEqualityEquivalencyStep.cs index ce7f0f5ef9..6f9d58ba81 100644 --- a/Src/FluentAssertions/Equivalency/Steps/StringEqualityEquivalencyStep.cs +++ b/Src/FluentAssertions/Equivalency/Steps/StringEqualityEquivalencyStep.cs @@ -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; } @@ -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()); } } diff --git a/Src/FluentAssertions/Equivalency/Tracing/InternalTraceWriter.cs b/Src/FluentAssertions/Equivalency/Tracing/InternalTraceWriter.cs new file mode 100644 index 0000000000..6c3b2d8b51 --- /dev/null +++ b/Src/FluentAssertions/Equivalency/Tracing/InternalTraceWriter.cs @@ -0,0 +1,3 @@ +namespace FluentAssertions.Equivalency.Tracing; + +internal sealed class InternalTraceWriter : StringBuilderTraceWriter; diff --git a/Src/FluentAssertions/Equivalency/Tracing/StringBuilderTraceWriter.cs b/Src/FluentAssertions/Equivalency/Tracing/StringBuilderTraceWriter.cs index cb1047defc..9253729a08 100644 --- a/Src/FluentAssertions/Equivalency/Tracing/StringBuilderTraceWriter.cs +++ b/Src/FluentAssertions/Equivalency/Tracing/StringBuilderTraceWriter.cs @@ -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); } } diff --git a/Src/FluentAssertions/Execution/AssertionScope.cs b/Src/FluentAssertions/Execution/AssertionScope.cs index 71a9a32f41..2348564751 100644 --- a/Src/FluentAssertions/Execution/AssertionScope.cs +++ b/Src/FluentAssertions/Execution/AssertionScope.cs @@ -210,7 +210,14 @@ public AssertionScope WithExpectation(string message, params object[] args) string actualReason = localReason?.Invoke() ?? string.Empty; string identifier = GetIdentifier(); - return messageBuilder.Build(message, args, actualReason, contextData, identifier, fallbackIdentifier); + // As the message builder will aggressively remove trailing whitespace, we need to add it back if the message ends with a space + string padding = string.Empty; + if (message.EndsWith(" ", StringComparison.Ordinal)) + { + padding = " "; + } + + return messageBuilder.Build(message, args, actualReason, contextData, identifier, fallbackIdentifier).TrimEnd() + padding; }; return this; diff --git a/Src/FluentAssertions/Execution/MessageBuilder.cs b/Src/FluentAssertions/Execution/MessageBuilder.cs index 27f1b82e2a..f7ecd891c5 100644 --- a/Src/FluentAssertions/Execution/MessageBuilder.cs +++ b/Src/FluentAssertions/Execution/MessageBuilder.cs @@ -41,6 +41,19 @@ public string Build(string message, object[] messageArgs, string reason, Context message = FormatArgumentPlaceholders(message, messageArgs); + message = TrimLineEndings(message); + + return message; + } + + private static string TrimLineEndings(string message) + { + string[] lines = message.Split([Environment.NewLine], StringSplitOptions.None); + if (lines.Length > 1) + { + return string.Join(Environment.NewLine, lines.Select(line => line.TrimEnd())); + } + return message; } diff --git a/Src/FluentAssertions/Execution/StringExtensions.cs b/Src/FluentAssertions/Execution/StringExtensions.cs new file mode 100644 index 0000000000..ced4cf28d8 --- /dev/null +++ b/Src/FluentAssertions/Execution/StringExtensions.cs @@ -0,0 +1,24 @@ +namespace FluentAssertions.Execution; + +internal static class StringExtensions +{ + /// + /// Can be used + /// + /// + /// + public static WithoutFormattingWrapper AsNonFormattable(this string value) + { + return new WithoutFormattingWrapper(value); + } + + /// + /// Can be used + /// + /// + /// + public static WithoutFormattingWrapper AsNonFormattable(this object value) + { + return new WithoutFormattingWrapper(value?.ToString()); + } +} diff --git a/Src/FluentAssertions/Execution/WithoutFormattingWrapper.cs b/Src/FluentAssertions/Execution/WithoutFormattingWrapper.cs new file mode 100644 index 0000000000..3c482a645f --- /dev/null +++ b/Src/FluentAssertions/Execution/WithoutFormattingWrapper.cs @@ -0,0 +1,11 @@ +using FluentAssertions.Formatting; + +namespace FluentAssertions.Execution; + +/// +/// Wrapper to tell the not to apply any value formatters on this string. +/// +internal class WithoutFormattingWrapper(string value) +{ + public override string ToString() => value; +} diff --git a/Src/FluentAssertions/Formatting/FormattedObjectGraph.cs b/Src/FluentAssertions/Formatting/FormattedObjectGraph.cs index b668e290b8..1150616bb0 100644 --- a/Src/FluentAssertions/Formatting/FormattedObjectGraph.cs +++ b/Src/FluentAssertions/Formatting/FormattedObjectGraph.cs @@ -121,7 +121,7 @@ private void AppendSafely(string line) throw new MaxLinesExceededException(); } - lines.Add(line); + lines.Add(line.TrimEnd()); } /// @@ -217,8 +217,10 @@ public void InsertLineOrFragment(string fragment) } else { - parentGraph.lines[startingLineCount] = parentGraph.lines[startingLineCount] - .Insert(startingLineBuilderIndex, InsertNewLineIntoFragment(fragment)); + string parentGraphLine = parentGraph.lines[startingLineCount]; + + parentGraph.lines[startingLineCount] = parentGraphLine + .Insert(Math.Min(startingLineBuilderIndex, parentGraphLine.Length), InsertNewLineIntoFragment(fragment)); } } @@ -226,10 +228,10 @@ private string InsertNewLineIntoFragment(string fragment) { if (StartingLineHasBeenAddedTo()) { - return fragment + Environment.NewLine + MakeWhitespace(parentGraph.indentation + 1); + return fragment.TrimEnd() + Environment.NewLine + MakeWhitespace(parentGraph.indentation + 1); } - return fragment; + return fragment.TrimEnd(); } private bool StartingLineHasBeenAddedTo() => parentGraph.lines[startingLineCount].Length > startingLineBuilderIndex; diff --git a/Src/FluentAssertions/Formatting/Formatter.cs b/Src/FluentAssertions/Formatting/Formatter.cs index 206d2b16d3..210880dabc 100644 --- a/Src/FluentAssertions/Formatting/Formatter.cs +++ b/Src/FluentAssertions/Formatting/Formatter.cs @@ -20,6 +20,7 @@ public static class Formatter private static readonly List DefaultFormatters = new() { + new PassthroughValueFormatter(), new XmlReaderValueFormatter(), new XmlNodeFormatter(), new AttributeBasedFormatter(), diff --git a/Src/FluentAssertions/Formatting/PassthroughValueFormatter.cs b/Src/FluentAssertions/Formatting/PassthroughValueFormatter.cs new file mode 100644 index 0000000000..7fdaf36e58 --- /dev/null +++ b/Src/FluentAssertions/Formatting/PassthroughValueFormatter.cs @@ -0,0 +1,17 @@ +using FluentAssertions.Execution; + +namespace FluentAssertions.Formatting; + +/// +/// Ensures that any value wrapped in a +/// is passed through as-is. +/// +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()); + } +} diff --git a/Src/FluentAssertions/Formatting/PredicateLambdaExpressionValueFormatter.cs b/Src/FluentAssertions/Formatting/PredicateLambdaExpressionValueFormatter.cs index 2528b91b33..23858ec787 100644 --- a/Src/FluentAssertions/Formatting/PredicateLambdaExpressionValueFormatter.cs +++ b/Src/FluentAssertions/Formatting/PredicateLambdaExpressionValueFormatter.cs @@ -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; diff --git a/Src/FluentAssertions/Primitives/StringEqualityStrategy.cs b/Src/FluentAssertions/Primitives/StringEqualityStrategy.cs index 211c8595cb..a864ff1c24 100644 --- a/Src/FluentAssertions/Primitives/StringEqualityStrategy.cs +++ b/Src/FluentAssertions/Primitives/StringEqualityStrategy.cs @@ -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)) { diff --git a/Src/FluentAssertions/Specialized/ExceptionAssertions.cs b/Src/FluentAssertions/Specialized/ExceptionAssertions.cs index 90c0957002..20340ad826 100644 --- a/Src/FluentAssertions/Specialized/ExceptionAssertions.cs +++ b/Src/FluentAssertions/Specialized/ExceptionAssertions.cs @@ -286,10 +286,7 @@ public static void Execute(IEnumerable messages, string expectation, str foreach (string failure in results.SelectClosestMatchFor()) { - string replacedCurlyBraces = - failure.EscapePlaceholders(); - - AssertionScope.Current.FailWith(replacedCurlyBraces); + AssertionScope.Current.FailWith("{0}", failure.AsNonFormattable()); } } } diff --git a/Tests/FSharp.Specs/FSharp.Specs.fsproj b/Tests/FSharp.Specs/FSharp.Specs.fsproj index d1bb7629f6..f03fd81b86 100644 --- a/Tests/FSharp.Specs/FSharp.Specs.fsproj +++ b/Tests/FSharp.Specs/FSharp.Specs.fsproj @@ -2,7 +2,7 @@ net6.0 - 6.0 + 8.0 diff --git a/Tests/FluentAssertions.Specs/Collections/CollectionAssertionSpecs.cs b/Tests/FluentAssertions.Specs/Collections/CollectionAssertionSpecs.cs index 49c2f4b692..cb40d2d8b1 100644 --- a/Tests/FluentAssertions.Specs/Collections/CollectionAssertionSpecs.cs +++ b/Tests/FluentAssertions.Specs/Collections/CollectionAssertionSpecs.cs @@ -71,7 +71,7 @@ public void When_the_collection_is_not_ordered_according_to_the_subsequent_ascen // Assert action.Should().Throw() - .WithMessage("Expected collection * to be ordered \"by Item2\"*"); + .WithMessage("Expected collection*to be ordered \"by Item2\"*"); } [Fact] @@ -163,7 +163,7 @@ public void When_the_collection_is_not_ordered_according_to_the_subsequent_desce // Assert action.Should().Throw() - .WithMessage("Expected collection * to be ordered \"by Item2\"*"); + .WithMessage("Expected collection*to be ordered \"by Item2\"*"); } [Fact] diff --git a/Tests/FluentAssertions.Specs/Collections/Data/DataRowCollectionAssertionSpecs.cs b/Tests/FluentAssertions.Specs/Collections/Data/DataRowCollectionAssertionSpecs.cs index 6780b8a2c5..ecca8b73e7 100644 --- a/Tests/FluentAssertions.Specs/Collections/Data/DataRowCollectionAssertionSpecs.cs +++ b/Tests/FluentAssertions.Specs/Collections/Data/DataRowCollectionAssertionSpecs.cs @@ -367,7 +367,7 @@ public void When_collection_is_compared_with_null_it_should_fail() // Assert action.Should().Throw().WithMessage( - "Expected nullDataRows to not have the same count as * because we care, but found ."); + "Expected nullDataRows to not have the same count as*because we care, but found ."); } [Fact] @@ -516,7 +516,7 @@ public void When_collection_does_not_contain_equivalent_row_it_should_fail() // Assert action.Should().Throw().WithMessage( - "Expected dataTable.Rows * to contain equivalent of System.Data.DataRow* because we care.*"); + "Expected dataTable.Rows*to contain equivalent of System.Data.DataRow*because we care.*"); } } @@ -580,7 +580,7 @@ public void When_collection_contains_equivalent_row_it_should_fail() // Assert action.Should().Throw() - .WithMessage("Expected dataTable.Rows * not to contain equivalent of System.Data.DataRow* because we " + + .WithMessage("Expected dataTable.Rows*not to contain equivalent of System.Data.DataRow*because we " + "care, but found one at index 1.*"); } diff --git a/Tests/FluentAssertions.Specs/ConfigurationSpecs.cs b/Tests/FluentAssertions.Specs/ConfigurationSpecs.cs index 36cbc680ef..0fc06da88a 100644 --- a/Tests/FluentAssertions.Specs/ConfigurationSpecs.cs +++ b/Tests/FluentAssertions.Specs/ConfigurationSpecs.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions.Common; using Xunit; +using Xunit.Sdk; namespace FluentAssertions.Specs; @@ -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 diff --git a/Tests/FluentAssertions.Specs/Execution/AssertionScope.MessageFormatingSpecs.cs b/Tests/FluentAssertions.Specs/Execution/AssertionScope.MessageFormatingSpecs.cs index 53119f1f6e..e399d30000 100644 --- a/Tests/FluentAssertions.Specs/Execution/AssertionScope.MessageFormatingSpecs.cs +++ b/Tests/FluentAssertions.Specs/Execution/AssertionScope.MessageFormatingSpecs.cs @@ -511,4 +511,37 @@ public void Message_should_contain_the_reason_as_defined_with_arguments() act.Should().Throw() .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().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 { [key] = "" }; + var expectation = new Dictionary { [key] = null }; + + // Act + var act = () => expectation.Should().BeEquivalentTo(subject); + + // Assert + act.Should().Throw() + .WithMessage($"Expected expectation[{key}] to be \"\", but found .*"); + } } diff --git a/Tests/FluentAssertions.Specs/Formatting/FormatterSpecs.cs b/Tests/FluentAssertions.Specs/Formatting/FormatterSpecs.cs index c50ea1a523..f814630108 100644 --- a/Tests/FluentAssertions.Specs/Formatting/FormatterSpecs.cs +++ b/Tests/FluentAssertions.Specs/Formatting/FormatterSpecs.cs @@ -211,32 +211,32 @@ public void When_the_object_is_a_generic_type_without_custom_string_representati act.Should().Throw() .WithMessage( """ - Expected stuff to be equal to + Expected stuff to be equal to { FluentAssertions.Specs.Formatting.FormatterSpecs+Stuff`1[[System.Int32*]] { - Children = {1, 2, 3, 4}, - Description = "Stuff_1", + Children = {1, 2, 3, 4}, + Description = "Stuff_1", StuffId = 1 - }, + }, FluentAssertions.Specs.Formatting.FormatterSpecs+Stuff`1[[System.Int32*]] { - Children = {1, 2, 3, 4}, - Description = "WRONG_DESCRIPTION", + Children = {1, 2, 3, 4}, + Description = "WRONG_DESCRIPTION", StuffId = 2 } - }, but + }, but { FluentAssertions.Specs.Formatting.FormatterSpecs+Stuff`1[[System.Int32*]] { - Children = {1, 2, 3, 4}, - Description = "Stuff_1", + Children = {1, 2, 3, 4}, + Description = "Stuff_1", StuffId = 1 - }, + }, FluentAssertions.Specs.Formatting.FormatterSpecs+Stuff`1[[System.Int32*]] { - Children = {1, 2, 3, 4}, - Description = "Stuff_2", + Children = {1, 2, 3, 4}, + Description = "Stuff_2", StuffId = 2 } } differs at index 1. @@ -258,9 +258,9 @@ public void When_the_object_is_a_user_defined_type_it_should_show_the_name_on_th """ Expected stuff to be , but found FluentAssertions.Specs.Formatting.FormatterSpecs+StuffRecord { - RecordChildren = {10, 20, 30, 40}, - RecordDescription = "description", - RecordId = 42, + RecordChildren = {10, 20, 30, 40}, + RecordDescription = "description", + RecordId = 42, SingleChild = FluentAssertions.Specs.Formatting.FormatterSpecs+ChildRecord { ChildRecordId = 24 @@ -293,18 +293,18 @@ public void When_the_object_is_an_anonymous_type_it_should_show_the_properties_r act.Should().Throw() .Which.Message.Should().Be( """ - Expected stuff to be + Expected stuff to be { - Children = {10, 20, 30, 40}, - SingleChild = + Children = {10, 20, 30, 40}, + SingleChild = { ChildId = 4 } - }, but found + }, but found { - Children = {1, 2, 3, 4}, - Description = "absent", - SingleChild = + Children = {1, 2, 3, 4}, + Description = "absent", + SingleChild = { ChildId = 8 } @@ -347,11 +347,11 @@ public void When_the_object_is_a_list_of_anonymous_type_it_should_show_the_prope act.Should().Throw() .Which.Message.Should().StartWith( """ - Expected stuff to be a collection with 1 item(s), but + Expected stuff to be a collection with 1 item(s), but { { Description = "absent" - }, + }, { Description = "absent" } @@ -360,11 +360,11 @@ contains 1 item(s) more than { { - ComplexChildren = + ComplexChildren = { { Property = "hello" - }, + }, { Property = "goodbye" } @@ -405,15 +405,15 @@ public void When_the_object_is_a_tuple_it_should_show_the_properties_recursively act.Should().Throw() .Which.Message.Should().Be( """ - Expected stuff to be equal to + Expected stuff to be equal to { - Item1 = 2, - Item2 = "WRONG_DESCRIPTION", + Item1 = 2, + Item2 = "WRONG_DESCRIPTION", Item3 = {4, 5, 6, 7} - }, but found + }, but found { - Item1 = 1, - Item2 = "description", + Item1 = 1, + Item2 = "description", Item3 = {1, 2, 3, 4} }. """); @@ -441,14 +441,14 @@ public void When_the_object_is_a_record_it_should_show_the_properties_recursivel act.Should().Throw() .Which.Message.Should().Be( """ - Expected stuff to be + Expected stuff to be { RecordDescription = "WRONG_DESCRIPTION" }, but found FluentAssertions.Specs.Formatting.FormatterSpecs+StuffRecord { - RecordChildren = {4, 5, 6, 7}, - RecordDescription = "descriptive", - RecordId = 9, + RecordChildren = {4, 5, 6, 7}, + RecordDescription = "descriptive", + RecordId = 9, SingleChild = FluentAssertions.Specs.Formatting.FormatterSpecs+ChildRecord { ChildRecordId = 80 @@ -914,7 +914,7 @@ public void string result = Formatter.ToString(subject, new FormattingOptions { UseLineBreaks = true }); // Assert - result.Should().Contain($"FluentAssertions.Specs.Formatting.FormatterSpecs+A, {Environment.NewLine}"); + result.Should().Contain($"FluentAssertions.Specs.Formatting.FormatterSpecs+A,{Environment.NewLine}"); result.Should().Contain($"FluentAssertions.Specs.Formatting.FormatterSpecs+B{Environment.NewLine}"); } @@ -1195,11 +1195,36 @@ public void When_defining_a_custom_enumerable_value_formatter_it_should_respect_ str.Should().Match(Environment.NewLine + "{*FluentAssertions*FormatterSpecs+CustomClass" + Environment.NewLine + " {" + Environment.NewLine + - " IntProperty = 1, " + Environment.NewLine + + " IntProperty = 1," + Environment.NewLine + " StringProperty = " + Environment.NewLine + " },*…1 more…*}*"); } + [Fact] + public void Can_render_an_empty_nested_collection_without_crashing() + { + // Arrange + IReadOnlyCollection> x = + [ + [], + [new SomeObject(42)] + ]; + + // Act + string result = Formatter.ToString(x, new FormattingOptions + { + MaxDepth = 5, + MaxLines = 100, + UseLineBreaks = false + }); + + // Assert + result.Should().NotBeEmpty(); + } + + // ReSharper disable once NotAccessedPositionalProperty.Local + private record SomeObject(int Bar); + private class SingleItemValueFormatter : EnumerableValueFormatter { protected override int MaxItems => 1; diff --git a/Tests/FluentAssertions.Specs/Formatting/PredicateLambdaExpressionValueFormatterSpecs.cs b/Tests/FluentAssertions.Specs/Formatting/PredicateLambdaExpressionValueFormatterSpecs.cs index b70ce9c4e5..fcf58bfc23 100644 --- a/Tests/FluentAssertions.Specs/Formatting/PredicateLambdaExpressionValueFormatterSpecs.cs +++ b/Tests/FluentAssertions.Specs/Formatting/PredicateLambdaExpressionValueFormatterSpecs.cs @@ -88,6 +88,7 @@ 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() { @@ -95,12 +96,28 @@ public void When_condition_contains_linq_extension_method_then_extension_method_ var allowed = new[] { 1, 2, 3 }; // Act - string result = Format(a => allowed.Contains(a)); + string result = Format(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(a => MemoryExtensions.Contains(allowed, a)); + + // Assert + result.Should().Match("*.Contains(a)"); + } +#endif +#pragma warning restore RCS1196 + [Fact] public void Formatting_a_lifted_binary_operator() { diff --git a/Tests/FluentAssertions.Specs/Primitives/ReferenceTypeAssertionsSpecs.cs b/Tests/FluentAssertions.Specs/Primitives/ReferenceTypeAssertionsSpecs.cs index 1f2369981a..78f891a335 100644 --- a/Tests/FluentAssertions.Specs/Primitives/ReferenceTypeAssertionsSpecs.cs +++ b/Tests/FluentAssertions.Specs/Primitives/ReferenceTypeAssertionsSpecs.cs @@ -43,10 +43,10 @@ public void When_two_different_objects_are_expected_to_be_the_same_it_should_fai .Should().Throw() .WithMessage( """ - Expected subject to refer to + Expected subject to refer to { UserName = "JohnDoe" - } because they are the same, but found + } because they are the same, but found { Name = "John Doe" }. @@ -61,7 +61,7 @@ public void When_a_derived_class_has_longer_formatting_than_the_base_class() act.Should().Throw() .WithMessage( """ - Expected subject to be empty, but found at least one item + Expected subject to be empty, but found at least one item { FluentAssertions.Specs.Primitives.Complex { diff --git a/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.Be.cs b/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.Be.cs index 6b158cc2b3..4e11444004 100644 --- a/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.Be.cs +++ b/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.Be.cs @@ -351,6 +351,38 @@ public void Mismatches_in_multiline_text_includes_the_line_number() ↑ (expected). """); } + + [Fact] + public void When_string_contains_opening_brace_it_should_not_throw_format_exception() + { + // Arrange - create a string longer than the default truncation length (80 chars) + // with an opening brace to trigger the full details display which had the bug + const int lengthToTriggerFullDetails = 80; + var actual = "{" + new string('x', lengthToTriggerFullDetails); + var expected = ""; + + // Act + Action act = () => actual.Should().Be(expected); + + // Assert - should throw XunitException, not FormatException + act.Should().Throw() + .WithMessage("Expected*to be the same string*"); + } + + [Fact] + public void When_long_string_contains_braces_it_should_display_properly() + { + // Arrange + const string subject = "public class Test { public void Method() { var x = 1; } }"; + const string expected = "public class Test { public void Method() { var x = 2; } }"; + + // Act + Action act = () => subject.Should().Be(expected); + + // Assert + act.Should().Throw() + .WithMessage("Expected*to be the same string*"); + } } public class NotBe diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 2d3f7526cb..6e11e199b8 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -7,6 +7,20 @@ sidebar: nav: "sidebar" --- +## 7.2.2 + +### Fixes + +* Fix a formatting exception when {} is used as a dictionary key - [#3029](https://github.com/fluentassertions/fluentassertions/pull/3029) +* Ensured `WithTracing` is safe when used with `BeEquivalentTo` globally - [#3067](https://github.com/fluentassertions/fluentassertions/pull/3067) +* Fixed a formatting exception when comparing strings containing braces - [#3151](https://github.com/fluentassertions/fluentassertions/pull/3151) + +## 7.2.1 + +### Fixes + +* Fixed a crash while formatting a collection with nested empty collections - [#3150](https://github.com/fluentassertions/fluentassertions/pull/3150) + ## 7.2.0 ### Fixes