diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-26 19:31:45 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-26 19:31:45 +0200 |
| commit | 59a196ea849c64c51311f56bf62130e74d90b784 (patch) | |
| tree | f322f955b0b5981dfafc85db72f224795941a4ad /tests | |
| parent | 1c034f6358f17c5c1d0eeaa04c42c33fac125d93 (diff) | |
| download | qtmaildir-59a196ea849c64c51311f56bf62130e74d90b784.tar.gz qtmaildir-59a196ea849c64c51311f56bf62130e74d90b784.zip | |
feat: open the unsynced-changes count to see what it counts
Item 119, and item 146 which is the same request recorded again. The status
bar's count answers "is my work safe to quit on" and could not say what the
work was.
The label opens a read-only list on a click. A QLabel has no clicked signal,
so the press is taken by MainWindow's existing event filter rather than by
replacing the label with a flat QToolButton, which would have brought the
style's button metrics into a status bar the label already sits correctly
in. The pointing-hand cursor is the affordance, since a status-bar label has
room for nothing else.
The layout is the user's own: a message appears once with its actions
beneath it. PendingChangesDialog::rowsFor() does the grouping over a run of
rows sharing an id, which the snapshot has already ordered, so the actions
under one message keep the order they were made in.
Read-only, deliberately. Retrying or discarding a change from here would be
a new mutation path with its own undo question, and the count exists to be
understood rather than edited.
Three rules the tests pin, each of which is a way the list could disagree
with the count it was opened from:
- Grouping must not collapse: two actions on one message are two rows.
- A thread row stays thread-scoped and reports how many messages it covered.
- An id the index no longer holds still opens a run of its own, showing that
its subject is unknown rather than folding its actions under the message
above it. This is why the row carries startsMessage rather than inferring
it from a non-empty subject.
The queued call carrying QStringList, QList<bool> and QList<int> is covered
by a test that drives it across a real thread, since a container whose
metatype does not resolve is dropped at runtime and the slot runs with a
default. Both survive on Qt 6.11; the test is what says so, and what would
fail if that changed.
Italian ships with it: five new strings, lupdate clean, lrelease 522
finished and 0 unfinished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 48 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 35 | ||||
| -rw-r--r-- | tests/test_pendingchangesdialog.cpp | 149 |
4 files changed, 233 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 69c57ee..2cb3651 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,6 +52,7 @@ add_qtmaildir_test(htmlbuilder) add_qtmaildir_test(notmuchworker) add_qtmaildir_test(tagcolors) add_qtmaildir_test(cardlayout) +add_qtmaildir_test(pendingchangesdialog) add_qtmaildir_test(avatar) add_qtmaildir_test(businesssenders) add_qtmaildir_test(marks) diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index be19652..a572fd0 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -47,6 +47,7 @@ #include "config.h" #include "keymap.h" #include "mainwindow.h" +#include "pendingchangesdialog.h" #include "messageview.h" #include "mimeparser.h" #include "notmuchworker.h" @@ -418,6 +419,7 @@ private slots: void everyPendingChangeCanNameItsMessages(); void theSnapshotGroupsActionsUnderTheirMessage(); void theSnapshotKeepsAThreadActionThreadScoped(); + void theIndicatorOpensItsListOnAClick(); void anEditDuringABackgroundSyncIsNotSentYet(); void aHeldEditIsSentWhenTheBackgroundSyncEnds(); void aHeldEditCountsAsUnsynced(); @@ -6574,6 +6576,52 @@ void TestMainWindow::theSnapshotKeepsAThreadActionThreadScoped() QCOMPARE(rows.size(), window.pendingEditCount()); } +void TestMainWindow::theIndicatorOpensItsListOnAClick() +{ + // The label is a QLabel and has no clicked signal, so the click is taken + // by an event filter. A test that called showPendingChanges() directly + // would pass with that filter never installed, which is the whole gesture. + const Config config; + MainWindow window(config); + + auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits")); + QVERIFY(label); + + TagChange change; + change.messageIds = { QStringLiteral("click@example.org") }; + change.added = { QStringLiteral("deleted") }; + change.description = QStringLiteral("Delete"); + QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied", + Q_ARG(TagChange, change))); + QVERIFY(!label->isHidden()); + + // With no worker the dialog opens directly and modally, so it is closed + // from a timer rather than by driving exec() to return some other way. + // Polled rather than checked once: exec() parents the dialog and spins its + // own event loop, so a single-shot timer can fire before it exists. + bool sawDialog = false; + auto *poll = new QTimer(&window); + poll->setInterval(1); + QObject::connect(poll, &QTimer::timeout, &window, [&window, &sawDialog]() { + if (auto *dialog = window.findChild<PendingChangesDialog *>()) { + sawDialog = true; + QCOMPARE(dialog->rows().size(), 1); + QCOMPARE(dialog->rows().at(0).action, QStringLiteral("Delete")); + dialog->reject(); + } + }); + poll->start(); + + QMouseEvent press(QEvent::MouseButtonRelease, QPointF(1, 1), + QPointF(1, 1), Qt::LeftButton, Qt::LeftButton, + Qt::NoModifier); + QCoreApplication::sendEvent(label, &press); + + // The subjects are resolved on the worker thread, so the dialog appears a + // round trip after the click rather than inside sendEvent(). + QTRY_VERIFY_WITH_TIMEOUT(sawDialog, 15000); +} + // Item 37. A tag edit made while a background sync holds notmuch's write lock // used to stall the worker: the read-write open BLOCKS until the lock frees // (measured 9.158s against a 12s hold, returning NOTMUCH_STATUS_SUCCESS), so diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index bcde45c..2e960be 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -58,6 +58,7 @@ private slots: void applyTagsToThreadsSpansMultipleThreads(); void applyTagsToThreadsWithNoThreadsDoesNothing(); + void pendingSubjectsCrossAQueuedCall(); void pendingSubjectsAnswerPositionally(); void aMissingPendingIdYieldsAnEmptySubject(); void requestAllTagsReturnsSortedTags(); @@ -964,6 +965,40 @@ void TestNotmuchWorker::applyTagsToThreadsWithNoThreadsDoesNothing() QVERIFY(errors.isEmpty()); } +void TestNotmuchWorker::pendingSubjectsCrossAQueuedCall() +{ + // The dialog reaches the worker over a QUEUED connection, and a container + // whose metatype is not registered under the name invokeMethod resolves is + // DROPPED at runtime with a warning, leaving the slot to run with a + // default. CLAUDE.md records that trap for Q_ENUM; QList<int> is the same + // trap in a different shape, and it is the type this signal answers with. + // + // Driven through invokeMethod on a real thread rather than by calling the + // slot directly: a direct call proves nothing about the queued path, which + // is the only one production uses. + NotmuchWorker worker(m_fixture.configPath()); + QThread thread; + worker.moveToThread(&thread); + thread.start(); + + QSignalSpy spy(&worker, &NotmuchWorker::pendingSubjectsResolved); + QVERIFY(QMetaObject::invokeMethod( + &worker, "resolvePendingSubjects", Qt::QueuedConnection, + Q_ARG(QStringList, QStringList{ QStringLiteral("b1@example.org") }), + Q_ARG(QList<bool>, QList<bool>{ false }))); + + QVERIFY2(spy.wait(5000), + "the queued call never produced an answer: a container argument " + "was most likely dropped for want of a registered metatype"); + QCOMPARE(spy.first().at(0).toStringList().size(), 1); + QVERIFY(!spy.first().at(0).toStringList().at(0).isEmpty()); + // And the counts survived the crossing as a real list, not a default. + QCOMPARE(spy.first().at(1).value<QList<int>>().size(), 1); + + thread.quit(); + QVERIFY(thread.wait(5000)); +} + void TestNotmuchWorker::pendingSubjectsAnswerPositionally() { // Item 119. The dialog has already decided what its rows are and in what diff --git a/tests/test_pendingchangesdialog.cpp b/tests/test_pendingchangesdialog.cpp new file mode 100644 index 0000000..078f6a8 --- /dev/null +++ b/tests/test_pendingchangesdialog.cpp @@ -0,0 +1,149 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include <QtTest> + +#include "pendingchangesdialog.h" + +/// The grouping the user asked for, asserted on the ROWS rather than on a +/// render. A pixel probe cannot tell a correct layout from a plausible one, +/// which is why MessageDetailsDialog exposes its rows too. +class TestPendingChangesDialog : public QObject +{ + Q_OBJECT +private slots: + void aMessageIsDrawnOnceWithItsActionsBeneath(); + void everyChangeKeepsARowOfItsOwn(); + void aThreadRowCarriesItsMessageCount(); + void anUnresolvedIdStillOpensItsRun(); +}; + +void TestPendingChangesDialog::aMessageIsDrawnOnceWithItsActionsBeneath() +{ + // The layout: subject once, actions under it. + // + // Build fails Delete + // Mark read + // August digest Delete + const QVector<PendingChange> changes { + { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("Build fails"), -1 }, + { QStringLiteral("a@example.org"), false, QStringLiteral("Mark read"), + QStringLiteral("Build fails"), -1 }, + { QStringLiteral("b@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("August digest"), -1 }, + }; + + const QVector<PendingChangeRow> rows = + PendingChangesDialog::rowsFor(changes); + QCOMPARE(rows.size(), 3); + + // First row of the run carries the subject. + QVERIFY(rows.at(0).startsMessage); + QCOMPARE(rows.at(0).subject, QStringLiteral("Build fails")); + QCOMPARE(rows.at(0).action, QStringLiteral("Delete")); + + // The second action of the same message carries NO subject, which is what + // puts it under the message rather than beside a repeated one. + QVERIFY(!rows.at(1).startsMessage); + QVERIFY2(rows.at(1).subject.isEmpty(), + "the subject was repeated instead of grouping the actions"); + QCOMPARE(rows.at(1).action, QStringLiteral("Mark read")); + + // A different message opens a new run. + QVERIFY(rows.at(2).startsMessage); + QCOMPARE(rows.at(2).subject, QStringLiteral("August digest")); +} + +void TestPendingChangesDialog::everyChangeKeepsARowOfItsOwn() +{ + // Grouping must not COLLAPSE anything: the count the user clicked has to + // equal the number of rows they are shown, so two actions on one message + // are two rows however they are drawn. + const QVector<PendingChange> changes { + { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("One"), -1 }, + { QStringLiteral("a@example.org"), false, QStringLiteral("Mark read"), + QStringLiteral("One"), -1 }, + { QStringLiteral("a@example.org"), false, QStringLiteral("Mark spam"), + QStringLiteral("One"), -1 }, + }; + + const QVector<PendingChangeRow> rows = + PendingChangesDialog::rowsFor(changes); + QCOMPARE(rows.size(), changes.size()); + + // Exactly one of them opens the run, and every action survives. + int starts = 0; + QStringList actions; + for (const PendingChangeRow &row : rows) { + if (row.startsMessage) + ++starts; + actions.append(row.action); + } + QCOMPARE(starts, 1); + QCOMPARE(actions, QStringList({ QStringLiteral("Delete"), + QStringLiteral("Mark read"), + QStringLiteral("Mark spam") })); +} + +void TestPendingChangesDialog::aThreadRowCarriesItsMessageCount() +{ + // A thread action reports how many messages it covered. The count belongs + // to the row that opens the run, since that is where the subject is drawn. + const QVector<PendingChange> changes { + { QStringLiteral("t1"), true, QStringLiteral("Delete thread"), + QStringLiteral("A conversation"), 4 }, + { QStringLiteral("m1@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("A message"), -1 }, + }; + + const QVector<PendingChangeRow> rows = + PendingChangesDialog::rowsFor(changes); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows.at(0).messageCount, 4); + // A message row claims no count: it stands for one message and saying "1" + // would read as a thread of one. + QCOMPARE(rows.at(1).messageCount, -1); +} + +void TestPendingChangesDialog::anUnresolvedIdStillOpensItsRun() +{ + // A stale id resolves to an empty subject. The row must still OPEN a run, + // or its actions would be drawn as though they belonged to the message + // above them, which is worse than saying the subject is unknown. + // + // This is why startsMessage is carried rather than inferred from a + // non-empty subject. + const QVector<PendingChange> changes { + { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("Known"), -1 }, + { QStringLiteral("gone@example.org"), false, QStringLiteral("Delete"), + QString(), -1 }, + }; + + const QVector<PendingChangeRow> rows = + PendingChangesDialog::rowsFor(changes); + QCOMPARE(rows.size(), 2); + QVERIFY2(rows.at(1).startsMessage, + "an unresolved id was folded into the message above it"); + QVERIFY(rows.at(1).subject.isEmpty()); +} + +QTEST_MAIN(TestPendingChangesDialog) +#include "test_pendingchangesdialog.moc" |
