aboutsummaryrefslogtreecommitdiffstats
path: root/tests
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
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')
-rw-r--r--tests/test_carddelegate.cpp128
-rw-r--r--tests/test_mainwindow.cpp1001
-rw-r--r--tests/test_notmuchworker.cpp50
-rw-r--r--tests/test_threadlistmodel.cpp613
4 files changed, 1778 insertions, 14 deletions
diff --git a/tests/test_carddelegate.cpp b/tests/test_carddelegate.cpp
index d5e55b5..a14671d 100644
--- a/tests/test_carddelegate.cpp
+++ b/tests/test_carddelegate.cpp
@@ -18,6 +18,9 @@
#include "carddelegate.h"
+#include "cardlayout.h"
+#include "tagchip.h"
+#include "tagcolors.h"
#include "threadlistmodel.h"
#include <QTest>
@@ -30,6 +33,9 @@ private slots:
void theAccentLiftsAMutedAccountColour();
void theAccentKeepsEachAccountTellableApart();
void anAccountWithNoColourFallsBackToTheNeutralLine();
+ void aSiblingChipIsMutedButStaysLegibleAndRecognisable();
+ void aSiblingChipFontIsSmallerThanItsOwnTier();
+ void aSiblingChipsPaddingShrinksWithItsFont();
};
namespace {
@@ -131,5 +137,127 @@ void TestCardDelegate::anAccountWithNoColourFallsBackToTheNeutralLine()
ThreadListModel::threadLineColour());
}
+void TestCardDelegate::aSiblingChipIsMutedButStaysLegibleAndRecognisable()
+{
+ // Item 111: a card shows its own tags at full size and the rest of the
+ // conversation's smaller and muted. "Muted" has two hard requirements that
+ // a look at the screen will not catch, so they are asserted here.
+ for (const QColor &colour : sampleAccounts()) {
+ const QColor muted = CardDelegate::mutedChipColour(colour);
+
+ // Actually muted, or the tier is not distinguishable at all.
+ QVERIFY2(saturationOf(muted) < saturationOf(colour),
+ qPrintable(QStringLiteral("%1 was not drained at all")
+ .arg(colour.name())));
+
+ // Same HUE. A sibling's `signed` has to stay recognisably the same
+ // colour as a full-size `signed` elsewhere in the list, or the muting
+ // reads as a different tag rather than a quieter one.
+ float h1 = 0, h2 = 0, s = 0, l = 0, a = 0;
+ colour.getHslF(&h1, &s, &l, &a);
+ muted.getHslF(&h2, &s, &l, &a);
+ QVERIFY2(qAbs(h1 - h2) < 0.001f,
+ qPrintable(QStringLiteral("%1 changed hue when muted")
+ .arg(colour.name())));
+
+ // Same LIGHTNESS, which is what keeps the text legible: TagColors
+ // picks the text colour from the fill, and a fill that drifted toward
+ // black or white could flip that choice or land mid-grey where neither
+ // works. Blending toward the background would do exactly that, which
+ // is the mistake accentLineColour() records.
+ QCOMPARE(lightnessOf(muted), lightnessOf(colour));
+ QCOMPARE(TagColors::textColourOn(muted),
+ TagColors::textColourOn(colour));
+ }
+
+ // An invalid colour stays invalid rather than becoming a real one.
+ QVERIFY(!CardDelegate::mutedChipColour(QColor()).isValid());
+}
+
+void TestCardDelegate::aSiblingChipFontIsSmallerThanItsOwnTier()
+{
+ // Size is what says whose tag a chip is, so the two tiers must differ, and
+ // by enough to SEE. The first version subtracted a point from smallFont(),
+ // and the user reported the tiers as indistinguishable: on their 14pt
+ // desktop that gave 13 and 12, a 7% step.
+ //
+ // The step is now a fraction of the card font, so it does not shrink as
+ // the desktop's font grows. Asserted as a ratio rather than as a size, to
+ // keep this about the DISTINCTION rather than about the constant.
+ QFont card;
+ card.setPointSizeF(14.0); // The user's own desktop size.
+ const qreal own = CardLayout::smallFont(card).pointSizeF();
+ const qreal sibling = CardLayout::siblingFont(card).pointSizeF();
+
+ QVERIFY(sibling < own);
+ QVERIFY2(sibling < own * 0.85,
+ qPrintable(QStringLiteral("sibling %1pt against own %2pt is under "
+ "a 15%% step, which reads as the same "
+ "size")
+ .arg(sibling)
+ .arg(own)));
+
+ // Proportional, not a fixed subtraction: the step must survive a larger
+ // desktop font rather than becoming proportionally smaller.
+ QFont big;
+ big.setPointSizeF(28.0);
+ QVERIFY(CardLayout::siblingFont(big).pointSizeF()
+ < CardLayout::smallFont(big).pointSizeF() * 0.85);
+
+ // The pixel branch too: qt6ct sets fonts in PIXELS, and pointSizeF() is -1
+ // for those, so a point-only implementation silently returns the original
+ // size and both tiers render identically. CLAUDE.md records this trap.
+ QFont pixels;
+ pixels.setPixelSize(14);
+ QVERIFY(pixels.pointSizeF() < 0);
+ QVERIFY2(CardLayout::siblingFont(pixels).pixelSize()
+ < CardLayout::smallFont(pixels).pixelSize(),
+ "a pixel-sized desktop font gives both tiers the same size, so "
+ "the distinction disappears entirely");
+
+ // Floored rather than shrinking without limit.
+ QFont tiny;
+ tiny.setPointSizeF(6.0);
+ QVERIFY(CardLayout::siblingFont(tiny).pointSizeF() >= 6.0);
+}
+
+void TestCardDelegate::aSiblingChipsPaddingShrinksWithItsFont()
+{
+ // Half of "smaller" is the padding, and leaving it fixed is why the first
+ // version still looked the same size. kPaddingX is 9 a side: on a sibling
+ // chip that is 18px of padding around roughly 30px of text, so the chip
+ // stayed wide while its letters shrank, which reads as "same chip, smaller
+ // text" rather than as a smaller chip.
+ QFont card;
+ card.setPointSizeF(14.0);
+ const QFontMetrics ownMetrics(CardLayout::smallFont(card));
+ const QFontMetrics siblingMetrics(CardLayout::siblingFont(card));
+
+ const QString tag = QStringLiteral("signed");
+ // Through CardDelegate::chipSize(), which is what the paint loop calls.
+ // Calling TagChip::sizeFor() directly here proved what THAT function does
+ // and nothing about whether the delegate asks it for a scaled padding: a
+ // mutation dropping the scale at the call site survived that version of
+ // this test.
+ const QSize own = CardDelegate::chipSize(ownMetrics, tag, true);
+ const QSize scaled = CardDelegate::chipSize(siblingMetrics, tag, false);
+ const QSize unscaled = TagChip::sizeFor(siblingMetrics, tag);
+
+ // The font alone is not enough: scaling the padding as well takes off
+ // measurably more width.
+ QVERIFY2(scaled.width() < unscaled.width(),
+ "the padding did not scale, so the chip keeps full-size margins "
+ "around smaller letters");
+ QVERIFY(scaled.width() < own.width());
+ QVERIFY(scaled.height() < own.height());
+
+ // Floored rather than collapsing to nothing: the corner radius is half the
+ // height, so a chip with no horizontal padding has its text on the curve.
+ const QSize tiny = TagChip::sizeFor(siblingMetrics, tag, 0.0);
+ QVERIFY2(tiny.width() > siblingMetrics.horizontalAdvance(tag),
+ "a zero scale left no horizontal padding at all, so the text sits "
+ "on the chip's rounded end");
+}
+
QTEST_MAIN(TestCardDelegate)
#include "test_carddelegate.moc"
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index f4357c6..07cc56b 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -57,6 +57,7 @@
#include <QComboBox>
#include <QScrollBar>
#include "tagchip.h"
+#include "tagstrip.h"
#include "threadlistmodel.h"
#include "threadlistview.h"
#include "notmuchfixture.h"
@@ -273,6 +274,23 @@ private slots:
void escapeBlanksTheMessagePane();
void deleteTogglesOnAnAlreadyDeletedThread();
void deleteOnAMixedSelectionDeletesRatherThanSplittingIt();
+ void deleteOnAReplyReadsItsOwnThreadNotTheFirstInTheList();
+ void toggleUnreadOnAReplyReadsItsOwnThreadNotTheFirstInTheList();
+ void editTagsOnAReplyCountsItsOwnThreadNotTheFirstInTheList();
+ void markCurrentThreadReadResolvesTheThreadThroughTheIndex();
+ void deletingAReplyRepaintsThatReplyRow();
+ void toggleUnreadOnAReplyReadsTheReplysOwnState();
+ void toggleUnreadOnAReplyRepaintsItInBothDirections();
+ void taggingTheOpenReplyUpdatesTheMessagePaneStrip();
+ void taggingAnUnrelatedReplyLeavesTheStripAlone();
+ void aHeldMessageEditIsSentWhenTheSyncEnds();
+ void anActionOnAThreadRowActsOnTheMessageItDisplays();
+ void theThreadSubmenuIsReachableFromBothMenus();
+ void autoMarkReadTouchesOnlyTheMessageOnDisplay();
+ void autoMarkReadArmsForAReplyToo();
+ void taggingTheOpenRootMessageKeepsTheStripPopulated();
+ void aLoadedMessageCorrectsTheStripFromTheThreadsUnion();
+ void aLoadedRootMessageGivesTheCardItsOwnTags();
void aTransientStatusMessageExpires();
void theSelectionCountIsStateAndDoesNotExpire();
void anEditUndoneNettsBackToZero();
@@ -790,6 +808,13 @@ static ThreadSummary makeThread(const QString &id, const QStringList &tags)
thread.subject = QStringLiteral("Subject ") + id;
thread.authors = QStringLiteral("Someone <someone@example.org>");
thread.tags = tags;
+
+ // The message the row displays, which the real worker fills in from the
+ // query. Required since item 108: an ordinary tag action resolves a thread
+ // row to THIS id, so a summary without one names no message and every
+ // action on it does nothing. A fixture missing it fails with "the action
+ // did not happen", which reads as a defect in the action.
+ thread.firstMessageId = id + QStringLiteral("-first@example.org");
return thread;
}
@@ -1372,8 +1397,11 @@ void TestMainWindow::anActionOnAThreadRowSaysItHitTheWholeThread()
selectThreadRow(view, 0);
QApplication::processEvents();
- auto *archive = window.findChild<QAction *>(QStringLiteral("archive"));
- QVERIFY2(archive, "no archive action to trigger");
+ // The THREAD action since item 108. The plain `archive` now acts on the
+ // one message a card displays, and would rightly not claim to have taken
+ // the whole thread; this suffix belongs to the action that really does.
+ auto *archive = window.findChild<QAction *>(QStringLiteral("archive_thread"));
+ QVERIFY2(archive, "no archive_thread action to trigger");
archive->trigger();
// Read BEFORE processEvents, deliberately. This binary has no worker
@@ -4327,7 +4355,13 @@ void TestMainWindow::aHeldEditIsSentBeforeTheSyncEndRefreshReadsTheDatabase()
// The write went out. Nothing else in this handler sends one, so its
// presence is what proves the flush ran, and the refresh below is what it
// has to have run BEFORE.
- QVERIFY2(!window.pendingThreadIdsForTesting().isEmpty(),
+ //
+ // Either scope counts. The gesture is Toggle unread on a thread row, which
+ // is message-scoped since item 108, so the ids land in the message list;
+ // the ORDER this test exists for is the same either way, and pinning the
+ // scope here would make it fail for a reason it does not care about.
+ QVERIFY2(!window.pendingThreadIdsForTesting().isEmpty()
+ || !window.pendingMessageIdsForTesting().isEmpty(),
"the held edit was dropped rather than sent");
const quint64 after = window.currentGenerationForTesting();
@@ -4593,7 +4627,10 @@ void TestMainWindow::deleteTogglesOnAnAlreadyDeletedThread()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ // The THREAD action, since this is about a THREAD's state. Item 108 made
+ // the plain `delete` act on the one message a card displays, and a thread
+ // summary carrying `deleted` says nothing about that message's own tags.
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete_thread"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"),
@@ -4621,7 +4658,7 @@ void TestMainWindow::deleteOnAMixedSelectionDeletesRatherThanSplittingIt()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete_thread"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"),
@@ -4637,6 +4674,889 @@ void TestMainWindow::deleteOnAMixedSelectionDeletesRatherThanSplittingIt()
"a mixed selection split instead of deleting the whole selection");
}
+/// Builds a window whose SECOND thread is expanded and carries one reply, with
+/// the two threads deliberately in opposite states.
+///
+/// Item 88's shape in one place. A tree numbers rows per parent, so the first
+/// reply of any thread has row() == 0 and threadAt(0) answers about the FIRST
+/// THREAD IN THE LIST. Every test below selects that reply and asserts on
+/// behaviour that can only be right if the thread was resolved through the
+/// index: with the row number, each one reads t1's state while acting on t2.
+///
+/// The opposite states are what makes the tests able to fail. Two threads in
+/// the same state give the same answer either way, which is how the reverted
+/// item 87 fix passed while corrupting mail.
+///
+/// \p replyTags defaults to the thread's own tags, which is the usual case.
+/// Pass it explicitly to make a reply DISAGREE with its thread, which is what
+/// separates "reads the right thread" from "reads the right message": a reply
+/// can be unread inside a thread that is not, and vice versa.
+static QModelIndex expandSecondThreadAndSelectItsReply(
+ QTreeView *view, ThreadListModel *model, const QStringList &firstTags,
+ const QStringList &secondTags,
+ const std::optional<QStringList> &replyTags = std::nullopt)
+{
+ ThreadSummary first = makeThread(QStringLiteral("t1"), firstTags);
+ ThreadSummary second = makeThread(QStringLiteral("t2"), secondTags);
+ second.totalCount = 2;
+ model->appendBatch({ first, second });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m0@example.org");
+ root.threadId = QStringLiteral("t2");
+ root.tags = secondTags;
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m1@example.org");
+ reply.threadId = QStringLiteral("t2");
+ reply.tags = replyTags.value_or(secondTags);
+ reply.depth = 1;
+ model->setThreadMessages(QStringLiteral("t2"), { root, reply });
+
+ const QModelIndex threadRow = model->index(1, 0, QModelIndex());
+ view->expand(threadRow);
+
+ const QModelIndex replyRow = model->index(0, 0, threadRow);
+ if (!replyRow.isValid() || !model->isMessageRow(replyRow))
+ return {};
+
+ // Row 0 under its parent, which is the trap: the number is a plausible
+ // top-level row and threadAt() cannot tell the difference.
+ if (replyRow.row() != 0)
+ return {};
+
+ view->selectionModel()->select(
+ replyRow, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ view->setCurrentIndex(replyRow);
+ QApplication::processEvents();
+ return replyRow;
+}
+
+void TestMainWindow::deleteOnAReplyReadsItsOwnThreadNotTheFirstInTheList()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(action);
+
+ // t1 deleted, t2 not. Reading t1's state for a reply of t2 makes the
+ // toggle choose UNDELETE for a thread that was never deleted.
+ const QModelIndex reply = expandSecondThreadAndSelectItsReply(
+ view, model, { QStringLiteral("deleted") }, {});
+ QVERIFY2(reply.isValid(),
+ "the fixture did not produce a reply row at row 0, so this test "
+ "would assert nothing about item 88's trap");
+
+ action->trigger();
+
+ // Delete, because the message's own thread is not deleted. The write goes
+ // through scopeFor() and lands on the message either way; what is under
+ // test is the DIRECTION, which is chosen from the state that was read.
+ QVERIFY2(window.pendingMessageIdsForTesting().contains(
+ QStringLiteral("m1@example.org")),
+ "Delete on a reply did not act on that reply");
+ QCOMPARE(window.undoDepthForTesting(), 1);
+ QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("Delete")),
+ qPrintable(QStringLiteral(
+ "Delete on a reply of an undeleted thread chose "
+ "the wrong direction: %1. It read the FIRST "
+ "thread's state, which is deleted.")
+ .arg(window.undoTextForTesting())));
+}
+
+void TestMainWindow::toggleUnreadOnAReplyReadsItsOwnThreadNotTheFirstInTheList()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(action);
+
+ // t1 unread, t2 read. Reading t1's state marks an already-read message
+ // read again, which is a no-op write the user sees as a dead key.
+ const QModelIndex reply = expandSecondThreadAndSelectItsReply(
+ view, model, { QStringLiteral("unread") }, {});
+ QVERIFY2(reply.isValid(), "the fixture did not produce a reply row at row 0");
+
+ action->trigger();
+
+ QCOMPARE(window.undoDepthForTesting(), 1);
+ QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("Mark unread")),
+ qPrintable(QStringLiteral(
+ "Toggle unread on a reply of a READ thread chose "
+ "the wrong direction: %1. It read the FIRST "
+ "thread's state, which is unread.")
+ .arg(window.undoTextForTesting())));
+}
+
+void TestMainWindow::editTagsOnAReplyCountsItsOwnThreadNotTheFirstInTheList()
+{
+ // The tag dialog is modal, so what is tested is the count it is BUILT
+ // from. Those counts drive its tri-state checkboxes, so a wrong count
+ // offers to remove a tag the message does not carry and shows the ones it
+ // does as unset.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ const QModelIndex reply = expandSecondThreadAndSelectItsReply(
+ view, model, { QStringLiteral("t1only") }, { QStringLiteral("t2only") });
+ QVERIFY2(reply.isValid(), "the fixture did not produce a reply row at row 0");
+
+ const QHash<QString, int> counts = window.selectionTagCountsForTesting();
+
+ QVERIFY2(counts.contains(QStringLiteral("t2only")),
+ "the tag dialog would not offer the tag the selected reply "
+ "actually carries");
+ QVERIFY2(!counts.contains(QStringLiteral("t1only")),
+ "the tag dialog counted the FIRST thread's tags for a reply of "
+ "the second, so it would offer to remove a tag that is not there");
+}
+
+void TestMainWindow::markCurrentThreadReadResolvesTheThreadThroughTheIndex()
+{
+ // Item 87 is blocked on this and will scope the write to one message. Today
+ // an unrelated guard hides the defect: onThreadSelected clears
+ // m_currentThreadId for a message row, so markCurrentThreadRead returns
+ // before it can read the wrong thread. That guard is not the protection
+ // this needs, and item 87 does not remove it, so the assertion here is on
+ // the resolution itself rather than on a write that cannot currently
+ // happen.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ const QModelIndex reply = expandSecondThreadAndSelectItsReply(
+ view, model, { QStringLiteral("unread") }, { QStringLiteral("unread") });
+ QVERIFY2(reply.isValid(), "the fixture did not produce a reply row at row 0");
+
+ // The question the timer's handler asks, in isolation: which thread is the
+ // current row part of. With the row number this answers "t1" for a reply
+ // of t2.
+ QCOMPARE(window.threadForCurrentRowForTesting().threadId,
+ QStringLiteral("t2"));
+}
+
+void TestMainWindow::deletingAReplyRepaintsThatReplyRow()
+{
+ // The user's report, at the gesture level: "I'm hitting delete on a reply
+ // to a thread, I see the edits counter increasing but I have no feedback
+ // if that message is being deleted." The model-level test proves
+ // applyMessageTagChange works; this proves the action reaches it, which is
+ // the half that was actually missing.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(action);
+
+ const QModelIndex reply =
+ expandSecondThreadAndSelectItsReply(view, model, {}, {});
+ QVERIFY2(reply.isValid(), "the fixture did not produce a reply row at row 0");
+
+ // Nothing to see before the gesture, so the assertion after it means
+ // something.
+ QVERIFY(!model->messageAt(reply).isDeleted());
+ const QVariant before = model->data(reply, Qt::BackgroundRole);
+
+ QSignalSpy spy(model, &QAbstractItemModel::dataChanged);
+ action->trigger();
+
+ QVERIFY2(model->messageAt(reply).isDeleted(),
+ "Delete on a reply left the reply's own row unchanged, so the "
+ "pending count moved and the user saw nothing");
+ QVERIFY2(spy.count() >= 1, "no repaint was requested for the reply's row");
+ QVERIFY2(model->data(reply, Qt::BackgroundRole) != before,
+ "the deleted reply paints exactly as it did before");
+
+ // The THREAD row must not follow: it stands for the whole conversation,
+ // and one deleted reply does not doom it.
+ const QModelIndex threadRow = reply.parent();
+ QVERIFY2(!model->threadFor(threadRow).isDeleted(),
+ "deleting one reply marked its whole thread deleted");
+}
+
+void TestMainWindow::toggleUnreadOnAReplyReadsTheReplysOwnState()
+{
+ // The user's report: "read/unread still doesn't trigger a repaint of the
+ // reply". The write was already message-scoped and the model already
+ // repaints a message row, so neither was the fault. The DIRECTION was:
+ // the action read threadFor(current).isUnread(), the THREAD's state, even
+ // when the selected row is a reply.
+ //
+ // The consequence is a dead key rather than a wrong write. On a read
+ // thread the answer is always "add unread", so pressing it on an
+ // already-unread reply re-adds a tag it has, which is a no-op the model
+ // correctly declines to repaint. Item 88 fixed WHICH thread this reads;
+ // this is about reading a message at all.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(action);
+
+ // A thread that is READ carrying a reply that is UNREAD. That disagreement
+ // is the whole test: with the thread's state the answer is "mark unread",
+ // with the message's it is "mark read".
+ const QModelIndex reply = expandSecondThreadAndSelectItsReply(
+ view, model, {}, {}, QStringList{ QStringLiteral("unread") });
+ QVERIFY2(reply.isValid(), "the fixture did not produce a reply row at row 0");
+ QVERIFY(model->messageAt(reply).isUnread());
+ QVERIFY2(!model->threadFor(reply).isUnread(),
+ "the fixture's thread is unread too, so this test cannot tell the "
+ "two sources apart");
+
+ action->trigger();
+
+ QVERIFY2(!model->messageAt(reply).isUnread(),
+ "Toggle unread on an unread reply did not mark it read: the "
+ "direction came from the THREAD, which is already read, so it "
+ "re-added a tag the reply already had and nothing changed");
+ QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("Mark read")),
+ qPrintable(QStringLiteral("wrong direction: %1")
+ .arg(window.undoTextForTesting())));
+}
+
+void TestMainWindow::toggleUnreadOnAReplyRepaintsItInBothDirections()
+{
+ // Visible BOTH ways. The user reached the repaint only by deleting and
+ // undoing, which is a different write forcing the row to redraw; the
+ // unread change itself has to do it on its own, in each direction.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(action);
+
+ const QModelIndex reply = expandSecondThreadAndSelectItsReply(
+ view, model, {}, {}, QStringList{ QStringLiteral("unread") });
+ QVERIFY(reply.isValid());
+
+ const QVariant unreadForeground = model->data(reply, Qt::ForegroundRole);
+ const QVariant unreadFont = model->data(reply, Qt::FontRole);
+
+ QSignalSpy spy(model, &QAbstractItemModel::dataChanged);
+ action->trigger();
+
+ QVERIFY2(spy.count() >= 1, "marking a reply read requested no repaint");
+ QVERIFY2(model->data(reply, Qt::ForegroundRole) != unreadForeground,
+ "a reply marked read paints exactly as it did while unread");
+ QVERIFY2(model->data(reply, Qt::FontRole) != unreadFont,
+ "a reply marked read keeps the unread font");
+
+ // And back. A toggle that is only visible one way is half a toggle.
+ spy.clear();
+ action->trigger();
+ QVERIFY(model->messageAt(reply).isUnread());
+ QVERIFY2(spy.count() >= 1, "marking a reply unread again requested no repaint");
+ QCOMPARE(model->data(reply, Qt::ForegroundRole), unreadForeground);
+ QCOMPARE(model->data(reply, Qt::FontRole), unreadFont);
+}
+
+void TestMainWindow::taggingTheOpenReplyUpdatesTheMessagePaneStrip()
+{
+ // The user's report: "the right pane chips are not [repainted], for it to
+ // sync I have to change message and go back to the edited one".
+ //
+ // sendThreadTagChange refreshes the strip when the edited thread is the
+ // open one. sendMessageTagChange had no equivalent, so a message-scoped
+ // write updated the list row and left the pane's chips describing the
+ // message as it was before the edit.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *strip = window.findChild<TagStrip *>();
+ QVERIFY2(strip, "no tag strip in the message pane");
+
+ // visible + hidden: TagStrip collapses what does not fit into a "+N" chip,
+ // and an unshown window has no width, so visibleTags() alone measures the
+ // layout rather than the data.
+ const auto stripTags = [strip]() {
+ return strip->visibleTags() + strip->hiddenTags();
+ };
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(action);
+
+ // A tag the strip will actually draw. Account tags are filtered out by the
+ // strip, and `unread` and `inbox` are hidden on the card but not here, so
+ // the fixture uses a plain functional tag to keep the assertion honest.
+ const QModelIndex reply = expandSecondThreadAndSelectItsReply(
+ view, model, {}, {}, QStringList{ QStringLiteral("todo") });
+ QVERIFY2(reply.isValid(), "the fixture did not produce a reply row at row 0");
+
+ // Selecting the reply is what puts it in the pane, and the strip has to be
+ // showing the reply's own tags before the edit or this asserts nothing.
+ QVERIFY2(stripTags().contains(QStringLiteral("todo")),
+ "the strip does not show the selected reply's tags, so this test "
+ "cannot tell a missing refresh from a strip that never had them");
+ QVERIFY(!stripTags().contains(QStringLiteral("deleted")));
+
+ action->trigger();
+
+ QVERIFY2(stripTags().contains(QStringLiteral("deleted")),
+ "the message pane's chips still describe the reply as it was "
+ "before the edit; the user has to select away and back to see it");
+}
+
+void TestMainWindow::taggingAnUnrelatedReplyLeavesTheStripAlone()
+{
+ // The guard, not the refresh. The strip describes the message ON DISPLAY,
+ // so a write to a different message must not repaint it with that
+ // message's tags. The thread path has the same guard, keyed on
+ // m_currentThreadId.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *strip = window.findChild<TagStrip *>();
+ QVERIFY(strip);
+
+ // visible + hidden: TagStrip collapses what does not fit into a "+N" chip,
+ // and an unshown window has no width, so visibleTags() alone measures the
+ // layout rather than the data.
+ const auto stripTags = [strip]() {
+ return strip->visibleTags() + strip->hiddenTags();
+ };
+
+ ThreadSummary thread = makeThread(QStringLiteral("t1"), {});
+ thread.totalCount = 3;
+ model->appendBatch({ thread });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m0@example.org");
+ root.threadId = QStringLiteral("t1");
+ root.depth = 0;
+ MessageNode first;
+ first.messageId = QStringLiteral("m1@example.org");
+ first.threadId = QStringLiteral("t1");
+ first.tags = QStringList{ QStringLiteral("todo") };
+ first.depth = 1;
+ MessageNode second;
+ second.messageId = QStringLiteral("m2@example.org");
+ second.threadId = QStringLiteral("t1");
+ second.tags = QStringList{ QStringLiteral("later") };
+ second.depth = 1;
+ model->setThreadMessages(QStringLiteral("t1"), { root, first, second });
+
+ const QModelIndex threadRow = model->index(0, 0, QModelIndex());
+ view->expand(threadRow);
+
+ // The FIRST reply is the one on display.
+ const QModelIndex displayed = model->index(0, 0, threadRow);
+ view->selectionModel()->select(
+ displayed,
+ QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ view->setCurrentIndex(displayed);
+ QApplication::processEvents();
+ QVERIFY(stripTags().contains(QStringLiteral("todo")));
+
+ // A write to the OTHER reply, reaching the send path directly: driving it
+ // through the action would move the selection and change what is on
+ // display, which is the thing being held still.
+ window.sendMessageTagChangeForTesting({ QStringLiteral("m2@example.org") },
+ { QStringLiteral("deleted") }, {},
+ QStringLiteral("Delete"));
+
+ QVERIFY2(!stripTags().contains(QStringLiteral("deleted")),
+ "the strip took on the tags of a message that is not the one in "
+ "the pane");
+ QVERIFY2(stripTags().contains(QStringLiteral("todo")),
+ "the strip stopped describing the message on display");
+}
+
+void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds()
+{
+ // Found by reading while fixing the strip refresh, not reported.
+ //
+ // flushHeldEdits() looped over edit.threadIds and called
+ // sendThreadTagChange() only. A message-scoped edit held during a sync
+ // carries no thread ids at all, so the loop did nothing, the send
+ // early-returned on an empty list, and the edit was DROPPED: applied
+ // optimistically to the row, counted as unsynced, and never written. The
+ // user would have seen the change, been told it was pending, and lost it.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(action);
+
+ const QModelIndex reply =
+ expandSecondThreadAndSelectItsReply(view, model, {}, {});
+ QVERIFY(reply.isValid());
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Running));
+ action->trigger();
+ QVERIFY2(window.hasEditAwaitingSend(),
+ "a message edit made during a sync was not held");
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+
+ QVERIFY2(!window.hasEditAwaitingSend(),
+ "the sync ending did not send the held message edit, so it was "
+ "silently dropped: shown on the row, counted as pending, never "
+ "written");
+
+ // Sent for the MESSAGE, not escalated to its thread. Losing the scope on
+ // the way out of the hold would delete every message in the thread.
+ QVERIFY2(window.pendingMessageIdsForTesting().contains(
+ QStringLiteral("m1@example.org")),
+ "the held edit was not sent with its message scope");
+ QVERIFY2(window.pendingThreadIdsForTesting().isEmpty(),
+ "a held MESSAGE edit was sent as a thread edit, which would tag "
+ "every message in the thread");
+
+ // And the row still shows it: the flush takes the optimistic update back
+ // before re-sending, so a bug there leaves the row wrong in the other
+ // direction.
+ QVERIFY2(model->messageAt(reply).isDeleted(),
+ "sending the held edit lost the tag from the reply's row");
+}
+
+void TestMainWindow::anActionOnAThreadRowActsOnTheMessageItDisplays()
+{
+ // Item 108, the whole point of it. A root card renders ONE message since
+ // item 66, so acting on it acts on that message; the conversation is
+ // reached through the explicit thread actions.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ ThreadSummary t = makeThread(QStringLiteral("t1"), {});
+ t.totalCount = 7;
+ model->appendBatch({ t });
+ selectThreadRow(view, 0);
+
+ auto *deleteAction = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(deleteAction);
+ deleteAction->trigger();
+
+ QCOMPARE(window.pendingMessageIdsForTesting(),
+ QStringList{ QStringLiteral("t1-first@example.org") });
+ QVERIFY2(window.pendingThreadIdsForTesting().isEmpty(),
+ "the ordinary Delete still acted on the whole thread, so it "
+ "touched six messages the card does not display");
+
+ // The thread action is how the conversation is reached, and it must still
+ // work from the same selection.
+ auto *deleteThread =
+ window.findChild<QAction *>(QStringLiteral("delete_thread"));
+ QVERIFY(deleteThread);
+ deleteThread->trigger();
+
+ QCOMPARE(window.pendingThreadIdsForTesting(),
+ QStringList{ QStringLiteral("t1") });
+
+ // Two commands, one per gesture, each recording the scope it used: a thread
+ // action that pushed the message command would undo a fraction of what it
+ // did.
+ QCOMPARE(window.undoDepthForTesting(), 2);
+}
+
+void TestMainWindow::theThreadSubmenuIsReachableFromBothMenus()
+{
+ // The user asked for "a submenu when right clicking and the same submenu
+ // under Message in the top menu". Both, not one: the context menu is where
+ // the gesture starts and the menu bar is where a shortcut is discovered.
+ //
+ // A QMenu belongs to ONE menu tree, so these are two instances holding the
+ // same actions. Adding a single instance to both silently gives it to
+ // whichever added it last, which is the failure this pins.
+ const Config config;
+ MainWindow window(config);
+
+ auto *context =
+ window.findChild<QMenu *>(QStringLiteral("threadContextMenu"));
+ QVERIFY(context);
+
+ const QStringList expected = {
+ QStringLiteral("archive_thread"),
+ QStringLiteral("delete_thread"),
+ QStringLiteral("spam_thread"),
+ QStringLiteral("toggle_unread_thread"),
+ QStringLiteral("flag_thread"),
+ };
+
+ // Every submenu instance in the window, wherever it was added.
+ const QList<QMenu *> submenus =
+ window.findChildren<QMenu *>(QStringLiteral("threadActionsMenu"));
+ QVERIFY2(submenus.size() >= 2,
+ qPrintable(QStringLiteral("expected the thread submenu in both "
+ "the context menu and the menu bar, "
+ "found %1 instance(s)")
+ .arg(submenus.size())));
+
+ for (QMenu *menu : submenus) {
+ QStringList names;
+ for (QAction *action : menu->actions()) {
+ if (!action->isSeparator())
+ names.append(action->objectName());
+ }
+ QCOMPARE(names, expected);
+ }
+
+ // One of them is the context menu's own, reached as a submenu rather than
+ // as a loose action.
+ bool inContextMenu = false;
+ for (QAction *action : context->actions()) {
+ if (action->menu()
+ && action->menu()->objectName()
+ == QStringLiteral("threadActionsMenu")) {
+ inContextMenu = true;
+ break;
+ }
+ }
+ QVERIFY2(inContextMenu,
+ "right-clicking a thread offers no whole-thread submenu");
+}
+
+void TestMainWindow::autoMarkReadTouchesOnlyTheMessageOnDisplay()
+{
+ // Item 87, reported 2026-08-14: "with the first message in a thread
+ // selected (not expanded), the 2s delay that marks it read applies to the
+ // whole thread, so all answers are marked read as well."
+ //
+ // Not cosmetic. maildir.synchronize_flags is on, so removing `unread`
+ // rewrites Maildir filenames and the next sync carries it to the server:
+ // mail the user never opened stops being unread everywhere, and nothing
+ // here can put it back except reading it again by hand.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("[general]\nmark_read_delay_ms = 0\n");
+ file.close();
+
+ Config config;
+ config.load(path);
+ QCOMPARE(config.markReadDelayMs(), 0);
+
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(timer);
+
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") });
+ t.totalCount = 7;
+ model->appendBatch({ t });
+
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+ QVERIFY2(timer->isActive() || !window.pendingMessageIdsForTesting().isEmpty(),
+ "selecting an unread thread armed no mark-read at all");
+
+ // Fire it. A zero-interval timer still goes through the event loop.
+ QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 2000);
+ QApplication::processEvents();
+
+ // ONE message, the one the card renders, and named rather than merely
+ // counted: a thread of seven whose first message is the target is exactly
+ // the case where a count of one could still be the wrong one.
+ QCOMPARE(window.pendingMessageIdsForTesting(),
+ QStringList{ QStringLiteral("t1-first@example.org") });
+ QVERIFY2(window.pendingThreadIdsForTesting().isEmpty(),
+ "the automatic mark-read still wrote to the whole thread, so six "
+ "messages the user never displayed were marked read and the next "
+ "sync carries that to the server");
+
+ // Still not on the undo stack. The user never took this action, so
+ // hijacking Ctrl+Z to reverse it would undo something they did not do.
+ QCOMPARE(window.undoDepthForTesting(), 0);
+}
+
+void TestMainWindow::autoMarkReadArmsForAReplyToo()
+{
+ // Selecting a reply displays that message, so the same rule applies to it.
+ // Before item 87 the timer was deliberately not armed for a message row,
+ // because the write it would have made was thread-scoped and would have
+ // marked the whole conversation read. With the write scoped to one message
+ // that objection is gone, and leaving it unarmed would mean the message
+ // the user is reading is the one kind that never gets marked read.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("[general]\nmark_read_delay_ms = 0\n");
+ file.close();
+
+ Config config;
+ config.load(path);
+
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(timer);
+
+ // The reply is unread; its thread is not, so a thread-keyed timer would
+ // have declined to arm at all.
+ //
+ // No "is it still unread" guard before the wait: the delay is 0 and the
+ // helper pumps the event loop, so the write has already happened by the
+ // time selection returns. The assertions below are on the write itself,
+ // which is what this test is about, and the fixture above is what
+ // establishes the reply started unread.
+ const QModelIndex reply = expandSecondThreadAndSelectItsReply(
+ view, model, {}, {}, QStringList{ QStringLiteral("unread") });
+ QVERIFY(reply.isValid());
+
+ QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 2000);
+ QApplication::processEvents();
+
+ QCOMPARE(window.pendingMessageIdsForTesting(),
+ QStringList{ QStringLiteral("m1@example.org") });
+ QVERIFY2(window.pendingThreadIdsForTesting().isEmpty(),
+ "reading one reply marked its whole thread read");
+
+ // And the row shows it, which is the half item 105 built.
+ QVERIFY2(!model->messageAt(reply).isUnread(),
+ "the reply was marked read without its row following");
+}
+
+void TestMainWindow::taggingTheOpenRootMessageKeepsTheStripPopulated()
+{
+ // The user, 2026-08-16: "right pane loses the chip row when repainting, it
+ // simply disappears".
+ //
+ // The strip refresh added for item 105 reads the message's tags through
+ // messageById(), which searches only the loaded CHILDREN. A root card's
+ // message is never among them, so the lookup returned a default-constructed
+ // node and the refresh set the strip to that node's empty tag list, wiping
+ // a strip that had been correct a moment earlier. Worse than not
+ // refreshing: it actively destroyed what was there.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *strip = window.findChild<TagStrip *>();
+ QVERIFY(strip);
+
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ { QStringLiteral("todo") });
+ t.totalCount = 1;
+ model->appendBatch({ t });
+
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+
+ // visible + hidden, not visible alone. TagStrip is a single row that
+ // collapses whatever does not fit into a trailing "+N" chip, and this
+ // window is never shown, so it has no width to lay out with and puts
+ // almost everything in the hidden half. Asserting on visibleTags() alone
+ // measures the layout, not the data, and fails for a reason this test does
+ // not care about.
+ const auto stripTags = [strip]() {
+ return strip->visibleTags() + strip->hiddenTags();
+ };
+
+ // The guard: the strip has to be showing something before the edit, or
+ // this cannot tell "wiped" from "never populated".
+ QVERIFY2(stripTags().contains(QStringLiteral("todo")),
+ "the strip never showed the selected thread's tags");
+
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(action);
+ action->trigger();
+
+ QVERIFY2(!stripTags().isEmpty(),
+ "the chip row was emptied: the refresh looked the message up "
+ "among the loaded replies, where a root card's message never is, "
+ "and set the strip to the resulting empty tag list");
+ QVERIFY2(stripTags().contains(QStringLiteral("todo")),
+ "the strip lost the tag the message still carries");
+ QVERIFY2(stripTags().contains(QStringLiteral("deleted")),
+ "the strip did not pick up the tag just written");
+}
+
+void TestMainWindow::aLoadedMessageCorrectsTheStripFromTheThreadsUnion()
+{
+ // Reported by hand, 2026-08-16, against a real four-message thread whose
+ // root carried `unread` and whose THIRD message carried `signed`:
+ // "the right pane chips update and both signed and unread disappear ...
+ // changing message and going back makes them reappear".
+ //
+ // Neither half was the write's doing. Selecting a thread row sets the strip
+ // from ThreadSummary::tags, which is notmuch's UNION over the thread, so
+ // the pane claimed the root message was `signed` when a sibling was. The
+ // mark-read write then replaced it with the root's real tags, correctly
+ // dropping both, and reselecting put the union back. The pane was lying
+ // BEFORE the write, not after it.
+ //
+ // The load is the authority: MessageRef carries the message's own tags.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *strip = window.findChild<TagStrip *>();
+ QVERIFY(strip);
+
+ const auto stripTags = [strip]() {
+ return strip->visibleTags() + strip->hiddenTags();
+ };
+
+ // The union carries `signed`; the root message does not.
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ { QStringLiteral("inbox"),
+ QStringLiteral("signed"),
+ QStringLiteral("unread") });
+ t.totalCount = 4;
+ model->appendBatch({ t });
+
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+
+ // Before the load the strip can only show the union, which is what the
+ // model holds. That is the state the user saw and reported.
+ QVERIFY(stripTags().contains(QStringLiteral("signed")));
+
+ // The worker answers with the message's OWN tags.
+ MessageRef ref;
+ ref.messageId = QStringLiteral("t1-first@example.org");
+ ref.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") };
+ QMetaObject::invokeMethod(
+ &window, "onMessageLoaded", Qt::DirectConnection,
+ Q_ARG(QVector<MessageRef>, QVector<MessageRef>{ ref }),
+ Q_ARG(quint64, window.currentGenerationForTesting()));
+ QApplication::processEvents();
+
+ QVERIFY2(!stripTags().contains(QStringLiteral("signed")),
+ "the pane still claims the root message is signed, which is a "
+ "sibling's tag: it is showing the thread's union rather than the "
+ "message on display");
+ QVERIFY2(stripTags().contains(QStringLiteral("unread")),
+ "the pane lost a tag the message really carries");
+}
+
+void TestMainWindow::aLoadedRootMessageGivesTheCardItsOwnTags()
+{
+ // The same correction, reaching the MODEL, which is what fixes the two
+ // repaint reports: "if I mark the root message read the left pane entry
+ // doesn't repaint (stays bold)" and the same for delete.
+ //
+ // The card could not repaint because the model had no per-message tags for
+ // a root at all, so a message-scoped write updated the thread summary only
+ // when the thread was a single message. A load gives the root the same
+ // per-message node a reply has had all along, and from then on the card
+ // draws the message it displays.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ { QStringLiteral("inbox"),
+ QStringLiteral("signed"),
+ QStringLiteral("unread") });
+ t.totalCount = 4;
+ model->appendBatch({ t });
+
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+
+ MessageRef ref;
+ ref.messageId = QStringLiteral("t1-first@example.org");
+ ref.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") };
+ QMetaObject::invokeMethod(
+ &window, "onMessageLoaded", Qt::DirectConnection,
+ Q_ARG(QVector<MessageRef>, QVector<MessageRef>{ ref }),
+ Q_ARG(quint64, window.currentGenerationForTesting()));
+ QApplication::processEvents();
+
+ // The model now knows what the root message itself carries.
+ const MessageNode root =
+ model->messageById(QStringLiteral("t1-first@example.org"));
+ QCOMPARE(root.messageId, QStringLiteral("t1-first@example.org"));
+ QVERIFY2(!root.tags.contains(QStringLiteral("signed")),
+ "the root's node still carries a sibling's tag");
+
+ const QModelIndex threadIndex = model->index(0, 0, QModelIndex());
+ QSignalSpy spy(model, &QAbstractItemModel::dataChanged);
+
+ auto *toggle = window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(toggle);
+ toggle->trigger();
+
+ QVERIFY2(spy.count() >= 1, "marking the root message read repainted nothing");
+ QVERIFY2(!model->messageById(QStringLiteral("t1-first@example.org"))
+ .isUnread(),
+ "the root message is still unread after being marked read");
+
+ // The card now draws that message, so it stops looking unread. Asserted on
+ // the FONT, which is what the user means by "stays bold".
+ QVERIFY2(!model->data(threadIndex, Qt::FontRole).value<QFont>().bold(),
+ "the card still reads as unread, so the row stays bold and the "
+ "user sees nothing");
+ QVERIFY2(model->threadAt(0).isUnread(),
+ "the thread summary was rewritten, claiming a four-message thread "
+ "is read when three of its messages still are not");
+}
+
void TestMainWindow::aTransientStatusMessageExpires()
{
// "Sync complete" describes an event, not a state, and reads as though it
@@ -4796,7 +5716,10 @@ void TestMainWindow::anEditDuringABackgroundSyncIsNotSentYet()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
+ // The THREAD action: this test asserts on the thread ROW, which a
+ // message-scoped write deliberately leaves alone since item 108. What
+ // is under test is the HOLD, which is identical either way.
+ auto *action = window.findChild<QAction *>(QStringLiteral("flag_thread"));
QVERIFY2(action, "no flag action registered");
model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
@@ -4827,7 +5750,10 @@ void TestMainWindow::aHeldEditIsSentWhenTheBackgroundSyncEnds()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
+ // The THREAD action: this test asserts on the thread ROW, which a
+ // message-scoped write deliberately leaves alone since item 108. What
+ // is under test is the HOLD, which is identical either way.
+ auto *action = window.findChild<QAction *>(QStringLiteral("flag_thread"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
@@ -5360,15 +6286,19 @@ void TestMainWindow::theImportantActionStillWritesTheFlaggedTag()
QVERIFY(action);
action->trigger();
- // sendThreadTagChange() applies the change to the model optimistically, so
- // the tag the action really wrote is observable here without a worker.
- QVERIFY2(model->threadAt(0).isFlagged(),
+ // Asserted on the CHANGE that was sent rather than on the thread's row.
+ // Since item 108 this action is message-scoped, so it writes to the
+ // message the card displays and the thread summary is deliberately left
+ // alone. The tag name is what this test is about, and the change carries
+ // it whichever scope the action uses.
+ const TagChange sent = window.pendingChangeForTesting();
+ QVERIFY2(sent.added.contains(QStringLiteral("flagged")),
"the renamed action no longer writes the `flagged` tag");
- QVERIFY2(model->threadAt(0).tags.contains(QStringLiteral("flagged")),
- "the tag written was not `flagged`");
- QVERIFY2(!model->threadAt(0).tags.contains(QStringLiteral("important")),
+ QVERIFY2(!sent.added.contains(QStringLiteral("important")),
"the rename reached the mail store: an `important` tag was "
"written, which no other tool reading this Maildir knows");
+ QVERIFY2(sent.removed.isEmpty(),
+ "marking important removed a tag, which it must not");
}
void TestMainWindow::theToolbarUsesTheConfiguredIconSize()
@@ -5756,20 +6686,59 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// Compared by cacheKey() rather than by the theme NAME, which this window
// does not keep. Two distinct names that resolve to the same art on a given
// theme are just as ambiguous on screen, and that is what the user sees.
+ // Narrowed by item 108 to the actions that can reach the TOOLBAR, which is
+ // where the rule comes from: an icon-only toolbar makes the icon the whole
+ // control. The five whole-thread actions live only in the "Whole thread"
+ // submenu, whose entries always carry text, and each deliberately shares
+ // the icon of its message-scoped twin: same operation, wider scope, with
+ // the words saying which. Giving them five invented shapes would be less
+ // clear than the pairing.
+ //
+ // Named as an exception list rather than by asking the toolbar what it
+ // holds, so that PUTTING one of these on the toolbar fails this test
+ // rather than silently passing it.
+ static const QStringList menuOnlyThreadActions = {
+ QStringLiteral("archive_thread"),
+ QStringLiteral("delete_thread"),
+ QStringLiteral("spam_thread"),
+ QStringLiteral("toggle_unread_thread"),
+ QStringLiteral("flag_thread"),
+ };
+
const Config config;
MainWindow window(config);
+ // The exception must not become a hiding place: every one of them still
+ // has to carry an icon, which everyActionCarriesAnIcon asserts, and none
+ // may sit on the toolbar.
+ auto *toolBar = window.findChild<QToolBar *>();
+ QVERIFY(toolBar);
+ for (const QString &name : menuOnlyThreadActions) {
+ auto *action = window.findChild<QAction *>(name);
+ QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
+ QVERIFY2(!toolBar->actions().contains(action),
+ qPrintable(QStringLiteral("%1 is on the toolbar, where a "
+ "shared icon is ambiguous, so it "
+ "cannot be exempt from this rule")
+ .arg(name)));
+ }
+
QHash<qint64, QString> owners;
QStringList collisions;
int withIcons = 0;
+ int compared = 0;
for (const QString &name : KeyMap::knownActions()) {
auto *action = window.findChild<QAction *>(name);
QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
+ if (!action->icon().isNull())
+ ++withIcons;
+ if (menuOnlyThreadActions.contains(name))
+ continue;
if (action->icon().isNull())
continue;
+ ++compared;
- ++withIcons;
const qint64 key = action->icon().cacheKey();
const auto existing = owners.constFind(key);
if (existing != owners.constEnd()) {
@@ -5789,6 +6758,10 @@ void TestMainWindow::noTwoActionsShareAnIcon()
.arg(withIcons)
.arg(KeyMap::knownActions().size())));
+ // And the exception list did not swallow the comparison itself.
+ QCOMPARE(compared, KeyMap::knownActions().size()
+ - menuOnlyThreadActions.size());
+
QVERIFY2(collisions.isEmpty(),
qPrintable(QStringLiteral("actions sharing one icon: %1")
.arg(collisions.join(QStringLiteral("; ")))));
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.
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index e338661..811b3e3 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -68,6 +68,20 @@ private slots:
void threadIdIsReachableFromAnIndex();
void invalidIndexesReturnNothing();
void threadAtOutOfRangeIsSafe();
+ void threadForResolvesAReplyThroughItsParent();
+ void applyMessageTagChangeRepaintsThatReplyAlone();
+ void aDeletedReplyIsPaintedAsDoomed();
+ void aDeletedReplyIsStruckThrough();
+ void markingAReplyReadChangesItsForeground();
+ void anUnreadReplyIsBoldAndStillSmallerThanItsThread();
+ void aThreadTagChangeReachesItsLoadedReplies();
+ void messageScopeResolvesAThreadRowToTheMessageItDisplays();
+ void messageScopeSkipsAThreadRowItCannotNameAMessageFor();
+ void aMessageTagChangeReachesTheRootCardsOwnMessage();
+ void aMessageTagChangeOnOneOfManyLeavesTheThreadSummaryAlone();
+ void aCardListsItsOwnTagsBeforeItsSiblings();
+ void theSplitIsKnownBeforeTheRowIsEverOpened();
+ void reconcileRefreshesASurvivorsOwnMessageTags();
void updatesTagsForMessage();
void tagChangeIsIdempotent();
void tagChangeSignalsExactlyTheChangedRow();
@@ -877,6 +891,605 @@ void TestThreadListModel::threadAtOutOfRangeIsSafe()
QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t1"));
}
+void TestThreadListModel::threadForResolvesAReplyThroughItsParent()
+{
+ // Item 88. threadAt() takes a top-level row and a tree numbers rows per
+ // parent, so the first reply of ANY thread has row() == 0 and threadAt(0)
+ // answers "t1" for a reply of t2. threadFor() resolves through the parent
+ // instead, which is what every caller holding an index needs.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")),
+ makeThread(QStringLiteral("t2"), QStringLiteral("two")) });
+ model.setThreadMessages(QStringLiteral("t2"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ makeNode(QStringLiteral("m1@example.org"), 1) });
+
+ const QModelIndex second = model.index(1, 0, QModelIndex());
+ QCOMPARE(model.threadFor(second).threadId, QStringLiteral("t2"));
+
+ const QModelIndex reply = model.index(0, 0, second);
+ QVERIFY(reply.isValid());
+ QVERIFY2(model.isMessageRow(reply),
+ "the fixture did not produce a message row");
+ QCOMPARE(reply.row(), 0); // The trap: a plausible top-level row number.
+
+ QCOMPARE(model.threadFor(reply).threadId, QStringLiteral("t2"));
+
+ // And the row-taking overload still does the wrong thing for that index,
+ // which is why it is documented as unsafe rather than merely deprecated.
+ QCOMPARE(model.threadAt(reply.row()).threadId, QStringLiteral("t1"));
+
+ // An invalid index gives an empty summary, which every caller treats as
+ // "nothing to do" rather than acting on row 0.
+ QVERIFY(model.threadFor(QModelIndex()).threadId.isEmpty());
+}
+
+void TestThreadListModel::applyMessageTagChangeRepaintsThatReplyAlone()
+{
+ // The user's report: hitting Delete or Ctrl+U on a reply moved the pending
+ // count and changed nothing on screen. sendMessageTagChange made no
+ // optimistic update at all, on the correct reasoning that repainting the
+ // THREAD row would claim every message in it had changed. The row that
+ // should have repainted is the reply's own.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0);
+ MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1);
+ reply.tags = QStringList{ QStringLiteral("unread") };
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+
+ const QModelIndex threadIndex = model.index(0, 0, QModelIndex());
+ const QModelIndex replyIndex = model.index(0, 0, threadIndex);
+ QVERIFY(model.isMessageRow(replyIndex));
+
+ const QStringList threadTagsBefore =
+ model.data(threadIndex, ThreadListModel::TagsRole).toStringList();
+
+ QSignalSpy spy(&model, &QAbstractItemModel::dataChanged);
+
+ model.applyMessageTagChange(QStringLiteral("m1@example.org"),
+ { QStringLiteral("deleted") },
+ { QStringLiteral("unread") });
+
+ // The node carries the change, which is what every reply-row role reads.
+ QCOMPARE(model.messageAt(replyIndex).isDeleted(), true);
+ QCOMPARE(model.messageAt(replyIndex).isUnread(), false);
+
+ // And the view was told, or the change is invisible until something else
+ // happens to repaint the row.
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toModelIndex(), replyIndex);
+
+ // The THREAD is untouched. Claiming the whole thread changed is the lie
+ // the missing update was avoiding, and it must stay avoided.
+ QCOMPARE(model.data(threadIndex, ThreadListModel::TagsRole).toStringList(),
+ threadTagsBefore);
+ QCOMPARE(model.messageAt(model.index(0, 0, threadIndex)).messageId,
+ QStringLiteral("m1@example.org"));
+
+ // An unknown message is a no-op rather than a wrong row repainted.
+ spy.clear();
+ model.applyMessageTagChange(QStringLiteral("nobody@example.org"),
+ { QStringLiteral("deleted") }, {});
+ QCOMPARE(spy.count(), 0);
+}
+
+void TestThreadListModel::aDeletedReplyIsPaintedAsDoomed()
+{
+ // Updating the node is not enough on its own: a reply row had no doomed
+ // branch at all, so a deleted reply repainted identically to an undeleted
+ // one and the user still saw nothing. The thread row has carried this cue
+ // since item 13.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0);
+ MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1);
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+
+ const QModelIndex threadIndex = model.index(0, 0, QModelIndex());
+ const QModelIndex replyIndex = model.index(0, 0, threadIndex);
+
+ const QVariant plainBackground =
+ model.data(replyIndex, Qt::BackgroundRole);
+
+ model.applyMessageTagChange(QStringLiteral("m1@example.org"),
+ { QStringLiteral("deleted") }, {});
+
+ const QVariant doomedBackground =
+ model.data(replyIndex, Qt::BackgroundRole);
+ QVERIFY2(doomedBackground != plainBackground,
+ "a deleted reply paints exactly like an undeleted one, so the "
+ "user has no way to see that Delete did anything");
+ QCOMPARE(doomedBackground.value<QBrush>().color(),
+ ThreadListModel::deletedColour());
+
+ // Spam is the other half of isDoomed() and gets its own colour, so the two
+ // are told apart by hue rather than by shade.
+ model.applyMessageTagChange(QStringLiteral("m1@example.org"),
+ { QStringLiteral("spam") },
+ { QStringLiteral("deleted") });
+ QCOMPARE(model.data(replyIndex, Qt::BackgroundRole).value<QBrush>().color(),
+ ThreadListModel::spamColour());
+}
+
+void TestThreadListModel::aDeletedReplyIsStruckThrough()
+{
+ // The fill is not the only cue, deliberately: a strike-out survives a
+ // screenshot, a colourblind reader and a theme that overrides the
+ // background. The thread row has carried both since item 13; a reply had
+ // neither.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+ model.setThreadMessages(QStringLiteral("t1"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ makeNode(QStringLiteral("m1@example.org"), 1) });
+
+ const QModelIndex replyIndex =
+ model.index(0, 0, model.index(0, 0, QModelIndex()));
+
+ QVERIFY(!model.data(replyIndex, Qt::FontRole).value<QFont>().strikeOut());
+
+ model.applyMessageTagChange(QStringLiteral("m1@example.org"),
+ { QStringLiteral("deleted") }, {});
+
+ QVERIFY2(model.data(replyIndex, Qt::FontRole).value<QFont>().strikeOut(),
+ "a deleted reply is not struck through, so the only cue it has "
+ "is a background colour");
+
+ // The reply's smaller font is not lost to the strike-out branch: a reply
+ // reads as subordinate whatever its tags say.
+ const QFont threadFont =
+ model.data(model.index(0, 0, QModelIndex()), Qt::FontRole).value<QFont>();
+ const QFont replyFont =
+ model.data(replyIndex, Qt::FontRole).value<QFont>();
+ if (threadFont.pointSize() > 0 && replyFont.pointSize() > 0)
+ QVERIFY(replyFont.pointSize() < threadFont.pointSize());
+}
+
+void TestThreadListModel::markingAReplyReadChangesItsForeground()
+{
+ // The user's second report: marking a reply read or unread moved the
+ // counter with no visible change. A reply is deliberately NEVER bold, so
+ // unlike a thread row its only cue is the foreground dimming. That cue has
+ // to at least exist and change, which is what this pins.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0);
+ MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1);
+ reply.tags = QStringList{ QStringLiteral("unread") };
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+
+ const QModelIndex replyIndex =
+ model.index(0, 0, model.index(0, 0, QModelIndex()));
+
+ const QVariant unreadForeground =
+ model.data(replyIndex, Qt::ForegroundRole);
+
+ model.applyMessageTagChange(QStringLiteral("m1@example.org"), {},
+ { QStringLiteral("unread") });
+
+ const QVariant readForeground = model.data(replyIndex, Qt::ForegroundRole);
+ QVERIFY2(readForeground != unreadForeground,
+ "marking a reply read changed nothing about how its row paints");
+ QCOMPARE(readForeground.value<QBrush>().color(),
+ ThreadListModel::readColour());
+
+ // And back, so the toggle is visible in both directions rather than only
+ // on the way to read.
+ model.applyMessageTagChange(QStringLiteral("m1@example.org"),
+ { QStringLiteral("unread") }, {});
+ QCOMPARE(model.data(replyIndex, Qt::ForegroundRole), unreadForeground);
+}
+
+void TestThreadListModel::anUnreadReplyIsBoldAndStillSmallerThanItsThread()
+{
+ // Requested by the user on 2026-08-16: "I prefer the bold on replies
+ // combined with the dimming." Replies were deliberately never bold before
+ // that, so this pins the decision rather than describing the code.
+ //
+ // Both halves matter. Bold is the second cue, next to the dimming; the
+ // smaller size is what still separates a reply from the thread heading
+ // above it, and dropping it would make an unread reply indistinguishable
+ // from a thread row.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0);
+ MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1);
+ reply.tags = QStringList{ QStringLiteral("unread") };
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+
+ const QModelIndex threadIndex = model.index(0, 0, QModelIndex());
+ const QModelIndex replyIndex = model.index(0, 0, threadIndex);
+
+ const QFont unreadFont =
+ model.data(replyIndex, Qt::FontRole).value<QFont>();
+ QVERIFY2(unreadFont.bold(), "an unread reply is not bold");
+
+ const QFont threadFont =
+ model.data(threadIndex, Qt::FontRole).value<QFont>();
+ if (threadFont.pointSize() > 0 && unreadFont.pointSize() > 0) {
+ QVERIFY2(unreadFont.pointSize() < threadFont.pointSize(),
+ "a bold reply is the same size as its thread row, so the two "
+ "kinds of row no longer read apart");
+ }
+
+ // Bold is the unread cue specifically, not decoration on every reply.
+ model.applyMessageTagChange(QStringLiteral("m1@example.org"), {},
+ { QStringLiteral("unread") });
+ QVERIFY2(!model.data(replyIndex, Qt::FontRole).value<QFont>().bold(),
+ "a read reply is still bold, so bold says nothing");
+}
+
+void TestThreadListModel::aThreadTagChangeReachesItsLoadedReplies()
+{
+ // The user's report: "if I hit read/unread on the main thread message [...]
+ // only the main message is repainted [...] the replies don't get
+ // repainted."
+ //
+ // A thread-scoped write reaches every message in the thread IN THE
+ // DATABASE. applyTagChange only ever updated the thread's summary, so an
+ // expanded thread kept showing replies with their old tags: bold, undimmed
+ // and unstruck, describing a state the database no longer held. The rows
+ // corrected themselves on the next query, which is what made this look
+ // like a repaint problem rather than a stale-model one.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ MessageNode root = makeNode(QStringLiteral("m0@example.org"), 0);
+ root.tags = QStringList{ QStringLiteral("unread") };
+ MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1);
+ reply.tags = QStringList{ QStringLiteral("unread") };
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+
+ const QModelIndex threadIndex = model.index(0, 0, QModelIndex());
+ const QModelIndex replyIndex = model.index(0, 0, threadIndex);
+ QVERIFY(model.messageAt(replyIndex).isUnread());
+
+ QSignalSpy spy(&model, &QAbstractItemModel::dataChanged);
+
+ model.applyTagChange(QStringLiteral("t1"), {},
+ { QStringLiteral("unread") });
+
+ QVERIFY2(!model.messageAt(replyIndex).isUnread(),
+ "a thread marked read left its loaded replies carrying unread, so "
+ "the rows describe a state the database does not hold");
+
+ // The reply's row was told to repaint, not merely mutated behind the view.
+ bool replyRepainted = false;
+ for (const QList<QVariant> &call : spy) {
+ const QModelIndex from = call.at(0).toModelIndex();
+ const QModelIndex to = call.at(1).toModelIndex();
+ if (from.parent() == threadIndex && replyIndex.row() >= from.row()
+ && replyIndex.row() <= to.row()) {
+ replyRepainted = true;
+ break;
+ }
+ }
+ QVERIFY2(replyRepainted,
+ "no dataChanged covered the reply rows, so the view has no reason "
+ "to redraw them");
+
+ // Both directions, since a toggle is only fixed if it is visible each way.
+ model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("unread") }, {});
+ QVERIFY(model.messageAt(replyIndex).isUnread());
+}
+
+void TestThreadListModel::messageScopeResolvesAThreadRowToTheMessageItDisplays()
+{
+ // Item 108. A thread root RENDERS one message since item 66, so acting on
+ // it acts on that message. The thread's other messages are reached through
+ // the explicit thread actions, which still resolve through scopeFor().
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ QStringLiteral("A subject"));
+ t.totalCount = 7;
+ t.firstMessageId = QStringLiteral("m0@example.org");
+ model.appendBatch({ t });
+
+ const QModelIndex root = model.index(0, 0, QModelIndex());
+
+ // Unexpanded, which is the case that matters: the id comes from the query,
+ // so this needs no children loaded.
+ QCOMPARE(model.rowCount(root), 0);
+
+ const ActionScope scope = model.messageScopeFor({ root });
+ QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m0@example.org") });
+ QVERIFY2(scope.threadIds.isEmpty(),
+ "a thread row still resolved to its whole thread, so every action "
+ "on a root card would touch messages it does not display");
+ QCOMPARE(scope.messageCount, 1);
+ QVERIFY2(!scope.wholeThread,
+ "the status bar would claim '(whole thread)' for a one-message "
+ "action");
+
+ // The old resolver is unchanged and is what the thread actions use.
+ const ActionScope threadScope = model.scopeFor({ root });
+ QCOMPARE(threadScope.threadIds, QStringList{ QStringLiteral("t1") });
+ QCOMPARE(threadScope.messageCount, 7);
+ QVERIFY(threadScope.wholeThread);
+
+ // A reply row is unchanged in both: it always stood for one message.
+ model.setThreadMessages(QStringLiteral("t1"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ makeNode(QStringLiteral("m1@example.org"), 1) });
+ const QModelIndex reply = model.index(0, 0, root);
+ QCOMPARE(model.messageScopeFor({ reply }).messageIds,
+ QStringList{ QStringLiteral("m1@example.org") });
+
+ // A root and one of its own replies is two DISTINCT messages, not one
+ // deduplicated to the thread.
+ const ActionScope both = model.messageScopeFor({ root, reply });
+ QCOMPARE(both.messageIds,
+ (QStringList{ QStringLiteral("m0@example.org"),
+ QStringLiteral("m1@example.org") }));
+ QCOMPARE(both.messageCount, 2);
+}
+
+void TestThreadListModel::messageScopeSkipsAThreadRowItCannotNameAMessageFor()
+{
+ // firstMessageId is populated by the worker from the query. A summary that
+ // arrived without one names no message, and the tempting fallback is to
+ // act on the whole thread instead. That is exactly the silent escalation
+ // item 108 exists to remove: the user would ask to act on one message and
+ // hit the conversation.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ QStringLiteral("A subject"));
+ t.totalCount = 4;
+ t.firstMessageId.clear();
+ model.appendBatch({ t });
+
+ const QModelIndex root = model.index(0, 0, QModelIndex());
+ const ActionScope scope = model.messageScopeFor({ root });
+
+ QVERIFY2(scope.isEmpty(),
+ "a thread row with no message id was escalated to its whole "
+ "thread rather than skipped");
+ QCOMPARE(scope.messageCount, 0);
+}
+
+void TestThreadListModel::aMessageTagChangeReachesTheRootCardsOwnMessage()
+{
+ // The user, 2026-08-16: "delete single message on the root message of a
+ // thread doesn't trigger the repaint, delete whole thread does".
+ //
+ // applyMessageTagChange only searched `children`, and the root message is
+ // never there: setThreadMessages drops depth 0 because the root row stands
+ // for it. So a write to the message a root card displays found nothing,
+ // updated nothing and repainted nothing, while the same write on a reply
+ // worked. Item 108 made this the ORDINARY case, so the every-day gesture
+ // was the broken one.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("one"));
+ t.totalCount = 1; // A single-message thread.
+ t.firstMessageId = QStringLiteral("m0@example.org");
+ t.tags = QStringList{ QStringLiteral("inbox") };
+ model.appendBatch({ t });
+
+ const QModelIndex threadIndex = model.index(0, 0, QModelIndex());
+ QSignalSpy spy(&model, &QAbstractItemModel::dataChanged);
+
+ model.applyMessageTagChange(QStringLiteral("m0@example.org"),
+ { QStringLiteral("deleted") }, {});
+
+ // The card has to repaint, which is the whole report.
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toModelIndex(), threadIndex);
+
+ // And it has to LOOK deleted. For a one-message thread the thread's tags
+ // ARE that message's tags: notmuch_thread_get_tags is a union over the
+ // thread, and a union over one message is that message.
+ QVERIFY2(model.threadAt(0).isDeleted(),
+ "the root card does not show the state of the message it "
+ "displays, so Delete on it looks like it did nothing");
+
+ // Works before the thread has ever been expanded, which is the case the
+ // user hits: nothing loads a root's node until then.
+ QCOMPARE(model.rowCount(threadIndex), 0);
+}
+
+void TestThreadListModel::aMessageTagChangeOnOneOfManyLeavesTheThreadSummaryAlone()
+{
+ // The other half, and the reason the fix is not "write it to the summary".
+ // A thread's tags are a UNION over its messages, so deleting one message of
+ // seven does not make the conversation deleted, and painting the card
+ // crimson would claim it did.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("one"));
+ t.totalCount = 7;
+ t.firstMessageId = QStringLiteral("m0@example.org");
+ t.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") };
+ model.appendBatch({ t });
+
+ const QModelIndex threadIndex = model.index(0, 0, QModelIndex());
+ QSignalSpy spy(&model, &QAbstractItemModel::dataChanged);
+
+ model.applyMessageTagChange(QStringLiteral("m0@example.org"),
+ { QStringLiteral("deleted") },
+ { QStringLiteral("unread") });
+
+ // Still repaints: MessageIdRole and anything else keyed on the root's own
+ // node has changed, and the row is what the user is looking at.
+ QCOMPARE(spy.count(), 1);
+
+ QVERIFY2(!model.threadAt(0).isDeleted(),
+ "deleting one message of a seven-message thread painted the whole "
+ "conversation as deleted");
+ QVERIFY2(model.threadAt(0).isUnread(),
+ "marking one message of a seven-message thread read claimed the "
+ "whole conversation was read, though six messages still are not");
+}
+
+void TestThreadListModel::aCardListsItsOwnTagsBeforeItsSiblings()
+{
+ // The user, 2026-08-16, looking at a real four-message thread: the card
+ // showed `mailing-list/SBo` and `signed`, and `signed` vanished the moment
+ // the row was selected, because it belongs to a SIBLING and item 110 made
+ // the card stop claiming it.
+ //
+ // Their answer, which is better than either extreme: show both, and let
+ // size say whose is whose. Own tags first at full size, the thread's other
+ // tags after, smaller and muted. Nothing disappears; a chip only shrinks
+ // once the split becomes known.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("one"));
+ t.totalCount = 4;
+ t.firstMessageId = QStringLiteral("m0@example.org");
+ // The UNION, as notmuch reports it: `signed` is a sibling's.
+ t.tags = QStringList{ QStringLiteral("inbox"),
+ QStringLiteral("mailing-list/SBo"),
+ QStringLiteral("signed"),
+ QStringLiteral("unread") };
+ model.appendBatch({ t });
+
+ const QModelIndex threadIndex = model.index(0, 0, QModelIndex());
+
+ // Before the row is opened there is no per-message answer, so every chip
+ // is in the own tier. This is what stops anything from appearing to vanish
+ // later: the split narrows the tier, it does not remove a chip.
+ const QStringList before =
+ model.data(threadIndex, ThreadListModel::PillTagsRole).toStringList();
+ QVERIFY(before.contains(QStringLiteral("mailing-list/SBo")));
+ QVERIFY(before.contains(QStringLiteral("signed")));
+ QCOMPARE(model.data(threadIndex, ThreadListModel::PillOwnCountRole).toInt(),
+ before.size());
+
+ // The message loads, carrying what it really has.
+ model.setRootMessageTags(QStringLiteral("m0@example.org"),
+ { QStringLiteral("inbox"),
+ QStringLiteral("mailing-list/SBo"),
+ QStringLiteral("unread") });
+
+ const QStringList after =
+ model.data(threadIndex, ThreadListModel::PillTagsRole).toStringList();
+
+ // Same chips, still all present. The user explicitly did not want the
+ // sibling's tag dropped.
+ QVERIFY2(after.contains(QStringLiteral("signed")),
+ "the sibling's tag was dropped from the card rather than being "
+ "shown smaller, which is what looked like a bug");
+ QVERIFY2(after.contains(QStringLiteral("mailing-list/SBo")),
+ "the card lost a tag the message really carries");
+
+ // Own first, siblings after, and the count is where the delegate switches
+ // fonts.
+ const int own =
+ model.data(threadIndex, ThreadListModel::PillOwnCountRole).toInt();
+ QVERIFY2(own > 0 && own < after.size(),
+ "the split did not happen: every chip is in one tier");
+ QCOMPARE(after.mid(0, own),
+ QStringList{ QStringLiteral("mailing-list/SBo") });
+ QCOMPARE(after.mid(own), QStringList{ QStringLiteral("signed") });
+
+ // Colours stay aligned with the tags, since the delegate walks them in
+ // step and a shift would colour a chip with its neighbour's colour.
+ QCOMPARE(model.data(threadIndex, ThreadListModel::PillColoursRole)
+ .toList()
+ .size(),
+ after.size());
+}
+
+void TestThreadListModel::theSplitIsKnownBeforeTheRowIsEverOpened()
+{
+ // The user, 2026-08-16: "not selecting the thread shows the chips at 'main'
+ // size, not smaller, not dimmed. After selecting the thread the unioned
+ // chips repaint to the correct size/color."
+ //
+ // The first version derived the split from the message LOAD, so an unopened
+ // row had no per-message answer and put every chip in the own tier. That is
+ // honest and useless: the list is mostly unopened rows, so the feature was
+ // invisible exactly where it was meant to be read, and selecting a row
+ // still changed the card.
+ //
+ // The query knows. The worker already walks to the card's message to get
+ // its id, so it reads that message's tags in the same pass and the split
+ // arrives with the row.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("one"));
+ t.totalCount = 4;
+ t.firstMessageId = QStringLiteral("m0@example.org");
+ t.tags = QStringList{ QStringLiteral("inbox"),
+ QStringLiteral("mailing-list/SBo"),
+ QStringLiteral("signed"),
+ QStringLiteral("unread") };
+ // What the worker now supplies: the CARD's message, not the thread.
+ t.firstMessageTags = QStringList{ QStringLiteral("inbox"),
+ QStringLiteral("mailing-list/SBo"),
+ QStringLiteral("unread") };
+ model.appendBatch({ t });
+
+ const QModelIndex threadIndex = model.index(0, 0, QModelIndex());
+
+ // Never opened, never expanded.
+ QCOMPARE(model.rowCount(threadIndex), 0);
+
+ const QStringList pills =
+ model.data(threadIndex, ThreadListModel::PillTagsRole).toStringList();
+ const int own =
+ model.data(threadIndex, ThreadListModel::PillOwnCountRole).toInt();
+
+ QVERIFY2(own < pills.size(),
+ "an unopened row still puts every chip in the own tier, so the "
+ "card renders them all at full size and only corrects itself "
+ "when the row is selected");
+ QCOMPARE(pills.mid(0, own), QStringList{ QStringLiteral("mailing-list/SBo") });
+ QCOMPARE(pills.mid(own), QStringList{ QStringLiteral("signed") });
+
+ // And a message-scoped write still lands, without a load having happened.
+ model.applyMessageTagChange(QStringLiteral("m0@example.org"),
+ { QStringLiteral("deleted") }, {});
+ QVERIFY(model.messageById(QStringLiteral("m0@example.org")).isDeleted());
+ QVERIFY2(!model.threadAt(0).isDeleted(),
+ "the thread summary was rewritten for a one-message edit on a "
+ "four-message thread");
+}
+
+void TestThreadListModel::reconcileRefreshesASurvivorsOwnMessageTags()
+{
+ // reconcile() keeps a surviving row's NODE, deliberately: its children and
+ // its loaded flag are the expansion state the method exists to preserve.
+ // That means the per-message tags have to be refreshed explicitly, and the
+ // change detector has to notice when only they moved.
+ //
+ // The case: a sync where the root message alone changed, which is exactly
+ // what an external `notmuch tag` or another client does. The thread's union
+ // can be identical while the card's own message is not.
+ ThreadListModel model;
+ ThreadSummary before = makeThread(QStringLiteral("t1"),
+ QStringLiteral("one"));
+ before.totalCount = 2;
+ before.firstMessageId = QStringLiteral("m0@example.org");
+ before.tags = QStringList{ QStringLiteral("inbox"),
+ QStringLiteral("unread") };
+ before.firstMessageTags = QStringList{ QStringLiteral("inbox"),
+ QStringLiteral("unread") };
+ model.appendBatch({ before });
+
+ QVERIFY(model.messageById(QStringLiteral("m0@example.org")).isUnread());
+
+ // The root was read elsewhere. The THREAD is still unread, because its
+ // reply is, so the union does not move at all.
+ ThreadSummary after = before;
+ after.firstMessageTags = QStringList{ QStringLiteral("inbox") };
+
+ QSignalSpy spy(&model, &QAbstractItemModel::dataChanged);
+ model.reconcile({ after });
+
+ QVERIFY2(!model.messageById(QStringLiteral("m0@example.org")).isUnread(),
+ "a sync that changed only the card's own message left the row "
+ "showing the old per-message tags");
+ QVERIFY2(spy.count() >= 1,
+ "the change was applied without telling the view, so the card "
+ "keeps its old pixels until something else repaints it");
+
+ // The expansion state is still what reconcile() exists to preserve.
+ QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t1"));
+}
+
void TestThreadListModel::updatesTagsForMessage()
{
ThreadListModel model;