From ef9bcb8ee31350c18d4865bf009159b310f7507d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 17 Mar 2026 12:52:14 +0100 Subject: [PATCH 1/3] [TrimmableTypeMap] Generate per-assembly acw-map.txt from scanner results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #10807. Stacked on #10924. The trimmable typemap path needs acw-map.txt for _ConvertCustomView to fix up custom view names in layout XMLs. This adds acw-map generation as a side-output of GenerateTrimmableTypeMap — the task already has all JavaPeerInfo records from scanning, so no extra scan is needed. Changes: - AcwMapWriter: converts JavaPeerInfo → acw-map.txt format (3 lines per type: partial-asm-qualified, managed, compat-jni — matching legacy format) - GenerateTrimmableTypeMap: new AcwMapDirectory input + PerAssemblyAcwMapFiles output, writes per-assembly acw-map.{AssemblyName}.txt during generation - Trimmable.targets: _MergeAcwMaps target concatenates per-assembly files into the single acw-map.txt consumed by _ConvertCustomView and R8 - 8 unit tests for AcwMapWriter (263 total tests pass) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/AcwMapWriter.cs | 82 ++++++++++++ ...soft.Android.Sdk.TypeMap.Trimmable.targets | 39 +++++- .../Tasks/GenerateTrimmableTypeMap.cs | 47 ++++++- .../Generator/AcwMapWriterTests.cs | 117 ++++++++++++++++++ 4 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs new file mode 100644 index 00000000000..e725f0c4245 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs @@ -0,0 +1,82 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +/// +/// Generates per-assembly acw-map.txt content from records. +/// The acw-map.txt file maps managed type names to Java/ACW type names, consumed by +/// _ConvertCustomView to fix up custom view names in layout XMLs. +/// +/// Format per type (3 lines): +/// Line 1: PartialAssemblyQualifiedName;JavaKey (always written) +/// Line 2: ManagedKey;JavaKey +/// Line 3: CompatJniName;JavaKey +/// +/// Java keys use dots (not slashes): e.g., "android.app.Activity" +/// +public static class AcwMapWriter +{ + /// + /// Writes acw-map lines for the given to the . + /// Conflict detection is NOT performed here — it happens at merge time when all per-assembly + /// maps are combined. Per-assembly maps write all 3 line variants unconditionally. + /// + public static void Write (TextWriter writer, IEnumerable peers) + { + foreach (var peer in peers.OrderBy (p => p.ManagedTypeName, StringComparer.Ordinal)) { + string javaKey = peer.JavaName.Replace ('/', '.'); + string managedKey = peer.ManagedTypeName; + string partialAsmQualifiedName = $"{managedKey}, {peer.AssemblyName}"; + string compatJniName = peer.CompatJniName.Replace ('/', '.'); + + // Line 1: PartialAssemblyQualifiedName;JavaKey + writer.Write (partialAsmQualifiedName); + writer.Write (';'); + writer.WriteLine (javaKey); + + // Line 2: ManagedKey;JavaKey + writer.Write (managedKey); + writer.Write (';'); + writer.WriteLine (javaKey); + + // Line 3: CompatJniName;JavaKey + writer.Write (compatJniName); + writer.Write (';'); + writer.WriteLine (javaKey); + } + } + + /// + /// Writes acw-map lines to a file, only updating the file if the content has changed. + /// Returns true if the file was written (content changed or file didn't exist). + /// + public static bool WriteToFile (string filePath, IEnumerable peers) + { + using var memoryStream = new MemoryStream (); + using (var writer = new StreamWriter (memoryStream, new System.Text.UTF8Encoding (encoderShouldEmitUTF8Identifier: false), bufferSize: 1024, leaveOpen: true)) { + Write (writer, peers); + } + + memoryStream.Position = 0; + byte[] newContent = memoryStream.ToArray (); + + if (File.Exists (filePath)) { + byte[] existingContent = File.ReadAllBytes (filePath); + if (existingContent.AsSpan ().SequenceEqual (newContent)) { + return false; + } + } + + string? directory = Path.GetDirectoryName (filePath); + if (directory != null) { + Directory.CreateDirectory (directory); + } + + File.WriteAllBytes (filePath, newContent); + return true; + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 6bb7062d75b..c7344d900b9 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -1,6 +1,6 @@ + JCW .java source files with registerNatives, and per-assembly acw-map files. --> @@ -14,6 +14,7 @@ <_TypeMapAssemblyName>_Microsoft.Android.TypeMaps <_TypeMapOutputDirectory>$(IntermediateOutputPath)typemap\ <_TypeMapJavaOutputDirectory>$(IntermediateOutputPath)typemap\java + <_PerAssemblyAcwMapDirectory>$(IntermediateOutputPath)acw-maps\ @@ -41,18 +42,54 @@ ResolvedAssemblies="@(_ResolvedAssemblies)" OutputDirectory="$(_TypeMapOutputDirectory)" JavaSourceOutputDirectory="$(_TypeMapJavaOutputDirectory)" + AcwMapDirectory="$(_PerAssemblyAcwMapDirectory)" TargetFrameworkVersion="$(TargetFrameworkVersion)"> + + + + + + <_PerAssemblyAcwMapFiles Include="$(_PerAssemblyAcwMapDirectory)*.txt" /> + + + + + + + + + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index f04d919153d..bb731a0aa12 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -12,9 +12,9 @@ namespace Xamarin.Android.Tasks; /// -/// Generates trimmable TypeMap assemblies and JCW Java source files from resolved assemblies. -/// Runs before the trimmer to produce per-assembly typemap .dll files and a root -/// _Microsoft.Android.TypeMaps.dll, plus .java files for ACW types with registerNatives. +/// Generates trimmable TypeMap assemblies, JCW Java source files, and per-assembly +/// acw-map files from resolved assemblies. The acw-map files are later merged into +/// a single acw-map.txt consumed by _ConvertCustomView for layout XML fixups. /// public class GenerateTrimmableTypeMap : AndroidTask { @@ -29,6 +29,12 @@ public class GenerateTrimmableTypeMap : AndroidTask [Required] public string JavaSourceOutputDirectory { get; set; } = ""; + /// + /// Directory for per-assembly acw-map.{AssemblyName}.txt files. + /// + [Required] + public string AcwMapDirectory { get; set; } = ""; + /// /// The .NET target framework version (e.g., "v11.0"). Used to set the System.Runtime /// assembly reference version in generated typemap assemblies. @@ -42,6 +48,13 @@ public class GenerateTrimmableTypeMap : AndroidTask [Output] public ITaskItem []? GeneratedJavaFiles { get; set; } + /// + /// Per-assembly acw-map files produced during scanning. Each file contains + /// ManagedName;JavaName lines for types in that assembly. + /// + [Output] + public ITaskItem []? PerAssemblyAcwMapFiles { get; set; } + public override bool RunTask () { var systemRuntimeVersion = ParseTargetFrameworkVersion (TargetFrameworkVersion); @@ -49,6 +62,7 @@ public override bool RunTask () Directory.CreateDirectory (OutputDirectory); Directory.CreateDirectory (JavaSourceOutputDirectory); + Directory.CreateDirectory (AcwMapDirectory); var allPeers = ScanAssemblies (assemblyPaths); if (allPeers.Count == 0) { @@ -58,6 +72,7 @@ public override bool RunTask () GeneratedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, assemblyPaths); GeneratedJavaFiles = GenerateJcwJavaSources (allPeers); + PerAssemblyAcwMapFiles = GeneratePerAssemblyAcwMaps (allPeers); return !Log.HasLoggedErrors; } @@ -153,6 +168,32 @@ ITaskItem [] GenerateJcwJavaSources (List allPeers) return items; } + ITaskItem [] GeneratePerAssemblyAcwMaps (List allPeers) + { + var peersByAssembly = allPeers + .GroupBy (p => p.AssemblyName, StringComparer.Ordinal) + .OrderBy (g => g.Key, StringComparer.Ordinal); + + var outputFiles = new List (); + + foreach (var group in peersByAssembly) { + var peers = group.ToList (); + string outputFile = Path.Combine (AcwMapDirectory, $"acw-map.{group.Key}.txt"); + bool written = AcwMapWriter.WriteToFile (outputFile, peers); + + Log.LogDebugMessage (written + ? $" acw-map.{group.Key}.txt: {peers.Count} types" + : $" acw-map.{group.Key}.txt: unchanged"); + + var item = new TaskItem (outputFile); + item.SetMetadata ("AssemblyName", group.Key); + outputFiles.Add (item); + } + + Log.LogDebugMessage ($"Generated {outputFiles.Count} per-assembly ACW map files."); + return outputFiles.ToArray (); + } + static Version ParseTargetFrameworkVersion (string tfv) { if (tfv.Length > 0 && (tfv [0] == 'v' || tfv [0] == 'V')) { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs new file mode 100644 index 00000000000..b7a31280a34 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class AcwMapWriterTests : FixtureTestBase +{ + static string WriteToString (IEnumerable peers) + { + using var writer = new StringWriter (); + AcwMapWriter.Write (writer, peers); + return writer.ToString (); + } + + [Fact] + public void Write_SingleMcwType_ProducesThreeLines () + { + var peer = MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"); + + var output = WriteToString (new [] { peer }); + var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + + Assert.Equal (3, lines.Length); + // Line 1: PartialAssemblyQualifiedName;JavaKey + Assert.Equal ("Android.App.Activity, Mono.Android;android.app.Activity", lines [0]); + // Line 2: ManagedKey;JavaKey + Assert.Equal ("Android.App.Activity;android.app.Activity", lines [1]); + // Line 3: CompatJniName;JavaKey + Assert.Equal ("android.app.Activity;android.app.Activity", lines [2]); + } + + [Fact] + public void Write_UserType_SlashesConvertedToDots () + { + var peer = new JavaPeerInfo { + JavaName = "crc64abcdef/MyActivity", + CompatJniName = "my.namespace/MyActivity", + ManagedTypeName = "My.Namespace.MyActivity", + ManagedTypeNamespace = "My.Namespace", + ManagedTypeShortName = "MyActivity", + AssemblyName = "MyApp", + }; + + var output = WriteToString (new [] { peer }); + var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + + Assert.Equal (3, lines.Length); + Assert.Equal ("My.Namespace.MyActivity, MyApp;crc64abcdef.MyActivity", lines [0]); + Assert.Equal ("My.Namespace.MyActivity;crc64abcdef.MyActivity", lines [1]); + Assert.Equal ("my.namespace.MyActivity;crc64abcdef.MyActivity", lines [2]); + } + + [Fact] + public void Write_MultipleTypes_OrderedByManagedName () + { + var peers = new [] { + MakeMcwPeer ("android/widget/TextView", "Android.Widget.TextView", "Mono.Android"), + MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"), + MakeMcwPeer ("android/content/Context", "Android.Content.Context", "Mono.Android"), + }; + + var output = WriteToString (peers); + var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + + // 3 types × 3 lines each = 9 lines + Assert.Equal (9, lines.Length); + + // First type alphabetically: Android.App.Activity + Assert.StartsWith ("Android.App.Activity, Mono.Android;", lines [0]); + // Second: Android.Content.Context + Assert.StartsWith ("Android.Content.Context, Mono.Android;", lines [3]); + // Third: Android.Widget.TextView + Assert.StartsWith ("Android.Widget.TextView, Mono.Android;", lines [6]); + } + + [Fact] + public void Write_EmptyList_ProducesEmptyOutput () + { + var output = WriteToString (Array.Empty ()); + Assert.Equal ("", output); + } + + [Fact] + public void Write_MatchesExpectedAcwMapFormat () + { + // Verify the format matches what LoadMapFile expects: + // each line is "key;value" where LoadMapFile splits on ';' + var peer = MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"); + + var output = WriteToString (new [] { peer }); + + foreach (var line in output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None)) { + var parts = line.Split (new [] { ';' }, count: 2); + Assert.Equal (2, parts.Length); + Assert.False (string.IsNullOrWhiteSpace (parts [0]), "Key should not be empty"); + Assert.False (string.IsNullOrWhiteSpace (parts [1]), "Value should not be empty"); + } + } + + [Fact] + public void Write_FromScannedFixtures_ProducesValidOutput () + { + var peers = ScanFixtures (); + Assert.NotEmpty (peers); + + var output = WriteToString (peers); + + foreach (var line in output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None)) { + var parts = line.Split (new [] { ';' }, count: 2); + Assert.Equal (2, parts.Length); + // No slashes in the output — they should all be converted to dots + Assert.DoesNotContain ("/", parts [1]); + } + } +} From c0fb36c83d06aeeba46bb435601d0a50143cf7f2 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 19 Mar 2026 16:03:12 +0100 Subject: [PATCH 2/3] Fix duplicate _PerAssemblyAcwMapFiles and AfterTargets chain Clear @(_PerAssemblyAcwMapFiles) before re-globbing in _CollectPerAssemblyAcwMaps to avoid duplicate items when _GenerateJavaStubs runs (task Output + glob both populate the group). Replace chained AfterTargets with DependsOnTargets for internal ordering, keeping a single AfterTargets hook on _MergeAcwMaps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index c7344d900b9..b056316d7db 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -60,20 +60,30 @@ + DependsOnTargets="_GenerateJavaStubs"> + <_PerAssemblyAcwMapFiles Remove="@(_PerAssemblyAcwMapFiles)" /> <_PerAssemblyAcwMapFiles Include="$(_PerAssemblyAcwMapDirectory)*.txt" /> + From 0d2d9085e742884f30774cd6e3ca96495eec57a4 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 19 Mar 2026 16:27:11 +0100 Subject: [PATCH 3/3] Address review: fix line endings in tests, update docs, add AcwMapDirectory to test call sites - Normalize line endings in AcwMapWriterTests (Split on \n after replacing \r\n) so tests pass on Windows - Fix PerAssemblyAcwMapFiles XML doc to describe actual 3-line format - Add AcwMapDirectory to GenerateTrimmableTypeMap test call sites - Clarify AcwMapWriter comment: no conflict detection, LoadMapFile uses first-entry-wins for duplicate keys Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/AcwMapWriter.cs | 5 ++-- .../Tasks/GenerateTrimmableTypeMap.cs | 3 +- .../Tasks/GenerateTrimmableTypeMapTests.cs | 2 ++ .../Generator/AcwMapWriterTests.cs | 29 ++++++++++--------- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs index e725f0c4245..2f7ed007710 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs @@ -22,8 +22,9 @@ public static class AcwMapWriter { /// /// Writes acw-map lines for the given to the . - /// Conflict detection is NOT performed here — it happens at merge time when all per-assembly - /// maps are combined. Per-assembly maps write all 3 line variants unconditionally. + /// Per-assembly maps write all 3 line variants unconditionally. No conflict detection + /// is performed — the merged acw-map.txt is a simple concatenation consumed by + /// LoadMapFile which uses first-entry-wins semantics for duplicate keys. /// public static void Write (TextWriter writer, IEnumerable peers) { diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index bb731a0aa12..ec0e58fe72a 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -50,7 +50,8 @@ public class GenerateTrimmableTypeMap : AndroidTask /// /// Per-assembly acw-map files produced during scanning. Each file contains - /// ManagedName;JavaName lines for types in that assembly. + /// three lines per type: PartialAssemblyQualifiedName;JavaKey, + /// ManagedKey;JavaKey, and CompatJniName;JavaKey. /// [Output] public ITaskItem []? PerAssemblyAcwMapFiles { get; set; } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs index b55c9acf734..3c4665ae958 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs @@ -158,6 +158,7 @@ public void Execute_InvalidTargetFrameworkVersion_Fails () ResolvedAssemblies = [], OutputDirectory = outputDir, JavaSourceOutputDirectory = javaDir, + AcwMapDirectory = Path.Combine (Root, path, "acw-maps"), TargetFrameworkVersion = "not-a-version", }; @@ -211,6 +212,7 @@ GenerateTrimmableTypeMap CreateTask (ITaskItem [] assemblies, string outputDir, ResolvedAssemblies = assemblies, OutputDirectory = outputDir, JavaSourceOutputDirectory = javaDir, + AcwMapDirectory = Path.Combine (outputDir, "..", "acw-maps"), TargetFrameworkVersion = tfv, }; } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs index b7a31280a34..0f2945402de 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs @@ -7,11 +7,15 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; public class AcwMapWriterTests : FixtureTestBase { - static string WriteToString (IEnumerable peers) + static string [] WriteLines (IEnumerable peers) { using var writer = new StringWriter (); AcwMapWriter.Write (writer, peers); - return writer.ToString (); + var output = writer.ToString ().TrimEnd (); + if (output.Length == 0) { + return Array.Empty (); + } + return output.Split (new [] { Environment.NewLine }, StringSplitOptions.None); } [Fact] @@ -19,8 +23,7 @@ public void Write_SingleMcwType_ProducesThreeLines () { var peer = MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"); - var output = WriteToString (new [] { peer }); - var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + var lines = WriteLines (new [] { peer }); Assert.Equal (3, lines.Length); // Line 1: PartialAssemblyQualifiedName;JavaKey @@ -43,8 +46,7 @@ public void Write_UserType_SlashesConvertedToDots () AssemblyName = "MyApp", }; - var output = WriteToString (new [] { peer }); - var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + var lines = WriteLines (new [] { peer }); Assert.Equal (3, lines.Length); Assert.Equal ("My.Namespace.MyActivity, MyApp;crc64abcdef.MyActivity", lines [0]); @@ -61,8 +63,7 @@ public void Write_MultipleTypes_OrderedByManagedName () MakeMcwPeer ("android/content/Context", "Android.Content.Context", "Mono.Android"), }; - var output = WriteToString (peers); - var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + var lines = WriteLines (peers); // 3 types × 3 lines each = 9 lines Assert.Equal (9, lines.Length); @@ -78,8 +79,8 @@ public void Write_MultipleTypes_OrderedByManagedName () [Fact] public void Write_EmptyList_ProducesEmptyOutput () { - var output = WriteToString (Array.Empty ()); - Assert.Equal ("", output); + var lines = WriteLines (Array.Empty ()); + Assert.Empty (lines); } [Fact] @@ -89,9 +90,9 @@ public void Write_MatchesExpectedAcwMapFormat () // each line is "key;value" where LoadMapFile splits on ';' var peer = MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"); - var output = WriteToString (new [] { peer }); + var lines = WriteLines (new [] { peer }); - foreach (var line in output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None)) { + foreach (var line in lines) { var parts = line.Split (new [] { ';' }, count: 2); Assert.Equal (2, parts.Length); Assert.False (string.IsNullOrWhiteSpace (parts [0]), "Key should not be empty"); @@ -105,9 +106,9 @@ public void Write_FromScannedFixtures_ProducesValidOutput () var peers = ScanFixtures (); Assert.NotEmpty (peers); - var output = WriteToString (peers); + var lines = WriteLines (peers); - foreach (var line in output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None)) { + foreach (var line in lines) { var parts = line.Split (new [] { ';' }, count: 2); Assert.Equal (2, parts.Length); // No slashes in the output — they should all be converted to dots