acdream/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
Erik b2a812d1fa feat(D.5.1): faithful toolbar slot numbers 1-9 (SetShortcutNum digit sprites, peace/war)
Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465) and
gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). The 9 top-row toolbar slots
show digit labels 1-9 at all times (even when empty — confirmed from the user''s
retail screenshot). The digit sprites are real 32x32 PFID_A8R8G8B8 RenderSurfaces
with glyphs baked into the top-left corner (rest alpha=0), drawn Alphablend over
the slot icon/empty sprite.

Digit DID arrays (peace: property 0x10000042, war: 0x10000043) are read at startup
from LayoutDesc 0x21000037 element 0x1000034A under composite 0x10000346 using the
same ArrayBaseProperty{DataIdBaseProperty} pattern as LayoutImporter.ReadState.
A cited-constant fallback (same confirmed dat ids) is used if the dat navigation
fails. The war glyph set (darker/golden glyphs) switches on any combat stance;
peace glyphs (lighter) restore on NonCombat — re-stamped by RestampShortcutNumbers()
called from both Populate() and SetCombatMode().

Changes:
- UiItemSlot: ShortcutNum/ShortcutPeace/PeaceDigits/WarDigits state; SetShortcutNum/
  ClearShortcutNum; OnDraw restructured (no early return) so digit draws after icon.
- ToolbarController: _peaceDigits/_warDigits/_peace fields; Bind() gains peaceDigits/
  warDigits optional params; RestampShortcutNumbers() helper; Populate() and
  SetCombatMode() both call RestampShortcutNumbers().
- GameWindow: reads digit arrays under _datLock from LayoutDesc 0x21000037, passes to
  Bind(); cited constants as fallback.
- Tests: 5 new UiItemSlotTests (SetShortcutNum/ClearShortcutNum state); 4 new
  ToolbarControllerTests (top-row/bottom-row labels, peace/war switch, array injection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:52:50 +02:00

275 lines
12 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 ItemRepository();
repo.AddOrUpdate(new ItemInstance { 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 ItemRepository(); // 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 ItemInstance { 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 ItemRepository();
repo.AddOrUpdate(new ItemInstance { 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 ItemRepository();
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 ItemRepository();
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 ItemRepository();
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).
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 };
/// <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 ItemRepository();
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 ItemRepository();
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 ItemRepository();
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 ItemRepository();
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);
}
}
}