This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
678 lines
25 KiB
C#
678 lines
25 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 int Clicks;
|
|
public int DoubleClicks;
|
|
public Action? ClickAction;
|
|
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;
|
|
Clicks++;
|
|
ClickAction?.Invoke();
|
|
return true;
|
|
}
|
|
if (e.Type == UiEventType.DoubleClick) { DoubleClicks++; 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 DoubleClick_EmitsRealDoubleClickEvent()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
|
|
root.AddChild(btn);
|
|
|
|
root.Tick(0, nowMs: 1_000);
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 20, 10);
|
|
root.Tick(0, nowMs: 1_300);
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 20, 10);
|
|
|
|
Assert.Equal(2, btn.Clicks);
|
|
Assert.Equal(1, btn.DoubleClicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void DoubleClick_WhenClickHidesCapturedWindow_KeepsOriginalEventTarget()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Width = 200, Height = 100 };
|
|
var btn = new ClickRecorder(handlesClick: true) { Width = 120, Height = 20 };
|
|
window.AddChild(btn);
|
|
root.AddChild(window);
|
|
root.RegisterWindow("test", window);
|
|
|
|
root.Tick(0, nowMs: 1_000);
|
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 10, 10);
|
|
|
|
btn.ClickAction = () => root.HideWindow("test");
|
|
root.Tick(0, nowMs: 1_300);
|
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 10, 10);
|
|
|
|
Assert.False(window.Visible);
|
|
Assert.Null(root.Captured);
|
|
Assert.Equal(2, btn.Clicks);
|
|
Assert.Equal(1, btn.DoubleClicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseUp_WhenClickTransfersCapture_PreservesNewCaptureOwner()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var btn = new ClickRecorder(handlesClick: true) { Width = 120, Height = 20 };
|
|
var modal = new UiPanel { Left = 200, Width = 100, Height = 100 };
|
|
root.AddChild(btn);
|
|
root.AddChild(modal);
|
|
btn.ClickAction = () => root.SetCapture(modal);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 10, 10);
|
|
|
|
Assert.Same(modal, root.Captured);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemDragReleasedOutsideUi_raisesOutsideReleaseEvent()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var cell = new UiItemSlot { Left = 0, Top = 0, Width = 32, Height = 32 };
|
|
cell.SetItem(0x50000A01u, 0x99u);
|
|
root.AddChild(cell);
|
|
object? payload = null;
|
|
int releaseX = 0;
|
|
int releaseY = 0;
|
|
root.DragReleasedOutsideUi += (p, x, y) =>
|
|
{
|
|
payload = p;
|
|
releaseX = x;
|
|
releaseY = y;
|
|
};
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 5, 5);
|
|
root.OnMouseMove(20, 20);
|
|
root.OnMouseUp(UiMouseButton.Left, 200, 200);
|
|
|
|
var drag = Assert.IsType<ItemDragPayload>(payload);
|
|
Assert.Equal(0x50000A01u, drag.ObjId);
|
|
Assert.Equal(200, releaseX);
|
|
Assert.Equal(200, releaseY);
|
|
}
|
|
|
|
[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 WindowDrag_ConstrainedPanel_StaysFullyInsideParent()
|
|
{
|
|
var root = new UiRoot { Width = 200, Height = 150 };
|
|
var panel = new UiPanel
|
|
{
|
|
Left = 10,
|
|
Top = 10,
|
|
Width = 120,
|
|
Height = 100,
|
|
Draggable = true,
|
|
ConstrainDragToParent = true,
|
|
};
|
|
root.AddChild(panel);
|
|
root.RegisterWindow("radar", panel);
|
|
(string name, UiElement window)? moved = null;
|
|
root.WindowMoved += (name, window) => moved = (name, window);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
|
root.OnMouseMove(500, 500);
|
|
Assert.Equal(80f, panel.Left);
|
|
Assert.Equal(50f, panel.Top);
|
|
|
|
root.OnMouseMove(-500, -500);
|
|
Assert.Equal(0f, panel.Left);
|
|
Assert.Equal(0f, panel.Top);
|
|
root.OnMouseUp(UiMouseButton.Left, -500, -500);
|
|
Assert.Equal("radar", moved?.name);
|
|
Assert.Same(panel, moved?.window);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiLocked_BlocksWindowMoveWithoutDisablingWindow()
|
|
{
|
|
var root = new UiRoot { Width = 200, Height = 150, UiLocked = true };
|
|
var panel = new UiPanel
|
|
{
|
|
Left = 10,
|
|
Top = 10,
|
|
Width = 100,
|
|
Height = 60,
|
|
Draggable = true,
|
|
};
|
|
root.AddChild(panel);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
|
root.OnMouseMove(100, 100);
|
|
|
|
Assert.Equal(10f, panel.Left);
|
|
Assert.Equal(10f, panel.Top);
|
|
Assert.True(panel.Enabled);
|
|
}
|
|
|
|
[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 BottomResize_ConstrainedToParent_ReachesCurrentScreenEdge()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel
|
|
{
|
|
Left = 100,
|
|
Top = 80,
|
|
Width = 300,
|
|
Height = 200,
|
|
Draggable = true,
|
|
Resizable = true,
|
|
ResizeX = false,
|
|
ResizeY = true,
|
|
ResizableEdges = ResizeEdges.Bottom,
|
|
ConstrainResizeToParent = true,
|
|
MinHeight = 100,
|
|
};
|
|
root.AddChild(window);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 250, 279);
|
|
root.OnMouseMove(250, 2_000);
|
|
|
|
Assert.Equal(520f, window.Height);
|
|
Assert.Equal(root.Height, window.Top + window.Height);
|
|
root.OnMouseUp(UiMouseButton.Left, 250, 2_000);
|
|
}
|
|
|
|
[Fact]
|
|
public void TopWithoutTopResizeEdge_IsWindowMoveAffordance()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel
|
|
{
|
|
Left = 100,
|
|
Top = 80,
|
|
Width = 300,
|
|
Height = 200,
|
|
Draggable = true,
|
|
Resizable = true,
|
|
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Bottom,
|
|
};
|
|
root.AddChild(window);
|
|
|
|
root.OnMouseMove(250, 80);
|
|
|
|
Assert.Equal(ResizeEdges.None, root.HoverResizeEdges);
|
|
Assert.True(root.HoverWindowMove);
|
|
}
|
|
|
|
[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.False(root.IsWindowVisible("inventory"));
|
|
Assert.True(root.ShowWindow("inventory"));
|
|
Assert.True(win.Visible);
|
|
Assert.True(root.IsWindowVisible("inventory"));
|
|
Assert.True(root.HideWindow("inventory"));
|
|
Assert.False(win.Visible);
|
|
Assert.False(root.IsWindowVisible("inventory"));
|
|
|
|
Assert.False(root.ShowWindow("nope"));
|
|
Assert.False(root.HideWindow("nope"));
|
|
Assert.False(root.ToggleWindow("nope"));
|
|
Assert.False(root.IsWindowVisible("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);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddChild_AssignsEventIdsRecursively()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var parent = new UiPanel { Width = 100, Height = 100 };
|
|
var child = new UiPanel { Width = 50, Height = 50 };
|
|
var grandchild = new UiPanel { Width = 25, Height = 25 };
|
|
child.AddChild(grandchild);
|
|
parent.AddChild(child);
|
|
|
|
root.AddChild(parent);
|
|
|
|
Assert.NotEqual(0u, parent.EventId);
|
|
Assert.NotEqual(0u, child.EventId);
|
|
Assert.NotEqual(0u, grandchild.EventId);
|
|
Assert.Equal(3, new[] { parent.EventId, child.EventId, grandchild.EventId }.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void RemovingSubtree_ClearsAllRootOwnership()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Width = 200, Height = 100 };
|
|
var field = new UiField { Width = 100, Height = 20 };
|
|
window.AddChild(field);
|
|
root.AddChild(window);
|
|
root.RegisterWindow("test", window);
|
|
root.DefaultTextInput = field;
|
|
root.Modal = window;
|
|
root.SetKeyboardFocus(field);
|
|
root.SetCapture(field);
|
|
|
|
Assert.True(root.RemoveChild(window));
|
|
|
|
Assert.Null(root.KeyboardFocus);
|
|
Assert.Null(root.Captured);
|
|
Assert.Null(root.DefaultTextInput);
|
|
Assert.Null(root.Modal);
|
|
Assert.False(root.ShowWindow("test"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_ChecksTooltipBeforeGlobalTimeMessage_AtRetailDelay()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var recorder = new TimeOrderRecorder { Width = 100, Height = 100 };
|
|
root.AddChild(recorder);
|
|
|
|
root.Tick(0, 1_000);
|
|
root.OnMouseMove(10, 10);
|
|
recorder.Events.Clear();
|
|
root.Tick(0, 1_249);
|
|
Assert.DoesNotContain("tooltip", recorder.Events);
|
|
recorder.Events.Clear();
|
|
|
|
root.Tick(0, 1_250);
|
|
|
|
Assert.Equal(new[] { "tooltip", "global" }, recorder.Events);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_GlobalTimeRemoval_SkipsDetachedSiblingWithoutInvalidatingWalk()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var victim = new TimeOrderRecorder { Width = 100, Height = 100 };
|
|
var remover = new GlobalTimeAction(() => root.RemoveChild(victim))
|
|
{ Width = 100, Height = 100 };
|
|
root.AddChild(remover);
|
|
root.AddChild(victim);
|
|
|
|
root.Tick(0, 1_000);
|
|
|
|
Assert.Empty(victim.Events);
|
|
Assert.Null(victim.Parent);
|
|
}
|
|
|
|
private sealed class TimeOrderRecorder : UiElement, IUiGlobalTimeListener
|
|
{
|
|
public List<string> Events { get; } = new();
|
|
|
|
public override bool OnEvent(in UiEvent e)
|
|
{
|
|
if (e.Type != UiEventType.Tooltip) return false;
|
|
Events.Add("tooltip");
|
|
return true;
|
|
}
|
|
|
|
public void OnGlobalUiTime(double nowSeconds) => Events.Add("global");
|
|
}
|
|
|
|
private sealed class GlobalTimeAction(Action action) : UiElement, IUiGlobalTimeListener
|
|
{
|
|
public void OnGlobalUiTime(double nowSeconds) => action();
|
|
}
|
|
}
|