acdream/tests/AcDream.App.Tests/UI/UiMenuTests.cs
Erik a9b6435f55 fix #385: Options dropdowns — white centered text + size-to-content popup
User gate report (Campaign OP happy-testing round, 2026-08-13): every
Config-tab dropdown drew its text gold + left-aligned and its popup a
fixed 6 rows regardless of item count. All three were unmeasured styling
divergences — the authored data (new probe menuprobe3, live DAT) says:

- button label child 0x10000355: fontColor white, hJustify=Center
- row template 0x1000035A: fontColor white, hJustify=Center
- popup ListBox 0x10000358: edge-docked L=T=R=B=1, the authored condition
  arming retail UIElement_Menu::RecalculatePopupSize @0x0046caf0 —
  popup resizes to the ListBox's summed content height, uncapped
  (0x0046e5f4..0046e66c via ResizeScrollableArea's 0x32 broadcast)

UiMenu gains three opt-in properties (ButtonTextCentered,
ItemTextCentered, PopupSizeToContent) plus retail Open @0x0046cc42's
empty-list gate; chat + vendor keep the class defaults, so their shipped
behavior is untouched. ConfigOptionsPageController.ApplyMenuChrome wires
all four corrections for the 8 Config menus with the probe citation.

The same probe found vendor's authored popup ListBox is ALSO docked while
our vendor dropdown ships G5's fixed 6-row window — filed as #386 +
register row AD-88 (UNCLEAR: the G5 retail screenshot and the decomp
mechanism conflict) instead of silently reworking a user-gated surface.

The "resolution change resizes the window" observation from the same
report is #374's designed windowed-mode behavior (display-mode switching
is #376/#377) — no change.

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

564 lines
26 KiB
C#

using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.UI;
public class UiMenuTests
{
// PopupH = RowsPerColumn(7) * RowHeight(17) = 119; popup opens upward so top = -119.
// Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17).
// Right column needs lx >= ColumnWidth(191) + Border(5) = lx >= 196 after bevel offset,
// but the original tests used lx=200 which maps ix=195 -> col=(int)(195/191)=1. OK.
// The 14 channel items verbatim (matches ChannelItems in ChatWindowController).
private static readonly UiMenu.MenuItem[] ChannelItems =
{
new("Squelch (ignore)", (object?)null),
new("Tell to Selected", (object?)null),
new("Chat to All", (object?)ChatChannelKind.Say),
new("Tell to Fellows", (object?)ChatChannelKind.Fellowship),
new("Tell to General Chat", (object?)ChatChannelKind.General),
new("Tell to LFG Chat", (object?)ChatChannelKind.Lfg),
new("Tell to Society Chat", (object?)ChatChannelKind.Society),
new("Tell to Monarch", (object?)ChatChannelKind.Monarch),
new("Tell to Patron", (object?)ChatChannelKind.Patron),
new("Tell to Vassals", (object?)ChatChannelKind.Vassals),
new("Tell to Allegiance", (object?)ChatChannelKind.Allegiance),
new("Tell to Trade Chat", (object?)ChatChannelKind.Trade),
new("Tell to Roleplay Chat", (object?)ChatChannelKind.Roleplay),
new("Tell to Olthoi Chat", (object?)ChatChannelKind.Olthoi),
};
// Availability gate identical to ChatWindowController's EnabledProvider: the null-payload
// specials (Squelch/Tell-to-Selected) are ENABLED/white like retail; only talk-CHANNEL
// items grey when unavailable. (The widget reports any enabled pick via OnSelect; the
// controller decides whether to update Selected, so specials are inert no-ops anyway.)
private static bool ChannelAvailable(object? p)
=> p is not ChatChannelKind ch
|| ch is ChatChannelKind.Say or ChatChannelKind.General
or ChatChannelKind.Trade or ChatChannelKind.Lfg;
private UiMenu MakeMenu() => new UiMenu
{
Width = 80f, Height = 18f,
Items = ChannelItems,
Selected = (object?)ChatChannelKind.Say,
EnabledProvider = ChannelAvailable,
};
[Fact]
public void Items_HasExpected14Entries()
{
Assert.Equal(14, ChannelItems.Length);
}
[Fact]
public void Items_FirstEntry_IsSquelch_Special()
{
Assert.Equal("Squelch (ignore)", ChannelItems[0].Label);
Assert.Null(ChannelItems[0].Payload);
}
[Fact]
public void Items_LastEntry_IsOlthoi()
{
var last = ChannelItems[^1];
Assert.Equal("Tell to Olthoi Chat", last.Label);
Assert.Equal(ChatChannelKind.Olthoi, last.Payload);
}
[Fact]
public void Items_ContainAll12ChannelKinds()
{
var kinds = new HashSet<ChatChannelKind>(
ChannelItems.Where(i => i.Payload is ChatChannelKind).Select(i => (ChatChannelKind)i.Payload!));
foreach (var k in new[]
{
ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg,
ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron,
ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay,
ChatChannelKind.Society, ChatChannelKind.Olthoi,
})
Assert.Contains(k, kinds);
}
[Fact]
public void DefaultSelected_IsNull_OnBlankMenu()
{
// A freshly constructed UiMenu has no Selected by default (controller sets it).
Assert.Null(new UiMenu().Selected);
}
[Fact]
public void Select_AvailableLeftColumnItem_FiresOnSelect()
{
var menu = MakeMenu();
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
object? fired = null;
// Mirror the controller: the widget reports the pick, the controller sets Selected.
menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; };
// "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76)));
Assert.Equal(ChatChannelKind.Say, fired);
Assert.Equal(ChatChannelKind.Say, menu.Selected);
}
[Fact]
public void Select_AvailableRightColumnItem_FiresOnSelect()
{
var menu = MakeMenu();
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
object? fired = null;
// Mirror the controller: the widget reports the pick, the controller sets Selected.
menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; };
// "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34).
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42)));
Assert.Equal(ChatChannelKind.Trade, fired);
Assert.Equal(ChatChannelKind.Trade, menu.Selected);
}
[Fact]
public void Select_SpecialItem_FiresNull_LeavesSelectionUnchanged()
{
var menu = MakeMenu(); // Selected = Say
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
// Mirror the controller: only channel payloads update Selected; the null-payload
// specials are deferred no-ops that leave the active channel + highlight unchanged.
bool fired = false; object? firedPayload = "sentinel";
menu.OnSelect = p => { fired = true; firedPayload = p; if (p is ChatChannelKind) menu.Selected = p; };
// "Squelch (ignore)" is index 0 = left col, row 0 (null payload), white/enabled.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110)));
Assert.True(fired); // the pick IS reported...
Assert.Null(firedPayload); // ...with the special's null payload
Assert.Equal(ChatChannelKind.Say, menu.Selected); // ...but selection is unchanged (deferred no-op)
}
[Fact]
public void Select_UnavailableChannel_DoesNotFire()
{
var menu = MakeMenu();
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
int fired = 0;
menu.OnSelect = _ => fired++;
// "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51).
// Fellowship is unavailable by the default static gate, so the click is inert.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(0, fired);
}
[Fact]
public void EnabledProvider_Overrides_DefaultGate()
{
// Override: all items enabled (even Fellowship which is normally greyed).
var menu = new UiMenu
{
Width = 80f, Height = 18f,
Items = ChannelItems,
Selected = (object?)ChatChannelKind.Say,
EnabledProvider = _ => true,
};
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
object? fired = null;
menu.OnSelect = p => fired = p;
// With every item enabled, "Tell to Fellows" (idx 3, row 3) now fires.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(ChatChannelKind.Fellowship, fired);
}
// ── G5 (vendor gate finding): Scrollable single-column popup ──────────────
// Retail's vendor category dropdown (LayoutDesc 0x21000043) is a SCROLLABLE
// single column with a docked scrollbar, not a column-major grid — see
// VendorUiController's "G5 correction" class-doc paragraph. Chat's own popup
// (exercised by every test above, Scrollable left at its false default) is
// completely unaffected: it's a structurally different code path
// (DrawGridPopup / the grid branch of OnEvent's MouseDown handling).
private const int Border = 5; // RetailChromeSprites.Border
private static UiMenu.MenuItem[] MakeCategoryItems(int count)
=> System.Linq.Enumerable.Range(0, count)
.Select(i => new UiMenu.MenuItem($"Category {i}", (object?)i))
.ToArray();
private static UiMenu MakeScrollableMenu(int itemCount = 18) => new UiMenu
{
Width = 100f, Height = 18f,
Items = MakeCategoryItems(itemCount),
Selected = (object?)0,
Scrollable = true,
RowsPerColumn = 6,
RowHeight = 18f,
ColumnWidth = 100f,
ScrollbarWidth = 16f,
ScrollButtonExtent = 16f,
};
/// <summary>Raw event Data2 (the same "ly" MouseDown/MouseMove receive) for a
/// point at popup-interior-local Y <paramref name="iy"/> — derives the mapping
/// from the SAME public geometry properties production code reads, mirroring
/// how CategoryMenu_OpensAndSelectsThroughRealHitPath (VendorUiControllerTests)
/// derives its click point rather than hardcoding a pixel constant.</summary>
private static int RawY(UiMenu menu, float iy)
{
float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * Border;
return (int)(iy - outerH + Border);
}
/// <summary>Raw event Data1 ("lx") for a point at popup-interior-local X <paramref name="ix"/>.</summary>
private static int RawX(float ix) => (int)(ix + Border);
[Fact]
public void Scrollable_18Categories_ConfiguresScrollExtentsFromAuthoredGeometry()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
// A wheel-scroll (no actual movement, Data0=0) is enough to configure
// PopupScroll from Items.Count/RowsPerColumn/RowHeight — the same
// "configure right before use" pattern UiItemList.LayoutCells follows.
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
Assert.Equal(18 * 18, menu.PopupScroll.ContentHeight); // 18 items * 18px row height
Assert.Equal(6 * 18, menu.PopupScroll.ViewHeight); // 6 visible rows (the authored window)
Assert.True(menu.PopupScroll.HasOverflow); // 18 > 6 -> scrollbar warranted
}
[Fact]
public void Scrollable_ClickInFirstVisibleRow_SelectsItemZero_ThroughTheRealHitPath()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
object? fired = null;
menu.OnSelect = p => fired = p;
// Row 0's vertical center — same "row * RowHeight + RowHeight/2" shape
// the existing grid-mode tests already use for their row math.
int ly = RawY(menu, menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
Assert.Equal(0, fired);
}
[Fact]
public void Scrollable_ClickInScrollbarColumn_DoesNotSelectAnItem_AndKeepsThePopupOpen()
{
// Before G5's fix, a click at this X (where the OLD grid math would have
// treated it as "column 1") could have picked the WRONG item entirely —
// this X now belongs to the scrollbar, which must never fire OnSelect.
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
var fired = new List<object?>();
menu.OnSelect = p => fired.Add(p);
// Middle of the scrollbar TRACK (below the up-button, above the down-button).
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
int trackMidY = RawY(menu, menu.RowsPerColumn * menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, trackMidY)));
Assert.Empty(fired); // scrollbar click never selects an item
// The popup stayed open — a subsequent item-column click still resolves
// (against whatever row is now visible after the scrollbar's page-scroll).
int rowLy = RawY(menu, menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), rowLy)));
Assert.Single(fired);
}
[Fact]
public void Scrollable_DownButtonClick_AdvancesByOneRow_AndSubsequentClickPicksTheAdvancedItem()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
// Down-button region: the bottom ScrollButtonExtent px of the scrollbar column.
int downX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
int downY = RawY(menu, menu.RowsPerColumn * menu.RowHeight - menu.ScrollButtonExtent / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, downX, downY)));
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY); // scrolled exactly one row
object? fired = null;
menu.OnSelect = p => fired = p;
// Row 0's ON-SCREEN position now shows item index 1 (the window advanced).
int ly = RawY(menu, menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
Assert.Equal(1, fired);
}
[Fact]
public void Scrollable_UpButtonClick_ReversesAPriorDownScroll()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5));
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
int downY = RawY(menu, menu.RowsPerColumn * menu.RowHeight - menu.ScrollButtonExtent / 2f);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, downY));
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, downY));
Assert.Equal((int)(2 * menu.RowHeight), menu.PopupScroll.ScrollY);
int upY = RawY(menu, menu.ScrollButtonExtent / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, upY)));
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY);
}
[Fact]
public void Scrollable_MouseWheel_ScrollsWhilePopupIsOpen()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
// Mirrors UiItemList's own wheel convention: +Y wheel (Data0>0) scrolls up/older.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: -1)));
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY);
}
[Fact]
public void Scrollable_ThumbDrag_MovesScrollPosition_AndReleaseKeepsThePopupOpen()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
// Establish the thumb's starting rect the SAME way production drawing/
// hit-testing does: configure the model, then ask UiScrollbar's own
// shared geometry helper (the exact function DrawPopupScrollbar/
// HandleScrollablePopupMouseDown use internally).
int trackTopY = (int)menu.ScrollButtonExtent;
float trackLen = menu.RowsPerColumn * menu.RowHeight - 2 * menu.ScrollButtonExtent;
// Force PopupScroll into a known-configured state via a zero-delta wheel
// event before computing the thumb rect from it.
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
var (thumbY, thumbH) = UiScrollbar.ThumbRect(menu.PopupScroll, trackTopY, trackLen);
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
int pressY = RawY(menu, thumbY + thumbH / 2f); // press inside the thumb
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, pressY)));
// Drag most of the way down the track.
int dragToIy = (int)(trackTopY + trackLen - thumbH / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseMove, 0, scrollbarMidX, RawY(menu, dragToIy))));
Assert.True(menu.PopupScroll.ScrollY > 0);
Assert.True(menu.PopupScroll.PositionRatio > 0.5f);
// Releasing must NOT close the popup — a subsequent scrollbar/item click
// still resolves through the same OnEvent path.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseUp, 0, scrollbarMidX, RawY(menu, dragToIy))));
object? fired = null;
menu.OnSelect = p => fired = p;
int ly = RawY(menu, menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
Assert.NotNull(fired); // popup was still open -> the click landed on a real row
}
// ── Vendor dropdown polish (items 1-3): arrow indicator, popup direction,
// left alignment. See VendorUiController's "G6"/"G7"/"G8" class-doc
// paragraphs for the retail citations these three fixes are ported from.
[Fact]
public void ArrowCapSprite_FlipsBetweenClosedAndOpen_OnToggle()
{
// G6: UiMenu.CurrentArrowCapSprite projects _open onto the two
// configured sprites — the same selection DrawArrowCap makes every
// frame, exposed as a test seam since the class has no OnDraw harness.
var menu = new UiMenu
{
Width = 80f, Height = 18f,
Items = ChannelItems,
ArrowCapClosedSprite = 111u,
ArrowCapOpenSprite = 222u,
};
Assert.Equal(111u, menu.CurrentArrowCapSprite); // closed by default
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
Assert.Equal(222u, menu.CurrentArrowCapSprite);
// A click in the popup's bevel ring (below every real row, iy >= InteriorH)
// closes without picking anything — the arrow must flip straight back.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -1)));
Assert.Equal(111u, menu.CurrentArrowCapSprite);
}
[Fact]
public void ArrowCapSprite_DefaultsToZero_NoOpForMenusThatDontAuthorOne()
{
// Chat's own controller never sets ArrowCapClosedSprite/OpenSprite (its
// arrow is baked into NormalSprite/PressedSprite already) — confirm the
// class default stays a no-op id so DrawArrowCap's `id == 0` guard fires.
var menu = new UiMenu();
Assert.Equal(0u, menu.CurrentArrowCapSprite);
Assert.Equal(0u, menu.ArrowCapClosedSprite);
Assert.Equal(0u, menu.ArrowCapOpenSprite);
}
[Fact]
public void OpenUpward_DefaultsTrue_PreservingChatsExistingUpwardGeometry()
{
// Every test above (chat's own shape) relies on this default never
// changing — G7 added the property but must not move chat's popup.
Assert.True(new UiMenu().OpenUpward);
}
[Fact]
public void OpenUpward_False_MovesThePopupBelowTheButton_NotAbove()
{
var menu = new UiMenu
{
Width = 80f, Height = 18f,
Items = ChannelItems,
Selected = (object?)ChatChannelKind.Say,
EnabledProvider = ChannelAvailable,
OpenUpward = false,
};
object? fired = null;
menu.OnSelect = p => fired = p;
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
// Where "Chat to All" (index 2, Say) used to resolve when the popup
// opened UPWARD (see Select_AvailableLeftColumnItem_FiresOnSelect,
// ly=-76) is now empty space above the button. With OpenUpward=false
// this ly no longer satisfies "clicked in popup" at all, so UiMenu
// treats it as an ordinary button click and just re-closes the menu —
// it must NOT fire a selection.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76)));
Assert.Null(fired);
// Re-open and click the SAME row (index 2, "Chat to All"/Say) at its
// NEW, downward position: the popup's top is now at the button's own
// bottom edge (ly = Height), not -OuterH.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
const int border = 5; // RetailChromeSprites.Border
float iy = 2 * menu.RowHeight + menu.RowHeight / 2f;
int ly = (int)(menu.Height + iy + border);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, ly)));
Assert.Equal(ChatChannelKind.Say, fired);
}
[Fact]
public void OpenUpward_False_HitTestCoversTheButtonAndTheDownwardPopup()
{
var menu = new UiMenu { Width = 80f, Height = 18f, Items = ChannelItems, OpenUpward = false };
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
// Public surface only exposes hit-testing via the protected OnHitTest,
// so exercise it the same indirect way the rest of this file does:
// a MouseDown at a point inside the downward popup's bevel is accepted
// as "in popup" (closes without firing), proving the popup geometry
// itself now lives below Height rather than above 0.
float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * 5;
int belowPopupBottom = (int)(menu.Height + outerH) + 1; // just past the popup's own bottom edge
// Outside the popup entirely -> falls through to the button-toggle path.
object? fired = null;
menu.OnSelect = p => fired = p;
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, belowPopupBottom)));
Assert.Null(fired);
}
[Fact]
public void TextIndent_And_ButtonTextIndent_DefaultToChatsBakedIconOffsets()
{
// G8: these used to be private consts (19f/19f-checkbox, 20f-LED);
// converting them to settable properties must not move chat's text.
var menu = new UiMenu();
Assert.Equal(19f, menu.TextIndent);
Assert.Equal(20f, menu.ButtonTextIndent);
}
[Fact]
public void TextIndent_And_ButtonTextIndent_AreSettable_ForIconLessMenus()
{
// Vendor's controller sets both to 0 (no checkbox/LED art to clear) —
// confirm the widget actually accepts the override.
var menu = new UiMenu { TextIndent = 0f, ButtonTextIndent = 0f };
Assert.Equal(0f, menu.TextIndent);
Assert.Equal(0f, menu.ButtonTextIndent);
}
// ------------------------------------------------------------------
// User gate report 2026-08-13: fixed-6-row popups for short option
// lists. Retail (UIElement_Menu::RecalculatePopupSize @0x0046caf0)
// sizes the popup to the ListBox's summed content height, uncapped,
// when the authored ListBox is edge-docked — see
// UiMenu.PopupSizeToContent's own doc for the full mechanism.
// ------------------------------------------------------------------
private static UiMenu MakeScrollableMenu(int itemCount, bool sizeToContent) => new UiMenu
{
Width = 120f, Height = 18f,
Scrollable = true,
OpenUpward = false,
RowsPerColumn = 6,
RowHeight = 18f,
ColumnWidth = 100f,
PopupSizeToContent = sizeToContent,
Items = Enumerable.Range(0, itemCount)
.Select(i => new UiMenu.MenuItem($"Item {i}", (object?)i)).ToArray(),
};
[Fact]
public void SizeToContent_ShrinksThePopupToTheItemCount()
{
// 3 items: interior 3*18, plus the 5px bevel top+bottom.
Assert.Equal(3 * 18f + 10f, MakeScrollableMenu(3, sizeToContent: true).PopupOuterHeight);
// Control: without size-to-content the fixed 6-row window stands.
Assert.Equal(6 * 18f + 10f, MakeScrollableMenu(3, sizeToContent: false).PopupOuterHeight);
}
[Fact]
public void SizeToContent_GrowsPastTheFixedWindow_AndTheLastRowIsPickable()
{
// Retail's content sum is UNCAPPED — 9 items = 9 rows, no scrolling.
UiMenu menu = MakeScrollableMenu(9, sizeToContent: true);
Assert.Equal(9 * 18f + 10f, menu.PopupOuterHeight);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
Assert.True(menu.IsOpen);
object? fired = null;
menu.OnSelect = p => fired = p;
// Row 8 (the 9th item) sits past the old 6-row window: it must be
// directly pickable with NO scroll. Downward popup: interior starts
// at Height + border.
float ly = menu.Height + 5f + 8 * menu.RowHeight + menu.RowHeight / 2f;
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, (int)ly)));
Assert.Equal(8, fired);
Assert.False(menu.IsOpen); // picking closes, same as every other path
}
[Fact]
public void EmptyItems_ButtonClickDoesNotOpen()
{
// Retail UIElement_Menu::Open @0x0046cc42 gates on
// m_listBox->m_listItems.m_num != 0 — an itemless menu never opens.
UiMenu menu = MakeScrollableMenu(0, sizeToContent: true);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)));
Assert.False(menu.IsOpen);
}
[Fact]
public void TextStyleDefaults_PreserveChatAndVendorBehavior()
{
// The three 2026-08-13 additions are opt-in: chat's gold left-aligned
// caption and vendor's fixed 6-row window are untouched by default.
var menu = new UiMenu();
Assert.False(menu.ButtonTextCentered);
Assert.False(menu.ItemTextCentered);
Assert.False(menu.PopupSizeToContent);
}
}