acdream/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
Erik 92ea3977b6
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
feat(vendor): Slice 6b/6c — move-to-use, buy staging, selling; the vendor arc is functionally complete
C1 an out-of-range Use now approaches first via the existing
client-predicted BeginApproach (Pickup's far-range shape mirrored;
retail's ItemHolder::UseObject @0x00588A80 has no range check and the
dispatch stays immediate). C2 Add-to-List stages into the Buying tab
via VendorStagingList (RemoveProfileFromList's two shapes,
pc:200497-200537), Buy All sends ONE batched 0x005F and flushes
staging on send exactly as retail does (SendShopEvent -> Flush,
pc:204075-204076 — not UseDone-gated), and X-close over a non-empty
staging list shows retail's confirm string recovered verbatim from the
binary data segment (0x007b5bd8) through the existing dialog factory.
C3 the Selling tab's list is the sole drop target (retail's single
IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports
InqAcceptability with all rejection strings recovered verbatim from
the raw data segment; the sell side prices with BuyPrice (retail's
inverted naming: what the vendor PAYS) and 0x0060 carries no trailing
currency field, unlike Buy. C4 the status-bar reproduction test PASSES
against the production toolbar mount — retail's toolbar shows count +
name with the split bar and NO price parenthetical (that figure is the
vendor row's own cost text); no code change, the live gate referees.
C5 pack order verified correct, untouched.

Register: AP-161 narrowed to its two pre-existing cosmetic gaps;
AP-162 extended over Buy All; AP-164 (non-sellable bitfield
unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal
test, bounded), AP-166 (purse text + pending-sell highlight cosmetic)
filed.

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

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

1889 lines
81 KiB
C#

using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Properties;
using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Slice 5.4 — mirrors <see cref="ExternalContainerControllerTests"/>'s
/// harness shape (hand-built <see cref="ImportedLayout"/> over
/// <see cref="RetailWindowFrame.Mount"/>) for the behavioral suite, plus one
/// real-dat-fixture smoke test (mirroring
/// <c>AppraisalUiControllerTests</c>'s use of <see cref="FixtureLoader"/>)
/// that catches drift between the hardcoded ids in
/// <see cref="VendorUiController"/> and the actual LayoutDesc 0x21000012.
/// </summary>
public sealed class VendorUiControllerTests
{
private const uint VendorGuid = 0x70000010u;
private const uint ArmorItemGuid = 0x60000101u;
private const uint FoodItemGuid = 0x60000102u;
private const uint StackedItemGuid = 0x60000103u;
// 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;
// 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()
{
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);
SellingPage.AddChild(SellingList);
SellingPage.AddChild(SellItemButton);
SellingPage.AddChild(SellAllButton);
SellingPage.AddChild(SellClearItemButton);
SellingPage.AddChild(SellClearListButton);
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,
});
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(
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
}
[Fact]
public void SelectingItem_InSplitExemptMask_ShowsPerUnitPriceAndSingularName()
{
// F2/F3: Food (0x20) DOES intersect the split-exempt mask, so
// quantity is forced to 1 even though the item has a larger
// authored stack — the mask-member case the review asked for.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
{
// perUnit = 500/50 = 10. quantity forced to 1 (Food is masked).
// SellPrice = ceil(2.0*10*1 - 0.1) = 20.
new VendorShopItem(
FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 500,
DescStackSize: 50),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
// F2: exempt seed is 1 regardless of the 50-unit authored stack —
// see the split-exempt comment on the sibling test above for why
// this harness seeds SplitQuantity explicitly.
h.SplitQuantity.Reset(50u, initialValue: 1u);
Assert.Equal("Bread", GetText(h.ItemNameText));
Assert.Equal(
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
}
[Fact]
public void SelectingItem_WithAuthoredPluralName_UsesItInsteadOfSingular()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Iron Key", (uint)ItemType.Key, 50u, 100,
DescStackSize: 10, PluralName: "Iron Keys"),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
// F2: Key is not split-exempt -> full authored stack (10).
h.SplitQuantity.Reset(10u, initialValue: 10u);
Assert.Equal("10 Iron Keys", GetText(h.ItemNameText));
}
[Fact]
public void SliderChange_AfterSelection_UpdatesNameAndPriceToLiveQuantity_MatchingWhatBuyWouldCharge()
{
// F2 (Slice 6 review): retail re-runs UpdateItemsUI on every slider
// change (gmVendorUI::RecvNotice_StackSliderChanged,
// pc:203262-203278) -- the OLD implementation froze the display's
// quantity at a static per-selection seed while BuySelectedItem
// separately read the LIVE split, so a slider drag after selecting
// never updated the sentence/price and could show a DIFFERENT price
// than what Buy would actually charge. Both now go through the SAME
// ResolveBuyQuantity, proven here by asserting the displayed price
// equals the amount the subsequent Buy press actually sends.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
{
// perUnit = 1000/100 = 10 (MissileWeapon, not split-exempt).
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
// F6 auto-selects the sole item; the harness's un-mounted-toolbar
// split state starts at its class default (Value=1/Maximum=1) —
// singular display, matching a fresh, not-yet-slider-touched
// selection.
Assert.Equal("Arrows", GetText(h.ItemNameText));
Assert.Equal(
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
// Player drags the slider to 40 AFTER selecting -- no re-click, no
// re-selection, purely a StackSplitQuantityState.Changed event.
h.SplitQuantity.Reset(100u, initialValue: 40u);
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
Assert.Equal(
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
h.BuyButton.OnClick!.Invoke();
Assert.Equal(40, h.Buys.Single().Amount);
}
[Fact]
public void SelectingItem_WithAlternateCurrency_ShowsFullRetailCostSentence()
{
// F2/F3: the OLD implementation just appended the currency name after
// a bare number ("50 Trade Notes"). Retail's actual alt-currency
// sentence is a full two-clause sentence with a "you have" tail
// (acclient_2013_pseudo_c.txt:991314), using RAW (non-comma-grouped)
// integers — unlike the primary-currency branch.
var h = new Harness();
h.State.Apply(
VendorGuid,
Profile(sellRate: 1.0f, altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 12345u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 50),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
// Armor (not in the split-exempt mask) with DescStackSize null ->
// PerUnitValue returns Value unchanged (50); quantity = 1 (no
// authored stack) -> SellPrice(50, Armor, 1.0, 1) = ceil(50-0.1) = 50.
Assert.Equal(
"This item costs 50 Trade Notes. You have 12345 Trade Notes.",
GetText(h.ItemCostText));
}
[Fact]
public void NoSelection_DisablesBuyButton_SelectionEnablesIt()
{
var h = new Harness();
Assert.False(h.BuyButton.Enabled);
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// F4 auto-selects the sole item on open, so Buy is already enabled
// — SetState(1), pc:202784-202789.
Assert.True(h.BuyButton.Enabled);
// Closing clears the selection -> SetState(0xd), pc:202572-202577.
h.State.Close();
Assert.False(h.BuyButton.Enabled);
}
[Fact]
public void AddButton_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);
}
[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());
}
[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);
}
[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);
}
// ══════════════════════════════════════════════════════════════════════
// 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);
}
[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);
}
[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);
}
[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());
}
[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);
}
// ══════════════════════════════════════════════════════════════════════
// 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.
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());
}
}