acdream/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
Erik 6a15dd063c
All checks were successful
CI / linux-portable (push) Successful in 3m13s
CI / windows-gate (push) Successful in 5m15s
CI / release (push) Successful in 2m2s
fix: retail text and golden-string tests must not follow the machine's locale
Run 170's Windows gate went red on 37 tests across four assemblies while the
same commit passed 14,370/0 locally. The failures were all one family:

  Expected: "You have 1 500p"     <- built with the machine's culture
  Actual:   "You have 1,500p"     <- production, correctly invariant

The runner is Swedish; this dev box is not. These tests had been passing on CI
only because that machine's registry locale had been pinned by hand — machine
state, which came undone (almost certainly the reboot after today's hang).
Re-pinning it would be a workaround on one machine for a defect in the repo,
so this fixes the repo instead.

Two genuinely different bugs were hiding in that one symptom.

1. TESTS that build an expected string with the ambient culture and compare it
   to invariant production output, and test-side recording sinks whose traces
   are compared against literal golden strings. Those only ever passed on a
   machine that happens to format like the invariant culture. Pinned to
   InvariantCulture: the vendor purse/cost expectations, and the motion-funnel,
   animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack
   trace sinks.

2. PRODUCTION that formats player-visible retail text with the ambient culture.
   This one matters beyond CI: retail is a US client, so it shows "2.50",
   "1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing
   "2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this
   alpha is literally Swedish. Converted 76 sites to InvariantCulture across the
   item/creature appraisal formatters, the character stat panel's buff and vitae
   parentheticals, the appraisal and link-status controllers, the chat
   /framerate and /location output, the camera sensitivity toast, the
   time-override toast, the F3 dump, the sky diagnostics, and the world-frame
   invariant-failure message.

   DATES are deliberately left on the current culture (CharacterController's
   birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer
   for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on
   them is a UX decision, not a retail-fidelity one.

Apparatus, so the next occurrence is reproducible instead of mysterious:
tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to
every test assembly, linked in through a new tests/Directory.Build.props.
Unset — what CI and everyone runs — it changes nothing.

  ACDREAM_TEST_CULTURE=sv-SE dotnet test ...

reproduced all 37 CI failures on this machine plus 6 more the runner's own
locale does not surface (the Unicode-minus family), and drove the fix.

Verified both ways on the full solution under the release-gate filter:
default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE
14,370 passed / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:56:09 +02:00

2657 lines
116 KiB
C#

using System.Globalization;
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;
// Slice 6b: a second same-category shop item, so two DIFFERENT items
// both appear in the SAME category-filtered Items list simultaneously
// (Armor and Food are different table entries and can't both show at
// once without touching the category dropdown).
private const uint AnotherArmorItemGuid = 0x60000104u;
// Slice 6c: player-OWNED pack items (never vendor stock) dragged onto
// the Selling tab.
private const uint PlayerOwnedArmorGuid = 0x60000201u;
private const uint PlayerOwnedWeaponGuid = 0x60000202u;
private const uint PlayerOwnedArmorGuid2 = 0x60000205u;
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;
// Slice 6b: "Buying" tab staging widgets.
public readonly UiItemList BuyingList = new();
public readonly UiButton BuyItemButton;
public readonly UiButton BuyAllButton;
public readonly UiButton BuyClearItemButton;
public readonly UiButton BuyClearListButton;
// R3: the Buying/Selling tabs' own staged summary text.
public readonly UiText BuyListText = new();
public readonly UiText BuyPurseText = new();
public readonly UiText SellListText = new();
public readonly UiText SellPurseText = new();
// Slice 6c: "Selling" tab staging widgets.
public readonly UiItemList SellingList = new();
public readonly UiButton SellItemButton;
public readonly UiButton SellAllButton;
public readonly UiButton SellClearItemButton;
public readonly UiButton SellClearListButton;
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 List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
public readonly List<string> SystemMessages = new();
public readonly ItemInteractionController ItemInteraction;
public readonly RetailDialogFactory Dialogs;
public ImportedLayout? ShownDialog;
public Harness()
{
// F1 (Slice 6b/6c review): a realistic player pack shape (102
// main-pack item slots, 7 side-pack/container slots — the
// conventional retail AC main-pack capacity) so the NEW Buy All
// capacity pre-checks have real room to work with; a stub
// ClientObject's default ItemsCapacity/ContainersCapacity=0
// would reject every Buy All. AddOrUpdate FIRST, then
// UpsertProperties SECOND — UpsertProperties mutates an
// EXISTING entry in place rather than replacing it wholesale.
Objects.AddOrUpdate(new ClientObject
{
ObjectId = PlayerGuid,
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
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));
BuyItemButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyItemButtonId, Type = 1 },
static _ => (0u, 0, 0));
BuyAllButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyAllButtonId, Type = 1 },
static _ => (0u, 0, 0));
BuyClearItemButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyClearItemButtonId, Type = 1 },
static _ => (0u, 0, 0));
BuyClearListButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyClearListButtonId, Type = 1 },
static _ => (0u, 0, 0));
SellItemButton = new UiButton(
new ElementInfo { Id = VendorUiController.SellItemButtonId, Type = 1 },
static _ => (0u, 0, 0));
SellAllButton = new UiButton(
new ElementInfo { Id = VendorUiController.SellAllButtonId, Type = 1 },
static _ => (0u, 0, 0));
SellClearItemButton = new UiButton(
new ElementInfo { Id = VendorUiController.SellClearItemButtonId, Type = 1 },
static _ => (0u, 0, 0));
SellClearListButton = new UiButton(
new ElementInfo { Id = VendorUiController.SellClearListButtonId, 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);
BuyingPage.AddChild(BuyingList);
BuyingPage.AddChild(BuyItemButton);
BuyingPage.AddChild(BuyAllButton);
BuyingPage.AddChild(BuyClearItemButton);
BuyingPage.AddChild(BuyClearListButton);
BuyingPage.AddChild(BuyListText);
BuyingPage.AddChild(BuyPurseText);
SellingPage.AddChild(SellingList);
SellingPage.AddChild(SellItemButton);
SellingPage.AddChild(SellAllButton);
SellingPage.AddChild(SellClearItemButton);
SellingPage.AddChild(SellClearListButton);
SellingPage.AddChild(SellListText);
SellingPage.AddChild(SellPurseText);
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,
[VendorUiController.BuyingListId] = BuyingList,
[VendorUiController.BuyItemButtonId] = BuyItemButton,
[VendorUiController.BuyAllButtonId] = BuyAllButton,
[VendorUiController.BuyClearItemButtonId] = BuyClearItemButton,
[VendorUiController.BuyClearListButtonId] = BuyClearListButton,
[VendorUiController.SellingListId] = SellingList,
[VendorUiController.SellItemButtonId] = SellItemButton,
[VendorUiController.SellAllButtonId] = SellAllButton,
[VendorUiController.SellClearItemButtonId] = SellClearItemButton,
[VendorUiController.SellClearListButtonId] = SellClearListButton,
[VendorUiController.BuyingListTextId] = BuyListText,
[VendorUiController.BuyingPurseTextId] = BuyPurseText,
[VendorUiController.SellingListTextId] = SellListText,
[VendorUiController.SellingPurseTextId] = SellPurseText,
});
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;
},
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
{
BuyAlls.Add((vendorGuid, items, alternateCurrencyId));
return true;
},
sendSell: (vendorGuid, items) =>
{
Sells.Add((vendorGuid, items));
return true;
});
Dialogs = new RetailDialogFactory(Screen, _ =>
ShownDialog = FixtureLoader.LoadConfirmationDialog());
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),
dialogs: Dialogs,
systemMessage: SystemMessages.Add)!;
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);
/// <summary>
/// Slice 6c: a profile shaped for <see cref="VendorSellAcceptability"/>
/// coverage — <see cref="Profile"/>'s all-zero merchandise fields would
/// reject every real item (MaxValue=0 rejects anything with value &gt; 0).
/// </summary>
private static VendorShopProfile SellProfile(
uint merchandiseItemTypes,
uint minValue = 0u,
uint maxValue = VendorSellAcceptability.NoLimit) =>
new(merchandiseItemTypes, minValue, maxValue, false, 1.0f, 1.5f, 0u, 0u, "");
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(
string.Create(
CultureInfo.InvariantCulture,
$"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(
string.Create(
CultureInfo.InvariantCulture,
$"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(
string.Create(
CultureInfo.InvariantCulture,
$"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(
string.Create(
CultureInfo.InvariantCulture,
$"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_EnablesWithSelection_NowThatStagingIsWired()
{
// Slice 6b (AP-161 F8 residual closes): "Add to List" now stages
// into the "Buying" tab and enables with selection exactly like Buy
// — an enabled Add is no longer a dead affordance.
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 now enables too.
Assert.True(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.
//
// G7: the popup opens DOWNWARD (retail authors no bool attribute 5
// on 0x100000BF — see the class doc's "G7" paragraph), so its top
// sits at the button's own bottom edge (ly = Height), not -OuterH.
const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness)
const int targetRow = 1;
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
float ly = h.TypeMenu.Height + iy + 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 CategoryMenu_OpensDownward_NotUpward_MatchingTheAuthoredAbsentAttribute5()
{
// G7: retail's UIElement_Menu::Open (pc:120210-120252) reads a PER-MENU
// bool attribute 5 to decide direction; it defaults ABSENT to false
// (pc:106749-106778) which places the popup at the button's own bottom
// edge. Element 0x100000BF's resolved attribute bag carries no
// property "5" at all (unlike chat's, which authors it true) — so
// OpenUpward must be false here, and the OLD upward click position
// (used before this fix) must no longer resolve anything.
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),
});
Assert.False(h.TypeMenu.OpenUpward);
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5))); // open
// The OLD (pre-fix) upward click position for row 1 ("Food") — see the
// math this same test used before G7 — now lands above the button,
// where nothing lives; UiMenu treats it as an ordinary button click
// and just re-closes the still-open menu instead of picking a row.
const int border = 5;
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 oldUpwardLy = iy - outerH + border;
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)oldUpwardLy)));
// Selection is unchanged (still Armor, the fresh-open default) and the
// item list was not re-scoped to Food — proving the old position no
// longer hits the popup at all.
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
}
[Fact]
public void CategoryMenu_ArrowCapSprites_AreWiredAndDistinctForOpenVsClosed()
{
// G6: retail's authored arrow-cap element 0x1000034E flips between its
// Normal (closed, 0x060012B1) and Highlight (open, 0x060012B2) states —
// confirm the controller actually wires both, non-zero and distinct,
// and that opening the dropdown flips CurrentArrowCapSprite the same
// way UiMenuTests exercises the mechanism generically.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
Assert.NotEqual(0u, h.TypeMenu.ArrowCapClosedSprite);
Assert.NotEqual(0u, h.TypeMenu.ArrowCapOpenSprite);
Assert.NotEqual(h.TypeMenu.ArrowCapClosedSprite, h.TypeMenu.ArrowCapOpenSprite);
Assert.Equal(h.TypeMenu.ArrowCapClosedSprite, h.TypeMenu.CurrentArrowCapSprite); // closed by default
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5))); // open
Assert.Equal(h.TypeMenu.ArrowCapOpenSprite, h.TypeMenu.CurrentArrowCapSprite);
}
[Fact]
public void CategoryMenu_TextIndentsAreFlushLeft_NotChatsCheckboxLedOffsets()
{
// G8: vendor's row template (0x10000352, live-dat HJustify=Left) has
// no checkbox child and its button-label child (0x1000034D) has no
// LED art either — both indents must be 0, not chat's 19px/20px.
var h = new Harness();
Assert.Equal(0f, h.TypeMenu.TextIndent);
Assert.Equal(0f, h.TypeMenu.ButtonTextIndent);
}
[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_SwitchesPageAndStartsWithAnEmptyStagedSellList()
{
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 6c: the Selling tab's own widgets are wired now (staging
// list + four buttons), but nothing is STAGED without a drag/drop —
// the list itself stays empty.
Assert.NotEmpty(h.SellingPage.Children);
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void BuyingTab_SwitchesPageAndStartsWithAnEmptyStagedBuyList()
{
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);
// Slice 6b: the Buying tab's own widgets are wired now (staging list
// + four buttons), but nothing is STAGED until "Add to List" is
// pressed — the list itself stays empty.
Assert.NotEmpty(h.BuyingPage.Children);
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[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);
}
/// <summary>
/// R1 gate-finding fix, task item 5 (2026-08-08): an UNLIMITED-STOCK
/// item with no wire <c>DescStackSize</c> (the real ACE shape for a
/// browse-list row — see <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/>'s
/// doc comment) but a real <see cref="VendorShopItem.MaxStackSize"/>
/// (1000, matching the live retail screenshot's Prismatic Taper) must
/// still let the player buy an arbitrary quantity up to that ceiling —
/// the slider isn't merely decorative. This harness doesn't mount
/// <c>SelectedObjectController</c> (production's toolbar owns split
/// seeding), so it stands in for "the toolbar showed a ceiling of 1000
/// (MaxStackSize) and the player dragged to 300" by seeding
/// <see cref="Harness.SplitQuantity"/> directly with that SAME ceiling.
/// </summary>
[Fact]
public void BuyButton_Press_UnlimitedStockItemUsingMaxStackSizeCeiling_SendsTheSliderQuantity()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Prismatic Taper", (uint)ItemType.SpellComponents, 300u, 1000,
DescStackSize: null, MaxStackSize: 1000),
});
h.SplitQuantity.Reset(1000u, initialValue: 300u);
h.BuyButton.OnClick!.Invoke();
Assert.Equal(300, 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);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6b — Buying tab staging (Add to List, Buy Item, Buy All, Clear)
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void AddToBuyList_StagesTheSelectedItem()
{
var h = new Harness();
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.
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId);
}
[Fact]
public void AddToBuyList_ReAddingTheSameItemUpsertsRatherThanDuplicatingTheRow()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
[Fact]
public void AddToBuyList_NothingSelected_IsANoOp()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
h.AddButton.OnClick!.Invoke();
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
/// <summary>
/// F2 (Slice 6b/6c review, byte-verified <c>pc:202934</c>): re-adding an
/// already-staged item ACCUMULATES onto the existing staged quantity —
/// a prior version of this port upserted/overwrote. Observed through a
/// subsequent Buy All send (the one path that reads
/// <see cref="VendorStagingEntry.Quantity"/> directly, unlike Buy
/// Item's own live-slider read).
/// </summary>
[Fact]
public void AddToBuyList_ReAddingTheSameStackableItem_AccumulatesTheStagedQuantity()
{
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: 10u);
h.AddButton.OnClick!.Invoke();
h.SplitQuantity.SetValue(15u);
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems()); // one row, not two
h.BuyAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (25, StackedItemGuid) }, items);
}
/// <summary>
/// F2: retail's 5000-unit cap (<c>VendorStagingList.MaxStagedQuantity</c>)
/// on an ACCUMULATE rejects with its own notice and leaves the entry
/// unchanged.
/// </summary>
[Fact]
public void AddToBuyList_AccumulatingPastTheCap_ShowsRetailsNoticeAndLeavesStagingUnchanged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100, MaxStackSize: 100),
});
h.SplitQuantity.Reset(5000u, initialValue: 4990u);
h.AddButton.OnClick!.Invoke();
h.SplitQuantity.Reset(5000u, initialValue: 20u);
h.AddButton.OnClick!.Invoke();
Assert.Equal(new[] { VendorStagingList.TooMuchMessage }, h.SystemMessages);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
// Boost the player's coin total so a follow-up Buy All send is only
// gated by staging content, not F1's affordability guard, proving
// the rejected accumulate above left the entry at its PRE-add value.
h.Objects.Get(Harness.PlayerGuid)!.Properties.Ints[(uint)PropertyInt.CoinValue] = 10_000_000;
h.BuyAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
Assert.Equal(4990, items.Single().Amount);
}
/// <summary>
/// F2 (Slice 6b/6c review): port of <c>VendorItemsUI::RemoveFromShop</c>
/// (<c>0x004c3ce0</c>) — staging the vendor's ENTIRE limited supply of an
/// item hides its Items-tab row (matching retail's <c>DeleteItem</c>),
/// and un-staging restores it — <see cref="AvailableShopQuantity"/> is
/// recomputed fresh from the untouched snapshot each time, so no
/// separate "restore" code path is needed.
/// </summary>
[Fact]
public void StagingConsumesLimitedShopSupply_HidingTheRowWhenExhaustedAndRestoringItWhenUnstaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, 2, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
});
Assert.Equal(1, h.ItemList.GetNumUIItems());
h.AddButton.OnClick!.Invoke(); // stages 1 of 2 available
Assert.Equal(1, h.ItemList.GetNumUIItems()); // still visible — 1 remains
h.AddButton.OnClick!.Invoke(); // stages 2 of 2 available — exhausted
Assert.Equal(0, h.ItemList.GetNumUIItems()); // row hidden
Assert.Null(h.Selection.SelectedObjectId); // selection cleared, matching RemoveFromShop's own SetSelectedObject(0,0)
h.BuyClearListButton.OnClick!.Invoke(); // un-stage everything
Assert.Equal(1, h.ItemList.GetNumUIItems()); // row restored
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
}
/// <summary>F2: retail's <c>var_c != 0xffffffff</c> guard — unlimited stock (StackSize == -1) is never hidden.</summary>
[Fact]
public void UnlimitedSupplyShopItem_IsNeverHiddenNoMatterHowMuchIsStaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
});
for (int i = 0; i < 5; i++)
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.ItemList.GetNumUIItems());
}
[Fact]
public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
h.ItemList.GetItem(1)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(2, h.BuyingList.GetNumUIItems());
h.BuyAllButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items, uint currency) =
Assert.Single(h.BuyAlls);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(
new (int Amount, uint ItemGuid)[] { (1, ArmorItemGuid), (1, AnotherArmorItemGuid) },
items);
Assert.Equal(0u, currency);
// Retail flushes m_buyList immediately after the send, not gated on
// a server response (pc:204075-204076) — see BuyAllButtonPressed's
// own doc comment.
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void BuyAllButton_WithNothingStaged_IsANoOp()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
}
// ══════════════════════════════════════════════════════════════════════
// F1 (Slice 6b/6c review) — Buy All's four client-side pre-send guards.
// Each blocking guard leaves staging fully intact; the existing
// BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess
// test above is the "all four pass" case (it already exercises the
// Harness's 102 item / 7 container capacity headroom).
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void BuyAllButton_InsufficientPyrealFunds_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
// Value=2000, no DescStackSize -> perUnit=2000; SellPrice(2000, Armor,
// 1.5, 1) = ceil(3000 - 0.1) = 3000, well above the Harness's 1500 coin default.
h.State.Apply(VendorGuid, Profile(sellRate: 1.5f), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2000),
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientAltCurrency_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(
VendorGuid,
Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 10u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientContainerSlots_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
});
h.AddButton.OnClick!.Invoke();
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 0;
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientItemSlots_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.Objects.Get(Harness.PlayerGuid)!.ItemsCapacity = 0;
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
}
/// <summary>
/// G1 (vendor gate finding): live testing showed "Buy All" false-blocking
/// a container purchase while the player visibly had free container
/// slots ("3 left"). Root cause: <c>CountPlayerContents</c> classified
/// "is this occupied slot a container" from the item's own
/// <see cref="ItemType.Container"/> bit OR nonzero
/// <c>ItemsCapacity</c>/<c>ContainersCapacity</c> — a LOCAL heuristic —
/// instead of retail's actual wire-carried classification
/// (<c>ContainerProperties</c>, threaded onto
/// <see cref="ClientObject.ContainerTypeHint"/> by every membership
/// path). A non-Container-typed object with a stray nonzero capacity
/// field (and a wire hint of <c>None</c>) was over-counted as an
/// occupied CONTAINER slot, shrinking the free-slot count below the
/// real one and false-blocking a purchase the player had room for.
/// This test pins a player pack with one such object (armor, hint=None,
/// but ItemsCapacity happens to read nonzero) plus 6 free container
/// slots out of 7 — buying ONE more container must succeed.
/// </summary>
[Fact]
public void BuyAllButton_StrayCapacityFieldOnNonContainerItem_DoesNotFalseBlockWithFreeSlots()
{
var h = new Harness();
// AddOrUpdate FIRST (creates the object carrying the stray capacity
// field), InitializeInventoryManifest SECOND (updates the SAME
// object's placement/hint in place — it does not touch Type/
// ItemsCapacity, matching AddOrUpdate's own doc comment: "does NOT
// update the container index").
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x60002001u,
Type = ItemType.Armor,
ItemsCapacity = 3,
});
h.Objects.InitializeInventoryManifest(Harness.PlayerGuid, new[]
{
// Wire truth (ContainerType=0/None): this is NOT a container.
// Its Type is Armor (not Container) but it carries a stray
// nonzero ItemsCapacity — the old heuristic misread that as
// "occupies a container slot."
new ContainerContentEntry(0x60002001u, 0u),
});
// Only 1 container slot total so a miscount of this ONE stray item
// as a container (containersUsed 1 instead of 0) actually flips the
// guard, instead of being absorbed by the harness's 7-slot default.
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 1;
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
});
h.AddButton.OnClick!.Invoke();
h.BuyAllButton.OnClick!.Invoke();
// 1 capacity - 0 REAL containers used = 1 free -> buying 1 succeeds.
Assert.Single(h.BuyAlls);
Assert.Empty(h.SystemMessages);
}
/// <summary>
/// G1 companion: a REAL side-pack (wire hint ContainerType=1/Container,
/// no ItemType.Container bit and no capacity fields populated — e.g. a
/// container object seen only via a membership manifest, not its own
/// full CreateObject) still correctly consumes a container slot. Proves
/// the fix's hint-primary classification isn't just permissive by
/// omission — it still catches a real container the OLD Type-bit-only
/// fallback would have missed too.
/// </summary>
[Fact]
public void BuyAllButton_HintOnlyContainer_StillCountsAgainstContainerCapacity()
{
var h = new Harness();
h.Objects.InitializeInventoryManifest(Harness.PlayerGuid, new[]
{
new ContainerContentEntry(0x60002010u, 1u), // Container, hint-only
});
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
});
h.AddButton.OnClick!.Invoke();
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 1;
h.BuyAllButton.OnClick!.Invoke();
// Capacity 1, 1 REAL container already used (via hint) -> 0 free,
// buying 1 more must block.
Assert.Empty(h.BuyAlls);
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
}
[Fact]
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
h.BuyItemButton.OnClick!.Invoke();
Assert.Equal(new[] { (VendorGuid, ArmorItemGuid, 1, 0u) }, h.Buys);
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void BuyClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutBuying()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
h.ItemList.GetItem(1)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(2, h.BuyingList.GetNumUIItems());
// AnotherArmorItemGuid is currently selected (last clicked).
h.BuyClearItemButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId);
Assert.Empty(h.Buys);
}
[Fact]
public void BuyClearListButton_ClearsEveryStagedEntryWithoutBuying()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.BuyClearListButton.OnClick!.Invoke();
Assert.Equal(0, h.BuyingList.GetNumUIItems());
Assert.Empty(h.Buys);
}
/// <summary>
/// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must
/// visibly move the highlight — a prior version of this port only
/// repainted the Buying/Selling strips when their OWN staging list
/// changed, so a pure selection change (clicking a DIFFERENT already-
/// staged row, no staging mutation) left both rows' <c>Selected</c>
/// flags stale.
/// </summary>
[Fact]
public void SelectingADifferentStagedBuyingRow_RepaintsBothRowsHighlight()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
h.ItemList.GetItem(1)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(2, h.BuyingList.GetNumUIItems());
// AnotherArmorItemGuid is currently selected+highlighted (last staged).
UiItemSlot firstRow = h.BuyingList.GetItem(0)!;
UiItemSlot secondRow = h.BuyingList.GetItem(1)!;
firstRow.Clicked?.Invoke(); // a pure selection change — no staging mutation
Assert.Equal(firstRow.ItemId, h.Selection.SelectedObjectId);
Assert.True(firstRow.Selected);
Assert.False(secondRow.Selected);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6c — Selling tab drag-to-sell staging
// ══════════════════════════════════════════════════════════════════════
private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1)
{
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = $"Item {guid:X8}",
Type = type,
Value = value,
StackSize = stackSize,
});
h.Objects.MoveItem(guid, Harness.PlayerGuid, h.Objects.GetContents(Harness.PlayerGuid).Count);
}
private static ItemDragPayload DragFromInventory(uint guid) =>
new(guid, ItemDragSource.Inventory, 0, new UiItemSlot());
[Fact]
public void OnDragOver_TargetIsNotTheSellingList_RejectsRegardlessOfAcceptability()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
ItemDragAcceptance result = h.Controller.OnDragOver(
h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(ItemDragAcceptance.Reject, result);
}
[Fact]
public void OnDragOver_AcceptableItemOverSellingList_Accepts()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
ItemDragAcceptance result = h.Controller.OnDragOver(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(ItemDragAcceptance.Accept, result);
}
[Fact]
public void OnDragOver_UnacceptableItemOverSellingList_RejectsSilently()
{
var h = new Harness();
// Vendor only deals in Armor -- a Weapon is a type mismatch.
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100);
ItemDragAcceptance result = h.Controller.OnDragOver(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
Assert.Equal(ItemDragAcceptance.Reject, result);
// silent=1 on hover -- no rejection string yet (VendorSellUI::
// OnItemListDragOver, pc:201320-201339).
Assert.Empty(h.SystemMessages);
}
/// <summary>
/// F5 (Slice 6b/6c review): every drag test above (and
/// <see cref="HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt"/>
/// below) calls <c>OnDragOver</c>/<c>HandleDropRelease</c> DIRECTLY,
/// bypassing <see cref="UiRoot"/>'s real pointer pipeline entirely — the
/// review flagged that this can never exercise the NEW auto-switch
/// behavior (<c>PollDragOver</c>), which reacts to <see cref="UiRoot.DragSource"/>/
/// <see cref="UiRoot.MouseX"/>/<see cref="UiRoot.MouseY"/>, not a direct
/// method call. This test drives the WHOLE thing through
/// <see cref="UiRoot.OnMouseDown"/>/<see cref="UiRoot.OnMouseMove"/>/
/// <see cref="UiRoot.Tick"/>/<see cref="UiRoot.OnMouseUp"/>: lift a drag
/// from an (unrelated) source cell, hover it over the vendor window
/// while the Items tab is still open, let the periodic global-UI-time
/// pulse (retail's message 3) auto-switch to Selling, then release
/// ONTO the now-visible Selling list's real hit-test geometry.
/// </summary>
[Fact]
public void DragOverTheVendorWindow_ThroughTheRealPointerPipeline_AutoSwitchesToSellingAndStages()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
// The Selling list needs real screen geometry (and at least one
// empty-slot placeholder cell) to be hit-testable — the hand-built
// harness never runs a real DAT-driven layout pass, so this test
// gives it one explicitly rather than relying on production sizing.
// Exactly ONE cell's worth of width so the post-drop repaint has no
// room left for an extra empty-slot placeholder alongside the real
// staged item.
h.SellingList.Width = 32f;
h.SellingList.Height = 32f;
using (h.SellingList.DeferLayout()) { }
// A standalone drag SOURCE cell standing in for "the player's own
// inventory panel" (not modeled by this harness) — positioned well
// outside the vendor window (which occupies roughly (0,0)-(800,110)
// here) so it cannot be confused with any vendor widget.
var sourceCell = new UiItemSlot { Left = 700f, Top = 550f, Width = 32f, Height = 32f };
sourceCell.SetItem(PlayerOwnedArmorGuid, 0u);
h.Screen.AddChild(sourceCell);
Assert.True(h.ItemsPage.Visible);
Assert.False(h.SellingPage.Visible);
// Press on the source cell, then move past the 3px promotion
// threshold to somewhere INSIDE the vendor window (but not
// specifically over the Selling list) while the Items tab is still
// showing — mirrors a player dragging toward "the vendor" in
// general before the panel has switched tabs for them.
h.Screen.OnMouseDown(UiMouseButton.Left, 710, 560);
h.Screen.OnMouseMove(400, 50);
Assert.Same(sourceCell, h.Screen.DragSource);
Assert.False(h.SellingPage.Visible);
// F5: the auto-switch happens on the periodic global-UI-time pulse,
// not on the mouse-move itself — retail polls this from
// UpdateDragOver via UI message 3.
h.Screen.Tick(0.016, 1L);
Assert.True(h.SellingPage.Visible);
Assert.False(h.ItemsPage.Visible);
// Move onto the now-visible Selling list's real geometry and
// release — UiRoot's own hit-test resolves the drop, not a direct
// HandleDropRelease call.
h.Screen.OnMouseMove(20, 15);
h.Screen.OnMouseUp(UiMouseButton.Left, 20, 15);
Assert.Null(h.Screen.DragSource);
Assert.Equal(1, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
Assert.Empty(h.SystemMessages);
}
[Fact]
public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.True(h.SellingPage.Visible);
Assert.False(h.ItemsPage.Visible);
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
Assert.Empty(h.SystemMessages);
}
/// <summary>
/// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records
/// the item's FULL stack — retail's <c>AddItemToSell</c> stages via a
/// LITERAL <c>-1</c> "full stack" argument
/// (<c>gmVendorUI::AddItem(..., -1, ...)</c>, <c>pc:203595</c>), never a
/// slider read. A prior version of this port read the LIVE split
/// slider here instead — this proves a PARTIAL slider selection at
/// drop time does not leak into the staged (or sent) quantity.
/// </summary>
[Fact]
public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
h.SellAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items);
}
[Fact]
public void HandleDropRelease_WrongTargetList_IsIgnored()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
// gmVendorUI::HandleDropRelease's IsAncestorOfMe gate — a drop on
// ANY other list in the panel (here, the Items list) is a structural
// no-op, never reaching AcceptDragObject.
h.Controller.HandleDropRelease(
h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void HandleDropRelease_UnacceptableType_ShowsTheGenericRejectionMessageAndDoesNotStage()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
}
[Fact]
public void HandleDropRelease_NoValueItem_ShowsTheNoValueRejectionMessage()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, value: 0);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "That item has no value and cannot be sold" }, h.SystemMessages);
}
[Fact]
public void HandleDropRelease_NotOwnedByPlayer_ShowsTheOwnershipRejectionMessage()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
// Registered but never moved into the player's own container --
// ContainerId/WielderId both stay 0, so IsOwnedByPlayer is false.
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = PlayerOwnedArmorGuid,
Name = "Someone else's chainmail",
Type = ItemType.Armor,
Value = 100,
StackSize = 1,
});
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages);
}
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c> is now checked end to
/// end — an item whose TYPE matches the vendor's merchandise mask is
/// still rejected (as the generic <c>WrongType</c> message, matching
/// retail's OR'd branch) when its bitfield carries the bit.
/// </summary>
[Fact]
public void HandleDropRelease_RetainedItem_RejectsEvenWhenTheTypeMaskMatches()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = PlayerOwnedArmorGuid,
Name = "Heirloom Chainmail",
Type = ItemType.Armor,
Value = 100,
StackSize = 1,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Retained,
});
h.Objects.MoveItem(PlayerOwnedArmorGuid, Harness.PlayerGuid, 0);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
}
[Fact]
public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2));
Assert.Equal(2, h.SellingList.GetNumUIItems());
h.SellAllButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(
new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid), (1, PlayerOwnedArmorGuid2) },
items);
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void SellAllButton_WithNothingStaged_IsANoOp()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
h.SellAllButton.OnClick!.Invoke();
Assert.Empty(h.Sells);
}
[Fact]
public void SellItemButton_SellsTheSelectedStagedItemAndRemovesItUnconditionallyOnSuccess()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.SellItemButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items);
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
/// <summary>
/// F13 (Slice 6b/6c review): retail's Sell Item reads
/// <c>ACCWeenieObject::selectedID</c> UNCONDITIONALLY — there is no
/// "must be staged first" requirement. A prior version of this port
/// required a matching <c>_sellStaging</c> entry, which this test would
/// have failed against (no drop/staging happens here at all — only a
/// direct global selection).
/// </summary>
[Fact]
public void SellItemButton_ActsOnTheGlobalSelectionEvenWhenNeverStaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Selection.Select(PlayerOwnedArmorGuid, SelectionChangeSource.Vendor);
Assert.Equal(0, h.SellingList.GetNumUIItems()); // never staged/dropped
h.SellItemButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items);
}
/// <summary>
/// F6 (Slice 6b/6c review, byte-verified <c>pc:201833-201864</c>):
/// <c>SellSingleItem</c> refuses a stackable item whose split slider is
/// not showing the FULL stack — shows the exact retail notice and sends
/// nothing.
/// </summary>
[Fact]
public void SellItemButton_PartialStackSelected_RefusesWithRetailsNoticeAndSendsNothing()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 5u); // a PARTIAL amount, not the full stack of 20
h.SellItemButton.OnClick!.Invoke();
Assert.Empty(h.Sells);
Assert.Equal(new[] { "Cannot sell part of a stack" }, h.SystemMessages);
}
/// <summary>
/// F6: once the slider shows the FULL stack, Sell Item proceeds and
/// sends amount <c>1</c> LITERALLY — not the 20-unit stack size —
/// matching retail's own literal <c>var_9c = 1</c> send.
/// </summary>
[Fact]
public void SellItemButton_FullStackSelected_SendsLiteralAmountOneNotTheStackSize()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 20u); // the FULL stack
h.SellItemButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedWeaponGuid) }, items);
}
[Fact]
public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2));
Assert.Equal(2, h.SellingList.GetNumUIItems());
// PlayerOwnedArmorGuid2 is currently selected (last dropped).
h.SellClearItemButton.OnClick!.Invoke();
Assert.Equal(1, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.Empty(h.Sells);
}
[Fact]
public void SellClearListButton_ClearsEveryStagedEntryWithoutSelling()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
h.SellClearListButton.OnClick!.Invoke();
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Empty(h.Sells);
}
// ══════════════════════════════════════════════════════════════════════
// F10 (Slice 6b/6c review) — unstage on removal/dispossession.
// ══════════════════════════════════════════════════════════════════════
/// <summary>
/// Sell side — retail's <c>RecvNotice_ServerSaysMoveItem</c>
/// (<c>0x004c44a0</c>) silently unstages a staged sell item once it's
/// no longer trackable/owned by the player; this port's closest
/// reachable analogue is the item leaving <c>ClientObjectTable</c>
/// entirely.
/// </summary>
[Fact]
public void RemovingAStagedSellItemFromClientObjectTable_SilentlyUnstagesIt()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.Objects.Remove(PlayerOwnedArmorGuid);
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Empty(h.SystemMessages); // silent — matches retail's own site
}
/// <summary>
/// Buy side — a staged shop item that drops out of the vendor's
/// CURRENT stock (retired from <c>ClientObjectTable</c> — production's
/// <c>VendorShopItemMaterializer</c> does this on every Refreshed
/// transition that drops a guid; this hand-built harness doesn't mount
/// that class, so the test performs the SAME removal directly) unstages
/// with retail's exact "Removing %s from shopping list" notice
/// (<c>0x004c4246</c>).
/// </summary>
[Fact]
public void ShopItemLeavingClientObjectTable_UnstagesTheBuyEntryWithRetailsNotice()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = ArmorItemGuid,
Name = "Chainmail",
Type = ItemType.Armor,
ContainerId = VendorGuid,
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
h.Objects.Remove(ArmorItemGuid);
Assert.Equal(0, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "Removing Chainmail from shopping list" }, h.SystemMessages);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6b/6c — X-close staging confirmation + session-boundary clears
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void CloseButtonPressed_WithNoStaging_HidesImmediatelyWithoutADialog()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
Assert.True(h.Window.IsVisible);
h.CloseButton.OnClick!.Invoke();
Assert.False(h.Window.IsVisible);
Assert.False(h.Dialogs.IsOpen);
}
[Fact]
public void CloseButtonPressed_WithStagedBuyItems_ShowsConfirmDialogInsteadOfHidingImmediately()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.CloseButton.OnClick!.Invoke();
Assert.True(h.Window.IsVisible);
Assert.True(h.Dialogs.IsOpen);
Assert.NotNull(h.ShownDialog);
// Exact retail string, read from the decompiled binary's data
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment. F7
// (Slice 6b/6c review): the raw bytes right after the declared
// string length are UTF-16LE for '?' before the null terminator.
Assert.Equal(
"You have not completed all transactions. Are you sure you want to leave this vendor?",
string.Join(" ", Assert.IsType<UiText>(h.ShownDialog!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
}
[Fact]
public void CloseConfirmDialog_Accepted_HidesTheWindowAndLeavesStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.CloseButton.OnClick!.Invoke();
Assert.IsType<UiButton>(h.ShownDialog!.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.False(h.Window.IsVisible);
Assert.False(h.Dialogs.IsOpen);
// Retail's CloseVendorDialogCallback never touches m_buyList/
// m_sellList -- staging survives so the next open shows it again.
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
[Fact]
public void CloseConfirmDialog_Rejected_KeepsTheWindowOpenAndStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.CloseButton.OnClick!.Invoke();
Assert.IsType<UiButton>(h.ShownDialog!.FindElement(
RetailConfirmationDialogView.RejectButtonId)).OnClick!();
Assert.True(h.Window.IsVisible);
Assert.False(h.Dialogs.IsOpen);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
[Fact]
public void CloseButtonPressed_WhileAConfirmationIsAlreadyUp_DoesNotOpenASecondOne()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.CloseButton.OnClick!.Invoke();
Assert.Equal(1, h.Dialogs.ActiveCount);
h.CloseButton.OnClick!.Invoke();
Assert.Equal(1, h.Dialogs.ActiveCount);
}
[Fact]
public void SessionClose_ClearsBothStagingLists()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.State.Close();
Assert.Equal(0, h.BuyingList.GetNumUIItems());
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void SessionReset_ClearsBothStagingLists()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.State.Reset();
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void OpeningADifferentVendor_ClearsStaleStagingFromThePreviousVendor()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
const uint otherVendor = 0x70000099u;
h.State.Apply(otherVendor, Profile(), new[]
{
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
});
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void RefreshedTransition_SameVendor_DoesNotClearAnUntouchedStagingList()
{
var h = new Harness();
var items = new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
};
h.State.Apply(VendorGuid, Profile(), items);
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
// Same vendor id re-approaching -- sameVendor==1, a Refreshed
// transition (e.g. post buy/sell ApproachVendor refresh).
h.State.Apply(VendorGuid, Profile(), items);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
// ══════════════════════════════════════════════════════════════════════
// R3 — the Buying/Selling tabs' own staged-count/total-value/purse text
// (grand-gate live evidence: retail's Buying tab shows "Buying 2 items
// worth 422p" / "You have 23p"; the strings are recovered byte-verbatim
// — see VendorUiController.BuildTransactionListText/BuildPurseText).
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void BuyingTabSummaryText_TracksStagedCountValueAndPlayerPurse()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// Before staging: zero staged count/value, still grammatically
// plural ("0 items"), purse text is live regardless of staging.
Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText));
Assert.Equal(
string.Create(
CultureInfo.InvariantCulture,
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
GetText(h.BuyPurseText));
h.AddButton.OnClick!.Invoke();
// ComputeBuyTransactionValue: sellRate 1.5 * value 500 * quantity 1
// = 750, ceil(750 - 0.1) = 750.
Assert.Equal("Buying 1 item worth 750p", GetText(h.BuyListText));
Assert.Equal(
string.Create(
CultureInfo.InvariantCulture,
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
GetText(h.BuyPurseText));
h.BuyClearListButton.OnClick!.Invoke();
Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText));
}
/// <summary>
/// R3: a money change with no staging change at all must still repaint
/// the purse line — retail's own purse text always reads the LIVE
/// holding, with no "did staging change" gate (see
/// <c>VendorUiController.OnObjectMoneyChanged</c>'s doc comment).
/// </summary>
[Fact]
public void BuyingTabPurseText_UpdatesOnAPlayerMoneyChangeAloneWithNoStagingChange()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
Assert.Equal(
string.Create(
CultureInfo.InvariantCulture,
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
GetText(h.BuyPurseText));
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.CoinValue] = 42;
h.Objects.UpsertProperties(Harness.PlayerGuid, bundle);
Assert.Equal("You have 42p", GetText(h.BuyPurseText));
// Selling's purse text shares the SAME live holding read.
Assert.Equal("You have 42p", GetText(h.SellPurseText));
}
[Fact]
public void SellingTabSummaryText_TracksStagedCountValueAndPlayerPurse()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText));
Assert.Equal(
string.Create(
CultureInfo.InvariantCulture,
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
GetText(h.SellPurseText));
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
// ComputeSellTransactionValue: SellProfile's BuyPrice 1.0 * value
// 100 * quantity 1 = 100, floor(100 + 0.1) = 100.
Assert.Equal("Selling 1 item worth 100p", GetText(h.SellListText));
Assert.Equal(
string.Create(
CultureInfo.InvariantCulture,
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
GetText(h.SellPurseText));
h.SellClearListButton.OnClick!.Invoke();
Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText));
}
/// <summary>
/// Two staged units of the SAME stackable row: the count is the STAGED
/// QUANTITY total (2), not the row count (1) — matching the live
/// evidence screenshot's "Buying 2 items worth 422p" exactly (a single
/// stacked row, quantity 2).
/// </summary>
[Fact]
public void BuyingTabSummaryText_CountsStagedQuantityNotRowCountForAStackedItem()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Prismatic Taper", (uint)ItemType.SpellComponents, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(100u, initialValue: 2u);
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
// ComputeBuyTransactionValue: perUnit = value 1000 / DescStackSize
// 100 = 10; sellRate 1.5 * perUnit 10 * quantity 2 = 30,
// ceil(30 - 0.1) = 30.
Assert.Equal("Buying 2 items worth 30p", GetText(h.BuyListText));
}
}