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; /// /// Slice 5.4 — mirrors 's /// harness shape (hand-built over /// ) for the behavioral suite, plus one /// real-dat-fixture smoke test (mirroring /// AppraisalUiControllerTests's use of ) /// that catches drift between the hardcoded ids in /// and the actual LayoutDesc 0x21000012. /// 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(layout.FindElement(VendorUiController.BuyingListId)); var sellingList = Assert.IsType(layout.FindElement(VendorUiController.SellingListId)); var buyingScrollbar = Assert.IsType( layout.FindElement(VendorUiController.BuyingScrollbarId)); var sellingScrollbar = Assert.IsType( 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 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 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); SellingPage.AddChild(SellingList); SellingPage.AddChild(SellItemButton); SellingPage.AddChild(SellAllButton); SellingPage.AddChild(SellClearItemButton); SellingPage.AddChild(SellClearListButton); var layout = new ImportedLayout(root, new Dictionary { [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); /// /// Slice 6c: a profile shaped for /// coverage — 's all-zero merchandise fields would /// reject every real item (MaxValue=0 rejects anything with value > 0). /// 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(); 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()); // (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()); h.AddButton.OnClick!.Invoke(); Assert.Equal(0, h.BuyingList.GetNumUIItems()); } /// /// F2 (Slice 6b/6c review, byte-verified pc:202934): 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 /// directly, unlike Buy /// Item's own live-slider read). /// [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); } /// /// F2: retail's 5000-unit cap (VendorStagingList.MaxStagedQuantity) /// on an ACCUMULATE rejects with its own notice and leaves the entry /// unchanged. /// [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), }); 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); } /// /// F2 (Slice 6b/6c review): port of VendorItemsUI::RemoveFromShop /// (0x004c3ce0) — staging the vendor's ENTIRE limited supply of an /// item hides its Items-tab row (matching retail's DeleteItem), /// and un-staging restores it — is /// recomputed fresh from the untouched snapshot each time, so no /// separate "restore" code path is needed. /// [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); } /// F2: retail's var_c != 0xffffffff guard — unlimited stock (StackSize == -1) is never hidden. [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()); 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); } [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); } /// /// 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' Selected /// flags stale. /// [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()); 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()); 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()); 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); } /// /// F5 (Slice 6b/6c review): every drag test above (and /// /// below) calls OnDragOver/HandleDropRelease DIRECTLY, /// bypassing 's real pointer pipeline entirely — the /// review flagged that this can never exercise the NEW auto-switch /// behavior (PollDragOver), which reacts to / /// /, not a direct /// method call. This test drives the WHOLE thing through /// // /// /: 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. /// [Fact] public void DragOverTheVendorWindow_ThroughTheRealPointerPipeline_AutoSwitchesToSellingAndStages() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); 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()); 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); } /// /// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records /// the item's FULL stack — retail's AddItemToSell stages via a /// LITERAL -1 "full stack" argument /// (gmVendorUI::AddItem(..., -1, ...), pc:203595), 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. /// [Fact] public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); 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()); 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()); 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()); 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()); // 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); } /// /// F4 (Slice 6b/6c review): BF_RETAINED is now checked end to /// end — an item whose TYPE matches the vendor's merchandise mask is /// still rejected (as the generic WrongType message, matching /// retail's OR'd branch) when its bitfield carries the bit. /// [Fact] public void HandleDropRelease_RetainedItem_RejectsEvenWhenTheTypeMaskMatches() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); 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()); 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()); 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()); 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()); } /// /// F13 (Slice 6b/6c review): retail's Sell Item reads /// ACCWeenieObject::selectedID UNCONDITIONALLY — there is no /// "must be staged first" requirement. A prior version of this port /// required a matching _sellStaging entry, which this test would /// have failed against (no drop/staging happens here at all — only a /// direct global selection). /// [Fact] public void SellItemButton_ActsOnTheGlobalSelectionEvenWhenNeverStaged() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); 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); } /// /// F6 (Slice 6b/6c review, byte-verified pc:201833-201864): /// SellSingleItem refuses a stackable item whose split slider is /// not showing the FULL stack — shows the exact retail notice and sends /// nothing. /// [Fact] public void SellItemButton_PartialStackSelected_RefusesWithRetailsNoticeAndSendsNothing() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); 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); } /// /// F6: once the slider shows the FULL stack, Sell Item proceeds and /// sends amount 1 LITERALLY — not the 20-unit stack size — /// matching retail's own literal var_9c = 1 send. /// [Fact] public void SellItemButton_FullStackSelected_SendsLiteralAmountOneNotTheStackSize() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); 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()); 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()); 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. // ══════════════════════════════════════════════════════════════════════ /// /// Sell side — retail's RecvNotice_ServerSaysMoveItem /// (0x004c44a0) 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 ClientObjectTable /// entirely. /// [Fact] public void RemovingAStagedSellItemFromClientObjectTable_SilentlyUnstagesIt() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); 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 } /// /// Buy side — a staged shop item that drops out of the vendor's /// CURRENT stock (retired from ClientObjectTable — production's /// VendorShopItemMaterializer 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 /// (0x004c4246). /// [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(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(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(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()); } }