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
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:
parent
58c8de264e
commit
5224e43890
8 changed files with 1061 additions and 51 deletions
|
|
@ -479,4 +479,48 @@ public class DragDropSpineTests
|
|||
Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
|
||||
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
|
||||
}
|
||||
|
||||
// ── G3 (vendor gate finding): occupied AllowDragSource=false cell ────────
|
||||
// Regression introduced by F3 (Slice 6 review): AllowDragSource=false makes
|
||||
// an OCCUPIED cell's IsDragSource false too, which — before the G3 fix —
|
||||
// meant UiRoot's mousedown dispatch found no reason to claim the press at
|
||||
// all (IsDragSource false, CapturesPointerDrag false, HandlesClick false)
|
||||
// and fell all the way through to the IA-12 whole-window-drag fallback,
|
||||
// exactly like an EMPTY cell. A vendor row must still capture its own
|
||||
// press/click (selection) while genuinely never minting a drag payload.
|
||||
[Fact]
|
||||
public void OccupiedNonDragSourceSlotInsideDraggableWindow_capturesClick_doesNotMoveWindow()
|
||||
{
|
||||
var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
|
||||
list.Cell.AllowDragSource = false; // vendor/salvage row shape
|
||||
bool clicked = false;
|
||||
list.Cell.Clicked = () => clicked = true;
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 310);
|
||||
root.OnMouseMove(40, 310); // would promote to drag if armed
|
||||
Assert.Null(root.DragSource); // never mints a drag payload
|
||||
Assert.Equal(10f, frame.Left); // window did NOT move
|
||||
Assert.Equal(300f, frame.Top);
|
||||
|
||||
// HandlesClick (G3) routes this cell through UiRoot's case #4
|
||||
// (CapturesPointerDrag/HandlesClick), the SAME branch a plain button
|
||||
// uses — no _dragCandidate is ever armed (unlike IsDragSource, case
|
||||
// #3), so there is no drag to distinguish an in-bounds move from: a
|
||||
// release still inside the cell's own screen rect is an ordinary
|
||||
// click regardless of the small in-cell move above, exactly like
|
||||
// HandlesClickWidget_insideDraggableWindow_stillEmitsClick.
|
||||
root.OnMouseUp(UiMouseButton.Left, 40, 310);
|
||||
Assert.True(clicked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OccupiedNonDragSourceSlotInsideDraggableWindow_hoverDoesNotShowMoveCursor()
|
||||
{
|
||||
var (root, _, list) = DraggableFrameWithSlot(0x5001u);
|
||||
list.Cell.AllowDragSource = false;
|
||||
|
||||
root.OnMouseMove(20, 310); // hover over the vendor row, no press
|
||||
|
||||
Assert.False(root.HoverWindowMove);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,82 @@ public sealed class VendorUiControllerTests
|
|||
Assert.NotNull(controller);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_FromRealDatFixture_BuyingAndSellingLists_ConfiguredForEmptySlotFill()
|
||||
{
|
||||
// G2 (vendor gate finding): the Buying/Selling pages' item strips
|
||||
// never got the empty-slot fill the Items list has, so they showed
|
||||
// the bare authored blue background instead. This proves the real
|
||||
// LayoutDesc 0x21000012 fixture's 0x100000C5 (Buying list)/0x100000CE
|
||||
// (Selling list) resolve to real UiItemList widgets and come out of
|
||||
// Bind configured identically to the Items strip (F7b) — same
|
||||
// single-row/horizontal-scroll/cell-size shape, fill enabled, a
|
||||
// non-drag-source empty-slot factory wired, and the sibling
|
||||
// scrollbar bound to the SAME list's scroll model. The lists stay
|
||||
// UNPOPULATED (no AddItem call anywhere in this path) — staging is
|
||||
// still deferred.
|
||||
ImportedLayout layout = FixtureLoader.LoadVendor();
|
||||
var screen = new UiRoot { Width = 1280f, Height = 800f };
|
||||
RetailWindowHandle window = RetailWindowFrame.Mount(
|
||||
screen,
|
||||
layout.Root,
|
||||
static _ => (0u, 0, 0),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = "vendor-fixture-smoke-2",
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Visible = false,
|
||||
});
|
||||
|
||||
var objects = new ClientObjectTable();
|
||||
using var itemInteraction = new ItemInteractionController(
|
||||
objects,
|
||||
new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
|
||||
new InteractionState(),
|
||||
playerGuid: static () => 0u,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null);
|
||||
VendorUiController? controller = VendorUiController.Bind(
|
||||
layout,
|
||||
new VendorState(),
|
||||
window,
|
||||
static (_, _, _, _, _) => 0u,
|
||||
objects,
|
||||
static () => 0u,
|
||||
itemInteraction,
|
||||
new SelectionState(),
|
||||
new StackSplitQuantityState(),
|
||||
datFont: null,
|
||||
debugFont: null,
|
||||
static _ => (0u, 0, 0));
|
||||
Assert.NotNull(controller);
|
||||
|
||||
var buyingList = Assert.IsType<UiItemList>(layout.FindElement(VendorUiController.BuyingListId));
|
||||
var sellingList = Assert.IsType<UiItemList>(layout.FindElement(VendorUiController.SellingListId));
|
||||
var buyingScrollbar = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(VendorUiController.BuyingScrollbarId));
|
||||
var sellingScrollbar = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(VendorUiController.SellingScrollbarId));
|
||||
|
||||
foreach (UiItemList list in new[] { buyingList, sellingList })
|
||||
{
|
||||
Assert.True(list.SingleRow);
|
||||
Assert.True(list.HorizontalScroll);
|
||||
Assert.Equal(32f, list.CellWidth);
|
||||
Assert.Equal(32f, list.CellHeight);
|
||||
Assert.True(list.FillVisibleEmptySlots);
|
||||
Assert.NotNull(list.EmptySlotFactory);
|
||||
Assert.Equal(0, list.GetNumUIItems()); // never populated
|
||||
}
|
||||
|
||||
Assert.Same(buyingList.Scroll, buyingScrollbar.Model);
|
||||
Assert.True(buyingScrollbar.Horizontal);
|
||||
Assert.Same(sellingList.Scroll, sellingScrollbar.Model);
|
||||
Assert.True(sellingScrollbar.Horizontal);
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
// F2/F3: a deterministic non-zero player coin total so the cost-text
|
||||
|
|
@ -310,7 +386,7 @@ public sealed class VendorUiControllerTests
|
|||
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
|
||||
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {2000:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
|
|
@ -338,7 +414,7 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
Assert.Equal("Bread", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +462,7 @@ public sealed class VendorUiControllerTests
|
|||
// selection.
|
||||
Assert.Equal("Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
GetText(h.ItemCostText));
|
||||
|
||||
// Player drags the slider to 40 AFTER selecting -- no re-click, no
|
||||
|
|
@ -396,7 +472,7 @@ public sealed class VendorUiControllerTests
|
|||
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
|
||||
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {800:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
GetText(h.ItemCostText));
|
||||
|
||||
h.BuyButton.OnClick!.Invoke();
|
||||
|
|
@ -576,6 +652,45 @@ public sealed class VendorUiControllerTests
|
|||
Assert.NotEqual(0u, cell.ItemId); // occupied -- would otherwise be a drag source by default
|
||||
Assert.False(cell.IsDragSource);
|
||||
Assert.Null(cell.GetDragPayload());
|
||||
// G3 (vendor gate finding): a row must still CAPTURE its own press
|
||||
// even though it never mints a drag payload -- see
|
||||
// UiItemSlot.HandlesClick and the real-event-path test below.
|
||||
Assert.True(cell.HandlesClick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShopRow_ClickEvent_SelectsItem_DespiteNotBeingADragSource()
|
||||
{
|
||||
// G3 (vendor gate finding): the F3 drag-suppression fix
|
||||
// (AllowDragSource=false) left occupied vendor rows with
|
||||
// IsDragSource==false -- before the G3 fix that meant UiRoot's
|
||||
// mousedown dispatch found no reason to claim the press at all, so
|
||||
// it fell through to the window-move fallback (hover showed the
|
||||
// move-window cursor; a press dragged the whole panel instead of
|
||||
// selecting a row). This drives the REAL UiItemSlot.OnEvent state
|
||||
// machine (MouseDown then Click), the same sequence UiRoot's
|
||||
// dispatch produces, rather than invoking the wired Clicked
|
||||
// delegate directly -- proving the row still completes a press then
|
||||
// click and drives selection.
|
||||
const uint SecondArmorGuid = 0x60000110u;
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
new VendorShopItem(SecondArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
|
||||
});
|
||||
Assert.Equal(ArmorItemGuid, h.Selection.SelectedObjectId); // auto-selected first
|
||||
|
||||
UiItemSlot secondCell = h.ItemList.GetItem(1)!;
|
||||
Assert.Equal(SecondArmorGuid, secondCell.ItemId);
|
||||
Assert.False(secondCell.IsDragSource); // never a drag source (F3)
|
||||
Assert.True(secondCell.HandlesClick); // but still claims its own press (G3)
|
||||
|
||||
secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.MouseDown));
|
||||
secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.Click));
|
||||
|
||||
Assert.Equal(SecondArmorGuid, h.Selection.SelectedObjectId);
|
||||
Assert.True(secondCell.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -720,8 +835,22 @@ public sealed class VendorUiControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButton_RoutesThroughVendorStateClose_NotADirectFieldWrite()
|
||||
public void CloseButton_HidesTheWindowOnly_LeavesTheSessionOpenForARefreshInPlaceReopen()
|
||||
{
|
||||
// G4 (vendor gate finding): retail's close/pushpin button —
|
||||
// gmVendorUI::HandleButtonClicks's 0x100000d6 case (pc:204147-204182)
|
||||
// — with nothing staged in the Buying/Selling lists (this port never
|
||||
// stages anything; Slice 6 territory) calls ONLY SetVisible(0),
|
||||
// never gmVendorUI::CloseVendor (pc:202080, the range-watcher-
|
||||
// unregister/session-teardown function VendorState.Close ports).
|
||||
// The OLD port called VendorState.Close() directly from this button
|
||||
// — an over-eager full teardown retail does not perform on an
|
||||
// ordinary close. RuntimeVendorRangeQuery.EnforceRange (evaluated
|
||||
// every frame regardless of window visibility) remains the sole
|
||||
// path to a full close once the player actually leaves UseRadius —
|
||||
// see Closed_HidesWindowAndClearsListAndText for that path,
|
||||
// unaffected by this change since it calls VendorState.Close()
|
||||
// directly rather than through this button.
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
|
|
@ -730,12 +859,25 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
// The panel never mutates VendorState directly — the ONLY path from
|
||||
// the close button to a cleared session is VendorState.Close()
|
||||
// itself, so VendorId reads back 0 through the owner's own public
|
||||
// surface, not a private field poke.
|
||||
Assert.Equal(0u, h.State.VendorId);
|
||||
Assert.False(h.Window.IsVisible);
|
||||
// Unlike the OLD port, the session itself is NOT torn down — the
|
||||
// owner still reports the same open vendor in the background,
|
||||
// matching retail's hidden-but-still-registered range watcher.
|
||||
Assert.Equal(VendorGuid, h.State.VendorId);
|
||||
|
||||
// Re-approaching the SAME vendor (e.g. pressing Use again while
|
||||
// still in range) now reaches retail's sameVendor==1 refresh-in-
|
||||
// place path (VendorStateTransitionKind.Refreshed) instead of a
|
||||
// from-scratch Opened, and reopens the window.
|
||||
var kinds = new List<VendorStateTransitionKind>();
|
||||
h.State.Changed += t => kinds.Add(t.Kind);
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.Equal([VendorStateTransitionKind.Refreshed], kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,136 @@ public sealed class RuntimeVendorLifecycleTests
|
|||
Assert.Equal(0, snapshot.MaterializedVendorItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondUse_AfterLocalXClose_StillDispatchesOverTheWireAndReopensOnReapproach()
|
||||
{
|
||||
// G4 (vendor gate finding, client-side half): the user reported that
|
||||
// after X-closing the vendor panel (VendorState.Close() -- a PURE
|
||||
// client-local UI teardown per research doc A.3/A.4, no wire send),
|
||||
// using the vendor again does nothing. This isolates the Runtime
|
||||
// machinery a repeat Use actually depends on --
|
||||
// RuntimeInteractionTransactionState's reservation/busy-count gate
|
||||
// and VendorState's own open/close bookkeeping -- with NO App-layer
|
||||
// world-picking in the loop (AcDream.Runtime.Tests cannot reference
|
||||
// AcDream.App). If this passes, the suppression (if real) is NOT in
|
||||
// Runtime; it would have to be in the App-layer picking/identity
|
||||
// chain (WorldSelectionQuery/SelectionInteractionController), which
|
||||
// needs its own harness to confirm or rule out.
|
||||
using GameRuntime runtime = Create();
|
||||
VendorState vendor = runtime.InventoryOwner.Vendor;
|
||||
using IDisposable wiring = Wire(vendor);
|
||||
RuntimeInteractionTransactionState transactions = runtime.ActionOwner.Transactions;
|
||||
var transport = new FakeTransport();
|
||||
const uint vendorGuid = 0x40001000u;
|
||||
|
||||
// --- First open: press Use -> dispatch -> ApproachVendor arrives ->
|
||||
// UseDone arrives (ACE's Player_Use.TryUseItem ALWAYS schedules
|
||||
// SendUseDoneEvent() after ActOnUse returns, since Vendor.ActOnUse
|
||||
// never sets LastUseTime = float.MinValue -- confirmed against
|
||||
// references/ACE/Source/ACE.Server/WorldObjects/{Vendor,Player_Use}.cs).
|
||||
ItemUseRequestReservation reservation1 =
|
||||
transactions.BeginUseRequestReservation();
|
||||
RuntimeInteractionDispatchResult result1 = transactions.TryDispatchUse(
|
||||
vendorGuid,
|
||||
ownedByPlayer: false,
|
||||
useable: true,
|
||||
reservation1,
|
||||
transport,
|
||||
out _);
|
||||
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result1);
|
||||
Assert.Equal(new[] { vendorGuid }, transport.Uses);
|
||||
Assert.Equal(1, transactions.Inventory.BusyCount);
|
||||
|
||||
Dispatch(BuildApproachVendorPayload(
|
||||
vendorGuid: vendorGuid,
|
||||
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
||||
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
||||
currencyName: "",
|
||||
items: []));
|
||||
Assert.Equal(vendorGuid, vendor.VendorId);
|
||||
|
||||
transactions.CompleteUse(0u);
|
||||
Assert.Equal(0, transactions.Inventory.BusyCount);
|
||||
|
||||
// --- X-close: the ONLY thing our client does locally.
|
||||
Assert.True(vendor.Close());
|
||||
Assert.Equal(0u, vendor.VendorId);
|
||||
|
||||
// --- Second Use attempt on the SAME vendor guid, well past the
|
||||
// 200ms retail throttle (irrelevant here since TryDispatchUse has no
|
||||
// throttle of its own -- that lives one layer up in
|
||||
// ItemInteractionController/App -- but asserted for clarity).
|
||||
ItemUseRequestReservation reservation2 =
|
||||
transactions.BeginUseRequestReservation();
|
||||
RuntimeInteractionDispatchResult result2 = transactions.TryDispatchUse(
|
||||
vendorGuid,
|
||||
ownedByPlayer: false,
|
||||
useable: true,
|
||||
reservation2,
|
||||
transport,
|
||||
out _);
|
||||
|
||||
// If this fails, RuntimeInteractionTransactionState/VendorState is
|
||||
// the suppressor. If it passes (expected, given VendorState.Close()
|
||||
// touches no interaction-transaction state and ActiveVendorId only
|
||||
// gates USING SHOP ITEMS, not the vendor NPC itself -- see
|
||||
// ItemInteractionPolicy.DecideUse's ContainerId check), the
|
||||
// suppression is NOT here.
|
||||
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result2);
|
||||
Assert.Equal(new[] { vendorGuid, vendorGuid }, transport.Uses);
|
||||
Assert.Equal(1, transactions.Inventory.BusyCount);
|
||||
|
||||
// --- Server re-approaches (mirrors Vendor.ActOnUse's UNCONDITIONAL
|
||||
// ApproachVendor -- confirmed no server-side "already open" gate
|
||||
// exists; see the G4 evidence chain in the final report). Our own
|
||||
// VendorState.Apply must reopen from a previous==0 baseline (Close()
|
||||
// already zeroed it), which VendorUiController.OnVendorChanged's
|
||||
// Opened case turns into _window.Show().
|
||||
var kinds = new List<VendorStateTransitionKind>();
|
||||
vendor.Changed += t => kinds.Add(t.Kind);
|
||||
Dispatch(BuildApproachVendorPayload(
|
||||
vendorGuid: vendorGuid,
|
||||
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
||||
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
||||
currencyName: "",
|
||||
items: []));
|
||||
|
||||
Assert.Equal(vendorGuid, vendor.VendorId);
|
||||
Assert.Equal([VendorStateTransitionKind.Opened], kinds);
|
||||
|
||||
transactions.CompleteUse(0u);
|
||||
Assert.Equal(0, transactions.Inventory.BusyCount);
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : IRuntimeInteractionTransport
|
||||
{
|
||||
private uint _sequence;
|
||||
public bool IsInWorld { get; set; } = true;
|
||||
public List<uint> Uses { get; } = [];
|
||||
|
||||
public bool TrySendUse(uint serverGuid, out uint sequence)
|
||||
{
|
||||
if (!IsInWorld)
|
||||
{
|
||||
sequence = 0u;
|
||||
return false;
|
||||
}
|
||||
sequence = ++_sequence;
|
||||
Uses.Add(serverGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TrySendPickup(
|
||||
uint itemGuid,
|
||||
uint destinationContainerId,
|
||||
int placement,
|
||||
out uint sequence)
|
||||
{
|
||||
sequence = 0u;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VendorId_IsTheLiveActiveVendorIdSeamSource()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue