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( 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, }; /// Raw event Data2 (the same "ly" MouseDown/MouseMove receive) for a /// point at popup-interior-local Y — 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. private static int RawY(UiMenu menu, float iy) { float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * Border; return (int)(iy - outerH + Border); } /// Raw event Data1 ("lx") for a point at popup-interior-local X . 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(); 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); } }