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>
95 lines
3.4 KiB
C#
95 lines
3.4 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Headless.XUnit;
|
|
using Avalonia.Media;
|
|
using Avalonia.Styling;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
|
|
namespace AvParser.UI.HeadlessTests;
|
|
|
|
public class StyleTests
|
|
{
|
|
private static T Show<T>(T control, ThemeVariant variant)
|
|
where T : Control
|
|
{
|
|
Application.Current!.RequestedThemeVariant = variant;
|
|
|
|
var window = new Window
|
|
{
|
|
Width = 400,
|
|
Height = 200,
|
|
Content = control,
|
|
};
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
return control;
|
|
}
|
|
|
|
private static Color TokenColor(string key, ThemeVariant variant)
|
|
{
|
|
Application.Current!.TryFindResource(key, variant, out var value).ShouldBeTrue();
|
|
return value.ShouldBeOfType<SolidColorBrush>().Color;
|
|
}
|
|
|
|
private static Color? RenderedBackground(Control control) =>
|
|
(
|
|
control.GetVisualDescendants().OfType<ContentPresenter>().FirstOrDefault()?.Background
|
|
?? control.GetValue(TemplatedControl.BackgroundProperty)
|
|
)
|
|
is SolidColorBrush brush
|
|
? brush.Color
|
|
: null;
|
|
|
|
[AvaloniaTheory]
|
|
[InlineData("Light")]
|
|
[InlineData("Dark")]
|
|
public void The_primary_button_is_filled_with_the_accent_colour(string variantName)
|
|
{
|
|
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
|
|
var button = Show(new Button { Classes = { "primary" }, Content = "Go" }, variant);
|
|
|
|
// If this fails the button paints itself white-on-white and simply disappears.
|
|
RenderedBackground(button).ShouldBe(TokenColor("AppAccentBrush", variant));
|
|
}
|
|
|
|
[AvaloniaTheory]
|
|
[InlineData("Light")]
|
|
[InlineData("Dark")]
|
|
public void A_card_is_distinguishable_from_the_page_behind_it(string variantName)
|
|
{
|
|
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
|
|
|
|
var page = TokenColor("AppSurfaceBrush", variant);
|
|
var card = TokenColor("AppSurfaceRaisedBrush", variant);
|
|
var border = TokenColor("AppBorderBrush", variant);
|
|
|
|
// Summed across three channels, 24 is roughly a 3% per-channel step — the point at which
|
|
// a card stops being a rectangle you have to squint for. The first light palette here
|
|
// scored 33 and was still visually indistinguishable, so the bar is deliberately high.
|
|
Distance(page, card).ShouldBeGreaterThan(30);
|
|
Distance(page, border).ShouldBeGreaterThan(24);
|
|
}
|
|
|
|
[AvaloniaTheory]
|
|
[InlineData("Light")]
|
|
[InlineData("Dark")]
|
|
public void The_card_style_reaches_a_bare_border(string variantName)
|
|
{
|
|
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
|
|
var card = Show(new Border { Classes = { "card" } }, variant);
|
|
|
|
card.BorderThickness.ShouldBe(new Thickness(1));
|
|
card.Padding.ShouldBe(new Thickness(16));
|
|
|
|
// The value a DynamicResource actually resolved to for this element's variant — not what
|
|
// TryFindResource can dig out when asked for a variant explicitly.
|
|
(card.Background as SolidColorBrush)?.Color.ShouldBe(TokenColor("AppSurfaceRaisedBrush", variant));
|
|
}
|
|
|
|
private static int Distance(Color a, Color b) => Math.Abs(a.R - b.R) + Math.Abs(a.G - b.G) + Math.Abs(a.B - b.B);
|
|
}
|