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:
Leonid Pershin
2026-08-13 18:15:01 +03:00
co-authored by Claude Opus 5
parent 9bf2ea5532
commit 8b552470b7
42 changed files with 2216 additions and 226 deletions
+2 -2
View File
@@ -14,9 +14,9 @@ namespace AvParser.UI.HeadlessTests;
/// project: these are a few lines of trivial stand-ins, and a shared test-kit assembly would have
/// to opt out of the <c>tests/</c> conventions (self-executing xUnit exe) to build at all.
/// </remarks>
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel
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;
}
@@ -0,0 +1,90 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Settings;
using AvParser.UI.Localization;
using AvParser.UI.Navigation;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.HeadlessTests;
public sealed class LocalizationViewTests : IDisposable
{
/// <summary>Leaves the localizer where the rest of the assembly expects it.</summary>
public void Dispose() => Localizer.Instance.SetLanguage(AppLanguage.English);
private static (ShellView View, ShellViewModel ViewModel) ShowShell()
{
var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Parse")]);
var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance);
var view = new ShellView { DataContext = viewModel };
new Window
{
Width = 1400,
Height = 800,
Content = view,
}.Show();
Dispatcher.UIThread.RunJobs();
return (view, viewModel);
}
private static IReadOnlyList<string> RailLabels(Visual view) =>
[
.. view.GetVisualDescendants()
.OfType<TextBlock>()
.Where(block => block.Classes.Contains("navLabel"))
.Select(block => block.Text ?? string.Empty),
];
[AvaloniaFact]
public void Rendered_text_follows_the_language_without_rebuilding_the_view()
{
Localizer.Instance.SetLanguage(AppLanguage.English);
var (view, _) = ShowShell();
var english = RailLabels(view);
english.ShouldBe(["Dashboard", "Parse"]);
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Dispatcher.UIThread.RunJobs();
// The same controls, not a rebuilt tree: this is the whole point of binding through the
// localizer's indexer rather than resolving strings once at load.
RailLabels(view).ShouldBe(["Обзор", "Разбор"]);
}
[AvaloniaFact]
public void The_title_bar_follows_the_language_too()
{
Localizer.Instance.SetLanguage(AppLanguage.English);
var (view, viewModel) = ShowShell();
var title = view.GetVisualDescendants().OfType<TextBlock>().Single(block => block.Name == "ShellTitle");
title.Text.ShouldBe("Dashboard");
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Dispatcher.UIThread.RunJobs();
viewModel.Title.ShouldBe("Обзор");
title.Text.ShouldBe("Обзор");
}
[AvaloniaFact]
public void Loc_markup_resolves_a_static_caption()
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
var (view, _) = ShowShell();
// Tooltips come from {l:Loc} rather than from a view model, so they exercise the markup
// extension itself.
var toggle = view.GetVisualDescendants().OfType<Button>().Single(button => button.Name == "PaneToggle");
ToolTip.GetTip(toggle).ShouldBe("Показать или скрыть навигацию");
}
}
@@ -5,6 +5,7 @@ using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Settings;
using AvParser.UI.Localization;
using AvParser.UI.Navigation;
using AvParser.UI.Responsive;
using AvParser.UI.Services;
@@ -27,7 +28,10 @@ public class ShellViewTests
/// </remarks>
private static (ShellView View, ShellViewModel ViewModel, Window Window) ShowShell(double width)
{
var navigation = new NavigationService([new FakePage("First"), new FakePage("Second", "IconDocument")]);
var navigation = new NavigationService([
new FakePage("Page.Dashboard"),
new FakePage("Page.Parse", "IconDocument"),
]);
// ImmediateSequencer, not the real main-thread one: derived properties then settle within
// the same layout pass, so a single RunJobs() is enough for the bindings to catch up.
@@ -108,7 +112,7 @@ public class ShellViewTests
var host = view.GetVisualDescendants().OfType<TransitioningContentControl>().Single();
host.Content.ShouldBeSameAs(second);
viewModel.Title.ShouldBe("Second");
viewModel.Title.ShouldBe(Localizer.Instance["Page.Parse"]);
}
/// <summary>