aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_mainwindow.cpp48
-rw-r--r--tests/test_notmuchworker.cpp35
-rw-r--r--tests/test_pendingchangesdialog.cpp149
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"