Keep retail's underlay-free m_pDragIcon separate from the full cell icon and reveal the authored ghost mesh on physical source cells for the complete drag lifecycle. This removes the backpack backing from the cursor and retires AP-47. Co-Authored-By: Codex <codex@openai.com>
339 lines
14 KiB
C#
339 lines
14 KiB
C#
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 ItemDragAcceptance Acceptance = ItemDragAcceptance.Accept;
|
|
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 ItemDragAcceptance OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
|
{ LastOver = (list, cell, p); return Acceptance; }
|
|
|
|
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<ItemDragPayload>(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);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetDragGhost_prefersDedicatedUnderlayFreeTexture()
|
|
{
|
|
var cell = new UiItemSlot { Width = 36, Height = 36 }; // retail bag cell is larger than its icon
|
|
cell.SetItem(0x5001u, 0x99u, dragIconTexture: 0x77u);
|
|
|
|
Assert.Equal((0x77u, 32, 32), cell.GetDragGhost());
|
|
Assert.Equal(0x99u, cell.IconTexture); // source cell keeps the full m_pIcon
|
|
}
|
|
|
|
// ── 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.Acceptance = ItemDragAcceptance.Accept;
|
|
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.Acceptance = ItemDragAcceptance.Reject;
|
|
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
|
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
|
}
|
|
|
|
[Fact]
|
|
public void DragEnter_keepsNeutralOverlay_whenHandlerIgnoresAlias()
|
|
{
|
|
var (_, cell, h) = ListWithHandler();
|
|
h.Acceptance = ItemDragAcceptance.None;
|
|
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
|
Assert.Equal(UiItemSlot.DragAcceptState.None, 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
|
|
}
|
|
|
|
[Fact]
|
|
public void InventoryDrag_ghostsSourceUntilRelease()
|
|
{
|
|
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
|
cell.SourceKind = ItemDragSource.Inventory;
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
|
root.OnMouseMove(20, 10);
|
|
Assert.True(cell.WaitingVisual);
|
|
|
|
root.OnMouseUp(UiMouseButton.Left, 600, 500);
|
|
Assert.False(cell.WaitingVisual);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShortcutDrag_doesNotGhostSource()
|
|
{
|
|
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
|
cell.SourceKind = ItemDragSource.ShortcutBar;
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
|
root.OnMouseMove(20, 10);
|
|
|
|
Assert.False(cell.WaitingVisual);
|
|
}
|
|
|
|
// ── 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<ItemDragPayload>(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)
|
|
}
|
|
}
|