From 851f64dae2df904205d722d9fc016d5389135749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 15:57:28 +0200 Subject: [PATCH 1/4] Extract shared TestResultCapture helpers to SharedExtensionHelpers (#8986) Move the shared truncation limits, truncation implementation, test method name projection, and common outcome classification into a linked SharedExtensionHelpers helper. Keep HTML and JUnit report-specific capture DTOs and cancellation mapping wrappers local so each report preserves its existing behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...osoft.Testing.Extensions.HtmlReport.csproj | 1 + .../TestResultCapture.cs | 64 ++++------------- ...soft.Testing.Extensions.JUnitReport.csproj | 1 + .../TestResultCapture.cs | 68 +++++-------------- .../TestResultCaptureHelper.cs | 66 ++++++++++++++++++ 5 files changed, 99 insertions(+), 101 deletions(-) create mode 100644 src/Platform/SharedExtensionHelpers/TestResultCaptureHelper.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj index a768b4b09b..fba261f720 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj @@ -46,6 +46,7 @@ This package extends Microsoft Testing Platform to produce self-contained HTML t + diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs index e48beed890..efe5a4b50b 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs @@ -1,10 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using Microsoft.Testing.Platform; using Microsoft.Testing.Platform.Extensions.Messages; -using Microsoft.Testing.Platform.Helpers; -using Microsoft.Testing.Platform.Messages; namespace Microsoft.Testing.Extensions.HtmlReport; @@ -13,11 +10,11 @@ namespace Microsoft.Testing.Extensions.HtmlReport; // stdout/stderr/stack traces) in memory for the whole session. internal static class TestResultCapture { - internal const int MaxStandardStreamLength = 32 * 1024; - internal const int MaxStackTraceLength = 32 * 1024; - internal const int MaxMessageLength = 16 * 1024; - internal const int MaxIdentityFieldLength = 4 * 1024; - internal const int MaxTraitFieldLength = 1024; + internal const int MaxStandardStreamLength = TestResultCaptureHelper.MaxStandardStreamLength; + internal const int MaxStackTraceLength = TestResultCaptureHelper.MaxStackTraceLength; + internal const int MaxMessageLength = TestResultCaptureHelper.MaxMessageLength; + internal const int MaxIdentityFieldLength = TestResultCaptureHelper.MaxIdentityFieldLength; + internal const int MaxTraitFieldLength = TestResultCaptureHelper.MaxTraitFieldLength; public static CapturedTestResult? TryCapture(TestNode node) { @@ -32,7 +29,8 @@ internal static class TestResultCapture TimingProperty? timing = node.Properties.SingleOrDefault(); TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero; - (string? className, string? methodName) = GetClassAndMethodName(node); + TestMethodIdentifierProperty? identifier = node.Properties.SingleOrDefault(); + (string? className, string? methodName) = TestResultCaptureHelper.GetClassAndMethodName(identifier); string? errorMessage = state.Explanation; string? stackTrace = null; @@ -96,51 +94,19 @@ internal static class TestResultCapture } private static string ClassifyOutcome(TestNodeStateProperty state) - => state switch - { - PassedTestNodeStateProperty => "passed", - SkippedTestNodeStateProperty => "skipped", - TimeoutTestNodeStateProperty => "timedOut", - ErrorTestNodeStateProperty => "errored", - FailedTestNodeStateProperty => "failed", - _ when Array.IndexOf(TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcomeFailedProperties, state.GetType()) >= 0 - => "failed", - _ => throw ApplicationStateGuard.Unreachable(), - }; - - private static (string? ClassName, string? MethodName) GetClassAndMethodName(TestNode node) { - TestMethodIdentifierProperty? identifier = node.Properties.SingleOrDefault(); - if (identifier is null) +#pragma warning disable CS0618, MTP0001 // CancelledTestNodeStateProperty is obsolete + // Cancellation is intentionally handled in report-specific wrappers. HTML has + // historically let cancellation fall through to the failed outcome category. + if (state is CancelledTestNodeStateProperty) { - return (null, null); + return TestResultCaptureHelper.ClassifyOutcome(state); } +#pragma warning restore CS0618, MTP0001 - string className = RoslynString.IsNullOrEmpty(identifier.Namespace) - ? identifier.TypeName - : $"{identifier.Namespace}.{identifier.TypeName}"; - - return (className, identifier.MethodName); + return TestResultCaptureHelper.ClassifyOutcome(state); } internal static string? Truncate(string? value, int maxLength) - { - if (value is null || value.Length <= maxLength) - { - return value; - } - - // Don't split a surrogate pair when truncating: drop the high surrogate too. - // The early return above guarantees cut < value.Length here, but we keep the - // explicit guard so the invariant is locally self-documenting and survives - // any future refactor that might call this block with cut == value.Length. - int cut = maxLength; - if (cut > 0 && cut < value.Length && char.IsHighSurrogate(value[cut - 1])) - { - cut--; - } - - return value.Substring(0, cut) - + $"\n…[truncated, original length: {value.Length.ToString(CultureInfo.InvariantCulture)}]"; - } + => TestResultCaptureHelper.Truncate(value, maxLength); } diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj index fb7e44559e..7c5463e305 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj @@ -50,6 +50,7 @@ This package extends Microsoft Testing Platform to produce JUnit XML test report + diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs index 2d223ae467..dbadf6041a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs @@ -1,10 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using Microsoft.Testing.Platform; using Microsoft.Testing.Platform.Extensions.Messages; -using Microsoft.Testing.Platform.Helpers; -using Microsoft.Testing.Platform.Messages; namespace Microsoft.Testing.Extensions.JUnitReport; @@ -13,11 +10,11 @@ namespace Microsoft.Testing.Extensions.JUnitReport; // stdout/stderr/stack traces) in memory for the whole session. internal static class TestResultCapture { - internal const int MaxStandardStreamLength = 32 * 1024; - internal const int MaxStackTraceLength = 32 * 1024; - internal const int MaxMessageLength = 16 * 1024; - internal const int MaxIdentityFieldLength = 4 * 1024; - internal const int MaxTraitFieldLength = 1024; + internal const int MaxStandardStreamLength = TestResultCaptureHelper.MaxStandardStreamLength; + internal const int MaxStackTraceLength = TestResultCaptureHelper.MaxStackTraceLength; + internal const int MaxMessageLength = TestResultCaptureHelper.MaxMessageLength; + internal const int MaxIdentityFieldLength = TestResultCaptureHelper.MaxIdentityFieldLength; + internal const int MaxTraitFieldLength = TestResultCaptureHelper.MaxTraitFieldLength; // The display name of a test node is also captured for non-terminal (Discovered / // InProgress) messages so that the engine can reconstruct the parent chain for @@ -84,7 +81,7 @@ static TProperty GetSingleOrDefaultValue(TProperty? existingProperty, string outcome = ClassifyOutcome(state); TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero; - (string? className, string? methodName) = GetClassAndMethodName(identifier); + (string? className, string? methodName) = TestResultCaptureHelper.GetClassAndMethodName(identifier); string? errorMessage = state.Explanation; string? stackTrace = null; @@ -135,54 +132,21 @@ static TProperty GetSingleOrDefaultValue(TProperty? existingProperty, } private static string ClassifyOutcome(TestNodeStateProperty state) - => state switch - { - PassedTestNodeStateProperty => "passed", - SkippedTestNodeStateProperty => "skipped", - TimeoutTestNodeStateProperty => "timedOut", - ErrorTestNodeStateProperty => "errored", - FailedTestNodeStateProperty => "failed", -#pragma warning disable CS0618, MTP0001 // CancelledTestNodeStateProperty is obsolete - // Cancellation is an interruption, not an assertion failure. The RFC - // maps it to in the generated XML; classifying it as its own - // bucket here (rather than letting it fall through to "failed") keeps - // that mapping local to the engine's outcome switch. - CancelledTestNodeStateProperty => "cancelled", -#pragma warning restore CS0618, MTP0001 - _ when Array.IndexOf(TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcomeFailedProperties, state.GetType()) >= 0 - => "failed", - _ => throw ApplicationStateGuard.Unreachable(), - }; - - private static (string? ClassName, string? MethodName) GetClassAndMethodName(TestMethodIdentifierProperty? identifier) { - if (identifier is null) +#pragma warning disable CS0618, MTP0001 // CancelledTestNodeStateProperty is obsolete + // Cancellation is an interruption, not an assertion failure. The RFC maps it + // to in the generated XML; classifying it as its own bucket here + // keeps that mapping local to JUnit. Keep the cancellation decision out of + // the shared helper so each report format preserves its existing behavior. + if (state is CancelledTestNodeStateProperty) { - return (null, null); + return "cancelled"; } +#pragma warning restore CS0618, MTP0001 - string className = RoslynString.IsNullOrEmpty(identifier.Namespace) - ? identifier.TypeName - : $"{identifier.Namespace}.{identifier.TypeName}"; - - return (className, identifier.MethodName); + return TestResultCaptureHelper.ClassifyOutcome(state); } internal static string? Truncate(string? value, int maxLength) - { - if (value is null || value.Length <= maxLength) - { - return value; - } - - // Don't split a surrogate pair when truncating: drop the high surrogate too. - int cut = maxLength; - if (cut > 0 && char.IsHighSurrogate(value[cut - 1])) - { - cut--; - } - - return value.Substring(0, cut) - + $"\n…[truncated, original length: {value.Length.ToString(CultureInfo.InvariantCulture)}]"; - } + => TestResultCaptureHelper.Truncate(value, maxLength); } diff --git a/src/Platform/SharedExtensionHelpers/TestResultCaptureHelper.cs b/src/Platform/SharedExtensionHelpers/TestResultCaptureHelper.cs new file mode 100644 index 0000000000..8391477326 --- /dev/null +++ b/src/Platform/SharedExtensionHelpers/TestResultCaptureHelper.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Messages; + +namespace Microsoft.Testing.Extensions; + +internal static class TestResultCaptureHelper +{ + internal const int MaxStandardStreamLength = 32 * 1024; + internal const int MaxStackTraceLength = 32 * 1024; + internal const int MaxMessageLength = 16 * 1024; + internal const int MaxIdentityFieldLength = 4 * 1024; + internal const int MaxTraitFieldLength = 1024; + + internal static string ClassifyOutcome(TestNodeStateProperty state) + => state switch + { + PassedTestNodeStateProperty => "passed", + SkippedTestNodeStateProperty => "skipped", + TimeoutTestNodeStateProperty => "timedOut", + ErrorTestNodeStateProperty => "errored", + FailedTestNodeStateProperty => "failed", + _ when Array.IndexOf(TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcomeFailedProperties, state.GetType()) >= 0 + => "failed", + _ => throw ApplicationStateGuard.Unreachable(), + }; + + internal static (string? ClassName, string? MethodName) GetClassAndMethodName(TestMethodIdentifierProperty? identifier) + { + if (identifier is null) + { + return (null, null); + } + + string className = RoslynString.IsNullOrEmpty(identifier.Namespace) + ? identifier.TypeName + : $"{identifier.Namespace}.{identifier.TypeName}"; + + return (className, identifier.MethodName); + } + + internal static string? Truncate(string? value, int maxLength) + { + if (value is null || value.Length <= maxLength) + { + return value; + } + + // Don't split a surrogate pair when truncating: drop the high surrogate too. + // The early return above guarantees cut < value.Length here, but we keep the + // explicit guard so the invariant is locally self-documenting and survives + // any future refactor that might call this block with cut == value.Length. + int cut = maxLength; + if (cut > 0 && cut < value.Length && char.IsHighSurrogate(value[cut - 1])) + { + cut--; + } + + return value.Substring(0, cut) + + $"\n…[truncated, original length: {value.Length.ToString(CultureInfo.InvariantCulture)}]"; + } +} From 0765097157abab671ac2ba015a857ec40b752d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 16:48:01 +0200 Subject: [PATCH 2/4] Drop redundant CancelledTestNodeStateProperty special-case in HtmlReport Both branches of the ClassifyOutcome wrapper delegated to TestResultCaptureHelper.ClassifyOutcome with no behavioral difference, so the if/pragma block added control flow without changing the result. Collapse the wrapper to a single expression-bodied delegation and keep the rationale comment so it stays clear why HtmlReport doesn't override cancellation classification (contrast with JUnit which still returns its own "cancelled" string). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestResultCapture.cs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs index efe5a4b50b..6efe24277f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs @@ -94,18 +94,10 @@ internal static class TestResultCapture } private static string ClassifyOutcome(TestNodeStateProperty state) - { -#pragma warning disable CS0618, MTP0001 // CancelledTestNodeStateProperty is obsolete // Cancellation is intentionally handled in report-specific wrappers. HTML has - // historically let cancellation fall through to the failed outcome category. - if (state is CancelledTestNodeStateProperty) - { - return TestResultCaptureHelper.ClassifyOutcome(state); - } -#pragma warning restore CS0618, MTP0001 - - return TestResultCaptureHelper.ClassifyOutcome(state); - } + // historically let cancellation fall through to the failed outcome category, so + // this wrapper simply delegates to the shared helper with no special-casing. + => TestResultCaptureHelper.ClassifyOutcome(state); internal static string? Truncate(string? value, int maxLength) => TestResultCaptureHelper.Truncate(value, maxLength); From e22d65921e01076406fce701da3293086c57d657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 21:40:19 +0200 Subject: [PATCH 3/4] Add unit tests for shared TestResultCaptureHelper Addresses self-review comment on PR #9044: the new shared helper had no direct test coverage. Adds 13 tests covering Truncate (incl. surrogate-pair boundary), ClassifyOutcome (all six outcome flavors plus Cancelled fall-through), and GetClassAndMethodName (with/without namespace, null identifier). Uses reflection (matching ReportFileNameSanitizationConsistencyTests) because the helper is linked into two extension assemblies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestResultCaptureHelperTests.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs new file mode 100644 index 0000000000..43c9861ea5 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs @@ -0,0 +1,137 @@ +// 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.Reflection; + +using Microsoft.Testing.Extensions.HtmlReport; +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class TestResultCaptureHelperTests +{ + // TestResultCaptureHelper is linked into multiple extension assemblies (HtmlReport, JUnitReport). + // Pick one explicitly so direct type references stay unambiguous. + private static readonly Type HelperType = + typeof(HtmlReportEngine).Assembly.GetType("Microsoft.Testing.Extensions.TestResultCaptureHelper", throwOnError: true)!; + + private static readonly MethodInfo TruncateMethod = + HelperType.GetMethod("Truncate", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Could not resolve TestResultCaptureHelper.Truncate."); + + private static readonly MethodInfo ClassifyOutcomeMethod = + HelperType.GetMethod("ClassifyOutcome", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Could not resolve TestResultCaptureHelper.ClassifyOutcome."); + + private static readonly MethodInfo GetClassAndMethodNameMethod = + HelperType.GetMethod("GetClassAndMethodName", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Could not resolve TestResultCaptureHelper.GetClassAndMethodName."); + + private static string? InvokeTruncate(string? value, int maxLength) + => (string?)TruncateMethod.Invoke(null, [value, maxLength]); + + private static string InvokeClassifyOutcome(TestNodeStateProperty state) + => (string)ClassifyOutcomeMethod.Invoke(null, [state])!; + + private static (string? ClassName, string? MethodName) InvokeGetClassAndMethodName(TestMethodIdentifierProperty? identifier) + => ((string?, string?))GetClassAndMethodNameMethod.Invoke(null, [identifier])!; + + [TestMethod] + public void Truncate_NullValue_ReturnsNull() + => Assert.IsNull(InvokeTruncate(null, 100)); + + [TestMethod] + public void Truncate_ValueShorterThanMax_ReturnsValueUnchanged() + => Assert.AreEqual("abc", InvokeTruncate("abc", 100)); + + [TestMethod] + public void Truncate_ValueExactlyMax_ReturnsValueUnchanged() + => Assert.AreEqual("abcde", InvokeTruncate("abcde", 5)); + + [TestMethod] + public void Truncate_ValueLongerThanMax_ReturnsTruncatedWithSuffix() + { + string? result = InvokeTruncate("abcdefghij", 4); + Assert.IsNotNull(result); + Assert.IsTrue(result.StartsWith("abcd", StringComparison.Ordinal)); + Assert.Contains("[truncated, original length: 10]", result); + } + + [TestMethod] + public void Truncate_DoesNotSplitSurrogatePair_DropsHighSurrogate() + { + // U+1F600 (😀) is encoded as the surrogate pair D83D DE00. + // Truncating at length 3 would otherwise leave a dangling high surrogate at index 2. + string value = "ab\uD83D\uDE00cd"; + string? result = InvokeTruncate(value, 3); + Assert.IsNotNull(result); + Assert.IsTrue(result.StartsWith("ab", StringComparison.Ordinal)); + Assert.IsFalse(char.IsHighSurrogate(result[1]), "Truncated value must not end with a dangling high surrogate."); + Assert.Contains($"[truncated, original length: {value.Length}]", result); + } + + [TestMethod] + public void ClassifyOutcome_Passed_ReturnsPassed() + => Assert.AreEqual("passed", InvokeClassifyOutcome(new PassedTestNodeStateProperty())); + + [TestMethod] + public void ClassifyOutcome_Skipped_ReturnsSkipped() + => Assert.AreEqual("skipped", InvokeClassifyOutcome(new SkippedTestNodeStateProperty())); + + [TestMethod] + public void ClassifyOutcome_Timeout_ReturnsTimedOut() + => Assert.AreEqual("timedOut", InvokeClassifyOutcome(new TimeoutTestNodeStateProperty())); + + [TestMethod] + public void ClassifyOutcome_Error_ReturnsErrored() + => Assert.AreEqual("errored", InvokeClassifyOutcome(new ErrorTestNodeStateProperty())); + + [TestMethod] + public void ClassifyOutcome_Failed_ReturnsFailed() + => Assert.AreEqual("failed", InvokeClassifyOutcome(new FailedTestNodeStateProperty())); + + [TestMethod] +#pragma warning disable CS0618, MTP0001 // Cancelled* is obsolete but still in the well-known failed set. + public void ClassifyOutcome_Cancelled_FallsThroughToFailed() + => Assert.AreEqual("failed", InvokeClassifyOutcome(new CancelledTestNodeStateProperty())); +#pragma warning restore CS0618, MTP0001 + + [TestMethod] + public void GetClassAndMethodName_NullIdentifier_ReturnsNullPair() + => Assert.AreEqual<(string?, string?)>((null, null), InvokeGetClassAndMethodName(null)); + + [TestMethod] + public void GetClassAndMethodName_WithNamespace_PrependsNamespace() + { + var identifier = new TestMethodIdentifierProperty( + assemblyFullName: "MyAsm", + @namespace: "My.Ns", + typeName: "MyType", + methodName: "MyMethod", + methodArity: 0, + parameterTypeFullNames: [], + returnTypeFullName: "System.Void"); + + (string? className, string? methodName) = InvokeGetClassAndMethodName(identifier); + Assert.AreEqual("My.Ns.MyType", className); + Assert.AreEqual("MyMethod", methodName); + } + + [TestMethod] + public void GetClassAndMethodName_WithoutNamespace_ReturnsBareTypeName() + { + var identifier = new TestMethodIdentifierProperty( + assemblyFullName: "MyAsm", + @namespace: string.Empty, + typeName: "MyType", + methodName: "MyMethod", + methodArity: 0, + parameterTypeFullNames: [], + returnTypeFullName: "System.Void"); + + (string? className, string? methodName) = InvokeGetClassAndMethodName(identifier); + Assert.AreEqual("MyType", className); + Assert.AreEqual("MyMethod", methodName); + } +} From bbb2007259cbcb9e4bf6b42d1736b691b387a636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 21:46:36 +0200 Subject: [PATCH 4/4] Tighten surrogate-pair truncation assertion Reviewer correctly pointed out that result[1] is 'b' so char.IsHighSurrogate trivially passed. Switch to exact-equality assertion against ab + suffix, which proves the high surrogate at original index 2 was dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestResultCaptureHelperTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs index 43c9861ea5..091b8f5cfb 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs @@ -63,12 +63,11 @@ public void Truncate_DoesNotSplitSurrogatePair_DropsHighSurrogate() { // U+1F600 (😀) is encoded as the surrogate pair D83D DE00. // Truncating at length 3 would otherwise leave a dangling high surrogate at index 2. + // With the guard, the high surrogate at index 2 is dropped and the truncation suffix + // starts immediately after "ab" — so the result must equal "ab" + suffix exactly. string value = "ab\uD83D\uDE00cd"; string? result = InvokeTruncate(value, 3); - Assert.IsNotNull(result); - Assert.IsTrue(result.StartsWith("ab", StringComparison.Ordinal)); - Assert.IsFalse(char.IsHighSurrogate(result[1]), "Truncated value must not end with a dangling high surrogate."); - Assert.Contains($"[truncated, original length: {value.Length}]", result); + Assert.AreEqual($"ab\n…[truncated, original length: {value.Length}]", result); } [TestMethod]