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>
187 lines
6.6 KiB
C#
187 lines
6.6 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless.XUnit;
|
|
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;
|
|
using AvParser.UI.ViewModels;
|
|
using AvParser.UI.Views;
|
|
using ReactiveUI.Primitives.Concurrency;
|
|
|
|
namespace AvParser.UI.HeadlessTests;
|
|
|
|
public class ShellViewTests
|
|
{
|
|
/// <summary>
|
|
/// Shows the shell inside a window of the requested width.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A real <see cref="Window"/> is required — a detached control never builds its visual tree,
|
|
/// so there would be no <see cref="SplitView"/> to assert on. The width is set on the window
|
|
/// rather than by calling Measure/Arrange by hand: a manual arrange is undone by the window's
|
|
/// own next layout pass, which made these assertions depend on pump ordering.
|
|
/// </remarks>
|
|
private static (ShellView View, ShellViewModel ViewModel, Window Window) ShowShell(double width)
|
|
{
|
|
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.
|
|
var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance);
|
|
var view = new ShellView { DataContext = viewModel };
|
|
var window = new Window
|
|
{
|
|
Width = width,
|
|
Height = 800,
|
|
Content = view,
|
|
};
|
|
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
return (view, viewModel, window);
|
|
}
|
|
|
|
private static void Resize(Window window, double width)
|
|
{
|
|
window.Width = width;
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static SplitView NavPaneOf(Visual view) => view.GetVisualDescendants().OfType<SplitView>().Single();
|
|
|
|
[AvaloniaTheory]
|
|
[InlineData(500, SplitViewDisplayMode.Overlay)]
|
|
[InlineData(900, SplitViewDisplayMode.CompactInline)]
|
|
[InlineData(1400, SplitViewDisplayMode.Inline)]
|
|
public void The_navigation_pane_adapts_to_the_shell_width(double width, SplitViewDisplayMode expected)
|
|
{
|
|
var (view, _, _) = ShowShell(width);
|
|
|
|
NavPaneOf(view).DisplayMode.ShouldBe(expected);
|
|
}
|
|
|
|
[AvaloniaTheory]
|
|
[InlineData(500, Breakpoint.Compact)]
|
|
[InlineData(900, Breakpoint.Medium)]
|
|
[InlineData(1400, Breakpoint.Expanded)]
|
|
public void The_shell_hands_its_breakpoint_to_the_view_model(double width, Breakpoint expected)
|
|
{
|
|
var (_, viewModel, _) = ShowShell(width);
|
|
|
|
viewModel.Breakpoint.ShouldBe(expected);
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void Narrowing_the_shell_closes_the_pane_and_widening_reopens_it()
|
|
{
|
|
var (_, viewModel, window) = ShowShell(1400);
|
|
viewModel.IsPaneOpen.ShouldBeTrue();
|
|
|
|
Resize(window, 480);
|
|
viewModel.IsPaneOpen.ShouldBeFalse();
|
|
|
|
Resize(window, 1400);
|
|
viewModel.IsPaneOpen.ShouldBeTrue();
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void The_rail_lists_every_registered_page()
|
|
{
|
|
var (view, _, _) = ShowShell(1400);
|
|
|
|
view.GetVisualDescendants().OfType<ListBox>().Single().ItemCount.ShouldBe(2);
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void Selecting_a_rail_entry_swaps_the_hosted_page()
|
|
{
|
|
var (view, viewModel, _) = ShowShell(1400);
|
|
var second = viewModel.Pages[1];
|
|
|
|
viewModel.SelectedPage = second;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var host = view.GetVisualDescendants().OfType<TransitioningContentControl>().Single();
|
|
host.Content.ShouldBeSameAs(second);
|
|
viewModel.Title.ShouldBe(Localizer.Instance["Page.Parse"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Guards against the shell stylesheet silently matching nothing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Avalonia type selectors match the exact type, so <c>UserControl.shell</c> does not match
|
|
/// <see cref="ShellView"/> (which derives from <c>ReactiveUserControl<T></c>). That
|
|
/// failure is completely silent — the app renders, just with default metrics — so it needs an
|
|
/// explicit assertion on a value only the stylesheet can produce.
|
|
/// </remarks>
|
|
[AvaloniaFact]
|
|
public void The_shell_stylesheet_is_actually_applied()
|
|
{
|
|
var (view, _, _) = ShowShell(1400);
|
|
|
|
// 248 comes from the NavPaneWidth token; SplitView's own default is 320.
|
|
NavPaneOf(view).OpenPaneLength.ShouldBe(248d);
|
|
NavPaneOf(view).CompactPaneLength.ShouldBe(56d);
|
|
|
|
var pageHost = view.GetVisualDescendants().OfType<Border>().Single(b => b.Name == "PageHost");
|
|
pageHost.Padding.ShouldBe(new Thickness(24));
|
|
|
|
var paneToggle = view.GetVisualDescendants().OfType<Button>().Single(b => b.Name == "PaneToggle");
|
|
paneToggle.IsVisible.ShouldBeFalse();
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void The_hamburger_reappears_once_the_pane_stops_being_inline()
|
|
{
|
|
var (view, _, _) = ShowShell(520);
|
|
|
|
var paneToggle = view.GetVisualDescendants().OfType<Button>().Single(b => b.Name == "PaneToggle");
|
|
paneToggle.IsVisible.ShouldBeTrue();
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void Labels_collapse_away_on_the_icon_rail()
|
|
{
|
|
var (view, _, _) = ShowShell(900);
|
|
|
|
var labels = view.GetVisualDescendants()
|
|
.OfType<TextBlock>()
|
|
.Where(t => t.Classes.Contains("navLabel"))
|
|
.ToList();
|
|
|
|
labels.ShouldNotBeEmpty();
|
|
labels.ShouldAllBe(label => !label.IsVisible);
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void The_theme_tokens_actually_change_with_the_variant()
|
|
{
|
|
var application = Application.Current.ShouldNotBeNull();
|
|
|
|
application.TryFindResource("AppSurfaceBrush", ThemeVariant.Light, out var light).ShouldBeTrue();
|
|
application.TryFindResource("AppSurfaceBrush", ThemeVariant.Dark, out var dark).ShouldBeTrue();
|
|
|
|
// If this fails, Styles/Index.axaml was not loaded by the test app and every other style
|
|
// assertion in this assembly is meaningless.
|
|
dark!.ToString().ShouldNotBe(light!.ToString());
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void Theme_service_maps_the_app_theme_onto_an_avalonia_variant()
|
|
{
|
|
ThemeService.ToVariant(AppTheme.Light).ShouldBe(ThemeVariant.Light);
|
|
ThemeService.ToVariant(AppTheme.Dark).ShouldBe(ThemeVariant.Dark);
|
|
ThemeService.ToVariant(AppTheme.System).ShouldBe(ThemeVariant.Default);
|
|
}
|
|
}
|