acdream/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
Erik e6acb800cc fix(chargen): Campaign CC gate round 1 re-test 3 — R4-1..R4-4
Four visual residuals from the lead's own live-client captures of
1.0.2-cc.m, all root-caused via decomp + live-DAT evidence:

- R4-1: Skills credits value overlapped mid-caption again. Root cause
  was a missing UiLayoutPolicy raw-edge reflow on UiButton's value-child
  rect (the child is base-inherited across four sibling buttons of
  differing widths, so its baked-in OriginalParentWidth diverges from
  the actual 231px-wide Skills credits button) plus an HJustify.Right
  value child mapped to Center instead of a real far-edge Right.
- R4-2: the single-sprite scrollbar thumb tiled (GL_REPEAT) instead of
  drawing once — DrawTiled was reused for a small fixed marker graphic
  whose native size is far smaller than the track-proportional thumb
  rect. New DrawThumbMarker draws exactly one native-size instance.
- R4-3: the skills info-box formula line clipped past the surrounding
  gold frame's own authored bottom edge (the pane's own raw box is 20px
  taller than the frame that visually contains it) — clamp the pane's
  Height to the frame's bottom (register AD-105, since retail's
  ShowSkillsText has no code relationship to the frame to cite).
- R4-4: the Appearance help text started mid-sentence — the box was
  never touched by its page controller, so it kept UiText's chat-style
  PreserveEndOnLayout=true default; the scroll model's wasAtEnd check is
  vacuously true on its first-ever overflow transition, pinning the
  first render to the bottom. Set PreserveEndOnLayout=false (a static
  top-oriented report, not a transcript) and wired the box's own nested
  authored scrollbar, never wired before.

App suite live-DAT env 5372/3 -> 5379/3 (+7, zero regressions). Runtime
1735/0 unchanged. Full solution 14585/4 skips/1 failure (the documented
Core.Net NakEmission full-solution-only flake, confirmed standalone-pass).

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

