Merge current upstream into finite-copy legality review

Preserve upstream master and the original nine-file copy-limit change. Retain QIODevice alongside upstream Oracle test includes. Invalidate derived format rules where the new upstream import reset and failure paths clear their cards.
This commit is contained in:
Bryce! 2026-11-04 16:01:48 -05:00
commit 3de8351b8b
443 changed files with 15637 additions and 11767 deletions

View file

@ -177,7 +177,29 @@ cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIB
cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME svg_recolor_test SOURCES svg_recolor_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(
NAME
user_games_model_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/server/user/user_games_model.cpp
user_games_model_test.cpp
LIBS
libcockatrice_network_client_abstract
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
${CMAKE_SOURCE_DIR}/libcockatrice_network
)
cockatrice_add_test(
NAME
spoiler_file_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp
spoiler_file_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
# The installer's holder probe is Windows-only, but the wording it produces and the
# question which programs are the payload's own are plain C++ and are what a stuck user
# has to act on, so they are tested everywhere.
@ -208,6 +230,18 @@ cockatrice_add_test(
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
card_art_crop_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/cards/card_art_utils.cpp
${VERSION_STRING_CPP}
card_art_crop_test.cpp
LIBS
libcockatrice_card
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test(
NAME
server_rate_limiter_test
@ -328,6 +362,40 @@ cockatrice_add_test(
LABELS
server
)
cockatrice_add_test(
NAME
server_forced_password_change_test
SOURCES
server_forced_password_change_test.cpp
LIBS
libcockatrice_network_server_remote
libcockatrice_rng
LABELS
server
)
cockatrice_add_test(
NAME
client_login_status_test
SOURCES
${VERSION_STRING_CPP}
client_login_status_test.cpp
LIBS
libcockatrice_network_client_remote
LABELS
ci
)
# The local game runs one client and one server in the same process.
# Either of them can be destroyed first; the survivor has to stay usable.
cockatrice_add_test(
NAME
local_client_lifetime_test
SOURCES
local_client_lifetime_test.cpp
LIBS
libcockatrice_network
LABELS
ci
)
cockatrice_add_test(
NAME
server_room_channels_test
@ -380,18 +448,10 @@ cockatrice_add_test(
)
cockatrice_add_test(
NAME
loader_local_matching_test
font_config_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
${VERSION_STRING_CPP}
loader_local_matching_test.cpp
LIBS
libcockatrice_network
libcockatrice_settings
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp
font_config_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
@ -446,6 +506,19 @@ endif()
# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt.
if(WITH_CLIENT)
cockatrice_add_test(
NAME
moxfield_signature_spells_test
SOURCES
${VERSION_STRING_CPP}
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_loader/card_node_function.cpp
moxfield_signature_spells_test.cpp
LIBS
libcockatrice_card
libcockatrice_deck_list
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test(
NAME
single_instance_manager_test
@ -493,7 +566,9 @@ add_subdirectory(card_zone_algorithms)
add_subdirectory(carddatabase)
add_subdirectory(deck_list_model)
add_subdirectory(deck_list_zones)
add_subdirectory(icon_assets)
add_subdirectory(loading_from_clipboard)
add_subdirectory(model_contract)
add_subdirectory(movecard_tests)
add_subdirectory(oracle)
add_subdirectory(settings)
@ -513,3 +588,46 @@ endif()
if(WITH_ORACLE OR WITH_CLIENT)
add_subdirectory(picture_loader_benchmark)
endif()
# loader_local_matching_test and deck_state_manager_test links libcockatrice_settings,
# which only exists when a client/UI-capable target is being built.
if(WITH_ORACLE OR WITH_CLIENT)
set(SETTINGS_SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${VERSION_STRING_CPP}
)
set(SETTINGS_LIBS libcockatrice_network libcockatrice_settings)
set(SETTINGS_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src)
cockatrice_add_test(
NAME
loader_local_matching_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
${SETTINGS_SOURCES}
loader_local_matching_test.cpp
LIBS
${SETTINGS_LIBS}
INCLUDE_DIRS
${SETTINGS_INCLUDE_DIRS}
)
cockatrice_add_test(
NAME
deck_state_manager_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp
${SETTINGS_SOURCES}
deck_state_manager_test.cpp
LIBS
libcockatrice_card
libcockatrice_deck_list
libcockatrice_models
libcockatrice_utility
${SETTINGS_LIBS}
INCLUDE_DIRS
${SETTINGS_INCLUDE_DIRS}
)
endif()

View file

@ -0,0 +1,92 @@
#include "interface/widgets/cards/card_art_utils.h"
#include <QApplication>
#include <QImage>
#include <QPainter>
#include <QTransform>
#include <gtest/gtest.h>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/printing/exact_card.h>
namespace
{
ExactCard makeCard(bool landscape)
{
CardInfo::UiAttributes attributes;
attributes.landscapeOrientation = landscape;
return ExactCard(CardInfo::newInstance("Artwork", "", false, {}, {}, {}, {}, attributes));
}
QPixmap landscapeCardWithFrame()
{
// Normalized landscape frame: the title reaches 14% down the upright
// image, and the Battle type line starts at 56%. Artwork has two colors
// so the test also detects a wrong or missing rotation.
QImage upright(1400, 1000, QImage::Format_RGB32);
upright.fill(Qt::red);
QPainter painter(&upright);
painter.fillRect(QRect(70, 140, 1260, 420), Qt::green);
painter.fillRect(QRect(700, 140, 630, 420), Qt::blue);
painter.end();
QTransform storedOrientation;
storedOrientation.rotate(-90);
return QPixmap::fromImage(upright.transformed(storedOrientation));
}
TEST(CardArtCrop, LandscapeCropExcludesNameAndRulesBoxesAndRotatesUpright)
{
const QImage crop = CardArtUtils::cropCardArt(landscapeCardWithFrame(), makeCard(true)).toImage();
ASSERT_GT(crop.width(), crop.height());
ASSERT_FALSE(crop.isNull());
for (int y = 0; y < crop.height(); ++y) {
for (int x = 0; x < crop.width(); ++x) {
EXPECT_EQ(x < crop.width() / 2 ? QColor(Qt::green) : QColor(Qt::blue), crop.pixelColor(x, y));
}
}
}
TEST(CardArtCrop, PortraitCropRetainsItsExistingArtworkBounds)
{
QImage source(745, 1040, QImage::Format_RGB32);
for (int y = 0; y < source.height(); ++y) {
for (int x = 0; x < source.width(); ++x) {
source.setPixelColor(x, y, QColor(x % 256, y % 256, (x + y) % 256));
}
}
const QPixmap input = QPixmap::fromImage(source);
const QImage crop = CardArtUtils::cropCardArt(input, makeCard(false)).toImage();
EXPECT_EQ(source.copy(QRect(52, 114, 641, 458)), crop);
}
TEST(CardArtCrop, DevicePixelRatioDoesNotChangeTheArtworkPixels)
{
QPixmap source = landscapeCardWithFrame();
const QPixmap normal = CardArtUtils::cropCardArt(source, makeCard(true));
source.setDevicePixelRatio(2.0);
const QPixmap highDpi = CardArtUtils::cropCardArt(source, makeCard(true));
EXPECT_EQ(normal.size(), highDpi.size());
EXPECT_EQ(2.0, highDpi.devicePixelRatio());
QImage normalPixels = normal.toImage();
QImage highDpiPixels = highDpi.toImage();
normalPixels.setDevicePixelRatio(1.0);
highDpiPixels.setDevicePixelRatio(1.0);
EXPECT_EQ(normalPixels, highDpiPixels);
}
TEST(CardArtCrop, EmptyAndSmallImagesStayWithinTheirBounds)
{
EXPECT_TRUE(CardArtUtils::cropCardArt({}, makeCard(true)).isNull());
QPixmap small(1, 1);
small.fill(Qt::green);
EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(true)).toImage());
EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(false)).toImage());
}
} // namespace
int main(int argc, char **argv)
{
qputenv("QT_QPA_PLATFORM", "offscreen");
QApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -35,6 +35,21 @@ if(WITH_ORACLE OR WITH_CLIENT)
LIBS
libcockatrice_filters
)
# ------------------------
# Visual Display Model Test
# ------------------------
cockatrice_add_test(
NAME
visual_display_model_test
SOURCES
${VERSION_STRING_CPP}
visual_display_model_test.cpp
mocks.cpp
LIBS
libcockatrice_card
libcockatrice_models
)
endif()
# ------------------------

View file

@ -36,6 +36,12 @@
#include <libcockatrice/utility/card_ref.h>
#include <string>
#ifdef Q_OS_UNIX
#include <csignal>
#include <sys/resource.h>
#include <unistd.h>
#endif
namespace
{
@ -204,6 +210,64 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip)
ASSERT_EQ(printings.first().getProperty("manacost"), "1R");
}
#ifdef Q_OS_UNIX
/**
* @brief A failed write keeps the previously installed card database intact.
*
* This is the writer Oracle uses to install a refreshed database, so the truncation
* bug has to be covered here and not only through OracleDatabase::saveXml.
*/
TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile)
{
NoopCardSetPriorityController controller;
CardSetPtr set =
CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary);
QHash<QString, QString> props;
props["manacost"] = "1R";
PrintingInfo printing(set, LazyPropertiesHash(props));
SetToPrintingsMap setsInfo;
setsInfo["TST"].append(printing);
CardInfo::UiAttributes attributes = {.tableRow = 1};
CardInfoPtr card =
CardInfo::newInstance("Lightning Bolt", QString(1024, 'x'), false, {}, {}, {}, setsInfo, attributes);
SetNameMap sets;
sets.insert("TST", set);
CardNameMap cards;
cards.insert("Lightning Bolt", card);
QTemporaryDir tempDir;
ASSERT_TRUE(tempDir.isValid());
const QString fileName = tempDir.filePath("cards.xml");
QFile installed(fileName);
ASSERT_TRUE(installed.open(QIODevice::WriteOnly));
ASSERT_EQ(installed.write("installed database"), 18);
installed.close();
// A real filesystem write failure, isolated in the death-test child so the
// process-wide file limit never affects other cases or the parent runner.
ASSERT_EXIT(
{
std::signal(SIGXFSZ, SIG_IGN);
struct rlimit limit;
limit.rlim_cur = 128;
limit.rlim_max = 128;
if (setrlimit(RLIMIT_FSIZE, &limit) != 0) {
_exit(2);
}
CockatriceXml4Parser writer(&controller);
_exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0);
},
::testing::ExitedWithCode(0), "");
ASSERT_TRUE(installed.open(QIODevice::ReadOnly));
EXPECT_EQ(installed.readAll(), "installed database");
}
#endif
/**
* @brief Path provider serving a sandboxed copy of the test card database.
*/

View file

@ -0,0 +1,188 @@
/**
* @file visual_display_model_test.cpp
* @brief Tests for the flattened model behind the visual card database grid.
*/
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include "libcockatrice/card/set/card_set.h"
#include "libcockatrice/interfaces/noop_card_preference_provider.h"
#include "libcockatrice/interfaces/noop_card_set_priority_controller.h"
#include "test_card_database_path_provider.h"
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QDate>
#include <QEventLoop>
#include <QHash>
#include <QMap>
#include <QString>
#include <QTimer>
#include <memory>
namespace
{
// GTest's main() creates no QCoreApplication, which QTimer needs for its event loop.
const QCoreApplication &application()
{
static int argc = 1;
static char executable[] = "visual_display_model_test";
static char *argv[] = {executable, nullptr};
static const QCoreApplication app(argc, argv);
return app;
}
/// Lets the rebuild timer fire, as it would in the running client.
void waitForRebuild()
{
QEventLoop loop;
QTimer::singleShot(100, &loop, &QEventLoop::quit);
loop.exec();
}
class CardDatabaseVisualDisplayModelTest : public ::testing::Test
{
protected:
void SetUp() override
{
(void)application();
db = std::make_unique<CardDatabase>(nullptr, new NoopCardPreferenceProvider(),
new TestCardDatabasePathProvider(), new NoopCardSetPriorityController());
db->loadCardDatabases();
cardModel = std::make_unique<CardDatabaseModel>(db.get(), false);
displayModel = std::make_unique<CardDatabaseDisplayModel>();
displayModel->setSourceModel(cardModel.get());
visualModel = std::make_unique<CardDatabaseVisualDisplayModel>(displayModel.get(), db->query());
visualModel->refresh();
}
/**
* @brief Builds a card with @p printingCount printings in each named set.
* @param printingCountPerSet Set short name to the number of printings in that set.
*/
[[nodiscard]] CardInfoPtr makeCard(const QString &name, const QMap<QString, int> &printingCountPerSet)
{
SetToPrintingsMap setsInfo;
for (auto it = printingCountPerSet.constBegin(); it != printingCountPerSet.constEnd(); ++it) {
CardSetPtr set = CardSet::newInstance(&setController, it.key(), it.key() + " test set", "expansion",
QDate(), CardSet::PriorityPrimary);
for (int printing = 0; printing < it.value(); ++printing) {
QHash<QString, QString> properties;
properties["uuid"] = QString("%1-%2-%3").arg(it.key(), name).arg(printing);
setsInfo[it.key()].append(PrintingInfo(set, LazyPropertiesHash(properties)));
}
}
CardInfo::UiAttributes attributes = {.tableRow = 1};
return CardInfo::newInstance(name, "", false, {}, {}, {}, setsInfo, attributes);
}
NoopCardSetPriorityController setController;
std::unique_ptr<CardDatabase> db;
std::unique_ptr<CardDatabaseModel> cardModel;
std::unique_ptr<CardDatabaseDisplayModel> displayModel;
std::unique_ptr<CardDatabaseVisualDisplayModel> visualModel;
};
TEST_F(CardDatabaseVisualDisplayModelTest, ExposesTheCardInfoBehindAProxyRow)
{
ASSERT_GT(displayModel->rowCount(), 0);
const QVariant info = displayModel->data(displayModel->index(0, CardDatabaseModel::NameColumn),
CardDatabaseDisplayModel::CardInfoRole);
ASSERT_TRUE(info.isValid());
EXPECT_FALSE(info.value<CardInfoPtr>().isNull());
}
TEST_F(CardDatabaseVisualDisplayModelTest, FlattensOneRowPerProxyCardWithoutSetFilters)
{
ASSERT_GT(displayModel->rowCount(), 0);
EXPECT_EQ(displayModel->rowCount(), visualModel->rowCount());
for (int row = 0; row < visualModel->rowCount(); ++row) {
const ExactCard card = visualModel->cardAt(row);
ASSERT_TRUE(card) << "row " << row;
EXPECT_EQ(
displayModel->data(displayModel->index(row, CardDatabaseModel::NameColumn), Qt::DisplayRole).toString(),
card.getName())
<< "row " << row;
}
}
TEST_F(CardDatabaseVisualDisplayModelTest, ShowsOneRowPerCardWhenNoSetIsFiltered)
{
const int rowsBefore = visualModel->rowCount();
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
EXPECT_EQ(rowsBefore + 1, visualModel->rowCount());
EXPECT_EQ("Multi", visualModel->cardAt(visualModel->rowCount() - 1).getName());
}
TEST_F(CardDatabaseVisualDisplayModelTest, ShowsEveryPrintingOfAFilteredSet)
{
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
visualModel->setSetFilterTerms({"TST"});
ASSERT_EQ(2, visualModel->rowCount());
for (int row = 0; row < visualModel->rowCount(); ++row) {
const ExactCard card = visualModel->cardAt(row);
ASSERT_TRUE(card) << "row " << row;
EXPECT_EQ("Multi", card.getName());
ASSERT_FALSE(card.getPrinting().isEmpty()) << "row " << row;
EXPECT_EQ("TST", card.getPrinting().getSet()->getShortName()) << "row " << row;
}
EXPECT_NE(visualModel->cardAt(0).getPrinting(), visualModel->cardAt(1).getPrinting());
}
TEST_F(CardDatabaseVisualDisplayModelTest, HidesCardsWithoutAPrintingInTheFilteredSets)
{
visualModel->setSetFilterTerms({"NO SUCH SET"});
EXPECT_EQ(0, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, RestoresOneRowPerCardWhenTheSetTermsClear)
{
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
const int rowsBefore = visualModel->rowCount();
visualModel->setSetFilterTerms({"TST"});
ASSERT_EQ(2, visualModel->rowCount());
visualModel->setSetFilterTerms({});
EXPECT_EQ(rowsBefore, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, RebuildsByItselfWhenTheProxyGainsARow)
{
const int rowsBefore = visualModel->rowCount();
db->addCard(makeCard("Late", {{"TST", 1}}));
waitForRebuild();
EXPECT_EQ(rowsBefore + 1, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, ReportsTheCardNameForDisplayAndAccessibleText)
{
ASSERT_GT(visualModel->rowCount(), 0);
const QString expected = visualModel->cardAt(0).getName();
EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::DisplayRole).toString());
EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::AccessibleTextRole).toString());
EXPECT_FALSE(visualModel->data(QModelIndex(), Qt::DisplayRole).isValid());
}
} // namespace

View file

@ -0,0 +1,116 @@
/** @file client_login_status_test.cpp
* @brief The login response state machine must keep the session a forced password change needs.
* @ingroup Tests
*/
#include "abstract_client.h"
#include "libcockatrice/interfaces/interface_network_settings_provider.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/response_login.pb.h"
#include "remote_client.h"
#include <QCoreApplication>
#include <QMetaObject>
#include <QString>
#include <gtest/gtest.h>
namespace
{
class StubNetworkSettingsProvider : public INetworkSettingsProvider
{
public:
QString getClientID() override
{
return {};
}
[[nodiscard]] int getTimeOut() const override
{
return 1000;
}
[[nodiscard]] int getKeepAlive() const override
{
return 60;
}
[[nodiscard]] bool getNotifyAboutUpdates() const override
{
return false;
}
void setKnownMissingFeatures(const QString &) override
{
}
QString getKnownMissingFeatures() override
{
return {};
}
};
/**
* @brief Feeds a login response to RemoteClient without a socket.
*
* `loginResponse` is the slot the socket thread runs for the login command's answer, so
* invoking it directly exercises the status transition that decides whether `readData()`
* tears the connection down right after it.
*/
class ClientLoginStatusTest : public ::testing::Test
{
protected:
void SetUp() override
{
client = new RemoteClient(nullptr, &provider);
}
void TearDown() override
{
delete client;
client = nullptr;
}
void deliverLoginResponse(Response::ResponseCode code)
{
Response response;
response.set_response_code(code);
response.MutableExtension(Response_Login::ext);
ASSERT_TRUE(
QMetaObject::invokeMethod(client, "loginResponse", Qt::DirectConnection, Q_ARG(Response, response)));
}
StubNetworkSettingsProvider provider;
RemoteClient *client = nullptr;
};
/**
* @brief A required password change is not a failed login.
*
* The server keeps the session open and accepts only ping and Command_AccountPassword until
* the password is replaced. StatusDisconnecting would make readData() close the socket right
* after this slot returns - before the dialog that collects the new password (queued to the
* UI thread) can send anything - and the pending command would never be answered because the
* ping timer that times it out dies with the connection. StatusLoggedIn would start the
* normal post-login flow for a session that cannot serve it.
*/
TEST_F(ClientLoginStatusTest, PasswordChangeRequiredKeepsTheSessionOpen)
{
deliverLoginResponse(Response::RespPasswordChangeRequired);
EXPECT_EQ(client->getStatus(), StatusPasswordChangeRequired);
EXPECT_NE(client->getStatus(), StatusDisconnecting);
EXPECT_NE(client->getStatus(), StatusLoggedIn);
}
/** @brief Every other login error still ends the connection, as it did before. */
TEST_F(ClientLoginStatusTest, OtherLoginErrorsDisconnect)
{
deliverLoginResponse(Response::RespWrongPassword);
EXPECT_EQ(client->getStatus(), StatusDisconnecting);
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -278,6 +278,37 @@ TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones)
EXPECT_EQ(mapping[1].second, 1);
}
TEST(DeckListModelCustomZones, ManaGroupsUseNumericOrderWithoutChangingOtherNodes)
{
InnerDecklistNode root;
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
const QStringList original = {"10", "2", "0.5", "1000000", "1", "unknown", "nan"};
for (const auto &name : original) {
new InnerDecklistNode(name, board);
}
auto *custom = new DecklistModelSubZoneNode("1x", board);
new InnerDecklistNode("10", custom);
new InnerDecklistNode("2", custom);
root.setSortMethod(DeckSortMethod::ByName);
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder, true);
EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "2", "10", "1000000", "nan", "unknown", "1x"}));
QStringList before = original;
before.append("1x");
for (const auto &move : mapping) {
EXPECT_EQ(board->at(move.second)->getName(), before.at(move.first));
}
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder, true);
EXPECT_EQ(childNames(board), (QStringList{"1000000", "10", "2", "1", "0.5", "unknown", "nan", "1x"}));
// The numeric flag only affects groups directly under a board.
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, custom, Qt::AscendingOrder, true);
EXPECT_EQ(childNames(custom), (QStringList{"10", "2"}));
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder);
EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "10", "1000000", "2", "nan", "unknown", "1x"}));
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);

