mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-07 10:33:24 -08:00
Merge branch 'master' into tooomm-qt5
This commit is contained in:
commit
94b85b8940
266 changed files with 11392 additions and 930 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
26
tests/picture_loader_benchmark/CMakeLists.txt
Normal file
26
tests/picture_loader_benchmark/CMakeLists.txt
Normal 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}
|
||||
)
|
||||
547
tests/picture_loader_benchmark/picture_loader_benchmark.cpp
Normal file
547
tests/picture_loader_benchmark/picture_loader_benchmark.cpp
Normal 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;
|
||||
}
|
||||
200
tests/picture_loader_benchmark/settings_cache_mock.cpp
Normal file
200
tests/picture_loader_benchmark/settings_cache_mock.cpp
Normal 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;
|
||||
}
|
||||
54
tests/report_categories_test.cpp
Normal file
54
tests/report_categories_test.cpp
Normal 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();
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
134
tests/single_instance_manager_test.cpp
Normal file
134
tests/single_instance_manager_test.cpp
Normal 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();
|
||||
}
|
||||
30
tests/theme_migration/CMakeLists.txt
Normal file
30
tests/theme_migration/CMakeLists.txt
Normal 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()
|
||||
154
tests/theme_migration/theme_migration_test.cpp
Normal file
154
tests/theme_migration/theme_migration_test.cpp
Normal 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();
|
||||
}
|
||||
262
tests/translation_loader_test.cpp
Normal file
262
tests/translation_loader_test.cpp
Normal 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();
|
||||
}
|
||||
15
tests/translation_loader_test.ts
Normal file
15
tests/translation_loader_test.ts
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue