diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index df603eb71..1760d86b8 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -798,6 +798,18 @@ if(Qt6LinguistTools_FOUND) elseif(WIN32) install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_WIN32_QM_INSTALL_DIR}) endif() + + # Qt attaches the .qm files to the default build target only, so building the executable on its own + # (CLion, --target cockatrice) leaves it without any translations: nothing is translated and the + # language selector stays empty. + # Depend on Qt's own lrelease target instead of attaching the .qm custom commands to a second + # target: two independent targets owning the same outputs race on MSBuild/Makefiles and are an + # error on Xcode. Older Qt versions already add this dependency themselves. + foreach(_lrelease_target Cockatrice_lrelease cockatrice_lrelease) + if(TARGET ${_lrelease_target}) + add_dependencies(cockatrice ${_lrelease_target}) + endif() + endforeach() endif() qt6_finalize_target(cockatrice) diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index b0fd0e018..57099fb4a 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -21,6 +21,7 @@ #include #include #include +#include enum startupCardUpdateCheckBehaviorIndex { @@ -333,22 +334,18 @@ GeneralSettingsPage::GeneralSettingsPage() QStringList GeneralSettingsPage::findQmFiles() { - QDir dir(translationPath); - QStringList fileNames = dir.entryList(QStringList(translationPrefix + "_*.qm"), QDir::Files, QDir::Name); - fileNames.replaceInStrings(QRegularExpression(translationPrefix + "_(.*)\\.qm"), "\\1"); - return fileNames; + return TranslationLoader::availableLanguages(translationPrefix, + TranslationLoader::applicationTranslationPaths(translationPath)); } QString GeneralSettingsPage::languageName(const QString &lang) { QTranslator qTranslator; - QString appNameHint = translationPrefix + "_" + lang; - bool appTranslationLoaded = qTranslator.load(appNameHint, translationPath); - if (!appTranslationLoaded) { - qCWarning(GeneralSettingsPageLog) - << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath; - } + // Only the language name is needed here, so the file is loaded without logging a line per + // language: findQmFiles() asked for languages whose files exist in the first place. + TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang, + TranslationLoader::applicationTranslationPaths(translationPath)); return qTranslator.translate("i18n", DEFAULT_LANG_NAME); } diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 5050ff8be..af519f436 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -39,7 +39,6 @@ #include #include #include -#include #include #include #include @@ -55,6 +54,7 @@ #include #include #include +#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -141,26 +141,13 @@ void installNewTranslator() { QString lang = SettingsCache::instance().personal().getLang(); - QString qtNameHint = "qt_" + lang; - QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath); - - bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath); - if (!qtTranslationLoaded) { - qCWarning(QtTranslatorDebug) << "Unable to load qt translation" << qtNameHint << "at" << qtTranslationPath; - } else { - qCInfo(QtTranslatorDebug) << "Loaded qt translation" << qtNameHint << "at" << qtTranslationPath; - } + // Qt's own strings and ours need a translator each: QTranslator discards its previous contents + // on every load, so loading both files into one translator silently drops the first of them. + TranslationLoader::loadQt(*qtTranslator, lang); qApp->installTranslator(qtTranslator); - QString appNameHint = translationPrefix + "_" + lang; - bool appTranslationLoaded = qtTranslator->load(appNameHint, translationPath); - if (!appTranslationLoaded) { - qCWarning(QtTranslatorDebug) << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" - << translationPath; - } else { - qCInfo(QtTranslatorDebug) << "Loaded" << translationPrefix << "translation" << appNameHint << "at" - << translationPath; - } + TranslationLoader::loadApplication(*translator, translationPrefix, lang, + TranslationLoader::applicationTranslationPaths(translationPath)); qApp->installTranslator(translator); } diff --git a/cockatrice/src/main.h b/cockatrice/src/main.h index ef75a41f4..eee262d2c 100644 --- a/cockatrice/src/main.h +++ b/cockatrice/src/main.h @@ -10,7 +10,6 @@ #include inline Q_LOGGING_CATEGORY(MainLog, "main"); -inline Q_LOGGING_CATEGORY(QtTranslatorDebug, "qt_translator"); class CardDatabase; class QString; diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index ab647258c..41f1bfa0f 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -12,6 +12,7 @@ set(UTILITY_SOURCES libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/translation_loader.cpp libcockatrice/utility/warning_categories.cpp ) @@ -32,6 +33,7 @@ set(UTILITY_HEADERS libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/translation_loader.h libcockatrice/utility/warning_categories.h ) diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp new file mode 100644 index 000000000..b06bcffc6 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp @@ -0,0 +1,166 @@ +#include "translation_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Kept under the historical category name so existing QT_LOGGING_RULES filters keep working. +Q_LOGGING_CATEGORY(TranslationLoaderLog, "qt_translator") + +QList TranslationLoader::qtTranslationCandidates() +{ + QStringList candidates; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + // Qt 6.8 exposes every path listed in qt.conf, path() only returns the first one. + candidates << QLibraryInfo::paths(QLibraryInfo::TranslationsPath); +#else + candidates << QLibraryInfo::path(QLibraryInfo::TranslationsPath); +#endif + + // A relocated Qt can report a prefix-relative path that does not exist, so probe the usual + // install layouts as well instead of trusting that single report. + const QString prefix = QLibraryInfo::path(QLibraryInfo::PrefixPath); + const QString dataPath = QLibraryInfo::path(QLibraryInfo::DataPath); + candidates << prefix + QStringLiteral("/share/qt6/translations") + << prefix + QStringLiteral("/share/qt/translations") << prefix + QStringLiteral("/translations") + << dataPath + QStringLiteral("/translations"); + + QList uniqueCandidates; + for (const QString &candidate : candidates) { + if (!candidate.isEmpty() && !uniqueCandidates.contains(candidate)) { + uniqueCandidates << candidate; + } + } + return uniqueCandidates; +} + +QList TranslationLoader::qtTranslationPaths() +{ + QList existingPaths; + for (const QString &candidate : qtTranslationCandidates()) { + if (QFileInfo::exists(candidate)) { + existingPaths << candidate; + } + } + return existingPaths; +} + +QList TranslationLoader::applicationTranslationPaths(const QString &_installedPath) +{ + // A build tree keeps the .qm files next to the executable, the installed layout puts them in + // _installedPath. Searching both keeps a developer build translated as well. + const QString executableDirectory = QCoreApplication::applicationDirPath(); + + QList paths; + for (const QString &path : {_installedPath, executableDirectory}) { + if (!path.isEmpty() && !paths.contains(path)) { + paths << path; + } + } + return paths; +} + +QList TranslationLoader::availableLanguages(const QString &_prefix, const QList &_directories) +{ + const QRegularExpression filePattern( + QRegularExpression::anchoredPattern(QRegularExpression::escape(_prefix) + QStringLiteral("_(.*)\\.qm"))); + + QSet languages; + for (const QString &directory : _directories) { + if (directory.isEmpty()) { + continue; + } + const QDir dir(directory); + for (const QString &fileName : dir.entryList(QDir::Files)) { + const auto match = filePattern.match(fileName); + if (match.hasMatch()) { + languages.insert(match.captured(1)); + } + } + } + + QList sortedLanguages(languages.cbegin(), languages.cend()); + std::sort(sortedLanguages.begin(), sortedLanguages.end()); + return sortedLanguages; +} + +TranslationLoadResult +TranslationLoader::loadFrom(QTranslator &_translator, const QString &_nameHint, const QList &_directories) +{ + bool anyDirectoryExists = false; + + for (const QString &directory : _directories) { + if (directory.isEmpty() || !QFileInfo::exists(directory)) { + continue; + } + anyDirectoryExists = true; + if (_translator.load(_nameHint, directory)) { + return TranslationLoadResult::Loaded; + } + } + + return anyDirectoryExists ? TranslationLoadResult::Missing : TranslationLoadResult::NoDirectory; +} + +TranslationLoadResult TranslationLoader::loadQt(QTranslator &_translator, const QString &_lang) +{ + // An unset language is the default for English, which has no translation file to begin with. + if (_lang.isEmpty()) { + return TranslationLoadResult::Missing; + } + + const QString nameHint = QStringLiteral("qt_") + _lang; + const auto result = loadFrom(_translator, nameHint, qtTranslationPaths()); + + switch (result) { + case TranslationLoadResult::Loaded: + qCInfo(TranslationLoaderLog) << "Loaded Qt translation" << nameHint << "at" << _translator.filePath(); + break; + case TranslationLoadResult::Missing: + qCDebug(TranslationLoaderLog) + << "No Qt translation" << nameHint << "in" << QStringList(qtTranslationPaths()); + break; + case TranslationLoadResult::NoDirectory: + qCWarning(TranslationLoaderLog) + << "No Qt translations directory exists, looked in" << QStringList(qtTranslationPaths()); + break; + } + + return result; +} + +TranslationLoadResult TranslationLoader::loadApplication(QTranslator &_translator, + const QString &_prefix, + const QString &_lang, + const QList &_directories) +{ + if (_lang.isEmpty()) { + return TranslationLoadResult::Missing; + } + + const QString nameHint = _prefix + QLatin1Char('_') + _lang; + const auto result = loadFrom(_translator, nameHint, _directories); + + switch (result) { + case TranslationLoadResult::Loaded: + qCInfo(TranslationLoaderLog) << "Loaded" << _prefix << "translation" << nameHint << "at" + << _translator.filePath(); + break; + case TranslationLoadResult::Missing: + qCDebug(TranslationLoaderLog) + << "No" << _prefix << "translation" << nameHint << "in" << QStringList(_directories); + break; + case TranslationLoadResult::NoDirectory: + qCWarning(TranslationLoaderLog) + << "No translation directory exists, looked in" << QStringList(_directories); + break; + } + + return result; +} diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.h b/libcockatrice_utility/libcockatrice/utility/translation_loader.h new file mode 100644 index 000000000..645489696 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.h @@ -0,0 +1,92 @@ +#ifndef TRANSLATION_LOADER_H +#define TRANSLATION_LOADER_H + +#include +#include +#include + +class QTranslator; + +Q_DECLARE_LOGGING_CATEGORY(TranslationLoaderLog) + +/** @brief The outcome of a translation load attempt. */ +enum class TranslationLoadResult +{ + Loaded, /**< A translation file was found and loaded into the translator. */ + Missing, /**< The search directories exist but hold no file for this language. */ + NoDirectory /**< Not a single search directory exists, so no lookup could be attempted. */ +}; + +namespace TranslationLoader +{ +/** + * @brief Every directory that may hold Qt's own translations, in order of preference. + * + * More than one entry is needed because a relocated Qt (Flatpak, AppImage, a bundled SDK) can report + * a prefix-relative translations path that does not exist: the KDE runtime reports /usr/translations + * while its qt_*.qm files live in /usr/share/qt6/translations. + */ +QList qtTranslationCandidates(); + +/** + * @brief The candidates that actually exist, most likely first, so no caller searches a path that is + * not there. + */ +QList qtTranslationPaths(); + +/** + * @brief The directories to search for an application's own translation files, in order of preference. + * + * @param _installedPath The location the platform installs the translation files in, derived from the + * executable's path. + * @return The installed location, followed by the executable's own directory: a build tree keeps the + * .qm files next to the binary instead of in the installed layout, so a developer build would + * otherwise find no translations at all. + */ +QList applicationTranslationPaths(const QString &_installedPath); + +/** + * @brief Loads the first _nameHint + language translation file found in _directories into _translator. + * + * QTranslator discards whatever it held before every load, so each translation file needs its own + * translator; loading a second file into the first one silently drops the first. + * + * @param _translator The translator to load into. + * @param _nameHint The translation file name without its language and suffix, e.g. "qt_de" or "cockatrice_de". + * @param _directories The directories to search, in order of preference. + * @return What the search found. + */ +TranslationLoadResult loadFrom(QTranslator &_translator, const QString &_nameHint, const QList &_directories); + +/** + * @brief Loads Qt's own translation for _lang, which covers the strings of Qt's own dialogs. + * + * @param _translator The translator to load into; must not also be used for an application translation. + * @param _lang The language code, e.g. "de". An empty language loads nothing. + * @return What the search found. + */ +TranslationLoadResult loadQt(QTranslator &_translator, const QString &_lang); + +/** + * @brief The language codes of the <_prefix>_.qm files in _directories, sorted by code. + * + * Lets a language selector offer exactly the languages that can be loaded. + */ +QList availableLanguages(const QString &_prefix, const QList &_directories); + +/** + * @brief Loads the <_prefix>_<_lang>.qm translation file from _directories into _translator. + * + * @param _translator The translator to load into; must not also be used for Qt's own translation. + * @param _prefix The translation file prefix, e.g. "cockatrice" or "oracle". + * @param _lang The language code, e.g. "de". An empty language loads nothing. + * @param _directories The directories to search, in order of preference. + * @return What the search found. + */ +TranslationLoadResult loadApplication(QTranslator &_translator, + const QString &_prefix, + const QString &_lang, + const QList &_directories); +} // namespace TranslationLoader + +#endif // TRANSLATION_LOADER_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index d8f30dcad..0003b77ac 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -299,6 +299,18 @@ if(Qt6LinguistTools_FOUND) elseif(WIN32) install(FILES ${oracle_QM} DESTINATION ${ORACLE_WIN32_QM_INSTALL_DIR}) endif() + + # Qt attaches the .qm files to the default build target only, so building the executable on its own + # (CLion, --target oracle) leaves it without any translations: nothing is translated and the + # language selector in the wizard stays empty. + # Depend on Qt's own lrelease target instead of attaching the .qm custom commands to a second + # target: two independent targets owning the same outputs race on MSBuild/Makefiles and are an + # error on Xcode. Older Qt versions already add this dependency themselves. + foreach(_lrelease_target Oracle_lrelease oracle_lrelease) + if(TARGET ${_lrelease_target}) + add_dependencies(oracle ${_lrelease_target}) + endif() + endforeach() endif() qt6_finalize_target(oracle) diff --git a/oracle/src/main.cpp b/oracle/src/main.cpp index 1d3983461..103c72784 100644 --- a/oracle/src/main.cpp +++ b/oracle/src/main.cpp @@ -7,10 +7,10 @@ #include #include #include -#include #include #include #include +#include QTranslator *translator, *qtTranslator; ThemeManager *themeManager; @@ -24,24 +24,13 @@ void installNewTranslator() { QString lang = SettingsCache::instance().personal().getLang(); - QString qtNameHint = "qt_" + lang; - QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath); - - bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath); - if (!qtTranslationLoaded) { - qDebug() << "Unable to load qt translation" << qtNameHint << "at" << qtTranslationPath; - } else { - qDebug() << "Loaded qt translation" << qtNameHint << "at" << qtTranslationPath; - } + // Qt's own strings and ours need a translator each: QTranslator discards its previous contents + // on every load, so loading both files into one translator silently drops the first of them. + TranslationLoader::loadQt(*qtTranslator, lang); qApp->installTranslator(qtTranslator); - QString appNameHint = translationPrefix + "_" + lang; - bool appTranslationLoaded = qtTranslator->load(appNameHint, translationPath); - if (!appTranslationLoaded) { - qDebug() << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath; - } else { - qDebug() << "Loaded" << translationPrefix << "translation" << appNameHint << "at" << translationPath; - } + TranslationLoader::loadApplication(*translator, translationPrefix, lang, + TranslationLoader::applicationTranslationPaths(translationPath)); qApp->installTranslator(translator); } diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index df0c3f51d..566c88520 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #ifdef HAS_LZMA #include "lzma/decompress.h" @@ -212,21 +213,18 @@ void IntroPage::initializePage() QStringList IntroPage::findQmFiles() { - QDir dir(translationPath); - QStringList fileNames = dir.entryList(QStringList(translationPrefix + "_*.qm"), QDir::Files, QDir::Name); - fileNames.replaceInStrings(QRegularExpression(translationPrefix + "_(.*)\\.qm"), "\\1"); - return fileNames; + return TranslationLoader::availableLanguages(translationPrefix, + TranslationLoader::applicationTranslationPaths(translationPath)); } QString IntroPage::languageName(const QString &lang) { QTranslator qTranslator; - QString appNameHint = translationPrefix + "_" + lang; - bool appTranslationLoaded = qTranslator.load(appNameHint, translationPath); - if (!appTranslationLoaded) { - qDebug() << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath; - } + // Only the language name is needed here, so the file is loaded without logging a line per + // language: findQmFiles() asked for languages whose files exist in the first place. + TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang, + TranslationLoader::applicationTranslationPaths(translationPath)); return qTranslator.translate("i18n", DEFAULT_LANG_NAME); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5c12957fb..b51d10560 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,10 @@ 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) @@ -38,6 +42,34 @@ 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}" + "$/" + 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) @@ -104,6 +136,9 @@ 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) @@ -152,6 +187,11 @@ target_link_libraries( target_link_libraries( warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +if(TARGET translation_loader_test) + target_link_libraries( + translation_loader_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} + ) +endif() target_link_libraries( report_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) diff --git a/tests/translation_loader_test.cpp b/tests/translation_loader_test.cpp new file mode 100644 index 000000000..d54001ad3 --- /dev/null +++ b/tests/translation_loader_test.cpp @@ -0,0 +1,262 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 candidates = TranslationLoader::qtTranslationCandidates(); + + EXPECT_FALSE(candidates.isEmpty()); + for (const QString &candidate : candidates) { + EXPECT_FALSE(candidate.isEmpty()); + } + EXPECT_EQ(candidates.size(), QSet(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 candidates = TranslationLoader::qtTranslationCandidates(); + const QList paths = TranslationLoader::qtTranslationPaths(); + + for (const QString &path : paths) { + EXPECT_FALSE(path.isEmpty()); + EXPECT_TRUE(QDir(path).exists()) << path.toStdString(); + } + + EXPECT_EQ(paths.size(), QSet(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 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 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 withoutInstalledPath = TranslationLoader::applicationTranslationPaths(QString()); + ASSERT_EQ(1, withoutInstalledPath.size()); + EXPECT_EQ(executableDirectory, withoutInstalledPath.at(0)); +} + +TEST(TranslationLoaderTest, AvailableLanguagesListsTheTranslationsThatCanBeLoaded) +{ + const QList 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(); +} diff --git a/tests/translation_loader_test.ts b/tests/translation_loader_test.ts new file mode 100644 index 000000000..a02b2e753 --- /dev/null +++ b/tests/translation_loader_test.ts @@ -0,0 +1,15 @@ + + + + + TranslationLoaderTest + + Load into the first translator + In den ersten Übersetzer laden + + + Load into the second translator + In den zweiten Übersetzer laden + + +