diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-29 20:06:24 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-29 20:06:24 +0200 |
| commit | 236ac86ca09a24300e724c2372f3576bf3f6ff84 (patch) | |
| tree | 9ba6eefb4ddd7d5e37e1cb167faf334ae2290cd0 /tests | |
| parent | fca0a2226e52b4e01ba199950a4eb308040c3e11 (diff) | |
| download | qtmaildir-236ac86ca09a24300e724c2372f3576bf3f6ff84.tar.gz qtmaildir-236ac86ca09a24300e724c2372f3576bf3f6ff84.zip | |
fix: open a conversation's first message itself for --messagecli-selectors
--message naming the first message of a multi-message conversation
selected the conversation row, so the pane showed the dashboard rather
than the message. The recovery matched the conversation row on its own
message id, which is the root's. The rule is that --message always
targets the message, shown inside its expanded thread, and that
--thread is the way to ask for the conversation.
Since item 177 setThreadMessages keeps a conversation's first message as
child 0, so the root already has a row of its own. applyPendingRecovery()
now lets the thread row answer for a named message only when that row is
not a conversation, which leaves a thread of one opening on its message
as before, and finds the root among the children like any reply. The
provisional pass still selects the thread while the tree loads and is
refined to the root's row when the children arrive.
The same recovery serves the stale notice and a double-click. A stale
notice raised while reading the root's row now reopens that row, which
is the same request. A double-click on a conversation row asks for the
conversation, so it no longer names the root and still lands on the
dashboard. recoveryOnTheFirstMessageSelectsTheThreadRow asserted the old
behaviour on the pre-177 premise that the root has no child row; it is
retargeted as recoveryOnTheFirstMessageSelectsItsOwnRow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_mainwindow.cpp | 209 |
1 files changed, 200 insertions, 9 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index a22c153..0aaf8ff 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -327,6 +327,8 @@ private slots: void aThreadSelectorOpensThatThread(); void aMessageSelectorOpensItsThread(); void aMessageSelectorRendersItsReplyWhateverTheKeyboardLastHeld(); + void aMessageSelectorForARootShowsTheMessageNotTheDashboard(); + void doubleClickingAConversationStillLandsOnItsDashboard(); void aThreadSelectorThatIsNotHexIsRefused(); void anEmptySelectorSetChangesNothing(); void aBracketedMessageSelectorOpensThatMessage(); @@ -387,7 +389,7 @@ private slots: void theStaleNoticeCarriesTheMessageBeingRead(); void recoveringAStaleThreadQueriesTheWholeThread(); void recoveryReselectsTheMessageThatWasBeingRead(); - void recoveryOnTheFirstMessageSelectsTheThreadRow(); + void recoveryOnTheFirstMessageSelectsItsOwnRow(); void doubleClickingAThreadOpensThatThreadAlone(); void doubleClickingAReplyOpensItsThreadNotTheReplyAlone(); void doubleClickingDoesNotLeaveTheMarkReadTimerArmed(); @@ -3476,12 +3478,15 @@ void TestMainWindow::recoveryReselectsTheMessageThatWasBeingRead() QStringLiteral("m2@example.org")); } -void TestMainWindow::recoveryOnTheFirstMessageSelectsTheThreadRow() +void TestMainWindow::recoveryOnTheFirstMessageSelectsItsOwnRow() { - // The trap in the model: setThreadMessages DROPS the depth-0 message, - // because the root card is that message. So a reader recovering from the - // thread's first message must land on the ROOT row; looking for it among - // the children finds nothing and would leave the selection nowhere. + // A named message is always a MESSAGE row, the conversation's first + // included. This test asserted the opposite until the --message rule: + // "we target ALWAYS the message". Its premise had gone with item 177, + // which made setThreadMessages keep the depth-0 message as a + // conversation's child 0, so the root has a row of its own and landing + // on the conversation row showed the dashboard instead of the message. + // A thread of one still lands on its only row, which is its message. const Config config; MainWindow window(config); @@ -3525,9 +3530,12 @@ void TestMainWindow::recoveryOnTheFirstMessageSelectsTheThreadRow() const QModelIndex current = view->currentIndex(); QVERIFY2(current.isValid(), "recovery selected nothing"); - QVERIFY2(!model->isMessageRow(current), - "the thread's first message is the ROOT row, not a child"); - QCOMPARE(model->threadAt(current.row()).threadId, QStringLiteral("T1")); + QVERIFY2(model->isMessageRow(current), + "the first message landed on the conversation row, which shows " + "the dashboard rather than the message"); + QCOMPARE(model->messageAt(current).messageId, + QStringLiteral("m0@example.org")); + QCOMPARE(model->threadFor(current).threadId, QStringLiteral("T1")); } void TestMainWindow::doubleClickingAThreadOpensThatThreadAlone() @@ -18114,6 +18122,189 @@ void TestMainWindow::aMessageSelectorRendersItsReplyWhateverTheKeyboardLastHeld( 15000); } +void TestMainWindow::aMessageSelectorForARootShowsTheMessageNotTheDashboard() +{ + // --message naming a conversation's FIRST message. The user's rule: + // "When running with --message we target ALWAYS the message. If that + // message is part of a thread we show it inside the thread, but still the + // message. If I want the thread there's --thread." + // + // The conversation row's own message id IS the root's, so a recovery that + // matched it there selected the conversation and the pane showed the + // dashboard. Under item 177 the root has a child row of its own inside an + // expanded conversation, and that row is what must end up current. + WorkerBackedWindow backed; + // Dates verified with `date -d <yyyy-mm-dd> +%A`: Qt::RFC2822Date + // validates the weekday against the date. + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("b1@example.org"), + QStringLiteral("Bravo opens"), QStringLiteral("c@example.org"), + QStringLiteral("Sun, 16 Aug 2026 10:00:00 +0200"), + QStringLiteral("Bravo one."))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("b2@example.org"), + QStringLiteral("Bravo later reply"), QStringLiteral("d@example.org"), + QStringLiteral("Mon, 17 Aug 2026 10:00:00 +0200"), + QStringLiteral("Bravo two."), true, + QStringLiteral("b1@example.org"))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("c1@example.org"), + QStringLiteral("Charlie alone"), QStringLiteral("e@example.org"), + QStringLiteral("Tue, 18 Aug 2026 10:00:00 +0200"), + QStringLiteral("Charlie one."))); + QVERIFY2(backed.build(), qPrintable(backed.error())); + + NotmuchWorker probe(backed.config().notmuchConfig()); + const QString bravo = + probe.threadIdForTesting(QStringLiteral("id:b1@example.org")); + const QString other = + probe.threadIdForTesting(QStringLiteral("id:c1@example.org")); + QVERIFY(!bravo.isEmpty()); + QVERIFY(!other.isEmpty()); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + QVERIFY(model); + auto *view = window.findChild<ThreadListView *>(); + QVERIFY(view); + auto *pane = window.findChild<MessageView *>(); + QVERIFY(pane); + auto *header = pane->findChild<QLabel *>(QStringLiteral("messageHeader")); + QVERIFY(header); + auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(queryEdit); + + // A different state first, asserted, so the checks after the selector + // mean something: another thread listed beside it, and another message on + // display. + queryEdit->setText(QStringLiteral("tag:inbox")); + emit queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 2, 15000); + QModelIndex otherRow; + for (int row = 0; row < model->rowCount(QModelIndex()); ++row) { + if (model->threadAt(row).threadId == other) + otherRow = model->index(row, 0, QModelIndex()); + } + QVERIFY(otherRow.isValid()); + view->selectionModel()->select( + otherRow, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + view->setCurrentIndex(otherRow); + QTRY_VERIFY_WITH_TIMEOUT( + !pane->showingPlaceholder() && !pane->showingDashboard() + && header->text().contains(QStringLiteral("Charlie alone")), + 15000); + + LaunchSelectors selectors; + selectors.messageId = QStringLiteral("b1@example.org"); + window.applySelectors(selectors); + + // The conversation alone is listed, and expanded. + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1 + && model->threadAt(0).threadId == bravo, + 15000); + const QModelIndex conversation = model->index(0, 0, QModelIndex()); + QTRY_VERIFY_WITH_TIMEOUT(view->isExpanded(conversation), 15000); + + // The ROOT's own message row current and selected, never the + // conversation row that stands for the whole thread. + QTRY_VERIFY_WITH_TIMEOUT( + view->currentIndex().isValid() + && model->isMessageRow(view->currentIndex()) + && model->messageAt(view->currentIndex()).messageId + == QStringLiteral("b1@example.org"), + 15000); + QVERIFY(view->selectionModel()->isSelected(view->currentIndex())); + QCOMPARE(view->currentIndex().parent(), conversation); + + // What the user sees: that message, not the dashboard and not the + // placeholder. + QTRY_VERIFY_WITH_TIMEOUT( + !pane->showingDashboard() && !pane->showingPlaceholder() + && header->text().contains(QStringLiteral("Bravo opens")), + 15000); +} + +void TestMainWindow::doubleClickingAConversationStillLandsOnItsDashboard() +{ + // The other side of --message's rule. A double-click on a CONVERSATION + // row asks for the conversation, and the recovery now treats any named + // message as a message row, so naming the root here would open the + // root's own row. The gesture names none, and lands on the dashboard. + WorkerBackedWindow backed; + // Dates verified with `date -d <yyyy-mm-dd> +%A`. + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("b1@example.org"), + QStringLiteral("Bravo opens"), QStringLiteral("c@example.org"), + QStringLiteral("Sun, 16 Aug 2026 10:00:00 +0200"), + QStringLiteral("Bravo one."))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("b2@example.org"), + QStringLiteral("Bravo later reply"), QStringLiteral("d@example.org"), + QStringLiteral("Mon, 17 Aug 2026 10:00:00 +0200"), + QStringLiteral("Bravo two."), true, + QStringLiteral("b1@example.org"))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("c1@example.org"), + QStringLiteral("Charlie alone"), QStringLiteral("e@example.org"), + QStringLiteral("Tue, 18 Aug 2026 10:00:00 +0200"), + QStringLiteral("Charlie one."))); + QVERIFY2(backed.build(), qPrintable(backed.error())); + + NotmuchWorker probe(backed.config().notmuchConfig()); + const QString bravo = + probe.threadIdForTesting(QStringLiteral("id:b1@example.org")); + const QString other = + probe.threadIdForTesting(QStringLiteral("id:c1@example.org")); + QVERIFY(!bravo.isEmpty()); + QVERIFY(!other.isEmpty()); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + QVERIFY(model); + auto *view = window.findChild<ThreadListView *>(); + QVERIFY(view); + auto *pane = window.findChild<MessageView *>(); + QVERIFY(pane); + auto *header = pane->findChild<QLabel *>(QStringLiteral("messageHeader")); + QVERIFY(header); + auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(queryEdit); + + // A message on display first, so landing on the dashboard is a change. + queryEdit->setText(QStringLiteral("tag:inbox")); + emit queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 2, 15000); + QModelIndex otherRow; + QModelIndex bravoRow; + for (int row = 0; row < model->rowCount(QModelIndex()); ++row) { + if (model->threadAt(row).threadId == other) + otherRow = model->index(row, 0, QModelIndex()); + if (model->threadAt(row).threadId == bravo) + bravoRow = model->index(row, 0, QModelIndex()); + } + QVERIFY(otherRow.isValid()); + QVERIFY(bravoRow.isValid()); + view->selectionModel()->select( + otherRow, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + view->setCurrentIndex(otherRow); + QTRY_VERIFY_WITH_TIMEOUT( + !pane->showingPlaceholder() && !pane->showingDashboard() + && header->text().contains(QStringLiteral("Charlie alone")), + 15000); + + emit view->doubleClicked(bravoRow); + + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1 + && model->threadAt(0).threadId == bravo, + 15000); + const QModelIndex conversation = model->index(0, 0, QModelIndex()); + QTRY_VERIFY_WITH_TIMEOUT(view->isExpanded(conversation) + && !window.hasPendingRecoveryForTesting(), + 15000); + QCOMPARE(view->currentIndex(), conversation); + QTRY_VERIFY_WITH_TIMEOUT(pane->showingDashboard(), 15000); +} + void TestMainWindow::aThreadSelectorThatIsNotHexIsRefused() { // The thread id now comes from another program's command line or the |
