From c49d1317f95e435e5b5af0d0352e6743a5d57025 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 8 Aug 2026 10:16:25 +0200 Subject: feat(worker): load a thread as a reply tree with per-message depth loadThread could not be extended to do this. It walks notmuch_query_search_messages, and a message obtained that way returns NULL from notmuch_message_get_replies (notmuch.h:1617-1628), so that walk cannot produce reply depth at all. The tree comes from notmuch_thread_get_toplevel_messages instead, and the pane keeps the flat list it wants. walkReplies takes raw notmuch_message_t*, against this file's rule that every handle is RAII-owned. Messages reached through a thread are freed with it (notmuch.h:1637), so an NmMessage wrapper would destroy memory the thread frees again. The NmThread in the caller is what keeps them alive. Every message in the thread gets a node regardless of the query: the list is where the reply count is read, and hiding unmatched replies would make that count disagree with the rows under it. Both tests mutation-checked. Flattening depth fails the depth assertion, and skipping the thread walk fails it too, so neither passes against the two mistakes the notmuch API invites. --- tests/test_notmuchworker.cpp | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) (limited to 'tests/test_notmuchworker.cpp') diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index b419915..0f47c55 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -58,6 +58,9 @@ private slots: void requestAllTagsReturnsSortedTags(); void requestAllTagsOnUnreadableConfigEmitsError(); + void loadThreadTreeReportsReplyDepth(); + void loadThreadTreeCarriesTheFactsARowNeeds(); + void requestCountsAnswersOneCountPerQuery(); void requestCountsKeepsPositionOnAnInvalidQuery(); void requestDatabaseStatsCountsMessagesNotThreads(); @@ -158,6 +161,55 @@ QStringList TestNotmuchWorker::tagsOf(const QString &messageId) return {}; } +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>(); + + 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>(); + 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 threads = runQuery(QStringLiteral("*")); -- cgit v1.2.3