1391 lines
61 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);
}
// ── Dialog roots: retail class types 0x13/0x19 → UiDialogRoot ────────────
[Theory]
[InlineData(0x13u)] // ConfirmationDialog (catalog root 0x15)
[InlineData(0x15u)] // ConfirmationTextInputDialog (catalog root 0x2C)
[InlineData(0x17u)] // MessageDialog (catalog root 0x24)
[InlineData(0x19u)] // WaitDialog (catalog root 0x31 — OP8 #396's live
// crash: unmapped type built a plain UiDatElement and
// RetailWaitDialogView's ctor threw out of OnClick)
public void DialogRootTypes_MakeUiDialogRoot(uint type)
{
var e = DatWidgetFactory.Create(
new ElementInfo { Type = type, Width = 800, Height = 600 }, NoTex, null);
Assert.IsType<UiDialogRoot>(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);
}
/// <summary>
/// Campaign LA gate round 2 finding 2: the retail character-select row
/// template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
/// confirmed) authors HJustify=Left DIRECTLY on the row's own
/// UIElement_Button — no separate Type-12 caption child (its label comes
/// from the runtime-bound character name, not an authored string), just
/// three stateful Type-3 highlight-art children. The old guard
/// (<c>!ReferenceEquals(labelInfo, info)</c>) only honored HJustify when
/// the label was LIFTED from a distinct Type-12 child, so a button
/// authoring its own justify with no such child fell through to
/// UiButton's Center default. This reproduces that exact shape.
/// </summary>
[Fact]
public void BuildButton_OwnHJustifyLeft_NoTextChild_MultipleStatefulFaces_LabelAlignsLeft()
{
var info = new ElementInfo
{
Type = 1,
Width = 160,
Height = 16,
HJustify = HJustify.Left,
};
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
info.States[3u] = new UiStateInfo { Id = 3u, Name = "Highlight" };
for (int i = 0; i < 3; i++)
{
var face = new ElementInfo { Type = 3, ReadOrder = (uint)i };
face.StateMedia["Normal_rollover"] = (0x06000000u + (uint)i, 1);
info.Children.Add(face);
}
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign);
// Direct (non-lifted) case: LabelOffsetX stays at UiButton's own
// default small left padding, not a bogus inner offset.
Assert.Equal(3f, button.LabelOffsetX);
}
/// <summary>
/// A button whose own authored HJustify really is Center (the normal
/// case — CREATE/ENTER/DELETE/RESTORE captions) must stay centered; the
/// fix only widens the Left branch, it must not force every button left.
/// </summary>
[Fact]
public void BuildButton_OwnHJustifyCenter_NoTextChild_StaysCentered()
{
var info = new ElementInfo
{
Type = 1,
Width = 160,
Height = 16,
HJustify = HJustify.Center,
};
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign);
}
/// <summary>
/// GF-11c (Campaign CC gate round 1 Batch B): the Town page's per-marker
/// caption shape (live-DAT-measured on 0x1000040D/0x10000409) — a
/// single stateful face child (the marker/pin, Normal/Highlight media)
/// PLUS a DISTINCT Type-12 caption child with its own authored rect and
/// Center justify, positioned independently of the marker (e.g. below
/// or above it, not necessarily beside it). Before this fix, the
/// caption's own rect/justify was discarded in favor of a Left-aligned
/// offset computed from the FACE rect — correct only for the heritage/
/// template row family below, where the label is authored directly on
/// the button itself (see the companion regression test).
/// </summary>
[Fact]
public void BuildButton_SingleFaceChild_LiftedCaptionWithOwnRect_HonorsLabelBoxNotFaceOffset()
{
uint stringId = 555u;
var info = new ElementInfo { Type = 1, Width = 106, Height = 80 };
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" };
var caption = new ElementInfo
{
Type = 12,
X = 0,
Y = 4,
Width = 100,
Height = 37,
HJustify = HJustify.Center,
};
caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
};
info.Children.Add(caption);
var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 };
marker.StateMedia["Normal"] = (0x06004D60u, 1);
marker.StateMedia["Highlight"] = (0x06004D61u, 1);
info.Children.Add(marker);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == stringId ? "Holtburg" : null));
Assert.Equal("Holtburg", button.Label);
Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign);
Assert.Equal((0f, 4f, 100f, 37f), button.LabelBox);
// The face geometry is still captured for the marker's own draw —
// just no longer used to derive the label's position.
Assert.Equal((36f, 36f, 38f, 38f), (button.FaceLeft, button.FaceTop, button.FaceWidth, button.FaceHeight));
}
/// <summary>
/// Regression companion: the heritage/template/Face-Clothes row shape —
/// the label string is authored DIRECTLY on the button (no distinct
/// Type-12 child), beside a single stateful face child (the radio dot).
/// This is the case the FACE-relative Left-aligned offset math IS
/// correct for, and it must keep working exactly as before GF-11c.
/// </summary>
[Fact]
public void BuildButton_SingleFaceChild_DirectLabel_KeepsFaceRelativeOffset()
{
uint stringId = 777u;
var info = new ElementInfo { Type = 1, Width = 305, Height = 32, HJustify = HJustify.Left };
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
};
info.States[RetailUiStateIds.Unselected] = new UiStateInfo { Id = RetailUiStateIds.Unselected, Name = "Unselected" };
info.States[RetailUiStateIds.Selected] = new UiStateInfo { Id = RetailUiStateIds.Selected, Name = "Selected" };
var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 };
dot.StateMedia["Unselected"] = (0x06006E35u, 1);
dot.StateMedia["Selected"] = (0x06006E21u, 1);
info.Children.Add(dot);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == stringId ? "Aluvian" : null));
Assert.Equal("Aluvian", button.Label);
Assert.Null(button.LabelBox);
Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign);
Assert.Equal(36f, button.LabelOffsetX); // face.X(0) + face.Width(32) + 4
}
/// <summary>
/// GF-4a (Campaign CC gate round 1 Batch C): retail's chargen display
/// buttons (live-DAT-measured shape) author their CAPTION directly as
/// their own P0x17 AND carry one SEPARATE, media-less Type-12 child for
/// the live value. <c>BuildButton</c> surfaces that child through
/// <see cref="UiButton.ValueBox"/>/<see cref="UiButton.ValueLabel"/>
/// instead of dropping it — coexisting with, not clobbering,
/// <see cref="UiButton.Label"/>.
/// </summary>
[Fact]
public void BuildButton_OwnCaptionPlusMediaLessTextChild_SurfacesValueSlotWithoutClobberingLabel()
{
uint captionStringId = 111u;
var info = new ElementInfo { Type = 1, Width = 80, Height = 20 };
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
};
// The button carries its own background media (authoredFaces stays
// empty — matches the real display buttons, which have their OWN
// frame art, not a lifted face-segment child).
info.StateMedia[""] = (0x06000001u, 1);
var valueChild = new ElementInfo { Type = 12, X = 5, Y = 2, Width = 60, Height = 16 };
info.Children.Add(valueChild);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == captionStringId ? "Attribute Credits" : null));
Assert.Equal("Attribute Credits", button.Label);
Assert.Null(button.ValueLabel); // nothing authored on the child itself
Assert.Equal((5f, 2f, 60f, 16f), button.ValueBox);
// Writing the live value (as CharacterCreationProfessionPage.SetDisplay
// does) must not touch the caption — the whole point of this fix.
button.ValueLabel = "42";
Assert.Equal("Attribute Credits", button.Label);
Assert.Equal("42", button.ValueLabel);
}
/// <summary>
/// R4-1 (Campaign CC gate round 1 re-test 3): the value child
/// (<c>0x100002f3</c>) is BASE-INHERITED across four sibling buttons of
/// DIFFERING widths — Health/Stamina/Mana at 150px share the same
/// child id/rect (local X=116) as the wider, 231px Skills credits
/// button — so the child's own authored <c>OriginalParentWidth</c>
/// (baked in at whichever button FIRST resolved it, 150) diverges from
/// the ACTUAL containing button's current width (231) for exactly the
/// wider button. Before this fix, <c>ValueBox</c> was the child's raw
/// (un-reflowed) rect regardless — this fixture reproduces that exact
/// shape (a 150px "design" width baked into the child, hosted under a
/// 231px-wide button, with the child's own Right-tracking edge modes
/// 2/1) and proves <c>UiLayoutPolicy</c>'s raw-edge reflow now shifts
/// the value box by the SAME 81px the button grew (116 -> 197),
/// landing clear of "Available Skill Credits"'s own measured span
/// instead of colliding mid-caption ("Available Skill0Credits").
/// </summary>
[Fact]
public void BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton()
{
uint captionStringId = 333u;
var info = new ElementInfo { Type = 1, Width = 231, Height = 28 };
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
};
var valueChild = new ElementInfo
{
Type = 12,
X = 116,
Y = 0,
Width = 34,
Height = 28,
Left = 2,
Top = 1,
Right = 1,
Bottom = 1,
OriginalParentWidth = 150,
OriginalParentHeight = 28,
HasOriginalParentSize = true,
HJustify = HJustify.Right,
};
info.Children.Add(valueChild);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null));
Assert.Equal("Available Skill Credits", button.Label);
// 116 + (231-150) = 197 -> width/height preserved (34/28).
Assert.Equal((197f, 0f, 34f, 28f), button.ValueBox);
Assert.Equal(UiButton.LabelAlignment.Right, button.ValueAlign);
}
/// <summary>
/// Negative companion to the reflow test above: a value child whose
/// OWN authored parent width already MATCHES the actual button (the
/// Health/Stamina/Mana shape, un-widened) reflows to its byte-identical
/// raw rect — delta is zero, so this is confirmed additive, not a
/// blanket shift.
/// </summary>
[Fact]
public void BuildButton_ValueChildOriginalParentMatchesActual_RectUnchanged()
{
uint captionStringId = 334u;
var info = new ElementInfo { Type = 1, Width = 150, Height = 28 };
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
};
var valueChild = new ElementInfo
{
Type = 12,
X = 116,
Y = 0,
Width = 34,
Height = 28,
Left = 2,
Top = 1,
Right = 1,
Bottom = 1,
OriginalParentWidth = 150,
OriginalParentHeight = 28,
HasOriginalParentSize = true,
};
info.Children.Add(valueChild);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == captionStringId ? "Health" : null));
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
}
/// <summary>
/// Negative companion: a button whose caption was LIFTED from a
/// distinct Type-12 child (the town-marker shape,
/// <c>!ReferenceEquals(labelInfo, info)</c>) must NOT pick up a
/// ValueBox even if the button happens to have another Type-12 child —
/// the gate is <c>ReferenceEquals(labelInfo, info)</c>, own-caption
/// only.
/// </summary>
[Fact]
public void BuildButton_LiftedCaption_NeverSurfacesValueSlot()
{
uint stringId = 222u;
var info = new ElementInfo { Type = 1, Width = 106, Height = 80 };
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" };
var caption = new ElementInfo { Type = 12, X = 0, Y = 4, Width = 100, Height = 37 };
caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
};
info.Children.Add(caption);
var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 };
marker.StateMedia["Normal"] = (0x06004D60u, 1);
marker.StateMedia["Highlight"] = (0x06004D61u, 1);
info.Children.Add(marker);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == stringId ? "Holtburg" : null));
Assert.Equal("Holtburg", button.Label);
Assert.Null(button.ValueBox);
Assert.Null(button.ValueLabel);
}
// ── 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);
}
/// <summary>
/// R3-4/R3-7 (Campaign CC gate round 1 re-test 2): a VERTICAL scrollbar
/// whose thumb child carries its OWN direct media (Normal/
/// Normal_rollover/Normal_pressed) and has NO slice children — the
/// exact live-DAT shape of the chargen Skills listbox scrollbar
/// (0x100003f8), Summary's OVERVIEW listbox scrollbar (0x10000401),
/// and the how-to box's own scrollbar (0x100002e7). Before this fix
/// ThumbSprite stayed 0 (the 3-slice-children search this factory was
/// originally built against — chat's scrollbar shape, see
/// <see cref="ChatLayoutConformanceTests.ChatFixture_ScrollbarImportsInheritedMediaRoles"/>
/// — found nothing to select), so the track and arrows rendered but
/// the thumb never did, regardless of overflow.
/// </summary>
[Fact]
public void Type11_VerticalScrollbar_SingleSpriteThumbWithNoSliceChildren_SetsThumbSprite()
{
const uint Thumb = 0x06005A11u;
const uint DecrementId = 0x10000071u;
const uint IncrementId = 0x10000072u;
var decrement = new ElementInfo { Id = DecrementId, Type = 1u, Y = 83f, Width = 37f, Height = 17f };
decrement.StateMedia["Normal"] = (0x06004C69u, 1);
var increment = new ElementInfo { Id = IncrementId, Type = 1u, Y = 0f, Width = 37f, Height = 17f };
increment.StateMedia["Normal"] = (0x06004C6Cu, 1);
var thumb = new ElementInfo { Id = 1u, Type = 1u, Width = 37f, Height = 39f };
thumb.StateMedia["Normal"] = (Thumb, 1);
thumb.StateMedia["Normal_rollover"] = (0x06005A12u, 1);
thumb.StateMedia["Normal_pressed"] = (0x06005A13u, 1);
thumb.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Thumb, 1) };
var info = new ElementInfo
{
Type = 11u,
Width = 37f,
Height = 307f,
Children = [decrement, increment, thumb],
};
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 };
info.States[UiStateInfo.DirectStateId] = state;
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(bar.Horizontal);
Assert.Equal(Thumb, bar.ThumbSprite);
Assert.Equal(0u, bar.ThumbTopSprite);
Assert.Equal(0u, bar.ThumbBotSprite);
}
/// <summary>
/// Negative companion: a VERTICAL thumb WITH real 3-slice children
/// (chat's own shape) is unaffected by the R3-4/R3-7 fallback — the
/// `slices.Length == 0` gate never triggers, so ThumbSprite/Top/Bot
/// come from the slice children exactly as before.
/// </summary>
[Fact]
public void Type11_VerticalScrollbar_ThumbWithSliceChildren_StillUsesSliceMedia()
{
const uint Top = 0x06004C60u;
const uint Mid = 0x06004C63u;
const uint Bot = 0x06004C66u;
var decrement = new ElementInfo { Id = 0x10000071u, Type = 1u, Y = 0f, Width = 16f, Height = 16f };
decrement.StateMedia["Normal"] = (0x06004C69u, 1);
var increment = new ElementInfo { Id = 0x10000072u, Type = 1u, Y = 32f, Width = 16f, Height = 16f };
increment.StateMedia["Normal"] = (0x06004C6Cu, 1);
var thumb = new ElementInfo { Id = 1u, Type = 1u, Width = 16f, Height = 16f };
var topCap = new ElementInfo { Id = 0x10000364u, Type = 3u, Y = 0f, Width = 16f, Height = 3f };
topCap.StateMedia["Normal"] = (Top, 1);
topCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Top, 1) };
var mid = new ElementInfo { Id = 0x10000365u, Type = 3u, Y = 3f, Width = 16f, Height = 10f };
mid.StateMedia["Normal"] = (Mid, 1);
mid.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Mid, 1) };
var botCap = new ElementInfo { Id = 0x10000366u, Type = 3u, Y = 13f, Width = 16f, Height = 3f };
botCap.StateMedia["Normal"] = (Bot, 1);
botCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Bot, 1) };
thumb.Children.Add(topCap);
thumb.Children.Add(mid);
thumb.Children.Add(botCap);
var info = new ElementInfo
{
Type = 11u,
Width = 16f,
Height = 73f,
Children = [decrement, increment, thumb],
};
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
state.Properties.Values[0x77u] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = 0x10000072u };
state.Properties.Values[0x78u] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = 0x10000071u };
info.States[UiStateInfo.DirectStateId] = state;
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(bar.Horizontal);
Assert.Equal(Top, bar.ThumbTopSprite);
Assert.Equal(Mid, bar.ThumbSprite);
Assert.Equal(Bot, bar.ThumbBotSprite);
}
[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 friends gate: a Type-12 element whose OWN states author
/// 0x17 strings (the friends row's status cell: 'Online'/'Offline' with
/// per-state colors) swaps its displayed line on TrySetRetailState —
/// retail's state-cascade text swap.
/// </summary>
[Fact]
public void BuildText_PerStateAuthoredStrings_SwapWithRetailState()
{
var info = new ElementInfo { Type = 12, Width = 270, Height = 24 };
var online = new UiStateInfo { Id = 0x10000054u, Name = "Online" };
online.Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
// (Token, StringId, TableId, ...) — StringId 2 = 'Online'.
StringInfoValue = new UiStringInfoValue(0, 2, 1, 0, 1, 0),
};
online.Properties.Values[0x1Bu] = Color(0, 255, 0);
var offline = new UiStateInfo { Id = 0x10000055u, Name = "Offline" };
offline.Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, 3, 1, 0, 1, 0),
};
info.States[0x10000054u] = online;
info.States[0x10000055u] = offline;
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: si => si.StringId == 2u ? "Online" : "Offline"));
Assert.True(text.TrySetRetailState(0x10000054u));
var line = Assert.Single(text.LinesProvider!());
Assert.Equal("Online", line.Text);
Assert.Equal(1f, line.Color.Y, 3); // authored green rides the state
Assert.True(text.TrySetRetailState(0x10000055u));
line = Assert.Single(text.LinesProvider!());
Assert.Equal("Offline", line.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),
};
// The mode state's own justification (0x14=0x3 Right — the powerbar
// modes all author it; the element default stays centered).
jumpText.Properties.Values[0x14u] = new UiPropertyValue
{
Kind = UiPropertyKind.Enum,
UnsignedValue = 0x3u,
};
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(UiMeterLabelAlign.Right, m.ActiveStateLabelAlign);
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);
}
}