Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Four live findings, each with the paper-verification failure named: G1 the container-capacity guard counted containers by a local type/capacity heuristic that over-classifies ordinary items; retail buckets from the wire's ContainerProperties at insert. Now reads ClientObjectTable's existing ContainerTypeHint (AP-168 narrowed to the shop-stock half; a pre-check must never false-block). G2 the amount bar never showed live because ACE never sets StackSize on browse listings — DescStackSize is null for every real vendor item and the C4 paper test hand-set the field, bypassing the materializer. The materializer now falls back to the packed supply count (AP-169, ACE adaptation); the new test drives the REAL materializer. G3 an out-of-range Use now dispatches ON ARRIVAL (pickup's shape): ACE's HandleActionUseItem only opens the vendor when the Use finds the player in range — a click-time send is greeted and dropped (AP-170, ACE adaptation; retail's server walks the player, ACE does not). G4 bought items appended because ACE's placement echo (UIQueue) can beat the CreateObject (SmartboxQueue) — cross-queue, no ordering guarantee — and the early echo was silently dropped. ClientObjectTable now stashes unresolved placements and replays them at Ingest: buys land at the retail list head. No register row — this RESTORES parity. Clean-room complete solution: 11,521 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2480 lines
108 KiB
C#
2480 lines
108 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Properties;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Slice 5.4 — mirrors <see cref="ExternalContainerControllerTests"/>'s
|
|
/// harness shape (hand-built <see cref="ImportedLayout"/> over
|
|
/// <see cref="RetailWindowFrame.Mount"/>) for the behavioral suite, plus one
|
|
/// real-dat-fixture smoke test (mirroring
|
|
/// <c>AppraisalUiControllerTests</c>'s use of <see cref="FixtureLoader"/>)
|
|
/// that catches drift between the hardcoded ids in
|
|
/// <see cref="VendorUiController"/> and the actual LayoutDesc 0x21000012.
|
|
/// </summary>
|
|
public sealed class VendorUiControllerTests
|
|
{
|
|
private const uint VendorGuid = 0x70000010u;
|
|
private const uint ArmorItemGuid = 0x60000101u;
|
|
private const uint FoodItemGuid = 0x60000102u;
|
|
private const uint StackedItemGuid = 0x60000103u;
|
|
// Slice 6b: a second same-category shop item, so two DIFFERENT items
|
|
// both appear in the SAME category-filtered Items list simultaneously
|
|
// (Armor and Food are different table entries and can't both show at
|
|
// once without touching the category dropdown).
|
|
private const uint AnotherArmorItemGuid = 0x60000104u;
|
|
// Slice 6c: player-OWNED pack items (never vendor stock) dragged onto
|
|
// the Selling tab.
|
|
private const uint PlayerOwnedArmorGuid = 0x60000201u;
|
|
private const uint PlayerOwnedWeaponGuid = 0x60000202u;
|
|
private const uint PlayerOwnedArmorGuid2 = 0x60000205u;
|
|
|
|
private sealed class TestElement : UiElement { }
|
|
|
|
[Fact]
|
|
public void Bind_FromRealDatFixture_ResolvesAllRequiredControls()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadVendor();
|
|
var screen = new UiRoot { Width = 1280f, Height = 800f };
|
|
RetailWindowHandle window = RetailWindowFrame.Mount(
|
|
screen,
|
|
layout.Root,
|
|
static _ => (0u, 0, 0),
|
|
new RetailWindowFrame.Options
|
|
{
|
|
WindowName = "vendor-fixture-smoke",
|
|
Chrome = RetailWindowChrome.Imported,
|
|
Visible = false,
|
|
});
|
|
|
|
var objects = new ClientObjectTable();
|
|
using var itemInteraction = new ItemInteractionController(
|
|
objects,
|
|
new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
|
|
new InteractionState(),
|
|
playerGuid: static () => 0u,
|
|
sendUse: null,
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null);
|
|
VendorUiController? controller = VendorUiController.Bind(
|
|
layout,
|
|
new VendorState(),
|
|
window,
|
|
static (_, _, _, _, _) => 0u,
|
|
objects,
|
|
static () => 0u,
|
|
itemInteraction,
|
|
new SelectionState(),
|
|
new StackSplitQuantityState(),
|
|
datFont: null,
|
|
debugFont: null,
|
|
static _ => (0u, 0, 0));
|
|
|
|
Assert.NotNull(controller);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_FromRealDatFixture_BuyingAndSellingLists_ConfiguredForEmptySlotFill()
|
|
{
|
|
// G2 (vendor gate finding): the Buying/Selling pages' item strips
|
|
// never got the empty-slot fill the Items list has, so they showed
|
|
// the bare authored blue background instead. This proves the real
|
|
// LayoutDesc 0x21000012 fixture's 0x100000C5 (Buying list)/0x100000CE
|
|
// (Selling list) resolve to real UiItemList widgets and come out of
|
|
// Bind configured identically to the Items strip (F7b) — same
|
|
// single-row/horizontal-scroll/cell-size shape, fill enabled, a
|
|
// non-drag-source empty-slot factory wired, and the sibling
|
|
// scrollbar bound to the SAME list's scroll model. The lists stay
|
|
// UNPOPULATED (no AddItem call anywhere in this path) — staging is
|
|
// still deferred.
|
|
ImportedLayout layout = FixtureLoader.LoadVendor();
|
|
var screen = new UiRoot { Width = 1280f, Height = 800f };
|
|
RetailWindowHandle window = RetailWindowFrame.Mount(
|
|
screen,
|
|
layout.Root,
|
|
static _ => (0u, 0, 0),
|
|
new RetailWindowFrame.Options
|
|
{
|
|
WindowName = "vendor-fixture-smoke-2",
|
|
Chrome = RetailWindowChrome.Imported,
|
|
Visible = false,
|
|
});
|
|
|
|
var objects = new ClientObjectTable();
|
|
using var itemInteraction = new ItemInteractionController(
|
|
objects,
|
|
new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
|
|
new InteractionState(),
|
|
playerGuid: static () => 0u,
|
|
sendUse: null,
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null);
|
|
VendorUiController? controller = VendorUiController.Bind(
|
|
layout,
|
|
new VendorState(),
|
|
window,
|
|
static (_, _, _, _, _) => 0u,
|
|
objects,
|
|
static () => 0u,
|
|
itemInteraction,
|
|
new SelectionState(),
|
|
new StackSplitQuantityState(),
|
|
datFont: null,
|
|
debugFont: null,
|
|
static _ => (0u, 0, 0));
|
|
Assert.NotNull(controller);
|
|
|
|
var buyingList = Assert.IsType<UiItemList>(layout.FindElement(VendorUiController.BuyingListId));
|
|
var sellingList = Assert.IsType<UiItemList>(layout.FindElement(VendorUiController.SellingListId));
|
|
var buyingScrollbar = Assert.IsType<UiScrollbar>(
|
|
layout.FindElement(VendorUiController.BuyingScrollbarId));
|
|
var sellingScrollbar = Assert.IsType<UiScrollbar>(
|
|
layout.FindElement(VendorUiController.SellingScrollbarId));
|
|
|
|
foreach (UiItemList list in new[] { buyingList, sellingList })
|
|
{
|
|
Assert.True(list.SingleRow);
|
|
Assert.True(list.HorizontalScroll);
|
|
Assert.Equal(32f, list.CellWidth);
|
|
Assert.Equal(32f, list.CellHeight);
|
|
Assert.True(list.FillVisibleEmptySlots);
|
|
Assert.NotNull(list.EmptySlotFactory);
|
|
Assert.Equal(0, list.GetNumUIItems()); // never populated
|
|
}
|
|
|
|
Assert.Same(buyingList.Scroll, buyingScrollbar.Model);
|
|
Assert.True(buyingScrollbar.Horizontal);
|
|
Assert.Same(sellingList.Scroll, sellingScrollbar.Model);
|
|
Assert.True(sellingScrollbar.Horizontal);
|
|
}
|
|
|
|
private sealed class Harness
|
|
{
|
|
// F2/F3: a deterministic non-zero player coin total so the cost-text
|
|
// "(you have ...)" tail is assertable.
|
|
public const int DefaultPlayerCoinValue = 1500;
|
|
public const uint PlayerGuid = 0x50000001u;
|
|
|
|
public readonly VendorState State = new();
|
|
public readonly SelectionState Selection = new();
|
|
public readonly StackSplitQuantityState SplitQuantity = new();
|
|
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
|
|
public readonly ClientObjectTable Objects = new();
|
|
public readonly UiItemList ItemList = new();
|
|
public readonly UiScrollbar ItemScrollbar = new();
|
|
public readonly UiMenu TypeMenu = new();
|
|
public readonly UiText ItemNameText = new();
|
|
public readonly UiText ItemCostText = new();
|
|
public readonly UiText ItemsTab = new();
|
|
public readonly UiText BuyingTab = new();
|
|
public readonly UiText SellingTab = new();
|
|
public readonly UiElement ItemsPage = new TestElement();
|
|
public readonly UiElement BuyingPage = new TestElement();
|
|
public readonly UiElement SellingPage = new TestElement();
|
|
public readonly UiButton CloseButton;
|
|
public readonly UiButton BuyButton;
|
|
public readonly UiButton AddButton;
|
|
// Slice 6b: "Buying" tab staging widgets.
|
|
public readonly UiItemList BuyingList = new();
|
|
public readonly UiButton BuyItemButton;
|
|
public readonly UiButton BuyAllButton;
|
|
public readonly UiButton BuyClearItemButton;
|
|
public readonly UiButton BuyClearListButton;
|
|
// Slice 6c: "Selling" tab staging widgets.
|
|
public readonly UiItemList SellingList = new();
|
|
public readonly UiButton SellItemButton;
|
|
public readonly UiButton SellAllButton;
|
|
public readonly UiButton SellClearItemButton;
|
|
public readonly UiButton SellClearListButton;
|
|
public readonly RetailWindowHandle Window;
|
|
public readonly VendorUiController Controller;
|
|
public readonly List<uint> Examines = new();
|
|
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
|
|
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
|
|
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
|
|
public readonly List<string> SystemMessages = new();
|
|
public readonly ItemInteractionController ItemInteraction;
|
|
public readonly RetailDialogFactory Dialogs;
|
|
public ImportedLayout? ShownDialog;
|
|
|
|
public Harness()
|
|
{
|
|
// 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<uint, UiElement>
|
|
{
|
|
[VendorUiController.CloseId] = CloseButton,
|
|
[VendorUiController.ItemsTabId] = ItemsTab,
|
|
[VendorUiController.BuyingTabId] = BuyingTab,
|
|
[VendorUiController.SellingTabId] = SellingTab,
|
|
[VendorUiController.ItemsPageId] = ItemsPage,
|
|
[VendorUiController.BuyingPageId] = BuyingPage,
|
|
[VendorUiController.SellingPageId] = SellingPage,
|
|
[VendorUiController.ItemListId] = ItemList,
|
|
[VendorUiController.ItemScrollbarId] = ItemScrollbar,
|
|
[VendorUiController.TypeFilterMenuId] = TypeMenu,
|
|
[VendorUiController.ItemNameTextId] = ItemNameText,
|
|
[VendorUiController.ItemCostTextId] = ItemCostText,
|
|
[VendorUiController.BuyButtonId] = BuyButton,
|
|
[VendorUiController.AddButtonId] = AddButton,
|
|
[VendorUiController.BuyingListId] = BuyingList,
|
|
[VendorUiController.BuyItemButtonId] = BuyItemButton,
|
|
[VendorUiController.BuyAllButtonId] = BuyAllButton,
|
|
[VendorUiController.BuyClearItemButtonId] = BuyClearItemButton,
|
|
[VendorUiController.BuyClearListButtonId] = BuyClearListButton,
|
|
[VendorUiController.SellingListId] = SellingList,
|
|
[VendorUiController.SellItemButtonId] = SellItemButton,
|
|
[VendorUiController.SellAllButtonId] = SellAllButton,
|
|
[VendorUiController.SellClearItemButtonId] = SellClearItemButton,
|
|
[VendorUiController.SellClearListButtonId] = SellClearListButton,
|
|
});
|
|
|
|
Window = RetailWindowFrame.Mount(
|
|
Screen,
|
|
root,
|
|
static _ => (0u, 0, 0),
|
|
new RetailWindowFrame.Options
|
|
{
|
|
WindowName = WindowNames.Vendor,
|
|
Chrome = RetailWindowChrome.Imported,
|
|
Visible = false,
|
|
Draggable = false,
|
|
Resizable = false,
|
|
});
|
|
|
|
ItemInteraction = new ItemInteractionController(
|
|
Objects,
|
|
new RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)),
|
|
new InteractionState(),
|
|
playerGuid: static () => PlayerGuid,
|
|
sendUse: null,
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null,
|
|
sendExamine: Examines.Add,
|
|
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
|
|
{
|
|
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
|
|
return true;
|
|
},
|
|
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
|
|
{
|
|
BuyAlls.Add((vendorGuid, items, alternateCurrencyId));
|
|
return true;
|
|
},
|
|
sendSell: (vendorGuid, items) =>
|
|
{
|
|
Sells.Add((vendorGuid, items));
|
|
return true;
|
|
});
|
|
|
|
Dialogs = new RetailDialogFactory(Screen, _ =>
|
|
ShownDialog = FixtureLoader.LoadConfirmationDialog());
|
|
|
|
Controller = VendorUiController.Bind(
|
|
layout,
|
|
State,
|
|
Window,
|
|
// F5: sum every argument so a test can prove underlay/overlay/
|
|
// effects were actually forwarded (iconId-only would silently
|
|
// regress to dropping the last three).
|
|
static (_, iconId, underlay, overlay, effects) => iconId + underlay + overlay + effects,
|
|
Objects,
|
|
static () => PlayerGuid,
|
|
ItemInteraction,
|
|
Selection,
|
|
SplitQuantity,
|
|
datFont: null,
|
|
debugFont: null,
|
|
static _ => (0u, 0, 0),
|
|
dialogs: Dialogs,
|
|
systemMessage: SystemMessages.Add)!;
|
|
Screen.WindowManager.AttachController(WindowNames.Vendor, Controller);
|
|
}
|
|
}
|
|
|
|
private static VendorShopProfile Profile(
|
|
float sellRate = 1.5f, uint altCurrency = 0u, string altName = "", uint altAmount = 0u) =>
|
|
new(0u, 0u, 0u, false, 1.0f, sellRate, altCurrency, altAmount, altName);
|
|
|
|
/// <summary>
|
|
/// Slice 6c: a profile shaped for <see cref="VendorSellAcceptability"/>
|
|
/// coverage — <see cref="Profile"/>'s all-zero merchandise fields would
|
|
/// reject every real item (MaxValue=0 rejects anything with value > 0).
|
|
/// </summary>
|
|
private static VendorShopProfile SellProfile(
|
|
uint merchandiseItemTypes,
|
|
uint minValue = 0u,
|
|
uint maxValue = VendorSellAcceptability.NoLimit) =>
|
|
new(merchandiseItemTypes, minValue, maxValue, false, 1.0f, 1.5f, 0u, 0u, "");
|
|
|
|
private static string GetText(UiText text)
|
|
=> string.Concat(text.LinesProvider().Select(line => line.Text));
|
|
|
|
[Fact]
|
|
public void Bind_WiresTheScrollbarToTheItemListsScrollModel()
|
|
{
|
|
var h = new Harness();
|
|
|
|
Assert.True(h.ItemScrollbar.Horizontal);
|
|
Assert.Same(h.ItemList.Scroll, h.ItemScrollbar.Model);
|
|
}
|
|
|
|
[Fact]
|
|
public void Opened_ShowsWindowAndPopulatesFirstPresentCategoryInTableOrder()
|
|
{
|
|
var h = new Harness();
|
|
var items = new[]
|
|
{
|
|
// Food is table entry #5, Armor is #1 — retail's fixed table
|
|
// order, not insertion order, decides the default selection.
|
|
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
};
|
|
|
|
h.State.Apply(VendorGuid, Profile(), items);
|
|
|
|
Assert.True(h.Window.IsVisible);
|
|
Assert.True(h.ItemsPage.Visible);
|
|
Assert.False(h.BuyingPage.Visible);
|
|
Assert.False(h.SellingPage.Visible);
|
|
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
|
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
|
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
|
}
|
|
|
|
[Fact]
|
|
public void Closed_HidesWindowAndClearsListAndText()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
|
Assert.True(h.Window.IsVisible);
|
|
Assert.NotEqual(string.Empty, GetText(h.ItemNameText));
|
|
|
|
h.State.Close();
|
|
|
|
Assert.False(h.Window.IsVisible);
|
|
Assert.Equal(0, h.ItemList.GetNumUIItems());
|
|
Assert.Equal(string.Empty, GetText(h.ItemNameText));
|
|
Assert.Equal(string.Empty, GetText(h.ItemCostText));
|
|
Assert.Empty(h.TypeMenu.Items);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_HidesWindowAndClearsContent()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
h.State.Reset();
|
|
|
|
Assert.False(h.Window.IsVisible);
|
|
Assert.Equal(0, h.ItemList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectingItem_NotInSplitExemptMask_ShowsWholeStackPriceAndFallbackPluralName()
|
|
{
|
|
// F2/F3 (Slice 5.4 review): MissileWeapon (0x100) does NOT intersect
|
|
// the 0xDC41CB0 split-exempt mask, so retail prices/names the WHOLE
|
|
// stack, not one unit — the review flagged the OLD test (asserting a
|
|
// per-unit price + bare "Arrows") as wrong.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
|
|
{
|
|
// Value=1000 is the STACK-TOTAL wire value for a 100-unit stack;
|
|
// per-unit = 1000/100 = 10. quantity = DescStackSize = 100 (not
|
|
// exempt). SellPrice = ceil(2.0*10*100 - 0.1) = 2000
|
|
// (VendorPricing.SellPrice, ShopSystem::SellPrice pc:702107).
|
|
new VendorShopItem(
|
|
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
|
DescStackSize: 100),
|
|
});
|
|
|
|
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
|
// F2 (Slice 6 review): the display now reads the LIVE split state
|
|
// instead of recomputing a static seed. Production seeds this via
|
|
// SelectedObjectController (mounted on the toolbar, subscribed to
|
|
// SelectionState.Changed BEFORE VendorUiController — RetailUiRuntime
|
|
// MountToolbar() runs before MountVendor()); this harness doesn't
|
|
// mount that controller, so the test seeds SplitQuantity directly to
|
|
// stand in for it, matching the SAME not-exempt full-stack seed
|
|
// SelectedObjectController.ApplySelection would have computed.
|
|
h.SplitQuantity.Reset(100u, initialValue: 100u);
|
|
|
|
// No authored PluralName -> ACCWeenieObject::GetObjectName falls back
|
|
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
|
|
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
|
|
Assert.Equal(
|
|
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
|
GetText(h.ItemCostText));
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectingItem_InSplitExemptMask_ShowsPerUnitPriceAndSingularName()
|
|
{
|
|
// F2/F3: Food (0x20) DOES intersect the split-exempt mask, so
|
|
// quantity is forced to 1 even though the item has a larger
|
|
// authored stack — the mask-member case the review asked for.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
|
|
{
|
|
// perUnit = 500/50 = 10. quantity forced to 1 (Food is masked).
|
|
// SellPrice = ceil(2.0*10*1 - 0.1) = 20.
|
|
new VendorShopItem(
|
|
FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 500,
|
|
DescStackSize: 50),
|
|
});
|
|
|
|
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
|
// F2: exempt seed is 1 regardless of the 50-unit authored stack —
|
|
// see the split-exempt comment on the sibling test above for why
|
|
// this harness seeds SplitQuantity explicitly.
|
|
h.SplitQuantity.Reset(50u, initialValue: 1u);
|
|
|
|
Assert.Equal("Bread", GetText(h.ItemNameText));
|
|
Assert.Equal(
|
|
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
|
GetText(h.ItemCostText));
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectingItem_WithAuthoredPluralName_UsesItInsteadOfSingular()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(
|
|
StackedItemGuid, -1, 3u, "Iron Key", (uint)ItemType.Key, 50u, 100,
|
|
DescStackSize: 10, PluralName: "Iron Keys"),
|
|
});
|
|
|
|
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
|
// F2: Key is not split-exempt -> full authored stack (10).
|
|
h.SplitQuantity.Reset(10u, initialValue: 10u);
|
|
|
|
Assert.Equal("10 Iron Keys", GetText(h.ItemNameText));
|
|
}
|
|
|
|
[Fact]
|
|
public void SliderChange_AfterSelection_UpdatesNameAndPriceToLiveQuantity_MatchingWhatBuyWouldCharge()
|
|
{
|
|
// F2 (Slice 6 review): retail re-runs UpdateItemsUI on every slider
|
|
// change (gmVendorUI::RecvNotice_StackSliderChanged,
|
|
// pc:203262-203278) -- the OLD implementation froze the display's
|
|
// quantity at a static per-selection seed while BuySelectedItem
|
|
// separately read the LIVE split, so a slider drag after selecting
|
|
// never updated the sentence/price and could show a DIFFERENT price
|
|
// than what Buy would actually charge. Both now go through the SAME
|
|
// ResolveBuyQuantity, proven here by asserting the displayed price
|
|
// equals the amount the subsequent Buy press actually sends.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
|
|
{
|
|
// perUnit = 1000/100 = 10 (MissileWeapon, not split-exempt).
|
|
new VendorShopItem(
|
|
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
|
DescStackSize: 100),
|
|
});
|
|
// F6 auto-selects the sole item; the harness's un-mounted-toolbar
|
|
// split state starts at its class default (Value=1/Maximum=1) —
|
|
// singular display, matching a fresh, not-yet-slider-touched
|
|
// selection.
|
|
Assert.Equal("Arrows", GetText(h.ItemNameText));
|
|
Assert.Equal(
|
|
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
|
GetText(h.ItemCostText));
|
|
|
|
// Player drags the slider to 40 AFTER selecting -- no re-click, no
|
|
// re-selection, purely a StackSplitQuantityState.Changed event.
|
|
h.SplitQuantity.Reset(100u, initialValue: 40u);
|
|
|
|
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
|
|
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
|
|
Assert.Equal(
|
|
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
|
GetText(h.ItemCostText));
|
|
|
|
h.BuyButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(40, h.Buys.Single().Amount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectingItem_WithAlternateCurrency_ShowsFullRetailCostSentence()
|
|
{
|
|
// F2/F3: the OLD implementation just appended the currency name after
|
|
// a bare number ("50 Trade Notes"). Retail's actual alt-currency
|
|
// sentence is a full two-clause sentence with a "you have" tail
|
|
// (acclient_2013_pseudo_c.txt:991314), using RAW (non-comma-grouped)
|
|
// integers — unlike the primary-currency branch.
|
|
var h = new Harness();
|
|
h.State.Apply(
|
|
VendorGuid,
|
|
Profile(sellRate: 1.0f, altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 12345u),
|
|
new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 50),
|
|
});
|
|
|
|
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
|
|
|
// Armor (not in the split-exempt mask) with DescStackSize null ->
|
|
// PerUnitValue returns Value unchanged (50); quantity = 1 (no
|
|
// authored stack) -> SellPrice(50, Armor, 1.0, 1) = ceil(50-0.1) = 50.
|
|
Assert.Equal(
|
|
"This item costs 50 Trade Notes. You have 12345 Trade Notes.",
|
|
GetText(h.ItemCostText));
|
|
}
|
|
|
|
[Fact]
|
|
public void NoSelection_DisablesBuyButton_SelectionEnablesIt()
|
|
{
|
|
var h = new Harness();
|
|
Assert.False(h.BuyButton.Enabled);
|
|
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
// F4 auto-selects the sole item on open, so Buy is already enabled
|
|
// — SetState(1), pc:202784-202789.
|
|
Assert.True(h.BuyButton.Enabled);
|
|
|
|
// Closing clears the selection -> SetState(0xd), pc:202572-202577.
|
|
h.State.Close();
|
|
Assert.False(h.BuyButton.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddButton_EnablesWithSelection_NowThatStagingIsWired()
|
|
{
|
|
// Slice 6b (AP-161 F8 residual closes): "Add to List" now stages
|
|
// into the "Buying" tab and enables with selection exactly like Buy
|
|
// — an enabled Add is no longer a dead affordance.
|
|
var h = new Harness();
|
|
Assert.False(h.AddButton.Enabled);
|
|
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
// F4/F6 auto-selects the sole item on open -- Add now enables too.
|
|
Assert.True(h.AddButton.Enabled);
|
|
|
|
h.State.Close();
|
|
Assert.False(h.AddButton.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShopItem_WithIconUnderlayOverlayEffects_ForwardsThemToResolveIcon()
|
|
{
|
|
// F5: PublicWeenieDescBody already carries these three; the review
|
|
// flagged VendorUiController hardcoding 0u/0u/0u instead of
|
|
// forwarding item.IconUnderlayId/IconOverlayId/Effects.
|
|
var h = new Harness();
|
|
var item = new VendorShopItem(
|
|
ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, IconId: 200u, Value: 500,
|
|
IconUnderlayId: 10u, IconOverlayId: 100u, Effects: 1000u);
|
|
h.State.Apply(VendorGuid, Profile(), new[] { item });
|
|
|
|
// Harness's resolveIcon sums every argument (see its comment) — this
|
|
// regresses to 200u alone if the three are dropped again.
|
|
Assert.Equal(200u + 10u + 100u + 1000u, h.ItemList.GetItem(0)!.IconTexture);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectingDifferentCategory_FiltersItemListToThatCategoryOnly()
|
|
{
|
|
var h = new Harness();
|
|
var armor = new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500);
|
|
var food = new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5);
|
|
h.State.Apply(VendorGuid, Profile(), new[] { armor, food });
|
|
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); // default: Armor (table order)
|
|
|
|
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
|
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
|
|
|
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
|
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectingDifferentCategory_AutoSelectsFirstFilteredItem()
|
|
{
|
|
// F4: VendorItemsUI::UpdateItemsList's tail (pc:201180-201190) —
|
|
// after a category switch the first item that passed the filter
|
|
// becomes the display selection, not a cleared blank.
|
|
var h = new Harness();
|
|
var armor = new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500);
|
|
var food = new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5);
|
|
h.State.Apply(VendorGuid, Profile(), new[] { armor, food });
|
|
|
|
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
|
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
|
|
|
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
|
Assert.True(h.ItemList.GetItem(0)!.Selected);
|
|
Assert.NotEqual(string.Empty, GetText(h.ItemNameText));
|
|
Assert.NotEqual(string.Empty, GetText(h.ItemCostText));
|
|
Assert.True(h.BuyButton.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectingDifferentCategory_ResetsListScrollToStart()
|
|
{
|
|
// F7a: VendorItemsUI::UpdateItemsList's tail (pc:201186-201190) —
|
|
// ScrollToShow(m_shopList, 0) fires unconditionally on every rebuild.
|
|
var h = new Harness();
|
|
var armor = new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500);
|
|
var food = new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5);
|
|
h.State.Apply(VendorGuid, Profile(), new[] { armor, food });
|
|
|
|
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
|
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
|
|
|
Assert.Equal(0, h.ItemList.Scroll.ScrollY);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemList_FillsVisibleEmptySlots()
|
|
{
|
|
// F7b: mirrors ExternalContainerController.ConfigureList's empty-slot
|
|
// fill so the authored 22-slot strip doesn't show blank space.
|
|
var h = new Harness();
|
|
|
|
Assert.True(h.ItemList.FillVisibleEmptySlots);
|
|
Assert.NotNull(h.ItemList.EmptySlotFactory);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShopRow_NeverMintsADragPayload()
|
|
{
|
|
// F3 (Slice 6 review): with shop items materialized into
|
|
// ClientObjectTable (Slice 6.1), a draggable vendor cell would pass
|
|
// every destination handler's existence guard -- a pack drop would
|
|
// send a real PutItemInContainer for vendor stock, the shortcut bar
|
|
// would persist a dangling guid, and PlaceIn3D would fire. Retail
|
|
// excludes vendor/salvage lists from ItemList_BeginDrag entirely; a
|
|
// populated shop row must never become a drag source at all.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
UiItemSlot cell = h.ItemList.GetItem(0)!;
|
|
|
|
Assert.NotEqual(0u, cell.ItemId); // occupied -- would otherwise be a drag source by default
|
|
Assert.False(cell.IsDragSource);
|
|
Assert.Null(cell.GetDragPayload());
|
|
// G3 (vendor gate finding): a row must still CAPTURE its own press
|
|
// even though it never mints a drag payload -- see
|
|
// UiItemSlot.HandlesClick and the real-event-path test below.
|
|
Assert.True(cell.HandlesClick);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShopRow_ClickEvent_SelectsItem_DespiteNotBeingADragSource()
|
|
{
|
|
// G3 (vendor gate finding): the F3 drag-suppression fix
|
|
// (AllowDragSource=false) left occupied vendor rows with
|
|
// IsDragSource==false -- before the G3 fix that meant UiRoot's
|
|
// mousedown dispatch found no reason to claim the press at all, so
|
|
// it fell through to the window-move fallback (hover showed the
|
|
// move-window cursor; a press dragged the whole panel instead of
|
|
// selecting a row). This drives the REAL UiItemSlot.OnEvent state
|
|
// machine (MouseDown then Click), the same sequence UiRoot's
|
|
// dispatch produces, rather than invoking the wired Clicked
|
|
// delegate directly -- proving the row still completes a press then
|
|
// click and drives selection.
|
|
const uint SecondArmorGuid = 0x60000110u;
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(SecondArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
|
|
});
|
|
Assert.Equal(ArmorItemGuid, h.Selection.SelectedObjectId); // auto-selected first
|
|
|
|
UiItemSlot secondCell = h.ItemList.GetItem(1)!;
|
|
Assert.Equal(SecondArmorGuid, secondCell.ItemId);
|
|
Assert.False(secondCell.IsDragSource); // never a drag source (F3)
|
|
Assert.True(secondCell.HandlesClick); // but still claims its own press (G3)
|
|
|
|
secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.MouseDown));
|
|
secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.Click));
|
|
|
|
Assert.Equal(SecondArmorGuid, h.Selection.SelectedObjectId);
|
|
Assert.True(secondCell.Selected);
|
|
}
|
|
|
|
[Fact]
|
|
public void Opened_WithDifferentVendor_ResetsToFirstPresentCategory_NotThePreviousVendors()
|
|
{
|
|
// F6: gmVendorUI::OpenVendor flushes sub-UIs when sameVendor==0
|
|
// (pc:203664-203667) — a different vendor must not inherit whatever
|
|
// category index the LAST vendor happened to have selected.
|
|
const uint OtherVendorGuid = 0x70000020u;
|
|
const uint OtherArmorGuid = 0x60000201u;
|
|
const uint OtherFoodGuid = 0x60000202u;
|
|
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
|
});
|
|
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
|
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
|
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
|
|
|
// A DIFFERENT vendor, which ALSO has >=2 categories (Armor then
|
|
// Food, table order) so a stale numeric index (1) would silently
|
|
// "validate" without the fix and land on Food again by coincidence
|
|
// rather than by the vendor's own contents.
|
|
h.State.Apply(OtherVendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(OtherArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
|
|
new VendorShopItem(OtherFoodGuid, -1, 5u, "Ale", (uint)ItemType.Food, 100u, 3),
|
|
});
|
|
|
|
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
|
Assert.Equal(OtherArmorGuid, h.ItemList.GetItem(0)!.ItemId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Refreshed_SameVendor_PreservesCategorySelection()
|
|
{
|
|
// F6 counterpart: gmVendorUI::OpenVendor's sameVendor==1 path does
|
|
// NOT flush sub-UIs, so a same-vendor refresh (Slice 6 post-buy/sell)
|
|
// must keep the player's chosen category.
|
|
var h = new Harness();
|
|
var items = new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
|
};
|
|
h.State.Apply(VendorGuid, Profile(), items);
|
|
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
|
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
|
|
|
// SAME vendor id re-approaches -> VendorStateTransitionKind.Refreshed.
|
|
h.State.Apply(VendorGuid, Profile(), items);
|
|
|
|
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
|
}
|
|
|
|
[Fact]
|
|
public void Refreshed_SameVendor_ReselectsFirstItemUnconditionally_NotThePreviouslySelectedSurvivor()
|
|
{
|
|
// F6 (Slice 6 review): retail's VendorItemsUI::UpdateItemsList
|
|
// selects UNCONDITIONALLY on every rebuild call site this
|
|
// controller reaches (pc:201180-201184, confirmed via
|
|
// VendorItemsUI::OpenVendor's unconditional clamp +
|
|
// SetSelectedItem(...,1) at pc:201022, which every vendor
|
|
// open/refresh runs regardless of the sameVendor flag) -- there is
|
|
// NO survival test. A same-vendor refresh that re-lists the SAME
|
|
// two items in the SAME order must reset to the FIRST one even
|
|
// though the player's prior selection (the second item) is still
|
|
// present in the new snapshot -- this is the case that would have
|
|
// diverged under the OLD "preserve if it survives" logic.
|
|
const uint SecondArmorGuid = 0x60000110u;
|
|
var h = new Harness();
|
|
var items = new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(SecondArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
|
|
};
|
|
h.State.Apply(VendorGuid, Profile(), items);
|
|
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); // auto-selected first
|
|
|
|
// Player explicitly picks the SECOND item.
|
|
h.ItemList.GetItem(1)!.Clicked?.Invoke();
|
|
Assert.True(h.ItemList.GetItem(1)!.Selected);
|
|
|
|
// SAME vendor, SAME two items, SAME order -> Refreshed transition.
|
|
h.State.Apply(VendorGuid, Profile(), items);
|
|
|
|
// Retail resets to the FIRST item, not the still-present second one.
|
|
Assert.True(h.ItemList.GetItem(0)!.Selected);
|
|
Assert.False(h.ItemList.GetItem(1)!.Selected);
|
|
Assert.Equal("Chainmail", GetText(h.ItemNameText));
|
|
}
|
|
|
|
[Fact]
|
|
public void CategoryMenu_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry()
|
|
{
|
|
// F1: the popup never rendered because SpriteResolve/fonts/sprites
|
|
// were never wired, and the geometry was chat's own authored values
|
|
// instead of the vendor dropdown's. This drives the SAME UiEvent
|
|
// hit-test path UiMenuTests uses (not a direct OnSelect call), and
|
|
// reads the geometry back off the live widget instead of hardcoding
|
|
// pixel offsets tied to a specific constant set.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
|
});
|
|
|
|
// Wiring: without these UiMenu.OnDrawOverlay early-returns (nothing
|
|
// renders) and the button face never draws either.
|
|
Assert.NotNull(h.TypeMenu.SpriteResolve);
|
|
Assert.NotEqual(0u, h.TypeMenu.NormalSprite);
|
|
Assert.NotEqual(0u, h.TypeMenu.ItemNormalSprite);
|
|
Assert.NotEqual(0u, h.TypeMenu.ItemHighlightSprite);
|
|
|
|
// Geometry from the authored template (LayoutDesc 0x21000043's row
|
|
// element 0x10000352 and ListBox 0x10000350), NOT chat's 7/17/191.
|
|
Assert.Equal(6, h.TypeMenu.RowsPerColumn);
|
|
Assert.Equal(18f, h.TypeMenu.RowHeight);
|
|
Assert.Equal(100f, h.TypeMenu.ColumnWidth);
|
|
|
|
// Open via the real widget event path.
|
|
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5)));
|
|
|
|
// "Food" is the 2nd present category in table order (Armor, then
|
|
// Food) -> row index 1, column 0. Derive the click point from the
|
|
// widget's own live geometry (mirrors UiMenu.OnEvent's own math)
|
|
// rather than a hardcoded pixel constant.
|
|
//
|
|
// G7: the popup opens DOWNWARD (retail authors no bool attribute 5
|
|
// on 0x100000BF — see the class doc's "G7" paragraph), so its top
|
|
// sits at the button's own bottom edge (ly = Height), not -OuterH.
|
|
const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness)
|
|
const int targetRow = 1;
|
|
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
|
|
float ly = h.TypeMenu.Height + iy + border;
|
|
|
|
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)ly)));
|
|
|
|
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
|
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
|
}
|
|
|
|
[Fact]
|
|
public void CategoryMenu_OpensDownward_NotUpward_MatchingTheAuthoredAbsentAttribute5()
|
|
{
|
|
// G7: retail's UIElement_Menu::Open (pc:120210-120252) reads a PER-MENU
|
|
// bool attribute 5 to decide direction; it defaults ABSENT to false
|
|
// (pc:106749-106778) which places the popup at the button's own bottom
|
|
// edge. Element 0x100000BF's resolved attribute bag carries no
|
|
// property "5" at all (unlike chat's, which authors it true) — so
|
|
// OpenUpward must be false here, and the OLD upward click position
|
|
// (used before this fix) must no longer resolve anything.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
|
});
|
|
|
|
Assert.False(h.TypeMenu.OpenUpward);
|
|
|
|
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5))); // open
|
|
|
|
// The OLD (pre-fix) upward click position for row 1 ("Food") — see the
|
|
// math this same test used before G7 — now lands above the button,
|
|
// where nothing lives; UiMenu treats it as an ordinary button click
|
|
// and just re-closes the still-open menu instead of picking a row.
|
|
const int border = 5;
|
|
float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border;
|
|
const int targetRow = 1;
|
|
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
|
|
float oldUpwardLy = iy - outerH + border;
|
|
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)oldUpwardLy)));
|
|
|
|
// Selection is unchanged (still Armor, the fresh-open default) and the
|
|
// item list was not re-scoped to Food — proving the old position no
|
|
// longer hits the popup at all.
|
|
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
|
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
|
}
|
|
|
|
[Fact]
|
|
public void CategoryMenu_ArrowCapSprites_AreWiredAndDistinctForOpenVsClosed()
|
|
{
|
|
// G6: retail's authored arrow-cap element 0x1000034E flips between its
|
|
// Normal (closed, 0x060012B1) and Highlight (open, 0x060012B2) states —
|
|
// confirm the controller actually wires both, non-zero and distinct,
|
|
// and that opening the dropdown flips CurrentArrowCapSprite the same
|
|
// way UiMenuTests exercises the mechanism generically.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
Assert.NotEqual(0u, h.TypeMenu.ArrowCapClosedSprite);
|
|
Assert.NotEqual(0u, h.TypeMenu.ArrowCapOpenSprite);
|
|
Assert.NotEqual(h.TypeMenu.ArrowCapClosedSprite, h.TypeMenu.ArrowCapOpenSprite);
|
|
|
|
Assert.Equal(h.TypeMenu.ArrowCapClosedSprite, h.TypeMenu.CurrentArrowCapSprite); // closed by default
|
|
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5))); // open
|
|
Assert.Equal(h.TypeMenu.ArrowCapOpenSprite, h.TypeMenu.CurrentArrowCapSprite);
|
|
}
|
|
|
|
[Fact]
|
|
public void CategoryMenu_TextIndentsAreFlushLeft_NotChatsCheckboxLedOffsets()
|
|
{
|
|
// G8: vendor's row template (0x10000352, live-dat HJustify=Left) has
|
|
// no checkbox child and its button-label child (0x1000034D) has no
|
|
// LED art either — both indents must be 0, not chat's 19px/20px.
|
|
var h = new Harness();
|
|
Assert.Equal(0f, h.TypeMenu.TextIndent);
|
|
Assert.Equal(0f, h.TypeMenu.ButtonTextIndent);
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseButton_HidesTheWindowOnly_LeavesTheSessionOpenForARefreshInPlaceReopen()
|
|
{
|
|
// G4 (vendor gate finding): retail's close/pushpin button —
|
|
// gmVendorUI::HandleButtonClicks's 0x100000d6 case (pc:204147-204182)
|
|
// — with nothing staged in the Buying/Selling lists (this port never
|
|
// stages anything; Slice 6 territory) calls ONLY SetVisible(0),
|
|
// never gmVendorUI::CloseVendor (pc:202080, the range-watcher-
|
|
// unregister/session-teardown function VendorState.Close ports).
|
|
// The OLD port called VendorState.Close() directly from this button
|
|
// — an over-eager full teardown retail does not perform on an
|
|
// ordinary close. RuntimeVendorRangeQuery.EnforceRange (evaluated
|
|
// every frame regardless of window visibility) remains the sole
|
|
// path to a full close once the player actually leaves UseRadius —
|
|
// see Closed_HidesWindowAndClearsListAndText for that path,
|
|
// unaffected by this change since it calls VendorState.Close()
|
|
// directly rather than through this button.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
h.CloseButton.OnClick!.Invoke();
|
|
|
|
Assert.False(h.Window.IsVisible);
|
|
// Unlike the OLD port, the session itself is NOT torn down — the
|
|
// owner still reports the same open vendor in the background,
|
|
// matching retail's hidden-but-still-registered range watcher.
|
|
Assert.Equal(VendorGuid, h.State.VendorId);
|
|
|
|
// Re-approaching the SAME vendor (e.g. pressing Use again while
|
|
// still in range) now reaches retail's sameVendor==1 refresh-in-
|
|
// place path (VendorStateTransitionKind.Refreshed) instead of a
|
|
// from-scratch Opened, and reopens the window.
|
|
var kinds = new List<VendorStateTransitionKind>();
|
|
h.State.Changed += t => kinds.Add(t.Kind);
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
Assert.True(h.Window.IsVisible);
|
|
Assert.Equal([VendorStateTransitionKind.Refreshed], kinds);
|
|
}
|
|
|
|
[Fact]
|
|
public void SellingTab_SwitchesPageAndStartsWithAnEmptyStagedSellList()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
h.SellingTab.OnClick!.Invoke();
|
|
|
|
Assert.True(h.SellingPage.Visible);
|
|
Assert.False(h.ItemsPage.Visible);
|
|
Assert.False(h.BuyingPage.Visible);
|
|
// Slice 6c: the Selling tab's own widgets are wired now (staging
|
|
// list + four buttons), but nothing is STAGED without a drag/drop —
|
|
// the list itself stays empty.
|
|
Assert.NotEmpty(h.SellingPage.Children);
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyingTab_SwitchesPageAndStartsWithAnEmptyStagedBuyList()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
h.BuyingTab.OnClick!.Invoke();
|
|
|
|
Assert.True(h.BuyingPage.Visible);
|
|
Assert.False(h.ItemsPage.Visible);
|
|
// Slice 6b: the Buying tab's own widgets are wired now (staging list
|
|
// + four buttons), but nothing is STAGED until "Add to List" is
|
|
// pressed — the list itself stays empty.
|
|
Assert.NotEmpty(h.BuyingPage.Children);
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemsTab_ReselectedAfterVisitingOtherTabs_ShowsBrowseListAgain()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.SellingTab.OnClick!.Invoke();
|
|
|
|
h.ItemsTab.OnClick!.Invoke();
|
|
|
|
Assert.True(h.ItemsPage.Visible);
|
|
Assert.False(h.SellingPage.Visible);
|
|
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void RightClickShopRow_SelectsAndRoutesThroughItemInteractionExamine()
|
|
{
|
|
// Slice 6.1: mirrors ExternalContainerControllerTests'
|
|
// RightClickLoot_selectsAndExaminesWithoutPickingUp — the shop list
|
|
// wires ExamineItemRequested the same way the container lists do,
|
|
// now that shop items are materialized into ClientObjectTable
|
|
// (AP-161 finding #2's examine gap closes here).
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
|
});
|
|
// Food, not the auto-selected Armor default, so the assertion below
|
|
// proves the right click itself drove the selection.
|
|
UiItemSlot? foodCell = null;
|
|
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
|
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
|
foodCell = h.ItemList.GetItem(0);
|
|
Assert.Equal(FoodItemGuid, foodCell!.ItemId);
|
|
|
|
foodCell.OnEvent(new UiEvent(0u, foodCell, UiEventType.RightClick));
|
|
|
|
Assert.Equal(new[] { FoodItemGuid }, h.Examines);
|
|
Assert.Equal("Bread", GetText(h.ItemNameText));
|
|
Assert.True(foodCell.Selected);
|
|
}
|
|
|
|
// ── Slice 6.3: Buy button ────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void BuyButton_Press_NonStackedItem_BuysQuantityOne()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
// F4 auto-selected the sole item; its own stack size (default/absent)
|
|
// is <=1, so StackSplitQuantityState was never seeded above 1 either
|
|
// (this harness doesn't mount SelectedObjectController, so the split
|
|
// state starts at its class default Value=1/Maximum=1 — exactly what
|
|
// production would also show for a non-stacked item).
|
|
|
|
h.BuyButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(
|
|
new[] { (VendorGuid, ArmorItemGuid, 1, 0u) },
|
|
h.Buys);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyButton_Press_StackedItem_UsesTheLiveSplitSliderQuantity()
|
|
{
|
|
// Slice 6.3: BuySingleItem (pc:201674-201681) reads the CURRENT
|
|
// slider value, not the full stack. This harness doesn't mount
|
|
// SelectedObjectController (the toolbar owns that seeding in
|
|
// production), so the test seeds SplitQuantity directly to stand in
|
|
// for "the player selected this item, then dragged the slider to
|
|
// 25" before pressing Buy.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(
|
|
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
|
DescStackSize: 100),
|
|
});
|
|
h.SplitQuantity.Reset(100u, initialValue: 25u);
|
|
|
|
h.BuyButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(25, h.Buys.Single().Amount);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyButton_Press_NonStackedItem_IgnoresAStaleSliderFromAPreviouslySelectedStackableItem()
|
|
{
|
|
// F7 (Slice 6 review): retail's BuySingleItem (pc:201674-201681)
|
|
// gates on the SELECTED item's OWN stack size FIRST -- quantity 1
|
|
// whenever it is <=1, regardless of whatever
|
|
// GenItemHolder::splitSize still holds from a PREVIOUSLY selected,
|
|
// DIFFERENT, stackable item. Without this guard,
|
|
// ResolveBuyQuantity's GetObjectSplitSize(item.ItemGuid, selected,
|
|
// stackSize) call trivially returns the live Value for ANY selected
|
|
// item (item.ItemGuid always equals the selection guid at this call
|
|
// site) -- leaking a stale quantity into a non-stack purchase.
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
// Simulate a stale slider left over from a PREVIOUSLY selected,
|
|
// different, stackable item -- this harness doesn't mount
|
|
// SelectedObjectController, which in production would already have
|
|
// reset the slider on selecting the (non-stack) armor; seeding it
|
|
// directly here proves the clamp doesn't depend on that reset
|
|
// having run.
|
|
h.SplitQuantity.Reset(50u, initialValue: 30u);
|
|
|
|
h.BuyButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(1, h.Buys.Single().Amount);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyButton_Press_AlternateCurrencyVendor_ForwardsTheVendorsTradeWcid()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(
|
|
VendorGuid,
|
|
Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 500u),
|
|
new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
h.BuyButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(0x12345678u, h.Buys.Single().AlternateCurrencyId);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyButton_DisablesTheInstantAPurchaseIsInFlight_AndReenablesOnCompletion()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
Assert.True(h.BuyButton.Enabled);
|
|
|
|
h.BuyButton.OnClick!.Invoke();
|
|
|
|
// TryBuy's reservation increments BusyCount synchronously, before
|
|
// any wire response — the button must reflect that immediately,
|
|
// with no per-frame polling (ItemInteractionController.StateChanged
|
|
// drives RecomputeBuyButtonEnabled).
|
|
Assert.False(h.BuyButton.Enabled);
|
|
|
|
h.ItemInteraction.CompleteUse(0);
|
|
|
|
Assert.True(h.BuyButton.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyButton_NoSelection_PressDoesNothing()
|
|
{
|
|
var h = new Harness();
|
|
|
|
h.BuyButton.OnClick!.Invoke();
|
|
|
|
Assert.Empty(h.Buys);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconciliationRoundTrip_MoneyCreateObjectAndApproachVendorRefresh_FlowThroughExistingMachinery()
|
|
{
|
|
// Slice 6.3: verifies the loop end-to-end with synthetic inbound
|
|
// messages, adding no new owner (research doc §C.4/§A.2 point 4):
|
|
// (1) a money property update applies to the SAME ClientObjectTable
|
|
// the vendor panel reads live for its cost text,
|
|
// (2) the purchase lands in the player's inventory via the ordinary
|
|
// CreateObject merge-upsert (ClientObjectTable.Ingest),
|
|
// (3) the vendor's post-buy ApproachVendor refresh
|
|
// (VendorStateTransitionKind.Refreshed) rebuilds the panel —
|
|
// here the bought-out item leaves the vendor's stock entirely
|
|
// (the common single-item-purchase case), so the rebuild is
|
|
// externally observable: the item leaves the list and the
|
|
// selection/buttons clear.
|
|
var h = new Harness();
|
|
const uint PurchasedItemGuid = 0x60000900u;
|
|
h.State.Apply(VendorGuid, Profile(sellRate: 1.0f), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
|
Assert.True(h.BuyButton.Enabled);
|
|
|
|
h.BuyButton.OnClick!.Invoke();
|
|
Assert.Single(h.Buys);
|
|
Assert.False(h.BuyButton.Enabled);
|
|
|
|
// (1) Money: PrivateUpdatePropertyInt(CoinValue) — the exact path
|
|
// ObjectTableWiring's PlayerIntPropertyUpdated handler calls.
|
|
int newCoinValue = Harness.DefaultPlayerCoinValue - 500;
|
|
h.Objects.UpdateIntProperty(Harness.PlayerGuid, (uint)PropertyInt.CoinValue, newCoinValue);
|
|
|
|
// (2) CreateObject: the purchased item lands in the player's own
|
|
// inventory — the ordinary Ingest merge-upsert every CreateObject
|
|
// uses (ObjectTableWiring.ApplyEntitySpawn), landing here with the
|
|
// player as its container.
|
|
h.Objects.Ingest(new WeenieData(
|
|
Guid: PurchasedItemGuid,
|
|
Name: "Chainmail",
|
|
Type: ItemType.Armor,
|
|
WeenieClassId: 2u,
|
|
IconId: 200u,
|
|
IconOverlayId: 0u,
|
|
IconUnderlayId: 0u,
|
|
Effects: 0u,
|
|
Value: 500,
|
|
StackSize: null,
|
|
StackSizeMax: null,
|
|
Burden: null,
|
|
ContainerId: Harness.PlayerGuid,
|
|
WielderId: 0u,
|
|
ValidLocations: null,
|
|
CurrentWieldedLocation: null,
|
|
Priority: null,
|
|
ItemsCapacity: null,
|
|
ContainersCapacity: null,
|
|
Structure: null,
|
|
MaxStructure: null,
|
|
Workmanship: null));
|
|
|
|
// (3) ApproachVendor refresh: sold out of the ONLY armor stack, so
|
|
// the SAME vendor's next snapshot no longer lists it.
|
|
h.State.Apply(VendorGuid, Profile(sellRate: 1.0f), System.Array.Empty<VendorShopItem>());
|
|
|
|
// (4) UseDone completes the reservation.
|
|
h.ItemInteraction.CompleteUse(0);
|
|
|
|
Assert.Equal(newCoinValue, h.Objects.Get(Harness.PlayerGuid)?.Properties.GetInt((uint)PropertyInt.CoinValue));
|
|
Assert.Equal(Harness.PlayerGuid, h.Objects.Get(PurchasedItemGuid)?.ContainerId);
|
|
Assert.Equal(0, h.ItemList.GetNumUIItems());
|
|
Assert.Equal(string.Empty, GetText(h.ItemNameText));
|
|
Assert.False(h.BuyButton.Enabled);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Slice 6b — Buying tab staging (Add to List, Buy Item, Buy All, Clear)
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public void AddToBuyList_StagesTheSelectedItem()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
// F4/F6 auto-selects the sole item on open.
|
|
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddToBuyList_ReAddingTheSameItemUpsertsRatherThanDuplicatingTheRow()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void AddToBuyList_NothingSelected_IsANoOp()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
|
|
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2 (Slice 6b/6c review, byte-verified <c>pc:202934</c>): re-adding an
|
|
/// already-staged item ACCUMULATES onto the existing staged quantity —
|
|
/// a prior version of this port upserted/overwrote. Observed through a
|
|
/// subsequent Buy All send (the one path that reads
|
|
/// <see cref="VendorStagingEntry.Quantity"/> directly, unlike Buy
|
|
/// Item's own live-slider read).
|
|
/// </summary>
|
|
[Fact]
|
|
public void AddToBuyList_ReAddingTheSameStackableItem_AccumulatesTheStagedQuantity()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(
|
|
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
|
DescStackSize: 100),
|
|
});
|
|
h.SplitQuantity.Reset(100u, initialValue: 10u);
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.SplitQuantity.SetValue(15u);
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems()); // one row, not two
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
|
|
Assert.Equal(new (int Amount, uint ItemGuid)[] { (25, StackedItemGuid) }, items);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2: retail's 5000-unit cap (<c>VendorStagingList.MaxStagedQuantity</c>)
|
|
/// on an ACCUMULATE rejects with its own notice and leaves the entry
|
|
/// unchanged.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AddToBuyList_AccumulatingPastTheCap_ShowsRetailsNoticeAndLeavesStagingUnchanged()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(
|
|
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
|
DescStackSize: 100),
|
|
});
|
|
h.SplitQuantity.Reset(5000u, initialValue: 4990u);
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.SplitQuantity.Reset(5000u, initialValue: 20u);
|
|
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(new[] { VendorStagingList.TooMuchMessage }, h.SystemMessages);
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
// Boost the player's coin total so a follow-up Buy All send is only
|
|
// gated by staging content, not F1's affordability guard, proving
|
|
// the rejected accumulate above left the entry at its PRE-add value.
|
|
h.Objects.Get(Harness.PlayerGuid)!.Properties.Ints[(uint)PropertyInt.CoinValue] = 10_000_000;
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
|
|
Assert.Equal(4990, items.Single().Amount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2 (Slice 6b/6c review): port of <c>VendorItemsUI::RemoveFromShop</c>
|
|
/// (<c>0x004c3ce0</c>) — staging the vendor's ENTIRE limited supply of an
|
|
/// item hides its Items-tab row (matching retail's <c>DeleteItem</c>),
|
|
/// and un-staging restores it — <see cref="AvailableShopQuantity"/> is
|
|
/// recomputed fresh from the untouched snapshot each time, so no
|
|
/// separate "restore" code path is needed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void StagingConsumesLimitedShopSupply_HidingTheRowWhenExhaustedAndRestoringItWhenUnstaged()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, 2, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
|
|
});
|
|
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
|
|
|
h.AddButton.OnClick!.Invoke(); // stages 1 of 2 available
|
|
Assert.Equal(1, h.ItemList.GetNumUIItems()); // still visible — 1 remains
|
|
|
|
h.AddButton.OnClick!.Invoke(); // stages 2 of 2 available — exhausted
|
|
|
|
Assert.Equal(0, h.ItemList.GetNumUIItems()); // row hidden
|
|
Assert.Null(h.Selection.SelectedObjectId); // selection cleared, matching RemoveFromShop's own SetSelectedObject(0,0)
|
|
|
|
h.BuyClearListButton.OnClick!.Invoke(); // un-stage everything
|
|
|
|
Assert.Equal(1, h.ItemList.GetNumUIItems()); // row restored
|
|
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
|
}
|
|
|
|
/// <summary>F2: retail's <c>var_c != 0xffffffff</c> guard — unlimited stock (StackSize == -1) is never hidden.</summary>
|
|
[Fact]
|
|
public void UnlimitedSupplyShopItem_IsNeverHiddenNoMatterHowMuchIsStaged()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
|
|
});
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
|
|
});
|
|
|
|
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.ItemList.GetItem(1)!.Clicked?.Invoke();
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(2, h.BuyingList.GetNumUIItems());
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items, uint currency) =
|
|
Assert.Single(h.BuyAlls);
|
|
Assert.Equal(VendorGuid, vendorGuid);
|
|
Assert.Equal(
|
|
new (int Amount, uint ItemGuid)[] { (1, ArmorItemGuid), (1, AnotherArmorItemGuid) },
|
|
items);
|
|
Assert.Equal(0u, currency);
|
|
// Retail flushes m_buyList immediately after the send, not gated on
|
|
// a server response (pc:204075-204076) — see BuyAllButtonPressed's
|
|
// own doc comment.
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyAllButton_WithNothingStaged_IsANoOp()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
Assert.Empty(h.BuyAlls);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// F1 (Slice 6b/6c review) — Buy All's four client-side pre-send guards.
|
|
// Each blocking guard leaves staging fully intact; the existing
|
|
// BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess
|
|
// test above is the "all four pass" case (it already exercises the
|
|
// Harness's 102 item / 7 container capacity headroom).
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public void BuyAllButton_InsufficientPyrealFunds_BlocksWithRetailsNoticeAndStagingIntact()
|
|
{
|
|
var h = new Harness();
|
|
// Value=2000, no DescStackSize -> perUnit=2000; SellPrice(2000, Armor,
|
|
// 1.5, 1) = ceil(3000 - 0.1) = 3000, well above the Harness's 1500 coin default.
|
|
h.State.Apply(VendorGuid, Profile(sellRate: 1.5f), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2000),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
Assert.Empty(h.BuyAlls);
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyAllButton_InsufficientAltCurrency_BlocksWithRetailsNoticeAndStagingIntact()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(
|
|
VendorGuid,
|
|
Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 10u),
|
|
new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
Assert.Empty(h.BuyAlls);
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyAllButton_InsufficientContainerSlots_BlocksWithRetailsNoticeAndStagingIntact()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 0;
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
Assert.Empty(h.BuyAlls);
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyAllButton_InsufficientItemSlots_BlocksWithRetailsNoticeAndStagingIntact()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.Objects.Get(Harness.PlayerGuid)!.ItemsCapacity = 0;
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
Assert.Empty(h.BuyAlls);
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// G1 (vendor gate finding): live testing showed "Buy All" false-blocking
|
|
/// a container purchase while the player visibly had free container
|
|
/// slots ("3 left"). Root cause: <c>CountPlayerContents</c> classified
|
|
/// "is this occupied slot a container" from the item's own
|
|
/// <see cref="ItemType.Container"/> bit OR nonzero
|
|
/// <c>ItemsCapacity</c>/<c>ContainersCapacity</c> — a LOCAL heuristic —
|
|
/// instead of retail's actual wire-carried classification
|
|
/// (<c>ContainerProperties</c>, threaded onto
|
|
/// <see cref="ClientObject.ContainerTypeHint"/> by every membership
|
|
/// path). A non-Container-typed object with a stray nonzero capacity
|
|
/// field (and a wire hint of <c>None</c>) was over-counted as an
|
|
/// occupied CONTAINER slot, shrinking the free-slot count below the
|
|
/// real one and false-blocking a purchase the player had room for.
|
|
/// This test pins a player pack with one such object (armor, hint=None,
|
|
/// but ItemsCapacity happens to read nonzero) plus 6 free container
|
|
/// slots out of 7 — buying ONE more container must succeed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuyAllButton_StrayCapacityFieldOnNonContainerItem_DoesNotFalseBlockWithFreeSlots()
|
|
{
|
|
var h = new Harness();
|
|
// AddOrUpdate FIRST (creates the object carrying the stray capacity
|
|
// field), InitializeInventoryManifest SECOND (updates the SAME
|
|
// object's placement/hint in place — it does not touch Type/
|
|
// ItemsCapacity, matching AddOrUpdate's own doc comment: "does NOT
|
|
// update the container index").
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x60002001u,
|
|
Type = ItemType.Armor,
|
|
ItemsCapacity = 3,
|
|
});
|
|
h.Objects.InitializeInventoryManifest(Harness.PlayerGuid, new[]
|
|
{
|
|
// Wire truth (ContainerType=0/None): this is NOT a container.
|
|
// Its Type is Armor (not Container) but it carries a stray
|
|
// nonzero ItemsCapacity — the old heuristic misread that as
|
|
// "occupies a container slot."
|
|
new ContainerContentEntry(0x60002001u, 0u),
|
|
});
|
|
// Only 1 container slot total so a miscount of this ONE stray item
|
|
// as a container (containersUsed 1 instead of 0) actually flips the
|
|
// guard, instead of being absorbed by the harness's 7-slot default.
|
|
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 1;
|
|
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
// 1 capacity - 0 REAL containers used = 1 free -> buying 1 succeeds.
|
|
Assert.Single(h.BuyAlls);
|
|
Assert.Empty(h.SystemMessages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// G1 companion: a REAL side-pack (wire hint ContainerType=1/Container,
|
|
/// no ItemType.Container bit and no capacity fields populated — e.g. a
|
|
/// container object seen only via a membership manifest, not its own
|
|
/// full CreateObject) still correctly consumes a container slot. Proves
|
|
/// the fix's hint-primary classification isn't just permissive by
|
|
/// omission — it still catches a real container the OLD Type-bit-only
|
|
/// fallback would have missed too.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuyAllButton_HintOnlyContainer_StillCountsAgainstContainerCapacity()
|
|
{
|
|
var h = new Harness();
|
|
h.Objects.InitializeInventoryManifest(Harness.PlayerGuid, new[]
|
|
{
|
|
new ContainerContentEntry(0x60002010u, 1u), // Container, hint-only
|
|
});
|
|
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 1;
|
|
|
|
h.BuyAllButton.OnClick!.Invoke();
|
|
|
|
// Capacity 1, 1 REAL container already used (via hint) -> 0 free,
|
|
// buying 1 more must block.
|
|
Assert.Empty(h.BuyAlls);
|
|
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
|
|
h.BuyItemButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(new[] { (VendorGuid, ArmorItemGuid, 1, 0u) }, h.Buys);
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutBuying()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
|
|
});
|
|
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.ItemList.GetItem(1)!.Clicked?.Invoke();
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(2, h.BuyingList.GetNumUIItems());
|
|
// AnotherArmorItemGuid is currently selected (last clicked).
|
|
|
|
h.BuyClearItemButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId);
|
|
Assert.Empty(h.Buys);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuyClearListButton_ClearsEveryStagedEntryWithoutBuying()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
h.BuyClearListButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
Assert.Empty(h.Buys);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must
|
|
/// visibly move the highlight — a prior version of this port only
|
|
/// repainted the Buying/Selling strips when their OWN staging list
|
|
/// changed, so a pure selection change (clicking a DIFFERENT already-
|
|
/// staged row, no staging mutation) left both rows' <c>Selected</c>
|
|
/// flags stale.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SelectingADifferentStagedBuyingRow_RepaintsBothRowsHighlight()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
|
|
});
|
|
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.ItemList.GetItem(1)!.Clicked?.Invoke();
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(2, h.BuyingList.GetNumUIItems());
|
|
// AnotherArmorItemGuid is currently selected+highlighted (last staged).
|
|
|
|
UiItemSlot firstRow = h.BuyingList.GetItem(0)!;
|
|
UiItemSlot secondRow = h.BuyingList.GetItem(1)!;
|
|
firstRow.Clicked?.Invoke(); // a pure selection change — no staging mutation
|
|
|
|
Assert.Equal(firstRow.ItemId, h.Selection.SelectedObjectId);
|
|
Assert.True(firstRow.Selected);
|
|
Assert.False(secondRow.Selected);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Slice 6c — Selling tab drag-to-sell staging
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1)
|
|
{
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = guid,
|
|
Name = $"Item {guid:X8}",
|
|
Type = type,
|
|
Value = value,
|
|
StackSize = stackSize,
|
|
});
|
|
h.Objects.MoveItem(guid, Harness.PlayerGuid, h.Objects.GetContents(Harness.PlayerGuid).Count);
|
|
}
|
|
|
|
private static ItemDragPayload DragFromInventory(uint guid) =>
|
|
new(guid, ItemDragSource.Inventory, 0, new UiItemSlot());
|
|
|
|
[Fact]
|
|
public void OnDragOver_TargetIsNotTheSellingList_RejectsRegardlessOfAcceptability()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
|
|
ItemDragAcceptance result = h.Controller.OnDragOver(
|
|
h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
|
|
Assert.Equal(ItemDragAcceptance.Reject, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnDragOver_AcceptableItemOverSellingList_Accepts()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
|
|
ItemDragAcceptance result = h.Controller.OnDragOver(
|
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
|
|
Assert.Equal(ItemDragAcceptance.Accept, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnDragOver_UnacceptableItemOverSellingList_RejectsSilently()
|
|
{
|
|
var h = new Harness();
|
|
// Vendor only deals in Armor -- a Weapon is a type mismatch.
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100);
|
|
|
|
ItemDragAcceptance result = h.Controller.OnDragOver(
|
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
|
|
|
|
Assert.Equal(ItemDragAcceptance.Reject, result);
|
|
// silent=1 on hover -- no rejection string yet (VendorSellUI::
|
|
// OnItemListDragOver, pc:201320-201339).
|
|
Assert.Empty(h.SystemMessages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F5 (Slice 6b/6c review): every drag test above (and
|
|
/// <see cref="HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt"/>
|
|
/// below) calls <c>OnDragOver</c>/<c>HandleDropRelease</c> DIRECTLY,
|
|
/// bypassing <see cref="UiRoot"/>'s real pointer pipeline entirely — the
|
|
/// review flagged that this can never exercise the NEW auto-switch
|
|
/// behavior (<c>PollDragOver</c>), which reacts to <see cref="UiRoot.DragSource"/>/
|
|
/// <see cref="UiRoot.MouseX"/>/<see cref="UiRoot.MouseY"/>, not a direct
|
|
/// method call. This test drives the WHOLE thing through
|
|
/// <see cref="UiRoot.OnMouseDown"/>/<see cref="UiRoot.OnMouseMove"/>/
|
|
/// <see cref="UiRoot.Tick"/>/<see cref="UiRoot.OnMouseUp"/>: lift a drag
|
|
/// from an (unrelated) source cell, hover it over the vendor window
|
|
/// while the Items tab is still open, let the periodic global-UI-time
|
|
/// pulse (retail's message 3) auto-switch to Selling, then release
|
|
/// ONTO the now-visible Selling list's real hit-test geometry.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DragOverTheVendorWindow_ThroughTheRealPointerPipeline_AutoSwitchesToSellingAndStages()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
|
|
// The Selling list needs real screen geometry (and at least one
|
|
// empty-slot placeholder cell) to be hit-testable — the hand-built
|
|
// harness never runs a real DAT-driven layout pass, so this test
|
|
// gives it one explicitly rather than relying on production sizing.
|
|
// Exactly ONE cell's worth of width so the post-drop repaint has no
|
|
// room left for an extra empty-slot placeholder alongside the real
|
|
// staged item.
|
|
h.SellingList.Width = 32f;
|
|
h.SellingList.Height = 32f;
|
|
using (h.SellingList.DeferLayout()) { }
|
|
|
|
// A standalone drag SOURCE cell standing in for "the player's own
|
|
// inventory panel" (not modeled by this harness) — positioned well
|
|
// outside the vendor window (which occupies roughly (0,0)-(800,110)
|
|
// here) so it cannot be confused with any vendor widget.
|
|
var sourceCell = new UiItemSlot { Left = 700f, Top = 550f, Width = 32f, Height = 32f };
|
|
sourceCell.SetItem(PlayerOwnedArmorGuid, 0u);
|
|
h.Screen.AddChild(sourceCell);
|
|
|
|
Assert.True(h.ItemsPage.Visible);
|
|
Assert.False(h.SellingPage.Visible);
|
|
|
|
// Press on the source cell, then move past the 3px promotion
|
|
// threshold to somewhere INSIDE the vendor window (but not
|
|
// specifically over the Selling list) while the Items tab is still
|
|
// showing — mirrors a player dragging toward "the vendor" in
|
|
// general before the panel has switched tabs for them.
|
|
h.Screen.OnMouseDown(UiMouseButton.Left, 710, 560);
|
|
h.Screen.OnMouseMove(400, 50);
|
|
Assert.Same(sourceCell, h.Screen.DragSource);
|
|
Assert.False(h.SellingPage.Visible);
|
|
|
|
// F5: the auto-switch happens on the periodic global-UI-time pulse,
|
|
// not on the mouse-move itself — retail polls this from
|
|
// UpdateDragOver via UI message 3.
|
|
h.Screen.Tick(0.016, 1L);
|
|
|
|
Assert.True(h.SellingPage.Visible);
|
|
Assert.False(h.ItemsPage.Visible);
|
|
|
|
// Move onto the now-visible Selling list's real geometry and
|
|
// release — UiRoot's own hit-test resolves the drop, not a direct
|
|
// HandleDropRelease call.
|
|
h.Screen.OnMouseMove(20, 15);
|
|
h.Screen.OnMouseUp(UiMouseButton.Left, 20, 15);
|
|
|
|
Assert.Null(h.Screen.DragSource);
|
|
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
|
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
|
|
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
|
|
Assert.Empty(h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
|
|
h.Controller.HandleDropRelease(
|
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
|
|
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
|
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
|
|
Assert.True(h.SellingPage.Visible);
|
|
Assert.False(h.ItemsPage.Visible);
|
|
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
|
|
Assert.Empty(h.SystemMessages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records
|
|
/// the item's FULL stack — retail's <c>AddItemToSell</c> stages via a
|
|
/// LITERAL <c>-1</c> "full stack" argument
|
|
/// (<c>gmVendorUI::AddItem(..., -1, ...)</c>, <c>pc:203595</c>), never a
|
|
/// slider read. A prior version of this port read the LIVE split
|
|
/// slider here instead — this proves a PARTIAL slider selection at
|
|
/// drop time does not leak into the staged (or sent) quantity.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
|
|
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
|
|
h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored
|
|
|
|
h.Controller.HandleDropRelease(
|
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
|
|
h.SellAllButton.OnClick!.Invoke();
|
|
|
|
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
|
Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items);
|
|
}
|
|
|
|
[Fact]
|
|
public void HandleDropRelease_WrongTargetList_IsIgnored()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
|
|
// gmVendorUI::HandleDropRelease's IsAncestorOfMe gate — a drop on
|
|
// ANY other list in the panel (here, the Items list) is a structural
|
|
// no-op, never reaching AcceptDragObject.
|
|
h.Controller.HandleDropRelease(
|
|
h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void HandleDropRelease_UnacceptableType_ShowsTheGenericRejectionMessageAndDoesNotStage()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100);
|
|
|
|
h.Controller.HandleDropRelease(
|
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
|
|
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void HandleDropRelease_NoValueItem_ShowsTheNoValueRejectionMessage()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, value: 0);
|
|
|
|
h.Controller.HandleDropRelease(
|
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "That item has no value and cannot be sold" }, h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void HandleDropRelease_NotOwnedByPlayer_ShowsTheOwnershipRejectionMessage()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
// Registered but never moved into the player's own container --
|
|
// ContainerId/WielderId both stay 0, so IsOwnedByPlayer is false.
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = PlayerOwnedArmorGuid,
|
|
Name = "Someone else's chainmail",
|
|
Type = ItemType.Armor,
|
|
Value = 100,
|
|
StackSize = 1,
|
|
});
|
|
|
|
h.Controller.HandleDropRelease(
|
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c> is now checked end to
|
|
/// end — an item whose TYPE matches the vendor's merchandise mask is
|
|
/// still rejected (as the generic <c>WrongType</c> message, matching
|
|
/// retail's OR'd branch) when its bitfield carries the bit.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HandleDropRelease_RetainedItem_RejectsEvenWhenTheTypeMaskMatches()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = PlayerOwnedArmorGuid,
|
|
Name = "Heirloom Chainmail",
|
|
Type = ItemType.Armor,
|
|
Value = 100,
|
|
StackSize = 1,
|
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Retained,
|
|
});
|
|
h.Objects.MoveItem(PlayerOwnedArmorGuid, Harness.PlayerGuid, 0);
|
|
|
|
h.Controller.HandleDropRelease(
|
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100);
|
|
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2));
|
|
Assert.Equal(2, h.SellingList.GetNumUIItems());
|
|
|
|
h.SellAllButton.OnClick!.Invoke();
|
|
|
|
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
|
Assert.Equal(VendorGuid, vendorGuid);
|
|
Assert.Equal(
|
|
new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid), (1, PlayerOwnedArmorGuid2) },
|
|
items);
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void SellAllButton_WithNothingStaged_IsANoOp()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
|
|
h.SellAllButton.OnClick!.Invoke();
|
|
|
|
Assert.Empty(h.Sells);
|
|
}
|
|
|
|
[Fact]
|
|
public void SellItemButton_SellsTheSelectedStagedItemAndRemovesItUnconditionallyOnSuccess()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
|
|
|
h.SellItemButton.OnClick!.Invoke();
|
|
|
|
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
|
Assert.Equal(VendorGuid, vendorGuid);
|
|
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items);
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
}
|
|
|
|
/// <summary>
|
|
/// F13 (Slice 6b/6c review): retail's Sell Item reads
|
|
/// <c>ACCWeenieObject::selectedID</c> UNCONDITIONALLY — there is no
|
|
/// "must be staged first" requirement. A prior version of this port
|
|
/// required a matching <c>_sellStaging</c> entry, which this test would
|
|
/// have failed against (no drop/staging happens here at all — only a
|
|
/// direct global selection).
|
|
/// </summary>
|
|
[Fact]
|
|
public void SellItemButton_ActsOnTheGlobalSelectionEvenWhenNeverStaged()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
h.Selection.Select(PlayerOwnedArmorGuid, SelectionChangeSource.Vendor);
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems()); // never staged/dropped
|
|
|
|
h.SellItemButton.OnClick!.Invoke();
|
|
|
|
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
|
Assert.Equal(VendorGuid, vendorGuid);
|
|
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F6 (Slice 6b/6c review, byte-verified <c>pc:201833-201864</c>):
|
|
/// <c>SellSingleItem</c> refuses a stackable item whose split slider is
|
|
/// not showing the FULL stack — shows the exact retail notice and sends
|
|
/// nothing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SellItemButton_PartialStackSelected_RefusesWithRetailsNoticeAndSendsNothing()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
|
|
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
|
|
h.SplitQuantity.Reset(20u, initialValue: 5u); // a PARTIAL amount, not the full stack of 20
|
|
|
|
h.SellItemButton.OnClick!.Invoke();
|
|
|
|
Assert.Empty(h.Sells);
|
|
Assert.Equal(new[] { "Cannot sell part of a stack" }, h.SystemMessages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F6: once the slider shows the FULL stack, Sell Item proceeds and
|
|
/// sends amount <c>1</c> LITERALLY — not the 20-unit stack size —
|
|
/// matching retail's own literal <c>var_9c = 1</c> send.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SellItemButton_FullStackSelected_SendsLiteralAmountOneNotTheStackSize()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
|
|
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
|
|
h.SplitQuantity.Reset(20u, initialValue: 20u); // the FULL stack
|
|
|
|
h.SellItemButton.OnClick!.Invoke();
|
|
|
|
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
|
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedWeaponGuid) }, items);
|
|
}
|
|
|
|
[Fact]
|
|
public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100);
|
|
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2));
|
|
Assert.Equal(2, h.SellingList.GetNumUIItems());
|
|
// PlayerOwnedArmorGuid2 is currently selected (last dropped).
|
|
|
|
h.SellClearItemButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
|
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
|
|
Assert.Empty(h.Sells);
|
|
}
|
|
|
|
[Fact]
|
|
public void SellClearListButton_ClearsEveryStagedEntryWithoutSelling()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
|
|
h.SellClearListButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
Assert.Empty(h.Sells);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// F10 (Slice 6b/6c review) — unstage on removal/dispossession.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Sell side — retail's <c>RecvNotice_ServerSaysMoveItem</c>
|
|
/// (<c>0x004c44a0</c>) silently unstages a staged sell item once it's
|
|
/// no longer trackable/owned by the player; this port's closest
|
|
/// reachable analogue is the item leaving <c>ClientObjectTable</c>
|
|
/// entirely.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RemovingAStagedSellItemFromClientObjectTable_SilentlyUnstagesIt()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
|
|
|
h.Objects.Remove(PlayerOwnedArmorGuid);
|
|
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
Assert.Empty(h.SystemMessages); // silent — matches retail's own site
|
|
}
|
|
|
|
/// <summary>
|
|
/// Buy side — a staged shop item that drops out of the vendor's
|
|
/// CURRENT stock (retired from <c>ClientObjectTable</c> — production's
|
|
/// <c>VendorShopItemMaterializer</c> does this on every Refreshed
|
|
/// transition that drops a guid; this hand-built harness doesn't mount
|
|
/// that class, so the test performs the SAME removal directly) unstages
|
|
/// with retail's exact "Removing %s from shopping list" notice
|
|
/// (<c>0x004c4246</c>).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ShopItemLeavingClientObjectTable_UnstagesTheBuyEntryWithRetailsNotice()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ArmorItemGuid,
|
|
Name = "Chainmail",
|
|
Type = ItemType.Armor,
|
|
ContainerId = VendorGuid,
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
|
|
h.Objects.Remove(ArmorItemGuid);
|
|
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(new[] { "Removing Chainmail from shopping list" }, h.SystemMessages);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Slice 6b/6c — X-close staging confirmation + session-boundary clears
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public void CloseButtonPressed_WithNoStaging_HidesImmediatelyWithoutADialog()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
Assert.True(h.Window.IsVisible);
|
|
|
|
h.CloseButton.OnClick!.Invoke();
|
|
|
|
Assert.False(h.Window.IsVisible);
|
|
Assert.False(h.Dialogs.IsOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseButtonPressed_WithStagedBuyItems_ShowsConfirmDialogInsteadOfHidingImmediately()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
h.CloseButton.OnClick!.Invoke();
|
|
|
|
Assert.True(h.Window.IsVisible);
|
|
Assert.True(h.Dialogs.IsOpen);
|
|
Assert.NotNull(h.ShownDialog);
|
|
// Exact retail string, read from the decompiled binary's data
|
|
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment. F7
|
|
// (Slice 6b/6c review): the raw bytes right after the declared
|
|
// string length are UTF-16LE for '?' before the null terminator.
|
|
Assert.Equal(
|
|
"You have not completed all transactions. Are you sure you want to leave this vendor?",
|
|
string.Join(" ", Assert.IsType<UiText>(h.ShownDialog!.FindElement(
|
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseConfirmDialog_Accepted_HidesTheWindowAndLeavesStagingIntact()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.CloseButton.OnClick!.Invoke();
|
|
|
|
Assert.IsType<UiButton>(h.ShownDialog!.FindElement(
|
|
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
|
|
|
Assert.False(h.Window.IsVisible);
|
|
Assert.False(h.Dialogs.IsOpen);
|
|
// Retail's CloseVendorDialogCallback never touches m_buyList/
|
|
// m_sellList -- staging survives so the next open shows it again.
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseConfirmDialog_Rejected_KeepsTheWindowOpenAndStagingIntact()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.CloseButton.OnClick!.Invoke();
|
|
|
|
Assert.IsType<UiButton>(h.ShownDialog!.FindElement(
|
|
RetailConfirmationDialogView.RejectButtonId)).OnClick!();
|
|
|
|
Assert.True(h.Window.IsVisible);
|
|
Assert.False(h.Dialogs.IsOpen);
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseButtonPressed_WhileAConfirmationIsAlreadyUp_DoesNotOpenASecondOne()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
h.CloseButton.OnClick!.Invoke();
|
|
Assert.Equal(1, h.Dialogs.ActiveCount);
|
|
|
|
h.CloseButton.OnClick!.Invoke();
|
|
|
|
Assert.Equal(1, h.Dialogs.ActiveCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionClose_ClearsBothStagingLists()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
|
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
|
|
|
h.State.Close();
|
|
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionReset_ClearsBothStagingLists()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
|
|
h.State.Reset();
|
|
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void OpeningADifferentVendor_ClearsStaleStagingFromThePreviousVendor()
|
|
{
|
|
var h = new Harness();
|
|
h.State.Apply(VendorGuid, Profile(), new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
});
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
|
|
const uint otherVendor = 0x70000099u;
|
|
h.State.Apply(otherVendor, Profile(), new[]
|
|
{
|
|
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
|
});
|
|
|
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshedTransition_SameVendor_DoesNotClearAnUntouchedStagingList()
|
|
{
|
|
var h = new Harness();
|
|
var items = new[]
|
|
{
|
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
|
};
|
|
h.State.Apply(VendorGuid, Profile(), items);
|
|
h.AddButton.OnClick!.Invoke();
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
|
|
// Same vendor id re-approaching -- sameVendor==1, a Refreshed
|
|
// transition (e.g. post buy/sell ApproachVendor refresh).
|
|
h.State.Apply(VendorGuid, Profile(), items);
|
|
|
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
|
}
|
|
}
|