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 { /// /// Shows the shell inside a window of the requested width. /// /// /// A real is required — a detached control never builds its visual tree, /// so there would be no 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. /// 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().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().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().Single(); host.Content.ShouldBeSameAs(second); viewModel.Title.ShouldBe("Second"); } /// /// Guards against the shell stylesheet silently matching nothing. /// /// /// Avalonia type selectors match the exact type, so UserControl.shell does not match /// (which derives from ReactiveUserControl<T>). 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. /// [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().Single(b => b.Name == "PageHost"); pageHost.Padding.ShouldBe(new Thickness(24)); var paneToggle = view.GetVisualDescendants().OfType