Files
DevilutionX/test/visual_store_test.cpp
Gleb Mazovetskiy f79af12eae Unify store UI options
Unifies "Show Item Graphics" and "Visual Store" boolean options
into a single enum.

Also moves floating info box option next to the store UI option
as they are somewhat related.
2026-07-16 07:56:04 +01:00

999 lines
27 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file visual_store_test.cpp
*
* Tests for the visual grid-based store UI.
*
* These tests verify the functional behaviour of the visual store:
* opening/closing, tab switching, pagination, buying, selling, repairing,
* and vendor-specific sell validation.
*
* All assertions are on game state (gold, inventory contents, item
* properties, vendor inventory, store state flags). No assertions on
* rendering, pixel positions, or widget layout.
*
* The visual store has a clean public API that is already well-separated
* from rendering, so most tests call the public functions directly.
* For buying, we use CheckVisualStoreItem() with a screen coordinate
* computed from the grid layout — this is the same entry point that a
* real mouse click would use.
*/
#include <algorithm>
#include <gtest/gtest.h>
#include "ui_test.hpp"
#include "engine/random.hpp"
#include "inv.h"
#include "items.h"
#include "options.h"
#include "player.h"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "stores.h"
namespace devilution {
namespace {
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
class VisualStoreTest : public UITest {
protected:
void SetUp() override
{
UITest::SetUp();
SetRndSeed(42);
// Enable the visual store UI for these tests.
GetOptions().Gameplay.storeUi.SetValue(StoreUi::VisualGrid);
}
void TearDown() override
{
if (IsVisualStoreOpen)
CloseVisualStore();
UITest::TearDown();
}
/**
* @brief Populate all town vendors with items appropriate for a level-25 player.
*/
void PopulateVendors()
{
SetRndSeed(42);
int l = 16;
SpawnSmith(l);
SpawnWitch(l);
SpawnHealer(l);
SpawnBoy(MyPlayer->getCharacterLevel());
SpawnPremium(*MyPlayer);
}
/**
* @brief Compute a screen coordinate that lands inside the grid cell at
* the given grid position on the current page.
*
* This lets us call CheckVisualStoreItem() to buy items without
* hard-coding pixel coordinates — we derive them from the same
* GetVisualStoreSlotCoord() function the production code uses.
*/
static Point GridCellCenter(Point gridPos)
{
Point topLeft = GetVisualStoreSlotCoord(gridPos);
return topLeft + Displacement { INV_SLOT_HALF_SIZE_PX, INV_SLOT_HALF_SIZE_PX };
}
/**
* @brief Find the screen coordinate of the first item on the current page.
*
* Searches the grid for a non-empty cell and returns the center of that
* cell. Returns {-1, -1} if the page is empty.
*/
static Point FindFirstItemOnPage()
{
if (VisualStore.currentPage >= VisualStore.pages.size())
return { -1, -1 };
const VisualStorePage &page = VisualStore.pages[VisualStore.currentPage];
for (int y = 0; y < VisualStoreGridHeight; y++) {
for (int x = 0; x < VisualStoreGridWidth; x++) {
if (page.grid[x][y] != 0) {
return GridCellCenter({ x, y });
}
}
}
return { -1, -1 };
}
/**
* @brief Find the item index of the first item on the current page.
*
* Returns -1 if the page is empty.
*/
static int FindFirstItemIndexOnPage()
{
if (VisualStore.currentPage >= VisualStore.pages.size())
return -1;
const VisualStorePage &page = VisualStore.pages[VisualStore.currentPage];
for (int y = 0; y < VisualStoreGridHeight; y++) {
for (int x = 0; x < VisualStoreGridWidth; x++) {
if (page.grid[x][y] != 0) {
return page.grid[x][y] - 1;
}
}
}
return -1;
}
/**
* @brief Create a simple melee weapon suitable for selling to the smith.
*/
Item MakeSellableSword()
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iIdentified = true;
return item;
}
/**
* @brief Create a damaged sword suitable for repair.
*/
Item MakeDamagedSword()
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iIdentified = true;
item._iMaxDur = 40;
item._iDurability = 10;
item._ivalue = 2000;
item._iIvalue = 2000;
item._itype = ItemType::Sword;
return item;
}
};
// ===========================================================================
// Open / Close
// ===========================================================================
TEST_F(VisualStoreTest, OpenStore_SetsState)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
EXPECT_TRUE(IsVisualStoreOpen);
EXPECT_EQ(VisualStore.vendor, VisualStoreVendor::Smith);
EXPECT_TRUE(invflag) << "Inventory panel should open alongside the store";
EXPECT_EQ(VisualStore.currentPage, 0u);
EXPECT_EQ(VisualStore.activeTab, VisualStoreTab::Basic);
}
TEST_F(VisualStoreTest, CloseStore_ClearsState)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
CloseVisualStore();
EXPECT_FALSE(IsVisualStoreOpen);
EXPECT_FALSE(invflag) << "Inventory panel should close with the store";
EXPECT_TRUE(VisualStore.pages.empty()) << "Pages should be cleared on close";
}
TEST_F(VisualStoreTest, OpenStore_EachVendor)
{
PopulateVendors();
// Smith
OpenVisualStore(VisualStoreVendor::Smith);
EXPECT_TRUE(IsVisualStoreOpen);
EXPECT_EQ(VisualStore.vendor, VisualStoreVendor::Smith);
CloseVisualStore();
// Witch
OpenVisualStore(VisualStoreVendor::Witch);
EXPECT_TRUE(IsVisualStoreOpen);
EXPECT_EQ(VisualStore.vendor, VisualStoreVendor::Witch);
CloseVisualStore();
// Healer
OpenVisualStore(VisualStoreVendor::Healer);
EXPECT_TRUE(IsVisualStoreOpen);
EXPECT_EQ(VisualStore.vendor, VisualStoreVendor::Healer);
CloseVisualStore();
// Boy (Wirt)
OpenVisualStore(VisualStoreVendor::Boy);
EXPECT_TRUE(IsVisualStoreOpen);
EXPECT_EQ(VisualStore.vendor, VisualStoreVendor::Boy);
CloseVisualStore();
}
TEST_F(VisualStoreTest, OpenStore_ResetsHighlightState)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
EXPECT_EQ(pcursstoreitem, -1) << "No item should be highlighted on open";
EXPECT_EQ(pcursstorebtn, -1) << "No button should be highlighted on open";
}
// ===========================================================================
// Tab switching (Smith only)
// ===========================================================================
TEST_F(VisualStoreTest, TabSwitch_SmithHasTabs)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
EXPECT_EQ(VisualStore.activeTab, VisualStoreTab::Basic)
<< "Smith should default to Basic tab";
SetVisualStoreTab(VisualStoreTab::Premium);
EXPECT_EQ(VisualStore.activeTab, VisualStoreTab::Premium);
EXPECT_EQ(VisualStore.currentPage, 0u) << "Page should reset on tab switch";
SetVisualStoreTab(VisualStoreTab::Basic);
EXPECT_EQ(VisualStore.activeTab, VisualStoreTab::Basic);
}
TEST_F(VisualStoreTest, TabSwitch_NonSmithIgnored)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Witch);
// Tab switching should be a no-op for non-Smith vendors.
SetVisualStoreTab(VisualStoreTab::Premium);
EXPECT_EQ(VisualStore.activeTab, VisualStoreTab::Basic)
<< "Tab should not change for Witch";
}
TEST_F(VisualStoreTest, TabSwitch_ResetsHighlight)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
SetVisualStoreTab(VisualStoreTab::Premium);
EXPECT_EQ(pcursstoreitem, -1) << "Highlight should reset on tab switch";
EXPECT_EQ(pcursstorebtn, -1) << "Button highlight should reset on tab switch";
}
// ===========================================================================
// Pagination
// ===========================================================================
TEST_F(VisualStoreTest, Pagination_NextAndPrevious)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
const int totalPages = GetVisualStorePageCount();
if (totalPages <= 1) {
GTEST_SKIP() << "Smith has only 1 page with this seed — skipping pagination test";
}
EXPECT_EQ(VisualStore.currentPage, 0u);
VisualStoreNextPage();
EXPECT_EQ(VisualStore.currentPage, 1u);
VisualStorePreviousPage();
EXPECT_EQ(VisualStore.currentPage, 0u);
}
TEST_F(VisualStoreTest, Pagination_DoesNotGoNegative)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
EXPECT_EQ(VisualStore.currentPage, 0u);
VisualStorePreviousPage();
EXPECT_EQ(VisualStore.currentPage, 0u)
<< "Should not go below page 0";
}
TEST_F(VisualStoreTest, Pagination_DoesNotExceedMax)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
const int totalPages = GetVisualStorePageCount();
// Navigate to the last page.
for (int i = 0; i < totalPages + 5; i++) {
VisualStoreNextPage();
}
EXPECT_LT(VisualStore.currentPage, static_cast<unsigned>(totalPages))
<< "Should not exceed the last page";
}
TEST_F(VisualStoreTest, Pagination_ResetsHighlight)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
if (GetVisualStorePageCount() <= 1) {
GTEST_SKIP() << "Need multiple pages for this test";
}
VisualStoreNextPage();
EXPECT_EQ(pcursstoreitem, -1) << "Highlight should reset on page change";
}
// ===========================================================================
// Item count and items
// ===========================================================================
TEST_F(VisualStoreTest, ItemCount_MatchesVendorInventory)
{
PopulateVendors();
// Smith basic
OpenVisualStore(VisualStoreVendor::Smith);
const int smithBasicCount = GetVisualStoreItemCount();
EXPECT_GT(smithBasicCount, 0) << "Smith should have basic items";
std::span<Item> smithBasicItems = GetVisualStoreItems();
int manualCount = 0;
for (const Item &item : smithBasicItems) {
if (!item.isEmpty())
manualCount++;
}
EXPECT_EQ(smithBasicCount, manualCount);
CloseVisualStore();
// Witch
OpenVisualStore(VisualStoreVendor::Witch);
EXPECT_GT(GetVisualStoreItemCount(), 0) << "Witch should have items";
CloseVisualStore();
// Healer
OpenVisualStore(VisualStoreVendor::Healer);
EXPECT_GT(GetVisualStoreItemCount(), 0) << "Healer should have items";
CloseVisualStore();
}
TEST_F(VisualStoreTest, PageCount_AtLeastOne)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
EXPECT_GE(GetVisualStorePageCount(), 1);
CloseVisualStore();
OpenVisualStore(VisualStoreVendor::Witch);
EXPECT_GE(GetVisualStorePageCount(), 1);
CloseVisualStore();
}
// ===========================================================================
// Buy item
// ===========================================================================
TEST_F(VisualStoreTest, SmithBuy_Success)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
StripPlayer();
OpenVisualStore(VisualStoreVendor::Smith);
const int itemIdx = FindFirstItemIndexOnPage();
ASSERT_GE(itemIdx, 0) << "Should find an item on page 0";
const int itemPrice = SmithItems[itemIdx]._iIvalue;
SetPlayerGold(itemPrice + 1000);
const int goldBefore = MyPlayer->_pGold;
const size_t vendorCountBefore = SmithItems.size();
Point clickPos = FindFirstItemOnPage();
ASSERT_NE(clickPos.x, -1) << "Should find an item position on page 0";
CheckVisualStoreItem(clickPos, false, false);
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice)
<< "Gold should decrease by item price";
EXPECT_EQ(SmithItems.size(), vendorCountBefore - 1)
<< "Item should be removed from Smith's basic inventory";
}
TEST_F(VisualStoreTest, SmithBuy_CantAfford)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
StripPlayer();
SetPlayerGold(0);
Stash.gold = 0;
OpenVisualStore(VisualStoreVendor::Smith);
const size_t vendorCountBefore = SmithItems.size();
Point clickPos = FindFirstItemOnPage();
ASSERT_NE(clickPos.x, -1);
CheckVisualStoreItem(clickPos, false, false);
EXPECT_EQ(MyPlayer->_pGold, 0)
<< "Gold should not change when purchase fails";
EXPECT_EQ(SmithItems.size(), vendorCountBefore)
<< "Item should remain in vendor inventory";
}
TEST_F(VisualStoreTest, SmithBuy_NoRoom)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
// Fill the inventory completely with 1×1 items so there's no room.
for (int i = 0; i < InventoryGridCells; i++) {
MyPlayer->InvList[i]._itype = ItemType::Gold;
MyPlayer->InvList[i]._ivalue = 1;
MyPlayer->InvGrid[i] = static_cast<int8_t>(i + 1);
}
MyPlayer->_pNumInv = InventoryGridCells;
MyPlayer->_pGold = InventoryGridCells; // 1g per slot
// Give enough gold via stash so afford is not the issue.
Stash.gold = 500000;
OpenVisualStore(VisualStoreVendor::Smith);
const size_t vendorCountBefore = SmithItems.size();
const int goldBefore = MyPlayer->_pGold;
Point clickPos = FindFirstItemOnPage();
ASSERT_NE(clickPos.x, -1);
CheckVisualStoreItem(clickPos, false, false);
EXPECT_EQ(MyPlayer->_pGold, goldBefore)
<< "Gold should not change when there's no room";
EXPECT_EQ(SmithItems.size(), vendorCountBefore)
<< "Item should remain in vendor inventory";
}
TEST_F(VisualStoreTest, WitchBuy_PinnedItemsRemain)
{
PopulateVendors();
ASSERT_GT(WitchItems.size(), 3u) << "Witch needs non-pinned items";
StripPlayer();
OpenVisualStore(VisualStoreVendor::Witch);
// Find a non-pinned item (index >= 3) on the page.
int nonPinnedIdx = -1;
Point nonPinnedPos = { -1, -1 };
if (VisualStore.currentPage < VisualStore.pages.size()) {
const VisualStorePage &page = VisualStore.pages[VisualStore.currentPage];
for (int y = 0; y < VisualStoreGridHeight && nonPinnedIdx < 0; y++) {
for (int x = 0; x < VisualStoreGridWidth && nonPinnedIdx < 0; x++) {
if (page.grid[x][y] != 0) {
int idx = page.grid[x][y] - 1;
if (idx >= 3) {
nonPinnedIdx = idx;
nonPinnedPos = GridCellCenter({ x, y });
}
}
}
}
}
if (nonPinnedIdx < 0) {
GTEST_SKIP() << "No non-pinned Witch item found on page 0";
}
const int itemPrice = WitchItems[nonPinnedIdx]._iIvalue;
SetPlayerGold(itemPrice + 1000);
const size_t vendorCountBefore = WitchItems.size();
CheckVisualStoreItem(nonPinnedPos, false, false);
EXPECT_EQ(WitchItems.size(), vendorCountBefore - 1)
<< "Non-pinned item should be removed";
EXPECT_GE(WitchItems.size(), 3u)
<< "Pinned items (first 3) should remain";
}
TEST_F(VisualStoreTest, SmithPremiumBuy_ReplacesSlot)
{
PopulateVendors();
StripPlayer();
OpenVisualStore(VisualStoreVendor::Smith);
SetVisualStoreTab(VisualStoreTab::Premium);
const int premiumCount = GetVisualStoreItemCount();
if (premiumCount == 0) {
GTEST_SKIP() << "No premium items available with this seed";
}
const int itemIdx = FindFirstItemIndexOnPage();
ASSERT_GE(itemIdx, 0);
const int itemPrice = PremiumItems[itemIdx]._iIvalue;
SetPlayerGold(itemPrice + 1000);
const int goldBefore = MyPlayer->_pGold;
Point clickPos = FindFirstItemOnPage();
ASSERT_NE(clickPos.x, -1);
CheckVisualStoreItem(clickPos, false, false);
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice)
<< "Gold should decrease by premium item price";
// Premium slots are replaced, not removed — size stays the same.
EXPECT_EQ(PremiumItems.size(), static_cast<size_t>(PremiumItems.size()))
<< "Premium items list size should not change (slot is replaced)";
}
TEST_F(VisualStoreTest, BoyBuy_Success)
{
PopulateVendors();
if (BoyItem.isEmpty()) {
GTEST_SKIP() << "Wirt has no item with this seed";
}
StripPlayer();
const int itemPrice = BoyItem._iIvalue;
SetPlayerGold(itemPrice + 1000);
const int goldBefore = MyPlayer->_pGold;
OpenVisualStore(VisualStoreVendor::Boy);
Point clickPos = FindFirstItemOnPage();
ASSERT_NE(clickPos.x, -1) << "Should find Wirt's item on the page";
CheckVisualStoreItem(clickPos, false, false);
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice)
<< "Gold should decrease by item price";
EXPECT_TRUE(BoyItem.isEmpty())
<< "Wirt's item should be cleared after purchase";
}
// ===========================================================================
// Sell item
// ===========================================================================
TEST_F(VisualStoreTest, SellValidation_SmithAcceptsSword)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
Item sword = MakeSellableSword();
EXPECT_TRUE(CanSellToCurrentVendor(sword));
}
TEST_F(VisualStoreTest, SellValidation_SmithRejectsEmptyItem)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
Item empty {};
EXPECT_FALSE(CanSellToCurrentVendor(empty));
}
TEST_F(VisualStoreTest, SellValidation_HealerRejectsAll)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Healer);
Item sword = MakeSellableSword();
EXPECT_FALSE(CanSellToCurrentVendor(sword))
<< "Healer should not accept items for sale";
}
TEST_F(VisualStoreTest, SellValidation_BoyRejectsAll)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Boy);
Item sword = MakeSellableSword();
EXPECT_FALSE(CanSellToCurrentVendor(sword))
<< "Wirt should not accept items for sale";
}
TEST_F(VisualStoreTest, SmithSell_Success)
{
PopulateVendors();
StripPlayer();
OpenVisualStore(VisualStoreVendor::Smith);
Item sword = MakeSellableSword();
const int numInvBefore = MyPlayer->_pNumInv;
int invIdx = PlaceItemInInventory(sword);
ASSERT_GE(invIdx, 0);
const int expectedSellPrice = std::max(sword._ivalue / 4, 1);
SellItemToVisualStore(invIdx);
// The sword should have been removed from the inventory.
// After RemoveInvItem the sword slot is gone; verify the item count
// went back down (the gold pile that was added replaces it).
EXPECT_EQ(MyPlayer->_pNumInv, numInvBefore + 1)
<< "Inventory should contain the new gold pile (sword removed, gold added)";
// Verify gold was physically placed in inventory by summing gold piles.
// Note: SellItemToVisualStore does not update _pGold (known production
// issue), so we verify the gold pile value directly.
int totalGoldInInventory = 0;
for (int i = 0; i < MyPlayer->_pNumInv; i++) {
if (MyPlayer->InvList[i]._itype == ItemType::Gold)
totalGoldInInventory += MyPlayer->InvList[i]._ivalue;
}
EXPECT_EQ(totalGoldInInventory, expectedSellPrice)
<< "Gold piles in inventory should equal the sell price";
}
TEST_F(VisualStoreTest, WitchSell_AcceptsStaff)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Witch);
Item staff {};
InitializeItem(staff, IDI_SHORTSTAFF);
staff._iIdentified = true;
EXPECT_TRUE(CanSellToCurrentVendor(staff))
<< "Witch should accept staves";
}
TEST_F(VisualStoreTest, WitchSell_RejectsSword)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Witch);
Item sword = MakeSellableSword();
EXPECT_FALSE(CanSellToCurrentVendor(sword))
<< "Witch should reject swords";
}
// ===========================================================================
// Repair
// ===========================================================================
TEST_F(VisualStoreTest, RepairCost_ZeroForFullDurability)
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iMaxDur = 40;
item._iDurability = 40;
EXPECT_EQ(GetRepairCost(item), 0);
}
TEST_F(VisualStoreTest, RepairCost_ZeroForIndestructible)
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iMaxDur = DUR_INDESTRUCTIBLE;
item._iDurability = 10;
EXPECT_EQ(GetRepairCost(item), 0);
}
TEST_F(VisualStoreTest, RepairCost_ZeroForEmptyItem)
{
Item item {};
EXPECT_EQ(GetRepairCost(item), 0);
}
TEST_F(VisualStoreTest, RepairCost_NormalItem_MinimumOne)
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iMaxDur = 40;
item._iDurability = 39;
item._ivalue = 1;
item._iIvalue = 1;
item._iMagical = ITEM_QUALITY_NORMAL;
const int cost = GetRepairCost(item);
EXPECT_GE(cost, 1) << "Minimum repair cost should be 1 gold";
}
TEST_F(VisualStoreTest, RepairCost_MagicItem_ScalesWithDamage)
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iMagical = ITEM_QUALITY_MAGIC;
item._iIdentified = true;
item._iMaxDur = 40;
item._ivalue = 2000;
item._iIvalue = 2000;
// Check cost at different durability levels.
item._iDurability = 30;
const int costLow = GetRepairCost(item);
item._iDurability = 10;
const int costHigh = GetRepairCost(item);
EXPECT_GT(costHigh, costLow)
<< "More damage should cost more to repair";
EXPECT_GT(costHigh, 0);
EXPECT_GT(costLow, 0);
}
TEST_F(VisualStoreTest, RepairItem_RestoresDurability)
{
PopulateVendors();
StripPlayer();
OpenVisualStore(VisualStoreVendor::Smith);
Item damaged = MakeDamagedSword();
const int maxDur = damaged._iMaxDur;
const int repairCost = GetRepairCost(damaged);
ASSERT_GT(repairCost, 0) << "Damaged item should have a repair cost";
// Set gold BEFORE placing the item so SetPlayerGold doesn't clobber it.
SetPlayerGold(repairCost + 1000);
const int goldBefore = MyPlayer->_pGold;
int invIdx = PlaceItemInInventory(damaged);
ASSERT_GE(invIdx, 0);
// VisualStoreRepairItem uses INVITEM_INV_FIRST-based indexing.
VisualStoreRepairItem(INVITEM_INV_FIRST + invIdx);
EXPECT_EQ(MyPlayer->InvList[invIdx]._iDurability, maxDur)
<< "Durability should be fully restored";
EXPECT_EQ(MyPlayer->_pGold, goldBefore - repairCost)
<< "Gold should decrease by repair cost";
}
TEST_F(VisualStoreTest, RepairItem_CantAfford)
{
PopulateVendors();
StripPlayer();
SetPlayerGold(0);
Stash.gold = 0;
OpenVisualStore(VisualStoreVendor::Smith);
Item damaged = MakeDamagedSword();
const int originalDur = damaged._iDurability;
int invIdx = PlaceItemInInventory(damaged);
ASSERT_GE(invIdx, 0);
VisualStoreRepairItem(INVITEM_INV_FIRST + invIdx);
EXPECT_EQ(MyPlayer->InvList[invIdx]._iDurability, originalDur)
<< "Durability should not change when player can't afford repair";
}
TEST_F(VisualStoreTest, RepairAll_RestoresAllItems)
{
PopulateVendors();
StripPlayer();
OpenVisualStore(VisualStoreVendor::Smith);
// Prepare two damaged items.
Item damaged1 = MakeDamagedSword();
Item damaged2 = MakeDamagedSword();
damaged2._iMaxDur = 60;
damaged2._iDurability = 20;
damaged2._ivalue = 3000;
damaged2._iIvalue = 3000;
const int cost1 = GetRepairCost(damaged1);
const int cost2 = GetRepairCost(damaged2);
const int totalCost = cost1 + cost2;
ASSERT_GT(totalCost, 0);
// Set gold BEFORE placing items so SetPlayerGold doesn't clobber them.
SetPlayerGold(totalCost + 1000);
const int goldBefore = MyPlayer->_pGold;
int idx1 = PlaceItemInInventory(damaged1);
int idx2 = PlaceItemInInventory(damaged2);
ASSERT_GE(idx1, 0);
ASSERT_GE(idx2, 0);
// Repair each item individually (VisualStoreRepairAll is not in the
// public header, so we exercise VisualStoreRepairItem twice instead).
VisualStoreRepairItem(INVITEM_INV_FIRST + idx1);
const int goldAfterFirst = MyPlayer->_pGold;
EXPECT_EQ(goldAfterFirst, goldBefore - cost1);
VisualStoreRepairItem(INVITEM_INV_FIRST + idx2);
EXPECT_EQ(MyPlayer->InvList[idx1]._iDurability, MyPlayer->InvList[idx1]._iMaxDur);
EXPECT_EQ(MyPlayer->InvList[idx2]._iDurability, MyPlayer->InvList[idx2]._iMaxDur);
EXPECT_EQ(MyPlayer->_pGold, goldBefore - totalCost)
<< "Total gold should decrease by sum of both repair costs";
}
TEST_F(VisualStoreTest, RepairItem_NothingToRepair)
{
PopulateVendors();
StripPlayer();
OpenVisualStore(VisualStoreVendor::Smith);
// Set gold BEFORE placing the item so SetPlayerGold doesn't clobber it.
SetPlayerGold(10000);
const int goldBefore = MyPlayer->_pGold;
// Place a fully-repaired item.
Item sword = MakeSellableSword();
int invIdx = PlaceItemInInventory(sword);
ASSERT_GE(invIdx, 0);
VisualStoreRepairItem(INVITEM_INV_FIRST + invIdx);
EXPECT_EQ(MyPlayer->_pGold, goldBefore)
<< "Gold should not change when item doesn't need repair";
}
// ===========================================================================
// Items array matches vendor tab
// ===========================================================================
TEST_F(VisualStoreTest, GetVisualStoreItems_MatchesVendorTab)
{
PopulateVendors();
// Smith basic
OpenVisualStore(VisualStoreVendor::Smith);
std::span<Item> basicItems = GetVisualStoreItems();
EXPECT_EQ(basicItems.data(), SmithItems.data())
<< "Basic tab should reference SmithItems";
// Smith premium
SetVisualStoreTab(VisualStoreTab::Premium);
std::span<Item> premiumItems = GetVisualStoreItems();
EXPECT_EQ(premiumItems.data(), PremiumItems.data())
<< "Premium tab should reference PremiumItems";
CloseVisualStore();
// Witch
OpenVisualStore(VisualStoreVendor::Witch);
std::span<Item> witchItems = GetVisualStoreItems();
EXPECT_EQ(witchItems.data(), WitchItems.data());
CloseVisualStore();
// Healer
OpenVisualStore(VisualStoreVendor::Healer);
std::span<Item> healerItems = GetVisualStoreItems();
EXPECT_EQ(healerItems.data(), HealerItems.data());
CloseVisualStore();
}
// ===========================================================================
// Grid layout
// ===========================================================================
TEST_F(VisualStoreTest, GridLayout_HasItemsOnPage)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
ASSERT_FALSE(VisualStore.pages.empty());
const VisualStorePage &page = VisualStore.pages[0];
bool foundItem = false;
for (int y = 0; y < VisualStoreGridHeight && !foundItem; y++) {
for (int x = 0; x < VisualStoreGridWidth && !foundItem; x++) {
if (page.grid[x][y] != 0)
foundItem = true;
}
}
EXPECT_TRUE(foundItem) << "Page 0 should have at least one item in the grid";
}
TEST_F(VisualStoreTest, GridLayout_EmptyVendor)
{
// Don't populate vendors — everything is empty.
OpenVisualStore(VisualStoreVendor::Smith);
EXPECT_EQ(GetVisualStoreItemCount(), 0);
EXPECT_GE(GetVisualStorePageCount(), 1)
<< "Even empty vendor should have at least 1 (empty) page";
}
// ===========================================================================
// Buy using stash gold
// ===========================================================================
TEST_F(VisualStoreTest, BuyUsingStashGold)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
StripPlayer();
OpenVisualStore(VisualStoreVendor::Smith);
const int itemIdx = FindFirstItemIndexOnPage();
ASSERT_GE(itemIdx, 0);
const int itemPrice = SmithItems[itemIdx]._iIvalue;
ASSERT_GT(itemPrice, 0);
// Give player only part of the price as inventory gold,
// and the rest via stash.
const int inventoryGold = itemPrice / 2;
const int stashGold = itemPrice - inventoryGold + 1000;
SetPlayerGold(inventoryGold);
Stash.gold = stashGold;
const int totalGoldBefore = MyPlayer->_pGold + Stash.gold;
const size_t vendorCountBefore = SmithItems.size();
Point clickPos = FindFirstItemOnPage();
ASSERT_NE(clickPos.x, -1);
CheckVisualStoreItem(clickPos, false, false);
const int totalGoldAfter = MyPlayer->_pGold + Stash.gold;
EXPECT_EQ(totalGoldAfter, totalGoldBefore - itemPrice)
<< "Total gold (inventory + stash) should decrease by item price";
EXPECT_EQ(SmithItems.size(), vendorCountBefore - 1)
<< "Item should be removed from vendor inventory";
}
// ===========================================================================
// Double close is safe
// ===========================================================================
TEST_F(VisualStoreTest, DoubleClose_IsSafe)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
CloseVisualStore();
// Second close should not crash or change state.
CloseVisualStore();
EXPECT_FALSE(IsVisualStoreOpen);
}
// ===========================================================================
// Re-opening resets state
// ===========================================================================
TEST_F(VisualStoreTest, Reopen_ResetsState)
{
PopulateVendors();
OpenVisualStore(VisualStoreVendor::Smith);
SetVisualStoreTab(VisualStoreTab::Premium);
if (GetVisualStorePageCount() > 1) {
VisualStoreNextPage();
}
CloseVisualStore();
OpenVisualStore(VisualStoreVendor::Smith);
EXPECT_EQ(VisualStore.activeTab, VisualStoreTab::Basic)
<< "Tab should reset to Basic on re-open";
EXPECT_EQ(VisualStore.currentPage, 0u)
<< "Page should reset to 0 on re-open";
}
} // namespace
} // namespace devilution