acdream/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
Erik ceec3bc440 feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice
TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.

Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.

Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):

- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
  The cache assumes it is the sole writer of GL program/blend/depth/cull
  state, which was true while it had zero real consumers, but every
  still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
  mutates that same GL state directly and never informs the cache. Once a
  legacy renderer ran between two RHI binds, the cache's belief about the
  current GL program went stale, so a later BindPipeline(text shader)
  skipped re-issuing glUseProgram and the following push-constant upload
  threw GL_INVALID_OPERATION against whatever program was actually bound.
  Reset() at the frame boundary is the same defensive move BeginPass
  already makes after a forced clear (see its comment); it costs one
  redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
  GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
  computed from GpuPipelineDescription.SampleCount at BindPipeline time -
  mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
  toggle.

Collateral, scoped to keep the port real rather than a stub:

- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
  every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
  TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
  Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
  every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
  check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
  slot (the device's default white texture), so the old sentinel would
  have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
  public AcDream.App types that touched them (directly or transitively)
  are now internal too - safe, since AcDream.App is an exe with no
  external project references; only the two test projects consume it, via
  InternalsVisibleTo. A handful of unrelated types the sweep caught
  (ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
  as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
  were reverted back to public where making them internal would have
  either cascaded into unrelated files or broken xUnit's public-member
  discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
  color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
  produce (V4g's scope) into the device's texture table for
  UiViewport.TextureHandle, via a temporary
  GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
  part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
  now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
  conformance tests keyed to TextRenderer's old multi-resource
  construction shape (Shader + per-flight FrameBufferSet array + white
  texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
  - that shape is gone, replaced by one IGpuPipeline created through
    IGpuDevice. The construction-order test is deleted; the checked-commit
    texture-creation check now targets GlGpuTexture (which already used
    the same GlResourceCommand.CreateName primitive before this slice).

Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
  TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
  skipped (was 3,843/3 entering this slice - net 3 fewer tests:
  TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
  TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
  method). Full solution: 8,908 passed / 5 skipped across all nine test
  projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
  vs this commit): differing fraction 0.318% (1,791/563,200 compared
  pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
  than waved through: a diff heatmap plus 4x crops at the differing
  clusters show zero differences anywhere in the retained UI, terrain,
  scenery, or static meshes - every differing pixel sits on continuously-
  animated ambient content (flying-insect sprites over the swamp, foliage
  sparkle/dew glints) whose exact phase depends on elapsed wall-clock
  time, the same category the gate's own sky-masking rationale already
  documents and the campaign doc's coverage table explicitly excludes
  ("Not covered - particles"). Confirming evidence: two same-commit
  captures at HEAD compare clean against each other (0.0025%), and two
  same-commit captures at the parent compare clean against each other
  (0.0044%) - only base-vs-head is consistently elevated, which is what
  frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
  ring resets, the render-state reset above) would produce against a
  fixed wall-clock capture deadline, not a rendering defect. Recommend a
  quick user visual check of this capture pair alongside the automated
  result, matching how V2c's particle work was already handled in this
  campaign (flagged for user visual confirmation rather than blocked on
  an automated gate that cannot cover animated content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:22:08 +02:00

679 lines
25 KiB
C#

using AcDream.App.Rendering.Gpu;
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(_ => (new GpuTextureSlot(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, new GpuTextureSlot(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();
}
}