diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 16 | ||||
| -rw-r--r-- | tests/test_config.cpp | 87 | ||||
| -rw-r--r-- | tests/test_translations.cpp | 226 |
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" |
