Merge branch 'master' into tooomm-qt5

This commit is contained in:
tooomm 2026-10-04 12:27:58 +02:00 • committed by GitHub
commit 94b85b8940
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
266 changed files with 11392 additions and 930 deletions

View file

@ -14,6 +14,11 @@ add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test)
add_test(NAME server_developer_role_test COMMAND server_developer_role_test)
add_test(NAME server_game_join_test COMMAND server_game_join_test)
add_test(NAME warning_categories_test COMMAND warning_categories_test)
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
if(TARGET Qt6::lrelease)
add_test(NAME translation_loader_test COMMAND translation_loader_test)
endif()
add_test(NAME report_categories_test COMMAND report_categories_test)
add_test(NAME lag_monitor_test COMMAND lag_monitor_test)
add_test(NAME latency_tracker_test COMMAND latency_tracker_test)
add_test(NAME metrics_registry_test COMMAND metrics_registry_test)
@ -35,6 +40,35 @@ add_executable(server_rate_limiter_test server_rate_limiter_test.cpp)
add_executable(server_developer_role_test server_developer_role_test.cpp)
add_executable(server_game_join_test server_game_join_test.cpp)
add_executable(warning_categories_test warning_categories_test.cpp)
if(TARGET Qt6::lrelease)
add_executable(translation_loader_test translation_loader_test.cpp)
# QTranslator only accepts real .qm files, so the translation loader test gets one compiled from its
# own .ts fixture rather than a stub.
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")
add_custom_command(
OUTPUT "${TRANSLATION_LOADER_TEST_QM}"
COMMAND Qt6::lrelease "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" -qm "${TRANSLATION_LOADER_TEST_QM}"
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts"
COMMENT "Compiling translation_loader_test.ts"
)
add_custom_target(translation_loader_test_qm DEPENDS "${TRANSLATION_LOADER_TEST_QM}")
add_dependencies(translation_loader_test translation_loader_test_qm)
target_compile_definitions(translation_loader_test PRIVATE TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}")
# A multi-config generator (MSBuild, Xcode) puts the executable in a per-config subdirectory while
# the .qm stays in the current binary directory, so copy the fixture next to the binary: that is the
# layout ABuildTreeFindsTheTranslationsNextToTheExecutable is about, and the only one it finds on
# those generators.
add_custom_command(
TARGET translation_loader_test
POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TRANSLATION_LOADER_TEST_QM}"
"$<TARGET_FILE_DIR:translation_loader_test>/"
VERBATIM
)
endif()
add_executable(report_categories_test report_categories_test.cpp)
add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp)
target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
add_executable(latency_tracker_test latency_tracker_test.cpp)
@ -51,6 +85,17 @@ add_executable(
)
target_include_directories(loader_local_matching_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt.
if(WITH_CLIENT)
add_executable(
single_instance_manager_test ${CMAKE_SOURCE_DIR}/cockatrice/src/single_instance_manager.cpp
single_instance_manager_test.cpp
)
target_include_directories(single_instance_manager_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
add_test(NAME single_instance_manager_test COMMAND single_instance_manager_test)
endif()
# Find GTest
find_package(GTest)
@ -90,10 +135,15 @@ if(NOT GTEST_FOUND)
add_dependencies(server_developer_role_test gtest)
add_dependencies(server_game_join_test gtest)
add_dependencies(warning_categories_test gtest)
if(TARGET translation_loader_test)
add_dependencies(translation_loader_test gtest)
endif()
add_dependencies(report_categories_test gtest)
add_dependencies(lag_monitor_test gtest)
add_dependencies(latency_tracker_test gtest)
add_dependencies(metrics_registry_test gtest)
add_dependencies(loader_local_matching_test gtest)
add_dependencies(single_instance_manager_test gtest)
endif()
include_directories(${GTEST_INCLUDE_DIRS})
@ -136,6 +186,14 @@ target_link_libraries(
target_link_libraries(
warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${QT_MODULES_TEST}
)
if(TARGET translation_loader_test)
target_link_libraries(
translation_loader_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${QT_MODULES_TEST}
)
endif()
target_link_libraries(
report_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${QT_MODULES_TEST}
)
target_link_libraries(lag_monitor_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${QT_MODULES_TEST})
target_link_libraries(
latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${QT_MODULES_TEST}
@ -146,6 +204,10 @@ target_link_libraries(
loader_local_matching_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${QT_MODULES_TEST}
)
if(WITH_CLIENT)
target_link_libraries(single_instance_manager_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
endif()
add_subdirectory(card_zone_algorithms)
add_subdirectory(carddatabase)
add_subdirectory(deck_list_model)
@ -154,3 +216,14 @@ add_subdirectory(loading_from_clipboard)
add_subdirectory(movecard_tests)
add_subdirectory(oracle)
add_subdirectory(settings)
# theme_migration_test compiles client sources, whose headers are only reachable from a client build.
if(WITH_CLIENT)
add_subdirectory(theme_migration)
endif()
# picture_loader_benchmark links libcockatrice_settings, which only exists when
# a client/UI-capable target is being built.
if(WITH_ORACLE OR WITH_CLIENT)
add_subdirectory(picture_loader_benchmark)
endif()

View file

@ -2,6 +2,9 @@
#include "test_card_database_path_provider.h"
#include "gtest/gtest.h"
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/printing/printing_info.h>
#include <libcockatrice/filters/filter_string.h>
#include <libcockatrice/interfaces/noop_card_preference_provider.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
@ -71,10 +74,142 @@ QUERY(Color2, cat, "c:gw", true)
QUERY(Color3, cat, "c!g", true)
QUERY(Color4, cat, "c!gw", false)
QUERY(SetCodeCaseInsensitive1, cat, "set:cat", true)
QUERY(SetCodeCaseInsensitive2, cat, "set:CAT", true)
QUERY(SetCodeCaseInsensitive3, cat, "set:CAt", true)
QUERY(SetCodeShortForm, cat, "e:cat", true)
QUERY(SetCodeWrongSet, cat, "set:who", false)
QUERY(SetCodeWrongSet2, doctor, "set:cat", false)
QUERY(BracketNextToUnquotedString, cat, "(o:woof OR o:meow)", true)
CardInfoPtr localizedCat()
{
CardInfoPtr localized = CardInfo::newInstance("Cat", "Meow!", false, {}, {}, {}, {}, {});
localized->setLocalizedName("de", "Kater");
localized->setLocalizedText("de", "miaut");
return localized;
}
TEST_F(CardQuery, SearchLanguageEnglishMatchesOnlyEnglish)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::English}).check(localized));
ASSERT_FALSE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::English}).check(localized));
}
TEST_F(CardQuery, SearchLanguageSelectedMatchesLocalizedNameAndText)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized));
ASSERT_TRUE(FilterString("o:miaut", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized));
ASSERT_FALSE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized));
}
TEST_F(CardQuery, SearchLanguageSelectedFallsBackToEnglishForUntranslatedCards)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"fr", SearchLanguageMode::Selected}).check(localized));
ASSERT_FALSE(FilterString("Kater", CardSearchLanguage{"fr", SearchLanguageMode::Selected}).check(localized));
}
TEST_F(CardQuery, SearchLanguageBothMatchesEitherLanguage)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized));
ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized));
}
TEST_F(CardQuery, SearchLanguageIsBoundPerInstance)
{
const CardData localized = localizedCat();
FilterString germanQuery("Kater", CardSearchLanguage{"de", SearchLanguageMode::Selected});
ASSERT_TRUE(germanQuery.check(localized));
// Constructing an English-bound instance afterwards must not change the
// language the earlier instance searches in.
FilterString englishQuery("Kater", CardSearchLanguage{"", SearchLanguageMode::English});
ASSERT_FALSE(englishQuery.check(localized));
ASSERT_TRUE(germanQuery.check(localized));
}
} // namespace
class SetQuery : public ::testing::Test
{
protected:
void SetUp() override
{
// EOE and EOC share a release date, like a set and its Commander counterpart.
const QDate sharedReleaseDate(2026, 3, 13);
mainSet = CardSet::newInstance(&controller, "EOE", "Edge of Eternities", "expansion", sharedReleaseDate,
CardSet::PriorityPrimary);
commanderSet = CardSet::newInstance(&controller, "EOC", "Edge of Eternities Commander", "commander",
sharedReleaseDate, CardSet::PrioritySecondary);
oldSet = CardSet::newInstance(&controller, "OLD", "Older Set", "expansion", QDate(2020, 1, 1),
CardSet::PriorityPrimary);
inBothSets = newCardWithPrintings({{"EOE", mainSet}, {"EOC", commanderSet}});
onlyInCommanderSet = newCardWithPrintings({{"EOC", commanderSet}});
onlyInOldSet = newCardWithPrintings({{"OLD", oldSet}});
// SetExpression resolves set codes to release dates through the global set list.
auto *database = CardDatabaseManager::getInstance();
database->addSet(mainSet);
database->addSet(commanderSet);
database->addSet(oldSet);
}
CardInfoPtr newCardWithPrintings(const QList<QPair<QString, CardSetPtr>> &printings)
{
SetToPrintingsMap setsInfo;
for (const auto &printing : printings) {
setsInfo[printing.first].append(PrintingInfo(printing.second));
}
return CardInfo::newInstance("Test Card", "", false, {}, {}, {}, setsInfo, CardInfo::UiAttributes());
}
NoopCardSetPriorityController controller;
CardSetPtr mainSet;
CardSetPtr commanderSet;
CardSetPtr oldSet;
CardInfoPtr inBothSets;
CardInfoPtr onlyInCommanderSet;
CardInfoPtr onlyInOldSet;
};
TEST_F(SetQuery, ExactMatchSeparatesSameDaySets)
{
EXPECT_TRUE(FilterString("set:EOE").check(inBothSets));
EXPECT_TRUE(FilterString("e:EOE").check(inBothSets));
EXPECT_TRUE(FilterString("set:EOC").check(inBothSets));
EXPECT_FALSE(FilterString("set:EOE").check(onlyInCommanderSet));
EXPECT_TRUE(FilterString("set:EOC").check(onlyInCommanderSet));
}
TEST_F(SetQuery, ExactMatchIsCaseInsensitive)
{
EXPECT_TRUE(FilterString("set:eoe").check(inBothSets));
EXPECT_TRUE(FilterString("set:EoE").check(inBothSets));
EXPECT_FALSE(FilterString("set:eoe").check(onlyInCommanderSet));
}
TEST_F(SetQuery, NotEqualsMatchesPrintingsOutsideTheSet)
{
EXPECT_FALSE(FilterString("set!EOE").check(inBothSets));
EXPECT_TRUE(FilterString("set!EOE").check(onlyInCommanderSet));
}
TEST_F(SetQuery, ComparisonMatchesByReleaseDate)
{
// OLD (2020) predates EOE/EOC (2026-03-13), so comparison operators still use release dates.
EXPECT_TRUE(FilterString("set<EOE").check(onlyInOldSet));
EXPECT_FALSE(FilterString("set>EOE").check(onlyInOldSet));
EXPECT_TRUE(FilterString("set>=EOE").check(inBothSets));
EXPECT_FALSE(FilterString("set<EOE").check(inBothSets));
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);

