Scaffold AvParser: Avalonia 12 shell with adaptive layout

Greenfield skeleton for a parser desktop app. The domain is deliberately a
placeholder — IParser<TIn,TOut> plus two sample parsers — so the shell is
runnable and verifiable end to end before real logic lands.

Layers run one way: Core (no Avalonia, no IO) <- Infrastructure <- UI <- Desktop.
UI is a class library rather than the exe so headless tests build real views
without dragging in Program.cs, Serilog or the container.

Adaptive layout is built from what Avalonia actually offers, since it has no
AdaptiveTrigger or media queries: ResponsiveLayout observes Visual.Bounds and
projects a breakpoint onto both an attached property and :compact/:medium/
:expanded pseudoclasses, with 24px hysteresis so dragging a window edge cannot
make the layout flap. Pane state lives in the view model because a style setter
loses to a local value permanently; styles own only the visual variance.

Stack notes worth remembering: Avalonia.ReactiveUI is deprecated in favour of
ReactiveUI.Avalonia, and ReactiveUI 24 runs on the Primitives engine (RxVoid,
ISequencer, Signal<T>) and no longer self-initialises. Avalonia.Headless.XUnit
12.x requires xUnit v3. InvariantGlobalization must stay false or Semi.Avalonia
throws in its static constructor.

102 tests across three projects, including headless guards for the two failures
that are otherwise completely silent: a stylesheet whose selectors match nothing,
and a light palette too low-contrast for cards to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 16:07:08 +03:00
co-authored by Claude Opus 5
commit 3db9d4dfc6
94 changed files with 5966 additions and 0 deletions
@@ -0,0 +1,182 @@
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.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("First"), new FakePage("Second", "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("Second");
}
/// <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);
}
}