acdream/tests/AcDream.App.Tests/UI/UiButtonTests.cs
Erik 73a04244e7 fix(ui): button property 0x0D was never "disabled", and it killed every Journal button
Reported symptom: Abandon, New, Record, Start, First and Last all unclickable.
That Abandon was in the list is what identified it — Abandon is deliberately
unwired, so if it behaved the same as the others the cause could not be wiring.

UiButton read authored property 0x0D as "starts disabled" (Enabled = !0x0D).
It was the one property read in that file with no citation, and it was wrong.
Every button on the Journal panel authors 0x0D, so every one built disabled:
visible, because drawing never consults Enabled, and unclickable, because
UiElement.HitTest skips disabled elements. Exactly the reported shape.

The evidence is a sweep of every installed layout (LayoutDump gained --ghosted
for it): 85 elements author 0x0D and ALL 85 author it TRUE — not one False
anywhere in the client — and no panel ever clears it, the only four
SetAttribute_Bool(.., 0xd, ..) sites in the binary being chargen appearance,
the keymap option and the barber. A flag that is only ever true, never cleared,
and sits on New, Record, Start, Delete and Reset cannot mean "dead button";
under the old reading 85 elements were permanently dead in a shipping game.

It is not a pure ghosted LOOK either, which is why this ignores it rather than
moving it to appearance: the same 85 mix live buttons with inert column headers
("Contract", "Status", "Title", "Timer", "Label", "#"), and one appearance
cannot be right for both. Registered as QJ-2 with the measurement, so the open
question is recorded rather than quietly decided.

The test that asserted the old behaviour carried no citation either — it
encoded the same assumption. It now asserts the evidenced behaviour, with a
companion test proving the state machine's own Ghosted transition still
suppresses a click: that mechanism is separate and did not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:58:03 +02:00