View file

@ -68,6 +68,18 @@ protected:
loader = nullptr;
}
/**
* @brief Destroys and rebuilds the loader so the CUSTOM index is re-scanned.
*
* The CUSTOM-folder index is snapshotted at construction; rebuild it after
* writing files so freshly placed images are discoverable.
*/
void rebuildLoader()
{
delete loader;
loader = new CardPictureLoaderLocal(nullptr);
}
/**
* @brief Writes a valid 1x1 PNG under the sandboxed pics path.
*/
@ -162,6 +174,28 @@ TEST_F(LocalMatcherTest, SetFolderCandidateTakesPrecedenceOverRootFallback)
EXPECT_EQ(image.pixelColor(0, 0), QColor(Qt::red)) << "The set-folder candidate must be preferred";
}
TEST_F(LocalMatcherTest, CustomSubfolderResolvesExactFile)
{
// Images in the CUSTOM folder are indexed by their full file path (extension included),
// unlike the extension-less candidate paths built from the naming schemes.
writePngUnderPics("CUSTOM/poker/TestCard.png");
rebuildLoader();
const QImage image = loader->tryLoad(cardFor("TestCard", "", ""));
EXPECT_FALSE(image.isNull()) << "A CUSTOM-folder image in a subdirectory must resolve";
}
TEST_F(LocalMatcherTest, CustomSubfolderIgnoresSuffixedFiles)
{
writePngUnderPics("CUSTOM/poker/TestCard (1).png");
rebuildLoader();
const QImage image = loader->tryLoad(cardFor("TestCard", "", ""));
EXPECT_TRUE(image.isNull()) << "A suffixed CUSTOM-folder file must not satisfy an exact name lookup";
}
} // namespace
int main(int argc, char **argv)

View file

