acdream/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

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

482 lines
18 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 bool? WaitingAtLift;
public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{
WaitingAtLift = cell.WaitingVisual;
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
Assert.False(h.WaitingAtLift); // retail selects/lifts before enabling the mesh
Assert.True(list.Cell.WaitingVisual);
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;
cell.Selected = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10);
Assert.True(cell.WaitingVisual);
Assert.True(cell.Selected); // selected indicator remains active above the waiting mesh
root.OnMouseUp(UiMouseButton.Left, 600, 500);
Assert.False(cell.WaitingVisual);
Assert.True(cell.Selected);
}
[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);
}
private static (UiRoot root, UiItemList list, UiCatalogSlot cell)
RootWithCatalogSlot(uint entryId)
{
var root = new UiRoot { Width = 800, Height = 600 };
var list = new UiItemList(_ => (1u, 1, 1))
{
Left = 0,
Top = 0,
Width = 32,
Height = 32,
};
list.Flush();
var cell = new UiCatalogSlot
{
EntryId = entryId,
Width = 32,
Height = 32,
SpriteResolve = _ => (1u, 1, 1),
};
list.AddItem(cell);
root.AddChild(list);
return (root, 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 PhysicalItemPress_selectsBeforeRelease_thenClickActivates()
{
var (root, list, cell) = RootWithBoundSlot(0x5001u);
var selected = new List<uint>();
bool used = false;
list.PrimaryItemPressed = item =>
{
selected.Add(item);
return false;
};
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
Assert.Equal(new uint[] { 0x5001u }, selected);
Assert.False(used);
root.OnMouseUp(UiMouseButton.Left, 10, 10);
Assert.True(used);
}
[Fact]
public void ConsumedPhysicalItemPress_suppressesCompletedActivation()
{
var (root, list, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
bool doubleUsed = false;
list.PrimaryItemPressed = _ => true;
cell.Clicked = () => used = true;
cell.DoubleClicked = () => doubleUsed = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseUp(UiMouseButton.Left, 10, 10);
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseUp(UiMouseButton.Left, 10, 10);
Assert.False(used);
Assert.False(doubleUsed);
}
[Fact]
public void ConsumedPhysicalItemPress_cannotPromoteIntoDrag()
{
var (root, list, _) = RootWithBoundSlot(0x5001u);
list.PrimaryItemPressed = _ => true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10);
Assert.Null(root.DragSource);
Assert.Null(root.DragPayload);
}
[Fact]
public void RightClick_withoutDrag_requestsItemListAppraisal()
{
var (root, list, _) = RootWithBoundSlot(0x5001u);
var examined = new List<uint>();
list.ExamineItemRequested = examined.Add;
root.OnMouseDown(UiMouseButton.Right, 10, 10);
root.OnMouseUp(UiMouseButton.Right, 10, 10);
Assert.Equal(new uint[] { 0x5001u }, examined);
Assert.Null(root.DragSource);
}
[Fact]
public void RightButtonMovement_cancelsAppraisal_andNeverStartsItemDrag()
{
var (root, list, _) = RootWithBoundSlot(0x5001u);
var examined = new List<uint>();
list.ExamineItemRequested = examined.Add;
root.OnMouseDown(UiMouseButton.Right, 10, 10);
root.OnMouseMove(14, 10);
root.OnMouseUp(UiMouseButton.Right, 14, 10);
Assert.Empty(examined);
Assert.Null(root.DragSource);
}
[Fact]
public void CatalogEntryPress_selectsBeforeRelease_withoutForgingItemIdentity()
{
var (root, list, cell) = RootWithCatalogSlot(42u);
var selected = new List<uint>();
list.PrimaryCatalogEntryPressed = selected.Add;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
Assert.Equal(new uint[] { 42u }, selected);
Assert.Equal(0u, cell.ItemId);
}
[Fact]
public void CatalogEntryRightClick_requestsLocalCatalogExamination()
{
var (root, list, _) = RootWithCatalogSlot(42u);
var examined = new List<uint>();
list.ExamineCatalogEntryRequested = examined.Add;
root.OnMouseDown(UiMouseButton.Right, 10, 10);
root.OnMouseUp(UiMouseButton.Right, 10, 10);
Assert.Equal(new uint[] { 42u }, examined);
}
[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)
}
}