Broaden naming to the data side of every server object (retail weenie_object_table shape). Pure rename; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
317 lines
13 KiB
C#
317 lines
13 KiB
C#
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 0–8) have
|
||
/// ShortcutNum == i (the slot position) and ShortcutPeace == true (default NonCombat).
|
||
/// Bottom-row cells (indices 9–17) 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);
|
||
}
|
||
}
|