feat(i18n): Add runtime language packs and language switching (#1)
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Media.Media3D;
|
||||
using CADability.GeoObject;
|
||||
using STPViewer.Models;
|
||||
using STPViewer.Services;
|
||||
using STPViewer.ViewModels;
|
||||
using IOPath = System.IO.Path;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
internal static class LocalizationTest
|
||||
{
|
||||
private static readonly Regex FormatIndex = new(@"(?<!\{)\{(\d+)(?:[^{}]*)\}(?!\})", RegexOptions.Compiled);
|
||||
|
||||
public static int Run()
|
||||
{
|
||||
int failures = 0;
|
||||
Exception? error = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try { failures = RunCore(); }
|
||||
catch (Exception ex) { error = ex; }
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
if (error is not null)
|
||||
{
|
||||
Console.WriteLine($"LocalizationTest: {error}");
|
||||
return 1;
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int RunCore()
|
||||
{
|
||||
int failures = CheckTemporaryPacks() + CheckBundledPacks() + CheckViewModel() + CheckSettings();
|
||||
Console.WriteLine(failures == 0 ? "LocalizationTest: all passed" : $"LocalizationTest: {failures} failed");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int CheckTemporaryPacks()
|
||||
{
|
||||
int failures = 0;
|
||||
string directory = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_i18n_" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
failures += Check("missing directory loads without languages", new LocalizationService(directory).Languages.Count == 0);
|
||||
Directory.CreateDirectory(directory);
|
||||
failures += Check("empty directory loads without languages", new LocalizationService(directory).Languages.Count == 0);
|
||||
WritePack(directory, "en", "English", new()
|
||||
{
|
||||
["Ui.Sample"] = "View", ["Ui.Fallback"] = "Fallback {0}",
|
||||
["Ui.BadBrace"] = "English {0}", ["Ui.BadIndex"] = "English {0}",
|
||||
["Ui.BothBad"] = "English {"
|
||||
});
|
||||
WritePack(directory, "zh_cn", "Simplified", new()
|
||||
{
|
||||
["Ui.Sample"] = "CN_VIEW", ["Ui.BadBrace"] = "Chinese {",
|
||||
["Ui.BadIndex"] = "Chinese {1}", ["Ui.BothBad"] = "Chinese {"
|
||||
});
|
||||
WritePack(directory, "zh_tw", "Traditional", new() { ["Ui.Sample"] = "TW_VIEW" });
|
||||
WritePack(directory, "pirate_dialect", "Pirate", new() { ["Ui.Sample"] = "ARR_VIEW" });
|
||||
File.WriteAllText(IOPath.Combine(directory, "broken.json"), "{invalid json");
|
||||
File.WriteAllText(IOPath.Combine(directory, "no_key.json"), "{\"data\":{\"Ui.Sample\":\"unused\"}}");
|
||||
File.WriteAllText(IOPath.Combine(directory, "no_data.json"), "{\"key\":\"Missing data\"}");
|
||||
File.WriteAllText(IOPath.Combine(directory, "non_string.json"), "{\"key\":\"Invalid\",\"data\":{\"Ui.Sample\":42}}");
|
||||
|
||||
CultureInfo oldUi = CultureInfo.CurrentUICulture;
|
||||
LocalizationService service;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
|
||||
service = new LocalizationService(directory);
|
||||
}
|
||||
finally { CultureInfo.CurrentUICulture = oldUi; }
|
||||
|
||||
failures += Check("valid packs isolated from malformed files", service.Languages.Count == 4 &&
|
||||
service.Languages.Select(l => l.Id).Order().SequenceEqual(new[] { "en", "pirate_dialect", "zh_cn", "zh_tw" }));
|
||||
failures += Check("display names and data loaded", service.Languages.Any(l =>
|
||||
l.Id == "pirate_dialect" && l.Key == "Pirate" && l.Data["Ui.Sample"] == "ARR_VIEW"));
|
||||
failures += Check("English selected from system culture", service.Language == "en");
|
||||
failures += Check("legacy Chinese IDs resolved", service.ResolveLanguage("zh-CN") == "zh_cn" &&
|
||||
service.ResolveLanguage("zh-TW") == "zh_tw");
|
||||
failures += Check("Chinese script and region aliases resolved", service.ResolveLanguage("zh-Hans") == "zh_cn" &&
|
||||
service.ResolveLanguage("zh-SG") == "zh_cn" && service.ResolveLanguage("zh-Hant") == "zh_tw" &&
|
||||
service.ResolveLanguage("zh-HK") == "zh_tw" && service.ResolveLanguage("zh-MO") == "zh_tw");
|
||||
failures += Check("custom language ID resolved", service.ResolveLanguage("pirate-dialect") == "pirate_dialect");
|
||||
failures += Check("unknown language falls back", service.ResolveLanguage("unknown") == "en");
|
||||
failures += Check("invalid custom ID falls back", service.ResolveLanguage("pirate_other") == "en");
|
||||
|
||||
CultureInfo beforeNull = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("zh-HK");
|
||||
failures += Check("null language uses system UI culture", service.ResolveLanguage(null) == "zh_tw");
|
||||
}
|
||||
finally { CultureInfo.CurrentUICulture = beforeNull; }
|
||||
|
||||
LanguageDefinition chinese = service.Languages.Single(l => l.Id == "zh_cn");
|
||||
bool selectedNotified = false, indexerNotified = false;
|
||||
chinese.PropertyChanged += (_, e) => selectedNotified |= e.PropertyName == nameof(LanguageDefinition.IsSelected);
|
||||
((INotifyPropertyChanged)service).PropertyChanged += (_, e) => indexerNotified |= e.PropertyName == "Item[]";
|
||||
service.SetLanguage("zh-CN");
|
||||
failures += Check("selection and indexer notify", service.Language == "zh_cn" &&
|
||||
chinese.IsSelected && selectedNotified && indexerNotified);
|
||||
failures += Check("selected data and fallback", service["Ui.Sample"] == "CN_VIEW" &&
|
||||
service["Ui.Fallback"] == "Fallback {0}" && service["Ui.Missing"] == "Ui.Missing");
|
||||
failures += Check("malformed brace uses English format", service.FormatText("Ui.BadBrace", "value") == "English value");
|
||||
failures += Check("missing format argument uses English format", service.FormatText("Ui.BadIndex", "value") == "English value");
|
||||
failures += Check("invalid English format returns key", service.FormatText("Ui.BothBad", "value") == "Ui.BothBad");
|
||||
service.SetLanguage("pirate_dialect");
|
||||
failures += Check("custom language selected", service.Language == "pirate_dialect" &&
|
||||
service["Ui.Sample"] == "ARR_VIEW");
|
||||
|
||||
WritePack(directory, "new_language", "New language", new() { ["Ui.Sample"] = "NEW_VIEW" });
|
||||
WritePack(directory, "en", "English", new() { ["Ui.Sample"] = "Updated view", ["Ui.Fallback"] = "Fallback {0}" });
|
||||
failures += Check("existing instance does not hot reload", service.Languages.Count == 4 &&
|
||||
service.Languages.Single(l => l.Id == "en").Data["Ui.Sample"] == "View");
|
||||
var reloaded = new LocalizationService(directory);
|
||||
failures += Check("new instance rescans files and values", reloaded.Languages.Count == 5 &&
|
||||
reloaded.Languages.Single(l => l.Id == "en").Data["Ui.Sample"] == "Updated view");
|
||||
|
||||
File.Delete(IOPath.Combine(directory, "zh_tw.json"));
|
||||
var withoutTraditional = new LocalizationService(directory);
|
||||
failures += Check("missing language not listed", withoutTraditional.Languages.All(l => l.Id != "zh_tw"));
|
||||
failures += Check("missing legacy ID falls back", withoutTraditional.ResolveLanguage("zh-TW") == "en");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static void WritePack(string directory, string id, string name, Dictionary<string, string> data) =>
|
||||
File.WriteAllText(IOPath.Combine(directory, id + ".json"), JsonSerializer.Serialize(new { key = name, data }));
|
||||
|
||||
private static int CheckBundledPacks()
|
||||
{
|
||||
int failures = 0;
|
||||
LocalizationService service = LocalizationService.Instance;
|
||||
failures += Check("bundled languages available", new[] { "en", "zh_cn", "zh_tw" }
|
||||
.All(id => service.Languages.Any(l => l.Id == id)));
|
||||
LanguageDefinition? english = service.Languages.FirstOrDefault(l => l.Id == "en");
|
||||
if (english is null) return failures;
|
||||
failures += Check("bundled English data nonempty", english.Data.Count > 0 &&
|
||||
english.Data.Values.All(v => !string.IsNullOrWhiteSpace(v)));
|
||||
int formatFailures = 0;
|
||||
foreach (LanguageDefinition language in service.Languages)
|
||||
foreach ((string key, string value) in language.Data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) formatFailures++;
|
||||
int[] indices = Indices(value);
|
||||
if (english.Data.TryGetValue(key, out string? fallback) && !Indices(fallback).SequenceEqual(indices))
|
||||
{
|
||||
Console.WriteLine($" FAIL: {language.Id} format arguments differ for {key}");
|
||||
formatFailures++;
|
||||
}
|
||||
object[] args = Enumerable.Range(0, indices.DefaultIfEmpty(-1).Max() + 1)
|
||||
.Select(i => (object)i).ToArray();
|
||||
try { _ = string.Format(CultureInfo.InvariantCulture, value, args); }
|
||||
catch (FormatException)
|
||||
{
|
||||
Console.WriteLine($" FAIL: {language.Id} format syntax for {key}");
|
||||
formatFailures++;
|
||||
}
|
||||
}
|
||||
failures += Check("bundled data and format strings valid", formatFailures == 0);
|
||||
failures += Check("missing key returns key", service["__missing_localization_key__"] == "__missing_localization_key__");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int CheckViewModel()
|
||||
{
|
||||
int failures = 0;
|
||||
LocalizationService service = LocalizationService.Instance;
|
||||
string originalLanguage = service.Language;
|
||||
CultureInfo numericCulture = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
service.SetLanguage("en");
|
||||
var vm = new MainViewModel();
|
||||
vm.InitializeLocalization();
|
||||
try
|
||||
{
|
||||
string oldLabel = vm.QuickBar["Import"].Label;
|
||||
bool labelChanged = false;
|
||||
vm.QuickBar["Import"].PropertyChanged += (_, e) =>
|
||||
labelChanged |= e.PropertyName == nameof(QuickBarItemViewModel.Label);
|
||||
|
||||
MeasurementResult result = new MeasurementService().MeasureDistance(
|
||||
new Point3D(0, 0, 0), new Point3D(25.4, 0, 0), "D1", 0.2);
|
||||
vm.Measurements.Add(result);
|
||||
vm.UseInch = true;
|
||||
string oneInch = 1d.ToString("F4", numericCulture) + " in";
|
||||
string beforeTitle = result.Title, beforeDetail = result.Detail;
|
||||
string beforeLabel = result.DynamicLabels.Single().Label.Text;
|
||||
var (distanceLabel, distanceTextFor) = result.DynamicLabels.Single();
|
||||
int labelRefreshes = 0;
|
||||
result.DynamicLabels[0] = (distanceLabel, u =>
|
||||
{
|
||||
labelRefreshes++;
|
||||
return distanceTextFor(u);
|
||||
});
|
||||
bool titleChanged = false, detailChanged = false;
|
||||
result.PropertyChanged += (_, e) =>
|
||||
{
|
||||
titleChanged |= e.PropertyName == nameof(MeasurementResult.Title);
|
||||
detailChanged |= e.PropertyName == nameof(MeasurementResult.Detail);
|
||||
};
|
||||
|
||||
vm.ChangeLanguageCommand.Execute("zh_tw");
|
||||
failures += Check("VM selects language", service.Language == "zh_tw" &&
|
||||
service.Languages.Single(l => l.Id == "zh_tw").IsSelected);
|
||||
failures += Check("existing distance detail translated", beforeDetail != result.Detail &&
|
||||
titleChanged && detailChanged && result.Detail.Contains(oneInch, StringComparison.Ordinal));
|
||||
failures += Check("distance and number culture preserved", vm.UseInch &&
|
||||
CultureInfo.CurrentCulture.Equals(numericCulture) && beforeTitle == result.Title &&
|
||||
result.Title.Contains(oneInch, StringComparison.Ordinal));
|
||||
failures += Check("3D distance label refreshed", labelRefreshes == 1 &&
|
||||
beforeLabel == oneInch && result.DynamicLabels.Single().Label.Text == beforeLabel);
|
||||
failures += Check("quick bar label refreshed", labelChanged && vm.QuickBar["Import"].Label != oldLabel);
|
||||
|
||||
LanguageDefinition selected = service.Languages.Single(l => l.Id == "zh_tw");
|
||||
bool reselected = false;
|
||||
selected.PropertyChanged += (_, e) => reselected |= e.PropertyName == nameof(LanguageDefinition.IsSelected);
|
||||
vm.ChangeLanguageCommand.Execute("zh_tw");
|
||||
failures += Check("reselecting current language restores check", reselected && selected.IsSelected);
|
||||
}
|
||||
finally { vm.DisposeLocalization(); }
|
||||
failures += CheckImportedNames(service);
|
||||
}
|
||||
finally { service.SetLanguage(originalLanguage); }
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int CheckImportedNames(LocalizationService service)
|
||||
{
|
||||
int failures = 0;
|
||||
MethodInfo blockNode = typeof(StepImportService).GetMethod("BlockNode", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var importer = new StepImportService();
|
||||
service.SetLanguage("en");
|
||||
|
||||
ImportedNode Import(Block block) => (ImportedNode)blockNode.Invoke(importer, [block])!;
|
||||
var namedSolidBlock = Block.Construct();
|
||||
namedSolidBlock.Name = "Solid";
|
||||
namedSolidBlock.Add(Solid.Construct());
|
||||
ImportedNode namedSolid = Import(namedSolidBlock);
|
||||
var namedShellBlock = Block.Construct();
|
||||
namedShellBlock.Name = "Shell";
|
||||
namedShellBlock.Add(Shell.Construct());
|
||||
ImportedNode namedShell = Import(namedShellBlock);
|
||||
var defaultSolidBlock = Block.Construct();
|
||||
defaultSolidBlock.Add(Solid.Construct());
|
||||
ImportedNode defaultSolid = Import(defaultSolidBlock);
|
||||
var defaultShellBlock = Block.Construct();
|
||||
defaultShellBlock.Add(Shell.Construct());
|
||||
ImportedNode defaultShellLeaf = Import(defaultShellBlock).Children.Single();
|
||||
var collisionBlock = Block.Construct();
|
||||
var realComponentShell = Shell.Construct();
|
||||
realComponentShell.Name = "Component";
|
||||
collisionBlock.Add(realComponentShell);
|
||||
ImportedNode collision = Import(collisionBlock);
|
||||
|
||||
ModelNodeViewModel ViewModel(ImportedNode node) => new(0)
|
||||
{
|
||||
Name = node.Name,
|
||||
NameKey = node.NameKey,
|
||||
NameNumber = node.NameNumber
|
||||
};
|
||||
var namedSolidVm = ViewModel(namedSolid);
|
||||
var namedShellVm = ViewModel(namedShell);
|
||||
var defaultSolidVm = ViewModel(defaultSolid);
|
||||
var defaultShellVm = ViewModel(defaultShellLeaf);
|
||||
var collisionVm = ViewModel(collision);
|
||||
service.SetLanguage("zh_cn");
|
||||
failures += Check("explicit solid name retained", namedSolid.NameKey is null && namedSolidVm.Name == "Solid");
|
||||
failures += Check("real block name retained", namedShell.NameKey is null && namedShellVm.Name == "Shell");
|
||||
failures += Check("default solid name translated", defaultSolid.NameKey == "Runtime.SolidName" &&
|
||||
defaultSolidVm.Name == service["Runtime.SolidName"]);
|
||||
failures += Check("default shell name translated", defaultShellLeaf.NameKey == "Runtime.ShellName" &&
|
||||
defaultShellVm.Name == service["Runtime.ShellName"]);
|
||||
failures += Check("real child name wins collision", collision.NameKey is null && collisionVm.Name == "Component");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int CheckSettings()
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
Language = "zh-CN", WindowWidth = 1200, WindowHeight = 800, WindowLeft = 100, WindowTop = 100
|
||||
};
|
||||
AppSettings? restored = JsonSerializer.Deserialize<AppSettings>(JsonSerializer.Serialize(settings));
|
||||
return Check("legacy ID round trips through settings JSON", restored?.Language == "zh-CN" &&
|
||||
LocalizationService.Instance.ResolveLanguage(restored.Language) == "zh_cn");
|
||||
}
|
||||
|
||||
private static int[] Indices(string format) => FormatIndex.Matches(format)
|
||||
.Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))
|
||||
.Distinct().Order().ToArray();
|
||||
|
||||
private static int Check(string name, bool ok)
|
||||
{
|
||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user