summaryrefslogtreecommitdiffstats
path: root/tests/test_mainwindow.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-07 12:03:14 +0200
committerDanilo M. <danix@danix.xyz>2026-08-07 12:03:14 +0200
commit5a4d8f5f021dc98b2a7cc471125aa3040c02675c (patch)
tree181eccf726124f39ae6a1c14734cfa18ac42ed9f /tests/test_mainwindow.cpp
parent0a9ef3c77c7c593f3568f25761aad5d1f55e0e33 (diff)
downloadqtmaildir-5a4d8f5f021dc98b2a7cc471125aa3040c02675c.tar.gz
qtmaildir-5a4d8f5f021dc98b2a7cc471125aa3040c02675c.zip
feat(tags): mark every thread in the view read, in one undoable step
An action removing "unread" from every thread in the current view, on the toolbar, the Message menu and Ctrl+Shift+U. It deliberately ignores the selection, which makes it the one action in the window that does, and it routes through the same funnel as every other tag change, so it is one write rather than one per thread. Disabled until the query reports its total. Threads arrive in batches, so before then the model holds only what has landed, and an action saying "all" must not silently skip the rest. A greyed control says "not yet" without needing a dialog or a stall the user cannot see. The state is also set at registration, since QAction starts enabled and a window that has not run a query has nothing to act on. Two things came out differently from the plan, both forced by existing code. It carries a default binding, because everyActionHasAShortcut requires every registered action to have one: an unbound action is unreachable from the keyboard, and that invariant is deliberate, so the action was given Ctrl+Shift+U rather than the invariant relaxed. And only the threads that are actually unread are sent, because sending the rest would inflate the pending-edit count with writes that change nothing, and the quit prompt reads that count. A view with nothing unread does nothing, pushes no command and says so: an undo entry that restores nothing is worse than none, since it absorbs a Ctrl+Z meant for the previous action. undoDepthForTesting() is new and exists for a reason worth recording: undo->isEnabled() cannot answer "was a command pushed", because the undo QAction is always enabled and tests canUndo() when triggered. The first version of the no-op test asserted on it and passed against a mutant with the unread filter removed. Closes item 43. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_mainwindow.cpp')
-rw-r--r--tests/test_mainwindow.cpp143
1 files changed, 143 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 02490a5..1fdeaf2 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -81,6 +81,9 @@ private slots:
void aSkippedLocalSyncStillReportsTheOtherRunFinishing();
void anUnobservableLockTableLeavesTheSyncButtonUsable();
void theStatusBarFollowsTheSyncPhase();
+ void markAllReadIsDisabledUntilTheQueryFinishes();
+ void markAllReadActsOnEveryRowAndUndoesInOneStep();
+ void markAllReadDoesNothingWhenNothingIsUnread();
void theSyncActionIsDisabledWhileABackgroundSyncHoldsTheLock();
void escapeBlanksTheMessagePane();
void deleteTogglesOnAnAlreadyDeletedThread();
@@ -367,6 +370,146 @@ static ThreadSummary makeThread(const QString &id, const QStringList &tags)
return thread;
}
+void TestMainWindow::markAllReadIsDisabledUntilTheQueryFinishes()
+{
+ // Threads arrive in batches, so acting mid-load would silently skip
+ // whatever had not arrived. Rather than acting on part of the view and
+ // calling it "all", or stalling on a wait the user cannot see, the action
+ // is simply unavailable until the result set is complete.
+ const Config config;
+ MainWindow window(config);
+
+ auto *action = window.findChild<QAction *>(QStringLiteral("mark_all_read"));
+ QVERIFY2(action, "no mark_all_read action registered");
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+
+ // A query in flight: rows are arriving but the worker has not said it is
+ // done, so the action must stay out of reach.
+ // A query is needed for runCurrentQuery to do anything: it returns early
+ // on an empty one, which would leave the flag untouched.
+ window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") }) });
+ QVERIFY2(!action->isEnabled(),
+ "the action was live while the query was still loading");
+
+ // The generation must match or the reply is discarded as stale, which is
+ // how a superseded query is ignored everywhere else in this window.
+ const quint64 generation = window.currentGenerationForTesting();
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 1), Q_ARG(quint64, generation));
+ QVERIFY2(action->isEnabled(),
+ "the action stayed disabled after the query finished");
+}
+
+void TestMainWindow::markAllReadActsOnEveryRowAndUndoesInOneStep()
+{
+ // Every row in the view, not just the selected ones, and one undo entry for
+ // the batch: a user who marks 400 threads read expects one Ctrl+Z to be
+ // enough.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTableView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("mark_all_read"));
+ QVERIFY(action);
+
+ // A query is needed for runCurrentQuery to do anything: it returns early
+ // on an empty one, which would leave the flag untouched.
+ window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") }),
+ makeThread(QStringLiteral("t2"),
+ { QStringLiteral("unread") }),
+ makeThread(QStringLiteral("t3"),
+ { QStringLiteral("unread"),
+ QStringLiteral("flagged") }) });
+ QMetaObject::invokeMethod(&window, "onQueryFinished", Q_ARG(int, 3),
+ Q_ARG(quint64,
+ window.currentGenerationForTesting()));
+
+ // One row selected, to prove the action ignores the selection rather than
+ // acting on it.
+ view->selectRow(0);
+
+ action->trigger();
+
+ for (int row = 0; row < 3; ++row) {
+ QVERIFY2(!model->threadAt(row).tags.contains(QStringLiteral("unread")),
+ qPrintable(QStringLiteral("row %1 kept its unread tag")
+ .arg(row)));
+ }
+ // An unrelated tag on a row is untouched: only unread is removed.
+ QVERIFY(model->threadAt(2).tags.contains(QStringLiteral("flagged")));
+
+ // ONE undo entry for the whole batch, not one per thread. Asserted as a
+ // depth, since triggering undo once and finding everything restored would
+ // also pass if three commands had been pushed and the model happened to
+ // recover on the first.
+ QCOMPARE(window.undoDepthForTesting(), 1);
+
+ auto *undo = window.findChild<QAction *>(QStringLiteral("undo"));
+ QVERIFY(undo);
+ undo->trigger();
+
+ for (int row = 0; row < 3; ++row) {
+ QVERIFY2(model->threadAt(row).tags.contains(QStringLiteral("unread")),
+ qPrintable(QStringLiteral("row %1 was not restored by one undo")
+ .arg(row)));
+ }
+}
+
+void TestMainWindow::markAllReadDoesNothingWhenNothingIsUnread()
+{
+ // No write, no undo entry, and no pending edit for a view that is already
+ // read: an undo entry that restores nothing is worse than none, since it
+ // absorbs a Ctrl+Z the user meant for their previous action.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *action = window.findChild<QAction *>(QStringLiteral("mark_all_read"));
+ QVERIFY(action);
+
+ // A query is needed for runCurrentQuery to do anything: it returns early
+ // on an empty one, which would leave the flag untouched.
+ window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("flagged") }),
+ makeThread(QStringLiteral("t2"), {}) });
+ QMetaObject::invokeMethod(&window, "onQueryFinished", Q_ARG(int, 2),
+ Q_ARG(quint64,
+ window.currentGenerationForTesting()));
+
+ QCOMPARE(window.undoDepthForTesting(), 0);
+
+ action->trigger();
+
+ // The real assertion: no command was pushed. Checking only that the tags
+ // did not change would pass against a version that sent a no-op write for
+ // every row, which still costs an undo entry and a pending edit each. The
+ // undo QAction cannot answer this: it is always enabled and tests canUndo()
+ // when triggered.
+ QVERIFY2(window.undoDepthForTesting() == 0,
+ "an undo entry was pushed for a view with nothing unread");
+ QVERIFY(model->threadAt(0).tags.contains(QStringLiteral("flagged")));
+
+ // And it says so rather than appearing to have done something.
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+ QVERIFY2(status->text().contains(QStringLiteral("Nothing unread")),
+ qPrintable(status->text()));
+}
+
void TestMainWindow::markReadTimerRestartsRatherThanStacking()
{
// The plan's hard requirement: arrowing quickly down a list must not mark