acdream/tests/AcDream.App.Tests/UI/UiMenuTests.cs
Erik 5224e43890
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(vendor): gate-findings pass — the X button HIDES like retail, clicks return, the dropdown scrolls, pyreal suffix, staged-tab slots
The user's connected gate found five issues; each fixed at the root:

G4 (the discovery): retail's vendor X button calls only SetVisible(0)
(pc:204147-204182) — the SESSION stays open and re-using the vendor
lands on the same-session refresh; the range watcher remains the sole
real close. Our port invented a full teardown on X, which is exactly
why reopening died. The Runtime fixture proves the wire dispatch was
never the problem; ACE has no already-open short-circuit.

G3 (regression from the drag-suppression fix): denying IsDragSource
also dropped press capture, so clicks fell through to window-drag.
UiItemSlot.HandlesClick now claims presses for any occupied cell
independent of drag eligibility — clickable and draggable are separate
concerns.

G5: the authored popup 0x21000043 is ONE scrollable column with a real
scrollbar subtree (live-dat scan: ListBox 0x10000350 + scrollbar
0x10000351), not a 3x6 grid. UiMenu gains an authored-driven
Scrollable mode (wheel, thumb drag, track paging, up/down buttons);
chat's menu is untouched and its ten tests prove it.

G1: retail's cost format is "%s %hsp (you have %hsp)" — the p after
each %hs is a LITERAL pyreal suffix the port swallowed as part of the
specifier. Restored.

G2: the Buying/Selling pages' authored lists (same cell template as
Items) get the empty-slot fill, presentation-only until staging.

Clean-room complete solution: 11,390 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 10:29:39 +02:00

366 lines
17 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
}
}