Found at visual verification: an occupied UiItemSlot sits inside the Draggable toolbar frame (UiNineSlicePanel.Draggable=true), so UiRoot.OnMouseDown's FindWindow returned the frame and the window-move branch won — press+drag on a slot moved the whole bar instead of picking up the item. The slot wasn't CapturesPointerDrag (that path is for self-driven text-selection and suppresses the BeginDrag promotion), and UiRoot had no path for "a drag-source inside a draggable window." Fix: add UiElement.IsDragSource (virtual, default false); UiItemSlot overrides it to `ItemId != 0` (occupancy-gated). UiRoot.OnMouseDown now prioritizes IsDragSource over window-move — an OCCUPIED slot starts the item drag (promotes to BeginDrag on >3px), an EMPTY slot falls through to the IA-12 whole-window-drag so the bar stays movable by its empty cells / chrome. UiRoot stays item-agnostic (reads only the bool). This REDUCES divergence (occupied cells now drag like retail) within IA-12's umbrella — no new register row. Regression tests reproduce the LIVE topology (slot inside a Draggable frame); the earlier RootWithBoundSlot tests put the slot directly under the root, so they could not catch it. Full suite 493 pass / 0 fail / 2 skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
248 lines
9.9 KiB
C#
248 lines
9.9 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 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_notAccepted_skipsDispatch()
|
|
{
|
|
var (_, cell, h) = ListWithHandler();
|
|
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
|
|
Assert.Null(h.LastDrop);
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
}
|