acdream/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
Erik 8a5d77f7f4 feat(net): port retail physics spawn and event timestamps
Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client.

Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 00:22:17 +02:00

1242 lines
51 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 const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u;
private const uint UseButtonId = 0x1000019Du;
private const uint ExamineButtonId = 0x100001A5u;
private static readonly (uint ElementId, uint PanelId)[] PanelButtons =
{
(0x10000197u, 12u),
(0x10000198u, 13u),
(CharacterButtonId, RetailPanelCatalog.Character),
(0x1000055Au, 25u),
(0x1000019Au, 16u),
(0x1000019Bu, 10u),
(InventoryButtonId, RetailPanelCatalog.Inventory),
};
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);
// Combat indicators are retail UiButtons; one is visible at a time,
// and every stance-specific instance dispatches the same toggle command.
foreach (var id in CombatIds)
{
AddButton(id);
indicators[id] = dict[id];
}
foreach (var (elementId, panelId) in PanelButtons)
AddButton(elementId, panelId);
AddButton(UseButtonId);
AddButton(ExamineButtonId);
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);
}
void AddButton(uint id, uint? panelId = null)
{
var info = new ElementInfo
{
Id = id,
Type = 1,
Width = 32,
Height = 32,
DefaultStateName = "Normal",
};
info.StateMedia["Normal"] = (0x1u, 1);
info.StateMedia["Highlight"] = (0x2u, 1);
info.StateMedia["Ghosted"] = (0x3u, 1);
if (panelId is { } value)
{
info.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Properties = new UiPropertyBag
{
Values =
{
[0x10000029u] = new UiPropertyValue
{
Kind = UiPropertyKind.Enum,
UnsignedValue = value,
},
},
},
};
}
var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 };
dict[id] = button;
root.AddChild(button);
}
}
[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<ShortcutEntry>
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 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 SessionTableClearRemovesResolvedShortcutPresentation()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject
{
ObjectId = 0x5001u,
WeenieClassId = 1u,
IconId = 0x06001234u,
});
var shortcuts = new List<ShortcutEntry>
{
new(Index: 0, ObjectId: 0x5001u, SpellId: 0),
};
ToolbarController.Bind(
layout,
repo,
() => shortcuts,
iconIds: (_, _, _, _, _) => 0x77u,
useItem: _ => { });
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
repo.Clear();
Assert.Equal(0u, slots[Row1[0]].Cell.ItemId);
}
[Fact]
public void DeferredRebind_whenItemArrivesLate()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable(); // item NOT present yet
var shortcuts = new List<ShortcutEntry>
{ new(Index: 2, ObjectId: 0x5002u, SpellId: 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 Dispose_UnsubscribesDeferredRebind_AndIsIdempotent()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var shortcuts = new List<ShortcutEntry>
{ new(Index: 2, ObjectId: 0x5002u, SpellId: 0) };
var controller = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { });
controller.Dispose();
controller.Dispose();
repo.AddOrUpdate(new ClientObject
{
ObjectId = 0x5002u,
WeenieClassId = 1u,
IconId = 0x06005678u,
});
Assert.Equal(0u, slots[Row1[2]].Cell.ItemId);
}
[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<ShortcutEntry>
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 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);
}
[Fact]
public void PanelButtons_bindByDatPanelId_andGhostUnavailablePanels()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
var toggles = new List<uint>();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
ctrl.BindPanelButtons(
panelId => panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
toggles.Add);
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(new[] { RetailPanelCatalog.Inventory, RetailPanelCatalog.Character }, toggles);
foreach (var (elementId, panelId) in PanelButtons)
{
var button = (UiButton)layout.FindElement(elementId)!;
Assert.Equal(
panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
button.Enabled);
}
}
[Fact]
public void InventoryButton_whenTargetModeActive_targetsPlayerInsteadOfTogglingInventory()
{
const uint player = 0x50000001u;
const uint pack = 0x50000010u;
const uint kit = 0x50000A01u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container, ItemsCapacity = 24 });
repo.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject
{
ObjectId = kit, Type = ItemType.Misc,
Useability = 0x00220008u, // USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF
TargetType = (uint)ItemType.Creature, // retail kind gate
});
repo.MoveItem(kit, pack, 0);
var useWithTarget = new List<(uint Source, uint Target)>();
var interaction = new ItemInteractionController(
repo,
playerGuid: () => player,
sendUse: null,
sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)),
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
int inventoryClicks = 0;
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u,
useItem: _ => { },
itemInteraction: interaction);
ctrl.BindPanelButtons(
panelId => panelId == RetailPanelCatalog.Inventory,
_ => inventoryClicks++);
interaction.ActivateItem(kit);
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(0, inventoryClicks);
Assert.Equal(new[] { (kit, player) }, useWithTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void InventoryButton_freshItemDrop_acceptsAndPlacesInPlayerBackpack()
{
const uint player = 0x5000u;
const uint item = 0x5001u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
var puts = new List<(uint Item, uint Container, int Placement)>();
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
playerGuid: () => player,
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 12, new UiItemSlot());
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)));
Assert.Equal(ItemDragAcceptance.Accept, button.ItemDragAcceptanceForTest);
Assert.Equal(0x060011F7u, button.ItemDragAcceptSprite);
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)));
Assert.Equal(ItemDragAcceptance.None, button.ItemDragAcceptanceForTest);
Assert.Equal(new[] { (item, player, 0) }, puts);
}
[Fact]
public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem()
{
const uint player = 0x5000u;
const uint item = 0x5001u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
var puts = new List<(uint Item, uint Container, int Placement)>();
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
playerGuid: () => player,
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
var payload = new ItemDragPayload(item, ItemDragSource.ShortcutBar, 3, new UiItemSlot());
button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload));
Assert.Equal(ItemDragAcceptance.None, button.ItemDragAcceptanceForTest);
button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload));
Assert.Empty(puts);
}
[Fact]
public void WindowButtonState_highlightsWhenOpen()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
ctrl.BindPanelButtons(_ => true, _ => { });
ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, true);
ctrl.SetPanelOpen(RetailPanelCatalog.Character, true);
Assert.Equal("Highlight", inventoryButton.ActiveState);
Assert.Equal("Highlight", characterButton.ActiveState);
ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, false);
ctrl.SetPanelOpen(RetailPanelCatalog.Character, false);
Assert.Equal("Normal", inventoryButton.ActiveState);
Assert.Equal("Normal", characterButton.ActiveState);
}
[Fact]
public void RetailFixture_panelButtonsExposeExactDatPanelIds()
{
ImportedLayout layout = FixtureLoader.LoadToolbar();
foreach (var (elementId, panelId) in PanelButtons)
{
var button = Assert.IsType<UiButton>(layout.FindElement(elementId));
Assert.True(button.TryGetEnumAttribute(0x10000029u, out uint actual));
Assert.Equal(panelId, actual);
}
}
[Fact]
public void RetailFixture_ammoNumberUsesAuthoredMissileIndicatorElement()
{
ImportedLayout layout = FixtureLoader.LoadToolbar();
var ammoIndicator = Assert.IsType<UiButton>(layout.FindElement(0x10000194u));
Assert.Equal(55f, ammoIndicator.Width);
Assert.Equal(58f, ammoIndicator.Height);
}
[Fact]
public void UseAndExamineButtons_dispatchSelectedObjectPolicy()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint item = 0x50000003u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
repo.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject
{
ObjectId = item,
Type = ItemType.Misc,
Useability = ItemUseability.Contained,
});
repo.MoveItem(item, pack, 0);
var uses = new List<uint>();
var examines = new List<uint>();
uint selected = item;
var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: uses.Add,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: examines.Add);
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
itemInteraction: interaction,
selectedObjectId: () => selected);
((UiButton)layout.FindElement(UseButtonId)!).OnClick!.Invoke();
((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke();
Assert.Equal(new[] { item }, uses);
Assert.Equal(new[] { item }, examines);
selected = 0;
((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke();
Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind);
}
// ── C1: combat-mode indicator tests ─────────────────────────────────────
[Fact]
public void AmmoNumber_usesStackableMissileWeapon_andTracksStackChanges()
{
const uint player = 0x50000001u;
const uint thrownWeapon = 0x50000002u;
const uint separateAmmo = 0x50000003u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject
{
ObjectId = thrownWeapon,
StackSize = 7,
StackSizeMax = 25,
});
repo.MoveItem(thrownWeapon, player, newEquipLocation: EquipMask.MissileWeapon);
repo.AddOrUpdate(new ClientObject
{
ObjectId = separateAmmo,
StackSize = 80,
StackSizeMax = 100,
});
repo.MoveItem(separateAmmo, player, newEquipLocation: EquipMask.MissileAmmo);
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
playerGuid: () => player);
var ammoButton = (UiButton)layout.FindElement(0x10000194u)!;
Assert.Equal("7", ammoButton.Label);
Assert.True(repo.UpdateStackSize(thrownWeapon, 6, value: 0));
Assert.Equal("6", ammoButton.Label);
}
[Fact]
public void AmmoNumber_forLauncher_usesAmmoSlot_andClearsOnUnwield()
{
const uint player = 0x50000001u;
const uint pack = 0x50000010u;
const uint bow = 0x50000002u;
const uint arrows = 0x50000003u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
repo.AddOrUpdate(new ClientObject { ObjectId = bow, StackSize = 1, StackSizeMax = 1 });
repo.MoveItem(bow, player, newEquipLocation: EquipMask.MissileWeapon);
repo.AddOrUpdate(new ClientObject { ObjectId = arrows, StackSize = 42, StackSizeMax = 100 });
repo.MoveItem(arrows, player, newEquipLocation: EquipMask.MissileAmmo);
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
playerGuid: () => player);
var ammoButton = (UiButton)layout.FindElement(0x10000194u)!;
Assert.Equal("42", ammoButton.Label);
Assert.True(repo.MoveItem(arrows, pack));
Assert.Null(ammoButton.Label);
}
[Fact]
public void CombatIndicators_allDispatchSharedRetailToggleCommand()
{
var (layout, _, indicators) = FakeToolbar();
var repo = new ClientObjectTable();
int toggles = 0;
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
toggleCombat: () => toggles++);
foreach (uint id in CombatIds)
((UiButton)indicators[id]).OnClick?.Invoke();
Assert.Equal(CombatIds.Length, toggles);
}
[Fact]
public void UseShortcut_usesOrSelectsAccordingToRetailIntent()
{
const uint itemId = 0x50001001u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = itemId, Type = ItemType.Misc });
var shortcuts = new[]
{
new ShortcutEntry(0, itemId, 0),
};
uint used = 0;
uint selected = 0;
var controller = ToolbarController.Bind(
layout,
repo,
() => shortcuts,
iconIds: (_, _, _, _, _) => 1u,
useItem: id => used = id,
selectItem: id => selected = id);
Assert.True(controller.UseShortcut(0, use: true));
Assert.Equal(itemId, used);
Assert.Equal(0u, selected);
Assert.True(controller.UseShortcut(0, use: false));
Assert.Equal(itemId, selected);
}
[Fact]
public void UseShortcut_targetModePrecedesUseAndClearsOneShotMode()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint source = 0x50000003u;
const uint target = 0x50000004u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
repo.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject
{
ObjectId = source,
Type = ItemType.Misc,
Useability = 0x00220008u,
TargetType = (uint)ItemType.Creature,
});
repo.MoveItem(source, pack, 0);
repo.AddOrUpdate(new ClientObject { ObjectId = target, Type = ItemType.Creature });
uint sentSource = 0;
uint sentTarget = 0;
var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: null,
sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t),
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var shortcuts = new[]
{
new ShortcutEntry(0, target, 0),
};
var controller = ToolbarController.Bind(
layout,
repo,
() => shortcuts,
iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { },
itemInteraction: interaction);
Assert.True(interaction.ActivateItem(source));
Assert.True(controller.UseShortcut(0, use: false));
Assert.Equal(source, sentSource);
Assert.Equal(target, sentTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void CreateShortcutToItem_addsFirstEmptyOwnedEligibleItemOnce()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint item = 0x50000003u;
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
repo.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
repo.MoveItem(item, pack, 0);
var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
var sends = new List<(uint Slot, uint Item)>();
var controller = ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { },
itemInteraction: interaction,
sendAddShortcut: entry => sends.Add(((uint)entry.Index, entry.ObjectId)));
Assert.True(controller.CreateShortcutToItem(item));
Assert.Equal(item, slots[Row1[0]].Cell.ItemId);
Assert.Equal(new[] { (0u, item) }, sends);
Assert.False(controller.CreateShortcutToItem(item));
Assert.Single(sends);
}
/// <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<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<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<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 regular entries (0x10..0x18), 9 ghosted entries (0x20..0x28),
// 9 empty (background) entries (0x30..0x38).
private static readonly uint[] FakeRegular = { 0x10u,0x11u,0x12u,0x13u,0x14u,0x15u,0x16u,0x17u,0x18u };
private static readonly uint[] FakeGhosted = { 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 regular/ghosted digit arrays, top-row cells (indices 08) have
/// ShortcutNum == i (the slot position) and ShortcutGhosted == false (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<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
// Top row: ShortcutNum == slot index, ghosted == false.
for (int i = 0; i < Row1.Length; i++)
{
var cell = slots[Row1[i]].Cell;
Assert.Equal(i, cell.ShortcutNum);
Assert.False(cell.ShortcutGhosted, $"top-row slot {i} should be regular at NonCombat");
}
// Bottom row: no shortcut number.
foreach (var id in Row2)
Assert.Equal(-1, slots[id].Cell.ShortcutNum);
}
/// <summary>
/// Retail keeps physical shortcut labels regular in NonCombat, Melee, and Missile.
/// gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610 passes ghosted=true only for Magic.
/// </summary>
[Theory]
[InlineData(CombatMode.NonCombat)]
[InlineData(CombatMode.Melee)]
[InlineData(CombatMode.Missile)]
public void ShortcutNumbers_nonMagicModes_useRegularDigits(CombatMode mode)
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
ctrl.SetCombatMode(mode);
for (int i = 0; i < Row1.Length; i++)
{
var cell = slots[Row1[i]].Cell;
Assert.Equal(i, cell.ShortcutNum);
Assert.False(cell.ShortcutGhosted, $"top-row slot {i} should be regular in {mode}");
cell.SetItem((uint)(0x5000 + i), 0x99u);
Assert.Same(FakeRegular, cell.ActiveDigitArray());
}
// Bottom row still has no number.
foreach (var id in Row2)
Assert.Equal(-1, slots[id].Cell.ShortcutNum);
}
/// <summary>
/// Magic mode ghosts physical shortcut labels, and returning to NonCombat restores them.
/// </summary>
[Fact]
public void ShortcutNumbers_magicGhosts_thenNonCombatRestoresRegularDigits()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
ctrl.SetCombatMode(CombatMode.Magic);
foreach (var id in Row1)
{
slots[id].Cell.SetItem(id, 0x99u);
Assert.True(slots[id].Cell.ShortcutGhosted);
Assert.Same(FakeGhosted, slots[id].Cell.ActiveDigitArray());
}
ctrl.SetCombatMode(CombatMode.NonCombat);
for (int i = 0; i < Row1.Length; i++)
{
Assert.False(slots[Row1[i]].Cell.ShortcutGhosted,
$"top-row slot {i} should be regular after returning to NonCombat");
Assert.Same(FakeRegular, slots[Row1[i]].Cell.ActiveDigitArray());
}
}
/// <summary>
/// Digit arrays are correctly injected into each cell.
/// </summary>
[Fact]
public void ShortcutNumbers_digitArraysInjected()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
foreach (var id in Row1)
{
Assert.Same(FakeRegular, slots[id].Cell.RegularDigits);
Assert.Same(FakeGhosted, slots[id].Cell.GhostedDigits);
}
}
/// <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<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted, 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<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted, 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<ShortcutEntry>
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 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<ShortcutEntry>
{ new(Index: 1, ObjectId: 0x5003u, SpellId: 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<ShortcutEntry>
{ new(Index: 3, ObjectId: 0x5004u, SpellId: 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<ShortcutEntry>
{ new(Index: 4, ObjectId: 0x5005u, SpellId: 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
}
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
/// <summary>Bind registers the controller as each slot list's drag handler and
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
[Fact]
public void Bind_registersDragHandler_andStampsSlots()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
for (int i = 0; i < Row1.Length; i++)
{
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
}
// Bottom row slots are indices 9..17; same handler + source kind as the top row.
for (int j = 0; j < Row2.Length; j++)
{
Assert.Same(ctrl, slots[Row2[j]].DragHandler);
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind);
}
}
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
[Fact]
public void OnDragOver_acceptsRealItem()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.Equal(ItemDragAcceptance.Accept, ctrl.OnDragOver(list, list.Cell, payload));
}
/// <summary>OnDragOver rejects a ghost payload with ObjId 0 (the guard against an
/// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem.</summary>
[Fact]
public void OnDragOver_rejectsZeroObjId()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.Equal(ItemDragAcceptance.None, ctrl.OnDragOver(list, list.Cell, payload));
}
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────
private static (System.Collections.Generic.List<ShortcutEntry> adds,
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<ShortcutEntry> add, out System.Action<uint> rem)
{
var adds = new System.Collections.Generic.List<ShortcutEntry>();
var removes = new System.Collections.Generic.List<uint>();
add = entry => adds.Add(entry);
rem = i => removes.Add(i);
return (adds, removes);
}
[Fact]
public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0) };
var (adds, removes) = NewSpies(out var add, out var rem);
uint selected = 0;
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem,
selectItem: item => selected = item, selectedObjectId: () => selected);
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId);
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
Assert.Equal(0x5001u, selected);
Assert.Contains(3u, removes);
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
}
[Fact]
public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0),
new(Index: 5, ObjectId: 0x5002u, SpellId: 0) };
var (adds, removes) = NewSpies(out var add, out var rem);
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem);
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId);
Assert.Equal(new[] { 3u, 5u }, removes.ToArray());
Assert.Contains(new ShortcutEntry(5, 0x5001u, 0u), adds);
Assert.Contains(new ShortcutEntry(3, 0x5002u, 0u), adds);
}
[Fact]
public void HandleDropRelease_reindexesWithoutLosingRawShortcutFields()
{
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(3, 0x5001u, 0x11223344u),
new ShortcutEntry(5, 0x5002u, 0xA5C31234u),
};
var (adds, _) = NewSpies(out var add, out var remove);
var controller = ToolbarController.Bind(
layout, repo, () => shortcuts,
iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { },
sendAddShortcut: add,
sendRemoveShortcut: remove);
var payload = Assert.IsType<ItemDragPayload>(slots[Row1[3]].Cell.GetDragPayload());
controller.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
controller.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
Assert.Contains(new ShortcutEntry(5, 0x5001u, 0x11223344u), adds);
Assert.Contains(new ShortcutEntry(3, 0x5002u, 0xA5C31234u), adds);
}
[Fact]
public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0) };
var (adds, removes) = NewSpies(out var add, out var rem);
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem);
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload);
Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId);
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
Assert.Contains(new ShortcutEntry(7, 0x5001u, 0u), adds);
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()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0) };
var (adds, removes) = NewSpies(out var add, out var rem);
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem);
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied
ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op)
Assert.Contains(new ShortcutEntry(3, 0x5001u, 0u), adds); // AddShortcut(3, A) sent
Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift]
Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place]
}
[Fact]
public void ToolbarSlots_useGreenCrossAcceptSprite()
{
var (layout, slots, _) = FakeToolbar();
ToolbarController.Bind(layout, new ClientObjectTable(),
() => System.Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
}
}