@ -0,0 +1,26 @@
# Manual benchmark hitting the real card image hosts. Not registered with
# add_test(): it requires a cards.xml, touches the network for minutes at a
# time, and needs network access. Build it explicitly and run by hand.
add_executable(
picture_loader_benchmark_test
${VERSION_STRING_CPP}
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
../../cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
../../cockatrice/src/client/settings/cache_settings.h
picture_loader_benchmark.cpp
settings_cache_mock.cpp
)
target_include_directories(picture_loader_benchmark_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
target_link_libraries(
picture_loader_benchmark_test
libcockatrice_card
libcockatrice_settings
libcockatrice_interfaces
libcockatrice_utility
Threads::Threads
${TEST_QT_MODULES}
)

View file

@ -0,0 +1,547 @@
/*
* Picture loader benchmark / regression suite against the real card image hosts.
*
* Deliberately not registered with ctest: it hits live Scryfall / Gatherer
* endpoints at ~10 requests per second and takes minutes. Run it by hand.
*
* picture_loader_benchmark_test --carddb /path/to/cards.xml [options]
*
* Modes
* -----
* default : for every URL template in the configured download list (or for each
* --url given), load #count pictures twice: once cold (network) and
* once cached (served from the QNetworkDiskCache). Both passes must
* load every card with zero failures. The cached pass must complete
* well under the cold time, which is the regression gate for serving
* cached pictures instead of re-fetching them. The cold pass must stay
* above a pacing lower bound, the regression gate for burst-free
* request throttling.
* --stress: two CardPictureLoaderWorker instances loading the same cards
* concurrently against one host (~20 req/s aggregate), which forces
* real 429 responses. Both workers must still complete 100% of their
* cards via the shared backoff logic.
*/
#include "client/settings/cache_settings.h"
#include "interface/card_picture_loader/card_picture_loader_worker.h"
#include "interface/card_picture_loader/card_picture_to_load.h"
#include <QCoreApplication>
#include <QDir>
#include <QElapsedTimer>
#include <QEventLoop>
#include <QFile>
#include <QImage>
#include <QList>
#include <QMessageLogContext>
#include <QMetaType>
#include <QMutex>
#include <QTemporaryDir>
#include <QThread>
#include <QTimer>
#include <QUrl>
#include <QtLogging>
#include <atomic>
#include <cstdio>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/printing/exact_card.h>
#include <libcockatrice/card/printing/printing_info.h>
#include <libcockatrice/interfaces/interface_card_database_path_provider.h>
#include <libcockatrice/interfaces/noop_card_preference_provider.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
#include <libcockatrice/settings/download_settings.h>
#include <optional>
class BenchmarkCardDatabasePathProvider : public ICardDatabasePathProvider
{
public:
BenchmarkCardDatabasePathProvider(QString _cardsXml, QString _customSetsDir)
: cardsXml(std::move(_cardsXml)), customSetsDir(std::move(_customSetsDir))
{
}
QString getCardDatabasePath() const override
{
return cardsXml;
}
QString getCustomCardDatabasePath() const override
{
return customSetsDir;
}
QString getTokenDatabasePath() const override
{
return QString();
}
QString getSpoilerCardDatabasePath() const override
{
return QString();
}
private:
QString cardsXml;
QString customSetsDir;
};
struct PassResult
{
int enqueued = 0;
int finished = 0;
int failed = 0;
qint64 elapsedMs = 0;
QStringList failedCards;
};
static QList<ExactCard> selectCardsForTemplate(CardDatabase &db, const QString &urlTemplate, int maxCards)
{
QList<ExactCard> selected;
const QList<CardInfoPtr> cards = db.getCardList().values();
for (const CardInfoPtr &card : cards) {
if (selected.size() >= maxCards) {
break;
}
const SetToPrintingsMap &sets = card->getSets();
if (sets.isEmpty()) {
continue;
}
const QList<PrintingInfo> printings = sets.first();
if (printings.isEmpty()) {
continue;
}
const ExactCard cardToLoad(card, printings.first());
if (CardPictureToLoad(cardToLoad).transformUrl(urlTemplate).isEmpty()) {
continue;
}
selected.append(cardToLoad);
}
return selected;
}
static PassResult runPass(CardPictureLoaderWorker *worker, const QList<ExactCard> &cards, int timeoutMs)
{
PassResult result;
result.enqueued = cards.size();
QEventLoop loop;
QTimer watchdog;
watchdog.setSingleShot(true);
watchdog.setInterval(timeoutMs);
QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit);
QElapsedTimer clock;
QObject::connect(worker, &CardPictureLoaderWorker::imageLoaded, &loop,
[&](const ExactCard &card, const QImage &image) {
++result.finished;
if (image.isNull()) {
++result.failed;
if (result.failedCards.size() < 10) {
result.failedCards.append(card.getName());
}
}
if (result.finished >= result.enqueued) {
loop.quit();
}
});
clock.start();
for (const ExactCard &card : cards) {
worker->enqueueImageLoad(card);
}
watchdog.start();
loop.exec();
result.elapsedMs = clock.elapsed();
return result;
}
struct StressResult
{
PassResult a;
PassResult b;
int http429Count = 0;
};
struct LogCounters
{
int http429 = 0;
QMutex mutex;
};
static std::atomic<LogCounters *> s_activeCounters{nullptr};
static QtMessageHandler s_previousMessageHandler = nullptr;
static void stressLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
if (LogCounters *counters = s_activeCounters.load(std::memory_order_acquire);
counters && msg.contains(QStringLiteral("Too many requests from"))) {
QMutexLocker locker(&counters->mutex);
++counters->http429;
}
if (s_previousMessageHandler) {
s_previousMessageHandler(type, context, msg);
} else {
// qInstallMessageHandler() reports the built-in handler as nullptr, so a plain `if` would
// swallow every message - including the 429 warnings this run is meant to surface. Fall
// back to Qt's message pattern written to stderr instead.
std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, msg)));
}
}
// Stops a worker's thread and frees it. shutdownThread()'s bounded wait guarantees that the worker
// object was freed by its finished() -> deleteLater chain, so the thread itself can then be deleted
// safely. A worker whose thread refused to stop is left alone (and leaked) rather than freed while
// still running.
static void destroyWorker(CardPictureLoaderWorker *worker)
{
if (!worker) {
return;
}
QThread *thread = worker->workerThread();
if (worker->shutdownThread()) {
delete thread;
}
}
static StressResult runStress(CardPictureLoaderWorker *workerA,
CardPictureLoaderWorker *workerB,
const QList<ExactCard> &cards,
int timeoutMs)
{
StressResult result;
result.a.enqueued = cards.size();
result.b.enqueued = cards.size();
int completed = 0;
QMutex completedMutex;
QEventLoop loop;
QTimer watchdog;
watchdog.setSingleShot(true);
watchdog.setInterval(timeoutMs);
QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit);
const auto finishOne = [&](PassResult &pass, const ExactCard &card, const QImage &image) {
++pass.finished;
if (image.isNull()) {
++pass.failed;
if (pass.failedCards.size() < 10) {
pass.failedCards.append(card.getName());
}
}
QMutexLocker locker(&completedMutex);
++completed;
if (completed >= result.a.enqueued + result.b.enqueued) {
loop.quit();
}
};
QElapsedTimer clock;
QObject::connect(workerA, &CardPictureLoaderWorker::imageLoaded, &loop,
[&](const ExactCard &card, const QImage &image) { finishOne(result.a, card, image); });
QObject::connect(workerB, &CardPictureLoaderWorker::imageLoaded, &loop,
[&](const ExactCard &card, const QImage &image) { finishOne(result.b, card, image); });
// Count 429 responses as seen by the shared rate limiter.
LogCounters counters;
s_activeCounters = &counters;
s_previousMessageHandler = qInstallMessageHandler(stressLogHandler);
clock.start();
for (const ExactCard &card : cards) {
workerA->enqueueImageLoad(card);
workerB->enqueueImageLoad(card);
}
watchdog.start();
loop.exec();
const qint64 elapsedMs = clock.elapsed();
result.a.elapsedMs = elapsedMs;
result.b.elapsedMs = elapsedMs;
// Stop both workers before touching the counters or restoring the message handler: their
// threads log from stressLogHandler, and must not outlive the stack-local counters (which is
// guaranteed on the watchdog path, where requests and deferred retries are still pending).
destroyWorker(workerA);
destroyWorker(workerB);
result.http429Count = counters.http429;
s_activeCounters.store(nullptr, std::memory_order_release);
qInstallMessageHandler(s_previousMessageHandler);
return result;
}
static QString formatDuration(qint64 ms)
{
return QStringLiteral("%1.%2 s").arg(ms / 1000).arg((ms % 1000) / 100);
}
static bool likelyRedirects(const QString &urlTemplate)
{
return urlTemplate.contains(QStringLiteral("api.scryfall.com"));
}
static QString hostOf(const QString &urlTemplate)
{
return QUrl(urlTemplate).host();
}
static void printUsage()
{
std::printf("usage: picture_loader_benchmark_test --carddb <path> [options]\n"
"\n"
"Loads card pictures from the real configured hosts (not a mock server) and\n"
"verifies the picture loader's pacing / cache 429 behavior.\n"
"\n"
"options:\n"
" --carddb <path> cards.xml to load card data from (required)\n"
" --count <N> cards to load per template (default 300)\n"
" --url <template> test only this URL template; repeatable\n"
" --stress run the two-worker concurrency stress instead\n"
" --stress-url <t> template used by --stress (default: scryfall uuid)\n"
" --timeout-min <N> per-pass watchdog in minutes (default auto)\n"
" --cache-dir <dir> reuse this directory as the sandbox root; non-empty\n"
" cache skips the cold pacing lower bound\n"
" --help show this help\n");
}
int main(int argc, char **argv)
{
QString cardDbArg;
QStringList explicitUrls;
int count = 300;
bool stress = false;
bool stressUrlSet = false;
QString stressUrl(QStringLiteral("https://api.scryfall.com/cards/!set:uuid!?format=image"));
QString cacheDirArg;
std::optional<int> timeoutMin;
QStringList args;
for (int i = 1; i < argc; ++i) {
args.append(QString::fromLocal8Bit(argv[i]));
}
for (int i = 0; i < args.size(); ++i) {
const QString &arg = args.at(i);
const auto value = [&]() -> QString { return i + 1 < args.size() ? args.at(++i) : QString(); };
if (arg == QLatin1String("--carddb")) {
cardDbArg = value();
} else if (arg == QLatin1String("--count")) {
count = value().toInt();
} else if (arg == QLatin1String("--url")) {
explicitUrls.append(value());
} else if (arg == QLatin1String("--stress")) {
stress = true;
} else if (arg == QLatin1String("--stress-url")) {
stressUrl = value();
stressUrlSet = true;
} else if (arg == QLatin1String("--timeout-min")) {
bool ok = false;
const int parsed = value().toInt(&ok);
if (!ok || parsed <= 0) {
std::fprintf(stderr, "error: --timeout-min must be a positive integer\n");
return 2;
}
timeoutMin = parsed;
} else if (arg == QLatin1String("--cache-dir")) {
cacheDirArg = value();
} else if (arg == QLatin1String("--help") || arg == QLatin1String("-h")) {
printUsage();
return 0;
} else {
std::fprintf(stderr, "unknown argument: %s\n", qPrintable(arg));
printUsage();
return 2;
}
}
if (cardDbArg.isEmpty()) {
std::fprintf(stderr, "error: --carddb is required (a cards.xml generated by Oracle)\n");
printUsage();
return 2;
}
if (count <= 0) {
std::fprintf(stderr, "error: --count must be positive\n");
return 2;
}
// In --stress mode an explicit --url selects the template to hammer, matching --url's meaning
// in the normal mode; only fall back to the built-in Scryfall template when neither --stress-url
// nor --url was supplied.
if (stress && !stressUrlSet && !explicitUrls.isEmpty()) {
stressUrl = explicitUrls.first();
}
QCoreApplication app(argc, argv);
// Unique names so a benchmark run can never read or write the real client's settings, cache or
// picture URLs on platforms where the XDG redirection below does not apply (macOS, Windows).
app.setApplicationName(QStringLiteral("Cockatrice-benchmark"));
app.setOrganizationName(QStringLiteral("Cockatrice-benchmark"));
app.setApplicationVersion(QStringLiteral("9.0.0-benchmark"));
// The ExactCard argument of imageLoaded crosses threads via a queued connection.
qRegisterMetaType<ExactCard>();
QTemporaryDir sandbox;
if (cacheDirArg.isEmpty() && !sandbox.isValid()) {
std::fprintf(stderr, "error: could not create a temporary sandbox directory\n");
return 2;
}
const QString rootDir = cacheDirArg.isEmpty() ? sandbox.path() : cacheDirArg;
QDir().mkpath(rootDir);
QDir().mkpath(rootDir + "/config");
QDir().mkpath(rootDir + "/data");
QDir().mkpath(rootDir + "/cache");
#ifdef Q_OS_LINUX
// Redirect every QStandardPaths lookup (and therefore SettingsCache paths) into the sandbox so
// the benchmark never touches user config or caches. XDG_* only affects Qt's path resolution on
// Linux; elsewhere the unique application/organization names above keep the run isolated.
qputenv("XDG_CONFIG_HOME", (rootDir + "/config").toUtf8());
qputenv("XDG_DATA_HOME", (rootDir + "/data").toUtf8());
qputenv("XDG_CACHE_HOME", (rootDir + "/cache").toUtf8());
#endif
// Derive the probe from SettingsCache rather than reconstructing it: Qt appends both the
// organization and the application name, so a hand-built path is easy to get wrong.
const bool warmStart = QDir(SettingsCache::instance().getNetworkCachePath())
.entryList(QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)
.size() > 0;
// Copied into the sandbox so the loader's binary cache ("cards.xml.cache")
// is written next to it instead of next to the user's file, and so a
// --cache-dir rerun can pick it up again.
const QString dataPath = SettingsCache::instance().getDataPath();
QDir().mkpath(dataPath);
const QString cardsXml = dataPath + "/cards.xml";
if (!QFile::exists(cardsXml)) {
if (!QFile::copy(cardDbArg, cardsXml)) {
std::fprintf(stderr, "error: could not copy %s to sandbox\n", qPrintable(cardDbArg));
return 2;
}
}
const QString customSetsDir = dataPath + "/customsets";
QDir().mkpath(customSetsDir);
auto *prefs = new NoopCardPreferenceProvider();
auto *priorityController = new NoopCardSetPriorityController();
auto *pathProvider = new BenchmarkCardDatabasePathProvider(cardsXml, customSetsDir);
CardDatabase db(nullptr, prefs, pathProvider, priorityController);
db.loadCardDatabases();
if (db.getLoadStatus() != Ok || db.getCardList().isEmpty()) {
std::fprintf(stderr, "error: failed to load card database from %s\n", qPrintable(cardsXml));
return 1;
}
const int availableCards = db.getCardList().size();
const QStringList urlsToTest =
explicitUrls.isEmpty() ? SettingsCache::instance().downloads().getAllURLs() : explicitUrls;
if (stress) {
const QList<ExactCard> cards = selectCardsForTemplate(db, stressUrl, count);
if (cards.isEmpty()) {
std::fprintf(stderr, "error: no cards satisfy template %s\n", qPrintable(stressUrl));
return 1;
}
SettingsCache::instance().downloads().setDownloadUrls({stressUrl});
std::printf("=== STRESS: 2 workers x %d cards, host %s ===\n", static_cast<int>(cards.size()),
qPrintable(hostOf(stressUrl)));
auto *workerA = new CardPictureLoaderWorker();
auto *workerB = new CardPictureLoaderWorker();
const int timeoutMs = (timeoutMin.has_value() ? timeoutMin.value() : 15) * 60 * 1000;
const StressResult result = runStress(workerA, workerB, cards, timeoutMs);
const bool completeA = result.a.finished >= result.a.enqueued;
const bool completeB = result.b.finished >= result.b.enqueued;
const bool zeroFailures = result.a.failed == 0 && result.b.failed == 0;
const bool pass = completeA && completeB && zeroFailures;
std::printf("worker A: %d/%d loaded, %d failed (%.2f images/s)\n", result.a.finished, result.a.enqueued,
result.a.failed,
result.a.elapsedMs > 0
? static_cast<double>(result.a.finished) * 1000.0 / static_cast<double>(result.a.elapsedMs)
: 0.0);
std::printf("worker B: %d/%d loaded, %d failed (%.2f images/s)\n", result.b.finished, result.b.enqueued,
result.b.failed,
result.b.elapsedMs > 0
? static_cast<double>(result.b.finished) * 1000.0 / static_cast<double>(result.b.elapsedMs)
: 0.0);
std::printf("429 responses observed: %d\n", result.http429Count);
std::printf("elapsed: %s\n", qPrintable(formatDuration(result.a.elapsedMs)));
if (result.a.failed > 0) {
std::printf(" worker A failures: %s\n", qPrintable(result.a.failedCards.join(QStringLiteral(", "))));
}
if (result.b.failed > 0) {
std::printf(" worker B failures: %s\n", qPrintable(result.b.failedCards.join(QStringLiteral(", "))));
}
std::printf("RESULT: %s\n", pass ? "PASS" : "FAIL");
return pass ? 0 : 1;
}
bool allPass = true;
std::printf("=== PICTURE LOADER BENCHMARK (%d cards available, %d per template)%s ===\n", availableCards, count,
warmStart ? ", WARM cache from previous run" : "");
for (const QString &urlTemplate : urlsToTest) {
const QList<ExactCard> cards = selectCardsForTemplate(db, urlTemplate, count);
if (cards.isEmpty()) {
std::printf("SKIP %-22s %-52s (no cards satisfy the template)\n", qPrintable(hostOf(urlTemplate)),
qPrintable(urlTemplate));
continue;
}
SettingsCache::instance().downloads().setDownloadUrls({urlTemplate});
const bool redirects = likelyRedirects(urlTemplate);
const qint64 perCardMs = redirects ? 200 : 100;
const int timeoutMs =
(timeoutMin.has_value() ? timeoutMin.value() : (cards.size() * perCardMs * 8 + 60000) / 60000) * 60 * 1000;
const qint64 coldLowerMs = static_cast<qint64>(cards.size()) * perCardMs / 2;
// Decoding and cache-reading scale with the card count, so a flat budget would spuriously
// fail larger --count runs served entirely from a healthy cache.
const qint64 cachedUpperMs = qMax<qint64>(2000, static_cast<qint64>(cards.size()) * 10);
// A fresh worker per pass: if the cold pass hits the watchdog, its outstanding cards stay
// in the worker's currentlyLoading set, which would make the cached pass silently skip them
// and burn its own watchdog; late cold replies would also be misattributed to the cached
// pass.
auto *coldWorker = new CardPictureLoaderWorker();
const PassResult cold = runPass(coldWorker, cards, timeoutMs);
destroyWorker(coldWorker);
auto *cachedWorker = new CardPictureLoaderWorker();
const PassResult cached = runPass(cachedWorker, cards, timeoutMs);
destroyWorker(cachedWorker);
const bool coldComplete = cold.finished >= cold.enqueued;
const bool coldZeroFailures = cold.failed == 0;
const bool coldPaced = warmStart || cold.elapsedMs >= coldLowerMs;
const bool cachedComplete = cached.finished >= cached.enqueued;
const bool cachedZeroFailures = cached.failed == 0;
const bool cachedFast = cached.elapsedMs < cachedUpperMs;
const bool pass =
coldComplete && coldZeroFailures && coldPaced && cachedComplete && cachedZeroFailures && cachedFast;
allPass = allPass && pass;
std::printf("%s %-22s %-52s cold %s (paced>=%s) cached %s (must be <%.0fs) | %d/%d %d/%d loaded, "
"%d/%d failed\n",
pass ? "PASS " : "FAIL ", qPrintable(hostOf(urlTemplate)), qPrintable(urlTemplate),
qPrintable(formatDuration(cold.elapsedMs)), qPrintable(formatDuration(coldLowerMs)),
qPrintable(formatDuration(cached.elapsedMs)), static_cast<double>(cachedUpperMs) / 1000.0,
cold.finished, cold.enqueued, cached.finished, cached.enqueued, cold.failed, cached.failed);
if (cold.failed > 0) {
std::printf(" cold failures: %s\n", qPrintable(cold.failedCards.join(QStringLiteral(", "))));
}
if (cached.failed > 0) {
std::printf(" cached failures: %s\n", qPrintable(cached.failedCards.join(QStringLiteral(", "))));
}
}
if (cacheDirArg.isEmpty()) {
std::printf("cache root: %s (temporary; pass --cache-dir <dir> to persist it for a warm rerun)\n",
qPrintable(rootDir));
} else {
std::printf("cache root: %s\n", qPrintable(rootDir));
}
std::printf("RESULT: %s\n", allPass ? "PASS" : "FAIL");
return allPass ? 0 : 1;
}

