diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-07 18:29:58 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-07 18:29:58 +0200 |
| commit | fbc65d2413190faa873d6b0b5cb9ed31ebba453b (patch) | |
| tree | 3a85bbd06e153bac58e6d0fbed6364a50458b0d7 /tests/test_mainwindow.cpp | |
| parent | 48c243d7aee3382713f7a4653be2179d313b432c (diff) | |
| download | qtmaildir-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/test_mainwindow.cpp')
| -rw-r--r-- | tests/test_mainwindow.cpp | 97 |
1 files changed, 97 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 |
