aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_mainwindow.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-31 12:33:10 +0100
committerDanilo M. <danix@danix.xyz>2026-10-31 12:33:10 +0100
commitc9882b609ade04a8b17a89f6b05d51bf939e330c (patch)
treec8bd66417264edafefaa2077d37b5455c046ff51 /tests/test_mainwindow.cpp
parentef42103bb1639006c5558eae5f857cd8f9ad2418 (diff)
downloadqtmaildir-c9882b609ade04a8b17a89f6b05d51bf939e330c.tar.gz
qtmaildir-c9882b609ade04a8b17a89f6b05d51bf939e330c.zip
fix: build a backfill's undo from the worker's answer, not the queue
Apply no longer arms commands on the shared confirmation queue. A backfill can run for a long time; a gesture made meanwhile cleared the queue, and the backfill's confirmation was then credited to the user's command, so undoing that command inverted the backfill over every message it touched. MainWindow counts the whole rule, confirms, and sends applyRule() with a token. onRuleApplied() drops a stale token, records each operation's effective change through the bookkeeping onTagsApplied() shares, and pushes one MessageTagCommand per operation with the ids it moved, inside one undo macro. Every configured account is marked edited, since a backfill reaches mail no row shows and a narrowed sync could otherwise clear the pending count without pushing it. Nothing moved shows the nothing-to-change status instead of "done". Also: a count of -1 says the rule could not be counted, and the confirmation says undo lasts until the next search, which is when runQuery() clears the stack. Item 212. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_mainwindow.cpp')
-rw-r--r--tests/test_mainwindow.cpp141
1 files changed, 141 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 4887647..17c6f8c 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -299,6 +299,8 @@ private slots:
void aWorkerBackedWindowReturnsRealThreads();
void aWriteSaysItIsApplyingUntilTheWorkerHasRunIt();
void applyingARuleConfirmsWithTheCountAndUndoesExactly();
+ void applyingARuleWhoseQueryNamesItsOwnTagWritesAllOfIt();
+ void aBackfillLeavesAnArmedUserCommandAlone();
void aPurgeTakesTheRowsOutOfTheViewWithoutARefresh();
void theDashboardFollowsAWriteToTheConversationItShows();
void anEditHeldByASyncSaysSoInsteadOfClaimingItLanded();
@@ -10005,6 +10007,9 @@ void TestMainWindow::applyingARuleConfirmsWithTheCountAndUndoesExactly()
QTRY_COMPARE_WITH_TIMEOUT(
notmuch({ QStringLiteral("count"), QStringLiteral("tag:promo") }),
QStringLiteral("2"), 15000);
+ // The undo entry is built from the worker's answer, which can trail the
+ // write it describes.
+ QTRY_COMPARE_WITH_TIMEOUT(window.undoDepthForTesting(), 1, 15000);
undo->trigger();
QTRY_COMPARE_WITH_TIMEOUT(
@@ -10020,6 +10025,142 @@ void TestMainWindow::applyingARuleConfirmsWithTheCountAndUndoesExactly()
QStringLiteral("1"));
}
+namespace {
+/// Runs the notmuch CLI against a test fixture's config, never the user's.
+QString fixtureNotmuch(const QString &configPath, const QStringList &args)
+{
+ QProcess p;
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert(QStringLiteral("NOTMUCH_CONFIG"), configPath);
+ p.setProcessEnvironment(env);
+ p.start(QStringLiteral("notmuch"), args);
+ p.waitForFinished();
+ return QString::fromUtf8(p.readAllStandardOutput()).trimmed();
+}
+
+/// Clicks Yes on the next QMessageBox, recording its text into `asked`.
+void answerNextBoxYes(QTimer &poll, QString &asked)
+{
+ poll.setInterval(20);
+ QObject::connect(&poll, &QTimer::timeout, [&poll, &asked]() {
+ auto *box = qobject_cast<QMessageBox *>(QApplication::activeModalWidget());
+ if (!box)
+ return;
+ asked = box->text();
+ poll.stop();
+ box->button(QMessageBox::Yes)->click();
+ });
+ poll.start();
+}
+} // namespace
+
+void TestMainWindow::applyingARuleWhoseQueryNamesItsOwnTagWritesAllOfIt()
+{
+ // Item 212. The query names `inbox`, which the rule removes. Resolving
+ // the query again per tag would let `-inbox` empty the match before
+ // `-todo` ran. The hook applies a rule once over one query; Apply must
+ // too, and undo must put back both tags.
+ WorkerBackedWindow backed;
+ const QString date = QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200");
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("one@example.org"),
+ QStringLiteral("One"), QStringLiteral("deals@example.net"), date,
+ QStringLiteral("Body.")));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("two@example.org"),
+ QStringLiteral("Two"), QStringLiteral("deals@example.net"), date,
+ QStringLiteral("Body.")));
+ QVERIFY2(backed.build(), qPrintable(backed.error()));
+ const QString cfg = backed.fixture().configPath();
+ fixtureNotmuch(cfg, { QStringLiteral("tag"), QStringLiteral("+todo"),
+ QStringLiteral("--"),
+ QStringLiteral("from:deals@example.net") });
+ QCOMPARE(fixtureNotmuch(cfg, { QStringLiteral("count"),
+ QStringLiteral("tag:inbox and tag:todo") }),
+ QStringLiteral("2"));
+
+ MainWindow window(backed.config());
+ auto *undo = window.findChild<QAction *>(QStringLiteral("undo"));
+ QVERIFY(undo);
+
+ TagRule rule;
+ rule.id = QStringLiteral("deals");
+ rule.query = QStringLiteral("from:deals@example.net and tag:inbox");
+ rule.remove = { QStringLiteral("inbox"), QStringLiteral("todo") };
+
+ QString asked;
+ QTimer poll;
+ answerNextBoxYes(poll, asked);
+ window.applyRuleToExistingMail(rule);
+ QTRY_VERIFY_WITH_TIMEOUT(!asked.isEmpty(), 15000);
+
+ QTRY_COMPARE_WITH_TIMEOUT(window.undoDepthForTesting(), 1, 15000);
+ QCOMPARE(fixtureNotmuch(cfg, { QStringLiteral("count"),
+ QStringLiteral("tag:inbox") }),
+ QStringLiteral("0"));
+ QCOMPARE(fixtureNotmuch(cfg, { QStringLiteral("count"),
+ QStringLiteral("tag:todo") }),
+ QStringLiteral("0"));
+
+ undo->trigger();
+ QTRY_COMPARE_WITH_TIMEOUT(
+ fixtureNotmuch(cfg, { QStringLiteral("count"),
+ QStringLiteral("tag:inbox and tag:todo") }),
+ QStringLiteral("2"), 15000);
+}
+
+void TestMainWindow::aBackfillLeavesAnArmedUserCommandAlone()
+{
+ // Item 212, from review. A backfill can run for a long time. If it armed
+ // commands on the shared confirmation queue, a gesture made meanwhile
+ // would clear them and take the backfill's confirmation instead, and its
+ // undo would then invert the backfill over every message it touched. A
+ // backfill arms nothing, so a command already waiting stays waiting.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("one@example.org"),
+ QStringLiteral("One"), QStringLiteral("deals@example.net"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body.")));
+ QVERIFY2(backed.build(), qPrintable(backed.error()));
+ const QString cfg = backed.fixture().configPath();
+
+ MainWindow window(backed.config());
+ TagCommand *user = window.armTagCommandForTesting(
+ {}, { QStringLiteral("inbox") });
+ QVERIFY(user);
+ QCOMPARE(window.undoDepthForTesting(), 1);
+
+ TagRule rule;
+ rule.id = QStringLiteral("deals");
+ rule.query = QStringLiteral("from:deals@example.net");
+ rule.remove = { QStringLiteral("inbox") };
+
+ QString asked;
+ QTimer poll;
+ answerNextBoxYes(poll, asked);
+ window.applyRuleToExistingMail(rule);
+ QTRY_VERIFY_WITH_TIMEOUT(!asked.isEmpty(), 15000);
+ QTRY_COMPARE_WITH_TIMEOUT(window.undoDepthForTesting(), 2, 15000);
+ QCOMPARE(fixtureNotmuch(cfg, { QStringLiteral("count"),
+ QStringLiteral("tag:inbox") }),
+ QStringLiteral("0"));
+
+ // The backfill's ids went nowhere near the user's command.
+ QVERIFY2(user->changedMessageIds().isEmpty(),
+ qPrintable(user->changedMessageIds().join(u',')));
+
+ // And it is still the one waiting: its own confirmation reaches it.
+ TagChange change;
+ change.messageIds = { QStringLiteral("armed@example.org") };
+ change.removed = { QStringLiteral("inbox") };
+ change.description = QStringLiteral("test");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+ QCOMPARE(user->changedMessageIds(),
+ QStringList{ QStringLiteral("armed@example.org") });
+}
+
/// 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".