diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_mainwindow.cpp | 81 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 72 |
2 files changed, 153 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 2e7d220..e2254f1 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -391,6 +391,7 @@ private slots: void markCurrentThreadReadResolvesTheThreadThroughTheIndex(); void deletingAReplyRepaintsThatReplyRow(); void deleteIsHiddenOnMailAlreadyInTheTrash(); + void aPartlyTrashedConversationIsNotJudgedOnOneMessage(); void restoreIsHiddenOnMailThatWasNeverDeleted(); void deleteAlsoMarksTheMessageRead(); void emptyTrashAsksBeforeDestroyingAnything(); @@ -5243,6 +5244,86 @@ static ThreadSummary threadAtPath(const QString &id, const QString &filePath, return thread; } +/// Item 178. A CONVERSATION is in the trash only when ALL of its messages are. +/// +/// The predicate read ThreadSummary::firstMessagePath for any row that is not +/// a message row, which was right while a thread row MEANT that message (item +/// 108) and stopped being right when item 177 made it mean the conversation. +/// So a partly trashed thread answered on whichever message the query returned +/// first: Delete hidden on a conversation that still has mail outside the +/// trash, Restore offered on one that mostly does not. +/// +/// qtmaildir cannot itself produce such a thread, since Delete is hidden on a +/// reply row and Restore is thread-scoped. Two things outside it can: another +/// client trashing one message (the user runs Thunderbird, item 104), and a +/// reply arriving after the conversation was trashed. +/// +/// The two messages are in DIFFERENT folders deliberately. Two in the same +/// folder answer identically whichever way the code resolves them, which is +/// the trap AGENTS.md records for item 87. +void TestMainWindow::aPartlyTrashedConversationIsNotJudgedOnOneMessage() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const Config config = configWithTrash(dir); + MainWindow window(config); + + auto *model = window.findChild<ThreadListModel *>(); + QVERIFY(model); + auto *view = window.findChild<QTreeView *>(); + QVERIFY(view); + auto *deleteAction = + window.findChild<QAction *>(QStringLiteral("delete")); + QVERIFY(deleteAction); + auto *restoreAction = + window.findChild<QAction *>(QStringLiteral("restore")); + QVERIFY(restoreAction); + + // totalCount is what item 177 reads to decide a row is a conversation. A + // summary left at the default is a MESSAGE row, so a test meaning to + // exercise a conversation would quietly exercise the other branch and pass + // for the wrong reason. + ThreadSummary partly = threadAtPath(QStringLiteral("t1"), + QStringLiteral("acct/trash/cur/1:2,S")); + partly.totalCount = 2; + model->appendBatch({ partly }); + + const QModelIndex row = model->index(0, 0, {}); + QVERIFY2(model->isConversationRow(row), + "the fixture is a message row, so this test cannot see item 178 " + "at all: set totalCount"); + + view->setCurrentIndex(row); + + // The paths the worker reports for this conversation: the displayed + // message is in the trash, the other is not. Delete must survive and + // Restore must not be offered, because the conversation is NOT wholly + // trashed however its first message looks. + window.setConversationPathsForTesting( + QStringLiteral("t1"), + { QStringLiteral("acct/trash/cur/1:2,S"), + QStringLiteral("acct/inbox/cur/2:2,S") }); + + QVERIFY2(deleteAction->isVisible(), + "Delete was hidden on a conversation with mail outside the " + "trash: it judged the thread on its first message"); + QVERIFY2(!restoreAction->isVisible(), + "Restore was offered on a conversation that is only partly " + "trashed"); + + // And the whole-conversation case still answers as it always did, which is + // what says the fix narrowed nothing. + window.setConversationPathsForTesting( + QStringLiteral("t1"), + { QStringLiteral("acct/trash/cur/1:2,S"), + QStringLiteral("acct/trash/cur/2:2,S") }); + + QVERIFY2(!deleteAction->isVisible(), + "Delete is still offered on a wholly trashed conversation"); + QVERIFY2(restoreAction->isVisible(), + "Restore vanished on a wholly trashed conversation"); +} + void TestMainWindow::deleteIsHiddenOnMailAlreadyInTheTrash() { // Item 168, from the user: "I noticed I can hit delete via context menu on diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index 1766589..7148f0c 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -123,6 +123,7 @@ private slots: void aDigestCountsSendersAndUnread(); void aDigestCapsItsUnreadListButNotItsCount(); + void aDigestCarriesEveryMessagePath(); void aOneMessageThreadGivesASaneSpan(); private: @@ -2242,6 +2243,77 @@ void TestNotmuchWorker::aDigestCountsSendersAndUnread() QVERIFY(digest.firstTimestamp <= digest.lastTimestamp); } +/// Item 178. Delete and Restore judge a CONVERSATION, and the summary carries +/// one message's path, so a partly-trashed thread answered on whichever +/// message the query returned first. +/// +/// The paths are collected by the walk the digest already makes over every +/// message, so this costs no extra query and no extra file read: a filename +/// comes from the INDEX, like everything else in ThreadDigest. +/// +/// The fixture puts the two messages in DIFFERENT folders deliberately. Two +/// messages in one folder answer identically whichever way the code resolves +/// them, which is the trap AGENTS.md records for item 87's opposite states. +void TestNotmuchWorker::aDigestCarriesEveryMessagePath() +{ + NotmuchFixture fixture; + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("p0@example.org"), + QStringLiteral("Split thread"), + QStringLiteral("alice@example.org"), + QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"), + QStringLiteral("Root."), false)); + QVERIFY(fixture.addMessage(QStringLiteral("Trash"), + QStringLiteral("p1@example.org"), + QStringLiteral("Re: Split thread"), + QStringLiteral("bob@example.org"), + QStringLiteral("Tue, 25 Aug 2026 10:00:00 +0200"), + QStringLiteral("Trashed reply."), false, + QStringLiteral("p0@example.org"))); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded); + + const QString threadId = worker.threadIdForTesting( + QStringLiteral("id:p0@example.org")); + QVERIFY(!threadId.isEmpty()); + worker.loadThreadDigest(threadId, 1); + + QCOMPARE(spy.count(), 1); + const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>(); + + QCOMPARE(digest.totalCount, 2); + QCOMPARE(digest.messagePaths.size(), 2); + + // RELATIVE to the mail root, for the reason ThreadSummary::firstMessagePath + // records: the UI knows an account only by its `maildir`, itself a + // database-relative prefix, so an absolute path here matches no account and + // silently resolves every row to none. + for (const QString &path : digest.messagePaths) { + QVERIFY2(!path.startsWith(QLatin1Char('/')), + qPrintable(QStringLiteral("absolute path: %1").arg(path))); + } + + // One in each folder, which is what makes a partly-trashed conversation + // answerable at all. + int inInbox = 0; + int inTrash = 0; + for (const QString &path : digest.messagePaths) { + // The mail root IS the fixture's maildir, so a relative path begins + // with the folder name and carries no leading separator. + if (path.startsWith(QStringLiteral("inbox/"))) + ++inInbox; + if (path.startsWith(QStringLiteral("Trash/"))) + ++inTrash; + } + QVERIFY2(inInbox + inTrash == 2, + qPrintable(QStringLiteral("unexpected paths: %1") + .arg(digest.messagePaths.join(QStringLiteral(", "))))); + QCOMPARE(inInbox, 1); + QCOMPARE(inTrash, 1); +} + void TestNotmuchWorker::aDigestCapsItsUnreadListButNotItsCount() { NotmuchFixture fixture; |
