Keep cached image redirects within the inclusive hop limit

Accept a terminal URL reached by exactly the permitted number of redirects,
while preserving cycle and overlong-chain invalidation. Match Qt's redirect
classification by omitting HTTP 305 from both reply paths. Add focused
GoogleTest coverage for the cache resolver and retain current upstream.
This commit is contained in:
Bryce! 2026-11-04 15:59:11 -05:00
commit 4ee7b416a9
451 changed files with 15753 additions and 11787 deletions

View file

@ -171,13 +171,36 @@ endfunction()
# --- Unit tests ------------------------------------------------------------------
cockatrice_add_test(NAME image_redirect_chain_test SOURCES image_redirect_chain_test.cpp)
cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility)
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 +231,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 +363,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 +449,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 +507,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 +567,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 +589,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,82 @@
#include "../cockatrice/src/interface/card_picture_loader/redirect_chain.h"
#include <gtest/gtest.h>
namespace
{
using RedirectCache = QHash<QUrl, QPair<QUrl, QDateTime>>;
QUrl url(int index)
{
return QUrl(QStringLiteral("https://cards.example/%1").arg(index));
}
RedirectCache chain(int hops)
{
RedirectCache cache;
for (int index = 0; index < hops; ++index) {
cache.insert(url(index), {url(index + 1), QDateTime()});
}
return cache;
}
} // namespace
TEST(ImageRedirectChain, MissingHopReturnsLastKnownUrlWithoutRemovingMappings)
{
auto cache = chain(2);
const auto original = cache;
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(2));
EXPECT_EQ(cache, original);
EXPECT_EQ(resolveImageRedirects(url(9), cache, 10), url(9));
}
TEST(ImageRedirectChain, ExactlyMaxHopsResolvesAndKeepsCache)
{
auto cache = chain(10);
const auto original = cache;
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(10));
EXPECT_EQ(cache, original);
}
TEST(ImageRedirectChain, CycleReturnsOriginalAndPurgesVisitedMappings)
{
auto cache = chain(2);
cache.insert(url(2), {url(1), QDateTime()});
cache.insert(url(9), {url(10), QDateTime()});
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(0));
EXPECT_FALSE(cache.contains(url(0)));
EXPECT_FALSE(cache.contains(url(1)));
EXPECT_FALSE(cache.contains(url(2)));
EXPECT_TRUE(cache.contains(url(9)));
}
TEST(ImageRedirectChain, OverlongChainReturnsOriginalAndPurgesVisitedMappings)
{
auto cache = chain(12);
cache.insert(url(20), {url(21), QDateTime()});
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(0));
for (int index = 0; index < 10; ++index) {
EXPECT_FALSE(cache.contains(url(index)));
}
EXPECT_TRUE(cache.contains(url(10)));
EXPECT_TRUE(cache.contains(url(11)));
EXPECT_TRUE(cache.contains(url(20)));
}
TEST(ImageRedirectChain, EmptyTargetStopsAtItsSource)
{
auto cache = chain(1);
cache.insert(url(1), {QUrl(), QDateTime()});
const auto original = cache;
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(1));
EXPECT_EQ(cache, original);
}
TEST(ImageRedirectChain, ZeroHopsDoesNotFollowOrRemoveMappings)
{
auto cache = chain(1);
EXPECT_EQ(resolveImageRedirects(url(5), cache, 0), url(5));
EXPECT_TRUE(cache.contains(url(0)));
EXPECT_EQ(resolveImageRedirects(url(0), cache, 0), url(0));
EXPECT_TRUE(cache.contains(url(0)));
}

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/raw_json_scanner.h"
#include "libcockatrice/card/card_info.h"
@ -6,6 +7,7 @@
#include <QByteArray>
#include <QChar>
#include <QDate>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@ -20,6 +22,7 @@
#include <QRegularExpressionMatch>
#include <QSharedPointer>
#include <QString>
#include <QTemporaryDir>
#include <QUuid>
#include <QVariant>
#include <QVariantMap>
@ -30,6 +33,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:
@ -1140,6 +1149,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();
}