aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_notmuchworker.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_notmuchworker.cpp')
-rw-r--r--tests/test_notmuchworker.cpp164
1 files changed, 151 insertions, 13 deletions
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 88dcf0b..fe0247c 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -65,6 +65,13 @@ private slots:
void loadThreadTreeReportsReplyDepth();
void loadThreadTreeCarriesTheFactsARowNeeds();
+ void loadThreadMatchedOnlyDropsTheRest();
+ void loadThreadMatchedOnlyWithNoQueryKeepsEverything();
+
+ void recipientsAreAbsentUnlessAskedFor();
+ void recipientsAreFoldedWhenAskedFor();
+ void recipientsCrossAQueuedCall();
+
void requestCountsAnswersOneCountPerQuery();
void requestCountsKeepsPositionOnAnInvalidQuery();
void requestDatabaseStatsCountsMessagesNotThreads();
@@ -74,10 +81,12 @@ private:
/// Tags of one message, read back through a fresh worker query.
QStringList tagsOf(const QString &messageId);
QVector<MessageRef> messagesOfThread(const QString &threadId,
- const QString &matchQuery = QString());
+ const QString &matchQuery = QString(),
+ bool matchedOnly = false);
QVector<ThreadSummary> runQuery(
const QString &query,
- NotmuchWorker::SortOrder sort = NotmuchWorker::NewestFirst);
+ NotmuchWorker::SortOrder sort = NotmuchWorker::NewestFirst,
+ bool withRecipients = false);
QString threadIdOf(const QString &subject);
NotmuchFixture m_fixture;
@@ -114,17 +123,38 @@ void TestNotmuchWorker::initTestCase()
QStringLiteral("Thu, 4 Jun 2026 10:00:00 +0000"),
QStringLiteral("fourth message"), false));
+ // Thread D: in a "sent" folder, with real recipients. The To header is the
+ // only thing that distinguishes these from the threads above, and it is
+ // what the recipient fold reads.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("d1@example.org"),
+ QStringLiteral("Preventivo"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Fri, 5 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("fifth message"), false, QString(),
+ QStringLiteral("Mario Rossi <mario@example.org>")));
+
+ // Thread E: several recipients, one of them with a comma inside a quoted
+ // display name, which is what defeats splitting on commas.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("e1@example.org"),
+ QStringLiteral("Riunione"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Sat, 6 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("sixth message"), false, QString(),
+ QStringLiteral("\"Rossi, Mario\" <mario@example.org>, "
+ "info@example.net, "
+ "third@example.org")));
+
QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error()));
}
QVector<ThreadSummary> TestNotmuchWorker::runQuery(
- const QString &query, NotmuchWorker::SortOrder sort)
+ const QString &query, NotmuchWorker::SortOrder sort, bool withRecipients)
{
NotmuchWorker worker(m_fixture.configPath());
QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
QSignalSpy finished(&worker, &NotmuchWorker::queryFinished);
- worker.runQuery(query, 1, sort);
+ worker.runQuery(query, 1, sort, withRecipients);
QVector<ThreadSummary> all;
for (const QList<QVariant> &args : ready)
@@ -143,11 +173,12 @@ QString TestNotmuchWorker::threadIdOf(const QString &subject)
}
QVector<MessageRef> TestNotmuchWorker::messagesOfThread(const QString &threadId,
- const QString &matchQuery)
+ const QString &matchQuery,
+ bool matchedOnly)
{
NotmuchWorker worker(m_fixture.configPath());
QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded);
- worker.loadThread(threadId, matchQuery, 1);
+ worker.loadThread(threadId, matchQuery, 1, matchedOnly);
if (loaded.isEmpty())
return {};
return loaded.first().at(0).value<QVector<MessageRef>>();
@@ -256,7 +287,7 @@ void TestNotmuchWorker::loadThreadTreeCarriesTheFactsARowNeeds()
void TestNotmuchWorker::queryReturnsAllThreads()
{
const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*"));
- QCOMPARE(threads.size(), 3);
+ QCOMPARE(threads.size(), 5);
}
void TestNotmuchWorker::queryFiltersByTag()
@@ -325,7 +356,7 @@ void TestNotmuchWorker::queryPassesGenerationThrough()
QCOMPARE(ready.size(), 1);
QCOMPARE(ready.first().at(1).value<quint64>(), quint64(42));
QCOMPARE(finished.size(), 1);
- QCOMPARE(finished.first().at(0).toInt(), 3);
+ QCOMPARE(finished.first().at(0).toInt(), 5);
QCOMPARE(finished.first().at(1).value<quint64>(), quint64(42));
}
@@ -513,7 +544,7 @@ void TestNotmuchWorker::queryStillWorksAfterWrite()
worker.runQuery(QStringLiteral("*"), 2);
QCOMPARE(ready.size(), 2);
- QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 3);
+ QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 5);
worker.applyTags(change.inverted());
}
@@ -611,6 +642,113 @@ void TestNotmuchWorker::requestAllTagsOnUnreadableConfigEmitsError()
QVERIFY(ready.isEmpty());
}
+void TestNotmuchWorker::loadThreadMatchedOnlyDropsTheRest()
+{
+ // Thread A is two messages, and only the reply carries "hamsterwheel".
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ // Without the flag: both messages, the non-matching one marked as a stub.
+ // This is the reading pane's normal behaviour and must not change.
+ const QVector<MessageRef> whole =
+ messagesOfThread(threadId, QStringLiteral("hamsterwheel"));
+ QCOMPARE(whole.size(), 2);
+
+ // With it: only the message that matched. The pane in a Sent view shows
+ // what the user sent, not the conversation their message started.
+ const QVector<MessageRef> matched =
+ messagesOfThread(threadId, QStringLiteral("hamsterwheel"), true);
+ QCOMPARE(matched.size(), 1);
+ QVERIFY(matched.at(0).matched);
+ QCOMPARE(matched.at(0).messageId, QStringLiteral("a2@example.org"));
+}
+
+void TestNotmuchWorker::loadThreadMatchedOnlyWithNoQueryKeepsEverything()
+{
+ // No query means nothing was filtered, so every message counts as matched
+ // and the flag has nothing to drop.
+ //
+ // This does NOT prove the haveMatchSet guard in loadThread: ref.matched is
+ // already true for every message in this case, so removing that guard
+ // leaves this passing, confirmed by mutation. It pins the BEHAVIOUR, which
+ // is what a caller depends on, and the guard is a stated invariant rather
+ // than a branch a test can reach.
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ const QVector<MessageRef> all = messagesOfThread(threadId, QString(), true);
+ QCOMPARE(all.size(), 2);
+}
+
+void TestNotmuchWorker::recipientsAreAbsentUnlessAskedFor()
+{
+ // Opt-in, and this is a PERFORMANCE contract rather than a preference.
+ // notmuch_message_get_header(m, "To") is not served from the index, it
+ // reads the message file: measured 2026-08-11 against a real database,
+ // folding every thread of a 4411-thread inbox took 38.2 seconds, 8.7 ms
+ // per thread, against 1.1 ms per thread over the 601-thread sent view.
+ //
+ // A version that always folds is correct in every other respect, which is
+ // exactly why it needs a test: nothing else here would notice.
+ const QVector<ThreadSummary> threads =
+ runQuery(QStringLiteral("subject:Preventivo"));
+
+ QCOMPARE(threads.size(), 1);
+ QVERIFY2(threads.at(0).recipients.isEmpty(),
+ "the To header was read for a query that never asked for it");
+}
+
+void TestNotmuchWorker::recipientsAreFoldedWhenAskedFor()
+{
+ const QVector<ThreadSummary> one =
+ runQuery(QStringLiteral("subject:Preventivo"),
+ NotmuchWorker::NewestFirst, true);
+ QCOMPARE(one.size(), 1);
+ QCOMPARE(one.at(0).recipients, QStringLiteral("Mario Rossi"));
+
+ // The comma-inside-a-display-name case, end to end through the worker
+ // rather than only against recipientSummary(): the header survives being
+ // written to a real maildir, indexed, and read back out of notmuch.
+ const QVector<ThreadSummary> many =
+ runQuery(QStringLiteral("subject:Riunione"),
+ NotmuchWorker::NewestFirst, true);
+ QCOMPARE(many.size(), 1);
+
+ const QString summary = many.at(0).recipients;
+ QVERIFY2(summary.startsWith(QStringLiteral("Rossi, Mario")),
+ qPrintable(QStringLiteral("lost the quoted display name: %1")
+ .arg(summary)));
+ QVERIFY2(summary.endsWith(QStringLiteral("+1")),
+ qPrintable(QStringLiteral("three recipients did not collapse to "
+ "two plus one: %1").arg(summary)));
+}
+
+void TestNotmuchWorker::recipientsCrossAQueuedCall()
+{
+ // The trap CLAUDE.md records for SortOrder, in the shape it takes for this
+ // argument. A bool is a registered metatype already, so this cannot fail
+ // the way an unregistered enum would, and the test exists to prove that
+ // rather than to assume it: the flag arriving as a default-constructed
+ // false would silently give an empty recipients column and nothing else.
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+
+ QVERIFY(QMetaObject::invokeMethod(
+ &worker, "runQuery", Qt::DirectConnection,
+ Q_ARG(QString, QStringLiteral("subject:Preventivo")),
+ Q_ARG(quint64, 1),
+ Q_ARG(NotmuchWorker::SortOrder, NotmuchWorker::NewestFirst),
+ Q_ARG(bool, true)));
+
+ QVector<ThreadSummary> all;
+ for (const QList<QVariant> &args : ready)
+ all += args.at(0).value<QVector<ThreadSummary>>();
+
+ QCOMPARE(all.size(), 1);
+ QVERIFY2(!all.at(0).recipients.isEmpty(),
+ "the recipients flag was dropped crossing invokeMethod");
+}
+
void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
{
NotmuchWorker worker(m_fixture.configPath());
@@ -626,7 +764,7 @@ void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
// Threads, not messages: thread A holds two messages and must count once,
// which is the number the pane's "N in inbox" line claims to be showing.
const QVector<int> counts = spy.at(0).at(0).value<QVector<int>>();
- QCOMPARE(counts, QVector<int>({ 1, 3, 0 }));
+ QCOMPARE(counts, QVector<int>({ 1, 5, 0 }));
}
void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
@@ -656,7 +794,7 @@ void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
// The queries either side keep their own answers, which is the property
// the pane depends on.
QCOMPARE(counts.at(0), 1);
- QCOMPARE(counts.at(2), 3);
+ QCOMPARE(counts.at(2), 5);
}
void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
@@ -676,8 +814,8 @@ void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
// one counts messages, which is what a user means by "how much mail". A
// reimplementation that reused the thread count would report 3 here and be
// confidently wrong under the label "messages".
- QCOMPARE(stats.messages, 4);
- QCOMPARE(stats.threads, 3);
+ QCOMPARE(stats.messages, 6);
+ QCOMPARE(stats.threads, 5);
QVERIFY2(stats.messages != stats.threads,
"messages and threads are equal, so this fixture cannot prove the "
"two counts are distinct: add a reply to it");