acdream/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
Erik 1672eaa620 feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay
- UiElement: two new virtuals GetDragPayload()/GetDragGhost() (default null)
  keep UiRoot item-agnostic; any leaf can opt into drag by overriding these.
- UiItemSlot: SlotIndex + SourceKind properties for payload identity; two
  overrides return ItemDragPayload / icon ghost when the slot is occupied.
  FindList() walks the parent chain to locate the owning UiItemList and its
  registered IItemListDragHandler.
- UiItemSlot.OnEvent: MouseDown now just consumes the press; use-item fires
  on Click (mouse-up) so a drag doesn't also trigger the use-item callback.
  DragEnter → ask handler, set Accept/Reject overlay. DragOver → reset to
  None (fires on leave). DropReleased → clear overlay + dispatch to handler
  when Data0 == 1 (accepted). DragBegin consumed (source).
- OnDraw: accept/reject sprite overlay drawn last, guarded on id != 0 to
  avoid the resolve(0)-→-magenta footgun.
- ToolbarControllerTests: Click_emitsUseForBoundItem changed from MouseDown
  to Click to match the new dispatch.
- 12 new DragDropSpineTests pass; full suite 481/483 (2 pre-existing skips).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:40:53 +02:00

424 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
public class ToolbarControllerTests
{
private static readonly uint[] Row1 =
{ 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF };
private static readonly uint[] Row2 =
{ 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF };
// The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list).
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
Dictionary<uint, UiElement> indicators) FakeToolbar()
{
var dict = new Dictionary<uint, UiElement>();
var slots = new Dictionary<uint, UiItemList>();
var indicators = new Dictionary<uint, UiElement>();
var root = new UiPanel();
foreach (var id in Row1) AddSlot(id);
foreach (var id in Row2) AddSlot(id);
// Add combat indicator elements as plain UiPanels keyed by id.
foreach (var id in CombatIds)
{
var e = new UiPanel { Visible = true };
dict[id] = e; indicators[id] = e; root.AddChild(e);
}
return (new ImportedLayout(root, dict), slots, indicators);
void AddSlot(uint id)
{
var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 };
dict[id] = list; slots[id] = list; root.AddChild(list);
}
}
[Fact]
public void Populate_bindsShortcutToCorrectSlot()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { });
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture);
Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty
}
[Fact]
public void DeferredRebind_whenItemArrivesLate()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable(); // item NOT present yet
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { });
Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u });
Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded
}
[Fact]
public void Click_emitsUseForBoundItem()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
uint used = 0;
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(0x5001u, used);
}
// ── C1: combat-mode indicator tests ─────────────────────────────────────
/// <summary>
/// At bind time (default NonCombat), only the peace indicator (0x10000192) is visible;
/// the melee/missile/magic indicators (0x10000193/4/5) are hidden.
/// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669).
/// </summary>
[Fact]
public void CombatIndicator_defaultNonCombat_onlyPeaceVisible()
{
var (layout, _, indicators) = FakeToolbar();
var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) =>0u, useItem: _ => { });
// Only peace indicator (index 0 = 0x10000192) is visible.
Assert.True (indicators[0x10000192u].Visible, "peace indicator should be visible after bind");
Assert.False(indicators[0x10000193u].Visible, "melee indicator should be hidden after bind");
Assert.False(indicators[0x10000194u].Visible, "missile indicator should be hidden after bind");
Assert.False(indicators[0x10000195u].Visible, "magic indicator should be hidden after bind");
}
/// <summary>
/// SetCombatMode(Melee) hides peace/missile/magic and shows only the melee indicator.
/// </summary>
[Fact]
public void CombatIndicator_setCombatModeMelee_onlyMeleeVisible()
{
var (layout, _, indicators) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) =>0u, useItem: _ => { });
ctrl.SetCombatMode(CombatMode.Melee);
Assert.False(indicators[0x10000192u].Visible, "peace indicator should be hidden in melee mode");
Assert.True (indicators[0x10000193u].Visible, "melee indicator should be visible in melee mode");
Assert.False(indicators[0x10000194u].Visible, "missile indicator should be hidden in melee mode");
Assert.False(indicators[0x10000195u].Visible, "magic indicator should be hidden in melee mode");
}
/// <summary>
/// CombatModeChanged event on CombatState automatically updates the indicator.
/// </summary>
[Fact]
public void CombatIndicator_liveSignal_updatesWhenCombatStateChanges()
{
var (layout, _, indicators) = FakeToolbar();
var repo = new ClientObjectTable();
var combat = new CombatState();
ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) =>0u, useItem: _ => { },
combatState: combat);
// Initially NonCombat after bind.
Assert.True(indicators[0x10000192u].Visible, "peace should be visible initially");
// Server fires CombatModeChanged → Magic.
combat.SetCombatMode(CombatMode.Magic);
Assert.False(indicators[0x10000192u].Visible, "peace should be hidden in magic mode");
Assert.False(indicators[0x10000193u].Visible, "melee should be hidden in magic mode");
Assert.False(indicators[0x10000194u].Visible, "missile should be hidden in magic mode");
Assert.True (indicators[0x10000195u].Visible, "magic indicator should be visible");
}
// ── D1: Shortcut number (slot label) tests ───────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621).
// Fake digit arrays: 9 peace entries (0x10..0x18), 9 war entries (0x20..0x28),
// 9 empty (background) entries (0x30..0x38).
private static readonly uint[] FakePeace = { 0x10u,0x11u,0x12u,0x13u,0x14u,0x15u,0x16u,0x17u,0x18u };
private static readonly uint[] FakeWar = { 0x20u,0x21u,0x22u,0x23u,0x24u,0x25u,0x26u,0x27u,0x28u };
private static readonly uint[] FakeEmpty = { 0x30u,0x31u,0x32u,0x33u,0x34u,0x35u,0x36u,0x37u,0x38u };
/// <summary>
/// After Bind with peace/war digit arrays, top-row cells (indices 08) have
/// ShortcutNum == i (the slot position) and ShortcutPeace == true (default NonCombat).
/// Bottom-row cells (indices 917) have ShortcutNum == -1 (no label).
/// Retail: numbers are slot LABELS — shown on ALL top-row slots including empty ones.
/// </summary>
[Fact]
public void ShortcutNumbers_afterBind_topRowHasNumbers_bottomRowEmpty()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar);
// Top row: ShortcutNum == slot index, peace == true.
for (int i = 0; i < Row1.Length; i++)
{
var cell = slots[Row1[i]].Cell;
Assert.Equal(i, cell.ShortcutNum);
Assert.True(cell.ShortcutPeace, $"top-row slot {i} should be peace at NonCombat");
}
// Bottom row: no shortcut number.
foreach (var id in Row2)
Assert.Equal(-1, slots[id].Cell.ShortcutNum);
}
/// <summary>
/// After SetCombatMode(Melee), top-row cells switch to ShortcutPeace == false (war).
/// </summary>
[Fact]
public void ShortcutNumbers_setCombatModeWar_topRowUsesWarDigits()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar);
ctrl.SetCombatMode(CombatMode.Melee);
// Top row: still ShortcutNum == i, but now peace == false.
for (int i = 0; i < Row1.Length; i++)
{
var cell = slots[Row1[i]].Cell;
Assert.Equal(i, cell.ShortcutNum);
Assert.False(cell.ShortcutPeace, $"top-row slot {i} should be war after Melee");
}
// Bottom row still has no number.
foreach (var id in Row2)
Assert.Equal(-1, slots[id].Cell.ShortcutNum);
}
/// <summary>
/// After SetCombatMode back to NonCombat, top-row switches back to peace (ShortcutPeace == true).
/// </summary>
[Fact]
public void ShortcutNumbers_backToNonCombat_restoresPeaceDigits()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar);
ctrl.SetCombatMode(CombatMode.Melee);
ctrl.SetCombatMode(CombatMode.NonCombat);
for (int i = 0; i < Row1.Length; i++)
Assert.True(slots[Row1[i]].Cell.ShortcutPeace,
$"top-row slot {i} should be peace after returning to NonCombat");
}
/// <summary>
/// Digit arrays are correctly injected into each cell (PeaceDigits + WarDigits references).
/// </summary>
[Fact]
public void ShortcutNumbers_digitArraysInjected()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar);
foreach (var id in Row1)
{
Assert.Same(FakePeace, slots[id].Cell.PeaceDigits);
Assert.Same(FakeWar, slots[id].Cell.WarDigits);
}
}
/// <summary>
/// EmptyDigits (0x1000005e background digit) is injected into every slot cell.
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — empty-slot branch.
/// </summary>
[Fact]
public void ShortcutNumbers_emptyDigitArrayInjected()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: FakeEmpty);
foreach (var id in Row1)
Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits);
foreach (var id in Row2)
Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits);
}
/// <summary>
/// When emptyDigits is null, cells have EmptyDigits == null (no digit on empty slots).
/// This is the safe fallback when the dat property 0x1000005e is absent.
/// </summary>
[Fact]
public void ShortcutNumbers_nullEmptyDigits_cellsHaveNullEmptyDigits()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: null);
foreach (var id in Row1)
Assert.Null(slots[id].Cell.EmptyDigits);
}
// ── E1: Guid filter + ObjectRemoved tests (D.5.4) ───────────────────────
/// <summary>
/// ObjectAdded for a guid NOT in the shortcut list does NOT call iconIds again
/// (no spurious Populate on creature/NPC spawns in a busy zone).
/// D.5.4: ToolbarController filters to shortcut guids only.
/// The iconIds spy lets us count how many times Populate actually ran.
/// </summary>
[Fact]
public void ObjectAdded_nonShortcutGuid_doesNotCallIconIds()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
int iconCallCount = 0;
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x77u; }, useItem: _ => { });
int callsAfterBind = iconCallCount; // 1 call from initial Populate
// Fire ObjectAdded with a completely unrelated guid (a creature, NOT a shortcut).
repo.AddOrUpdate(new ClientObject { ObjectId = 0xDEADBEEFu, WeenieClassId = 42u, IconId = 0u });
// iconIds must NOT have been called again — the filter blocked Populate.
Assert.Equal(callsAfterBind, iconCallCount);
}
/// <summary>
/// ObjectAdded for a guid that IS in the shortcut list calls iconIds again (deferred bind).
/// This is the filtered-path counterpart of DeferredRebind_whenItemArrivesLate.
/// </summary>
[Fact]
public void ObjectAdded_shortcutGuid_callsIconIds()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable(); // item NOT present yet
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 1, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0) };
int iconCallCount = 0;
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x99u; }, useItem: _ => { });
Assert.Equal(0, iconCallCount); // not called — item absent during initial Populate
Assert.Equal(0u, slots[Row1[1]].Cell.ItemId);
// Now the shortcut item arrives — filter must PASS and Populate re-run.
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5003u, WeenieClassId = 1u, IconId = 0x06005678u });
Assert.Equal(1, iconCallCount); // iconIds called exactly once for the deferred bind
Assert.Equal(0x5003u, slots[Row1[1]].Cell.ItemId);
}
/// <summary>
/// ObjectRemoved for a guid that IS in the shortcut list clears the slot.
/// D.5.4: subscribes to ObjectRemoved so a removed item evicts its icon.
/// </summary>
[Fact]
public void ObjectRemoved_shortcutGuid_clearsSlot()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5004u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 3, ObjectGuid: 0x5004u, SpellId: 0, Layer: 0) };
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0xAAu, useItem: _ => { });
Assert.Equal(0x5004u, slots[Row1[3]].Cell.ItemId); // bound
// Remove the item from the session (server despawn / trade away).
// Populate re-runs: item is gone from repo → slot clears.
repo.Remove(0x5004u);
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
}
/// <summary>
/// ObjectRemoved for a guid NOT in the shortcut list does NOT call iconIds again.
/// D.5.4: the ObjectRemoved subscription also filters to shortcut guids.
/// </summary>
[Fact]
public void ObjectRemoved_nonShortcutGuid_doesNotCallIconIds()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5005u, WeenieClassId = 1u, IconId = 0x06001234u });
repo.AddOrUpdate(new ClientObject { ObjectId = 0xCAFEBABEu, WeenieClassId = 99u, IconId = 0u });
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 4, ObjectGuid: 0x5005u, SpellId: 0, Layer: 0) };
int iconCallCount = 0;
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => { iconCallCount++; return 0xBBu; }, useItem: _ => { });
int callsAfterBind = iconCallCount; // 1 call for the shortcut item
// Remove an unrelated object — filter must block Populate.
repo.Remove(0xCAFEBABEu);
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
}
}