Localise the UI into Russian and English, switchable without a restart
Strings move into Localization/Strings.resx plus a Russian satellite. XAML uses
a {l:Loc Key} markup extension that yields a binding through the localizer's
indexer rather than a resolved string, so changing the language raises
PropertyChanged for the indexer and every caption in the app re-reads at once.
Resolving strings once at load would have been simpler and would have needed an
app restart to take effect.
Three places where this is more than a string swap:
Russian has three plural forms, so counted messages are assembled from .One /
.Few / .Many keys via Localizer.Plural instead of "{0} records" with an English
plural glued on. "1 запись", "3 записи", "7 записей".
Enum captions in pickers go through LocalizedOption<T>. A value converter would
resolve the caption once and never notice a language change; the wrapper keeps
identity on the enum value so the selection survives, while the label follows
the localizer. It needed a non-generic base for DataTemplate x:DataType, since
Avalonia 12 compiles bindings by default and cannot infer one for an open
generic.
Text originating in the domain would otherwise have stayed English under a
Russian UI — the screenshots showed exactly that. AvParser.Core still knows
nothing about languages: ParseError now carries a Code and Arguments, and the UI
translates Parse.Error.{Code} with a fallback to the English message. Parser
names work the same way (Parser.{id}.Name falling back to DisplayName), which
keeps "add a parser = one registration line" true — an untranslated parser shows
its own name rather than a missing-key marker.
Both .resx files are generated from one table so a key cannot exist in one and
be missing from the other, and the tests assert that, plus no blank translations
and identical {0} placeholder sets — a translation that drops a placeholder
throws at runtime rather than merely reading oddly. A headless test switches
language on a live shell and asserts the rendered text changes without the tree
being rebuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9bf2ea5532
commit
8b552470b7
@@ -3,9 +3,13 @@ using AvParser.UI.ViewModels;
|
||||
namespace AvParser.UI.Tests.Fakes;
|
||||
|
||||
/// <summary>A navigation destination with no behaviour, for exercising the shell and the stack.</summary>
|
||||
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel
|
||||
/// <remarks>
|
||||
/// Takes a real resource key rather than a literal caption, so the tests exercise the same
|
||||
/// lookup the app does instead of a special case that would hide a broken key.
|
||||
/// </remarks>
|
||||
internal sealed class FakePage(string titleKey, string iconKey = "IconHome") : PageViewModel
|
||||
{
|
||||
public override string Title { get; } = title;
|
||||
public override string TitleKey { get; } = titleKey;
|
||||
|
||||
public override string IconKey { get; } = iconKey;
|
||||
}
|
||||
@@ -13,7 +17,7 @@ internal sealed class FakePage(string title, string iconKey = "IconHome") : Page
|
||||
/// <summary>A second page type, so <c>NavigateTo<TPage>()</c> has something to discriminate on.</summary>
|
||||
internal sealed class OtherFakePage : PageViewModel
|
||||
{
|
||||
public override string Title => "Other";
|
||||
public override string TitleKey => "Page.About";
|
||||
|
||||
public override string IconKey => "IconInfo";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Globalization;
|
||||
using System.Resources;
|
||||
using System.Text.RegularExpressions;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Localization;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
public sealed class LocalizationTests : IDisposable
|
||||
{
|
||||
private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en");
|
||||
private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru");
|
||||
|
||||
private static readonly ResourceManager Resources = new(
|
||||
"AvParser.UI.Localization.Strings",
|
||||
typeof(Localizer).Assembly
|
||||
);
|
||||
|
||||
/// <summary>Leaves the localizer where the rest of the suite expects it.</summary>
|
||||
public void Dispose() => Localizer.Instance.SetLanguage(AppLanguage.English);
|
||||
|
||||
/// <summary>
|
||||
/// Reads one language's resources without letting it fall back to another.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// English lives in the neutral <c>Strings.resx</c>, which resolves under the invariant
|
||||
/// culture rather than under "en". <c>tryParents: false</c> matters: with fallback on, a
|
||||
/// missing Russian satellite would quietly serve English and every comparison below would
|
||||
/// pass while the app shipped untranslated.
|
||||
/// </remarks>
|
||||
private static IReadOnlyDictionary<string, string> Read(CultureInfo culture)
|
||||
{
|
||||
var lookup = Equals(culture, English) ? CultureInfo.InvariantCulture : culture;
|
||||
|
||||
// Deliberately not disposed: ResourceManager hands back a cached instance, and disposing
|
||||
// it breaks every later read in the assembly.
|
||||
var set = Resources.GetResourceSet(lookup, createIfNotExists: true, tryParents: false);
|
||||
set.ShouldNotBeNull();
|
||||
|
||||
return set.Cast<System.Collections.DictionaryEntry>()
|
||||
.ToDictionary(
|
||||
entry => (string)entry.Key,
|
||||
entry => (string?)entry.Value ?? string.Empty,
|
||||
StringComparer.Ordinal
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Russian_translates_every_english_key()
|
||||
{
|
||||
var english = Read(English);
|
||||
var russian = Read(Russian);
|
||||
|
||||
english.ShouldNotBeEmpty();
|
||||
russian.Keys.Except(english.Keys, StringComparer.Ordinal).ShouldBeEmpty("Russian has keys English does not");
|
||||
english.Keys.Except(russian.Keys, StringComparer.Ordinal).ShouldBeEmpty("Russian is missing keys");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_translation_is_blank() =>
|
||||
Read(Russian).Where(entry => string.IsNullOrWhiteSpace(entry.Value)).Select(entry => entry.Key).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Format_placeholders_match_between_languages()
|
||||
{
|
||||
var english = Read(English);
|
||||
var russian = Read(Russian);
|
||||
|
||||
// A translation that drops a {0} throws at runtime rather than merely reading oddly, so
|
||||
// the placeholder sets have to be identical.
|
||||
var mismatched = english
|
||||
.Where(entry => Placeholders(entry.Value) != Placeholders(russian[entry.Key]))
|
||||
.Select(entry => entry.Key)
|
||||
.ToArray();
|
||||
|
||||
mismatched.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_plural_key_has_all_three_forms()
|
||||
{
|
||||
var english = Read(English);
|
||||
|
||||
// Russian needs One/Few/Many; a family missing one of them would silently render "!key!".
|
||||
var families = english
|
||||
.Keys.Where(key => key.EndsWith(".One", StringComparison.Ordinal))
|
||||
.Select(key => key[..^4])
|
||||
.ToArray();
|
||||
|
||||
families.ShouldNotBeEmpty();
|
||||
|
||||
foreach (var family in families)
|
||||
{
|
||||
foreach (var suffix in new[] { "One", "Few", "Many" })
|
||||
{
|
||||
english.ShouldContainKey($"{family}.{suffix}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "One")]
|
||||
[InlineData(2, "Few")]
|
||||
[InlineData(4, "Few")]
|
||||
[InlineData(5, "Many")]
|
||||
[InlineData(11, "Many")]
|
||||
[InlineData(12, "Many")]
|
||||
[InlineData(14, "Many")]
|
||||
[InlineData(21, "One")]
|
||||
[InlineData(22, "Few")]
|
||||
[InlineData(25, "Many")]
|
||||
[InlineData(101, "One")]
|
||||
[InlineData(111, "Many")]
|
||||
[InlineData(0, "Many")]
|
||||
public void Russian_plural_rules(int count, string expected) =>
|
||||
Localizer.SuffixFor(count, Russian).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "Many")]
|
||||
[InlineData(1, "One")]
|
||||
[InlineData(2, "Many")]
|
||||
[InlineData(21, "Many")]
|
||||
public void English_plural_rules(int count, string expected) =>
|
||||
Localizer.SuffixFor(count, English).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Plural_lookup_picks_the_right_form()
|
||||
{
|
||||
Localizer.Instance.SetLanguage(AppLanguage.Russian);
|
||||
|
||||
Localizer.Instance.Plural("Parse.Count.Records", 1).ShouldBe("1 запись");
|
||||
Localizer.Instance.Plural("Parse.Count.Records", 3).ShouldBe("3 записи");
|
||||
Localizer.Instance.Plural("Parse.Count.Records", 7).ShouldBe("7 записей");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Switching_language_changes_what_lookups_return()
|
||||
{
|
||||
Localizer.Instance.SetLanguage(AppLanguage.English);
|
||||
var english = Localizer.Instance["Page.Settings"];
|
||||
|
||||
Localizer.Instance.SetLanguage(AppLanguage.Russian);
|
||||
|
||||
Localizer.Instance["Page.Settings"].ShouldNotBe(english);
|
||||
Localizer.Instance["Page.Settings"].ShouldBe("Настройки");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Switching_language_invalidates_bindings()
|
||||
{
|
||||
Localizer.Instance.SetLanguage(AppLanguage.English);
|
||||
|
||||
var notifications = new List<string?>();
|
||||
void Handler(object? sender, System.ComponentModel.PropertyChangedEventArgs e) =>
|
||||
notifications.Add(e.PropertyName);
|
||||
|
||||
Localizer.Instance.PropertyChanged += Handler;
|
||||
try
|
||||
{
|
||||
Localizer.Instance.SetLanguage(AppLanguage.Russian);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Localizer.Instance.PropertyChanged -= Handler;
|
||||
}
|
||||
|
||||
// "Item[]" is what makes every {l:Loc} binding re-read; without it the UI would keep the
|
||||
// previous language until it was rebuilt.
|
||||
notifications.ShouldContain("Item[]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setting_the_same_language_twice_does_not_churn_bindings()
|
||||
{
|
||||
Localizer.Instance.SetLanguage(AppLanguage.Russian);
|
||||
|
||||
var raised = 0;
|
||||
void Handler(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => raised++;
|
||||
|
||||
Localizer.Instance.PropertyChanged += Handler;
|
||||
try
|
||||
{
|
||||
Localizer.Instance.SetLanguage(AppLanguage.Russian);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Localizer.Instance.PropertyChanged -= Handler;
|
||||
}
|
||||
|
||||
raised.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_key_is_visibly_marked() => Localizer.Instance["No.Such.Key"].ShouldBe("!No.Such.Key!");
|
||||
|
||||
[Fact]
|
||||
public void A_fallback_is_used_when_a_key_is_absent() =>
|
||||
Localizer.Instance.GetOrDefault("Parser.brand-new.Name", "Brand new").ShouldBe("Brand new");
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppLanguage.English, "en")]
|
||||
[InlineData(AppLanguage.Russian, "ru")]
|
||||
public void Explicit_languages_map_to_their_culture(AppLanguage language, string expected) =>
|
||||
AppLanguages.ToCulture(language).TwoLetterISOLanguageName.ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("ru-RU", "ru")]
|
||||
[InlineData("ru", "ru")]
|
||||
[InlineData("en-GB", "en")]
|
||||
[InlineData("de-DE", "en")]
|
||||
[InlineData("ja-JP", "en")]
|
||||
public void System_follows_the_os_only_where_a_translation_exists(string system, string expected) =>
|
||||
// A half-translated German would be worse than plain English, so anything unsupported
|
||||
// lands on English rather than on the OS language.
|
||||
AppLanguages
|
||||
.ToCulture(AppLanguage.System, CultureInfo.GetCultureInfo(system))
|
||||
.TwoLetterISOLanguageName.ShouldBe(expected);
|
||||
|
||||
private static string Placeholders(string value) =>
|
||||
string.Concat(Regex.Matches(value, @"\{\d+\}").Select(match => match.Value).Order());
|
||||
}
|
||||
@@ -84,7 +84,7 @@ public class ProxiesViewModelTests
|
||||
);
|
||||
|
||||
await Run(page.RefreshCommand);
|
||||
page.ProtocolFilter = ProxyProtocolFilter.Socks5;
|
||||
page.ProtocolFilter = page.ProtocolFilters.Single(option => option.Value == ProxyProtocolFilter.Socks5);
|
||||
await Task.Delay(250, TestContext.Current.CancellationToken);
|
||||
|
||||
page.Proxies.ShouldHaveSingleItem().Protocol.ShouldBe("SOCKS5");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Localization;
|
||||
using AvParser.UI.Navigation;
|
||||
using AvParser.UI.Responsive;
|
||||
using AvParser.UI.Tests.Fakes;
|
||||
@@ -15,7 +16,11 @@ public class ShellViewModelTests
|
||||
AppTheme theme = AppTheme.System
|
||||
)
|
||||
{
|
||||
var navigation = new NavigationService([new FakePage("First"), new FakePage("Second"), new OtherFakePage()]);
|
||||
var navigation = new NavigationService([
|
||||
new FakePage("Page.Dashboard"),
|
||||
new FakePage("Page.Parse"),
|
||||
new OtherFakePage(),
|
||||
]);
|
||||
var themeService = new FakeThemeService(theme);
|
||||
|
||||
// ImmediateSequencer makes every derived property settle before the next line runs,
|
||||
@@ -98,7 +103,7 @@ public class ShellViewModelTests
|
||||
|
||||
navigation.Current.ShouldBeSameAs(shell.Pages[2]);
|
||||
shell.CurrentPage.ShouldBeSameAs(shell.Pages[2]);
|
||||
shell.Title.ShouldBe("Other");
|
||||
shell.Title.ShouldBe(Localizer.Instance["Page.About"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user