aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_notmuchworker.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-16 21:58:18 +0200
committerDanilo M. <danix@danix.xyz>2026-08-16 21:58:18 +0200
commit019117aa8e52ce39cab58f77b57a9a67f510696f (patch)
treed96c71e0e43a777dcdbce05cb7f0e58f135139b1 /tests/test_notmuchworker.cpp
parentb405e3288bf625bd478204a065572e41a93fb4c3 (diff)
downloadqtmaildir-019117aa8e52ce39cab58f77b57a9a67f510696f.tar.gz
qtmaildir-019117aa8e52ce39cab58f77b57a9a67f510696f.zip
feat(ui): act on the message a row displays, not its whole thread
A thread's card has rendered one message since item 66, but every tag action still acted on the entire conversation. Delete, Archive, Important, Mark spam and Toggle unread now act on the message the card shows; the whole-thread versions move to a "Whole thread" submenu in the Message menu and the thread list's context menu, on Ctrl+Alt+<key>. Closes items 87, 88, 105, 106, 107, 108, 109, 110 and 111. The defects fixed along the way, several found by reading rather than by report: - threadAt(current.row()) answered about the wrong thread for a reply row, because a tree numbers rows per parent. The audit found four live sites, not the one reported: Delete and Toggle unread each chose their DIRECTION from an unrelated thread, and the tag dialog counted the wrong thread's tags. threadFor(index) replaces them. - A message-scoped write made no optimistic model update and no reply row carried a doomed cue, so acting on a reply moved the pending-edit count and changed nothing on screen. - Both toggles read the state of a reply's THREAD, which a message-scoped write never changes, so they were one-way: the second press re-sent a tag the message already had. - flushHeldEdits() re-sent only thread-scoped edits, so a tag change made on one message during a sync was applied to the row, counted as unsynced, and then dropped without ever being written. - applyTagChange() updated a thread's summary but not its loaded replies, leaving an expanded thread's rows describing a state the database no longer held. - A thread's first message is not among its children, so both message-scoped lookups missed it: acting on a root card repainted nothing and emptied the message pane's chip row. - ThreadSummary::tags is notmuch's union over the thread, so a card standing for one message drew tags belonging to its siblings. The worker now reads that message's own tags in the walk that already finds its id, so the split is known before a row is ever opened. The card shows both tiers: its own message's tags at full size, the rest of the conversation's smaller and muted, so nothing appears to vanish when a row is selected. Auto mark-read is message-scoped as a result, and now arms for a reply, which it never did. With maildir.synchronize_flags on, the old thread-wide write reached the server for mail that had never been displayed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_notmuchworker.cpp')
-rw-r--r--tests/test_notmuchworker.cpp50
1 files changed, 50 insertions, 0 deletions
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index de96dbf..899fd11 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -52,6 +52,7 @@ private slots:
void applyTagsIgnoresUnknownMessageIds();
void applyTagsWithNoIdsDoesNothing();
void queryStillWorksAfterWrite();
+ void aThreadCarriesItsCardMessagesOwnTags();
void applyTagsToThreadsTagsEveryMessage();
void applyTagsToThreadsSpansMultipleThreads();
@@ -274,6 +275,55 @@ void TestNotmuchWorker::aQueryCarriesEachThreadsFirstMessageId()
QVERIFY2(sawTheThread, "the two-message thread was not in the results");
}
+void TestNotmuchWorker::aThreadCarriesItsCardMessagesOwnTags()
+{
+ // Item 111. A card draws its own message's tags at full size and the rest
+ // of the conversation's smaller, so it needs BOTH: `tags` is notmuch's
+ // union over the thread and `firstMessageTags` is the one message the card
+ // stands for.
+ //
+ // Derived from the message LOAD at first, which meant an unopened row had
+ // no split and drew everything as its own, correcting itself only when the
+ // user selected it. The user reported exactly that. The query knows, and
+ // the walk that finds firstMessageId is already holding the message, so
+ // this is the same index read rather than a second pass.
+
+ // A tag on the REPLY only, which is the case that separates the two: a1 is
+ // the card's message, a2 its reply.
+ NotmuchWorker writer(m_fixture.configPath());
+ writer.applyTags(TagChange{ { QStringLiteral("a2@example.org") },
+ { QStringLiteral("signed") },
+ {},
+ QStringLiteral("Sign the reply") });
+
+ const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*"));
+ bool sawTheThread = false;
+ for (const ThreadSummary &t : threads) {
+ if (t.subject != QStringLiteral("Release notes"))
+ continue;
+ sawTheThread = true;
+
+ QCOMPARE(t.firstMessageId, QStringLiteral("a1@example.org"));
+
+ // The union carries the reply's tag, as notmuch reports it.
+ QVERIFY2(t.tags.contains(QStringLiteral("signed")),
+ "the thread's own tags stopped being the union, which the "
+ "sibling tier is derived from");
+
+ // The card's message does not, and this is the whole point: without it
+ // the card claims a tag belonging to a message it does not display.
+ QVERIFY2(!t.firstMessageTags.isEmpty(),
+ "the query carried no per-message tags, so an unopened row "
+ "has no split and draws every chip at full size");
+ QVERIFY2(!t.firstMessageTags.contains(QStringLiteral("signed")),
+ "the card's message was given its reply's tag");
+
+ // And it does carry its own.
+ QVERIFY(t.firstMessageTags.contains(QStringLiteral("inbox")));
+ }
+ QVERIFY2(sawTheThread, "the two-message thread was not in the results");
+}
+
void TestNotmuchWorker::aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst()
{
// THE case the Sent branch exists for, and the one hardest to get right.