diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-31 12:33:10 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-31 12:33:10 +0100 |
| commit | c9882b609ade04a8b17a89f6b05d51bf939e330c (patch) | |
| tree | c8bd66417264edafefaa2077d37b5455c046ff51 /tests/test_mainwindow.cpp | |
| parent | ef42103bb1639006c5558eae5f857cd8f9ad2418 (diff) | |
| download | qtmaildir-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.cpp | 141 |
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". |
