From b1f6660b9f1471659ead81cf527d13d93dd57146 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 31 Oct 2026 12:08:42 +0100 Subject: feat: apply a saved rule to existing mail, confirmed and undoable The dialog's Apply reaches MainWindow, which counts the messages the rule would actually move, confirms with that count, then writes through the worker. The rule is written one tag at a time inside one undo macro, because the worker's effective set is per message: a single +a -b write would make the undo strip a tag from messages that already had it. Each undo entry is filled from its own confirmation, so it reverts exactly what moved. Refused while a sync holds the write lock, checked again after the confirmation closes. Item 212. Co-Authored-By: Claude Opus 5.5 --- tests/test_mainwindow.cpp | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) (limited to 'tests/test_mainwindow.cpp') diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 71795fa..4887647 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -79,6 +80,7 @@ #include "tagstrip.h" #include "threaddashboard.h" #include "threadlistmodel.h" +#include "tagrules.h" #include "threadlistview.h" #include "notmuchfixture.h" @@ -296,6 +298,7 @@ private slots: void aMalformedAccountIsReportedWithoutBlockingTheConstructor(); void aWorkerBackedWindowReturnsRealThreads(); void aWriteSaysItIsApplyingUntilTheWorkerHasRunIt(); + void applyingARuleConfirmsWithTheCountAndUndoesExactly(); void aPurgeTakesTheRowsOutOfTheViewWithoutARefresh(); void theDashboardFollowsAWriteToTheConversationItShows(); void anEditHeldByASyncSaysSoInsteadOfClaimingItLanded(); @@ -9929,6 +9932,94 @@ void TestMainWindow::aWriteSaysItIsApplyingUntilTheWorkerHasRunIt() qPrintable(status->text()), 15000); } +void TestMainWindow::applyingARuleConfirmsWithTheCountAndUndoesExactly() +{ + // Items 212 and 176. Two matching messages in OPPOSITE states: `had` + // already carries promo, `lacked` does not. The confirmation must count + // what the whole rule moves, and undo must leave `had` tagged. + WorkerBackedWindow backed; + // Friday, verified with `date -d 2026-08-14 +%A`. + const QString date = QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("had@example.org"), + QStringLiteral("One"), QStringLiteral("deals@example.net"), date, + QStringLiteral("Body."))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("lacked@example.org"), + QStringLiteral("Two"), QStringLiteral("deals@example.net"), date, + QStringLiteral("Body."))); + QVERIFY2(backed.build(), qPrintable(backed.error())); + + const auto notmuch = [&backed](const QStringList &args) { + QProcess p; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("NOTMUCH_CONFIG"), + backed.fixture().configPath()); + p.setProcessEnvironment(env); + p.start(QStringLiteral("notmuch"), args); + p.waitForFinished(); + return QString::fromUtf8(p.readAllStandardOutput()).trimmed(); + }; + notmuch({ QStringLiteral("tag"), QStringLiteral("+promo"), QStringLiteral("--"), + QStringLiteral("id:had@example.org") }); + // Guards: the fixture state the assertions below depend on. + QCOMPARE(notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }), + QStringLiteral("2")); + QCOMPARE(notmuch({ QStringLiteral("count"), + QStringLiteral("tag:promo and id:had@example.org") }), + QStringLiteral("1")); + + MainWindow window(backed.config()); + auto *undo = window.findChild(QStringLiteral("undo")); + QVERIFY(undo); + + TagRule rule; + rule.id = QStringLiteral("promo"); + rule.query = QStringLiteral("from:deals@example.net"); + rule.add = { QStringLiteral("promo") }; + rule.remove = { QStringLiteral("inbox") }; + + // Answer the confirmation when it appears, recording what it said. + QString asked; + QTimer poll; + poll.setInterval(20); + QObject::connect(&poll, &QTimer::timeout, [&]() { + auto *box = qobject_cast(QApplication::activeModalWidget()); + if (!box) + return; + asked = box->text(); + poll.stop(); + box->button(QMessageBox::Yes)->click(); + }); + poll.start(); + + window.applyRuleToExistingMail(rule); + QTRY_VERIFY_WITH_TIMEOUT(!asked.isEmpty(), 15000); + // The whole rule's count: both messages still carry inbox, so both + // would move, even though `had` already has promo. + QVERIFY2(asked.contains(QStringLiteral("2")), qPrintable(asked)); + + QTRY_COMPARE_WITH_TIMEOUT( + notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }), + QStringLiteral("0"), 15000); + QTRY_COMPARE_WITH_TIMEOUT( + notmuch({ QStringLiteral("count"), QStringLiteral("tag:promo") }), + QStringLiteral("2"), 15000); + + undo->trigger(); + QTRY_COMPARE_WITH_TIMEOUT( + notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }), + QStringLiteral("2"), 15000); + QTRY_COMPARE_WITH_TIMEOUT( + notmuch({ QStringLiteral("count"), + QStringLiteral("tag:promo and id:lacked@example.org") }), + QStringLiteral("0"), 15000); + // `had` carried promo before the backfill, so undo must not take it off. + QCOMPARE(notmuch({ QStringLiteral("count"), + QStringLiteral("tag:promo and id:had@example.org") }), + QStringLiteral("1")); +} + /// Item 181, from the user: "the thread dashboard doesn't update live with the /// modifications applied to the list pane. If I mark the thread as read, the /// dash still reports N unread". -- cgit v1.2.3