diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_tagcolors.cpp | 251 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 88 |
3 files changed, 333 insertions, 7 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1833f29..e761cb6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,7 @@ target_compile_definitions(test_mimeparser PRIVATE add_qtmaildir_test(interceptor) add_qtmaildir_test(htmlbuilder) add_qtmaildir_test(notmuchworker) +add_qtmaildir_test(tagcolors) add_qtmaildir_test(threadlistmodel) add_qtmaildir_test(mailsync) add_qtmaildir_test(threadcidmap) diff --git a/tests/test_tagcolors.cpp b/tests/test_tagcolors.cpp new file mode 100644 index 0000000..53c0210 --- /dev/null +++ b/tests/test_tagcolors.cpp @@ -0,0 +1,251 @@ +/* + * 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. + */ + +#include <QSettings> +#include <QTemporaryDir> +#include <QtTest> + +#include "tagcolors.h" + +class TestTagColors : public QObject +{ + Q_OBJECT +private slots: + void builtInDefaultsExist(); + void prefixColoursWholeHierarchy(); + void exactTagBeatsItsPrefix(); + void configOverridesABuiltIn(); + void unknownTagStillGetsAColour(); + void accountTagsAreRecognised(); + void accountColourComesFromTheAccount(); + void accountLabelDefaultsToTheKey(); + void accountLabelCanBeOverridden(); + void malformedColourIsReported(); + void textContrastsWithItsBackground(); +}; + +void TestTagColors::builtInDefaultsExist() +{ + // The common state tags must be styled out of the box: a user who never + // writes a [tagcolors] section still needs flagged to stand out. + TagColors colours; + const QStringList expected = { QStringLiteral("flagged"), + QStringLiteral("unread"), + QStringLiteral("deleted"), + QStringLiteral("spam"), + QStringLiteral("attachment"), + QStringLiteral("replied") }; + for (const QString &tag : expected) { + QVERIFY2(colours.hasColour(tag), + qPrintable(QStringLiteral("no built-in colour for '%1'").arg(tag))); + } +} + +void TestTagColors::prefixColoursWholeHierarchy() +{ + // 96 tags, many of them shopping/foo and mailing-list/bar. Colouring by + // top-level prefix is what keeps the config from listing every one. + TagColors colours; + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("tagcolors")); + s.setValue(QStringLiteral("shopping"), QStringLiteral("#3366cc")); + s.endGroup(); + } + QSettings s(path, QSettings::IniFormat); + colours.load(s); + + QCOMPARE(colours.colourFor(QStringLiteral("shopping/amazon")), + QColor(QStringLiteral("#3366cc"))); + QCOMPARE(colours.colourFor(QStringLiteral("shopping/nike")), + QColor(QStringLiteral("#3366cc"))); + // The bare prefix itself is a tag too. + QCOMPARE(colours.colourFor(QStringLiteral("shopping")), + QColor(QStringLiteral("#3366cc"))); + // A different hierarchy is unaffected. + QVERIFY(colours.colourFor(QStringLiteral("mailing-list/SBo")) + != QColor(QStringLiteral("#3366cc"))); +} + +void TestTagColors::exactTagBeatsItsPrefix() +{ + // Specific beats general, or you could never single out one child tag. + TagColors colours; + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("tagcolors")); + s.setValue(QStringLiteral("shopping"), QStringLiteral("#3366cc")); + s.setValue(QStringLiteral("shopping/amazon"), QStringLiteral("#ff9900")); + s.endGroup(); + } + QSettings s(path, QSettings::IniFormat); + colours.load(s); + + QCOMPARE(colours.colourFor(QStringLiteral("shopping/amazon")), + QColor(QStringLiteral("#ff9900"))); + QCOMPARE(colours.colourFor(QStringLiteral("shopping/nike")), + QColor(QStringLiteral("#3366cc"))); + + // Regression: QSettings treats '/' as a group separator, so a + // hierarchical tag is a nested key that childKeys() never returns. Reading + // the group with childKeys() silently dropped every tag with a '/' in it, + // which is most of this user's, and they all fell through to their prefix. + QVERIFY(colours.hasColour(QStringLiteral("shopping/amazon"))); +} + +void TestTagColors::configOverridesABuiltIn() +{ + TagColors colours; + const QColor original = colours.colourFor(QStringLiteral("flagged")); + + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("tagcolors")); + s.setValue(QStringLiteral("flagged"), QStringLiteral("#00ff00")); + s.endGroup(); + } + QSettings s(path, QSettings::IniFormat); + colours.load(s); + + QCOMPARE(colours.colourFor(QStringLiteral("flagged")), + QColor(QStringLiteral("#00ff00"))); + QVERIFY(colours.colourFor(QStringLiteral("flagged")) != original); +} + +void TestTagColors::unknownTagStillGetsAColour() +{ + // A chip with no colour would render as an invisible blank, so every tag + // resolves to something even when nothing is configured for it. + TagColors colours; + const QColor colour = colours.colourFor(QStringLiteral("no-such-tag-anywhere")); + QVERIFY(colour.isValid()); + + // Stable across calls: a tag must not change colour as you scroll. + QCOMPARE(colours.colourFor(QStringLiteral("no-such-tag-anywhere")), colour); +} + +void TestTagColors::accountTagsAreRecognised() +{ + // Account tags are a different taxonomy from functional tags: which + // mailbox a thread came from, not what state it is in. They are shown + // separately, so they have to be identifiable. + QVERIFY(TagColors::isAccountTag(QStringLiteral("account-gmail-danixland"))); + QVERIFY(!TagColors::isAccountTag(QStringLiteral("flagged"))); + QVERIFY(!TagColors::isAccountTag(QStringLiteral("shopping/amazon"))); + + // The INI key for [account.gmail-danixland] is what follows "account-". + QCOMPARE(TagColors::accountKeyForTag(QStringLiteral("account-gmail-danixland")), + QStringLiteral("gmail-danixland")); + QVERIFY(TagColors::accountKeyForTag(QStringLiteral("flagged")).isEmpty()); + + // Round trip, since the mapping is derived rather than configured. + QCOMPARE(TagColors::tagForAccountKey(QStringLiteral("gmail-danixland")), + QStringLiteral("account-gmail-danixland")); +} + +void TestTagColors::accountColourComesFromTheAccount() +{ + // Per the account stanza, not [tagcolors]: the colour belongs to the + // account, and the tag name is derived from its key. + TagColors colours; + colours.setAccountColour(QStringLiteral("gmail-danixland"), + QColor(QStringLiteral("#cc0000"))); + + QCOMPARE(colours.colourFor(QStringLiteral("account-gmail-danixland")), + QColor(QStringLiteral("#cc0000"))); +} + +void TestTagColors::accountLabelDefaultsToTheKey() +{ + // Without a configured label the chip shows the account key, which is what + // it did before labels existed. + TagColors colours; + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-gmail-danixland")), + QStringLiteral("gmail-danixland")); + + // Not an account tag: nothing to label. + QVERIFY(colours.labelForAccountTag(QStringLiteral("flagged")).isEmpty()); +} + +void TestTagColors::accountLabelCanBeOverridden() +{ + // "account-privateemail-danilo.macri" is 33 characters of chip for what is + // really one bit of information, so the label is configurable. + TagColors colours; + colours.setAccountLabel(QStringLiteral("gmail-danixland"), + QStringLiteral("GM-danixland")); + colours.setAccountLabel(QStringLiteral("privateemail-danix"), + QStringLiteral("PE-danix")); + + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-gmail-danixland")), + QStringLiteral("GM-danixland")); + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-privateemail-danix")), + QStringLiteral("PE-danix")); + + // An account left unlabelled still falls back to its key. + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-work")), + QStringLiteral("work")); + + // An empty label is not an override: it would render a blank chip. + colours.setAccountLabel(QStringLiteral("gmail-danixland"), QString()); + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-gmail-danixland")), + QStringLiteral("GM-danixland")); +} + +void TestTagColors::malformedColourIsReported() +{ + // A typo must be visible rather than silently ignored, matching how the + // rest of the config reports its problems. + TagColors colours; + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("tagcolors")); + s.setValue(QStringLiteral("flagged"), QStringLiteral("not-a-colour")); + s.endGroup(); + } + QSettings s(path, QSettings::IniFormat); + colours.load(s); + + QCOMPARE(colours.warnings().size(), 1); + QVERIFY(colours.warnings().first().contains(QStringLiteral("flagged"))); + // The built-in survives, so one bad line does not leave the tag unstyled. + QVERIFY(colours.colourFor(QStringLiteral("flagged")).isValid()); +} + +void TestTagColors::textContrastsWithItsBackground() +{ + // A chip is coloured text on a coloured fill, so the pair has to stay + // legible whatever colour the user picks. + QCOMPARE(TagColors::textColourOn(QColor(Qt::black)), QColor(Qt::white)); + QCOMPARE(TagColors::textColourOn(QColor(Qt::white)), QColor(Qt::black)); + QCOMPARE(TagColors::textColourOn(QColor(QStringLiteral("#8b2c2c"))), + QColor(Qt::white)); + QCOMPARE(TagColors::textColourOn(QColor(QStringLiteral("#ffee88"))), + QColor(Qt::black)); +} + +QTEST_MAIN(TestTagColors) +#include "test_tagcolors.moc" diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp index 98c477c..e8a5fa8 100644 --- a/tests/test_threadlistmodel.cpp +++ b/tests/test_threadlistmodel.cpp @@ -34,6 +34,9 @@ private slots: void subjectShowsMessageCountOnlyForRealThreads(); void unreadThreadsRenderBold(); void tagsAreTheFirstColumnAndSubjectTheLast(); + void accountTagBecomesAChipLabel(); + void unreadStylingSurvivesAnAccountChip(); + void accountChipUsesTheConfiguredColour(); void deletedThreadsAreRedAndStruckThrough(); void spamThreadsAreOrangeAndStruckThrough(); void doomedStylingCoversEveryColumn(); @@ -118,9 +121,11 @@ void TestThreadListModel::reportsSubjectAndAuthors() const QModelIndex date = model.index(0, ThreadListModel::DateColumn); QVERIFY(!model.data(date, Qt::DisplayRole).toString().isEmpty()); - const QModelIndex tags = model.index(0, ThreadListModel::TagsColumn); - QCOMPARE(model.data(tags, Qt::DisplayRole).toString(), - QStringLiteral("inbox unread")); + // Tags are no longer a column; they reach the strip under the message + // pane through a role instead. + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QCOMPARE(model.data(subject, ThreadListModel::TagsRole).toStringList(), + QStringList({ QStringLiteral("inbox"), QStringLiteral("unread") })); } void TestThreadListModel::subjectShowsMessageCountOnlyForRealThreads() @@ -163,17 +168,86 @@ void TestThreadListModel::tagsAreTheFirstColumnAndSubjectTheLast() // Subject stretches to fill the view, so whatever sits after it is pushed // off-screen. Tags used to be there, which is why acting on a thread // looked like it did nothing: the only column that changed was invisible. - QCOMPARE(ThreadListModel::TagsColumn, 0); QCOMPARE(ThreadListModel::SubjectColumn, ThreadListModel::ColumnCount - 1); ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("hello")) }); - QCOMPARE(model.headerData(ThreadListModel::TagsColumn, Qt::Horizontal, - Qt::DisplayRole).toString(), - QStringLiteral("Tags")); QCOMPARE(model.headerData(ThreadListModel::SubjectColumn, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("Subject")); + + // No tags column at all: spelling out a dozen tags per row consumed most + // of the list's width and was unreadable. + for (int column = 0; column < ThreadListModel::ColumnCount; ++column) { + QVERIFY(model.headerData(column, Qt::Horizontal, Qt::DisplayRole) + .toString() != QStringLiteral("Tags")); + } +} + +void TestThreadListModel::accountTagBecomesAChipLabel() +{ + // The account tag is a different taxonomy from a functional one: which + // mailbox the thread arrived in. It renders as a chip in front of the + // subject, so the model exposes its label and colour separately. + ThreadListModel model; + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); + thread.tags = QStringList{ QStringLiteral("inbox"), + QStringLiteral("account-gmail-danixland") }; + model.appendBatch({ thread }); + + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QCOMPARE(model.data(subject, ThreadListModel::AccountLabelRole).toString(), + QStringLiteral("gmail-danixland")); + QVERIFY(model.data(subject, ThreadListModel::AccountColourRole) + .value<QColor>().isValid()); + + // A thread with no account tag gets no chip rather than an empty one. + ThreadListModel plain; + ThreadSummary untagged = makeThread(QStringLiteral("t2"), QStringLiteral("hi")); + untagged.tags = QStringList{ QStringLiteral("inbox") }; + plain.appendBatch({ untagged }); + QVERIFY(plain.data(plain.index(0, ThreadListModel::SubjectColumn), + ThreadListModel::AccountLabelRole).toString().isEmpty()); +} + +void TestThreadListModel::unreadStylingSurvivesAnAccountChip() +{ + // The subject cell is drawn by a delegate when the thread has an account + // chip. The delegate paints the text itself, so it has to keep honouring + // the model's font: otherwise an unread thread stops rendering bold for + // exactly those threads that carry an account tag, which is all of them. + ThreadListModel model; + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); + thread.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread"), + QStringLiteral("account-gmail-danixland") }; + model.appendBatch({ thread }); + + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QVERIFY(!model.data(subject, ThreadListModel::AccountLabelRole) + .toString().isEmpty()); + + const QVariant font = model.data(subject, Qt::FontRole); + QVERIFY2(font.isValid(), "unread thread with an account tag has no font"); + QVERIFY2(font.value<QFont>().bold(), "unread thread is not bold"); +} + +void TestThreadListModel::accountChipUsesTheConfiguredColour() +{ + // The colour comes from the account's own stanza, so a configured one must + // reach the chip rather than the generated fallback. + TagColors colours; + colours.setAccountColour(QStringLiteral("gmail-danixland"), + QColor(QStringLiteral("#cc0000"))); + + ThreadListModel model; + model.setTagColors(&colours); + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); + thread.tags = QStringList{ QStringLiteral("account-gmail-danixland") }; + model.appendBatch({ thread }); + + QCOMPARE(model.data(model.index(0, ThreadListModel::SubjectColumn), + ThreadListModel::AccountColourRole).value<QColor>(), + QColor(QStringLiteral("#cc0000"))); } void TestThreadListModel::deletedThreadsAreRedAndStruckThrough() |
