diff --git a/Directory.Packages.props b/Directory.Packages.props index ae4c326..4eacb2f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,18 +2,18 @@ true - + + - + - + \ No newline at end of file diff --git a/README.md b/README.md index fc22aef..0145e3d 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ Without it, such a project is reported as `RESWP0005` and no code is generated f 🗨 [How to inject generated resources](docs/Injectable-resource-providers.md) +### Pseudo-localization + +🗨 [How to test localization before translations are available](docs/Pseudo-localization.md) + ### Generator performance diagnostics To include compiler-measured source-generator timings in detailed build output, enable: diff --git a/ReswPlus.slnx b/ReswPlus.slnx index 0de3f81..3354543 100644 --- a/ReswPlus.slnx +++ b/ReswPlus.slnx @@ -7,6 +7,7 @@ + diff --git a/docs/Pseudo-localization.md b/docs/Pseudo-localization.md new file mode 100644 index 0000000..77cbd45 --- /dev/null +++ b/docs/Pseudo-localization.md @@ -0,0 +1,103 @@ +# Pseudo-localization + +Pseudo-localization generates artificial translations from the project's default-language `.resw` files. +It makes localization problems visible before real translations are available: + +- accented characters reveal hard-coded UI strings +- expanded values reveal clipped text and fixed-width layouts +- mirrored text reveals right-to-left layout assumptions +- visible boundary markers reveal unintended trimming or concatenation + +The generated files are build artifacts under `obj`; ReswPlus never changes or adds files to the source +language folders. + +## Enable an accented pseudo-language + +Enable pseudo-localization with an MSBuild property: + +```xml + + Accented + +``` + +ReswPlus reads the `.resw` files under the project's `DefaultLanguage` folder and adds generated +`qps-ploc` resources to the PRI before Windows resource indexing. For that build, it removes the original +`.resw` items from PRI indexing and replaces the generated AppX manifest languages with the enabled +pseudo-language. The transformed intermediate resources are indexed as the package's neutral strings, which +avoids retaining a real default-language fallback. This makes the pseudo-localized resources the only strings +Windows can select. For example: + +```text +Welcome, {0}! +``` + +becomes similar to: + +```text +⟦Ŵëŀçømë, {0}! ~~~~~~~⟧ +``` + +Composite-format placeholders, escaped braces, and XML-like markup inside values are preserved. +Resource names, comments, and `#Format` declarations are not transformed. + +## Test right-to-left layout + +Use `Mirrored` to generate the Windows `qps-plocm` pseudo-locale: + +```xml + + Mirrored + +``` + +| Mode | Windows language | Behavior | +| --- | --- | --- | +| `Accented` | `qps-ploc` | Accents characters, expands text, and adds boundary markers | +| `Mirrored` | `qps-plocm` | Applies the same transformation inside a right-to-left override | + +## Select the pseudo-language + +No application code or system-language change is required. ReswPlus accepts one pseudo-localization mode per +build, excludes the original `.resw` languages, and advertises only the selected pseudo-language in the +generated AppX manifest. Windows therefore selects it automatically when the application starts. + +Build once with `Accented` and once with `Mirrored` when a test suite needs to exercise both modes. + +### System-level availability + +Nothing needs to be installed or enabled at the system level on Windows 10 version 1803 and newer, +including Windows 11. Windows includes the `qps-*` pseudo-locales in its National Language Support +APIs, but intentionally hides them from language enumeration. They are not display-language packs, +so they cannot be selected as the Windows display language through Settings. + +On these Windows versions, registry edits do not make pseudo-locales appear in the system language +list. A ReswPlus pseudo-localization build needs no override because its real localized strings are +excluded and the selected pseudo-language is the only packaged string language. + +Windows 10 version 1709 and older allowed pseudo-locales to be exposed for enumeration by adding +their LCIDs under `HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Nls\Locale`. ReswPlus supports +modern UWP and Windows App SDK targets, so this legacy system configuration is not required. See +[Using pseudo-locales for localizability testing](https://learn.microsoft.com/windows/win32/intl/using-pseudo-locales-for-localization-testing) +for the legacy registry values and NLS details. + +## Configure text expansion + +Accented and mirrored values expand by 30 percent by default. Adjust the percentage from 0 through 200: + +```xml +50 +``` + +## Use in CI + +Pseudo-localization is disabled unless `ReswPlusPseudoLocalization` is set. Because enabling it replaces the +packaged string languages, keep it scoped to test builds. A CI job can enable it without changing the project: + +```console +dotnet build -p:ReswPlusPseudoLocalization=Accented +``` + +This verifies that the generated pseudo-language remains packageable and that all source resources can be +parsed. UI automation can then launch the built application and check for clipping, overlap, untranslated +strings, and right-to-left regressions. diff --git a/nuget/ReswPlus.SourceGenerator.NugetPackage.props b/nuget/ReswPlus.SourceGenerator.NugetPackage.props index 5f1fdfb..4df1a5a 100644 --- a/nuget/ReswPlus.SourceGenerator.NugetPackage.props +++ b/nuget/ReswPlus.SourceGenerator.NugetPackage.props @@ -23,6 +23,10 @@ + + diff --git a/nuget/ReswPlus.targets b/nuget/ReswPlus.targets index bf19306..5d0e2cf 100644 --- a/nuget/ReswPlus.targets +++ b/nuget/ReswPlus.targets @@ -17,7 +17,18 @@ true false + + None + 30 + <_ReswPlusBuildTasksAssembly Condition="'$(_ReswPlusBuildTasksAssembly)' == '' And Exists('$(MSBuildThisFileDirectory)..\tools\netstandard2.0\ReswPlus.BuildTasks.dll')">$(MSBuildThisFileDirectory)..\tools\netstandard2.0\ReswPlus.BuildTasks.dll + <_ReswPlusBuildTasksAssembly Condition="'$(_ReswPlusBuildTasksAssembly)' == '' And Exists('$(MSBuildThisFileDirectory)..\src\ReswPlus.BuildTasks\bin\$(Configuration)\netstandard2.0\ReswPlus.BuildTasks.dll')">$(MSBuildThisFileDirectory)..\src\ReswPlus.BuildTasks\bin\$(Configuration)\netstandard2.0\ReswPlus.BuildTasks.dll + + @@ -25,4 +36,55 @@ true + + + + + + + + + $(_ReswPlusPseudoLanguage) + $(_ReswPlusPseudoLanguage) + + + + + + + + + + + <_ReswPlusPseudoManifestInput>$(IntermediateOutputPath)ReswPlus\PseudoLocalization\manifest.mode + + + + + <_GenerateCurrentProjectAppxManifestInput Include="$(_ReswPlusPseudoManifestInput)" /> + + + + + + \ No newline at end of file diff --git a/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs b/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs new file mode 100644 index 0000000..bba3ab3 --- /dev/null +++ b/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace ReswPlus.BuildTasks; + +public sealed class GeneratePseudoResources : Task +{ + [Required] + public ITaskItem[] Resources { get; set; } = Array.Empty(); + + [Required] + public string DefaultLanguage { get; set; } = ""; + + [Required] + public string ProjectDirectory { get; set; } = ""; + + [Required] + public string IntermediateOutputPath { get; set; } = ""; + + [Required] + public string Modes { get; set; } = ""; + + public int ExpansionPercentage { get; set; } = 30; + + [Output] + public ITaskItem[] GeneratedResources { get; private set; } = Array.Empty(); + + [Output] + public string PseudoLanguage { get; private set; } = ""; + + public override bool Execute() + { + if (ExpansionPercentage is < 0 or > 200) + { + Log.LogError( + "ReswPlus pseudo-localization expansion must be between 0 and 200, but was {0}.", + ExpansionPercentage); + return false; + } + + IReadOnlyList<(PseudoLocalizationMode Mode, string Language)> modes; + + try + { + modes = PseudoLocalizer.ParseModes(Modes); + } + catch (ArgumentException exception) + { + Log.LogError(exception.Message); + return false; + } + + PseudoLanguage = modes[0].Language; + + var sourceResources = Resources + .Where(resource => Path.GetExtension(resource.ItemSpec).Equals(".resw", StringComparison.OrdinalIgnoreCase)) + .Select(resource => (Item: resource, LogicalPath: GetLogicalPath(resource))) + .Where(resource => ContainsLanguageFolder(resource.LogicalPath, DefaultLanguage)) + .ToArray(); + + if (sourceResources.Length == 0) + { + Log.LogError( + "ReswPlus pseudo-localization could not find a .resw file in the default-language folder '{0}'.", + DefaultLanguage); + return false; + } + + var generated = new List(); + var outputPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var source in sourceResources) + { + foreach (var mode in modes) + { + var logicalPath = RemoveLanguageFolder(source.LogicalPath, DefaultLanguage); + var outputPath = Path.GetFullPath(Path.Combine( + IntermediateOutputPath, + "ReswPlus", + "PseudoLocalization", + ToSafeRelativePath(logicalPath))); + + if (!outputPaths.Add(outputPath)) + { + Log.LogError( + "Two resources would generate the same pseudo-localized file '{0}'. Set Link metadata to give linked resources distinct paths.", + logicalPath); + return false; + } + + if (!TryGenerate(source.Item.ItemSpec, outputPath, mode.Mode)) + { + return false; + } + + var output = new TaskItem(outputPath); + output.SetMetadata("Link", logicalPath); + output.SetMetadata("TargetPath", logicalPath); + output.SetMetadata("ReswPlusPseudoLocalization", mode.Mode.ToString()); + generated.Add(output); + } + } + + GeneratedResources = generated.ToArray(); + return !Log.HasLoggedErrors; + } + + private bool TryGenerate(string sourcePath, string outputPath, PseudoLocalizationMode mode) + { + try + { + var document = XDocument.Load(sourcePath, LoadOptions.PreserveWhitespace); + + foreach (var value in document + .Descendants() + .Where(element => element.Name.LocalName == "value" && + element.Parent?.Name.LocalName == "data")) + { + value.Value = PseudoLocalizer.Transform(value.Value, mode, ExpansionPercentage); + } + + var bytes = Serialize(document); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + + if (!File.Exists(outputPath) || !File.ReadAllBytes(outputPath).SequenceEqual(bytes)) + { + File.WriteAllBytes(outputPath, bytes); + } + + return true; + } + catch (XmlException exception) + { + Log.LogError("Could not pseudo-localize '{0}': {1}", sourcePath, exception.Message); + return false; + } + catch (IOException exception) + { + Log.LogError("Could not pseudo-localize '{0}': {1}", sourcePath, exception.Message); + return false; + } + catch (UnauthorizedAccessException exception) + { + Log.LogError("Could not pseudo-localize '{0}': {1}", sourcePath, exception.Message); + return false; + } + } + + private string GetLogicalPath(ITaskItem resource) + { + var link = resource.GetMetadata("Link"); + if (!string.IsNullOrWhiteSpace(link)) + { + return link; + } + + var sourcePath = Path.GetFullPath(resource.ItemSpec); + var projectPath = Path.GetFullPath(ProjectDirectory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + if (sourcePath.StartsWith(projectPath, StringComparison.OrdinalIgnoreCase)) + { + return sourcePath.Substring(projectPath.Length); + } + + var languageDirectory = Path.GetDirectoryName(sourcePath); + var resourceDirectory = Path.GetDirectoryName(languageDirectory); + return Path.Combine( + Path.GetFileName(resourceDirectory) ?? "Strings", + Path.GetFileName(languageDirectory) ?? DefaultLanguage, + Path.GetFileName(sourcePath)); + } + + private static bool ContainsLanguageFolder(string path, string language) + { + return SplitPath(path).Any(segment => segment.Equals(language, StringComparison.OrdinalIgnoreCase)); + } + + private static string RemoveLanguageFolder(string path, string sourceLanguage) + { + var segments = SplitPath(path); + + for (var index = segments.Length - 1; index >= 0; index--) + { + if (segments[index].Equals(sourceLanguage, StringComparison.OrdinalIgnoreCase)) + { + return string.Join( + Path.DirectorySeparatorChar.ToString(), + segments.Where((_, segmentIndex) => segmentIndex != index)); + } + } + + throw new InvalidOperationException($"The path '{path}' does not contain the language '{sourceLanguage}'."); + } + + private static string[] SplitPath(string path) + { + return path.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + } + + private static string ToSafeRelativePath(string path) + { + return string.Join( + Path.DirectorySeparatorChar.ToString(), + SplitPath(path).Where(segment => segment != "." && segment != ".." && !segment.Contains(':'))); + } + + private static byte[] Serialize(XDocument document) + { + using var stream = new MemoryStream(); + using (var writer = XmlWriter.Create(stream, new XmlWriterSettings + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Indent = false, + OmitXmlDeclaration = document.Declaration is null, + })) + { + document.Save(writer); + } + + return stream.ToArray(); + } +} diff --git a/src/ReswPlus.BuildTasks/PseudoLocalizationMode.cs b/src/ReswPlus.BuildTasks/PseudoLocalizationMode.cs new file mode 100644 index 0000000..6f236d1 --- /dev/null +++ b/src/ReswPlus.BuildTasks/PseudoLocalizationMode.cs @@ -0,0 +1,7 @@ +namespace ReswPlus.BuildTasks; + +internal enum PseudoLocalizationMode +{ + Accented, + Mirrored, +} diff --git a/src/ReswPlus.BuildTasks/PseudoLocalizer.cs b/src/ReswPlus.BuildTasks/PseudoLocalizer.cs new file mode 100644 index 0000000..e7f0643 --- /dev/null +++ b/src/ReswPlus.BuildTasks/PseudoLocalizer.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace ReswPlus.BuildTasks; + +internal static class PseudoLocalizer +{ + private static readonly Regex ProtectedToken = new( + @"(\{\{|\}\}|\{[^{}\r\n]*\}|<[^>\r\n]+>)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static string Transform(string value, PseudoLocalizationMode mode, int expansionPercentage) + { + if (value.Length == 0) + { + return value; + } + + var transformed = new StringBuilder(value.Length); + var letterCount = 0; + var currentIndex = 0; + + foreach (Match match in ProtectedToken.Matches(value)) + { + AppendAccented(value, currentIndex, match.Index - currentIndex, transformed, ref letterCount); + transformed.Append(match.Value); + currentIndex = match.Index + match.Length; + } + + AppendAccented(value, currentIndex, value.Length - currentIndex, transformed, ref letterCount); + + var paddingLength = (int)Math.Ceiling(letterCount * expansionPercentage / 100d); + if (paddingLength > 0) + { + transformed.Append(' '); + transformed.Append('~', paddingLength); + } + + return mode == PseudoLocalizationMode.Mirrored + ? "\u202e\u27e6" + transformed + "\u27e7\u202c" + : "\u27e6" + transformed + "\u27e7"; + } + + public static IReadOnlyList<(PseudoLocalizationMode Mode, string Language)> ParseModes(string modes) + { + var parsed = new List<(PseudoLocalizationMode, string)>(); + var seen = new HashSet(); + + foreach (var value in modes.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + var normalized = value.Trim(); + PseudoLocalizationMode mode; + + if (normalized.Equals("true", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("accented", StringComparison.OrdinalIgnoreCase)) + { + mode = PseudoLocalizationMode.Accented; + } + else if (normalized.Equals("mirrored", StringComparison.OrdinalIgnoreCase)) + { + mode = PseudoLocalizationMode.Mirrored; + } + else + { + throw new ArgumentException( + $"Unknown pseudo-localization mode '{normalized}'. Use Accented or Mirrored.", + nameof(modes)); + } + + if (seen.Add(mode)) + { + if (seen.Count > 1) + { + throw new ArgumentException( + "Choose one pseudo-localization mode per build: Accented or Mirrored.", + nameof(modes)); + } + + parsed.Add((mode, mode == PseudoLocalizationMode.Accented ? "qps-ploc" : "qps-plocm")); + } + } + + if (parsed.Count == 0) + { + throw new ArgumentException( + "Choose one pseudo-localization mode per build: Accented or Mirrored.", + nameof(modes)); + } + + return parsed; + } + + private static void AppendAccented( + string value, + int start, + int length, + StringBuilder output, + ref int letterCount) + { + for (var index = start; index < start + length; index++) + { + var character = value[index]; + output.Append(Accent(character)); + + if (char.IsLetter(character)) + { + letterCount++; + } + } + } + + private static char Accent(char character) + { + return character switch + { + 'A' => '\u00c5', + 'B' => '\u0243', + 'C' => '\u00c7', + 'D' => '\u00d0', + 'E' => '\u00cb', + 'F' => '\u0191', + 'G' => '\u011c', + 'H' => '\u0126', + 'I' => '\u00cf', + 'J' => '\u0134', + 'K' => '\u0136', + 'L' => '\u013f', + 'M' => '\u1e40', + 'N' => '\u00d1', + 'O' => '\u00d8', + 'P' => '\u00de', + 'Q' => '\u01ea', + 'R' => '\u0158', + 'S' => '\u0160', + 'T' => '\u0166', + 'U' => '\u00dc', + 'V' => '\u1e7c', + 'W' => '\u0174', + 'X' => '\u1e8a', + 'Y' => '\u0178', + 'Z' => '\u017d', + 'a' => '\u00e5', + 'b' => '\u0180', + 'c' => '\u00e7', + 'd' => '\u00f0', + 'e' => '\u00eb', + 'f' => '\u0192', + 'g' => '\u011d', + 'h' => '\u0127', + 'i' => '\u00ef', + 'j' => '\u0135', + 'k' => '\u0137', + 'l' => '\u0140', + 'm' => '\u1e41', + 'n' => '\u00f1', + 'o' => '\u00f8', + 'p' => '\u00fe', + 'q' => '\u01eb', + 'r' => '\u0159', + 's' => '\u0161', + 't' => '\u0167', + 'u' => '\u00fc', + 'v' => '\u1e7d', + 'w' => '\u0175', + 'x' => '\u1e8b', + 'y' => '\u00ff', + 'z' => '\u017e', + _ => character, + }; + } +} diff --git a/src/ReswPlus.BuildTasks/ReswPlus.BuildTasks.csproj b/src/ReswPlus.BuildTasks/ReswPlus.BuildTasks.csproj new file mode 100644 index 0000000..ee6fcb3 --- /dev/null +++ b/src/ReswPlus.BuildTasks/ReswPlus.BuildTasks.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + enable + false + + + + + + + + + diff --git a/src/ReswPlus.BuildTasks/SetPseudoLanguagesInAppxManifest.cs b/src/ReswPlus.BuildTasks/SetPseudoLanguagesInAppxManifest.cs new file mode 100644 index 0000000..4f9f2ed --- /dev/null +++ b/src/ReswPlus.BuildTasks/SetPseudoLanguagesInAppxManifest.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace ReswPlus.BuildTasks; + +public sealed class SetPseudoLanguagesInAppxManifest : Task +{ + [Required] + public string ManifestPath { get; set; } = ""; + + [Required] + public string Modes { get; set; } = ""; + + public override bool Execute() + { + try + { + var document = XDocument.Load(ManifestPath, LoadOptions.PreserveWhitespace); + var package = document.Root; + var resources = package?.Elements().FirstOrDefault(element => element.Name.LocalName == "Resources"); + + if (resources is null) + { + Log.LogError("The generated AppX manifest '{0}' has no Resources element.", ManifestPath); + return false; + } + + var languages = PseudoLocalizer.ParseModes(Modes) + .Select(mode => mode.Language) + .ToArray(); + var existing = resources + .Elements() + .Where(element => element.Name.LocalName == "Resource") + .Select(element => element.Attribute("Language")?.Value ?? "") + .ToArray(); + var changed = !existing.SequenceEqual(languages, StringComparer.OrdinalIgnoreCase); + + if (changed) + { + resources.Elements() + .Where(element => element.Name.LocalName == "Resource") + .Remove(); + + foreach (var language in languages) + { + resources.Add(new XElement( + resources.Name.Namespace + "Resource", + new XAttribute("Language", language))); + } + + using var writer = XmlWriter.Create(ManifestPath, new XmlWriterSettings + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Indent = true, + OmitXmlDeclaration = document.Declaration is null, + }); + document.Save(writer); + } + + return true; + } + catch (ArgumentException exception) + { + Log.LogError(exception.Message); + return false; + } + catch (XmlException exception) + { + Log.LogError("Could not set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message); + return false; + } + catch (IOException exception) + { + Log.LogError("Could not set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message); + return false; + } + catch (UnauthorizedAccessException exception) + { + Log.LogError("Could not set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message); + return false; + } + } +} \ No newline at end of file diff --git a/src/ReswPlus.SourceGenerator/ReswPlus.SourceGenerator.csproj b/src/ReswPlus.SourceGenerator/ReswPlus.SourceGenerator.csproj index 8ff7ae8..1c44b69 100644 --- a/src/ReswPlus.SourceGenerator/ReswPlus.SourceGenerator.csproj +++ b/src/ReswPlus.SourceGenerator/ReswPlus.SourceGenerator.csproj @@ -26,6 +26,9 @@ + diff --git a/tests/ReswPlusUnitTests/PseudoLocalization.cs b/tests/ReswPlusUnitTests/PseudoLocalization.cs new file mode 100644 index 0000000..7baff86 --- /dev/null +++ b/tests/ReswPlusUnitTests/PseudoLocalization.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Build.Utilities; +using ReswPlus.BuildTasks; +using Xunit; + +namespace ReswPlusUnitTests; + +public class PseudoLocalization +{ + [Fact] + public void AccentedTextPreservesFormatPlaceholdersAndMarkup() + { + var transformed = PseudoLocalizer.Transform( + "Hello {0}, {name} {{literal}}", + PseudoLocalizationMode.Accented, + expansionPercentage: 30); + + Assert.StartsWith("\u27e6\u0126\u00eb\u0140\u0140\u00f8 ", transformed); + Assert.EndsWith("\u27e7", transformed); + Assert.Contains("{0}", transformed); + Assert.Contains("{name}", transformed); + Assert.Contains("{{\u0140\u00ef\u0167\u00eb\u0159\u00e5\u0140}}", transformed); + Assert.Contains("~", transformed); + } + + [Fact] + public void MirroredTextCarriesRightToLeftControlCharacters() + { + var transformed = PseudoLocalizer.Transform( + "Save", + PseudoLocalizationMode.Mirrored, + expansionPercentage: 0); + + Assert.Equal("\u202e\u27e6\u0160\u00e5\u1e7d\u00eb\u27e7\u202c", transformed); + } + + [Fact] + public void ModesMapToWindowsPseudoLocales() + { + var accented = Assert.Single(PseudoLocalizer.ParseModes("Accented")); + var mirrored = Assert.Single(PseudoLocalizer.ParseModes("Mirrored")); + + Assert.Equal((PseudoLocalizationMode.Accented, "qps-ploc"), accented); + Assert.Equal((PseudoLocalizationMode.Mirrored, "qps-plocm"), mirrored); + } + + [Fact] + public void MultipleModesAreRejected() + { + var exception = Assert.Throws(() => + PseudoLocalizer.ParseModes("Accented;Mirrored")); + + Assert.Contains("one pseudo-localization mode", exception.Message); + } + + [Fact] + public void UnknownModesAreRejected() + { + var exception = Assert.Throws(() => PseudoLocalizer.ParseModes("Expanded")); + + Assert.Contains("Accented", exception.Message); + Assert.Contains("Mirrored", exception.Message); + } + + [Fact] + public void TheBuildTaskGeneratesIntermediateResourcesFromTheDefaultLanguage() + { + var root = Path.Combine(Path.GetTempPath(), "ReswPlusUnitTests", Guid.NewGuid().ToString("N")); + var projectDirectory = Path.Combine(root, "Project"); + var sourcePath = Path.Combine(projectDirectory, "Strings", "en-US", "Resources.resw"); + var outputDirectory = Path.Combine(root, "obj"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + File.WriteAllText(sourcePath, ReswTestHelpers.CreateResw( + ("Welcome", "Welcome, {0}!", "#Format[String name]"))); + + var source = new TaskItem(sourcePath); + source.SetMetadata("Link", @"Strings\en-US\Resources.resw"); + + var task = new GeneratePseudoResources + { + Resources = [source], + DefaultLanguage = "en-US", + ProjectDirectory = projectDirectory, + IntermediateOutputPath = outputDirectory, + Modes = "Accented", + ExpansionPercentage = 30, + }; + + Assert.True(task.Execute()); + var accented = Assert.Single(task.GeneratedResources); + + Assert.Equal("qps-ploc", task.PseudoLanguage); + Assert.Equal(@"Strings\Resources.resw", accented.GetMetadata("Link")); + Assert.Equal(accented.GetMetadata("Link"), accented.GetMetadata("TargetPath")); + Assert.Equal("Accented", accented.GetMetadata("ReswPlusPseudoLocalization")); + + var document = XDocument.Load(accented.ItemSpec); + var value = document.Descendants("data").Single().Element("value")!.Value; + var comment = document.Descendants("comment").Single().Value; + + Assert.Contains("\u0174\u00eb\u0140\u00e7\u00f8\u1e41\u00eb", value); + Assert.Contains("{0}", value); + Assert.Equal("#Format[String name]", comment); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Fact] + public void PseudoLanguagesAreAddedToTheGeneratedAppxManifest() + { + var root = Path.Combine(Path.GetTempPath(), "ReswPlusUnitTests", Guid.NewGuid().ToString("N")); + var manifestPath = Path.Combine(root, "AppxManifest.xml"); + + try + { + Directory.CreateDirectory(root); + File.WriteAllText( + manifestPath, + """ + + + + + + + """); + + var task = new SetPseudoLanguagesInAppxManifest + { + ManifestPath = manifestPath, + Modes = "Accented", + }; + + Assert.True(task.Execute()); + Assert.True(task.Execute()); + + var document = XDocument.Load(manifestPath); + var languages = document + .Descendants() + .Where(element => element.Name.LocalName == "Resource") + .Select(element => element.Attribute("Language")!.Value) + .ToArray(); + + Assert.Equal(["qps-ploc"], languages); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } +} \ No newline at end of file diff --git a/tests/ReswPlusUnitTests/ReswPlusUnitTests.csproj b/tests/ReswPlusUnitTests/ReswPlusUnitTests.csproj index 4424e26..c6a16da 100644 --- a/tests/ReswPlusUnitTests/ReswPlusUnitTests.csproj +++ b/tests/ReswPlusUnitTests/ReswPlusUnitTests.csproj @@ -7,6 +7,7 @@ + @@ -22,6 +23,7 @@ ReswPlus.SourceGenerator +