aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
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"