From 5a3f827a01d1902a0dadc5debb4200138d4af885 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 15 Aug 2026 12:37:41 +0200 Subject: 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 --- tests/test_translations.cpp | 226 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 tests/test_translations.cpp (limited to 'tests/test_translations.cpp') 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. + * + * 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 + +#include +#include +#include +#include + +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 as (context, source, translation, unfinished). +struct Entry { + QString context; + QString source; + QString translation; + bool unfinished = false; +}; + +static QList readEntries(const QString &path, QString *error) +{ + QList 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; + + // appears inside both and ; only the one + // directly under 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 per plural + // form. Italian has two, and an empty one is as untranslated + // as an empty . + 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("context"); + QTest::addColumn("source"); + QTest::addColumn("translation"); + QTest::addColumn("unfinished"); + + QString error; + const QList 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 entries = readEntries(tsPath(), &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + + QSet 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" -- cgit v1.2.3