836 lines
34 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class UiButtonTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
private bool _clicked;
[Fact]
public void Click_InvokesOnClick()
{
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
{ OnClick = () => _clicked = true };
b.OnEvent(new UiEvent(0, null, UiEventType.Click));
Assert.True(_clicked);
}
[Fact]
public void Click_ProvidesLocalCoordinatesToPositionAwareHandler()
{
(int X, int Y) clicked = default;
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
{
OnClickAt = (x, y) => clicked = (x, y),
};
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 17, Data2: 9));
Assert.Equal((17, 9), clicked);
}
[Fact]
public void DoubleClick_IsOptInAndDisabledButtonsSwallowWithoutInvoking()
{
int activations = 0;
var button = new UiButton(
new ElementInfo { Type = 1, Width = 46, Height = 18 },
NoTex);
var doubleClick = new UiEvent(
0,
button,
UiEventType.DoubleClick);
Assert.False(button.OnEvent(doubleClick));
button.OnDoubleClick = () => activations++;
Assert.True(button.OnEvent(doubleClick));
Assert.Equal(1, activations);
button.Enabled = false;
Assert.True(button.OnEvent(doubleClick));
Assert.Equal(1, activations);
}
[Fact]
public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
{
var transitions = new List<string>();
var b = new UiButton(new ElementInfo { Type = 1, Width = 20, Height = 20 }, NoTex)
{
Width = 20,
Height = 20,
OnPressed = () => transitions.Add("pressed"),
OnReleased = () => transitions.Add("released"),
};
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 30, Data2: 30));
Assert.Equal(["pressed", "released"], transitions);
}
[Fact]
public void NotClickThrough_SoItReceivesClicks()
{
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
Assert.False(b.ClickThrough);
}
[Fact]
public void PointerTransitions_UseRetailNormalStates()
{
var b = ButtonWithStates("Normal", "Normal_rollover", "Normal_pressed");
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 50, Data2: 50));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.Equal("Normal_rollover", b.ActiveState);
}
[Fact]
public void ToggleRelease_SelectsHighlightState()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void SuppressSelfToggle_PressReleaseDoesNotFlipSelected()
{
// CH6a/b REJECT-review SHOULD-FIX 3: the chat-window 1-4 indicators
// (0x10000522-0x10000525) carry DAT property 0x0B (ToggleBehavior) =
// true — same shape as ToggleRelease_SelectsHighlightState above —
// but retail's own click dispatch has no case for their element ids
// (gmMainChatUI::ListenToElementMessage @0x004CDA80), so a click must
// NOT flip their Selected mirror. Only SetIndicatorOpen (an external
// writer) may change it.
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.SuppressSelfToggle = true;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.False(b.Selected);
Assert.Equal("Normal", b.ActiveState);
// The external mirror path still works — SuppressSelfToggle only
// blocks the self-click, not a producer's own write.
b.Selected = true;
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void Property0x0DDoesNotDisableTheButton()
{
// This test previously asserted the opposite, encoding the same
// uncited assumption the constructor made: that 0x0D means "starts
// disabled". Measured across every installed layout
// (LayoutDump --ghosted), 85 elements author 0x0D and ALL 85 author it
// TRUE — never once False — and no panel ever clears it: the only
// SetAttribute_Bool(…, 0xd, …) sites in the whole binary are chargen
// appearance, the keymap option and the barber.
//
// A flag that is only ever true, never cleared, and sits on New,
// Record, Start, Delete and Reset cannot mean "dead button". Reading
// it that way made every button on the Journal panel visible but
// unclickable, because drawing ignores Enabled while
// UiElement.HitTest skips disabled elements.
var info = ButtonInfo("Normal", "Ghosted");
AddBoolProperty(info, 0x0Du, true);
var b = CreateButton(info);
b.OnClick = () => _clicked = true;
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
Assert.True(b.Enabled);
Assert.True(_clicked);
}
[Fact]
public void AnExplicitGhostedStateStillSuppressesTheClick()
{
// The state machine's own Ghosted transition is a separate mechanism
// from the authored 0x0D property and is NOT what changed — a panel
// that deliberately ghosts a button still gets a dead button.
var info = ButtonInfo("Normal", "Ghosted");
var b = CreateButton(info);
b.OnClick = () => _clicked = true;
b.TrySetRetailState(UiButtonStateMachine.Ghosted);
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
Assert.False(b.Enabled);
Assert.False(_clicked);
}
[Fact]
public void MissingStandardState_PreservesCustomSemanticState()
{
var info = ButtonInfo("LockedUI");
info.DefaultStateName = "LockedUI";
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("LockedUI", b.ActiveState);
}
[Fact]
public void PropertyOnlyPressedState_PreservesDrawableNormalFace()
{
// #416 media-rule port: retail COMMITS the authored empty-media
// Normal_pressed (UIElement::SetState @0x00464E70 commits any
// authored state) while the FACE keeps the Normal art (the
// @0x004651c0 media rule: an empty media array never replaces the
// playing media) — the press must not blank the button.
var info = ButtonInfo("Normal", "Highlight");
info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo
{
Id = UiButtonStateMachine.NormalPressed,
Name = "Normal_pressed",
};
var b = CreateDrawableButton(info);
Assert.Equal(1u, DrawnFaceFile(b)); // ButtonInfo assigns "Normal" file 1
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
Assert.Equal(1u, DrawnFaceFile(b));
}
[Fact]
public void DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState()
{
// #382: LayoutImporter.BuildWidget's post-attach state reapply cascades a
// PARENT's PassToChildren DirectState to EVERY IUiDatStateful child (the
// chat window's indicator-button backing panel, 0x10000600, authors exactly
// this). Every button structurally carries a DirectStateId entry in its own
// States dict purely as the property bag for ToggleBehavior/RolloverEnabled/
// etc (see AddBoolProperty below) — that structural presence must NOT be
// enough to accept a DirectState transition when the button has no real ""
// media, or an ancestor's unrelated cascade blanks an already-correct
// "Normal" resolution before first paint.
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x13u, true); // RolloverEnabled — populates States[DirectStateId]
var b = CreateButton(info);
Assert.Equal("Normal", b.ActiveState);
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
Assert.False(ok);
Assert.Equal("Normal", b.ActiveState);
}
[Fact]
public void DirectStateTransition_WithRealMedia_StillSucceeds()
{
// The companion positive case: a button that legitimately authors ""
// (DirectState) media must still be able to transition to it explicitly —
// the fix narrows the check to "has real media", it does not disable the
// DirectState branch outright.
var info = ButtonInfo("Normal");
info.StateMedia[""] = (7u, 1);
var b = CreateButton(info);
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
Assert.True(ok);
Assert.Equal("", b.ActiveState);
}
[Fact]
public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick()
{
var info = ButtonInfo("Normal");
AddBoolProperty(info, 0x0Fu, true);
AddFloatProperty(info, 0x10u, 0.10f);
AddFloatProperty(info, 0x11u, 0.05f);
int clicks = 0;
var b = CreateButton(info);
b.OnClick = () => clicks++;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.00);
b.OnGlobalUiTime(1.09);
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.11);
Assert.Equal(2, clicks);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 5, Data2: 5));
Assert.Equal(2, clicks);
}
/// <summary>
/// GF-1/GF-8 (Campaign CC gate round 1 Batch B): the "gender button"
/// shape — retail's custom Unselected/Selected radio-pair media authored
/// DIRECTLY on the button's own StateMedia (no separate face-segment
/// child), live-DAT-measured on 0x100003A7/0x100003A8 (Female/Male).
/// Before this fix, .Selected committed nothing: the standard
/// AddAvailableStates loop never recognized the "Unselected"/"Selected"
/// names, so _availableStates was empty and UpdateVisualState's
/// RequestedState (which only ever returns Normal/Highlight/Ghosted ids)
/// could never match anyway.
/// </summary>
[Fact]
public void CustomSelectionPair_MediaDirectlyOnButton_SelectedTogglesActiveState()
{
var info = ButtonInfo("Unselected", "Selected");
var b = CreateButton(info);
Assert.Equal("Unselected", b.ActiveState);
b.Selected = true;
Assert.Equal("Selected", b.ActiveState);
b.Selected = false;
Assert.Equal("Unselected", b.ActiveState);
}
/// <summary>
/// The "heritage/template/sub-tab row" shape — the parent authors the
/// Unselected/Selected state DESCRIPTORS (property bag only, no media),
/// and a single stateful child (the radio dot / icon) carries the
/// actual per-state art, matching <c>FindStatefulFaceChildren</c>'s
/// name-overlap detection. Live-DAT-measured on the Heritage row
/// (0x100003BF, dot child 0x100003C0) and the Profession template row
/// (0x100003D9, icon child 0x100002E9).
/// </summary>
[Fact]
public void CustomSelectionPair_MediaOnFaceChild_SelectedTogglesActiveState()
{
var info = new ElementInfo { Type = 1, Width = 305, Height = 32 };
info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo
{
Id = UiButtonStateMachine.NormalPressed,
Name = "Normal_pressed",
};
info.States[RetailUiStateIds.Unselected] = new UiStateInfo
{
Id = RetailUiStateIds.Unselected,
Name = "Unselected",
};
info.States[RetailUiStateIds.Selected] = new UiStateInfo
{
Id = RetailUiStateIds.Selected,
Name = "Selected",
};
info.DefaultStateName = "Unselected";
var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 };
dot.StateMedia["Unselected"] = (0x06006E35u, 1);
dot.StateMedia["Selected"] = (0x06006E21u, 1);
info.Children.Add(dot);
// Face-child discovery (FindStatefulFaceChildren) is DatWidgetFactory's
// job, not UiButton's own constructor — go through the real factory
// path so this fixture matches production exactly (raw CreateButton
// below bypasses that discovery entirely).
var b = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal("Unselected", b.ActiveState);
b.Selected = true;
Assert.Equal("Selected", b.ActiveState);
Assert.Equal(RetailUiStateIds.Selected, b.ActiveRetailStateId);
b.Selected = false;
Assert.Equal("Unselected", b.ActiveState);
}
/// <summary>
/// Regression pin: a STANDARD ToggleBehavior button (no Unselected/
/// Selected states authored at all — the overwhelming majority of
/// buttons, including every pre-existing ToggleBehavior consumer) keeps
/// behaving exactly as before the custom-pair bypass was added.
/// </summary>
[Fact]
public void CustomSelectionPair_Absent_StandardToggleBehaviorUnchanged()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.Selected = true;
Assert.Equal("Highlight", b.ActiveState);
b.Selected = false;
Assert.Equal("Normal", b.ActiveState);
}
/// <summary>
/// AP-222 / GF-11b, re-derived at the #416 media-rule port: the
/// Appearance spins author a PROPERTY-ONLY Highlight StateDesc (the
/// 0x1B/0x21 label style, no media). Retail's machine gate
/// (UIElement_Button::UpdateState_ @0x00471CF0) admits any AUTHORED
/// state, and UIElement::SetState @0x00464E70 then commits it — the
/// label recolors — while the ARROW ART stays on Normal through the
/// SetState media rule (@0x004651c0: an empty media array never
/// replaces the playing media). Live-DAT-measured values: Normal
/// (218,167,85), Highlight (255,221,131), outline off -&gt; on.
/// </summary>
[Fact]
public void PerStateLabelStyle_PropertyOnlyHighlight_CommitsAndKeepsFace()
{
var info = ButtonInfo("Normal"); // Highlight authors NO media...
info.States[UiButtonStateMachine.Highlight] = new UiStateInfo
{
Id = UiButtonStateMachine.Highlight,
Name = "Highlight", // ...but IS authored (property-only)
};
AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior
var b = CreateDrawableButton(info);
b.Label = "Hair Style";
b.LabelColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f);
var colors = new Dictionary<uint, System.Numerics.Vector4>
{
[UiButtonStateMachine.Normal] = new(218f / 255f, 167f / 255f, 85f / 255f, 1f),
[UiButtonStateMachine.Highlight] = new(255f / 255f, 221f / 255f, 131f / 255f, 1f),
};
var outlines = new Dictionary<uint, bool>
{
[UiButtonStateMachine.Normal] = false,
[UiButtonStateMachine.Highlight] = true,
};
b.SetPerStateLabelStyle(colors, outlines);
Assert.Equal(colors[UiButtonStateMachine.Normal], b.LabelColor);
Assert.False(b.Outline);
Assert.Equal(1u, DrawnFaceFile(b)); // "Normal" art (file 1)
b.Selected = true;
// The authored property-only Highlight COMMITS (retail SetState) and
// the label recolors; the face keeps the Normal art (media rule).
Assert.Equal("Highlight", b.ActiveState);
Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor);
Assert.True(b.Outline);
Assert.Equal(1u, DrawnFaceFile(b));
}
/// <summary>
/// Regression pin: a button with NO per-state color map (null, the
/// overwhelming majority — every existing external post-construction
/// LabelColor assignment such as ChatWindowController's Send caption or
/// PaperdollController's Slots label) never has its LabelColor touched
/// by a state change.
/// </summary>
[Fact]
public void PerStateLabelStyle_Absent_ExternalLabelColorAssignmentSurvivesStateChanges()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
var externalColor = new System.Numerics.Vector4(1f, 0.92f, 0.72f, 1f);
b.LabelColor = externalColor;
b.Selected = true;
Assert.Equal("Highlight", b.ActiveState);
Assert.Equal(externalColor, b.LabelColor);
b.Selected = false;
Assert.Equal(externalColor, b.LabelColor);
}
// ── R2-2/R2-3 (Campaign CC gate round 1 Batch E): WrapBlockLines ────
private static float BitmapMeasure(string text) => text.Length * 8f;
/// <summary>
/// A single line that already fits its box draws with the SAME
/// centered-block geometry the pre-fix unconditional one-line math
/// produced — the fix is a strict superset for every already-working
/// button caption.
/// </summary>
[Fact]
public void WrapBlockLines_SingleLineThatFits_MatchesPriorOneLineGeometry()
{
var lines = UiButton.WrapBlockLines(
"Health", BitmapMeasure, lineHeight: 24f,
boxX: 0f, boxY: 0f, boxWidth: 150f, boxHeight: 50f,
UiButton.LabelAlignment.Left, leftOffset: 3f);
Assert.Single(lines);
Assert.Equal("Health", lines[0].Text);
Assert.Equal(3f, lines[0].X); // boxX + leftOffset
Assert.Equal((50f - 24f) * 0.5f, lines[0].Y); // vertically centered, one line
}
/// <summary>
/// R2-2: an authored newline (already normalized to a real '\n' by
/// DatWidgetFactory's ResolveAuthoredString) splits into stacked lines
/// even when EACH half individually fits the box — "Attribute\nCredits"
/// must become two lines, not one literal run.
/// </summary>
[Fact]
public void WrapBlockLines_EmbeddedNewline_ProducesTwoStackedLines()
{
var lines = UiButton.WrapBlockLines(
"Attribute\nCredits", BitmapMeasure, lineHeight: 24f,
boxX: 0f, boxY: 0f, boxWidth: 90f, boxHeight: 50f,
UiButton.LabelAlignment.Left, leftOffset: 3f);
Assert.Equal(2, lines.Count);
Assert.Equal("Attribute", lines[0].Text);
Assert.Equal("Credits", lines[1].Text);
// Block-centered: total height 48 in a 50-tall box -> start Y = 1.
Assert.Equal(1f, lines[0].Y);
Assert.Equal(25f, lines[1].Y); // startY + 1*lineHeight
}
/// <summary>
/// R3-2 (re-test 2 correction, supersedes the retired Batch E
/// "WordWrapsToFitAvailableWidth" expectation): a single-paragraph
/// caption with NO authored newline stays ONE line even when it
/// overflows the available width — the exact live-DAT shape of the
/// Skills credits button's own "Available Skill Credits" caption
/// (measured 193px, live-DAT-probed against the button's own FULL
/// 231px width, which it fits comfortably — the 113px figure in the
/// old test was the WRONG width in the first place, since retail never
/// confines a caption's wrap width to a sibling value element's rect;
/// see <see cref="UiButton.DrawBlockLabel"/>'s own doc for the
/// GlyphList::Recalculate citation). Even forced into an artificially
/// narrow box (as here), the caption must NOT wrap — retail's
/// UIElement_Button captions only ever split on an authored <c>\n</c>.
/// </summary>
[Fact]
public void WrapBlockLines_LongSingleParagraph_NeverWordWraps()
{
var lines = UiButton.WrapBlockLines(
"Available Skill Credits", BitmapMeasure, lineHeight: 24f,
boxX: 0f, boxY: 0f, boxWidth: 113f, boxHeight: 28f,
UiButton.LabelAlignment.Left, leftOffset: 3f);
Assert.Single(lines);
Assert.Equal("Available Skill Credits", lines[0].Text);
}
/// <summary>
/// R3-1 (re-test 2): the Profession/chargen attribute slider name label
/// (element <c>0x100002ed</c>, e.g. "Coordination") authors <c>OneLine=
/// true</c> and a 115px-wide box — live-DAT-measured against the real
/// dat font, the caption itself is 113px wide, just 1px narrower than
/// the raw box but 1px WIDER than the box minus the class's own default
/// 3px <c>LabelOffsetX</c> (112px) — exactly the boundary the retired
/// Batch E width-check would have tripped on, wrapping a single WORD
/// (no space to break at) into a garbled two-line split. Pins that this
/// no longer happens for any box/text combination, narrow or not.
/// </summary>
[Fact]
public void WrapBlockLines_SingleWordNarrowerThanBoxButWiderThanOffsetAdjustedWidth_StaysOneLine()
{
var lines = UiButton.WrapBlockLines(
"Coordination", BitmapMeasure, lineHeight: 24f,
boxX: 0f, boxY: 0f, boxWidth: 115f, boxHeight: 24f,
UiButton.LabelAlignment.Left, leftOffset: 3f);
Assert.Single(lines);
Assert.Equal("Coordination", lines[0].Text);
}
/// <summary>
/// The ValueBox-vs-Label confinement math itself, exercised through
/// OnDraw's own computation: a button with BOTH Label and a coexisting
/// ValueBox still shrinks the caption's OWN boxWidth to stop before the
/// value box starts. As of R3-2 (re-test 2) this confined width no
/// longer changes whether or how the caption draws — a single
/// (unwrapped) line is never clipped to it (see
/// <see cref="UiButton.DrawBlockLabel"/>'s own doc) — so this test only
/// pins that the computation itself is unchanged, not that it gates
/// any rendering decision.
/// </summary>
[Fact]
public void BuildButton_OwnCaptionWithCoexistingValueBox_ConfinesLabelWidthBeforeValueBox()
{
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),
};
info.StateMedia[""] = (0x06000001u, 1);
var valueChild = new ElementInfo { Type = 12, X = 116, Y = 0, Width = 34, Height = 28 };
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);
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
// The caption's own available width for WrapBlockLines is bounded by
// ValueBox.X (116), NOT the button's full Width (231) — reproducing
// OnDraw's own confinement math here (private OnDraw isn't directly
// callable, so this pins the INPUT the fix computes for it).
float confinedWidth = System.MathF.Min(button.Width, button.ValueBox!.Value.X - 0f);
Assert.Equal(116f, confinedWidth);
Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button");
}
// ── R2-2 authored caption (source-decoded) ───────────────────────────
/// <summary>
/// R2-2's successor contract (2026-08-17 systemic round): the DAT's
/// LITERAL two-character escape "\n" (0x5C 0x6E — the Profession
/// credits button's own authored caption is exactly this shape) decodes
/// at the string SOURCE (DatStringResolver → RetailStringEscapes,
/// retail's own placement), so the resolver seam hands BuildButton a
/// caption with a REAL line break — and the factory passes it through
/// verbatim, with no second decode that would corrupt an authored
/// backslash pair.
/// </summary>
[Fact]
public void BuildButton_OwnCaption_PassesSourceDecodedTextThrough()
{
uint stringId = 444u;
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
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),
};
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
// The resolver seam models DatStringResolver's post-decode
// output: a REAL '\n', plus a literal backslash pair that a
// stray second decode would corrupt into a line break.
stringResolve: value => value.StringId == stringId
? "Attribute\n Credits \\not-an-escape"
: null));
Assert.Equal("Attribute\n Credits \\not-an-escape", button.Label);
}
private static UiButton ButtonWithStates(params string[] states)
{
var info = ButtonInfo(states);
AddBoolProperty(info, 0x13u, true);
return CreateButton(info);
}
// ── 2026-08-17 morning gate finding 3: the PassToChildren cascade +
// per-state P0x3B (Invisible) honor. Fixture mirrors the live map
// town-hotspot template (0x100001F0 in 0x21000026, raw-DAT-dumped):
// a media-less RolloverEnabled button whose Normal/Normal_rollover
// descriptors are PassToChildren and whose highlight child authors
// Normal={0x3B:true} / Normal_rollover={0x3B:false}. ──
private static ElementInfo MapNoteShapedInfo(bool passToChildren = true)
{
var info = new ElementInfo { Type = 1, Width = 10, Height = 10 };
AddBoolProperty(info, 0x13u, true); // P0x13 RolloverEnabled
info.States[UiButtonStateMachine.Normal] = new UiStateInfo
{
Id = UiButtonStateMachine.Normal,
Name = "Normal",
PassToChildren = passToChildren,
};
info.States[UiButtonStateMachine.NormalRollover] = new UiStateInfo
{
Id = UiButtonStateMachine.NormalRollover,
Name = "Normal_rollover",
PassToChildren = passToChildren,
};
return info;
}
private static UiDatElement HighlightChild()
{
var info = new ElementInfo { Type = 3, Width = 10, Height = 10 };
var normal = new UiStateInfo { Id = 1, Name = "Normal" };
normal.Properties.Values[0x3Bu] = new UiPropertyValue
{ Kind = UiPropertyKind.Bool, BoolValue = true };
var rollover = new UiStateInfo { Id = 2, Name = "Normal_rollover" };
rollover.Properties.Values[0x3Bu] = new UiPropertyValue
{ Kind = UiPropertyKind.Bool, BoolValue = false };
info.States[1] = normal;
info.States[2] = rollover;
return new UiDatElement(info, NoTex);
}
[Fact]
public void PassToChildrenStates_CascadeToStatefulChildren_DrivingPerStateInvisible()
{
// Retail UIElement::SetState @0x00464E70's PassToChildren cascade +
// OnSetAttribute @0x00462d80 case 8 (P0x3B -> SetVisible(value==0)):
// the map marker's green rollover frame is hidden at rest and shown
// only while the pointer is over the button.
var b = CreateButton(MapNoteShapedInfo());
UiDatElement kid = HighlightChild();
b.AddChild(kid);
Assert.True(kid.Visible); // pre-cascade construction default
// The initial Normal application (retail's own initial UpdateState_)
// hides the highlight.
Assert.True(b.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.False(kid.Visible);
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
Assert.True(kid.Visible); // Normal_rollover: P0x3B=false -> shown
b.OnEvent(new UiEvent(0, b, UiEventType.HoverLeave));
Assert.False(kid.Visible); // back to Normal: P0x3B=true -> hidden
}
[Fact]
public void StatesWithoutPassToChildren_DoNotCascade()
{
var b = CreateButton(MapNoteShapedInfo(passToChildren: false));
UiDatElement kid = HighlightChild();
b.AddChild(kid);
b.TrySetRetailState(UiButtonStateMachine.Normal);
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
Assert.True(kid.Visible); // never cascaded, never hidden/shown
}
[Fact]
public void UiDatElement_TrySetRetailState_HonorsPerStateInvisible()
{
// The child half in isolation — the state-transition application of
// dat property 0x3B, distinct from ElementReader.Invisible's
// construction-time effective read (GF-13/AP-230).
UiDatElement kid = HighlightChild();
Assert.True(kid.Visible);
Assert.True(kid.TrySetRetailState(1u));
Assert.False(kid.Visible);
Assert.True(kid.TrySetRetailState(2u));
Assert.True(kid.Visible);
}
private static ElementInfo ButtonInfo(params string[] states)
{
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
uint file = 1;
foreach (string state in states)
info.StateMedia[state] = (file++, 1);
if (states.Length > 0)
info.DefaultStateName = states[0];
return info;
}
private static void AddBoolProperty(ElementInfo info, uint id, bool value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = value,
};
}
private static void AddFloatProperty(ElementInfo info, uint id, float value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Float,
FloatValue = value,
};
}
private static UiButton CreateButton(ElementInfo info)
=> new(info, NoTex) { Width = info.Width, Height = info.Height };
/// <summary>
/// #420 regression. A multi-segment face whose committed state authors no
/// media used to leave that segment's media-state name NULL (the array
/// started as <c>new string[n]</c> and NextMediaState returns the previous
/// value unchanged on that arm), and the null then reached
/// <c>ElementInfo.StateMedia.TryGetValue</c>, throwing
/// ArgumentNullException from inside OnDraw. Live symptom: the client
/// crashed on the character-select screen on every launch.
///
/// <para>The assertion is secondary — the point is that drawing COMPLETES.
/// Before the fix this test throws instead of failing.</para>
/// </summary>
[Fact]
public void MultiSegmentFace_CommittedStateWithoutMedia_DrawsInsteadOfThrowing()
{
var info = new ElementInfo { Type = 1, Width = 32, Height = 16 };
var button = new UiButton(
info,
static file => (file, 8, 8),
mediaInfo: null,
faceSegments: [new ElementInfo { Type = 1, Width = 16, Height = 16 }])
{
Width = info.Width,
Height = info.Height,
};
Assert.Equal(0u, DrawnFaceFile(button));
}
// ── #416 media-rule draw harness ─────────────────────────────────────
private sealed class NullGpuFrameSource
: AcDream.App.Rendering.ICurrentGpuFrameSource
{
public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
}
/// <summary>Identity resolve (texture handle == file id) so the drawn
/// face file is directly observable through the recording renderer.</summary>
private static UiButton CreateDrawableButton(ElementInfo info)
=> new(info, static file => (file, 8, 8))
{
Width = info.Width,
Height = info.Height,
};
private static uint DrawnFaceFile(UiButton button)
{
var device = new AcDream.App.Tests.Rendering.Gpu.RecordingGpuDevice();
var renderer = new AcDream.App.Rendering.TextRenderer(
device, new NullGpuFrameSource(), "unused");
renderer.Begin(new System.Numerics.Vector2(200f, 200f));
var ctx = new UiRenderContext(
renderer, new System.Numerics.Vector2(200f, 200f));
button.DrawSelfAndChildren(ctx);
return renderer.DebugSpriteSegmentVerts.Count == 0
? 0u
: renderer.DebugSpriteSegmentVerts[0].Item1;
}
}