acdream/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
Erik a33e897400 perf(D.5.4): toolbar re-binds only on shortcut-guid object changes; clear on remove
Now that the object table holds ALL entities (creatures, NPCs, world objects),
filtering ObjectAdded/Updated/Removed to the 18 shortcut guids prevents the bar
from thrashing on every creature spawn in a busy zone.

Also subscribes to ObjectRemoved so a despawned/traded-away item clears its slot
(matching retail gmToolbarUI::SetDelayedShortcutNum's deferred-bind contract).

Four new unit tests (iconIds spy pattern) verify: non-shortcut ObjectAdded/Removed
do NOT invoke Populate; shortcut ObjectAdded deferred-binds; shortcut ObjectRemoved
clears the slot. 2671 tests, 4 skipped, 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:57:04 +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);
// UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
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
}
}