diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_config.cpp | 119 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 1270 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 180 | ||||
| -rw-r--r-- | tests/test_tagdialog.cpp | 4 |
4 files changed, 1468 insertions, 105 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp index e69a073..70e3705 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -120,11 +120,17 @@ private slots: void anAccountCarriesItsTrashFolder(); void aBracketedTrashFolderIsQuoted(); void anAccountWithoutATrashFolderWarns(); + void anAccountCarriesItsSpamFolder(); + void aBracketedSpamFolderIsQuoted(); + void anAccountWithoutASpamFolderWarns(); + void allSpamQueryJoinsAndSkips(); void theDraftsFilterComposesPerAccount(); void theDraftsFilterMatchesNothingWithoutAFolder(); void theDraftsFilterIsFlatLikeSent(); void theTrashFilterComposesPerAccount(); void theTrashFilterMatchesNothingWithoutAFolder(); + void theSpamFilterComposesPerAccount(); + void theSpamFilterMatchesNothingWithoutAFolder(); void anAccountWithoutASendCommandIsReceiveOnly(); void composeSettingsDefaultWhenTheSectionIsAbsent(); void aZeroSendDelayIsHonouredRatherThanTreatedAsUnset(); @@ -451,7 +457,8 @@ void TestConfig::absentSyncCommandIsNoticeNotProblem() const QString path = writeIni(dir, QStringLiteral( "[account.work]\n" "maildir=work-mail\n" - "trash=Trash\n")); + "trash=Trash\n" + "spam=Spam\n")); Config config; config.load(path); @@ -472,7 +479,8 @@ void TestConfig::brokenSyncCommandIsAProblem() "\n" "[account.work]\n" "maildir=work-mail\n" - "trash=Trash\n")); + "trash=Trash\n" + "spam=Spam\n")); Config config; config.load(path); @@ -508,7 +516,8 @@ void TestConfig::validConfigHasNoProblems() "[account.work]\n" "maildir=work-mail\n" "address=user@example.org\n" - "trash=Trash\n")); + "trash=Trash\n" + "spam=Spam\n")); Config config; config.load(path); @@ -929,7 +938,8 @@ void TestConfig::sentQueryIsEmptyWithoutTheKey() config.load(writeIni(dir, QStringLiteral( "[account.provider-c]\n" "maildir = provider-c\n" - "trash = Trash\n"))); + "trash = Trash\n" + "spam = Spam\n"))); QCOMPARE(config.accounts().size(), 1); QVERIFY(config.accounts().at(0).sentQuery().isEmpty()); @@ -1028,6 +1038,52 @@ void TestConfig::anAccountWithoutATrashFolderWarns() QVERIFY(joined.contains(QStringLiteral("trash"))); } +void TestConfig::anAccountCarriesItsSpamFolder() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\nmaildir=work\nspam=Spam\n"))); + const Account account = config.account(QStringLiteral("work")); + QCOMPARE(account.spam, QStringLiteral("Spam")); + QCOMPARE(account.spamQuery(), QStringLiteral("path:\"work/Spam/**\"")); +} + +void TestConfig::aBracketedSpamFolderIsQuoted() +{ + Account account; + account.maildir = QStringLiteral("provider-a"); + account.spam = QStringLiteral("[Provider]/Spam"); + QCOMPARE(account.spamQuery(), + QStringLiteral("path:\"provider-a/[Provider]/Spam/**\"")); +} + +void TestConfig::anAccountWithoutASpamFolderWarns() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\nmaildir=work\n"))); + QVERIFY(config.account(QStringLiteral("work")).isValid()); + const QString joined = config.warnings().join(QLatin1Char('\n')); + QVERIFY(joined.contains(QStringLiteral("work"))); + QVERIFY(joined.contains(QStringLiteral("spam"))); +} + +void TestConfig::allSpamQueryJoinsAndSkips() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\nmaildir=work\nspam=Spam\n" + "\n[account.personal]\nmaildir=personal\nspam=[Provider]/Spam\n" + "\n[account.none]\nmaildir=none\n"))); + const QString all = config.allSpamQuery(); + QVERIFY(all.contains(QStringLiteral("path:\"work/Spam/**\""))); + QVERIFY(all.contains(QStringLiteral("path:\"personal/[Provider]/Spam/**\""))); + QVERIFY(!all.contains(QStringLiteral("none"))); +} + void TestConfig::theDraftsFilterComposesPerAccount() { // Item 138. Follows `sent` and `trash`, which match a FOLDER: `draft` is a @@ -1144,6 +1200,34 @@ void TestConfig::theTrashFilterMatchesNothingWithoutAFolder() QCOMPARE(config.resolvedQuery(trash, QString()), Config::matchNothingQuery()); } +void TestConfig::theSpamFilterComposesPerAccount() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\nmaildir=work\nspam=Spam\n" + "\n[account.personal]\nmaildir=personal\nspam=[Provider]/Spam\n"))); + const SavedQuery spam = Config::builtinFilter(QStringLiteral("spam")); + QVERIFY(spam.isGenerated()); + QVERIFY2(!spam.flat, "spam must be threaded, like trash"); + const QString all = config.resolvedQuery(spam, QString()); + QVERIFY(all.contains(QStringLiteral("path:\"work/Spam/**\""))); + QVERIFY(all.contains(QStringLiteral("path:\"personal/[Provider]/Spam/**\""))); + const QString scoped = config.resolvedQuery(spam, QStringLiteral("work")); + QCOMPARE(scoped, QStringLiteral("path:\"work/Spam/**\"")); + QVERIFY(!scoped.contains(QStringLiteral("personal"))); +} + +void TestConfig::theSpamFilterMatchesNothingWithoutAFolder() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\nmaildir=work\n"))); + const SavedQuery spam = Config::builtinFilter(QStringLiteral("spam")); + QCOMPARE(config.resolvedQuery(spam, QString()), Config::matchNothingQuery()); +} + void TestConfig::sentQueryComposesWithScopedQuery() { // A Sent view under one account must not show another account's sent mail. @@ -1265,10 +1349,12 @@ void TestConfig::theStartupAccountIsReadAndValidated() "[account.work]\n" "maildir=work\n" "trash=Trash\n" + "spam=Spam\n" "\n" "[account.personal]\n" "maildir=personal\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QCOMPARE(config.startupAccount(), QStringLiteral("work")); QVERIFY(config.problems().isEmpty()); @@ -1294,7 +1380,8 @@ void TestConfig::theStartupAccountIsReadAndValidated() "\n" "[account.work]\n" "maildir=work\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QVERIFY2(wrong.startupAccount().isEmpty(), "an unknown startup account was passed through rather than " "falling back to All accounts"); @@ -1320,7 +1407,8 @@ void TestConfig::theStartupAccountTakesTheKeyNotTheSyncChannel() "[account.provider-work.mailbox]\n" "maildir=provider-work.mailbox\n" "channel=provider-workmailbox\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QCOMPARE(config.accounts().size(), 1); QCOMPARE(config.accounts().constFirst().key, @@ -1343,7 +1431,8 @@ void TestConfig::theStartupAccountTakesTheKeyNotTheSyncChannel() "[account.provider-work.mailbox]\n" "maildir=provider-work.mailbox\n" "channel=provider-workmailbox\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QVERIFY2(byChannel.startupAccount().isEmpty(), "the sync channel was accepted as an account key"); @@ -1428,7 +1517,8 @@ void TestConfig::theStartupQuerySurvivesATranslatedFilterName() "[account.work]\n" "maildir=work\n" "sent=Sent\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QVERIFY2(!config.savedQueries().isEmpty(), "queries.json did not load, so the warning path is unreachable"); @@ -1454,7 +1544,8 @@ void TestConfig::theStartupQuerySurvivesATranslatedFilterName() "[account.work]\n" "maildir=work\n" "sent=Sent\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QVERIFY(!byLabel.savedQueries().isEmpty()); QCOMPARE(byLabel.startupSavedQuery().generated, QStringLiteral("inbox")); QVERIFY(byLabel.problems().isEmpty()); @@ -1567,7 +1658,7 @@ void TestConfig::everyBuiltinFilterIsAKnownGenerator() Config config; const QList<SavedQuery> filters = config.builtinFilters(); - QCOMPARE(filters.size(), 6); + QCOMPARE(filters.size(), 7); QStringList names; for (const SavedQuery &filter : filters) { @@ -1590,7 +1681,8 @@ void TestConfig::everyBuiltinFilterIsAKnownGenerator() QStringLiteral("Important"), QStringLiteral("Sent"), QStringLiteral("Drafts"), - QStringLiteral("Trash") })); + QStringLiteral("Trash"), + QStringLiteral("Spam") })); } void TestConfig::aFilterAcrossAllAccountsIsTheUnscopedQuery() @@ -1684,7 +1776,8 @@ void TestConfig::draftsQueryIsEmptyWithoutTheKey() config.load(writeIni(dir, QStringLiteral( "[account.provider-c]\n" "maildir = provider-c\n" - "trash = Trash\n"))); + "trash = Trash\n" + "spam = Spam\n"))); QCOMPARE(config.accounts().size(), 1); QVERIFY(config.accounts().at(0).draftsQuery().isEmpty()); diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index b7f7846..e87843d 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -130,6 +130,11 @@ public: /// account without one sends and files nothing, which is a real /// configuration rather than an error. QString sent; + /// Where spam is filed. Written only when non-empty, like trash: an + /// account without one has no Spam filter and no Empty Spam, and a + /// test that needs the per-account grouping has to say where each + /// account's spam folder is. + QString spam; }; /// Writes several accounts, for the compose cases. @@ -148,7 +153,8 @@ public: bool build(const QString &accountKey = QString(), const QString &accountMaildir = QString(), - const QString &accountTrash = QString()) + const QString &accountTrash = QString(), + const QString &accountSpam = QString()) { if (!m_fixture.isValid()) { m_error = QStringLiteral("fixture directory invalid"); @@ -186,6 +192,11 @@ public: << "maildir=" << accountMaildir << "\n"; if (!accountTrash.isEmpty()) out << "trash=" << accountTrash << "\n"; + // Mark spam moves a file into this folder, exactly as Delete + // does into the trash, so a spam test needs it declared for + // the same reason a delete test needs `trash`. + if (!accountSpam.isEmpty()) + out << "spam=" << accountSpam << "\n"; // The fixture's folders are lowercase, unlike the Maildir // convention Account::inboxFolder() defaults to. Stated rather // than assumed, which is the whole point of the key: naming a @@ -210,6 +221,8 @@ public: out << "drafts=" << account.drafts << "\n"; if (!account.sent.isEmpty()) out << "sent=" << account.sent << "\n"; + if (!account.spam.isEmpty()) + out << "spam=" << account.spam << "\n"; } } file.close(); @@ -394,7 +407,7 @@ private slots: void importantOnAReplyReadsItsOwnStateNotItsThreads(); void editTagsOnAReplyCountsItsOwnThreadNotTheFirstInTheList(); void markCurrentThreadReadResolvesTheThreadThroughTheIndex(); - void deletingAReplyRepaintsThatReplyRow(); + void taggingAReplyRepaintsThatReplyRow(); void deleteIsHiddenOnMailAlreadyInTheTrash(); void aPartlyTrashedConversationIsNotJudgedOnOneMessage(); void restoreIsHiddenOnMailThatWasNeverDeleted(); @@ -459,6 +472,7 @@ private slots: void placeholderCountsDropAnUncountableQuery(); void flatModeDoesNotSurviveTheNextQuery(); void noTwoActionsShareAnIcon(); + void theSpamActionCarriesTheBugIconWithAFallback(); void theMessagePaneCarriesItsOwnActionBar(); void theMainToolbarKeepsOnlyListWideActions(); void theMessageBarSitsAboveTheBodyAndBelowTheHeader(); @@ -509,6 +523,33 @@ private slots: void theRefreshAfterARestoreLeavesUndoIntact(); void deletingOutsideTheTrashViewLeavesTheRowInPlace(); + // Mark spam is Delete's sibling: it moves the file into the account's spam + // folder rather than only tagging it. + void spamMovesTheMessageToTheSpamFolder(); + void undoOfMarkingSpamReturnsTheFileAndDropsBothTags(); + void aMessageInTheSpamFolderIsNotInTheTrash(); + void emptySpamMovesEachAccountsMailToItsOwnTrash(); + void emptySpamRewritesTheOriginToTheSpamFolder(); + void emptySpamRefusesAnUnconfiguredFolder(); + void theSpamCleanupQueryExcludesTheSpamFolder(); + void theSpamCleanupQueryWithoutASpamFolderIsJustTheTag(); + + // Item 187's final review. Mark spam is Delete's sibling, so it is hidden + // exactly where Delete is, and the model keeps one origin per message. + void spamIsAbsentOnAReplyRow(); + void spamIsHiddenOnMailAlreadyInTheTrash(); + void restoringAfterTwoMovesReturnsToTheLatestOrigin(); + + // Item 201: a message in the spam folder could not be un-spammed. Not spam + // is Mark spam's inverse, and Restore is its template. + void notSpamReturnsAMessageToItsOriginFolder(); + void notSpamFallsBackToInboxWithoutAnOriginTag(); + void notSpamIsOfferedInTheSpamView(); + void notSpamIsAbsentOnAReplyRow(); + void notSpamIsHiddenOutsideTheSpamFolder(); + void notSpamThreadMovesEveryMessageHome(); + void undoOfNotSpamReturnsTheFileToTheSpamFolder(); + // ComposeWindow, item 123. These need a window but no worker: the composer // never touches NotmuchWorker, it reads its context from the value struct // MainWindow hands it, so a Config written to a temporary INI is the whole @@ -5227,13 +5268,15 @@ void TestMainWindow::markCurrentThreadReadResolvesTheThreadThroughTheIndex() QStringLiteral("t2")); } -void TestMainWindow::deletingAReplyRepaintsThatReplyRow() +void TestMainWindow::taggingAReplyRepaintsThatReplyRow() { - // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs - // an account with a configured trash folder and a worker to do the move; - // this bare window has neither, and Delete correctly refuses. What is - // under test here is unchanged by that: `spam` is the other message-scoped - // tag-only action, and it paints the same doomed state. + // `flag`, not `delete` and not `spam`. Since item 103 Delete MOVES the + // file, and `spam` now does too, neither needs only a tag write; this bare + // window has no worker and no configured folders, and both correctly + // refuse. `flag` is the remaining message-scoped tag-only action, and what + // is under test is unchanged by that: the action reaches + // applyMessageTagChange on the reply's OWN row. + // // The user's report, at the gesture level: "I'm hitting delete on a reply // to a thread, I see the edits counter increasing but I have no feedback // if that message is being deleted." The model-level test proves @@ -5246,7 +5289,7 @@ void TestMainWindow::deletingAReplyRepaintsThatReplyRow() QVERIFY(model); auto *view = window.findChild<QTreeView *>(); QVERIFY(view); - auto *action = window.findChild<QAction *>(QStringLiteral("spam")); + auto *action = window.findChild<QAction *>(QStringLiteral("flag")); QVERIFY(action); const QModelIndex reply = @@ -5255,24 +5298,21 @@ void TestMainWindow::deletingAReplyRepaintsThatReplyRow() // Nothing to see before the gesture, so the assertion after it means // something. - QVERIFY(!model->messageAt(reply).isSpam()); - const QVariant before = model->data(reply, Qt::BackgroundRole); + QVERIFY(!model->messageAt(reply).isFlagged()); QSignalSpy spy(model, &QAbstractItemModel::dataChanged); action->trigger(); - QVERIFY2(model->messageAt(reply).isSpam(), - "Delete on a reply left the reply's own row unchanged, so the " + QVERIFY2(model->messageAt(reply).isFlagged(), + "the action on a reply left the reply's own row unchanged, so the " "pending count moved and the user saw nothing"); QVERIFY2(spy.count() >= 1, "no repaint was requested for the reply's row"); - QVERIFY2(model->data(reply, Qt::BackgroundRole) != before, - "the deleted reply paints exactly as it did before"); // The THREAD row must not follow: it stands for the whole conversation, - // and one deleted reply does not doom it. + // and one changed reply does not change the conversation. const QModelIndex threadRow = reply.parent(); - QVERIFY2(!model->threadFor(threadRow).isSpam(), - "deleting one reply marked its whole thread deleted"); + QVERIFY2(!model->threadFor(threadRow).isFlagged(), + "changing one reply marked its whole thread flagged"); } /// A window whose one account owns `acct/`, with its trash at `acct/trash`. @@ -5289,6 +5329,7 @@ static Config configWithTrash(QTemporaryDir &dir) out << "[account.acct]\n" << "maildir = acct\n" << "trash = trash\n" + << "spam = spam\n" << "inbox = inbox\n"; } Config config; @@ -5890,11 +5931,11 @@ void TestMainWindow::toggleUnreadOnAReplyRepaintsItInBothDirections() void TestMainWindow::taggingTheOpenReplyUpdatesTheMessagePaneStrip() { - // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs - // an account with a configured trash folder and a worker to do the move; - // this bare window has neither, and Delete correctly refuses. What is - // under test here is unchanged by that: `spam` is the other message-scoped - // tag-only action, and it paints the same doomed state. + // `flag`, not `delete` and not `spam`. Since item 103 Delete MOVES the + // file, and `spam` now does too, neither is a tag-only action; this bare + // window has no worker and no configured folders, and both correctly + // refuse. `flag` is the remaining message-scoped tag-only action, and it + // exercises the same sendMessageTagChange() strip refresh. // The user's report: "the right pane chips are not [repainted], for it to // sync I have to change message and go back to the edited one". // @@ -5921,7 +5962,7 @@ void TestMainWindow::taggingTheOpenReplyUpdatesTheMessagePaneStrip() const auto stripTags = [strip]() { return strip->visibleTags() + strip->hiddenTags(); }; - auto *action = window.findChild<QAction *>(QStringLiteral("spam")); + auto *action = window.findChild<QAction *>(QStringLiteral("flag")); QVERIFY(action); // A tag the strip will actually draw. Account tags are filtered out by the @@ -5936,11 +5977,11 @@ void TestMainWindow::taggingTheOpenReplyUpdatesTheMessagePaneStrip() QVERIFY2(stripTags().contains(QStringLiteral("todo")), "the strip does not show the selected reply's tags, so this test " "cannot tell a missing refresh from a strip that never had them"); - QVERIFY(!stripTags().contains(QStringLiteral("spam"))); + QVERIFY(!stripTags().contains(QStringLiteral("flagged"))); action->trigger(); - QVERIFY2(stripTags().contains(QStringLiteral("spam")), + QVERIFY2(stripTags().contains(QStringLiteral("flagged")), "the message pane's chips still describe the reply as it was " "before the edit; the user has to select away and back to see it"); } @@ -6021,11 +6062,11 @@ void TestMainWindow::taggingAnUnrelatedReplyLeavesTheStripAlone() void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds() { - // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs - // an account with a configured trash folder and a worker to do the move; - // this bare window has neither, and Delete correctly refuses. What is - // under test here is unchanged by that: `spam` is the other message-scoped - // tag-only action, and it paints the same doomed state. + // `flag`, not `delete` and not `spam`. Since item 103 Delete MOVES the + // file, and `spam` now does too, neither is a tag-only action; this bare + // window has no worker and no configured folders, and both correctly + // refuse. `flag` is the remaining message-scoped tag-only action, and the + // held-edit path it exercises is the same for every tag write. // Found by reading while fixing the strip refresh, not reported. // // flushHeldEdits() looped over edit.threadIds and called @@ -6041,7 +6082,7 @@ void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds() QVERIFY(model); auto *view = window.findChild<QTreeView *>(); QVERIFY(view); - auto *action = window.findChild<QAction *>(QStringLiteral("spam")); + auto *action = window.findChild<QAction *>(QStringLiteral("flag")); QVERIFY(action); const QModelIndex reply = @@ -6065,7 +6106,7 @@ void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds() "written"); // Sent for the MESSAGE, not escalated to its thread. Losing the scope on - // the way out of the hold would delete every message in the thread. + // the way out of the hold would tag every message in the thread. QVERIFY2(window.pendingMessageIdsForTesting().contains( QStringLiteral("m1@example.org")), "the held edit was not sent with its message scope"); @@ -6076,7 +6117,7 @@ void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds() // And the row still shows it: the flush takes the optimistic update back // before re-sending, so a bug there leaves the row wrong in the other // direction. - QVERIFY2(model->messageAt(reply).isSpam(), + QVERIFY2(model->messageAt(reply).isFlagged(), "sending the held edit lost the tag from the reply's row"); } @@ -6089,10 +6130,10 @@ void TestMainWindow::anActionOnAConversationRowTakesTheConversation() // with replies is now the conversation, and a row without them is still // its message. // - // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs - // an account with a configured trash folder and a worker to do the move; - // this bare window has neither. `spam` is the other tag-only action and - // resolves its scope through the same tagSelected(). + // `flag`, not `delete` and not `spam`. Since item 103 Delete MOVES the + // file, and `spam` now does too, neither is tag-only any more; this bare + // window has no worker and no configured folders. `flag` is the remaining + // tag-only action and resolves its scope through the same tagSelected(). const Config config; MainWindow window(config); @@ -6109,12 +6150,12 @@ void TestMainWindow::anActionOnAConversationRowTakesTheConversation() one.totalCount = 1; model->appendBatch({ many, one }); - auto *spam = window.findChild<QAction *>(QStringLiteral("spam")); - QVERIFY(spam); + auto *flag = window.findChild<QAction *>(QStringLiteral("flag")); + QVERIFY(flag); selectThreadRow(view, 0); QApplication::processEvents(); - spam->trigger(); + flag->trigger(); QCOMPARE(window.pendingThreadIdsForTesting(), QStringList{ QStringLiteral("t1") }); @@ -6126,7 +6167,7 @@ void TestMainWindow::anActionOnAConversationRowTakesTheConversation() // than a blanket escalation: a thread of one is still its message. selectThreadRow(view, 1); QApplication::processEvents(); - spam->trigger(); + flag->trigger(); QCOMPARE(window.pendingMessageIdsForTesting(), QStringList{ QStringLiteral("t2-first@example.org") }); @@ -6407,11 +6448,11 @@ void TestMainWindow::autoMarkReadArmsForAReplyToo() void TestMainWindow::taggingTheOpenRootMessageKeepsTheStripPopulated() { - // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs - // an account with a configured trash folder and a worker to do the move; - // this bare window has neither, and Delete correctly refuses. What is - // under test here is unchanged by that: `spam` is the other message-scoped - // tag-only action, and it paints the same doomed state. + // `flag`, not `delete` and not `spam`. Since item 103 Delete MOVES the + // file, and `spam` now does too, neither is a tag-only action; this bare + // window has no worker and no configured folders, and both correctly + // refuse. `flag` is the remaining message-scoped tag-only action and + // exercises the same strip refresh. // The user, 2026-08-16: "right pane loses the chip row when repainting, it // simply disappears". // @@ -6457,7 +6498,7 @@ void TestMainWindow::taggingTheOpenRootMessageKeepsTheStripPopulated() QVERIFY2(stripTags().contains(QStringLiteral("todo")), "the strip never showed the selected thread's tags"); - auto *action = window.findChild<QAction *>(QStringLiteral("spam")); + auto *action = window.findChild<QAction *>(QStringLiteral("flag")); QVERIFY(action); action->trigger(); @@ -6467,7 +6508,7 @@ void TestMainWindow::taggingTheOpenRootMessageKeepsTheStripPopulated() "and set the strip to the resulting empty tag list"); QVERIFY2(stripTags().contains(QStringLiteral("todo")), "the strip lost the tag the message still carries"); - QVERIFY2(stripTags().contains(QStringLiteral("spam")), + QVERIFY2(stripTags().contains(QStringLiteral("flagged")), "the strip did not pick up the tag just written"); } @@ -8471,7 +8512,8 @@ void TestMainWindow::theMessagePaneCarriesItsOwnActionBar() // this after seeing the first version, and the split is now by what the // action needs rather than by what it is about. const QStringList expected = { QStringLiteral("reply"), - QStringLiteral("forward") }; + QStringLiteral("forward"), + QStringLiteral("spam") }; for (const QString &name : expected) { auto *action = window.findChild<QAction *>(name); QVERIFY2(action, qPrintable(QStringLiteral("no action %1").arg(name))); @@ -8676,6 +8718,18 @@ void TestMainWindow::noTwoActionsShareAnIcon() // rather than silently passing it. static const QStringList menuOnlySharedIconActions = { QStringLiteral("reply_no_quote"), + // Empty Spam shares `purge`'s `user-trash`, Task 6. It is a + // Message-menu-only entry that always carries its text and never + // reaches the main toolbar, so the icon is not the whole control, + // exactly as for reply_no_quote above. The assertion below still + // fails if it is ever put on the toolbar, so this is not a hiding + // place. + QStringLiteral("empty_spam"), + // Find stranded spam shares `cleanup_stranded`'s `system-search`, + // Task 7. Same property again: a Message-menu-only entry that always + // carries its text and never reaches the main toolbar, allowed for the + // same reason and caught here if it is ever put on the toolbar. + QStringLiteral("cleanup_stranded_spam"), }; const Config config; @@ -8744,6 +8798,25 @@ void TestMainWindow::noTwoActionsShareAnIcon() .arg(collisions.join(QStringLiteral("; "))))); } +void TestMainWindow::theSpamActionCarriesTheBugIconWithAFallback() +{ + // Task 5. `bug` is the glyph the user asked for, and it is not a + // freedesktop name every theme carries, so the table pairs it with + // `mail-mark-junk` as a fallback: a theme without the bug still draws a + // junk icon rather than degrading the action to text alone. + const Config config; + MainWindow window(config); + + auto *spam = window.findChild<QAction *>(QStringLiteral("spam")); + QVERIFY2(spam, "no action named spam"); + QVERIFY2(!spam->icon().isNull(), "the spam action carries no icon"); + + const QPair<QString, QString> names = + MainWindow::iconNamesForTesting(QStringLiteral("spam")); + QCOMPARE(names.first, QStringLiteral("bug")); + QCOMPARE(names.second, QStringLiteral("mail-mark-junk")); +} + // Constructing a MainWindow needs a QApplication and a platform plugin. The // test has no display under ctest, so it runs offscreen unless the caller // asked for something else. @@ -11008,12 +11081,13 @@ void TestMainWindow::everyBuiltinFilterButtonCarriesAnIconAndItsText() }); // A trash key too, or the Trash filter finds nothing and is skipped from // the row entirely (item 103), leaving no trashButton for this loop to - // find. Drafts behaves the same way since item 138. + // find. Drafts and Spam behave the same way since item 138. { QSettings s(path, QSettings::IniFormat); s.beginGroup(QStringLiteral("account.work")); s.setValue(QStringLiteral("trash"), QStringLiteral("Trash")); s.setValue(QStringLiteral("drafts"), QStringLiteral("Drafts")); + s.setValue(QStringLiteral("spam"), QStringLiteral("Spam")); s.endGroup(); } Config config; @@ -11947,6 +12021,306 @@ void TestMainWindow::deleteMovesTheMessageToTrash() QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); } +void TestMainWindow::spamMovesTheMessageToTheSpamFolder() +{ + // Mark spam is Delete's sibling: it MOVES the file into the account's spam + // folder, tagging it `spam` and recording `moved-from:inbox`. Before this + // item it only added a tag, so spam mail sat in the inbox for good. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("spam1@example.org"), + QStringLiteral("Spam me"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + + const QString root = backed.fixture().maildirPath(); + const QString inbox = root + QStringLiteral("/acct/inbox/new"); + const QString stem = QStringLiteral("spam1.example.org"); + QVERIFY(folderHasMessageFile(inbox, stem)); + + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("spam"))->trigger(); + + // The filesystem half. cur/, never new/: a file in new/ is re-announced as + // fresh mail by every reader of the Maildir. + const QString spam = root + QStringLiteral("/acct/Spam/cur"); + QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(spam, stem), 15000); + QVERIFY2(!folderHasMessageFile(inbox, stem), + "the file is in the spam folder and still in the inbox"); + QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), + stem), + "the file is in the spam folder and still in the inbox"); + + // The tags land only once the worker confirms the move, so they are waited + // for separately. `unread` goes with it, exactly as Delete strips it: a + // decision about the message must not leave the unread count including it. + const QString cfg = backed.fixture().configPath(); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:spam1@example.org and tag:spam " + "and tag:\"moved-from:inbox\" and " + "not tag:unread")) == 1, + 15000); +} + +void TestMainWindow::undoOfMarkingSpamReturnsTheFileAndDropsBothTags() +{ + // Undo is this project's stand-in for a confirmation dialog, so a move + // into the spam folder that cannot be undone is one with no safety net. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("spamundo@example.org"), + QStringLiteral("Undo my spam"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + + const QString root = backed.fixture().maildirPath(); + const QString stem = QStringLiteral("spamundo.example.org"); + const QString spam = root + QStringLiteral("/acct/Spam/cur"); + + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("spam"))->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(spam, stem), 15000); + + // The command reaches the stack only once the worker CONFIRMS the move, and + // the file appearing is that move's rename, which lands a moment earlier. + // Undo before the push is a no-op, and the assertion below would then blame + // the move-back for a race in the test. + QTRY_VERIFY_WITH_TIMEOUT(window.undoDepthForTesting() >= 1, 15000); + + window.findChild<QAction *>(QStringLiteral("undo"))->trigger(); + + // Back in its ORIGIN folder, not guessed. A move-back to a hardcoded inbox + // would pass a laxer assertion than this one. + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), stem) + || folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), + stem), + 15000); + QVERIFY2(!folderHasMessageFile(spam, stem), + "undo returned the file and left a copy in the spam folder"); + + // Both tags gone, asked of the database: a `moved-from:` left behind makes + // Restore offer to move a message that is already home. Waited separately, + // since the undo's tag writes land after its rename. + const QString cfg = backed.fixture().configPath(); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:spamundo@example.org and " + "(tag:spam or " + "tag:\"moved-from:inbox\")")) == 0, + 15000); + // The guard the assertion above needs: a message that vanished would + // satisfy it too. + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:spamundo@example.org")), 1); +} + +void TestMainWindow::aMessageInTheSpamFolderIsNotInTheTrash() +{ + // The trash predicate must answer for the TRASH folder only. `spam` is a + // different folder, so mail in it is offered Delete like any other mail + // and never Restore. + 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); + + model->appendBatch({ + threadAtPath(QStringLiteral("t1"), + QStringLiteral("acct/trash/cur/1:2,S")), + threadAtPath(QStringLiteral("t2"), + QStringLiteral("acct/spam/cur/2:2,S")), + }); + + view->setCurrentIndex(model->index(0, 0, {})); + QVERIFY2(window.everySelectedRowIsInATrashFolderForTesting(), + "the predicate does not recognise mail in the trash, so it " + "cannot tell the spam case apart"); + + view->setCurrentIndex(model->index(1, 0, {})); + QVERIFY2(!window.everySelectedRowIsInATrashFolderForTesting(), + "mail in the spam folder is judged to be in the trash"); +} + +void TestMainWindow::emptySpamMovesEachAccountsMailToItsOwnTrash() +{ + // Empty Spam is per-ACCOUNT: one gesture over the All accounts view moves + // each account's spam to that account's own trash. A single destination + // composed once would put one account's junk in the other's trash, where + // its files' paths and its Restore origin would both be wrong. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct1/Spam"), QStringLiteral("espam1@example.org"), + QStringLiteral("First spam"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct2/Junk"), QStringLiteral("espam2@example.org"), + QStringLiteral("Second spam"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.buildWithAccounts( + { { QStringLiteral("acct1"), QStringLiteral("acct1"), + QStringLiteral("Trash"), {}, {}, {}, {}, + QStringLiteral("Spam") }, + { QStringLiteral("acct2"), QStringLiteral("acct2"), + QStringLiteral("Trash"), {}, {}, {}, {}, + QStringLiteral("Junk") } }), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *action = window.findChild<QAction *>(QStringLiteral("empty_spam")); + QVERIFY2(action, "empty_spam does not exist"); + action->trigger(); + + const QString root = backed.fixture().maildirPath(); + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct1/Trash/cur"), + QStringLiteral("espam1.example.org")), + 15000); + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct2/Trash/cur"), + QStringLiteral("espam2.example.org")), + 15000); + + QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct2/Trash/cur"), + QStringLiteral("espam1.example.org")), + "the first account's spam landed in the second account's trash"); + QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct1/Trash/cur"), + QStringLiteral("espam2.example.org")), + "the second account's spam landed in the first account's trash"); +} + +void TestMainWindow::emptySpamRewritesTheOriginToTheSpamFolder() +{ + // A message that travelled inbox -> spam -> trash carries exactly one + // origin, and it names the folder it left LAST: the spam folder. A stale + // `moved-from:inbox` left behind would make Restore send it back to the + // inbox instead of where Empty Spam took it from. The overwrite rule is + // Task 3's; this pins that Empty Spam asks for it. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("origin1@example.org"), + QStringLiteral("Travels"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + + const QString root = backed.fixture().maildirPath(); + const QString cfg = backed.fixture().configPath(); + const QString stem = QStringLiteral("origin1.example.org"); + + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("spam"))->trigger(); + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct/Spam/cur"), stem), + 15000); + // Wait for the FIRST move's origin tag before emptying, or the assertion + // below could observe a moment when neither tag has landed. + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:origin1@example.org and " + "tag:\"moved-from:inbox\"")) == 1, + 15000); + + auto *emptySpam = window.findChild<QAction *>(QStringLiteral("empty_spam")); + QVERIFY2(emptySpam, "empty_spam does not exist"); + emptySpam->trigger(); + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"), stem), + 15000); + + // Exactly one origin remains, and it names the spam folder. + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("tag:\"moved-from:Spam\"")) == 1, + 15000); + QCOMPARE(notmuchCount(cfg, QStringLiteral("tag:\"moved-from:inbox\"")), 0); + QCOMPARE(notmuchCount(cfg, QStringLiteral("tag:\"moved-from:Spam\"")), 1); + // The guard the count above needs: a message that vanished would satisfy + // the two assertions too. + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:origin1@example.org")), 1); +} + +void TestMainWindow::emptySpamRefusesAnUnconfiguredFolder() +{ + // An account with no spam folder produces an EMPTY query, and an empty + // notmuch query matches EVERYTHING. Without the guard Empty Spam would + // move the whole Maildir into the trash, so the refusal is the whole + // safety of the action. Asserting the message is still in the inbox is + // the observable consequence of refusing; the status names the cause. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("guard1@example.org"), + QStringLiteral("Untouched"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage")); + QVERIFY(status); + auto *action = window.findChild<QAction *>(QStringLiteral("empty_spam")); + QVERIFY2(action, "empty_spam does not exist"); + + action->trigger(); + + QCOMPARE(status->text(), QStringLiteral("No spam folder is configured")); + + const QString root = backed.fixture().maildirPath(); + const QString stem = QStringLiteral("guard1.example.org"); + QVERIFY2(folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), + stem), + "the guard ran an empty query and moved the inbox"); + QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"), + stem), + "an unconfigured spam folder moved mail to the trash anyway"); +} + void TestMainWindow::deleteRecordsWhereTheMessageCameFrom() { // A Maildir filename does not record where a message came from, and once @@ -11980,7 +12354,7 @@ void TestMainWindow::deleteRecordsWhereTheMessageCameFrom() // would report the tag whether or not the write ever landed. // Re-queried by id and asserted on the TAG LIST the database returns. // - // Not with `tag:"deleted-from:inbox"` in the query: notmuch's parser does + // Not with `tag:"moved-from:inbox"` in the query: notmuch's parser does // not match a quoted tag containing a colon that way, so such a query // returns nothing against a perfectly tagged message and reads as the // feature being broken. Asking for the message and inspecting its tags @@ -11997,7 +12371,7 @@ void TestMainWindow::deleteRecordsWhereTheMessageCameFrom() QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); const QStringList tags = model->threadAt(0).tags; tagged = tags.contains(QStringLiteral("deleted")) - && tags.contains(QStringLiteral("deleted-from:inbox")); + && tags.contains(QStringLiteral("moved-from:inbox")); if (!tagged) QTest::qWait(200); } @@ -12016,8 +12390,8 @@ void TestMainWindow::deletingTwiceLeavesNoOriginTagBehind() // onMessagesMoved() resolved the origin placeholder from the folder the // WORKER reported, which is where the message came FROM. On a delete that // is the inbox and correct. On a restore it is the TRASH, so the restore - // asked to remove `deleted-from:Trash`, a tag that had never been written, - // while the real `deleted-from:inbox` was never named and stayed on the + // asked to remove `moved-from:Trash`, a tag that had never been written, + // while the real `moved-from:inbox` was never named and stayed on the // message. It came home still claiming to have been deleted from // somewhere, which makes Restore offer to move a message already at home. // @@ -12065,7 +12439,7 @@ void TestMainWindow::deletingTwiceLeavesNoOriginTagBehind() QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(backed.fixture().configPath(), QStringLiteral("id:twice@example.org and " - "tag:\"deleted-from:inbox\"")) == 1, + "tag:\"moved-from:inbox\"")) == 1, 15000); // Second press on the same message, which restores it. @@ -12095,11 +12469,11 @@ void TestMainWindow::deletingTwiceLeavesNoOriginTagBehind() == 0, 15000); - // BOTH tags gone, asked of the database. `deleted-from:` left behind is + // BOTH tags gone, asked of the database. `moved-from:` left behind is // the defect this covers, and it survived a green suite before. // The origin tag specifically, asserted on its OWN query. // - // A combined `tag:deleted or tag:"deleted-from:inbox"` query is NOT + // A combined `tag:deleted or tag:"moved-from:inbox"` query is NOT // equivalent and passed against the bug: `deleted` is removed correctly // and promptly, so the disjunction went to zero on that term alone while // the origin tag was still on the message. Split, so the assertion can @@ -12119,18 +12493,18 @@ void TestMainWindow::deletingTwiceLeavesNoOriginTagBehind() QCOMPARE(notmuchCount(cfg, QStringLiteral("id:twice@example.org")), 1); // The origin tag is gone. This is the defect: it used to survive the - // restore, because the placeholder resolved to `deleted-from:Trash`, the + // restore, because the placeholder resolved to `moved-from:Trash`, the // folder the message was coming FROM, and stripped a tag that had never // been written. QCOMPARE(notmuchCount(cfg, QStringLiteral("id:twice@example.org and " - "tag:\"deleted-from:inbox\"")), + "tag:\"moved-from:inbox\"")), 0); // And no tag naming the trash was invented in its place. QCOMPARE(notmuchCount(cfg, QStringLiteral("id:twice@example.org and " - "tag:\"deleted-from:Trash\"")), + "tag:\"moved-from:Trash\"")), 0); // `deleted` itself, so a fix that dropped this one instead cannot hide. @@ -12150,7 +12524,7 @@ void TestMainWindow::undoOfADeleteRemovesTheOriginTagToo() // placeholder for the tags it wrote to the database, but handed the undo // command the raw list. Undo then asked to remove a tag by the // placeholder's literal name, which no message carries, so the removal - // was a silent no-op and `deleted-from:inbox` survived. The message came + // was a silent no-op and `moved-from:inbox` survived. The message came // home still claiming to have been deleted from somewhere, which makes // Restore offer to move a message that is already at home. // @@ -12192,7 +12566,7 @@ void TestMainWindow::undoOfADeleteRemovesTheOriginTagToo() // about it being REMOVED rather than never having existed. QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, QStringLiteral("id:undotag@example.org and " - "tag:\"deleted-from:inbox\"")) == 1, + "tag:\"moved-from:inbox\"")) == 1, 15000); window.findChild<QAction *>(QStringLiteral("undo"))->trigger(); @@ -12216,11 +12590,11 @@ void TestMainWindow::undoOfADeleteRemovesTheOriginTagToo() QCOMPARE(notmuchCount(cfg, QStringLiteral("id:undotag@example.org")), 1); QCOMPARE(notmuchCount(cfg, QStringLiteral("id:undotag@example.org and " - "tag:\"deleted-from:inbox\"")), + "tag:\"moved-from:inbox\"")), 0); QCOMPARE(notmuchCount(cfg, QStringLiteral("id:undotag@example.org and " - "tag:\"deleted-from:Trash\"")), + "tag:\"moved-from:Trash\"")), 0); } @@ -12236,8 +12610,8 @@ void TestMainWindow::deletingALoneMessageRemovesItFromTheInboxAndUndoReturnsIt() // from the mismatch: the toggle asked a thread ROW about its thread's // tags, which notmuch gives as a UNION, so deleting the root left the // union carrying no `deleted` and a second press ran Delete AGAIN, - // trash-to-trash, producing `deleted-from:inbox` and - // `deleted-from:Trash` at once with no way back. Item 177 dissolves the + // trash-to-trash, producing `moved-from:inbox` and + // `moved-from:Trash` at once with no way back. Item 177 dissolves the // mismatch rather than patching it: the row and the write now agree about // what they are for. The trash-to-trash assertions stay, because they are // what proves a delete cannot run twice on one message. @@ -12285,7 +12659,7 @@ void TestMainWindow::deletingALoneMessageRemovesItFromTheInboxAndUndoReturnsIt() QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(trash, stem), 15000); QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, QStringLiteral("id:tlone@example.org and " - "tag:\"deleted-from:inbox\"")) == 1, + "tag:\"moved-from:inbox\"")) == 1, 15000); // There is no second press to make any more, and that is the point. @@ -12329,12 +12703,12 @@ void TestMainWindow::deletingALoneMessageRemovesItFromTheInboxAndUndoReturnsIt() // query bar passes against any state of the database. QCOMPARE(notmuchCount(cfg, QStringLiteral("id:tlone@example.org")), 1); QCOMPARE(notmuchCount(cfg, QStringLiteral("id:tlone@example.org and " - "tag:\"deleted-from:inbox\"")), + "tag:\"moved-from:inbox\"")), 0); // The tag a re-delete would invent. Its presence is the signature of a // trash-to-trash move rather than a variation on the origin-tag defects. QCOMPARE(notmuchCount(cfg, QStringLiteral("id:tlone@example.org and " - "tag:\"deleted-from:Trash\"")), + "tag:\"moved-from:Trash\"")), 0); QVERIFY2(!folderHasMessageFile(trash, stem), "the message was left in the trash"); @@ -12348,7 +12722,7 @@ void TestMainWindow::deleteThreadMovesEveryMessageAndRepaintsTheRootCard() // when Delete became a move, so a whole conversation stayed in the inbox // wearing a `deleted` chip, which is the half-deleted state item 103 // existed to remove. It moves every message now, each carrying its own - // `deleted-from:` origin so a thread spanning folders reassembles. + // `moved-from:` origin so a thread spanning folders reassembles. // // And the ROOT card did not repaint until it was clicked, while its // replies did. A thread-scoped move updated each message's node; @@ -12417,7 +12791,7 @@ void TestMainWindow::deleteThreadMovesEveryMessageAndRepaintsTheRootCard() // Each with its own origin, which is what makes the move reversible. QCOMPARE(notmuchCount(cfg, thread + QStringLiteral(" and " - "tag:\"deleted-from:inbox\"")), + "tag:\"moved-from:inbox\"")), 3); // The ROOT CARD's own state, which is what the user watches. Read from the @@ -12440,19 +12814,29 @@ void TestMainWindow::deleteThreadMovesEveryMessageAndRepaintsTheRootCard() QCOMPARE(notmuchCount(cfg, thread), 3); QCOMPARE(notmuchCount(cfg, thread + QStringLiteral(" and " - "tag:\"deleted-from:inbox\"")), + "tag:\"moved-from:inbox\"")), 0); QVERIFY(!folderHasMessageFile(trash, QStringLiteral("dt0.example.org"))); QVERIFY(!folderHasMessageFile(trash, QStringLiteral("dt1.example.org"))); QVERIFY(!folderHasMessageFile(trash, QStringLiteral("dt2.example.org"))); + + // And the `inbox` tag came back with each message, not only the file. The + // message-scoped restore always did this; the thread-scoped one did not, + // because it passed an empty add list. Item 201 folded the two routes into + // one implementation, and this is the property that was silently missing: + // the conversation sat in the inbox FOLDER with no `inbox` tag, so the + // Inbox view could not see it until the next hook run. + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, thread + QStringLiteral(" and tag:inbox")) == 3, + 15000); } void TestMainWindow::aFolderNameWithASpaceSurvivesTheRoundTrip() { // A notmuch tag MAY contain a space, and a Maildir folder name may too. // The worker reported each message's tags as one space-joined string, so - // `deleted-from:Inbox/SlackBuilds users` was split back into - // "deleted-from:Inbox/SlackBuilds" and "users", and Restore moved the + // `moved-from:Inbox/SlackBuilds users` was split back into + // "moved-from:Inbox/SlackBuilds" and "users", and Restore moved the // messages to the truncated folder, CREATING it. On the user's real // Maildir that put four messages into a directory mbsync does not sync, // beside the real folder of 808, and they read as missing. @@ -12504,7 +12888,7 @@ void TestMainWindow::aFolderNameWithASpaceSurvivesTheRoundTrip() // The origin tag carries the WHOLE folder name, space included. QCOMPARE(notmuchCount(cfg, thread - + QStringLiteral(" and tag:\"deleted-from:" + + QStringLiteral(" and tag:\"moved-from:" "Inbox/SlackBuilds users\"")), 2); @@ -12520,13 +12904,13 @@ void TestMainWindow::aFolderNameWithASpaceSurvivesTheRoundTrip() // could see, could not type, and could not remove. QCOMPARE(notmuchCount(cfg, thread - + QStringLiteral(" and tag:\"deleted-from:" + + QStringLiteral(" and tag:\"moved-from:" "Inbox/SlackBuilds users\"")), 0); // Nor a truncated one, which is what a space-split would have written. QCOMPARE(notmuchCount(cfg, thread - + QStringLiteral(" and tag:\"deleted-from:" + + QStringLiteral(" and tag:\"moved-from:" "Inbox/SlackBuilds\"")), 0); @@ -12753,7 +13137,7 @@ void TestMainWindow::restoreReturnsAMessageToItsOriginFolder() // removes the race rather than papering over it with a longer timeout. QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, QStringLiteral("id:ro1@example.org and " - "tag:\"deleted-from:inbox\"")) == 0, + "tag:\"moved-from:inbox\"")) == 0, 15000); QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, @@ -12783,7 +13167,7 @@ void TestMainWindow::restoreReturnsAMessageToItsOriginFolder() void TestMainWindow::restoreFallsBackToInboxWithoutAnOriginTag() { // A message trashed by ANOTHER client: it sits in the trash folder and - // carries no `deleted-from:` tag, because nothing here put it there. The + // carries no `moved-from:` tag, because nothing here put it there. The // real Maildir has such messages, which is why the trash view is path // based rather than tag based. // @@ -12813,7 +13197,7 @@ void TestMainWindow::restoreFallsBackToInboxWithoutAnOriginTag() // The guard this test needs: no origin tag, so the fallback is what is // under test rather than an ordinary restore. QCOMPARE(notmuchCount(cfg, QStringLiteral("id:foreign@example.org and " - "tag:\"deleted-from:inbox\"")), + "tag:\"moved-from:inbox\"")), 0); queryEdit->setText(QStringLiteral("path:\"acct/Trash/**\"")); @@ -12881,10 +13265,10 @@ void TestMainWindow::undoMovesTheMessageBack() QVERIFY2(!folderHasMessageFile(trash, stem), "undo restored the file and left a copy in the trash"); - // Both tags gone, asked of the database. `deleted-from:` left behind would + // Both tags gone, asked of the database. `moved-from:` left behind would // make Restore offer to move a message that is already home. queryEdit->setText(QStringLiteral( - "id:del3@example.org and (tag:deleted or tag:\"deleted-from:inbox\")")); + "id:del3@example.org and (tag:deleted or tag:\"moved-from:inbox\")")); queryEdit->returnPressed(); QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 0, 15000); // The guard the assertion above needs: a query that matches nothing @@ -13100,7 +13484,7 @@ void TestMainWindow::twoDeletesToOneTrashBothGetTheirTags() // Deletes in one account before the first confirmation arrived both named // `acct/Trash`: the second insert overwrote the first and the second // confirmation took an empty entry. That file reached the trash carrying - // neither `deleted` nor `deleted-from:`, which makes it unrestorable by + // neither `deleted` nor `moved-from:`, which makes it unrestorable by // Restore and invisible to a `tag:deleted` query. WorkerBackedWindow backed; QVERIFY(backed.fixture().addMessage( @@ -13155,7 +13539,7 @@ void TestMainWindow::twoDeletesToOneTrashBothGetTheirTags() // the defect was a write that never happened, and the model would have // shown the optimistic state either way. queryEdit->setText(QStringLiteral( - "tag:deleted and tag:\"deleted-from:inbox\" and " + "tag:deleted and tag:\"moved-from:inbox\" and " "(id:two1@example.org or id:two2@example.org)")); queryEdit->returnPressed(); QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 2, 15000); @@ -13327,6 +13711,54 @@ void TestMainWindow::theCleanupQueryExcludesMailAlreadyInTrash() QCOMPARE(notmuchCount(cfg, queryEdit->text()), 0); } +void TestMainWindow::theSpamCleanupQueryExcludesTheSpamFolder() +{ + // Task 7, mirroring theCleanupQueryExcludesMailAlreadyInTrash(). Properly + // spammed mail carries the tag AND sits in the spam folder, so without the + // exclusion this reports every message ever moved to spam. + WorkerBackedWindow backed; + QVERIFY2(backed.build(QStringLiteral("work"), QStringLiteral("work"), + QString(), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + auto *cleanup = + window.findChild<QAction *>(QStringLiteral("cleanup_stranded_spam")); + QVERIFY(queryEdit); + QVERIFY2(cleanup, "there is no cleanup_stranded_spam action"); + + cleanup->trigger(); + + // The composed query, asserted whole: the exclusion has to wrap the + // account's own spam path, and the tag has to be there. + QCOMPARE(queryEdit->text(), + QStringLiteral("tag:spam and not (path:\"work/Spam/**\")")); +} + +void TestMainWindow::theSpamCleanupQueryWithoutASpamFolderIsJustTheTag() +{ + // The other branch of the same composition. An empty exclusion must never + // be written as `not ()`: notmuch parses that happily and matches nothing, + // so an account with no spam folder would report a clean database. + WorkerBackedWindow backed; + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + auto *cleanup = + window.findChild<QAction *>(QStringLiteral("cleanup_stranded_spam")); + QVERIFY(queryEdit); + QVERIFY2(cleanup, "there is no cleanup_stranded_spam action"); + + cleanup->trigger(); + + QCOMPARE(queryEdit->text(), QStringLiteral("tag:spam")); +} + void TestMainWindow::aMoveThatRelocatesNothingWritesNoTag() { // The spec's ordering bullet, at the UI level: a failed rename must leave @@ -13384,7 +13816,7 @@ void TestMainWindow::aMoveThatRelocatesNothingWritesNoTag() QCOMPARE(notmuchCount(cfg, QStringLiteral("id:nomove@example.org and " "tag:deleted")), 0); QCOMPARE(notmuchCount(cfg, QStringLiteral("id:nomove@example.org and " - "tag:\"deleted-from:inbox\"")), 0); + "tag:\"moved-from:inbox\"")), 0); // And the file never left. QVERIFY(folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), @@ -16681,4 +17113,670 @@ void TestMainWindow::undoingAMarkReadRestoresOnlyWhatWasUnread() 0); } +void TestMainWindow::spamIsAbsentOnAReplyRow() +{ + // Item 187's final review. Mark spam is Delete's sibling, so it follows the + // same rule (item 177): a single reply cannot be moved out of its + // conversation, and moving only that one message is not a gesture this + // application offers. Absent on a reply, back on the conversation row. + const Config config; + MainWindow window(config); + + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<QTreeView *>(); + QVERIFY(model && view); + + ThreadSummary first = makeThread(QStringLiteral("t1"), {}); + first.totalCount = 1; + ThreadSummary many = makeThread(QStringLiteral("t2"), {}); + many.totalCount = 2; + 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()); + view->expand(thread); + const QModelIndex replyRow = model->index(0, 0, thread); + view->selectionModel()->select( + replyRow, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + view->setCurrentIndex(replyRow); + QApplication::processEvents(); + + auto *spam = window.findChild<QAction *>(QStringLiteral("spam")); + QVERIFY(spam); + QVERIFY2(!spam->isVisible() || !spam->isEnabled(), + "Mark spam is offered on a reply: one reply cannot be moved out of " + "its conversation, the same rule Delete and Archive follow"); + + // And the mirror: on the conversation row it is back, so the hide is about + // what the row IS and not a stuck flag. + selectThreadRow(view, 1); + QApplication::processEvents(); + QVERIFY2(spam->isVisible() && spam->isEnabled(), + "Mark spam stayed hidden on a conversation row"); +} + +void TestMainWindow::spamIsHiddenOnMailAlreadyInTheTrash() +{ + // The trash view does not afford Mark spam, exactly as it does not afford + // Delete: the message is already thrown away, and a move from the trash + // into the spam folder is not a gesture the view offers. Asked of the PATH, + // never the `deleted` tag, for the reason Delete is. + 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 *spam = window.findChild<QAction *>(QStringLiteral("spam")); + QVERIFY(spam); + + model->appendBatch({ + threadAtPath(QStringLiteral("t1"), + QStringLiteral("acct/inbox/cur/1:2,S")), + threadAtPath(QStringLiteral("t2"), + QStringLiteral("acct/trash/cur/2:2,S")), + }); + + view->setCurrentIndex(model->index(0, 0, {})); + QVERIFY2(spam->isVisible(), + "Mark spam is hidden on mail that is NOT in the trash, so this " + "test cannot tell the two cases apart"); + + view->setCurrentIndex(model->index(1, 0, {})); + QVERIFY2(!spam->isVisible(), + "Mark spam is still offered on mail already in the trash"); + + // A folder whose name STARTS with the trash folder's is a different folder, + // so the prefix must be compared with its separator here too. + model->appendBatch({ threadAtPath(QStringLiteral("t3"), + QStringLiteral("acct/trash-old/cur/3:2,S")) }); + view->setCurrentIndex(model->index(2, 0, {})); + QVERIFY2(spam->isVisible(), + "Mark spam vanished on mail in acct/trash-old, which is not the " + "trash"); +} + +void TestMainWindow::restoringAfterTwoMovesReturnsToTheLatestOrigin() +{ + // Item 187's final review. The worker keeps exactly ONE `moved-from:` per + // message, overwriting the old one (NotmuchWorker::applyTags). The + // optimistic MODEL update did not: on a second move it added the new origin + // beside the old, and restoreSelected() reads the model and takes the FIRST + // `moved-from:` with a break(). So a message that travelled + // inbox -> Spam -> Trash restored to the INBOX rather than to the spam + // folder it actually came from, silently and with no way back. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("twoorig@example.org"), + QStringLiteral("Two origins"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + const QString root = backed.fixture().maildirPath(); + const QString cfg = backed.fixture().configPath(); + const QString stem = QStringLiteral("twoorig.example.org"); + + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + + // inbox -> Spam, origin moved-from:inbox. + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("spam"))->trigger(); + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct/Spam/cur"), stem), + 15000); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:twoorig@example.org and " + "tag:\"moved-from:inbox\"")) == 1, + 15000); + + // Spam -> Trash, which overwrites the origin with moved-from:Spam. An + // `id:` query keeps the row in the model across the move, so the second + // Delete press can select it. + queryEdit->setText(QStringLiteral("id:twoorig@example.org")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); + + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"), stem), + 15000); + + // The DATABASE holds exactly one origin, and it names the spam folder. + // This is the worker's overwrite rule, already correct. + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:twoorig@example.org and " + "tag:\"moved-from:Spam\"")) == 1, + 15000); + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:twoorig@example.org and " + "tag:\"moved-from:inbox\"")), + 0); + + // A second Delete press restores it. The MODEL restoreSelected() reads must + // also hold one origin; without the model-side overwrite it still held + // moved-from:inbox and sent the message home to the inbox. + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); + + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct/Spam/cur"), stem), + 15000); + QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), + stem) + && !folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), + stem), + "the restore went to the inbox instead of the spam folder the " + "message actually came from: the model held two origins"); +} + +void TestMainWindow::notSpamReturnsAMessageToItsOriginFolder() +{ + // Item 201. A message marked spam had no way back: the Spam view offered + // Mark spam and Delete, and Restore is trash-only. Not spam is Mark spam's + // inverse and Restore's twin, and it must return the file to the EXACT + // folder the origin tag names rather than to a guessed inbox. + // + // The origin here is a folder that is NOT the inbox, deliberately: a + // move-back that hardcoded the inbox would pass a laxer assertion, and it + // must also NOT add the `inbox` tag, which would make the message claim to + // belong to a view it was never returned to. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/Archive"), QStringLiteral("ns1@example.org"), + QStringLiteral("Not spam me"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + const QString root = backed.fixture().maildirPath(); + const QString cfg = backed.fixture().configPath(); + const QString stem = QStringLiteral("ns1.example.org"); + const QString spam = root + QStringLiteral("/acct/Spam/cur"); + + // Mark it spam first, from an `id:` query so the row survives the move. + queryEdit->setText(QStringLiteral("id:ns1@example.org")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("spam"))->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(spam, stem), 15000); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:ns1@example.org and tag:spam " + "and tag:\"moved-from:Archive\"")) == 1, + 15000); + + // Not spam, from the Spam view: the action is only offered there. + queryEdit->setText(QStringLiteral("path:\"acct/Spam/**\"")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("not_spam"))->trigger(); + + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct/Archive/cur"), stem) + || folderHasMessageFile(root + QStringLiteral("/acct/Archive/new"), + stem), + 15000); + QVERIFY2(!folderHasMessageFile(spam, stem), + "Not spam left a copy in the spam folder"); + + // The tags this test is about: `spam` and the origin are gone, and `inbox` + // did NOT come back, because the destination is not an inbox. + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:ns1@example.org and " + "(tag:spam or " + "tag:\"moved-from:Archive\")")) == 0, + 15000); + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:ns1@example.org and " + "tag:inbox")), + 0); + // The guard the assertion above needs: a message that vanished satisfies it. + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:ns1@example.org")), 1); +} + +void TestMainWindow::notSpamFallsBackToInboxWithoutAnOriginTag() +{ + // A provider-caught message: it sits in the spam folder and carries no + // `moved-from:` tag, because nothing here put it there. The inbox is the + // documented fallback, and it is REPORTED: a guess the user is not told + // about is worse than the guess. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/Spam"), QStringLiteral("nsfb@example.org"), + QStringLiteral("Caught upstream"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + const QString root = backed.fixture().maildirPath(); + const QString cfg = backed.fixture().configPath(); + const QString stem = QStringLiteral("nsfb.example.org"); + + // The guard: no origin, so the fallback is what is under test. + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:nsfb@example.org and " + "tag:\"moved-from:inbox\"")), + 0); + + queryEdit->setText(QStringLiteral("path:\"acct/Spam/**\"")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("not_spam"))->trigger(); + + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), stem) + || folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), + stem), + 15000); + QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct/Spam/cur"), + stem), + "the provider-caught message was copied rather than moved"); + + // And the user is told the destination was a fallback. The status is the + // only place that says so. + auto *status = + window.findChild<QLabel *>(QStringLiteral("statusMessage")); + QVERIFY(status); + QTRY_VERIFY_WITH_TIMEOUT( + status->text().contains(QStringLiteral("no record")), 15000); +} + +void TestMainWindow::notSpamIsOfferedInTheSpamView() +{ + // The action is offered where it means something and only there: a + // selection in a spam folder. Asked of the PATH, never the `spam` tag: a + // provider-caught message carries no tag of ours and must still be + // un-spammable. + // + // Asserted on the message BAR as well as on the QAction. The two are set by + // different code (populateMessageBar() vs refreshTrashActions()), so + // deleting the bar's spam branch leaves a QAction-only test green. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/Spam"), QStringLiteral("nsv@example.org"), + QStringLiteral("Offered here"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + // A reply in the same conversation, so the bar's reply behaviour inside a + // spam folder can be checked too: the only action the spam branch offers is + // hidden on a reply, so taking that branch would leave the bar EMPTY. + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/Spam"), QStringLiteral("nsv2@example.org"), + QStringLiteral("Re: Offered here"), QStringLiteral("other@example.org"), + QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"), + QStringLiteral("Reply text."), true, + QStringLiteral("nsv@example.org"))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + auto *bar = window.findChild<QToolBar *>(QStringLiteral("message_toolbar")); + QVERIFY(model && view && queryEdit && bar); + auto *notSpam = window.findChild<QAction *>(QStringLiteral("not_spam")); + QVERIFY(notSpam); + + const auto barHolds = [&](const QString &name) { + const auto actions = bar->actions(); + return std::any_of(actions.cbegin(), actions.cend(), + [&](const QAction *action) { + return action && action->objectName() == name + && action->isVisible(); + }); + }; + + queryEdit->setText(QStringLiteral("path:\"acct/Spam/**\"")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + QApplication::processEvents(); + + QVERIFY2(notSpam->isVisible() && notSpam->isEnabled(), + "Not spam is not offered in the spam view, where it is the point"); + QVERIFY2(barHolds(QStringLiteral("not_spam")), + "the message bar does not carry Not spam in the spam view"); + + // The reply inside the spam conversation. Not spam is absent there, as on + // any reply, but the bar must fall back to the ordinary actions rather than + // go empty. + const QModelIndex thread = model->index(0, 0, QModelIndex()); + view->expand(thread); + // Both messages are children since item 177; wait for the worker round trip + // or the reply row is not there and the selection below is a no-op. + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(thread) == 2, 15000); + const QModelIndex replyRow = model->index(1, 0, thread); + QVERIFY(model->isMessageRow(replyRow)); + view->selectionModel()->select( + replyRow, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + view->setCurrentIndex(replyRow); + QApplication::processEvents(); + + QVERIFY2(!barHolds(QStringLiteral("not_spam")), + "Not spam is on the message bar for a reply inside the spam folder"); + QVERIFY2(barHolds(QStringLiteral("reply")), + "the message bar went empty on a reply inside the spam folder: it " + "took the spam branch, whose only action is hidden on a reply"); + QVERIFY2(barHolds(QStringLiteral("forward")), + "the message bar lost Forward on a reply inside the spam folder"); +} + +void TestMainWindow::notSpamIsAbsentOnAReplyRow() +{ + // Mark spam is Delete's sibling and Not spam is Restore's, so both follow + // item 177: a single reply cannot be moved out of its conversation. Absent + // on a reply, back on the conversation row. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const Config config = configWithTrash(dir); + MainWindow window(config); + + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<QTreeView *>(); + QVERIFY(model && view); + + model->appendBatch({ threadAtPath(QStringLiteral("t1"), + QStringLiteral("acct/spam/cur/1:2,S")) }); + + MessageNode root; + root.messageId = QStringLiteral("m1"); + root.threadId = QStringLiteral("t1"); + root.depth = 0; + root.filePath = QStringLiteral("acct/spam/cur/1:2,S"); + MessageNode reply; + reply.messageId = QStringLiteral("m2"); + reply.threadId = QStringLiteral("t1"); + reply.depth = 1; + // A REAL spam path, so the predicate answers true for this row and the + // ONLY thing that can hide Not spam is the reply guard. Without it the + // empty path made everySelectedRowIsInAFolder() return false and the test + // would pass against a missing guard. + reply.filePath = QStringLiteral("acct/spam/cur/2:2,S"); + model->setThreadMessages(QStringLiteral("t1"), { root, reply }); + + const QModelIndex thread = model->index(0, 0, QModelIndex()); + view->expand(thread); + // Child 1, not child 0: since item 177 a conversation lists its FIRST + // message as a child too, so child 0 is the root message and child 1 is + // the reply whose row is under test. + const QModelIndex replyRow = model->index(1, 0, thread); + view->selectionModel()->select( + replyRow, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + view->setCurrentIndex(replyRow); + QApplication::processEvents(); + + auto *notSpam = window.findChild<QAction *>(QStringLiteral("not_spam")); + QVERIFY(notSpam); + QVERIFY2(!notSpam->isVisible(), + "Not spam is offered on a reply: one reply cannot be moved out of " + "its conversation, the rule Delete and Mark spam follow"); + + // The mirror: on the conversation row it is back, so the hide is about what + // the row IS and not a stuck flag. + selectThreadRow(view, 0); + QApplication::processEvents(); + QVERIFY2(notSpam->isVisible(), + "Not spam stayed hidden on a conversation in the spam folder"); +} + +void TestMainWindow::notSpamIsHiddenOutsideTheSpamFolder() +{ + // Everywhere else the message is not in a spam folder, so there is nothing + // to come out of. Trash included: mail already thrown away is not offered a + // second move. + 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 *notSpam = window.findChild<QAction *>(QStringLiteral("not_spam")); + QVERIFY(notSpam); + + model->appendBatch({ + threadAtPath(QStringLiteral("t1"), + QStringLiteral("acct/spam/cur/1:2,S")), + threadAtPath(QStringLiteral("t2"), + QStringLiteral("acct/inbox/cur/2:2,S")), + threadAtPath(QStringLiteral("t3"), + QStringLiteral("acct/trash/cur/3:2,S")), + }); + + view->setCurrentIndex(model->index(1, 0, {})); + QVERIFY2(!notSpam->isVisible(), + "Not spam is offered on mail in the inbox"); + + view->setCurrentIndex(model->index(2, 0, {})); + QVERIFY2(!notSpam->isVisible(), + "Not spam is offered on mail already in the trash"); + + // A folder whose name STARTS with the spam folder's is a different folder, + // so the prefix must be compared with its separator here too. + model->appendBatch({ threadAtPath(QStringLiteral("t4"), + QStringLiteral("acct/spam-old/cur/4:2,S")) }); + view->setCurrentIndex(model->index(3, 0, {})); + QVERIFY2(!notSpam->isVisible(), + "Not spam is offered on mail in acct/spam-old, which is not the " + "spam folder"); + + view->setCurrentIndex(model->index(0, 0, {})); + QVERIFY2(notSpam->isVisible(), + "Not spam is hidden on mail in the spam folder, so this test " + "cannot tell the cases apart"); +} + +void TestMainWindow::undoOfNotSpamReturnsTheFileToTheSpamFolder() +{ + // Undo is the safety net that replaces the confirmation dialog, so an + // un-spam that cannot be retracted is one with no net at all. The undo puts + // the file back in the spam folder AND restores the tags the move removed. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("nsundo@example.org"), + QStringLiteral("Undo my not-spam"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + const QString root = backed.fixture().maildirPath(); + const QString cfg = backed.fixture().configPath(); + const QString stem = QStringLiteral("nsundo.example.org"); + const QString spam = root + QStringLiteral("/acct/Spam/cur"); + + queryEdit->setText(QStringLiteral("id:nsundo@example.org")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("spam"))->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(spam, stem), 15000); + QTRY_VERIFY_WITH_TIMEOUT(window.undoDepthForTesting() >= 1, 15000); + + // Not spam, and wait for its own command to reach the stack before undoing + // it: undo before the push is a no-op and would blame the wrong move. The + // query above cleared the stack, so this is the only entry on it. + queryEdit->setText(QStringLiteral("path:\"acct/Spam/**\"")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("not_spam"))->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(window.undoDepthForTesting() >= 1, 15000); + + window.findChild<QAction *>(QStringLiteral("undo"))->trigger(); + + QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(spam, stem), 15000); + QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), stem) + && !folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), + stem), + "undo returned the file to the inbox instead of the spam folder"); + + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:nsundo@example.org and tag:spam " + "and tag:\"moved-from:inbox\"")) == 1, + 15000); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:nsundo@example.org and " + "tag:inbox")) == 0, + 15000); + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:nsundo@example.org")), 1); +} + +void TestMainWindow::notSpamThreadMovesEveryMessageHome() +{ + // The thread-scoped half of Not spam, mirroring + // deleteThreadMovesEveryMessageAndRepaintsTheRootCard: a conversation row + // moves its whole conversation, each message back to its OWN origin. The + // single-message test cannot see notSpamThreads(), the optimistic + // applyTagChange, m_pendingThreadScope or the wholeThreadIds repaint. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("nst0@example.org"), + QStringLiteral("NST root"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Root body."))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("nst1@example.org"), + QStringLiteral("Re: NST root"), QStringLiteral("other@example.org"), + QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"), + QStringLiteral("Reply one."), true, QStringLiteral("nst0@example.org"))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("acct/inbox"), QStringLiteral("nst2@example.org"), + QStringLiteral("Re: NST root"), QStringLiteral("third@example.org"), + QStringLiteral("Fri, 14 Aug 2026 12:00:00 +0200"), + QStringLiteral("Reply two."), true, QStringLiteral("nst0@example.org"))); + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + const QString root = backed.fixture().maildirPath(); + const QString cfg = backed.fixture().configPath(); + const QString spam = root + QStringLiteral("/acct/Spam/cur"); + const QString thread = QStringLiteral("thread:{id:nst0@example.org}"); + + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + QCOMPARE(notmuchCount(cfg, thread), 3); + + // A conversation row, so the move is thread-scoped. + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("spam"))->trigger(); + + QTRY_VERIFY_WITH_TIMEOUT( + folderHasMessageFile(spam, QStringLiteral("nst0.example.org")) + && folderHasMessageFile(spam, QStringLiteral("nst1.example.org")) + && folderHasMessageFile(spam, QStringLiteral("nst2.example.org")), + 15000); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, thread + QStringLiteral(" and tag:spam")) == 3, + 15000); + QCOMPARE(notmuchCount(cfg, thread + + QStringLiteral(" and " + "tag:\"moved-from:inbox\"")), + 3); + + // Now Not spam on the same conversation row, from the Spam view. + queryEdit->setText(QStringLiteral("path:\"acct/Spam/**\"")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + window.findChild<QAction *>(QStringLiteral("not_spam"))->trigger(); + + // Every message back in the inbox folder, none left in spam, each carrying + // the `inbox` tag its origin move stripped. + QTRY_VERIFY_WITH_TIMEOUT( + (folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), + QStringLiteral("nst0.example.org")) + || folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), + QStringLiteral("nst0.example.org"))) + && (folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), + QStringLiteral("nst1.example.org")) + || folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), + QStringLiteral("nst1.example.org"))) + && (folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), + QStringLiteral("nst2.example.org")) + || folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"), + QStringLiteral("nst2.example.org"))), + 15000); + QVERIFY(!folderHasMessageFile(spam, QStringLiteral("nst0.example.org"))); + QVERIFY(!folderHasMessageFile(spam, QStringLiteral("nst1.example.org"))); + QVERIFY(!folderHasMessageFile(spam, QStringLiteral("nst2.example.org"))); + + QCOMPARE(notmuchCount(cfg, thread), 3); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, thread + QStringLiteral(" and tag:spam")) == 0, + 15000); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, thread + + QStringLiteral(" and " + "tag:\"moved-from:inbox\"")) == 0, + 15000); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, thread + QStringLiteral(" and tag:inbox")) == 3, + 15000); +} + #include "test_mainwindow.moc" diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index 970f51d..b2810d1 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -54,6 +54,7 @@ private slots: void applyTagsWithNoIdsDoesNothing(); void applyTagsReportsOnlyTheMessagesItChanged(); void applyTagsThatChangeNothingEmitNothing(); + void applyTagsKeepsOnlyTheNewestOriginTag(); void queryStillWorksAfterWrite(); void aThreadCarriesItsCardMessagesOwnTags(); @@ -99,6 +100,8 @@ private slots: void requestFoldersOnUnreadableConfigEmitsError(); void moveMessagesRelocatesTheFile(); + void moveMessagesToSpamRelocatesTheFileAndTagsIt(); + void aSecondMoveToSpamKeepsOnlyTheNewestOriginTag(); void moveMessagesReindexesAtTheNewPath(); void moveMessagesKeepsTheMessagesTags(); void moveMessagesReportsOnlyWhatMoved(); @@ -142,8 +145,10 @@ private: QString fileOf(const QString &messageId, const QString &configPath = QString()); - /// Tags of one message, read back through a fresh worker query. - QStringList tagsOf(const QString &messageId); + /// Tags of one message, read back through a fresh worker query. Defaults to + /// the shared fixture; a test with its own fixture passes its own path. + QStringList tagsOf(const QString &messageId, + const QString &configPath = QString()); QVector<MessageRef> messagesOfThread(const QString &threadId, const QString &matchQuery = QString(), bool matchedOnly = false); @@ -401,9 +406,11 @@ QVector<MessageRef> TestNotmuchWorker::messagesOfThread(const QString &threadId, return loaded.first().at(0).value<QVector<MessageRef>>(); } -QStringList TestNotmuchWorker::tagsOf(const QString &messageId) +QStringList TestNotmuchWorker::tagsOf(const QString &messageId, + const QString &configPath) { - NotmuchWorker worker(m_fixture.configPath()); + NotmuchWorker worker(configPath.isEmpty() ? m_fixture.configPath() + : configPath); QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded); worker.loadThread(QStringLiteral("{id:%1}").arg(messageId), QString(), 1); if (loaded.isEmpty()) @@ -1117,6 +1124,68 @@ void TestNotmuchWorker::applyTagsThatChangeNothingEmitNothing() QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); } +void TestNotmuchWorker::applyTagsKeepsOnlyTheNewestOriginTag() +{ + // A message that travelled inbox -> spam -> trash must hold exactly ONE + // `moved-from:` tag, the newest, or Restore's first-match scan picks an + // origin arbitrarily. Its own fixture, so the tags this test leaves behind + // cannot move the shared one's thread counts. + NotmuchFixture fixture; + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("origin1@example.org"), + QStringLiteral("Travelled"), + QStringLiteral("Erin <erin@example.org>"), + QStringLiteral("Sun, 7 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"), false)); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("origin2@example.org"), + QStringLiteral("Fresh"), + QStringLiteral("Erin <erin@example.org>"), + QStringLiteral("Sun, 7 Jun 2026 11:00:00 +0000"), + QStringLiteral("body"), false)); + QVERIFY(fixture.index()); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + // An earlier move left an origin behind. + worker.applyTags(TagChange{ { QStringLiteral("origin1@example.org") }, + { QStringLiteral("moved-from:inbox") }, + {}, + QStringLiteral("Earlier move") }); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + QVERIFY(tagsOf(QStringLiteral("origin1@example.org"), fixture.configPath()) + .contains(QStringLiteral("moved-from:inbox"))); + + // The move under test: a new origin lands while the old one is still there. + worker.applyTags(TagChange{ + { QStringLiteral("origin1@example.org"), + QStringLiteral("origin2@example.org") }, + { QStringLiteral("moved-from:Spam"), QStringLiteral("spam") }, + { QStringLiteral("inbox"), QStringLiteral("unread") }, + QStringLiteral("Mark spam") }); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + + const QStringList travelled = + tagsOf(QStringLiteral("origin1@example.org"), fixture.configPath()); + QVERIFY(!travelled.contains(QStringLiteral("moved-from:inbox"))); + QVERIFY(travelled.contains(QStringLiteral("moved-from:Spam"))); + int origins = 0; + for (const QString &tag : travelled) { + if (tag.startsWith(QStringLiteral("moved-from:"))) + ++origins; + } + QCOMPARE(origins, 1); + + // The message that never carried an origin keeps exactly the new one, and + // the move still removed what a move removes. + const QStringList fresh = + tagsOf(QStringLiteral("origin2@example.org"), fixture.configPath()); + QVERIFY(fresh.contains(QStringLiteral("moved-from:Spam"))); + QVERIFY(fresh.contains(QStringLiteral("spam"))); + QVERIFY(!fresh.contains(QStringLiteral("inbox"))); +} + void TestNotmuchWorker::queryStillWorksAfterWrite() { // applyTags closes the read-only handle to take the write lock. The same @@ -1729,6 +1798,109 @@ void TestNotmuchWorker::moveMessagesRelocatesTheFile() QVERIFY(!QFile::exists(before)); } +void TestNotmuchWorker::moveMessagesToSpamRelocatesTheFileAndTagsIt() +{ + // Mark spam is Delete's sibling, and its own fixture rather than the + // shared one: this needs an UNREAD message so the `unread` removal the + // account's spam folder config implies is actually observable. The shared + // fixture's movable messages are all read, which would make that assertion + // pass against nothing. + NotmuchFixture fixture; + QVERIFY(fixture.isValid()); + const QString id = QStringLiteral("spam1@example.org"); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), id, + QStringLiteral("Suspect"), + QStringLiteral("Erin <erin@example.org>"), + QStringLiteral("Sun, 7 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"), true)); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + QVERIFY2(tagsOf(id, fixture.configPath()).contains(QStringLiteral("unread")), + "the fixture message is already read, so the unread removal below " + "would prove nothing"); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy moved(&worker, &NotmuchWorker::messagesMoved); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.moveMessages({ id }, QStringLiteral("spam")); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + + QCOMPARE(moved.size(), 1); + QCOMPARE(moved.first().at(0).toStringList(), QStringList{ id }); + QCOMPARE(moved.first().at(1).toString(), QStringLiteral("spam")); + + const QString expectedDir = + fixture.maildirPath() + QStringLiteral("/spam/cur"); + const QString after = fileOf(id, fixture.configPath()); + QVERIFY2(!after.isEmpty(), + "the message is not in the database after the move"); + QCOMPARE(QFileInfo(after).absolutePath(), expectedDir); + QVERIFY2(QFile::exists(after), qPrintable(after)); + + // The tag half travels with the move the way onMessagesMoved() composes + // it: `spam` and the origin in, `unread` and `inbox` out. + worker.applyTags(TagChange{ { id }, + { QStringLiteral("spam"), + QStringLiteral("moved-from:inbox") }, + { QStringLiteral("unread"), + QStringLiteral("inbox") }, + QStringLiteral("Mark spam") }); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + + const QStringList tags = tagsOf(id, fixture.configPath()); + QVERIFY(tags.contains(QStringLiteral("spam"))); + QVERIFY(tags.contains(QStringLiteral("moved-from:inbox"))); + QVERIFY(!tags.contains(QStringLiteral("unread"))); +} + +void TestNotmuchWorker::aSecondMoveToSpamKeepsOnlyTheNewestOriginTag() +{ + // The overwrite rule, reached through a second move: inbox -> spam -> a + // later move that writes `moved-from:Spam` must leave exactly ONE + // `moved-from:` tag, the newest. Two would make Restore's first-match scan + // pick an origin arbitrarily, and the message would go home by a coin toss. + NotmuchFixture fixture; + QVERIFY(fixture.isValid()); + const QString id = QStringLiteral("reorigin@example.org"); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), id, + QStringLiteral("Travelled"), + QStringLiteral("Erin <erin@example.org>"), + QStringLiteral("Sun, 7 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"), true)); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + // An earlier move left an origin behind. + worker.applyTags(TagChange{ { id }, + { QStringLiteral("moved-from:inbox") }, + {}, + QStringLiteral("Earlier move") }); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + + // The move under test: a new origin lands while the old one is still there. + worker.applyTags(TagChange{ { id }, + { QStringLiteral("spam"), + QStringLiteral("moved-from:Spam") }, + { QStringLiteral("unread"), + QStringLiteral("inbox") }, + QStringLiteral("Mark spam") }); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + + const QStringList tags = tagsOf(id, fixture.configPath()); + QVERIFY(!tags.contains(QStringLiteral("moved-from:inbox"))); + QVERIFY(tags.contains(QStringLiteral("moved-from:Spam"))); + + int origins = 0; + for (const QString &tag : tags) { + if (tag.startsWith(QStringLiteral("moved-from:"))) + ++origins; + } + QCOMPARE(origins, 1); +} + void TestNotmuchWorker::purgeMessagesDoesNotClaimAnIdItCouldNotDelete() { // The report drives what the UI tells the user, and the one number they diff --git a/tests/test_tagdialog.cpp b/tests/test_tagdialog.cpp index 9b12109..93a7229 100644 --- a/tests/test_tagdialog.cpp +++ b/tests/test_tagdialog.cpp @@ -176,14 +176,14 @@ void TestTagDialog::aTagWithASpaceCanStillBeRemoved() // they can see and cannot get rid of. // // Reached by a real Maildir: a folder named "Inbox/SlackBuilds users" - // produced `deleted-from:Inbox/SlackBuilds users`, and the one dialog that + // produced `moved-from:Inbox/SlackBuilds users`, and the one dialog that // could have cleared it refused the only text that names it. // // Only the TYPED route was blocked. Unchecking appends to the removal list // after validation has run, so it worked throughout; that asymmetry is why // both routes are asserted here rather than just the one that failed. const QString spaced = - QStringLiteral("deleted-from:Inbox/SlackBuilds users"); + QStringLiteral("moved-from:Inbox/SlackBuilds users"); QHash<QString, int> current; current.insert(spaced, 1); |
