acdream/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.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

1164 lines
50 KiB
C#

using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Properties;
using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Slice 5.4 — mirrors <see cref="ExternalContainerControllerTests"/>'s
/// harness shape (hand-built <see cref="ImportedLayout"/> over
/// <see cref="RetailWindowFrame.Mount"/>) for the behavioral suite, plus one
/// real-dat-fixture smoke test (mirroring
/// <c>AppraisalUiControllerTests</c>'s use of <see cref="FixtureLoader"/>)
/// that catches drift between the hardcoded ids in
/// <see cref="VendorUiController"/> and the actual LayoutDesc 0x21000012.
/// </summary>
public sealed class VendorUiControllerTests
{
private const uint VendorGuid = 0x70000010u;
private const uint ArmorItemGuid = 0x60000101u;
private const uint FoodItemGuid = 0x60000102u;
private const uint StackedItemGuid = 0x60000103u;
private sealed class TestElement : UiElement { }
[Fact]
public void Bind_FromRealDatFixture_ResolvesAllRequiredControls()
{
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",
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);
}
[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
// "(you have ...)" tail is assertable.
public const int DefaultPlayerCoinValue = 1500;
public const uint PlayerGuid = 0x50000001u;
public readonly VendorState State = new();
public readonly SelectionState Selection = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
public readonly ClientObjectTable Objects = new();
public readonly UiItemList ItemList = new();
public readonly UiScrollbar ItemScrollbar = new();
public readonly UiMenu TypeMenu = new();
public readonly UiText ItemNameText = new();
public readonly UiText ItemCostText = new();
public readonly UiText ItemsTab = new();
public readonly UiText BuyingTab = new();
public readonly UiText SellingTab = new();
public readonly UiElement ItemsPage = new TestElement();
public readonly UiElement BuyingPage = new TestElement();
public readonly UiElement SellingPage = new TestElement();
public readonly UiButton CloseButton;
public readonly UiButton BuyButton;
public readonly UiButton AddButton;
public readonly RetailWindowHandle Window;
public readonly VendorUiController Controller;
public readonly List<uint> Examines = new();
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
public readonly ItemInteractionController ItemInteraction;
public Harness()
{
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.CoinValue] = DefaultPlayerCoinValue;
Objects.UpsertProperties(PlayerGuid, bundle);
var root = new TestElement { Width = 800f, Height = 110f };
CloseButton = new UiButton(
new ElementInfo { Id = VendorUiController.CloseId, Type = 1 },
static _ => (0u, 0, 0));
BuyButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyButtonId, Type = 1 },
static _ => (0u, 0, 0));
AddButton = new UiButton(
new ElementInfo { Id = VendorUiController.AddButtonId, Type = 1 },
static _ => (0u, 0, 0));
root.AddChild(CloseButton);
root.AddChild(ItemsTab);
root.AddChild(BuyingTab);
root.AddChild(SellingTab);
root.AddChild(ItemsPage);
root.AddChild(BuyingPage);
root.AddChild(SellingPage);
ItemsPage.AddChild(ItemList);
ItemsPage.AddChild(ItemScrollbar);
ItemsPage.AddChild(TypeMenu);
ItemsPage.AddChild(ItemNameText);
ItemsPage.AddChild(ItemCostText);
ItemsPage.AddChild(BuyButton);
ItemsPage.AddChild(AddButton);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[VendorUiController.CloseId] = CloseButton,
[VendorUiController.ItemsTabId] = ItemsTab,
[VendorUiController.BuyingTabId] = BuyingTab,
[VendorUiController.SellingTabId] = SellingTab,
[VendorUiController.ItemsPageId] = ItemsPage,
[VendorUiController.BuyingPageId] = BuyingPage,
[VendorUiController.SellingPageId] = SellingPage,
[VendorUiController.ItemListId] = ItemList,
[VendorUiController.ItemScrollbarId] = ItemScrollbar,
[VendorUiController.TypeFilterMenuId] = TypeMenu,
[VendorUiController.ItemNameTextId] = ItemNameText,
[VendorUiController.ItemCostTextId] = ItemCostText,
[VendorUiController.BuyButtonId] = BuyButton,
[VendorUiController.AddButtonId] = AddButton,
});
Window = RetailWindowFrame.Mount(
Screen,
root,
static _ => (0u, 0, 0),
new RetailWindowFrame.Options
{
WindowName = WindowNames.Vendor,
Chrome = RetailWindowChrome.Imported,
Visible = false,
Draggable = false,
Resizable = false,
});
ItemInteraction = new ItemInteractionController(
Objects,
new RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)),
new InteractionState(),
playerGuid: static () => PlayerGuid,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: Examines.Add,
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
{
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
return true;
});
Controller = VendorUiController.Bind(
layout,
State,
Window,
// F5: sum every argument so a test can prove underlay/overlay/
// effects were actually forwarded (iconId-only would silently
// regress to dropping the last three).
static (_, iconId, underlay, overlay, effects) => iconId + underlay + overlay + effects,
Objects,
static () => PlayerGuid,
ItemInteraction,
Selection,
SplitQuantity,
datFont: null,
debugFont: null,
static _ => (0u, 0, 0))!;
Screen.WindowManager.AttachController(WindowNames.Vendor, Controller);
}
}
private static VendorShopProfile Profile(
float sellRate = 1.5f, uint altCurrency = 0u, string altName = "", uint altAmount = 0u) =>
new(0u, 0u, 0u, false, 1.0f, sellRate, altCurrency, altAmount, altName);
private static string GetText(UiText text)
=> string.Concat(text.LinesProvider().Select(line => line.Text));
[Fact]
public void Bind_WiresTheScrollbarToTheItemListsScrollModel()
{
var h = new Harness();
Assert.True(h.ItemScrollbar.Horizontal);
Assert.Same(h.ItemList.Scroll, h.ItemScrollbar.Model);
}
[Fact]
public void Opened_ShowsWindowAndPopulatesFirstPresentCategoryInTableOrder()
{
var h = new Harness();
var items = new[]
{
// Food is table entry #5, Armor is #1 — retail's fixed table
// order, not insertion order, decides the default selection.
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
};
h.State.Apply(VendorGuid, Profile(), items);
Assert.True(h.Window.IsVisible);
Assert.True(h.ItemsPage.Visible);
Assert.False(h.BuyingPage.Visible);
Assert.False(h.SellingPage.Visible);
Assert.Equal(1, h.ItemList.GetNumUIItems());
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
}
[Fact]
public void Closed_HidesWindowAndClearsListAndText()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
Assert.True(h.Window.IsVisible);
Assert.NotEqual(string.Empty, GetText(h.ItemNameText));
h.State.Close();
Assert.False(h.Window.IsVisible);
Assert.Equal(0, h.ItemList.GetNumUIItems());
Assert.Equal(string.Empty, GetText(h.ItemNameText));
Assert.Equal(string.Empty, GetText(h.ItemCostText));
Assert.Empty(h.TypeMenu.Items);
}
[Fact]
public void Reset_HidesWindowAndClearsContent()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.State.Reset();
Assert.False(h.Window.IsVisible);
Assert.Equal(0, h.ItemList.GetNumUIItems());
}
[Fact]
public void SelectingItem_NotInSplitExemptMask_ShowsWholeStackPriceAndFallbackPluralName()
{
// F2/F3 (Slice 5.4 review): MissileWeapon (0x100) does NOT intersect
// the 0xDC41CB0 split-exempt mask, so retail prices/names the WHOLE
// stack, not one unit — the review flagged the OLD test (asserting a
// per-unit price + bare "Arrows") as wrong.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
{
// Value=1000 is the STACK-TOTAL wire value for a 100-unit stack;
// per-unit = 1000/100 = 10. quantity = DescStackSize = 100 (not
// exempt). SellPrice = ceil(2.0*10*100 - 0.1) = 2000
// (VendorPricing.SellPrice, ShopSystem::SellPrice pc:702107).
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
// F2 (Slice 6 review): the display now reads the LIVE split state
// instead of recomputing a static seed. Production seeds this via
// SelectedObjectController (mounted on the toolbar, subscribed to
// SelectionState.Changed BEFORE VendorUiController — RetailUiRuntime
// MountToolbar() runs before MountVendor()); this harness doesn't
// mount that controller, so the test seeds SplitQuantity directly to
// stand in for it, matching the SAME not-exempt full-stack seed
// SelectedObjectController.ApplySelection would have computed.
h.SplitQuantity.Reset(100u, initialValue: 100u);
// No authored PluralName -> ACCWeenieObject::GetObjectName falls back
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
Assert.Equal(
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
}
[Fact]
public void SelectingItem_InSplitExemptMask_ShowsPerUnitPriceAndSingularName()
{
// F2/F3: Food (0x20) DOES intersect the split-exempt mask, so
// quantity is forced to 1 even though the item has a larger
// authored stack — the mask-member case the review asked for.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
{
// perUnit = 500/50 = 10. quantity forced to 1 (Food is masked).
// SellPrice = ceil(2.0*10*1 - 0.1) = 20.
new VendorShopItem(
FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 500,
DescStackSize: 50),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
// F2: exempt seed is 1 regardless of the 50-unit authored stack —
// see the split-exempt comment on the sibling test above for why
// this harness seeds SplitQuantity explicitly.
h.SplitQuantity.Reset(50u, initialValue: 1u);
Assert.Equal("Bread", GetText(h.ItemNameText));
Assert.Equal(
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
}
[Fact]
public void SelectingItem_WithAuthoredPluralName_UsesItInsteadOfSingular()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Iron Key", (uint)ItemType.Key, 50u, 100,
DescStackSize: 10, PluralName: "Iron Keys"),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
// F2: Key is not split-exempt -> full authored stack (10).
h.SplitQuantity.Reset(10u, initialValue: 10u);
Assert.Equal("10 Iron Keys", GetText(h.ItemNameText));
}
[Fact]
public void SliderChange_AfterSelection_UpdatesNameAndPriceToLiveQuantity_MatchingWhatBuyWouldCharge()
{
// F2 (Slice 6 review): retail re-runs UpdateItemsUI on every slider
// change (gmVendorUI::RecvNotice_StackSliderChanged,
// pc:203262-203278) -- the OLD implementation froze the display's
// quantity at a static per-selection seed while BuySelectedItem
// separately read the LIVE split, so a slider drag after selecting
// never updated the sentence/price and could show a DIFFERENT price
// than what Buy would actually charge. Both now go through the SAME
// ResolveBuyQuantity, proven here by asserting the displayed price
// equals the amount the subsequent Buy press actually sends.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
{
// perUnit = 1000/100 = 10 (MissileWeapon, not split-exempt).
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
// F6 auto-selects the sole item; the harness's un-mounted-toolbar
// split state starts at its class default (Value=1/Maximum=1) —
// singular display, matching a fresh, not-yet-slider-touched
// selection.
Assert.Equal("Arrows", GetText(h.ItemNameText));
Assert.Equal(
$"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
// re-selection, purely a StackSplitQuantityState.Changed event.
h.SplitQuantity.Reset(100u, initialValue: 40u);
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
Assert.Equal(
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
h.BuyButton.OnClick!.Invoke();
Assert.Equal(40, h.Buys.Single().Amount);
}
[Fact]
public void SelectingItem_WithAlternateCurrency_ShowsFullRetailCostSentence()
{
// F2/F3: the OLD implementation just appended the currency name after
// a bare number ("50 Trade Notes"). Retail's actual alt-currency
// sentence is a full two-clause sentence with a "you have" tail
// (acclient_2013_pseudo_c.txt:991314), using RAW (non-comma-grouped)
// integers — unlike the primary-currency branch.
var h = new Harness();
h.State.Apply(
VendorGuid,
Profile(sellRate: 1.0f, altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 12345u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 50),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
// Armor (not in the split-exempt mask) with DescStackSize null ->
// PerUnitValue returns Value unchanged (50); quantity = 1 (no
// authored stack) -> SellPrice(50, Armor, 1.0, 1) = ceil(50-0.1) = 50.
Assert.Equal(
"This item costs 50 Trade Notes. You have 12345 Trade Notes.",
GetText(h.ItemCostText));
}
[Fact]
public void NoSelection_DisablesBuyButton_SelectionEnablesIt()
{
var h = new Harness();
Assert.False(h.BuyButton.Enabled);
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// F4 auto-selects the sole item on open, so Buy is already enabled
// — SetState(1), pc:202784-202789.
Assert.True(h.BuyButton.Enabled);
// Closing clears the selection -> SetState(0xd), pc:202572-202577.
h.State.Close();
Assert.False(h.BuyButton.Enabled);
}
[Fact]
public void AddButton_IsPermanentlyDisabled_RegardlessOfSelection()
{
// F8 (Slice 6 review): "Add to List" has no wired OnClick at all
// (staging into the "Buying" tab is deferred, contract decision 6)
// — an enabled button that silently does nothing on click is a
// dead-affordance bug, worse than a disabled one. It must never
// enable, with or without a selection.
var h = new Harness();
Assert.False(h.AddButton.Enabled);
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// F4/F6 auto-selects the sole item on open -- Add stays disabled.
Assert.False(h.AddButton.Enabled);
h.State.Close();
Assert.False(h.AddButton.Enabled);
}
[Fact]
public void ShopItem_WithIconUnderlayOverlayEffects_ForwardsThemToResolveIcon()
{
// F5: PublicWeenieDescBody already carries these three; the review
// flagged VendorUiController hardcoding 0u/0u/0u instead of
// forwarding item.IconUnderlayId/IconOverlayId/Effects.
var h = new Harness();
var item = new VendorShopItem(
ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, IconId: 200u, Value: 500,
IconUnderlayId: 10u, IconOverlayId: 100u, Effects: 1000u);
h.State.Apply(VendorGuid, Profile(), new[] { item });
// Harness's resolveIcon sums every argument (see its comment) — this
// regresses to 200u alone if the three are dropped again.
Assert.Equal(200u + 10u + 100u + 1000u, h.ItemList.GetItem(0)!.IconTexture);
}
[Fact]
public void SelectingDifferentCategory_FiltersItemListToThatCategoryOnly()
{
var h = new Harness();
var armor = new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500);
var food = new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5);
h.State.Apply(VendorGuid, Profile(), new[] { armor, food });
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); // default: Armor (table order)
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
h.TypeMenu.OnSelect!.Invoke(foodPayload);
Assert.Equal(1, h.ItemList.GetNumUIItems());
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
}
[Fact]
public void SelectingDifferentCategory_AutoSelectsFirstFilteredItem()
{
// F4: VendorItemsUI::UpdateItemsList's tail (pc:201180-201190) —
// after a category switch the first item that passed the filter
// becomes the display selection, not a cleared blank.
var h = new Harness();
var armor = new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500);
var food = new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5);
h.State.Apply(VendorGuid, Profile(), new[] { armor, food });
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
h.TypeMenu.OnSelect!.Invoke(foodPayload);
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
Assert.True(h.ItemList.GetItem(0)!.Selected);
Assert.NotEqual(string.Empty, GetText(h.ItemNameText));
Assert.NotEqual(string.Empty, GetText(h.ItemCostText));
Assert.True(h.BuyButton.Enabled);
}
[Fact]
public void SelectingDifferentCategory_ResetsListScrollToStart()
{
// F7a: VendorItemsUI::UpdateItemsList's tail (pc:201186-201190) —
// ScrollToShow(m_shopList, 0) fires unconditionally on every rebuild.
var h = new Harness();
var armor = new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500);
var food = new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5);
h.State.Apply(VendorGuid, Profile(), new[] { armor, food });
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
h.TypeMenu.OnSelect!.Invoke(foodPayload);
Assert.Equal(0, h.ItemList.Scroll.ScrollY);
}
[Fact]
public void ItemList_FillsVisibleEmptySlots()
{
// F7b: mirrors ExternalContainerController.ConfigureList's empty-slot
// fill so the authored 22-slot strip doesn't show blank space.
var h = new Harness();
Assert.True(h.ItemList.FillVisibleEmptySlots);
Assert.NotNull(h.ItemList.EmptySlotFactory);
}
[Fact]
public void ShopRow_NeverMintsADragPayload()
{
// F3 (Slice 6 review): with shop items materialized into
// ClientObjectTable (Slice 6.1), a draggable vendor cell would pass
// every destination handler's existence guard -- a pack drop would
// send a real PutItemInContainer for vendor stock, the shortcut bar
// would persist a dangling guid, and PlaceIn3D would fire. Retail
// excludes vendor/salvage lists from ItemList_BeginDrag entirely; a
// populated shop row must never become a drag source at all.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
UiItemSlot cell = h.ItemList.GetItem(0)!;
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]
public void Opened_WithDifferentVendor_ResetsToFirstPresentCategory_NotThePreviousVendors()
{
// F6: gmVendorUI::OpenVendor flushes sub-UIs when sameVendor==0
// (pc:203664-203667) — a different vendor must not inherit whatever
// category index the LAST vendor happened to have selected.
const uint OtherVendorGuid = 0x70000020u;
const uint OtherArmorGuid = 0x60000201u;
const uint OtherFoodGuid = 0x60000202u;
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
});
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
h.TypeMenu.OnSelect!.Invoke(foodPayload);
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
// A DIFFERENT vendor, which ALSO has >=2 categories (Armor then
// Food, table order) so a stale numeric index (1) would silently
// "validate" without the fix and land on Food again by coincidence
// rather than by the vendor's own contents.
h.State.Apply(OtherVendorGuid, Profile(), new[]
{
new VendorShopItem(OtherArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
new VendorShopItem(OtherFoodGuid, -1, 5u, "Ale", (uint)ItemType.Food, 100u, 3),
});
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
Assert.Equal(OtherArmorGuid, h.ItemList.GetItem(0)!.ItemId);
}
[Fact]
public void Refreshed_SameVendor_PreservesCategorySelection()
{
// F6 counterpart: gmVendorUI::OpenVendor's sameVendor==1 path does
// NOT flush sub-UIs, so a same-vendor refresh (Slice 6 post-buy/sell)
// must keep the player's chosen category.
var h = new Harness();
var items = new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
};
h.State.Apply(VendorGuid, Profile(), items);
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
h.TypeMenu.OnSelect!.Invoke(foodPayload);
// SAME vendor id re-approaches -> VendorStateTransitionKind.Refreshed.
h.State.Apply(VendorGuid, Profile(), items);
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
}
[Fact]
public void Refreshed_SameVendor_ReselectsFirstItemUnconditionally_NotThePreviouslySelectedSurvivor()
{
// F6 (Slice 6 review): retail's VendorItemsUI::UpdateItemsList
// selects UNCONDITIONALLY on every rebuild call site this
// controller reaches (pc:201180-201184, confirmed via
// VendorItemsUI::OpenVendor's unconditional clamp +
// SetSelectedItem(...,1) at pc:201022, which every vendor
// open/refresh runs regardless of the sameVendor flag) -- there is
// NO survival test. A same-vendor refresh that re-lists the SAME
// two items in the SAME order must reset to the FIRST one even
// though the player's prior selection (the second item) is still
// present in the new snapshot -- this is the case that would have
// diverged under the OLD "preserve if it survives" logic.
const uint SecondArmorGuid = 0x60000110u;
var h = new Harness();
var items = new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(SecondArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
};
h.State.Apply(VendorGuid, Profile(), items);
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); // auto-selected first
// Player explicitly picks the SECOND item.
h.ItemList.GetItem(1)!.Clicked?.Invoke();
Assert.True(h.ItemList.GetItem(1)!.Selected);
// SAME vendor, SAME two items, SAME order -> Refreshed transition.
h.State.Apply(VendorGuid, Profile(), items);
// Retail resets to the FIRST item, not the still-present second one.
Assert.True(h.ItemList.GetItem(0)!.Selected);
Assert.False(h.ItemList.GetItem(1)!.Selected);
Assert.Equal("Chainmail", GetText(h.ItemNameText));
}
[Fact]
public void CategoryMenu_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry()
{
// F1: the popup never rendered because SpriteResolve/fonts/sprites
// were never wired, and the geometry was chat's own authored values
// instead of the vendor dropdown's. This drives the SAME UiEvent
// hit-test path UiMenuTests uses (not a direct OnSelect call), and
// reads the geometry back off the live widget instead of hardcoding
// pixel offsets tied to a specific constant set.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
});
// Wiring: without these UiMenu.OnDrawOverlay early-returns (nothing
// renders) and the button face never draws either.
Assert.NotNull(h.TypeMenu.SpriteResolve);
Assert.NotEqual(0u, h.TypeMenu.NormalSprite);
Assert.NotEqual(0u, h.TypeMenu.ItemNormalSprite);
Assert.NotEqual(0u, h.TypeMenu.ItemHighlightSprite);
// Geometry from the authored template (LayoutDesc 0x21000043's row
// element 0x10000352 and ListBox 0x10000350), NOT chat's 7/17/191.
Assert.Equal(6, h.TypeMenu.RowsPerColumn);
Assert.Equal(18f, h.TypeMenu.RowHeight);
Assert.Equal(100f, h.TypeMenu.ColumnWidth);
// Open via the real widget event path.
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5)));
// "Food" is the 2nd present category in table order (Armor, then
// Food) -> row index 1, column 0. Derive the click point from the
// widget's own live geometry (mirrors UiMenu.OnEvent's own math)
// rather than a hardcoded pixel constant.
const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness)
float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border;
const int targetRow = 1;
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
float ly = iy - outerH + border;
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)ly)));
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
}
[Fact]
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[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.CloseButton.OnClick!.Invoke();
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]
public void SellingTab_SwitchesPageButPopulatesNoSellContent()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.SellingTab.OnClick!.Invoke();
Assert.True(h.SellingPage.Visible);
Assert.False(h.ItemsPage.Visible);
Assert.False(h.BuyingPage.Visible);
// Slice 6 fence: no sell-list/price/button wiring exists at all —
// the page is exactly the authored-empty container it started as.
Assert.Empty(h.SellingPage.Children);
}
[Fact]
public void BuyingTab_SwitchesPageButPopulatesNoBuyContent()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.BuyingTab.OnClick!.Invoke();
Assert.True(h.BuyingPage.Visible);
Assert.False(h.ItemsPage.Visible);
Assert.Empty(h.BuyingPage.Children);
}
[Fact]
public void ItemsTab_ReselectedAfterVisitingOtherTabs_ShowsBrowseListAgain()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.SellingTab.OnClick!.Invoke();
h.ItemsTab.OnClick!.Invoke();
Assert.True(h.ItemsPage.Visible);
Assert.False(h.SellingPage.Visible);
Assert.Equal(1, h.ItemList.GetNumUIItems());
}
[Fact]
public void RightClickShopRow_SelectsAndRoutesThroughItemInteractionExamine()
{
// Slice 6.1: mirrors ExternalContainerControllerTests'
// RightClickLoot_selectsAndExaminesWithoutPickingUp — the shop list
// wires ExamineItemRequested the same way the container lists do,
// now that shop items are materialized into ClientObjectTable
// (AP-161 finding #2's examine gap closes here).
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
});
// Food, not the auto-selected Armor default, so the assertion below
// proves the right click itself drove the selection.
UiItemSlot? foodCell = null;
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
h.TypeMenu.OnSelect!.Invoke(foodPayload);
foodCell = h.ItemList.GetItem(0);
Assert.Equal(FoodItemGuid, foodCell!.ItemId);
foodCell.OnEvent(new UiEvent(0u, foodCell, UiEventType.RightClick));
Assert.Equal(new[] { FoodItemGuid }, h.Examines);
Assert.Equal("Bread", GetText(h.ItemNameText));
Assert.True(foodCell.Selected);
}
// ── Slice 6.3: Buy button ────────────────────────────────────────────
[Fact]
public void BuyButton_Press_NonStackedItem_BuysQuantityOne()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// F4 auto-selected the sole item; its own stack size (default/absent)
// is <=1, so StackSplitQuantityState was never seeded above 1 either
// (this harness doesn't mount SelectedObjectController, so the split
// state starts at its class default Value=1/Maximum=1 — exactly what
// production would also show for a non-stacked item).
h.BuyButton.OnClick!.Invoke();
Assert.Equal(
new[] { (VendorGuid, ArmorItemGuid, 1, 0u) },
h.Buys);
}
[Fact]
public void BuyButton_Press_StackedItem_UsesTheLiveSplitSliderQuantity()
{
// Slice 6.3: BuySingleItem (pc:201674-201681) reads the CURRENT
// slider value, not the full stack. This harness doesn't mount
// SelectedObjectController (the toolbar owns that seeding in
// production), so the test seeds SplitQuantity directly to stand in
// for "the player selected this item, then dragged the slider to
// 25" before pressing Buy.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(100u, initialValue: 25u);
h.BuyButton.OnClick!.Invoke();
Assert.Equal(25, h.Buys.Single().Amount);
}
[Fact]
public void BuyButton_Press_NonStackedItem_IgnoresAStaleSliderFromAPreviouslySelectedStackableItem()
{
// F7 (Slice 6 review): retail's BuySingleItem (pc:201674-201681)
// gates on the SELECTED item's OWN stack size FIRST -- quantity 1
// whenever it is <=1, regardless of whatever
// GenItemHolder::splitSize still holds from a PREVIOUSLY selected,
// DIFFERENT, stackable item. Without this guard,
// ResolveBuyQuantity's GetObjectSplitSize(item.ItemGuid, selected,
// stackSize) call trivially returns the live Value for ANY selected
// item (item.ItemGuid always equals the selection guid at this call
// site) -- leaking a stale quantity into a non-stack purchase.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// Simulate a stale slider left over from a PREVIOUSLY selected,
// different, stackable item -- this harness doesn't mount
// SelectedObjectController, which in production would already have
// reset the slider on selecting the (non-stack) armor; seeding it
// directly here proves the clamp doesn't depend on that reset
// having run.
h.SplitQuantity.Reset(50u, initialValue: 30u);
h.BuyButton.OnClick!.Invoke();
Assert.Equal(1, h.Buys.Single().Amount);
}
[Fact]
public void BuyButton_Press_AlternateCurrencyVendor_ForwardsTheVendorsTradeWcid()
{
var h = new Harness();
h.State.Apply(
VendorGuid,
Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 500u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.BuyButton.OnClick!.Invoke();
Assert.Equal(0x12345678u, h.Buys.Single().AlternateCurrencyId);
}
[Fact]
public void BuyButton_DisablesTheInstantAPurchaseIsInFlight_AndReenablesOnCompletion()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
Assert.True(h.BuyButton.Enabled);
h.BuyButton.OnClick!.Invoke();
// TryBuy's reservation increments BusyCount synchronously, before
// any wire response — the button must reflect that immediately,
// with no per-frame polling (ItemInteractionController.StateChanged
// drives RecomputeBuyButtonEnabled).
Assert.False(h.BuyButton.Enabled);
h.ItemInteraction.CompleteUse(0);
Assert.True(h.BuyButton.Enabled);
}
[Fact]
public void BuyButton_NoSelection_PressDoesNothing()
{
var h = new Harness();
h.BuyButton.OnClick!.Invoke();
Assert.Empty(h.Buys);
}
[Fact]
public void ReconciliationRoundTrip_MoneyCreateObjectAndApproachVendorRefresh_FlowThroughExistingMachinery()
{
// Slice 6.3: verifies the loop end-to-end with synthetic inbound
// messages, adding no new owner (research doc §C.4/§A.2 point 4):
// (1) a money property update applies to the SAME ClientObjectTable
// the vendor panel reads live for its cost text,
// (2) the purchase lands in the player's inventory via the ordinary
// CreateObject merge-upsert (ClientObjectTable.Ingest),
// (3) the vendor's post-buy ApproachVendor refresh
// (VendorStateTransitionKind.Refreshed) rebuilds the panel —
// here the bought-out item leaves the vendor's stock entirely
// (the common single-item-purchase case), so the rebuild is
// externally observable: the item leaves the list and the
// selection/buttons clear.
var h = new Harness();
const uint PurchasedItemGuid = 0x60000900u;
h.State.Apply(VendorGuid, Profile(sellRate: 1.0f), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
Assert.Equal(1, h.ItemList.GetNumUIItems());
Assert.True(h.BuyButton.Enabled);
h.BuyButton.OnClick!.Invoke();
Assert.Single(h.Buys);
Assert.False(h.BuyButton.Enabled);
// (1) Money: PrivateUpdatePropertyInt(CoinValue) — the exact path
// ObjectTableWiring's PlayerIntPropertyUpdated handler calls.
int newCoinValue = Harness.DefaultPlayerCoinValue - 500;
h.Objects.UpdateIntProperty(Harness.PlayerGuid, (uint)PropertyInt.CoinValue, newCoinValue);
// (2) CreateObject: the purchased item lands in the player's own
// inventory — the ordinary Ingest merge-upsert every CreateObject
// uses (ObjectTableWiring.ApplyEntitySpawn), landing here with the
// player as its container.
h.Objects.Ingest(new WeenieData(
Guid: PurchasedItemGuid,
Name: "Chainmail",
Type: ItemType.Armor,
WeenieClassId: 2u,
IconId: 200u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: 500,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: Harness.PlayerGuid,
WielderId: 0u,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null));
// (3) ApproachVendor refresh: sold out of the ONLY armor stack, so
// the SAME vendor's next snapshot no longer lists it.
h.State.Apply(VendorGuid, Profile(sellRate: 1.0f), System.Array.Empty<VendorShopItem>());
// (4) UseDone completes the reservation.
h.ItemInteraction.CompleteUse(0);
Assert.Equal(newCoinValue, h.Objects.Get(Harness.PlayerGuid)?.Properties.GetInt((uint)PropertyInt.CoinValue));
Assert.Equal(Harness.PlayerGuid, h.Objects.Get(PurchasedItemGuid)?.ContainerId);
Assert.Equal(0, h.ItemList.GetNumUIItems());
Assert.Equal(string.Empty, GetText(h.ItemNameText));
Assert.False(h.BuyButton.Enabled);
}
}