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>
564 lines
26 KiB
C#
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);
|
|
}
|
|
}
|