fix(ui): spell-bar drag-reorder works — the per-frame rebuild was destroying the dragged cell (#354)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Everything already existed — the drag payloads, the favorite wire pair
(0x1E3 add-at-position / 0x1E4 remove, byte-confirmed against retail's
Event_AddSpellFavorite @0x006A0F70 and ACE), the insert-shift state
ops. The bug: lifting a favorite fires SpellbookChanged, the next
per-frame Tick rebuilt the bar, the rebuild flushed and recreated
every cell, and UiRoot's subtree-removal safety net canceled the
in-flight drag whose source had just been destroyed — one frame after
every lift, before any drop could land.

The rebuild now defers for the duration of the drag gesture, and the
drop ports retail's own -1-if-lifted-before-target index adjustment
(SpellCastSubMenu::AddFavorite @0x004C7060) so final positions are
byte-identical: insert-shift, not swap; drag-out still deletes (the
lift's removal stands on a missed drop, retail's shape). The
real-pointer-pipeline test fails against the pre-fix code with the
exact cancellation and passes after; a discriminator pins that
physical-item drop handlers reject the spell payload.

AP-172 files the one presentation divergence (mid-drag reflow happens
on release, not continuously) — renumbered from the agent's AP-171
draft, which collided with the same-day double-click row. #354 filed
and closed.

Clean-room complete solution: 11,541 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 18:28:47 +02:00
parent 0a996a1a91
commit 81a9d85a1d
5 changed files with 260 additions and 5 deletions

View file

@ -170,6 +170,111 @@ public sealed class SpellcastingUiControllerTests
Assert.Equal([2u], used);
}
/// <summary>
/// #354 (user gate finding): "I should be able to rearrange spells on the
/// spell bar ... drag one out and drop it in another position." Every prior
/// test in this file drives <c>Dropped</c>/<c>OnEvent</c> directly, bypassing
/// <see cref="UiRoot"/>'s real pointer pipeline -- which is exactly why the
/// regression this test guards against was invisible: BeginFavoriteDrag's
/// press-time removal fires SpellbookChanged, and a per-frame Tick() (as
/// production's RetailUiRuntime.Tick drives unconditionally) used to call
/// Rebuild(), which flushes and recreates every slot. UiRoot's subtree-removal
/// safety net then canceled the in-flight drag because its source cell had
/// just been destroyed out from under it. This drives the WHOLE gesture
/// through <see cref="UiRoot.OnMouseDown"/>/<see cref="UiRoot.OnMouseMove"/>/
/// a mid-drag <see cref="SpellcastingUiController.Tick"/>/
/// <see cref="UiRoot.OnMouseUp"/> and asserts both the retail-faithful final
/// order (insert-shift, not swap) and the exact remove-then-add wire pair.
/// </summary>
[Fact]
public void DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(1u, 1f);
spellbook.OnSpellLearned(2u, 1f);
spellbook.OnSpellLearned(3u, 1f);
spellbook.SetFavorite(0, 0, 1u);
spellbook.SetFavorite(0, 1, 2u);
spellbook.SetFavorite(0, 2, 3u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var selection = new SelectionState();
var casting = new RuntimeSpellCastState(spellbook, selection, new NoopSpellCastOperations());
var adds = new List<(int Tab, int Position, uint SpellId)>();
var removes = new List<(int Tab, uint SpellId)>();
using SpellcastingUiController? controller = SpellcastingUiController.Bind(
layout, spellbook, casting, objects, () => 1u,
spellId => spellId,
item => item.ObjectId,
_ => { },
selection,
(tab, position, spellId) =>
{
adds.Add((tab, position, spellId));
spellbook.SetFavorite(tab, position, spellId);
},
(tab, spellId) =>
{
removes.Add((tab, spellId));
spellbook.RemoveFavorite(tab, spellId);
});
Assert.True(controller is not null, DescribeBinding(layout));
var screen = new UiRoot { Width = 1280f, Height = 800f };
screen.AddChild(layout.Root);
controller.Tick();
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiCatalogSlot slot0 = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot slot2 = Assert.IsType<UiCatalogSlot>(list.GetItem(2));
Assert.Equal(1u, slot0.EntryId);
Assert.Equal(3u, slot2.EntryId);
System.Numerics.Vector2 p0 = slot0.ScreenPosition;
System.Numerics.Vector2 p2 = slot2.ScreenPosition;
int x0 = (int)(p0.X + slot0.Width / 2f);
int y0 = (int)(p0.Y + slot0.Height / 2f);
int x2 = (int)(p2.X + slot2.Width / 2f);
int y2 = (int)(p2.Y + slot2.Height / 2f);
// Press on spell 1's cell and drag past the 3px promotion threshold --
// BeginFavoriteDrag fires here and removes spell 1 from the model + wire.
screen.OnMouseDown(UiMouseButton.Left, x0, y0);
screen.OnMouseMove(x0 + 10, y0);
Assert.Same(slot0, screen.DragSource);
Assert.Equal([(0, 1u)], removes);
// A live per-frame Tick, exactly like production's RetailUiRuntime.Tick,
// must NOT cancel the drag just because the lift already changed the
// Spellbook -- this is the regression this test exists to catch.
controller.Tick();
Assert.Same(slot0, screen.DragSource);
// Move onto spell 3's cell and release there.
screen.OnMouseMove(x2, y2);
screen.OnMouseUp(UiMouseButton.Left, x2, y2);
Assert.Null(screen.DragSource);
// Insert-shift, not swap: dropping spell 1 onto spell 3's (pre-lift index 2)
// cell lands it directly before spell 3, matching PlayerModule::AddSpellFavorite
// @ 0x005D43E0's InsertPos semantics.
Assert.Equal(0, adds[0].Tab);
Assert.Equal(1, adds[0].Position);
Assert.Equal(1u, adds[0].SpellId);
Assert.Equal(new uint[] { 2u, 1u, 3u }, spellbook.GetFavorites(0));
// The list resyncs to the final state on the next tick.
controller.Tick();
Assert.Equal(2u, Assert.IsType<UiCatalogSlot>(list.GetItem(0)).EntryId);
Assert.Equal(1u, Assert.IsType<UiCatalogSlot>(list.GetItem(1)).EntryId);
Assert.Equal(3u, Assert.IsType<UiCatalogSlot>(list.GetItem(2)).EntryId);
}
[Fact]
public void FavoriteDrop_IgnoresForeignInventoryPayload()
{
@ -197,6 +302,42 @@ public sealed class SpellcastingUiControllerTests
Assert.Equal([42u], spellbook.GetFavorites(0));
}
/// <summary>
/// #354 discriminator sabotage check, symmetric to
/// <see cref="FavoriteDrop_IgnoresForeignInventoryPayload"/>: a catalog payload
/// minted by the favorite bar must be rejected by every PHYSICAL item slot too
/// (toolbar / inventory / vendor / paperdoll all route drops through
/// <see cref="IItemListDragHandler"/>, which only accepts <see cref="ItemDragPayload"/>).
/// If <see cref="SpellFavoriteDragPayload"/> were ever widened to match, a spell
/// icon dragged onto the toolbar would silently misbehave instead of no-op'ing.
/// </summary>
[Fact]
public void SpellFavoritePayload_IsRejectedByAPhysicalItemListDragHandler()
{
var recordedDrops = new List<uint>();
var handler = new RecordingDragHandler(recordedDrops);
var list = new UiItemList();
list.RegisterDragHandler(handler);
var cell = list.Cell;
var payload = new SpellFavoriteDragPayload(0, 0, 42u);
bool handled = cell.OnEvent(new UiEvent(
0, cell, UiEventType.DropReleased, Payload: payload));
Assert.True(handled);
Assert.Empty(recordedDrops);
}
private sealed class RecordingDragHandler(List<uint> drops) : IItemListDragHandler
{
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
=> drops.Add(payload.ObjId);
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> ItemDragAcceptance.None;
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> drops.Add(payload.ObjId);
}
[Fact]
public void SpellbookShortcutDrop_AddsToOpenTabWithoutRemovingLearnedSpell()
{

View file

@ -199,6 +199,55 @@ public sealed class SpellbookTests
Assert.Equal(1, book.ActiveCount);
}
/// <summary>
/// #354: retail's SpellCastSubMenu::AddFavorite @ 0x004C7060 reorders a favorite
/// bar by removing the dragged spell from wherever it currently sits, then
/// InsertPos-ing it at the (possibly shifted) target index — never a two-slot
/// swap. Dragging slot 0 onto slot 2 must land it directly BEFORE the spell
/// that was there, shifting everything between one slot toward the vacated gap.
/// </summary>
[Fact]
public void SetFavorite_MovingWithinTheSameBar_InsertShiftsRatherThanSwaps()
{
var book = new Spellbook();
book.SetFavorite(0, 0, 1u);
book.SetFavorite(0, 1, 2u);
book.SetFavorite(0, 2, 3u);
// Drag spell 1 (index 0) onto spell 3's slot (index 2, already adjusted for
// the pre-lift removal by the caller, matching PlayerModule::AddSpellFavorite
// @ 0x005D43E0's InsertPos(list, position, spellId) contract).
book.SetFavorite(0, 1, 1u);
Assert.Equal(new uint[] { 2u, 1u, 3u }, book.GetFavorites(0));
}
[Fact]
public void SetFavorite_MovingToTheEnd_AppendsRatherThanLeavingAGap()
{
var book = new Spellbook();
book.SetFavorite(0, 0, 1u);
book.SetFavorite(0, 1, 2u);
book.SetFavorite(0, 2, 3u);
book.SetFavorite(0, 2, 1u);
Assert.Equal(new uint[] { 2u, 3u, 1u }, book.GetFavorites(0));
}
[Fact]
public void RemoveFavorite_LeavesTheRemainingBarContiguous()
{
var book = new Spellbook();
book.SetFavorite(0, 0, 1u);
book.SetFavorite(0, 1, 2u);
book.SetFavorite(0, 2, 3u);
book.RemoveFavorite(0, 2u);
Assert.Equal(new uint[] { 1u, 3u }, book.GetFavorites(0));
}
[Fact]
public void OnEnchantmentRemoved_FiresEvent()
{