acdream/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
Erik adf8335675 fix: powerbar mode captions - jump 'Height' + combat 'Power'/'Accuracy'
Retail authors ONE caption element per bar with per-mode state strings,
switched by a PassToChildren state cascade (gmPowerbarUI::
RecvNotice_BeginPowerbar @0x004DA730 sets 0x10000042 Jump / 0x10000043
Melee / 0x10000044 Missile / 0x10000045 DDD; installed-DAT probe
confirmed every string + PassToChildren flag).

- Jump bar (user gate): the floaty powerbar's caption child (0x10000035:
  JumpMode 'Height', authored HJustify=Center over the bar) was dropped
  by UiMeter's child absorption. The stateful-fill meter build now
  absorbs it into per-state labels; TrySetRetailState latches the
  caption and OnDraw shows it when no live Label provider is bound.
  JumpPowerbarController's existing JumpMode flip now surfaces 'Height'
  with zero controller changes. The mount gained the string resolver the
  Build call never passed.
- Combat bar (user gate): label 0x10000052 authors 'MeleeCombat' ->
  'Power' and 'MissileCombat' -> 'Accuracy'; the controller latched the
  MELEE string once at bind. CombatUiLabels now resolves both authored
  strings and OnCombatModeChanged sets the mode's string - switching
  live when swapping melee <-> missile weapons in combat. Also fixed
  the mode-state flip target: the states live on the BASIC PANEL
  (0x1000005C, PassToChildren), not the layout root (Hide/ShowDetail
  only) - the old _root flip was a silent no-op.

New env-gated ACDREAM_PROBE_POWERBAR layout probe (kept, house
pattern). App suite 4,987/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:14:14 +02:00

890 lines
38 KiB
C#

