diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-28 12:33:52 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-28 12:33:52 +0200 |
| commit | 978c9b51935f8597ce307166bfba29368bd4fc23 (patch) | |
| tree | d07ee82615113e31f788c731ffdfffa626e7ec21 /tests/test_mainwindow.cpp | |
| parent | cc551a4ed348e663601f2fb8e06b59c07a09ead4 (diff) | |
| download | qtmaildir-978c9b51935f8597ce307166bfba29368bd4fc23.tar.gz qtmaildir-978c9b51935f8597ce307166bfba29368bd4fc23.zip | |
feat: scope an action to the row it was invoked on
The five *_thread actions and their submenu are gone: the row's identity is
what decides the scope, so a second set of actions was a second answer to a
settled question. mark_thread_unread went with them, being the sixth entry in
the same submenu. tagSelected() loses its TagScope parameter, and
everySelectedRowHasTag() its own, so the direction and the write ask the same
question of the same object. ThreadListModel::scopeFor() and messageScopeFor()
are deleted; scopeForSelection() is the one resolver.
Labels name the scope. Archive, Delete, Restore, Spam, Important and the
unread toggle all say "thread" on a conversation row, and Delete, Restore and
Archive are ABSENT on a reply: a single reply cannot be removed from a
conversation.
Compose follows the same rule. Forward, Save, Reply-all and Reply without
quoting disappear on a conversation row, which shows no message to act on, and
Reply becomes "Reply to this thread": reply-all, quoting nothing, threaded off
the conversation's NEWEST message so the answer lands at its end rather than
forking the discussion at its opening post. That id is not in the model, since
an unexpanded conversation holds no nodes for its replies, so it comes from
resolveThreadMessages(); resolveQuery() states its newest-first sort rather
than inheriting notmuch's default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iDeN6C7y97nHYPvP6ST4L
Diffstat (limited to 'tests/test_mainwindow.cpp')
| -rw-r--r-- | tests/test_mainwindow.cpp | 866 |
1 files changed, 552 insertions, 314 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 4ed62f9..8fe1f87 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -383,7 +383,6 @@ private slots: void escapeBlanksTheMessagePane(); void deleteTogglesOnAnAlreadyDeletedThread(); void deleteOnAMixedSelectionDeletesRatherThanSplittingIt(); - void deleteOnAReplyReadsItsOwnThreadNotTheFirstInTheList(); void toggleUnreadOnAReplyReadsItsOwnThreadNotTheFirstInTheList(); void importantOnAnAlreadyImportantThreadRemovesTheTag(); void importantOnAPlainThreadStillAddsTheTag(); @@ -398,15 +397,13 @@ private slots: void theUnreadLabelSaysWhichDirectionItWillGo(); void theUnreadLabelFollowsAWriteWithoutReselecting(); void theUnreadActionIsHiddenOnAMixedSelection(); - void markThreadUnreadReachesAMixedThread(); - void markThreadReadAndUnreadAreSeparateActions(); + void aMixedThreadIsMarkedReadAndEditTagsIsTheWayBack(); void toggleUnreadOnAReplyReadsTheReplysOwnState(); void toggleUnreadOnAReplyRepaintsItInBothDirections(); void taggingTheOpenReplyUpdatesTheMessagePaneStrip(); void taggingAnUnrelatedReplyLeavesTheStripAlone(); void aHeldMessageEditIsSentWhenTheSyncEnds(); - void anActionOnAThreadRowActsOnTheMessageItDisplays(); - void theThreadSubmenuIsReachableFromBothMenus(); + void anActionOnAConversationRowTakesTheConversation(); void autoMarkReadTouchesOnlyTheMessageOnDisplay(); void autoMarkReadArmsForAReplyToo(); void taggingTheOpenRootMessageKeepsTheStripPopulated(); @@ -481,7 +478,7 @@ private slots: void twoDeletesToOneTrashBothGetTheirTags(); void deletingTwiceLeavesNoOriginTagBehind(); void undoOfADeleteRemovesTheOriginTagToo(); - void deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt(); + void deletingALoneMessageRemovesItFromTheInboxAndUndoReturnsIt(); void deleteThreadMovesEveryMessageAndRepaintsTheRootCard(); void aFolderNameWithASpaceSurvivesTheRoundTrip(); void deleteIsBoundToTheDeleteKey(); @@ -548,6 +545,15 @@ private slots: void aSmallSizeLimitIsNotDescribedAsZeroMegabytes(); void theBusinessSenderListIsLoadedAtStartup(); + // Item 177, task 5: the scope comes from the row, and the labels say so. + void theUnreadActionNamesTheThreadOnAConversationRow(); + void deleteIsAbsentOnAReplyRow(); + void theWholeThreadSubmenuIsGone(); + void forwardAndSaveAreAbsentOnAConversationRow(); + void replyOnAConversationRowNamesTheThread(); + void replyToAConversationAnswersItsNewestMessage(); + void replyIsUntouchedOnAMessageRow(); + private: /// Owns the throwaway lock table init() points every test at. A pointer /// rather than a value because it is rebuilt per test, and QTemporaryDir @@ -1585,11 +1591,12 @@ void TestMainWindow::anActionOnAThreadRowSaysItHitTheWholeThread() selectThreadRow(view, 0); QApplication::processEvents(); - // 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"); + // The plain `archive`, on a CONVERSATION row. Item 108's separate + // `archive_thread` is gone: since item 177 the row's identity is what + // makes this thread-scoped, and the fixture's totalCount of 7 is what + // makes the row a conversation. + auto *archive = window.findChild<QAction *>(QStringLiteral("archive")); + QVERIFY2(archive, "no archive action to trigger"); archive->trigger(); // Read BEFORE processEvents, deliberately. This binary has no worker @@ -4822,14 +4829,16 @@ void TestMainWindow::deleteTogglesOnAnAlreadyDeletedThread() QVERIFY(model); auto *view = window.findChild<QTreeView *>(); QVERIFY(view); - // 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")); + // `delete` on a CONVERSATION row, which is the thread-scoped delete since + // item 177: the row's identity is what decides the scope, so a summary + // carrying `deleted` is the right thing to read here. + auto *action = window.findChild<QAction *>(QStringLiteral("delete")); QVERIFY(action); - model->appendBatch({ makeThread(QStringLiteral("t1"), - { QStringLiteral("deleted") }) }); + ThreadSummary deleted = makeThread(QStringLiteral("t1"), + { QStringLiteral("deleted") }); + deleted.totalCount = 3; + model->appendBatch({ deleted }); selectThreadRow(view, 0); action->trigger(); @@ -4853,12 +4862,17 @@ void TestMainWindow::deleteOnAMixedSelectionDeletesRatherThanSplittingIt() QVERIFY(model); auto *view = window.findChild<QTreeView *>(); QVERIFY(view); - auto *action = window.findChild<QAction *>(QStringLiteral("delete_thread")); + auto *action = window.findChild<QAction *>(QStringLiteral("delete")); QVERIFY(action); - model->appendBatch({ makeThread(QStringLiteral("t1"), - { QStringLiteral("deleted") }), - makeThread(QStringLiteral("t2"), {}) }); + // Conversations, so `delete` is thread-scoped on both and the two rows + // really are in opposite THREAD states. + ThreadSummary first = makeThread(QStringLiteral("t1"), + { QStringLiteral("deleted") }); + first.totalCount = 2; + ThreadSummary second = makeThread(QStringLiteral("t2"), {}); + second.totalCount = 2; + model->appendBatch({ first, second }); view->selectAll(); QCOMPARE(view->selectionModel()->selectedRows().size(), 2); @@ -4927,59 +4941,6 @@ static QModelIndex expandSecondThreadAndSelectItsReply( return replyRow; } -void TestMainWindow::deleteOnAReplyReadsItsOwnThreadNotTheFirstInTheList() -{ - // Item 88's trap, still live: a toggle must read the state of the row it - // is on, not of whichever thread sits at that row NUMBER in the list. - // - // Through `delete_thread` rather than `delete`. Since item 103 Delete - // MOVES the file, so it is no longer a pure toggle over a tag and needs a - // configured trash folder and a worker; `delete_thread` is the variant - // that stayed tag-only, and it is a toggle over `deleted` exactly as - // Delete used to be. The message-scoped Delete's own direction choice is - // covered by the worker-backed cases at the bottom of this file, which is - // where a move can actually be observed. - 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_thread")); - 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"); - - // t2 is the reply's thread and is NOT deleted, so the correct direction - // is Delete. Reading t1's state instead would choose Undelete. - QVERIFY2(!model->threadAt(1).isDeleted(), - "the fixture's second thread is already deleted, so both " - "directions would look alike and this test would assert nothing"); - - action->trigger(); - - // Asserted on the MODEL, not on the undo stack. Delete thread MOVES since - // item 103's follow-up, and the undo entry is pushed once the worker - // confirms the move, which this bare window has no database to perform. - // The DIRECTION is chosen synchronously and is what item 88's trap was - // about: the repaint below happens only on the delete direction. - QVERIFY2(model->threadAt(1).isDeleted(), - "Delete on a reply of an undeleted thread chose the wrong " - "direction: it read the FIRST thread's state, which is deleted"); - // And the OTHER thread is untouched: the action must act on the reply's - // own conversation, not on both. - QVERIFY2(model->threadAt(0).isDeleted(), - "the fixture's first thread stopped being deleted, which means " - "the action reached a thread it was never pointed at"); -} - void TestMainWindow::toggleUnreadOnAReplyReadsItsOwnThreadNotTheFirstInTheList() { const Config config; @@ -5557,20 +5518,25 @@ void TestMainWindow::theUnreadActionIsHiddenOnAMixedSelection() "the action did not return when the selection agreed again"); } -void TestMainWindow::markThreadUnreadReachesAMixedThread() +void TestMainWindow::aMixedThreadIsMarkedReadAndEditTagsIsTheWayBack() { - // Item 112. The user's report: on a thread with two unread replies, asking - // to mark the whole thread unread marked it READ instead. + // What item 112 became under item 177. That item's report was real: on a + // thread with two unread replies, asking to mark the whole thread unread + // marked it READ, because ThreadSummary::tags is notmuch's UNION and a + // thread holding even one unread message answers "unread". A union is not + // a state, and a toggle needs a state. // - // ThreadSummary::tags is notmuch's UNION over the conversation, so a - // thread containing even one unread message answers "unread" and a toggle - // reading that predicate always picks "mark read". There was no input that - // could reach "mark thread unread" on a mixed thread: the only threads - // taking that branch were the ones already entirely read. + // Its fix was two fixed-direction thread actions in a submenu. Item 177 + // deleted that submenu: the ROW decides the scope, so a second set of + // actions was a second answer to a settled question. The cost is recorded + // here rather than hidden. On a mixed conversation the toggle still goes + // ONE way, and that way is "mark read", which is the safe direction: it + // takes the thread to a state it can then be toggled out of, where the + // reverse would have left it mixed and the key still dead. // - // A union is not a state. The fix is two fixed-direction actions, so this - // asserts the direction rather than the resulting tags: on a mixed thread - // BOTH directions are reachable, which is the property that was missing. + // The way back is Edit tags, which is absolute rather than a toggle and + // works whatever the mix. That is what the deleted submenu was really + // providing, and it did not need six actions to provide it. const Config config; MainWindow window(config); @@ -5584,61 +5550,62 @@ void TestMainWindow::markThreadUnreadReachesAMixedThread() // identically whichever way the direction is computed, so a uniform // fixture passes against the bug (CLAUDE.md, item 88's opposite-states // requirement). - model->appendBatch({ makeThread(QStringLiteral("T1"), - { QStringLiteral("unread") }) }); + ThreadSummary mixed = makeThread(QStringLiteral("T1"), + { QStringLiteral("unread") }); + mixed.totalCount = 3; + model->appendBatch({ mixed }); + const QModelIndex thread = model->index(0, 0, {}); QVERIFY(thread.isValid()); + QVERIFY2(model->isConversationRow(thread), + "the fixture's row is not a conversation, so the toggle would " + "read one message and this test would assert nothing about the " + "union"); QVERIFY2(model->threadFor(thread).isUnread(), "the fixture's union does not carry unread, so this test cannot " "reach the branch the defect lives in"); - view->setCurrentIndex(thread); - auto *markUnread = - window.findChild<QAction *>(QStringLiteral("mark_thread_unread")); - QVERIFY2(markUnread, "mark_thread_unread does not exist: the thread toggle " - "was not split, so a mixed thread still has no way to " - "be marked unread"); - markUnread->trigger(); - - QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("unread")), - qPrintable(QStringLiteral("wrong direction on a mixed thread: %1") - .arg(window.undoTextForTesting()))); - QVERIFY2(!window.undoTextForTesting().contains(QStringLiteral("Mark thread read")), - qPrintable(QStringLiteral("marked the thread READ when asked to " - "mark it unread: %1") - .arg(window.undoTextForTesting()))); -} + view->setCurrentIndex(thread); + view->selectionModel()->select( + thread, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + QApplication::processEvents(); -void TestMainWindow::markThreadReadAndUnreadAreSeparateActions() -{ - // The other half: the read direction must still be reachable, and must be - // its own action rather than the same one answering differently. Both are - // asserted on the SAME mixed thread, which a toggle cannot do: whichever - // direction it picks, the other is unreachable there. - const Config config; - MainWindow window(config); + auto *toggle = window.findChild<QAction *>(QStringLiteral("toggle_unread")); + QVERIFY(toggle); - auto *model = window.findChild<ThreadListModel *>(); - QVERIFY(model); - auto *view = window.findChild<QTreeView *>(); - QVERIFY(view); + // The LABEL is the promise, and it must name the thread and the direction + // before the key is pressed. A label saying only "Mark as read" on a row + // that is about to touch three messages is the ambiguity item 177 exists + // to remove. + QVERIFY2(toggle->text().contains(QStringLiteral("thread"), + Qt::CaseInsensitive), + qPrintable(QStringLiteral("the label does not name the thread: %1") + .arg(toggle->text()))); + QVERIFY2(toggle->text().contains(QStringLiteral("read"), Qt::CaseInsensitive) + && !toggle->text().contains(QStringLiteral("unread"), + Qt::CaseInsensitive), + qPrintable(QStringLiteral("the label does not promise the read " + "direction: %1").arg(toggle->text()))); + + toggle->trigger(); - model->appendBatch({ makeThread(QStringLiteral("T1"), - { QStringLiteral("unread") }) }); - const QModelIndex thread = model->index(0, 0, {}); - view->setCurrentIndex(thread); + QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("Mark read")), + qPrintable(QStringLiteral("the toggle went the other way on a " + "mixed thread: %1") + .arg(window.undoTextForTesting()))); - auto *markRead = - window.findChild<QAction *>(QStringLiteral("mark_thread_read")); - QVERIFY(markRead); - markRead->trigger(); - QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("Mark thread read")), - qPrintable(window.undoTextForTesting())); + // And it was THREAD-scoped, which is the whole of item 177: one keystroke + // on a conversation row took the conversation, not the one message its + // card shows. + QVERIFY2(!model->threadAt(0).isUnread(), + "the thread's own tags did not move, so the write was scoped to " + "one message and the other two are still unread"); - // The old toggle must be gone rather than left beside its replacements, - // which would leave the defect reachable from the menu it still sat in. - QVERIFY2(!window.findChild<QAction *>(QStringLiteral("toggle_unread_thread")), - "toggle_unread_thread still exists beside the split actions"); + // The route back exists and is not the toggle. + auto *editTags = window.findChild<QAction *>(QStringLiteral("edit_tags")); + QVERIFY2(editTags && editTags->isEnabled(), + "Edit tags is the absolute route the deleted submenu used to " + "provide, and it is not available"); } void TestMainWindow::toggleUnreadOnAReplyReadsTheReplysOwnState() @@ -5910,16 +5877,19 @@ void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds() "sending the held edit lost the tag from the reply's row"); } -void TestMainWindow::anActionOnAThreadRowActsOnTheMessageItDisplays() +void TestMainWindow::anActionOnAConversationRowTakesTheConversation() { + // The inversion item 177 is. Item 108 made a thread row act on the ONE + // message its card displays, and this test asserted exactly that; the user + // then reported it as the defect, because a card that stands above a + // conversation and acts on one message of it is two things at once. A row + // with replies is now the conversation, and a row without them is still + // its message. + // // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs // an account with a configured trash folder and a worker to do the move; - // this bare window has neither, and Delete correctly refuses. What is - // under test here is unchanged by that: `spam` is the other message-scoped - // tag-only action, and it paints the same doomed state. - // 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. + // this bare window has neither. `spam` is the other tag-only action and + // resolves its scope through the same tagSelected(). const Config config; MainWindow window(config); @@ -5928,99 +5898,35 @@ void TestMainWindow::anActionOnAThreadRowActsOnTheMessageItDisplays() 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("spam")); - 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. - // - // Asserted on the MODEL rather than on a pending write. Delete thread - // MOVES every message since item 103's follow-up, and a move needs ids and - // paths that only the database holds for a thread this bare window never - // expanded, so the write is issued after a worker round trip that never - // completes here. What is synchronous, and what this test is about, is the - // scope: the whole thread is marked, not the one message its card shows. - auto *deleteThread = - window.findChild<QAction *>(QStringLiteral("delete_thread")); - QVERIFY(deleteThread); - QVERIFY2(!model->threadAt(0).isDeleted(), - "the thread already read as deleted, so the check below would " - "pass without the action doing anything"); - deleteThread->trigger(); - - QVERIFY2(model->threadAt(0).isDeleted(), - "Delete thread did not mark the whole thread, so the card paints " - "undeleted until the row is clicked"); -} + // A conversation FIRST and a thread of one SECOND, so a wrong answer is + // visible in both directions rather than accidentally right in one. + ThreadSummary many = makeThread(QStringLiteral("t1"), {}); + many.totalCount = 7; + ThreadSummary one = makeThread(QStringLiteral("t2"), {}); + one.totalCount = 1; + model->appendBatch({ many, one }); -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 *spam = window.findChild<QAction *>(QStringLiteral("spam")); + QVERIFY(spam); - auto *context = - window.findChild<QMenu *>(QStringLiteral("threadContextMenu")); - QVERIFY(context); + selectThreadRow(view, 0); + QApplication::processEvents(); + spam->trigger(); - const QStringList expected = { - QStringLiteral("archive_thread"), - QStringLiteral("delete_thread"), - QStringLiteral("spam_thread"), - QStringLiteral("mark_thread_read"), - QStringLiteral("mark_thread_unread"), - QStringLiteral("flag_thread"), - }; + QCOMPARE(window.pendingThreadIdsForTesting(), + QStringList{ QStringLiteral("t1") }); + QVERIFY2(window.pendingMessageIdsForTesting().isEmpty(), + "a conversation row acted on one message, so six of the seven " + "messages the card stands above were left untouched"); - // 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); - } + // And the other half of the rule, which is what makes it a rule rather + // than a blanket escalation: a thread of one is still its message. + selectThreadRow(view, 1); + QApplication::processEvents(); + spam->trigger(); - // 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"); + QCOMPARE(window.pendingMessageIdsForTesting(), + QStringList{ QStringLiteral("t2-first@example.org") }); } void TestMainWindow::autoMarkReadTouchesOnlyTheMessageOnDisplay() @@ -6483,8 +6389,14 @@ void TestMainWindow::theSnapshotKeepsAThreadActionThreadScoped() auto *model = window.findChild<ThreadListModel *>(); auto *view = window.findChild<QTreeView *>(); QVERIFY(model && view); - model->appendBatch({ makeThread(QStringLiteral("t1"), {}) }); + // A CONVERSATION row, which is what makes `flag` thread-scoped since item + // 177. A thread of one would produce a message edit and this test would + // assert nothing about thread ids. + ThreadSummary many = makeThread(QStringLiteral("t1"), {}); + many.totalCount = 3; + model->appendBatch({ many }); selectThreadRow(view, 0); + QApplication::processEvents(); // A cron sync takes the lock, which is what makes the edit HELD rather // than sent, and a held edit is the only thing that carries thread ids. @@ -6492,7 +6404,7 @@ void TestMainWindow::theSnapshotKeepsAThreadActionThreadScoped() Q_ARG(SyncMonitor::State, SyncMonitor::State::Running)); - auto *action = window.findChild<QAction *>(QStringLiteral("flag_thread")); + auto *action = window.findChild<QAction *>(QStringLiteral("flag")); QVERIFY(action); action->trigger(); QVERIFY(window.hasEditAwaitingSend()); @@ -6572,14 +6484,18 @@ void TestMainWindow::anEditDuringABackgroundSyncIsNotSentYet() QVERIFY(model); auto *view = window.findChild<QTreeView *>(); QVERIFY(view); - // 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")); + // A CONVERSATION row, which is what makes `flag` thread-scoped since item + // 177. This test asserts on the thread ROW, which a message-scoped write + // deliberately leaves alone. What is under test is the HOLD, which is + // identical either way. + auto *action = window.findChild<QAction *>(QStringLiteral("flag")); QVERIFY2(action, "no flag action registered"); - model->appendBatch({ makeThread(QStringLiteral("t1"), {}) }); + ThreadSummary many = makeThread(QStringLiteral("t1"), {}); + many.totalCount = 3; + model->appendBatch({ many }); selectThreadRow(view, 0); + QApplication::processEvents(); // A cron sync takes the lock. QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged", @@ -6606,14 +6522,18 @@ void TestMainWindow::aHeldEditIsSentWhenTheBackgroundSyncEnds() QVERIFY(model); auto *view = window.findChild<QTreeView *>(); QVERIFY(view); - // 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")); + // A CONVERSATION row, which is what makes `flag` thread-scoped since item + // 177. This test asserts on the thread ROW, which a message-scoped write + // deliberately leaves alone. What is under test is the HOLD, which is + // identical either way. + auto *action = window.findChild<QAction *>(QStringLiteral("flag")); QVERIFY(action); - model->appendBatch({ makeThread(QStringLiteral("t1"), {}) }); + ThreadSummary many = makeThread(QStringLiteral("t1"), {}); + many.totalCount = 3; + model->appendBatch({ many }); selectThreadRow(view, 0); + QApplication::processEvents(); QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged", Q_ARG(SyncMonitor::State, @@ -7981,28 +7901,19 @@ void TestMainWindow::noTwoActionsShareAnIcon() // 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. - // - // reply_no_quote joined them in item 123 for exactly the same reason: it - // shares reply's icon, it is a menu entry that always carries its text, - // and it is not on the toolbar. The list is therefore no longer only the - // thread tier, which is why it is named for the PROPERTY that earns the - // exemption rather than for the tier that first needed it. + // control. The exemption was six whole-thread actions sharing their + // message-scoped twins' icons; item 177 deleted those six, so the list is + // down to the one entry that earns it on its own terms. + // + // reply_no_quote shares reply's icon, is a menu entry that always carries + // its text, and is not on the toolbar. The list is named for the PROPERTY + // that earns the exemption rather than for the tier that first needed it, + // which is why it survives that tier's deletion unchanged. // // 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 menuOnlySharedIconActions = { - QStringLiteral("archive_thread"), - QStringLiteral("delete_thread"), - QStringLiteral("spam_thread"), - QStringLiteral("mark_thread_read"), - QStringLiteral("mark_thread_unread"), - QStringLiteral("flag_thread"), QStringLiteral("reply_no_quote"), }; @@ -11393,44 +11304,33 @@ void TestMainWindow::undoOfADeleteRemovesTheOriginTagToo() 0); } -void TestMainWindow::deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt() +void TestMainWindow::deletingALoneMessageRemovesItFromTheInboxAndUndoReturnsIt() { - // The toggle asked a THREAD ROW about its thread's tags, which notmuch - // gives as a UNION over the conversation. Delete the root of a - // three-message thread and the two replies are untouched, so the union - // carries no `deleted`, so a second press read the row as not-deleted and - // ran Delete AGAIN: the message was moved trash-to-trash and came out - // carrying `deleted`, `deleted-from:inbox` AND `deleted-from:Trash`, with - // no way back, since a later restore would send it to the trash it now - // claims to have come from. + // Delete's message-scoped half, end to end, on the row where it still + // lives: a thread of ONE. Since item 177 a row with replies is the + // conversation and Delete there takes every message, so the only Delete + // that writes one message is this one. // - // The union was a documented approximation, called bounded because the - // worst case for a TAG toggle was re-applying a tag the message already - // had, which is a no-op. A MOVE re-applies the move. The comment outlived - // the code it described. + // This test used to run on the ROOT of a three-message thread, because + // that was the message-scoped case then, and it pinned a defect that came + // from the mismatch: the toggle asked a thread ROW about its thread's + // tags, which notmuch gives as a UNION, so deleting the root left the + // union carrying no `deleted` and a second press ran Delete AGAIN, + // trash-to-trash, producing `deleted-from:inbox` and + // `deleted-from:Trash` at once with no way back. Item 177 dissolves the + // mismatch rather than patching it: the row and the write now agree about + // what they are for. The trash-to-trash assertions stay, because they are + // what proves a delete cannot run twice on one message. // - // The row must be left ALONE between the two presses: a re-query rebuilds - // it from the database and hides the defect, which is why an earlier - // version of this probe passed. The user's gesture is two presses on the - // list as it stands. + // The row must be left ALONE between the presses: a re-query rebuilds it + // from the database and hides that class of defect, which is why an + // earlier version of this probe passed. WorkerBackedWindow backed; QVERIFY(backed.fixture().addMessage( - QStringLiteral("acct/inbox"), QStringLiteral("troot@example.org"), - QStringLiteral("Thread root"), QStringLiteral("sender@example.org"), + QStringLiteral("acct/inbox"), QStringLiteral("tlone@example.org"), + QStringLiteral("On its own"), QStringLiteral("sender@example.org"), QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), - QStringLiteral("Root body."))); - QVERIFY(backed.fixture().addMessage( - QStringLiteral("acct/inbox"), QStringLiteral("trep1@example.org"), - QStringLiteral("Re: Thread root"), QStringLiteral("other@example.org"), - QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"), - QStringLiteral("Reply one."), true, - QStringLiteral("troot@example.org"))); - QVERIFY(backed.fixture().addMessage( - QStringLiteral("acct/inbox"), QStringLiteral("trep2@example.org"), - QStringLiteral("Re: Thread root"), QStringLiteral("third@example.org"), - QStringLiteral("Fri, 14 Aug 2026 12:00:00 +0200"), - QStringLiteral("Reply two."), true, - QStringLiteral("troot@example.org"))); + QStringLiteral("Body."))); QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"), QStringLiteral("Trash")), qPrintable(backed.error())); @@ -11448,32 +11348,26 @@ void TestMainWindow::deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt() const QString root = backed.fixture().maildirPath(); const QString cfg = backed.fixture().configPath(); - const QString stem = QStringLiteral("troot.example.org"); + const QString stem = QStringLiteral("tlone.example.org"); const QString trash = root + QStringLiteral("/acct/Trash/cur"); - // Three messages, so the union genuinely differs from the root's own - // tags. With one message the two are identical and the defect cannot - // appear at all. - QCOMPARE(notmuchCount(cfg, QStringLiteral("thread:{id:troot@example.org}")), - 3); + // The guard that says this is the message-scoped path at all. With a + // second message the row would be a conversation and Delete would take + // the thread, which is a different test. + const QModelIndex row = model->index(0, 0, QModelIndex()); + QVERIFY2(!model->isConversationRow(row), + "the fixture's row is a conversation, so Delete is thread-scoped " + "here and this test asserts nothing about a lone message"); - view->setCurrentIndex(model->index(0, 0, QModelIndex())); + view->setCurrentIndex(row); + QApplication::processEvents(); window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(trash, stem), 15000); QTRY_VERIFY_WITH_TIMEOUT( - notmuchCount(cfg, QStringLiteral("id:troot@example.org and " + notmuchCount(cfg, QStringLiteral("id:tlone@example.org and " "tag:\"deleted-from:inbox\"")) == 1, 15000); - // Only the root moved. The replies are what make the union disagree, so - // this is also the guard the rest of the test depends on. - QCOMPARE(notmuchCount(cfg, QStringLiteral("id:trep1@example.org and " - "tag:deleted")), - 0); - QCOMPARE(notmuchCount(cfg, QStringLiteral("id:trep2@example.org and " - "tag:deleted")), - 0); - // There is no second press to make any more, and that is the point. // // Item 16's double-press-to-undelete existed because the deleted row @@ -11496,7 +11390,7 @@ void TestMainWindow::deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt() 15000); QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, - QStringLiteral("id:troot@example.org and tag:deleted")) + QStringLiteral("id:tlone@example.org and tag:deleted")) == 0, 15000); @@ -11506,24 +11400,24 @@ void TestMainWindow::deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt() // it was returned to. QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, - QStringLiteral("id:troot@example.org and tag:inbox")) + QStringLiteral("id:tlone@example.org and tag:inbox")) == 1, 15000); // Asked of notmuch directly: a UI query reads 0 rows for the whole // interval before the worker answers, so an absence assertion through the // query bar passes against any state of the database. - QCOMPARE(notmuchCount(cfg, QStringLiteral("id:troot@example.org")), 1); - QCOMPARE(notmuchCount(cfg, QStringLiteral("id:troot@example.org and " + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:tlone@example.org")), 1); + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:tlone@example.org and " "tag:\"deleted-from:inbox\"")), 0); - // The tag the re-delete invented. Its presence is the signature of this - // defect rather than a variation on the origin-tag ones. - QCOMPARE(notmuchCount(cfg, QStringLiteral("id:troot@example.org and " + // The tag a re-delete would invent. Its presence is the signature of a + // trash-to-trash move rather than a variation on the origin-tag defects. + QCOMPARE(notmuchCount(cfg, QStringLiteral("id:tlone@example.org and " "tag:\"deleted-from:Trash\"")), 0); QVERIFY2(!folderHasMessageFile(trash, stem), - "the second press left the message in the trash"); + "the message was left in the trash"); } void TestMainWindow::deleteThreadMovesEveryMessageAndRepaintsTheRootCard() @@ -11588,7 +11482,7 @@ void TestMainWindow::deleteThreadMovesEveryMessageAndRepaintsTheRootCard() view->setCurrentIndex(model->index(0, 0, QModelIndex())); view->expand(model->index(0, 0, QModelIndex())); - window.findChild<QAction *>(QStringLiteral("delete_thread"))->trigger(); + window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); // Every message MOVED, not merely tagged. This is the half that was // missing entirely: the action tagged and moved nothing. @@ -11616,7 +11510,7 @@ void TestMainWindow::deleteThreadMovesEveryMessageAndRepaintsTheRootCard() // Second press restores the whole thread, which only works if the toggle // can see the state the first press produced. view->setCurrentIndex(model->index(0, 0, QModelIndex())); - window.findChild<QAction *>(QStringLiteral("delete_thread"))->trigger(); + window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, thread + QStringLiteral(" and tag:deleted")) == 0, @@ -11681,7 +11575,7 @@ void TestMainWindow::aFolderNameWithASpaceSurvivesTheRoundTrip() const QString home = root + QLatin1Char('/') + folder; view->setCurrentIndex(model->index(0, 0, QModelIndex())); - window.findChild<QAction *>(QStringLiteral("delete_thread"))->trigger(); + window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, thread + QStringLiteral(" and tag:deleted")) == 2, @@ -11696,7 +11590,7 @@ void TestMainWindow::aFolderNameWithASpaceSurvivesTheRoundTrip() // Back again. view->setCurrentIndex(model->index(0, 0, QModelIndex())); - window.findChild<QAction *>(QStringLiteral("delete_thread"))->trigger(); + window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); QTRY_VERIFY_WITH_TIMEOUT( notmuchCount(cfg, thread + QStringLiteral(" and tag:deleted")) == 0, @@ -15026,4 +14920,348 @@ void TestMainWindow::theBusinessSenderListIsLoadedAtStartup() QStringLiteral("cofidis.it"))); } +void TestMainWindow::theUnreadActionNamesTheThreadOnAConversationRow() +{ + const Config config; + MainWindow window(config); + + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<QTreeView *>(); + QVERIFY(model && view); + + ThreadSummary one = makeThread(QStringLiteral("t1"), + QStringList{ QStringLiteral("unread") }); + one.totalCount = 1; + ThreadSummary many = makeThread(QStringLiteral("t2"), + QStringList{ QStringLiteral("unread") }); + many.totalCount = 4; + model->appendBatch({ one, many }); + + auto *action = window.findChild<QAction *>(QStringLiteral("toggle_unread")); + QVERIFY(action); + + selectThreadRow(view, 0); + QApplication::processEvents(); + const QString onMessage = action->text(); + + selectThreadRow(view, 1); + QApplication::processEvents(); + const QString onThread = action->text(); + + QVERIFY2(onMessage != onThread, + "the label reads the same on a message and on a conversation, so " + "nothing tells the user which one the key will act on"); + QVERIFY2(onThread.contains(QStringLiteral("thread"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("a conversation row's label does not " + "name the thread: %1").arg(onThread))); +} + +void TestMainWindow::deleteIsAbsentOnAReplyRow() +{ + const Config config; + MainWindow window(config); + + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<QTreeView *>(); + QVERIFY(model && view); + + ThreadSummary first = makeThread(QStringLiteral("t1"), {}); + first.totalCount = 1; + ThreadSummary many = makeThread(QStringLiteral("t2"), {}); + many.totalCount = 2; + model->appendBatch({ first, many }); + + MessageNode root; + root.messageId = QStringLiteral("m1"); + root.threadId = QStringLiteral("t2"); + root.depth = 0; + MessageNode reply; + reply.messageId = QStringLiteral("m2"); + reply.threadId = QStringLiteral("t2"); + reply.depth = 1; + model->setThreadMessages(QStringLiteral("t2"), { root, reply }); + + const QModelIndex thread = model->index(1, 0, QModelIndex()); + view->expand(thread); + const QModelIndex replyRow = model->index(0, 0, thread); + view->selectionModel()->select( + replyRow, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + view->setCurrentIndex(replyRow); + QApplication::processEvents(); + + auto *del = window.findChild<QAction *>(QStringLiteral("delete")); + QVERIFY(del); + QVERIFY2(!del->isVisible() || !del->isEnabled(), + "Delete is offered on a reply: deleting is a conversation-level " + "action and a single reply cannot be removed from a thread"); + + auto *archive = window.findChild<QAction *>(QStringLiteral("archive")); + QVERIFY(archive); + QVERIFY2(!archive->isVisible() || !archive->isEnabled(), + "Archive is offered on a reply: it is conversation-level for the " + "same reason Delete is"); + + // And the mirror: on the conversation row itself both are back, so the + // hide is about what the row IS and not a stuck flag. + selectThreadRow(view, 1); + QApplication::processEvents(); + QVERIFY2(del->isVisible() && del->isEnabled(), + "Delete stayed hidden on a conversation row"); + QVERIFY2(archive->isVisible() && archive->isEnabled(), + "Archive stayed hidden on a conversation row"); +} + +void TestMainWindow::theWholeThreadSubmenuIsGone() +{ + const Config config; + MainWindow window(config); + + for (const QString &name : { QStringLiteral("archive_thread"), + QStringLiteral("delete_thread"), + QStringLiteral("spam_thread"), + QStringLiteral("flag_thread"), + QStringLiteral("mark_thread_read"), + QStringLiteral("mark_thread_unread") }) { + QVERIFY2(!window.findChild<QAction *>(name), + qPrintable(QStringLiteral("%1 still exists; the scope now " + "comes from the row, so a separate " + "action is a second answer to a " + "settled question").arg(name))); + } + + QVERIFY2(window.findChildren<QMenu *>( + QStringLiteral("threadActionsMenu")).isEmpty(), + "the Whole thread submenu is still built"); +} + +void TestMainWindow::forwardAndSaveAreAbsentOnAConversationRow() +{ + // A conversation row shows no message, so the three actions that need one + // cannot mean what they usually do. Forward and Save simply go; Reply + // becomes "reply to the thread" and is covered by the next test. + const Config config; + MainWindow window(config); + + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<QTreeView *>(); + QVERIFY(model && view); + + ThreadSummary one = makeThread(QStringLiteral("t1"), {}); + one.totalCount = 1; + ThreadSummary many = makeThread(QStringLiteral("t2"), {}); + many.totalCount = 3; + model->appendBatch({ one, many }); + + auto *forward = window.findChild<QAction *>(QStringLiteral("forward")); + auto *save = window.findChild<QAction *>(QStringLiteral("save_message")); + auto *replyAll = window.findChild<QAction *>(QStringLiteral("reply_all")); + auto *noQuote = + window.findChild<QAction *>(QStringLiteral("reply_no_quote")); + QVERIFY(forward && save && replyAll && noQuote); + + selectThreadRow(view, 0); + QApplication::processEvents(); + QVERIFY2(forward->isVisible() && save->isVisible(), + "Forward and Save are hidden on a one-message row, where they " + "mean exactly what they always did"); + + selectThreadRow(view, 1); + QApplication::processEvents(); + QVERIFY2(!forward->isVisible(), + "Forward is offered on a conversation row, which shows no " + "message to forward"); + QVERIFY2(!save->isVisible(), + "Save is offered on a conversation row, which names no file"); + QVERIFY2(!replyAll->isVisible() && !noQuote->isVisible(), + "the reply variants are offered on a conversation row, where " + "there is one reply action and it is the thread's"); +} + +void TestMainWindow::replyOnAConversationRowNamesTheThread() +{ + const Config config; + MainWindow window(config); + + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<QTreeView *>(); + QVERIFY(model && view); + + ThreadSummary one = makeThread(QStringLiteral("t1"), {}); + one.totalCount = 1; + ThreadSummary many = makeThread(QStringLiteral("t2"), {}); + many.totalCount = 3; + model->appendBatch({ one, many }); + + auto *reply = window.findChild<QAction *>(QStringLiteral("reply")); + QVERIFY(reply); + + selectThreadRow(view, 0); + QApplication::processEvents(); + const QString onMessage = reply->text(); + + selectThreadRow(view, 1); + QApplication::processEvents(); + const QString onThread = reply->text(); + + QVERIFY2(reply->isVisible(), + "Reply disappeared on a conversation row; one reply action stays, " + "and it answers the thread"); + QVERIFY2(onMessage != onThread, + "Reply reads the same on a message and on a conversation, so " + "nothing says the answer goes to the whole thread"); + QVERIFY2(onThread.contains(QStringLiteral("thread"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("a conversation row's Reply does not " + "name the thread: %1").arg(onThread))); +} + +void TestMainWindow::replyToAConversationAnswersItsNewestMessage() +{ + // The routing, which the label test does NOT cover: a probe on the action's + // TEXT passes with the conversation branch of composeReply() deleted + // outright, measured. This asserts through the composer that opens. + // + // The newest message rather than the first is the whole decision. A card + // stands above a conversation and shows its OPENING post, so answering + // what the card displays would thread the reply off a message the + // discussion has moved on from: In-Reply-To and References would fork the + // thread, and the recipients would be whoever was in it at the start. + // + // Three senders, one per message, so "answered the newest" is + // distinguishable from "answered the first" AND from "answered any of + // them". Reply-all, so the To and Cc together carry the whole cast and the + // assertion is about which message supplied the headers, not about which + // fold the addresses landed in. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("work/inbox"), QStringLiteral("rt0@example.org"), + QStringLiteral("RT root"), QStringLiteral("first@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Root body."), false)); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("work/inbox"), QStringLiteral("rt1@example.org"), + QStringLiteral("Re: RT root"), QStringLiteral("middle@example.org"), + QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"), + QStringLiteral("Reply one."), false, QStringLiteral("rt0@example.org"))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("work/inbox"), QStringLiteral("rt2@example.org"), + QStringLiteral("Re: RT root"), QStringLiteral("newest@example.org"), + QStringLiteral("Fri, 14 Aug 2026 12:00:00 +0200"), + QStringLiteral("Reply two."), false, QStringLiteral("rt0@example.org"))); + QVERIFY2(backed.buildWithAccounts( + { { QStringLiteral("work"), QStringLiteral("work"), + QString(), QStringLiteral("/bin/true"), + QStringLiteral("you@example.org") } }), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(model && view && queryEdit); + + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1 + && !window.mailRootForTesting().isEmpty(), + 15000); + + const QModelIndex row = model->index(0, 0, QModelIndex()); + QVERIFY2(model->isConversationRow(row), + "the fixture's row is not a conversation, so Reply would take the " + "ordinary message path and this test would assert nothing"); + + view->setCurrentIndex(row); + view->selectionModel()->select( + row, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + QApplication::processEvents(); + + auto *reply = window.findChild<QAction *>(QStringLiteral("reply")); + QVERIFY(reply); + QVERIFY2(reply->isEnabled(), + "the account cannot send, so Reply is disabled and the gesture " + "never reaches the code under test"); + reply->trigger(); + + QTRY_VERIFY_WITH_TIMEOUT(window.openComposerCount() == 1, 15000); + ComposeWindow *composer = window.openComposersForTest().value(0); + QVERIFY(composer); + + auto *to = composer->findChild<QLineEdit *>(QStringLiteral("to")); + auto *cc = composer->findChild<QLineEdit *>(QStringLiteral("cc")); + QVERIFY(to && cc); + const QString recipients = to->text() + QLatin1Char(' ') + cc->text(); + + QVERIFY2(recipients.contains(QStringLiteral("newest@example.org")), + qPrintable(QStringLiteral("the reply does not answer the " + "conversation's newest message: %1") + .arg(recipients))); + + // And it QUOTES NOTHING, per the user: "we just add an answer to the + // thread". A quoted body would be the newest message's text, which is a + // second, separate way for this to be wrong. + auto *body = composer->findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + QVERIFY2(!body->toPlainText().contains(QStringLiteral("Reply two.")), + qPrintable(QStringLiteral("the reply quoted the message it " + "answers: %1").arg(body->toPlainText()))); + + composer->show(); + composer->close(); +} + +void TestMainWindow::replyIsUntouchedOnAMessageRow() +{ + // The other half of the rule: a reply row is a message like any other, so + // every compose action behaves exactly as it did before item 177. + const Config config; + MainWindow window(config); + + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<QTreeView *>(); + QVERIFY(model && view); + + ThreadSummary many = makeThread(QStringLiteral("t1"), {}); + many.totalCount = 2; + model->appendBatch({ many }); + + MessageNode root; + root.messageId = QStringLiteral("m1"); + root.threadId = QStringLiteral("t1"); + root.depth = 0; + MessageNode reply; + reply.messageId = QStringLiteral("m2"); + reply.threadId = QStringLiteral("t1"); + reply.depth = 1; + model->setThreadMessages(QStringLiteral("t1"), { root, reply }); + + const QModelIndex thread = model->index(0, 0, QModelIndex()); + view->expand(thread); + const QModelIndex replyRow = model->index(0, 0, thread); + view->selectionModel()->select( + replyRow, + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + view->setCurrentIndex(replyRow); + QApplication::processEvents(); + + for (const QString &name : { QStringLiteral("reply"), + QStringLiteral("reply_all"), + QStringLiteral("reply_no_quote"), + QStringLiteral("forward"), + QStringLiteral("save_message") }) { + auto *action = window.findChild<QAction *>(name); + QVERIFY(action); + QVERIFY2(action->isVisible(), + qPrintable(QStringLiteral("%1 is hidden on a reply row, which " + "is an ordinary message").arg(name))); + } + + auto *replyAction = window.findChild<QAction *>(QStringLiteral("reply")); + QVERIFY2(!replyAction->text().contains(QStringLiteral("thread"), + Qt::CaseInsensitive), + qPrintable(QStringLiteral("a reply row's Reply claims to answer " + "the thread: %1") + .arg(replyAction->text()))); +} + #include "test_mainwindow.moc" |
