mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-06 01:53:25 -08:00
Merge current upstream into random-reveal review continuation
Preserve SET_PLAYER_ORDER alongside REVEAL_RANDOM_CARDS in the shared command enum; retain current upstream source and the reviewed PlayerActions routing.
This commit is contained in:
commit
e32bd364bd
443 changed files with 15635 additions and 11767 deletions
|
|
@ -177,7 +177,29 @@ cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIB
|
|||
cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME svg_recolor_test SOURCES svg_recolor_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
user_games_model_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/server/user/user_games_model.cpp
|
||||
user_games_model_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network_client_abstract
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
${CMAKE_SOURCE_DIR}/libcockatrice_network
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
spoiler_file_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp
|
||||
spoiler_file_test.cpp
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
)
|
||||
# The installer's holder probe is Windows-only, but the wording it produces and the
|
||||
# question which programs are the payload's own are plain C++ and are what a stuck user
|
||||
# has to act on, so they are tested everywhere.
|
||||
|
|
@ -208,6 +230,18 @@ cockatrice_add_test(
|
|||
libcockatrice_deck_list
|
||||
libcockatrice_utility
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
card_art_crop_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/cards/card_art_utils.cpp
|
||||
${VERSION_STRING_CPP}
|
||||
card_art_crop_test.cpp
|
||||
LIBS
|
||||
libcockatrice_card
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
server_rate_limiter_test
|
||||
|
|
@ -339,6 +373,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
|
||||
|
|
@ -391,18 +459,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
|
||||
)
|
||||
|
|
@ -457,6 +517,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
|
||||
|
|
@ -504,7 +577,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)
|
||||
|
|
@ -524,3 +599,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()
|
||||
|
|
|
|||
92
tests/card_art_crop_test.cpp
Normal file
92
tests/card_art_crop_test.cpp
Normal 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();
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
# ------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
188
tests/carddatabase/visual_display_model_test.cpp
Normal file
188
tests/carddatabase/visual_display_model_test.cpp
Normal 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
|
||||
116
tests/client_login_status_test.cpp
Normal file
116
tests/client_login_status_test.cpp
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
190
tests/deck_state_manager_test.cpp
Normal file
190
tests/deck_state_manager_test.cpp
Normal 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
195
tests/font_config_test.cpp
Normal 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();
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
110
tests/game/card_state_test.cpp
Normal file
110
tests/game/card_state_test.cpp
Normal 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());
|
||||
}
|
||||
107
tests/game/card_zone_logic_test.cpp
Normal file
107
tests/game/card_zone_logic_test.cpp
Normal 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
64
tests/game/data/cards.xml
Normal 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>
|
||||
4
tests/game/data/spoilers.xml
Normal file
4
tests/game/data/spoilers.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<cockatrice_carddatabase version="4">
|
||||
<cards />
|
||||
</cockatrice_carddatabase>
|
||||
21
tests/game/data/tokens.xml
Normal file
21
tests/game/data/tokens.xml
Normal 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>
|
||||
566
tests/game/game_event_handler_test.cpp
Normal file
566
tests/game/game_event_handler_test.cpp
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
129
tests/game/game_test_fixture.h
Normal file
129
tests/game/game_test_fixture.h
Normal 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
|
||||
126
tests/game/game_test_main.cpp
Normal file
126
tests/game/game_test_main.cpp
Normal 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();
|
||||
}
|
||||
119
tests/game/player_actions_test.cpp
Normal file
119
tests/game/player_actions_test.cpp
Normal 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
349
tests/game/replay_test.cpp
Normal 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());
|
||||
}
|
||||
3
tests/icon_assets/CMakeLists.txt
Normal file
3
tests/icon_assets/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
cockatrice_add_test(
|
||||
NAME icon_assets_test SOURCES icon_assets_test.cpp DEFINITIONS COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
)
|
||||
241
tests/icon_assets/icon_assets_test.cpp
Normal file
241
tests/icon_assets/icon_assets_test.cpp
Normal 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();
|
||||
}
|
||||
149
tests/local_client_lifetime_test.cpp
Normal file
149
tests/local_client_lifetime_test.cpp
Normal 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
|
||||
17
tests/model_contract/CMakeLists.txt
Normal file
17
tests/model_contract/CMakeLists.txt
Normal 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/"
|
||||
)
|
||||
137
tests/model_contract/model_contract_test.cpp
Normal file
137
tests/model_contract/model_contract_test.cpp
Normal 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();
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
268
tests/movecard_tests/player_order_test.cpp
Normal file
268
tests/movecard_tests/player_order_test.cpp
Normal 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);
|
||||
}
|
||||
84
tests/moxfield_signature_spells_test.cpp
Normal file
84
tests/moxfield_signature_spells_test.cpp
Normal 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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
234
tests/server_forced_password_change_test.cpp
Normal file
234
tests/server_forced_password_change_test.cpp
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
228
tests/settings/servers_settings_test.cpp
Normal file
228
tests/settings/servers_settings_test.cpp
Normal 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
149
tests/spoiler_file_test.cpp
Normal 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
|
||||
35
tests/support/model_contract_checker.h
Normal file
35
tests/support/model_contract_checker.h
Normal 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
|
||||
66
tests/svg_recolor_test.cpp
Normal file
66
tests/svg_recolor_test.cpp
Normal 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
|
||||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
189
tests/theme_migration/theme_icon_render_test.cpp
Normal file
189
tests/theme_migration/theme_icon_render_test.cpp
Normal 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);
|
||||
}
|
||||
248
tests/user_games_model_test.cpp
Normal file
248
tests/user_games_model_test.cpp
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue