using AcDream.App.UI; using Xunit; namespace AcDream.App.Tests.UI; public class DragDropSpineTests { // A spy handler used across the spine tests. private sealed class SpyHandler : IItemListDragHandler { public bool AcceptResult = true; public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver; public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop; public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p) { LastLift = (list, cell, p); } public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p) { LastOver = (list, cell, p); return AcceptResult; } public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p) { LastDrop = (list, cell, p); } } [Fact] public void Payload_holdsAllFields() { var src = new UiItemSlot(); var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src); Assert.Equal(0x5001u, p.ObjId); Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); Assert.Equal(3, p.SourceSlot); Assert.Same(src, p.SourceCell); } [Fact] public void UiItemList_registerDragHandler_roundtrips() { var list = new UiItemList(_ => (0u, 0, 0)); Assert.Null(list.DragHandler); var h = new SpyHandler(); list.RegisterDragHandler(h); Assert.Same(h, list.DragHandler); } // ── UiItemSlot drag-source payload/ghost ──────────────────────────────── [Fact] public void GetDragPayload_emptyCell_isNull() => Assert.Null(new UiItemSlot().GetDragPayload()); [Fact] public void GetDragPayload_boundCell_snapshotsFields() { var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar }; cell.SetItem(0x5001u, 0x99u); var p = Assert.IsType(cell.GetDragPayload()); Assert.Equal(0x5001u, p.ObjId); Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); Assert.Equal(4, p.SourceSlot); Assert.Same(cell, p.SourceCell); } [Fact] public void GetDragGhost_emptyCell_isNull() => Assert.Null(new UiItemSlot().GetDragGhost()); [Fact] public void GetDragGhost_boundCell_returnsIconTuple() { var cell = new UiItemSlot { Width = 32, Height = 32 }; cell.SetItem(0x5001u, 0x99u); var g = cell.GetDragGhost(); Assert.NotNull(g); Assert.Equal(0x99u, g!.Value.tex); Assert.Equal(32, g.Value.w); Assert.Equal(32, g.Value.h); } // ── cell drop-target: DragEnter overlay + DropReleased dispatch ────────── private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler() { var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless var h = new SpyHandler(); list.RegisterDragHandler(h); return (list, list.Cell, h); } private static ItemDragPayload SomePayload() => new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); [Fact] public void DragEnter_setsAcceptOverlay_whenHandlerAccepts() { var (_, cell, h) = ListWithHandler(); h.AcceptResult = true; cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual); } [Fact] public void DragEnter_setsRejectOverlay_whenHandlerRejects() { var (_, cell, h) = ListWithHandler(); h.AcceptResult = false; cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); } [Fact] public void DragOver_resetsOverlayToNeutral() { var (_, cell, h) = ListWithHandler(); cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload())); Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual); } [Fact] public void DropReleased_accepted_dispatchesToHandler() { var (list, cell, h) = ListWithHandler(); var p = SomePayload(); cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p)); Assert.NotNull(h.LastDrop); Assert.Same(list, h.LastDrop!.Value.list); Assert.Same(cell, h.LastDrop.Value.cell); Assert.Same(p, h.LastDrop.Value.payload); } [Fact] public void DropReleased_dispatchesToHandler_regardlessOfData0() { // Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a // hit), so the handler is authoritative — it dispatches whether or not Data0 is set. var (list, cell, h) = ListWithHandler(); cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); Assert.NotNull(h.LastDrop); } [Fact] public void DragBegin_callsHandlerOnDragLift() { var (list, cell, h) = ListWithHandler(); var p = SomePayload(); cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p)); Assert.NotNull(h.LastLift); Assert.Same(list, h.LastLift!.Value.list); Assert.Same(cell, h.LastLift.Value.cell); Assert.Same(p, h.LastLift.Value.payload); } [Fact] public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing() { var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99 root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost cell.Clear(); // simulate the lift emptying the source Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); } [Fact] public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() { var root = new UiRoot { Width = 800, Height = 600 }; var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; list.Cell.Width = 32; list.Cell.Height = 32; list.Cell.SetItem(0x5001u, 0x99u); var h = new SpyHandler(); list.RegisterDragHandler(h); root.AddChild(list); root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseMove(20, 10); // BeginDrag → OnDragLift root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space Assert.NotNull(h.LastLift); // lift happened Assert.Null(h.LastDrop); // no drop dispatched (off-bar) Assert.Null(root.DragSource); // cleaned up } // ── Full UiRoot chain: arming + use-vs-drag ───────────────────────────── // A bound, hit-testable slot inside a list, sized for the hit-test. private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId) { var root = new UiRoot { Width = 800, Height = 600 }; var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; // Tests don't run OnDraw (which sizes the cell), so size the cell explicitly. list.Cell.Width = 32; list.Cell.Height = 32; if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); root.AddChild(list); return (root, list, list.Cell); } [Fact] public void BeginDrag_arms_whenPayloadNonNull() { var (root, _, cell) = RootWithBoundSlot(0x5001u); root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseMove(20, 10); // >3px → promote to drag Assert.Same(cell, root.DragSource); Assert.IsType(root.DragPayload); } [Fact] public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot() { var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseMove(20, 10); Assert.Null(root.DragSource); // never armed } [Fact] public void Click_withoutDrag_firesUse() { var (root, _, cell) = RootWithBoundSlot(0x5001u); bool used = false; cell.Clicked = () => used = true; root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted Assert.True(used); } [Fact] public void CompletedDrag_doesNotFireUse() { var (root, _, cell) = RootWithBoundSlot(0x5001u); bool used = false; cell.Clicked = () => used = true; root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseMove(20, 10); // promote to drag root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click Assert.False(used); } // ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ── [Fact] public void DragEnter_orphanCell_noList_defaultsToReject() { var cell = new UiItemSlot(); // no parent list → FindList() null cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); } [Fact] public void DragEnter_listWithoutHandler_defaultsToReject() { var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload())); Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual); } // ── item drag inside a Draggable window (the LIVE toolbar topology) ────── // Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar // frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag // (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window // drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier // RootWithBoundSlot tests put the slot directly under the root (no draggable // ancestor), so they could not catch this. private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId) { var root = new UiRoot { Width = 800, Height = 600 }; var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true }; var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 }; list.Cell.Width = 32; list.Cell.Height = 32; if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); frame.AddChild(list); root.AddChild(frame); return (root, frame, list); } [Fact] public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow() { var (root, frame, list) = DraggableFrameWithSlot(0x5001u); // Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px. root.OnMouseDown(UiMouseButton.Left, 20, 310); root.OnMouseMove(40, 310); Assert.Same(list.Cell, root.DragSource); // item drag armed Assert.Equal(10f, frame.Left); // window did NOT move Assert.Equal(300f, frame.Top); } [Fact] public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag() { var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source root.OnMouseDown(UiMouseButton.Left, 20, 310); root.OnMouseMove(40, 310); Assert.Null(root.DragSource); // no item drag Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30) Assert.Equal(300f, frame.Top); // y unchanged (310-10=300) } }