Campaign OP gate 2 root cause: UiElement.HitTest walks siblings front-to-back by z-order, so an OPEN UiMenu's extended button+popup hit-test union was never consulted when a LATER sibling's rect overlapped the popup area — on the Config tab every dropdown has rows below it, so Resolution-item clicks toggled the Full Screen / VSync rows underneath (the gate session's persisted fullscreen/vsync flips were exactly those stolen clicks). Latent since UiMenu existed; vendor/chat menus only worked by z-order luck. Fix: UiMenu's open/close now registers with UiRoot (SetActivePopup / ClearActivePopup); a registered popup gets FIRST claim on mouse-down, scroll, and hover routing; a press outside a live popup dismisses it and is SWALLOWED (the dismissing click must not act on what sat underneath); hidden/detached owners self-heal the registration on the next pointer event. UiMenu gains the IsOpen seam and a single SetOpen writer. Also in this commit, from the same investigation: - SilkRuntimeDisplayWindowTarget.Apply documents the fullscreen half honestly: IViewProperties.VideoMode is READ-ONLY, so a resolution pick while fullscreen cannot switch the display mode through Silk's abstract API — split out as #376 (native glfwSetWindowMonitor port) rather than half-shipping untested native interop at a gate tail. - Gate script §OP6 step 8 re-scoped: test resolution in WINDOWED mode. Regressed by tests/AcDream.App.Tests/UI/UiMenuPopupRoutingTests.cs — 4 tests driving the real UiRoot input path on a mounted overlapping tree, with an in-test overlap CONTROL click so the popup assertions cannot pass vacuously (the #372 lesson: only mount+drive-input tests catch this class; every fixture-conformance test stayed green through this bug). Full Release suite: 13,081 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
157 lines
5.8 KiB
C#
157 lines
5.8 KiB
C#
using AcDream.App.UI;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// #374 (Campaign OP gate 2): an OPEN UiMenu dropdown must get first claim on
|
|
/// pointer events. UiElement.HitTest walks siblings front-to-back by z-order,
|
|
/// so any sibling added AFTER the menu whose rect overlaps the popup area used
|
|
/// to win the walk before the menu's extended OnHitTest union was consulted —
|
|
/// on the Options panel's Config tab every dropdown has rows below it, so
|
|
/// picking a Resolution item actually toggled the Full Screen / VSync rows
|
|
/// underneath the popup. These tests drive the REAL UiRoot input path
|
|
/// (synthetic OnMouseDown/OnMouseUp on a mounted tree), the exact class of
|
|
/// coverage the fixture-conformance suites cannot provide (#372's lesson).
|
|
/// </summary>
|
|
public sealed class UiMenuPopupRoutingTests
|
|
{
|
|
private sealed class ClickRecorder : UiElement
|
|
{
|
|
public int MouseDowns;
|
|
public int Clicks;
|
|
public override bool HandlesClick => true;
|
|
public override bool OnEvent(in UiEvent e)
|
|
{
|
|
if (e.Type == UiEventType.MouseDown) { MouseDowns++; return true; }
|
|
if (e.Type == UiEventType.Click) { Clicks++; return true; }
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static (UiRoot root, UiMenu menu, ClickRecorder stealer, Func<string?> selected)
|
|
BuildOverlappingTree()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var panel = new UiPanel { Left = 0, Top = 0, Width = 300, Height = 300 };
|
|
|
|
string? picked = null;
|
|
var menu = new UiMenu
|
|
{
|
|
Left = 10, Top = 20, Width = 46, Height = 20,
|
|
OpenUpward = false,
|
|
Items = new[]
|
|
{
|
|
new UiMenu.MenuItem("A", "a"),
|
|
new UiMenu.MenuItem("B", "b"),
|
|
},
|
|
};
|
|
menu.OnSelect = p => picked = p as string;
|
|
|
|
// The sibling "row below" — added AFTER the menu, so the front-to-back
|
|
// sibling walk consults it FIRST. Its rect (screen y 44..74) overlaps
|
|
// the open popup's first item row (screen y 45..62).
|
|
var stealer = new ClickRecorder { Left = 0, Top = 44, Width = 250, Height = 30 };
|
|
|
|
panel.AddChild(menu);
|
|
panel.AddChild(stealer);
|
|
root.AddChild(panel);
|
|
return (root, menu, stealer, () => picked);
|
|
}
|
|
|
|
private static void Click(UiRoot root, int x, int y)
|
|
{
|
|
root.OnMouseDown(UiMouseButton.Left, x, y);
|
|
root.OnMouseUp(UiMouseButton.Left, x, y);
|
|
}
|
|
|
|
[Fact]
|
|
public void OpenPopup_ItemClick_ReachesTheMenu_NotTheOverlappingFrontSibling()
|
|
{
|
|
var (root, menu, stealer, selected) = BuildOverlappingTree();
|
|
|
|
// CONTROL: with the menu closed, this exact point belongs to the
|
|
// stealer — proving the overlap is real, so the popup assertion below
|
|
// cannot pass vacuously on wrong geometry.
|
|
Click(root, 30, 50);
|
|
Assert.Equal(1, stealer.MouseDowns);
|
|
|
|
Click(root, 30, 30); // the menu button (screen 10..56 x 20..40)
|
|
Assert.True(menu.IsOpen);
|
|
|
|
// First popup item row: menu-local (20, 30) → interior (15, 5) → row 0.
|
|
// The same screen point the control click just proved the stealer owns.
|
|
Click(root, 30, 50);
|
|
|
|
Assert.Equal("a", selected());
|
|
Assert.False(menu.IsOpen);
|
|
Assert.Equal(1, stealer.MouseDowns); // unchanged — the popup won
|
|
Assert.Equal(1, stealer.Clicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void OpenPopup_OutsidePress_DismissesAndSwallows_ThenNormalRoutingResumes()
|
|
{
|
|
var (root, menu, stealer, selected) = BuildOverlappingTree();
|
|
|
|
Click(root, 30, 30);
|
|
Assert.True(menu.IsOpen);
|
|
|
|
// (230, 60): inside the stealer's rect (x < 250, y in 44..74) but past
|
|
// the popup's right edge (screen x 10 + outer width 201 = 211). The
|
|
// dismissing press must close the popup WITHOUT acting on the widget
|
|
// underneath — the standard dropdown-dismiss gesture, and exactly the
|
|
// accidental-toggle hazard #374's gate session demonstrated.
|
|
Click(root, 230, 60);
|
|
|
|
Assert.False(menu.IsOpen);
|
|
Assert.Null(selected());
|
|
Assert.Equal(0, stealer.MouseDowns);
|
|
Assert.Equal(0, stealer.Clicks);
|
|
|
|
// With the popup gone, the same spot routes normally again.
|
|
Click(root, 230, 60);
|
|
Assert.Equal(1, stealer.MouseDowns);
|
|
Assert.Equal(1, stealer.Clicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReopeningAfterDismiss_StillRoutesItemClicks()
|
|
{
|
|
var (root, menu, _, selected) = BuildOverlappingTree();
|
|
|
|
Click(root, 30, 30); // open
|
|
Click(root, 230, 60); // dismiss (swallowed)
|
|
Assert.False(menu.IsOpen);
|
|
|
|
Click(root, 30, 30); // reopen
|
|
Assert.True(menu.IsOpen);
|
|
Click(root, 30, 50); // pick item A again
|
|
Assert.Equal("a", selected());
|
|
}
|
|
|
|
[Fact]
|
|
public void HidingThePopupsWindowWhileOpen_SelfHeals_NoStalePopupRouting()
|
|
{
|
|
var (root, menu, stealer, selected) = BuildOverlappingTree();
|
|
|
|
Click(root, 30, 30);
|
|
Assert.True(menu.IsOpen);
|
|
|
|
// The panel hosting the menu closes while the popup is open (e.g. the
|
|
// Options panel is toggled shut). The stale registration must not keep
|
|
// swallowing clicks for an invisible popup.
|
|
menu.Parent!.Visible = false;
|
|
Click(root, 230, 60); // self-heal press (dismissed, swallowed)
|
|
Assert.False(menu.IsOpen);
|
|
|
|
Click(root, 230, 60);
|
|
// The stealer is inside the hidden panel too, so the press falls
|
|
// through to nothing — the point is that the popup no longer claims it.
|
|
Assert.Equal(0, stealer.MouseDowns);
|
|
|
|
menu.Parent!.Visible = true;
|
|
Click(root, 230, 60);
|
|
Assert.Equal(1, stealer.MouseDowns);
|
|
Assert.Null(selected());
|
|
}
|
|
}
|