Files
av-parser/tests/AvParser.UI.HeadlessTests/ShellViewTests.cs
T
Leonid PershinandClaude Opus 5 8b552470b7 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>
2026-08-13 18:15:01 +03:00

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&lt;T&gt;</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);
}
}