diff --git a/src/TestFramework/TestFramework/Assertions/Assert.cs b/src/TestFramework/TestFramework/Assertions/Assert.cs index 0703444ae6..56b13403ec 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.cs @@ -107,6 +107,75 @@ static bool ShouldLaunchDebugger() private static AssertFailedException CreateAssertFailedException(string assertionName, string? message) => new(FormatAssertionFailed(assertionName, message)); + private static AssertFailedException CreateAssertFailedException(StructuredAssertionMessage structuredMessage) + { + AssertFailedException exception = new(structuredMessage.Format()) + { + ExpectedText = structuredMessage.ExpectedText, + ActualText = structuredMessage.ActualText, + }; + + if (structuredMessage.ExpectedText is not null) + { + exception.Data["assert.expected"] = structuredMessage.ExpectedText; + } + + if (structuredMessage.ActualText is not null) + { + exception.Data["assert.actual"] = structuredMessage.ActualText; + } + + return exception; + } + + /// + /// Reports an assertion failure using a structured message. Within an , + /// the failure is collected and execution continues. Outside a scope, the failure is thrown immediately. + /// + /// + /// The structured assertion failure message. + /// +#pragma warning disable CS8763 // A method marked [DoesNotReturn] should not return - Deliberately keeping [DoesNotReturn] annotation while using soft assertions. Within an AssertScope, the postcondition is not enforced (same as all other assertion postconditions in scoped mode). + [DoesNotReturn] + [StackTraceHidden] + internal static void ReportAssertFailed(StructuredAssertionMessage structuredMessage) + { + LaunchDebuggerIfNeeded(); + AssertFailedException assertionFailedException = CreateAssertFailedException(structuredMessage); + if (AssertScope.Current is { } scope) + { + try + { + throw assertionFailedException; + } + catch (AssertFailedException ex) + { + assertionFailedException = ex; + } + + scope.AddError(assertionFailedException); + return; + } + + throw assertionFailedException; + } +#pragma warning restore CS8763 // A method marked [DoesNotReturn] should not return + + /// + /// Reports an assertion failure using a structured message and always throws, + /// even within an . + /// + /// + /// The structured assertion failure message. + /// + [DoesNotReturn] + [StackTraceHidden] + internal static void ThrowAssertFailed(StructuredAssertionMessage structuredMessage) + { + LaunchDebuggerIfNeeded(); + throw CreateAssertFailedException(structuredMessage); + } + private static string FormatAssertionFailed(string assertionName, string? message) { string failedMessage = string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AssertionFailed, assertionName); diff --git a/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs b/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs new file mode 100644 index 0000000000..e4a40a153c --- /dev/null +++ b/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Renders values for display in structured assertion messages following the RFC 012 value rendering rules. +/// +internal static class AssertionValueRenderer +{ + /// + /// Renders a value as a string suitable for display in the evidence block. + /// + internal static string RenderValue(object? value) + => value switch + { + null => "null", + string s => RenderString(s), + bool b => b ? "true" : "false", + char c => RenderChar(c), + IEnumerable enumerable => RenderCollection(enumerable), + _ => value.ToString() ?? value.GetType().FullName ?? value.GetType().Name, + }; + + /// + /// Renders a string value with double quotes and escape sequences for control characters. + /// + private static string RenderString(string value) + { + StringBuilder sb = new(value.Length + 2); + sb.Append('"'); + foreach (char c in value) + { + switch (c) + { + case '"': + sb.Append("\\\""); + break; + case '\\': + sb.Append("\\\\"); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + case '\0': + sb.Append("\\0"); + break; + default: + if (char.IsControl(c)) + { + sb.Append("\\u"); + sb.Append(((int)c).ToString("X4", CultureInfo.InvariantCulture)); + } + else + { + sb.Append(c); + } + + break; + } + } + + sb.Append('"'); + return sb.ToString(); + } + + /// + /// Renders a char value with single quotes and escape sequences. + /// + private static string RenderChar(char value) => + value switch + { + '\n' => "'\\n'", + '\r' => "'\\r'", + '\t' => "'\\t'", + '\0' => "'\\0'", + _ when char.IsControl(value) => $"'\\u{(int)value:X4}'", + _ => $"'{value}'", + }; + + /// + /// Renders a collection in JSON-style array notation. + /// + private static string RenderCollection(IEnumerable enumerable) + { + StringBuilder sb = new(); + sb.Append('['); + bool first = true; + foreach (object? item in enumerable) + { + if (!first) + { + sb.Append(", "); + } + + sb.Append(RenderValue(item)); + first = false; + } + + sb.Append(']'); + return sb.ToString(); + } +} diff --git a/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs b/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs new file mode 100644 index 0000000000..4bc26ac237 --- /dev/null +++ b/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Represents the evidence block of a structured assertion message, containing labeled value lines +/// such as expected/actual values and assertion-specific details. +/// +internal sealed class EvidenceBlock +{ + private readonly List _lines = []; + + internal static EvidenceBlock Create() => new(); + + internal IReadOnlyList Lines => _lines; + + internal EvidenceBlock AddLine(string label, string value) + { + _lines.Add(new EvidenceLine(label, value)); + return this; + } + + /// + /// Formats the evidence block as aligned label: value lines. + /// Labels are right-padded so all values start at the same column. + /// + internal string Format() + { + if (_lines.Count == 0) + { + return string.Empty; + } + + int maxLabelLength = 0; + foreach (EvidenceLine line in _lines) + { + if (line.Label.Length > maxLabelLength) + { + maxLabelLength = line.Label.Length; + } + } + + StringBuilder sb = new(); + for (int i = 0; i < _lines.Count; i++) + { + if (i > 0) + { + sb.Append(Environment.NewLine); + } + + EvidenceLine line = _lines[i]; + + // Pad label (which includes trailing colon) to align values, then append a space and value + sb.Append(line.Label.PadRight(maxLabelLength)); + sb.Append(' '); + sb.Append(line.Value); + } + + return sb.ToString(); + } +} diff --git a/src/TestFramework/TestFramework/Assertions/EvidenceLine.cs b/src/TestFramework/TestFramework/Assertions/EvidenceLine.cs new file mode 100644 index 0000000000..bb09f5f150 --- /dev/null +++ b/src/TestFramework/TestFramework/Assertions/EvidenceLine.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Represents a single labeled line in the evidence block of a structured assertion message. +/// +internal readonly record struct EvidenceLine(string Label, string Value); diff --git a/src/TestFramework/TestFramework/Assertions/StructuredAssertionMessage.cs b/src/TestFramework/TestFramework/Assertions/StructuredAssertionMessage.cs new file mode 100644 index 0000000000..01b55290cf --- /dev/null +++ b/src/TestFramework/TestFramework/Assertions/StructuredAssertionMessage.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Builds a structured assertion failure message following the format: +/// +/// Assertion failed. <summary> +/// <user message> +/// +/// <evidence block> +/// +/// <call-site expression> +/// +/// +internal sealed class StructuredAssertionMessage +{ + private const string AssertionPrefix = "Assertion failed."; + + private readonly string _summary; + private readonly List _additionalSummaryLines = []; + private readonly List _evidenceBlocks = []; + private string? _userMessage; + private string? _callSiteExpression; + + internal StructuredAssertionMessage(string summary) + { + _summary = summary; + } + + internal string? ExpectedText { get; private set; } + + internal string? ActualText { get; private set; } + + internal StructuredAssertionMessage WithAdditionalSummaryLine(string line) + { + _additionalSummaryLines.Add(line); + return this; + } + + internal StructuredAssertionMessage WithUserMessage(string? userMessage) + { + if (!string.IsNullOrWhiteSpace(userMessage)) + { + _userMessage = userMessage; + } + + return this; + } + + internal StructuredAssertionMessage WithEvidence(EvidenceBlock evidenceBlock) + { + _evidenceBlocks.Add(evidenceBlock); + return this; + } + + internal StructuredAssertionMessage WithExpectedAndActual(string? expectedText, string? actualText) + { + ExpectedText = expectedText; + ActualText = actualText; + return this; + } + + internal StructuredAssertionMessage WithCallSiteExpression(string? callSiteExpression) + { + if (!string.IsNullOrWhiteSpace(callSiteExpression)) + { + _callSiteExpression = callSiteExpression; + } + + return this; + } + + /// + /// Formats the structured message as a multi-line string following the RFC 012 layout. + /// + internal string Format() + { + StringBuilder sb = new(); + + // Line 1: Assertion prefix + summary + sb.Append(AssertionPrefix); + if (!string.IsNullOrEmpty(_summary)) + { + sb.Append(' '); + sb.Append(_summary); + } + + // Additional summary lines + foreach (string additionalLine in _additionalSummaryLines) + { + sb.Append(Environment.NewLine); + sb.Append(additionalLine); + } + + // User message (on its own line, no label) + if (_userMessage is not null) + { + sb.Append(Environment.NewLine); + sb.Append(_userMessage); + } + + // Evidence blocks (each separated by blank line) + foreach (EvidenceBlock evidence in _evidenceBlocks) + { + string formattedEvidence = evidence.Format(); + if (!string.IsNullOrEmpty(formattedEvidence)) + { + sb.Append(Environment.NewLine); + sb.Append(Environment.NewLine); + sb.Append(formattedEvidence); + } + } + + // Call-site expression (separated by blank line) + if (_callSiteExpression is not null) + { + sb.Append(Environment.NewLine); + sb.Append(Environment.NewLine); + sb.Append(_callSiteExpression); + } + + return sb.ToString(); + } + + /// + public override string ToString() => Format(); +} diff --git a/src/TestFramework/TestFramework/Exceptions/AssertFailedException.cs b/src/TestFramework/TestFramework/Exceptions/AssertFailedException.cs index 2bbd14c132..42a8b22397 100644 --- a/src/TestFramework/TestFramework/Exceptions/AssertFailedException.cs +++ b/src/TestFramework/TestFramework/Exceptions/AssertFailedException.cs @@ -39,6 +39,20 @@ public AssertFailedException() { } + /// + /// Gets the pre-formatted text representation of the expected value, as displayed + /// in the expected: line of the structured assertion message. Returns + /// when the assertion has no natural expected value (e.g. ). + /// + public string? ExpectedText { get; internal set; } + + /// + /// Gets the pre-formatted text representation of the actual value, as displayed + /// in the actual: line of the structured assertion message. Returns + /// when the assertion has no natural actual value. + /// + public string? ActualText { get; internal set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt b/src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt index ee81f202d3..cc143fac2e 100644 --- a/src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt @@ -1,2 +1,4 @@ #nullable enable [MSTESTEXP]static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.Scope() -> System.IDisposable! +Microsoft.VisualStudio.TestTools.UnitTesting.AssertFailedException.ActualText.get -> string? +Microsoft.VisualStudio.TestTools.UnitTesting.AssertFailedException.ExpectedText.get -> string? diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertFailedExceptionTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertFailedExceptionTests.cs new file mode 100644 index 0000000000..2b740c88fa --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertFailedExceptionTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests; + +public class AssertFailedExceptionTests : TestContainer +{ + public void ExpectedText_DefaultsToNull() + { + var exception = new AssertFailedException("test message"); + + exception.ExpectedText.Should().BeNull(); + } + + public void ActualText_DefaultsToNull() + { + var exception = new AssertFailedException("test message"); + + exception.ActualText.Should().BeNull(); + } + + public void ExpectedAndActualText_CanBeSet() + { + var exception = new AssertFailedException("test message") + { + ExpectedText = "42", + ActualText = "37", + }; + + exception.ExpectedText.Should().Be("42"); + exception.ActualText.Should().Be("37"); + } + + public void ThrowAssertFailed_WithStructuredMessage_PopulatesExpectedAndActualTextAndDataEntries() + { + EvidenceBlock evidence = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37"); + + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithEvidence(evidence); + message.WithExpectedAndActual("42", "37"); + + AssertFailedException? caught = null; + try + { + Assert.ThrowAssertFailed(message); + } + catch (AssertFailedException ex) + { + caught = ex; + } + + caught.Should().NotBeNull(); + caught!.ExpectedText.Should().Be("42"); + caught.ActualText.Should().Be("37"); + caught.Data["assert.expected"].Should().Be("42"); + caught.Data["assert.actual"].Should().Be("37"); + } + + public void ThrowAssertFailed_WithStructuredMessage_NullExpectedAndActual_DoesNotPopulateDataEntries() + { + StructuredAssertionMessage message = new("Condition failed."); + + AssertFailedException? caught = null; + try + { + Assert.ThrowAssertFailed(message); + } + catch (AssertFailedException ex) + { + caught = ex; + } + + caught.Should().NotBeNull(); + caught!.ExpectedText.Should().BeNull(); + caught.ActualText.Should().BeNull(); + caught.Data.Contains("assert.expected").Should().BeFalse(); + caught.Data.Contains("assert.actual").Should().BeFalse(); + } +} diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertionValueRendererTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertionValueRendererTests.cs new file mode 100644 index 0000000000..20cbe332aa --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertionValueRendererTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; + +using AwesomeAssertions; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests; + +public class AssertionValueRendererTests : TestContainer +{ + public void RenderValue_Null_ReturnsNull() => + AssertionValueRenderer.RenderValue(null).Should().Be("null"); + + public void RenderValue_EmptyString_ReturnsQuotedEmpty() => + AssertionValueRenderer.RenderValue(string.Empty).Should().Be("\"\""); + + public void RenderValue_SimpleString_ReturnsQuotedString() => + AssertionValueRenderer.RenderValue("hello world").Should().Be("\"hello world\""); + + public void RenderValue_StringWithEmbeddedQuotes_EscapesQuotes() => + AssertionValueRenderer.RenderValue("she said \"hello\"").Should().Be("\"she said \\\"hello\\\"\""); + + public void RenderValue_StringWithNewline_EscapesNewline() => + AssertionValueRenderer.RenderValue("line1\nline2").Should().Be("\"line1\\nline2\""); + + public void RenderValue_StringWithCarriageReturn_EscapesCR() => + AssertionValueRenderer.RenderValue("line1\rline2").Should().Be("\"line1\\rline2\""); + + public void RenderValue_StringWithTab_EscapesTab() => + AssertionValueRenderer.RenderValue("col1\tcol2").Should().Be("\"col1\\tcol2\""); + + public void RenderValue_StringWithNullChar_EscapesNull() => + AssertionValueRenderer.RenderValue("abc\0def").Should().Be("\"abc\\0def\""); + + public void RenderValue_StringWithBackslash_EscapesBackslash() => + AssertionValueRenderer.RenderValue("path\\to\\file").Should().Be("\"path\\\\to\\\\file\""); + + public void RenderValue_WhitespaceOnlyString_ReturnsQuotedWhitespace() => + AssertionValueRenderer.RenderValue(" ").Should().Be("\" \""); + + public void RenderValue_Integer_ReturnsUnquoted() => + AssertionValueRenderer.RenderValue(42).Should().Be("42"); + + public void RenderValue_NegativeInteger_ReturnsUnquoted() => + AssertionValueRenderer.RenderValue(-7).Should().Be("-7"); + + public void RenderValue_Double_ReturnsUnquoted() => + AssertionValueRenderer.RenderValue(3.14).Should().Be(3.14.ToString(CultureInfo.CurrentCulture)); + + public void RenderValue_BoolTrue_ReturnsLowercase() => + AssertionValueRenderer.RenderValue(true).Should().Be("true"); + + public void RenderValue_BoolFalse_ReturnsLowercase() => + AssertionValueRenderer.RenderValue(false).Should().Be("false"); + + public void RenderValue_ListOfInts_ReturnsJsonArray() + { + var list = new List { 1, 2, 3 }; + AssertionValueRenderer.RenderValue(list).Should().Be("[1, 2, 3]"); + } + + public void RenderValue_EmptyList_ReturnsEmptyBrackets() => + AssertionValueRenderer.RenderValue(new List()).Should().Be("[]"); + + public void RenderValue_ListOfStrings_ReturnsQuotedElements() + { + var list = new List { "apple", "cherry", "date" }; + AssertionValueRenderer.RenderValue(list).Should().Be("[\"apple\", \"cherry\", \"date\"]"); + } + + public void RenderValue_ListWithNull_RendersNullElement() + { + var list = new List { "apple", null, "date" }; + AssertionValueRenderer.RenderValue(list).Should().Be("[\"apple\", null, \"date\"]"); + } + + public void RenderValue_ObjectWithToString_ReturnsToString() => + AssertionValueRenderer.RenderValue(new ObjectWithCustomToString("my-object")).Should().Be("my-object"); + + public void RenderValue_Char_ReturnsSingleQuoted() => + AssertionValueRenderer.RenderValue('a').Should().Be("'a'"); + + public void RenderValue_CharNewline_ReturnsEscaped() => + AssertionValueRenderer.RenderValue('\n').Should().Be("'\\n'"); + + private sealed class ObjectWithCustomToString + { + private readonly string _value; + + public ObjectWithCustomToString(string value) + { + _value = value; + } + + public override string ToString() => _value; + } +} diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/EvidenceBlockTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/EvidenceBlockTests.cs new file mode 100644 index 0000000000..9bc0a523a1 --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/EvidenceBlockTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests; + +public class EvidenceBlockTests : TestContainer +{ + public void Format_EmptyBlock_ReturnsEmptyString() + { + var block = EvidenceBlock.Create(); + + string result = block.Format(); + + result.Should().BeEmpty(); + } + + public void Format_SingleLine_FormatsLabelAndValue() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "42"); + + string result = block.Format(); + + result.Should().Be("expected: 42"); + } + + public void Format_TwoLines_AlignsLabels() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37"); + + string result = block.Format(); + + // "expected:" is 9 chars, "actual:" is 7 chars + // "actual:" should be padded to 9 chars for alignment + string expected = "expected: 42" + Environment.NewLine + "actual: 37"; + result.Should().Be(expected); + } + + public void Format_MultipleLines_AlignsToLongestLabel() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37") + .AddLine("ignore case:", "true") + .AddLine("culture:", "tr-TR"); + + string result = block.Format(); + + string[] lines = result.Split([Environment.NewLine], StringSplitOptions.None); + lines.Should().HaveCount(4); + lines[0].Should().Be("expected: 42"); + lines[1].Should().Be("actual: 37"); + lines[2].Should().Be("ignore case: true"); + lines[3].Should().Be("culture: tr-TR"); + } + + public void Lines_ReturnsAddedLines() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37"); + + block.Lines.Should().HaveCount(2); + block.Lines[0].Label.Should().Be("expected:"); + block.Lines[0].Value.Should().Be("42"); + block.Lines[1].Label.Should().Be("actual:"); + block.Lines[1].Value.Should().Be("37"); + } +} diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/StructuredAssertionMessageTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/StructuredAssertionMessageTests.cs new file mode 100644 index 0000000000..c8c6857665 --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/StructuredAssertionMessageTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests; + +public class StructuredAssertionMessageTests : TestContainer +{ + public void Format_SummaryOnly_ReturnsAssertionPrefix() + { + StructuredAssertionMessage message = new("Expected values to be equal."); + + string result = message.Format(); + + result.Should().Be("Assertion failed. Expected values to be equal."); + } + + public void Format_EmptySummary_ReturnsJustPrefix() + { + StructuredAssertionMessage message = new(string.Empty); + + string result = message.Format(); + + result.Should().Be("Assertion failed."); + } + + public void Format_WithUserMessage_ShowsMessageAfterSummary() + { + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithUserMessage("Discount should be applied after tax"); + + string result = message.Format(); + + result.Should().Be( + """ + Assertion failed. Expected values to be equal. + Discount should be applied after tax + """); + } + + public void Format_WithEvidenceBlock_SeparatedByBlankLine() + { + EvidenceBlock evidence = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37"); + + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithEvidence(evidence); + + string result = message.Format(); + + result.Should().Be( + """ + Assertion failed. Expected values to be equal. + + expected: 42 + actual: 37 + """); + } + + public void Format_WithUserMessageAndEvidence_CorrectLayout() + { + EvidenceBlock evidence = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37"); + + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithUserMessage("Discount should be applied after tax"); + message.WithEvidence(evidence); + + string result = message.Format(); + + result.Should().Be( + """ + Assertion failed. Expected values to be equal. + Discount should be applied after tax + + expected: 42 + actual: 37 + """); + } + + public void Format_WithCallSiteExpression_SeparatedByBlankLine() + { + EvidenceBlock evidence = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37"); + + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithEvidence(evidence); + message.WithCallSiteExpression("Assert.AreEqual(expectedCount, actualCount)"); + + string result = message.Format(); + + result.Should().Be( + """ + Assertion failed. Expected values to be equal. + + expected: 42 + actual: 37 + + Assert.AreEqual(expectedCount, actualCount) + """); + } + + public void Format_FullMessage_CorrectLayout() + { + EvidenceBlock evidence = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37"); + + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithAdditionalSummaryLine("Values differ at position 3."); + message.WithUserMessage("Check the discount logic"); + message.WithEvidence(evidence); + message.WithCallSiteExpression("Assert.AreEqual(expected, actual)"); + + string result = message.Format(); + + result.Should().Be( + """ + Assertion failed. Expected values to be equal. + Values differ at position 3. + Check the discount logic + + expected: 42 + actual: 37 + + Assert.AreEqual(expected, actual) + """); + } + + public void Format_NullUserMessage_OmitsUserMessageLine() + { + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithUserMessage(null); + + string result = message.Format(); + + result.Should().Be("Assertion failed. Expected values to be equal."); + } + + public void Format_WhitespaceUserMessage_OmitsUserMessageLine() + { + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithUserMessage(" "); + + string result = message.Format(); + + result.Should().Be("Assertion failed. Expected values to be equal."); + } + + public void WithExpectedAndActual_SetsProperties() + { + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithExpectedAndActual("42", "37"); + + message.ExpectedText.Should().Be("42"); + message.ActualText.Should().Be("37"); + } + + public void ToString_ReturnsSameAsFormat() + { + StructuredAssertionMessage message = new("Expected values to be equal."); + + message.ToString().Should().Be(message.Format()); + } + + public void Format_WithMultipleEvidenceBlocks_SeparatedByBlankLines() + { + EvidenceBlock evidence1 = EvidenceBlock.Create() + .AddLine("expected:", "42") + .AddLine("actual:", "37"); + + EvidenceBlock evidence2 = EvidenceBlock.Create() + .AddLine("ignore case:", "true") + .AddLine("culture:", "tr-TR"); + + StructuredAssertionMessage message = new("Expected values to be equal."); + message.WithEvidence(evidence1); + message.WithEvidence(evidence2); + + string result = message.Format(); + + result.Should().Be( + """ + Assertion failed. Expected values to be equal. + + expected: 42 + actual: 37 + + ignore case: true + culture: tr-TR + """); + } +}