294 lines
12 KiB
C#
294 lines
12 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 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<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);
|
|
}
|
|
|
|
// ── 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<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)
|
|
}
|
|
}
|