View file

@ -393,6 +393,28 @@ TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash)
EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash());
}
TEST(DeckListZones, PlainExportPutsMainFirstWithoutReorderingSavedZones)
{
DeckList deck;
auto *tree = deck.getTree();
ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr);
tree->addCard("Side Card", 2, "Side Tech", -1);
tree->addCard("Token Card", 1, DECK_ZONE_TOKENS, -1);
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
tree->addCard("Main Card", 3, "Removal", -1);
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr);
tree->addCard("Maybe Card", 4, "Candidates", -1);
const QString native = deck.writeToString_Native();
EXPECT_EQ(deck.writeToString_Plain(true, false), QString("3 Main Card\nSB: 2 Side Card\n1 Token Card\n"));
EXPECT_EQ(deck.writeToString_Plain(false, false), QString("3 Main Card\n2 Side Card\n1 Token Card\n"));
EXPECT_EQ(deck.writeToString_Native(), native);
DeckList reopened(native);
EXPECT_EQ(reopened.getZoneNodes().first()->getName(), DECK_ZONE_SIDE);
EXPECT_EQ(reopened.writeToString_Plain(true, false), deck.writeToString_Plain(true, false));
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);

View file

@ -0,0 +1,190 @@
/** @file deck_state_manager_test.cpp
* @brief Tests for DeckStateManager operations.
* @ingroup Tests
*/
#include "libcockatrice/card/database/card_database.h"
#include <QCoreApplication>
#include <QDir>
#include <QStandardPaths>
#include <client/settings/cache_settings.h>
#include <deck_list_model.h>
#include <gtest/gtest.h>
#include <interface/widgets/deck_editor/deck_state_manager.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/printing/exact_card.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
namespace
{
/**
* @brief Assert that the DeckStateManager's deck list contains only the given zone,
* and that that zone only contains the given number of cards, all with the given card name.
*
* @param manager the DeckStateManager
* @param restrictToZones In which zones to look for the zone and cards. Empty means use the entire deck
* @param zone The only zone that should exist in the deck
* @param cardName The name of the only card that should exist in the deck
* @param cardNumber The number of cards that should be in the deck
*/
static void containsExactly(const DeckStateManager &manager,
const QSet<QString> &restrictToZones,
QString zone,
QString cardName,
int cardNumber)
{
QList<const InnerDecklistNode *> zones = manager.getDeckList().getZoneNodes(restrictToZones);
ASSERT_EQ(zones.size(), 1);
ASSERT_EQ(zones[0]->getName(), zone);
ASSERT_EQ(zones[0]->size(), 1);
const DecklistCardNode *card = dynamic_cast<const DecklistCardNode *>((*zones[0])[0]);
ASSERT_NE(card, nullptr);
ASSERT_EQ(card->getName(), cardName);
ASSERT_EQ(card->getNumber(), cardNumber);
}
/** @brief Return CardInfo of a token with the given name. */
static CardInfoPtr makeToken(QString name)
{
return CardInfo::newInstance(name, "", true, {}, {}, {}, {}, {});
}
// =====================================================================================================================
// addCard
// =====================================================================================================================
TEST(DeckStateManager, AddCards)
{
DeckStateManager manager;
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1);
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
}
TEST(DeckStateManager, AddCardZones)
{
DeckStateManager manager;
manager.addCard(ExactCard(CardInfo::newInstance("Grizzly Bears")), DECK_ZONE_MAIN);
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE);
manager.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), DECK_ZONE_MAYBEBOARD);
// Check if the cards got put into the correct zones.
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 3);
containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Grizzly Bears", 1);
containsExactly(manager, {DECK_ZONE_SIDE}, DECK_ZONE_SIDE, "Lightning Bolt", 1);
containsExactly(manager, {DECK_ZONE_MAYBEBOARD}, DECK_ZONE_MAYBEBOARD, "Swords to Plowshares", 1);
}
TEST(DeckStateManager, AddTokens)
{
DeckStateManager manager;
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAYBEBOARD);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
// A token should always be put into the tokens zone.
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 4);
}
// =====================================================================================================================
// decrementCard
// =====================================================================================================================
TEST(DeckStateManager, DecrementCards)
{
DeckStateManager manager;
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
// Removing from the sideboard should do nothing, as there is nothing in the side board.
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
// Remove the first card.
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1);
// Removing the second card should also remove the zone.
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
}
TEST(DeckStateManager, DecrementTokens)
{
DeckStateManager manager;
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 3);
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 2);
// Removing tokens from a given zone should remove them from the
// token zone if the given zone does not contain the token.
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 1);
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
}
TEST(DeckStateManager, DecrementTokenMainboard)
{
DeckStateManager manager;
// Add cards to the deck list directly so tokens are not
// automatically added to the token zone.
manager.getDeckListShared()->addCard("Beast", DECK_ZONE_MAIN);
manager.getDeckListShared()->addCard("Beast", DECK_ZONE_TOKENS);
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 2);
containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Beast", 1);
containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1);
// decrementCard uses the model to find the card, so we need to rebuild it.
manager.getModel()->rebuildTree();
// Tokens should be removed from the main zone first.
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
ASSERT_EQ(manager.getDeckList().getZoneNodes({DECK_ZONE_MAIN}).size(), 0);
containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1);
// If the main zone is empty they can be removed from the token zone.
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
}
} // namespace
int main(int argc, char **argv)
{
// Redirect SettingsCache reads/writes (app-data location) away from the real user profile.
QStandardPaths::setTestModeEnabled(true);
QCoreApplication app(argc, argv);
QCoreApplication::setOrganizationName(QStringLiteral("CockatriceTest"));
QCoreApplication::setApplicationName(
QStringLiteral("deck-state-manager-test-%1").arg(QCoreApplication::applicationPid()));
::testing::InitGoogleTest(&argc, argv);
// Add cards to the card database
auto *db = CardDatabaseManager::getInstance();
db->addCard(CardInfo::newInstance("Lightning Bolt"));
db->addCard(CardInfo::newInstance("Grizzly Bears"));
db->addCard(CardInfo::newInstance("Swords to Plowshares"));
db->addCard(makeToken("Beast"));
int result = RUN_ALL_TESTS();
// Clean up the settings directory in ~/.qttest if the tests ran.
if (!::testing::GTEST_FLAG(list_tests)) {
QDir(SettingsCache::instance().getDataPath()).removeRecursively();
QDir(SettingsCache::instance().getCachePath()).removeRecursively();
}
return result;
}

195
tests/font_config_test.cpp Normal file
View file

@ -0,0 +1,195 @@
#include "interface/theme_config.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFont>
#include <QIODevice>
#include <QString>
#include <QStringLiteral>
#include <QTemporaryDir>
#include <QTextStream>
#include <gtest/gtest.h>
namespace
{
// Writes content to a file inside tmp, creating parents as needed.
QString writeTempFile(QTemporaryDir &tmp, const QString &relativePath, const QString &content)
{
const QString filePath = QDir(tmp.path()).absoluteFilePath(relativePath);
EXPECT_TRUE(QDir().mkpath(QFileInfo(filePath).absolutePath()));
QFile file(filePath);
EXPECT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text));
QTextStream out(&file);
out << content;
file.close();
return filePath;
}
QFont makeFont(const QString &family, int pointSize)
{
QFont font(family);
font.setPointSize(pointSize);
return font;
}
} // namespace
TEST(FontConfigTest, FileNameIsFontsToml)
{
EXPECT_EQ(FontConfig::fileName(), QStringLiteral("fonts.toml"));
}
TEST(FontConfigTest, EmptyByDefault)
{
FontConfig cfg;
EXPECT_FALSE(cfg.hasFonts());
EXPECT_TRUE(cfg.toToml().isEmpty());
EXPECT_EQ(cfg, FontConfig());
EXPECT_EQ(cfg, FontConfig::fromFile("/nonexistent/path/fonts.toml"));
EXPECT_EQ(cfg, FontConfig::fromThemeDir(QString()));
}
TEST(FontConfigTest, RoundTripPreservesFamilyAndSize)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
FontConfig cfg;
cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
cfg.fonts[AppFont::ChatLog] = makeFont("JetBrains Mono", 9);
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11);
cfg.fonts[AppFont::DeckList] = makeFont("Roboto", 12);
ASSERT_TRUE(cfg.hasFonts());
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromFile(filePath);
EXPECT_EQ(loaded, cfg);
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].family(), QStringLiteral("Inter"));
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].pointSize(), 10);
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("JetBrains Mono"));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 9);
EXPECT_EQ(loaded.fonts[AppFont::CardText].family(), QStringLiteral("Noto Serif"));
EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 11);
EXPECT_EQ(loaded.fonts[AppFont::DeckList].family(), QStringLiteral("Roboto"));
EXPECT_EQ(loaded.fonts[AppFont::DeckList].pointSize(), 12);
}
TEST(FontConfigTest, RoundTripStripsWeightAndItalic)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
QFont styled("Inter");
styled.setPointSize(10);
styled.setBold(true);
styled.setItalic(true);
FontConfig cfg;
cfg.fonts[AppFont::ChatLog] = styled;
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromFile(filePath);
ASSERT_TRUE(loaded.fonts.contains(AppFont::ChatLog));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("Inter"));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 10);
EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].bold());
EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].italic());
EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("Inter", 10));
}
TEST(FontConfigTest, ToTomlFormat)
{
FontConfig cfg;
cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
const QString toml = cfg.toToml();
EXPECT_TRUE(toml.contains(QStringLiteral("[Fonts]")));
EXPECT_TRUE(toml.contains(QStringLiteral("InterfaceDefault")));
EXPECT_TRUE(toml.contains(QStringLiteral("\"Inter, 10\"")));
}
TEST(FontConfigTest, MalformedLinesAreSkipped)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString filePath = writeTempFile(tmp, "fonts.toml",
"# a comment\n"
"; another comment\n"
"\n"
"StrayBeforeSection = \"Inter, 10\"\n"
"\n"
"[Other]\n"
"ChatLog = \"ShouldBeIgnored, 10\"\n"
"\n"
"[Fonts]\n"
"InterfaceDefault = \"Inter, 10\"\n"
"ChatLog = \"JetBrains Mono\", 9 # inline comment\n"
"CardText = Noto Serif, 11\n"
"AppFont::DeckList = \"Roboto, 12\"\n"
"UnknownRole = \"Inter, 10\"\n"
"QPalette::Window = \"Inter, 10\"\n"
"NoComma = \"Inter\"\n"
"EmptyFamily = \", 10\"\n"
"ZeroSize = \"Inter, 0\"\n"
"NegativeSize = \"Inter, -3\"\n"
"NotANumber = \"Inter, big\"\n"
"NotAnEntry\n");
const FontConfig loaded = FontConfig::fromFile(filePath);
ASSERT_TRUE(loaded.hasFonts());
EXPECT_EQ(loaded.fonts.size(), 4);
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault], makeFont("Inter", 10));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("JetBrains Mono", 9));
EXPECT_EQ(loaded.fonts[AppFont::CardText], makeFont("Noto Serif", 11));
EXPECT_EQ(loaded.fonts[AppFont::DeckList], makeFont("Roboto", 12));
}
TEST(FontConfigTest, FromThemeDirReadsFontsToml)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString themeDir = QDir(tmp.path()).absoluteFilePath("MyTheme");
ASSERT_TRUE(QDir().mkpath(themeDir));
FontConfig cfg;
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11);
writeTempFile(tmp, "MyTheme/fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromThemeDir(themeDir);
EXPECT_EQ(loaded, cfg);
// A theme directory without fonts.toml loads as empty.
const QString bareDir = QDir(tmp.path()).absoluteFilePath("BareTheme");
ASSERT_TRUE(QDir().mkpath(bareDir));
EXPECT_FALSE(FontConfig::fromThemeDir(bareDir).hasFonts());
}
TEST(FontConfigTest, InequalityComparesRoles)
{
FontConfig left;
left.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
FontConfig right = left;
EXPECT_EQ(left, right);
EXPECT_FALSE(left != right);
right.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 11);
EXPECT_NE(left, right);
right = left;
right.fonts[AppFont::ChatLog] = makeFont("Inter", 10);
EXPECT_NE(left, right);
}
// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and
// the fallback then links the bare gtest library without gtest_main.
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -1,28 +1,18 @@
add_executable(
game_smoke_test
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
cockatrice_add_test(
NAME
game_logic_test
SOURCES
game_test_main.cpp
card_state_test.cpp
card_zone_logic_test.cpp
game_event_handler_test.cpp
player_actions_test.cpp
replay_test.cpp
${VERSION_STRING_CPP}
game_smoke_test.cpp
LIBS
libcockatrice_game
INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}
DEFINITIONS
GAME_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data"
)
# SettingsCache is still reached from the game logic, but it lives in the application sources
# rather than in libcockatrice_settings, so the client settings sources are compiled in here
# exactly like loader_local_matching_test and theme_migration_test do.
target_include_directories(game_smoke_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
target_link_libraries(
game_smoke_test
PRIVATE libcockatrice_game
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
PRIVATE ${TEST_QT_MODULES}
)
add_test(NAME game_smoke_test COMMAND game_smoke_test)
if(NOT GTEST_FOUND)
add_dependencies(game_smoke_test gtest)
endif()

View file

@ -0,0 +1,110 @@
#include "gtest/gtest.h"
#include <QMetaType>
#include <QString>
#include <QVariantList>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/utility/card_ref.h>
namespace
{
CardRef cardRefFor(const QString &name)
{
CardRef cardRef;
cardRef.name = name;
return cardRef;
}
} // namespace
TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent)
{
CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1);
CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2);
guest.setAttachedTo(&host);
host.addAttachedCard(&guest);
EXPECT_EQ(guest.getAttachedTo(), &host);
ASSERT_EQ(host.getAttachedCards().size(), 1);
EXPECT_EQ(host.getAttachedCards().first(), &guest);
host.removeAttachedCard(&guest);
guest.setAttachedTo(nullptr);
EXPECT_TRUE(host.getAttachedCards().isEmpty());
EXPECT_EQ(guest.getAttachedTo(), nullptr);
}
TEST(CardStateTest, CountersAddUpdateAndClear)
{
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1);
int changes = 0;
QObject::connect(&card, &CardState::countersChanged, [&changes]() { ++changes; });
card.insertCounter(1, 2);
EXPECT_EQ(card.getCounters().value(1), 2);
card.setCounter(1, 3);
EXPECT_EQ(card.getCounters().value(1), 3);
card.setCounter(1, 0);
EXPECT_FALSE(card.getCounters().contains(1));
card.setCounter(2, 1);
card.clearCounters();
EXPECT_TRUE(card.getCounters().isEmpty());
EXPECT_EQ(changes, 5);
}
TEST(CardStateTest, TapFlipAttackingAndReset)
{
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1);
card.setTapped(true);
card.setFaceDown(true);
card.setAttacking(true);
card.setAnnotation(QStringLiteral("note"));
card.setPT(QStringLiteral("4/4"));
card.setDoesntUntap(true);
card.setDestroyOnZoneChange(true);
card.setColor(QStringLiteral("red"));
card.insertCounter(1, 2);
card.resetState(true);
EXPECT_FALSE(card.getTapped());
EXPECT_FALSE(card.getAttacking());
EXPECT_TRUE(card.getCounters().isEmpty());
EXPECT_TRUE(card.getPT().isEmpty());
EXPECT_FALSE(card.getDoesntUntap());
EXPECT_EQ(card.getAnnotation(), QStringLiteral("note"));
// resetState() deliberately leaves these three alone.
EXPECT_TRUE(card.getFaceDown());
EXPECT_EQ(card.getColor(), QStringLiteral("red"));
EXPECT_TRUE(card.getDestroyOnZoneChange());
card.setAnnotation(QStringLiteral("other"));
card.resetState(false);
EXPECT_TRUE(card.getAnnotation().isEmpty());
}
TEST(CardStateTest, ParsePT)
{
const QVariantList plain = CardState::parsePT(QStringLiteral("3/3"));
ASSERT_EQ(plain.size(), 2);
EXPECT_EQ(plain.at(0).toString(), QStringLiteral("3"));
EXPECT_EQ(plain.at(1).toString(), QStringLiteral("3"));
const QVariantList negative = CardState::parsePT(QStringLiteral("-1/2"));
ASSERT_EQ(negative.size(), 2);
EXPECT_EQ(negative.at(0).typeId(), QMetaType::Int);
EXPECT_EQ(negative.at(0).toInt(), -1);
EXPECT_EQ(negative.at(1).toString(), QStringLiteral("2"));
const QVariantList star = CardState::parsePT(QStringLiteral("*"));
ASSERT_EQ(star.size(), 1);
EXPECT_EQ(star.at(0).toString(), QStringLiteral("*"));
EXPECT_TRUE(CardState::parsePT(QString()).isEmpty());
}

View file

@ -0,0 +1,107 @@
#include "gtest/gtest.h"
#include <QSignalSpy>
#include <QString>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/game/zones/hand_zone_logic.h>
#include <libcockatrice/game/zones/view_zone_logic.h>
#include <libcockatrice/utility/card_ref.h>
namespace
{
CardRef cardRefFor(const QString &name)
{
CardRef cardRef;
cardRef.name = name;
return cardRef;
}
} // namespace
TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide)
{
HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7);
QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded);
QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged);
zone.addCard(&card, false, 0);
ASSERT_EQ(zone.getCards().size(), 1);
EXPECT_EQ(zone.getCards().first(), &card);
EXPECT_EQ(card.getZone(), &zone);
EXPECT_EQ(cardAddedSpy.count(), 1);
EXPECT_GE(cardCountSpy.count(), 1);
EXPECT_EQ(zone.takeCard(0, 7), &card);
EXPECT_TRUE(zone.getCards().isEmpty());
EXPECT_EQ(card.getId(), 7);
}
TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards)
{
HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true);
// The graphics side owns the view logic and registers it on the zone it
// shows; adding a card then goes through prepareAddCard and the request
// signal instead of the test emitting the signal itself.
ZoneViewZoneLogic view(nullptr, &zone, 1, true, false, false);
zone.getViews().append(&view);
QSignalSpy requestSpy(&view, &CardZoneLogic::createCardRequested);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7);
zone.addCard(&card, true, 0, 0);
ASSERT_EQ(requestSpy.count(), 1);
const QList<QVariant> arguments = requestSpy.takeFirst();
const ServerInfo_Card cardInfo = arguments.at(0).value<ServerInfo_Card>();
EXPECT_EQ(cardInfo.id(), 7);
EXPECT_EQ(cardInfo.name(), "Lightning Bolt");
EXPECT_TRUE(arguments.at(1).toBool());
EXPECT_EQ(card.getZone(), &zone);
EXPECT_EQ(zone.getCards().size(), 1);
}
TEST(CardZoneLogicTest, MoveBetweenZonesUpdatesCardZone)
{
HandZoneLogic first(nullptr, QStringLiteral("hand"), false, false, true);
HandZoneLogic second(nullptr, QStringLiteral("grave"), false, false, true);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7);
first.addCard(&card, false, 0);
ASSERT_EQ(first.getCards().size(), 1);
CardState *taken = first.takeCard(0, 7);
ASSERT_EQ(taken, &card);
EXPECT_TRUE(first.getCards().isEmpty());
second.addCard(taken, false, 0);
EXPECT_EQ(second.getCards().size(), 1);
EXPECT_EQ(card.getZone(), &second);
}
TEST(CardZoneLogicTest, GetCardFindsById)
{
HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true);
CardState first(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7);
CardState second(nullptr, nullptr, cardRefFor(QStringLiteral("Dog")), 8);
zone.addCard(&first, false, 0);
zone.addCard(&second, false, 0);
EXPECT_EQ(zone.getCard(7), &first);
EXPECT_EQ(zone.getCard(8), &second);
EXPECT_EQ(zone.getCard(99), nullptr);
}
TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd)
{
HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7);
QSignalSpy reorganizeSpy(&zone, &CardZoneLogic::reorganizeCards);
zone.addCard(&card, true, 0);
EXPECT_EQ(reorganizeSpy.count(), 1);
}

