Implement in-game navigation with keyboard

Implements a start to the keyboard navigation with the direction arrows
and the space key for card selection.
Some binds are still needed, but navigating the board is now possible.
The feature includes tests for the implemented feature.

Closes #5043

Co-authored-by: Manuel Ramos Monge <manuel.monge@tecnico.ulisboa.pt>
This commit is contained in:
Vasco Guerreiro Vintém Morais 2026-06-02 17:09:39 +01:00
parent 3fa377a11c
commit 5acce8998e
22 changed files with 1023 additions and 46 deletions

View file

@ -75,6 +75,7 @@ target_link_libraries(
)
add_subdirectory(card_zone_algorithms)
add_subdirectory(keyboard_navigator_tests)
add_subdirectory(carddatabase)
add_subdirectory(loading_from_clipboard)
add_subdirectory(movecard_tests)

View file

@ -0,0 +1,32 @@
add_executable(keyboard_navigator_test keyboard_card_navigator_test.cpp keyboard_navigator_test_stubs.cpp)
target_compile_options(keyboard_navigator_test PRIVATE --coverage)
target_link_options(keyboard_navigator_test PRIVATE --coverage)
target_include_directories(
keyboard_navigator_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/cockatrice/src/game
${CMAKE_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/libcockatrice_interfaces
)
target_link_libraries(
keyboard_navigator_test
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
PRIVATE ${TEST_QT_MODULES}
PRIVATE libcockatrice_settings
PRIVATE libcockatrice_interfaces
PRIVATE libcockatrice_protocol
PRIVATE libcockatrice_card
PRIVATE libcockatrice_deck_list
PRIVATE libcockatrice_models
PRIVATE libcockatrice_rng
PRIVATE libcockatrice_network
PRIVATE libcockatrice_utility
PRIVATE Qt6::Widgets
)
add_test(NAME keyboard_navigator_test COMMAND keyboard_navigator_test)
if(NOT GTEST_FOUND)
add_dependencies(keyboard_navigator_test gtest)
endif()

View file

@ -0,0 +1,229 @@
#include "game/keyboard_card_navigator.h"
#include <QApplication>
#include <QKeyEvent>
#include <QList>
#include <QMap>
#include <QMetaObject>
#include <gtest/gtest.h>
// Some tests require us to get the zones out of a player, so instead of changing code we didn't make, we
// decided to use this dirty trick.
#define private public
#include "game/player/player_logic.h"
#undef private
#include "game/board/abstract_card_item.h"
#include "game/board/arrow_item.h"
#include "game/board/card_item.h"
#include "game/board/card_list.h"
#include "game/keyboard_card_navigator.cpp"
#include "game/zones/card_zone_logic.h"
#include "game/zones/hand_zone_logic.h"
#include "game/zones/stack_zone_logic.h"
#include "game/zones/table_zone_logic.h"
#include "keyboard_navigator_test_fakes.h"
class KeyboardCardNavigatorTest : public ::testing::Test
{
protected:
KeyboardCardNavigator *navigator;
FakeHandZoneLogic *handZone;
FakeTableZoneLogic *tableZone;
FakeStackZoneLogic *stackZone;
PlayerLogic *player;
void SetUp() override
{
handZone = new FakeHandZoneLogic("hand");
tableZone = new FakeTableZoneLogic("table");
stackZone = new FakeStackZoneLogic("stack");
// Player is mocked like this, so we fill the zones for the tests.
player = (PlayerLogic *)malloc(sizeof(PlayerLogic));
new (&player->zones) QMap<QString, CardZoneLogic *>();
player->zones.insert("table", tableZone);
player->zones.insert("stack", stackZone);
player->zones.insert("hand", handZone);
navigator = new KeyboardCardNavigator(player);
}
void TearDown() override
{
delete navigator;
free(player);
delete handZone;
delete tableZone;
delete stackZone;
}
};
/* This test verifies the behaviour of spawning the cursor:
When pressing the left arrow, it goes to the first card.
When pressing the right arrow, it goes to the last card of the zone. */
TEST_F(KeyboardCardNavigatorTest, LeftRightArrowTest)
{
// Set an arbitrary amount of cards for the zone
handZone->setDummyCardCount(5);
// Set the default index
navigator->setHoveredCardIndex(-1);
navigator->setCurrentZone(handZone);
// Simulate a key press and a card switch
QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
navigator->switchCardInZone(&eRight);
// Verify the first card is selected
EXPECT_EQ(navigator->getHoveredIndex(), 0);
// Reset the index
navigator->setHoveredCardIndex(-1);
// Simulate a key press and a card switch
QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier);
navigator->switchCardInZone(&eLeft);
// Check if the last card was selected
EXPECT_EQ(navigator->getHoveredIndex(), 4);
}
/* This test verifies the moving behaviour of the cursor. */
TEST_F(KeyboardCardNavigatorTest, NormalSwitchCards)
{
// Set an arbitrary amount of cards for the zone
handZone->setDummyCardCount(5);
navigator->setCurrentZone(handZone);
// Select the second card
navigator->setHoveredCardIndex(1);
// If right is pressed, go to index + 1
QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
navigator->switchCardInZone(&eRight);
EXPECT_EQ(navigator->getHoveredIndex(), 2);
// If left is pressed, go to index - 1
QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier);
navigator->switchCardInZone(&eLeft);
EXPECT_EQ(navigator->getHoveredIndex(), 1);
}
/* This test verifies the edge case moving behaviour of the cursor.
If we are on the first card, and left is pressed, we should go to the
last card, and vice-versa */
TEST_F(KeyboardCardNavigatorTest, ZoneLoopsTest)
{
// Set an arbitrary amount of cards for the zone
tableZone->setDummyCardCount(2);
// Select the first card
navigator->setCurrentZone(tableZone);
navigator->setHoveredCardIndex(0);
// If we press left, it wraps around to the end, and the zone doesn't change
QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier);
navigator->switchCardInZone(&eLeft);
EXPECT_EQ(navigator->getHoveredIndex(), 1);
EXPECT_EQ(navigator->getCurrentZone(), tableZone);
// If we press right, it wraps around to the start, and the zone doesn't change
QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
navigator->switchCardInZone(&eRight);
EXPECT_EQ(navigator->getHoveredIndex(), 0);
EXPECT_EQ(navigator->getCurrentZone(), tableZone);
}
/* This test verifies the switching of zones if the zone we are currently on is empty. */
TEST_F(KeyboardCardNavigatorTest, EmptyZoneLoopTest)
{
QList<CardZoneLogic *> zonesList;
zonesList.append(tableZone);
zonesList.append(stackZone);
zonesList.append(handZone);
tableZone->setDummyCardCount(2);
stackZone->setDummyCardCount(0); // empty
handZone->setDummyCardCount(2);
// Simulate key up when switching zones
CardZoneLogic *newZone = navigator->findZoneWithCards(zonesList, 0, true);
// The result should be zone 2
EXPECT_EQ(newZone, handZone);
// Simulate key down when switching zones
newZone = navigator->findZoneWithCards(zonesList, 2, false);
// The result should be zone 2
EXPECT_EQ(newZone, tableZone);
}
/* This test verifies the switching of zones with the up and down keys. */
TEST_F(KeyboardCardNavigatorTest, SwitchZoneTest)
{
tableZone->setDummyCardCount(2);
stackZone->setDummyCardCount(2);
handZone->setDummyCardCount(2);
navigator->setCurrentZone(tableZone);
navigator->setHoveredCardIndex(0);
// Simulate key up when switching zones
QKeyEvent eUp(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier);
navigator->switchZone(&eUp);
// The result should be zone 1 and the card selected is the first one
EXPECT_EQ(navigator->getCurrentZone(), stackZone);
EXPECT_EQ(navigator->getHoveredIndex(), 0);
// Simulate key down when switching zones
QKeyEvent eDown(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier);
navigator->switchZone(&eDown);
// The result should be zone 0 and the card selected is the first one
EXPECT_EQ(navigator->getCurrentZone(), tableZone);
EXPECT_EQ(navigator->getHoveredIndex(), 0);
}
/* This test verifies the case where every zone is empty. */
TEST_F(KeyboardCardNavigatorTest, EmptyZoneSwitchZones)
{
QList<CardZoneLogic *> zonesList;
zonesList.append(tableZone);
zonesList.append(stackZone);
zonesList.append(handZone);
tableZone->setDummyCardCount(0);
stackZone->setDummyCardCount(0);
handZone->setDummyCardCount(0);
// The expected zone is the starting one
CardZoneLogic *newZone = navigator->findZoneWithCards(zonesList, 0, true);
EXPECT_EQ(newZone, tableZone);
}
/* This test verifies the behaviour when someone moves a card with the mouse and the
keyboard is used after a zone becomes empty. */
TEST_F(KeyboardCardNavigatorTest, ZoneEmptySwitchTest)
{
tableZone->setDummyCardCount(1);
stackZone->setDummyCardCount(0);
handZone->setDummyCardCount(1);
// Set a card as hovered in the table zone
navigator->setCurrentZone(tableZone);
navigator->setHoveredCardIndex(0);
// Simulate the user deleting/moving all cards out of the table zone
tableZone->setDummyCardCount(0);
// Now the user presses an arrow key in the empty zone
QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
navigator->switchCardInZone(&eRight);
// The expected zone is the next one with cards
EXPECT_EQ(navigator->getCurrentZone(), handZone);
EXPECT_EQ(navigator->getHoveredIndex(), 0);
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,67 @@
#ifndef KEYBOARD_NAVIGATOR_TEST_FAKES_H
#define KEYBOARD_NAVIGATOR_TEST_FAKES_H
#include "game/board/card_item.h"
#include "game/zones/card_zone_logic.h"
#include "game/zones/hand_zone_logic.h"
#include "game/zones/stack_zone_logic.h"
#include "game/zones/table_zone_logic.h"
// Define safe macro replacements for the visual tests
#define setFocus() zValue()
#define getVisuallyOrderedHandCards() getCards()
namespace QApplicationMock
{
extern bool hasPopup;
}
class FakeCardZoneLogic : public CardZoneLogic
{
public:
QString name;
FakeCardZoneLogic(const QString &n) : CardZoneLogic(nullptr, n, false, false, true, nullptr), name(n)
{
}
void addCardImpl(CardItem *, int, int) override
{
}
void setDummyCardCount(int count)
{
cards.clear();
for (int i = 0; i < count; i++) {
cards.insert(i, (CardItem *)nullptr);
}
}
const QString getName() const
{
return name;
}
};
#define DECLARE_FAKE_ZONE(FakeName, TargetName) \
class FakeName : public FakeCardZoneLogic \
{ \
public: \
FakeName(const QString &n) : FakeCardZoneLogic(n) \
{ \
} \
const QMetaObject *metaObject() const override \
{ \
return &TargetName::staticMetaObject; \
} \
void *qt_metacast(const char *clname) override \
{ \
if (!clname) \
return nullptr; \
if (!strcmp(clname, #TargetName)) \
return static_cast<void *>(this); \
return FakeCardZoneLogic::qt_metacast(clname); \
} \
};
DECLARE_FAKE_ZONE(FakeTableZoneLogic, TableZoneLogic)
DECLARE_FAKE_ZONE(FakeStackZoneLogic, StackZoneLogic)
DECLARE_FAKE_ZONE(FakeHandZoneLogic, HandZoneLogic)
#endif // KEYBOARD_NAVIGATOR_TEST_FAKES_H

View file

@ -0,0 +1,106 @@
#include "game/board/abstract_card_item.h"
#include "game/board/arrow_item.h"
#include "game/board/card_item.h"
#include "game/board/card_list.h"
#include "game/player/player_logic.h"
#include "game/zones/card_zone_logic.h"
#include "game/zones/hand_zone_logic.h"
#include "game/zones/stack_zone_logic.h"
#include "game/zones/table_zone_logic.h"
#include <QApplication>
#include <QColor>
#include <QMetaObject>
#include <QString>
// Stubs for AbstractCardItem
void AbstractCardItem::setHovered(bool)
{
}
// Stubs for ArrowItem
ArrowItem::ArrowItem(PlayerLogic *, int, ArrowTarget *, ArrowTarget *, QColor const &)
: QObject(nullptr), QGraphicsItem(nullptr)
{
}
void ArrowItem::sendCreateArrowCommand(PlayerLogic *, CardItem *, ArrowTarget *, QColor const &, int)
{
}
void ArrowItem::paint(QPainter *, QStyleOptionGraphicsItem const *, QWidget *)
{
}
void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent *)
{
}
const QMetaObject *ArrowItem::metaObject() const
{
return nullptr;
}
void *ArrowItem::qt_metacast(const char *)
{
return nullptr;
}
int ArrowItem::qt_metacall(QMetaObject::Call, int, void **)
{
return 0;
}
// Stubs for QMetaObject
const QMetaObject TableZoneLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
const QMetaObject StackZoneLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
const QMetaObject HandZoneLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
const QMetaObject PlayerLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
const QMetaObject CardZoneLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
// Stubs for CardList
CardList::CardList(bool) : QList<CardItem *>()
{
}
// Stubs for CardZoneLogic
CardZoneLogic::CardZoneLogic(PlayerLogic *, const QString &, bool, bool, bool _contentsKnown, QObject *)
: QObject(nullptr), cards(_contentsKnown)
{
}
CardItem *CardZoneLogic::getCard(int)
{
return nullptr;
}
CardItem *CardZoneLogic::takeCard(int, int, bool)
{
return nullptr;
}
void CardZoneLogic::addCardImpl(CardItem *, int, int)
{
}
QString CardZoneLogic::getTranslatedName(bool, GrammaticalCase) const
{
return QString();
}
const QMetaObject *CardZoneLogic::metaObject() const
{
return nullptr;
}
void *CardZoneLogic::qt_metacast(const char *)
{
return nullptr;
}
int CardZoneLogic::qt_metacall(QMetaObject::Call, int, void **)
{
return 0;
}
// QApplication Mock implementation
namespace QApplicationMock
{
bool hasPopup = false;
}
QWidget *QApplication::activePopupWidget()
{
return QApplicationMock::hasPopup ? (QWidget *)1 : nullptr;
}