aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_mainwindow.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_mainwindow.cpp')
-rw-r--r--tests/test_mainwindow.cpp1001
1 files changed, 987 insertions, 14 deletions
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("; ")))));