64
tests/game/data/cards.xml Normal file
View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<cockatrice_carddatabase version="4">
<cards>
<card>
<name>Cat</name>
<set>CAT</set>
<tablerow>0</tablerow>
<text>Meow!</text>
<prop>
<muid>111</muid>
<colors>G</colors>
<manacost>2G</manacost>
<cmc>2</cmc>
<type>Creature — Cat</type>
<maintype>Creature</maintype>
<pt>3/3</pt>
</prop>
</card>
<card>
<name>Grizzly Bears</name>
<set>LEA</set>
<tablerow>0</tablerow>
<cipt>1</cipt>
<text></text>
<prop>
<muid>112</muid>
<colors>G</colors>
<manacost>1G</manacost>
<cmc>2</cmc>
<type>Creature — Bear</type>
<maintype>Creature</maintype>
<pt>2/2</pt>
</prop>
</card>
<card>
<name>Lightning Bolt</name>
<set>LEA</set>
<tablerow>3</tablerow>
<text>Lightning Bolt deals 3 damage to any target.</text>
<prop>
<muid>113</muid>
<colors>R</colors>
<manacost>R</manacost>
<cmc>1</cmc>
<type>Instant</type>
<maintype>Instant</maintype>
</prop>
</card>
<card>
<name>Forest</name>
<set>LEA</set>
<tablerow>2</tablerow>
<text>(Add G.)</text>
<prop>
<muid>114</muid>
<colors>G</colors>
<manacost></manacost>
<cmc>0</cmc>
<type>Land</type>
<maintype>Land</maintype>
</prop>
</card>
</cards>
</cockatrice_carddatabase>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<cockatrice_carddatabase version="4">
<cards />
</cockatrice_carddatabase>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<cockatrice_carddatabase version="4">
<cards>
<card>
<name>Kitten</name>
<set>CAT</set>
<tablerow>0</tablerow>
<text></text>
<token>1</token>
<prop>
<muid>211</muid>
<colors>G</colors>
<manacost></manacost>
<cmc></cmc>
<type>Token</type>
<maintype>Token</maintype>
<pt>1/1</pt>
</prop>
</card>
</cards>
</cockatrice_carddatabase>

View file

@ -0,0 +1,566 @@
#include "game_test_fixture.h"
#include "gtest/gtest.h"
#include <QList>
#include <QObject>
#include <QPoint>
#include <QString>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/game/board/counter_state.h>
#include <libcockatrice/game/game_event_handler.h>
#include <libcockatrice/game/game_meta_info.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <libcockatrice/protocol/pb/context_concede.pb.h>
#include <libcockatrice/protocol/pb/event_attach_card.pb.h>
#include <libcockatrice/protocol/pb/event_change_zone_properties.pb.h>
#include <libcockatrice/protocol/pb/event_create_counter.pb.h>
#include <libcockatrice/protocol/pb/event_create_token.pb.h>
#include <libcockatrice/protocol/pb/event_del_counter.pb.h>
#include <libcockatrice/protocol/pb/event_destroy_card.pb.h>
#include <libcockatrice/protocol/pb/event_draw_cards.pb.h>
#include <libcockatrice/protocol/pb/event_flip_card.pb.h>
#include <libcockatrice/protocol/pb/event_game_closed.pb.h>
#include <libcockatrice/protocol/pb/event_game_host_changed.pb.h>
#include <libcockatrice/protocol/pb/event_game_state_changed.pb.h>
#include <libcockatrice/protocol/pb/event_kicked.pb.h>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
#include <libcockatrice/protocol/pb/event_move_card.pb.h>
#include <libcockatrice/protocol/pb/event_player_properties_changed.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_phase.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_player.pb.h>
#include <libcockatrice/protocol/pb/event_set_card_attr.pb.h>
#include <libcockatrice/protocol/pb/event_set_card_counter.pb.h>
#include <libcockatrice/protocol/pb/event_set_counter.pb.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
#include <libcockatrice/protocol/pb/game_event_context.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_counter.pb.h>
#include <libcockatrice/utility/zone_names.h>
#include <string>
namespace
{
/**
* Wraps one player-scoped event in the container the server would send and
* pushes it through the real dispatcher, so the test exercises the same
* GameEventHandler -> PlayerEventHandler path as a live client.
*/
template <typename EventType>
void sendGameEvent(GameHarness &harness,
int playerId,
const EventType &message,
const GameEventContext *context = nullptr)
{
GameEventContainer container;
container.set_game_id(42);
if (context != nullptr) {
container.mutable_context()->CopyFrom(*context);
}
GameEvent *event = container.add_event_list();
event->set_player_id(playerId);
event->MutableExtension(EventType::ext)->CopyFrom(message);
harness.game()->getGameEventHandler()->processGameEventContainer(container, harness.client(),
EventProcessingOptions());
}
Event_SetCardAttr cardAttrEvent(const char *zoneName, int cardId, CardAttribute attribute, const char *value)
{
Event_SetCardAttr event;
event.set_zone_name(zoneName);
event.set_card_id(cardId);
event.set_attribute(attribute);
event.set_attr_value(value);
return event;
}
} // namespace
TEST(GameEventHandlerTest, MoveCardMovesBetweenZones)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
Event_MoveCard event;
event.set_card_id(7);
event.set_start_player_id(1);
event.set_start_zone(ZoneNames::HAND);
event.set_position(0);
event.set_target_player_id(1);
event.set_target_zone(ZoneNames::TABLE);
event.set_x(0);
event.set_y(TableZoneLogic::tableRowToGridY(0));
event.set_new_card_id(7);
event.set_face_down(false);
sendGameEvent(harness, 1, event);
EXPECT_TRUE(harness.zone(ZoneNames::HAND)->getCards().isEmpty());
CardZoneLogic *table = harness.zone(ZoneNames::TABLE);
ASSERT_EQ(table->getCards().size(), 1);
EXPECT_EQ(table->getCards().first(), cat);
EXPECT_EQ(cat->getZone(), table);
EXPECT_EQ(cat->getId(), 7);
EXPECT_EQ(cat->getCardRef().name, QStringLiteral("Cat"));
EXPECT_FALSE(cat->getFaceDown());
EXPECT_EQ(cat->getPT(), QStringLiteral("3/3"));
EXPECT_EQ(cat->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0)));
}
TEST(GameEventHandlerTest, MoveCardFaceDownKeepsTheCardHidden)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
Event_MoveCard event;
event.set_card_id(7);
event.set_start_player_id(1);
event.set_start_zone(ZoneNames::HAND);
event.set_position(0);
event.set_target_player_id(1);
event.set_target_zone(ZoneNames::TABLE);
event.set_x(1);
event.set_y(TableZoneLogic::tableRowToGridY(2));
event.set_new_card_id(7);
event.set_face_down(true);
sendGameEvent(harness, 1, event);
ASSERT_EQ(harness.zone(ZoneNames::TABLE)->getCards().size(), 1);
EXPECT_TRUE(cat->getFaceDown());
// A face-down card keeps whatever P/T it had instead of picking up the
// printed one from the card database.
EXPECT_TRUE(cat->getPT().isEmpty());
}
TEST(GameEventHandlerTest, SetCardAttrUpdatesSingleCard)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAttacking, "1"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrPT, "5/5"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAnnotation, "note"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrColor, "red"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrDoesntUntap, "1"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrFaceDown, "1"));
EXPECT_TRUE(cat->getTapped());
EXPECT_TRUE(cat->getAttacking());
EXPECT_EQ(cat->getPT(), QStringLiteral("5/5"));
EXPECT_EQ(cat->getAnnotation(), QStringLiteral("note"));
EXPECT_EQ(cat->getColor(), QStringLiteral("red"));
EXPECT_TRUE(cat->getDoesntUntap());
EXPECT_TRUE(cat->getFaceDown());
}
TEST(GameEventHandlerTest, SetCardAttrWithoutCardIdTouchesEveryCardInZone)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(cat, nullptr);
ASSERT_NE(bears, nullptr);
Event_SetCardAttr event;
event.set_zone_name(ZoneNames::TABLE);
event.set_attribute(AttrAnnotation);
event.set_attr_value("marked");
sendGameEvent(harness, 1, event);
EXPECT_EQ(cat->getAnnotation(), QStringLiteral("marked"));
EXPECT_EQ(bears->getAnnotation(), QStringLiteral("marked"));
}
TEST(GameEventHandlerTest, UntapAllLeavesDoesntUntapCardsTapped)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(cat, nullptr);
ASSERT_NE(bears, nullptr);
cat->setDoesntUntap(true);
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 9, AttrTapped, "1"));
Event_SetCardAttr untapAll;
untapAll.set_zone_name(ZoneNames::TABLE);
untapAll.set_attribute(AttrTapped);
untapAll.set_attr_value("0");
sendGameEvent(harness, 1, untapAll);
EXPECT_TRUE(cat->getTapped());
EXPECT_FALSE(bears->getTapped());
}
TEST(GameEventHandlerTest, SetCardCounterAddsAndRemoves)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
Event_SetCardCounter add;
add.set_zone_name(ZoneNames::HAND);
add.set_card_id(7);
add.set_counter_id(1);
add.set_counter_value(2);
sendGameEvent(harness, 1, add);
EXPECT_EQ(cat->getCounters().value(1), 2);
Event_SetCardCounter remove = add;
remove.set_counter_value(0);
sendGameEvent(harness, 1, remove);
EXPECT_FALSE(cat->getCounters().contains(1));
}
TEST(GameEventHandlerTest, AttachCardLinksBothEndsAndUnattachClearsThem)
{
GameHarness harness;
CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(host, nullptr);
ASSERT_NE(guest, nullptr);
Event_AttachCard attach;
attach.set_start_zone(ZoneNames::TABLE);
attach.set_card_id(9);
attach.set_target_player_id(1);
attach.set_target_zone(ZoneNames::TABLE);
attach.set_target_card_id(7);
sendGameEvent(harness, 1, attach);
EXPECT_EQ(guest->getAttachedTo(), host);
ASSERT_EQ(host->getAttachedCards().size(), 1);
EXPECT_EQ(host->getAttachedCards().first(), guest);
Event_AttachCard detach;
detach.set_start_zone(ZoneNames::TABLE);
detach.set_card_id(9);
sendGameEvent(harness, 1, detach);
EXPECT_EQ(guest->getAttachedTo(), nullptr);
EXPECT_TRUE(host->getAttachedCards().isEmpty());
}
TEST(GameEventHandlerTest, ChangeZonePropertiesUpdatesReveal)
{
GameHarness harness;
CardZoneLogic *deck = harness.zone(ZoneNames::DECK);
ASSERT_NE(deck, nullptr);
Event_ChangeZoneProperties reveal;
reveal.set_zone_name(ZoneNames::DECK);
reveal.set_always_reveal_top_card(true);
sendGameEvent(harness, 1, reveal);
EXPECT_TRUE(deck->getAlwaysRevealTopCard());
reveal.set_always_reveal_top_card(false);
sendGameEvent(harness, 1, reveal);
EXPECT_FALSE(deck->getAlwaysRevealTopCard());
}
TEST(GameEventHandlerTest, DrawCardsMovesNamedCardsFromDeckToHand)
{
GameHarness harness;
ASSERT_NE(harness.addCardToZone(ZoneNames::DECK, QStringLiteral("Cat"), 7), nullptr);
Event_DrawCards draw;
draw.set_number(1);
ServerInfo_Card *cardInfo = draw.add_cards();
cardInfo->set_id(7);
cardInfo->set_name("Cat");
sendGameEvent(harness, 1, draw);
EXPECT_TRUE(harness.zone(ZoneNames::DECK)->getCards().isEmpty());
ASSERT_EQ(harness.zone(ZoneNames::HAND)->getCards().size(), 1);
CardState *drawn = harness.zone(ZoneNames::HAND)->getCards().first();
EXPECT_EQ(drawn->getId(), 7);
EXPECT_EQ(drawn->getName(), QStringLiteral("Cat"));
}
TEST(GameEventHandlerTest, FlipCardHidesAndRevealsTheCard)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
Event_FlipCard flip;
flip.set_zone_name(ZoneNames::TABLE);
flip.set_card_id(7);
flip.set_face_down(true);
sendGameEvent(harness, 1, flip);
EXPECT_TRUE(cat->getFaceDown());
// Flipping face down does not touch the card reference: the client must not
// learn (or overwrite) the name of a card it is not allowed to see.
EXPECT_EQ(cat->getName(), QStringLiteral("Cat"));
// Turning it face up carries the real printing, so the state picks it up.
flip.set_face_down(false);
flip.set_card_name("Grizzly Bears");
sendGameEvent(harness, 1, flip);
EXPECT_FALSE(cat->getFaceDown());
EXPECT_EQ(cat->getName(), QStringLiteral("Grizzly Bears"));
}
TEST(GameEventHandlerTest, DestroyCardRemovesItAndDetachesAttachedCards)
{
GameHarness harness;
CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(host, nullptr);
ASSERT_NE(guest, nullptr);
guest->setAttachedTo(host);
ASSERT_EQ(guest->getAttachedTo(), host);
ASSERT_EQ(host->getAttachedCards().size(), 1);
Event_DestroyCard destroy;
destroy.set_zone_name(ZoneNames::TABLE);
destroy.set_card_id(7);
sendGameEvent(harness, 1, destroy);
CardZoneLogic *table = harness.zone(ZoneNames::TABLE);
ASSERT_EQ(table->getCards().size(), 1);
EXPECT_EQ(table->getCards().first(), guest);
EXPECT_FALSE(table->getCards().contains(host));
// The attached card survives the host and is left un-attached, ready to be
// laid out again by the zone.
EXPECT_EQ(guest->getAttachedTo(), nullptr);
EXPECT_TRUE(host->getAttachedCards().isEmpty());
EXPECT_EQ(guest->getZone(), table);
}
TEST(GameEventHandlerTest, CreateTokenUsesDatabasePTUnlessFaceDown)
{
GameHarness harness;
CardZoneLogic *table = harness.zone(ZoneNames::TABLE);
ASSERT_NE(table, nullptr);
QList<ServerInfo_Card> created;
QObject::connect(table, &CardZoneLogic::createCardRequested, table,
[&created](const ServerInfo_Card &cardInfo, bool) { created.append(cardInfo); });
Event_CreateToken token;
token.set_zone_name(ZoneNames::TABLE);
token.set_card_id(11);
token.set_card_name("Kitten");
token.set_x(2);
token.set_y(1);
token.set_color("green");
token.set_annotation("a kitten");
token.set_destroy_on_zone_change(true);
sendGameEvent(harness, 1, token);
ASSERT_EQ(created.size(), 1);
// The event carries no P/T, so the token takes the printed one.
EXPECT_EQ(created.first().pt(), "1/1");
EXPECT_EQ(created.first().name(), "Kitten");
EXPECT_EQ(created.first().x(), 2);
EXPECT_EQ(created.first().y(), 1);
EXPECT_EQ(created.first().color(), "green");
EXPECT_EQ(created.first().annotation(), "a kitten");
EXPECT_TRUE(created.first().destroy_on_zone_change());
EXPECT_FALSE(created.first().face_down());
// An explicit P/T always wins over the database.
Event_CreateToken explicitPt = token;
explicitPt.set_card_id(12);
explicitPt.set_pt("3/3");
sendGameEvent(harness, 1, explicitPt);
ASSERT_EQ(created.size(), 2);
EXPECT_EQ(created.last().pt(), "3/3");
// A face-down token must not leak the printed P/T.
Event_CreateToken faceDown = token;
faceDown.set_card_id(13);
faceDown.set_face_down(true);
sendGameEvent(harness, 1, faceDown);
ASSERT_EQ(created.size(), 3);
EXPECT_TRUE(created.last().face_down());
EXPECT_TRUE(created.last().pt().empty());
}
TEST(GameEventHandlerTest, ConcedeAndUnconcedeUpdatePlayerState)
{
GameHarness harness;
ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr);
EXPECT_FALSE(harness.player()->getConceded());
Event_PlayerPropertiesChanged properties;
properties.mutable_player_properties()->set_player_id(1);
properties.mutable_player_properties()->set_conceded(true);
GameEventContext concedeContext;
concedeContext.MutableExtension(Context_Concede::ext);
sendGameEvent(harness, 1, properties, &concedeContext);
EXPECT_TRUE(harness.player()->getConceded());
// Conceding tears the board down for that player.
EXPECT_TRUE(harness.zone(ZoneNames::TABLE)->getCards().isEmpty());
Event_PlayerPropertiesChanged back = properties;
back.mutable_player_properties()->set_conceded(false);
GameEventContext unconcedeContext;
unconcedeContext.MutableExtension(Context_Unconcede::ext);
sendGameEvent(harness, 1, back, &unconcedeContext);
EXPECT_FALSE(harness.player()->getConceded());
}
TEST(GameEventHandlerTest, SetActivePlayerAndPhaseUpdateGameState)
{
GameHarness harness;
GameState *state = harness.game()->getGameState();
ASSERT_NE(state, nullptr);
EXPECT_EQ(state->getActivePlayer(), -1);
EXPECT_EQ(state->getCurrentPhase(), -1);
Event_SetActivePlayer setPlayer;
setPlayer.set_active_player_id(1);
sendGameEvent(harness, 1, setPlayer);
EXPECT_EQ(state->getActivePlayer(), 1);
Event_SetActivePhase setPhase;
setPhase.set_phase(3);
sendGameEvent(harness, 1, setPhase);
EXPECT_EQ(state->getCurrentPhase(), 3);
}
TEST(GameEventHandlerTest, CreateSetAndDeletePlayerCounter)
{
GameHarness harness;
ServerInfo_Counter counter;
counter.set_id(1);
counter.set_name("life");
counter.set_radius(1);
counter.set_count(20);
Event_CreateCounter create;
create.mutable_counter_info()->CopyFrom(counter);
sendGameEvent(harness, 1, create);
CounterState *state = harness.player()->getCounters().value(1);
ASSERT_NE(state, nullptr);
EXPECT_EQ(state->getName(), QStringLiteral("life"));
EXPECT_EQ(state->getValue(), 20);
EXPECT_EQ(harness.player()->getLifeCounter(), state);
Event_SetCounter set;
set.set_counter_id(1);
set.set_value(15);
sendGameEvent(harness, 1, set);
EXPECT_EQ(state->getValue(), 15);
// Updating a counter that does not exist is a no-op, not a crash.
Event_SetCounter unknown = set;
unknown.set_counter_id(99);
sendGameEvent(harness, 1, unknown);
EXPECT_FALSE(harness.player()->getCounters().contains(99));
Event_DelCounter remove;
remove.set_counter_id(1);
sendGameEvent(harness, 1, remove);
EXPECT_FALSE(harness.player()->getCounters().contains(1));
EXPECT_EQ(harness.player()->getLifeCounter(), nullptr);
}
TEST(GameEventHandlerTest, LeaveRemovesThePlayer)
{
GameHarness harness;
ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr);
ASSERT_NE(harness.game()->getPlayerManager()->getPlayer(1), nullptr);
Event_Leave leave;
leave.set_reason(Event_Leave::USER_LEFT);
sendGameEvent(harness, 1, leave);
EXPECT_EQ(harness.game()->getPlayerManager()->getPlayer(1), nullptr);
EXPECT_TRUE(harness.game()->getPlayerManager()->getPlayers().isEmpty());
}
TEST(GameEventHandlerTest, KickedSignalsGameClosed)
{
GameHarness harness;
bool closed = false;
QObject::connect(harness.game()->getGameEventHandler(), &GameEventHandler::gameClosed,
[&closed] { closed = true; });
sendGameEvent(harness, 1, Event_Kicked());
EXPECT_TRUE(closed);
}
TEST(GameEventHandlerTest, GameClosedMarksTheSessionClosed)
{
GameHarness harness;
ASSERT_FALSE(harness.game()->getGameState()->isGameClosed());
Event_GameStateChanged started;
started.set_game_started(true);
sendGameEvent(harness, 1, started);
ASSERT_TRUE(harness.game()->getGameMetaInfo()->started());
sendGameEvent(harness, 1, Event_GameClosed());
EXPECT_TRUE(harness.game()->getGameState()->isGameClosed());
EXPECT_FALSE(harness.game()->getGameMetaInfo()->started());
}
TEST(GameEventHandlerTest, GameHostChangedMovesTheHostRole)
{
GameHarness harness;
ServerInfo_User bob;
bob.set_name("Bob");
bob.set_user_level(ServerInfo_User::IsUser);
ASSERT_NE(harness.game()->getPlayerManager()->addPlayer(2, bob), nullptr);
sendGameEvent(harness, 2, Event_GameHostChanged());
EXPECT_EQ(harness.game()->getGameState()->getHostId(), 2);
}

