/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #include #include #include #include "tagcolors.h" #include "threadlistmodel.h" class TestThreadListModel : public QObject { Q_OBJECT private slots: void aRowLeavesTheViewWhenItLosesTheViewsTag(); void rowsLosingTheTagAreRemovedInOneContiguousRun(); void theTrashViewDrawsNoDoomedFill(); void messageNodeHoldsDisplayFacts(); void rootRowsSurviveTheTreeConversion(); void repliesBecomeChildRowsUnderTheirThread(); void messageRowsShowTheirOwnSenderAndSubject(); void modelHasOneColumn(); void aFlatThreadStillListsItsReplies(); void theRootCardKnowsItsOwnMessage(); void replyShowsOnlyItsOwnTags(); void replySharingEveryThreadTagShowsNone(); void reloadingAThreadReplacesItsRepliesRatherThanRepeatingThem(); void anUnexpandedMultiMessageThreadOffersAnExpander(); void startsEmpty(); void accountKeysComeFromTheAccountTags(); void accountKeysCoverAThreadSpanningTwoAccounts(); void accountKeysAreEmptyForAnUnknownThread(); void appendsBatches(); void appendingEmptyBatchIsNoOp(); void clearResetsModel(); void reportsSubjectAndAuthors(); void thePillCountsTheThreadsMessages(); void unreadThreadsRenderBold(); void readThreadsAreDimmedAndUnreadAreNot(); void flaggedThreadsShowAStar(); void pillTagsExcludeWhatTheRowAlreadyShows(); void aTagDrawnAsAMarkIsNotAlsoAChip(); void theUnreadCueDoesNotDependOnFontWeight(); void aDoomedThreadKeepsItsContrastEvenWhenRead(); void accountTagBecomesAChipLabel(); void unreadStylingSurvivesAnAccountChip(); void accountChipUsesTheConfiguredColour(); void deletedThreadsAreRedAndStruckThrough(); void attachmentIsMarkedOnlyOnTaggedThreads(); void spamThreadsAreOrangeAndStruckThrough(); void doomedStylingCoversTheWholeCard(); void ordinaryThreadsCarryNoRowColour(); void threadIdIsReachableFromAnIndex(); void invalidIndexesReturnNothing(); void threadAtOutOfRangeIsSafe(); void threadForResolvesAReplyThroughItsParent(); void applyMessageTagChangeRepaintsThatReplyAlone(); void aDeletedReplyIsPaintedAsDoomed(); void aDeletedReplyIsStruckThrough(); void markingAReplyReadChangesItsForeground(); void anUnreadReplyIsBoldAndStillSmallerThanItsThread(); void aThreadTagChangeReachesItsLoadedReplies(); void aMessageTagChangeReachesTheRootCardsOwnMessage(); void aMessageTagChangeOnOneOfManyLeavesTheThreadSummaryAlone(); void aConversationRowDrawsTheThreadsTags(); void updatesTagsForMessage(); void tagChangeIsIdempotent(); void tagChangeSignalsExactlyTheChangedRow(); void tagChangeForUnknownThreadIsIgnored(); void tagChangeRoundTripsForRevert(); void modelPassesQtTester(); void reconcileAddsNewThreadsInTheOrderGiven(); void reconcileRemovesThreadsThatNoLongerMatch(); void reconcileKeepsSurvivingRowsAndTheirExpansion(); void reconcileUpdatesTagsOnASurvivingThread(); void reconcileOnAnEmptyModelFillsIt(); void reconcileWithAnIdenticalResultChangesNothing(); void reconcileMovesAThreadBumpedByANewReply(); void reconcileKeepsAMovedRowsPersistentIndex(); void flatModeOffersNoExpanderAndNoReplyCount(); void flatModeIsOffByDefaultAndReversible(); void recipientsReplaceTheSenderWhenPresent(); void aRowCarriesItsSenderAndAccountAddress(); void aMessageRowCarriesItsOwnSenderAndAddress(); void aFlatViewsAvatarFollowsTheRecipient(); void aSummaryWithOneMessageIsAMessageRow(); void aSummaryWithRepliesIsAConversationRow(); void aLoadedThreadTrustsItsChildrenOverItsCount(); void aMessageRowIsNeverAConversationRow(); void aConversationRowResolvesToItsThread(); void aRowNamingNoMessageIsSkippedNotEscalated(); void aLoneMessageRowResolvesToItsMessage(); void aReplyRowResolvesToItsMessage(); void aConversationListsItsFirstMessageAsAChild(); void aMessageRowDoesNotListItselfBeneathItself(); void aMixedSelectionCarriesBothScopes(); }; static ThreadSummary makeThread(const QString &id, const QString &subject) { ThreadSummary t; t.threadId = id; t.subject = subject; t.authors = QStringLiteral("Alice"); t.date = QDateTime::fromSecsSinceEpoch(1750000000); t.totalCount = 2; t.matchedCount = 1; t.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") }; return t; } static MessageNode makeNode(const QString &id, int depth, const QString &from = QStringLiteral("Alice"), const QString &subject = QStringLiteral("Re: Hi")) { MessageNode n; n.messageId = id; n.threadId = QStringLiteral("t1"); n.from = from; n.subject = subject; n.date = QDateTime::fromSecsSinceEpoch(1750000000); n.depth = depth; return n; } void TestThreadListModel::repliesBecomeChildRowsUnderTheirThread() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("A subject")) }); // EVERY message sits under a conversation row, the first included. This // read "one root and two children" until item 177, when the root stopped // meaning the first message and started meaning the conversation; the // first message then had no row anywhere and the user could not reach it. model.setThreadMessages(QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 1), makeNode(QStringLiteral("m2@example.org"), 2) }); const QModelIndex root = model.index(0, 0, QModelIndex()); QCOMPARE(model.rowCount(root), 3); const QModelIndex child = model.index(1, 0, root); QVERIFY(child.isValid()); QCOMPARE(model.parent(child), model.index(0, 0, QModelIndex())); QVERIFY(model.data(child, ThreadListModel::IsMessageRole).toBool()); QCOMPARE(model.data(child, ThreadListModel::MessageIdRole).toString(), QStringLiteral("m1@example.org")); // A message row still belongs to a thread, so a caller that only needs the // containing thread does not have to walk up itself. QCOMPARE(model.data(child, ThreadListModel::ThreadIdRole).toString(), QStringLiteral("t1")); // A thread root is not a message ROW, but it does carry a message id: the // root card is the thread's first message, and selecting it renders that // message alone. It used to answer nothing here, which is what made the // first message of every thread unreachable. QVERIFY(!model.data(root, ThreadListModel::IsMessageRole).toBool()); QCOMPARE(model.data(root, ThreadListModel::MessageIdRole).toString(), QStringLiteral("m0@example.org")); QAbstractItemModelTester tester( &model, QAbstractItemModelTester::FailureReportingMode::Warning); Q_UNUSED(tester); } void TestThreadListModel::messageRowsShowTheirOwnSenderAndSubject() { // A reply's row shows the REPLY's sender, not the thread's author summary. // Reading the thread's fields for a child row is the obvious mistake and // would look almost right, since the first sender is usually in both. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("A subject")) }); model.setThreadMessages( QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 1, QStringLiteral("Bob "), QStringLiteral("Re: A subject")) }); const QModelIndex root = model.index(0, 0, QModelIndex()); const QModelIndex reply = model.index(1, 0, root); QCOMPARE(model.data(reply, ThreadListModel::SendersRole).toString(), QStringLiteral("Bob ")); QCOMPARE(model.data(reply, ThreadListModel::SubjectRole).toString(), QStringLiteral("Re: A subject")); // No tag strip under a child row. The strip is a row-wide band carrying the // THREAD's tags; one under every reply would stripe the list and repeat the // same tags down the whole expansion. QVERIFY(model.data(reply, ThreadListModel::PillTagsRole) .toStringList().isEmpty()); } void TestThreadListModel::reloadingAThreadReplacesItsRepliesRatherThanRepeatingThem() { // A thread reloaded after a sync must not end up listing its replies twice. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("A subject")) }); const QVector nodes{ makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 1) }; // Two messages, two rows, both times. The number is what item 177 changed; // the property under test is that a reload REPLACES rather than appends, so // what matters is that the second call does not double it. model.setThreadMessages(QStringLiteral("t1"), nodes); const QModelIndex root = model.index(0, 0, QModelIndex()); QCOMPARE(model.rowCount(root), 2); model.setThreadMessages(QStringLiteral("t1"), nodes); QCOMPARE(model.rowCount(root), 2); QAbstractItemModelTester tester( &model, QAbstractItemModelTester::FailureReportingMode::Warning); Q_UNUSED(tester); } void TestThreadListModel::anUnexpandedMultiMessageThreadOffersAnExpander() { // This is what makes lazy loading work at all. rowCount is 0 until the // worker has walked the thread, so a view inferring the expander from // rowCount alone draws none, the user can never expand, and the replies are // never requested. hasChildren answers from the summary's count instead. ThreadListModel model; ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Has replies")); many.totalCount = 4; ThreadSummary lone = makeThread(QStringLiteral("t2"), QStringLiteral("Single message")); lone.totalCount = 1; model.appendBatch({ many, lone }); const QModelIndex withReplies = model.index(0, 0, QModelIndex()); const QModelIndex single = model.index(1, 0, QModelIndex()); // Guard: neither is expanded, so this really is the unloaded case. QCOMPARE(model.rowCount(withReplies), 0); QCOMPARE(model.rowCount(single), 0); QVERIFY(model.hasChildren(withReplies)); QVERIFY(!model.hasChildren(single)); // Once loaded the children are the truth, including "there are none": a // thread whose count included duplicates must stop offering an expander // that opens onto nothing. model.setThreadMessages(QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0) }); QVERIFY(!model.hasChildren(withReplies)); // A message row is always a leaf. model.setThreadMessages(QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 1) }); QVERIFY(model.hasChildren(withReplies)); QVERIFY(!model.hasChildren(model.index(0, 0, withReplies))); } void TestThreadListModel::accountKeysComeFromTheAccountTags() { // Item 49 reads this to decide which mbsync channels a sync needs. Only // account tags count: a functional tag names no mailbox. ThreadListModel model; ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("Hi")); t.tags.append(TagColors::tagForAccountKey(QStringLiteral("work"))); model.appendBatch({ t }); QCOMPARE(model.accountKeysForThread(QStringLiteral("t1")), QStringList{ QStringLiteral("work") }); } void TestThreadListModel::accountKeysCoverAThreadSpanningTwoAccounts() { // The row shows one chip, but tagging this thread touches files under both // mailboxes. Returning only the first would strand the other's edits. ThreadListModel model; ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("Hi")); t.tags.append(TagColors::tagForAccountKey(QStringLiteral("work"))); t.tags.append(TagColors::tagForAccountKey(QStringLiteral("personal"))); model.appendBatch({ t }); QStringList keys = model.accountKeysForThread(QStringLiteral("t1")); keys.sort(); QCOMPARE(keys, (QStringList{ QStringLiteral("personal"), QStringLiteral("work") })); } void TestThreadListModel::accountKeysAreEmptyForAnUnknownThread() { // A thread the model no longer holds must yield nothing rather than // matching some other row. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("Hi")) }); QVERIFY(model.accountKeysForThread(QStringLiteral("nope")).isEmpty()); } void TestThreadListModel::messageNodeHoldsDisplayFacts() { // A message ROW has to be drawn without opening the message, so the display // facts live on the node itself. MessageRef, which exists for rendering a // thread into the pane, carries none of them. MessageNode node; node.messageId = QStringLiteral("id@example.org"); node.from = QStringLiteral("A Sender "); node.subject = QStringLiteral("Re: a subject"); node.date = QDateTime::fromSecsSinceEpoch(1000); node.depth = 2; node.tags = QStringList{ QStringLiteral("unread") }; QCOMPARE(node.depth, 2); QVERIFY(node.isUnread()); QCOMPARE(node.from, QStringLiteral("A Sender ")); QCOMPARE(node.subject, QStringLiteral("Re: a subject")); // Depth 0 is the thread's first message, which the ROOT row stands for. // Defaulting to 0 rather than 1 keeps "is this the root" a plain check. const MessageNode fresh; QCOMPARE(fresh.depth, 0); QVERIFY(!fresh.isUnread()); } void TestThreadListModel::rootRowsSurviveTheTreeConversion() { // The point of this test is NOT the tree. It is that converting the base // class from QAbstractTableModel changed nothing a thread row does: a table // answers index() and parent() too, just trivially, and every existing test // in this file is the real regression net beside it. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("A subject")) }); // A tree model reports its roots under an INVALID parent. QCOMPARE(model.rowCount(QModelIndex()), 1); QCOMPARE(model.columnCount(QModelIndex()), 1); const QModelIndex root = model.index(0, 0, QModelIndex()); QVERIFY(root.isValid()); QVERIFY(!model.parent(root).isValid()); QCOMPARE(model.data(root, ThreadListModel::ThreadIdRole).toString(), QStringLiteral("t1")); // No children until a thread's messages are asked for. An expander drawn // over a thread whose replies were never loaded would open onto nothing. QCOMPARE(model.rowCount(root), 0); // Qt's own conformance check. It walks index/parent/rowCount for // consistency and catches the classic tree-model faults, such as a parent() // that does not round-trip, which a hand-written assertion misses. QAbstractItemModelTester tester( &model, QAbstractItemModelTester::FailureReportingMode::Warning); Q_UNUSED(tester); } void TestThreadListModel::startsEmpty() { ThreadListModel model; QCOMPARE(model.rowCount(), 0); QCOMPARE(model.columnCount(), 1); } void TestThreadListModel::appendsBatches() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); QCOMPARE(model.rowCount(), 1); model.appendBatch({ makeThread(QStringLiteral("t2"), QStringLiteral("two")), makeThread(QStringLiteral("t3"), QStringLiteral("three")) }); QCOMPARE(model.rowCount(), 3); QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t3")); } void TestThreadListModel::appendingEmptyBatchIsNoOp() { ThreadListModel model; QSignalSpy inserted(&model, &QAbstractItemModel::rowsInserted); model.appendBatch({}); QCOMPARE(model.rowCount(), 0); // An empty beginInsertRows(first, first - 1) range is a Qt contract // violation, so the guard must come before the signal. QVERIFY(inserted.isEmpty()); } void TestThreadListModel::clearResetsModel() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); QSignalSpy spy(&model, &QAbstractItemModel::modelReset); model.clear(); QCOMPARE(model.rowCount(), 0); QCOMPARE(spy.count(), 1); } void TestThreadListModel::reportsSubjectAndAuthors() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("hello")) }); // One index, every field, by role. The card draws them all at once, so // reading them through Qt::DisplayRole as five columns did is no longer // possible: DisplayRole answers the subject alone. const QModelIndex card = model.index(0, 0); QCOMPARE(model.data(card, ThreadListModel::SendersRole).toString(), QStringLiteral("Alice")); QVERIFY(model.data(card, ThreadListModel::DateRole).toDateTime().isValid()); QCOMPARE(model.data(card, ThreadListModel::SubjectRole).toString(), QStringLiteral("hello")); QCOMPARE(model.data(card, ThreadListModel::TagsRole).toStringList(), 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 ")); reply.senderAddress = QStringLiteral("bob@example.org"); model.setThreadMessages(QStringLiteral("t1"), { root, reply }); const QModelIndex replyIndex = model.index(1, 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 ")); } 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::thePillCountsTheThreadsMessages() { ThreadListModel model; ThreadSummary single = makeThread(QStringLiteral("t1"), QStringLiteral("alone")); single.totalCount = 1; ThreadSummary multi = makeThread(QStringLiteral("t2"), QStringLiteral("group")); multi.totalCount = 4; model.appendBatch({ single, multi }); // The count used to be a "(4)" suffix on the subject. It is the expander // on the card's second line now, and it counts MESSAGES: the row stands // for the conversation since item 177, so a thread of one message and // three replies reads "4 messages". A thread of one shows nothing to // expand, so its count stays 0. QCOMPARE(model.data(model.index(0, 0), ThreadListModel::MessageCountRole).toInt(), 0); QCOMPARE(model.data(model.index(1, 0), ThreadListModel::MessageCountRole).toInt(), 4); // And the subject is bare, with no count spliced into it. QCOMPARE(model.data(model.index(1, 0), ThreadListModel::SubjectRole).toString(), QStringLiteral("group")); } void TestThreadListModel::unreadThreadsRenderBold() { ThreadListModel model; ThreadSummary read = makeThread(QStringLiteral("t1"), QStringLiteral("read")); read.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ read, makeThread(QStringLiteral("t2"), QStringLiteral("unread")) }); const QVariant readFont = model.data(model.index(0, 0), Qt::FontRole); QVERIFY(!readFont.isValid()); const QVariant unreadFont = model.data(model.index(1, 0), Qt::FontRole); QVERIFY(unreadFont.isValid()); QVERIFY(unreadFont.value().bold()); } void TestThreadListModel::readThreadsAreDimmedAndUnreadAreNot() { // Bold was unread's ONLY cue, which leaves nothing to see when the // desktop's own font is configured bold: every row renders bold and // setBold() changes nothing. That is what the original report turned out // to be, a qt6ct setting rather than a defect here, but a cue with one // point of failure is worth reinforcing. // // Read rows are dimmed as well, which inverts the emphasis: unread sits at // full contrast and the bulk of a mostly-read list recedes. Bold still // applies on top. ThreadListModel model; ThreadSummary read = makeThread(QStringLiteral("t1"), QStringLiteral("read")); read.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch( { read, makeThread(QStringLiteral("t2"), QStringLiteral("unread")) }); const QVariant readFg = model.data(model.index(0, 0), Qt::ForegroundRole); const QVariant unreadFg = model.data(model.index(1, 0), Qt::ForegroundRole); QVERIFY2(readFg.isValid(), "a read thread carries no dimming"); QVERIFY2(!unreadFg.isValid(), "an unread thread must be left at the palette's own colour, so it " "is the one that stands out"); } void TestThreadListModel::flaggedThreadsShowAStar() { // "flagged" is an ordinary notmuch tag already carried in ThreadSummary, // so this needs no worker query, exactly as the paperclip did not. ThreadListModel model; ThreadSummary plain = makeThread(QStringLiteral("t1"), QStringLiteral("plain")); plain.tags = QStringList{ QStringLiteral("inbox") }; ThreadSummary starred = makeThread(QStringLiteral("t2"), QStringLiteral("starred")); starred.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("flagged") }; model.appendBatch({ plain, starred }); QVERIFY2(!model.data(model.index(0, 0), ThreadListModel::IsFlaggedRole).toBool(), "an unflagged thread reports itself flagged"); QVERIFY2(model.data(model.index(1, 0), ThreadListModel::IsFlaggedRole).toBool(), "a flagged thread does not report itself flagged"); } void TestThreadListModel::pillTagsExcludeWhatTheRowAlreadyShows() { // The pills exist to say what the row does not already say. Repeating the // account, the flag, the attachment or the read state as text beside the // chip, the star, the paperclip and the dimming would spend the new space // on things already visible. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("noisy")); thread.tags = QStringList{ QStringLiteral("inbox"), // structural, always true here QStringLiteral("unread"), // shown by not being dimmed QStringLiteral("flagged"), // shown by the star column QStringLiteral("attachment"), // shown by the paperclip column QStringLiteral("account-work"), // shown as the chip QStringLiteral("SBo"), // worth showing QStringLiteral("shopping/amazon"), }; model.appendBatch({ thread }); const QStringList pills = model.data(model.index(0, 0), ThreadListModel::PillTagsRole).toStringList(); QVERIFY2(pills.contains(QStringLiteral("SBo")), qPrintable(pills.join(','))); QVERIFY2(pills.contains(QStringLiteral("shopping/amazon")), qPrintable(pills.join(','))); for (const QString &hidden : { QStringLiteral("inbox"), QStringLiteral("unread"), QStringLiteral("flagged"), QStringLiteral("attachment") }) { QVERIFY2(!pills.contains(hidden), qPrintable(QStringLiteral("'%1' is repeated as a pill") .arg(hidden))); } // The account tag is matched by shape rather than by name, since the key // varies per user: whatever TagColors calls an account tag is excluded. for (const QString &tag : pills) { QVERIFY2(!TagColors::isAccountTag(tag), qPrintable(QStringLiteral("account tag '%1' repeated as a pill") .arg(tag))); } // Stable order, so a row does not reshuffle its own pills between repaints. QStringList sorted = pills; sorted.sort(); QCOMPARE(pills, sorted); } void TestThreadListModel::theUnreadCueDoesNotDependOnFontWeight() { // The property that matters, stated directly: strip every font from the // model's answer and the two states must still be distinguishable. A test // asserting only that bold is set passes on a system where bold paints // exactly like regular, which is precisely how this went unnoticed. ThreadListModel model; ThreadSummary read = makeThread(QStringLiteral("t1"), QStringLiteral("read")); read.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch( { read, makeThread(QStringLiteral("t2"), QStringLiteral("unread")) }); const QVariant readFg = model.data(model.index(0, 0), Qt::ForegroundRole); const QVariant unreadFg = model.data(model.index(1, 0), Qt::ForegroundRole); QVERIFY2(readFg != unreadFg, "read and unread cards render identically once the font is " "ignored"); } void TestThreadListModel::aDoomedThreadKeepsItsContrastEvenWhenRead() { // Both cues write ForegroundRole, so they share one channel and the order // matters. A deleted row forces white text onto its crimson fill; dimming // it because it also happens to be read would drop that contrast to // unreadable. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("doomed and read")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); const QModelIndex subject = model.index(0, 0); QCOMPARE(model.data(subject, Qt::ForegroundRole).value().color(), QColor(Qt::white)); } 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-webmail-personal") }; model.appendBatch({ thread }); const QModelIndex subject = model.index(0, 0); QCOMPARE(model.data(subject, ThreadListModel::AccountLabelRole).toString(), QStringLiteral("webmail-personal")); QVERIFY(model.data(subject, ThreadListModel::AccountColourRole) .value().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, 0), 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-webmail-personal") }; model.appendBatch({ thread }); const QModelIndex subject = model.index(0, 0); 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().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("webmail-personal"), QColor(QStringLiteral("#cc0000"))); ThreadListModel model; model.setTagColors(&colours); ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); thread.tags = QStringList{ QStringLiteral("account-webmail-personal") }; model.appendBatch({ thread }); QCOMPARE(model.data(model.index(0, 0), ThreadListModel::AccountColourRole).value(), QColor(QStringLiteral("#cc0000"))); } void TestThreadListModel::deletedThreadsAreRedAndStruckThrough() { ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("doomed")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); const QModelIndex subject = model.index(0, 0); QVERIFY(!model.data(subject, Qt::BackgroundRole).isValid()); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); const QVariant background = model.data(subject, Qt::BackgroundRole); QVERIFY(background.isValid()); QCOMPARE(background.value().color(), ThreadListModel::deletedColour()); // White text on the fill, and struck through so the state reads even in a // screenshot with the colours stripped. QCOMPARE(model.data(subject, Qt::ForegroundRole).value().color(), QColor(Qt::white)); QVERIFY(model.data(subject, Qt::FontRole).value().strikeOut()); } void TestThreadListModel::spamThreadsAreOrangeAndStruckThrough() { ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("junk")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("spam") }, {}); const QModelIndex subject = model.index(0, 0); QCOMPARE(model.data(subject, Qt::BackgroundRole).value().color(), ThreadListModel::spamColour()); QVERIFY(model.data(subject, Qt::FontRole).value().strikeOut()); // Spam and deleted must be distinguishable, not two shades of one colour. QVERIFY(ThreadListModel::spamColour() != ThreadListModel::deletedColour()); } void TestThreadListModel::doomedStylingCoversTheWholeCard() { // The cue is on the card itself. It used to be asserted per column, // because a cue on one column vanished the moment that column scrolled out // of view; one column cannot scroll away, but the roles still have to be // answered or a deleted card looks untouched. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("doomed")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); const QModelIndex index = model.index(0, 0); QVERIFY2(model.data(index, Qt::BackgroundRole).isValid(), "a deleted card has no background"); QVERIFY2(model.data(index, Qt::FontRole).value().strikeOut(), "a deleted card is not struck through"); } void TestThreadListModel::ordinaryThreadsCarryNoRowColour() { // Undo has to restore the plain look, not merely drop the tag. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("normal")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("deleted") }); const QModelIndex subject = model.index(0, 0); QVERIFY(!model.data(subject, Qt::BackgroundRole).isValid()); const QVariant font = model.data(subject, Qt::FontRole); QVERIFY(!font.isValid() || !font.value().strikeOut()); // The foreground goes back to the dimming a read thread carries, NOT to // nothing: this thread has no unread tag, so plain for it means dimmed. // What matters is that the doomed white is gone. const QVariant foreground = model.data(subject, Qt::ForegroundRole); if (foreground.isValid()) { QVERIFY2(foreground.value().color() != QColor(Qt::white), "the doomed white text survived the undo"); } } void TestThreadListModel::threadIdIsReachableFromAnIndex() { // The view hands MainWindow a QModelIndex; the worker needs a thread id. // Without a role for it, every caller has to reach around the model. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")), makeThread(QStringLiteral("t2"), QStringLiteral("two")) }); const QModelIndex index = model.index(1, 0); QCOMPARE(model.data(index, ThreadListModel::ThreadIdRole).toString(), QStringLiteral("t2")); } void TestThreadListModel::invalidIndexesReturnNothing() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); QVERIFY(!model.data(QModelIndex(), Qt::DisplayRole).isValid()); // Qt refuses to hand out an out-of-range index at all, so data() cannot be // reached with one through the public API. Verified empirically: index() // returns invalid for these, and a QPersistentModelIndex is invalidated by // the reset in clear() before data() ever sees it. data() still checks its // own bounds, but that guard is unreachable defence, not something these // assertions can falsify. QVERIFY(!model.index(0, 1).isValid()); QVERIFY(!model.index(5, 0).isValid()); QVERIFY(!model.index(-1, 0).isValid()); // A child index must yield nothing: this is a table, not a tree. const QModelIndex child = model.index(1, 0, model.index(0, 0)); QVERIFY(!child.isValid()); QVERIFY(!model.data(child, Qt::DisplayRole).isValid()); } void TestThreadListModel::threadAtOutOfRangeIsSafe() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); QVERIFY(model.threadAt(-1).threadId.isEmpty()); QVERIFY(model.threadAt(99).threadId.isEmpty()); QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t1")); } void TestThreadListModel::threadForResolvesAReplyThroughItsParent() { // Item 88. threadAt() takes a top-level row and a tree numbers rows per // parent, so a child's row() indexes its siblings and threadAt() reads it // as a position in the top-level list. threadFor() resolves through the // parent instead, which is what every caller holding an index needs. // // THREE threads, and the message taken from the LAST one. The child row // and the thread's own row have to differ, or threadAt() returns the right // answer by coincidence and the test proves nothing. With the conversation // at top-level row 2 and its message at child row 0, threadAt(0) names the // first thread while the message belongs to the third. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")), makeThread(QStringLiteral("t2"), QStringLiteral("two")), makeThread(QStringLiteral("t3"), QStringLiteral("three")) }); model.setThreadMessages(QStringLiteral("t3"), { makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 1) }); const QModelIndex second = model.index(2, 0, QModelIndex()); QCOMPARE(model.threadFor(second).threadId, QStringLiteral("t3")); const QModelIndex reply = model.index(0, 0, second); QVERIFY(reply.isValid()); QVERIFY2(model.isMessageRow(reply), "the fixture did not produce a message row"); QCOMPARE(reply.row(), 0); // The trap: a plausible top-level row number. QCOMPARE(model.threadFor(reply).threadId, QStringLiteral("t3")); // And the row-taking overload still does the wrong thing for that index, // which is why it is documented as unsafe rather than merely deprecated. // threadAt() reads a top-level row number and this index carries a child's. QCOMPARE(model.threadAt(reply.row()).threadId, QStringLiteral("t1")); QVERIFY2(model.threadAt(reply.row()).threadId != model.threadFor(reply).threadId, "threadAt() and threadFor() agree for this index, so the test no " "longer demonstrates why the row-taking overload is unsafe"); // An invalid index gives an empty summary, which every caller treats as // "nothing to do" rather than acting on row 0. QVERIFY(model.threadFor(QModelIndex()).threadId.isEmpty()); } void TestThreadListModel::applyMessageTagChangeRepaintsThatReplyAlone() { // The user's report: hitting Delete or Ctrl+U on a reply moved the pending // count and changed nothing on screen. sendMessageTagChange made no // optimistic update at all, on the correct reasoning that repainting the // THREAD row would claim every message in it had changed. The row that // should have repainted is the reply's own. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0); MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1); reply.tags = QStringList{ QStringLiteral("unread") }; model.setThreadMessages(QStringLiteral("t1"), { root, reply }); const QModelIndex threadIndex = model.index(0, 0, QModelIndex()); const QModelIndex replyIndex = model.index(1, 0, threadIndex); QVERIFY(model.isMessageRow(replyIndex)); const QStringList threadTagsBefore = model.data(threadIndex, ThreadListModel::TagsRole).toStringList(); QSignalSpy spy(&model, &QAbstractItemModel::dataChanged); model.applyMessageTagChange(QStringLiteral("m1@example.org"), { QStringLiteral("deleted") }, { QStringLiteral("unread") }); // The node carries the change, which is what every reply-row role reads. QCOMPARE(model.messageAt(replyIndex).isDeleted(), true); QCOMPARE(model.messageAt(replyIndex).isUnread(), false); // And the view was told, or the change is invisible until something else // happens to repaint the row. QCOMPARE(spy.count(), 1); QCOMPARE(spy.at(0).at(0).toModelIndex(), replyIndex); // The THREAD is untouched. Claiming the whole thread changed is the lie // the missing update was avoiding, and it must stay avoided. QCOMPARE(model.data(threadIndex, ThreadListModel::TagsRole).toStringList(), threadTagsBefore); QCOMPARE(model.messageAt(model.index(1, 0, threadIndex)).messageId, QStringLiteral("m1@example.org")); // An unknown message is a no-op rather than a wrong row repainted. spy.clear(); model.applyMessageTagChange(QStringLiteral("nobody@example.org"), { QStringLiteral("deleted") }, {}); QCOMPARE(spy.count(), 0); } void TestThreadListModel::aDeletedReplyIsPaintedAsDoomed() { // Updating the node is not enough on its own: a reply row had no doomed // branch at all, so a deleted reply repainted identically to an undeleted // one and the user still saw nothing. The thread row has carried this cue // since item 13. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0); MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1); model.setThreadMessages(QStringLiteral("t1"), { root, reply }); const QModelIndex threadIndex = model.index(0, 0, QModelIndex()); const QModelIndex replyIndex = model.index(1, 0, threadIndex); const QVariant plainBackground = model.data(replyIndex, Qt::BackgroundRole); model.applyMessageTagChange(QStringLiteral("m1@example.org"), { QStringLiteral("deleted") }, {}); const QVariant doomedBackground = model.data(replyIndex, Qt::BackgroundRole); QVERIFY2(doomedBackground != plainBackground, "a deleted reply paints exactly like an undeleted one, so the " "user has no way to see that Delete did anything"); QCOMPARE(doomedBackground.value().color(), ThreadListModel::deletedColour()); // Spam is the other half of isDoomed() and gets its own colour, so the two // are told apart by hue rather than by shade. model.applyMessageTagChange(QStringLiteral("m1@example.org"), { QStringLiteral("spam") }, { QStringLiteral("deleted") }); QCOMPARE(model.data(replyIndex, Qt::BackgroundRole).value().color(), ThreadListModel::spamColour()); } void TestThreadListModel::aDeletedReplyIsStruckThrough() { // The fill is not the only cue, deliberately: a strike-out survives a // screenshot, a colourblind reader and a theme that overrides the // background. The thread row has carried both since item 13; a reply had // neither. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); model.setThreadMessages(QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 1) }); const QModelIndex replyIndex = model.index(1, 0, model.index(0, 0, QModelIndex())); QVERIFY(!model.data(replyIndex, Qt::FontRole).value().strikeOut()); model.applyMessageTagChange(QStringLiteral("m1@example.org"), { QStringLiteral("deleted") }, {}); QVERIFY2(model.data(replyIndex, Qt::FontRole).value().strikeOut(), "a deleted reply is not struck through, so the only cue it has " "is a background colour"); // The reply's smaller font is not lost to the strike-out branch: a reply // reads as subordinate whatever its tags say. const QFont threadFont = model.data(model.index(0, 0, QModelIndex()), Qt::FontRole).value(); const QFont replyFont = model.data(replyIndex, Qt::FontRole).value(); if (threadFont.pointSize() > 0 && replyFont.pointSize() > 0) QVERIFY(replyFont.pointSize() < threadFont.pointSize()); } void TestThreadListModel::markingAReplyReadChangesItsForeground() { // The user's second report: marking a reply read or unread moved the // counter with no visible change. A reply is deliberately NEVER bold, so // unlike a thread row its only cue is the foreground dimming. That cue has // to at least exist and change, which is what this pins. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0); MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1); reply.tags = QStringList{ QStringLiteral("unread") }; model.setThreadMessages(QStringLiteral("t1"), { root, reply }); const QModelIndex replyIndex = model.index(1, 0, model.index(0, 0, QModelIndex())); const QVariant unreadForeground = model.data(replyIndex, Qt::ForegroundRole); model.applyMessageTagChange(QStringLiteral("m1@example.org"), {}, { QStringLiteral("unread") }); const QVariant readForeground = model.data(replyIndex, Qt::ForegroundRole); QVERIFY2(readForeground != unreadForeground, "marking a reply read changed nothing about how its row paints"); QCOMPARE(readForeground.value().color(), ThreadListModel::readColour()); // And back, so the toggle is visible in both directions rather than only // on the way to read. model.applyMessageTagChange(QStringLiteral("m1@example.org"), { QStringLiteral("unread") }, {}); QCOMPARE(model.data(replyIndex, Qt::ForegroundRole), unreadForeground); } void TestThreadListModel::anUnreadReplyIsBoldAndStillSmallerThanItsThread() { // Requested by the user on 2026-08-16: "I prefer the bold on replies // combined with the dimming." Replies were deliberately never bold before // that, so this pins the decision rather than describing the code. // // Both halves matter. Bold is the second cue, next to the dimming; the // smaller size is what still separates a reply from the thread heading // above it, and dropping it would make an unread reply indistinguishable // from a thread row. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0); MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1); reply.tags = QStringList{ QStringLiteral("unread") }; model.setThreadMessages(QStringLiteral("t1"), { root, reply }); const QModelIndex threadIndex = model.index(0, 0, QModelIndex()); const QModelIndex replyIndex = model.index(1, 0, threadIndex); const QFont unreadFont = model.data(replyIndex, Qt::FontRole).value(); QVERIFY2(unreadFont.bold(), "an unread reply is not bold"); const QFont threadFont = model.data(threadIndex, Qt::FontRole).value(); if (threadFont.pointSize() > 0 && unreadFont.pointSize() > 0) { QVERIFY2(unreadFont.pointSize() < threadFont.pointSize(), "a bold reply is the same size as its thread row, so the two " "kinds of row no longer read apart"); } // Bold is the unread cue specifically, not decoration on every reply. model.applyMessageTagChange(QStringLiteral("m1@example.org"), {}, { QStringLiteral("unread") }); QVERIFY2(!model.data(replyIndex, Qt::FontRole).value().bold(), "a read reply is still bold, so bold says nothing"); } void TestThreadListModel::aThreadTagChangeReachesItsLoadedReplies() { // The user's report: "if I hit read/unread on the main thread message [...] // only the main message is repainted [...] the replies don't get // repainted." // // A thread-scoped write reaches every message in the thread IN THE // DATABASE. applyTagChange only ever updated the thread's summary, so an // expanded thread kept showing replies with their old tags: bold, undimmed // and unstruck, describing a state the database no longer held. The rows // corrected themselves on the next query, which is what made this look // like a repaint problem rather than a stale-model one. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0); root.tags = QStringList{ QStringLiteral("unread") }; MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1); reply.tags = QStringList{ QStringLiteral("unread") }; model.setThreadMessages(QStringLiteral("t1"), { root, reply }); const QModelIndex threadIndex = model.index(0, 0, QModelIndex()); const QModelIndex replyIndex = model.index(1, 0, threadIndex); QVERIFY(model.messageAt(replyIndex).isUnread()); QSignalSpy spy(&model, &QAbstractItemModel::dataChanged); model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("unread") }); QVERIFY2(!model.messageAt(replyIndex).isUnread(), "a thread marked read left its loaded replies carrying unread, so " "the rows describe a state the database does not hold"); // The reply's row was told to repaint, not merely mutated behind the view. bool replyRepainted = false; for (const QList &call : spy) { const QModelIndex from = call.at(0).toModelIndex(); const QModelIndex to = call.at(1).toModelIndex(); if (from.parent() == threadIndex && replyIndex.row() >= from.row() && replyIndex.row() <= to.row()) { replyRepainted = true; break; } } QVERIFY2(replyRepainted, "no dataChanged covered the reply rows, so the view has no reason " "to redraw them"); // Both directions, since a toggle is only fixed if it is visible each way. model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("unread") }, {}); QVERIFY(model.messageAt(replyIndex).isUnread()); } void TestThreadListModel::aMessageTagChangeReachesTheRootCardsOwnMessage() { // The user, 2026-08-16: "delete single message on the root message of a // thread doesn't trigger the repaint, delete whole thread does". // // applyMessageTagChange only searched `children`, and the root message is // never there: setThreadMessages drops depth 0 because the root row stands // for it. So a write to the message a root card displays found nothing, // updated nothing and repainted nothing, while the same write on a reply // worked. Item 108 made this the ORDINARY case, so the every-day gesture // was the broken one. ThreadListModel model; ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("one")); t.totalCount = 1; // A single-message thread. t.firstMessageId = QStringLiteral("m0@example.org"); t.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ t }); const QModelIndex threadIndex = model.index(0, 0, QModelIndex()); QSignalSpy spy(&model, &QAbstractItemModel::dataChanged); model.applyMessageTagChange(QStringLiteral("m0@example.org"), { QStringLiteral("deleted") }, {}); // The card has to repaint, which is the whole report. QCOMPARE(spy.count(), 1); QCOMPARE(spy.at(0).at(0).toModelIndex(), threadIndex); // And it has to LOOK deleted. For a one-message thread the thread's tags // ARE that message's tags: notmuch_thread_get_tags is a union over the // thread, and a union over one message is that message. QVERIFY2(model.threadAt(0).isDeleted(), "the root card does not show the state of the message it " "displays, so Delete on it looks like it did nothing"); // Works before the thread has ever been expanded, which is the case the // user hits: nothing loads a root's node until then. QCOMPARE(model.rowCount(threadIndex), 0); } void TestThreadListModel::aMessageTagChangeOnOneOfManyLeavesTheThreadSummaryAlone() { // The other half, and the reason the fix is not "write it to the summary". // A thread's tags are a UNION over its messages, so deleting one message of // seven does not make the conversation deleted, and painting the card // crimson would claim it did. ThreadListModel model; ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("one")); t.totalCount = 7; t.firstMessageId = QStringLiteral("m0@example.org"); t.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") }; model.appendBatch({ t }); const QModelIndex threadIndex = model.index(0, 0, QModelIndex()); QSignalSpy spy(&model, &QAbstractItemModel::dataChanged); model.applyMessageTagChange(QStringLiteral("m0@example.org"), { QStringLiteral("deleted") }, { QStringLiteral("unread") }); // Still repaints: MessageIdRole and anything else keyed on the root's own // node has changed, and the row is what the user is looking at. QCOMPARE(spy.count(), 1); QVERIFY2(!model.threadAt(0).isDeleted(), "deleting one message of a seven-message thread painted the whole " "conversation as deleted"); QVERIFY2(model.threadAt(0).isUnread(), "marking one message of a seven-message thread read claimed the " "whole conversation was read, though six messages still are not"); } void TestThreadListModel::aConversationRowDrawsTheThreadsTags() { // Item 110 made a card draw its first message's tags so a four-message // thread would stop claiming a `signed` its displayed message lacked. // Under item 177 the row IS the conversation, so the union is what it // means and the substitution is wrong. ThreadListModel model; ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("Talk")); t.totalCount = 4; t.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("signed") }; t.firstMessageId = QStringLiteral("m1"); t.firstMessageTags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ t }); const QModelIndex row = model.index(0, 0, QModelIndex()); const QStringList pills = model.data(row, ThreadListModel::PillTagsRole).toStringList(); QVERIFY2(pills.contains(QStringLiteral("signed")), "the card dropped a tag the conversation carries, so a signed " "thread does not read as one until it is expanded"); QCOMPARE(model.data(row, ThreadListModel::TagsRole).toStringList(), t.tags); } void TestThreadListModel::updatesTagsForMessage() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); QVERIFY(model.threadAt(0).isUnread()); // Optimistic UI: the model changes before the worker confirms. model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("unread") }); QVERIFY(!model.threadAt(0).isUnread()); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("flagged") }, {}); QVERIFY(model.threadAt(0).isFlagged()); } void TestThreadListModel::tagChangeIsIdempotent() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); // Adding a tag twice must not duplicate it: the tag list is shown joined, // and "inbox inbox" is visible garbage. model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("inbox") }, {}); QCOMPARE(model.threadAt(0).tags.count(QStringLiteral("inbox")), 1); // Removing an absent tag is equally harmless. model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("nosuchtag") }); QCOMPARE(model.threadAt(0).tags.count(QStringLiteral("inbox")), 1); } void TestThreadListModel::tagChangeSignalsExactlyTheChangedRow() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")), makeThread(QStringLiteral("t2"), QStringLiteral("two")), makeThread(QStringLiteral("t3"), QStringLiteral("three")) }); QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); model.applyTagChange(QStringLiteral("t2"), {}, { QStringLiteral("unread") }); QCOMPARE(changed.size(), 1); const QModelIndex topLeft = changed.first().at(0).value(); const QModelIndex bottomRight = changed.first().at(1).value(); QCOMPARE(topLeft.row(), 1); QCOMPARE(bottomRight.row(), 1); // One column, so the range is a single index: the card repaints whole. QCOMPARE(topLeft.column(), 0); QCOMPARE(bottomRight.column(), 0); } void TestThreadListModel::tagChangeForUnknownThreadIsIgnored() { ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); model.applyTagChange(QStringLiteral("nosuchthread"), { QStringLiteral("x") }, {}); QVERIFY(changed.isEmpty()); QCOMPARE(model.threadAt(0).tags, (QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") })); } void TestThreadListModel::tagChangeRoundTripsForRevert() { // MainWindow reverts a failed write by re-applying the change with add and // remove swapped. That only restores the original state if the round trip // is exact. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); const QStringList before = model.threadAt(0).tags; const QStringList add{ QStringLiteral("flagged") }; const QStringList remove{ QStringLiteral("inbox") }; model.applyTagChange(QStringLiteral("t1"), add, remove); QVERIFY(model.threadAt(0).tags != before); model.applyTagChange(QStringLiteral("t1"), remove, add); const QStringList after = model.threadAt(0).tags; QCOMPARE(after.size(), before.size()); for (const QString &tag : before) QVERIFY(after.contains(tag)); } void TestThreadListModel::modelPassesQtTester() { ThreadListModel model; // Catches signal/rowCount contract violations that hand-written tests miss. QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest); model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")), makeThread(QStringLiteral("t2"), QStringLiteral("two")) }); model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("unread") }); model.clear(); } void TestThreadListModel::attachmentIsMarkedOnlyOnTaggedThreads() { // The mark is drawn on the card's second line by CardDelegate. What the // model owes it is the flag and the glyph, which is what this asserts: // the column that used to carry it is gone. ThreadSummary plain = makeThread(QStringLiteral("t1"), QStringLiteral("no attachment")); ThreadSummary withFile = makeThread(QStringLiteral("t2"), QStringLiteral("has one")); // notmuch applies this tag itself while indexing, so no MIME parsing and // no extra worker query are involved. withFile.tags.append(QStringLiteral("attachment")); ThreadListModel model; model.appendBatch({ plain, withFile }); const QModelIndex plainCell = model.index(0, 0); const QModelIndex fileCell = model.index(1, 0); QVERIFY(!model.data(plainCell, ThreadListModel::HasAttachmentRole).toBool()); QVERIFY(model.data(fileCell, ThreadListModel::HasAttachmentRole).toBool()); // Only the marked thread gets a tooltip, or an empty cell would claim to // have an attachment on hover. QVERIFY(model.data(plainCell, Qt::ToolTipRole).toString().isEmpty()); QVERIFY(!model.data(fileCell, Qt::ToolTipRole).toString().isEmpty()); } void TestThreadListModel::aTagDrawnAsAMarkIsNotAlsoAChip() { // Items 69 and 70. passed and replied are drawn marks on line two now, so a // chip repeating the word puts the same fact on the card twice. Caught by // rendering a real card rather than by any existing test, which is why this // one exists: every geometry assertion passed while the row said "passed" // as both an arrow and a green pill. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("subject")); thread.tags = { QStringLiteral("inbox"), QStringLiteral("flagged"), QStringLiteral("attachment"), QStringLiteral("passed"), QStringLiteral("replied"), QStringLiteral("project") }; model.appendBatch({ thread }); const QStringList pills = model.data(model.index(0, 0), ThreadListModel::PillTagsRole) .toStringList(); for (const QString &drawn : { QStringLiteral("flagged"), QStringLiteral("attachment"), QStringLiteral("passed"), QStringLiteral("replied") }) { QVERIFY2(!pills.contains(drawn), qPrintable(QStringLiteral("'%1' is drawn as a mark and still " "appears as a chip") .arg(drawn))); } // Guard: a tag with no mark still becomes a chip, or the assertions above // would pass against a model that dropped every pill. QVERIFY2(pills.contains(QStringLiteral("project")), "an ordinary tag lost its chip, so the filter is too broad"); } void TestThreadListModel::modelHasOneColumn() { ThreadListModel model; ThreadSummary thread; thread.threadId = QStringLiteral("T1"); thread.subject = QStringLiteral("Build fails"); thread.authors = QStringLiteral("alice@example.org"); thread.date = QDateTime::currentDateTime(); thread.totalCount = 1; model.appendBatch({ thread }); QCOMPARE(model.columnCount(), 1); // Every field the five columns used to answer is still reachable, by role // rather than by column, because the card draws them all. const QModelIndex index = model.index(0, 0); QCOMPARE(index.data(ThreadListModel::SubjectRole).toString(), QStringLiteral("Build fails")); QCOMPARE(index.data(ThreadListModel::SendersRole).toString(), QStringLiteral("alice@example.org")); QVERIFY(index.data(ThreadListModel::DateRole).toDateTime().isValid()); // A single-message thread offers no expander: it is its own message and // has nothing to open onto. QCOMPARE(index.data(ThreadListModel::MessageCountRole).toInt(), 0); } void TestThreadListModel::aFlatThreadStillListsItsReplies() { // A thread whose messages carry no reply structure: notmuch returns them // all from get_toplevel_messages at depth 0, which is what happens when the // mail has no usable In-Reply-To. Measured in the user's own database: // of 396 inbox threads, three are like this, one of them nine messages // deep, and every one of them showed a reply count that expanded to // nothing because the model kept only nodes with depth > 0. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("flat thread")); thread.totalCount = 3; model.appendBatch({ thread }); model.setThreadMessages(QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 0), makeNode(QStringLiteral("m2@example.org"), 0) }); const QModelIndex root = model.index(0, 0); // Three children, not zero: a flat thread's messages all arrive at depth 0, // and selecting on depth would list none of them. Position is the rule, and // since item 177 it starts at the first message rather than after it. QCOMPARE(model.rowCount(root), 3); QCOMPARE(model.index(0, 0, root).data(ThreadListModel::MessageIdRole) .toString(), QStringLiteral("m0@example.org")); QCOMPARE(model.index(1, 0, root).data(ThreadListModel::MessageIdRole) .toString(), QStringLiteral("m1@example.org")); // The pill counts MESSAGES, which is exactly what the rows are: since item // 177 a conversation lists every message under itself, so the number on // the card and the number of rows it opens onto agree. QCOMPARE(root.data(ThreadListModel::MessageCountRole).toInt(), model.rowCount(root)); } void TestThreadListModel::theRootCardKnowsItsOwnMessage() { // The root card IS the thread's first message, so it has to be able to say // which message that is. Without this the pane renders the whole thread // when the root is selected, and the first message is unreachable: the // only rows offering it are the replies, and it is not one of them. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("a subject")); thread.totalCount = 2; model.appendBatch({ thread }); const QModelIndex root = model.index(0, 0); // Before the replies are loaded there is nothing to report, and the caller // must fall back to loading the whole thread rather than a wrong message. QVERIFY(root.data(ThreadListModel::MessageIdRole).toString().isEmpty()); model.setThreadMessages(QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 1) }); QCOMPARE(root.data(ThreadListModel::MessageIdRole).toString(), QStringLiteral("m0@example.org")); // The card still knows its own message, which the dashboard's heading and // the thread-scoped actions read. Child 0 is now that same first message // rather than the first reply: since item 177 the conversation lists every // message, so the row under the card is m0 and m1 follows it. QCOMPARE(model.index(0, 0, root).data(ThreadListModel::MessageIdRole) .toString(), QStringLiteral("m0@example.org")); QCOMPARE(model.index(1, 0, root).data(ThreadListModel::MessageIdRole) .toString(), QStringLiteral("m1@example.org")); } void TestThreadListModel::replyShowsOnlyItsOwnTags() { ThreadListModel model; ThreadSummary thread; thread.threadId = QStringLiteral("T1"); thread.subject = QStringLiteral("Build fails"); thread.totalCount = 2; thread.tags = { QStringLiteral("inbox"), QStringLiteral("work") }; model.appendBatch({ thread }); MessageNode reply; reply.messageId = QStringLiteral("M2"); reply.threadId = QStringLiteral("T1"); reply.from = QStringLiteral("bob@example.org"); reply.depth = 1; // Two the thread already has, one it does not. reply.tags = { QStringLiteral("inbox"), QStringLiteral("work"), QStringLiteral("todo") }; // Led by the thread's FIRST message, which is what the worker sends and // what the root card draws. setThreadMessages drops it by position. MessageNode root; root.messageId = QStringLiteral("M1"); root.threadId = QStringLiteral("T1"); root.depth = 0; model.setThreadMessages(QStringLiteral("T1"), { root, reply }); const QModelIndex threadIndex = model.index(0, 0); QVERIFY(model.hasChildren(threadIndex)); const QModelIndex replyIndex = model.index(1, 0, threadIndex); QVERIFY(replyIndex.isValid()); const QStringList own = replyIndex.data(ThreadListModel::MessageOwnTagsRole).toStringList(); QCOMPARE(own, QStringList{ QStringLiteral("todo") }); // The colours must line up with the names one for one, or the delegate // walks the two lists together and paints a chip in another tag's colour. const QVariantList colours = replyIndex.data(ThreadListModel::MessageOwnColoursRole).toList(); QCOMPARE(colours.size(), own.size()); QVERIFY(colours.first().value().isValid()); } void TestThreadListModel::replySharingEveryThreadTagShowsNone() { ThreadListModel model; ThreadSummary thread; thread.threadId = QStringLiteral("T1"); thread.totalCount = 2; thread.tags = { QStringLiteral("inbox"), QStringLiteral("work") }; model.appendBatch({ thread }); MessageNode reply; reply.messageId = QStringLiteral("M2"); reply.threadId = QStringLiteral("T1"); reply.depth = 1; reply.tags = { QStringLiteral("inbox"), QStringLiteral("work") }; MessageNode root; root.messageId = QStringLiteral("M1"); root.threadId = QStringLiteral("T1"); root.depth = 0; model.setThreadMessages(QStringLiteral("T1"), { root, reply }); const QModelIndex replyIndex = model.index(1, 0, model.index(0, 0)); QVERIFY(replyIndex.isValid()); QVERIFY(replyIndex.data(ThreadListModel::MessageOwnTagsRole) .toStringList() .isEmpty()); // A thread row has no thread to differ from, so it never answers these: // its own chips come from PillTagsRole. QVERIFY(model.index(0, 0) .data(ThreadListModel::MessageOwnTagsRole) .toStringList() .isEmpty()); } void TestThreadListModel::reconcileAddsNewThreadsInTheOrderGiven() { // Item 35b. The auto-refresh hands the model a fresh result set and the // model works out the difference, rather than being cleared and refilled. // // Position comes from the result, never from a rule of this model's own: // the query is sorted by the worker, so with newest-first a new thread // arrives at the front and with oldest-first at the back. A model that // forced new rows to the top would contradict the sort the user chose. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")) }); model.reconcile({ makeThread(QStringLiteral("t3"), QStringLiteral("Newest")), makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")) }); QCOMPARE(model.rowCount(), 3); QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t3")); QCOMPARE(model.threadAt(1).threadId, QStringLiteral("t1")); QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t2")); } void TestThreadListModel::reconcileRemovesThreadsThatNoLongerMatch() { // A thread read out of an Unread view stops matching, and the list has to // say so. Leaving it would make the list disagree with its own query, and // every view-wide action (Mark all read) acts on what the list holds. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")) }); model.reconcile({ makeThread(QStringLiteral("t2"), QStringLiteral("Two")) }); QCOMPARE(model.rowCount(), 1); QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t2")); } void TestThreadListModel::reconcileKeepsSurvivingRowsAndTheirExpansion() { // The whole point of reconciling rather than clearing. A surviving thread // must keep the SAME row identity, because the view's selection, its // expanded state and the open message all hang off persistent indexes: a // beginResetModel drops every one of them, which is what made the old // refresh close the thread being read. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")) }); MessageNode root = makeNode(QStringLiteral("m1"), 0); MessageNode reply = makeNode(QStringLiteral("m2"), 1); model.setThreadMessages(QStringLiteral("t1"), { root, reply }); QCOMPARE(model.rowCount(model.index(0, 0)), 2); const QPersistentModelIndex survivor(model.index(0, 0)); QVERIFY(survivor.isValid()); // t2 leaves, t3 arrives, t1 stays put. model.reconcile({ makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t3"), QStringLiteral("Three")) }); QVERIFY2(survivor.isValid(), "reconciling invalidated a surviving row, so the selection and " "the open thread would be lost exactly as a reset loses them"); QCOMPARE(model.threadAt(survivor.row()).threadId, QStringLiteral("t1")); // Its loaded messages survive too, or the thread collapses under the // reader. Two of them since item 177: a conversation lists its first // message as well as its replies. QCOMPARE(model.rowCount(model.index(survivor.row(), 0)), 2); } void TestThreadListModel::reconcileUpdatesTagsOnASurvivingThread() { // A thread that stays but changed state: read elsewhere, tagged by a // filter, flagged on the phone. The row has to repaint, or the list shows // stale state while claiming to be current. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("One")) }); QVERIFY(model.threadAt(0).tags.contains(QStringLiteral("unread"))); ThreadSummary readNow = makeThread(QStringLiteral("t1"), QStringLiteral("One")); readNow.tags = QStringList{ QStringLiteral("inbox") }; QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); model.reconcile({ readNow }); QCOMPARE(model.rowCount(), 1); QVERIFY2(!model.threadAt(0).tags.contains(QStringLiteral("unread")), "a surviving thread kept its stale tags"); QVERIFY2(!changed.isEmpty(), "the row's new state was stored without repainting it"); } void TestThreadListModel::reconcileOnAnEmptyModelFillsIt() { // Item 35a's case, now reached through the same path as every other // refresh rather than through a special one: read the view empty, cron // indexes new mail, it appears. ThreadListModel model; QCOMPARE(model.rowCount(), 0); model.reconcile({ makeThread(QStringLiteral("t1"), QStringLiteral("New")) }); QCOMPARE(model.rowCount(), 1); QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t1")); } void TestThreadListModel::reconcileWithAnIdenticalResultChangesNothing() { // The common case: the sync brought nothing this query cares about. It // runs every ten minutes under a reader, so it must not churn rows, and // must not emit a reset that would collapse an expanded thread. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")) }); const QPersistentModelIndex kept(model.index(1, 0)); QSignalSpy reset(&model, &QAbstractItemModel::modelReset); QSignalSpy inserted(&model, &QAbstractItemModel::rowsInserted); QSignalSpy removed(&model, &QAbstractItemModel::rowsRemoved); model.reconcile({ makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")) }); QCOMPARE(model.rowCount(), 2); QVERIFY2(reset.isEmpty(), "an unchanged result reset the model"); QVERIFY2(inserted.isEmpty(), "an unchanged result inserted rows"); QVERIFY2(removed.isEmpty(), "an unchanged result removed rows"); QVERIFY(kept.isValid()); QCOMPARE(kept.row(), 1); } void TestThreadListModel::reconcileMovesAThreadBumpedByANewReply() { // The case the other reconcile tests all miss, and the commonest reordering // there is: an old thread gets a new reply, so under newest-first the // worker returns it at the FRONT although it was already on screen. It is // neither an arrival nor a departure, and a reconcile that only handles // those two leaves it where it was, showing an order the query does not // agree with. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")), makeThread(QStringLiteral("t3"), QStringLiteral("Three")) }); // t3 was replied to and now sorts first. model.reconcile({ makeThread(QStringLiteral("t3"), QStringLiteral("Three")), makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")) }); QCOMPARE(model.rowCount(), 3); QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t3")); QCOMPARE(model.threadAt(1).threadId, QStringLiteral("t1")); QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t2")); } void TestThreadListModel::reconcileKeepsAMovedRowsPersistentIndex() { // A reordering seen from the VIEW's side rather than the data's. // // reconcile() places rows with beginMoveRows, and the assertions on // threadAt() cannot tell a correct move from a broken one: QVector::move // reorders the storage whatever Qt was told, so the data lands right even // if the signal is wrong and only a persistent index reports the // difference. A real view's selection rides on exactly that. // // Every move reconcile() makes is upwards, which is a property of the walk // and not of this data: the result is walked front to back, so rows ahead // of the target are already final and a misplaced survivor is always // pulled forward. The model asserts that invariant. ThreadListModel model; // Fatal, and attached BEFORE the move. The tester is what actually checks // the beginMoveRows arguments against the rows that end up moving; the // assertions below read m_threads, which QVector::move reorders correctly // whatever destination Qt was told. Without this a wrong destination // corrupts only what the VIEW is told, and every assertion here still // passes while a real view's selection lands on the wrong row. QAbstractItemModelTester tester( &model, QAbstractItemModelTester::FailureReportingMode::Fatal); Q_UNUSED(tester); model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("One")), makeThread(QStringLiteral("t2"), QStringLiteral("Two")), makeThread(QStringLiteral("t3"), QStringLiteral("Three")) }); const QPersistentModelIndex moved(model.index(0, 0)); QVERIFY(moved.isValid()); // t1 ends last. Reached by t2 and t3 each being pulled forward past it, // which is what makes t1's persistent index the thing under test: it is // displaced twice without ever being the row that moves. model.reconcile({ makeThread(QStringLiteral("t2"), QStringLiteral("Two")), makeThread(QStringLiteral("t3"), QStringLiteral("Three")), makeThread(QStringLiteral("t1"), QStringLiteral("One")) }); QCOMPARE(model.rowCount(), 3); QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t2")); QCOMPARE(model.threadAt(1).threadId, QStringLiteral("t3")); QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t1")); // The persistent index followed the row rather than being invalidated, // which is what keeps a selection on a thread that reordered under it. // This is the assertion the destination adjustment is answerable to: with // the wrong destination the DATA still lands correctly (QVector::move does // not care what Qt was told) and only this reports the difference. QVERIFY2(moved.isValid(), "a moved row lost its persistent index"); QCOMPARE(moved.row(), 2); } void TestThreadListModel::flatModeOffersNoExpanderAndNoReplyCount() { // A sent message lives on its own: the user's mental model of "what I sent" // is a list, not a set of conversations, and a thread pulled in whole shows // the replies they received under a view that claims to be their outbox. // // Deliberately not a second model or a filtered query. The expander is // driven by hasChildren() and the card's count by MessageCountRole, both // already here, so flat mode is those two answering differently. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("Subject")) }); const QModelIndex thread = model.index(0, 0); QVERIFY(thread.isValid()); // The tree shape, before anything is turned off. Guards the assertions // below: a test whose subject was already flat would pass either way. QVERIFY2(model.hasChildren(thread), "the fixture thread is not expandable, so this proves nothing"); QCOMPARE(model.data(thread, ThreadListModel::MessageCountRole).toInt(), 2); model.setFlatMode(true); QVERIFY2(!model.hasChildren(thread), "a flat list still offered an expander"); QCOMPARE(model.data(thread, ThreadListModel::MessageCountRole).toInt(), 0); // rowCount has to agree, or the view draws an expander it cannot open, or // opens onto rows the card said were not there. QCOMPARE(model.rowCount(thread), 0); // The thread itself is still a row. Flat means one row per thread, not // fewer threads. QCOMPARE(model.rowCount(), 1); } void TestThreadListModel::flatModeIsOffByDefaultAndReversible() { // The whole condition the user set for this feature: it must not leak into // any other view. Off by default is what guarantees that, and returning to // false has to restore the tree rather than leaving the model flattened // for the next query. ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("Subject")) }); const QModelIndex thread = model.index(0, 0); QVERIFY2(model.hasChildren(thread), "a fresh model is flat, so every ordinary view lost its replies"); model.setFlatMode(true); QVERIFY(!model.hasChildren(thread)); model.setFlatMode(false); QVERIFY2(model.hasChildren(thread), "leaving flat mode did not restore the tree"); QCOMPARE(model.data(thread, ThreadListModel::MessageCountRole).toInt(), 2); } void TestThreadListModel::recipientsReplaceTheSenderWhenPresent() { // In a Sent view the sender is the user on every row, so the card shows // who it went TO instead. One role, so the delegate needs no branch and // cannot disagree with the model about which name a row is showing. ThreadListModel model; ThreadSummary sent = makeThread(QStringLiteral("t1"), QStringLiteral("Preventivo")); sent.authors = QStringLiteral("You"); sent.recipients = QStringLiteral("Mario Rossi"); // No recipients: an ordinary view, where authors is the answer. Same // fixture otherwise, so the difference is the field and nothing else. ThreadSummary received = makeThread(QStringLiteral("t2"), QStringLiteral("Newsletter")); received.authors = QStringLiteral("Carol"); model.appendBatch({ sent, received }); QCOMPARE(model.data(model.index(0, 0), ThreadListModel::SendersRole).toString(), QStringLiteral("Mario Rossi")); QCOMPARE(model.data(model.index(1, 0), ThreadListModel::SendersRole).toString(), QStringLiteral("Carol")); // A thread whose To could not be parsed falls back rather than showing an // empty name. The fold returns an empty string for a malformed or absent // header, and a blank where a sender belongs reads as a rendering fault. ThreadSummary unparseable = makeThread(QStringLiteral("t3"), QStringLiteral("Broken")); unparseable.authors = QStringLiteral("You"); unparseable.recipients = QString(); model.appendBatch({ unparseable }); QCOMPARE(model.data(model.index(2, 0), ThreadListModel::SendersRole).toString(), QStringLiteral("You")); } void TestThreadListModel::aRowLeavesTheViewWhenItLosesTheViewsTag() { ThreadListModel model; ThreadSummary a = makeThread(QStringLiteral("t1"), QStringLiteral("Keep")); a.firstMessageId = QStringLiteral("m1"); ThreadSummary b = makeThread(QStringLiteral("t2"), QStringLiteral("Drop")); b.firstMessageId = QStringLiteral("m2"); ThreadSummary c = makeThread(QStringLiteral("t3"), QStringLiteral("Keep2")); c.firstMessageId = QStringLiteral("m3"); model.appendBatch({ a, b, c }); QCOMPARE(model.rowCount(), 3); // The middle row loses `inbox`, as Delete strips it. Middle deliberately: // a removal at either end can be right by accident while the index // arithmetic is wrong. model.applyMessageTagChange(QStringLiteral("m2"), {}, { QStringLiteral("inbox") }); model.removeThreadsWithoutTag(QStringLiteral("inbox")); QCOMPARE(model.rowCount(), 2); QCOMPARE(model.index(0, 0, QModelIndex()) .data(ThreadListModel::SubjectRole).toString(), QStringLiteral("Keep")); QCOMPARE(model.index(1, 0, QModelIndex()) .data(ThreadListModel::SubjectRole).toString(), QStringLiteral("Keep2")); } void TestThreadListModel::rowsLosingTheTagAreRemovedInOneContiguousRun() { ThreadListModel model; QList batch; for (int i = 1; i <= 5; ++i) { ThreadSummary t = makeThread(QStringLiteral("t%1").arg(i), QStringLiteral("S%1").arg(i)); t.firstMessageId = QStringLiteral("m%1").arg(i); batch.append(t); } model.appendBatch(batch); // Three adjacent rows go at once, which is the case a backwards walk in // runs handles and a naive forward loop gets wrong by renumbering. for (const QString &id : { QStringLiteral("m2"), QStringLiteral("m3"), QStringLiteral("m4") }) { model.applyMessageTagChange(id, {}, { QStringLiteral("inbox") }); } model.removeThreadsWithoutTag(QStringLiteral("inbox")); QCOMPARE(model.rowCount(), 2); QCOMPARE(model.index(0, 0, QModelIndex()) .data(ThreadListModel::SubjectRole).toString(), QStringLiteral("S1")); QCOMPARE(model.index(1, 0, QModelIndex()) .data(ThreadListModel::SubjectRole).toString(), QStringLiteral("S5")); } void TestThreadListModel::theTrashViewDrawsNoDoomedFill() { ThreadListModel model; ThreadSummary deleted = makeThread(QStringLiteral("t1"), QStringLiteral("Thrown away")); deleted.tags = QStringList{ QStringLiteral("deleted") }; ThreadSummary spam = makeThread(QStringLiteral("t2"), QStringLiteral("Junk")); spam.tags = QStringList{ QStringLiteral("deleted"), QStringLiteral("spam") }; model.appendBatch({ deleted, spam }); const QModelIndex first = model.index(0, 0, QModelIndex()); const QModelIndex second = model.index(1, 0, QModelIndex()); // Outside the trash both are filled, which is the guard proving the // assertion below can fail. QVERIFY(first.data(Qt::BackgroundRole).isValid()); QVERIFY(second.data(Qt::BackgroundRole).isValid()); model.setTrashView(true); // A plainly deleted row loses the fill AND the white text that only reads // against it; the strike-out is what still says deleted, and is asserted // by the font role rather than by colour. QVERIFY(!first.data(Qt::BackgroundRole).isValid()); QVERIFY(first.data(Qt::FontRole).value().strikeOut()); // A spam row keeps its tint: the trash promises "thrown away", not // "harmless". QVERIFY(second.data(Qt::BackgroundRole).isValid()); // And the flag does not stick: leaving the trash restores the fill, which // is the leak the setter's comment warns about. model.setTrashView(false); QVERIFY(first.data(Qt::BackgroundRole).isValid()); } void TestThreadListModel::aSummaryWithOneMessageIsAMessageRow() { ThreadListModel model; ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone")); one.totalCount = 1; one.firstMessageId = QStringLiteral("m1"); model.appendBatch({ one }); const QModelIndex row = model.index(0, 0, QModelIndex()); QVERIFY2(!model.isConversationRow(row), "a thread of one message is not a conversation: it has no replies " "to stand for, and must open its message on one click"); } void TestThreadListModel::aSummaryWithRepliesIsAConversationRow() { ThreadListModel model; ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk")); many.totalCount = 4; many.firstMessageId = QStringLiteral("m1"); model.appendBatch({ many }); const QModelIndex row = model.index(0, 0, QModelIndex()); QVERIFY(model.isConversationRow(row)); } void TestThreadListModel::aLoadedThreadTrustsItsChildrenOverItsCount() { // notmuch's totalCount counts duplicates, so a "thread of 2" can load with // no replies at all. Once loaded the children are the truth, exactly as // hasChildren() already decides. ThreadListModel model; ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Dupe")); many.totalCount = 2; many.firstMessageId = QStringLiteral("m1"); model.appendBatch({ many }); MessageNode root; root.messageId = QStringLiteral("m1"); root.threadId = QStringLiteral("t1"); root.depth = 0; model.setThreadMessages(QStringLiteral("t1"), { root }); const QModelIndex row = model.index(0, 0, QModelIndex()); QVERIFY2(!model.isConversationRow(row), "a thread whose count came from duplicates still claims to be a " "conversation after loading no replies at all"); } void TestThreadListModel::aConversationListsItsFirstMessageAsAChild() { // The user's report: "I can't reach the first message in a thread. All // messages were supposed to sit below the new thread row. They start // from #2." // // setThreadMessages() dropped nodes.first() until item 177, and that was // correct while a thread row MEANT its first message: listing the message // under itself would have shown it twice. Item 177 made the row stand for // the conversation and render a dashboard instead of any one message, so // the drop left the first message with no row anywhere in the interface. // Unreachable, not merely unlabelled: expanding was the only route to it. ThreadListModel model; ThreadSummary conversation = makeThread(QStringLiteral("t1"), QStringLiteral("A subject")); conversation.totalCount = 3; conversation.firstMessageId = QStringLiteral("m0@example.org"); model.appendBatch({ conversation }); model.setThreadMessages( QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0), makeNode(QStringLiteral("m1@example.org"), 1), makeNode(QStringLiteral("m2@example.org"), 2) }); const QModelIndex root = model.index(0, 0, QModelIndex()); QVERIFY2(model.isConversationRow(root), "the fixture is not a conversation, so this test says nothing " "about the case the defect lives in"); // Three messages, three rows. Counting is not enough on its own: a row // could be added anywhere, so the first child is named explicitly. QCOMPARE(model.rowCount(root), 3); QCOMPARE(model.data(model.index(0, 0, root), ThreadListModel::MessageIdRole).toString(), QStringLiteral("m0@example.org")); QCOMPARE(model.data(model.index(2, 0, root), ThreadListModel::MessageIdRole).toString(), QStringLiteral("m2@example.org")); // And it is reachable as a MESSAGE row, which is what makes it selectable // and actionable rather than merely drawn. const QModelIndex first = model.index(0, 0, root); QVERIFY(model.isMessageRow(first)); QCOMPARE(model.messageAt(first).messageId, QStringLiteral("m0@example.org")); } void TestThreadListModel::aMessageRowDoesNotListItselfBeneathItself() { // The other half, and the reason the drop existed. A thread of one is // still a MESSAGE row under item 177: its card renders that message, so a // child carrying the same message would show it twice, once as the card // and once beneath it. // // Decided on the messages that ARRIVED rather than on summary.totalCount, // which counts duplicates: a "thread of 2" that loads one message must // take this branch, or it gains a child and then claims through // isConversationRow() to be a conversation it cannot open. ThreadListModel model; ThreadSummary lone = makeThread(QStringLiteral("t1"), QStringLiteral("Alone")); lone.totalCount = 2; // notmuch counted a duplicate; one message arrives. lone.firstMessageId = QStringLiteral("m0@example.org"); model.appendBatch({ lone }); model.setThreadMessages(QStringLiteral("t1"), { makeNode(QStringLiteral("m0@example.org"), 0) }); const QModelIndex root = model.index(0, 0, QModelIndex()); QCOMPARE(model.rowCount(root), 0); QVERIFY2(!model.isConversationRow(root), "a thread that loaded one message claims to be a conversation, so " "it offers an expander onto nothing"); } void TestThreadListModel::aMessageRowIsNeverAConversationRow() { ThreadListModel model; ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk")); many.totalCount = 2; many.firstMessageId = QStringLiteral("m1"); model.appendBatch({ many }); MessageNode root; root.messageId = QStringLiteral("m1"); root.threadId = QStringLiteral("t1"); root.depth = 0; MessageNode reply; reply.messageId = QStringLiteral("m2"); reply.threadId = QStringLiteral("t1"); reply.depth = 1; model.setThreadMessages(QStringLiteral("t1"), { root, reply }); const QModelIndex thread = model.index(0, 0, QModelIndex()); const QModelIndex replyRow = model.index(1, 0, thread); QVERIFY(model.isMessageRow(replyRow)); QVERIFY2(!model.isConversationRow(replyRow), "a reply row answered yes, so an action on it would scope to the " "whole conversation"); } void TestThreadListModel::aConversationRowResolvesToItsThread() { ThreadListModel model; ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone")); one.totalCount = 1; one.firstMessageId = QStringLiteral("m1"); // SECOND, so a wrong answer is visible rather than accidentally right. ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk")); many.totalCount = 4; many.firstMessageId = QStringLiteral("m2"); model.appendBatch({ one, many }); const ActionScope scope = model.scopeForSelection({ model.index(1, 0, QModelIndex()) }); QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t2") }); QVERIFY2(scope.messageIds.isEmpty(), "a conversation row named a message, so an action on it would " "touch one message of the thread it claims to act on"); QVERIFY(scope.wholeThread); // totalCount, not the loaded children: nothing is expanded here, and a // status bar counting only what happens to be on screen would understate // what the action is about to do. QCOMPARE(model.rowCount(model.index(1, 0, QModelIndex())), 0); QCOMPARE(scope.messageCount, 4); } void TestThreadListModel::aRowNamingNoMessageIsSkippedNotEscalated() { // firstMessageId comes from the query. A summary that arrived without one // names no message, and the tempting fallback is to act on the whole // thread instead. That is a silent escalation: the user asked to act on // one message and would hit the conversation. // // A thread of ONE, deliberately. A conversation row resolves to its thread // and needs no message id at all, so the escalation can only be reached // where the row really does stand for one message. ThreadListModel model; ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("Alone")); t.totalCount = 1; t.firstMessageId.clear(); model.appendBatch({ t }); const QModelIndex root = model.index(0, 0, QModelIndex()); QVERIFY(!model.isConversationRow(root)); const ActionScope scope = model.scopeForSelection({ root }); QVERIFY2(scope.isEmpty(), "a row with no message id was escalated to its whole thread " "rather than skipped"); QCOMPARE(scope.messageCount, 0); } void TestThreadListModel::aLoneMessageRowResolvesToItsMessage() { ThreadListModel model; ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk")); many.totalCount = 4; many.firstMessageId = QStringLiteral("m1"); ThreadSummary one = makeThread(QStringLiteral("t2"), QStringLiteral("Alone")); one.totalCount = 1; one.firstMessageId = QStringLiteral("m2"); model.appendBatch({ many, one }); const ActionScope scope = model.scopeForSelection({ model.index(1, 0, QModelIndex()) }); QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m2") }); QVERIFY(scope.threadIds.isEmpty()); QVERIFY(!scope.wholeThread); } void TestThreadListModel::aReplyRowResolvesToItsMessage() { ThreadListModel model; ThreadSummary first = makeThread(QStringLiteral("t1"), QStringLiteral("One")); first.totalCount = 1; first.firstMessageId = QStringLiteral("m0"); ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk")); many.totalCount = 2; many.firstMessageId = QStringLiteral("m1"); model.appendBatch({ first, many }); MessageNode root; root.messageId = QStringLiteral("m1"); root.threadId = QStringLiteral("t2"); root.depth = 0; MessageNode reply; reply.messageId = QStringLiteral("m2"); reply.threadId = QStringLiteral("t2"); reply.depth = 1; model.setThreadMessages(QStringLiteral("t2"), { root, reply }); const QModelIndex thread = model.index(1, 0, QModelIndex()); // Child 1, not child 0: since item 177 a conversation lists its first // message too, so child 0 is m1 and the REPLY this test is about is m2. const ActionScope scope = model.scopeForSelection({ model.index(1, 0, thread) }); QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m2") }); QVERIFY(scope.threadIds.isEmpty()); } void TestThreadListModel::aMixedSelectionCarriesBothScopes() { ThreadListModel model; ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone")); one.totalCount = 1; one.firstMessageId = QStringLiteral("m1"); ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk")); many.totalCount = 4; many.firstMessageId = QStringLiteral("m2"); model.appendBatch({ one, many }); const ActionScope scope = model.scopeForSelection({ model.index(0, 0, QModelIndex()), model.index(1, 0, QModelIndex()) }); QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m1") }); QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t2") }); } QTEST_MAIN(TestThreadListModel) #include "test_threadlistmodel.moc"