summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp81
-rw-r--r--tests/test_notmuchworker.cpp72
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;