View file

@ -1,106 +0,0 @@
/**
* @file game_smoke_test.cpp
*
* Links the game logic library into a target that contains no graphics code at all, and drives
* the two seams the graphics layer used to own: the attachment graph on CardState and the
* card-creation request a zone makes so the graphics side can build the matching card item.
*/
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QLoggingCategory>
#include <QSignalSpy>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/game/zones/hand_zone_logic.h>
namespace
{
CardRef cardRefFor(const QString &name)
{
CardRef cardRef;
cardRef.name = name;
return cardRef;
}
} // namespace
TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent)
{
CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1);
CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2);
guest.setAttachedTo(&host);
host.addAttachedCard(&guest);
EXPECT_EQ(guest.getAttachedTo(), &host);
ASSERT_EQ(host.getAttachedCards().size(), 1);
EXPECT_EQ(host.getAttachedCards().first(), &guest);
host.removeAttachedCard(&guest);
guest.setAttachedTo(nullptr);
EXPECT_TRUE(host.getAttachedCards().isEmpty());
EXPECT_EQ(guest.getAttachedTo(), nullptr);
}
TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide)
{
HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7);
QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded);
QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged);
zone.addCard(&card, false, 0);
ASSERT_EQ(zone.getCards().size(), 1);
EXPECT_EQ(zone.getCards().first(), &card);
EXPECT_EQ(card.getZone(), &zone);
EXPECT_EQ(cardAddedSpy.count(), 1);
EXPECT_GE(cardCountSpy.count(), 1);
EXPECT_EQ(zone.takeCard(0, 7), &card);
EXPECT_TRUE(zone.getCards().isEmpty());
EXPECT_EQ(card.getId(), 7);
}
TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards)
{
HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true);
QSignalSpy requestSpy(&zone, &CardZoneLogic::createCardRequested);
ServerInfo_Card cardInfo;
cardInfo.set_name("Lightning Bolt");
cardInfo.set_id(7);
cardInfo.set_x(0);
cardInfo.set_y(1);
zone.createCardRequested(cardInfo, true);
ASSERT_EQ(requestSpy.count(), 1);
const QList<QVariant> arguments = requestSpy.takeFirst();
EXPECT_EQ(arguments.at(0).value<ServerInfo_Card>().id(), 7);
EXPECT_TRUE(arguments.at(1).toBool());
}
int main(int argc, char **argv)
{
// Keep SettingsCache reads and writes away from the real user profile: the game logic still
// reaches for a handful of settings, and the client settings sources linked into this test
// would otherwise touch the developer's own configuration.
QStandardPaths::setTestModeEnabled(true);
QTemporaryDir home;
if (home.isValid()) {
qputenv("HOME", home.path().toLocal8Bit());
}
QCoreApplication app(argc, argv);
QLoggingCategory::setFilterRules("settings_cache.*=false");
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,129 @@
/**
* @file game_test_fixture.h
*
* Fixtures for tests that need a complete game/player/zone graph without the
* client, a server or any graphics object. Commands are captured on a recording
* AbstractClient, which is the same seam the real client uses.
*/
#ifndef COCKATRICE_TESTS_GAME_TEST_FIXTURE_H
#define COCKATRICE_TESTS_GAME_TEST_FIXTURE_H
#include <QList>
#include <QMap>
#include <QString>
#include <QtAlgorithms>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/game/game.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/event_game_joined.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/utility/card_ref.h>
#include <memory>
#include <utility>
/**
* Captures every command the game logic sends, instead of writing it to a socket.
*
* AbstractClient::sendCommand uses a direct signal/slot connection in the same
* thread, so a command is in `sent` as soon as sendCommand returns.
*/
class RecordingClient : public AbstractClient
{
public:
QList<CommandContainer> sent;
protected:
void sendCommandContainer(const CommandContainer &cont) override
{
sent.append(cont);
}
};
/**
* Owns a local, single-client Game with one local player, plus the cards added
* to its zones.
*/
class GameHarness
{
public:
explicit GameHarness(int playerId = 1) : playerId_(playerId), client_(std::make_unique<RecordingClient>())
{
Event_GameJoined event;
event.set_host_id(playerId_);
event.set_player_id(playerId_);
event.mutable_game_info()->set_game_id(42);
event.mutable_game_info()->set_started(true);
QList<AbstractClient *> clients{client_.get()};
QMap<int, QString> roomGameTypes;
game_ = std::make_unique<Game>(nullptr, true, clients, event, roomGameTypes);
ServerInfo_User user;
user.set_name("Alice");
user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered);
game_->getPlayerManager()->addPlayer(playerId_, user);
}
~GameHarness()
{
qDeleteAll(ownedCards_);
ownedCards_.clear();
}
GameHarness(const GameHarness &) = delete;
GameHarness &operator=(const GameHarness &) = delete;
[[nodiscard]] Game *game() const
{
return game_.get();
}
[[nodiscard]] RecordingClient *client() const
{
return client_.get();
}
[[nodiscard]] PlayerLogic *player() const
{
return game_->getPlayerManager()->getPlayer(playerId_);
}
[[nodiscard]] CardZoneLogic *zone(const QString &zoneName) const
{
return player()->getZones().value(zoneName);
}
CardState *addCardToZone(const QString &zoneName, const QString &cardName, int cardId)
{
CardZoneLogic *targetZone = zone(zoneName);
if (!targetZone) {
return nullptr;
}
auto *card = new CardState(nullptr, targetZone, CardRef{cardName, QString()}, cardId);
// The constructor only stores the ref; this is what fills in the card
// data (table row, P/T, ...) the actions read.
card->refreshCardInfo();
targetZone->addCard(card, false, 0);
ownedCards_.append(card);
return card;
}
[[nodiscard]] const CommandContainer &lastCommand() const
{
return client_->sent.last();
}
private:
int playerId_;
std::unique_ptr<RecordingClient> client_;
std::unique_ptr<Game> game_;
QList<CardState *> ownedCards_;
};
#endif // COCKATRICE_TESTS_GAME_TEST_FIXTURE_H

View file

@ -0,0 +1,126 @@
/**
* @file game_test_main.cpp
*
* Test entry point for the headless game-logic suite. The game library has no
* graphics dependency, so a QCoreApplication is enough; the only process-wide
* setup left is the card database, which the game logic still reaches through
* CardDatabaseManager and which therefore has to exist before the first zone is
* constructed.
*/
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QLoggingCategory>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <cstdio>
#include <cstdlib>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/interfaces/interface_card_database_path_provider.h>
namespace
{
class GameTestPathProvider : public ICardDatabasePathProvider
{
public:
explicit GameTestPathProvider(QString baseDir) : baseDir_(std::move(baseDir))
{
}
QString getCardDatabasePath() const override
{
return baseDir_ + QStringLiteral("/cards.xml");
}
QString getCustomCardDatabasePath() const override
{
return baseDir_ + QStringLiteral("/customsets/");
}
QString getTokenDatabasePath() const override
{
return baseDir_ + QStringLiteral("/tokens.xml");
}
QString getSpoilerCardDatabasePath() const override
{
return baseDir_ + QStringLiteral("/spoilers.xml");
}
private:
QString baseDir_;
};
bool copyFixture(const QString &fileName, const QString &targetDir)
{
const QString source = QString::fromUtf8(GAME_TEST_DATA_DIR) + QLatin1Char('/') + fileName;
const QString target = targetDir + QLatin1Char('/') + fileName;
if (QFile::copy(source, target)) {
return true;
}
std::fprintf(stderr, "Failed to copy test fixture %s to %s\n", qPrintable(source), qPrintable(target));
return false;
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
// gtest_discover_tests runs the binary with --gtest_list_tests while the
// build is still in progress. No test runs there, and building the card
// database would leave process-lifetime allocations that the sanitizer
// leg's default leak detection reports at exit, failing discovery.
if (::testing::GTEST_FLAG(list_tests)) {
return RUN_ALL_TESTS();
}
QStandardPaths::setTestModeEnabled(true);
QTemporaryDir home;
if (!home.isValid()) {
std::fprintf(stderr, "Failed to create a temporary home directory\n");
return EXIT_FAILURE;
}
qputenv("HOME", home.path().toLocal8Bit());
QCoreApplication app(argc, argv);
QLoggingCategory::setFilterRules(QStringLiteral("card_database.loading=false\n"
"card_database.loading.success_or_failure=false\n"
"settings_cache.*=false"));
// Copy the fixtures so the database loader never writes its .cache file into
// the source tree, then point the singleton at the copy.
QTemporaryDir dataDir;
if (!dataDir.isValid() || !QDir().mkpath(dataDir.filePath(QStringLiteral("customsets")))) {
std::fprintf(stderr, "Failed to create a temporary card database directory\n");
return EXIT_FAILURE;
}
if (!copyFixture(QStringLiteral("cards.xml"), dataDir.path()) ||
!copyFixture(QStringLiteral("tokens.xml"), dataDir.path()) ||
!copyFixture(QStringLiteral("spoilers.xml"), dataDir.path())) {
return EXIT_FAILURE;
}
// Function-local static: constructed before the CardDatabase singleton below,
// so it is destroyed after it and the raw pointer stored in the manager stays
// valid for the database's lifetime.
static GameTestPathProvider pathProvider(dataDir.path());
CardDatabaseManager::setCardDatabasePathProvider(&pathProvider);
CardDatabase *database = CardDatabaseManager::getInstance();
database->loadCardDatabases();
if (database->getLoadStatus() != Ok) {
std::fprintf(stderr, "Failed to load the game test card database (status %d)\n",
static_cast<int>(database->getLoadStatus()));
return EXIT_FAILURE;
}
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,119 @@
#include "game_test_fixture.h"
#include "gtest/gtest.h"
#include <QString>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/utility/zone_names.h>
#include <string>
TEST(PlayerActionsTest, PlayCreatureSendsMoveToTable)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
harness.player()->getPlayerActions()->playCard(cat, false);
ASSERT_FALSE(harness.client()->sent.isEmpty());
const CommandContainer &container = harness.lastCommand();
ASSERT_EQ(container.game_command_size(), 1);
const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext);
EXPECT_EQ(command.start_player_id(), 1);
EXPECT_EQ(command.start_zone(), std::string(ZoneNames::HAND));
EXPECT_EQ(command.target_player_id(), 1);
EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE));
EXPECT_EQ(command.x(), -1);
EXPECT_EQ(command.y(), TableZoneLogic::tableRowToGridY(0));
ASSERT_EQ(command.cards_to_move().card_size(), 1);
const CardToMove &card = command.cards_to_move().card(0);
EXPECT_EQ(card.card_id(), 7);
EXPECT_TRUE(card.has_face_down());
EXPECT_FALSE(card.face_down());
EXPECT_TRUE(card.has_pt());
EXPECT_EQ(card.pt(), "3/3");
EXPECT_TRUE(card.has_tapped());
EXPECT_FALSE(card.tapped());
}
TEST(PlayerActionsTest, PlayInstantSendsMoveToStack)
{
GameHarness harness;
CardState *bolt = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Lightning Bolt"), 8);
ASSERT_NE(bolt, nullptr);
harness.player()->getPlayerActions()->playCard(bolt, false);
ASSERT_FALSE(harness.client()->sent.isEmpty());
const CommandContainer &container = harness.lastCommand();
ASSERT_EQ(container.game_command_size(), 1);
const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext);
EXPECT_EQ(command.target_zone(), std::string(ZoneNames::STACK));
EXPECT_EQ(command.x(), -1);
EXPECT_EQ(command.y(), 0);
ASSERT_EQ(command.cards_to_move().card_size(), 1);
const CardToMove &card = command.cards_to_move().card(0);
EXPECT_EQ(card.card_id(), 8);
// The stack branch deliberately leaves the other attributes unset.
EXPECT_FALSE(card.has_face_down());
EXPECT_FALSE(card.has_pt());
EXPECT_FALSE(card.has_tapped());
}
TEST(PlayerActionsTest, PlayFaceDownCreatureUsesFaceDownRow)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
harness.player()->getPlayerActions()->playCard(cat, true);
ASSERT_FALSE(harness.client()->sent.isEmpty());
const CommandContainer &container = harness.lastCommand();
ASSERT_EQ(container.game_command_size(), 1);
const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext);
EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE));
EXPECT_EQ(command.x(), -1);
EXPECT_EQ(command.y(), TableZoneLogic::tableRowToGridY(2));
ASSERT_EQ(command.cards_to_move().card_size(), 1);
const CardToMove &card = command.cards_to_move().card(0);
EXPECT_EQ(card.card_id(), 7);
EXPECT_TRUE(card.has_face_down());
EXPECT_TRUE(card.face_down());
EXPECT_FALSE(card.has_pt());
EXPECT_TRUE(card.has_tapped());
EXPECT_FALSE(card.tapped());
}
TEST(PlayerActionsTest, PlayCiptCreatureSendsTapped)
{
GameHarness harness;
CardState *bears = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(bears, nullptr);
harness.player()->getPlayerActions()->playCard(bears, false);
ASSERT_FALSE(harness.client()->sent.isEmpty());
const CommandContainer &container = harness.lastCommand();
ASSERT_EQ(container.game_command_size(), 1);
const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext);
EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE));
ASSERT_EQ(command.cards_to_move().card_size(), 1);
const CardToMove &card = command.cards_to_move().card(0);
EXPECT_EQ(card.card_id(), 9);
EXPECT_TRUE(card.has_face_down());
EXPECT_FALSE(card.face_down());
EXPECT_TRUE(card.has_pt());
EXPECT_EQ(card.pt(), "2/2");
EXPECT_TRUE(card.has_tapped());
EXPECT_TRUE(card.tapped());
}

349
tests/game/replay_test.cpp Normal file
View file

