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:
@@ -0,0 +1,96 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using AvParser.UI.Responsive;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
public class ResponsiveTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(320, Breakpoint.Compact)]
|
||||
[InlineData(719, Breakpoint.Compact)]
|
||||
[InlineData(720, Breakpoint.Medium)]
|
||||
[InlineData(1000, Breakpoint.Medium)]
|
||||
[InlineData(1100, Breakpoint.Expanded)]
|
||||
[InlineData(1920, Breakpoint.Expanded)]
|
||||
public void Classify_maps_width_to_a_breakpoint(double width, Breakpoint expected) =>
|
||||
ResponsiveLayout.Classify(width).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Hysteresis_widens_whichever_band_we_are_already_in()
|
||||
{
|
||||
// Sitting just under the medium threshold, a nudge upward must not flip the layout...
|
||||
ResponsiveLayout
|
||||
.Classify(ResponsiveLayout.MediumMinWidth + 10, Breakpoint.Compact)
|
||||
.ShouldBe(Breakpoint.Compact);
|
||||
// ...but a decisive move past the deadband must.
|
||||
ResponsiveLayout
|
||||
.Classify(ResponsiveLayout.MediumMinWidth + ResponsiveLayout.Hysteresis, Breakpoint.Compact)
|
||||
.ShouldBe(Breakpoint.Medium);
|
||||
|
||||
// Symmetrically on the way down from expanded.
|
||||
ResponsiveLayout
|
||||
.Classify(ResponsiveLayout.ExpandedMinWidth - 10, Breakpoint.Expanded)
|
||||
.ShouldBe(Breakpoint.Expanded);
|
||||
ResponsiveLayout
|
||||
.Classify(ResponsiveLayout.ExpandedMinWidth - ResponsiveLayout.Hysteresis - 1, Breakpoint.Expanded)
|
||||
.ShouldBe(Breakpoint.Medium);
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(500, Breakpoint.Compact, ":compact")]
|
||||
[InlineData(900, Breakpoint.Medium, ":medium")]
|
||||
[InlineData(1400, Breakpoint.Expanded, ":expanded")]
|
||||
public void Laying_out_a_control_sets_the_breakpoint_and_its_pseudoclass(
|
||||
double width,
|
||||
Breakpoint expected,
|
||||
string pseudoClass
|
||||
)
|
||||
{
|
||||
var host = new Border();
|
||||
ResponsiveLayout.SetIsEnabled(host, true);
|
||||
|
||||
// Measure/Arrange directly rather than resizing a Window: the headless window manager's
|
||||
// resize path is the flakiest thing available, and this is what actually drives Bounds.
|
||||
host.Measure(new Size(width, 800));
|
||||
host.Arrange(new Rect(0, 0, width, 800));
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
ResponsiveLayout.GetBreakpoint(host).ShouldBe(expected);
|
||||
host.Classes.Contains(pseudoClass).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Only_one_breakpoint_pseudoclass_is_active_at_a_time()
|
||||
{
|
||||
var host = new Border();
|
||||
ResponsiveLayout.SetIsEnabled(host, true);
|
||||
|
||||
host.Measure(new Size(400, 600));
|
||||
host.Arrange(new Rect(0, 0, 400, 600));
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
host.Classes.Contains(":compact").ShouldBeTrue();
|
||||
host.Classes.Contains(":medium").ShouldBeFalse();
|
||||
host.Classes.Contains(":expanded").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Disabling_the_behaviour_stops_further_updates()
|
||||
{
|
||||
var host = new Border();
|
||||
ResponsiveLayout.SetIsEnabled(host, true);
|
||||
host.Measure(new Size(400, 600));
|
||||
host.Arrange(new Rect(0, 0, 400, 600));
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
ResponsiveLayout.SetIsEnabled(host, false);
|
||||
host.Measure(new Size(1400, 600));
|
||||
host.Arrange(new Rect(0, 0, 1400, 600));
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
ResponsiveLayout.GetBreakpoint(host).ShouldBe(Breakpoint.Compact);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user