You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
999 lines
27 KiB
999 lines
27 KiB
/** |
|
* @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.visualStoreUI.SetValue(true); |
|
} |
|
|
|
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
|