Browse Source

Add tests for most of the UI (#8530)

pull/8226/merge
Anders Jenbo 22 hours ago committed by GitHub
parent
commit
e05887278b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CMake/Tests.cmake
  2. 8
      Source/control/control.hpp
  3. 2
      Source/diablo.h
  4. 4
      Source/gamemenu.h
  5. 4
      Source/gmenu.h
  6. 1
      Source/inv.h
  7. 303
      test/char_panel_test.cpp
  8. 246
      test/game_menu_test.cpp
  9. 359
      test/inventory_ui_test.cpp
  10. 461
      test/spell_ui_test.cpp
  11. 698
      test/stash_test.cpp

5
CMake/Tests.cmake

@ -41,6 +41,11 @@ set(tests
panel_state_test
store_transaction_test
visual_store_test
stash_test
inventory_ui_test
spell_ui_test
char_panel_test
game_menu_test
)
set(standalone_tests
codec_test

8
Source/control/control.hpp

@ -40,8 +40,8 @@ constexpr Size SidePanelSize { 320, 352 };
constexpr Rectangle InfoBoxRect = { { 177, 46 }, { 288, 64 } };
extern bool CharPanelButton[4];
extern bool CharPanelButtonActive;
extern DVL_API_FOR_TEST bool CharPanelButton[4];
extern DVL_API_FOR_TEST bool CharPanelButtonActive;
extern int SpellbookTab;
@ -51,7 +51,7 @@ extern StringOrView InfoString;
extern StringOrView FloatingInfoString;
extern Rectangle MainPanelButtonRect[8];
extern Rectangle CharPanelButtonRect[4];
extern DVL_API_FOR_TEST Rectangle CharPanelButtonRect[4];
extern bool MainPanelButtonDown;
extern bool LevelButtonDown;
@ -145,7 +145,7 @@ void RedBack(const Surface &out);
void DrawDeathText(const Surface &out);
void DrawSpellBook(const Surface &out);
extern Rectangle CharPanelButtonRect[4];
extern DVL_API_FOR_TEST Rectangle CharPanelButtonRect[4];
bool CheckKeypress(SDL_Keycode vkey);
void DiabloHotkeyMsg(uint32_t dwMsg);

2
Source/diablo.h

@ -70,7 +70,7 @@ enum class PlayerActionType : uint8_t {
extern uint32_t DungeonSeeds[NUMLEVELS];
extern DVL_API_FOR_TEST std::optional<uint32_t> LevelSeeds[NUMLEVELS];
extern Point MousePosition;
extern DVL_API_FOR_TEST Point MousePosition;
extern bool gbRunGameResult;
extern bool ReturnToMainMenu;

4
Source/gamemenu.h

@ -5,6 +5,8 @@
*/
#pragma once
#include "utils/attributes.h"
namespace devilution {
void gamemenu_on();
@ -15,6 +17,6 @@ void gamemenu_quit_game(bool bActivate);
void gamemenu_load_game(bool bActivate);
void gamemenu_save_game(bool bActivate);
extern bool isGameMenuOpen;
extern DVL_API_FOR_TEST bool isGameMenuOpen;
} // namespace devilution

4
Source/gmenu.h

@ -7,6 +7,8 @@
#include <cstdint>
#include "utils/attributes.h"
#ifdef USE_SDL3
#include <SDL3/SDL_keycode.h>
#else
@ -76,7 +78,7 @@ struct TMenuItem {
}
};
extern TMenuItem *sgpCurrentMenu;
extern DVL_API_FOR_TEST TMenuItem *sgpCurrentMenu;
void gmenu_draw_pause(const Surface &out);
void FreeGMenu();

1
Source/inv.h

@ -13,6 +13,7 @@
#include "items.h"
#include "player.h"
#include "utils/algorithm/container.hpp"
#include "utils/attributes.h"
namespace devilution {

303
test/char_panel_test.cpp

@ -0,0 +1,303 @@
/**
* @file char_panel_test.cpp
*
* Tests for the character panel (stat display and stat point allocation).
*
* Covers: open/close/toggle, stat point allocation via CheckChrBtns +
* ReleaseChrBtns, no-allocation when stat points are zero, and stat
* value consistency checks.
*
* All assertions are on game state (CharFlag, stat values, stat points).
* No assertions on rendering, pixel positions, or widget layout.
*
* NOTE: The actual stat increase (e.g. +1 Strength) is applied via
* NetSendCmdParam1 loopback OnAddStrength ModifyPlrStr. With
* SELCONN_LOOPBACK the message is queued but not processed synchronously
* (there is no message pump in the test harness). Therefore stat
* allocation tests verify the LOCAL side-effects _pStatPts decreasing
* and CheckChrBtns/ReleaseChrBtns flow rather than the final stat value.
*/
#include <gtest/gtest.h>
#include "ui_test.hpp"
#include "control/control.hpp"
#include "control/control_panel.hpp"
#include "diablo.h"
#include "player.h"
namespace devilution {
namespace {
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
class CharPanelTest : public UITest {
protected:
void SetUp() override
{
UITest::SetUp();
// Reset stat allocation state.
CharPanelButtonActive = false;
for (int i = 0; i < 4; i++)
CharPanelButton[i] = false;
}
/**
* @brief Simulate pressing and releasing a stat button for the given
* attribute.
*
* Positions MousePosition inside the adjusted button rect, calls
* CheckChrBtns() to "press", then ReleaseChrBtns() to "release"
* (which triggers the stat decrease locally and sends a net command).
*/
void ClickStatButton(CharacterAttribute attribute)
{
auto buttonId = static_cast<size_t>(attribute);
Rectangle button = CharPanelButtonRect[buttonId];
SetPanelObjectPosition(UiPanels::Character, button);
// Position mouse in the centre of the button.
MousePosition = Point {
button.position.x + button.size.width / 2,
button.position.y + button.size.height / 2
};
CheckChrBtns();
ReleaseChrBtns(false);
}
};
// ===========================================================================
// Open / Close / Toggle
// ===========================================================================
TEST_F(CharPanelTest, Open_SetsCharFlag)
{
ASSERT_FALSE(CharFlag);
OpenCharPanel();
EXPECT_TRUE(CharFlag);
}
TEST_F(CharPanelTest, Close_ClearsCharFlag)
{
OpenCharPanel();
ASSERT_TRUE(CharFlag);
CloseCharPanel();
EXPECT_FALSE(CharFlag);
}
TEST_F(CharPanelTest, Toggle_OpensWhenClosed)
{
ASSERT_FALSE(CharFlag);
ToggleCharPanel();
EXPECT_TRUE(CharFlag);
}
TEST_F(CharPanelTest, Toggle_ClosesWhenOpen)
{
OpenCharPanel();
ASSERT_TRUE(CharFlag);
ToggleCharPanel();
EXPECT_FALSE(CharFlag);
}
TEST_F(CharPanelTest, Toggle_DoubleToggle_ReturnsToClosed)
{
ASSERT_FALSE(CharFlag);
ToggleCharPanel();
ToggleCharPanel();
EXPECT_FALSE(CharFlag);
}
// ===========================================================================
// Stat point allocation — verify _pStatPts decrease (local effect)
// ===========================================================================
TEST_F(CharPanelTest, StatAllocation_StrengthDecreasesStatPoints)
{
MyPlayer->_pStatPts = 5;
const int ptsBefore = MyPlayer->_pStatPts;
ClickStatButton(CharacterAttribute::Strength);
EXPECT_EQ(MyPlayer->_pStatPts, ptsBefore - 1)
<< "Stat points should decrease by 1 after allocating to Strength";
}
TEST_F(CharPanelTest, StatAllocation_MagicDecreasesStatPoints)
{
MyPlayer->_pStatPts = 5;
const int ptsBefore = MyPlayer->_pStatPts;
ClickStatButton(CharacterAttribute::Magic);
EXPECT_EQ(MyPlayer->_pStatPts, ptsBefore - 1)
<< "Stat points should decrease by 1 after allocating to Magic";
}
TEST_F(CharPanelTest, StatAllocation_DexterityDecreasesStatPoints)
{
MyPlayer->_pStatPts = 5;
const int ptsBefore = MyPlayer->_pStatPts;
ClickStatButton(CharacterAttribute::Dexterity);
EXPECT_EQ(MyPlayer->_pStatPts, ptsBefore - 1)
<< "Stat points should decrease by 1 after allocating to Dexterity";
}
TEST_F(CharPanelTest, StatAllocation_VitalityDecreasesStatPoints)
{
MyPlayer->_pStatPts = 5;
const int ptsBefore = MyPlayer->_pStatPts;
ClickStatButton(CharacterAttribute::Vitality);
EXPECT_EQ(MyPlayer->_pStatPts, ptsBefore - 1)
<< "Stat points should decrease by 1 after allocating to Vitality";
}
TEST_F(CharPanelTest, StatAllocation_CheckChrBtnsActivatesButton)
{
MyPlayer->_pStatPts = 5;
auto buttonId = static_cast<size_t>(CharacterAttribute::Strength);
Rectangle button = CharPanelButtonRect[buttonId];
SetPanelObjectPosition(UiPanels::Character, button);
MousePosition = Point {
button.position.x + button.size.width / 2,
button.position.y + button.size.height / 2
};
CheckChrBtns();
EXPECT_TRUE(CharPanelButtonActive)
<< "CharPanelButtonActive should be true after CheckChrBtns with stat points";
EXPECT_TRUE(CharPanelButton[buttonId])
<< "The specific button should be marked as pressed";
}
// ===========================================================================
// No stat points available
// ===========================================================================
TEST_F(CharPanelTest, NoStatPoints_CheckChrBtnsDoesNothing)
{
MyPlayer->_pStatPts = 0;
// Position mouse over the first button.
Rectangle button = CharPanelButtonRect[0];
SetPanelObjectPosition(UiPanels::Character, button);
MousePosition = Point {
button.position.x + button.size.width / 2,
button.position.y + button.size.height / 2
};
CheckChrBtns();
EXPECT_FALSE(CharPanelButtonActive)
<< "Buttons should not activate when there are no stat points";
}
TEST_F(CharPanelTest, NoStatPoints_StatPointsUnchanged)
{
MyPlayer->_pStatPts = 0;
ClickStatButton(CharacterAttribute::Strength);
EXPECT_EQ(MyPlayer->_pStatPts, 0)
<< "Stat points should remain zero";
}
// ===========================================================================
// Stat values match player
// ===========================================================================
TEST_F(CharPanelTest, StatValues_MatchPlayerStruct)
{
// The level-25 Warrior created by UITest should have known stat values.
// Just verify the getter returns matching values.
EXPECT_EQ(MyPlayer->GetBaseAttributeValue(CharacterAttribute::Strength),
MyPlayer->_pBaseStr);
EXPECT_EQ(MyPlayer->GetBaseAttributeValue(CharacterAttribute::Magic),
MyPlayer->_pBaseMag);
EXPECT_EQ(MyPlayer->GetBaseAttributeValue(CharacterAttribute::Dexterity),
MyPlayer->_pBaseDex);
EXPECT_EQ(MyPlayer->GetBaseAttributeValue(CharacterAttribute::Vitality),
MyPlayer->_pBaseVit);
}
// ===========================================================================
// Multiple allocations
// ===========================================================================
TEST_F(CharPanelTest, MultipleAllocations_AllStatPointsUsed)
{
MyPlayer->_pStatPts = 3;
ClickStatButton(CharacterAttribute::Strength);
ClickStatButton(CharacterAttribute::Strength);
ClickStatButton(CharacterAttribute::Strength);
EXPECT_EQ(MyPlayer->_pStatPts, 0)
<< "All 3 stat points should be consumed";
}
TEST_F(CharPanelTest, MultipleAllocations_DifferentStats)
{
MyPlayer->_pStatPts = 4;
ClickStatButton(CharacterAttribute::Strength);
ClickStatButton(CharacterAttribute::Magic);
ClickStatButton(CharacterAttribute::Dexterity);
ClickStatButton(CharacterAttribute::Vitality);
EXPECT_EQ(MyPlayer->_pStatPts, 0)
<< "All 4 stat points should be consumed across different stats";
}
// ===========================================================================
// Edge case: allocation stops at max stat
// ===========================================================================
TEST_F(CharPanelTest, AllocationStopsAtMaxStat)
{
// Set strength to the maximum.
const int maxStr = MyPlayer->GetMaximumAttributeValue(CharacterAttribute::Strength);
MyPlayer->_pBaseStr = maxStr;
MyPlayer->_pStatPts = 5;
// Position mouse and try to press — CheckChrBtns should skip the button
// because the stat is already at max.
auto buttonId = static_cast<size_t>(CharacterAttribute::Strength);
Rectangle button = CharPanelButtonRect[buttonId];
SetPanelObjectPosition(UiPanels::Character, button);
MousePosition = Point {
button.position.x + button.size.width / 2,
button.position.y + button.size.height / 2
};
CheckChrBtns();
EXPECT_FALSE(CharPanelButton[buttonId])
<< "Strength button should not activate when stat is at maximum";
EXPECT_EQ(MyPlayer->_pStatPts, 5)
<< "Stat points should be unchanged";
}
} // namespace
} // namespace devilution

246
test/game_menu_test.cpp

@ -0,0 +1,246 @@
/**
* @file game_menu_test.cpp
*
* Tests for the in-game menu (open/close, toggle, active state,
* and slider get/set functions).
*
* All assertions are on game state (isGameMenuOpen, gmenu_is_active(),
* slider values). No assertions on rendering or widget layout.
*
* NOTE: gamemenu_off() calls gmenu_set_items(nullptr, nullptr) which
* triggers SaveOptions(), and SaveOptions() dereferences the global
* std::optional<Ini> that is not initialised in headless/test mode.
* To avoid this crash, TearDown resets the menu state manually instead
* of calling gamemenu_off().
*/
#include <gtest/gtest.h>
#include "ui_test.hpp"
#include "diablo.h"
#include "gamemenu.h"
#include "gmenu.h"
namespace devilution {
namespace {
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
class GameMenuTest : public UITest {
protected:
void SetUp() override
{
UITest::SetUp();
// Ensure the menu starts closed without calling gamemenu_off()
// (which would trigger SaveOptions → crash on uninitialised Ini).
ForceCloseMenu();
}
void TearDown() override
{
ForceCloseMenu();
UITest::TearDown();
}
/**
* @brief Force-close the game menu by resetting the underlying state
* directly, bypassing gamemenu_off() and its SaveOptions() call.
*/
static void ForceCloseMenu()
{
isGameMenuOpen = false;
sgpCurrentMenu = nullptr;
}
};
// ===========================================================================
// Open / Close
// ===========================================================================
TEST_F(GameMenuTest, Open_SetsIsGameMenuOpen)
{
ASSERT_FALSE(isGameMenuOpen);
gamemenu_on();
EXPECT_TRUE(isGameMenuOpen);
}
TEST_F(GameMenuTest, Open_ActivatesMenu)
{
ASSERT_FALSE(gmenu_is_active());
gamemenu_on();
EXPECT_TRUE(gmenu_is_active())
<< "gmenu_is_active() should return true after gamemenu_on()";
}
TEST_F(GameMenuTest, Close_ClearsIsGameMenuOpen)
{
gamemenu_on();
ASSERT_TRUE(isGameMenuOpen);
// Use ForceCloseMenu instead of gamemenu_off to avoid SaveOptions crash.
ForceCloseMenu();
EXPECT_FALSE(isGameMenuOpen);
}
TEST_F(GameMenuTest, Close_DeactivatesMenu)
{
gamemenu_on();
ASSERT_TRUE(gmenu_is_active());
ForceCloseMenu();
EXPECT_FALSE(gmenu_is_active())
<< "gmenu_is_active() should return false after closing the menu";
}
TEST_F(GameMenuTest, HandlePrevious_OpensWhenClosed)
{
ASSERT_FALSE(gmenu_is_active());
gamemenu_handle_previous();
EXPECT_TRUE(isGameMenuOpen);
EXPECT_TRUE(gmenu_is_active());
}
TEST_F(GameMenuTest, HandlePrevious_ClosesWhenOpen)
{
gamemenu_on();
ASSERT_TRUE(gmenu_is_active());
// gamemenu_handle_previous calls gamemenu_off when menu is active,
// which triggers SaveOptions. Instead, test the toggle logic by
// verifying that calling handle_previous on an open menu would
// try to close it. We test the "close" path indirectly:
// gamemenu_handle_previous checks gmenu_is_active() — if true,
// it calls gamemenu_off(). We can't call it directly due to
// SaveOptions, so we verify the open path works and test close
// via ForceCloseMenu.
ForceCloseMenu();
EXPECT_FALSE(isGameMenuOpen);
EXPECT_FALSE(gmenu_is_active());
}
TEST_F(GameMenuTest, DoubleOpen_StillOpen)
{
gamemenu_on();
gamemenu_on();
EXPECT_TRUE(isGameMenuOpen);
EXPECT_TRUE(gmenu_is_active());
}
TEST_F(GameMenuTest, MenuStateConsistency)
{
// gmenu_is_active() should mirror whether sgpCurrentMenu is set.
ASSERT_FALSE(gmenu_is_active());
ASSERT_EQ(sgpCurrentMenu, nullptr);
gamemenu_on();
EXPECT_TRUE(gmenu_is_active());
EXPECT_NE(sgpCurrentMenu, nullptr)
<< "sgpCurrentMenu should be non-null when menu is open";
ForceCloseMenu();
EXPECT_FALSE(gmenu_is_active());
EXPECT_EQ(sgpCurrentMenu, nullptr);
}
// ===========================================================================
// Slider functions (pure computation on TMenuItem, no global state)
// ===========================================================================
TEST_F(GameMenuTest, Slider_SetAndGet)
{
TMenuItem item = {};
item.dwFlags = GMENU_SLIDER | GMENU_ENABLED;
gmenu_slider_steps(&item, 10);
gmenu_slider_set(&item, 0, 100, 50);
int value = gmenu_slider_get(&item, 0, 100);
EXPECT_EQ(value, 50)
<< "Slider should return the value that was set";
}
TEST_F(GameMenuTest, Slider_MinValue)
{
TMenuItem item = {};
item.dwFlags = GMENU_SLIDER | GMENU_ENABLED;
gmenu_slider_steps(&item, 10);
gmenu_slider_set(&item, 0, 100, 0);
int value = gmenu_slider_get(&item, 0, 100);
EXPECT_EQ(value, 0)
<< "Slider should return 0 when set to minimum";
}
TEST_F(GameMenuTest, Slider_MaxValue)
{
TMenuItem item = {};
item.dwFlags = GMENU_SLIDER | GMENU_ENABLED;
gmenu_slider_steps(&item, 10);
gmenu_slider_set(&item, 0, 100, 100);
int value = gmenu_slider_get(&item, 0, 100);
EXPECT_EQ(value, 100)
<< "Slider should return 100 when set to maximum";
}
TEST_F(GameMenuTest, Slider_MidRange)
{
TMenuItem item = {};
item.dwFlags = GMENU_SLIDER | GMENU_ENABLED;
gmenu_slider_steps(&item, 100);
// With 100 steps, set/get should be very accurate.
gmenu_slider_set(&item, 0, 100, 75);
int value = gmenu_slider_get(&item, 0, 100);
EXPECT_EQ(value, 75)
<< "Slider with 100 steps should accurately represent 75/100";
}
TEST_F(GameMenuTest, Slider_CustomRange)
{
TMenuItem item = {};
item.dwFlags = GMENU_SLIDER | GMENU_ENABLED;
gmenu_slider_steps(&item, 50);
gmenu_slider_set(&item, 10, 60, 35);
int value = gmenu_slider_get(&item, 10, 60);
EXPECT_EQ(value, 35)
<< "Slider should work correctly with a custom range [10, 60]";
}
TEST_F(GameMenuTest, Slider_Steps_AffectsGranularity)
{
// With very few steps, values get quantised.
TMenuItem item = {};
item.dwFlags = GMENU_SLIDER | GMENU_ENABLED;
gmenu_slider_steps(&item, 2);
gmenu_slider_set(&item, 0, 100, 0);
EXPECT_EQ(gmenu_slider_get(&item, 0, 100), 0);
gmenu_slider_set(&item, 0, 100, 100);
EXPECT_EQ(gmenu_slider_get(&item, 0, 100), 100);
}
} // namespace
} // namespace devilution

359
test/inventory_ui_test.cpp

@ -0,0 +1,359 @@
/**
* @file inventory_ui_test.cpp
*
* Tests for inventory operations that are not covered by inv_test.cpp.
*
* Covers: AutoEquip, AutoPlaceItemInInventory, CanFitItemInInventory,
* belt placement, RemoveEquipment, ReorganizeInventory, and
* TransferItemToStash.
*/
#include <gtest/gtest.h>
#include "ui_test.hpp"
#include "inv.h"
#include "items.h"
#include "player.h"
#include "qol/stash.h"
namespace devilution {
namespace {
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
class InventoryUITest : public UITest {
protected:
void SetUp() override
{
UITest::SetUp();
// Each test starts with a completely stripped player and clean stash.
StripPlayer();
Stash = {};
Stash.gold = 0;
Stash.dirty = false;
}
// --- helpers ---
/** @brief Create a sword (2×1 one-hand weapon, requires 15 Str). */
static Item MakeSword()
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iIdentified = true;
return item;
}
/** @brief Create a healing potion (1×1, beltable). */
static Item MakePotion()
{
Item item {};
InitializeItem(item, IDI_HEAL);
return item;
}
/** @brief Create a short staff (1×3 two-hand weapon, requires 0 Str). */
static Item MakeStaff()
{
Item item {};
InitializeItem(item, IDI_SHORTSTAFF);
item._iIdentified = true;
return item;
}
/**
* @brief Fill the entire inventory grid with 1×1 healing potions.
*
* After this call every one of the 40 inventory cells is occupied.
*/
void FillInventory()
{
for (int i = 0; i < InventoryGridCells; i++) {
Item potion = MakePotion();
ASSERT_TRUE(AutoPlaceItemInInventory(*MyPlayer, potion))
<< "Failed to place potion at cell " << i;
}
}
/** @brief Fill all 8 belt slots with healing potions. */
void FillBelt()
{
for (int i = 0; i < MaxBeltItems; i++) {
Item potion = MakePotion();
ASSERT_TRUE(AutoPlaceItemInBelt(*MyPlayer, potion, /*persistItem=*/true))
<< "Failed to place potion in belt slot " << i;
}
}
};
// ===========================================================================
// AutoEquip tests
// ===========================================================================
TEST_F(InventoryUITest, AutoEquip_SwordGoesToHand)
{
Item sword = MakeSword();
sword._iStatFlag = true; // Player meets stat requirements
bool equipped = AutoEquip(*MyPlayer, sword);
EXPECT_TRUE(equipped) << "AutoEquip should succeed for a usable sword";
EXPECT_FALSE(MyPlayer->InvBody[INVLOC_HAND_LEFT].isEmpty())
<< "Sword should be placed in the left hand slot";
EXPECT_EQ(MyPlayer->InvBody[INVLOC_HAND_LEFT].IDidx, sword.IDidx)
<< "Equipped item should match the sword we created";
}
TEST_F(InventoryUITest, AutoEquip_ReturnsFalseForUnusableItem)
{
Item sword = MakeSword();
sword._iStatFlag = false; // Player does NOT meet stat requirements
bool equipped = AutoEquip(*MyPlayer, sword);
EXPECT_FALSE(equipped)
<< "AutoEquip should return false when _iStatFlag is false";
EXPECT_TRUE(MyPlayer->InvBody[INVLOC_HAND_LEFT].isEmpty())
<< "Left hand slot should remain empty";
EXPECT_TRUE(MyPlayer->InvBody[INVLOC_HAND_RIGHT].isEmpty())
<< "Right hand slot should remain empty";
}
TEST_F(InventoryUITest, AutoEquip_FailsWhenSlotOccupied)
{
// Equip the first sword.
Item sword1 = MakeSword();
sword1._iStatFlag = true;
ASSERT_TRUE(AutoEquip(*MyPlayer, sword1));
// Also fill the right hand so neither hand slot is free.
Item sword2 = MakeSword();
sword2._iStatFlag = true;
MyPlayer->InvBody[INVLOC_HAND_RIGHT] = sword2;
// Now try to auto-equip a third sword — both hand slots are occupied,
// so CanEquip should reject it (AutoEquip does NOT swap).
Item sword3 = MakeSword();
sword3._iStatFlag = true;
bool equipped = AutoEquip(*MyPlayer, sword3);
EXPECT_FALSE(equipped)
<< "AutoEquip should return false when all valid body slots are occupied";
}
// ===========================================================================
// AutoPlaceItemInInventory / CanFitItemInInventory tests
// ===========================================================================
TEST_F(InventoryUITest, AutoPlaceItem_EmptyInventory)
{
Item sword = MakeSword();
bool placed = AutoPlaceItemInInventory(*MyPlayer, sword);
EXPECT_TRUE(placed) << "Should be able to place an item in an empty inventory";
EXPECT_EQ(MyPlayer->_pNumInv, 1)
<< "Inventory count should be 1 after placing one item";
// Verify the item is actually stored.
EXPECT_EQ(MyPlayer->InvList[0].IDidx, sword.IDidx)
<< "InvList[0] should contain the sword";
// Verify at least one grid cell is set (non-zero).
bool gridSet = false;
for (int i = 0; i < InventoryGridCells; i++) {
if (MyPlayer->InvGrid[i] != 0) {
gridSet = true;
break;
}
}
EXPECT_TRUE(gridSet) << "At least one InvGrid cell should be non-zero";
}
TEST_F(InventoryUITest, AutoPlaceItem_FullInventory)
{
FillInventory();
// Inventory is full — a new item should not fit.
Item extraPotion = MakePotion();
bool placed = AutoPlaceItemInInventory(*MyPlayer, extraPotion);
EXPECT_FALSE(placed)
<< "Should not be able to place an item in a completely full inventory";
}
TEST_F(InventoryUITest, CanFitItem_EmptyInventory)
{
Item sword = MakeSword();
EXPECT_TRUE(CanFitItemInInventory(*MyPlayer, sword))
<< "An empty inventory should have room for a sword";
}
TEST_F(InventoryUITest, CanFitItem_FullInventory)
{
FillInventory();
Item extraPotion = MakePotion();
EXPECT_FALSE(CanFitItemInInventory(*MyPlayer, extraPotion))
<< "A completely full inventory should report no room";
}
// ===========================================================================
// Belt tests
// ===========================================================================
TEST_F(InventoryUITest, CanBePlacedOnBelt_Potion)
{
Item potion = MakePotion();
EXPECT_TRUE(CanBePlacedOnBelt(*MyPlayer, potion))
<< "A healing potion (1×1) should be placeable on the belt";
}
TEST_F(InventoryUITest, CanBePlacedOnBelt_Sword)
{
Item sword = MakeSword();
EXPECT_FALSE(CanBePlacedOnBelt(*MyPlayer, sword))
<< "A sword (2×1 weapon) should NOT be placeable on the belt";
}
TEST_F(InventoryUITest, AutoPlaceBelt_Success)
{
Item potion = MakePotion();
bool placed = AutoPlaceItemInBelt(*MyPlayer, potion, /*persistItem=*/true);
EXPECT_TRUE(placed) << "Should be able to place a potion in an empty belt";
// Verify at least one belt slot contains the item.
bool found = false;
for (int i = 0; i < MaxBeltItems; i++) {
if (!MyPlayer->SpdList[i].isEmpty()) {
found = true;
break;
}
}
EXPECT_TRUE(found) << "Potion should appear in one of the belt slots";
}
TEST_F(InventoryUITest, AutoPlaceBelt_Full)
{
FillBelt();
Item extraPotion = MakePotion();
bool placed = AutoPlaceItemInBelt(*MyPlayer, extraPotion, /*persistItem=*/true);
EXPECT_FALSE(placed)
<< "Should not be able to place a potion when all 8 belt slots are occupied";
}
// ===========================================================================
// RemoveEquipment test
// ===========================================================================
TEST_F(InventoryUITest, RemoveEquipment_ClearsBodySlot)
{
// Equip a sword in the left hand.
Item sword = MakeSword();
sword._iStatFlag = true;
ASSERT_TRUE(AutoEquip(*MyPlayer, sword));
ASSERT_FALSE(MyPlayer->InvBody[INVLOC_HAND_LEFT].isEmpty())
<< "Precondition: left hand should have the sword";
RemoveEquipment(*MyPlayer, INVLOC_HAND_LEFT, false);
EXPECT_TRUE(MyPlayer->InvBody[INVLOC_HAND_LEFT].isEmpty())
<< "Left hand slot should be empty after RemoveEquipment";
}
// ===========================================================================
// ReorganizeInventory test
// ===========================================================================
TEST_F(InventoryUITest, ReorganizeInventory_DefragmentsGrid)
{
// Place three potions via AutoPlace so the grid is properly populated.
Item p1 = MakePotion();
Item p2 = MakePotion();
Item p3 = MakePotion();
ASSERT_TRUE(AutoPlaceItemInInventory(*MyPlayer, p1));
ASSERT_TRUE(AutoPlaceItemInInventory(*MyPlayer, p2));
ASSERT_TRUE(AutoPlaceItemInInventory(*MyPlayer, p3));
ASSERT_EQ(MyPlayer->_pNumInv, 3);
// Remove the middle item to create a gap.
MyPlayer->RemoveInvItem(1);
ASSERT_EQ(MyPlayer->_pNumInv, 2);
// Reorganize should keep all remaining items and defragment the grid.
ReorganizeInventory(*MyPlayer);
EXPECT_EQ(MyPlayer->_pNumInv, 2)
<< "Item count should be preserved after reorganization";
// After reorganization, a potion should still fit (there are 38 free cells).
Item extra = MakePotion();
EXPECT_TRUE(CanFitItemInInventory(*MyPlayer, extra))
<< "Should be able to fit another item after reorganization";
// Verify no InvList entries in the active range are empty.
for (int i = 0; i < MyPlayer->_pNumInv; i++) {
EXPECT_FALSE(MyPlayer->InvList[i].isEmpty())
<< "InvList[" << i << "] should not be empty within active range";
}
}
// ===========================================================================
// TransferItemToStash tests
// ===========================================================================
TEST_F(InventoryUITest, TransferToStash_FromInventory)
{
IsStashOpen = true;
// Place a sword in the inventory.
Item sword = MakeSword();
int idx = PlaceItemInInventory(sword);
ASSERT_GE(idx, 0) << "Failed to place sword in inventory";
ASSERT_EQ(MyPlayer->_pNumInv, 1);
int invLocation = INVITEM_INV_FIRST + idx;
TransferItemToStash(*MyPlayer, invLocation);
// Item should now be in the stash.
EXPECT_FALSE(Stash.stashList.empty())
<< "Stash should contain the transferred item";
// Item should be removed from inventory.
EXPECT_EQ(MyPlayer->_pNumInv, 0)
<< "Inventory should be empty after transferring the only item";
}
TEST_F(InventoryUITest, TransferToStash_InvalidLocation)
{
IsStashOpen = true;
size_t stashSizeBefore = Stash.stashList.size();
int invCountBefore = MyPlayer->_pNumInv;
// Passing -1 should be a no-op (early return), not a crash.
TransferItemToStash(*MyPlayer, -1);
EXPECT_EQ(Stash.stashList.size(), stashSizeBefore)
<< "Stash should be unchanged after invalid transfer";
EXPECT_EQ(MyPlayer->_pNumInv, invCountBefore)
<< "Inventory should be unchanged after invalid transfer";
}
} // namespace
} // namespace devilution

461
test/spell_ui_test.cpp

@ -0,0 +1,461 @@
/**
* @file spell_ui_test.cpp
*
* Tests for the spell book and spell list UI functionality.
*
* Covers:
* - GetSpellListItems() returning learned spells, abilities, and scroll spells
* - SetSpell() changing the player's active/readied spell
* - SetSpeedSpell() assigning spells to hotkey slots
* - IsValidSpeedSpell() validating hotkey slot assignments
* - DoSpeedBook() opening the speed spell selection overlay
* - ToggleSpell() cycling through available spell types for a hotkey
*/
#include <algorithm>
#include <vector>
#include <gtest/gtest.h>
#include "ui_test.hpp"
#include "control/control.hpp"
#include "diablo.h"
#include "panels/spell_icons.hpp"
#include "panels/spell_list.hpp"
#include "player.h"
#include "spells.h"
#include "tables/spelldat.h"
namespace devilution {
namespace {
/**
* @brief Test fixture for spell UI tests.
*
* Inherits from UITest which provides a fully-initialised single-player game
* with a level-25 Warrior, 100,000 gold, all panels closed, loopback
* networking, and HeadlessMode enabled.
*/
class SpellUITest : public UITest {
protected:
void SetUp() override
{
UITest::SetUp();
// Ensure all hotkey slots start invalid so tests are deterministic.
for (size_t i = 0; i < NumHotkeys; ++i) {
MyPlayer->_pSplHotKey[i] = SpellID::Invalid;
MyPlayer->_pSplTHotKey[i] = SpellType::Invalid;
}
}
/**
* @brief Teach the player a memorised spell at the given level.
*
* Sets the appropriate bit in _pMemSpells and assigns a spell level
* so the spell is usable (level > 0).
*/
static void TeachSpell(SpellID spell, uint8_t level = 5)
{
MyPlayer->_pMemSpells |= GetSpellBitmask(spell);
MyPlayer->_pSplLvl[static_cast<int8_t>(spell)] = level;
}
/**
* @brief Add a scroll-type spell to the player's available spells.
*
* Sets the appropriate bit in _pScrlSpells. Note that for the spell
* to actually be castable the player would need a scroll item, but
* for UI listing purposes the bitmask is sufficient.
*/
static void AddScrollSpell(SpellID spell)
{
MyPlayer->_pScrlSpells |= GetSpellBitmask(spell);
}
/**
* @brief Open the speed book overlay.
*
* Sets SpellSelectFlag = true and positions the mouse via DoSpeedBook().
* DoSpeedBook() internally calls SetCursorPos() which, because
* ControlDevice defaults to ControlTypes::None (not KeyboardAndMouse),
* simply writes to MousePosition without touching SDL windowing.
*/
static void OpenSpeedBook()
{
DoSpeedBook();
// DoSpeedBook sets SpellSelectFlag = true.
}
/**
* @brief Search the spell list for an item matching the given spell ID and type.
* @return Pointer to the matching SpellListItem, or nullptr if not found.
*/
static const SpellListItem *FindInSpellList(
const std::vector<SpellListItem> &items,
SpellID id,
SpellType type)
{
for (const auto &item : items) {
if (item.id == id && item.type == type)
return &item;
}
return nullptr;
}
/**
* @brief Find a spell list item by ID only (any type).
*/
static const SpellListItem *FindInSpellListById(
const std::vector<SpellListItem> &items,
SpellID id)
{
for (const auto &item : items) {
if (item.id == id)
return &item;
}
return nullptr;
}
/**
* @brief Position the mouse over a spell list item so that
* GetSpellListSelection() will consider it "selected".
*
* The spell list item's `location` field gives the bottom-left corner
* of the icon. The icon occupies a SPLICONLENGTH x SPLICONLENGTH area
* from (location.x, location.y - SPLICONLENGTH) to
* (location.x + SPLICONLENGTH - 1, location.y - 1).
* We position the mouse in the centre of that area.
*/
static void PositionMouseOver(const SpellListItem &item)
{
// The selection check in GetSpellListItems() is:
// MousePosition.x >= lx && MousePosition.x < lx + SPLICONLENGTH
// MousePosition.y >= ly && MousePosition.y < ly + SPLICONLENGTH
// where lx = item.location.x, ly = item.location.y - SPLICONLENGTH
MousePosition = Point { item.location.x + SPLICONLENGTH / 2,
item.location.y - SPLICONLENGTH / 2 };
}
};
// ===========================================================================
// Test: GetSpellListItems returns learned (memorised) spells
// ===========================================================================
TEST_F(SpellUITest, GetSpellListItems_ReturnsLearnedSpells)
{
// Teach the player Firebolt as a memorised spell.
TeachSpell(SpellID::Firebolt, 5);
// Open the speed book so GetSpellListItems() has the right context.
OpenSpeedBook();
const auto items = GetSpellListItems();
// The list should contain Firebolt with SpellType::Spell.
const SpellListItem *found = FindInSpellList(items, SpellID::Firebolt, SpellType::Spell);
ASSERT_NE(found, nullptr)
<< "Firebolt should appear in the spell list after being taught";
EXPECT_EQ(found->id, SpellID::Firebolt);
EXPECT_EQ(found->type, SpellType::Spell);
}
// ===========================================================================
// Test: GetSpellListItems includes the Warrior's innate abilities
// ===========================================================================
TEST_F(SpellUITest, GetSpellListItems_IncludesAbilities)
{
// After CreatePlayer() for a Warrior, _pAblSpells should include the
// Warrior's skill (ItemRepair). Verify it appears in the spell list.
ASSERT_NE(MyPlayer->_pAblSpells, 0u)
<< "Warrior should have at least one ability after CreatePlayer()";
OpenSpeedBook();
const auto items = GetSpellListItems();
// The Warrior's skill is ItemRepair (loaded from starting_loadout.tsv).
const SpellListItem *found = FindInSpellList(items, SpellID::ItemRepair, SpellType::Skill);
EXPECT_NE(found, nullptr)
<< "Warrior's ItemRepair ability should appear in the spell list";
}
// ===========================================================================
// Test: GetSpellListItems includes scroll spells
// ===========================================================================
TEST_F(SpellUITest, GetSpellListItems_IncludesScrollSpells)
{
// Give the player a Town Portal scroll spell via the bitmask.
AddScrollSpell(SpellID::TownPortal);
OpenSpeedBook();
const auto items = GetSpellListItems();
const SpellListItem *found = FindInSpellList(items, SpellID::TownPortal, SpellType::Scroll);
ASSERT_NE(found, nullptr)
<< "TownPortal should appear in the spell list as a scroll spell";
EXPECT_EQ(found->type, SpellType::Scroll);
}
// ===========================================================================
// Test: GetSpellListItems is empty when all spell bitmasks are cleared
// ===========================================================================
TEST_F(SpellUITest, GetSpellListItems_EmptyWhenAllSpellsCleared)
{
// Clear every spell bitmask, including abilities.
MyPlayer->_pMemSpells = 0;
MyPlayer->_pAblSpells = 0;
MyPlayer->_pScrlSpells = 0;
MyPlayer->_pISpells = 0;
OpenSpeedBook();
const auto items = GetSpellListItems();
EXPECT_TRUE(items.empty())
<< "Spell list should be empty when all spell bitmasks are zero";
}
// ===========================================================================
// Test: SetSpell changes the player's active/readied spell
// ===========================================================================
TEST_F(SpellUITest, SetSpell_ChangesActiveSpell)
{
// Teach the player Firebolt.
TeachSpell(SpellID::Firebolt, 5);
// Open speed book — this sets SpellSelectFlag and positions the mouse.
OpenSpeedBook();
// Get the spell list and find Firebolt's icon position.
auto items = GetSpellListItems();
const SpellListItem *firebolt = FindInSpellList(items, SpellID::Firebolt, SpellType::Spell);
ASSERT_NE(firebolt, nullptr)
<< "Firebolt must be in the spell list for SetSpell to work";
// Position the mouse over Firebolt's icon so it becomes "selected".
PositionMouseOver(*firebolt);
// Re-fetch items to confirm the selection is detected.
items = GetSpellListItems();
const SpellListItem *selected = FindInSpellList(items, SpellID::Firebolt, SpellType::Spell);
ASSERT_NE(selected, nullptr);
EXPECT_TRUE(selected->isSelected)
<< "Firebolt should be selected after positioning mouse over it";
// Now call SetSpell — should set the player's readied spell.
SetSpell();
EXPECT_EQ(MyPlayer->_pRSpell, SpellID::Firebolt)
<< "Active spell should be Firebolt after SetSpell()";
EXPECT_EQ(MyPlayer->_pRSplType, SpellType::Spell)
<< "Active spell type should be Spell after SetSpell()";
// SetSpell also clears SpellSelectFlag.
EXPECT_FALSE(SpellSelectFlag)
<< "SpellSelectFlag should be cleared after SetSpell()";
}
// ===========================================================================
// Test: SetSpeedSpell assigns a spell to a hotkey slot
// ===========================================================================
TEST_F(SpellUITest, SetSpeedSpell_AssignsHotkey)
{
// Teach the player Firebolt.
TeachSpell(SpellID::Firebolt, 5);
OpenSpeedBook();
// Find Firebolt's position and move the mouse there.
auto items = GetSpellListItems();
const SpellListItem *firebolt = FindInSpellList(items, SpellID::Firebolt, SpellType::Spell);
ASSERT_NE(firebolt, nullptr);
PositionMouseOver(*firebolt);
// Assign to hotkey slot 0.
SetSpeedSpell(0);
// Verify the hotkey was assigned.
EXPECT_TRUE(IsValidSpeedSpell(0))
<< "Hotkey slot 0 should be valid after assigning Firebolt";
EXPECT_EQ(MyPlayer->_pSplHotKey[0], SpellID::Firebolt)
<< "Hotkey slot 0 should contain Firebolt";
EXPECT_EQ(MyPlayer->_pSplTHotKey[0], SpellType::Spell)
<< "Hotkey slot 0 type should be Spell";
}
// ===========================================================================
// Test: IsValidSpeedSpell returns false for an unassigned slot
// ===========================================================================
TEST_F(SpellUITest, IsValidSpeedSpell_InvalidSlot)
{
// Slot 0 was cleared to SpellID::Invalid in SetUp().
EXPECT_FALSE(IsValidSpeedSpell(0))
<< "Unassigned hotkey slot should not be valid";
}
// ===========================================================================
// Test: DoSpeedBook opens the spell selection overlay
// ===========================================================================
TEST_F(SpellUITest, DoSpeedBook_OpensSpellSelect)
{
// Ensure it's closed initially.
ASSERT_FALSE(SpellSelectFlag);
DoSpeedBook();
EXPECT_TRUE(SpellSelectFlag)
<< "SpellSelectFlag should be true after DoSpeedBook()";
}
// ===========================================================================
// Test: SpellSelectFlag can be toggled off (simulating closing the speed book)
// ===========================================================================
TEST_F(SpellUITest, DoSpeedBook_ClosesSpellSelect)
{
// Open the speed book.
DoSpeedBook();
ASSERT_TRUE(SpellSelectFlag);
// Simulate closing by clearing the flag (this is what the key handler does).
SpellSelectFlag = false;
EXPECT_FALSE(SpellSelectFlag)
<< "SpellSelectFlag should be false after being manually cleared";
}
// ===========================================================================
// Test: ToggleSpell cycles through available spell types for a hotkey
// ===========================================================================
TEST_F(SpellUITest, ToggleSpell_CyclesThroughTypes)
{
// Set up a spell that is available as both a memorised spell and a scroll.
// Using Firebolt for this test.
TeachSpell(SpellID::Firebolt, 5);
AddScrollSpell(SpellID::Firebolt);
// Assign Firebolt (as Spell type) to hotkey slot 0.
MyPlayer->_pSplHotKey[0] = SpellID::Firebolt;
MyPlayer->_pSplTHotKey[0] = SpellType::Spell;
ASSERT_TRUE(IsValidSpeedSpell(0))
<< "Hotkey slot 0 should be valid with Firebolt as Spell";
// ToggleSpell activates the spell from the hotkey — it sets the player's
// readied spell to whatever is in the hotkey slot.
ToggleSpell(0);
// After ToggleSpell, the player's readied spell should match the hotkey.
EXPECT_EQ(MyPlayer->_pRSpell, SpellID::Firebolt);
EXPECT_EQ(MyPlayer->_pRSplType, SpellType::Spell);
// Now change the hotkey to Scroll type and toggle again.
MyPlayer->_pSplTHotKey[0] = SpellType::Scroll;
ASSERT_TRUE(IsValidSpeedSpell(0))
<< "Hotkey slot 0 should be valid with Firebolt as Scroll";
ToggleSpell(0);
EXPECT_EQ(MyPlayer->_pRSpell, SpellID::Firebolt);
EXPECT_EQ(MyPlayer->_pRSplType, SpellType::Scroll)
<< "After toggling with Scroll type, readied spell type should be Scroll";
}
// ===========================================================================
// Test: SetSpeedSpell unsets a hotkey when called with the same spell
// ===========================================================================
TEST_F(SpellUITest, SetSpeedSpell_UnsetsOnDoubleAssign)
{
// Teach the player Firebolt.
TeachSpell(SpellID::Firebolt, 5);
OpenSpeedBook();
auto items = GetSpellListItems();
const SpellListItem *firebolt = FindInSpellList(items, SpellID::Firebolt, SpellType::Spell);
ASSERT_NE(firebolt, nullptr);
PositionMouseOver(*firebolt);
// Assign to slot 0 the first time.
SetSpeedSpell(0);
ASSERT_TRUE(IsValidSpeedSpell(0));
ASSERT_EQ(MyPlayer->_pSplHotKey[0], SpellID::Firebolt);
// Re-fetch items and re-position mouse (SetSpeedSpell doesn't move the cursor).
items = GetSpellListItems();
firebolt = FindInSpellList(items, SpellID::Firebolt, SpellType::Spell);
ASSERT_NE(firebolt, nullptr);
PositionMouseOver(*firebolt);
// Assign to slot 0 again — should unset (toggle off).
SetSpeedSpell(0);
EXPECT_EQ(MyPlayer->_pSplHotKey[0], SpellID::Invalid)
<< "Assigning the same spell to the same slot should unset the hotkey";
EXPECT_FALSE(IsValidSpeedSpell(0))
<< "Hotkey slot should be invalid after being unset";
}
// ===========================================================================
// Test: IsValidSpeedSpell returns false when the spell is no longer available
// ===========================================================================
TEST_F(SpellUITest, IsValidSpeedSpell_InvalidAfterSpellRemoved)
{
// Teach and assign Firebolt to slot 0.
TeachSpell(SpellID::Firebolt, 5);
MyPlayer->_pSplHotKey[0] = SpellID::Firebolt;
MyPlayer->_pSplTHotKey[0] = SpellType::Spell;
ASSERT_TRUE(IsValidSpeedSpell(0));
// Remove the spell from the player's memory.
MyPlayer->_pMemSpells &= ~GetSpellBitmask(SpellID::Firebolt);
// The hotkey still points to Firebolt, but the player no longer knows it.
EXPECT_FALSE(IsValidSpeedSpell(0))
<< "Hotkey should be invalid when the underlying spell is no longer available";
}
// ===========================================================================
// Test: Multiple spells appear in the spell list simultaneously
// ===========================================================================
TEST_F(SpellUITest, GetSpellListItems_MultipleSpells)
{
// Teach multiple spells.
TeachSpell(SpellID::Firebolt, 3);
TeachSpell(SpellID::HealOther, 2);
AddScrollSpell(SpellID::TownPortal);
OpenSpeedBook();
const auto items = GetSpellListItems();
// Verify all three appear (plus the Warrior's innate ability).
EXPECT_NE(FindInSpellList(items, SpellID::Firebolt, SpellType::Spell), nullptr)
<< "Firebolt (memorised) should be in the list";
EXPECT_NE(FindInSpellList(items, SpellID::HealOther, SpellType::Spell), nullptr)
<< "HealOther (memorised) should be in the list";
EXPECT_NE(FindInSpellList(items, SpellID::TownPortal, SpellType::Scroll), nullptr)
<< "TownPortal (scroll) should be in the list";
EXPECT_NE(FindInSpellList(items, SpellID::ItemRepair, SpellType::Skill), nullptr)
<< "Warrior's ItemRepair ability should still be present";
// We should have at least 4 items.
EXPECT_GE(items.size(), 4u);
}
} // namespace
} // namespace devilution

698
test/stash_test.cpp

@ -0,0 +1,698 @@
/**
* @file stash_test.cpp
*
* Tests for the player stash system.
*
* These tests verify the functional behaviour of the shared item stash:
* item placement, removal, page navigation, gold storage, transfer
* operations between stash and inventory, and the dirty flag.
*
* All assertions are on game state (stash contents, grid cells, gold
* values, dirty flag, inventory contents). No assertions on rendering,
* pixel positions, or widget layout.
*/
#include <gtest/gtest.h>
#include "ui_test.hpp"
#include "inv.h"
#include "items.h"
#include "player.h"
#include "qol/stash.h"
namespace devilution {
namespace {
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
class StashTest : public UITest {
protected:
void SetUp() override
{
UITest::SetUp();
// Start each test with a completely clean stash.
Stash = {};
Stash.gold = 0;
Stash.dirty = false;
}
// --- helpers ---
/** @brief Create a simple 1×1 item (healing potion). */
static Item MakeSmallItem()
{
Item item {};
InitializeItem(item, IDI_HEAL);
return item;
}
/** @brief Create a sword (larger than 1×1). */
static Item MakeSword()
{
Item item {};
InitializeItem(item, IDI_BARDSWORD);
item._iIdentified = true;
return item;
}
/** @brief Create a gold item with the given value. */
static Item MakeGold(int value)
{
Item item {};
item._itype = ItemType::Gold;
item._ivalue = value;
item._iMiscId = IMISC_NONE;
return item;
}
/** @brief Count the number of non-empty cells in a stash grid page. */
static int CountOccupiedCells(const StashStruct::StashGrid &grid)
{
int count = 0;
for (const auto &row : grid) {
for (StashStruct::StashCell cell : row) {
if (cell != 0)
count++;
}
}
return count;
}
/** @brief Fill a stash page completely with 1×1 items. */
void FillStashPage(unsigned page)
{
for (int x = 0; x < 10; x++) {
for (int y = 0; y < 10; y++) {
Item item = MakeSmallItem();
Stash.SetPage(page);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true))
<< "Failed to place item at logical position on page " << page;
}
}
}
};
// ---------------------------------------------------------------------------
// AutoPlaceItemInStash
// ---------------------------------------------------------------------------
TEST_F(StashTest, PlaceItem_EmptyStash)
{
Item item = MakeSmallItem();
bool placed = AutoPlaceItemInStash(*MyPlayer, item, true);
EXPECT_TRUE(placed);
EXPECT_FALSE(Stash.stashList.empty());
EXPECT_EQ(Stash.stashList.size(), 1u);
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, PlaceItem_DryRunDoesNotMutate)
{
Item item = MakeSmallItem();
bool canPlace = AutoPlaceItemInStash(*MyPlayer, item, false);
EXPECT_TRUE(canPlace);
EXPECT_TRUE(Stash.stashList.empty()) << "Dry-run should not add item to stashList";
EXPECT_FALSE(Stash.dirty) << "Dry-run should not set dirty flag";
}
TEST_F(StashTest, PlaceItem_GridCellOccupied)
{
Item item = MakeSmallItem();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
// The item should occupy at least one cell on the current page.
const auto &grid = Stash.stashGrids[Stash.GetPage()];
EXPECT_GT(CountOccupiedCells(grid), 0);
}
TEST_F(StashTest, PlaceItem_MultipleItemsOnSamePage)
{
Item item1 = MakeSmallItem();
Item item2 = MakeSmallItem();
Item item3 = MakeSword();
EXPECT_TRUE(AutoPlaceItemInStash(*MyPlayer, item1, true));
EXPECT_TRUE(AutoPlaceItemInStash(*MyPlayer, item2, true));
EXPECT_TRUE(AutoPlaceItemInStash(*MyPlayer, item3, true));
EXPECT_EQ(Stash.stashList.size(), 3u);
}
TEST_F(StashTest, PlaceItem_FullPageOverflowsToNextPage)
{
Stash.SetPage(0);
Stash.dirty = false;
FillStashPage(0);
size_t itemsAfterPage0 = Stash.stashList.size();
// Page 0 should be completely full now. Placing another item should go to page 1.
Item overflow = MakeSmallItem();
Stash.SetPage(0); // Reset to page 0 so AutoPlace starts searching from page 0.
EXPECT_TRUE(AutoPlaceItemInStash(*MyPlayer, overflow, true));
EXPECT_EQ(Stash.stashList.size(), itemsAfterPage0 + 1);
// The overflow item should be on page 1 (or later), not page 0.
// Page 0 should still have only the original cells occupied.
const auto &grid0 = Stash.stashGrids[0];
EXPECT_EQ(CountOccupiedCells(grid0), 100) << "Page 0 should remain fully occupied";
// Page 1 should have the overflow item.
EXPECT_TRUE(Stash.stashGrids.count(1) > 0) << "Page 1 should have been created";
EXPECT_GT(CountOccupiedCells(Stash.stashGrids[1]), 0) << "Overflow item should be on page 1";
}
TEST_F(StashTest, PlaceItem_SwordOccupiesCorrectArea)
{
Item sword = MakeSword();
const Size swordSize = GetInventorySize(sword);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, sword, true));
const auto &grid = Stash.stashGrids[Stash.GetPage()];
int occupiedCells = CountOccupiedCells(grid);
EXPECT_EQ(occupiedCells, swordSize.width * swordSize.height)
<< "Sword should occupy exactly " << swordSize.width << "×" << swordSize.height << " cells";
}
// ---------------------------------------------------------------------------
// Gold in stash
// ---------------------------------------------------------------------------
TEST_F(StashTest, PlaceGold_AddsToStashGold)
{
Item gold = MakeGold(5000);
EXPECT_TRUE(AutoPlaceItemInStash(*MyPlayer, gold, true));
EXPECT_EQ(Stash.gold, 5000);
EXPECT_TRUE(Stash.stashList.empty()) << "Gold should not be added to stashList";
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, PlaceGold_DryRunDoesNotMutate)
{
Item gold = MakeGold(3000);
EXPECT_TRUE(AutoPlaceItemInStash(*MyPlayer, gold, false));
EXPECT_EQ(Stash.gold, 0) << "Dry-run should not change stash gold";
EXPECT_FALSE(Stash.dirty);
}
TEST_F(StashTest, PlaceGold_AccumulatesMultipleDeposits)
{
Item gold1 = MakeGold(1000);
Item gold2 = MakeGold(2500);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, gold1, true));
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, gold2, true));
EXPECT_EQ(Stash.gold, 3500);
}
TEST_F(StashTest, PlaceGold_RejectsOverflow)
{
Stash.gold = std::numeric_limits<int>::max() - 100;
Item gold = MakeGold(200);
EXPECT_FALSE(AutoPlaceItemInStash(*MyPlayer, gold, true))
<< "Should reject gold that would cause integer overflow";
EXPECT_EQ(Stash.gold, std::numeric_limits<int>::max() - 100)
<< "Stash gold should be unchanged after rejected deposit";
}
// ---------------------------------------------------------------------------
// RemoveStashItem
// ---------------------------------------------------------------------------
TEST_F(StashTest, RemoveItem_ClearsGridAndList)
{
Item item = MakeSmallItem();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
ASSERT_EQ(Stash.stashList.size(), 1u);
Stash.dirty = false;
Stash.RemoveStashItem(0);
EXPECT_TRUE(Stash.stashList.empty());
EXPECT_EQ(CountOccupiedCells(Stash.stashGrids[Stash.GetPage()]), 0)
<< "Grid cells should be cleared after removing item";
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, RemoveItem_LastItemSwap)
{
// Place two items, then remove the first. The second item should be
// moved to index 0 in stashList, and grid references updated.
Item item1 = MakeSmallItem();
Item item2 = MakeSword();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item1, true));
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item2, true));
ASSERT_EQ(Stash.stashList.size(), 2u);
// Remember the type of the second item.
const ItemType secondItemType = Stash.stashList[1]._itype;
Stash.RemoveStashItem(0);
ASSERT_EQ(Stash.stashList.size(), 1u);
// The former item at index 1 should now be at index 0.
EXPECT_EQ(Stash.stashList[0]._itype, secondItemType);
// Grid should reference the moved item correctly (cell value = index + 1 = 1).
const auto &grid = Stash.stashGrids[Stash.GetPage()];
bool foundReference = false;
for (const auto &row : grid) {
for (StashStruct::StashCell cell : row) {
if (cell == 1) { // index 0 + 1
foundReference = true;
}
}
}
EXPECT_TRUE(foundReference)
<< "Grid should have updated references to the swapped item";
}
TEST_F(StashTest, RemoveItem_MiddleOfThree)
{
// Place three items, remove the middle one. The last item should be
// swapped into slot 1, and stashList should have size 2.
Item item1 = MakeSmallItem();
Item item2 = MakeSmallItem();
Item item3 = MakeSmallItem();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item1, true));
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item2, true));
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item3, true));
ASSERT_EQ(Stash.stashList.size(), 3u);
Stash.RemoveStashItem(1);
EXPECT_EQ(Stash.stashList.size(), 2u);
}
// ---------------------------------------------------------------------------
// Page navigation
// ---------------------------------------------------------------------------
TEST_F(StashTest, SetPage_SetsCorrectPage)
{
Stash.SetPage(5);
EXPECT_EQ(Stash.GetPage(), 5u);
Stash.SetPage(42);
EXPECT_EQ(Stash.GetPage(), 42u);
}
TEST_F(StashTest, SetPage_ClampsToLastPage)
{
// LastStashPage = 99 (CountStashPages - 1).
Stash.SetPage(200);
EXPECT_EQ(Stash.GetPage(), 99u);
}
TEST_F(StashTest, SetPage_SetsDirtyFlag)
{
Stash.dirty = false;
Stash.SetPage(3);
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, NextPage_AdvancesByOne)
{
Stash.SetPage(0);
Stash.dirty = false;
Stash.NextPage();
EXPECT_EQ(Stash.GetPage(), 1u);
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, NextPage_AdvancesByOffset)
{
Stash.SetPage(5);
Stash.NextPage(10);
EXPECT_EQ(Stash.GetPage(), 15u);
}
TEST_F(StashTest, NextPage_ClampsAtLastPage)
{
Stash.SetPage(98);
Stash.NextPage(5);
EXPECT_EQ(Stash.GetPage(), 99u) << "Should clamp to last page, not wrap around";
}
TEST_F(StashTest, NextPage_AlreadyAtLastPage)
{
Stash.SetPage(99);
Stash.NextPage();
EXPECT_EQ(Stash.GetPage(), 99u) << "Should stay at last page";
}
TEST_F(StashTest, PreviousPage_GoesBackByOne)
{
Stash.SetPage(5);
Stash.dirty = false;
Stash.PreviousPage();
EXPECT_EQ(Stash.GetPage(), 4u);
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, PreviousPage_GoesBackByOffset)
{
Stash.SetPage(20);
Stash.PreviousPage(10);
EXPECT_EQ(Stash.GetPage(), 10u);
}
TEST_F(StashTest, PreviousPage_ClampsAtPageZero)
{
Stash.SetPage(2);
Stash.PreviousPage(5);
EXPECT_EQ(Stash.GetPage(), 0u) << "Should clamp to page 0, not underflow";
}
TEST_F(StashTest, PreviousPage_AlreadyAtPageZero)
{
Stash.SetPage(0);
Stash.PreviousPage();
EXPECT_EQ(Stash.GetPage(), 0u) << "Should stay at page 0";
}
// ---------------------------------------------------------------------------
// Grid query helpers
// ---------------------------------------------------------------------------
TEST_F(StashTest, GetItemIdAtPosition_EmptyCell)
{
Stash.SetPage(0);
StashStruct::StashCell id = Stash.GetItemIdAtPosition({ 0, 0 });
EXPECT_EQ(id, StashStruct::EmptyCell);
}
TEST_F(StashTest, IsItemAtPosition_EmptyCell)
{
Stash.SetPage(0);
EXPECT_FALSE(Stash.IsItemAtPosition({ 0, 0 }));
}
TEST_F(StashTest, GetItemIdAtPosition_OccupiedCell)
{
Item item = MakeSmallItem();
Stash.SetPage(0);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
// The first item should be placed at (0,0) in an empty stash.
StashStruct::StashCell id = Stash.GetItemIdAtPosition({ 0, 0 });
EXPECT_NE(id, StashStruct::EmptyCell);
EXPECT_EQ(id, 0u) << "First item should have stashList index 0";
}
TEST_F(StashTest, IsItemAtPosition_OccupiedCell)
{
Item item = MakeSmallItem();
Stash.SetPage(0);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
EXPECT_TRUE(Stash.IsItemAtPosition({ 0, 0 }));
}
// ---------------------------------------------------------------------------
// TransferItemToInventory
// ---------------------------------------------------------------------------
TEST_F(StashTest, TransferToInventory_Success)
{
// Clear inventory so there is room.
StripPlayer();
Item item = MakeSmallItem();
Stash.SetPage(0);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
ASSERT_EQ(Stash.stashList.size(), 1u);
TransferItemToInventory(*MyPlayer, 0);
EXPECT_TRUE(Stash.stashList.empty()) << "Item should be removed from stash";
EXPECT_EQ(CountOccupiedCells(Stash.stashGrids[0]), 0)
<< "Grid should be cleared";
// Item should now be in the player's inventory.
bool foundInInventory = false;
for (int i = 0; i < MyPlayer->_pNumInv; i++) {
if (!MyPlayer->InvList[i].isEmpty()) {
foundInInventory = true;
break;
}
}
EXPECT_TRUE(foundInInventory) << "Item should appear in player inventory";
}
TEST_F(StashTest, TransferToInventory_EmptyCell)
{
// Transferring EmptyCell should be a no-op.
TransferItemToInventory(*MyPlayer, StashStruct::EmptyCell);
// Nothing should crash and stash should remain unchanged.
EXPECT_TRUE(Stash.stashList.empty());
}
TEST_F(StashTest, TransferToInventory_InventoryFull)
{
// Fill inventory completely so there's no room.
ClearInventory();
for (int i = 0; i < InventoryGridCells; i++) {
Item filler = MakeSmallItem();
MyPlayer->InvList[i] = filler;
MyPlayer->InvGrid[i] = static_cast<int8_t>(i + 1);
}
MyPlayer->_pNumInv = InventoryGridCells;
Item item = MakeSmallItem();
Stash.SetPage(0);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
ASSERT_EQ(Stash.stashList.size(), 1u);
TransferItemToInventory(*MyPlayer, 0);
// Item should remain in stash because inventory is full.
EXPECT_EQ(Stash.stashList.size(), 1u)
<< "Item should remain in stash when inventory is full";
}
// ---------------------------------------------------------------------------
// TransferItemToStash
// ---------------------------------------------------------------------------
TEST_F(StashTest, TransferToStash_Success)
{
StripPlayer();
IsStashOpen = true;
// Place an item in inventory slot 0.
Item sword = MakeSword();
int idx = PlaceItemInInventory(sword);
ASSERT_GE(idx, 0);
int invLocation = INVITEM_INV_FIRST + idx;
TransferItemToStash(*MyPlayer, invLocation);
// Item should now be in stash.
EXPECT_FALSE(Stash.stashList.empty()) << "Item should appear in stash";
// Item should be removed from inventory.
EXPECT_TRUE(MyPlayer->InvList[idx].isEmpty() || MyPlayer->_pNumInv == 0)
<< "Item should be removed from inventory";
}
TEST_F(StashTest, TransferToStash_InvalidLocation)
{
// Transferring from location -1 should be a no-op.
TransferItemToStash(*MyPlayer, -1);
EXPECT_TRUE(Stash.stashList.empty());
}
// ---------------------------------------------------------------------------
// Dirty flag
// ---------------------------------------------------------------------------
TEST_F(StashTest, DirtyFlag_SetOnPlaceItem)
{
Stash.dirty = false;
Item item = MakeSmallItem();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, DirtyFlag_SetOnPlaceGold)
{
Stash.dirty = false;
Item gold = MakeGold(100);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, gold, true));
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, DirtyFlag_SetOnRemoveItem)
{
Item item = MakeSmallItem();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
Stash.dirty = false;
Stash.RemoveStashItem(0);
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, DirtyFlag_SetOnPageChange)
{
Stash.dirty = false;
Stash.SetPage(1);
EXPECT_TRUE(Stash.dirty);
}
TEST_F(StashTest, DirtyFlag_NotSetOnDryRun)
{
Stash.dirty = false;
Item item = MakeSmallItem();
AutoPlaceItemInStash(*MyPlayer, item, false);
EXPECT_FALSE(Stash.dirty);
}
// ---------------------------------------------------------------------------
// IsStashOpen flag
// ---------------------------------------------------------------------------
TEST_F(StashTest, IsStashOpen_InitiallyClosed)
{
EXPECT_FALSE(IsStashOpen);
}
TEST_F(StashTest, IsStashOpen_CanBeToggled)
{
IsStashOpen = true;
EXPECT_TRUE(IsStashOpen);
IsStashOpen = false;
EXPECT_FALSE(IsStashOpen);
}
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
TEST_F(StashTest, PlaceItem_CurrentPagePreferred)
{
// When the stash is empty, the item should be placed on the current page.
Stash.SetPage(5);
Stash.dirty = false;
Item item = MakeSmallItem();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
EXPECT_TRUE(Stash.stashGrids.count(5) > 0)
<< "Item should be placed on the current page (5)";
EXPECT_GT(CountOccupiedCells(Stash.stashGrids[5]), 0);
}
TEST_F(StashTest, PlaceItem_WrapsAroundPages)
{
// Set page to 99 (last page), fill it, then place another item.
// It should wrap around to page 0.
Stash.SetPage(99);
FillStashPage(99);
Item overflow = MakeSmallItem();
Stash.SetPage(99); // Reset to page 99 so search starts there.
EXPECT_TRUE(AutoPlaceItemInStash(*MyPlayer, overflow, true));
// The item should have been placed on page 0 (wrapped around).
EXPECT_TRUE(Stash.stashGrids.count(0) > 0)
<< "Item should wrap around to page 0";
EXPECT_GT(CountOccupiedCells(Stash.stashGrids[0]), 0);
}
TEST_F(StashTest, MultipleItemTypes_CoexistOnSamePage)
{
Stash.SetPage(0);
Item potion = MakeSmallItem();
Item sword = MakeSword();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, potion, true));
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, sword, true));
EXPECT_EQ(Stash.stashList.size(), 2u);
// Both items should be on page 0.
const auto &grid = Stash.stashGrids[0];
const Size swordSize = GetInventorySize(sword);
int expectedCells = 1 + (swordSize.width * swordSize.height);
EXPECT_EQ(CountOccupiedCells(grid), expectedCells);
}
TEST_F(StashTest, RemoveItem_ThenPlaceNew)
{
// Place an item, remove it, then place a new one. The stash should
// reuse the slot correctly.
Item item1 = MakeSmallItem();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item1, true));
ASSERT_EQ(Stash.stashList.size(), 1u);
Stash.RemoveStashItem(0);
ASSERT_TRUE(Stash.stashList.empty());
Item item2 = MakeSword();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item2, true));
EXPECT_EQ(Stash.stashList.size(), 1u);
}
TEST_F(StashTest, GoldStorageIndependentOfItems)
{
// Gold and items use separate storage. Verify they don't interfere.
Stash.SetPage(0);
Item gold = MakeGold(5000);
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, gold, true));
Item item = MakeSmallItem();
ASSERT_TRUE(AutoPlaceItemInStash(*MyPlayer, item, true));
EXPECT_EQ(Stash.gold, 5000) << "Gold should be tracked separately";
EXPECT_EQ(Stash.stashList.size(), 1u) << "Only the non-gold item should be in stashList";
}
} // namespace
} // namespace devilution
Loading…
Cancel
Save