Browse Source

Add panel and store transaction tests with UI test fixture (#8528)

Signed-off-by: staphen <staphen@gmail.com>
Co-authored-by: staphen <staphen@gmail.com>
pull/8530/head
Anders Jenbo 1 day ago committed by GitHub
parent
commit
e6f958be25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      CMake/Tests.cmake
  2. 6
      Source/control/control.hpp
  3. 3
      Source/controls/control_mode.hpp
  4. 2
      Source/inv.h
  5. 3
      Source/minitext.h
  6. 5
      Source/qol/stash.h
  7. 9
      Source/qol/visual_store.h
  8. 2
      Source/quests.h
  9. 27
      Source/stores.cpp
  10. 19
      Source/stores.h
  11. 624
      test/panel_state_test.cpp
  12. 932
      test/store_transaction_test.cpp
  13. 251
      test/ui_test.hpp
  14. 999
      test/visual_store_test.cpp

3
CMake/Tests.cmake

@ -38,6 +38,9 @@ set(tests
townerdat_test
writehero_test
vendor_test
panel_state_test
store_transaction_test
visual_store_test
)
set(standalone_tests
codec_test

6
Source/control/control.hpp

@ -61,9 +61,9 @@ extern OptionalOwnedClxSpriteList GoldBoxBuffer;
extern bool MainPanelFlag;
extern bool ChatFlag;
extern bool SpellbookFlag;
extern bool CharFlag;
extern bool SpellSelectFlag;
extern DVL_API_FOR_TEST bool SpellbookFlag;
extern DVL_API_FOR_TEST bool CharFlag;
extern DVL_API_FOR_TEST bool SpellSelectFlag;
[[nodiscard]] const Rectangle &GetMainPanel();
[[nodiscard]] const Rectangle &GetLeftPanel();

3
Source/controls/control_mode.hpp

@ -3,6 +3,7 @@
#include <cstdint>
#include "controls/controller_buttons.h"
#include "utils/attributes.h"
namespace devilution {
@ -13,7 +14,7 @@ enum class ControlTypes : uint8_t {
VirtualGamepad,
};
extern ControlTypes ControlMode;
extern DVL_API_FOR_TEST ControlTypes ControlMode;
/**
* @brief Controlling device type.

2
Source/inv.h

@ -82,7 +82,7 @@ enum item_color : uint8_t {
// clang-format on
};
extern bool invflag;
extern DVL_API_FOR_TEST bool invflag;
extern const Rectangle InvRect[NUM_XY_SLOTS];
void InvDrawSlotBack(const Surface &out, Point targetPosition, Size size, item_quality itemQuality);

3
Source/minitext.h

@ -7,11 +7,12 @@
#include "engine/surface.hpp"
#include "tables/textdat.h"
#include "utils/attributes.h"
namespace devilution {
/** Specify if the quest dialog window is being shown */
extern bool qtextflag;
extern DVL_API_FOR_TEST bool qtextflag;
/**
* @brief Free the resources used by the quest dialog window

5
Source/qol/stash.h

@ -13,6 +13,7 @@
#include "engine/point.hpp"
#include "engine/points_in_rectangle_range.hpp"
#include "items.h"
#include "utils/attributes.h"
namespace devilution {
@ -68,8 +69,8 @@ private:
constexpr Point InvalidStashPoint { -1, -1 };
extern bool IsStashOpen;
extern StashStruct Stash;
extern DVL_API_FOR_TEST bool IsStashOpen;
extern DVL_API_FOR_TEST StashStruct Stash;
extern bool IsWithdrawGoldOpen;
extern int WithdrawGoldValue;

9
Source/qol/visual_store.h

@ -12,6 +12,7 @@
#include "engine/point.hpp"
#include "engine/surface.hpp"
#include "items.h"
#include "utils/attributes.h"
namespace devilution {
@ -48,10 +49,10 @@ struct VisualStoreState {
std::vector<VisualStorePage> pages;
};
extern bool IsVisualStoreOpen;
extern VisualStoreState VisualStore;
extern int16_t pcursstoreitem; // Currently highlighted store item index (-1 if none)
extern int16_t pcursstorebtn;
extern DVL_API_FOR_TEST bool IsVisualStoreOpen;
extern DVL_API_FOR_TEST VisualStoreState VisualStore;
extern DVL_API_FOR_TEST int16_t pcursstoreitem; // Currently highlighted store item index (-1 if none)
extern DVL_API_FOR_TEST int16_t pcursstorebtn;
/**
* @brief Load visual store graphics.

2
Source/quests.h

@ -106,7 +106,7 @@ struct QuestData {
std::string _qlstr;
};
extern bool QuestLogIsOpen;
extern DVL_API_FOR_TEST bool QuestLogIsOpen;
extern OptionalOwnedClxSpriteList pQLogCel;
extern DVL_API_FOR_TEST Quest Quests[MAXQUESTS];
extern Point ReturnLvlPosition;

27
Source/stores.cpp

@ -55,6 +55,19 @@ StaticVector<Item, NumWitchItemsHf> WitchItems;
int BoyItemLevel;
Item BoyItem;
/** Remember currently selected text line from TextLine while displaying a dialog */
int OldTextLine;
/** Currently selected text line from TextLine */
int CurrentTextLine;
/** Remember last scroll position */
int OldScrollPos;
/** Scroll position */
int ScrollPos;
/** Remember current store while displaying a dialog */
TalkID OldActiveStore;
/** Temporary item used to hold the item being traded */
Item TempItem;
namespace {
/** The current towner being interacted with */
@ -65,10 +78,6 @@ bool IsTextFullSize;
/** Number of text lines in the current dialog */
int NumTextLines;
/** Remember currently selected text line from TextLine while displaying a dialog */
int OldTextLine;
/** Currently selected text line from TextLine */
int CurrentTextLine;
struct STextStruct {
enum Type : uint8_t {
@ -110,10 +119,6 @@ bool RenderGold;
/** Does the current panel have a scrollbar */
bool HasScrollbar;
/** Remember last scroll position */
int OldScrollPos;
/** Scroll position */
int ScrollPos;
/** Next scroll position */
int NextScrollPos;
/** Previous scroll position */
@ -123,12 +128,6 @@ int8_t CountdownScrollUp;
/** Countdown for the push state of the scroll down button */
int8_t CountdownScrollDown;
/** Remember current store while displaying a dialog */
TalkID OldActiveStore;
/** Temporary item used to hold the item being traded */
Item TempItem;
/** Maps from towner IDs to NPC names. */
const char *const TownerNames[] = {
N_("Griswold"),

19
Source/stores.h

@ -64,7 +64,7 @@ enum class TalkID : uint8_t {
};
/** Currently active store */
extern TalkID ActiveStore;
extern DVL_API_FOR_TEST TalkID ActiveStore;
/** Current index into PlayerItemIndexes/PlayerItems */
extern DVL_API_FOR_TEST int CurrentItemIndex;
@ -89,9 +89,22 @@ extern DVL_API_FOR_TEST StaticVector<Item, NumHealerItemsHf> HealerItems;
extern DVL_API_FOR_TEST StaticVector<Item, NumWitchItemsHf> WitchItems;
/** Current level of the item sold by Wirt */
extern int BoyItemLevel;
extern DVL_API_FOR_TEST int BoyItemLevel;
/** Current item sold by Wirt */
extern Item BoyItem;
extern DVL_API_FOR_TEST Item BoyItem;
/** Currently selected text line from TextLine */
extern DVL_API_FOR_TEST int CurrentTextLine;
/** Remember currently selected text line from TextLine while displaying a dialog */
extern DVL_API_FOR_TEST int OldTextLine;
/** Scroll position */
extern DVL_API_FOR_TEST int ScrollPos;
/** Remember last scroll position */
extern DVL_API_FOR_TEST int OldScrollPos;
/** Remember current store while displaying a dialog */
extern DVL_API_FOR_TEST TalkID OldActiveStore;
/** Temporary item used to hold the item being traded */
extern DVL_API_FOR_TEST Item TempItem;
void AddStoreHoldRepair(Item *itm, int8_t i);

624
test/panel_state_test.cpp

@ -0,0 +1,624 @@
/**
* @file panel_state_test.cpp
*
* Tests for the panel state machine the mutual-exclusion and toggle rules
* that govern which side panels (inventory, spellbook, character sheet,
* quest log, stash, visual store) can be open simultaneously.
*
* These tests call the same functions the keymapper invokes (or their
* constituent parts where the top-level function lacks a header declaration).
* Assertions are purely on boolean panel-state flags, making them resilient
* to rendering, layout, or widget-tree refactors.
*/
#include <gtest/gtest.h>
#include "ui_test.hpp"
#include "control/control.hpp"
#include "inv.h"
#include "options.h"
#include "panels/spell_list.hpp"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "quests.h"
#include "stores.h"
namespace devilution {
namespace {
// ---------------------------------------------------------------------------
// The *KeyPressed() functions in diablo.cpp have no header declarations.
// Rather than modifying production headers just for tests, we replicate their
// observable behaviour by toggling the same globals and calling the same
// sub-functions that *are* declared in public headers.
//
// Each helper here is a faithful mirror of the corresponding function in
// diablo.cpp, minus the CanPanelsCoverView()/SetCursorPos() cursor-
// repositioning logic that is irrelevant in headless mode.
// ---------------------------------------------------------------------------
/// Mirror of InventoryKeyPressed() (Source/diablo.cpp).
void DoInventoryKeyPress()
{
if (IsPlayerInStore())
return;
invflag = !invflag;
SpellbookFlag = false;
CloseStash();
if (IsVisualStoreOpen)
CloseVisualStore();
}
/// Mirror of SpellBookKeyPressed() (Source/diablo.cpp).
void DoSpellBookKeyPress()
{
if (IsPlayerInStore())
return;
SpellbookFlag = !SpellbookFlag;
CloseInventory(); // closes stash, visual store, and sets invflag=false
}
/// Mirror of CharacterSheetKeyPressed() (Source/diablo.cpp).
void DoCharacterSheetKeyPress()
{
if (IsPlayerInStore())
return;
ToggleCharPanel(); // OpenCharPanel closes quest log, stash, visual store
}
/// Mirror of QuestLogKeyPressed() (Source/diablo.cpp).
void DoQuestLogKeyPress()
{
if (IsPlayerInStore())
return;
if (!QuestLogIsOpen) {
StartQuestlog();
} else {
QuestLogIsOpen = false;
}
CloseCharPanel();
CloseStash();
if (IsVisualStoreOpen)
CloseVisualStore();
}
/// Mirror of DisplaySpellsKeyPressed() (Source/diablo.cpp).
void DoDisplaySpellsKeyPress()
{
if (IsPlayerInStore())
return;
CloseCharPanel();
QuestLogIsOpen = false;
CloseInventory();
SpellbookFlag = false;
if (!SpellSelectFlag) {
DoSpeedBook();
} else {
SpellSelectFlag = false;
}
}
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
class PanelStateTest : public UITest {
protected:
void SetUp() override
{
UITest::SetUp();
// Initialise quests so StartQuestlog() doesn't crash.
InitQuests();
}
};
// ===== Basic toggle tests =================================================
TEST_F(PanelStateTest, InventoryTogglesOnAndOff)
{
ASSERT_FALSE(invflag);
DoInventoryKeyPress();
EXPECT_TRUE(invflag);
DoInventoryKeyPress();
EXPECT_FALSE(invflag);
}
TEST_F(PanelStateTest, SpellBookTogglesOnAndOff)
{
ASSERT_FALSE(SpellbookFlag);
DoSpellBookKeyPress();
EXPECT_TRUE(SpellbookFlag);
DoSpellBookKeyPress();
EXPECT_FALSE(SpellbookFlag);
}
TEST_F(PanelStateTest, CharacterSheetTogglesOnAndOff)
{
ASSERT_FALSE(CharFlag);
DoCharacterSheetKeyPress();
EXPECT_TRUE(CharFlag);
DoCharacterSheetKeyPress();
EXPECT_FALSE(CharFlag);
}
TEST_F(PanelStateTest, QuestLogTogglesOnAndOff)
{
ASSERT_FALSE(QuestLogIsOpen);
DoQuestLogKeyPress();
EXPECT_TRUE(QuestLogIsOpen);
DoQuestLogKeyPress();
EXPECT_FALSE(QuestLogIsOpen);
}
// ===== Right-panel mutual exclusion (inventory vs spellbook) ==============
TEST_F(PanelStateTest, OpeningInventoryClosesSpellBook)
{
DoSpellBookKeyPress();
ASSERT_TRUE(SpellbookFlag);
DoInventoryKeyPress();
EXPECT_TRUE(invflag);
EXPECT_FALSE(SpellbookFlag) << "Opening inventory must close spellbook";
}
TEST_F(PanelStateTest, OpeningSpellBookClosesInventory)
{
DoInventoryKeyPress();
ASSERT_TRUE(invflag);
DoSpellBookKeyPress();
EXPECT_TRUE(SpellbookFlag);
EXPECT_FALSE(invflag) << "Opening spellbook must close inventory";
}
// ===== Left-panel mutual exclusion (character sheet vs quest log) ==========
TEST_F(PanelStateTest, OpeningCharSheetClosesQuestLog)
{
DoQuestLogKeyPress();
ASSERT_TRUE(QuestLogIsOpen);
DoCharacterSheetKeyPress();
EXPECT_TRUE(CharFlag);
EXPECT_FALSE(QuestLogIsOpen) << "Opening character sheet must close quest log";
}
TEST_F(PanelStateTest, OpeningQuestLogClosesCharSheet)
{
DoCharacterSheetKeyPress();
ASSERT_TRUE(CharFlag);
DoQuestLogKeyPress();
EXPECT_TRUE(QuestLogIsOpen);
EXPECT_FALSE(CharFlag) << "Opening quest log must close character sheet";
}
// ===== Cross-side independence =============================================
// Left-side panels should NOT close right-side panels and vice versa.
TEST_F(PanelStateTest, InventoryDoesNotCloseCharSheet)
{
DoCharacterSheetKeyPress();
ASSERT_TRUE(CharFlag);
DoInventoryKeyPress();
EXPECT_TRUE(invflag);
EXPECT_TRUE(CharFlag) << "Inventory (right) must not close char sheet (left)";
}
TEST_F(PanelStateTest, CharSheetDoesNotCloseInventory)
{
DoInventoryKeyPress();
ASSERT_TRUE(invflag);
DoCharacterSheetKeyPress();
EXPECT_TRUE(CharFlag);
EXPECT_TRUE(invflag) << "Char sheet (left) must not close inventory (right)";
}
TEST_F(PanelStateTest, SpellBookDoesNotCloseQuestLog)
{
DoQuestLogKeyPress();
ASSERT_TRUE(QuestLogIsOpen);
DoSpellBookKeyPress();
EXPECT_TRUE(SpellbookFlag);
EXPECT_TRUE(QuestLogIsOpen) << "Spellbook (right) must not close quest log (left)";
}
TEST_F(PanelStateTest, QuestLogDoesNotCloseSpellBook)
{
DoSpellBookKeyPress();
ASSERT_TRUE(SpellbookFlag);
DoQuestLogKeyPress();
EXPECT_TRUE(QuestLogIsOpen);
EXPECT_TRUE(SpellbookFlag) << "Quest log (left) must not close spellbook (right)";
}
// ===== Both sides open at once =============================================
TEST_F(PanelStateTest, CanOpenBothSidesSimultaneously)
{
DoInventoryKeyPress();
DoCharacterSheetKeyPress();
EXPECT_TRUE(invflag);
EXPECT_TRUE(CharFlag);
EXPECT_TRUE(IsRightPanelOpen());
EXPECT_TRUE(IsLeftPanelOpen());
}
TEST_F(PanelStateTest, SpellBookAndQuestLogBothOpen)
{
DoSpellBookKeyPress();
DoQuestLogKeyPress();
EXPECT_TRUE(SpellbookFlag);
EXPECT_TRUE(QuestLogIsOpen);
EXPECT_TRUE(IsRightPanelOpen());
EXPECT_TRUE(IsLeftPanelOpen());
}
// ===== Rapid cycling =======================================================
TEST_F(PanelStateTest, RapidRightPanelCycling)
{
DoInventoryKeyPress();
EXPECT_TRUE(invflag);
EXPECT_FALSE(SpellbookFlag);
DoSpellBookKeyPress();
EXPECT_FALSE(invflag);
EXPECT_TRUE(SpellbookFlag);
DoInventoryKeyPress();
EXPECT_TRUE(invflag);
EXPECT_FALSE(SpellbookFlag);
DoSpellBookKeyPress();
EXPECT_FALSE(invflag);
EXPECT_TRUE(SpellbookFlag);
DoSpellBookKeyPress(); // toggle off
EXPECT_FALSE(invflag);
EXPECT_FALSE(SpellbookFlag);
}
TEST_F(PanelStateTest, RapidLeftPanelCycling)
{
DoCharacterSheetKeyPress();
EXPECT_TRUE(CharFlag);
EXPECT_FALSE(QuestLogIsOpen);
DoQuestLogKeyPress();
EXPECT_FALSE(CharFlag);
EXPECT_TRUE(QuestLogIsOpen);
DoCharacterSheetKeyPress();
EXPECT_TRUE(CharFlag);
EXPECT_FALSE(QuestLogIsOpen);
DoQuestLogKeyPress();
EXPECT_FALSE(CharFlag);
EXPECT_TRUE(QuestLogIsOpen);
DoQuestLogKeyPress(); // toggle off
EXPECT_FALSE(CharFlag);
EXPECT_FALSE(QuestLogIsOpen);
}
// ===== IsLeftPanelOpen / IsRightPanelOpen helpers ==========================
TEST_F(PanelStateTest, IsLeftPanelOpenReflectsCharFlag)
{
EXPECT_FALSE(IsLeftPanelOpen());
DoCharacterSheetKeyPress();
EXPECT_TRUE(IsLeftPanelOpen());
DoCharacterSheetKeyPress();
EXPECT_FALSE(IsLeftPanelOpen());
}
TEST_F(PanelStateTest, IsLeftPanelOpenReflectsQuestLog)
{
EXPECT_FALSE(IsLeftPanelOpen());
DoQuestLogKeyPress();
EXPECT_TRUE(IsLeftPanelOpen());
DoQuestLogKeyPress();
EXPECT_FALSE(IsLeftPanelOpen());
}
TEST_F(PanelStateTest, IsRightPanelOpenReflectsInventory)
{
EXPECT_FALSE(IsRightPanelOpen());
DoInventoryKeyPress();
EXPECT_TRUE(IsRightPanelOpen());
DoInventoryKeyPress();
EXPECT_FALSE(IsRightPanelOpen());
}
TEST_F(PanelStateTest, IsRightPanelOpenReflectsSpellbook)
{
EXPECT_FALSE(IsRightPanelOpen());
DoSpellBookKeyPress();
EXPECT_TRUE(IsRightPanelOpen());
DoSpellBookKeyPress();
EXPECT_FALSE(IsRightPanelOpen());
}
// ===== Stash interactions ==================================================
TEST_F(PanelStateTest, IsLeftPanelOpenReflectsStash)
{
EXPECT_FALSE(IsLeftPanelOpen());
IsStashOpen = true;
EXPECT_TRUE(IsLeftPanelOpen());
IsStashOpen = false;
EXPECT_FALSE(IsLeftPanelOpen());
}
TEST_F(PanelStateTest, OpeningInventoryClosesStash)
{
IsStashOpen = true;
ASSERT_TRUE(IsLeftPanelOpen());
DoInventoryKeyPress();
EXPECT_TRUE(invflag);
EXPECT_FALSE(IsStashOpen) << "Opening inventory must close stash";
}
TEST_F(PanelStateTest, OpeningQuestLogClosesStash)
{
IsStashOpen = true;
ASSERT_TRUE(IsLeftPanelOpen());
DoQuestLogKeyPress();
EXPECT_TRUE(QuestLogIsOpen);
EXPECT_FALSE(IsStashOpen) << "Opening quest log must close stash";
}
TEST_F(PanelStateTest, OpeningCharSheetClosesStash)
{
IsStashOpen = true;
ASSERT_TRUE(IsLeftPanelOpen());
DoCharacterSheetKeyPress();
EXPECT_TRUE(CharFlag);
EXPECT_FALSE(IsStashOpen) << "Opening character sheet must close stash";
}
// ===== Store blocks panel toggling =========================================
TEST_F(PanelStateTest, InventoryBlockedWhileInStore)
{
ActiveStore = TalkID::Smith;
ASSERT_TRUE(IsPlayerInStore());
DoInventoryKeyPress();
EXPECT_FALSE(invflag) << "Inventory toggle must be blocked while in store";
}
TEST_F(PanelStateTest, SpellBookBlockedWhileInStore)
{
ActiveStore = TalkID::Witch;
ASSERT_TRUE(IsPlayerInStore());
DoSpellBookKeyPress();
EXPECT_FALSE(SpellbookFlag) << "Spellbook toggle must be blocked while in store";
}
TEST_F(PanelStateTest, CharSheetBlockedWhileInStore)
{
ActiveStore = TalkID::Healer;
ASSERT_TRUE(IsPlayerInStore());
DoCharacterSheetKeyPress();
EXPECT_FALSE(CharFlag) << "Char sheet toggle must be blocked while in store";
}
TEST_F(PanelStateTest, QuestLogBlockedWhileInStore)
{
ActiveStore = TalkID::Storyteller;
ASSERT_TRUE(IsPlayerInStore());
DoQuestLogKeyPress();
EXPECT_FALSE(QuestLogIsOpen) << "Quest log toggle must be blocked while in store";
}
TEST_F(PanelStateTest, DisplaySpellsBlockedWhileInStore)
{
ActiveStore = TalkID::Smith;
ASSERT_TRUE(IsPlayerInStore());
DoDisplaySpellsKeyPress();
// The key observation is that nothing should change — no panels opened.
EXPECT_FALSE(SpellSelectFlag);
}
// ===== DisplaySpells (speed book) ==========================================
TEST_F(PanelStateTest, DisplaySpellsClosesAllPanels)
{
DoInventoryKeyPress();
DoCharacterSheetKeyPress();
ASSERT_TRUE(invflag);
ASSERT_TRUE(CharFlag);
DoDisplaySpellsKeyPress();
EXPECT_FALSE(invflag) << "Display spells must close inventory";
EXPECT_FALSE(SpellbookFlag) << "Display spells must close spellbook";
EXPECT_FALSE(CharFlag) << "Display spells must close character sheet";
EXPECT_FALSE(QuestLogIsOpen) << "Display spells must close quest log";
}
// ===== Complex multi-step scenarios ========================================
TEST_F(PanelStateTest, FullPanelWorkflow)
{
// Open inventory
DoInventoryKeyPress();
EXPECT_TRUE(invflag);
EXPECT_FALSE(SpellbookFlag);
// Open character sheet alongside
DoCharacterSheetKeyPress();
EXPECT_TRUE(invflag);
EXPECT_TRUE(CharFlag);
// Switch right panel to spellbook (closes inventory)
DoSpellBookKeyPress();
EXPECT_FALSE(invflag);
EXPECT_TRUE(SpellbookFlag);
EXPECT_TRUE(CharFlag); // left panel unaffected
// Switch left panel to quest log (closes char sheet)
DoQuestLogKeyPress();
EXPECT_TRUE(SpellbookFlag); // right panel unaffected
EXPECT_FALSE(CharFlag);
EXPECT_TRUE(QuestLogIsOpen);
// Close everything with display spells
DoDisplaySpellsKeyPress();
EXPECT_FALSE(invflag);
EXPECT_FALSE(SpellbookFlag);
EXPECT_FALSE(CharFlag);
EXPECT_FALSE(QuestLogIsOpen);
}
TEST_F(PanelStateTest, StorePreventsAllToggles)
{
ActiveStore = TalkID::SmithBuy;
ASSERT_TRUE(IsPlayerInStore());
DoInventoryKeyPress();
DoSpellBookKeyPress();
DoCharacterSheetKeyPress();
DoQuestLogKeyPress();
DoDisplaySpellsKeyPress();
EXPECT_FALSE(invflag);
EXPECT_FALSE(SpellbookFlag);
EXPECT_FALSE(CharFlag);
EXPECT_FALSE(QuestLogIsOpen);
EXPECT_FALSE(SpellSelectFlag);
}
TEST_F(PanelStateTest, PanelsWorkAfterStoreCloses)
{
// Open store, try to open panels (should be blocked), then close store.
ActiveStore = TalkID::Smith;
DoInventoryKeyPress();
EXPECT_FALSE(invflag);
// Close the store.
ActiveStore = TalkID::None;
ASSERT_FALSE(IsPlayerInStore());
// Now panels should work again.
DoInventoryKeyPress();
EXPECT_TRUE(invflag);
DoCharacterSheetKeyPress();
EXPECT_TRUE(CharFlag);
}
// ===== Edge cases ==========================================================
TEST_F(PanelStateTest, NoPanelsOpenInitially)
{
EXPECT_FALSE(invflag);
EXPECT_FALSE(SpellbookFlag);
EXPECT_FALSE(CharFlag);
EXPECT_FALSE(QuestLogIsOpen);
EXPECT_FALSE(SpellSelectFlag);
EXPECT_FALSE(IsStashOpen);
EXPECT_FALSE(IsVisualStoreOpen);
EXPECT_FALSE(IsLeftPanelOpen());
EXPECT_FALSE(IsRightPanelOpen());
}
TEST_F(PanelStateTest, ToggleSamePanelTwiceReturnsToOriginal)
{
// Double-toggle each panel — should end up closed.
DoInventoryKeyPress();
DoInventoryKeyPress();
EXPECT_FALSE(invflag);
DoSpellBookKeyPress();
DoSpellBookKeyPress();
EXPECT_FALSE(SpellbookFlag);
DoCharacterSheetKeyPress();
DoCharacterSheetKeyPress();
EXPECT_FALSE(CharFlag);
DoQuestLogKeyPress();
DoQuestLogKeyPress();
EXPECT_FALSE(QuestLogIsOpen);
}
TEST_F(PanelStateTest, OpeningSpellBookClosesStash)
{
IsStashOpen = true;
ASSERT_TRUE(IsLeftPanelOpen());
// Spellbook is a right-side panel, but CloseInventory() (called inside
// SpellBookKeyPressed) also closes the stash.
DoSpellBookKeyPress();
EXPECT_TRUE(SpellbookFlag);
EXPECT_FALSE(IsStashOpen) << "Opening spellbook calls CloseInventory which closes stash";
}
TEST_F(PanelStateTest, MultipleStoreTypesAllBlockPanels)
{
// Verify a selection of different TalkID values all count as "in store".
const TalkID stores[] = {
TalkID::Smith,
TalkID::SmithBuy,
TalkID::SmithSell,
TalkID::SmithRepair,
TalkID::Witch,
TalkID::WitchBuy,
TalkID::Boy,
TalkID::BoyBuy,
TalkID::Healer,
TalkID::HealerBuy,
TalkID::Storyteller,
TalkID::StorytellerIdentify,
TalkID::SmithPremiumBuy,
TalkID::Confirm,
TalkID::NoMoney,
TalkID::NoRoom,
TalkID::Gossip,
TalkID::Tavern,
TalkID::Drunk,
TalkID::Barmaid,
};
for (TalkID store : stores) {
CloseAllPanels();
ActiveStore = store;
ASSERT_TRUE(IsPlayerInStore())
<< "TalkID value " << static_cast<int>(store) << " should count as in-store";
DoInventoryKeyPress();
EXPECT_FALSE(invflag)
<< "Inventory should be blocked for TalkID " << static_cast<int>(store);
ActiveStore = TalkID::None;
}
}
} // namespace
} // namespace devilution

932
test/store_transaction_test.cpp

@ -0,0 +1,932 @@
/**
* @file store_transaction_test.cpp
*
* End-to-end tests for text-based store transactions.
*
* These tests drive the store state machine through its TalkID transitions
* (StartStore browse items select Confirm commit) and assert only
* on **game state outcomes**: player gold, inventory contents, item
* properties, vendor inventory changes.
*
* No assertions are made on text lines, rendering, or UI layout so these
* tests remain valid when the text-based store is replaced with a new UI,
* provided the replacement exposes equivalent "buy / sell / repair / identify
* / recharge" entry points with the same game-state semantics.
*
* The store state machine works as follows:
* - StartStore(TalkID) sets up the text UI and sets ActiveStore at the end.
* - StoreEnter() dispatches on ActiveStore to the appropriate *Enter() fn.
* - *Enter() functions check CurrentTextLine to decide what action to take:
* - For item lists, item index = ScrollPos + ((CurrentTextLine - 5) / 4)
* where 5 is PreviousScrollPos (set by ScrollVendorStore).
* - For confirmations, line 18 = Yes, line 20 = No.
* - On buy: checks afford checks room copies to TempItem Confirm.
* - ConfirmEnter dispatches to the actual transaction function on Yes.
*/
#include <gtest/gtest.h>
#include "ui_test.hpp"
#include "control/control.hpp"
#include "engine/random.hpp"
#include "inv.h"
#include "items.h"
#include "minitext.h"
#include "options.h"
#include "player.h"
#include "qol/stash.h"
#include "quests.h"
#include "stores.h"
#include "storm/storm_net.hpp"
#include "tables/itemdat.h"
#include "tables/playerdat.hpp"
#include "tables/spelldat.h"
namespace devilution {
namespace {
// ---------------------------------------------------------------------------
// Helpers to drive the store state machine at a high level.
//
// These abstract over the text-line / scroll-position encoding so that
// tests read as "select item 0, confirm yes" rather than
// "set CurrentTextLine=5, ScrollPos=0, call StoreEnter, ...".
// ---------------------------------------------------------------------------
/**
* @brief Open a vendor's top-level menu.
*
* Equivalent to the player clicking on a towner NPC.
*/
void OpenVendor(TalkID vendor)
{
StartStore(vendor);
}
/**
* @brief In a top-level vendor menu, select a menu option by its text line.
*
* The line numbers are fixed by the Start*() functions:
* Smith: 12=Buy, 14=Premium, 16=Sell, 18=Repair, 20=Leave
* Witch: 12=Talk, 14=Buy, 16=Sell, 18=Recharge, 20=Leave
* Healer: 12=Talk, 14=Buy, 18=Leave
* Storyteller: 12=Talk, 14=Identify, 18=Leave
* Boy: 18=What have you got? (if item exists)
*/
void SelectMenuLine(int line)
{
CurrentTextLine = line;
StoreEnter();
}
/**
* @brief In an item list (buy/sell/repair/identify/recharge), select item
* at the given 0-based index.
*
* The store text layout puts items starting at line 5 (PreviousScrollPos),
* with each item taking 4 lines. So item N is at line 5 + N*4 when
* ScrollPos is 0.
*/
void SelectItemAtIndex(int itemIndex)
{
ScrollPos = 0;
CurrentTextLine = 5 + itemIndex * 4;
StoreEnter();
}
/**
* @brief Confirm a pending transaction (press "Yes" in the Confirm dialog).
*
* Precondition: ActiveStore == TalkID::Confirm.
*/
void ConfirmYes()
{
CurrentTextLine = 18;
StoreEnter();
}
/**
* @brief Decline a pending transaction (press "No" in the Confirm dialog).
*
* Precondition: ActiveStore == TalkID::Confirm.
*/
void ConfirmNo()
{
CurrentTextLine = 20;
StoreEnter();
}
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
class StoreTransactionTest : public UITest {
protected:
void SetUp() override
{
UITest::SetUp();
// Seed the RNG for deterministic item generation.
SetRndSeed(42);
// Make sure visualStoreUI is off (the base fixture does this too,
// but be explicit).
GetOptions().Gameplay.visualStoreUI.SetValue(false);
}
/**
* @brief Populate all town vendors with items appropriate for a level-25 player.
*
* Must be called after SetUp() and after any adjustments to player level.
*/
void PopulateVendors()
{
SetRndSeed(42);
int l = 16; // max store level
SpawnSmith(l);
SpawnWitch(l);
SpawnHealer(l);
SpawnBoy(MyPlayer->getCharacterLevel());
SpawnPremium(*MyPlayer);
}
/**
* @brief Create a simple melee weapon item suitable for selling to the smith.
*/
Item MakeSellableSword()
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iIdentified = true;
return item;
}
/**
* @brief Create a magic item that is unidentified (for Cain tests).
*/
Item MakeUnidentifiedMagicItem()
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iMagical = ITEM_QUALITY_MAGIC;
item._iIdentified = false;
item._iIvalue = 2000;
item._ivalue = 2000;
return item;
}
/**
* @brief Create a damaged item suitable for repair at the smith.
*/
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;
}
};
// ===========================================================================
// Level A: Transaction primitive tests (completely UI-agnostic)
// ===========================================================================
TEST_F(StoreTransactionTest, PlayerCanAfford_SufficientGold)
{
SetPlayerGold(10000);
EXPECT_TRUE(PlayerCanAfford(5000));
EXPECT_TRUE(PlayerCanAfford(10000));
}
TEST_F(StoreTransactionTest, PlayerCanAfford_InsufficientGold)
{
SetPlayerGold(1000);
EXPECT_FALSE(PlayerCanAfford(5000));
}
TEST_F(StoreTransactionTest, PlayerCanAfford_IncludesStashGold)
{
SetPlayerGold(2000);
Stash.gold = 3000;
EXPECT_TRUE(PlayerCanAfford(5000));
EXPECT_TRUE(PlayerCanAfford(4999));
EXPECT_FALSE(PlayerCanAfford(5001));
}
TEST_F(StoreTransactionTest, TakePlrsMoney_DeductsFromInventory)
{
SetPlayerGold(10000);
int goldBefore = MyPlayer->_pGold;
TakePlrsMoney(3000);
EXPECT_EQ(MyPlayer->_pGold, goldBefore - 3000);
}
TEST_F(StoreTransactionTest, TakePlrsMoney_OverflowsToStash)
{
SetPlayerGold(2000);
Stash.gold = 5000;
TakePlrsMoney(4000);
// 2000 from inventory + 2000 from stash
EXPECT_EQ(MyPlayer->_pGold, 0);
EXPECT_EQ(Stash.gold, 3000);
}
TEST_F(StoreTransactionTest, StoreAutoPlace_EmptyInventory)
{
ClearInventory();
ClearBelt();
ClearEquipment();
Item item {};
InitializeItem(item, IDI_HEAL);
// Dry-run: should succeed.
EXPECT_TRUE(StoreAutoPlace(item, false));
// Persist: item should appear in player's inventory/belt/equipment.
EXPECT_TRUE(StoreAutoPlace(item, true));
}
TEST_F(StoreTransactionTest, SmithWillBuy_AcceptsMeleeWeapon)
{
Item sword {};
InitializeItem(sword, IDI_BARDSWORD);
sword._iIdentified = true;
EXPECT_TRUE(SmithWillBuy(sword));
}
TEST_F(StoreTransactionTest, SmithWillBuy_RejectsMiscItems)
{
Item scroll {};
InitializeItem(scroll, IDI_HEAL);
EXPECT_FALSE(SmithWillBuy(scroll));
}
TEST_F(StoreTransactionTest, WitchWillBuy_AcceptsStaff)
{
// Witch buys staves and misc magical items but not most weapons.
Item staff {};
InitializeItem(staff, IDI_SHORTSTAFF);
staff._iIdentified = true;
EXPECT_TRUE(WitchWillBuy(staff));
}
TEST_F(StoreTransactionTest, WitchWillBuy_RejectsSword)
{
Item sword {};
InitializeItem(sword, IDI_BARDSWORD);
sword._iIdentified = true;
EXPECT_FALSE(WitchWillBuy(sword));
}
// ===========================================================================
// Level B: End-to-end store flows through the state machine
// ===========================================================================
// ---- Smith Buy ------------------------------------------------------------
TEST_F(StoreTransactionTest, SmithBuy_Success)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
// Record state before the transaction.
const int itemPrice = SmithItems[0]._iIvalue;
const size_t vendorCountBefore = SmithItems.size();
StripPlayer();
SetPlayerGold(itemPrice + 1000);
const int goldBefore = MyPlayer->_pGold;
// Drive the state machine: open Smith → browse items → select first → confirm.
OpenVendor(TalkID::SmithBuy);
ASSERT_EQ(ActiveStore, TalkID::SmithBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm)
<< "Selecting an affordable item should go to Confirm";
ConfirmYes();
// Assertions on game state.
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice)
<< "Player gold should decrease by item price";
EXPECT_EQ(SmithItems.size(), vendorCountBefore - 1)
<< "Purchased item should be removed from vendor inventory";
}
TEST_F(StoreTransactionTest, SmithBuy_CantAfford)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
SetPlayerGold(0);
Stash.gold = 0;
OpenVendor(TalkID::SmithBuy);
ASSERT_EQ(ActiveStore, TalkID::SmithBuy);
const size_t vendorCountBefore = SmithItems.size();
SelectItemAtIndex(0);
EXPECT_EQ(ActiveStore, TalkID::NoMoney)
<< "Should transition to NoMoney when player can't afford item";
EXPECT_EQ(SmithItems.size(), vendorCountBefore)
<< "Vendor inventory should be unchanged";
}
TEST_F(StoreTransactionTest, SmithBuy_NoRoom)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
// Give the player enough gold but fill the inventory completely.
SetPlayerGold(100000);
// Fill every inventory grid cell, every belt slot, and every equipment slot
// so StoreAutoPlace returns false.
for (int i = 0; i < InventoryGridCells; i++) {
MyPlayer->InvList[i]._itype = ItemType::Misc;
MyPlayer->InvGrid[i] = static_cast<int8_t>(i + 1);
}
MyPlayer->_pNumInv = InventoryGridCells;
for (int i = 0; i < MaxBeltItems; i++) {
MyPlayer->SpdList[i]._itype = ItemType::Misc;
}
for (auto &bodyItem : MyPlayer->InvBody) {
bodyItem._itype = ItemType::Misc;
}
OpenVendor(TalkID::SmithBuy);
ASSERT_EQ(ActiveStore, TalkID::SmithBuy);
const size_t vendorCountBefore = SmithItems.size();
SelectItemAtIndex(0);
EXPECT_EQ(ActiveStore, TalkID::NoRoom)
<< "Should transition to NoRoom when inventory is full";
EXPECT_EQ(SmithItems.size(), vendorCountBefore)
<< "Vendor inventory should be unchanged";
}
TEST_F(StoreTransactionTest, SmithBuy_ConfirmNo_NoChange)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
const int itemPrice = SmithItems[0]._iIvalue;
StripPlayer();
SetPlayerGold(itemPrice + 1000);
const int goldBefore = MyPlayer->_pGold;
const size_t vendorCountBefore = SmithItems.size();
OpenVendor(TalkID::SmithBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmNo();
EXPECT_EQ(MyPlayer->_pGold, goldBefore)
<< "Declining should not change gold";
EXPECT_EQ(SmithItems.size(), vendorCountBefore)
<< "Declining should not remove item from vendor";
}
// ---- Smith Sell -----------------------------------------------------------
TEST_F(StoreTransactionTest, SmithSell_Success)
{
StripPlayer();
SetPlayerGold(0);
// Place a sellable sword in the player's inventory.
Item sword = MakeSellableSword();
int invIdx = PlaceItemInInventory(sword);
ASSERT_GE(invIdx, 0);
ASSERT_EQ(MyPlayer->_pNumInv, 1);
// Open the sell sub-store directly (Smith menu line 16 → SmithSell).
OpenVendor(TalkID::SmithSell);
ASSERT_EQ(ActiveStore, TalkID::SmithSell);
// The sell list should contain our sword.
ASSERT_GT(CurrentItemIndex, 0) << "Smith should see at least one sellable item";
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
// The sword should be gone and gold should have increased.
EXPECT_GT(MyPlayer->_pGold, 0) << "Player should have received gold from the sale";
}
// ---- Smith Repair ---------------------------------------------------------
TEST_F(StoreTransactionTest, SmithRepair_RestoresDurability)
{
StripPlayer();
// Equip a damaged sword in the right hand.
Item damaged = MakeDamagedSword();
const int maxDur = damaged._iMaxDur;
ASSERT_LT(damaged._iDurability, maxDur);
MyPlayer->InvBody[INVLOC_HAND_RIGHT] = damaged;
SetPlayerGold(100000);
const int goldBefore = MyPlayer->_pGold;
// Open the repair sub-store directly.
OpenVendor(TalkID::SmithRepair);
ASSERT_EQ(ActiveStore, TalkID::SmithRepair);
// The repair list should contain our damaged sword.
ASSERT_GT(CurrentItemIndex, 0) << "Smith should see the damaged item";
// Record the repair cost.
const int repairCost = PlayerItems[0]._iIvalue;
ASSERT_GT(repairCost, 0);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
EXPECT_EQ(MyPlayer->InvBody[INVLOC_HAND_RIGHT]._iDurability,
MyPlayer->InvBody[INVLOC_HAND_RIGHT]._iMaxDur)
<< "Durability should be fully restored after repair";
EXPECT_EQ(MyPlayer->_pGold, goldBefore - repairCost)
<< "Repair cost should be deducted from gold";
}
TEST_F(StoreTransactionTest, SmithRepair_CantAfford)
{
StripPlayer();
Item damaged = MakeDamagedSword();
const int originalDur = damaged._iDurability;
MyPlayer->InvBody[INVLOC_HAND_RIGHT] = damaged;
SetPlayerGold(0);
Stash.gold = 0;
OpenVendor(TalkID::SmithRepair);
ASSERT_EQ(ActiveStore, TalkID::SmithRepair);
ASSERT_GT(CurrentItemIndex, 0);
SelectItemAtIndex(0);
EXPECT_EQ(ActiveStore, TalkID::NoMoney)
<< "Should transition to NoMoney when can't afford repair";
EXPECT_EQ(MyPlayer->InvBody[INVLOC_HAND_RIGHT]._iDurability, originalDur)
<< "Durability should be unchanged";
}
// ---- Healer ---------------------------------------------------------------
TEST_F(StoreTransactionTest, Healer_FreeHealOnTalk)
{
// Damage the player.
MyPlayer->_pHitPoints = MyPlayer->_pMaxHP / 2;
MyPlayer->_pHPBase = MyPlayer->_pMaxHPBase / 2;
ASSERT_NE(MyPlayer->_pHitPoints, MyPlayer->_pMaxHP);
const int goldBefore = MyPlayer->_pGold;
// Just opening the healer menu heals the player for free.
OpenVendor(TalkID::Healer);
ASSERT_EQ(ActiveStore, TalkID::Healer);
EXPECT_EQ(MyPlayer->_pHitPoints, MyPlayer->_pMaxHP)
<< "Player should be fully healed just by talking to Pepin";
EXPECT_EQ(MyPlayer->_pHPBase, MyPlayer->_pMaxHPBase)
<< "Player HP base should also be restored";
EXPECT_EQ(MyPlayer->_pGold, goldBefore)
<< "Healing at Pepin is free — gold should be unchanged";
}
TEST_F(StoreTransactionTest, HealerBuy_Success)
{
PopulateVendors();
ASSERT_FALSE(HealerItems.empty());
StripPlayer();
const int itemPrice = HealerItems[0]._iIvalue;
SetPlayerGold(itemPrice + 1000);
const int goldBefore = MyPlayer->_pGold;
// Navigate through the healer menu like a real player would:
// First open the healer top-level menu, then select "Buy items" (line 14).
// This ensures StartHealerBuy() is called, which sets PreviousScrollPos
// correctly via ScrollVendorStore.
OpenVendor(TalkID::Healer);
ASSERT_EQ(ActiveStore, TalkID::Healer);
SelectMenuLine(14);
ASSERT_EQ(ActiveStore, TalkID::HealerBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice)
<< "Gold should decrease by the price of the healing item";
}
// ---- Boy (Wirt) -----------------------------------------------------------
TEST_F(StoreTransactionTest, BoyBuy_Success)
{
PopulateVendors();
// Wirt must have an item.
if (BoyItem.isEmpty()) {
GTEST_SKIP() << "Wirt has no item with this seed — skipping";
}
// Wirt charges a 50g viewing fee, then the item price with markup.
int price = BoyItem._iIvalue;
price += BoyItem._iIvalue / 2; // Diablo 50% markup
StripPlayer();
SetPlayerGold(price + 100);
const int goldBefore = MyPlayer->_pGold;
// Open Wirt's menu.
OpenVendor(TalkID::Boy);
ASSERT_EQ(ActiveStore, TalkID::Boy);
// Pay the viewing fee (line 18 when Wirt has an item).
SelectMenuLine(18);
// This should deduct 50g and transition to BoyBuy.
ASSERT_EQ(ActiveStore, TalkID::BoyBuy)
<< "After paying viewing fee, should see Wirt's item";
EXPECT_EQ(MyPlayer->_pGold, goldBefore - 50)
<< "50 gold viewing fee should be deducted";
// Select the item (it's at line 10 for Boy).
CurrentTextLine = 10;
StoreEnter();
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
EXPECT_EQ(MyPlayer->_pGold, goldBefore - 50 - price)
<< "Gold should decrease by viewing fee + item price";
EXPECT_TRUE(BoyItem.isEmpty())
<< "Wirt's item should be cleared after purchase";
}
TEST_F(StoreTransactionTest, BoyBuy_CantAffordViewingFee)
{
PopulateVendors();
if (BoyItem.isEmpty()) {
GTEST_SKIP() << "Wirt has no item with this seed — skipping";
}
SetPlayerGold(30); // Less than 50g viewing fee.
Stash.gold = 0;
OpenVendor(TalkID::Boy);
ASSERT_EQ(ActiveStore, TalkID::Boy);
SelectMenuLine(18);
EXPECT_EQ(ActiveStore, TalkID::NoMoney)
<< "Should get NoMoney when can't afford viewing fee";
}
// ---- Storyteller (Cain) — Identify ----------------------------------------
TEST_F(StoreTransactionTest, StorytellerIdentify_Success)
{
StripPlayer();
SetPlayerGold(10000);
// Place an unidentified magic item in inventory.
Item magic = MakeUnidentifiedMagicItem();
int invIdx = PlaceItemInInventory(magic);
ASSERT_GE(invIdx, 0);
ASSERT_FALSE(MyPlayer->InvList[invIdx]._iIdentified);
const int goldBefore = MyPlayer->_pGold;
// Open identify.
OpenVendor(TalkID::StorytellerIdentify);
ASSERT_EQ(ActiveStore, TalkID::StorytellerIdentify);
ASSERT_GT(CurrentItemIndex, 0) << "Cain should see the unidentified item";
// The identify cost is always 100 gold.
EXPECT_EQ(PlayerItems[0]._iIvalue, 100);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
// After ConfirmEnter for identify, it transitions to IdentifyShow.
EXPECT_EQ(ActiveStore, TalkID::StorytellerIdentifyShow)
<< "Should show the identified item";
// The actual item in the player's inventory should now be identified.
EXPECT_TRUE(MyPlayer->InvList[invIdx]._iIdentified)
<< "Item should be identified after Cain's service";
EXPECT_EQ(MyPlayer->_pGold, goldBefore - 100)
<< "Identification costs 100 gold";
}
TEST_F(StoreTransactionTest, StorytellerIdentify_CantAfford)
{
StripPlayer();
SetPlayerGold(50); // Less than 100g identify cost.
Stash.gold = 0;
Item magic = MakeUnidentifiedMagicItem();
int invIdx = PlaceItemInInventory(magic);
ASSERT_GE(invIdx, 0);
OpenVendor(TalkID::StorytellerIdentify);
ASSERT_EQ(ActiveStore, TalkID::StorytellerIdentify);
ASSERT_GT(CurrentItemIndex, 0);
SelectItemAtIndex(0);
EXPECT_EQ(ActiveStore, TalkID::NoMoney)
<< "Should get NoMoney when can't afford identification";
EXPECT_FALSE(MyPlayer->InvList[invIdx]._iIdentified)
<< "Item should remain unidentified";
}
// ---- Witch Buy ------------------------------------------------------------
TEST_F(StoreTransactionTest, WitchBuy_PinnedItemsRemainAfterPurchase)
{
PopulateVendors();
ASSERT_GE(WitchItems.size(), static_cast<size_t>(NumWitchPinnedItems));
// Buy the first pinned item (e.g., mana potion).
const int itemPrice = WitchItems[0]._iIvalue;
StripPlayer();
SetPlayerGold(itemPrice + 1000);
const size_t vendorCountBefore = WitchItems.size();
OpenVendor(TalkID::WitchBuy);
ASSERT_EQ(ActiveStore, TalkID::WitchBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
// Pinned items (first NumWitchPinnedItems) should NOT be removed.
EXPECT_EQ(WitchItems.size(), vendorCountBefore)
<< "Pinned witch items should remain after purchase (infinite stock)";
}
TEST_F(StoreTransactionTest, WitchBuy_NonPinnedItemRemoved)
{
PopulateVendors();
// Skip past the pinned items. We need at least one non-pinned item.
if (WitchItems.size() <= static_cast<size_t>(NumWitchPinnedItems)) {
GTEST_SKIP() << "Not enough non-pinned witch items";
}
const int idx = NumWitchPinnedItems; // First non-pinned item.
const int itemPrice = WitchItems[idx]._iIvalue;
StripPlayer();
SetPlayerGold(itemPrice + 1000);
const size_t vendorCountBefore = WitchItems.size();
OpenVendor(TalkID::WitchBuy);
ASSERT_EQ(ActiveStore, TalkID::WitchBuy);
SelectItemAtIndex(idx);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
EXPECT_EQ(WitchItems.size(), vendorCountBefore - 1)
<< "Non-pinned witch item should be removed after purchase";
}
// ---- Repair cost calculation (extends existing stores_test.cpp) -----------
TEST_F(StoreTransactionTest, RepairCost_MagicItem_ScalesWithDurabilityLoss)
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iMagical = ITEM_QUALITY_MAGIC;
item._iIdentified = true;
item._iMaxDur = 60;
item._iIvalue = 19000;
item._ivalue = 2000;
// Test a range of durability losses.
for (int dur = 1; dur < item._iMaxDur; dur++) {
item._iDurability = dur;
item._ivalue = 2000;
item._iIvalue = 19000;
CurrentItemIndex = 0;
AddStoreHoldRepair(&item, 0);
if (CurrentItemIndex > 0) {
const int due = item._iMaxDur - dur;
const int expectedCost = 30 * 19000 * due / (item._iMaxDur * 100 * 2);
if (expectedCost > 0) {
EXPECT_EQ(PlayerItems[0]._iIvalue, expectedCost)
<< "Repair cost mismatch at durability " << dur;
}
}
}
}
TEST_F(StoreTransactionTest, RepairCost_NormalItem_MinimumOneGold)
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iMagical = ITEM_QUALITY_NORMAL;
item._iIdentified = true;
item._iMaxDur = 20;
item._ivalue = 10;
item._iIvalue = 10;
item._iDurability = 19; // Only 1 durability lost.
CurrentItemIndex = 0;
AddStoreHoldRepair(&item, 0);
ASSERT_EQ(CurrentItemIndex, 1);
EXPECT_GE(PlayerItems[0]._iIvalue, 1)
<< "Repair cost should be at least 1 gold for normal items";
}
// ---- Gold from stash used in transactions ---------------------------------
TEST_F(StoreTransactionTest, BuyUsingStashGold)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
const int itemPrice = SmithItems[0]._iIvalue;
ASSERT_GT(itemPrice, 0);
// Give the player less gold than the price in inventory, make up
// the difference with stash gold.
const int inventoryGold = itemPrice / 3;
const int stashGold = itemPrice - inventoryGold + 500;
StripPlayer();
SetPlayerGold(inventoryGold);
Stash.gold = stashGold;
ASSERT_TRUE(PlayerCanAfford(itemPrice));
OpenVendor(TalkID::SmithBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
// Total gold (inventory + stash) should have decreased by itemPrice.
const int totalGoldAfter = MyPlayer->_pGold + Stash.gold;
const int expectedTotal = inventoryGold + stashGold - itemPrice;
EXPECT_EQ(totalGoldAfter, expectedTotal)
<< "Total gold (inventory + stash) should decrease by item price";
}
// ---- Multiple transactions ------------------------------------------------
TEST_F(StoreTransactionTest, SmithBuy_MultipleItemsPurchased)
{
PopulateVendors();
ASSERT_GE(SmithItems.size(), 3u);
const size_t initialCount = SmithItems.size();
int totalSpent = 0;
// Buy three items in succession. Strip and re-fund between purchases
// because purchased items occupy inventory slots and gold piles also
// occupy slots — we need room for both the gold and the next item.
for (int purchase = 0; purchase < 3; purchase++) {
ASSERT_FALSE(SmithItems.empty());
const int price = SmithItems[0]._iIvalue;
StripPlayer();
SetPlayerGold(price + 1000);
const int goldBefore = MyPlayer->_pGold;
OpenVendor(TalkID::SmithBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm)
<< "Purchase " << purchase << " should reach Confirm";
ConfirmYes();
EXPECT_EQ(MyPlayer->_pGold, goldBefore - price)
<< "Gold should decrease by item price on purchase " << purchase;
totalSpent += price;
}
EXPECT_EQ(SmithItems.size(), initialCount - 3)
<< "Three items should have been removed from Smith's inventory";
}
// ---- Store leaves correct state after ESC ---------------------------------
TEST_F(StoreTransactionTest, StoreESC_ClosesStore)
{
OpenVendor(TalkID::Smith);
ASSERT_EQ(ActiveStore, TalkID::Smith);
ASSERT_TRUE(IsPlayerInStore());
// Select "Leave" (line 20 for Smith without visual store, or the last option).
SelectMenuLine(20);
EXPECT_EQ(ActiveStore, TalkID::None)
<< "Leaving the store should set ActiveStore to None";
EXPECT_FALSE(IsPlayerInStore());
}
// ---- Confirm dialog returns to correct sub-store --------------------------
TEST_F(StoreTransactionTest, ConfirmNo_ReturnsToItemList)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
const int itemPrice = SmithItems[0]._iIvalue;
StripPlayer();
SetPlayerGold(itemPrice + 1000);
OpenVendor(TalkID::SmithBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmNo();
EXPECT_EQ(ActiveStore, TalkID::SmithBuy)
<< "Declining should return to the buy list";
}
// ---- NoMoney returns to correct sub-store on enter ------------------------
TEST_F(StoreTransactionTest, NoMoney_ReturnsToItemListOnEnter)
{
PopulateVendors();
ASSERT_FALSE(SmithItems.empty());
SetPlayerGold(0);
Stash.gold = 0;
OpenVendor(TalkID::SmithBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::NoMoney);
// Pressing enter on NoMoney should return to the original item list.
StoreEnter();
EXPECT_EQ(ActiveStore, TalkID::SmithBuy)
<< "Entering on NoMoney should return to the buy list";
}
// ---- Premium items --------------------------------------------------------
TEST_F(StoreTransactionTest, SmithPremiumBuy_ReplacesSlot)
{
PopulateVendors();
ASSERT_FALSE(PremiumItems.empty());
const int itemPrice = PremiumItems[0]._iIvalue;
StripPlayer();
SetPlayerGold(itemPrice + 5000);
const size_t premiumCountBefore = PremiumItems.size();
const int goldBefore = MyPlayer->_pGold;
OpenVendor(TalkID::SmithPremiumBuy);
ASSERT_EQ(ActiveStore, TalkID::SmithPremiumBuy);
SelectItemAtIndex(0);
ASSERT_EQ(ActiveStore, TalkID::Confirm);
ConfirmYes();
// Premium items are _replaced_, not removed. Count should stay the same.
EXPECT_EQ(PremiumItems.size(), premiumCountBefore)
<< "Premium item slot should be replaced, not removed";
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice)
<< "Gold should decrease by premium item price";
}
} // namespace
} // namespace devilution

