[Translations] Load Qt's own translations and build ours with the binary (#7384)

* [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_<lang>.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 <Bruebach.Lukas@bdosecurity.de>

* [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 <Bruebach.Lukas@bdosecurity.de>

* [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 <Project>_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 <noreply@anthropic.com>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: ZeldaZach <zahalpern+github@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BruebachL 2026-10-03 15:04:30 +02:00 • committed by GitHub
parent 3aba1ef929
commit 0934380a5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 627 additions and 56 deletions

View file

@ -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}"
"$<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)
@ -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}
)

View file

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

View file

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