summaryrefslogtreecommitdiffstats
path: root/tests/test_notmuchworker.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-10 09:33:44 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 09:33:44 +0200
commit01419de209c2b5e2ae7b996e6b5ff1baa2efb3da (patch)
tree25718fd36e87eac721f41b02cca46b6fb07e94d9 /tests/test_notmuchworker.cpp
parentf72dba9f6c463c6823d85701e51d8be38dd22a62 (diff)
parente1dba2987a9a1e87b92801959df9c9d4f1375d2f (diff)
downloadqtmaildir-01419de209c2b5e2ae7b996e6b5ff1baa2efb3da.tar.gz
qtmaildir-01419de209c2b5e2ae7b996e6b5ff1baa2efb3da.zip
Merge branch 'card-list': the thread pane as a list of cards
Replaces the five-column grid with a single column of three-line cards. Item 53 recorded that the columns, not the cues drawn inside them, were what made the list read as a table of records; item 20 had already shipped finished, tested and green and been rejected on sight for exactly that reason. A card is sender and date, subject with the flag, attachment and reply-count marks, and tags, at one uniform height. Replies indent under a continuous spine and show only the tags their thread does not carry. The account colour runs down the card's left edge, replacing the chip that used to eat a third of every subject line, with matching swatches in the account dropdown. Sorting newest or oldest first is new and remembered. Closes items 20, 51, 53 and 60. The four defects that mattered were all found by rendering cards to an image and looking at them, with the suite green through every one: a date clipped on unread cards because bold is wider than the font the layout measured, an accent bar painted in a colour identical to the background, an expander pill in a palette role a theme had made equal to Base, and three separate faults from trusting notmuch's reply depth to mean structure when it only means how notmuch happened to thread the mail.
Diffstat (limited to 'tests/test_notmuchworker.cpp')
-rw-r--r--tests/test_notmuchworker.cpp140
1 files changed, 137 insertions, 3 deletions
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index b419915..88dcf0b 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -39,6 +39,8 @@ private slots:
void malformedQueryYieldsNoThreads();
void unreadableConfigEmitsError();
void queryPassesGenerationThrough();
+ void oldestFirstReversesTheOrder();
+ void theSortOrderCrossesAQueuedCall();
void loadThreadReturnsMessagesOldestFirst();
void loadThreadMarksMatchedMessages();
@@ -58,6 +60,11 @@ private slots:
void requestAllTagsReturnsSortedTags();
void requestAllTagsOnUnreadableConfigEmitsError();
+ void loadMessageReturnsOnlyThatMessage();
+ void loadMessageOnAnUnknownIdReturnsNothing();
+ void loadThreadTreeReportsReplyDepth();
+ void loadThreadTreeCarriesTheFactsARowNeeds();
+
void requestCountsAnswersOneCountPerQuery();
void requestCountsKeepsPositionOnAnInvalidQuery();
void requestDatabaseStatsCountsMessagesNotThreads();
@@ -68,7 +75,9 @@ private:
QStringList tagsOf(const QString &messageId);
QVector<MessageRef> messagesOfThread(const QString &threadId,
const QString &matchQuery = QString());
- QVector<ThreadSummary> runQuery(const QString &query);
+ QVector<ThreadSummary> runQuery(
+ const QString &query,
+ NotmuchWorker::SortOrder sort = NotmuchWorker::NewestFirst);
QString threadIdOf(const QString &subject);
NotmuchFixture m_fixture;
@@ -108,13 +117,14 @@ void TestNotmuchWorker::initTestCase()
QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error()));
}
-QVector<ThreadSummary> TestNotmuchWorker::runQuery(const QString &query)
+QVector<ThreadSummary> TestNotmuchWorker::runQuery(
+ const QString &query, NotmuchWorker::SortOrder sort)
{
NotmuchWorker worker(m_fixture.configPath());
QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
QSignalSpy finished(&worker, &NotmuchWorker::queryFinished);
- worker.runQuery(query, 1);
+ worker.runQuery(query, 1, sort);
QVector<ThreadSummary> all;
for (const QList<QVariant> &args : ready)
@@ -158,6 +168,91 @@ QStringList TestNotmuchWorker::tagsOf(const QString &messageId)
return {};
}
+void TestNotmuchWorker::loadMessageReturnsOnlyThatMessage()
+{
+ // a2 is a reply in a two-message thread. Selecting a reply row must render
+ // that message alone; loadThread would hand back the whole thread and the
+ // pane would show the conversation the user was trying to look inside.
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy loaded(&worker, &NotmuchWorker::messageLoaded);
+ worker.loadMessage(QStringLiteral("a2@example.org"), 1);
+
+ QCOMPARE(loaded.count(), 1);
+ const auto messages = loaded.first().at(0).value<QVector<MessageRef>>();
+
+ QCOMPARE(messages.size(), 1);
+ QCOMPARE(messages.first().messageId, QStringLiteral("a2@example.org"));
+ QVERIFY(!messages.first().filePath.isEmpty());
+
+ // matched, so the pane renders it expanded rather than as a stub. The user
+ // asked for this message by clicking it, which is as matched as it gets.
+ QVERIFY(messages.first().matched);
+}
+
+void TestNotmuchWorker::loadMessageOnAnUnknownIdReturnsNothing()
+{
+ // Empty rather than an error: a stale row after a reindex is an ordinary
+ // race, not a failure worth a message in the status bar.
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy loaded(&worker, &NotmuchWorker::messageLoaded);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.loadMessage(QStringLiteral("nonexistent@example.org"), 1);
+
+ QCOMPARE(loaded.count(), 1);
+ QVERIFY(loaded.first().at(0).value<QVector<MessageRef>>().isEmpty());
+ QCOMPARE(errors.count(), 0);
+}
+
+void TestNotmuchWorker::loadThreadTreeReportsReplyDepth()
+{
+ // Thread A is a root plus one reply carrying In-Reply-To, which is what
+ // notmuch threads on. Without that header the two would be separate threads
+ // and this test would assert nothing about depth.
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy loaded(&worker, &NotmuchWorker::threadTreeLoaded);
+ worker.loadThreadTree(threadId, QString(), 1);
+
+ QCOMPARE(loaded.count(), 1);
+ const auto nodes = loaded.first().at(0).value<QVector<MessageNode>>();
+
+ QCOMPARE(nodes.size(), 2);
+ QCOMPARE(nodes.at(0).messageId, QStringLiteral("a1@example.org"));
+ QCOMPARE(nodes.at(0).depth, 0);
+ QCOMPARE(nodes.at(1).messageId, QStringLiteral("a2@example.org"));
+ QCOMPARE(nodes.at(1).depth, 1);
+}
+
+void TestNotmuchWorker::loadThreadTreeCarriesTheFactsARowNeeds()
+{
+ // A row is drawn without opening the message, so the walk has to read the
+ // headers. loadThread does not, which is why a separate signal exists.
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy loaded(&worker, &NotmuchWorker::threadTreeLoaded);
+ worker.loadThreadTree(threadId, QString(), 1);
+
+ QCOMPARE(loaded.count(), 1);
+ const auto nodes = loaded.first().at(0).value<QVector<MessageNode>>();
+ QCOMPARE(nodes.size(), 2);
+
+ const MessageNode &reply = nodes.at(1);
+ QVERIFY(reply.from.contains(QStringLiteral("bob@example.org")));
+ QCOMPARE(reply.subject, QStringLiteral("Re: Release notes"));
+ QVERIFY(reply.date.isValid());
+ QVERIFY(!reply.filePath.isEmpty());
+
+ // Every node names its thread, so a batch does not need the caller to keep
+ // track of which thread it asked about.
+ QCOMPARE(reply.threadId, threadId);
+ QCOMPARE(nodes.at(0).threadId, threadId);
+}
+
void TestNotmuchWorker::queryReturnsAllThreads()
{
const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*"));
@@ -234,6 +329,45 @@ void TestNotmuchWorker::queryPassesGenerationThrough()
QCOMPARE(finished.first().at(1).value<quint64>(), quint64(42));
}
+void TestNotmuchWorker::oldestFirstReversesTheOrder()
+{
+ const QVector<ThreadSummary> newest = runQuery(QStringLiteral("*"));
+ const QVector<ThreadSummary> oldest =
+ runQuery(QStringLiteral("*"), NotmuchWorker::OldestFirst);
+
+ QCOMPARE(oldest.size(), newest.size());
+
+ // The guard: with fewer than two threads, or with every thread carrying
+ // the same date, a reversal is indistinguishable from no sorting at all
+ // and every assertion below would pass against a hardcoded order.
+ QVERIFY(newest.size() >= 2);
+ QVERIFY(newest.first().date != newest.last().date);
+
+ QCOMPARE(oldest.first().threadId, newest.last().threadId);
+ QCOMPARE(oldest.last().threadId, newest.first().threadId);
+}
+
+void TestNotmuchWorker::theSortOrderCrossesAQueuedCall()
+{
+ // MainWindow reaches the worker with invokeMethod(..., QueuedConnection)
+ // across a thread boundary, and a Q_ARG whose type the meta-object system
+ // does not know FAILS AT RUNTIME with a warning, not at compile time. So
+ // the enum's registration is asserted here rather than assumed from Q_ENUM.
+ QVERIFY2(QMetaType::fromName("NotmuchWorker::SortOrder").isValid(),
+ "SortOrder is not a registered metatype, so the queued runQuery "
+ "call will drop its sort argument at runtime");
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+
+ // The real call shape, invoked by NAME exactly as MainWindow does.
+ QVERIFY(QMetaObject::invokeMethod(
+ &worker, "runQuery", Qt::DirectConnection,
+ Q_ARG(QString, QStringLiteral("*")), Q_ARG(quint64, 1),
+ Q_ARG(NotmuchWorker::SortOrder, NotmuchWorker::OldestFirst)));
+ QCOMPARE(ready.size(), 1);
+}
+
void TestNotmuchWorker::loadThreadReturnsMessagesOldestFirst()
{
const QString threadId = threadIdOf(QStringLiteral("Release notes"));