diff --git a/CMake/Tests.cmake b/CMake/Tests.cmake index 95328c02a..a48aedc02 100644 --- a/CMake/Tests.cmake +++ b/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 diff --git a/Source/control/control.hpp b/Source/control/control.hpp index fe5f08489..d6090de25 100644 --- a/Source/control/control.hpp +++ b/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(); diff --git a/Source/controls/control_mode.hpp b/Source/controls/control_mode.hpp index 6363c5195..0a8653a39 100644 --- a/Source/controls/control_mode.hpp +++ b/Source/controls/control_mode.hpp @@ -3,6 +3,7 @@ #include #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. diff --git a/Source/inv.h b/Source/inv.h index 4a1d56c70..325b19e97 100644 --- a/Source/inv.h +++ b/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); diff --git a/Source/minitext.h b/Source/minitext.h index 2dfd293e4..45ca792d7 100644 --- a/Source/minitext.h +++ b/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 diff --git a/Source/qol/stash.h b/Source/qol/stash.h index 4e5038f05..5dac0bcd1 100644 --- a/Source/qol/stash.h +++ b/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; diff --git a/Source/qol/visual_store.h b/Source/qol/visual_store.h index 45889cec5..18855c96b 100644 --- a/Source/qol/visual_store.h +++ b/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 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. diff --git a/Source/quests.h b/Source/quests.h index 549584e1d..9354184f1 100644 --- a/Source/quests.h +++ b/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; diff --git a/Source/stores.cpp b/Source/stores.cpp index b23368803..436cb3fab 100644 --- a/Source/stores.cpp +++ b/Source/stores.cpp @@ -55,6 +55,19 @@ StaticVector 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"), diff --git a/Source/stores.h b/Source/stores.h index a783c3289..0f1d2d7d0 100644 --- a/Source/stores.h +++ b/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 HealerItems; extern DVL_API_FOR_TEST StaticVector 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); diff --git a/test/panel_state_test.cpp b/test/panel_state_test.cpp new file mode 100644 index 000000000..0fa13a612 --- /dev/null +++ b/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 + +#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(store) << " should count as in-store"; + + DoInventoryKeyPress(); + EXPECT_FALSE(invflag) + << "Inventory should be blocked for TalkID " << static_cast(store); + + ActiveStore = TalkID::None; + } +} + +} // namespace +} // namespace devilution \ No newline at end of file diff --git a/test/store_transaction_test.cpp b/test/store_transaction_test.cpp new file mode 100644 index 000000000..d1e22b86c --- /dev/null +++ b/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 + +#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(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(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(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 \ No newline at end of file diff --git a/test/ui_test.hpp b/test/ui_test.hpp new file mode 100644 index 000000000..63e6ec59a --- /dev/null +++ b/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 + +#include + +#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(GOLD_MAX_LIMIT)); + MyPlayer->InvList[slot]._itype = ItemType::Gold; + MyPlayer->InvList[slot]._ivalue = pileSize; + MyPlayer->InvGrid[slot] = static_cast(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(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 \ No newline at end of file diff --git a/test/visual_store_test.cpp b/test/visual_store_test.cpp new file mode 100644 index 000000000..10bf7ee50 --- /dev/null +++ b/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 + +#include + +#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(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 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(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(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 basicItems = GetVisualStoreItems(); + EXPECT_EQ(basicItems.data(), SmithItems.data()) + << "Basic tab should reference SmithItems"; + + // Smith premium + SetVisualStoreTab(VisualStoreTab::Premium); + std::span premiumItems = GetVisualStoreItems(); + EXPECT_EQ(premiumItems.data(), PremiumItems.data()) + << "Premium tab should reference PremiumItems"; + + CloseVisualStore(); + + // Witch + OpenVisualStore(VisualStoreVendor::Witch); + std::span witchItems = GetVisualStoreItems(); + EXPECT_EQ(witchItems.data(), WitchItems.data()); + CloseVisualStore(); + + // Healer + OpenVisualStore(VisualStoreVendor::Healer); + std::span 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 \ No newline at end of file