diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1362d4..239a7f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,3 +85,7 @@ jobs: - name: Build Avalonia Demo run: dotnet build examples/Avalonia.Demo/AvaloniaDemo.csproj --configuration Release + + # Renders every component and drives them with mouse and keyboard, headless (no display needed) + - name: Run Avalonia headless tests + run: dotnet test tests/ShellUI.Native.Avalonia.Tests/ShellUI.Native.Avalonia.Tests.csproj --configuration Release diff --git a/ShellUI.Native.slnx b/ShellUI.Native.slnx index d125114..0257fb5 100644 --- a/ShellUI.Native.slnx +++ b/ShellUI.Native.slnx @@ -18,6 +18,7 @@ + diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs index 41d6a8a..c1a2911 100644 --- a/examples/Avalonia.Demo/Components/UI/Shell.cs +++ b/examples/Avalonia.Demo/Components/UI/Shell.cs @@ -135,7 +135,9 @@ public static class ShellTheme public const double RadiusMd = 6; public const double RadiusLg = 8; - private static bool _initialized; + // The app the palettes were published to. A new Application (e.g. one per headless test) + // gets them too. + private static Application? _initialized; // Raised when a palette is published or the theme variant changes, for anything resources // can't reach (e.g. a BoxShadow). @@ -161,10 +163,9 @@ public static Color Get(ShellToken token, double opacity) public static void EnsureInitialized() { - if (_initialized) return; var app = Application.Current; - if (app is null) return; - _initialized = true; + if (app is null || ReferenceEquals(app, _initialized)) return; + _initialized = app; app.ActualThemeVariantChanged += (_, _) => ThemeChanged?.Invoke(null, EventArgs.Empty); Apply(); } diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index 04ee006..c47d83d 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -1209,7 +1209,9 @@ public static class ShellTheme public const double RadiusMd = 6; public const double RadiusLg = 8; - private static bool _initialized; + // The app the palettes were published to. A new Application (e.g. one per headless test) + // gets them too. + private static Application? _initialized; // Raised when a palette is published or the theme variant changes, for anything resources // can't reach (e.g. a BoxShadow). @@ -1235,10 +1237,9 @@ public static Color Get(ShellToken token, double opacity) public static void EnsureInitialized() { - if (_initialized) return; var app = Application.Current; - if (app is null) return; - _initialized = true; + if (app is null || ReferenceEquals(app, _initialized)) return; + _initialized = app; app.ActualThemeVariantChanged += (_, _) => ThemeChanged?.Invoke(null, EventArgs.Empty); Apply(); } diff --git a/tests/ShellUI.Native.Avalonia.Tests/ChangeEventTests.cs b/tests/ShellUI.Native.Avalonia.Tests/ChangeEventTests.cs new file mode 100644 index 0000000..e666f78 --- /dev/null +++ b/tests/ShellUI.Native.Avalonia.Tests/ChangeEventTests.cs @@ -0,0 +1,223 @@ +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.VisualTree; +using AvaloniaDemo.Components.UI; +using Xunit; +using Switch = AvaloniaDemo.Components.UI.Switch; +using Slider = AvaloniaDemo.Components.UI.Slider; + +namespace ShellUI.Native.Avalonia.Tests; + +// Change events report user input only: values set from code stay silent, a click or a key raises +// the event once. +public class ChangeEventTests +{ + [AvaloniaFact] + public void Switch_reports_clicks_and_keys_not_code() + { + var sw = new Switch { Label = "Wi-Fi" }; + var window = Ui.Show(sw); + var raised = 0; + sw.Toggled += (_, _) => raised++; + + sw.IsToggled = true; + Assert.Equal(0, raised); + + Ui.Click(window, sw); + Assert.Equal(1, raised); + Assert.False(sw.IsToggled); + + sw.Focus(); + Ui.Press(window, Key.Space); + Assert.Equal(2, raised); + Assert.True(sw.IsToggled); + } + + [AvaloniaFact] + public void Checkbox_reports_clicks_not_code() + { + var cb = new Checkbox { Label = "Accept" }; + var window = Ui.Show(cb); + var raised = 0; + cb.CheckedChanged += (_, _) => raised++; + + cb.IsChecked = true; + Assert.Equal(0, raised); + Ui.Click(window, cb); + Assert.Equal(1, raised); + Assert.False(cb.IsChecked); + } + + [AvaloniaFact] + public void Toggle_reports_clicks_not_code() + { + var toggle = new Toggle { Text = "Bold" }; + var window = Ui.Show(toggle); + var raised = 0; + toggle.PressedChanged += (_, _) => raised++; + + toggle.IsPressed = true; + Assert.Equal(0, raised); + Ui.Click(window, toggle); + Assert.Equal(1, raised); + } + + [AvaloniaFact] + public void Slider_reports_drags_and_keys_not_code_or_new_limits() + { + var slider = new Slider { Width = 300, HorizontalAlignment = HorizontalAlignment.Left }; + var window = Ui.Show(slider); + var raised = 0; + slider.ValueChanged += (_, _) => raised++; + + slider.Value = 70; + slider.Maximum = 60; // coerces Value to 60 + Assert.Equal(0, raised); + + var left = slider.TranslatePoint(new Point(10, slider.Bounds.Height / 2), window)!.Value; + Ui.ClickAt(window, left); + Assert.Equal(1, raised); + Assert.True(slider.Value < 10); + + slider.Focus(); + Ui.Press(window, Key.Right); + Assert.Equal(2, raised); + } + + [AvaloniaFact] + public void Tabs_report_trigger_clicks_and_arrow_keys_not_code() + { + var a = new TabsTrigger { Value = "a", Text = "Account" }; + var b = new TabsTrigger { Value = "b", Text = "Password" }; + var list = new TabsList(); + list.Items.Add(a); + list.Items.Add(b); + var tabs = new Tabs { Value = "a", Children = { list } }; + var window = Ui.Show(tabs); + var raised = 0; + tabs.ValueChanged += (_, _) => raised++; + + tabs.Value = "b"; + Assert.Equal(0, raised); + + Ui.Click(window, a); + Assert.Equal(1, raised); + Assert.Equal("a", tabs.Value); + + a.Focus(); + Ui.Press(window, Key.Right); + Assert.Equal(2, raised); + Assert.Equal("b", tabs.Value); + } + + [AvaloniaFact] + public void Pagination_reports_page_clicks_not_code() + { + var pagination = new Pagination { TotalPages = 20, Page = 5 }; + var window = Ui.Show(pagination); + var raised = 0; + pagination.PageChanged += (_, _) => raised++; + + pagination.Page = 3; + Assert.Equal(0, raised); + Ui.Settle(); // the page items are rebuilt + + var page4 = pagination.GetVisualDescendants().OfType().First(b => AutomationProperties.GetName(b) == "Go to page 4"); + Ui.Click(window, page4); + Assert.Equal(1, raised); + Assert.Equal(4, pagination.Page); + } + + [AvaloniaFact] + public void Accordion_reports_trigger_clicks_not_code() + { + var accordion = new Accordion { Value = "a" }; + foreach (var value in new[] { "a", "b" }) + { + var item = new AccordionItem { Value = value }; + item.Items.Add(new AccordionTrigger { Text = value }); + item.Items.Add(new AccordionContent { Child = new TextBlock { Text = value } }); + accordion.Children.Add(item); + } + var window = Ui.Show(accordion); + var toggled = 0; + var itemOpened = 0; + accordion.ItemToggled += (_, _) => toggled++; + foreach (var item in accordion.FindDescendantsOfType()) + item.OpenChanged += (_, _) => itemOpened++; + + accordion.Value = "b"; + Assert.Equal(0, toggled); + Assert.Equal(0, itemOpened); + + // Single mode: opening "a" closes "b", and each change is reported. + Ui.Click(window, accordion.FindDescendantsOfType().First()); + Assert.Equal(2, toggled); + Assert.Equal(2, itemOpened); + Assert.True(accordion.IsOpen("a")); + } + + [AvaloniaFact] + public void Collapsible_reports_trigger_clicks_not_code() + { + var trigger = new CollapsibleTrigger { Child = new TextBlock { Text = "Show more" } }; + var collapsible = new Collapsible { Children = { trigger, new CollapsibleContent { Child = new TextBlock { Text = "More" } } } }; + var window = Ui.Show(collapsible); + var raised = 0; + collapsible.OpenChanged += (_, _) => raised++; + + collapsible.Open = true; + collapsible.Toggle(); + Assert.Equal(0, raised); + + Ui.Click(window, trigger); + Assert.Equal(1, raised); + Assert.True(collapsible.Open); + } + + [AvaloniaFact] + public void RadioGroup_reports_item_clicks_not_code() + { + var first = new RadioGroupItem { Value = "a", Text = "Default" }; + var second = new RadioGroupItem { Value = "b", Text = "Compact" }; + var group = new RadioGroup { Value = "a", Children = { first, second } }; + var window = Ui.Show(group); + var raised = 0; + group.ValueChanged += (_, _) => raised++; + + group.Value = "b"; + group.SetValue("a"); + Assert.Equal(0, raised); + + Ui.Click(window, second); + Assert.Equal(1, raised); + Assert.Equal("b", group.Value); + } + + [AvaloniaFact] + public void Select_reports_picked_items_not_code() + { + var select = new Select { Width = 240, ItemsSource = new[] { "Kenya", "Norway", "Zimbabwe" } }; + var window = Ui.Show(select); + var raised = 0; + select.SelectedIndexChanged += (_, _) => raised++; + + select.SelectedIndex = 1; + Assert.Equal(0, raised); + + Ui.Click(window, select); + Assert.True(select.IsOpen); + Ui.Wait(250); // the list fades and scales in + var zimbabwe = window.GetVisualDescendants().OfType() + .First(t => t.Text == "Zimbabwe" && t.IsEffectivelyVisible && !Ui.IsWithin(t, select)); + Ui.Click(window, zimbabwe); + Assert.Equal(1, raised); + Assert.Equal(2, select.SelectedIndex); + Assert.False(select.IsOpen); + } +} diff --git a/tests/ShellUI.Native.Avalonia.Tests/ComponentSmokeTests.cs b/tests/ShellUI.Native.Avalonia.Tests/ComponentSmokeTests.cs new file mode 100644 index 0000000..8054f20 --- /dev/null +++ b/tests/ShellUI.Native.Avalonia.Tests/ComponentSmokeTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Styling; +using AvaloniaDemo.Components.UI; +using Xunit; + +namespace ShellUI.Native.Avalonia.Tests; + +// Every component can be created, laid out and drawn on its own, in both themes. +public class ComponentSmokeTests +{ + public static IEnumerable Components() => + typeof(ShellTheme).Assembly.GetTypes() + .Where(t => t.Namespace == typeof(ShellTheme).Namespace && t.IsPublic && !t.IsAbstract + && typeof(Control).IsAssignableFrom(t) && t.GetConstructor(Type.EmptyTypes) != null) + .OrderBy(t => t.Name) + .Select(t => new object[] { t.Name }); + + [AvaloniaTheory] + [MemberData(nameof(Components))] + public void Renders_in_light_and_dark(string typeName) + { + var type = typeof(ShellTheme).Assembly.GetTypes().Single(t => t.Name == typeName && t.Namespace == typeof(ShellTheme).Namespace); + foreach (var theme in new[] { ThemeVariant.Light, ThemeVariant.Dark }) + { + var control = (Control)Activator.CreateInstance(type)!; + var window = Ui.Show(control, theme); + Assert.NotNull(window.CaptureRenderedFrame()); + window.Close(); + } + } +} diff --git a/tests/ShellUI.Native.Avalonia.Tests/OverlayTests.cs b/tests/ShellUI.Native.Avalonia.Tests/OverlayTests.cs new file mode 100644 index 0000000..365c236 --- /dev/null +++ b/tests/ShellUI.Native.Avalonia.Tests/OverlayTests.cs @@ -0,0 +1,112 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.VisualTree; +using AvaloniaDemo.Components.UI; +using AvaloniaDemo.Components.UI.Variants; +using Xunit; +using Button = AvaloniaDemo.Components.UI.Button; + +namespace ShellUI.Native.Avalonia.Tests; + +// Dialogs keep focus inside and hand it back; floating panels close on a click outside or Escape. +public class OverlayTests +{ + [AvaloniaFact] + public void Dialog_traps_tab_and_returns_focus_on_escape() + { + var open = new Button { Text = "Open" }; + var first = new Button { Text = "Cancel" }; + var second = new Button { Text = "Continue" }; + var content = new DialogContent(); + content.Items.Add(first); + content.Items.Add(second); + var dialog = new Dialog(); + dialog.Items.Add(new DialogTrigger { Child = open }); + dialog.Items.Add(content); + var window = Ui.Show(new StackPanel { Children = { new Button { Text = "Before" }, dialog } }); + + Ui.Click(window, open); + Assert.True(dialog.Open); + Ui.Wait(250); + Assert.True(Ui.IsWithin(Ui.Focused(window), content)); + + // More presses than there are tab stops: focus wraps and never reaches the page behind. + for (var i = 0; i < 6; i++) + { + Ui.Press(window, Key.Tab); + Assert.True(Ui.IsWithin(Ui.Focused(window), content), $"Tab {i + 1} left the dialog"); + } + Ui.Press(window, Key.Tab, RawInputModifiers.Shift); + Assert.True(Ui.IsWithin(Ui.Focused(window), content)); + + Ui.Press(window, Key.Escape); + Assert.False(dialog.Open); + Assert.True(Ui.IsWithin(Ui.Focused(window), open)); + } + + [AvaloniaFact] + public void Dropdown_closes_on_a_click_outside_without_clicking_through() + { + var picked = 0; + var behind = 0; + var item = new DropdownItem { Text = "Profile" }; + item.Clicked += (_, _) => picked++; + var menu = new DropdownContent(); + menu.Items.Add(item); + var dropdown = new Dropdown(); + dropdown.Items.Add(new DropdownTrigger { Child = new Button { Text = "Options" } }); + dropdown.Items.Add(menu); + var below = new Button { Text = "Below", Margin = new Thickness(0, 300, 0, 0) }; + below.Clicked += (_, _) => behind++; + var window = Ui.Show(new StackPanel { HorizontalAlignment = HorizontalAlignment.Left, Children = { dropdown, below } }); + + Ui.Click(window, dropdown); + Assert.True(dropdown.IsOpen); + Ui.Wait(250); + + // The click that dismisses does nothing else. + Ui.Click(window, below); + Assert.False(dropdown.IsOpen); + Assert.Equal(0, behind); + + Ui.Click(window, dropdown); + Ui.Wait(250); + Ui.Click(window, item); + Assert.Equal(1, picked); + Assert.False(dropdown.IsOpen); + + Ui.Click(window, dropdown); + Ui.Wait(250); + Ui.Press(window, Key.Escape); + Assert.False(dropdown.IsOpen); + } + + [AvaloniaFact] + public void HoverCard_stays_open_while_hovered_and_closes_on_leave() + { + var link = new Button { Text = "@shewart", Variant = ButtonVariant.Link }; + var card = new HoverCard(); + card.Items.Add(new HoverCardTrigger { Child = link }); + card.Items.Add(new HoverCardContent { Child = new TextBlock { Text = "Shewart Shepherd" } }); + var window = Ui.Show(card); + + Ui.Hover(window, link); + Ui.Wait(450); // OpenDelay is 300 ms + Assert.True(card.IsOpen); + + // Nothing covers the trigger, so the pointer stays on it and the card doesn't flicker shut. + Assert.DoesNotContain(window.GetVisualDescendants(), v => v.GetType().Name == "LightDismissOverlayLayer" && v.IsVisible); + Ui.Hover(window, link); + Ui.Wait(400); + Assert.True(card.IsOpen); + + window.MouseMove(new Point(850, 650), RawInputModifiers.None); + Ui.Wait(400); // CloseDelay is 200 ms + Assert.False(card.IsOpen); + } +} diff --git a/tests/ShellUI.Native.Avalonia.Tests/ShellUI.Native.Avalonia.Tests.csproj b/tests/ShellUI.Native.Avalonia.Tests/ShellUI.Native.Avalonia.Tests.csproj new file mode 100644 index 0000000..03144c2 --- /dev/null +++ b/tests/ShellUI.Native.Avalonia.Tests/ShellUI.Native.Avalonia.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + + Exe + enable + + disable + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/ShellUI.Native.Avalonia.Tests/TestApp.cs b/tests/ShellUI.Native.Avalonia.Tests/TestApp.cs new file mode 100644 index 0000000..5a93c7c --- /dev/null +++ b/tests/ShellUI.Native.Avalonia.Tests/TestApp.cs @@ -0,0 +1,27 @@ +using Avalonia; +using Avalonia.Headless; +using Avalonia.Themes.Fluent; +using AvaloniaDemo.Components.UI; +using ShellUI.Native.Avalonia.Tests; + +[assembly: AvaloniaTestApplication(typeof(TestApp))] +// One UI thread runs every test; in parallel, one test's waits would run another's work mid-test. +[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)] + +namespace ShellUI.Native.Avalonia.Tests; + +// The app a user's project has after `shellui-native init`: Fluent, the Inter font and the ShellUI +// theme. Rendering goes through Skia, so tests can lay out, draw and send input without a screen. +public class TestApp : Application +{ + public override void Initialize() + { + Styles.Add(new FluentTheme()); + ShellTheme.EnsureInitialized(); + } + + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .UseSkia() + .WithInterFont() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); +} diff --git a/tests/ShellUI.Native.Avalonia.Tests/ThemeTests.cs b/tests/ShellUI.Native.Avalonia.Tests/ThemeTests.cs new file mode 100644 index 0000000..be4d039 --- /dev/null +++ b/tests/ShellUI.Native.Avalonia.Tests/ThemeTests.cs @@ -0,0 +1,45 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Styling; +using AvaloniaDemo.Components.UI; +using Xunit; + +namespace ShellUI.Native.Avalonia.Tests; + +// Token bindings follow the theme, and every test's new Application gets the palettes. +public class ThemeTests +{ + [AvaloniaFact] + public void Token_brushes_follow_SetTheme() + { + var surface = new Border { Width = 40, Height = 40 }.Token(Border.BackgroundProperty, ShellToken.Background); + Ui.Show(surface, ThemeVariant.Light); + Assert.Equal(ShellTheme.Light[ShellToken.Background], ((ISolidColorBrush)surface.Background!).Color); + + ShellTheme.SetTheme(ThemeVariant.Dark); + Ui.Settle(); + Assert.True(ShellTheme.IsDarkMode); + Assert.Equal(ShellTheme.Dark[ShellToken.Background], ((ISolidColorBrush)surface.Background!).Color); + Assert.Equal(ShellTheme.Dark[ShellToken.Primary], ShellTheme.Get(ShellToken.Primary)); + } + + [AvaloniaFact] + public void ThemeChanged_is_raised_when_the_variant_changes() + { + Ui.Show(new Border(), ThemeVariant.Light); + var raised = 0; + void OnChanged(object? s, System.EventArgs e) => raised++; + ShellTheme.ThemeChanged += OnChanged; + try + { + ShellTheme.ToggleTheme(); + Ui.Settle(); + Assert.True(raised >= 1); + } + finally + { + ShellTheme.ThemeChanged -= OnChanged; + } + } +} diff --git a/tests/ShellUI.Native.Avalonia.Tests/Ui.cs b/tests/ShellUI.Native.Avalonia.Tests/Ui.cs new file mode 100644 index 0000000..0a1eb61 --- /dev/null +++ b/tests/ShellUI.Native.Avalonia.Tests/Ui.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaDemo.Components.UI; + +namespace ShellUI.Native.Avalonia.Tests; + +// Shows controls in a headless window and drives them through the real input pipeline: +// pointer events land on whatever is under the point, keys go to the focused control. +public static class Ui +{ + // Windows from earlier tests, closed when the next one shows. + private static readonly List Shown = new(); + + public static Window Show(Control content, ThemeVariant? theme = null, double width = 900, double height = 700) + { + foreach (var open in Shown) open.Close(); + Shown.Clear(); + ShellTheme.SetTheme(theme ?? ThemeVariant.Light); + var window = new Window { Content = content, Width = width, Height = height }; + Shown.Add(window); + window.Show(); + Settle(); + return window; + } + + // Runs queued work, layout and a render tick, so the next step sees the result of the last one. + public static void Settle() + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + } + // Lets animations and transitions (popups open in about 120 ms) run for `milliseconds`. + public static void Wait(int milliseconds) + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + while (clock.ElapsedMilliseconds < milliseconds) + { + System.Threading.Thread.Sleep(16); + Settle(); + } + } + + public static Point CenterOf(Window window, Visual target) + { + var center = new Point(target.Bounds.Width / 2, target.Bounds.Height / 2); + return target.TranslatePoint(center, window) ?? throw new InvalidOperationException($"{target.GetType().Name} is not in the window"); + } + + public static void Click(Window window, Visual target) => ClickAt(window, CenterOf(window, target)); + + public static void ClickAt(Window window, Point point) + { + window.MouseMove(point, RawInputModifiers.None); + window.MouseDown(point, MouseButton.Left, RawInputModifiers.None); + window.MouseUp(point, MouseButton.Left, RawInputModifiers.None); + Settle(); + } + + public static void Hover(Window window, Visual target) + { + window.MouseMove(CenterOf(window, target), RawInputModifiers.None); + Settle(); + } + + public static void Press(Window window, Key key, RawInputModifiers modifiers = RawInputModifiers.None) + { + window.KeyPress(key, modifiers, PhysicalKey.None, null); + window.KeyRelease(key, modifiers, PhysicalKey.None, null); + Settle(); + } + + public static IInputElement? Focused(Window window) => window.FocusManager?.GetFocusedElement(); + + // Where popups, menus and overlays show (they stay in the window's visual tree). + public static Visual Overlay(Window window) => OverlayLayer.GetOverlayLayer(window) ?? (Visual)window; + + // True when `element` is `ancestor` or inside it in the visual tree. + public static bool IsWithin(object? element, Visual ancestor) => + element is Visual v && (ReferenceEquals(v, ancestor) || v.GetVisualAncestors().Contains(ancestor)); +}