using System.Numerics;
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);
}
[Fact]
public void RetailRadarClass_MakesUiRadar()
{
var e = DatWidgetFactory.Create(
new ElementInfo { Type = UiRadar.RetailClassId, Width = 120, Height = 140 },
NoTex,
null);
Assert.IsType<UiRadar>(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);
}
[Fact]
public void Type2_Dragbar_IsWindowMoveHandle_AndClaimsThePointer()
{
// Retail UIElement_Dragbar (element class 2, Register @ 0x0046C840). It must
// opt back out of UiDatElement's decoration ClickThrough so the press reaches
// it and starts the window move.
var e = DatWidgetFactory.Create(
new ElementInfo { Type = 2, Width = 600, Height = 5 }, NoTex, null);
Assert.IsType<UiDatElement>(e);
Assert.True(e.WindowMoveHandle);
Assert.False(e.ClickThrough);
}
// ── Type 9 → UiResizeGrip (retail UIElement_Resizebar) ───────────────────
// Campaign CH slice CH6a: LayoutDesc 0x2100006F's main chat window authors
// 7 live Type-9 grips (corners + 3 straight edges — the top edge is a
// separate Type-2 Dragbar move handle, not a grip) via property bools
// 0x2A=bottom/0x2B=left/0x2C=right/0x2D=top. Real element ids + their
// decoded bools, confirmed against the installed DAT:
// 0x1000069B (TL) bottom=F left=T right=F top=T -> UpperLeft
// 0x1000069D (TR) bottom=F left=F right=T top=T -> UpperRight
// 0x1000069E (L) bottom=F left=T right=F top=F -> Left
// 0x1000069F (BL) bottom=T left=T right=F top=F -> LowerLeft
// 0x100006A0 (B) bottom=T left=F right=F top=F -> Bottom
// 0x100006A1 (BR) bottom=T left=F right=T top=F -> LowerRight
// 0x100006A2 (R) bottom=F left=F right=T top=F -> Right
[Fact]
public void Type9_Resizebar_NoBoolsSet_DecodesToNoneEdges()
{
var e = DatWidgetFactory.Create(GripInfo(), NoTex, null);
var grip = Assert.IsType<UiResizeGrip>(e);
Assert.Equal(UiResizeGrip.Border.None, grip.BorderLocation);
Assert.Equal(ResizeEdges.None, grip.Edges);
}
[Theory]
// (bottom, left, right, top) -> (Border, Edges) — the 8 authored grip shapes
// (edges set exactly one bool; corners set exactly two adjacent bools).
[InlineData(false, false, false, true, UiResizeGrip.Border.Top, ResizeEdges.Top)]
[InlineData(true, false, false, false, UiResizeGrip.Border.Bottom, ResizeEdges.Bottom)]
[InlineData(false, true, false, false, UiResizeGrip.Border.Left, ResizeEdges.Left)]
[InlineData(false, false, true, false, UiResizeGrip.Border.Right, ResizeEdges.Right)]
[InlineData(false, true, false, true, UiResizeGrip.Border.UpperLeft, ResizeEdges.Left | ResizeEdges.Top)]
[InlineData(false, false, true, true, UiResizeGrip.Border.UpperRight, ResizeEdges.Right | ResizeEdges.Top)]
[InlineData(true, true, false, false, UiResizeGrip.Border.LowerLeft, ResizeEdges.Left | ResizeEdges.Bottom)]
[InlineData(true, false, true, false, UiResizeGrip.Border.LowerRight, ResizeEdges.Right | ResizeEdges.Bottom)]
public void Type9_Resizebar_DecodesEachOfTheEightAuthoredGrips(
bool bottom, bool left, bool right, bool top,
UiResizeGrip.Border expectedBorder, ResizeEdges expectedEdges)
{
var e = DatWidgetFactory.Create(GripInfo(bottom, left, right, top), NoTex, null);
var grip = Assert.IsType<UiResizeGrip>(e);
Assert.Equal(expectedBorder, grip.BorderLocation);
Assert.Equal(expectedEdges, grip.Edges);
}
[Fact]
public void Type9_Resizebar_IsNotClickThrough_SoItCanCaptureTheResizeDrag()
{
// A grip is a precise hit region — it must NOT default to UiDatElement's
// decoration ClickThrough=true, or UiRoot's hit-test would skip straight
// past it to whatever's behind.
var e = DatWidgetFactory.Create(GripInfo(bottom: true), NoTex, null);
var grip = Assert.IsType<UiResizeGrip>(e);
Assert.False(grip.ClickThrough);
}
[Fact]
public void DecodeBorderLocation_MatchesRetailBranchOrder_RightBeatsLeftBeatsTopBeatsBottom()
{
// UIElement_Resizebar::StartMouseResizing @0x0046B7E0's if/else-if chain —
// retail's authored data never sets more than 2 bools, but the branch
// ORDER is still part of the port: right wins over left, and within a
// branch top wins over bottom.
Assert.Equal(
UiResizeGrip.Border.UpperRight,
UiResizeGrip.DecodeBorderLocation(bottom: true, left: true, right: true, top: true));
Assert.Equal(
UiResizeGrip.Border.UpperLeft,
UiResizeGrip.DecodeBorderLocation(bottom: true, left: true, right: false, top: true));
Assert.Equal(
UiResizeGrip.Border.Top,
UiResizeGrip.DecodeBorderLocation(bottom: true, left: false, right: false, top: true));
}
private static ElementInfo GripInfo(bool bottom = false, bool left = false, bool right = false, bool top = false)
{
var info = new ElementInfo { Id = 0x1000069Bu, Type = 9, Width = 5, Height = 5 };
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
if (bottom) state.Properties.Values[0x2Au] = Bool(true);
if (left) state.Properties.Values[0x2Bu] = Bool(true);
if (right) state.Properties.Values[0x2Cu] = Bool(true);
if (top) state.Properties.Values[0x2Du] = Bool(true);
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
// ── 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);
var text = Assert.IsType<UiText>(e);
Assert.False(text.Selectable);
Assert.True(text.ClickThrough);
Assert.False(text.AcceptsFocus);
Assert.False(text.CapturesPointerDrag);
}
[Fact]
public void Type12_EditableProperty_MakesUiFieldInPlace()
{
var info = TextInfo(
(0x16u, Bool(true)),
(0x20u, Bool(true)),
(0x27u, Bool(true)),
(0x1Eu, Integer(80)));
var field = Assert.IsType<UiField>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(info.Id, field.ElementId);
Assert.True(field.OneLine);
Assert.True(field.Selectable);
Assert.Equal(80, field.MaxCharacters);
}
[Fact]
public void Type12_SelectableProperty_MakesSelectableUiText()
{
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
TextInfo((0x27u, Bool(true))),
NoTex,
null));
Assert.True(text.Selectable);
Assert.False(text.ClickThrough);
Assert.True(text.AcceptsFocus);
Assert.True(text.CapturesPointerDrag);
}
// ── 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);
}
[Fact]
public void ImportedDescendant_PreservesExactRawEdgePolicy()
{
var info = new ElementInfo
{
Type = 3,
X = 10,
Y = 20,
Width = 30,
Height = 40,
Left = 4,
Top = 3,
Right = 1,
Bottom = 0,
OriginalParentWidth = 100,
OriginalParentHeight = 200,
HasOriginalParentSize = true,
};
var element = DatWidgetFactory.Create(info, NoTex, null)!;
var policy = Assert.IsType<UiLayoutPolicy>(element.LayoutPolicy);
Assert.Equal(4u, policy.LeftMode);
Assert.Equal(3u, policy.TopMode);
Assert.Equal(1u, policy.RightMode);
Assert.Equal(0u, policy.BottomMode);
Assert.Equal(new UiPixelRect(0, 0, 99, 199), policy.OriginalParent);
}
[Fact]
public void ImportedRoot_HasNoRawEdgePolicy()
{
var element = DatWidgetFactory.Create(
new ElementInfo { Type = 3, Width = 100, Height = 200 },
NoTex,
null)!;
Assert.Null(element.LayoutPolicy);
}
[Fact]
public void Create_PropagatesStateCursors()
{
var info = new ElementInfo { Type = 3 };
info.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06008888u, 7, 8);
var e = DatWidgetFactory.Create(info, NoTex, null)!;
Assert.Equal(new UiCursorMedia(0x06008888u, 7, 8),
e.CursorForState("Drag_rollover_accept", allowFallback: false));
}
// ── 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);
}
[Fact]
public void Type11_HorizontalScrollbar_usesDirectRetailTrackAndThumbChildren()
{
const uint Track = 0x06004CF6u;
const uint Thumb = 0x06005DC3u;
var info = new ElementInfo { Type = 11, Id = 0x100001A4u, Width = 90, Height = 14 };
var track = new ElementInfo { Id = 4u, Type = 3u, Width = 90, Height = 14 };
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Track, 1) };
var thumb = new ElementInfo { Id = 1u, Type = 3u, Width = 16, Height = 14 };
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Thumb, 1) };
info.Children.Add(track);
info.Children.Add(thumb);
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(bar.Horizontal);
Assert.Equal(Track, bar.TrackSprite);
Assert.Equal(Thumb, bar.ThumbSprite);
Assert.Equal(0u, bar.UpSprite);
Assert.Equal(0u, bar.DownSprite);
}
[Fact]
public void Type11_HorizontalScrollbar_ImportsAuthoredArrowButtonsAndHideDisabled()
{
const uint DecrementId = 0x10000071u;
const uint IncrementId = 0x10000072u;
const uint DecrementSprite = 0x06004CDCu;
const uint IncrementSprite = 0x06004CDEu;
const uint DecrementRollover = 0x06004CDDu;
const uint IncrementRollover = 0x06004CDFu;
var decrement = new ElementInfo
{
Id = DecrementId, Type = 1u, X = 63f, Width = 23f, Height = 36f,
};
decrement.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(DecrementSprite, 1),
};
decrement.StateMedia["Normal"] = (DecrementSprite, 1);
decrement.StateMedia["Normal_rollover"] = (DecrementRollover, 1);
decrement.StateMedia["Normal_pressed"] = (DecrementSprite, 1);
var increment = new ElementInfo
{
Id = IncrementId, Type = 1u, X = 0f, Width = 23f, Height = 36f,
};
increment.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(IncrementSprite, 1),
};
increment.StateMedia["Normal"] = (IncrementSprite, 1);
increment.StateMedia["Normal_rollover"] = (IncrementRollover, 1);
increment.StateMedia["Normal_pressed"] = (IncrementSprite, 1);
var info = new ElementInfo
{
Type = 11u,
Id = SpellcastingUiController.FavoriteScrollbarId,
Width = 685f,
Height = 36f,
Children = [decrement, increment],
};
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
state.Properties.Values[0x77u] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = IncrementId };
state.Properties.Values[0x78u] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = DecrementId };
state.Properties.Values[0x79u] = Bool(true);
info.States[UiStateInfo.DirectStateId] = state;
var bar = Assert.IsType<UiScrollbar>(
DatWidgetFactory.Create(info, NoTex, null));
Assert.True(bar.Horizontal);
Assert.True(bar.HideWhenDisabled);
Assert.Equal(23f, bar.DecrementButtonExtent);
Assert.Equal(23f, bar.IncrementButtonExtent);
Assert.Equal(IncrementSprite, bar.UpSprite);
Assert.Equal(IncrementRollover, bar.UpRolloverSprite);
Assert.Equal(IncrementSprite, bar.UpPressedSprite);
Assert.Equal(DecrementSprite, bar.DownSprite);
Assert.Equal(DecrementRollover, bar.DownRolloverSprite);
Assert.Equal(DecrementSprite, bar.DownPressedSprite);
Assert.Equal(0u, bar.TrackSprite);
Assert.Equal(0u, bar.ThumbSprite);
}
[Fact]
public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider()
{
ImportedLayout layout = FixtureLoader.LoadToolbar();
var entry = Assert.IsType<UiField>(layout.FindElement(0x100001A3u));
Assert.True(entry.RightAligned);
var bar = Assert.IsType<UiScrollbar>(layout.FindElement(0x100001A4u));
Assert.True(bar.Horizontal);
Assert.Equal(0x06004CF6u, bar.TrackSprite);
Assert.Equal(0x06005DC3u, bar.ThumbSprite);
}
// ── 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);
}
[Fact]
public void BuildMeter_StatefulSingleImageShape_MapsNumericFillStatesAndHidesDirectRange()
{
const uint TrackFile = 0x06004D0Bu;
const uint JumpState = 0x10000042u;
const uint JumpFile = 0x06001354u;
const uint RangeFile = 0x0600715Eu;
var meter = new ElementInfo { Type = 7, Id = 0x10000034u, Width = 600, Height = 15 };
meter.StateMedia[""] = (TrackFile, 1);
var fills = new ElementInfo { Type = 3, Id = 2u, ReadOrder = 2 };
fills.States[JumpState] = new UiStateInfo { Id = JumpState, Name = "JumpMode" };
fills.StateMedia["JumpMode"] = (JumpFile, 1);
meter.Children.Add(fills);
var range = new ElementInfo { Type = 3, Id = 0x100005EEu, ReadOrder = 1 };
range.StateMedia[""] = (RangeFile, 1);
meter.Children.Add(range);
var result = Assert.IsType<UiMeter>(DatWidgetFactory.Create(meter, NoTex, null));
Assert.Equal(TrackFile, result.BackTile);
Assert.Equal(0u, result.FrontTile);
Assert.True(result.TrySetRetailState(JumpState));
Assert.Equal(JumpState, result.ActiveRetailStateId);
Assert.Equal(JumpFile, result.FrontTile);
Assert.NotEqual(RangeFile, result.BackTile);
Assert.NotEqual(RangeFile, result.FrontTile);
}
// ── 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);
}
// ── Justification build-time application (new for importer Fix A) ────────
/// <summary>
/// A Type-12 text element with HJustify=Center (the default) must produce a
/// UiText with Centered=true and RightAligned=false at build time.
/// This proves BuildText applies the dat's HJustify at construction without a
/// controller binding step.
/// </summary>
[Fact]
public void BuildText_HJustifyCenter_SetsCentered()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered);
Assert.False(t.RightAligned);
Assert.Equal(VJustify.Center, t.VerticalJustify);
}
/// <summary>
/// A Type-12 text element with HJustify=Right must produce a UiText with
/// Centered=false and RightAligned=true at build time.
/// </summary>
[Fact]
public void BuildText_HJustifyRight_SetsRightAligned()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.True(t.RightAligned);
}
/// <summary>
/// A Type-12 text element with HJustify=Left must produce a UiText with
/// Centered=false and RightAligned=false at build time.
/// </summary>
[Fact]
public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.False(t.RightAligned);
}
/// <summary>
/// A Type-12 text element with VJustify=Top must produce a UiText with
/// VerticalJustify=Top at build time.
/// </summary>
[Fact]
public void BuildText_VJustifyTop_SetsVerticalJustifyTop()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(VJustify.Top, t.VerticalJustify);
}
/// <summary>
/// A controller override: build-time sets Centered=true (HJustify=Center), then
/// a controller sets Centered=false afterward. The controller's override wins —
/// this is the backward-compatibility guarantee.
/// </summary>
[Fact]
public void BuildText_ControllerOverrideWinsAfterBuild()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered); // build-time value
t.Centered = false; // controller override
Assert.False(t.Centered); // override wins
}
// ── DefaultColor from dat FontColor (importer Fix B) ─────────────────────
/// <summary>
/// When ElementInfo.FontColor is set (dat carried 0x1B ColorBaseProperty),
/// DatWidgetFactory.BuildText must copy it onto UiText.DefaultColor.
/// </summary>
[Fact]
public void BuildText_FontColorPresent_SetsDefaultColor()
{
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = gold };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(gold, t.DefaultColor);
}
[Fact]
public void BuildText_RetailConstructorDefaultsToZeroMargins()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20 };
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(0f, text.Padding);
Assert.False(text.OneLine);
}
[Fact]
public void BuildText_AuthoredLineTracksStateFontColor()
{
var info = new ElementInfo
{
Type = 12,
Width = 100,
Height = 20,
DefaultStateId = RetailUiStateIds.Closed,
DefaultStateName = "Closed",
};
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
direct.Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
};
var closed = new UiStateInfo { Id = RetailUiStateIds.Closed, Name = "Closed" };
closed.Properties.Values[0x1Bu] = Color(127, 127, 127);
var open = new UiStateInfo { Id = RetailUiStateIds.Open, Name = "Open" };
open.Properties.Values[0x1Bu] = Color(204, 204, 204);
info.States[UiStateInfo.DirectStateId] = direct;
info.States[RetailUiStateIds.Closed] = closed;
info.States[RetailUiStateIds.Open] = open;
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
info, NoTex, null, stringResolve: _ => "Spells"));
Assert.Equal(127f / 255f, text.LinesProvider()[0].Color.X, 5);
Assert.True(text.TrySetRetailState(RetailUiStateIds.Open));
Assert.Equal(204f / 255f, text.LinesProvider()[0].Color.X, 5);
}
/// <summary>
/// 2026-08-13 social gate round 3: a MULTILINE authored string (literal
/// backslash-n escapes in the DAT) word-wraps each authored line to the
/// widget's live width — retail's GlyphList draw, the same wrap the
/// confirmation dialog view uses. The fellowship empty-state was
/// rendering its three authored lines as three clipped runs.
/// </summary>
[Fact]
public void BuildText_MultilineAuthored_WordWrapsToLiveWidth()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 80 };
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
direct.Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
};
info.States[UiStateInfo.DirectStateId] = direct;
// No DatFont in this fixture — the fallback measure is 8 px/char, so
// Width=100 fits 12 characters per wrapped line.
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: _ => "one two three four five\\nsix"));
var lines = text.LinesProvider!();
Assert.True(lines.Count >= 3);
Assert.All(lines, line => Assert.True(line.Text.Length <= 12));
Assert.Equal(
"one two three four five six",
string.Join(" ", lines.Select(static line => line.Text)));
// The provider tracks the LIVE width — widening re-wraps.
text.Width = 400f;
lines = text.LinesProvider!();
Assert.Equal(2, lines.Count);
Assert.Equal("one two three four five", lines[0].Text);
Assert.Equal("six", lines[1].Text);
}
/// <summary>
/// 2026-08-14 gate: the stateful-fill meter (gmPowerbarUI's shape in
/// LayoutDesc 0x21000072) also absorbs its Type-12 caption child
/// (0x10000035, per-mode strings 'Height'/'Power'/'Accuracy') into
/// per-state labels latched by TrySetRetailState — the absorbed
/// equivalent of retail's PassToChildren state cascade.
/// </summary>
[Fact]
public void BuildMeter_StatefulFill_AbsorbsCaptionChildPerStateStrings()
{
var meter = new ElementInfo { Id = 0x34, Type = 7, Width = 600, Height = 15 };
meter.StateMedia[""] = (0x06004D0Bu, 1);
var fill = new ElementInfo { Id = 2, Type = 3, Width = 600, Height = 15 };
fill.States[0x10000042u] = new UiStateInfo { Id = 0x10000042u, Name = "JumpMode" };
fill.StateMedia["JumpMode"] = (0x06001354u, 1);
meter.Children.Add(fill);
var caption = new ElementInfo { Id = 0x35, Type = 12, Width = 600, Height = 15 };
var jumpText = new UiStateInfo { Id = 0x10000042u, Name = "JumpMode" };
jumpText.Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
};
caption.States[0x10000042u] = jumpText;
meter.Children.Add(caption);
var m = Assert.IsType<UiMeter>(DatWidgetFactory.Create(
meter, NoTex, null, stringResolve: _ => "Height"));
Assert.Null(m.ActiveStateLabel);
Assert.True(m.TrySetRetailState(0x10000042u));
Assert.Equal("Height", m.ActiveStateLabel);
Assert.Equal(0x06001354u, m.FrontTile);
}
[Fact]
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
{
var track = new ElementInfo { Id = 4, Type = 3, Width = 507, Height = 14 };
track.StateMedia[""] = (0x06001919u, 1);
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001919u, 1) };
var thumb = new ElementInfo { Id = 1, Type = 1, Width = 12, Height = 14 };
thumb.StateMedia[""] = (0x06001923u, 1);
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001923u, 1) };
var fill = new ElementInfo { Id = 2, Type = 3, Width = 507, Height = 14 };
fill.StateMedia[""] = (0x06001200u, 1);
fill.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001200u, 1) };
var range = new ElementInfo
{
Id = 0x100005EFu,
Type = 3,
X = 63,
Width = 407,
Height = 14,
OriginalParentWidth = 507,
OriginalParentHeight = 14,
HasOriginalParentSize = true,
};
range.StateMedia[""] = (0x0600715Eu, 1);
range.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x0600715Eu, 1) };
var meter = new ElementInfo
{
Id = CombatUiController.PowerControlId + 1,
Type = 7,
Width = 507,
Height = 14,
Children = [fill, range],
};
var meterState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
meterState.Properties.Values[0x6Fu] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = 1u };
meter.States[UiStateInfo.DirectStateId] = meterState;
var info = new ElementInfo
{
Id = CombatUiController.PowerControlId,
Type = 11,
Width = 507,
Height = 14,
Children = [track, meter, thumb],
};
var bar = Assert.IsType<UiScrollbar>(
DatWidgetFactory.Create(info, NoTex, null));
Assert.True(bar.Horizontal);
Assert.Equal(0x06001919u, bar.TrackSprite);
Assert.Equal(0x06001923u, bar.ThumbSprite);
Assert.Equal(0x06001200u, bar.ScalarFillSprite);
Assert.Equal(0x0600715Eu, bar.ScalarRangeSprite);
Assert.NotNull(bar.ScalarRangeLayoutPolicy);
Assert.False(bar.ScalarFillFromRight);
}
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
{
var info = new ElementInfo { Id = 0x10000016u, Type = 12, Width = 100, Height = 20 };
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
foreach (var property in properties)
state.Properties.Values[property.Id] = property.Value;
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
private static UiPropertyValue Bool(bool value)
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
private static UiPropertyValue Integer(int value)
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
private static UiPropertyValue Color(byte red, byte green, byte blue)
=> new()
{
Kind = UiPropertyKind.Color,
ColorValue = new UiColorValue(blue, green, red, 255),
};
/// <summary>
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
/// stay at white (Vector4.One) — the backward-compatible default.
/// </summary>
[Fact]
public void BuildText_FontColorAbsent_DefaultColorIsWhite()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = null };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(Vector4.One, t.DefaultColor);
}
/// <summary>
/// Backward-compat guarantee: a controller that sets LinesProvider with an
/// explicit per-line color AFTER build is unaffected by DefaultColor.
/// The controller's per-line color is stored in the Line record, not DefaultColor,
/// so DefaultColor changing from dat does not touch existing controller bindings.
/// </summary>
[Fact]
public void BuildText_ControllerExplicitLineColor_UnaffectedByDefaultColor()
{
// Dat sets a parchment color.
var parchment = new Vector4(0.92f, 0.90f, 0.82f, 1f);
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = parchment };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
// Controller overrides with an explicit gold color in the Line record.
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
t.LinesProvider = () => new[] { new UiText.Line("text", gold) };
// The line's color is gold (controller wins), NOT the dat parchment.
Assert.Equal(gold, t.LinesProvider()[0].Color);
// DefaultColor still holds the dat parchment (unmodified by the controller binding).
Assert.Equal(parchment, t.DefaultColor);
}
}