fix(vendor): gate-findings pass — the X button HIDES like retail, clicks return, the dropdown scrolls, pyreal suffix, staged-tab slots
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

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>
This commit is contained in:
Erik 2026-08-08 10:29:39 +02:00
parent 58c8de264e
commit 5224e43890
8 changed files with 1061 additions and 51 deletions

View file

@ -175,4 +175,192 @@ public class UiMenuTests
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
}
}