acdream/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
Erik 8ee3d89feb fix(D.2b): Slice 2 — buttons inside a whole-window-Draggable frame get their Click
Visual gate 2 (user): the "Slots" toggle caption was visible but unclickable.

Root cause (UiRoot.OnMouseDown/OnMouseUp): a left-press on a non-drag-source
widget inside a whole-window-Draggable frame (the inventory window's IA-12
drag) set _windowDragTarget; OnMouseUp then early-returned before emitting the
Click. So the paperdoll Slots button (the first plain button inside the
draggable inventory frame) never received its click. Chat/toolbar buttons
escape this — their frames aren't whole-window-draggable.

Fix (toolkit, root cause not band-aid): add UiElement.HandlesClick (a virtual
opt-out parallel to IsDragSource); UiButton overrides it true; OnMouseDown
routes a HandlesClick press to the widget (like CapturesPointerDrag) instead of
the window-drag, so OnMouseUp emits the Click. 2 regression tests lock it
(HandlesClick widget in a Draggable frame emits Click; a plain one doesn't).

Build + full App suite green (596, +2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:08:59 +02:00

380 lines
16 KiB
C#

using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public class UiRootInputTests
{
[Fact]
public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks()
{
// Regression: the per-frame anchor pass must NOT reset a window's rect,
// or move/resize get undone every frame. Windows are user-positioned.
var panel = new UiNineSlicePanel(_ => ((uint)1, 32, 32));
Assert.Equal(AnchorEdges.None, panel.Anchors);
}
private sealed class ClickRecorder : UiElement
{
private readonly bool _handlesClick;
public bool Clicked;
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
public override bool HandlesClick => _handlesClick;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click) { Clicked = true; return true; }
return false;
}
}
[Fact]
public void HandlesClickWidget_insideDraggableWindow_stillEmitsClick()
{
// Regression (paperdoll "Slots" toggle): a HandlesClick widget (e.g. a UiButton) inside a
// whole-window-Draggable frame (the inventory window, IA-12 whole-window-drag) must still
// receive its Click. Before the fix the window-move branch captured the press and OnMouseUp
// returned before emitting Click, so the toggle button did nothing.
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
frame.AddChild(btn);
root.AddChild(frame);
root.OnMouseDown(UiMouseButton.Left, 20, 310); // press over the button (screen rect 15,305..135,319)
root.OnMouseUp(UiMouseButton.Left, 20, 310); // release same spot → Click
Assert.True(btn.Clicked);
}
[Fact]
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
{
// Contrast that locks the distinction: a NON-HandlesClick child inside the Draggable frame is
// captured as a whole-window-drag, so no Click is emitted (the frame is draggable by it).
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
var plain = new ClickRecorder(handlesClick: false) { Left = 5, Top = 5, Width = 120, Height = 14 };
frame.AddChild(plain);
root.AddChild(frame);
root.OnMouseDown(UiMouseButton.Left, 20, 310);
root.OnMouseUp(UiMouseButton.Left, 20, 310);
Assert.False(plain.Clicked);
}
private sealed class CoordRecorder : UiElement
{
public (int x, int y)? Down, Move;
public CoordRecorder() { CapturesPointerDrag = true; }
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.MouseDown) { Down = (e.Data1, e.Data2); return true; }
if (e.Type == UiEventType.MouseMove) { Move = (e.Data1, e.Data2); return true; }
return false;
}
}
[Fact]
public void MouseDown_And_MouseMove_DeliverSameTargetLocalFrame_ForNestedChild()
{
// Regression (adversarial review): a nested child must receive target-LOCAL
// coords on MouseDown AND MouseMove for the same physical point — otherwise
// drag-select anchors ~(child offset) px off from where you click. Before the
// fix MouseDown used HitTestTopDown's window-relative coords (50,40) while
// MouseMove used target-local (42,32).
var root = new UiRoot { Width = 800, Height = 600 };
var panel = new UiPanel { Left = 50, Top = 60, Width = 200, Height = 100 };
var child = new CoordRecorder { Left = 8, Top = 8, Width = 150, Height = 80 };
panel.AddChild(child);
root.AddChild(panel);
// child ScreenPosition = (58,68). Click screen (100,100) -> local (42,32).
root.OnMouseDown(UiMouseButton.Left, 100, 100);
Assert.Equal((42, 32), child.Down);
// drag to (120,110) -> local (62,42); MUST share the MouseDown frame.
root.OnMouseMove(120, 110);
Assert.Equal((62, 42), child.Move);
}
[Fact]
public void ApplyAnchor_None_IsNoOp()
{
var e = new UiPanel { Left = 50, Top = 60, Width = 100, Height = 40, Anchors = AnchorEdges.None };
e.ApplyAnchor(800, 600);
Assert.Equal(50f, e.Left);
Assert.Equal(60f, e.Top);
Assert.Equal(100f, e.Width);
Assert.Equal(40f, e.Height);
}
[Fact]
public void WantsMouse_TrueOverWidget_FalseOverEmptySpace()
{
var root = new UiRoot { Width = 800, Height = 600 };
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 };
root.AddChild(panel);
root.OnMouseMove(50, 30); // inside the panel
Assert.True(root.WantsMouse);
root.OnMouseMove(500, 400); // empty space
Assert.False(root.WantsMouse);
}
[Fact]
public void WindowDrag_RepositionsDraggablePanel_StopsOnRelease()
{
var root = new UiRoot { Width = 800, Height = 600 };
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50, Draggable = true };
root.AddChild(panel);
root.OnMouseDown(UiMouseButton.Left, 20, 20); // grab at (10,10) into the panel
root.OnMouseMove(120, 90); // drag
Assert.Equal(110f, panel.Left); // 120 - 10
Assert.Equal(80f, panel.Top); // 90 - 10
root.OnMouseUp(UiMouseButton.Left, 120, 90);
root.OnMouseMove(300, 300); // released — must not move
Assert.Equal(110f, panel.Left);
Assert.Equal(80f, panel.Top);
}
[Fact]
public void NonDraggablePanel_DoesNotMoveOnDrag()
{
var root = new UiRoot { Width = 800, Height = 600 };
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; // Draggable defaults false
root.AddChild(panel);
root.OnMouseDown(UiMouseButton.Left, 20, 20);
root.OnMouseMove(120, 90);
Assert.Equal(10f, panel.Left);
Assert.Equal(10f, panel.Top);
}
[Fact]
public void CapturesPointerDragChild_DoesNotMoveDraggableAncestor_OnInteriorDrag()
{
// A child that captures pointer drags (text selection) must NOT move its
// draggable ancestor window when the user drags inside it.
var root = new UiRoot { Width = 800, Height = 600 };
var window = new UiPanel { Left = 10, Top = 10, Width = 200, Height = 100, Draggable = true };
var child = new UiPanel { Left = 20, Top = 20, Width = 120, Height = 60, CapturesPointerDrag = true };
window.AddChild(child);
root.AddChild(window);
// Press deep inside the child, then drag.
root.OnMouseDown(UiMouseButton.Left, 60, 60);
root.OnMouseMove(160, 160);
// Window stays put; the captured child receives the drag itself.
Assert.Equal(10f, window.Left);
Assert.Equal(10f, window.Top);
Assert.Same(child, root.Captured);
root.OnMouseUp(UiMouseButton.Left, 160, 160);
Assert.Equal(10f, window.Left);
Assert.Equal(10f, window.Top);
}
[Fact]
public void CapturesPointerDragChild_StillAllowsEdgeResizeOfResizableWindow()
{
// Edge resize must still win even when a CapturesPointerDrag child covers
// the frame: a resizable chat window can be resized from its border.
var root = new UiRoot { Width = 800, Height = 600 };
var window = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 };
// Child fills the whole window (anchored) and captures interior drags.
var child = new UiPanel { Left = 0, Top = 0, Width = 200, Height = 100,
CapturesPointerDrag = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom };
window.AddChild(child);
root.AddChild(window);
// Grab within ResizeGrip(5) of the right edge (x=298 of right edge x=300) → resize.
root.OnMouseDown(UiMouseButton.Left, 298, 150);
root.OnMouseMove(338, 150);
Assert.Equal(240f, window.Width);
Assert.Equal(100f, window.Left);
root.OnMouseUp(UiMouseButton.Left, 338, 150);
}
[Fact]
public void ResizeRect_RightBottom_GrowsSizeOnly()
{
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
Assert.Equal(10f, x); Assert.Equal(20f, y);
Assert.Equal(130f, w); Assert.Equal(65f, h);
}
[Fact]
public void ResizeRect_LeftTop_MovesOriginAndClampsToMin()
{
// Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40,
// origin shifts so the RIGHT edge (110) stays put → x = 70.
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
Assert.Equal(40f, w);
Assert.Equal(70f, x);
}
[Fact]
public void ResizeRect_Bottom_ClampsToMaxH()
{
// dy=1000 on a 50-tall window with maxH=128 → height clamps to 128, origin unchanged.
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Bottom, dx: 0, dy: 1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
Assert.Equal(128f, h);
Assert.Equal(20f, y);
}
[Fact]
public void ResizeRect_Top_ClampsToMaxH()
{
// Drag the top edge UP by 1000 on a 50-tall window with maxH=128 → height clamps to 128,
// origin shifts so the bottom edge (70) stays put → y = 20 + 50 - 128.
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Top, dx: 0, dy: -1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
Assert.Equal(128f, h);
Assert.Equal(20f + 50f - 128f, y);
}
[Fact]
public void HitEdges_DetectsCornerAndInteriorNone()
{
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100 };
// bottom-right corner (300,200)
Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, UiRoot.HitEdges(panel, 300, 200, 5));
// deep interior → no edges
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 150, 5));
}
[Fact]
public void EdgeDrag_ResizesPanel_InteriorDragMoves()
{
var root = new UiRoot { Width = 800, Height = 600 };
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 };
root.AddChild(panel);
// grab just inside the right edge (x=298, within ResizeGrip=5 of x=300) and drag right → wider, same origin
root.OnMouseDown(UiMouseButton.Left, 298, 150);
root.OnMouseMove(338, 150);
Assert.Equal(240f, panel.Width);
Assert.Equal(100f, panel.Left);
root.OnMouseUp(UiMouseButton.Left, 338, 150);
// grab the interior and drag → moves
root.OnMouseDown(UiMouseButton.Left, 200, 150);
root.OnMouseMove(220, 170);
Assert.Equal(120f, panel.Left);
Assert.Equal(120f, panel.Top);
root.OnMouseUp(UiMouseButton.Left, 220, 170);
}
[Fact]
public void HitEdges_RespectsResizeAxisLock()
{
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, ResizeY = false };
// right edge still detected (X allowed)
Assert.True((UiRoot.HitEdges(panel, 300, 150, 5) & ResizeEdges.Right) != 0);
// bottom edge masked out (Y locked)
Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0);
}
[Fact]
public void ComputeAnchoredRect_LeftRight_StretchesWidth()
{
// bar at x=8,w=200 in a 220-wide parent (right margin 12). Parent grows to 300.
var (x, _, w, _) = UiElement.ComputeAnchoredRect(
AnchorEdges.Left | AnchorEdges.Right | AnchorEdges.Top,
mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96);
Assert.Equal(8f, x);
Assert.Equal(280f, w); // 300 - 12 - 8
}
[Fact]
public void ComputeAnchoredRect_LeftTopOnly_KeepsFixedSizeAndOrigin()
{
var (x, y, w, h) = UiElement.ComputeAnchoredRect(
AnchorEdges.Left | AnchorEdges.Top,
mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96);
Assert.Equal(8f, x); Assert.Equal(24f, y);
Assert.Equal(200f, w); Assert.Equal(14f, h);
}
[Fact]
public void ToggleWindow_FlipsVisible_AndReturnsNewState()
{
var root = new UiRoot { Width = 800, Height = 600 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(win);
root.RegisterWindow("inventory", win);
Assert.True(root.ToggleWindow("inventory")); // hidden -> shown
Assert.True(win.Visible);
Assert.False(root.ToggleWindow("inventory")); // shown -> hidden
Assert.False(win.Visible);
}
[Fact]
public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp()
{
var root = new UiRoot { Width = 800, Height = 600 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(win);
root.RegisterWindow("inventory", win);
Assert.True(root.ShowWindow("inventory"));
Assert.True(win.Visible);
Assert.True(root.HideWindow("inventory"));
Assert.False(win.Visible);
Assert.False(root.ShowWindow("nope"));
Assert.False(root.HideWindow("nope"));
Assert.False(root.ToggleWindow("nope"));
}
[Fact]
public void ShowWindow_RaisesAbovePeers()
{
var root = new UiRoot { Width = 800, Height = 600 };
var a = new UiPanel { Width = 100, Height = 100 };
var b = new UiPanel { Width = 100, Height = 100 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(a); root.AddChild(b); root.AddChild(win);
root.RegisterWindow("inventory", win);
root.ShowWindow("inventory");
Assert.True(win.ZOrder > a.ZOrder);
Assert.True(win.ZOrder > b.ZOrder);
}
[Fact]
public void BringToFront_SetsStrictlyGreatestZOrder()
{
var root = new UiRoot { Width = 800, Height = 600 };
var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 };
var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 };
root.AddChild(a); root.AddChild(b);
root.BringToFront(a);
Assert.True(a.ZOrder > b.ZOrder); // 10 > 9
}
[Fact]
public void MouseDown_OnWindow_RaisesItAbovePeers()
{
var root = new UiRoot { Width = 800, Height = 600 };
// 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does.
var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 };
var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true };
root.AddChild(peer); root.AddChild(clicked);
root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50)
Assert.True(clicked.ZOrder > peer.ZOrder);
root.OnMouseUp(UiMouseButton.Left, 50, 50);
}
}