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,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.UI.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
<ProjectReference Include="..\..\src\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\AvParser.UI\AvParser.UI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ReactiveUI.Testing" />
</ItemGroup>
<ItemGroup>
<!-- Subscribe(Action&lt;T&gt;) and the operator set live here; without it every test file
needs the same using just to call .Subscribe on an IObservable. -->
<Using Include="ReactiveUI.Primitives" />
</ItemGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
using AvParser.UI.ViewModels;
namespace AvParser.UI.Tests.Fakes;
/// <summary>A navigation destination with no behaviour, for exercising the shell and the stack.</summary>
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel
{
public override string Title { get; } = title;
public override string IconKey { get; } = iconKey;
}
/// <summary>A second page type, so <c>NavigateTo&lt;TPage&gt;()</c> has something to discriminate on.</summary>
internal sealed class OtherFakePage : PageViewModel
{
public override string Title => "Other";
public override string IconKey => "IconInfo";
}
@@ -0,0 +1,35 @@
using AvParser.Core.Settings;
using ReactiveUI.Primitives.Signals;
namespace AvParser.UI.Tests.Fakes;
/// <summary>In-memory settings, so tests never touch the developer's real profile.</summary>
internal sealed class FakeSettingsService(AppSettings? initial = null) : ISettingsService, IDisposable
{
private readonly BehaviorSignal<AppSettings> _current = new(initial ?? new AppSettings());
public AppSettings Current => _current.Value;
public IObservable<AppSettings> Changes => _current;
/// <summary>How many times <see cref="FlushAsync"/> was called.</summary>
public int FlushCount { get; private set; }
/// <summary>Every value the settings have taken, oldest first.</summary>
public List<AppSettings> History { get; } = [];
public void Update(Func<AppSettings, AppSettings> mutate)
{
var next = mutate(_current.Value);
History.Add(next);
_current.OnNext(next);
}
public Task FlushAsync(CancellationToken cancellationToken = default)
{
FlushCount++;
return Task.CompletedTask;
}
public void Dispose() => _current.Dispose();
}
@@ -0,0 +1,27 @@
using AvParser.Core.Settings;
using AvParser.UI.Services;
using ReactiveUI.Primitives.Signals;
namespace AvParser.UI.Tests.Fakes;
/// <summary>Theme service with no Avalonia <c>Application</c> behind it.</summary>
internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : IThemeService, IDisposable
{
private readonly BehaviorSignal<AppTheme> _current = new(initial);
public AppTheme Current => _current.Value;
public IObservable<AppTheme> Changes => _current;
public void Apply(AppTheme theme)
{
if (theme == _current.Value)
{
return;
}
_current.OnNext(theme);
}
public void Dispose() => _current.Dispose();
}
@@ -0,0 +1,111 @@
using AvParser.UI.Navigation;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
namespace AvParser.UI.Tests;
public class NavigationServiceTests
{
private static (NavigationService Service, FakePage First, FakePage Second, OtherFakePage Third) Build()
{
var first = new FakePage("First");
var second = new FakePage("Second");
var third = new OtherFakePage();
return (new NavigationService([first, second, third]), first, second, third);
}
[Fact]
public void Starts_on_the_first_registered_page()
{
var (service, first, _, _) = Build();
service.Current.ShouldBeSameAs(first);
}
[Fact]
public void Rejects_an_empty_page_set() =>
Should.Throw<ArgumentException>(() => new NavigationService(Array.Empty<PageViewModel>()));
[Fact]
public void Navigating_pushes_the_previous_page()
{
var (service, first, second, _) = Build();
service.NavigateTo(second);
service.Current.ShouldBeSameAs(second);
service.GoBack();
service.Current.ShouldBeSameAs(first);
}
[Fact]
public void Navigating_to_the_current_page_is_a_no_op()
{
var (service, first, _, _) = Build();
service.NavigateTo(first);
service.GoBack();
// Nothing was pushed, so GoBack had nothing to pop.
service.Current.ShouldBeSameAs(first);
}
[Fact]
public void GoBack_on_an_empty_stack_does_nothing()
{
var (service, first, _, _) = Build();
service.GoBack();
service.Current.ShouldBeSameAs(first);
}
[Fact]
public void CanGoBack_tracks_the_stack()
{
var (service, _, second, _) = Build();
var observed = new List<bool>();
using var subscription = service.CanGoBack.Subscribe(observed.Add);
service.NavigateTo(second);
service.GoBack();
observed.ShouldBe([false, true, false]);
}
[Fact]
public void Navigates_by_type()
{
var (service, _, _, third) = Build();
service.NavigateTo<OtherFakePage>();
service.Current.ShouldBeSameAs(third);
}
[Fact]
public void Throws_when_navigating_to_an_unregistered_type()
{
var service = new NavigationService([new FakePage("Only")]);
Should
.Throw<InvalidOperationException>(service.NavigateTo<OtherFakePage>)
.Message.ShouldContain("OtherFakePage");
}
[Fact]
public void CurrentChanges_replays_the_present_value()
{
var (service, first, second, _) = Build();
service.NavigateTo(second);
PageViewModel? seen = null;
using var subscription = service.CurrentChanges.Subscribe(page =>
{
seen ??= page;
});
seen.ShouldBeSameAs(second);
seen.ShouldNotBeSameAs(first);
}
}
@@ -0,0 +1,154 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using AvParser.Core.Settings;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
public class ParseViewModelTests
{
private static (ParseViewModel Page, FakeSettingsService Settings) Build(AppSettings? settings = null)
{
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser()]);
var settingsService = new FakeSettingsService(settings);
return (
new ParseViewModel(
catalog,
settingsService,
NullLogger<ParseViewModel>.Instance,
ImmediateSequencer.Instance
),
settingsService
);
}
private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask();
[Fact]
public void Restores_the_last_used_parser()
{
var (page, _) = Build(new AppSettings { LastParserId = "key-value" });
page.SelectedParser.Id.ShouldBe("key-value");
}
[Fact]
public void Falls_back_to_the_default_parser_for_an_unknown_id()
{
var (page, _) = Build(new AppSettings { LastParserId = "removed-in-a-past-version" });
page.SelectedParser.Id.ShouldBe("delimited");
}
[Fact]
public void Remembers_the_selected_parser()
{
var (page, settings) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
settings.Current.LastParserId.ShouldBe("key-value");
}
[Theory]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("id,name\n1,Ada", true)]
public void Parsing_requires_non_blank_input(string input, bool expected)
{
var (page, _) = Build();
var canExecute = true;
using var subscription = page.ParseCommand.CanExecute.Subscribe(value => canExecute = value);
page.InputText = input;
canExecute.ShouldBe(expected);
}
[Fact]
public async Task Parsing_fills_the_records_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(2);
page.Errors.ShouldBeEmpty();
page.Progress.ShouldBe(1d);
page.StatusMessage!.ShouldContain("2 records");
}
[Fact]
public async Task Bad_lines_land_in_the_errors_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
page.Errors.Count.ShouldBe(1);
page.StatusMessage!.ShouldContain("1 error");
}
[Fact]
public async Task A_second_run_replaces_the_previous_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
}
[Fact]
public async Task Cancelling_stops_the_run_and_says_so()
{
var (page, _) = Build();
page.InputText = string.Join(
'\n',
Enumerable.Range(0, 200_000).Select(i => i == 0 ? "id,name" : $"{i},row{i}")
);
var run = RunAsync(page);
page.CancelCommand.Execute().Subscribe(_ => { });
await run;
page.StatusMessage!.ShouldStartWith("Cancelled");
}
[Fact]
public void Loading_the_sample_matches_the_selected_parser()
{
var (page, _) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
page.LoadSampleCommand.Execute().Subscribe(_ => { });
page.InputText.ShouldContain("host = localhost");
}
[Fact]
public async Task Clearing_empties_the_input_and_the_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
page.ClearCommand.Execute().Subscribe(_ => { });
page.InputText.ShouldBeEmpty();
page.Records.ShouldBeEmpty();
page.StatusMessage.ShouldBeNull();
page.Progress.ShouldBe(0d);
}
}
@@ -0,0 +1,26 @@
using System.Runtime.CompilerServices;
using ReactiveUI.Builder;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
/// <summary>Initialises ReactiveUI once for the whole test assembly.</summary>
/// <remarks>
/// ReactiveUI 24 no longer self-initialises: <c>WhenAnyValue</c> throws
/// <see cref="InvalidOperationException"/> until the builder has run. In the app that happens
/// inside <c>AppBuilder.UseReactiveUI()</c>; here there is no Avalonia app, so a module
/// initialiser does it before any test constructs a view model.
/// </remarks>
internal static class ReactiveUiBootstrap
{
[ModuleInitializer]
internal static void Initialize() =>
RxAppBuilder
.CreateReactiveUIBuilder()
// No dispatcher exists in these tests, so anything ReactiveUI marshals internally
// must run inline rather than being queued onto a thread that never pumps.
.WithMainThreadScheduler(ImmediateSequencer.Instance)
.WithTaskPoolScheduler(ImmediateSequencer.Instance)
.WithCoreServices()
.BuildApp();
}
@@ -0,0 +1,163 @@
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");
}
}