acdream/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs
Erik 6636e50c2a feat(D.5.3a): selected-object meter — Health bar + name on the action bar
Port of gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635).
When the player selects a world object the action bar's bottom strip shows the
object name + (for player/pet/attackable targets) a live Health meter; deselect
clears it. Mana (#140) + stack slider deferred.

- SelectedObjectController (new): clear-then-populate on selection change; sets
  name (UiText child, VitalsController pattern), overlay state (ObjectSelected /
  StackedItemSelected via UiDatElement.ActiveState), shows the health meter and
  sends QueryHealth for health targets. Subscribes via a delegate seam (no
  GameWindow coupling).
- GameWindow: _selectedGuid field -> SelectedGuid property + SelectionChanged
  event (fires on actual change only); 3 write sites converted, reads untouched.
  All selection-write paths (LMB pick, Tab/Q, despawn-clear via Tick()) run on
  the render thread, so the event-driven UI mutation is single-threaded.
- WorldSession.SendQueryHealth (0x01BF) — wraps SocialActions.BuildQueryHealth.
- DatWidgetFactory.BuildMeter: handle the single-image toolbar meter shape
  (back-track on the element's own DirectState, fill on one Type-3 child). The
  sprites go in the TILE slot (DrawMode=Normal tiles to full bar geometry per
  UIElement_Meter::DrawChildren) — a left-cap assignment would gap/clamp a
  sub-140px sprite. Vitals 3-slice path unchanged.
- ToolbarController.HiddenIds: A1 (health) now owned by SelectedObjectController;
  A2 (mana) + A4 (stack) stay hidden (deferred) so their dat back-tracks don't
  render as stray empty bars.

Adversarial Opus review found + fixed: the mana-meter orphan (A2 left unhidden)
and the meter tile-vs-cap render bug (C1). Divergence rows AP-46 (health gate
approximation: IsLiveCreatureTarget vs IsPlayer||pet||attackable) + AP-47
(meter shown on select vs on UpdateHealth reply). Spec §5 corrected.

Build + full test suite green (2,684 passed / 4 skipped). Health meter render
fidelity (full-width fill + fraction mapping) pending the user's visual gate.

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

461 lines
20 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 System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="SelectedObjectController"/> — the
/// <c>gmToolbarUI::HandleSelectionChanged</c> analogue
/// (<c>acclient_2013_pseudo_c.txt:198635</c>).
///
/// <para>
/// Layout construction mirrors <see cref="ToolbarControllerTests"/>: build a minimal
/// <see cref="ImportedLayout"/> from a root <see cref="UiPanel"/> + a
/// <see cref="Dictionary{TKey,TValue}"/> keyed by element id. Elements are constructed
/// directly (no importer / no dat / no GL) so tests are pure in-process.
/// </para>
/// </summary>
public class SelectedObjectControllerTests
{
// ── Shared layout ────────────────────────────────────────────────────────
/// <summary>
/// Build a minimal toolbar layout containing the three selected-object elements:
/// <list type="bullet">
/// <item>0x1000019F → a <see cref="UiPanel"/> name container (100×20).</item>
/// <item>0x100001A0 → a <see cref="UiDatElement"/> overlay with "ObjectSelected"
/// and "StackedItemSelected" states wired to distinct file ids.</item>
/// <item>0x100001A1 → a <see cref="UiMeter"/> health meter.</item>
/// </list>
/// Additional element ids can be added by the caller for edge-case tests.
/// </summary>
private static (
ImportedLayout layout,
UiPanel nameEl,
UiDatElement overlayEl,
UiMeter healthMeterEl)
FakeLayout()
{
var dict = new Dictionary<uint, UiElement>();
var root = new UiPanel();
// Name element: a plain panel that will have a UiText child attached by the controller.
var nameEl = new UiPanel { Width = 100, Height = 20 };
dict[SelectedObjectController.NameId] = nameEl;
root.AddChild(nameEl);
// Overlay element: a UiDatElement with the two named states the controller switches between.
var overlayInfo = new ElementInfo
{
Id = SelectedObjectController.OverlayId,
Type = 3, // Type 3 = container/chrome — the overlay field's dat type
StateMedia =
{
[""] = (0x06000001u, 3), // DirectState (blank)
["ObjectSelected"] = (0x06001937u, 3), // ObjectSelected sprite id from toolbar dump
["StackedItemSelected"] = (0x06004CF4u, 3), // StackedItemSelected sprite id
},
};
var overlayEl = new UiDatElement(overlayInfo, _ => (0u, 0, 0));
dict[SelectedObjectController.OverlayId] = overlayEl;
root.AddChild(overlayEl);
// Health meter element.
var healthMeterEl = new UiMeter { Width = 100, Height = 10, Visible = true };
dict[SelectedObjectController.HealthMeterId] = healthMeterEl;
root.AddChild(healthMeterEl);
return (new ImportedLayout(root, dict), nameEl, overlayEl, healthMeterEl);
}
// ── Recording delegates ──────────────────────────────────────────────────
/// <summary>
/// Build a recording set of delegates. Name, health, stack are keyed by guid;
/// <paramref name="queryHealthCalls"/> accumulates every guid passed to sendQueryHealth.
/// </summary>
private static (
Action<Action<uint?>> subscribe,
Action<uint?> fireSelection,
Func<uint, bool> isHealthTarget,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
List<uint> queryHealthCalls)
MakeDelegates(
Dictionary<uint, bool> healthTargetMap,
Dictionary<uint, string> nameMap,
Dictionary<uint, float> healthMap,
Dictionary<uint, uint> stackMap)
{
Action<uint?>? registeredHandler = null;
var queryHealthCalls = new List<uint>();
Action<Action<uint?>> subscribe = h => registeredHandler = h;
Action<uint?> fireSelection = guid => registeredHandler?.Invoke(guid);
Func<uint, bool> isHealthTarget = g => healthTargetMap.TryGetValue(g, out var v) && v;
Func<uint, string?> name = g => nameMap.TryGetValue(g, out var v) ? v : null;
Func<uint, float> healthPercent = g => healthMap.TryGetValue(g, out var v) ? v : 1f;
Func<uint, uint> stackSize = g => stackMap.TryGetValue(g, out var v) ? v : 0u;
Action<uint> sendQueryHealth = g => queryHealthCalls.Add(g);
return (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, queryHealthCalls);
}
// ── B1: Bind initialisation ──────────────────────────────────────────────
/// <summary>
/// After Bind:
/// - the health meter is hidden (controller owns initial-hidden state).
/// - the name element has exactly one UiText child (the name label).
/// </summary>
[Fact]
public void Bind_healthMeterHidden_andNameTextChildAttached()
{
var (layout, nameEl, _, healthMeterEl) = FakeLayout();
var (subscribe, _, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) =
MakeDelegates(
healthTargetMap: new(),
nameMap: new(),
healthMap: new(),
stackMap: new());
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null);
// Health meter must start hidden.
Assert.False(healthMeterEl.Visible,
"health meter must be Visible=false immediately after Bind");
// A UiText child should have been attached to the name element.
var textChild = nameEl.Children.OfType<UiText>().SingleOrDefault();
Assert.NotNull(textChild);
Assert.True(textChild!.Centered, "name UiText must be Centered");
Assert.True(textChild.ClickThrough, "name UiText must be ClickThrough (non-interactive decoration)");
Assert.False(textChild.AcceptsFocus, "AcceptsFocus must be false on name label");
Assert.False(textChild.IsEditControl, "IsEditControl must be false on name label");
Assert.False(textChild.CapturesPointerDrag, "CapturesPointerDrag must be false on name label");
}
/// <summary>
/// After Bind, the attached UiText's LinesProvider yields no lines (nothing selected yet).
/// </summary>
[Fact]
public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected()
{
var (layout, nameEl, _, _) = FakeLayout();
var (subscribe, _, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) =
MakeDelegates(new(), new(), new(), new());
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null);
var textChild = nameEl.Children.OfType<UiText>().Single();
var lines = textChild.LinesProvider();
Assert.Empty(lines);
}
// ── H1: Select a health target (creature) ───────────────────────────────
/// <summary>
/// Selecting a health target (stackSize=1, isHealthTarget=true):
/// - overlay ActiveState == "ObjectSelected"
/// - meter Visible == true
/// - sendQueryHealth invoked exactly once with the guid
/// - name LinesProvider yields a single white line with the expected name
/// </summary>
[Fact]
public void SelectHealthTarget_meterVisible_overlayObjectSelected_queryHealthFired()
{
const uint Guid = 0xAA01u;
const string ExpectedName = "Drudge Prowler";
var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout();
var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, queryHealthCalls) =
MakeDelegates(
healthTargetMap: new() { [Guid] = true },
nameMap: new() { [Guid] = ExpectedName },
healthMap: new() { [Guid] = 0.75f },
stackMap: new() { [Guid] = 1u }); // stackSize=1 → ObjectSelected
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null);
// Fire the selection.
fireSelection(Guid);
Assert.True(healthMeterEl.Visible,
"health meter must become Visible after selecting a health target");
Assert.Equal("ObjectSelected", overlayEl.ActiveState);
Assert.Single(queryHealthCalls);
Assert.Equal(Guid, queryHealthCalls[0]);
// Name label: the LinesProvider should yield the creature's name as a white line.
var textChild = nameEl.Children.OfType<UiText>().Single();
var lines = textChild.LinesProvider();
Assert.Single(lines);
Assert.Equal(ExpectedName, lines[0].Text);
Assert.Equal(new Vector4(1f, 1f, 1f, 1f), lines[0].Color);
}
// ── H2: Select a stacked item ────────────────────────────────────────────
/// <summary>
/// Selecting a stacked item (stackSize > 1): overlay ActiveState == "StackedItemSelected".
/// </summary>
[Fact]
public void SelectStackedItem_overlayStackedItemSelected()
{
const uint Guid = 0xBB02u;
var (layout, _, overlayEl, healthMeterEl) = FakeLayout();
var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) =
MakeDelegates(
healthTargetMap: new() { [Guid] = false },
nameMap: new() { [Guid] = "Heal Kits" },
healthMap: new(),
stackMap: new() { [Guid] = 5u }); // stackSize > 1
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null);
fireSelection(Guid);
Assert.Equal("StackedItemSelected", overlayEl.ActiveState);
// Not a health target → meter stays hidden.
Assert.False(healthMeterEl.Visible);
}
// ── H3: Select a non-health target (friendly NPC / scenery) ─────────────
/// <summary>
/// Selecting a non-health target (isHealthTarget=false):
/// - meter stays hidden
/// - sendQueryHealth NOT invoked
/// - name and overlay are still set
/// </summary>
[Fact]
public void SelectNonHealthTarget_meterHidden_noQueryHealth_nameSet()
{
const uint Guid = 0xCC03u;
const string ExpectedName = "Town Crier";
var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout();
var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, queryHealthCalls) =
MakeDelegates(
healthTargetMap: new() { [Guid] = false },
nameMap: new() { [Guid] = ExpectedName },
healthMap: new(),
stackMap: new() { [Guid] = 0u }); // non-stack → ObjectSelected
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null);
fireSelection(Guid);
Assert.False(healthMeterEl.Visible, "meter must stay hidden for a non-health target");
Assert.Empty(queryHealthCalls); // sendQueryHealth must NOT be invoked for a non-health target
// Overlay and name are still populated.
Assert.Equal("ObjectSelected", overlayEl.ActiveState);
var textChild = nameEl.Children.OfType<UiText>().Single();
var lines = textChild.LinesProvider();
Assert.Single(lines);
Assert.Equal(ExpectedName, lines[0].Text);
}
// ── H4: Deselect (null) ──────────────────────────────────────────────────
/// <summary>
/// Selecting null clears the strip:
/// - meter Visible == false
/// - overlay ActiveState == ""
/// - name LinesProvider yields empty
/// </summary>
[Fact]
public void SelectNull_clearsStrip()
{
const uint Guid = 0xDD04u;
var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout();
var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) =
MakeDelegates(
healthTargetMap: new() { [Guid] = true },
nameMap: new() { [Guid] = "Wolf" },
healthMap: new() { [Guid] = 0.5f },
stackMap: new() { [Guid] = 0u });
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null);
// First select something...
fireSelection(Guid);
Assert.True(healthMeterEl.Visible);
// ...then deselect.
fireSelection(null);
Assert.False(healthMeterEl.Visible, "meter must be hidden after deselect");
Assert.Equal("", overlayEl.ActiveState);
var textChild = nameEl.Children.OfType<UiText>().Single();
var lines = textChild.LinesProvider();
Assert.Empty(lines);
}
// ── H5: Clear → new selection (re-select) ────────────────────────────────
/// <summary>
/// Selecting one target then another should clear the first and apply the second.
/// </summary>
[Fact]
public void ReSelect_differentGuid_clearsFirstThenAppliesSecond()
{
const uint GuidA = 0xEE05u;
const uint GuidB = 0xFF06u;
var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout();
var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, queryHealthCalls) =
MakeDelegates(
healthTargetMap: new() { [GuidA] = true, [GuidB] = false },
nameMap: new() { [GuidA] = "Bandit", [GuidB] = "Chest" },
healthMap: new() { [GuidA] = 1.0f },
stackMap: new() { [GuidA] = 0u, [GuidB] = 0u });
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null);
// Select A (health target).
fireSelection(GuidA);
Assert.True(healthMeterEl.Visible);
Assert.Single(queryHealthCalls);
// Select B (non-health target) — must clear A's state and apply B.
fireSelection(GuidB);
Assert.False(healthMeterEl.Visible, "health meter must be cleared when switching to non-health target");
Assert.Equal("ObjectSelected", overlayEl.ActiveState);
// sendQueryHealth must NOT be called again (B is not a health target).
Assert.Single(queryHealthCalls);
// Name should reflect B.
var textChild = nameEl.Children.OfType<UiText>().Single();
var lines = textChild.LinesProvider();
Assert.Single(lines);
Assert.Equal("Chest", lines[0].Text);
}
// ── H6: Partial layout (missing elements) ────────────────────────────────
/// <summary>
/// When elements are absent (partial layout), Bind does not throw and
/// OnSelectionChanged does not throw for any combination.
/// </summary>
[Fact]
public void PartialLayout_noElements_doesNotThrow()
{
// Empty layout — none of the three ids are present.
var root = new UiPanel();
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>());
Action<uint?>? registeredHandler = null;
var queryHealthCalls = new List<uint>();
SelectedObjectController.Bind(
layout,
subscribeSelectionChanged: h => registeredHandler = h,
isHealthTarget: _ => true,
name: _ => "Something",
healthPercent: _ => 1f,
stackSize: _ => 0u,
sendQueryHealth: g => queryHealthCalls.Add(g),
datFont: null);
Assert.NotNull(registeredHandler);
// Firing selection / deselection on a partial layout must not throw.
var ex = Record.Exception(() => registeredHandler!.Invoke(0x12345678u));
Assert.Null(ex);
ex = Record.Exception(() => registeredHandler!.Invoke(null));
Assert.Null(ex);
// QueryHealth must still be called (the delegate doesn't depend on the meter element).
Assert.Single(queryHealthCalls);
Assert.Equal(0x12345678u, queryHealthCalls[0]);
}
// ── H7: Fill closure reflects live healthPercent ─────────────────────────
/// <summary>
/// The meter's Fill closure reads the current guid's health percent from the
/// <c>healthPercent</c> delegate on every poll — so if the server updates the
/// health between polls the fill reflects the new value without re-selecting.
/// </summary>
[Fact]
public void HealthMeterFill_reflectsLiveHealthPercent()
{
const uint Guid = 0xAA07u;
float currentHealth = 0.5f;
var (layout, _, _, healthMeterEl) = FakeLayout();
var (subscribe, fireSelection, isHealthTarget, name, _, stackSize, sendQueryHealth, _) =
MakeDelegates(
healthTargetMap: new() { [Guid] = true },
nameMap: new() { [Guid] = "Arwic Banderling" },
healthMap: new(), // not used here
stackMap: new() { [Guid] = 0u });
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name,
healthPercent: _ => currentHealth, // reads the captured variable
stackSize, sendQueryHealth, datFont: null);
fireSelection(Guid);
// Fill should return the current health value.
Assert.Equal(0.5f, healthMeterEl.Fill());
// Simulate server updating health (as if UpdateHealth 0x01C0 arrived).
currentHealth = 0.25f;
Assert.Equal(0.25f, healthMeterEl.Fill());
}
// ── H8: Fill returns 0 when nothing is selected ──────────────────────────
/// <summary>
/// After deselect, the meter Fill returns 0f (empty bar) rather than
/// the last selected target's health value.
/// </summary>
[Fact]
public void HealthMeterFill_returnsZero_whenNothingSelected()
{
const uint Guid = 0xAA08u;
var (layout, _, _, healthMeterEl) = FakeLayout();
var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) =
MakeDelegates(
healthTargetMap: new() { [Guid] = true },
nameMap: new() { [Guid] = "Spider" },
healthMap: new() { [Guid] = 0.8f },
stackMap: new() { [Guid] = 0u });
SelectedObjectController.Bind(layout, subscribe,
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null);
fireSelection(Guid);
Assert.Equal(0.8f, healthMeterEl.Fill()); // sanity check
fireSelection(null);
// After deselect, Fill() must return 0f (or null coerced to 0f).
var fill = healthMeterEl.Fill();
Assert.Equal(0f, fill ?? 0f);
}
}