From 0934380a5c52500cc00769e1e153434beebbacb9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:04:30 +0200 Subject: [PATCH] [Translations] Load Qt's own translations and build ours with the binary (#7384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Translations] Load Qt's own translations and build ours with the binary Three separate bugs kept translations from working. installNewTranslator() loaded the application translation into qtTranslator. QTranslator discards its previous contents on every load, so that wiped the qt_.qm loaded one line earlier and installed an empty translator instead. Qt's own strings were never translated on any platform. Give each file its own translator. QLibraryInfo::path(TranslationsPath) was the only directory searched for Qt's translations. A relocated Qt can report a path that does not exist: the KDE Flatpak runtime reports /usr/translations while its files live in /usr/share/qt6/translations. Probe the usual install layouts and only hand back the ones that exist. The .qm files were attached to the default build target only, so building the executable on its own left it without any translations and the language selector empty. Attach them to the target as well. Move the shared lookup into TranslationLoader so client and Oracle cannot drift again, and have it report a missing translation at debug level. An unset language is the default for English, which has no file to begin with, and warning about it only added noise to every fresh debug log. Co-authored-by: Lukas Brübach * [Tests] Define main() in translation_loader_test Without a GTest installation the fallback in tests/CMakeLists.txt links only gtest, not gtest_main, so the test has to bring its own main(). Every other test already does; only this one relied on the distribution package and failed to link on CI. Co-authored-by: Lukas Brübach * [Tests] Put the translation loader fixture next to the test executable A multi-config generator (MSBuild, Xcode) writes the executable to a per-config subdirectory while the .qm stays in the current binary directory, so applicationDirPath() never saw the fixture and ABuildTreeFindsTheTranslationsNextToTheExecutable failed on Windows. * [Translations] Depend on Qt's lrelease target and skip the loader test without lrelease Attaching the .qm custom commands to a second custom target races with Qt's own _lrelease target on MSBuild/Makefiles and is rejected by Xcode, so depend on Qt's target instead. Only build the translation loader test when Qt6::lrelease is available. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Lukas Brübach Co-authored-by: ZeldaZach Co-authored-by: Claude Opus 5.5 --- cockatrice/CMakeLists.txt | 12 + .../settings_page/general_settings_page.cpp | 17 +- cockatrice/src/main.cpp | 25 +- cockatrice/src/main.h | 1 - libcockatrice_utility/CMakeLists.txt | 2 + .../utility/translation_loader.cpp | 166 +++++++++++ .../utility/translation_loader.h | 92 ++++++ oracle/CMakeLists.txt | 12 + oracle/src/main.cpp | 23 +- oracle/src/pages.cpp | 16 +- tests/CMakeLists.txt | 40 +++ tests/translation_loader_test.cpp | 262 ++++++++++++++++++ tests/translation_loader_test.ts | 15 + 13 files changed, 627 insertions(+), 56 deletions(-) create mode 100644 libcockatrice_utility/libcockatrice/utility/translation_loader.cpp create mode 100644 libcockatrice_utility/libcockatrice/utility/translation_loader.h create mode 100644 tests/translation_loader_test.cpp create mode 100644 tests/translation_loader_test.ts 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 + + +