251
test/ui_test.hpp

@ -0,0 +1,251 @@
/**
* @file ui_test.hpp
*
* Shared test fixture for UI domain tests.
*
* Provides a fully-initialised single-player game state with a level-25 Warrior
* who has plenty of gold. All panels are closed before and after each test.
*
* Tests that need MPQ assets skip gracefully when they are not available.
*/
#pragma once
#include <algorithm>
#include <gtest/gtest.h>
#include "control/control.hpp"
#include "controls/control_mode.hpp"
#include "cursor.h"
#include "engine/assets.hpp"
#include "inv.h"
#include "items.h"
#include "minitext.h"
#include "options.h"
#include "player.h"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "quests.h"
#include "stores.h"
#include "storm/storm_net.hpp"
#include "tables/itemdat.h"
#include "tables/playerdat.hpp"
#include "tables/spelldat.h"
namespace devilution {
constexpr const char UITestMissingMpqMsg[] = "MPQ assets (spawn.mpq or DIABDAT.MPQ) not found - skipping test suite";
/**
* @brief Base test fixture that boots enough of the engine for UI-level tests.
*
* Usage:
* class MyTest : public UITest { ... };
* TEST_F(MyTest, SomeScenario) { ... }
*
* The fixture guarantees:
* - A single-player game with one Warrior at character level 25 and 100 000 gold.
* - ControlMode pinned to KeyboardAndMouse (tests are deterministic regardless of host input).
* - All panels / stores / overlays closed before and after each test.
* - visualStoreUI option disabled so we always exercise the text-based store path.
* - A loopback network provider (needed by functions that send net commands).
*/
class UITest : public ::testing::Test {
protected:
/* ---- once per test suite ---- */
static void SetUpTestSuite()
{
LoadCoreArchives();
LoadGameArchives();
missingAssets_ = !HaveMainData();
if (missingAssets_)
return;
LoadPlayerDataFiles();
LoadItemData();
LoadSpellData();
LoadQuestData();
// Note: do NOT call InitCursor() here. CreatePlayer() calls
// CreatePlrItems() which does its own InitCursor()/FreeCursor() cycle.
}
/* ---- every test ---- */
void SetUp() override
{
if (missingAssets_) {
GTEST_SKIP() << UITestMissingMpqMsg;
}
Players.resize(1);
MyPlayer = &Players[0];
// Ensure we are in single-player Diablo mode.
gbIsHellfire = false;
gbIsMultiplayer = false;
CreatePlayer(*MyPlayer, HeroClass::Warrior);
MyPlayer->setCharacterLevel(25);
// CreatePlayer() calls CreatePlrItems() which does InitCursor()/FreeCursor().
// Re-initialise cursors because store operations (StoreAutoPlace, etc.)
// need cursor sprite data to determine item sizes.
InitCursor();
// Give the player a generous amount of gold, distributed as inventory gold piles.
SetPlayerGold(100000);
// Initialise stash with some gold too so stash-related paths work.
Stash = {};
Stash.gold = 0;
// Pin the control mode so behaviour is deterministic.
ControlMode = ControlTypes::KeyboardAndMouse;
// Always use the text-based store path so we can drive its state machine.
GetOptions().Gameplay.visualStoreUI.SetValue(false);
GetOptions().Gameplay.showItemGraphicsInStores.SetValue(false);
// Close everything that might be open.
CloseAllPanels();
// Set up loopback networking (needed for inventory mutations that send net commands).
SNetInitializeProvider(SELCONN_LOOPBACK, nullptr);
// Clear store state.
InitStores();
// Make sure quest-text overlay is off.
qtextflag = false;
}
void TearDown() override
{
CloseAllPanels();
ActiveStore = TalkID::None;
FreeCursor();
}
/* ---- helpers ---- */
/**
* @brief Close every panel / overlay without relying on ClosePanels()
* (which has no header declaration and does cursor-position side-effects
* that are undesirable in headless tests).
*/
static void CloseAllPanels()
{
invflag = false;
SpellbookFlag = false;
CharFlag = false;
QuestLogIsOpen = false;
SpellSelectFlag = false;
IsStashOpen = false;
if (IsVisualStoreOpen) {
IsVisualStoreOpen = false;
}
ActiveStore = TalkID::None;
}
/**
* @brief Give the player exactly @p amount gold, placed as inventory gold piles.
*/
static void SetPlayerGold(int amount)
{
// Clear existing gold piles.
for (int i = 0; i < MyPlayer->_pNumInv; ++i) {
if (MyPlayer->InvList[i]._itype == ItemType::Gold) {
MyPlayer->InvList[i].clear();
}
}
MyPlayer->_pGold = 0;
if (amount <= 0)
return;
// Place gold in a single pile (up to GOLD_MAX_LIMIT per pile).
int remaining = amount;
int slot = 0;
while (remaining > 0 && slot < InventoryGridCells) {
int pileSize = std::min(remaining, static_cast<int>(GOLD_MAX_LIMIT));
MyPlayer->InvList[slot]._itype = ItemType::Gold;
MyPlayer->InvList[slot]._ivalue = pileSize;
MyPlayer->InvGrid[slot] = static_cast<int8_t>(slot + 1);
remaining -= pileSize;
slot++;
}
MyPlayer->_pNumInv = slot;
MyPlayer->_pGold = amount;
}
/**
* @brief Clear the player's inventory completely (items + grid + gold).
*/
static void ClearInventory()
{
for (int i = 0; i < InventoryGridCells; i++) {
MyPlayer->InvList[i] = {};
MyPlayer->InvGrid[i] = 0;
}
MyPlayer->_pNumInv = 0;
MyPlayer->_pGold = 0;
}
/**
* @brief Clear the player's belt.
*/
static void ClearBelt()
{
for (int i = 0; i < MaxBeltItems; i++) {
MyPlayer->SpdList[i].clear();
}
}
/**
* @brief Clear all equipment slots.
*/
static void ClearEquipment()
{
for (auto &bodyItem : MyPlayer->InvBody) {
bodyItem = {};
}
}
/**
* @brief Completely strip the player of all items and gold.
*/
void StripPlayer()
{
ClearInventory();
ClearBelt();
ClearEquipment();
MyPlayer->_pGold = 0;
}
/**
* @brief Place a simple item in the player's inventory at the first free slot.
* @return The inventory index where the item was placed, or -1 on failure.
*/
int PlaceItemInInventory(const Item &item)
{
int idx = MyPlayer->_pNumInv;
if (idx >= InventoryGridCells)
return -1;
MyPlayer->InvList[idx] = item;
MyPlayer->InvGrid[idx] = static_cast<int8_t>(idx + 1);
MyPlayer->_pNumInv = idx + 1;
return idx;
}
private:
static bool missingAssets_;
};
// Static member definition — must appear in exactly one translation unit.
// Since this is a header, we use `inline` (C++17).
inline bool UITest::missingAssets_ = false;
} // namespace devilution

999
test/visual_store_test.cpp

@ -0,0 +1,999 @@
/**
* @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
Loading…
Cancel
Save