fix(ui): port source-aware toolbar drops

Plan retail shortcut drops from a complete post-lift snapshot so fresh inventory items use cyclic-right displacement while toolbar aliases restore to their vacated slot. Apply local mutations atomically, preserve raw shortcut fields, emit exact Remove/Add ordering, and retire AP-102 after the live ACE gate.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 09:26:57 +02:00
parent b5b230c860
commit 3281e0acb4
8 changed files with 462 additions and 23 deletions

View file

@ -865,6 +865,67 @@ public class ToolbarControllerTests
Assert.DoesNotContain(adds, a => a.Index == 3);
}
[Fact]
public void HandleDropRelease_freshInventoryToOccupied_usesCyclicRightSlotNotInventoryIndex()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, IconId = 1u });
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, IconId = 2u });
var shortcuts = new[]
{
new ShortcutEntry(5, 0x5002u, 0xA5C31234u),
};
var wire = new List<string>();
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { },
sendAddShortcut: entry => wire.Add($"add:{entry.Index}:{entry.ObjectId:X8}:{entry.SpellId:X8}"),
sendRemoveShortcut: slot => wire.Add($"remove:{slot}"));
var payload = new ItemDragPayload(
0x5001u, ItemDragSource.Inventory, SourceSlot: 12, new UiItemSlot());
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
Assert.Equal(0x5002u, slots[Row1[6]].Cell.ItemId);
Assert.Equal(0u, slots[Row2[3]].Cell.ItemId); // inventory grid index 12 is irrelevant
Assert.Equal(new[]
{
"remove:5",
"add:5:00005001:00000000",
"add:6:00005002:A5C31234",
}, wire);
}
[Fact]
public void ReplaceFullyMergedShortcut_rekeysEntryAndPreservesRawFields()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, IconId = 1u });
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, IconId = 2u });
var shortcuts = new[]
{
new ShortcutEntry(4, 0x5001u, 0x11223344u),
};
var wire = new List<string>();
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { },
sendAddShortcut: entry => wire.Add($"add:{entry.Index}:{entry.ObjectId:X8}:{entry.SpellId:X8}"),
sendRemoveShortcut: slot => wire.Add($"remove:{slot}"));
ctrl.ReplaceFullyMergedShortcut(0x5001u, 0x5002u);
Assert.Equal(0x5002u, slots[Row1[4]].Cell.ItemId);
Assert.Equal(new[]
{
"remove:4",
"add:4:00005002:11223344",
}, wire);
}
[Fact]
public void HandleDropRelease_ontoSelf_reAddsToSource()
{

View file

@ -0,0 +1,162 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ShortcutDropPlannerTests
{
[Fact]
public void FreshItemToEmpty_AddsOnlyAtTarget()
{
var slots = Empty();
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
slots, ShortcutDropSource.FreshItem, sourceSlot: 11, targetSlot: 4,
new ShortcutEntry(11, 0xA001u, 0u));
Assert.Equal(new[]
{
ShortcutMutation.Add(new ShortcutEntry(4, 0xA001u, 0u)),
}, plan);
}
[Fact]
public void FreshItemToOccupied_MovesDisplacedToFirstEmptyOnRight()
{
var slots = Empty();
slots[5] = new ShortcutEntry(5, 0xB001u, 0x11223344u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 5,
new ShortcutEntry(0, 0xA001u, 0u));
Assert.Equal(new[]
{
ShortcutMutation.Remove(5),
ShortcutMutation.Add(new ShortcutEntry(5, 0xA001u, 0u)),
ShortcutMutation.Add(new ShortcutEntry(6, 0xB001u, 0x11223344u)),
}, plan);
}
[Fact]
public void FreshItemToOccupied_CyclicSearchWrapsToSlotZeroSide()
{
var slots = Full();
slots[2] = null;
slots[16] = new ShortcutEntry(16, 0xB001u, 0u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 16,
new ShortcutEntry(0, 0xA001u, 0u));
Assert.Equal(ShortcutMutation.Add(new ShortcutEntry(2, 0xB001u, 0u)), plan[^1]);
}
[Fact]
public void FreshItemToFullBar_DropsDisplacedWhenNoEmptySlotExists()
{
var slots = Full();
slots[5] = new ShortcutEntry(5, 0xB001u, 0u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 5,
new ShortcutEntry(0, 0xA001u, 0u));
Assert.Equal(new[]
{
ShortcutMutation.Remove(5),
ShortcutMutation.Add(new ShortcutEntry(5, 0xA001u, 0u)),
}, plan);
}
[Fact]
public void ShortcutAliasToOccupied_RestoresDisplacedToVacatedSource()
{
var slots = Empty(); // alias was already removed from source slot 3 at lift
slots[5] = new ShortcutEntry(5, 0xB001u, 0x55667788u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
slots, ShortcutDropSource.ShortcutAlias, sourceSlot: 3, targetSlot: 5,
new ShortcutEntry(3, 0xA001u, 0x11223344u));
Assert.Equal(new[]
{
ShortcutMutation.Remove(5),
ShortcutMutation.Add(new ShortcutEntry(5, 0xA001u, 0x11223344u)),
ShortcutMutation.Add(new ShortcutEntry(3, 0xB001u, 0x55667788u)),
}, plan);
}
[Fact]
public void FreshDuplicate_RemovesOldOccurrenceBeforeSearchingForDisplacedSlot()
{
var slots = Full();
slots[5] = new ShortcutEntry(5, 0xB001u, 0u);
slots[7] = new ShortcutEntry(7, 0xA001u, 0xA5C31234u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 5,
new ShortcutEntry(0, 0xA001u, 0u));
Assert.Equal(new[]
{
ShortcutMutation.Remove(5),
ShortcutMutation.Remove(7),
ShortcutMutation.Add(new ShortcutEntry(5, 0xA001u, 0u)),
ShortcutMutation.Add(new ShortcutEntry(7, 0xB001u, 0u)),
}, plan);
}
[Fact]
public void NonObjectRecord_IsVisuallyEmptyForCyclicSearch()
{
var slots = Full();
slots[6] = new ShortcutEntry(6, 0u, 0x01020304u);
slots[5] = new ShortcutEntry(5, 0xB001u, 0u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 5,
new ShortcutEntry(0, 0xA001u, 0u));
Assert.Equal(ShortcutMutation.Add(new ShortcutEntry(6, 0xB001u, 0u)), plan[^1]);
}
[Fact]
public void FullStackMerge_RekeysAllMatchesAndPreservesRawSpellWord()
{
var slots = Empty();
slots[2] = new ShortcutEntry(2, 0xA001u, 0x11223344u);
slots[9] = new ShortcutEntry(9, 0xA001u, 0x55667788u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
slots, 0xA001u, 0xB001u);
Assert.Equal(new[]
{
ShortcutMutation.Remove(2),
ShortcutMutation.Add(new ShortcutEntry(2, 0xB001u, 0x11223344u)),
ShortcutMutation.Remove(9),
ShortcutMutation.Add(new ShortcutEntry(9, 0xB001u, 0x55667788u)),
}, plan);
}
[Fact]
public void InvalidDrop_ReturnsNoMutation()
{
Assert.Empty(ShortcutDropPlanner.PlanDrop(
Empty(), ShortcutDropSource.FreshItem, 0, -1,
new ShortcutEntry(0, 0xA001u, 0u)));
Assert.Empty(ShortcutDropPlanner.PlanDrop(
Empty(), ShortcutDropSource.FreshItem, 0, 0,
new ShortcutEntry(0, 0u, 0u)));
}
private static ShortcutEntry?[] Empty() => new ShortcutEntry?[ShortcutStore.SlotCount];
private static ShortcutEntry?[] Full()
{
var slots = Empty();
for (int i = 0; i < slots.Length; i++)
slots[i] = new ShortcutEntry(i, (uint)(0xC000 + i), 0u);
return slots;
}
}