View file

@ -0,0 +1,200 @@
/*
* Minimal SettingsCache implementation for the picture loader benchmark.
*
* The picture loader code insists on reading every URL, cache and path via
* SettingsCache::instance(). Compiling the real client SettingsCache would drag
* in the network update-checker graph, so instead we provide the SettingsCache
* member functions it actually uses, backed by the real libcockatrice_settings
* manager classes pointed at a sandboxed settings directory.
*
* The mock must be named SettingsCache: PathsSettings declares
* `friend class SettingsCache`, which is the only way to construct it.
*
* Paths resolved through QStandardPaths are redirected into the sandbox by
* setting the XDG_* environment variables before the first instance() call.
*/
#include "client/settings/cache_settings.h"
#include "interface/card_picture_loader/card_picture_loader_cache_method.h"
#include <QDir>
#include <QFile>
#include <QSettings>
#include <QStandardPaths>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <utility>
QString SettingsCache::getDataPath()
{
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
}
QString SettingsCache::getSettingsPath()
{
return getDataPath() + "/settings/";
}
QString SettingsCache::getCachePath() const
{
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
}
QString SettingsCache::getNetworkCachePath() const
{
return getCachePath() + "/downloaded/";
}
QString SettingsCache::getCustomCardDatabasePath() const
{
return paths().getCustomCardDatabasePath();
}
QString SettingsCache::getCardDatabasePath() const
{
return paths().getCardDatabasePath();
}
QString SettingsCache::getSpoilerCardDatabasePath() const
{
return paths().getSpoilerCardDatabasePath();
}
QString SettingsCache::getTokenDatabasePath() const
{
return paths().getTokenDatabasePath();
}
int SettingsCache::getKeepAlive() const
{
return 0;
}
int SettingsCache::getTimeOut() const
{
return 0;
}
bool SettingsCache::getNotifyAboutUpdates() const
{
return false;
}
void SettingsCache::setKnownMissingFeatures(const QString & /*_knownMissingFeatures*/)
{
}
QString SettingsCache::getKnownMissingFeatures()
{
return QString();
}
QString SettingsCache::getClientID()
{
return QString();
}
DownloadSettings &SettingsCache::downloads() const
{
return *downloadSettings;
}
CacheStorageSettings &SettingsCache::cacheStorage() const
{
return *cacheStorageSettings;
}
CardsDisplaySettings &SettingsCache::cardsDisplay() const
{
return *cardsDisplaySettings;
}
PathsSettings &SettingsCache::paths() const
{
return *pathsSettings;
}
SettingsCache::SettingsCache()
{
settings = nullptr;
isPortableBuild = false;
const QString settingsPath = getSettingsPath();
QDir().mkpath(settingsPath);
downloadSettings = new DownloadSettings(settingsPath, this);
cacheStorageSettings = new CacheStorageSettings(settingsPath, this);
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
pathsSettings = new PathsSettings(settingsPath, this);
// Picture downloads enabled, default host list, network disk cache storage.
downloadSettings->setPicDownload(true);
downloadSettings->resetToDefaultURLs();
cacheStorageSettings->setCardImageCacheMethod(
static_cast<int>(CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE));
loadPaths();
}
void SettingsCache::loadPaths()
{
QString dataPath = getDataPath();
QSettings pathsIni(getSettingsPath() + "paths.ini", QSettings::IniFormat);
auto computePath = [&](const QString &key, const QString &defaultPath) -> QString {
QString val = pathsIni.value("paths/" + key).toString();
if (val.isEmpty() || !QDir(val).exists()) {
if (!QDir().mkpath(defaultPath)) {
qCInfo(SettingsCacheLog) << "[SettingsCache] Could not create folder:" << defaultPath;
}
val = defaultPath;
pathsIni.setValue("paths/" + key, val);
}
return val;
};
auto computeFilePath = [&](const QString &key, const QString &defaultPath) -> QString {
QString val = pathsIni.value("paths/" + key).toString();
if (!QFile::exists(val) || val.isEmpty()) {
val = defaultPath;
pathsIni.setValue("paths/" + key, val);
}
return val;
};
computePath("decks", dataPath + "/decks/");
computePath("filters", dataPath + "/filters/");
computePath("replays", dataPath + "/replays/");
computePath("themes", dataPath + "/themes/");
computePath("pics", dataPath + "/pics/");
computePath("redirects", getCachePath() + "/redirects/");
// customPicsPath derived from picsPath
QString picsPath = pathsIni.value("paths/pics").toString();
if (picsPath.endsWith("/")) {
computePath("customPics", picsPath + "CUSTOM/");
} else {
computePath("customPics", picsPath + "/CUSTOM/");
}
computePath("customSets", dataPath + "/customsets/");
computeFilePath("cardDatabase", dataPath + "/cards.xml");
computeFilePath("tokenDatabase", dataPath + "/tokens.xml");
computeFilePath("spoilerDatabase", dataPath + "/spoiler.xml");
}
void SettingsCache::setThemeName(const QString &_themeName)
{
if (themeName != _themeName) {
themeName = _themeName;
emit themeChanged();
}
}
SettingsCache &SettingsCache::instance()
{
static SettingsCache settingsCache;
return settingsCache;
}

