aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-15 12:37:41 +0200
committerDanilo M. <danix@danix.xyz>2026-08-15 12:37:41 +0200
commit5a3f827a01d1902a0dadc5debb4200138d4af885 (patch)
tree5b00207aefccf625a54ac4b928a05561d1a0fff1 /tests
parent8e7208dc771bc9344f583442ad58cdd5b4178233 (diff)
downloadqtmaildir-5a3f827a01d1902a0dadc5debb4200138d4af885.tar.gz
qtmaildir-5a3f827a01d1902a0dadc5debb4200138d4af885.zip
feat(i18n): wire translations and ship an Italian one (item 22)
Nothing loaded a translation before this: no QTranslator, no .ts file and no build rule, so every string was English whatever the locale said. The language now comes from the environment, LANG=it_IT.UTF-8, and any other locale runs in English as before. The audit found that the tr() discipline was largely holding, and found eight strings that could never be translated into any language. kFields[] in tagrulesdialog.cpp declared the rule-builder field labels with QT_TR_NOOP inside an anonymous namespace, where lupdate reports "tr() cannot be called without context" and extracts nothing, while the use site calls TagRulesDialog::tr() on them at runtime. From, To, Cc, Subject, Tag, Folder, Attachment and Date: the whole vocabulary of the rule builder, absent from every translation file that could ever exist. The source compiles and reads correctly; only lupdate reveals it. Q_DECLARE_TR_FUNCTIONS is not the fix for that case, though it is the fix for a free function calling tr(). Measured against lupdate: a class carrying the macro beside the array still extracts 0 strings, because the context must be attached to the literal itself. QT_TRANSLATE_NOOP names it explicitly and matches the tr() that already reads them, so the use site needed no change. Twenty configuration and keybinding warnings were not translatable either. They are user-facing, reaching the status label and the "Configuration problems" dialog. Config already had the tr() macro; KeyMap needed it. Translating the filter labels then broke startup_query, found in hand testing: a filter's name is a translated label, so `startup_query = Inbox` matched nothing where the filter shows as "In arrivo". The application opened a different view and reported the user's own working config as invalid. Resolution matches the generator as well now, which is stored in queries.json and identical in every locale; the translated name still works. The regression test installs a real QTranslator rather than a stub, since the bug lives in the gap between the stored string and the displayed one, and it writes a queries.json because the warning it asserts on is guarded by a non-empty saved-query list: without one the branch never runs and the test passes against a broken check. main.cpp's --help and --version stay bare printf, as they run before QApplication exists and no translator could serve them. Verified per the backlog's own standard, that lupdate output is the evidence rather than reading: 355 strings extracted with zero context warnings, where before there were 327 with eight; lrelease reporting 355 finished and 0 unfinished; the built .qm loaded in a standalone probe printing "From -> Da" and both Italian plural forms; and the install rule placing it where main.cpp looks. test_translations guards it and was mutation checked, failing on an emptied translation and naming the defect when QT_TRANSLATE_NOOP is reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt16
-rw-r--r--tests/test_config.cpp87
-rw-r--r--tests/test_translations.cpp226
3 files changed, 329 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 5f7bd48..f9bc7d1 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -34,6 +34,16 @@ endfunction()
add_qtmaildir_test(keymap)
add_qtmaildir_test(config)
+# One test installs the real Italian translation, to prove startup_query still
+# resolves when a filter's displayed name is not its English one. It needs the
+# compiled .qm, so it depends on the target that builds it.
+if(Qt6LinguistTools_FOUND)
+ target_compile_definitions(test_config PRIVATE
+ TRANSLATIONS_QM="${CMAKE_BINARY_DIR}/src/translations/qtmaildir_it_IT.qm")
+ add_dependencies(test_config qtmaildir_translations)
+else()
+ target_compile_definitions(test_config PRIVATE TRANSLATIONS_QM="")
+endif()
add_qtmaildir_test(mimeparser)
target_compile_definitions(test_mimeparser PRIVATE
FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures")
@@ -57,3 +67,9 @@ add_qtmaildir_test(rulequery)
add_qtmaildir_test(searchterm)
add_qtmaildir_test(tagstrip)
add_qtmaildir_test(messagedetailsdialog)
+add_qtmaildir_test(translations)
+# Asserts on the tracked .ts rather than the generated .qm: an untranslated
+# string is dropped by lrelease, so it is invisible in the .qm and shows up
+# only as English in a running Italian UI.
+target_compile_definitions(test_translations PRIVATE
+ TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations")
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index e2dffb0..00e8d89 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -18,6 +18,7 @@
#include <QtTest>
#include <QTemporaryDir>
+#include <QTranslator>
#include <QSettings>
#include <QJsonArray>
#include <QJsonDocument>
@@ -99,6 +100,7 @@ private slots:
void theStartupAccountIsReadAndValidated();
void theStartupAccountTakesTheKeyNotTheSyncChannel();
void theStartupQueryCanNameABuiltinFilter();
+ void theStartupQuerySurvivesATranslatedFilterName();
void theStartupQueryPrefersASavedQueryOverAFilterOfTheSameName();
void anUnmatchedStartupQueryFallsBackToAFilterNotAStrayQuery();
void theFlaggedFilterIsCalledImportant();
@@ -1175,6 +1177,91 @@ void TestConfig::theStartupQueryCanNameABuiltinFilter()
QStringLiteral("tag:inbox"));
}
+void TestConfig::theStartupQuerySurvivesATranslatedFilterName()
+{
+ // Reported by the user running the 0.23.0 Italian translation: the app
+ // started on the wrong view and said
+ //
+ // La ricerca iniziale 'Inbox' non è una ricerca salvata; verrà aperta
+ // 'Non letti'.
+ //
+ // A filter's NAME is a translated label, so `startup_query = Inbox` matched
+ // nothing once the Inbox filter was called "In arrivo": a config file that
+ // had always worked broke because the UI language changed, and the warning
+ // named the user's own correct config as the fault.
+ //
+ // The fix matches the GENERATOR too, which is stored in queries.json and
+ // identical in every locale. Uses a real QTranslator rather than a stub,
+ // because the bug lives in the gap between the stored string and the
+ // displayed one, and only an actual translation opens that gap.
+ QTranslator translator;
+ const QString qm = QStringLiteral(TRANSLATIONS_QM);
+ QVERIFY2(QFile::exists(qm),
+ qPrintable(QStringLiteral("no compiled translation at %1").arg(qm)));
+ QVERIFY2(translator.load(qm), "the Italian translation failed to load");
+ QVERIFY(qApp->installTranslator(&translator));
+
+ // Proves the translator is actually in effect. Without this the test passes
+ // when the translation silently fails to load, asserting nothing: the names
+ // stay English and every comparison below succeeds for the wrong reason.
+ const SavedQuery inbox = Config::builtinFilter(QStringLiteral("inbox"));
+ QCOMPARE(inbox.name, QStringLiteral("In arrivo"));
+
+ QTemporaryDir dir;
+ // A saved query has to exist for the warning to be reachable at all: the
+ // check is guarded by !m_savedQueries.isEmpty(). Without this file the
+ // branch never runs, and an assertion that no problem was reported passes
+ // against a broken check by never reaching it. Measured: with no
+ // queries.json, reverting the fix left this test green.
+ {
+ QFile queries(dir.filePath(QStringLiteral("queries.json")));
+ QVERIFY(queries.open(QIODevice::WriteOnly));
+ queries.write(QStringLiteral(R"({
+ "version": 1,
+ "queries": [ { "name": "Mine", "query": "tag:mine" } ]
+ })").toUtf8());
+ }
+
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "startup_query=Inbox\n"
+ "\n"
+ "[account.work]\n"
+ "maildir=work\n"
+ "sent=Sent\n")));
+ QVERIFY2(!config.savedQueries().isEmpty(),
+ "queries.json did not load, so the warning path is unreachable");
+
+ const SavedQuery startup = config.startupSavedQuery();
+ QCOMPARE(startup.generated, QStringLiteral("inbox"));
+ QCOMPARE(config.resolvedQuery(startup, QString()),
+ QStringLiteral("tag:inbox"));
+
+ // And it must not warn about a config that is working. The user saw the
+ // warning as well as the wrong view, and a warning they cannot act on is
+ // its own defect.
+ QVERIFY2(config.problems().isEmpty(),
+ qPrintable(QStringLiteral("unexpected problem: %1")
+ .arg(config.problems().join(QLatin1Char(' ')))));
+
+ // The translated name still works, since that is what a user reading their
+ // own Italian UI would naturally write.
+ Config byLabel;
+ byLabel.load(writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "startup_query=In arrivo\n"
+ "\n"
+ "[account.work]\n"
+ "maildir=work\n"
+ "sent=Sent\n")));
+ QVERIFY(!byLabel.savedQueries().isEmpty());
+ QCOMPARE(byLabel.startupSavedQuery().generated, QStringLiteral("inbox"));
+ QVERIFY(byLabel.problems().isEmpty());
+
+ qApp->removeTranslator(&translator);
+}
+
void TestConfig::theFlaggedFilterIsCalledImportant()
{
// Item 57 decided this and item 93 contradicted it. The `flag` ACTION has
diff --git a/tests/test_translations.cpp b/tests/test_translations.cpp
new file mode 100644
index 0000000..86ff517
--- /dev/null
+++ b/tests/test_translations.cpp
@@ -0,0 +1,226 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+// Guards the shipped translation against the two ways it rots silently: a new
+// user-facing string added without a translation, and the extraction defect
+// item 22 exists to fix, where a literal is invisible to lupdate and therefore
+// untranslatable while the source looks correct.
+//
+// This asserts on the .ts file rather than on a running UI deliberately. The
+// backlog entry is explicit that lupdate output is the evidence here, not
+// reading: the eight rule-builder labels below were wrapped in QT_TR_NOOP,
+// compiled, ran, and were still unreachable by any translation.
+
+#include <QtTest>
+
+#include <QFile>
+#include <QSet>
+#include <QString>
+#include <QXmlStreamReader>
+
+class TestTranslations : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void everyStringIsTranslated();
+ void everyStringIsTranslated_data();
+
+ void theRuleBuilderFieldLabelsAreExtracted();
+ void theFileIsWellFormedAndItalian();
+
+private:
+ static QString tsPath()
+ {
+ return QStringLiteral(TRANSLATIONS_DIR "/qtmaildir_it_IT.ts");
+ }
+};
+
+// Reads every <message> as (context, source, translation, unfinished).
+struct Entry {
+ QString context;
+ QString source;
+ QString translation;
+ bool unfinished = false;
+};
+
+static QList<Entry> readEntries(const QString &path, QString *error)
+{
+ QList<Entry> entries;
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ *error = QStringLiteral("cannot open %1: %2").arg(path, file.errorString());
+ return entries;
+ }
+
+ QXmlStreamReader xml(&file);
+ QString context;
+ while (!xml.atEnd()) {
+ xml.readNext();
+ if (!xml.isStartElement())
+ continue;
+
+ // <name> appears inside both <context> and <message>; only the one
+ // directly under <context> names the class.
+ if (xml.name() == QLatin1String("name")) {
+ context = xml.readElementText();
+ continue;
+ }
+ if (xml.name() != QLatin1String("message"))
+ continue;
+
+ Entry entry;
+ entry.context = context;
+ const bool numerus =
+ xml.attributes().value(QLatin1String("numerus")) == QLatin1String("yes");
+
+ while (!(xml.isEndElement() && xml.name() == QLatin1String("message"))
+ && !xml.atEnd()) {
+ xml.readNext();
+ if (!xml.isStartElement())
+ continue;
+ if (xml.name() == QLatin1String("source")) {
+ entry.source = xml.readElementText();
+ } else if (xml.name() == QLatin1String("translation")) {
+ entry.unfinished =
+ xml.attributes().value(QLatin1String("type"))
+ == QLatin1String("unfinished");
+ if (!numerus) {
+ entry.translation = xml.readElementText();
+ } else {
+ // A numerus message carries one <numerusform> per plural
+ // form. Italian has two, and an empty one is as untranslated
+ // as an empty <translation>.
+ QStringList forms;
+ while (!(xml.isEndElement()
+ && xml.name() == QLatin1String("translation"))
+ && !xml.atEnd()) {
+ xml.readNext();
+ if (xml.isStartElement()
+ && xml.name() == QLatin1String("numerusform"))
+ forms << xml.readElementText();
+ }
+ entry.translation = forms.join(QLatin1Char('\x1f'));
+ if (forms.size() != 2 || forms.contains(QString()))
+ entry.translation.clear();
+ }
+ }
+ }
+ entries.append(entry);
+ }
+
+ if (xml.hasError())
+ *error = xml.errorString();
+ return entries;
+}
+
+void TestTranslations::everyStringIsTranslated_data()
+{
+ QTest::addColumn<QString>("context");
+ QTest::addColumn<QString>("source");
+ QTest::addColumn<QString>("translation");
+ QTest::addColumn<bool>("unfinished");
+
+ QString error;
+ const QList<Entry> entries = readEntries(tsPath(), &error);
+ QVERIFY2(error.isEmpty(), qPrintable(error));
+
+ // A file that parsed to nothing would let every row-driven check below pass
+ // by never running, which is the rendering-probe trap in a different shape.
+ QVERIFY2(entries.size() > 300,
+ qPrintable(QStringLiteral("only %1 entries; the .ts looks truncated")
+ .arg(entries.size())));
+
+ for (const Entry &entry : entries) {
+ const QByteArray tag =
+ (entry.context + QLatin1String(" :: ") + entry.source).toUtf8();
+ QTest::newRow(tag.constData())
+ << entry.context << entry.source << entry.translation
+ << entry.unfinished;
+ }
+}
+
+void TestTranslations::everyStringIsTranslated()
+{
+ QFETCH(QString, translation);
+ QFETCH(bool, unfinished);
+
+ // lrelease drops anything still flagged unfinished, so such a string ships
+ // as English inside an otherwise Italian UI rather than failing the build.
+ QVERIFY2(!unfinished, "still marked type=\"unfinished\"");
+ QVERIFY2(!translation.trimmed().isEmpty(), "no translation");
+}
+
+void TestTranslations::theRuleBuilderFieldLabelsAreExtracted()
+{
+ QString error;
+ const QList<Entry> entries = readEntries(tsPath(), &error);
+ QVERIFY2(error.isEmpty(), qPrintable(error));
+
+ QSet<QString> found;
+ for (const Entry &entry : entries) {
+ if (entry.context == QLatin1String("TagRulesDialog"))
+ found.insert(entry.source);
+ }
+
+ // These eight sat in an anonymous namespace under QT_TR_NOOP, where lupdate
+ // reports "tr() cannot be called without context" and extracts nothing,
+ // while TagRulesDialog::tr() read them at runtime. Every one was
+ // untranslatable and the source looked right. QT_TRANSLATE_NOOP, naming the
+ // context explicitly, is what fixed it; Q_DECLARE_TR_FUNCTIONS on a
+ // neighbouring class does NOT, measured at 0 extracted.
+ //
+ // The context asserted here must stay TagRulesDialog: it is what the
+ // reading tr() resolves against, so a mismatch is untranslated at runtime
+ // with a perfectly populated .ts.
+ for (const QString &label : { QStringLiteral("From"), QStringLiteral("To"),
+ QStringLiteral("Cc"), QStringLiteral("Subject"),
+ QStringLiteral("Tag"), QStringLiteral("Folder"),
+ QStringLiteral("Attachment"),
+ QStringLiteral("Date") }) {
+ QVERIFY2(found.contains(label),
+ qPrintable(QStringLiteral(
+ "TagRulesDialog/%1 is missing from the .ts: lupdate cannot "
+ "see it, so it can never be translated").arg(label)));
+ }
+}
+
+void TestTranslations::theFileIsWellFormedAndItalian()
+{
+ QFile file(tsPath());
+ QVERIFY2(file.open(QIODevice::ReadOnly | QIODevice::Text),
+ qPrintable(file.errorString()));
+
+ QXmlStreamReader xml(&file);
+ QString language;
+ while (!xml.atEnd()) {
+ xml.readNext();
+ if (xml.isStartElement() && xml.name() == QLatin1String("TS")) {
+ language = xml.attributes().value(QLatin1String("language")).toString();
+ break;
+ }
+ }
+ QVERIFY2(!xml.hasError(), qPrintable(xml.errorString()));
+
+ // QTranslator::load() derives the file from the locale, so the language
+ // attribute is what pairs this file with LANG=it_IT.
+ QCOMPARE(language, QStringLiteral("it_IT"));
+}
+
+QTEST_MAIN(TestTranslations)
+#include "test_translations.moc"