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(); 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 DisabledProperty_SelectsGhostedAndSuppressesClick() { 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.False(b.Enabled); Assert.Equal("Ghosted", b.ActiveState); 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() { var info = ButtonInfo("Normal", "Highlight"); info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo { Id = UiButtonStateMachine.NormalPressed, Name = "Normal_pressed", }; var b = CreateButton(info); b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5)); Assert.Equal("Normal", b.ActiveState); } [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); } /// /// 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. /// [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); } /// /// 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 FindStatefulFaceChildren's /// name-overlap detection. Live-DAT-measured on the Heritage row /// (0x100003BF, dot child 0x100003C0) and the Profession template row /// (0x100003D9, icon child 0x100002E9). /// [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(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); } /// /// 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. /// [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); } /// /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label /// color/outline reacts to the REQUESTED retail state id even when the /// standard art-availability gate never lets ActiveState reach it — the /// Appearance spins' exact shape (their arrow face segments carry no /// Highlight media at all, so ActiveState is permanently stuck at /// "Normal", but the label text must still recolor). Live-DAT-measured /// values: Normal (218,167,85), Highlight (255,221,131), outline /// off -> on. /// [Fact] public void PerStateLabelStyle_AppliesEvenWhenActiveStateCannotReachIt() { var info = ButtonInfo("Normal"); // no Highlight media at all AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior var b = CreateButton(info); b.Label = "Hair Style"; b.LabelColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f); var colors = new Dictionary { [UiButtonStateMachine.Normal] = new(218f / 255f, 167f / 255f, 85f / 255f, 1f), [UiButtonStateMachine.Highlight] = new(255f / 255f, 221f / 255f, 131f / 255f, 1f), }; var outlines = new Dictionary { [UiButtonStateMachine.Normal] = false, [UiButtonStateMachine.Highlight] = true, }; b.SetPerStateLabelStyle(colors, outlines); Assert.Equal(colors[UiButtonStateMachine.Normal], b.LabelColor); Assert.False(b.Outline); b.Selected = true; // The art stays "Normal" (no Highlight media exists to commit to) — // this is the exact AP-222 no-op the standard gate always produced — // but the label color/outline must still reach the Highlight values. Assert.Equal("Normal", b.ActiveState); Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor); Assert.True(b.Outline); } /// /// 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. /// [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; /// /// 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. /// [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 } /// /// 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. /// [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 } /// /// 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 '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 \n. /// [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); } /// /// R3-1 (re-test 2): the Profession/chargen attribute slider name label /// (element 0x100002ed, e.g. "Coordination") authors OneLine= /// true 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 LabelOffsetX (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. /// [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); } /// /// 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 /// 's own doc) — so this test only /// pins that the computation itself is unchanged, not that it gates /// any rendering decision. /// [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(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 escape-normalize ──────────────────────────────────────────── /// /// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way /// BuildText's authored-string path always has — the DAT stores the /// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession /// credits button's own authored caption is exactly this shape. /// [Fact] public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape() { 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(DatWidgetFactory.Create( info, NoTex, null, // The raw resolved string carries the LITERAL two characters // '\' and 'n', matching what the installed DAT actually stores. stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null)); Assert.Equal("Attribute\n Credits", button.Label); } private static UiButton ButtonWithStates(params string[] states) { var info = ButtonInfo(states); AddBoolProperty(info, 0x13u, true); return CreateButton(info); } 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 }; }