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>
164 lines
4.7 KiB
C#
164 lines
4.7 KiB
C#
using Avalonia.Controls;
|
|
using AvParser.Core.Settings;
|
|
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("First"), new FakePage("Second"), 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("Other");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
}
|