From 7a841836c1ce91436b10d382ef2f8af72aeaedf3 Mon Sep 17 00:00:00 2001 From: zignd Date: Sun, 11 Oct 2026 00:18:45 -0300 Subject: [PATCH 1/2] feat(testing): headless input driver; fix(listbox): row content clicks reach the ListBox --- docs/headless-testing.md | 37 ++++++ src/Forma.Testing/HeadlessInput.cs | 134 +++++++++++++++++++++ src/Forma/UIContext.cs | 32 ++++- tests/Forma.Tests/ListBoxRowPointerTest.cs | 90 ++++++++++++++ 4 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 docs/headless-testing.md create mode 100644 src/Forma.Testing/HeadlessInput.cs create mode 100644 tests/Forma.Tests/ListBoxRowPointerTest.cs diff --git a/docs/headless-testing.md b/docs/headless-testing.md new file mode 100644 index 00000000..0d121611 --- /dev/null +++ b/docs/headless-testing.md @@ -0,0 +1,37 @@ +# Headless UI testing + +`UIContext` needs no window or `GraphicsDevice` for layout, hit testing and input, so control behavior can be tested in +plain NUnit/xUnit. `Forma.Testing` adds `HeadlessInput`, a small driver over the context's `Inject*` side channel. + +```csharp +var input = HeadlessInput.Create(800, 600); // UIContext with SuppressPolledInput on +input.Context.Add(myList); +input.Context.Layout(); + +input.Click(myList.GetRealizedContainer(1)); // move + press + release at the control's centre +input.DoubleClick(row); +input.Wheel(-120, position); +input.Key(Keys.A, Keys.LeftControl); // chord +input.Type("hello"); // committed text +input.Gamepad(HeadlessGamepadButton.DPadDown); // maps to the navigation key +input.Advance(TimeSpan.FromMilliseconds(100)); // run a frame (animations, timers) +``` + +Notes: + +- Positions are physical pixels, equal to logical pixels at the default `DisplayScale` of 1. +- Gamepad input is expressed as the keys UI navigation uses (D-pad = arrows, Accept = Enter, Cancel = Escape); + `UIContext` has no gamepad-specific entry point. +- Inject calls run synchronously on the calling thread; call them between frames. +- For game-level tests, drive the same context the game's screens read, and make the game's own input (for example a + mouse snapshot) come from one source, or a test can pass while real input fails. +- Time-based behavior (double-click window) follows `UIContext.CurrentTime`; use `Advance` rather than sleeping. +- Rendering (`Draw`, render targets) still needs a graphics device. A game built on a MonoGame fork with a headless + backend can run `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy` set in the *process* environment (setting them + inside the test is too late on Unix). + +## Render tests on macOS + +`Forma.RenderTests` excludes macOS because it uses the stock DesktopGL package, which needs a real GL window. The +headless backend lives in a MonoGame fork; Forma can use it only through `MonoGameProjectPath`, and then only for +tests that do not read back pixels. These tests were not re-enabled. diff --git a/src/Forma.Testing/HeadlessInput.cs b/src/Forma.Testing/HeadlessInput.cs new file mode 100644 index 00000000..274c944c --- /dev/null +++ b/src/Forma.Testing/HeadlessInput.cs @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Igor Hipólito Vieira +// SPDX-License-Identifier: MIT + +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace Forma.Testing +{ + /// Gamepad buttons can press. UI navigation is keyboard-driven, so each maps to a key. + public enum HeadlessGamepadButton { DPadUp, DPadDown, DPadLeft, DPadRight, Accept, Cancel } + + /// + /// Builds and drives a with no window or graphics device, through the context's + /// Inject* side channel. Positions are logical pixels when the context's DisplayScale is 1 (the default). + /// + public sealed class HeadlessInput + { + public HeadlessInput(UIContext context) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + context.SuppressPolledInput = true; + } + + public UIContext Context { get; } + public Point Position { get; private set; } + + /// Creates a context with the given viewport. Dispose it when done. + public static UIContext CreateContext(int width = 800, int height = 600) => + new UIContext { ViewportSize = new Vector2(width, height) }; + + /// Creates a context and the driver for it. + public static HeadlessInput Create(int width = 800, int height = 600) => new HeadlessInput(CreateContext(width, height)); + + /// Centre of a control's laid-out bounds, for aiming input at it. + public static Point CenterOf(Control control) + { + if (control == null) throw new ArgumentNullException(nameof(control)); + control.Context?.Layout(); + var b = control.Bounds; + return new Point((int)(b.X + b.Width / 2), (int)(b.Y + b.Height / 2)); + } + + public HeadlessInput Move(Point position) + { + Position = position; + Context.InjectPointerMove(position); + return this; + } + + public HeadlessInput Move(Control control) => Move(CenterOf(control)); + + public HeadlessInput Press(PointerButton button = PointerButton.Left) + { + Context.InjectPointerPress(Position, button); + return this; + } + + public HeadlessInput Release(PointerButton button = PointerButton.Left) + { + Context.InjectPointerRelease(Position, button); + return this; + } + + /// Move, press and release at a position or the centre of a control. + public HeadlessInput Click(Point position, PointerButton button = PointerButton.Left) + { + Move(position); + Press(button); + return Release(button); + } + + public HeadlessInput Click(Control control, PointerButton button = PointerButton.Left) => Click(CenterOf(control), button); + + /// Two clicks in a row at the same spot with no time passing between them. + public HeadlessInput DoubleClick(Control control) => Click(control).Click(control); + + public HeadlessInput Wheel(int delta, Point? position = null) + { + if (position.HasValue) Move(position.Value); + Context.InjectPointerWheel(Position, delta); + return this; + } + + public HeadlessInput Key(Keys key, params Keys[] modifiers) + { + Context.InjectKeyPress(key, modifiers); + Context.InjectKeyRelease(key); + return this; + } + + public HeadlessInput KeyDown(Keys key, params Keys[] modifiers) + { + Context.InjectKeyPress(key, modifiers); + return this; + } + + public HeadlessInput KeyUp(Keys key) + { + Context.InjectKeyRelease(key); + return this; + } + + public HeadlessInput Type(string text) + { + Context.InjectText(text); + return this; + } + + /// Presses and releases a gamepad button as the key Forma's UI navigation uses for it. + public HeadlessInput Gamepad(HeadlessGamepadButton button) + { + var key = button switch + { + HeadlessGamepadButton.DPadUp => Keys.Up, + HeadlessGamepadButton.DPadDown => Keys.Down, + HeadlessGamepadButton.DPadLeft => Keys.Left, + HeadlessGamepadButton.DPadRight => Keys.Right, + HeadlessGamepadButton.Accept => Keys.Enter, + HeadlessGamepadButton.Cancel => Keys.Escape, + _ => throw new ArgumentOutOfRangeException(nameof(button)), + }; + return Key(key); + } + + /// Advances the frame by the given time so animations, timers and double-click windows move. + public HeadlessInput Advance(TimeSpan elapsed) + { + var total = Context.CurrentTime + elapsed; + Context.Update(new GameTime(total, elapsed), default, default); + return this; + } + } +} diff --git a/src/Forma/UIContext.cs b/src/Forma/UIContext.cs index 8a75941a..b2f5fdcf 100644 --- a/src/Forma/UIContext.cs +++ b/src/Forma/UIContext.cs @@ -866,8 +866,30 @@ private static void DispatchPointerPressed(Control target, Point point) for (var control = target; control != null; control = control.VisualParent) { control.PointerPressed(point); - if (control.ConsumeEventAccepted() || control.MouseFilter != MouseFilter.Pass) break; + if (control.ConsumeEventAccepted()) break; + if (control.MouseFilter != MouseFilter.Pass) + { + GetRowOwnerForStoppedPointer(control)?.PointerPressed(point); + break; + } + } + } + + // Row content (a Label, Border, container) defaults to MouseFilter.Stop, so a press on it never bubbles to the + // ListBox that owns the row. When a non-interactive control stops the event inside a ListBoxItem, the list + // still receives it; a focusable control (a Button) keeps the click for itself. + private static ListBox GetRowOwnerForStoppedPointer(Control stoppedAt) + { + if (stoppedAt is ListBox || (stoppedAt.FocusMode != FocusMode.None && stoppedAt is not ListBoxItem)) return null; + for (var control = stoppedAt; control != null; control = control.VisualParent) + { + if (control is ListBox) return null; + if (control is not ListBoxItem) continue; + for (var parent = control.VisualParent; parent != null; parent = parent.VisualParent) + if (parent is ListBox list) return list; + return null; } + return null; } private static void DispatchPointerMoved(Control target, Point point) @@ -904,7 +926,13 @@ private static void DispatchPointerReleased(Control target, Point point) for (var control = target; control != null; control = control.VisualParent) { control.PointerReleased(point, control.ContainsPoint(point)); - if (control.ConsumeEventAccepted() || control.MouseFilter != MouseFilter.Pass) break; + if (control.ConsumeEventAccepted()) break; + if (control.MouseFilter != MouseFilter.Pass) + { + var owner = GetRowOwnerForStoppedPointer(control); + owner?.PointerReleased(point, owner.ContainsPoint(point)); + break; + } } } diff --git a/tests/Forma.Tests/ListBoxRowPointerTest.cs b/tests/Forma.Tests/ListBoxRowPointerTest.cs new file mode 100644 index 00000000..6e22f67a --- /dev/null +++ b/tests/Forma.Tests/ListBoxRowPointerTest.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Igor Hipólito Vieira +// SPDX-License-Identifier: MIT + +using System; +using Forma.Testing; +using Microsoft.Xna.Framework; + +namespace Forma.Tests; + +/// +/// A press on content inside a row must reach the owning ListBox, whatever the content's MouseFilter, +/// so rows select and activate on click without per-template tweaks. +/// +public sealed class ListBoxRowPointerTest +{ + private static (HeadlessInput Input, ListBox List) Build(Func row, bool singleClick = false) + { + var input = HeadlessInput.Create(); + var list = new ListBox + { + Size = new Vector2(300, 300), + ActivateOnSingleClick = singleClick, + ItemTemplate = DataTemplate.Create((_, item) => row()), + ItemsSource = new[] { "a", "b", "c" }, + }; + input.Context.Add(list); + input.Context.Layout(); + return (input, list); + } + + private static Control Row(Control child) + { + var box = new HBoxContainer { CustomMinimumSize = new Vector2(280, 30) }; + box.AddChild(child); + return box; + } + + private static Control Stopper() => new Border { CustomMinimumSize = new Vector2(280, 30), MouseFilter = MouseFilter.Stop }; + private static Control Plain() => new Label { Text = "row", CustomMinimumSize = new Vector2(280, 30) }; + + [TestCase(false), TestCase(true)] + public void ClickOnRowContentSelectsTheRow(bool stop) + { + var (input, list) = Build(() => Row(stop ? Stopper() : Plain())); + using var context = input.Context; + var container = list.GetRealizedContainer(1); + input.Click(HeadlessInput.CenterOf(container)); + Assert.That(list.SelectedIndex, Is.EqualTo(1)); + } + + [Test] + public void SingleClickActivatesWhenEnabled() + { + var (input, list) = Build(() => Row(Stopper()), singleClick: true); + using var context = input.Context; + var activated = -1; + list.ItemActivated += (_, e) => activated = e.Index; + input.Click(HeadlessInput.CenterOf(list.GetRealizedContainer(2))); + Assert.That(activated, Is.EqualTo(2)); + } + + [Test] + public void DoubleClickActivatesAndSingleClickDoesNot() + { + var (input, list) = Build(() => Row(Stopper())); + using var context = input.Context; + var activated = 0; + list.ItemActivated += (_, _) => activated++; + var at = HeadlessInput.CenterOf(list.GetRealizedContainer(0)); + input.Click(at); + Assert.That(activated, Is.EqualTo(0)); + input.Click(at); + Assert.That(activated, Is.EqualTo(1)); + } + + [Test] + public void ButtonInsideARowStillGetsItsOwnClick() + { + var clicked = 0; + var (input, list) = Build(() => + { + var button = new Button { Text = "x", CustomMinimumSize = new Vector2(280, 30) }; + button.Pressed += (_, _) => clicked++; + return Row(button); + }); + using var context = input.Context; + input.Click(HeadlessInput.CenterOf(list.GetRealizedContainer(0))); + Assert.That(clicked, Is.EqualTo(1)); + } +} From 3a0abf4c27daafff72e7dbb15aa8435fa9341ade Mon Sep 17 00:00:00 2001 From: zignd Date: Sun, 11 Oct 2026 00:36:00 -0300 Subject: [PATCH 2/2] docs: teach spelling about the SDL driver variables --- config/cspell-project-words.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/cspell-project-words.txt b/config/cspell-project-words.txt index e2aa19b4..c077e71c 100644 --- a/config/cspell-project-words.txt +++ b/config/cspell-project-words.txt @@ -106,3 +106,5 @@ AARRGGBB llms unitless Lnnnn +VIDEODRIVER +AUDIODRIVER