summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-07 18:29:58 +0200
committerDanilo M. <danix@danix.xyz>2026-08-07 18:29:58 +0200
commitfbc65d2413190faa873d6b0b5cb9ed31ebba453b (patch)
tree3a85bbd06e153bac58e6d0fbed6364a50458b0d7 /tests
parent48c243d7aee3382713f7a4653be2179d313b432c (diff)
downloadqtmaildir-fbc65d2413190faa873d6b0b5cb9ed31ebba453b.tar.gz
qtmaildir-fbc65d2413190faa873d6b0b5cb9ed31ebba453b.zip
feat(ui): add a Maildir overview under Help
Nothing in the UI reported database-level facts: every query gave a thread count for that query, and nothing said how much mail there is overall. A dialog under Help now shows messages, threads and tags from notmuch, plus the account list from config, since notmuch does not model accounts at all. A separate worker call rather than a reuse of requestCounts, which counts threads to match the row count of a query. This counts messages, which is what a user means by "how much mail is in here". The test pins 4 messages in 3 threads against the fixture and fails if they are ever made equal, so routing both through one count cannot pass unnoticed. Every field starts at -1 and renders as "unknown" when notmuch could not answer it. Printing 0 would say the Maildir is empty, and telling someone their mail is gone is the worst way to report an index that failed to open. The dialog opens showing "Counting..." rather than blocking, since counting every message is not free on a large database. That makes two lifetimes matter: the reply can arrive after the dialog is closed, so the label is a QPointer, and the dialog can be closed and reopened while a count runs, so a generation counter drops the older answer. The test drains DeferredDelete before firing the late reply, because close() deletes through deleteLater and without that the dangling case is never actually reached.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp97
-rw-r--r--tests/test_notmuchworker.cpp44
2 files changed, 141 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 2625bf5..ef6f99d 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -78,6 +78,8 @@ private slots:
void theStatusBarReportsAMultiRowSelection();
void clearSelectionBlanksThePaneAndDeselects();
void clearPaneLeavesTheSelectionAlone();
+ void maildirOverviewShowsUnknownRatherThanZero();
+ void maildirOverviewIgnoresAStaleReply();
void theThreadListOffersAContextMenu();
void aSecondRowBlanksThePaneNotOnlyAThird();
void aLocalSyncIsNotReportedAsABackgroundOne();
@@ -1179,6 +1181,101 @@ void TestMainWindow::clearPaneLeavesTheSelectionAlone()
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
}
+void TestMainWindow::maildirOverviewShowsUnknownRatherThanZero()
+{
+ // A field notmuch could not answer must not render as 0. "0 messages" says
+ // the Maildir is empty, which is a claim; the truth is that the count
+ // failed, and telling someone their mail is gone is the worst available
+ // way to report an unreadable index.
+ const Config config;
+ MainWindow window(config);
+
+ auto *action = window.findChild<QAction *>(QStringLiteral("maildirOverview"));
+ QVERIFY2(action, "no maildirOverview action");
+ action->trigger();
+
+ auto *counts = window.findChild<QLabel *>(QStringLiteral("maildirCounts"));
+ QVERIFY2(counts, "the overview dialog has no counts label");
+
+ // The worker never answers in this fixture, so drive the slot directly
+ // with the all-unknown stats a failed open produces.
+ QMetaObject::invokeMethod(
+ &window, "onDatabaseStatsReady", Qt::DirectConnection,
+ Q_ARG(DatabaseStats, DatabaseStats{}),
+ Q_ARG(quint64, window.statsGenerationForTesting()));
+
+ QVERIFY2(counts->text().contains(QStringLiteral("unknown")),
+ qPrintable(QStringLiteral("counts label says '%1'")
+ .arg(counts->text())));
+ QVERIFY2(!counts->text().contains(QStringLiteral(">0<")),
+ qPrintable(QStringLiteral("an unanswered count rendered as zero: "
+ "'%1'").arg(counts->text())));
+
+ // WA_DeleteOnClose, so closing is what frees it. Left open, each test
+ // leaks a window for the rest of the run.
+ counts->window()->close();
+}
+
+void TestMainWindow::maildirOverviewIgnoresAStaleReply()
+{
+ // Counting every message is slow enough that closing and reopening the
+ // dialog while one runs is realistic. The older answer must not fill in the
+ // newer dialog, or the numbers silently predate whatever prompted the
+ // reopen.
+ const Config config;
+ MainWindow window(config);
+
+ auto *action = window.findChild<QAction *>(QStringLiteral("maildirOverview"));
+ QVERIFY(action);
+ action->trigger();
+
+ const quint64 stale = window.statsGenerationForTesting();
+
+ // Reopening bumps the generation, which is what makes the first reply old.
+ action->trigger();
+ QVERIFY2(window.statsGenerationForTesting() != stale,
+ "reopening the dialog did not bump the generation, so a reply for "
+ "the previous one cannot be told apart");
+
+ auto *counts = window.findChild<QLabel *>(QStringLiteral("maildirCounts"));
+ QVERIFY(counts);
+ const QString before = counts->text();
+
+ DatabaseStats old;
+ old.messages = 4321;
+ old.threads = 999;
+ old.tags = 42;
+ QMetaObject::invokeMethod(&window, "onDatabaseStatsReady",
+ Qt::DirectConnection,
+ Q_ARG(DatabaseStats, old),
+ Q_ARG(quint64, stale));
+
+ QCOMPARE(counts->text(), before);
+ QVERIFY2(!counts->text().contains(QStringLiteral("4321")),
+ "a reply for the previous dialog filled in the current one");
+
+ QPointer<QLabel> watch(counts);
+ counts->window()->close();
+
+ // WA_DeleteOnClose deletes through deleteLater, so the label outlives
+ // close() until the event loop runs. Drain it, or the "reply after the
+ // dialog is gone" case below is not actually being tested.
+ QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
+ QVERIFY2(watch.isNull(),
+ "the dialog was not destroyed, so the case below is not the one "
+ "this test means to exercise");
+
+ // The QPointer's reason for being: counting a large database takes long
+ // enough that closing the dialog first is ordinary, and the reply then
+ // arrives for a label that has been deleted. A raw pointer would dangle
+ // here, so this must not crash.
+ QMetaObject::invokeMethod(&window, "onDatabaseStatsReady",
+ Qt::DirectConnection,
+ Q_ARG(DatabaseStats, old),
+ Q_ARG(quint64,
+ window.statsGenerationForTesting()));
+}
+
void TestMainWindow::theThreadListOffersAContextMenu()
{
// Right-click is the other half of discoverability: until now every tag
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index be62ad3..b419915 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -60,6 +60,8 @@ private slots:
void requestCountsAnswersOneCountPerQuery();
void requestCountsKeepsPositionOnAnInvalidQuery();
+ void requestDatabaseStatsCountsMessagesNotThreads();
+ void requestDatabaseStatsOnUnreadableConfigEmitsError();
private:
/// Tags of one message, read back through a fresh worker query.
@@ -523,5 +525,47 @@ void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
QCOMPARE(counts.at(2), 3);
}
+void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::databaseStatsReady);
+
+ worker.requestDatabaseStats(11);
+
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(1).value<quint64>(), quint64(11));
+ const auto stats = spy.at(0).at(0).value<DatabaseStats>();
+
+ // The fixture holds four messages in three threads: thread A is a message
+ // and its reply. **That difference is the whole point of this call.**
+ // requestCounts() counts threads, to match the row count of a query; this
+ // one counts messages, which is what a user means by "how much mail". A
+ // reimplementation that reused the thread count would report 3 here and be
+ // confidently wrong under the label "messages".
+ QCOMPARE(stats.messages, 4);
+ QCOMPARE(stats.threads, 3);
+ QVERIFY2(stats.messages != stats.threads,
+ "messages and threads are equal, so this fixture cannot prove the "
+ "two counts are distinct: add a reply to it");
+
+ // Every tag the fixture creates, plus notmuch's own.
+ QVERIFY(stats.tags > 0);
+}
+
+void TestNotmuchWorker::requestDatabaseStatsOnUnreadableConfigEmitsError()
+{
+ // Fails closed like every other entry point. The dialog then shows its
+ // fields as unknown rather than as zero, since "no mail at all" is the
+ // wrong thing to tell someone whose index failed to open.
+ NotmuchWorker worker(QStringLiteral("/nonexistent/qtmaildir-test/config"));
+ QSignalSpy ready(&worker, &NotmuchWorker::databaseStatsReady);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.requestDatabaseStats(1);
+
+ QCOMPARE(errors.size(), 1);
+ QVERIFY(ready.isEmpty());
+}
+
QTEST_MAIN(TestNotmuchWorker)
#include "test_notmuchworker.moc"