View file

@ -0,0 +1,54 @@
#include "gtest/gtest.h"
#include <QList>
#include <QString>
#include <QStringList>
#include <libcockatrice/utility/report_categories.h>
TEST(ReportCategoriesTest, CanonicalListIsNonEmptyAndStable)
{
const QStringList categories = ReportCategories::keys();
ASSERT_FALSE(categories.isEmpty());
EXPECT_EQ("cheating", categories.at(0));
EXPECT_EQ("other", categories.last());
}
TEST(ReportCategoriesTest, CanonicalListContainsMergedClientAndServerCategories)
{
const QStringList expected = {"cheating", "bug_abuse", "harassment", "verbal_abuse",
"hate_speech", "spam", "other"};
EXPECT_EQ(expected, ReportCategories::keys());
EXPECT_EQ(7, ReportCategories::keys().size());
}
TEST(ReportCategoriesTest, AllCanonicalKeysAreValid)
{
for (const QString &category : ReportCategories::keys()) {
EXPECT_TRUE(ReportCategories::isValid(category)) << category.toStdString();
}
}
TEST(ReportCategoriesTest, LegacyCategoriesRemainValid)
{
EXPECT_TRUE(ReportCategories::isValid("bug_abuse"));
EXPECT_TRUE(ReportCategories::isValid("verbal_abuse"));
}
TEST(ReportCategoriesTest, ValidationIsCaseInsensitive)
{
EXPECT_TRUE(ReportCategories::isValid("Cheating"));
EXPECT_TRUE(ReportCategories::isValid("HATE_SPEECH"));
}
TEST(ReportCategoriesTest, UnknownAndEmptyCategoriesAreRejected)
{
EXPECT_FALSE(ReportCategories::isValid(QString()));
EXPECT_FALSE(ReportCategories::isValid("hate speech"));
EXPECT_FALSE(ReportCategories::isValid("griefing"));
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -306,6 +306,12 @@ TEST_F(SettingsDefaultsTest, Chat_IgnoreAllPrivateMessages_Default)
ASSERT_EQ(s.getIgnoreAllPrivateMessages(), false);
}
TEST_F(SettingsDefaultsTest, Chat_UseGameTime_Default)
{
ChatSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getUseGameTime(), false);
}
// --- PersonalSettings ---
TEST_F(SettingsDefaultsTest, Personal_Lang_Default)
@ -334,6 +340,38 @@ TEST_F(SettingsDefaultsTest, Download_DownloadSpoilersStatus_Default)
ASSERT_EQ(s.getDownloadSpoilersStatus(), false);
}
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_Default)
{
DownloadSettings s(settingsPath, nullptr);
ASSERT_TRUE(s.getHostRequestLimits().isEmpty());
}
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_SetAndGet)
{
DownloadSettings s(settingsPath, nullptr);
s.setHostRequestLimits({{"api.scryfall.com", 5}});
const QHash<QString, int> limits = s.getHostRequestLimits();
ASSERT_EQ(limits.size(), 1);
ASSERT_EQ(limits.value("api.scryfall.com"), 5);
}
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_StackedHostCaps)
{
DownloadSettings s(settingsPath, nullptr);
// The developer cap for the Scryfall API lowers the ceiling to 9; a user can
// reduce it further but can never raise it above the cap.
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 9), 9);
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 20), 9);
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 5), 5);
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 0), 1);
// Hosts without a developer cap fall back to the global default ceiling.
ASSERT_EQ(s.clampHostRequestLimit("gatherer.wizards.com", 10), 10);
ASSERT_EQ(s.clampHostRequestLimit("gatherer.wizards.com", 20), 10);
// The Scryfall CDN is unlocked: no upper bound (values are only floored).
ASSERT_EQ(s.clampHostRequestLimit("cards.scryfall.io", 20), 20);
ASSERT_EQ(s.clampHostRequestLimit("cards.scryfall.io", 0), 1);
}
// --- AppearanceSettings ---
TEST_F(SettingsDefaultsTest, Appearance_ThemeName_Default)
@ -538,6 +576,21 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDeckEditorCardSize_Default)
ASSERT_EQ(s.getVisualDeckEditorCardSize(), 100);
}
TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDeckEditorShowCardCounts_Default)
{
CardsDisplaySettings s(settingsPath, nullptr);
ASSERT_EQ(s.getVisualDeckEditorShowCardCounts(), true);
}
TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDeckEditorShowCardCounts_SetAndGet)
{
CardsDisplaySettings s(settingsPath, nullptr);
s.setVisualDeckEditorShowCardCounts(false);
ASSERT_EQ(s.getVisualDeckEditorShowCardCounts(), false);
s.setVisualDeckEditorShowCardCounts(true);
ASSERT_EQ(s.getVisualDeckEditorShowCardCounts(), true);
}
TEST_F(SettingsDefaultsTest, CardsDisplay_EDHRecCardSize_Default)
{
CardsDisplaySettings s(settingsPath, nullptr);
@ -575,6 +628,19 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_SetAndGet)
ASSERT_EQ(s.getCardLang(), QString("de"));
}
TEST_F(SettingsDefaultsTest, CardsDisplay_CardSearchLanguage_Default)
{
CardsDisplaySettings s(settingsPath, nullptr);
ASSERT_EQ(s.getCardSearchLanguage(), static_cast<int>(SearchLanguageMode::English));
}
TEST_F(SettingsDefaultsTest, CardsDisplay_CardSearchLanguage_SetAndGet)
{
CardsDisplaySettings s(settingsPath, nullptr);
s.setCardSearchLanguage(static_cast<int>(SearchLanguageMode::Selected));
ASSERT_EQ(s.getCardSearchLanguage(), static_cast<int>(SearchLanguageMode::Selected));
}
// --- VisualDeckStorageSettings ---
TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default)
@ -603,6 +669,19 @@ TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_SetAndGet)
ASSERT_EQ(s.getVisualDeckStorageDefaultTagsList(), custom);
}
TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_Default)
{
VisualDeckStorageSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), true);
}
TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_SetAndGet)
{
VisualDeckStorageSettings s(settingsPath, nullptr);
s.setVisualDeckStorageShowUploadTime(false);
ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), false);
}
} // namespace
int main(int argc, char **argv)

View file

