acdream/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
Erik 73adc3768c docs(D.5.2): retire IA-16, add IA-18/AP-43..45, roadmap + memory
Divergence register:
- Retire IA-16 (item-icon composite PARTIAL — D.5.2 now complete).
- Add IA-18 (effect overlay = ReplaceColor tint SOURCE, faithful retail
  behavior; anti-regression guard — do NOT re-implement as a blit layer;
  cites IconData::RenderIcons 0x0058d180 + ReplaceColor 0x00441530).
- Add AP-43 (effect tint = mean-opaque color; exact retail byte
  decompiler-ambiguous, visual/cdb confirmation pending).
- Add AP-44 (effects==0 black-fallback recolor skipped; regression-risk
  avoidance, pending visual/cdb confirm).
- Add AP-45 (0x02CE sequence byte not honored, latest-wins).
Section header counts updated: IA 15→17, AP 41→44.

Roadmap: mark D.5.2 shipped (419c3ac..2f789da; appraise dropped as no-op;
effect recolor + live 0x02CE).

Tests: update ToolbarControllerTests iconIds lambda arity 4→5 to match the
D.5.2 GetIcon signature change (was caught by the build).

Memory: project_d2b_retail_ui.md updated with D.5.2 shipped entry
(via claude-memory symlink to ~/.claude/projects/.../memory/).

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

317 lines
13 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),
// 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 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);
}
}
/// <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 ItemRepository();
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 ItemRepository();
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);
}
}