From fe1894ebd6e4111c029e92a660dc9d41865daa1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 6 Sep 2026 11:29:40 +0200 Subject: [PATCH 1/2] tests: add manual picture loader benchmark against the real card hosts (cherry picked from commit 588d71d3b9a8278ab98b847802908f322c1035b7) --- tests/CMakeLists.txt | 6 + tests/picture_loader_benchmark/CMakeLists.txt | 26 + .../picture_loader_benchmark.cpp | 479 ++++++++++++++++++ .../settings_cache_mock.cpp | 200 ++++++++ 4 files changed, 711 insertions(+) create mode 100644 tests/picture_loader_benchmark/CMakeLists.txt create mode 100644 tests/picture_loader_benchmark/picture_loader_benchmark.cpp create mode 100644 tests/picture_loader_benchmark/settings_cache_mock.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7bb834d7e..d23d9ba04 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -155,3 +155,9 @@ add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) add_subdirectory(oracle) add_subdirectory(settings) + +# 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() diff --git a/tests/picture_loader_benchmark/CMakeLists.txt b/tests/picture_loader_benchmark/CMakeLists.txt new file mode 100644 index 000000000..a3b75fe88 --- /dev/null +++ b/tests/picture_loader_benchmark/CMakeLists.txt @@ -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} +) diff --git a/tests/picture_loader_benchmark/picture_loader_benchmark.cpp b/tests/picture_loader_benchmark/picture_loader_benchmark.cpp new file mode 100644 index 000000000..44d930d37 --- /dev/null +++ b/tests/picture_loader_benchmark/picture_loader_benchmark.cpp @@ -0,0 +1,479 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 selectCardsForTemplate(CardDatabase &db, const QString &urlTemplate, int maxCards) +{ + QList selected; + const QList 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 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 &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 LogCounters *s_activeCounters = nullptr; +static QtMessageHandler s_previousMessageHandler = nullptr; + +static void stressLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) +{ + if (s_activeCounters && msg.contains(QStringLiteral("Too many requests from"))) { + QMutexLocker locker(&s_activeCounters->mutex); + ++s_activeCounters->http429; + } + if (s_previousMessageHandler) { + s_previousMessageHandler(type, context, msg); + } +} + +static StressResult runStress(CardPictureLoaderWorker *workerA, + CardPictureLoaderWorker *workerB, + const QList &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; + + result.http429Count = counters.http429; + s_activeCounters = nullptr; + 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 [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 cards.xml to load card data from (required)\n" + " --count cards to load per template (default 300)\n" + " --url