aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_tagcolors.cpp251
-rw-r--r--tests/test_threadlistmodel.cpp88
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()