aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt2
-rw-r--r--tests/test_avatar.cpp268
-rw-r--r--tests/test_businesssenders.cpp232
-rw-r--r--tests/test_carddelegate.cpp75
-rw-r--r--tests/test_cardlayout.cpp73
-rw-r--r--tests/test_mainwindow.cpp33
-rw-r--r--tests/test_notmuchworker.cpp105
-rw-r--r--tests/test_threadlistmodel.cpp81
8 files changed, 867 insertions, 2 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 830e2b2..69c57ee 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -52,6 +52,8 @@ add_qtmaildir_test(htmlbuilder)
add_qtmaildir_test(notmuchworker)
add_qtmaildir_test(tagcolors)
add_qtmaildir_test(cardlayout)
+add_qtmaildir_test(avatar)
+add_qtmaildir_test(businesssenders)
add_qtmaildir_test(marks)
add_qtmaildir_test(carddelegate)
add_qtmaildir_test(threadlistmodel)
diff --git a/tests/test_avatar.cpp b/tests/test_avatar.cpp
new file mode 100644
index 0000000..cea88b3
--- /dev/null
+++ b/tests/test_avatar.cpp
@@ -0,0 +1,268 @@
+/*
+ * 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 <QTest>
+
+#include "avatar.h"
+
+class TestAvatar : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void twoWordNameTakesOneLetterFromEach();
+ void oneWordNameTakesItsFirstTwoLetters();
+ void bareAddressTakesLocalAndDomain();
+ void nothingUsableFallsBackToTheAccountLabel();
+ void initialsAreAlwaysTwoLetters();
+ void aRawFromHeaderIsNotSplitOnItsBracket();
+ void aCommaJoinedAuthorListTakesTheFirstAuthor();
+ void aSeparatorIsNotAWord();
+ void aDisplayNameMeansAPerson();
+ void theListOverridesADisplayName();
+ void aColourIsStablePerAddress();
+ void aPixmapIsStableAndDiffersPerSeed();
+ void bothTwoToneHuesReachTheFace();
+};
+
+void TestAvatar::twoWordNameTakesOneLetterFromEach()
+{
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("John Doe"),
+ QStringLiteral("john@example.org"),
+ QStringLiteral("Work")),
+ QStringLiteral("JD"));
+ // Three words still take the FIRST two, not the first and last.
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("Maria Grazia Rossi"),
+ QStringLiteral("maria@example.org"),
+ QStringLiteral("Work")),
+ QStringLiteral("MG"));
+}
+
+void TestAvatar::oneWordNameTakesItsFirstTwoLetters()
+{
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("Cofidis"),
+ QStringLiteral("noreply@cofidis.it"),
+ QStringLiteral("Work")),
+ QStringLiteral("CO"));
+}
+
+void TestAvatar::bareAddressTakesLocalAndDomain()
+{
+ QCOMPARE(Avatar::initialsFor(QString(),
+ QStringLiteral("noreply@cofidis.it"),
+ QStringLiteral("Work")),
+ QStringLiteral("NC"));
+}
+
+void TestAvatar::nothingUsableFallsBackToTheAccountLabel()
+{
+ // No name and no address at all: the account's label is the last resort,
+ // so a card always carries a squircle rather than a hole.
+ QCOMPARE(Avatar::initialsFor(QString(), QString(),
+ QStringLiteral("Work")),
+ QStringLiteral("WO"));
+ // And with nothing whatsoever, still two characters rather than empty.
+ QCOMPARE(Avatar::initialsFor(QString(), QString(), QString()).size(), 2);
+}
+
+void TestAvatar::initialsAreAlwaysTwoLetters()
+{
+ // The shape is the point: every squircle reads the same. An address with
+ // no domain, a one-letter local part and a name of one letter all still
+ // produce two characters.
+ const QStringList names { QString(), QStringLiteral("X"),
+ QStringLiteral("A B") };
+ const QStringList addresses { QStringLiteral("a@b.org"),
+ QStringLiteral("malformed"),
+ QString() };
+ for (const QString &name : names) {
+ for (const QString &address : addresses) {
+ const QString initials =
+ Avatar::initialsFor(name, address, QStringLiteral("Acct"));
+ QCOMPARE(initials.size(), 2);
+ }
+ }
+}
+
+void TestAvatar::aRawFromHeaderIsNotSplitOnItsBracket()
+{
+ // A reply row's first line is the RAW header, so a naive space split gave
+ // the name's first letter and a literal `<`.
+ QCOMPARE(Avatar::initialsFor(
+ QStringLiteral("tsujan <notifications@github.com>"),
+ QStringLiteral("notifications@github.com"),
+ QStringLiteral("Work")),
+ QStringLiteral("TS"));
+ // A bare address in the name's place is not a name: the address branch
+ // answers, rather than the local part's first two letters.
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("info@moomhotel.com"),
+ QStringLiteral("info@moomhotel.com"),
+ QStringLiteral("Work")),
+ QStringLiteral("IM"));
+ // And the fill agrees: neither of those is a display name.
+ QCOMPARE(Avatar::fillFor(QStringLiteral("info@moomhotel.com"), false),
+ Avatar::Fill::TwoTone);
+}
+
+void TestAvatar::aCommaJoinedAuthorListTakesTheFirstAuthor()
+{
+ // notmuch's author summary joins participants with a comma, so one letter
+ // from each gave initials belonging to two different people.
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("Standreas, tsujan"),
+ QStringLiteral("notifications@github.com"),
+ QStringLiteral("Work")),
+ QStringLiteral("ST"));
+ // A QUOTED name may legally contain a comma and must survive whole.
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("\"Rossi, Mario\""),
+ QStringLiteral("m@example.org"),
+ QStringLiteral("Work")),
+ QStringLiteral("RM"));
+}
+
+void TestAvatar::aSeparatorIsNotAWord()
+{
+ // `INE - Expert IT Training` took the dash as its second word and drew
+ // `I-`. A word has to carry a letter or a digit.
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("INE - Expert IT Training"),
+ QStringLiteral("news@example.org"),
+ QStringLiteral("Work")),
+ QStringLiteral("IE"));
+ // Leading punctuation is trimmed rather than disqualifying the word.
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("(Acme) Support"),
+ QStringLiteral("s@example.org"),
+ QStringLiteral("Work")),
+ QStringLiteral("AS"));
+ // Punctuation ONLY is no name at all: the address answers.
+ QCOMPARE(Avatar::initialsFor(QStringLiteral("- ---"),
+ QStringLiteral("news@example.org"),
+ QStringLiteral("Work")),
+ QStringLiteral("NE"));
+}
+
+void TestAvatar::aDisplayNameMeansAPerson()
+{
+ // The case the user asked for by name: a corporate address that presents
+ // itself as a person reads as a person.
+ QCOMPARE(Avatar::fillFor(QStringLiteral("Ian Farrell"), false),
+ Avatar::Fill::Identicon);
+ QCOMPARE(Avatar::fillFor(QString(), false), Avatar::Fill::TwoTone);
+}
+
+void TestAvatar::theListOverridesADisplayName()
+{
+ // A listed address stays a business even when it sets a friendly name.
+ QCOMPARE(Avatar::fillFor(QStringLiteral("Cofidis"), true),
+ Avatar::Fill::TwoTone);
+}
+
+void TestAvatar::aColourIsStablePerAddress()
+{
+ const QColor first = Avatar::colourFor(QStringLiteral("a@example.org"));
+ const QColor again = Avatar::colourFor(QStringLiteral("a@example.org"));
+ QCOMPARE(first, again);
+ QVERIFY(first.isValid());
+ QVERIFY(Avatar::colourFor(QStringLiteral("b@example.org")) != first);
+}
+
+void TestAvatar::aPixmapIsStableAndDiffersPerSeed()
+{
+ const QFont font;
+ const QPixmap first = Avatar::pixmapFor(QStringLiteral("a@example.org"),
+ QStringLiteral("AE"),
+ Avatar::Fill::Identicon, 44, font);
+ QCOMPARE(first.size(), QSize(44, 44));
+ QVERIFY(!first.isNull());
+
+ const QPixmap again = Avatar::pixmapFor(QStringLiteral("a@example.org"),
+ QStringLiteral("AE"),
+ Avatar::Fill::Identicon, 44, font);
+ // Same seed, same image, byte for byte: the identity must not drift
+ // between repaints.
+ QCOMPARE(first.toImage(), again.toImage());
+
+ const QPixmap other = Avatar::pixmapFor(QStringLiteral("b@example.org"),
+ QStringLiteral("AE"),
+ Avatar::Fill::Identicon, 44, font);
+ // Different sender, different image, even with identical initials.
+ QVERIFY(first.toImage() != other.toImage());
+
+ const QPixmap twoTone = Avatar::pixmapFor(QStringLiteral("a@example.org"),
+ QStringLiteral("AE"),
+ Avatar::Fill::TwoTone, 44, font);
+ // The two fills are actually different renderings, not one with a flag.
+ QVERIFY(first.toImage() != twoTone.toImage());
+}
+
+void TestAvatar::bothTwoToneHuesReachTheFace()
+{
+ // The split has to cross the squircle, not graze its edge. Building the
+ // gradient axis as a RADIUS from the centre put the 0.5 stop on the
+ // boundary, so one hue filled almost the whole face and the fill read as
+ // flat: reported against noreply@cofidis.it. A colour count is what
+ // distinguishes the two, since both versions paint every pixel.
+ //
+ // Several seeds, because one unlucky angle proves nothing either way.
+ const QStringList seeds { QStringLiteral("noreply@cofidis.it"),
+ QStringLiteral("a@example.org"),
+ QStringLiteral("b@example.org"),
+ QStringLiteral("c@example.org") };
+ for (const QString &seed : seeds) {
+ const QImage face =
+ Avatar::pixmapFor(seed, QStringLiteral("XX"),
+ Avatar::Fill::TwoTone, 64, QFont()).toImage();
+
+ // The two hues, as painted. Sampled by counting pixels of each rather
+ // than probing a corner: which corner gets which hue depends on the
+ // hashed angle.
+ const QColor base = Avatar::colourFor(seed);
+ const QColor dark = base.darker(135);
+ int light = 0, shade = 0;
+ for (int y = 0; y < face.height(); ++y) {
+ for (int x = 0; x < face.width(); ++x) {
+ const QColor pixel = face.pixelColor(x, y);
+ if (pixel.alpha() < 255)
+ continue; // The squircle's antialiased corners.
+ // Nearest of the two, not an exact match: the gradient
+ // interpolates in premultiplied space and the pixel format
+ // rounds, so an exact compare finds NEITHER hue and the probe
+ // reports 0 against 0 whatever the code does.
+ const int toBase = qAbs(pixel.red() - base.red())
+ + qAbs(pixel.green() - base.green())
+ + qAbs(pixel.blue() - base.blue());
+ const int toDark = qAbs(pixel.red() - dark.red())
+ + qAbs(pixel.green() - dark.green())
+ + qAbs(pixel.blue() - dark.blue());
+ if (toBase < toDark)
+ ++light;
+ else
+ ++shade;
+ }
+ }
+
+ // A fifth of the face each: enough that neither is a sliver, loose
+ // enough that the hashed angle is free to put the split anywhere.
+ const int fifth = face.width() * face.height() / 5;
+ QVERIFY2(light > fifth && shade > fifth,
+ qPrintable(QStringLiteral("%1: one hue took the face, %2 "
+ "light against %3 dark")
+ .arg(seed).arg(light).arg(shade)));
+ }
+}
+
+QTEST_MAIN(TestAvatar)
+#include "test_avatar.moc"
diff --git a/tests/test_businesssenders.cpp b/tests/test_businesssenders.cpp
new file mode 100644
index 0000000..fb26e28
--- /dev/null
+++ b/tests/test_businesssenders.cpp
@@ -0,0 +1,232 @@
+/*
+ * 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 <QFileInfo>
+#include <QTemporaryDir>
+#include <QTest>
+
+#include "businesssenders.h"
+
+class TestBusinessSenders : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void anExactAddressMatches();
+ void aDomainEntryMatchesEveryAddressUnderIt();
+ void commentsAndBlankLinesAreIgnored();
+ void whitespaceAroundAnEntryIsIgnored();
+ void matchingIsCaseInsensitive();
+ void anAbsentFileMatchesNothing();
+ void candidatesAreAppendedCommentedOut();
+ void appendingDoesNotCorruptALineThatLacksATrailingNewline();
+ void anAddressAlreadyPresentIsNeverReproposed();
+ void onlyBulkLookingLocalPartsAreProposed();
+ void theFirstRunScansEverything();
+ void alaterRunScansOnlyRecentMail();
+};
+
+void TestBusinessSenders::anExactAddressMatches()
+{
+ const BusinessSenders::List list = BusinessSenders::parse(
+ QStringLiteral("noreply@cofidis.it\n"));
+ QVERIFY(BusinessSenders::contains(list,
+ QStringLiteral("noreply@cofidis.it")));
+ QVERIFY(!BusinessSenders::contains(list,
+ QStringLiteral("someone@cofidis.it")));
+}
+
+void TestBusinessSenders::aDomainEntryMatchesEveryAddressUnderIt()
+{
+ const BusinessSenders::List list =
+ BusinessSenders::parse(QStringLiteral("@cofidis.it\n"));
+ QVERIFY(BusinessSenders::contains(list,
+ QStringLiteral("noreply@cofidis.it")));
+ QVERIFY(BusinessSenders::contains(list,
+ QStringLiteral("billing@cofidis.it")));
+ QVERIFY(!BusinessSenders::contains(list,
+ QStringLiteral("a@example.org")));
+}
+
+void TestBusinessSenders::commentsAndBlankLinesAreIgnored()
+{
+ // A commented entry is the REJECT gesture: present in the file, not
+ // applied. This is the property the whole file format rests on.
+ const BusinessSenders::List list = BusinessSenders::parse(
+ QStringLiteral("# noreply@cofidis.it (47 messages)\n"
+ "\n"
+ " \n"
+ "billing@example.org\n"));
+ QVERIFY(!BusinessSenders::contains(list,
+ QStringLiteral("noreply@cofidis.it")));
+ QVERIFY(BusinessSenders::contains(list,
+ QStringLiteral("billing@example.org")));
+}
+
+void TestBusinessSenders::whitespaceAroundAnEntryIsIgnored()
+{
+ const BusinessSenders::List list =
+ BusinessSenders::parse(QStringLiteral(" billing@example.org \n"));
+ QVERIFY(BusinessSenders::contains(list,
+ QStringLiteral("billing@example.org")));
+}
+
+void TestBusinessSenders::matchingIsCaseInsensitive()
+{
+ // Addresses arrive from headers in whatever case the sender used, so a
+ // list entry that matched only one casing would look broken at random.
+ const BusinessSenders::List list =
+ BusinessSenders::parse(QStringLiteral("NoReply@Cofidis.IT\n"));
+ QVERIFY(BusinessSenders::contains(list,
+ QStringLiteral("noreply@cofidis.it")));
+}
+
+void TestBusinessSenders::anAbsentFileMatchesNothing()
+{
+ QTemporaryDir dir;
+ const BusinessSenders::List list =
+ BusinessSenders::load(dir.filePath(QStringLiteral("does-not-exist")));
+ QVERIFY(!BusinessSenders::contains(list, QStringLiteral("a@example.org")));
+}
+
+void TestBusinessSenders::candidatesAreAppendedCommentedOut()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("business-senders"));
+
+ QHash<QString, int> counts;
+ counts.insert(QStringLiteral("noreply@cofidis.it"), 47);
+ BusinessSenders::appendCandidates(path, counts);
+
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text));
+ const QString written = QString::fromUtf8(file.readAll());
+
+ // Commented, and carrying the count so the user can judge it.
+ QVERIFY(written.contains(QStringLiteral("# noreply@cofidis.it")));
+ QVERIFY(written.contains(QStringLiteral("47")));
+
+ // Nothing it wrote may take effect on its own.
+ const BusinessSenders::List list = BusinessSenders::load(path);
+ QVERIFY(!BusinessSenders::contains(list,
+ QStringLiteral("noreply@cofidis.it")));
+}
+
+void TestBusinessSenders::appendingDoesNotCorruptALineThatLacksATrailingNewline()
+{
+ // Hand-editing, the documented workflow, can leave the file without a
+ // trailing newline. Appending then glued the first candidate onto the last
+ // existing line, silently breaking the user's own active entry so it
+ // stopped matching. The guard writes a newline before the additions.
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("business-senders"));
+ QFile seed(path);
+ QVERIFY(seed.open(QIODevice::WriteOnly | QIODevice::Text));
+ seed.write("billing@example.org"); // deliberately no trailing newline
+ seed.close();
+
+ QHash<QString, int> counts;
+ counts.insert(QStringLiteral("noreply@a.org"), 3);
+ BusinessSenders::appendCandidates(path, counts);
+
+ // The original entry is intact and still matches.
+ const BusinessSenders::List list = BusinessSenders::load(path);
+ QVERIFY(BusinessSenders::contains(list,
+ QStringLiteral("billing@example.org")));
+
+ // ...and the candidate sits on its own commented line, not glued onto it.
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text));
+ const QString written = QString::fromUtf8(file.readAll());
+ QVERIFY(written.contains(QStringLiteral(
+ "billing@example.org\n# noreply@a.org (3 messages)")));
+}
+
+void TestBusinessSenders::anAddressAlreadyPresentIsNeverReproposed()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("business-senders"));
+
+ // Both forms count as present: an active entry and a rejected one. The
+ // rejected case is the one that matters, since re-proposing it would undo
+ // the user's decision every ten minutes with no explanation.
+ QFile seed(path);
+ QVERIFY(seed.open(QIODevice::WriteOnly | QIODevice::Text));
+ seed.write("billing@example.org\n# noreply@cofidis.it (47 messages)\n");
+ seed.close();
+ const qint64 sizeBefore = QFileInfo(path).size();
+
+ QHash<QString, int> counts;
+ counts.insert(QStringLiteral("noreply@cofidis.it"), 51);
+ counts.insert(QStringLiteral("billing@example.org"), 12);
+ BusinessSenders::appendCandidates(path, counts);
+
+ QCOMPARE(QFileInfo(path).size(), sizeBefore);
+}
+
+void TestBusinessSenders::onlyBulkLookingLocalPartsAreProposed()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("business-senders"));
+
+ QHash<QString, int> counts;
+ counts.insert(QStringLiteral("noreply@a.org"), 3);
+ counts.insert(QStringLiteral("john.doe@b.org"), 3);
+ BusinessSenders::appendCandidates(path, counts);
+
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text));
+ const QString written = QString::fromUtf8(file.readAll());
+ QVERIFY(written.contains(QStringLiteral("noreply@a.org")));
+ QVERIFY(!written.contains(QStringLiteral("john.doe@b.org")));
+}
+
+void TestBusinessSenders::theFirstRunScansEverything()
+{
+ QTemporaryDir dir;
+ const QString missing = dir.filePath(QStringLiteral("business-senders"));
+
+ // No file at all: a week of mail would propose almost nothing and the
+ // list would take months to become useful, so the first run pays for a
+ // full scan once.
+ QCOMPARE(BusinessSenders::scanQuery(missing), QStringLiteral("*"));
+
+ // A file holding ONLY rejected candidates is still a first run: nothing
+ // has been accepted yet. Rescanning re-proposes none of them, since
+ // appendCandidates skips anything already mentioned.
+ QFile rejected(missing);
+ QVERIFY(rejected.open(QIODevice::WriteOnly | QIODevice::Text));
+ rejected.write("# noreply@cofidis.it (47 messages)\n");
+ rejected.close();
+ QCOMPARE(BusinessSenders::scanQuery(missing), QStringLiteral("*"));
+}
+
+void TestBusinessSenders::alaterRunScansOnlyRecentMail()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("business-senders"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("billing@example.org\n");
+ file.close();
+
+ QCOMPARE(BusinessSenders::scanQuery(path), QStringLiteral("date:1week.."));
+}
+
+QTEST_MAIN(TestBusinessSenders)
+#include "test_businesssenders.moc"
diff --git a/tests/test_carddelegate.cpp b/tests/test_carddelegate.cpp
index a14671d..bb21ae1 100644
--- a/tests/test_carddelegate.cpp
+++ b/tests/test_carddelegate.cpp
@@ -36,6 +36,10 @@ private slots:
void aSiblingChipIsMutedButStaysLegibleAndRecognisable();
void aSiblingChipFontIsSmallerThanItsOwnTier();
void aSiblingChipsPaddingShrinksWithItsFont();
+ void theFadeEndsAtSixtyPercentOfTheCard();
+ void aReplyFadeStartsAtItsOwnSpine();
+ void theDelegateAsksForAScaledSquircle();
+ void aRowWithNoSenderFallsBackToTheAccount();
};
namespace {
@@ -259,5 +263,76 @@ void TestCardDelegate::aSiblingChipsPaddingShrinksWithItsFont()
"on the chip's rounded end");
}
+void TestCardDelegate::theFadeEndsAtSixtyPercentOfTheCard()
+{
+ const QRect card(0, 0, 500, 60);
+ const QRect root = CardDelegate::fadeRectFor(card, QRect());
+ // Anchored at the card's RIGHT edge: the hard stop belongs where the card
+ // ends, not 60% across it, which read as a slab.
+ QCOMPARE(root.right(), card.right());
+ QCOMPARE(root.width(), 300);
+}
+
+void TestCardDelegate::aReplyFadeStartsAtItsOwnSpine()
+{
+ const QRect card(0, 0, 500, 60);
+ // The innermost spine of a nested reply, which is its own coloured border.
+ const QRect spine(80, 0, 2, 60);
+ // A spine deep enough to cut into the wash, which starts at 40% here.
+ const QRect deep(300, 0, 2, 60);
+ const QRect reply = CardDelegate::fadeRectFor(card, deep);
+
+ // Clamped at the spine, so the wash never runs under a reply's own border.
+ QCOMPARE(reply.left(), deep.left());
+ // Still anchored at the card's right edge, so a deeper reply's wash is
+ // shorter rather than displaced.
+ QCOMPARE(reply.right(), CardDelegate::fadeRectFor(card, QRect()).right());
+ QVERIFY(reply.width() < CardDelegate::fadeRectFor(card, QRect()).width());
+
+ // A shallow spine sits left of where the wash begins and changes nothing.
+ const QRect shallow(80, 0, 2, 60);
+ QCOMPARE(CardDelegate::fadeRectFor(card, shallow),
+ CardDelegate::fadeRectFor(card, QRect()));
+}
+
+void TestCardDelegate::theDelegateAsksForAScaledSquircle()
+{
+ // Asserted through the function the PRODUCTION path calls, not through
+ // Avatar::pixmapFor() directly: a test pointed at the function being
+ // called into proves what that function does and nothing about whether the
+ // delegate asks it for the right thing. CLAUDE.md records a mutation that
+ // survived exactly that mistake.
+ const QRect card(0, 0, 500, 60);
+ const QFont font;
+ const CardLayout layout =
+ CardLayout::compute(CardLayout::Input(), card, font);
+
+ const QPixmap pixmap = CardDelegate::avatarFor(
+ QStringLiteral("john@example.org"), QStringLiteral("John Doe"),
+ QStringLiteral("me@example.org"), QStringLiteral("Work"), false,
+ layout.avatarRect.width(), font);
+
+ QCOMPARE(pixmap.size(),
+ QSize(layout.avatarRect.width(), layout.avatarRect.width()));
+}
+
+void TestCardDelegate::aRowWithNoSenderFallsBackToTheAccount()
+{
+ const QFont font;
+ // No sender address at all: the squircle is still drawn, seeded from the
+ // account, so a card never shows a hole.
+ const QPixmap fallback = CardDelegate::avatarFor(
+ QString(), QString(), QStringLiteral("me@example.org"),
+ QStringLiteral("Work"), false, 44, font);
+ QVERIFY(!fallback.isNull());
+
+ // And it is the ACCOUNT's identity, not an arbitrary one: seeding from the
+ // same account twice agrees.
+ const QPixmap again = CardDelegate::avatarFor(
+ QString(), QString(), QStringLiteral("me@example.org"),
+ QStringLiteral("Work"), false, 44, font);
+ QCOMPARE(fallback.toImage(), again.toImage());
+}
+
QTEST_MAIN(TestCardDelegate)
#include "test_carddelegate.moc"
diff --git a/tests/test_cardlayout.cpp b/tests/test_cardlayout.cpp
index f5f40ab..c81296f 100644
--- a/tests/test_cardlayout.cpp
+++ b/tests/test_cardlayout.cpp
@@ -45,6 +45,10 @@ private slots:
void theDateFitsWhenTheCardIsBold();
void theDateFollowsTheSystemLocale();
void aConfiguredDateFormatIsUsedAndReservedFor();
+ void everyRowCarriesAnAvatar();
+ void theAvatarPushesTheContentRight();
+ void theAvatarFollowsTheIndent();
+ void theAvatarIsSquareAndFitsTheCard();
};
namespace {
@@ -410,8 +414,14 @@ void TestCardLayout::marksDoNotCollideWithEachOtherOrTheExpander()
// The subject survives at a usable width rather than being squeezed to
// nothing by four marks: they are small and fixed, it is the elastic part.
- QVERIFY2(card.subjectRect.width() > 100,
- "four marks left the subject with almost no room on a 400px card");
+ // Relative rather than absolute: the avatar gutter shifts every text rect
+ // right, so a fixed pixel floor like 100 fails on a card that gained a
+ // gutter and would pass on one that had not. Comparing against another
+ // fixed element of the same card keeps the real invariant: the marks must
+ // not leave the subject narrower than the avatar gutter beside it.
+ QVERIFY2(card.subjectRect.width() > card.avatarRect.width(),
+ "four marks left the subject narrower than the avatar gutter on a "
+ "400px card");
}
void TestCardLayout::dateIsFlushRight()
@@ -580,5 +590,64 @@ void TestCardLayout::theDateFitsWhenTheCardIsBold()
.arg(boldWidth)));
}
+void TestCardLayout::everyRowCarriesAnAvatar()
+{
+ const QFont font;
+ const QRect rect(0, 0, 600, CardLayout::heightFor(font));
+
+ CardLayout::Input thread;
+ const CardLayout rootCard = CardLayout::compute(thread, rect, font);
+ QVERIFY(!rootCard.avatarRect.isEmpty());
+
+ // A reply gets one too: it is the row where the sender actually changes.
+ CardLayout::Input reply;
+ reply.isMessage = true;
+ reply.depth = 1;
+ const CardLayout replyCard = CardLayout::compute(reply, rect, font);
+ QVERIFY(!replyCard.avatarRect.isEmpty());
+}
+
+void TestCardLayout::theAvatarPushesTheContentRight()
+{
+ const QFont font;
+ const QRect rect(0, 0, 600, CardLayout::heightFor(font));
+ const CardLayout card = CardLayout::compute(CardLayout::Input(), rect, font);
+
+ // The text starts after the squircle, never on it.
+ QVERIFY(card.contentLeft >= card.avatarRect.right() + 1);
+}
+
+void TestCardLayout::theAvatarFollowsTheIndent()
+{
+ const QFont font;
+ const QRect rect(0, 0, 600, CardLayout::heightFor(font));
+
+ CardLayout::Input shallow;
+ shallow.isMessage = true;
+ shallow.depth = 1;
+ CardLayout::Input deep;
+ deep.isMessage = true;
+ deep.depth = 3;
+
+ const CardLayout shallowCard = CardLayout::compute(shallow, rect, font);
+ const CardLayout deepCard = CardLayout::compute(deep, rect, font);
+
+ // The squircle sits inside the card's own rect and moves with the nesting,
+ // which is the same reason contentLeft does. Asserting on the RECT here is
+ // safe precisely because it is CardLayout's own output, not a visualRect.
+ QVERIFY(deepCard.avatarRect.left() > shallowCard.avatarRect.left());
+}
+
+void TestCardLayout::theAvatarIsSquareAndFitsTheCard()
+{
+ const QFont font;
+ const QRect rect(0, 0, 600, CardLayout::heightFor(font));
+ const CardLayout card = CardLayout::compute(CardLayout::Input(), rect, font);
+
+ QCOMPARE(card.avatarRect.width(), card.avatarRect.height());
+ QVERIFY(card.avatarRect.top() >= rect.top());
+ QVERIFY(card.avatarRect.bottom() <= rect.bottom());
+}
+
QTEST_MAIN(TestCardLayout)
#include "test_cardlayout.moc"
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 2032793..5d2316f 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -543,6 +543,7 @@ private slots:
void aCloseDuringTheCountdownIsRefused();
void aFailedSendKeepsTheTextThatFailedToGo();
void aSmallSizeLimitIsNotDescribedAsZeroMegabytes();
+ void theBusinessSenderListIsLoadedAtStartup();
private:
/// Owns the throwaway lock table init() points every test at. A pointer
@@ -11853,6 +11854,21 @@ void TestMainWindow::restoreReturnsAMessageToItsOriginFolder()
QCOMPARE(notmuchCount(cfg, QStringLiteral("id:ro1@example.org")), 1);
QVERIFY(!folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"),
stem));
+
+ // And the `inbox` TAG came back with it. Delete strips that tag so the
+ // message leaves the Inbox view, which makes restoring it the other half
+ // of the same change: without it the message sits in the inbox FOLDER
+ // carrying no `inbox` tag and the Inbox view cannot see it, which reads as
+ // "I restored it and it is gone".
+ //
+ // The file assertions above all passed while this was broken: the folder
+ // comparison that decides it was made against the finished TAG rather than
+ // against the destination folder, so it was always false. Nothing else
+ // here would have noticed.
+ QTRY_VERIFY_WITH_TIMEOUT(
+ notmuchCount(cfg, QStringLiteral("id:ro1@example.org and tag:inbox"))
+ == 1,
+ 15000);
}
void TestMainWindow::restoreFallsBackToInboxWithoutAnOriginTag()
@@ -14886,4 +14902,21 @@ void TestMainWindow::aSmallSizeLimitIsNotDescribedAsZeroMegabytes()
"1.5 MB lost its decimal");
}
+void TestMainWindow::theBusinessSenderListIsLoadedAtStartup()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("business-senders"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("@cofidis.it\n");
+ file.close();
+
+ const Config config;
+ MainWindow window(config);
+ window.loadBusinessSenders(path);
+
+ QVERIFY(window.businessSendersForTest().domains.contains(
+ QStringLiteral("cofidis.it")));
+}
+
#include "test_mainwindow.moc"
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 3f75898..9a0896d 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -65,6 +65,8 @@ private slots:
void loadMessageOnAnUnknownIdReturnsNothing();
void aQueryCarriesEachThreadsFirstMessageId();
void aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst();
+ void queryCarriesTheFirstMessageSender();
+ void sendersAreCountedForTheCandidateList();
void loadThreadTreeReportsReplyDepth();
void loadThreadTreeCarriesTheFactsARowNeeds();
@@ -74,6 +76,7 @@ private slots:
void recipientsAreAbsentUnlessAskedFor();
void recipientsAreFoldedWhenAskedFor();
void recipientsCrossAQueuedCall();
+ void theFirstRecipientsAddressCrossesForTheAvatar();
void requestCountsAnswersOneCountPerQuery();
void requestCountsKeepsPositionOnAnInvalidQuery();
@@ -515,6 +518,79 @@ void TestNotmuchWorker::aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst()
}
}
+void TestNotmuchWorker::queryCarriesTheFirstMessageSender()
+{
+ // Item 169. The card has no address to hash: `authors` is notmuch's own
+ // summarised string and carries display names only, measured on the real
+ // index as 'Ryanair' and 'The Hacker News tramite LinkedIn', with no `@`
+ // anywhere. firstMessageSender is the BARE address of the one message the
+ // root card stands for.
+ //
+ // The From header carries a display name on purpose: a wrong
+ // implementation returning the display name or the authors string fails
+ // this test.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("sender-probe@example.org"),
+ QStringLiteral("Probe subject"),
+ QStringLiteral("Probe <sender-probe@example.org>"),
+ QStringLiteral("Mon, 8 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body")));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ NotmuchWorker worker(fixture.configPath());
+
+ QSignalSpy spy(&worker, &NotmuchWorker::threadsReady);
+ worker.runQuery(QStringLiteral("subject:\"Probe subject\""), 1,
+ NotmuchWorker::NewestFirst, false);
+ QVERIFY(spy.count() > 0);
+
+ const auto threads = spy.first().at(0).value<QVector<ThreadSummary>>();
+ QCOMPARE(threads.size(), 1);
+ // The bare address, not the display name and not notmuch's authors string.
+ QCOMPARE(threads.first().firstMessageSender,
+ QStringLiteral("sender-probe@example.org"));
+}
+
+void TestNotmuchWorker::sendersAreCountedForTheCandidateList()
+{
+ // The counts that BusinessSenders::appendCandidates() consumes: per-sender
+ // totals over a query, keyed by the lower-cased BARE address (a display
+ // name would defeat the bulk-sender guess). Two messages from one sender
+ // must count twice.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("n1@example.org"),
+ QStringLiteral("Receipt one"),
+ QStringLiteral("noreply@shop.example"),
+ QStringLiteral("Mon, 8 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body")));
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("n2@example.org"),
+ QStringLiteral("Receipt two"),
+ QStringLiteral("noreply@shop.example"),
+ QStringLiteral("Tue, 9 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body")));
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("j1@example.org"),
+ QStringLiteral("Hello"),
+ QStringLiteral("john@example.org"),
+ QStringLiteral("Wed, 10 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body")));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::senderCountsReady);
+ worker.countSenders(QStringLiteral("*"));
+ QVERIFY(spy.count() > 0);
+
+ const auto counts = spy.first().at(0).value<QHash<QString, int>>();
+ QCOMPARE(counts.value(QStringLiteral("noreply@shop.example")), 2);
+ QCOMPARE(counts.value(QStringLiteral("john@example.org")), 1);
+}
+
void TestNotmuchWorker::loadThreadTreeReportsReplyDepth()
{
// Thread A is a root plus one reply carrying In-Reply-To, which is what
@@ -1003,6 +1079,35 @@ void TestNotmuchWorker::recipientsAreFoldedWhenAskedFor()
"two plus one: %1").arg(summary)));
}
+void TestNotmuchWorker::theFirstRecipientsAddressCrossesForTheAvatar()
+{
+ // Item 169's flat-view avatar. `recipients` is a DISPLAY summary and
+ // carries no address at all when every recipient has a name, so the hash
+ // needs the bare one; it rides the same fold, so it costs nothing extra.
+ const QVector<ThreadSummary> one =
+ runQuery(QStringLiteral("subject:Preventivo"),
+ NotmuchWorker::NewestFirst, true);
+ QCOMPARE(one.size(), 1);
+ QCOMPARE(one.at(0).firstMessageRecipient,
+ QStringLiteral("mario@example.org"));
+
+ // A quoted display name containing a comma must not defeat the parse, for
+ // the same reason it must not defeat the summary.
+ const QVector<ThreadSummary> many =
+ runQuery(QStringLiteral("subject:Riunione"),
+ NotmuchWorker::NewestFirst, true);
+ QCOMPARE(many.size(), 1);
+ QCOMPARE(many.at(0).firstMessageRecipient,
+ QStringLiteral("mario@example.org"));
+
+ // And it stays empty when the query never asked, exactly as `recipients`
+ // does: it is behind the same performance contract.
+ const QVector<ThreadSummary> unasked =
+ runQuery(QStringLiteral("subject:Preventivo"));
+ QCOMPARE(unasked.size(), 1);
+ QVERIFY(unasked.at(0).firstMessageRecipient.isEmpty());
+}
+
void TestNotmuchWorker::recipientsCrossAQueuedCall()
{
// The trap CLAUDE.md records for SortOrder, in the shape it takes for this
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 593a777..9ca35e3 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -102,6 +102,9 @@ private slots:
void flatModeOffersNoExpanderAndNoReplyCount();
void flatModeIsOffByDefaultAndReversible();
void recipientsReplaceTheSenderWhenPresent();
+ void aRowCarriesItsSenderAndAccountAddress();
+ void aMessageRowCarriesItsOwnSenderAndAddress();
+ void aFlatViewsAvatarFollowsTheRecipient();
};
static ThreadSummary makeThread(const QString &id, const QString &subject)
@@ -510,6 +513,84 @@ void TestThreadListModel::reportsSubjectAndAuthors()
QStringList({ QStringLiteral("inbox"), QStringLiteral("unread") }));
}
+void TestThreadListModel::aRowCarriesItsSenderAndAccountAddress()
+{
+ // Task 8: the avatar needs the row's bare sender address to hash and to
+ // match against the business-senders list, and the display name to take
+ // initials from. Both come from the row itself, not from the load.
+ ThreadListModel model;
+ ThreadSummary summary;
+ summary.threadId = QStringLiteral("t1");
+ summary.subject = QStringLiteral("Subject");
+ summary.authors = QStringLiteral("John Doe");
+ summary.firstMessageId = QStringLiteral("m1");
+ summary.firstMessageSender = QStringLiteral("john@example.org");
+ model.appendBatch({ summary });
+
+ const QModelIndex index = model.index(0, 0);
+ QCOMPARE(index.data(ThreadListModel::SenderAddressRole).toString(),
+ QStringLiteral("john@example.org"));
+ // The display name comes from `authors`, which is all notmuch gives.
+ QCOMPARE(index.data(ThreadListModel::SenderNameRole).toString(),
+ QStringLiteral("John Doe"));
+}
+
+void TestThreadListModel::aMessageRowCarriesItsOwnSenderAndAddress()
+{
+ // Task 8 counterpart of aRowCarriesItsSenderAndAccountAddress: that test
+ // covers the thread-row branch, and a role added to one branch and not the
+ // other is silently absent with nothing to flag it. A selected reply's
+ // avatar reads these, so the row that actually answers must carry them.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("A subject")) });
+
+ MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0);
+ MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1,
+ QStringLiteral("Bob <bob@example.org>"));
+ reply.senderAddress = QStringLiteral("bob@example.org");
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+
+ const QModelIndex replyIndex =
+ model.index(0, 0, model.index(0, 0, QModelIndex()));
+ QVERIFY(model.isMessageRow(replyIndex));
+
+ QCOMPARE(replyIndex.data(ThreadListModel::SenderAddressRole).toString(),
+ QStringLiteral("bob@example.org"));
+ QCOMPARE(replyIndex.data(ThreadListModel::SenderNameRole).toString(),
+ QStringLiteral("Bob <bob@example.org>"));
+}
+
+void TestThreadListModel::aFlatViewsAvatarFollowsTheRecipient()
+{
+ // In a Sent or Drafts view firstMessageSender is the USER on every row, so
+ // hashing it gives one pattern for the whole list. The recipient is what
+ // the row is about, and SendersRole already follows the same rule.
+ ThreadListModel model;
+ ThreadSummary summary;
+ summary.threadId = QStringLiteral("t1");
+ summary.subject = QStringLiteral("Subject");
+ summary.authors = QStringLiteral("Me");
+ summary.firstMessageId = QStringLiteral("m1");
+ summary.firstMessageSender = QStringLiteral("me@example.org");
+ summary.recipients = QStringLiteral("John Doe");
+ summary.firstMessageRecipient = QStringLiteral("john@example.org");
+ model.appendBatch({ summary });
+
+ QCOMPARE(model.index(0, 0).data(ThreadListModel::SenderAddressRole)
+ .toString(),
+ QStringLiteral("john@example.org"));
+
+ // No usable To: the sender is the fallback rather than a blank seed.
+ ThreadListModel bare;
+ summary.recipients.clear();
+ summary.firstMessageRecipient.clear();
+ bare.appendBatch({ summary });
+ QCOMPARE(bare.index(0, 0).data(ThreadListModel::SenderAddressRole)
+ .toString(),
+ QStringLiteral("me@example.org"));
+}
+
void TestThreadListModel::theReplyCountExcludesTheRootMessage()
{
ThreadListModel model;