@ -0,0 +1,349 @@
/**
* @file replay_test.cpp
*
* Replays a synthetic GameReplay through the real dispatcher. This is the only
* place that exercises PlayerLogic::processPlayerInfo / processCardAttachment
* end to end: the game state arrives as a GAME_STATE_CHANGED event and the
* graphics side is replaced by a small materializer that mirrors what
* CardZone::onCreateCardRequested does with the logic objects.
*
* The replay is built in code rather than from a .cor file: event application
* is deterministic (the client never shuffles on its own for a replay), and a
* checked-in binary fixture would be opaque about what it asserts.
*/
#include "gtest/gtest.h"
#include <QList>
#include <QObject>
#include <QString>
#include <QtAlgorithms>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/game/game_event_handler.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/player/event_processing_options.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
#include <libcockatrice/game/replay.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <libcockatrice/protocol/pb/event_game_state_changed.pb.h>
#include <libcockatrice/protocol/pb/event_move_card.pb.h>
#include <libcockatrice/protocol/pb/event_set_card_attr.pb.h>
#include <libcockatrice/protocol/pb/event_set_card_counter.pb.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
#include <libcockatrice/protocol/pb/game_replay.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_player.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_zone.pb.h>
#include <libcockatrice/utility/card_ref.h>
#include <libcockatrice/utility/zone_names.h>
#include <memory>
#include <string>
#include <utility>
namespace
{
ServerInfo_Card makeCard(int id, const QString &name, int x = -1, int y = -1)
{
ServerInfo_Card card;
card.set_id(id);
card.set_name(name.toStdString());
card.set_x(x);
card.set_y(y);
return card;
}
ServerInfo_Zone makeZone(const char *name,
ServerInfo_Zone::ZoneType type,
bool withCoords,
const QList<ServerInfo_Card> &cards,
int cardCount = 0)
{
ServerInfo_Zone zone;
zone.set_name(name);
zone.set_type(type);
zone.set_with_coords(withCoords);
for (const auto &card : cards) {
zone.add_card_list()->CopyFrom(card);
}
if (cardCount > 0) {
zone.set_card_count(cardCount);
}
return zone;
}
ServerInfo_Player makePlayer(int playerId, const QString &name, const QList<ServerInfo_Zone> &zones)
{
ServerInfo_Player player;
ServerInfo_PlayerProperties *properties = player.mutable_properties();
properties->set_player_id(playerId);
properties->mutable_user_info()->set_name(name.toStdString());
properties->mutable_user_info()->set_user_level(ServerInfo_User::IsUser);
for (const auto &zone : zones) {
player.add_zone_list()->CopyFrom(zone);
}
return player;
}
template <typename EventType> GameEventContainer makeContainer(int playerId, const EventType &message)
{
GameEventContainer container;
container.set_game_id(42);
GameEvent *event = container.add_event_list();
event->set_player_id(playerId);
event->MutableExtension(EventType::ext)->CopyFrom(message);
return container;
}
/**
* Stands in for the graphics side of a replay: when the logic asks for a card
* item, create the CardState and add it to the zone, copying the state fields
* the real CardItem::processCardInfo applies.
*
* The production view owns the visual item; the state itself is all a headless
* test needs, and this is the smallest thing that lets processPlayerInfo build
* a real zone.
*/
class HeadlessZoneView : public QObject
{
public:
using QObject::QObject;
~HeadlessZoneView() override
{
qDeleteAll(cards_);
cards_.clear();
}
void attach(PlayerLogic *player)
{
for (CardZoneLogic *zone : player->getZones()) {
connect(zone, &CardZoneLogic::createCardRequested, this,
[this, zone](const ServerInfo_Card &cardInfo, bool reorganize) {
materialize(zone, cardInfo, reorganize);
});
}
}
private:
void materialize(CardZoneLogic *zone, const ServerInfo_Card &info, bool reorganize)
{
const CardRef cardRef{QString::fromStdString(info.name()), QString::fromStdString(info.provider_id())};
auto *card = new CardState(nullptr, zone, cardRef, info.id());
// The real CardItem constructor refreshes the card info before
// processCardInfo overwrites the state fields.
card->refreshCardInfo();
card->clearCounters();
for (int i = 0; i < info.counter_list_size(); ++i) {
card->insertCounter(info.counter_list(i).id(), info.counter_list(i).value());
}
card->setAttacking(info.attacking());
card->setFaceDown(info.face_down());
card->setPT(QString::fromStdString(info.pt()));
card->setAnnotation(QString::fromStdString(info.annotation()));
card->setColor(QString::fromStdString(info.color()));
card->setTapped(info.tapped());
card->setDestroyOnZoneChange(info.destroy_on_zone_change());
card->setDoesntUntap(info.doesnt_untap());
zone->addCard(card, reorganize, info.x(), info.y());
cards_.append(card);
}
QList<CardState *> cards_;
};
/** Owns a Replay built from a GameReplay and feeds its containers through it. */
class ReplayHarness
{
public:
explicit ReplayHarness(GameReplay replay)
: replayProto_(std::move(replay)), view_(std::make_unique<HeadlessZoneView>()),
replay_(std::make_unique<Replay>(nullptr, &replayProto_, false))
{
QObject::connect(replay_->getPlayerManager(), &PlayerManager::playerAdded, view_.get(),
&HeadlessZoneView::attach);
}
ReplayHarness(const ReplayHarness &) = delete;
ReplayHarness &operator=(const ReplayHarness &) = delete;
void processAll()
{
for (const GameEventContainer &container : replayProto_.event_list()) {
replay_->getGameEventHandler()->processGameEventContainer(container, nullptr, EventProcessingOptions());
}
}
[[nodiscard]] Replay *replay() const
{
return replay_.get();
}
[[nodiscard]] PlayerLogic *player(int playerId) const
{
return replay_->getPlayerManager()->getPlayer(playerId);
}
private:
GameReplay replayProto_;
std::unique_ptr<HeadlessZoneView> view_;
std::unique_ptr<Replay> replay_;
};
GameReplay makeReplay(const QList<GameEventContainer> &containers)
{
GameReplay replay;
replay.mutable_game_info()->set_game_id(42);
replay.mutable_game_info()->set_started(true);
for (const auto &container : containers) {
replay.add_event_list()->CopyFrom(container);
}
return replay;
}
Event_GameStateChanged initialGameState()
{
Event_GameStateChanged state;
state.set_game_started(true);
state.set_active_player_id(1);
state.set_active_phase(0);
return state;
}
} // namespace
TEST(ReplayTest, MaterializesGameStateZonesAndAttachments)
{
Event_GameStateChanged state = initialGameState();
ServerInfo_Card cat = makeCard(7, QStringLiteral("Cat"));
// Cross-player attachment: Alice's card is attached to Bob's creature.
cat.set_attach_player_id(2);
cat.set_attach_zone(ZoneNames::TABLE);
cat.set_attach_card_id(9);
const ServerInfo_Player alice = makePlayer(1, QStringLiteral("Alice"),
{makeZone(ZoneNames::HAND, ServerInfo_Zone::PrivateZone, false, {cat}),
makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true,
{makeCard(9, QStringLiteral("Grizzly Bears"), 2, 1)}),
makeZone(ZoneNames::DECK, ServerInfo_Zone::HiddenZone, false, {}, 2)});
const ServerInfo_Player bob = makePlayer(2, QStringLiteral("Bob"),
{makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true,
{makeCard(9, QStringLiteral("Grizzly Bears"), 1, 0)})});
state.add_player_list()->CopyFrom(alice);
state.add_player_list()->CopyFrom(bob);
ReplayHarness harness(makeReplay({makeContainer(-1, state)}));
harness.processAll();
PlayerLogic *aliceLogic = harness.player(1);
PlayerLogic *bobLogic = harness.player(2);
ASSERT_NE(aliceLogic, nullptr);
ASSERT_NE(bobLogic, nullptr);
CardZoneLogic *hand = aliceLogic->getZones().value(ZoneNames::HAND);
CardZoneLogic *table = aliceLogic->getZones().value(ZoneNames::TABLE);
CardZoneLogic *deck = aliceLogic->getZones().value(ZoneNames::DECK);
ASSERT_NE(hand, nullptr);
ASSERT_NE(table, nullptr);
ASSERT_NE(deck, nullptr);
ASSERT_EQ(hand->getCards().size(), 1);
CardState *catState = hand->getCards().first();
EXPECT_EQ(catState->getId(), 7);
EXPECT_EQ(catState->getName(), QStringLiteral("Cat"));
ASSERT_EQ(table->getCards().size(), 1);
CardState *host = table->getCards().first();
EXPECT_EQ(host->getName(), QStringLiteral("Grizzly Bears"));
EXPECT_EQ(host->getGridPoint(), QPoint(2, 1));
// The hidden deck only carries a count, and the view materializes that many
// unknown cards.
EXPECT_EQ(deck->getCards().size(), 2);
for (const CardState *hidden : deck->getCards()) {
EXPECT_EQ(hidden->getId(), -1);
}
// processCardAttachment links both sides after every player is materialized.
CardZoneLogic *bobTable = bobLogic->getZones().value(ZoneNames::TABLE);
ASSERT_NE(bobTable, nullptr);
ASSERT_EQ(bobTable->getCards().size(), 1);
CardState *bobHost = bobTable->getCards().first();
EXPECT_EQ(catState->getAttachedTo(), bobHost);
ASSERT_EQ(bobHost->getAttachedCards().size(), 1);
EXPECT_EQ(bobHost->getAttachedCards().first(), catState);
// The identically numbered card on Alice's own table is a different card.
EXPECT_TRUE(host->getAttachedCards().isEmpty());
EXPECT_TRUE(harness.replay()->getGameState()->isGameStateKnown());
}
TEST(ReplayTest, AppliesMovesAttributesAndCountersInOrder)
{
Event_GameStateChanged state = initialGameState();
state.add_player_list()->CopyFrom(makePlayer(
1, QStringLiteral("Alice"),
{makeZone(ZoneNames::HAND, ServerInfo_Zone::PrivateZone, false, {makeCard(7, QStringLiteral("Cat"))}),
makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true,
{makeCard(9, QStringLiteral("Grizzly Bears"), 0, TableZoneLogic::tableRowToGridY(0))}),
makeZone(ZoneNames::DECK, ServerInfo_Zone::HiddenZone, false, {}, 2)}));
Event_MoveCard move;
move.set_card_id(7);
move.set_start_player_id(1);
move.set_start_zone(ZoneNames::HAND);
move.set_position(0);
move.set_target_player_id(1);
move.set_target_zone(ZoneNames::TABLE);
move.set_x(3);
move.set_y(TableZoneLogic::tableRowToGridY(1));
move.set_new_card_id(7);
move.set_face_down(false);
Event_SetCardAttr tap;
tap.set_zone_name(ZoneNames::TABLE);
tap.set_card_id(7);
tap.set_attribute(AttrTapped);
tap.set_attr_value("1");
Event_SetCardCounter counter;
counter.set_zone_name(ZoneNames::TABLE);
counter.set_card_id(7);
counter.set_counter_id(1);
counter.set_counter_value(2);
ReplayHarness harness(makeReplay(
{makeContainer(-1, state), makeContainer(1, move), makeContainer(1, tap), makeContainer(1, counter)}));
harness.processAll();
PlayerLogic *alice = harness.player(1);
ASSERT_NE(alice, nullptr);
CardZoneLogic *hand = alice->getZones().value(ZoneNames::HAND);
CardZoneLogic *table = alice->getZones().value(ZoneNames::TABLE);
ASSERT_NE(hand, nullptr);
ASSERT_NE(table, nullptr);
EXPECT_TRUE(hand->getCards().isEmpty());
ASSERT_EQ(table->getCards().size(), 2);
CardState *moved = table->getCard(7);
ASSERT_NE(moved, nullptr);
EXPECT_EQ(moved->getGridPoint(), QPoint(3, TableZoneLogic::tableRowToGridY(1)));
EXPECT_EQ(moved->getPT(), QStringLiteral("3/3"));
EXPECT_TRUE(moved->getTapped());
EXPECT_EQ(moved->getCounters().value(1), 2);
// The untouched creature keeps its own position and state.
CardState *bears = table->getCard(9);
ASSERT_NE(bears, nullptr);
EXPECT_EQ(bears->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0)));
EXPECT_FALSE(bears->getTapped());
}

View file

@ -0,0 +1,3 @@
cockatrice_add_test(
NAME icon_assets_test SOURCES icon_assets_test.cpp DEFINITIONS COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
)

View file

@ -0,0 +1,241 @@
#include <QColor>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QSet>
#include <QString>
#include <QStringList>
#include <QXmlStreamReader>
#include <gtest/gtest.h>
namespace
{
const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons");
QString iconsDir()
{
return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH);
}
/** @brief True for content art that is allowed to carry full color. */
bool isContentArt(const QString &relativePath)
{
return relativePath.startsWith(QStringLiteral("mana/"));
}
QString violation(const QString &rule, const QString &relativePath)
{
return rule + QLatin1Char(' ') + relativePath;
}
/**
* @brief Reads one SVG and returns true when the root carries a usable viewBox.
*
* Also reports malformed XML and editor-only metadata through the out params.
*/
bool inspectSvg(const QString &absolutePath,
bool &validXml,
bool &hasViewBox,
bool &hasEditorMetadata,
bool &hasPhysicalSize)
{
validXml = false;
hasViewBox = false;
hasEditorMetadata = false;
hasPhysicalSize = false;
QFile file(absolutePath);
if (!file.open(QIODevice::ReadOnly)) {
return false;
}
QXmlStreamReader reader(&file);
bool sawRoot = false;
while (!reader.atEnd()) {
reader.readNext();
if (reader.hasError()) {
return false;
}
if (!reader.isStartElement()) {
continue;
}
if (!sawRoot) {
sawRoot = true;
if (reader.name() != QStringLiteral("svg")) {
return false;
}
const QString viewBox = reader.attributes().value(QStringLiteral("viewBox")).toString().simplified();
hasViewBox = viewBox == QStringLiteral("0 0 24 24");
hasPhysicalSize = !reader.attributes().value(QStringLiteral("width")).isEmpty() ||
!reader.attributes().value(QStringLiteral("height")).isEmpty();
}
if (reader.name() == QStringLiteral("metadata") || reader.name() == QStringLiteral("namedview")) {
hasEditorMetadata = true;
}
for (const QXmlStreamAttribute &attribute : reader.attributes()) {
const QString prefix = attribute.prefix().toString();
if (prefix == QStringLiteral("inkscape") || prefix == QStringLiteral("sodipodi") ||
prefix == QStringLiteral("dc") || prefix == QStringLiteral("cc") || prefix == QStringLiteral("rdf")) {
hasEditorMetadata = true;
}
}
}
validXml = sawRoot && !reader.hasError();
return validXml;
}
bool isAllowedPaintValue(const QString &value)
{
const QString normalized = value.trimmed().toLower();
return normalized.isEmpty() || normalized == QStringLiteral("none") ||
normalized == QStringLiteral("currentcolor") || normalized == QStringLiteral("inherit") ||
normalized == QStringLiteral("context-fill") || normalized == QStringLiteral("context-stroke") ||
normalized.startsWith(QStringLiteral("url("));
}
QRegularExpression paintValuePattern()
{
static const QRegularExpression pattern(
QStringLiteral(R"re((?:fill|stroke|stop-color|color)\s*[:=]\s*["']?\s*([^"'\s;>]+))re"),
QRegularExpression::CaseInsensitiveOption);
return pattern;
}
bool hasHardcodedColor(const QString &absolutePath)
{
QFile file(absolutePath);
if (!file.open(QIODevice::ReadOnly)) {
return false;
}
const QString content = QString::fromUtf8(file.readAll());
auto matches = paintValuePattern().globalMatch(content);
while (matches.hasNext()) {
const QString value = matches.next().captured(1).trimmed();
if (isAllowedPaintValue(value)) {
continue;
}
const QString normalized = value.toLower();
if (normalized.startsWith(QLatin1Char('#')) || normalized.contains(QStringLiteral("rgb(")) ||
normalized.contains(QStringLiteral("hsl(")) || QColor(value).isValid()) {
return true;
}
}
return false;
}
bool hasCurrentColorToken(const QString &absolutePath)
{
QFile file(absolutePath);
if (!file.open(QIODevice::ReadOnly)) {
return false;
}
const QString content = QString::fromUtf8(file.readAll());
auto matches = paintValuePattern().globalMatch(content);
while (matches.hasNext()) {
if (matches.next().captured(1).trimmed().compare(QStringLiteral("currentColor"), Qt::CaseInsensitive) == 0) {
return true;
}
}
return false;
}
bool isSchemeVariant(const QString &fileName)
{
return fileName.contains(QStringLiteral("-dark."), Qt::CaseInsensitive) ||
fileName.contains(QStringLiteral("-light."), Qt::CaseInsensitive);
}
bool hasColorInFilename(const QString &fileName)
{
static const QRegularExpression colorSuffix(
QStringLiteral(R"(_(green|blue|red|yellow|black|white|gray|grey)\.svg$)"),
QRegularExpression::CaseInsensitiveOption);
return colorSuffix.match(fileName).hasMatch();
}
/** @brief Collects every current violation as "rule relative/path.svg". */
QSet<QString> collectViolations()
{
QSet<QString> violations;
QDirIterator it(iconsDir(), QDir::Files, QDirIterator::Subdirectories);
while (it.hasNext()) {
it.next();
const QFileInfo info = it.fileInfo();
const QString relativePath = QDir(iconsDir()).relativeFilePath(info.absoluteFilePath());
if (isContentArt(relativePath)) {
continue;
}
if (info.suffix().compare(QStringLiteral("svg"), Qt::CaseInsensitive) != 0) {
violations.insert(violation(QStringLiteral("raster"), relativePath));
continue;
}
bool validXml = false;
bool hasViewBox = false;
bool hasEditorMetadata = false;
bool hasPhysicalSize = false;
inspectSvg(info.absoluteFilePath(), validXml, hasViewBox, hasEditorMetadata, hasPhysicalSize);
if (!validXml) {
violations.insert(violation(QStringLiteral("xml"), relativePath));
}
if (validXml && !hasViewBox) {
violations.insert(violation(QStringLiteral("viewbox"), relativePath));
}
if (hasPhysicalSize) {
violations.insert(violation(QStringLiteral("physical-size"), relativePath));
}
if (hasEditorMetadata) {
violations.insert(violation(QStringLiteral("editor-metadata"), relativePath));
}
if (hasHardcodedColor(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) {
violations.insert(violation(QStringLiteral("hardcoded-color"), relativePath));
}
if (!hasCurrentColorToken(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) {
violations.insert(violation(QStringLiteral("no-token"), relativePath));
}
if (hasColorInFilename(info.fileName())) {
violations.insert(violation(QStringLiteral("color-filename"), relativePath));
}
}
return violations;
}
QString formatSet(const QSet<QString> &entries)
{
QStringList lines(entries.begin(), entries.end());
lines.sort();
return lines.join(QLatin1Char('\n'));
}
} // namespace
/**
* @brief Every UI glyph follows the palette-token rules.
*
* The legacy set was paid down icon by icon, so no baseline grandfathers
* violations: a new violation fails right here. Scheme variants
* (`*-dark.svg`/`*-light.svg`) and mana art are exempt inside collectViolations().
*/
TEST(IconAssets, AssetsFollowTheRules)
{
ASSERT_TRUE(QDir::isAbsolutePath(iconsDir()));
ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString();
const QSet<QString> violations = collectViolations();
EXPECT_TRUE(violations.isEmpty()) << "Icon rule violations. Fix the icon; do not add exceptions:\n"
<< formatSet(violations).toStdString();
}

View file

@ -0,0 +1,149 @@
/**
* @file local_client_lifetime_test.cpp
* @brief Tests that a LocalClient survives the death of the LocalServer it talks to.
* @ingroup Tests
*/
#include <QCoreApplication>
#include <QDebug>
#include <QMessageLogContext>
#include <QString>
#include <QStringList>
#include <cstdio>
#include <cstring>
#include <gtest/gtest.h>
#include <libcockatrice/network/client/local/local_client.h>
#include <libcockatrice/network/server/local/local_server.h>
#include <libcockatrice/network/server/local/local_server_interface.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <memory>
// The server library refers to this global; the real applications define it in main.cpp.
RNG_Abstract *rng = nullptr;
namespace
{
// GTest's main() creates no QCoreApplication, which QObject::deleteLater() needs.
const QCoreApplication &application()
{
static int argc = 1;
static char executable[] = "local_client_lifetime_test";
static char *argv[] = {executable, nullptr};
static const QCoreApplication app(argc, argv);
return app;
}
/// Collects the warnings one logging category emits while this object is alive.
///
/// The handler is process-global and Qt does not nest handlers, so only one capture can be
/// installed at a time; GTest running one case at a time is what makes that safe.
class CategoryWarningCapture
{
public:
explicit CategoryWarningCapture(const char *_category) : category(_category)
{
previousHandler = qInstallMessageHandler(captureMessage);
s_active = this;
}
~CategoryWarningCapture()
{
qInstallMessageHandler(previousHandler);
s_active = nullptr;
}
CategoryWarningCapture(const CategoryWarningCapture &) = delete;
CategoryWarningCapture &operator=(const CategoryWarningCapture &) = delete;
const QStringList &warnings() const
{
return capturedWarnings;
}
private:
static void captureMessage(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (s_active != nullptr && type == QtWarningMsg && context.category != nullptr &&
std::strcmp(s_active->category, context.category) == 0) {
s_active->capturedWarnings.append(message);
}
// Forward everything else, so a failure elsewhere in the test is still visible.
if (previousHandler != nullptr) {
previousHandler(type, context, message);
return;
}
std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, message)));
}
const char *category;
QStringList capturedWarnings;
static CategoryWarningCapture *s_active;
static QtMessageHandler previousHandler;
};
CategoryWarningCapture *CategoryWarningCapture::s_active = nullptr;
QtMessageHandler CategoryWarningCapture::previousHandler = nullptr;
class LocalClientLifetimeTest : public ::testing::Test
{
protected:
void SetUp() override
{
(void)application();
server = std::make_unique<LocalServer>();
LocalServerInterface *lsi = server->newConnection();
ASSERT_NE(lsi, nullptr);
// Building the client logs in and joins room 0 through that interface, exactly as
// MainWindow::startLocalGame() does.
client = std::make_unique<LocalClient>(lsi, QStringLiteral("lifetime-test-player"),
QStringLiteral("lifetime-test-client-id"));
}
void TearDown() override
{
// Either case may already have deleted one of these.
client.reset();
server.reset();
}
std::unique_ptr<LocalServer> server;
std::unique_ptr<LocalClient> client;
};
TEST_F(LocalClientLifetimeTest, SendAfterServerDeletedIsDropped)
{
server.reset(); // also destroys the LocalServerInterface the client was built on
CategoryWarningCapture capture("local_client");
// A default container stands in for a command still queued when the local game ended.
client->sendCommandContainer(CommandContainer());
ASSERT_EQ(capture.warnings().size(), 1);
EXPECT_TRUE(capture.warnings().first().contains(QStringLiteral("dropping command")))
<< capture.warnings().first().toStdString();
// The surviving client is intact: reading its state must not touch the deleted interface.
EXPECT_EQ(client->getUserName(), QStringLiteral("lifetime-test-player"));
}
TEST_F(LocalClientLifetimeTest, DestroyingClientAfterServerIsSafe)
{
server.reset();
CategoryWarningCapture capture("local_client");
client.reset(); // must neither crash nor touch the destroyed interface
// The destructor itself must have been silent.
EXPECT_TRUE(capture.warnings().isEmpty());
// A working capture proves the emptiness above is real rather than a broken handler.
qCWarning(LocalClientLog) << "local_client_lifetime_test capture self-check";
EXPECT_EQ(capture.warnings().size(), 1);
}
} // namespace

View file

@ -0,0 +1,17 @@
cockatrice_add_test(
NAME
model_contract_test
SOURCES
${VERSION_STRING_CPP}
model_contract_test.cpp
LIBS
libcockatrice_models
libcockatrice_card
libcockatrice_deck_list
libcockatrice_settings
INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}/../support
${CMAKE_CURRENT_SOURCE_DIR}/../carddatabase
DEFINITIONS
CARDDB_DATADIR="${CMAKE_SOURCE_DIR}/tests/carddatabase/data/"
)

View file

