You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

461 lines
16 KiB

/**
* @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