acdream/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.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

231 lines
10 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class DatWidgetFactoryTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
// ── Test 1: Type 7 → UiMeter ─────────────────────────────────────────────
[Fact]
public void Type7_Meter_MakesUiMeter()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
Assert.IsType<UiMeter>(e);
}
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
[Fact]
public void UnknownType_FallsBackToGeneric()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
Assert.IsType<UiDatElement>(e);
}
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
[Fact]
public void Type12_Text_MakesUiText()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
Assert.IsType<UiText>(e);
}
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
/// <summary>
/// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=1 should have
/// its rect + anchors copied onto the returned widget.
/// Per UIElement::UpdateForParentSizeChange @0x00462640:
/// Left=1 → AnchorEdges.Left (near-pin); Top=1 → AnchorEdges.Top;
/// Right=1 → AnchorEdges.Right (stretch / track parent right); Bottom=0 → neither.
/// Combined: Left | Top | Right.
/// </summary>
[Fact]
public void RectAndAnchors_SetFromElementInfo()
{
var info = new ElementInfo
{
Type = 3,
X = 5, Y = 21,
Width = 150, Height = 16,
Left = 1, Top = 1,
Right = 1, Bottom = 0,
};
var e = DatWidgetFactory.Create(info, NoTex, null)!;
Assert.Equal(5f, e.Left);
Assert.Equal(21f, e.Top);
Assert.Equal(150f, e.Width);
Assert.Equal(16f, e.Height);
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
}
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
[Fact]
public void Create_PropagatesReadOrderToZOrder()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, ReadOrder = 7 }, NoTex, null);
Assert.Equal(7, e!.ZOrder);
}
// ── Test G1a: Type 12 always produces UiText (with or without own sprites) ──
[Fact]
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
{
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
}
// ── Test 5c: Type 1 → UiButton ──────────────────────────────────────────
[Fact]
public void Type1_Button_MakesUiButton()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
Assert.IsType<UiButton>(e);
}
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
[Fact]
public void Type11_Scrollbar_MakesUiScrollbar()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
Assert.IsType<UiScrollbar>(e);
}
// ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ────
//
// Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat
// bevel chrome + the transcript/input container panels) are inert sprite-bearing
// chrome, not editable fields. They stay on the UiDatElement fallback so their
// sprites render and they gain no spurious focus/edit affordance. The one true
// editable field (the chat input, 0x10000016) resolves to Type 12 and is
// controller-placed as a UiField. Register Type 3 → UiField only when a window
// carries a factory-built editable Type-3 field.
[Fact]
public void Type3_NotRegistered_FallsBackToGeneric()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
Assert.IsType<UiDatElement>(e);
}
// ── Test 5d: Type 6 → UiMenu ─────────────────────────────────────────────
[Fact]
public void Type6_Menu_MakesUiMenu()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
Assert.IsType<UiMenu>(e);
}
// ── Test 7: Type 0x10000031 → UiItemList ────────────────────────────────
[Fact]
public void Create_buildsUiItemList_forItemListClassId()
{
var info = new AcDream.App.UI.Layout.ElementInfo { Id = 0x100001A7u, Type = 0x10000031u, Width = 32, Height = 32 };
var w = AcDream.App.UI.Layout.DatWidgetFactory.Create(info, _ => (0u, 0, 0), null);
Assert.IsType<AcDream.App.UI.UiItemList>(w);
}
// ── Test M1: Single-image meter (toolbar selected-object meters) ────────
//
// The toolbar health/mana meters (0x100001A1 / 0x100001A2) use a DIFFERENT
// shape from the vitals 3-slice meters: the back-track sprite lives on the
// meter ELEMENT's own DirectState ("" key), and there is exactly ONE Type-3
// child whose own DirectState ("" key) carries the fill sprite. That child
// has no image grandchildren, so SliceIds would return all-zero — the new
// Count==1 branch reads the StateMedia entries directly instead.
// The sprites go in the TILE slot (Back/FrontTile), NOT the cap slot: DrawMode=Normal
// tiles at native width across the full bar geometry (UIElement_Meter::DrawChildren),
// so the back spans all 140px and the fill clips to 140*fraction for any native width.
// Back/FrontLeft + Back/FrontRight must be 0 (no caps on a single-image bar).
[Fact]
public void BuildMeter_SingleImageShape_ReadsDirectStateFromElementAndFillChild()
{
const uint BackFile = 0x0600193Eu; // health back-track (from toolbar dump)
const uint FillFile = 0x0600193Fu; // health fill (from toolbar dump)
// Meter element: Type 7, own DirectState = back-track sprite.
var meter = new ElementInfo { Type = 7, Id = 0x100001A1u, Width = 140, Height = 31 };
meter.StateMedia[""] = (BackFile, 1);
// Single Type-3 fill container: own DirectState = fill sprite, no grandchildren.
var fillContainer = new ElementInfo { Type = 3, ReadOrder = 1 };
fillContainer.StateMedia[""] = (FillFile, 1);
meter.Children.Add(fillContainer);
var e = DatWidgetFactory.Create(meter, NoTex, null);
var m = Assert.IsType<UiMeter>(e);
// Back-track on the meter element's own DirectState, fill on the single child —
// both in the TILE slot so they tile across the full 140px bar (DrawMode=Normal).
Assert.Equal(BackFile, m.BackTile);
Assert.Equal(0u, m.BackLeft);
Assert.Equal(0u, m.BackRight);
Assert.Equal(FillFile, m.FrontTile);
Assert.Equal(0u, m.FrontLeft);
Assert.Equal(0u, m.FrontRight);
}
// ── Test 6: Meter slice extraction (the important one) ───────────────────
/// <summary>
/// A meter (Type 7) whose two Type-3 containers each carry 3 image children
/// (ordered by X, bearing a DirectState "" sprite), plus the front container
/// has a fourth expand-overlay child with ONLY a named "ShowDetail" state —
/// that overlay must be excluded from the slice count.
/// </summary>
[Fact]
public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay()
{
// Slice ids sourced from format doc §11 — real health-bar ids.
const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u;
const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u;
const uint OverlayFile = 0x06007490u;
// Back container (ReadOrder 0 — drawn first / behind)
var backChild = new ElementInfo { Type = 3, ReadOrder = 0 };
backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } });
backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } });
backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } });
// Front container (ReadOrder 1 — drawn on top)
var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 };
frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } });
frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } });
frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } });
// Expand-detail overlay: named state only — NO DirectState "" — must be ignored.
frontChild.Children.Add(new ElementInfo
{
X = 0,
StateMedia = { ["ShowDetail"] = (OverlayFile, 3) }
});
var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 };
meter.Children.Add(backChild);
meter.Children.Add(frontChild);
var e = DatWidgetFactory.Create(meter, NoTex, null);
var m = Assert.IsType<UiMeter>(e);
Assert.Equal(BackL, m.BackLeft);
Assert.Equal(BackT, m.BackTile);
Assert.Equal(BackR, m.BackRight);
Assert.Equal(FrontL, m.FrontLeft);
Assert.Equal(FrontT, m.FrontTile);
Assert.Equal(FrontR, m.FrontRight);
// Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot.
Assert.NotEqual(OverlayFile, m.FrontRight);
Assert.NotEqual(OverlayFile, m.FrontTile);
}
}