@ -0,0 +1,137 @@
#include "libcockatrice/deck_list/deck_list.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
#include "libcockatrice/models/database/card_database_display_model.h"
#include "libcockatrice/models/database/card_database_model.h"
#include "libcockatrice/models/database/card_set/card_sets_model.h"
#include "libcockatrice/models/deck_list/deck_list_model.h"
#include "model_contract_checker.h"
#include "test_card_database_path_provider.h"
#include "gtest/gtest.h"
#include <QAbstractItemModel>
#include <QCoreApplication>
#include <QModelIndex>
#include <QSharedPointer>
#include <QSignalSpy>
#include <QString>
#include <QTemporaryDir>
#include <QVariant>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/game_specific_terms.h>
#include <libcockatrice/card/printing/exact_card.h>
#include <libcockatrice/interfaces/noop_card_preference_provider.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
#include <libcockatrice/settings/download_settings.h>
namespace
{
QSharedPointer<DeckList> makeDeck()
{
return QSharedPointer<DeckList>(new DeckList());
}
void loadCardDatabase(CardDatabase &database)
{
database.loadCardDatabases();
ASSERT_EQ(database.getLoadStatus(), Ok);
}
TEST(ModelContract, DeckListModelKeepsContractWhileZonesAndCardsChange)
{
const QSharedPointer<DeckList> deck = makeDeck();
DeckListModel model(nullptr, deck);
test_support::ModelContractChecker checker(&model);
ASSERT_NE(deck->getTree()->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
model.rebuildTree();
CardInfoPtr bolt = CardInfo::newInstance("Lightning Bolt");
const QModelIndex added = model.addCard(ExactCard(bolt), DECK_ZONE_MAIN);
ASSERT_TRUE(added.isValid());
CardInfoPtr bears = CardInfo::newInstance("Grizzly Bears");
bears->setProperty(Mtg::MainCardType, "Creature");
const QModelIndex grouped = model.addCard(ExactCard(bears), "Removal");
ASSERT_TRUE(grouped.isValid());
ASSERT_TRUE(model.removeRows(grouped.row(), 1, grouped.parent()));
}
TEST(ModelContract, DeckListModelEmitsCardAddedSignal)
{
const QSharedPointer<DeckList> deck = makeDeck();
DeckListModel model(nullptr, deck);
QSignalSpy spy(&model, &DeckListModel::cardAddedAt);
const QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
ASSERT_TRUE(added.isValid());
EXPECT_EQ(spy.count(), 1);
}
TEST(ModelContract, CardDatabaseModelAndDisplayProxyKeepContract)
{
CardDatabase database(nullptr, new NoopCardPreferenceProvider(), new TestCardDatabasePathProvider(),
new NoopCardSetPriorityController());
ASSERT_NO_FATAL_FAILURE(loadCardDatabase(database));
CardDatabaseModel source(&database, false);
test_support::ModelContractChecker sourceChecker(&source);
CardDatabaseDisplayModel display;
display.setSourceModel(&source);
test_support::ModelContractChecker displayChecker(&display);
ASSERT_GT(source.rowCount(), 0);
EXPECT_EQ(display.rowCount(), source.rowCount());
// A card row is a leaf, so its index must not claim child columns. The
// tester does not check this, hence the explicit assertion.
const QModelIndex sourceCard = source.index(0, 0);
ASSERT_TRUE(sourceCard.isValid());
EXPECT_EQ(source.columnCount(sourceCard), 0);
const QModelIndex displayCard = display.index(0, 0);
ASSERT_TRUE(displayCard.isValid());
EXPECT_EQ(display.columnCount(displayCard), 0);
QSignalSpy dirtySpy(&display, &CardDatabaseDisplayModel::modelDirty);
display.setCardName("Do");
ASSERT_TRUE(dirtySpy.wait());
EXPECT_GT(display.rowCount(), 0);
EXPECT_LT(display.rowCount(), source.rowCount());
}
TEST(ModelContract, SetsModelKeepsContract)
{
CardDatabase database(nullptr, new NoopCardPreferenceProvider(), new TestCardDatabasePathProvider(),
new NoopCardSetPriorityController());
ASSERT_NO_FATAL_FAILURE(loadCardDatabase(database));
SetsModel model(&database);
test_support::ModelContractChecker checker(&model);
ASSERT_GT(model.rowCount(), 0);
ASSERT_GT(model.columnCount(), 0);
}
TEST(ModelContract, SettingsSignalIsObservableWithQSignalSpy)
{
QTemporaryDir tempDir;
ASSERT_TRUE(tempDir.isValid());
DownloadSettings settings(tempDir.path() + "/", nullptr);
QSignalSpy spy(&settings, &DownloadSettings::downloadSpoilerStatusChanged);
settings.setDownloadSpoilerStatus(true);
ASSERT_EQ(spy.count(), 1);
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -9,3 +9,15 @@ cockatrice_add_test(
LABELS
server
)
cockatrice_add_test(
NAME
player_order_test
SOURCES
player_order_test.cpp
LIBS
libcockatrice_network_server_remote
libcockatrice_rng
LABELS
server
)

View file

@ -0,0 +1,268 @@
#include "game/game_config.h"
#include "game/role_info.h"
#include "game/server_abstract_participant.h"
#include "game/server_game.h"
#include "server_abstractuserinterface.h"
#include "server_response_containers.h"
#include "server_room.h"
#include "server_test_helpers.h"
#include <gtest/gtest.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/room_event.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_event.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
RNG_Abstract *rng = nullptr; // this needs to be defined due to other functions in server
class ScriptedRng : public RNG_Abstract
{
public:
QList<unsigned int> values;
unsigned int rand(int min, int max) override
{
const unsigned int value = values.takeFirst();
EXPECT_GE(value, static_cast<unsigned int>(min));
EXPECT_LE(value, static_cast<unsigned int>(max));
return value;
}
};
class FakeUserInterface : public Server_AbstractUserInterface
{
public:
FakeUserInterface(Server *_server, const ServerInfo_User &_userInfo) : Server_AbstractUserInterface(_server)
{
setUserInfo(_userInfo);
}
int getLastCommandTime() const override
{
return 0;
}
bool addSaidMessageSize(int) override
{
return true;
}
void sendProtocolItem(const Response &) override
{
}
void sendProtocolItem(const SessionEvent &) override
{
}
void sendProtocolItem(const GameEventContainer &) override
{
}
void sendProtocolItem(const RoomEvent &) override
{
}
};
namespace
{
QStringList participantNamesInOrder(const Server_Game &game)
{
QStringList names;
for (int id : game.getSeatOrder()) {
const auto *participant = game.getParticipants().value(id, nullptr);
if (participant) {
names.append(QString::fromStdString(participant->getUserInfo()->name()));
}
}
return names;
}
QList<int> participantIdsInOrder(const Server_Game &game)
{
return game.getSeatOrder();
}
} // namespace
class PlayerOrderTest : public ::testing::Test
{
protected:
ServerInfo_User creator;
ServerInfo_User userB;
ServerInfo_User userC;
PlayerOrderTest()
{
creator.set_name("A");
creator.set_user_level(ServerInfo_User::IsRegistered);
userB.set_name("B");
userB.set_user_level(ServerInfo_User::IsRegistered);
userC.set_name("C");
userC.set_user_level(ServerInfo_User::IsRegistered);
}
};
TEST_F(PlayerOrderTest, JoinOrderIsSeatOrder)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
EXPECT_EQ(participantNamesInOrder(game), QStringList({"A", "B", "C"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({0, 1, 2}));
EXPECT_EQ(game.getHostId(), 0);
EXPECT_EQ(game.getPlayerCount(), 3);
}
TEST_F(PlayerOrderTest, ReorderPlayerSeats)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
game.reorderPlayerSeats({"C", "A", "B"});
// player ids are stable; only the seat order changes
EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({2, 0, 1}));
EXPECT_EQ(game.getParticipants().size(), 3);
// the host keeps its seat: creator A is still in the game, so host stays 0
EXPECT_EQ(game.getHostId(), 0);
// no re-keying means every mapping keyed by player id stays valid
const int gameId = game.getGameId();
EXPECT_EQ(uiA.getGames().value(gameId).second, 0);
EXPECT_EQ(uiB.getGames().value(gameId).second, 1);
EXPECT_EQ(uiC.getGames().value(gameId).second, 2);
}
TEST_F(PlayerOrderTest, ReorderPlayerSeatsKeepsPlayerCount)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
// listing only a subset appends the remaining players in their current order
game.reorderPlayerSeats({"C"});
EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({2, 0, 1}));
EXPECT_EQ(game.getPlayerCount(), 3);
EXPECT_EQ(game.getHostId(), 0);
}
TEST_F(PlayerOrderTest, ShufflePlayerSeatsIsAPermutation)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
// fisher-yates on [A, B, C]: rand(0, 2) -> 1 (take B), rand(0, 1) -> 0 (take A), rand(0, 0) -> 0 (take C)
auto *scriptedRng = new ScriptedRng;
scriptedRng->values = {1, 0, 0};
rng = scriptedRng;
game.shufflePlayerSeats();
EXPECT_EQ(participantNamesInOrder(game), QStringList({"B", "A", "C"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({1, 0, 2}));
QSet<int> idSet(participantIdsInOrder(game).cbegin(), participantIdsInOrder(game).cend());
EXPECT_EQ(idSet.size(), 3); // ids are unique
// ids are stable, so the host (creator A, id 0) is unchanged
EXPECT_EQ(game.getHostId(), 0);
}

View file

@ -0,0 +1,84 @@
#include "client/network/parsers/interface_json_deck_parser.h"
#include <QJsonDocument>
#include <gtest/gtest.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
TEST(MoxfieldImport, SignatureSpellIsAddedToMainWithoutReplacingCommanderBanner)
{
// Moxfield v2 board objects map IDs to entries with quantity and card.
const auto input = QJsonDocument::fromJson(R"json({
"name": "Oathbreaker example", "description": "Notes retained",
"mainboard": {"land": {"quantity": 1, "card": {"name": "Island", "set": "abc", "cn": "1"}}},
"sideboard": {"side": {"quantity": 1, "card": {"name": "Mountain", "set": "abc", "cn": "2"}}},
"commanders": {"walker": {"quantity": 1, "card": {"name": "Jace", "set": "abc", "cn": "3", "scryfall_id": "walker-id"}}},
"signatureSpells": {"spell": {"quantity": 1, "card": {"name": "Lightning Bolt", "set": "xyz", "cn": "4", "scryfall_id": "spell-id"}}}
})json")
.object();
MoxfieldJsonParser parser;
DeckList deck = parser.parse(input);
int mainSpellCount = 0;
int sideSpellCount = 0;
int mainLands = 0;
int sideLands = 0;
deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) {
if (card->getName() == "Island" && zone->getName() == DECK_ZONE_MAIN) {
mainLands += card->getNumber();
}
if (card->getName() == "Mountain" && zone->getName() == DECK_ZONE_SIDE) {
sideLands += card->getNumber();
}
if (card->getName() != "Lightning Bolt") {
return;
}
if (zone->getName() == DECK_ZONE_MAIN) {
mainSpellCount += card->getNumber();
EXPECT_EQ(card->getCardSetShortName(), "XYZ");
EXPECT_EQ(card->getCardCollectorNumber(), "4");
EXPECT_EQ(card->getCardProviderId(), "spell-id");
} else if (zone->getName() == DECK_ZONE_SIDE) {
sideSpellCount += card->getNumber();
}
});
EXPECT_EQ(mainSpellCount, 1);
EXPECT_EQ(sideSpellCount, 0);
EXPECT_EQ(mainLands, 1);
EXPECT_EQ(sideLands, 1);
EXPECT_EQ(deck.getName(), "Oathbreaker example");
EXPECT_EQ(deck.getComments(), "Notes retained");
EXPECT_EQ(deck.getBannerCard().name, "Jace");
EXPECT_EQ(deck.getBannerCard().providerId, "walker-id");
}
TEST(MoxfieldImport, QuantityAwareDeckInsertionRetainsCountAndPrinting)
{
DeckList deck;
auto *card = deck.addCards("Island", 2, DECK_ZONE_MAIN, -1, "ABC", "4", "id");
ASSERT_NE(card, nullptr);
EXPECT_EQ(card->getNumber(), 2);
EXPECT_EQ(card->getCardProviderId(), "id");
EXPECT_EQ(deck.addCards("Island", 0, DECK_ZONE_MAIN), nullptr);
}
TEST(OnlineImport, ArchidektPreservesNameDescriptionAndCards)
{
const auto input = QJsonDocument::fromJson(R"json({
"name": "Online deck", "description": "Deck notes",
"cards": [{"quantity": 2, "card": {
"oracleCard": {"name": "Island"}, "edition": {"editioncode": "abc"}, "collectorNumber": "1"
}}]
})json")
.object();
ArchidektJsonParser parser;
const DeckList deck = parser.parse(input);
int count = 0;
deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) {
if (zone->getName() == DECK_ZONE_MAIN && card->getName() == "Island") {
count += card->getNumber();
}
});
EXPECT_EQ(count, 2);
EXPECT_EQ(deck.getName(), "Online deck");
EXPECT_EQ(deck.getComments(), "Deck notes");
}

View file

@ -9,6 +9,7 @@ cockatrice_add_test(
../../oracle/src/oracleimporter.cpp
../../oracle/src/parsehelpers.cpp
../../oracle/src/raw_json_scanner.cpp
../../oracle/src/database_writer.cpp
oracle_importer_test.cpp
LIBS
libcockatrice_card

View file

@ -1,3 +1,4 @@
#include "../../oracle/src/database_writer.h"
#include "../../oracle/src/oracleimporter.h"
#include "../../oracle/src/parsehelpers.h"
#include "../../oracle/src/raw_json_scanner.h"
@ -36,6 +37,12 @@
#include <string>
#include <utility>
#ifdef Q_OS_UNIX
#include <csignal>
#include <sys/resource.h>
#include <unistd.h>
#endif
class OracleImporterTest : public ::testing::Test
{
protected:
@ -1302,6 +1309,212 @@ TEST_F(OracleImporterTest, StartImportAppliesLocalizedData)
ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo.");
}
namespace
{
QByteArray refreshSource(const QString &name = "New Card")
{
QJsonObject card{{"name", name}, {"text", "Rules text."}, {"layout", "normal"},
{"type", "Artifact"}, {"number", "1"}, {"rarity", "common"}};
QJsonObject set{{"code", "TST"}, {"name", "Test Set"}, {"type", "expansion"}, {"cards", QJsonArray{card}}};
return QJsonDocument(QJsonObject{{"data", QJsonObject{{"TST", set}}}}).toJson(QJsonDocument::Compact);
}
} // namespace
TEST_F(OracleImporterTest, FailedLazyParseKeepsInstalledDatabase)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("existing database"), 17);
original.close();
// The metadata scanner skips numeric values without converting them. Qt's
// per-set parser rejects an overflowing number after selection succeeds.
auto badSet = QJsonDocument::fromJson(refreshSource()).object().value("data").toObject().value("TST").toObject();
auto goodSet = badSet;
goodSet["code"] = "AAA";
goodSet["name"] = "A successful first set";
auto badCard = badSet.value("cards").toArray().first().toObject();
badCard["number"] = "overflow";
badSet["cards"] = QJsonArray{badCard};
QByteArray data = QJsonDocument(QJsonObject{{"data", QJsonObject{{"AAA", goodSet}, {"TST", badSet}}}})
.toJson(QJsonDocument::Compact);
data.replace("\"number\":\"overflow\"", "\"number\":1e9999");
ASSERT_TRUE(importer->readSetsFromByteArray(data));
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getImportError().contains("TST"));
EXPECT_TRUE(importer->getCardList().isEmpty());
EXPECT_FALSE(importer->saveToFile(path, {}, {}));
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "existing database");
}
TEST_F(OracleImporterTest, EmptyImportCannotReplaceInstalledCards)
{
const QByteArray source = R"({"data":{"TST":{"code":"TST","name":"Test","type":"expansion","cards":[]}}})";
ASSERT_TRUE(importer->readSetsFromByteArray(source));
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_FALSE(importer->getImportError().isEmpty());
}
TEST_F(OracleImporterTest, MissingSourceBytesCannotProduceSuccessfulImport)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
const auto selected = importer->getSets();
importer->releaseSetData();
importer->getSets() = selected;
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getImportError().contains("TST"));
}
TEST_F(OracleImporterTest, CancellationDuringFinalSetCannotSavePartialDatabase)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
QObject::connect(
importer, &OracleImporter::setIndexChanged, importer,
[this](int, int, const QString &name) {
if (!name.isEmpty()) {
importer->cancelImport();
}
},
Qt::DirectConnection);
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getCardList().isEmpty());
}
TEST_F(OracleImporterTest, CancellationBeforeWorkerStartsIsNotLost)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
importer->prepareImport();
importer->cancelImport();
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
importer->prepareImport();
EXPECT_EQ(importer->startImport(), 1);
EXPECT_TRUE(importer->hasSuccessfulImport());
}
TEST_F(OracleImporterTest, NewImportReplacesPreviousInMemoryCards)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("Old Card")));
ASSERT_EQ(importer->startImport(), 1);
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("New Card")));
EXPECT_FALSE(importer->hasSuccessfulImport());
ASSERT_EQ(importer->startImport(), 1);
EXPECT_EQ(importer->getCardList().size(), 1);
EXPECT_FALSE(importer->getCardList().contains("Old Card"));
EXPECT_TRUE(importer->getCardList().contains("New Card"));
}
TEST_F(OracleImporterTest, FailedSourceLoadCannotReusePreviousSuccessfulImport)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
ASSERT_EQ(importer->startImport(), 1);
ASSERT_FALSE(importer->readSetsFromByteArray("invalid JSON"));
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_EQ(importer->startImport(), 0);
}
TEST_F(OracleImporterTest, SuccessfulRefreshCommitsCompleteXml)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
ASSERT_EQ(importer->startImport(), 1);
importer->releaseSetData();
ASSERT_TRUE(importer->saveToFile(path, "test-source", "v1"));
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::ReadOnly));
const QByteArray xml = file.readAll();
EXPECT_TRUE(xml.contains("New Card"));
EXPECT_TRUE(xml.contains("test-source"));
EXPECT_TRUE(xml.contains("</cockatrice_carddatabase>"));
}
TEST(OracleDatabaseWriter, AcceptsCompleteSupportedVersions)
{
EXPECT_TRUE(OracleDatabase::validateXml(
"<?xml version=\"1.0\"?><cockatrice_carddatabase version=\"3\"><cards/></cockatrice_carddatabase>")
.isEmpty());
EXPECT_TRUE(OracleDatabase::validateXml("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>")
.isEmpty());
EXPECT_FALSE(
OracleDatabase::validateXml("<cockatrice_carddatabase version=\"5\"><cards/></cockatrice_carddatabase>")
.isEmpty());
}
TEST(OracleDatabaseWriter, InvalidDownloadPreservesInstalledBytes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("tokens.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("installed tokens"), 16);
original.close();
for (const QByteArray &payload : {QByteArray("<html>server error</html>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><cards>"), QByteArray()}) {
QString error;
EXPECT_FALSE(OracleDatabase::saveXml(path, payload, &error));
EXPECT_FALSE(error.isEmpty());
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "installed tokens");
original.close();
}
}
TEST(OracleDatabaseWriter, CompleteDownloadReplacesInstalledBytes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
const QByteArray data("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>");
QString error = "previous failure";
ASSERT_TRUE(OracleDatabase::saveXml(path, data, &error));
EXPECT_TRUE(error.isEmpty());
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::ReadOnly));
EXPECT_EQ(file.readAll(), data);
}
#ifdef Q_OS_UNIX
TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("installed database"), 18);
original.close();
const QByteArray data = "<cockatrice_carddatabase version=\"4\"><!--" + QByteArray(16384, 'x') +
"--><cards/></cockatrice_carddatabase>";
// A real filesystem write failure, isolated in the death-test child so the
// process-wide file limit never affects other cases or the parent runner.
ASSERT_EXIT(
{
std::signal(SIGXFSZ, SIG_IGN);
struct rlimit limit;
limit.rlim_cur = 128;
limit.rlim_max = 128;
if (setrlimit(RLIMIT_FSIZE, &limit) != 0) {
_exit(2);
}
_exit(OracleDatabase::saveXml(path, data) ? 1 : 0);
},
::testing::ExitedWithCode(0), "");
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "installed database");
}
#endif
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);

