diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | tests/test_avatar.cpp | 268 | ||||
| -rw-r--r-- | tests/test_businesssenders.cpp | 232 | ||||
| -rw-r--r-- | tests/test_carddelegate.cpp | 75 | ||||
| -rw-r--r-- | tests/test_cardlayout.cpp | 73 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 33 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 105 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 81 |
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; |