@ -20,7 +20,11 @@ protected:
void SetUp() override
{
settingsPath = tempDir.path() + "/";
// Mirror the production layout: the settings folder lives inside the data folder, so
// migration backups (siblings of settings/) stay isolated inside tempDir instead of
// landing in the shared system temp directory.
settingsPath = tempDir.path() + "/settings/";
QDir().mkpath(settingsPath);
// Isolate the settings used by the legacy migration tests inside the temporary
// directory so the tests never read or write the real user config (registry on
@ -64,6 +68,25 @@ protected:
}
return false;
}
// The migration backups are timestamped, so tests address them by index rather than by name.
QStringList backupFolderNames() const
{
return QDir(tempDir.path())
.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name)
.filter(QStringLiteral("settings-backup-"));
}
QVariant readFromBackup(const QString &folderName, const QString &fileName, const QString &key) const
{
QSettings ini(QDir::cleanPath(tempDir.path() + '/' + folderName + '/' + fileName), QSettings::IniFormat);
return ini.value(key);
}
bool backupFileExists(const QString &folderName, const QString &fileName) const
{
return QFile::exists(QDir::cleanPath(tempDir.path() + '/' + folderName + '/' + fileName));
}
};
TEST_F(SettingsMigrationTest, NoGlobalIniDoesNothing)
@ -542,6 +565,34 @@ TEST_F(SettingsMigrationTest, PartialMigrationOnlyMigratesPresentKeys)
ASSERT_FALSE(fileExists("chat.ini"));
}
TEST_F(SettingsMigrationTest, ThemeNameDefaultIsRewrittenToSystem)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("theme/name", "Default");
g.sync();
}
ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath));
ASSERT_TRUE(fileExists("appearance.ini"));
ASSERT_EQ(readFromIni("appearance.ini", "appearance/themeName"), QVariant("System"));
}
TEST_F(SettingsMigrationTest, OtherThemeNamesAreLeftAlone)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("theme/name", "VelvetMarble");
g.sync();
}
ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath));
ASSERT_TRUE(fileExists("appearance.ini"));
ASSERT_EQ(readFromIni("appearance.ini", "appearance/themeName"), QVariant("VelvetMarble"));
}
TEST_F(SettingsMigrationTest, KeyMapTranslationIsCorrect)
{
{
@ -697,6 +748,168 @@ TEST_F(SettingsMigrationTest, RenameFailureWithExistingBackup)
ASSERT_FALSE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath));
}
// --- pre-migration backup of the settings folder ---
TEST_F(SettingsMigrationTest, BackupHoldsPreMigrationStateOfPerDomainFiles)
{
// A user already migrated once, changed a setting, then ran an older build that wrote to
// global.ini again. Re-migrating clobbers the per-domain file, so the backup has to hold the
// pre-migration value.
{
QSettings tabs(settingsPath + "tabs.ini", QSettings::IniFormat);
tabs.setValue("tabs/visualDeckStorage", false);
tabs.sync();
}
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("tabs/visualDeckStorage", true);
g.sync();
}
const QString backupPath = SettingsMigration::backupSettingsDirectory(settingsPath);
ASSERT_FALSE(backupPath.isEmpty());
ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath));
// The migration overwrote the user's change ...
ASSERT_EQ(readFromIni("tabs.ini", "tabs/visualDeckStorage"), QVariant(true));
// ... but the backup still holds it, so it can be restored by hand.
const QStringList backups = backupFolderNames();
ASSERT_EQ(backups.size(), 1);
ASSERT_EQ(readFromBackup(backups.at(0), "tabs.ini", "tabs/visualDeckStorage"), QVariant(false));
ASSERT_EQ(readFromBackup(backups.at(0), "global.ini", "tabs/visualDeckStorage"), QVariant(true));
}
TEST_F(SettingsMigrationTest, BackupIsASiblingOfTheSettingsFolder)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
const QString backupPath = SettingsMigration::backupSettingsDirectory(settingsPath);
ASSERT_FALSE(backupPath.isEmpty());
const QDir backupDir(backupPath);
ASSERT_TRUE(backupDir.dirName().startsWith("settings-backup-"));
// Same parent as the settings folder, and not inside it, so a backup is never copied into
// another backup and "Open settings folder" shows only live files.
ASSERT_EQ(QDir::cleanPath(backupDir.absolutePath() + "/.."), QDir::cleanPath(tempDir.path()));
ASSERT_FALSE(backupDir.absolutePath().startsWith(QDir::cleanPath(settingsPath) + '/'));
ASSERT_TRUE(backupFileExists(backupDir.dirName(), "global.ini"));
}
TEST_F(SettingsMigrationTest, BackupCopiesNestedFolders)
{
ASSERT_TRUE(QDir().mkpath(settingsPath + "nested"));
{
QFile f(settingsPath + "nested/deep.ini");
ASSERT_TRUE(f.open(QIODevice::WriteOnly));
f.write("[visualDeckStorage]\nfirstDeck=1234\n");
}
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
const QString backupPath = SettingsMigration::backupSettingsDirectory(settingsPath);
ASSERT_FALSE(backupPath.isEmpty());
const QStringList backups = backupFolderNames();
ASSERT_EQ(backups.size(), 1);
ASSERT_TRUE(backupFileExists(backups.at(0), "nested/deep.ini"));
ASSERT_EQ(readFromBackup(backups.at(0), "nested/deep.ini", "visualDeckStorage/firstDeck"), QVariant("1234"));
}
TEST_F(SettingsMigrationTest, NoBackupWhenSettingsFolderIsEmpty)
{
ASSERT_TRUE(SettingsMigration::backupSettingsDirectory(settingsPath).isEmpty());
ASSERT_TRUE(backupFolderNames().isEmpty());
}
TEST_F(SettingsMigrationTest, NoBackupWhenEverySettingsFileIsEmpty)
{
// A fresh profile: QSettings has created global.ini, but it holds nothing worth preserving.
{
QFile f(settingsPath + "global.ini");
ASSERT_TRUE(f.open(QIODevice::WriteOnly));
f.close();
}
ASSERT_TRUE(SettingsMigration::backupSettingsDirectory(settingsPath).isEmpty());
ASSERT_TRUE(backupFolderNames().isEmpty());
}
TEST_F(SettingsMigrationTest, BackupFailsGracefullyOnMissingSettingsFolder)
{
ASSERT_TRUE(SettingsMigration::backupSettingsDirectory(tempDir.path() + "/does-not-exist/").isEmpty());
}
TEST_F(SettingsMigrationTest, BackupKeepsEveryPreviousSnapshot)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
ASSERT_FALSE(SettingsMigration::backupSettingsDirectory(settingsPath).isEmpty());
ASSERT_FALSE(SettingsMigration::backupSettingsDirectory(settingsPath).isEmpty());
// A second snapshot in the same second must not merge into the first one.
const QStringList backups = backupFolderNames();
ASSERT_EQ(backups.size(), 2);
ASSERT_NE(backups.at(0), backups.at(1));
}
TEST_F(SettingsMigrationTest, IsMigrationPendingWhenGlobalIniHasKeys)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
ASSERT_TRUE(SettingsMigration::isMigrationPending(settingsPath));
}
TEST_F(SettingsMigrationTest, IsNotMigrationPendingAfterSentinelIsSet)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath));
// The sentinel-only global.ini and the legacy sentinel leave nothing to migrate.
{
QSettings personal(settingsPath + "personal.ini", QSettings::IniFormat);
personal.setValue("migration/legacy_complete", true);
personal.sync();
}
ASSERT_FALSE(SettingsMigration::isMigrationPending(settingsPath));
}
TEST_F(SettingsMigrationTest, IsMigrationPendingWhenOnlyLegacySettingsRemain)
{
QSettings().setValue("server/host", "example.com");
QSettings().sync();
ASSERT_TRUE(SettingsMigration::isMigrationPending(settingsPath));
}
TEST_F(SettingsMigrationTest, IsNotMigrationPendingWithoutLegacyData)
{
// Nothing to migrate: a fresh profile must not leave a backup folder behind on every start.
QSettings().clear();
QSettings().sync();
ASSERT_FALSE(SettingsMigration::isMigrationPending(settingsPath));
}
} // namespace
int main(int argc, char **argv)

View file

