From 46acb489691e5298181ce52bfe847889ee06fe68 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 28 Aug 2026 18:37:47 +0200 Subject: feat: read a thread's digest from the index Senders, unread messages and an activity histogram for the dashboard, as a plain value struct over a queued signal. Everything comes from the index, so no message file is opened; the unread list is capped and unreadTotal carries the real number. --- tests/test_notmuchworker.cpp | 136 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) (limited to 'tests/test_notmuchworker.cpp') diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index f70a8f5..1766589 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -21,6 +21,7 @@ #include "notmuchfixture.h" #include "notmuchworker.h" +#include "threaddigest.h" #include "types.h" /// NotmuchWorker against a throwaway database. This is the only code in the @@ -120,6 +121,10 @@ private slots: void aQuerySeesMailIndexedAfterTheWorkerOpened(); + void aDigestCountsSendersAndUnread(); + void aDigestCapsItsUnreadListButNotItsCount(); + void aOneMessageThreadGivesASaneSpan(); + private: /// Adds one read message in `folder` and reindexes, for the move tests. /// Each of those takes its own message, because a move is destructive and @@ -2178,5 +2183,136 @@ void TestNotmuchWorker::aSplitIndexListsTheMaildirsFolders() "the index's own directory was listed as a mail folder"); } +void TestNotmuchWorker::aDigestCountsSendersAndUnread() +{ + NotmuchFixture fixture; + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("d0@example.org"), + QStringLiteral("Digest root"), + QStringLiteral("alice@example.org"), + QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"), + QStringLiteral("Root."), false)); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("d1@example.org"), + QStringLiteral("Re: Digest root"), + QStringLiteral("alice@example.org"), + QStringLiteral("Tue, 25 Aug 2026 10:00:00 +0200"), + QStringLiteral("Again."), false, + QStringLiteral("d0@example.org"))); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("d2@example.org"), + QStringLiteral("Re: Digest root"), + QStringLiteral("bob@example.org"), + QStringLiteral("Wed, 26 Aug 2026 10:00:00 +0200"), + QStringLiteral("Unread one."), true, + QStringLiteral("d0@example.org"))); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded); + + const QString threadId = worker.threadIdForTesting( + QStringLiteral("id:d0@example.org")); + QVERIFY(!threadId.isEmpty()); + worker.loadThreadDigest(threadId, 1); + + QCOMPARE(spy.count(), 1); + const ThreadDigest digest = spy.at(0).at(0).value(); + + QCOMPARE(digest.totalCount, 3); + QCOMPARE(digest.unreadTotal, 1); + QCOMPARE(digest.unread.size(), 1); + QCOMPARE(digest.unread.at(0).messageId, QStringLiteral("d2@example.org")); + + // Alice twice, Bob once, most prolific first. + QCOMPARE(digest.senders.size(), 2); + QCOMPARE(digest.senders.at(0).second, 2); + QCOMPARE(digest.senders.at(1).second, 1); + + QCOMPARE(digest.buckets.size(), ThreadDigest::kBuckets); + int summed = 0; + for (int n : digest.buckets) + summed += n; + QCOMPARE(summed, 3); + + // The dashboard reconstructs the busiest bucket's date from the span, so + // an index outside the histogram would name a date the thread never saw. + QVERIFY(digest.busiestBucket >= 0); + QVERIFY(digest.busiestBucket < ThreadDigest::kBuckets); + QVERIFY(digest.firstTimestamp <= digest.lastTimestamp); +} + +void TestNotmuchWorker::aDigestCapsItsUnreadListButNotItsCount() +{ + NotmuchFixture fixture; + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("c0@example.org"), + QStringLiteral("Cap root"), + QStringLiteral("alice@example.org"), + QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"), + QStringLiteral("Root."), false)); + for (int i = 1; i <= 8; ++i) { + QVERIFY(fixture.addMessage( + QStringLiteral("inbox"), + QStringLiteral("c%1@example.org").arg(i), + QStringLiteral("Re: Cap root"), + QStringLiteral("bob@example.org"), + QStringLiteral("Tue, 25 Aug 2026 %1:00:00 +0200") + .arg(i, 2, 10, QLatin1Char('0')), + QStringLiteral("Reply."), true, QStringLiteral("c0@example.org"))); + } + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded); + worker.loadThreadDigest( + worker.threadIdForTesting(QStringLiteral("id:c0@example.org")), 1); + + QCOMPARE(spy.count(), 1); + const ThreadDigest digest = spy.at(0).at(0).value(); + QCOMPARE(digest.unreadTotal, 8); + QCOMPARE(digest.unread.size(), ThreadDigest::kUnreadShown); + // Newest first: c8 is the latest. + QCOMPARE(digest.unread.at(0).messageId, QStringLiteral("c8@example.org")); +} + +void TestNotmuchWorker::aOneMessageThreadGivesASaneSpan() +{ + // The degenerate case the dashboard would otherwise divide by: one message + // is a zero-width span, and a bucket width of zero either divides by zero + // or throws every message into the last bucket. + NotmuchFixture fixture; + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("s0@example.org"), + QStringLiteral("Alone"), + QStringLiteral("alice@example.org"), + QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"), + QStringLiteral("Only."), false)); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded); + worker.loadThreadDigest( + worker.threadIdForTesting(QStringLiteral("id:s0@example.org")), 1); + + QCOMPARE(spy.count(), 1); + const ThreadDigest digest = spy.at(0).at(0).value(); + QCOMPARE(digest.totalCount, 1); + QCOMPARE(digest.buckets.size(), ThreadDigest::kBuckets); + QCOMPARE(digest.buckets.at(0), 1); + QCOMPARE(digest.busiestBucket, 0); + QCOMPARE(digest.firstTimestamp, digest.lastTimestamp); + + // An unknown thread yields an EMPTY digest rather than nothing at all, so a + // caller that arms state on the request always gets its reply. + QSignalSpy missing(&worker, &NotmuchWorker::threadDigestLoaded); + worker.loadThreadDigest(QStringLiteral("0000000000000000"), 2); + QCOMPARE(missing.count(), 1); + const ThreadDigest empty = missing.at(0).at(0).value(); + QCOMPARE(empty.totalCount, 0); + QCOMPARE(empty.buckets.size(), ThreadDigest::kBuckets); + QCOMPARE(empty.busiestBucket, -1); +} + QTEST_MAIN(TestNotmuchWorker) #include "test_notmuchworker.moc" -- cgit v1.2.3