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() { 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()); } [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); } [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()); 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); } [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); } [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); } [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()); } [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); } // ══════════════════════════════════════════════════════════════════════ // 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(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()); } }