View file

@ -52,6 +52,17 @@ TEST(PasswordHashTest, DeriveKeyMatchesKnownVector)
ASSERT_EQ(derived.toHex(), expected.toHex());
}
TEST(PasswordHashTest, DeriveKeyCoversTheWholeAcceptedRangeOfR)
{
// costParamsAreSane accepts r up to 32. The memory budget handed to OpenSSL has to cover
// scrypt's XY array (256 * r bytes) on top of V and B, or derivation fails for r >= 17.
for (const int r : {17, 32}) {
const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, r, 1);
ASSERT_FALSE(derived.isEmpty()) << "deriveKey failed for accepted r=" << r;
ASSERT_EQ(derived.size(), SCRYPT_VERIFIER_LENGTH);
}
}
TEST(PasswordHashTest, PasswordVerifierRoundTrip)
{
const QString stored = PasswordHasher::generatePasswordVerifier("hunter2");

View file

@ -0,0 +1,234 @@
/** @file server_forced_password_change_test.cpp
* @brief Tests for the restricted session a forced password change leaves behind at login.
* @ingroup Tests
*/
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/server_message.pb.h"
#include "server.h"
#include "server_database_interface.h"
#include "server_protocolhandler.h"
#include <QString>
#include <gtest/gtest.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/room_commands.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
RNG_Abstract *rng = nullptr; // referenced by the server_remote library
namespace
{
class MockDatabaseInterface : public Server_DatabaseInterface
{
public:
AuthenticationResult checkUserPassword(Server_ProtocolHandler *,
const QString &,
const QString &,
const QString &,
QString &,
int &,
bool) override
{
return PasswordChangeRequired;
}
ServerInfo_User getUserData(const QString &name, bool withId) override
{
ServerInfo_User user;
user.set_name(name.toStdString());
user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsModerator);
if (withId) {
user.set_id(1);
}
return user;
}
int getNextReplayId() override
{
return 1;
}
int getNextGameId() override
{
return 1;
}
int getActiveUserCount(QString) override
{
return 0;
}
};
class FakeServer : public Server
{
public:
FakeServer()
{
setDatabaseInterface(new MockDatabaseInterface());
}
};
class TestHandler : public Server_ProtocolHandler
{
public:
TestHandler(Server *_server, Server_DatabaseInterface *_db) : Server_ProtocolHandler(_server, _db)
{
}
QString getAddress() const override
{
return {};
}
QString getConnectionType() const override
{
return {};
}
Response::ResponseCode lastResponseCode = Response::RespNothing;
int lastExtendedSessionCommand = -1;
protected:
Response::ResponseCode
processExtendedSessionCommand(int cmdType, const SessionCommand & /*cmd*/, ResponseContainer & /*rc*/) override
{
lastExtendedSessionCommand = cmdType;
return Response::RespOk;
}
void transmitProtocolItem(const ServerMessage &item) override
{
if (item.message_type() == ServerMessage::RESPONSE) {
lastResponseCode = item.response().response_code();
}
}
};
class ForcedPasswordChangeTest : public ::testing::Test
{
protected:
FakeServer server;
TestHandler handler{&server, server.getDatabaseInterface()};
void login()
{
CommandContainer cont;
cont.set_cmd_id(1);
Command_Login cmd;
cmd.set_user_name("mod");
cont.add_session_command()->MutableExtension(Command_Login::ext)->CopyFrom(cmd);
handler.processCommandContainer(cont);
}
Response::ResponseCode sendPing()
{
CommandContainer cont;
cont.set_cmd_id(2);
cont.add_session_command()->MutableExtension(Command_Ping::ext);
handler.processCommandContainer(cont);
return handler.lastResponseCode;
}
Response::ResponseCode sendSessionCommand()
{
CommandContainer cont;
cont.set_cmd_id(3);
cont.add_session_command()->MutableExtension(Command_ListRooms::ext);
handler.processCommandContainer(cont);
return handler.lastResponseCode;
}
Response::ResponseCode sendRoomCommand()
{
CommandContainer cont;
cont.set_cmd_id(4);
cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->set_message("hello");
handler.processCommandContainer(cont);
return handler.lastResponseCode;
}
Response::ResponseCode sendMixedSessionAndRoomCommand()
{
CommandContainer cont;
cont.set_cmd_id(5);
cont.add_session_command()->MutableExtension(Command_Ping::ext);
cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->set_message("hello");
handler.processCommandContainer(cont);
return handler.lastResponseCode;
}
Response::ResponseCode sendAccountPassword()
{
CommandContainer cont;
cont.set_cmd_id(6);
Command_AccountPassword cmd;
cmd.set_old_password("temporary-password");
cmd.set_new_password("replacement-password");
cont.add_session_command()->MutableExtension(Command_AccountPassword::ext)->CopyFrom(cmd);
handler.processCommandContainer(cont);
return handler.lastResponseCode;
}
};
TEST_F(ForcedPasswordChangeTest, LoginReportsTheRequiredChange)
{
login();
EXPECT_EQ(Response::RespPasswordChangeRequired, handler.lastResponseCode);
ASSERT_NE(nullptr, handler.getUserInfo());
EXPECT_EQ("mod", QString::fromStdString(handler.getUserInfo()->name()));
}
TEST_F(ForcedPasswordChangeTest, PendingSessionIsNotRegisteredAsAUser)
{
login();
// The session may change its password, but it must not take a user slot or appear in
// the session table before the client reconnects with the new password.
EXPECT_FALSE(handler.getUserInfo()->has_session_id());
EXPECT_EQ(0, server.getUsersCount());
}
TEST_F(ForcedPasswordChangeTest, PendingModeratorIsNotReportedOnline)
{
login();
server.addClient(&handler);
EXPECT_TRUE(server.getOnlineModeratorList().isEmpty());
}
TEST_F(ForcedPasswordChangeTest, OnlyPingAndPasswordChangeAreServed)
{
login();
EXPECT_EQ(Response::RespOk, sendPing());
EXPECT_EQ(Response::RespFunctionNotAllowed, sendSessionCommand());
EXPECT_EQ(Response::RespFunctionNotAllowed, sendRoomCommand());
// Dispatch prefers game/room over session, so a container carrying both must not slip past
// the restriction by carrying a session command.
EXPECT_EQ(Response::RespFunctionNotAllowed, sendMixedSessionAndRoomCommand());
}
TEST_F(ForcedPasswordChangeTest, AccountPasswordReachesTheCommand)
{
login();
// The forced change is useless if the gate refuses the command it needs. ACCOUNT_PASSWORD is
// served by the extended-command hook, so it must arrive there instead of being filtered.
EXPECT_EQ(Response::RespOk, sendAccountPassword());
EXPECT_EQ(SessionCommand::ACCOUNT_PASSWORD, handler.lastExtendedSessionCommand);
}
TEST_F(ForcedPasswordChangeTest, CompletedLoginIsStillReportedOnline)
{
TestHandler loggedIn{&server, server.getDatabaseInterface()};
ServerInfo_User user;
user.set_name("online-mod");
user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsModerator);
user.set_session_id(7);
loggedIn.setUserInfo(user);
server.addClient(&loggedIn);
const QList<QString> moderators = server.getOnlineModeratorList();
ASSERT_EQ(1, moderators.size());
EXPECT_EQ("online-mod", moderators.first());
}
} // namespace

View file

@ -4,6 +4,7 @@
*/
#include "game/game_config.h"
#include "game/role_info.h"
#include "game/server_game.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "server.h"
@ -119,13 +120,13 @@ TEST_F(GameJoinOverrideTest, StaffBypassPasswordRestriction)
game = makeGame(true, false, false, true);
// A plain user cannot override the password even with the override flag set.
EXPECT_EQ(game->checkJoin(&plainUser, "wrong", false, true, false), Response::RespWrongPassword);
EXPECT_EQ(game->checkJoin(&plainUser, "wrong", {}, true), Response::RespWrongPassword);
// Moderators and judges may enter any game regardless of the password.
EXPECT_EQ(game->checkJoin(&moderator, "wrong", false, true, false), Response::RespOk);
EXPECT_EQ(game->checkJoin(&judge, "wrong", false, true, false), Response::RespOk);
EXPECT_EQ(game->checkJoin(&moderator, "wrong", {}, true), Response::RespOk);
EXPECT_EQ(game->checkJoin(&judge, "wrong", {}, true), Response::RespOk);
// Without the override flag judges are still subject to the password.
EXPECT_EQ(game->checkJoin(&judge, "wrong", false, false, true), Response::RespWrongPassword);
EXPECT_EQ(game->checkJoin(&judge, "secret", false, false, true), Response::RespOk);
EXPECT_EQ(game->checkJoin(&judge, "wrong", {.isJudge = true}, false), Response::RespWrongPassword);
EXPECT_EQ(game->checkJoin(&judge, "secret", {.isJudge = true}, false), Response::RespOk);
}
TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction)
@ -133,9 +134,9 @@ TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction)
game = makeGame(false, true, false, true);
// Without the override flag the only-registered restriction still applies.
EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, false, false), Response::RespUserLevelTooLow);
EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), {}, false), Response::RespUserLevelTooLow);
// An unregistered judge may enter when overriding restrictions.
EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, true, false), Response::RespOk);
EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), {}, true), Response::RespOk);
}
TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction)
@ -143,10 +144,10 @@ TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction)
game = makeGame(false, false, true, true);
// A plain user who is not on the creator's buddy list gets rejected.
EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, false), Response::RespOnlyBuddies);
EXPECT_EQ(game->checkJoin(&plainUser, QString(), {}, true), Response::RespOnlyBuddies);
// Moderators and judges bypass the buddies-only restriction.
EXPECT_EQ(game->checkJoin(&moderator, QString(), false, true, false), Response::RespOk);
EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, false), Response::RespOk);
EXPECT_EQ(game->checkJoin(&moderator, QString(), {}, true), Response::RespOk);
EXPECT_EQ(game->checkJoin(&judge, QString(), {}, true), Response::RespOk);
}
TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction)
@ -154,11 +155,11 @@ TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction)
game = makeGame(false, false, false, false);
// A plain user cannot spectate when the game disallows spectators.
EXPECT_EQ(game->checkJoin(&plainUser, QString(), true, false, false), Response::RespSpectatorsNotAllowed);
EXPECT_EQ(game->checkJoin(&plainUser, QString(), {.isSpectator = true}, false), Response::RespSpectatorsNotAllowed);
// Moderators and judges may spectate any game regardless of the password
// and the spectator restriction.
EXPECT_EQ(game->checkJoin(&moderator, "wrong", true, true, false), Response::RespOk);
EXPECT_EQ(game->checkJoin(&judge, "wrong", true, true, false), Response::RespOk);
EXPECT_EQ(game->checkJoin(&moderator, "wrong", {.isSpectator = true}, true), Response::RespOk);
EXPECT_EQ(game->checkJoin(&judge, "wrong", {.isSpectator = true}, true), Response::RespOk);
}
TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser)
@ -166,8 +167,8 @@ TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser)
game = makeGame(false, false, false, true);
// joining with join_as_judge still requires the judge flag even when overriding.
EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, true), Response::RespUserLevelTooLow);
EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, true), Response::RespOk);
EXPECT_EQ(game->checkJoin(&plainUser, QString(), {.isJudge = true}, true), Response::RespUserLevelTooLow);
EXPECT_EQ(game->checkJoin(&judge, QString(), {.isJudge = true}, true), Response::RespOk);
}
} // namespace

View file

@ -1,5 +1,7 @@
cockatrice_add_test(NAME settings_manager_test SOURCES settings_manager_test.cpp LIBS libcockatrice_settings)
cockatrice_add_test(NAME servers_settings_test SOURCES servers_settings_test.cpp LIBS libcockatrice_settings)
cockatrice_add_test(
NAME
settings_defaults_test

View file

@ -0,0 +1,228 @@
#include "gtest/gtest.h"
#include <QSettings>
#include <QString>
#include <QTemporaryDir>
#include <libcockatrice/settings/servers_settings.h>
namespace
{
class ServersSettingsTest : public ::testing::Test
{
protected:
QTemporaryDir tempDir;
QString settingsPath;
void SetUp() override
{
settingsPath = tempDir.path() + "/";
}
QString settingsFile() const
{
return settingsPath + "servers.ini";
}
void writeLegacyServer(int index,
const QString &saveName,
const QString &host,
const QString &port,
const QString &username,
const QString &password,
bool savePassword,
const QString &site)
{
QSettings settings(settingsFile(), QSettings::IniFormat);
const QString prefix = QStringLiteral("server/server_details/");
settings.setValue(prefix + QStringLiteral("saveName%1").arg(index), saveName);
settings.setValue(prefix + QStringLiteral("server%1").arg(index), host);
settings.setValue(prefix + QStringLiteral("port%1").arg(index), port);
settings.setValue(prefix + QStringLiteral("username%1").arg(index), username);
settings.setValue(prefix + QStringLiteral("password%1").arg(index), password);
settings.setValue(prefix + QStringLiteral("savePassword%1").arg(index), savePassword);
settings.setValue(prefix + QStringLiteral("site%1").arg(index), site);
}
};
TEST_F(ServersSettingsTest, AddServerStoresAllFields)
{
ServersSettings settings(settingsPath, nullptr);
settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "https://alpha.example");
const auto servers = settings.getServers();
ASSERT_EQ(servers.size(), 1);
EXPECT_EQ(servers.first().saveName, QString("Alpha"));
EXPECT_EQ(servers.first().host, QString("alpha.example"));
EXPECT_EQ(servers.first().port, QString("4748"));
EXPECT_EQ(servers.first().username, QString("alice"));
EXPECT_EQ(servers.first().password, QString("secret"));
EXPECT_TRUE(servers.first().savePassword);
EXPECT_EQ(servers.first().site, QString("https://alpha.example"));
}
TEST_F(ServersSettingsTest, AddExistingHostAndPortUpdatesInsteadOfDuplicating)
{
ServersSettings settings(settingsPath, nullptr);
settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "site-a");
settings.addNewServer("Renamed", "alpha.example", "4748", "bob", "new-secret", true, "site-b");
const auto servers = settings.getServers();
ASSERT_EQ(servers.size(), 1);
EXPECT_EQ(servers.first().saveName, QString("Renamed"));
EXPECT_EQ(servers.first().username, QString("bob"));
EXPECT_EQ(servers.first().password, QString("new-secret"));
EXPECT_EQ(servers.first().site, QString("site-b"));
}
TEST_F(ServersSettingsTest, RemoveServerRemovesOnlyMatchingHostAndKeepsOrder)
{
ServersSettings settings(settingsPath, nullptr);
settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "a", true, "");
settings.addNewServer("Beta", "beta.example", "4748", "bob", "b", true, "");
settings.addNewServer("Gamma", "gamma.example", "4748", "carol", "c", true, "");
settings.removeServer("beta.example");
const auto servers = settings.getServers();
ASSERT_EQ(servers.size(), 2);
EXPECT_EQ(servers.at(0).saveName, QString("Alpha"));
EXPECT_EQ(servers.at(1).saveName, QString("Gamma"));
}
TEST_F(ServersSettingsTest, RemovingLastServerDropsItsStaleArraySlot)
{
ServersSettings settings(settingsPath, nullptr);
settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "a", true, "");
settings.addNewServer("Beta", "beta.example", "4748", "bob", "b", true, "");
settings.addNewServer("Gamma", "gamma.example", "4748", "carol", "gamma-secret", true, "");
settings.removeServer("gamma.example");
QSettings raw(settingsFile(), QSettings::IniFormat);
EXPECT_FALSE(raw.contains("server/server_details/servers/3/password"));
EXPECT_FALSE(raw.contains("server/server_details/servers/3/saveName"));
ASSERT_EQ(settings.getServers().size(), 2);
}
TEST_F(ServersSettingsTest, UpdateWithoutLossKeepsCredentials)
{
ServersSettings settings(settingsPath, nullptr);
settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "");
EXPECT_TRUE(settings.updateExistingServerWithoutLoss("Renamed", "alpha.example", "1234", "site"));
const auto server = settings.getServer(settings.findServerIndex("alpha.example", "1234"));
ASSERT_TRUE(server.has_value());
EXPECT_EQ(server->saveName, QString("Renamed"));
EXPECT_EQ(server->username, QString("alice"));
EXPECT_EQ(server->password, QString("secret"));
EXPECT_TRUE(server->savePassword);
EXPECT_EQ(server->site, QString("site"));
}
TEST_F(ServersSettingsTest, SetServerPasswordUpdatesOnlyTheNamedProfile)
{
ServersSettings settings(settingsPath, nullptr);
settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "old", true, "");
settings.addNewServer("Beta", "beta.example", "4748", "bob", "keep", true, "");
settings.setServerPassword("Beta", "new");
EXPECT_EQ(settings.getServer(0)->password, QString("old"));
EXPECT_EQ(settings.getServer(1)->password, QString("new"));
}
TEST_F(ServersSettingsTest, LegacyProfilesAreMigrated)
{
{
QSettings settings(settingsFile(), QSettings::IniFormat);
settings.setValue("server/server_details/totalServers", 2);
}
writeLegacyServer(1, "Alpha", "alpha.example", "4748", "alice", "secret", true, "site-a");
writeLegacyServer(2, "Beta", "beta.example", "4748", "bob", "other", false, "site-b");
ServersSettings settings(settingsPath, nullptr);
const auto servers = settings.getServers();
ASSERT_EQ(servers.size(), 2);
EXPECT_EQ(servers.at(0).saveName, QString("Alpha"));
EXPECT_EQ(servers.at(0).host, QString("alpha.example"));
EXPECT_TRUE(servers.at(0).savePassword);
EXPECT_EQ(servers.at(1).saveName, QString("Beta"));
EXPECT_FALSE(servers.at(1).savePassword);
QSettings raw(settingsFile(), QSettings::IniFormat);
EXPECT_FALSE(raw.contains("server/server_details/totalServers"));
EXPECT_FALSE(raw.contains("server/server_details/saveName1"));
EXPECT_FALSE(raw.contains("server/server_details/server1"));
}
TEST_F(ServersSettingsTest, MigrationRunsOnce)
{
writeLegacyServer(1, "Alpha", "alpha.example", "4748", "alice", "secret", true, "");
{
QSettings settings(settingsFile(), QSettings::IniFormat);
settings.setValue("server/server_details/totalServers", 1);
}
{
ServersSettings first(settingsPath, nullptr);
ASSERT_EQ(first.getServers().size(), 1);
}
ServersSettings second(settingsPath, nullptr);
ASSERT_EQ(second.getServers().size(), 1);
}
TEST_F(ServersSettingsTest, LegacyProfileAddedAfterMigrationIsMerged)
{
{
ServersSettings first(settingsPath, nullptr);
first.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "");
}
// An older build writes its own profile back in the legacy format after the
// array migration already happened.
writeLegacyServer(1, "Beta", "beta.example", "4748", "bob", "other", true, "site-b");
// A duplicate of a profile the array already holds must not be added twice.
writeLegacyServer(2, "Alpha duplicate", "alpha.example", "4748", "eve", "dupe", true, "");
{
QSettings settings(settingsFile(), QSettings::IniFormat);
settings.setValue("server/server_details/totalServers", 2);
}
ServersSettings settings(settingsPath, nullptr);
const auto servers = settings.getServers();
ASSERT_EQ(servers.size(), 2);
EXPECT_EQ(servers.at(0).saveName, QString("Alpha"));
EXPECT_EQ(servers.at(0).password, QString("secret"));
EXPECT_EQ(servers.at(1).saveName, QString("Beta"));
EXPECT_EQ(servers.at(1).host, QString("beta.example"));
EXPECT_EQ(servers.at(1).username, QString("bob"));
EXPECT_EQ(servers.at(1).password, QString("other"));
EXPECT_TRUE(servers.at(1).savePassword);
EXPECT_EQ(servers.at(1).site, QString("site-b"));
QSettings raw(settingsFile(), QSettings::IniFormat);
EXPECT_FALSE(raw.contains("server/server_details/totalServers"));
EXPECT_FALSE(raw.contains("server/server_details/saveName1"));
EXPECT_FALSE(raw.contains("server/server_details/server2"));
// The merge is persisted, so a further start adds nothing.
ServersSettings again(settingsPath, nullptr);
ASSERT_EQ(again.getServers().size(), 2);
}
TEST_F(ServersSettingsTest, OutOfRangeIndexReturnsNullopt)
{
ServersSettings settings(settingsPath, nullptr);
EXPECT_FALSE(settings.getServer(-1).has_value());
EXPECT_FALSE(settings.getServer(0).has_value());
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

149
tests/spoiler_file_test.cpp Normal file
View file

@ -0,0 +1,149 @@
#include "client/network/update/card_spoiler/spoiler_file.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QTemporaryDir>
#include <gtest/gtest.h>
#ifdef Q_OS_UNIX
#include <csignal>
#include <sys/resource.h>
#include <unistd.h>
#endif
namespace
{
class SpoilerFileTest : public ::testing::Test
{
protected:
QTemporaryDir directory;
QString path;
QString error;
const QByteArray original = "<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>";
void SetUp() override
{
ASSERT_TRUE(directory.isValid());
path = directory.filePath("spoiler.xml");
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
ASSERT_EQ(file.write(original), original.size());
}
QByteArray read()
{
QFile file(path);
EXPECT_TRUE(file.open(QIODevice::ReadOnly));
return file.readAll();
}
SpoilerFile::SaveResult save(const QByteArray &data)
{
return SpoilerFile::save(path, data, [] { return true; }, error);
}
};
TEST_F(SpoilerFileTest, InvalidDocumentsPreserveExistingFile)
{
for (const QByteArray &invalid :
{QByteArray(), QByteArray("<html><cards/></html>"),
QByteArray("<cockatrice_carddatabase version=\"2\"><cards/></cockatrice_carddatabase>"),
QByteArray("<cockatrice_carddatabase><cards/></cockatrice_carddatabase>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><sets/></cockatrice_carddatabase>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><cards>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase><extra/>")}) {
EXPECT_EQ(save(invalid), SpoilerFile::SaveResult::Failed);
EXPECT_FALSE(error.isEmpty());
EXPECT_EQ(read(), original);
}
}
TEST_F(SpoilerFileTest, SupportedVersionsAreAccepted)
{
EXPECT_TRUE(SpoilerFile::validate(original).isEmpty());
EXPECT_TRUE(
SpoilerFile::validate("<cockatrice_carddatabase version=\"3\"><cards/></cockatrice_carddatabase>").isEmpty());
}
TEST_F(SpoilerFileTest, IdenticalFileIsUnchanged)
{
EXPECT_EQ(save(original), SpoilerFile::SaveResult::Unchanged);
EXPECT_TRUE(error.isEmpty());
EXPECT_EQ(read(), original);
}
TEST_F(SpoilerFileTest, CompleteFileHashDetectsChangesAfterLargeCommonPrefix)
{
QByteArray changed = "<cockatrice_carddatabase version=\"4\"><!--";
changed += QByteArray(100000, 'x');
changed += "--><cards/></cockatrice_carddatabase>";
ASSERT_EQ(save(changed), SpoilerFile::SaveResult::Written);
changed.replace("<cards/>", "<cards></cards>");
EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Written);
EXPECT_EQ(read(), changed);
EXPECT_TRUE(error.isEmpty());
}
TEST_F(SpoilerFileTest, CancellationBeforeWritePreservesExistingFile)
{
EXPECT_EQ(SpoilerFile::save(path, original, [] { return false; }, error), SpoilerFile::SaveResult::Failed);
EXPECT_FALSE(error.isEmpty());
EXPECT_EQ(read(), original);
}
TEST_F(SpoilerFileTest, CancellationBeforeCommitPreservesExistingFile)
{
int checks = 0;
EXPECT_EQ(SpoilerFile::save(
path, original + "\n", [&checks] { return ++checks == 1; }, error),
SpoilerFile::SaveResult::Failed);
EXPECT_EQ(checks, 2);
EXPECT_FALSE(error.isEmpty());
EXPECT_EQ(read(), original);
EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"});
}
TEST_F(SpoilerFileTest, MissingParentIsCreated)
{
path = directory.filePath("nested/spoiler.xml");
EXPECT_EQ(save(original), SpoilerFile::SaveResult::Written);
EXPECT_EQ(read(), original);
}
TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt)
{
EXPECT_EQ(SpoilerFile::save(
QDir(path).filePath("spoiler.xml"), original, [] { return true; }, error),
SpoilerFile::SaveResult::Failed);
EXPECT_FALSE(error.isEmpty());
EXPECT_EQ(read(), original);
}
#ifdef Q_OS_UNIX
TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact)
{
// Limit only the child process: a real short write exercises QSaveFile's
// buffered flush failure without depending on disk capacity or file ownership.
const QByteArray changed = "<cockatrice_carddatabase version=\"4\"><!--" + QByteArray(100000, 'x') +
"--><cards/></cockatrice_carddatabase>";
EXPECT_EXIT(
{
struct rlimit limit;
if (getrlimit(RLIMIT_FSIZE, &limit) != 0) {
_exit(2);
}
limit.rlim_cur = 128;
if (setrlimit(RLIMIT_FSIZE, &limit) != 0) {
_exit(3);
}
std::signal(SIGXFSZ, SIG_IGN);
const auto result = save(changed);
_exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1);
},
::testing::ExitedWithCode(0), "");
EXPECT_EQ(read(), original);
EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"});
}
#endif
} // namespace

