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>
169 lines
4.8 KiB
C#
169 lines
4.8 KiB
C#
using Avalonia.Controls;
|
|
using AvParser.Core.Settings;
|
|
using AvParser.UI.Localization;
|
|
using AvParser.UI.Navigation;
|
|
using AvParser.UI.Responsive;
|
|
using AvParser.UI.Tests.Fakes;
|
|
using AvParser.UI.ViewModels;
|
|
using ReactiveUI.Primitives;
|
|
using ReactiveUI.Primitives.Concurrency;
|
|
|
|
namespace AvParser.UI.Tests;
|
|
|
|
public class ShellViewModelTests
|
|
{
|
|
private static (ShellViewModel Shell, NavigationService Navigation, FakeThemeService Theme) Build(
|
|
AppTheme theme = AppTheme.System
|
|
)
|
|
{
|
|
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,
|
|
// which is what lets these read as plain synchronous assertions.
|
|
return (new ShellViewModel(navigation, themeService, ImmediateSequencer.Instance), navigation, themeService);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(Breakpoint.Expanded, SplitViewDisplayMode.Inline)]
|
|
[InlineData(Breakpoint.Medium, SplitViewDisplayMode.CompactInline)]
|
|
[InlineData(Breakpoint.Compact, SplitViewDisplayMode.Overlay)]
|
|
public void Breakpoint_selects_the_pane_display_mode(Breakpoint breakpoint, SplitViewDisplayMode expected)
|
|
{
|
|
var (shell, _, _) = Build();
|
|
|
|
shell.Breakpoint = breakpoint;
|
|
|
|
shell.PaneDisplayMode.ShouldBe(expected);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(Breakpoint.Expanded, true)]
|
|
[InlineData(Breakpoint.Medium, false)]
|
|
[InlineData(Breakpoint.Compact, false)]
|
|
public void Crossing_a_breakpoint_resets_the_pane(Breakpoint breakpoint, bool expectedOpen)
|
|
{
|
|
var (shell, _, _) = Build();
|
|
|
|
shell.Breakpoint = breakpoint;
|
|
|
|
shell.IsPaneOpen.ShouldBe(expectedOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_manual_toggle_survives_until_the_next_breakpoint_change()
|
|
{
|
|
var (shell, _, _) = Build();
|
|
shell.Breakpoint = Breakpoint.Compact;
|
|
|
|
shell.TogglePaneCommand.Execute().Subscribe(_ => { });
|
|
shell.IsPaneOpen.ShouldBeTrue();
|
|
|
|
shell.Breakpoint = Breakpoint.Expanded;
|
|
shell.IsPaneOpen.ShouldBeTrue();
|
|
|
|
shell.Breakpoint = Breakpoint.Compact;
|
|
shell.IsPaneOpen.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Choosing_a_destination_dismisses_the_compact_drawer()
|
|
{
|
|
var (shell, _, _) = Build();
|
|
shell.Breakpoint = Breakpoint.Compact;
|
|
shell.TogglePaneCommand.Execute().Subscribe(_ => { });
|
|
shell.IsPaneOpen.ShouldBeTrue();
|
|
|
|
shell.SelectedPage = shell.Pages[1];
|
|
|
|
shell.IsPaneOpen.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Choosing_a_destination_leaves_the_expanded_sidebar_open()
|
|
{
|
|
var (shell, _, _) = Build();
|
|
shell.Breakpoint = Breakpoint.Expanded;
|
|
|
|
shell.SelectedPage = shell.Pages[1];
|
|
|
|
shell.IsPaneOpen.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Selecting_a_page_navigates_to_it()
|
|
{
|
|
var (shell, navigation, _) = Build();
|
|
|
|
shell.SelectedPage = shell.Pages[2];
|
|
|
|
navigation.Current.ShouldBeSameAs(shell.Pages[2]);
|
|
shell.CurrentPage.ShouldBeSameAs(shell.Pages[2]);
|
|
shell.Title.ShouldBe(Localizer.Instance["Page.About"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Navigating_from_elsewhere_updates_the_rail_selection()
|
|
{
|
|
var (shell, navigation, _) = Build();
|
|
|
|
navigation.NavigateTo<OtherFakePage>();
|
|
|
|
shell.SelectedPage.ShouldBeSameAs(navigation.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void Back_is_disabled_until_something_is_on_the_stack()
|
|
{
|
|
var (shell, _, _) = Build();
|
|
|
|
shell.CanGoBack.ShouldBeFalse();
|
|
|
|
shell.SelectedPage = shell.Pages[1];
|
|
|
|
shell.CanGoBack.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Back_returns_to_the_previous_page()
|
|
{
|
|
var (shell, _, _) = Build();
|
|
var first = shell.Pages[0];
|
|
shell.SelectedPage = shell.Pages[1];
|
|
|
|
shell.GoBackCommand.Execute().Subscribe(_ => { });
|
|
|
|
shell.CurrentPage.ShouldBeSameAs(first);
|
|
shell.SelectedPage.ShouldBeSameAs(first);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(AppTheme.Dark, AppTheme.Light)]
|
|
[InlineData(AppTheme.Light, AppTheme.Dark)]
|
|
[InlineData(AppTheme.System, AppTheme.Dark)]
|
|
public void Toggling_the_theme_flips_between_light_and_dark(AppTheme start, AppTheme expected)
|
|
{
|
|
var (shell, _, theme) = Build(start);
|
|
|
|
shell.ToggleThemeCommand.Execute().Subscribe(_ => { });
|
|
|
|
theme.Current.ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_theme_button_shows_the_theme_it_would_switch_to()
|
|
{
|
|
var (shell, _, theme) = Build(AppTheme.Light);
|
|
|
|
shell.ThemeIconKey.ShouldBe("IconMoon");
|
|
|
|
theme.Apply(AppTheme.Dark);
|
|
|
|
shell.ThemeIconKey.ShouldBe("IconSun");
|
|
}
|
|
}
|