@ -0,0 +1,134 @@
#include "single_instance_manager.h"
#include <QCoreApplication>
#include <QDataStream>
#include <QEventLoop>
#include <QLocalServer>
#include <QLocalSocket>
#include <QTimer>
#include <gtest/gtest.h>
namespace
{
constexpr int HANDSHAKE_TIMEOUT_MS = 5000;
constexpr int MODAL_DIALOG_MS = 50;
const QStringList PAYLOAD{QStringLiteral("cockatrice://joingame?hostname=127.0.0.1&port=4747&roomid=0&gameid=1")};
/// Serializes a payload the way SingleInstanceManager::forwardToPrimary() does.
QByteArray encodePayload(const QStringList &files)
{
QByteArray payload;
QDataStream out(&payload, QIODevice::WriteOnly);
out << files;
QByteArray message;
QDataStream msgStream(&message, QIODevice::WriteOnly);
msgStream << static_cast<quint32>(payload.size());
message.append(payload);
return message;
}
} // namespace
// Regression test for the use-after-free in SingleInstanceManager::handleNewConnection().
//
// The forwarded payload is handed to the handlers from inside the forwarding
// socket's readyRead emission, which is what lets the "Join game?" confirmation
// appear at all. A handler can spin a nested event loop (a modal QMessageBox),
// and the sending instance exits as soon as it has the acknowledgment, so the
// peer closes *while that loop runs*. deleteLater() called from inside such a
// nested loop does not wait for the outer loop: Qt frees the object as soon as
// the nested loop unwinds, still inside the readyRead emission, so Qt's own
// signal dispatch and the handler lambda both end up holding a freed socket.
// Nothing may therefore delete the socket from the disconnected signal, and the
// teardown is queued until that frame has unwound.
TEST(SingleInstanceManagerTest, HandlersRunSynchronouslyAndSocketIsNotTouchedAfterTheyReturn)
{
// Scope the hand-off socket to this test process so a Cockatrice the
// developer happens to be running cannot claim it.
qputenv("USER", QByteArrayLiteral("cockatrice-test-") + QByteArray::number(QCoreApplication::applicationPid()));
SingleInstanceManager primary;
ASSERT_TRUE(primary.tryRun(QStringList())) << "could not become the primary instance";
auto *server = primary.findChild<QLocalServer *>();
ASSERT_NE(nullptr, server) << "the primary instance is not listening";
QLocalSocket sender;
QStringList handled;
QByteArray acknowledgment;
int handlerCalls = 0;
bool socketConnectedWhenHandled = false;
bool socketReleased = false;
// Waited on after the handlers return: the deferred teardown deletes the
// forwarding socket once the readyRead frame has unwound.
QEventLoop teardownLoop;
QEventLoop loop;
QObject::connect(&primary, &SingleInstanceManager::filesReceived, &primary, [&](const QStringList &files) {
++handlerCalls;
// The payload must reach the handlers while the forwarding socket is
// still connected: deferring the emit would suppress the modal "Join
// game?" confirmation this hand-off exists to show.
auto *connection = server->findChild<QLocalSocket *>();
socketConnectedWhenHandled = connection != nullptr && connection->state() == QLocalSocket::ConnectedState;
// Observe the socket's destruction rather than polling for it. `destroyed`
// is emitted just before the object goes away, so this stays correct
// however the teardown ends up deleting it, and `&primary` keeps the
// observer alive even once the socket itself is gone.
if (connection != nullptr) {
QObject::connect(connection, &QObject::destroyed, &primary, [&] {
socketReleased = true;
teardownLoop.quit();
});
}
// The primary acknowledges the payload before handling it, so that a
// sender waiting on the acknowledgment never mistakes a busy primary
// for a dead one.
sender.waitForReadyRead(HANDSHAKE_TIMEOUT_MS);
acknowledgment = sender.readAll();
// Stand in for the modal "Join game?" confirmation: the sending instance
// goes away while this nested loop is running, which is what used to
// destroy the socket underneath this readyRead frame.
sender.abort();
QEventLoop modalDialog;
QTimer::singleShot(MODAL_DIALOG_MS, &modalDialog, &QEventLoop::quit);
modalDialog.exec();
handled = files;
loop.quit();
});
sender.connectToServer(server->serverName());
ASSERT_TRUE(sender.waitForConnected(HANDSHAKE_TIMEOUT_MS));
sender.write(encodePayload(PAYLOAD));
sender.flush();
QTimer::singleShot(HANDSHAKE_TIMEOUT_MS, &loop, &QEventLoop::quit);
loop.exec();
EXPECT_EQ(1, handlerCalls) << "the payload must be handed to the handlers exactly once";
EXPECT_EQ(PAYLOAD, handled);
EXPECT_TRUE(socketConnectedWhenHandled) << "the payload was not handled from inside the readyRead emission";
EXPECT_FALSE(acknowledgment.isEmpty()) << "the primary never acknowledged the payload";
// The deferred teardown must not be skipped, and must not delete the socket
// from inside the nested loop above: either would show up here as a crash or
// as a socket that outlives the hand-off and leaks a connection per payload.
QTimer::singleShot(HANDSHAKE_TIMEOUT_MS, &teardownLoop, &QEventLoop::quit);
teardownLoop.exec();
EXPECT_TRUE(socketReleased) << "the forwarding socket was never released after the hand-off";
}
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,30 @@
add_executable(
theme_migration_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/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
)
target_include_directories(theme_migration_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
target_link_libraries(
theme_migration_test
libcockatrice_settings
libcockatrice_network
libcockatrice_network_server_remote
libcockatrice_protocol
Threads::Threads
${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
add_test(NAME theme_migration_test COMMAND theme_migration_test)
if(NOT GTEST_FOUND)
add_dependencies(theme_migration_test gtest)
endif()

View file

@ -0,0 +1,154 @@
#include "interface/theme_manager.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QString>
#include <QTemporaryDir>
#include <gtest/gtest.h>
// Owned by main.cpp in the real application; pixel_map_generator.cpp references
// it, so the test binary needs a definition even though it never renders.
ThemeManager *themeManager = nullptr;
namespace
{
const QString LEGACY = QStringLiteral("Default");
const QString CURRENT = QStringLiteral("System");
// Writes content at <themesPath>/<theme>/<relativePath>, creating parents.
void writeThemeFile(const QString &themesPath,
const QString &theme,
const QString &relativePath,
const QByteArray &content)
{
const QString filePath = QDir(QDir(themesPath).absoluteFilePath(theme)).absoluteFilePath(relativePath);
ASSERT_TRUE(QDir().mkpath(QFileInfo(filePath).absolutePath()));
QFile file(filePath);
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
file.write(content);
file.close();
}
QByteArray readThemeFile(const QString &themesPath, const QString &theme, const QString &relativePath)
{
QFile file(QDir(QDir(themesPath).absoluteFilePath(theme)).absoluteFilePath(relativePath));
if (!file.open(QIODevice::ReadOnly)) {
return QByteArray();
}
return file.readAll();
}
class ThemeMigrationTest : public ::testing::Test
{
protected:
QTemporaryDir tmp;
QString themesPath;
void SetUp() override
{
ASSERT_TRUE(tmp.isValid());
themesPath = QDir(tmp.path()).absoluteFilePath("themes");
ASSERT_TRUE(QDir().mkpath(themesPath));
}
};
} // namespace
TEST_F(ThemeMigrationTest, NoLegacyDirectoryIsANoOp)
{
writeThemeFile(themesPath, CURRENT, "theme.cfg", "[Appearance]\n");
ThemeManager::migrateLegacyThemeDir(themesPath, CURRENT);
EXPECT_TRUE(QDir(QDir(themesPath).absoluteFilePath(CURRENT)).exists());
EXPECT_TRUE(QDir(QDir(themesPath).absoluteFilePath(LEGACY)).exists() == false);
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "theme.cfg").toStdString(), std::string("[Appearance]\n"));
}
TEST_F(ThemeMigrationTest, RenamesDirectoryWhenTargetIsFree)
{
// A customised "Default": stylesheet, zone graphics in a subdirectory and a
// palette, i.e. everything a user is liable to have personalised.
writeThemeFile(themesPath, LEGACY, "style.css", "QPushButton { color: red; }");
writeThemeFile(themesPath, LEGACY, "zones/handzone.png", "handzone-bytes");
writeThemeFile(themesPath, LEGACY, "zones/tablezone-dark.svg", "tablezone-bytes");
writeThemeFile(themesPath, LEGACY, "palette-dark.toml", "palette");
ThemeManager::migrateLegacyThemeDir(themesPath, LEGACY);
EXPECT_FALSE(QDir(QDir(themesPath).absoluteFilePath(LEGACY)).exists());
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(),
std::string("QPushButton { color: red; }"));
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "zones/handzone.png").toStdString(), std::string("handzone-bytes"));
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "zones/tablezone-dark.svg").toStdString(),
std::string("tablezone-bytes"));
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "palette-dark.toml").toStdString(), std::string("palette"));
}
TEST_F(ThemeMigrationTest, RenamesForAnAlreadyMigratedUserWhoseDirectoryWasLeftBehind)
{
// The settings value was rewritten to "System" on an earlier run, but the
// customised directory is still called "Default". The customisations are
// still reachable and must still be carried over.
writeThemeFile(themesPath, LEGACY, "style.css", "legacy-css");
ThemeManager::migrateLegacyThemeDir(themesPath, CURRENT);
EXPECT_FALSE(QDir(QDir(themesPath).absoluteFilePath(LEGACY)).exists());
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("legacy-css"));
}
TEST_F(ThemeMigrationTest, MergesIntoAnExistingTargetWithoutOverwriting)
{
// Post-rename re-customisation: "System" already exists and holds newer
// files. Those win; the legacy directory only fills the gaps.
writeThemeFile(themesPath, CURRENT, "style.css", "newer-css");
writeThemeFile(themesPath, CURRENT, "zones/handzone.png", "newer-handzone");
writeThemeFile(themesPath, LEGACY, "style.css", "legacy-css");
writeThemeFile(themesPath, LEGACY, "zones/handzone.png", "legacy-handzone");
writeThemeFile(themesPath, LEGACY, "zones/stackzone.png", "legacy-stackzone");
writeThemeFile(themesPath, LEGACY, "palette-light.toml", "legacy-palette");
ThemeManager::migrateLegacyThemeDir(themesPath, CURRENT);
EXPECT_FALSE(QDir(QDir(themesPath).absoluteFilePath(LEGACY)).exists());
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("newer-css"));
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "zones/handzone.png").toStdString(), std::string("newer-handzone"));
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "zones/stackzone.png").toStdString(), std::string("legacy-stackzone"));
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "palette-light.toml").toStdString(), std::string("legacy-palette"));
}
TEST_F(ThemeMigrationTest, LeavesAnotherThemesDefaultDirectoryAlone)
{
// A user on an unrelated theme may legitimately have made a "Default"
// folder of their own; it must not be folded into "System".
writeThemeFile(themesPath, LEGACY, "style.css", "unrelated");
ThemeManager::migrateLegacyThemeDir(themesPath, QStringLiteral("Leather"));
EXPECT_TRUE(QDir(QDir(themesPath).absoluteFilePath(LEGACY)).exists());
EXPECT_EQ(readThemeFile(themesPath, LEGACY, "style.css").toStdString(), std::string("unrelated"));
EXPECT_FALSE(QDir(QDir(themesPath).absoluteFilePath(CURRENT)).exists());
}
TEST_F(ThemeMigrationTest, IsIdempotent)
{
writeThemeFile(themesPath, LEGACY, "style.css", "css");
ThemeManager::migrateLegacyThemeDir(themesPath, LEGACY);
ASSERT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css"));
ThemeManager::migrateLegacyThemeDir(themesPath, CURRENT);
ThemeManager::migrateLegacyThemeDir(themesPath, LEGACY);
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css"));
}
// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and
// the fallback then links the bare gtest library without gtest_main.
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,262 @@
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QDir>
#include <QLibraryInfo>
#include <QSet>
#include <QString>
#include <QTemporaryDir>
#include <QTranslator>
#include <libcockatrice/utility/translation_loader.h>
// The directory holding translation_loader_test_de.qm, compiled from translation_loader_test.ts by the
// build. QTranslator rejects anything that is not a real .qm file, so a stub cannot stand in here.
#ifndef TRANSLATION_LOADER_TEST_DIR
#define TRANSLATION_LOADER_TEST_DIR ""
#endif
namespace
{
const QString testContext = QStringLiteral("TranslationLoaderTest");
const QString firstMessage = QStringLiteral("Load into the first translator");
const QString secondMessage = QStringLiteral("Load into the second translator");
const QString germanFirstMessage = QStringLiteral("In den ersten Übersetzer laden");
const QString germanSecondMessage = QStringLiteral("In den zweiten Übersetzer laden");
QString translate(const QTranslator &_translator, const QString &_sourceText)
{
return _translator.translate(testContext.toUtf8().constData(), _sourceText.toUtf8().constData());
}
QString testTranslationDirectory()
{
return QString(TRANSLATION_LOADER_TEST_DIR);
}
// GTest's main() creates no QCoreApplication, which the search paths are derived from.
const QCoreApplication &application()
{
static int argc = 1;
static char executable[] = "translation_loader_test";
static char *argv[] = {executable, nullptr};
static const QCoreApplication app(argc, argv);
return app;
}
} // namespace
TEST(TranslationLoaderTest, LoadsFromTheFirstDirectoryHoldingTheFile)
{
QTemporaryDir emptyDir;
ASSERT_TRUE(emptyDir.isValid());
QTranslator translator;
// The first directory is searched but has no file, the second one has.
EXPECT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"),
{emptyDir.path(), testTranslationDirectory()}));
EXPECT_EQ(germanFirstMessage, translate(translator, firstMessage));
}
TEST(TranslationLoaderTest, LoadedTranslatorResolvesTheTranslatedString)
{
QTranslator translator;
ASSERT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"),
{testTranslationDirectory()}));
EXPECT_EQ(germanFirstMessage, translate(translator, firstMessage));
EXPECT_EQ(germanSecondMessage, translate(translator, secondMessage));
}
TEST(TranslationLoaderTest, SeparateTranslatorsKeepTheirOwnFiles)
{
// QTranslator discards its previous contents on every load, so a caller reusing one translator
// for two files keeps only the last of them. That is why Qt's translation and the application
// translation are loaded into translators of their own.
QTranslator firstTranslator;
QTranslator secondTranslator;
ASSERT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(firstTranslator, QStringLiteral("translation_loader_test_de"),
{testTranslationDirectory()}));
ASSERT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(secondTranslator, QStringLiteral("translation_loader_test_de"),
{testTranslationDirectory()}));
EXPECT_FALSE(firstTranslator.isEmpty());
EXPECT_FALSE(secondTranslator.isEmpty());
EXPECT_EQ(germanFirstMessage, translate(firstTranslator, firstMessage));
EXPECT_EQ(germanSecondMessage, translate(secondTranslator, secondMessage));
}
TEST(TranslationLoaderTest, ExistingDirectoryWithoutTheFileIsMissing)
{
QTemporaryDir emptyDir;
ASSERT_TRUE(emptyDir.isValid());
QTranslator translator;
EXPECT_EQ(TranslationLoadResult::Missing,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"), {emptyDir.path()}));
EXPECT_TRUE(translator.isEmpty());
}
TEST(TranslationLoaderTest, NonexistentDirectoriesAreNotSearched)
{
QTemporaryDir emptyDir;
ASSERT_TRUE(emptyDir.isValid());
QTranslator translator;
const QString missingDirectory = emptyDir.path() + QStringLiteral("/does-not-exist");
EXPECT_EQ(
TranslationLoadResult::NoDirectory,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"), {missingDirectory}));
EXPECT_EQ(TranslationLoadResult::NoDirectory,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"), {}));
EXPECT_TRUE(translator.isEmpty());
}
TEST(TranslationLoaderTest, EmptyDirectoryEntriesAreSkipped)
{
QTranslator translator;
EXPECT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"),
{QString(), testTranslationDirectory()}));
}
TEST(TranslationLoaderTest, LanguageVariantsFallBackToTheBaseLanguage)
{
QTranslator translator;
// Users pick languages such as en_US or zh-Hans that need not have a translation file of their
// own; a language without one falls back to the file without its country or script part.
EXPECT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de_CH"),
{testTranslationDirectory()}));
}
TEST(TranslationLoaderTest, QtTranslationCandidatesCoverTheUsualInstallLayouts)
{
const QList<QString> candidates = TranslationLoader::qtTranslationCandidates();
EXPECT_FALSE(candidates.isEmpty());
for (const QString &candidate : candidates) {
EXPECT_FALSE(candidate.isEmpty());
}
EXPECT_EQ(candidates.size(), QSet<QString>(candidates.cbegin(), candidates.cend()).size());
// A relocated Qt can report a prefix-relative translations path that does not exist (the KDE
// runtime reports /usr/translations while its files live in /usr/share/qt6/translations), so the
// layout Qt actually installs into has to be among the candidates.
const QString prefix = QLibraryInfo::path(QLibraryInfo::PrefixPath);
EXPECT_TRUE(candidates.contains(prefix + QStringLiteral("/share/qt6/translations")));
}
TEST(TranslationLoaderTest, QtTranslationPathsOnlyHoldExistingDirectories)
{
const QList<QString> candidates = TranslationLoader::qtTranslationCandidates();
const QList<QString> paths = TranslationLoader::qtTranslationPaths();
for (const QString &path : paths) {
EXPECT_FALSE(path.isEmpty());
EXPECT_TRUE(QDir(path).exists()) << path.toStdString();
}
EXPECT_EQ(paths.size(), QSet<QString>(paths.cbegin(), paths.cend()).size());
// Every candidate that exists is searched, and nothing else is.
for (const QString &candidate : candidates) {
if (QDir(candidate).exists()) {
EXPECT_TRUE(paths.contains(candidate)) << candidate.toStdString();
}
}
}
TEST(TranslationLoaderTest, LoadApplicationUsesThePrefixAndTheLanguage)
{
QTranslator translator;
ASSERT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadApplication(translator, QStringLiteral("translation_loader_test"),
QStringLiteral("de"), {testTranslationDirectory()}));
EXPECT_EQ(germanSecondMessage, translate(translator, secondMessage));
}
TEST(TranslationLoaderTest, ABuildTreeFindsTheTranslationsNextToTheExecutable)
{
// The regression this guards: a build tree keeps the .qm files next to the executable rather than
// in the installed layout, so a developer build loaded no translation and offered no language.
// This test binary's .qm file sits in its own directory, just like cockatrice's ones do.
const QString executableDirectory = application().applicationDirPath();
const QList<QString> paths = TranslationLoader::applicationTranslationPaths(
executableDirectory + QStringLiteral("/../share/cockatrice/translations"));
ASSERT_EQ(2, paths.size());
EXPECT_EQ(executableDirectory + QStringLiteral("/../share/cockatrice/translations"), paths.at(0));
EXPECT_EQ(executableDirectory, paths.at(1));
const QList<QString> languages =
TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), paths);
EXPECT_EQ(QStringList({QStringLiteral("de")}), QStringList(languages.cbegin(), languages.cend()));
}
TEST(TranslationLoaderTest, ApplicationTranslationPathsHoldNoDuplicates)
{
const QString executableDirectory = application().applicationDirPath();
EXPECT_EQ(1, TranslationLoader::applicationTranslationPaths(executableDirectory).size());
// Without an installed layout to search there is nothing left but the executable's directory.
const QList<QString> withoutInstalledPath = TranslationLoader::applicationTranslationPaths(QString());
ASSERT_EQ(1, withoutInstalledPath.size());
EXPECT_EQ(executableDirectory, withoutInstalledPath.at(0));
}
TEST(TranslationLoaderTest, AvailableLanguagesListsTheTranslationsThatCanBeLoaded)
{
const QList<QString> languages =
TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), {testTranslationDirectory()});
EXPECT_EQ(QStringList({QStringLiteral("de")}), QStringList(languages.cbegin(), languages.cend()));
// Files of another application and files without a language code are not languages.
EXPECT_TRUE(TranslationLoader::availableLanguages(QStringLiteral("some_other_app"), {testTranslationDirectory()})
.isEmpty());
EXPECT_TRUE(TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), {}).isEmpty());
}
TEST(TranslationLoaderTest, LoadApplicationReportsAMissingTranslationDirectory)
{
QTemporaryDir emptyDir;
ASSERT_TRUE(emptyDir.isValid());
QTranslator translator;
EXPECT_EQ(TranslationLoadResult::NoDirectory,
TranslationLoader::loadApplication(translator, QStringLiteral("translation_loader_test"),
QStringLiteral("de"),
{emptyDir.path() + QStringLiteral("/translations")}));
}
TEST(TranslationLoaderTest, UnsetLanguageLoadsNothing)
{
QTranslator translator;
// An unset language is the default for English, which has no translation file to begin with:
// nothing is searched for and the translator is left untouched.
EXPECT_EQ(TranslationLoadResult::Missing,
TranslationLoader::loadApplication(translator, QStringLiteral("translation_loader_test"), QString(),
{testTranslationDirectory()}));
EXPECT_EQ(TranslationLoadResult::Missing, TranslationLoader::loadQt(translator, QString()));
EXPECT_TRUE(translator.isEmpty());
EXPECT_TRUE(translator.filePath().isEmpty());
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="de">
<context>
<name>TranslationLoaderTest</name>
<message>
<source>Load into the first translator</source>
<translation>In den ersten Übersetzer laden</translation>
</message>
<message>
<source>Load into the second translator</source>
<translation>In den zweiten Übersetzer laden</translation>
</message>
</context>
</TS>