View file

@ -0,0 +1,35 @@
#ifndef COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H
#define COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H
#include <QAbstractItemModel>
#include <QAbstractItemModelTester>
#include <memory>
namespace test_support
{
/**
* @brief Keeps a QAbstractItemModelTester alive for the lifetime of a scope.
*
* The tester only observes the model while it exists, so construct this before
* the mutations a test wants to validate and keep it in scope until the end.
* Fatal reporting mode aborts on the first contract violation; a warning would
* let the test pass while printing a line nobody reads in CI.
*/
class ModelContractChecker
{
public:
explicit ModelContractChecker(QAbstractItemModel *model)
: tester(
std::make_unique<QAbstractItemModelTester>(model, QAbstractItemModelTester::FailureReportingMode::Fatal))
{
}
ModelContractChecker(const ModelContractChecker &) = delete;
ModelContractChecker &operator=(const ModelContractChecker &) = delete;
private:
std::unique_ptr<QAbstractItemModelTester> tester;
};
} // namespace test_support
#endif // COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H

View file

@ -0,0 +1,66 @@
#include "libcockatrice/utility/svg_recolor.h"
#include <QByteArray>
#include <gtest/gtest.h>
namespace
{
TEST(SvgRecolor, ReplacesEverySentinel)
{
const QByteArray svg = R"(<svg><path fill="currentColor"/><path stroke="currentColor"/></svg>)";
const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800");
EXPECT_EQ(result.count("#ff8800"), 2);
EXPECT_FALSE(result.contains("currentColor"));
}
TEST(SvgRecolor, ReplacesSentinelRegardlessOfCase)
{
const QByteArray svg = R"(<svg><path fill="CurrentColor"/><path stroke="CURRENTCOLOR"/></svg>)";
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ff8800").count("#ff8800"), 2);
}
TEST(SvgRecolor, LeavesTheRestOfTheDocumentIntact)
{
const QByteArray svg =
R"(<svg viewBox="0 0 24 24"><!-- an ordinary comment --><path d="M0 0h24v24" fill="currentColor"/></svg>)";
const QByteArray result = resolveSvgColorSentinel(svg, "#123456");
EXPECT_TRUE(result.contains(R"(viewBox="0 0 24 24")"));
EXPECT_TRUE(result.contains(R"(d="M0 0h24v24")"));
EXPECT_TRUE(result.contains("an ordinary comment"));
}
TEST(SvgRecolor, DoesNotModifyTheInput)
{
const QByteArray svg = R"(<svg><path fill="currentColor"/></svg>)";
const QByteArray original = svg;
resolveSvgColorSentinel(svg, "#000000");
EXPECT_EQ(svg, original);
}
TEST(SvgRecolor, PreservesBytesThatAreNotValidUtf8)
{
QByteArray svg = "<svg>";
svg.append("\xff\xfe");
svg.append(R"(<path fill="currentColor"/></svg>)");
const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800");
EXPECT_TRUE(result.contains("\xff\xfe"));
EXPECT_FALSE(result.contains("currentColor"));
EXPECT_EQ(result.count("#ff8800"), 1);
}
TEST(SvgRecolor, NoSentinelIsANoOp)
{
const QByteArray svg = R"(<svg><path fill="#abcdef"/></svg>)";
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg);
}
} // namespace

View file

@ -5,12 +5,14 @@ cockatrice_add_test(
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
${VERSION_STRING_CPP}
theme_migration_test.cpp
theme_icon_render_test.cpp
LIBS
libcockatrice_settings
libcockatrice_network
@ -18,4 +20,6 @@ cockatrice_add_test(
libcockatrice_protocol
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
DEFINITIONS
COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
)

View file

@ -0,0 +1,189 @@
#include "interface/theme_icon.h"
#include "interface/theme_manager.h"
#include <QApplication>
#include <QColor>
#include <QDir>
#include <QFile>
#include <QGuiApplication>
#include <QImage>
#include <QPalette>
#include <QPixmap>
#include <QSize>
#include <QStringList>
#include <QStringLiteral>
#include <QTemporaryDir>
#include <gtest/gtest.h>
namespace
{
/**
* @brief Creates the GUI application QPixmap needs, using the offscreen plugin.
*
* The theme-migration binary is headless; creating this lazily keeps its other
* tests free of a display dependency.
*/
void ensureGuiApplication()
{
static int argc = 1;
static char name[] = "theme_icon_test";
static char *argv[] = {name, nullptr};
// Leaked on purpose: a function-local static QApplication is destroyed before Qt's
// own library statics at exit, and the network module's destructors then run
// against a torn-down QObject layer (SEGV under AddressSanitizer).
static QApplication *app = new QApplication(argc, argv);
Q_UNUSED(app);
}
QString resourcesDir()
{
return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources"));
}
int countOpaquePixels(const QPixmap &pixmap, const QColor &expected)
{
const QImage image = pixmap.toImage();
int count = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
const QColor pixel = image.pixelColor(x, y);
if (pixel.alpha() > 200 && pixel.red() == expected.red() && pixel.green() == expected.green() &&
pixel.blue() == expected.blue()) {
++count;
}
}
}
return count;
}
QByteArray svgWith(const QByteArray &colorToken)
{
return QByteArrayLiteral(R"(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill=")") +
colorToken + QByteArrayLiteral(R"(" d="M2 2h20v20H2z"/></svg>)");
}
/**
* @brief Restores the global theme search paths and palette after each test.
*
* The icon engine reads both process-wide values, so a failing assertion must
* not leak a temporary theme directory or palette into the next test.
*/
class ThemeIconRenderTest : public ::testing::Test
{
protected:
void SetUp() override
{
qputenv("QT_QPA_PLATFORM", "offscreen");
ensureGuiApplication();
savedSearchPaths = QDir::searchPaths(QStringLiteral("theme"));
savedPalette = qApp->palette();
themeManager = new ThemeManager;
QDir::setSearchPaths(QStringLiteral("theme"), {resourcesDir()});
}
void TearDown() override
{
delete themeManager;
themeManager = nullptr;
QDir::setSearchPaths(QStringLiteral("theme"), savedSearchPaths);
qApp->setPalette(savedPalette);
}
QStringList savedSearchPaths; ///< Search paths captured before the test.
QPalette savedPalette; ///< Application palette captured before the test.
};
/** @brief A non-square request must letterbox, not squash, a 24×24 glyph. */
TEST_F(ThemeIconRenderTest, NonSquareRequestPreservesAspectRatio)
{
const QIcon icon = loadThemedIcon(QStringLiteral("icons/player"));
ASSERT_FALSE(icon.isNull());
EXPECT_EQ(icon.actualSize(QSize(20, 15)), QSize(15, 15));
const QPixmap pixmap = icon.pixmap(QSize(20, 15));
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15));
}
/** @brief The engine must follow palette flips, icon modes, and DPR. */
TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr)
{
QPalette palette = qApp->palette();
palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456")));
qApp->setPalette(palette);
const QIcon icon = loadThemedIcon(QStringLiteral("icons/search"));
ASSERT_FALSE(icon.isNull());
const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0);
ASSERT_FALSE(normal.isNull());
EXPECT_EQ(normal.size(), QSize(24, 24));
EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0);
EXPECT_GT(countOpaquePixels(normal, QColor(QStringLiteral("#123456"))), 0);
const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0);
ASSERT_FALSE(hidpi.isNull());
EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0);
EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(24, 24));
palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef")));
qApp->setPalette(palette);
const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0);
EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 0);
QPalette selectedPalette = qApp->palette();
selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00")));
qApp->setPalette(selectedPalette);
const QPixmap selected = icon.pixmap(QSize(24, 24), 1.0, QIcon::Selected, QIcon::Off);
EXPECT_GT(countOpaquePixels(selected, QColor(QStringLiteral("#00ff00"))), 0);
}
} // namespace
TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor)
{
QPalette palette;
palette.setColor(QPalette::WindowText, QColor(Qt::white));
qApp->setPalette(palette);
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"));
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(pixmap.size(), QSize(24, 24));
EXPECT_GT(countOpaquePixels(pixmap, Qt::white), 0);
}
TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull)
{
QTemporaryDir themeDir;
ASSERT_TRUE(themeDir.isValid());
QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg")));
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
file.write(svgWith("#000000"));
file.close();
QDir::addSearchPath(QStringLiteral("theme"), themeDir.path());
EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("plain.svg")).isNull());
EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("icons/search.png")).isNull());
}
TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved)
{
QTemporaryDir themeDir;
ASSERT_TRUE(themeDir.isValid());
QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("lower.svg")));
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
file.write(svgWith("currentcolor"));
file.close();
QDir::addSearchPath(QStringLiteral("theme"), themeDir.path());
QPalette palette;
palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456")));
qApp->setPalette(palette);
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg"));
ASSERT_FALSE(pixmap.isNull());
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0);
}

View file

@ -0,0 +1,248 @@
#include "interface/widgets/server/user/user_games_model.h"
#include <QAbstractItemModelTester>
#include <QCoreApplication>
#include <QPersistentModelIndex>
#include <QSignalSpy>
#include <gtest/gtest.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/server_message.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <memory>
RNG_Abstract *rng = nullptr; // required by the transitive server library; this suite sends no game commands
namespace
{
class GamesClient : public AbstractClient
{
public:
QList<CommandContainer> sent;
GamesClient()
{
setStatus(StatusLoggedIn);
}
~GamesClient() override
{
qDeleteAll(pendingCommands);
pendingCommands.clear();
}
void changeStatus(ClientStatus status)
{
setStatus(status);
}
void answer(int request, Response response)
{
response.set_cmd_id(sent.at(request).cmd_id());
ServerMessage message;
message.set_message_type(ServerMessage::RESPONSE);
*message.mutable_response() = response;
processProtocolItem(message);
}
protected:
void sendCommandContainer(const CommandContainer &command) override
{
sent.append(command);
}
};
Response gamesResponse(std::initializer_list<int> ids)
{
Response response;
response.set_response_code(Response::RespOk);
auto *games = response.MutableExtension(Response_GetGamesOfUser::ext);
for (int id : ids) {
auto *game = games->add_game_list();
game->set_game_id(id);
game->set_room_id(7);
}
return response;
}
using State = UserGamesModel::State;
TEST(UserGamesModel, OfflineUserDoesNotSendARequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", false);
model.refresh();
EXPECT_TRUE(client.sent.isEmpty());
EXPECT_EQ(State::Offline, model.getState());
EXPECT_FALSE(model.canRefresh());
EXPECT_EQ(0, model.rowCount());
}
TEST(UserGamesModel, PresenceLossInvalidatesTheOutstandingResponse)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
EXPECT_EQ(State::Loading, model.getState());
EXPECT_FALSE(model.canRefresh());
model.setUser("Alice", false);
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Offline, model.getState());
EXPECT_EQ(0, model.rowCount());
model.setUser("Alice", true);
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_TRUE(model.canRefresh());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, SwitchingBackToTheSameUserDoesNotAcceptTheirOldRequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
model.setUser("Bob", true);
model.setUser("Alice", true);
client.answer(2, gamesResponse({3}));
client.answer(0, gamesResponse({1}));
client.answer(1, gamesResponse({2}));
EXPECT_EQ(1, model.rowCount());
EXPECT_EQ(3, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, RefreshSupersedesThePreviousRequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
model.refresh();
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Loading, model.getState());
EXPECT_EQ(0, model.rowCount());
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, EmptySuccessAndMissingPayloadAreDifferentStates)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
client.answer(0, gamesResponse({}));
EXPECT_EQ(State::Empty, model.getState());
EXPECT_TRUE(model.canRefresh());
model.refresh();
Response missingPayload;
missingPayload.set_response_code(Response::RespOk);
client.answer(1, missingPayload);
EXPECT_EQ(State::Error, model.getState());
EXPECT_TRUE(model.canRefresh());
}
TEST(UserGamesModel, FailedRequestsCanBeRetried)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
Response error;
error.set_response_code(Response::RespNameNotFound);
client.answer(0, error);
EXPECT_EQ(State::Error, model.getState());
model.refresh();
client.answer(1, gamesResponse({4}));
EXPECT_EQ(State::Ready, model.getState());
}
TEST(UserGamesModel, DisconnectRejectsLateRepliesAndReconnectFetchesAgain)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
client.changeStatus(StatusDisconnecting);
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Disconnected, model.getState());
EXPECT_FALSE(model.canRefresh());
model.refresh();
EXPECT_EQ(1, client.sent.size());
client.changeStatus(StatusLoggedIn);
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
}
TEST(UserGamesModel, ClientDestructionClearsGamesAndDisablesRefresh)
{
auto client = std::make_unique<GamesClient>();
UserGamesModel model(client.get());
model.setUser("Alice", true);
client->answer(0, gamesResponse({1}));
client.reset();
EXPECT_EQ(State::Disconnected, model.getState());
EXPECT_EQ(0, model.rowCount());
EXPECT_FALSE(model.canRefresh());
model.refresh();
EXPECT_EQ(State::Disconnected, model.getState());
}
TEST(UserGamesModel, ResetInvalidatesAnOpenGameActionAndPreservesTheModelContract)
{
GamesClient client;
UserGamesModel model(&client);
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::Fatal);
model.setUser("Alice", true);
client.answer(0, gamesResponse({1, 2, 3}));
QPersistentModelIndex selected(model.index(1));
EXPECT_TRUE(selected.isValid());
model.refresh();
EXPECT_FALSE(selected.isValid());
EXPECT_EQ(0, model.rowCount());
client.answer(1, gamesResponse({4}));
EXPECT_EQ(1, model.rowCount());
}
TEST(UserGamesModel, StateListenerCanSelectAnotherUserBeforeTheRequestIsSent)
{
GamesClient client;
UserGamesModel model(&client);
bool switched = false;
QObject::connect(&model, &UserGamesModel::stateChanged, &model, [&] {
if (!switched && model.getState() == State::Loading) {
switched = true;
model.setUser("Bob", false);
}
});
model.setUser("Alice", true);
EXPECT_TRUE(client.sent.isEmpty());
EXPECT_EQ(State::Offline, model.getState());
}
TEST(UserGamesModel, TimedOutResponseCannotReplaceARetry)
{
GamesClient client;
UserGamesModel model(&client, 50);
model.setUser("Alice", true);
QSignalSpy changes(&model, &UserGamesModel::stateChanged);
ASSERT_TRUE(changes.wait(1000));
ASSERT_EQ(State::Error, model.getState());
EXPECT_TRUE(model.canRefresh());
model.refresh();
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Loading, model.getState());
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}