From 6feaf62fc0ee0a2dd3b42afa8d0d3e12341ac349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sat, 20 Jun 2026 15:57:09 +0200 Subject: [PATCH 1/2] Extract NormalizeForLocalization helpers into AcceptanceAssert Move the duplicated NormalizeForComparison and AssertOutputContainsNormalized/ AssertOutputDoesNotContainNormalized private helpers from LocalizationTests and LocalizationFailingTests into AcceptanceAssert as public extension methods. This eliminates the code duplication between the two recently-modified test files and makes the normalization helpers available for any future localization tests. The helpers are renamed to NormalizeForLocalization and made into proper extension methods following the existing AcceptanceAssert pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/AcceptanceAssert.cs | 33 +++++++++++++ .../LocalizationFailingTests.cs | 23 +--------- .../LocalizationTests.cs | 46 ++++--------------- 3 files changed, 44 insertions(+), 58 deletions(-) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceAssert.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceAssert.cs index eead86e9ad..db3518c038 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceAssert.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceAssert.cs @@ -133,6 +133,34 @@ public static void AssertOutputMatchesRegex(this DotnetMuxerResult dotnetMuxerRe public static void AssertOutputDoesNotContain(this TestHostResult testHostResult, string value, [CallerMemberName] string? callerMemberName = null, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0) => Assert.IsFalse(testHostResult.StandardOutput.Contains(value, StringComparison.Ordinal), GenerateFailedAssertionMessage(testHostResult, callerMemberName: callerMemberName, callerFilePath: callerFilePath, callerLineNumber: callerLineNumber)); + /// + /// Asserts that the test host output contains the given value after Unicode normalization (FormC) and + /// non-breaking space (U+00A0) replacement. Use this instead of when + /// comparing localized strings that may use different normalization forms or typographic spacing conventions. + /// + public static void AssertOutputContainsNormalized(this TestHostResult testHostResult, string value, [CallerMemberName] string? callerMemberName = null, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0) + { + string normalizedOutput = NormalizeForLocalization(testHostResult.StandardOutput); + string normalizedValue = NormalizeForLocalization(value); + Assert.IsTrue( + normalizedOutput.Contains(normalizedValue, StringComparison.Ordinal), + $"Output does not contain '{value}'.{Environment.NewLine}Output:{Environment.NewLine}{testHostResult.StandardOutput}"); + } + + /// + /// Asserts that the test host output does not contain the given value after Unicode normalization (FormC) and + /// non-breaking space (U+00A0) replacement. Use this instead of when + /// comparing localized strings that may use different normalization forms or typographic spacing conventions. + /// + public static void AssertOutputDoesNotContainNormalized(this TestHostResult testHostResult, string value, [CallerMemberName] string? callerMemberName = null, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0) + { + string normalizedOutput = NormalizeForLocalization(testHostResult.StandardOutput); + string normalizedValue = NormalizeForLocalization(value); + Assert.IsFalse( + normalizedOutput.Contains(normalizedValue, StringComparison.Ordinal), + $"Output should not contain '{value}'.{Environment.NewLine}Output:{Environment.NewLine}{testHostResult.StandardOutput}"); + } + public static void AssertStandardErrorContains(this TestHostResult testHostResult, string value, [CallerMemberName] string? callerMemberName = null, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0) => Assert.Contains(value, testHostResult.StandardError, StringComparison.Ordinal, GenerateFailedAssertionMessage(testHostResult, callerMemberName: callerMemberName, callerFilePath: callerFilePath, callerLineNumber: callerLineNumber)); @@ -165,4 +193,9 @@ private static string GenerateFailedAssertionMessage(TestHostResult testHostResu private static string GenerateFailedAssertionMessage(DotnetMuxerResult dotnetMuxerResult, string? callerMemberName, string? callerFilePath, int callerLineNumber, [CallerMemberName] string? assertCallerMemberName = null) => $"Expression '{assertCallerMemberName}' failed for member '{callerMemberName}' at line {callerLineNumber} of file '{callerFilePath}'. Output of the dotnet muxer is:{Environment.NewLine}{dotnetMuxerResult}"; + + // Localized resource strings may use different Unicode normalization forms (NFC vs NFD) than C# string literals. + // French locale also uses non-breaking space (U+00A0) before colons per typographic convention. + private static string NormalizeForLocalization(string text) + => text.Normalize(NormalizationForm.FormC).Replace('\u00A0', ' '); } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationFailingTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationFailingTests.cs index a18df5479d..ec7421bdbd 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationFailingTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationFailingTests.cs @@ -1,8 +1,6 @@ // 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.Text; - namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; // Temporarily disabled: OneLocBuild keeps reverting the TerminalResources.*.xlf targets to English, @@ -13,23 +11,6 @@ public sealed class LocalizationFailingTests : AcceptanceTestBase text.Normalize(NormalizationForm.FormC).Replace('\u00A0', ' '); - - private static void AssertOutputContainsNormalized(TestHostResult testHostResult, string value) - { - string normalizedOutput = NormalizeForComparison(testHostResult.StandardOutput); - string normalizedValue = NormalizeForComparison(value); - Assert.IsTrue( - normalizedOutput.Contains(normalizedValue, StringComparison.Ordinal), - $"Output does not contain '{value}'.{Environment.NewLine}Output:{Environment.NewLine}{testHostResult.StandardOutput}"); - } - [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] [TestMethod] public async Task Execution_WithFailingTest_OutputContainsTranslatedFailureSummary(string tfm) @@ -42,8 +23,8 @@ public async Task Execution_WithFailingTest_OutputContainsTranslatedFailureSumma testHostResult.AssertExitCodeIs(ExitCode.AtLeastOneTestFailed); // Verify failure summary is in French ("Résumé de série de tests : Échec!") - AssertOutputContainsNormalized(testHostResult, "Résumé de série de tests : Échec!"); - AssertOutputContainsNormalized(testHostResult, "échec: 1"); + testHostResult.AssertOutputContainsNormalized("Résumé de série de tests : Échec!"); + testHostResult.AssertOutputContainsNormalized("échec: 1"); } public sealed class TestAssetFixture() : TestAssetFixtureBase() diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationTests.cs index 3a1243b0c8..de8362093b 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationTests.cs @@ -1,8 +1,6 @@ // 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.Text; - namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; // Temporarily disabled: OneLocBuild keeps reverting the TerminalResources.*.xlf targets to English, @@ -13,32 +11,6 @@ public class LocalizationTests : AcceptanceTestBase text.Normalize(NormalizationForm.FormC).Replace('\u00A0', ' '); - - private static void AssertOutputContainsNormalized(TestHostResult testHostResult, string value) - { - string normalizedOutput = NormalizeForComparison(testHostResult.StandardOutput); - string normalizedValue = NormalizeForComparison(value); - Assert.IsTrue( - normalizedOutput.Contains(normalizedValue, StringComparison.Ordinal), - $"Output does not contain '{value}'.{Environment.NewLine}Output:{Environment.NewLine}{testHostResult.StandardOutput}"); - } - - private static void AssertOutputDoesNotContainNormalized(TestHostResult testHostResult, string value) - { - string normalizedOutput = NormalizeForComparison(testHostResult.StandardOutput); - string normalizedValue = NormalizeForComparison(value); - Assert.IsFalse( - normalizedOutput.Contains(normalizedValue, StringComparison.Ordinal), - $"Output should not contain '{value}'.{Environment.NewLine}Output:{Environment.NewLine}{testHostResult.StandardOutput}"); - } - [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] [TestMethod] public async Task Execution_WithFrenchLocale_OutputContainsTranslatedSummary(string tfm) @@ -51,17 +23,17 @@ public async Task Execution_WithFrenchLocale_OutputContainsTranslatedSummary(str testHostResult.AssertExitCodeIs(ExitCode.Success); // Verify the summary line is in French ("Résumé de série de tests : Réussite!") - AssertOutputContainsNormalized(testHostResult, "Résumé de série de tests : Réussite!"); + testHostResult.AssertOutputContainsNormalized("Résumé de série de tests : Réussite!"); // Verify the count labels are in French - AssertOutputContainsNormalized(testHostResult, "total: 2"); - AssertOutputContainsNormalized(testHostResult, "échec: 0"); - AssertOutputContainsNormalized(testHostResult, "opération réussie: 2"); - AssertOutputContainsNormalized(testHostResult, "ignoré: 0"); + testHostResult.AssertOutputContainsNormalized("total: 2"); + testHostResult.AssertOutputContainsNormalized("échec: 0"); + testHostResult.AssertOutputContainsNormalized("opération réussie: 2"); + testHostResult.AssertOutputContainsNormalized("ignoré: 0"); // Verify English strings are NOT in the output - AssertOutputDoesNotContainNormalized(testHostResult, "Test run summary:"); - AssertOutputDoesNotContainNormalized(testHostResult, "succeeded:"); + testHostResult.AssertOutputDoesNotContainNormalized("Test run summary:"); + testHostResult.AssertOutputDoesNotContainNormalized("succeeded:"); } [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] @@ -105,8 +77,8 @@ public async Task Execution_WithTestingPlatformUILanguage_TakesPrecedenceOverDot testHostResult.AssertExitCodeIs(ExitCode.Success); // French should win because TESTINGPLATFORM_UI_LANGUAGE has higher precedence - AssertOutputContainsNormalized(testHostResult, "Résumé de série de tests : Réussite!"); - AssertOutputDoesNotContainNormalized(testHostResult, "Resumen de la serie de pruebas:"); + testHostResult.AssertOutputContainsNormalized("Résumé de série de tests : Réussite!"); + testHostResult.AssertOutputDoesNotContainNormalized("Resumen de la serie de pruebas:"); } public sealed class TestAssetFixture() : TestAssetFixtureBase() From a97f00ed87e1e3eef3bfa71d5c6fdf0768fc6b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sat, 20 Jun 2026 16:15:48 +0200 Subject: [PATCH 2/2] Address review: use shared GenerateFailedAssertionMessage in normalized asserts The AssertOutputContainsNormalized / AssertOutputDoesNotContainNormalized helpers accepted caller-info parameters but never used them (IDE0060) and built ad-hoc failure messages. Route them through GenerateFailedAssertionMessage like every other AcceptanceAssert helper so failures include the member/file/line context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/AcceptanceAssert.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceAssert.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceAssert.cs index db3518c038..76b2c29d8d 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceAssert.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceAssert.cs @@ -144,7 +144,7 @@ public static void AssertOutputContainsNormalized(this TestHostResult testHostRe string normalizedValue = NormalizeForLocalization(value); Assert.IsTrue( normalizedOutput.Contains(normalizedValue, StringComparison.Ordinal), - $"Output does not contain '{value}'.{Environment.NewLine}Output:{Environment.NewLine}{testHostResult.StandardOutput}"); + GenerateFailedAssertionMessage(testHostResult, callerMemberName, callerFilePath, callerLineNumber)); } /// @@ -158,7 +158,7 @@ public static void AssertOutputDoesNotContainNormalized(this TestHostResult test string normalizedValue = NormalizeForLocalization(value); Assert.IsFalse( normalizedOutput.Contains(normalizedValue, StringComparison.Ordinal), - $"Output should not contain '{value}'.{Environment.NewLine}Output:{Environment.NewLine}{testHostResult.StandardOutput}"); + GenerateFailedAssertionMessage(testHostResult, callerMemberName, callerFilePath, callerLineNumber)); } public static void AssertStandardErrorContains(this TestHostResult testHostResult, string value, [CallerMemberName] string? callerMemberName = null, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)