acdream/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
Erik 6005c51c4d fix(ui): finish retail stack selector polish
Keep the horizontal thumb at its raw pointer position so both endpoints are reachable, honor the stack entry's authored right alignment, and preserve PublicWeenieDesc plural names from CreateObject through the object table. Port retail's singular s/es fallback when no plural was sent.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 10:22:11 +02:00

505 lines
21 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);
}
// ── 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 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);
}
// ── 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);
}
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 };
/// <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);
}
}