Browse Source
Signed-off-by: staphen <staphen@gmail.com> Co-authored-by: staphen <staphen@gmail.com>pull/8530/head
14 changed files with 2855 additions and 30 deletions
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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…
Reference in new issue