summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-10 20:43:05 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 20:43:05 +0200
commited0e085377440a68cef63ade6dc2afd322c22df9 (patch)
treea8b4e5631a6eb5ff56341253f3775b45e3a4c026 /tests
parent39a055fef99a3ce6877829753f384843b6a19177 (diff)
downloadqtmaildir-ed0e085377440a68cef63ade6dc2afd322c22df9.tar.gz
qtmaildir-ed0e085377440a68cef63ade6dc2afd322c22df9.zip
feat(view): follow a background sync without a keystroke
The thread list now updates itself when a sync finishes, whether it is empty or populated. New threads appear where the sort puts them, threads that stopped matching leave, and threads whose state changed repaint. Refreshing used to mean re-running the query, which cleared the model, the selection, the message pane and the undo stack, so 0.8.0 declined to do it on a cron timer and asked the user to press Enter instead. The result was a list that quietly disagreed with the database: mail indexed by cron never appeared, and an Unread view read to the end sat empty in front of it. ThreadListModel::reconcile() diffs a result against the current rows by thread id instead, so a surviving thread keeps its row, its persistent index and its loaded replies. Order comes from the result and is never imposed here, which is what makes the sort dropdown authoritative. The undo constraint this was sized around did not exist: no undo entry was ever keyed on a row. ThreadTagCommand stores thread ids and MessageTagCommand stores message ids, and applyTagChange() looks its target up by id, so an entry already survived its rows leaving the view. A thread read out of the current view now leaves the list, which is correct and would otherwise strand the reader, so MessageView grows a notice saying the open thread no longer matches, with a button that re-queries it. Recovery lists the whole conversation, expands it, and restores the message that was on screen rather than reopening at the first one. Ten defects were found building this, nine of them by hand testing: - SyncMonitor::start() polls synchronously, so an idle lock file emits stateChanged(Idle) from inside buildUi() and the first handler to touch a widget segfaults before the window exists. - QTreeView sets a current index when it takes focus with none set, and current drives loading, so new mail opened itself and was marked read without the user having looked at it. Selection is now required. - The notice outlived what it described, both when the pane was blanked and when another message replaced it. - Retiring the "Background sync completed" message left the bar claiming a sync was still running: silent means saying nothing new, not leaving a stale claim on screen. - A thread root sets both the thread id and the message id, so treating the message id as the message-row case discarded it for the commonest way to open a thread. - A freshly queried root does not know its own first message until the tree loads, so recovery selected nothing and left the pane blank. - A user query mid-recovery had its result hijacked by the pending selection. - MessageView emitted the recovery signal with its own members, so a direct connection handed MainWindow references that runCurrentQuery() then cleared by blanking the pane. The ids went empty mid-slot and no recovery ever ran. Every test passed against this, because reaching a slot through invokeMethod copies its arguments. A Qt signal argument is a reference until something copies it. Emitting a member to a slot that can re-enter the emitter is a use-after-write, and it presents as a wrong value rather than as a crash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp1059
-rw-r--r--tests/test_threadlistmodel.cpp248
2 files changed, 1299 insertions, 8 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 922705c..504c10c 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -20,6 +20,7 @@
#include <QAction>
#include <QApplication>
+#include <QFocusEvent>
#include <QCloseEvent>
#include <QDir>
#include <QKeyEvent>
@@ -99,6 +100,27 @@ private slots:
void aLocalSyncIsNotReportedAsABackgroundOne();
void aLocalSyncsOwnLockIsNeverReportedAsBackground();
void aSkippedLocalSyncStillReportsTheOtherRunFinishing();
+ void aCronSyncRefreshesTheListWithoutAQuery();
+ void aCronSyncRefreshesOverASelectionWithoutClearingIt();
+ void aCronSyncDoesNotRefreshBeforeAnyQueryHasRun();
+ void aRefreshAddsNewMailAndDropsWhatStoppedMatching();
+ void theOpenThreadLeavingTheListRaisesTheStaleNotice();
+ void aThreadStillMatchingRaisesNoStaleNotice();
+ void theStaleNoticeCarriesTheMessageBeingRead();
+ void recoveringAStaleThreadQueriesTheWholeThread();
+ void recoveryReselectsTheMessageThatWasBeingRead();
+ void recoveryOnTheFirstMessageSelectsTheThreadRow();
+ void aUserQueryAbandonsAPendingRecovery();
+ void blankingThePaneAlsoDropsTheStaleNotice();
+ void aNewQueryDropsTheStaleNotice();
+ void aFinishedBackgroundSyncStopsSayingItIsRunning();
+ void aRefreshDoesNotStampOverASelectionMessage();
+ void aRefreshDoesNotOpenNewMailByItself();
+ void openingAnotherMessageDropsTheStaleNoticeOfThePreviousOne();
+ void theStaleNoticeKeepsTheMessageOfAThreadRootToo();
+ void recoveryExpandsTheThreadAndSelectsRatherThanOnlyPointing();
+ void recoveryFromAnExpandedThreadRestoresTheReply();
+ void theRecoveryButtonSurvivesThePaneBeingBlanked();
void anUnobservableLockTableLeavesTheSyncButtonUsable();
void theStatusBarFollowsTheSyncPhase();
void aSelectedReadThreadIsNotDimmedIntoTheHighlight();
@@ -2357,12 +2379,18 @@ void TestMainWindow::aLocalSyncIsNotReportedAsABackgroundOne()
qPrintable(QStringLiteral("a background sync was not announced, "
"status says '%1'").arg(status->text())));
+ // The finish is no longer ANNOUNCED, since item 35b made the refresh
+ // silent, but it must still be acted on: the running message it wrote is
+ // retired. Asserting the absence of "running" rather than the presence of
+ // "completed" keeps the test on this window's subject, which is that the
+ // lock period was attributed to a background sync rather than to a local
+ // one, without pinning wording that has already changed once.
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Idle));
- QVERIFY2(status->text().contains(QStringLiteral("Background")),
- qPrintable(QStringLiteral("a finished background sync was not "
- "announced, status says '%1'")
+ QVERIFY2(!status->text().contains(QStringLiteral("running")),
+ qPrintable(QStringLiteral("a finished background sync left the bar "
+ "claiming it was still running: '%1'")
.arg(status->text())));
}
@@ -2437,14 +2465,1029 @@ void TestMainWindow::aSkippedLocalSyncStillReportsTheOtherRunFinishing()
qPrintable(QStringLiteral("a skip was reported as a failure: '%1'")
.arg(status->text())));
- // The other run finishing must still be announced.
+ // The other run finishing must still be ACTED ON. What that means depends
+ // on the state of the list, and this fixture has an empty one with nothing
+ // selected, so item 35a's free-refresh branch is what a handed-back lock
+ // reaches: the window re-runs the query rather than printing "press Enter".
+ //
+ // Asserting the generation rather than the status text is deliberate. The
+ // property under test is that the Idle was attributed to the OTHER run
+ // instead of being swallowed as this window's own; which of the two
+ // responses it then produces is item 35a's business, and pinning the
+ // wording here would fail every time that decision is revisited.
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ const quint64 before = window.currentGenerationForTesting();
+
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Idle));
- QVERIFY2(status->text().contains(QStringLiteral("Background")),
- qPrintable(QStringLiteral("after a skipped local sync, the other "
- "run finishing was swallowed; status "
- "says '%1'").arg(status->text())));
+ QVERIFY2(window.currentGenerationForTesting() > before,
+ "after a skipped local sync, the other run finishing was "
+ "swallowed rather than attributed to the background sync");
+}
+
+void TestMainWindow::aCronSyncRefreshesTheListWithoutAQuery()
+{
+ // Item 35b. A background sync brings the list up to date on its own, with
+ // no keystroke: this is the whole point of the item, and it holds whether
+ // the list is empty or full.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ // The refresh is observed as a NEW query being issued. There is no worker
+ // in this fixture, so no result ever arrives; what is asserted is that the
+ // query went out at all, which is what used to be missing.
+ const quint64 before = window.currentGenerationForTesting();
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+
+ QVERIFY2(window.currentGenerationForTesting() > before,
+ "a finished cron sync did not refresh the list");
+}
+
+void TestMainWindow::aCronSyncRefreshesOverASelectionWithoutClearingIt()
+{
+ // The behaviour 0.8.0 refused to build and the reason it refused: a refresh
+ // used to mean runCurrentQuery(), which clears the model, the selection and
+ // the message pane, so firing it on a cron timer would close the thread
+ // being read six times an hour.
+ //
+ // refreshCurrentQuery() reconciles instead, so the refresh runs AND the
+ // selection survives. A test that only checked the query was issued would
+ // pass against the destructive version, which is the version this item
+ // exists to avoid.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ model->appendBatch({ makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") }) });
+ const QModelIndex first = model->index(0, 0, QModelIndex());
+ QVERIFY(first.isValid());
+ view->setCurrentIndex(first);
+ QVERIFY2(view->selectionModel()->hasSelection(),
+ "the fixture failed to select a row, so this proves nothing");
+
+ const quint64 before = window.currentGenerationForTesting();
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+
+ QVERIFY2(window.currentGenerationForTesting() > before,
+ "a cron sync did not refresh a populated list");
+
+ // The rows are untouched until the result comes back, and the selection is
+ // still there. A clear() would have emptied both.
+ QCOMPARE(model->rowCount(QModelIndex()), 1);
+ QVERIFY2(view->selectionModel()->hasSelection(),
+ "the refresh cleared the selection, which is what made the old "
+ "one unusable on a cron timer");
+}
+
+void TestMainWindow::aCronSyncDoesNotRefreshBeforeAnyQueryHasRun()
+{
+ // The query bar holds text the user has typed but not run, and a refresh
+ // must not execute it: that is a search they never asked for. The refresh
+ // re-runs the LAST RUN query, so with none there is nothing to do.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:draft-i-was-typing"));
+
+ const quint64 before = window.currentGenerationForTesting();
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+
+ QCOMPARE(window.currentGenerationForTesting(), before);
+}
+
+void TestMainWindow::aRefreshAddsNewMailAndDropsWhatStoppedMatching()
+{
+ // The round trip end to end, driven through the real handlers: the refresh
+ // query goes out, its batches accumulate, and the result reconciles into
+ // the model in one go.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ model->appendBatch({ makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") }),
+ makeThread(QStringLiteral("T2"),
+ { QStringLiteral("unread") }) });
+ QCOMPARE(model->rowCount(QModelIndex()), 2);
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+
+ // T2 was read elsewhere and no longer matches; T3 is new mail.
+ const QVector<ThreadSummary> result{
+ makeThread(QStringLiteral("T3"), { QStringLiteral("unread") }),
+ makeThread(QStringLiteral("T1"), { QStringLiteral("unread") })
+ };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, refresh));
+
+ // Nothing has changed yet: a refresh applies its result whole, never batch
+ // by batch, or the first batch would delete every row after it.
+ QCOMPARE(model->rowCount(QModelIndex()), 2);
+ QCOMPARE(model->threadAt(0).threadId, QStringLiteral("T1"));
+
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 2), Q_ARG(quint64, refresh));
+
+ QCOMPARE(model->rowCount(QModelIndex()), 2);
+ QCOMPARE(model->threadAt(0).threadId, QStringLiteral("T3"));
+ QCOMPARE(model->threadAt(1).threadId, QStringLiteral("T1"));
+}
+
+void TestMainWindow::theOpenThreadLeavingTheListRaisesTheStaleNotice()
+{
+ // The user is reading a thread when a refresh drops it: read the last
+ // unread message and the thread stops matching tag:unread. The pane keeps
+ // rendering it, correctly, so without a notice the message becomes an
+ // orphan with no route back to its thread.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+
+ model->appendBatch({ makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") }) });
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ QCOMPARE(window.currentThreadId(), QStringLiteral("T1"));
+ QVERIFY(pane->staleThreadId().isEmpty());
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+
+ // The refresh comes back empty: the thread was read and is gone.
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 0), Q_ARG(quint64, refresh));
+
+ QCOMPARE(model->rowCount(QModelIndex()), 0);
+ QCOMPARE(pane->staleThreadId(), QStringLiteral("T1"));
+}
+
+void TestMainWindow::aThreadStillMatchingRaisesNoStaleNotice()
+{
+ // The common case, and the guard on the test above: a refresh that changes
+ // nothing must be invisible. A notice on every sync would be noise, and it
+ // would be a lie.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+
+ model->appendBatch({ makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") }) });
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+
+ const QVector<ThreadSummary> result{
+ makeThread(QStringLiteral("T1"), { QStringLiteral("unread") })
+ };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, refresh));
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 1), Q_ARG(quint64, refresh));
+
+ QCOMPARE(model->rowCount(QModelIndex()), 1);
+ QVERIFY2(pane->staleThreadId().isEmpty(),
+ "a thread that still matches was reported as stale");
+}
+
+void TestMainWindow::theStaleNoticeCarriesTheMessageBeingRead()
+{
+ // Reading reply four of eight when the thread drops out. Recovery has to
+ // restore the READER'S place, so the notice carries the message id as well
+ // as the thread; without it the thread reopens at its first message.
+ //
+ // Selecting a message row clears m_currentThreadId, so a notice keyed on
+ // that alone never fires for exactly the reader who is deepest into a
+ // thread. That is the case this pins.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+
+ ThreadSummary thread = makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") });
+ thread.totalCount = 3;
+ model->appendBatch({ thread });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m0@example.org");
+ root.threadId = QStringLiteral("T1");
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m1@example.org");
+ reply.threadId = QStringLiteral("T1");
+ reply.depth = 1;
+ model->setThreadMessages(QStringLiteral("T1"), { root, reply });
+
+ const QModelIndex threadIndex = model->index(0, 0, QModelIndex());
+ const QModelIndex replyIndex = model->index(0, 0, threadIndex);
+ QVERIFY(replyIndex.isValid());
+ view->setCurrentIndex(replyIndex);
+
+ // A message row, so the window is tracking a message rather than a thread.
+ QVERIFY2(window.currentThreadId().isEmpty(),
+ "the fixture selected a thread row, so this proves nothing");
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 0), Q_ARG(quint64, refresh));
+
+ QCOMPARE(pane->staleThreadId(), QStringLiteral("T1"));
+ QCOMPARE(pane->staleMessageId(), QStringLiteral("m1@example.org"));
+}
+
+void TestMainWindow::recoveringAStaleThreadQueriesTheWholeThread()
+{
+ // Clicking "Show it anyway" runs thread:<id>, not a query for the single
+ // message: the user asked to get the whole conversation back, with their
+ // place in it, so the list has to offer every message of it.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ QMetaObject::invokeMethod(&window, "recoverStaleThread",
+ Q_ARG(QString, QStringLiteral("T1")),
+ Q_ARG(QString, QStringLiteral("m1@example.org")));
+
+ QCOMPARE(queryEdit->text(), QStringLiteral("thread:T1"));
+}
+
+void TestMainWindow::recoveryReselectsTheMessageThatWasBeingRead()
+{
+ // The whole point of carrying the message id: reading reply four of eight,
+ // the thread comes back, and the selection lands on reply four rather than
+ // at the top of the thread.
+ //
+ // Driven through the real handlers because that is the only way the
+ // sequencing is exercised: the query has to come back before the thread
+ // can be expanded, and the expansion before the reply row exists at all.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ QMetaObject::invokeMethod(&window, "recoverStaleThread",
+ Q_ARG(QString, QStringLiteral("T1")),
+ Q_ARG(QString, QStringLiteral("m2@example.org")));
+ const quint64 generation = window.currentGenerationForTesting();
+
+ ThreadSummary thread = makeThread(QStringLiteral("T1"), {});
+ thread.totalCount = 3;
+ const QVector<ThreadSummary> result{ thread };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, generation));
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 1), Q_ARG(quint64, generation));
+
+ // The thread is listed, and nothing can be selected inside it yet: its
+ // replies are not loaded, so the recovery is still pending.
+ QCOMPARE(model->rowCount(QModelIndex()), 1);
+
+ 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.depth = 1;
+ MessageNode target;
+ target.messageId = QStringLiteral("m2@example.org");
+ target.threadId = QStringLiteral("T1");
+ target.depth = 1;
+ const QVector<MessageNode> nodes{ root, first, target };
+ QMetaObject::invokeMethod(&window, "onThreadTreeLoaded",
+ Q_ARG(QVector<MessageNode>, nodes),
+ Q_ARG(quint64, generation));
+
+ const QModelIndex current = view->currentIndex();
+ QVERIFY2(current.isValid(), "recovery selected nothing");
+ QVERIFY2(model->isMessageRow(current),
+ "recovery landed on the thread rather than on the message");
+ QCOMPARE(model->messageAt(current).messageId,
+ QStringLiteral("m2@example.org"));
+}
+
+void TestMainWindow::recoveryOnTheFirstMessageSelectsTheThreadRow()
+{
+ // 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.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ QMetaObject::invokeMethod(&window, "recoverStaleThread",
+ Q_ARG(QString, QStringLiteral("T1")),
+ Q_ARG(QString, QStringLiteral("m0@example.org")));
+ const quint64 generation = window.currentGenerationForTesting();
+
+ ThreadSummary thread = makeThread(QStringLiteral("T1"), {});
+ thread.totalCount = 2;
+ const QVector<ThreadSummary> result{ thread };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, generation));
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m0@example.org");
+ root.threadId = QStringLiteral("T1");
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m1@example.org");
+ reply.threadId = QStringLiteral("T1");
+ reply.depth = 1;
+ const QVector<MessageNode> nodes{ root, reply };
+ QMetaObject::invokeMethod(&window, "onThreadTreeLoaded",
+ Q_ARG(QVector<MessageNode>, nodes),
+ Q_ARG(quint64, generation));
+
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 1), Q_ARG(quint64, generation));
+
+ 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"));
+}
+
+void TestMainWindow::aUserQueryAbandonsAPendingRecovery()
+{
+ // A recovery spans two round-trips, so the user can type a query in the
+ // middle of one. That is them choosing to go somewhere else, and the
+ // pending selection must not follow them there: restoring a thread's
+ // message into a result the user asked for something else from would yank
+ // the view out from under them.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ // Recovering the thread with NO message pinned, so the pending recovery
+ // selects its thread row the moment that thread appears. A recovery
+ // waiting on a specific reply would pass this test without the guard,
+ // simply by never reaching its target: it would sit expanding a thread
+ // whose replies this fixture never delivers.
+ QMetaObject::invokeMethod(&window, "recoverStaleThread",
+ Q_ARG(QString, QStringLiteral("T1")),
+ Q_ARG(QString, QString()));
+
+ // The user changes their mind before the recovery's query comes back.
+ queryEdit->setText(QStringLiteral("tag:flagged"));
+ queryEdit->returnPressed();
+ const quint64 generation = window.currentGenerationForTesting();
+
+ // That query happens to contain the same thread, which is what makes this
+ // a trap rather than a theoretical case: the recovery would find its
+ // target and select it.
+ ThreadSummary thread = makeThread(QStringLiteral("T1"), {});
+ thread.totalCount = 2;
+ const QVector<ThreadSummary> result{
+ makeThread(QStringLiteral("T9"), {}), thread
+ };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, generation));
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 2), Q_ARG(quint64, generation));
+
+ QVERIFY2(!view->currentIndex().isValid(),
+ "an abandoned recovery selected a row in the query the user ran "
+ "instead");
+}
+
+void TestMainWindow::blankingThePaneAlsoDropsTheStaleNotice()
+{
+ // Reported by the user against the first build of item 35b. The notice
+ // outlived the message it describes: blanking the pane left the bar sitting
+ // above an empty pane, still naming a thread that was no longer shown, with
+ // a button offering to recover it.
+ //
+ // The bar belongs to the rendered message, exactly as the remote-content
+ // bar does, and MessageView::clear() already hides that one. This is the
+ // same rule applied to the same place.
+ const Config config;
+ MainWindow window(config);
+
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+
+ pane->setStaleThread(QStringLiteral("T1"),
+ QStringLiteral("m1@example.org"));
+ QCOMPARE(pane->staleThreadId(), QStringLiteral("T1"));
+
+ pane->clear();
+
+ QVERIFY2(pane->staleThreadId().isEmpty(),
+ "the stale notice survived the pane being blanked, so it names a "
+ "message that is no longer displayed");
+ QVERIFY2(pane->staleMessageId().isEmpty(),
+ "the stale notice kept the message id of a cleared pane");
+}
+
+void TestMainWindow::aNewQueryDropsTheStaleNotice()
+{
+ // The user's actual route to the bug: read a thread out of a view, get the
+ // notice, then type a new query. The pane blanks and the bar must go with
+ // it. Driven through the window rather than through MessageView::clear()
+ // directly, because the defect was that nothing on this path called it.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+
+ model->appendBatch({ makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") }) });
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 0), Q_ARG(quint64, refresh));
+ QCOMPARE(pane->staleThreadId(), QStringLiteral("T1"));
+
+ // The user runs a different query.
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ QVERIFY2(pane->staleThreadId().isEmpty(),
+ "after a new query the notice still offered to recover the thread "
+ "from the previous view");
+}
+
+void TestMainWindow::aFinishedBackgroundSyncStopsSayingItIsRunning()
+{
+ // Reported by the user against item 35b. "Background sync running..." is
+ // written straight to the status label when the lock appears, and the
+ // "Background sync completed" message on the way out was the only thing
+ // that ever replaced it. Removing that message, so a refresh could be
+ // silent, left the bar claiming a sync was running long after it finished.
+ //
+ // Silent means "says nothing NEW", not "leaves a stale claim standing".
+ const Config config;
+ MainWindow window(config);
+
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Running));
+ QVERIFY2(status->text().contains(QStringLiteral("running")),
+ qPrintable(QStringLiteral("the fixture never announced a running "
+ "sync, status says '%1'")
+ .arg(status->text())));
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+
+ QVERIFY2(!status->text().contains(QStringLiteral("running")),
+ qPrintable(QStringLiteral("the status bar still claims a sync is "
+ "running after it finished: '%1'")
+ .arg(status->text())));
+}
+
+void TestMainWindow::aRefreshDoesNotStampOverASelectionMessage()
+{
+ // The other half, and the reason this is not simply "always write the
+ // thread count". A refresh runs on a cron timer under a user who may be
+ // doing something, and the bar carries their selection count while they
+ // are. Overwriting that every ten minutes is the noise the silence rule
+ // exists to prevent.
+ const Config config;
+ MainWindow window(config);
+
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+
+ model->appendBatch({ makeThread(QStringLiteral("T1"), {}),
+ makeThread(QStringLiteral("T2"), {}) });
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ view->selectionModel()->select(model->index(1, 0, QModelIndex()),
+ QItemSelectionModel::Select);
+
+ const QString before = status->text();
+ QVERIFY2(!before.isEmpty(),
+ "the fixture left the status bar empty, so this proves nothing");
+
+ // A refresh completes with no sync ever having been announced.
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+ const QVector<ThreadSummary> result{
+ makeThread(QStringLiteral("T1"), {}), makeThread(QStringLiteral("T2"), {})
+ };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, refresh));
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 2), Q_ARG(quint64, refresh));
+
+ QCOMPARE(status->text(), before);
+}
+
+void TestMainWindow::aRefreshDoesNotOpenNewMailByItself()
+{
+ // Reported by the user against item 35b. A thread read to the end empties
+ // an Unread view; the refresh then brings in one new message, and it opens
+ // ITSELF in the message pane, marking it read two seconds later without
+ // the user ever having looked at it.
+ //
+ // Nothing in MainWindow selects it: QTreeView sets a current index of its
+ // own when rows are inserted into a model that had none, and selecting a
+ // row is what loads it. An automatic refresh must not do that, or a cron
+ // timer decides what the user is reading.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ // The view has been read empty: no rows, nothing current.
+ QCOMPARE(model->rowCount(QModelIndex()), 0);
+ QVERIFY(!view->currentIndex().isValid());
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+
+ const QVector<ThreadSummary> result{
+ makeThread(QStringLiteral("NEW"), { QStringLiteral("unread") })
+ };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, refresh));
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 1), Q_ARG(quint64, refresh));
+
+ QCOMPARE(model->rowCount(QModelIndex()), 1);
+
+ // Insertion alone does not do it, which is why the bug only showed up when
+ // the user came back from another desktop: QTreeView gives itself a current
+ // index when it takes FOCUS with none set. Reproduced here rather than
+ // asserted from the report, since a test that only inserts rows passes
+ // against the defect.
+ view->setFocus();
+ QApplication::sendEvent(view, new QFocusEvent(QEvent::FocusIn));
+
+ QVERIFY2(window.currentThreadId().isEmpty(),
+ "the refresh opened the new mail in the message pane, which marks "
+ "it read without the user having looked at it");
+}
+
+void TestMainWindow::openingAnotherMessageDropsTheStaleNoticeOfThePreviousOne()
+{
+ // The other half of the user's report: the pane showed the new message
+ // while the notice above it still named the thread they had been reading.
+ //
+ // The notice itself was right at the moment it was raised. What made it a
+ // lie was the pane being replaced underneath it, so this pins the rule that
+ // the notice belongs to whatever is currently rendered: selecting anything
+ // else retires it, exactly as blanking the pane does.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ model->appendBatch({ makeThread(QStringLiteral("OLD"),
+ { QStringLiteral("unread") }) });
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ // The refresh drops the thread being read and brings in new mail.
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+ const QVector<ThreadSummary> result{
+ makeThread(QStringLiteral("NEW"), { QStringLiteral("unread") })
+ };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, refresh));
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 1), Q_ARG(quint64, refresh));
+
+ QCOMPARE(pane->staleThreadId(), QStringLiteral("OLD"));
+
+ // The user chooses to open the new mail themselves.
+ const QModelIndex fresh = model->index(0, 0, QModelIndex());
+ QVERIFY(fresh.isValid());
+ QCOMPARE(model->threadAt(0).threadId, QStringLiteral("NEW"));
+ view->setCurrentIndex(fresh);
+ view->selectionModel()->select(fresh, QItemSelectionModel::Select);
+
+ QVERIFY2(pane->staleThreadId().isEmpty(),
+ "the notice still named the previous thread while the pane showed "
+ "a different message");
+}
+
+void TestMainWindow::theStaleNoticeKeepsTheMessageOfAThreadRootToo()
+{
+ // Reported by the user: recovering a thread they were reading brought the
+ // thread back collapsed, with the pane blank, instead of reopening the
+ // message they had been on.
+ //
+ // The cause is that a thread ROOT sets both ids. The root card IS the
+ // thread's first message and the pane renders exactly that message, so
+ // m_currentThreadId and m_currentMessageId are both filled; the notice read
+ // the message id only when the thread id was EMPTY, so the root case threw
+ // away a message id it had. Recovery then had nothing to restore, landed on
+ // the thread row and never expanded it.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ ThreadSummary thread = makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") });
+ thread.totalCount = 4;
+ model->appendBatch({ thread });
+
+ // The root knows its own message once the tree is loaded, which is what
+ // makes the pane show one message rather than the conversation.
+ MessageNode root;
+ root.messageId = QStringLiteral("m0@example.org");
+ root.threadId = QStringLiteral("T1");
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m1@example.org");
+ reply.threadId = QStringLiteral("T1");
+ reply.depth = 1;
+ model->setThreadMessages(QStringLiteral("T1"), { root, reply });
+
+ const QModelIndex threadIndex = model->index(0, 0, QModelIndex());
+ view->setCurrentIndex(threadIndex);
+ view->selectionModel()->select(threadIndex, QItemSelectionModel::Select);
+ QCOMPARE(window.currentThreadId(), QStringLiteral("T1"));
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+ const quint64 refresh = window.currentGenerationForTesting();
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 0), Q_ARG(quint64, refresh));
+
+ QCOMPARE(pane->staleThreadId(), QStringLiteral("T1"));
+ QVERIFY2(!pane->staleMessageId().isEmpty(),
+ "the notice dropped the message of a thread root, so recovery has "
+ "nothing to reopen and lands on a collapsed thread");
+ QCOMPARE(pane->staleMessageId(), QStringLiteral("m0@example.org"));
+}
+
+void TestMainWindow::recoveryExpandsTheThreadAndSelectsRatherThanOnlyPointing()
+{
+ // The rest of the same report: recovery brought the thread back COLLAPSED
+ // with the pane BLANK. Two separate faults behind one symptom.
+ //
+ // setCurrentIndex() alone sets a current row without selecting it, and
+ // since the fix for the auto-open defect onThreadSelected() ignores exactly
+ // that: an unselected current index is Qt's housekeeping, not the user. So
+ // recovery pointed at the row and nothing rendered.
+ //
+ // And nothing expanded the thread, so the reply the user had been reading
+ // was not on screen even when it was the target.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ // Recovering onto the thread's FIRST message, which is the root card: the
+ // case the user hit by opening a thread rather than a reply.
+ QMetaObject::invokeMethod(&window, "recoverStaleThread",
+ Q_ARG(QString, QStringLiteral("T1")),
+ Q_ARG(QString, QStringLiteral("m0@example.org")));
+ const quint64 generation = window.currentGenerationForTesting();
+
+ ThreadSummary thread = makeThread(QStringLiteral("T1"), {});
+ thread.totalCount = 4;
+ const QVector<ThreadSummary> result{ thread };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, result),
+ Q_ARG(quint64, generation));
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 1), Q_ARG(quint64, generation));
+
+ // No tree reply. This is the user's actual case and the one the earlier
+ // test missed: the thread comes back from the query with its replies NOT
+ // yet loaded, which is the normal state of a freshly queried row. Recovery
+ // has to ask for them rather than assuming they are already there.
+ const QModelIndex threadIndex = model->index(0, 0, QModelIndex());
+ QVERIFY(threadIndex.isValid());
+ QCOMPARE(model->rowCount(threadIndex), 0);
+
+ QVERIFY2(view->selectionModel()->hasSelection(),
+ "recovery pointed at the row without selecting it, so nothing "
+ "renders and the pane stays blank");
+ QCOMPARE(window.currentThreadId(), QStringLiteral("T1"));
+ QVERIFY2(view->isExpanded(threadIndex),
+ "recovery brought the thread back collapsed, so the conversation "
+ "the user was reading is not on screen");
+}
+
+void TestMainWindow::recoveryFromAnExpandedThreadRestoresTheReply()
+{
+ // The user's case, staged exactly: a thread ALREADY EXPANDED with the
+ // fourth reply selected and rendered, dropped by a refresh, then recovered.
+ // Reported twice as still broken while the earlier recovery tests passed,
+ // which means those tests were not reproducing it.
+ //
+ // What they missed is the whole round trip. Recovery re-runs thread:<id>,
+ // and that query REPLACES the model contents, so the recovered thread
+ // arrives collapsed with no replies loaded whatever state the old row was
+ // in. The reply the user wants therefore does not exist as a row at the
+ // moment recovery first runs, and the only thing that can create it is the
+ // tree reply arriving after an expand.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ // Reading reply 4 of a 4-message thread, expanded.
+ ThreadSummary thread = makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") });
+ thread.totalCount = 4;
+ model->appendBatch({ thread });
+
+ QVector<MessageNode> nodes;
+ for (int i = 0; i < 4; ++i) {
+ MessageNode n;
+ n.messageId = QStringLiteral("m%1@example.org").arg(i);
+ n.threadId = QStringLiteral("T1");
+ n.depth = i == 0 ? 0 : 1;
+ nodes.append(n);
+ }
+ model->setThreadMessages(QStringLiteral("T1"), nodes);
+
+ const QModelIndex threadIndex = model->index(0, 0, QModelIndex());
+ view->expand(threadIndex);
+ QCOMPARE(model->rowCount(threadIndex), 3);
+
+ const QModelIndex fourth = model->index(2, 0, threadIndex);
+ QVERIFY(fourth.isValid());
+ QCOMPARE(model->messageAt(fourth).messageId,
+ QStringLiteral("m3@example.org"));
+ view->setCurrentIndex(fourth);
+ view->selectionModel()->select(fourth, QItemSelectionModel::Select);
+
+ // The refresh drops it, and the user follows the notice.
+ QMetaObject::invokeMethod(&window, "recoverStaleThread",
+ Q_ARG(QString, QStringLiteral("T1")),
+ Q_ARG(QString, QStringLiteral("m3@example.org")));
+ const quint64 generation = window.currentGenerationForTesting();
+
+ // The recovery query comes back: ONE collapsed thread, no replies. This is
+ // what the query really returns, and the state the earlier tests skipped.
+ const QVector<ThreadSummary> recovered{ thread };
+ QMetaObject::invokeMethod(&window, "onThreadsReady",
+ Q_ARG(QVector<ThreadSummary>, recovered),
+ Q_ARG(quint64, generation));
+ QMetaObject::invokeMethod(&window, "onQueryFinished",
+ Q_ARG(int, 1), Q_ARG(quint64, generation));
+
+ const QModelIndex back = model->index(0, 0, QModelIndex());
+ QVERIFY(back.isValid());
+ QVERIFY2(view->isExpanded(back),
+ "the recovered thread came back collapsed");
+
+ // Expanding asks the worker for the tree; that reply is what creates the
+ // reply rows. Without it the conversation is not on screen at all.
+ QMetaObject::invokeMethod(&window, "onThreadTreeLoaded",
+ Q_ARG(QVector<MessageNode>, nodes),
+ Q_ARG(quint64, generation));
+
+ QVERIFY2(view->isExpanded(back),
+ "the thread collapsed again once its replies arrived");
+ QCOMPARE(model->rowCount(back), 3);
+
+ const QModelIndex current = view->currentIndex();
+ QVERIFY2(current.isValid(), "recovery left nothing selected");
+ QVERIFY2(model->isMessageRow(current),
+ "recovery landed on the thread rather than on the reply the user "
+ "was reading");
+ QCOMPARE(model->messageAt(current).messageId,
+ QStringLiteral("m3@example.org"));
+}
+
+void TestMainWindow::theRecoveryButtonSurvivesThePaneBeingBlanked()
+{
+ // The defect that survived six wrong diagnoses and every other recovery
+ // test in this file, because all of them reach the slot through
+ // invokeMethod, which COPIES its arguments.
+ //
+ // MessageView emitted the signal with its own members, so a direct
+ // connection handed MainWindow::recoverStaleThread() references to them.
+ // That slot calls runCurrentQuery(), which blanks the pane, which calls
+ // setStaleThread() and assigns to those very members. The ids the slot was
+ // still holding went empty mid-call, the recovery target was stored as an
+ // empty string, and nothing was ever recovered: the thread came back
+ // collapsed with the pane blank.
+ //
+ // Driven through the real button so the real signal runs. A test that
+ // calls the slot directly cannot see this and will pass against it.
+ const Config config;
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+ auto *button =
+ pane->findChild<QPushButton *>(QStringLiteral("staleThreadButton"));
+ QVERIFY(button);
+
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ pane->setStaleThread(QStringLiteral("T1"),
+ QStringLiteral("m3@example.org"));
+ QCOMPARE(pane->staleThreadId(), QStringLiteral("T1"));
+
+ button->click();
+
+ // The query the button ran is the thread's own, which is only true if the
+ // id survived the round trip.
+ QCOMPARE(queryEdit->text(), QStringLiteral("thread:T1"));
+
+ // And the target is still pending, waiting for the result. Empty here means
+ // the reference was clobbered and the recovery is already dead.
+ QVERIFY2(window.hasPendingRecoveryForTesting(),
+ "the recovery target was lost during the slot, so the thread will "
+ "come back collapsed with a blank pane");
}
void TestMainWindow::theSyncActionIsDisabledWhileABackgroundSyncHoldsTheLock()
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index aa71080..947a6b3 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -73,6 +73,14 @@ private slots:
void tagChangeForUnknownThreadIsIgnored();
void tagChangeRoundTripsForRevert();
void modelPassesQtTester();
+ void reconcileAddsNewThreadsInTheOrderGiven();
+ void reconcileRemovesThreadsThatNoLongerMatch();
+ void reconcileKeepsSurvivingRowsAndTheirExpansion();
+ void reconcileUpdatesTagsOnASurvivingThread();
+ void reconcileOnAnEmptyModelFillsIt();
+ void reconcileWithAnIdenticalResultChangesNothing();
+ void reconcileMovesAThreadBumpedByANewReply();
+ void reconcileKeepsAMovedRowsPersistentIndex();
};
static ThreadSummary makeThread(const QString &id, const QString &subject)
@@ -1172,5 +1180,245 @@ void TestThreadListModel::replySharingEveryThreadTagShowsNone()
.isEmpty());
}
+void TestThreadListModel::reconcileAddsNewThreadsInTheOrderGiven()
+{
+ // Item 35b. The auto-refresh hands the model a fresh result set and the
+ // model works out the difference, rather than being cleared and refilled.
+ //
+ // Position comes from the result, never from a rule of this model's own:
+ // the query is sorted by the worker, so with newest-first a new thread
+ // arrives at the front and with oldest-first at the back. A model that
+ // forced new rows to the top would contradict the sort the user chose.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")) });
+
+ model.reconcile({ makeThread(QStringLiteral("t3"),
+ QStringLiteral("Newest")),
+ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")) });
+
+ QCOMPARE(model.rowCount(), 3);
+ QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t3"));
+ QCOMPARE(model.threadAt(1).threadId, QStringLiteral("t1"));
+ QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t2"));
+}
+
+void TestThreadListModel::reconcileRemovesThreadsThatNoLongerMatch()
+{
+ // A thread read out of an Unread view stops matching, and the list has to
+ // say so. Leaving it would make the list disagree with its own query, and
+ // every view-wide action (Mark all read) acts on what the list holds.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")) });
+
+ model.reconcile({ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")) });
+
+ QCOMPARE(model.rowCount(), 1);
+ QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t2"));
+}
+
+void TestThreadListModel::reconcileKeepsSurvivingRowsAndTheirExpansion()
+{
+ // The whole point of reconciling rather than clearing. A surviving thread
+ // must keep the SAME row identity, because the view's selection, its
+ // expanded state and the open message all hang off persistent indexes: a
+ // beginResetModel drops every one of them, which is what made the old
+ // refresh close the thread being read.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")) });
+
+ MessageNode root = makeNode(QStringLiteral("m1"), 0);
+ MessageNode reply = makeNode(QStringLiteral("m2"), 1);
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+ QCOMPARE(model.rowCount(model.index(0, 0)), 1);
+
+ const QPersistentModelIndex survivor(model.index(0, 0));
+ QVERIFY(survivor.isValid());
+
+ // t2 leaves, t3 arrives, t1 stays put.
+ model.reconcile({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t3"),
+ QStringLiteral("Three")) });
+
+ QVERIFY2(survivor.isValid(),
+ "reconciling invalidated a surviving row, so the selection and "
+ "the open thread would be lost exactly as a reset loses them");
+ QCOMPARE(model.threadAt(survivor.row()).threadId, QStringLiteral("t1"));
+
+ // Its loaded replies survive too, or the thread collapses under the reader.
+ QCOMPARE(model.rowCount(model.index(survivor.row(), 0)), 1);
+}
+
+void TestThreadListModel::reconcileUpdatesTagsOnASurvivingThread()
+{
+ // A thread that stays but changed state: read elsewhere, tagged by a
+ // filter, flagged on the phone. The row has to repaint, or the list shows
+ // stale state while claiming to be current.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")) });
+ QVERIFY(model.threadAt(0).tags.contains(QStringLiteral("unread")));
+
+ ThreadSummary readNow = makeThread(QStringLiteral("t1"),
+ QStringLiteral("One"));
+ readNow.tags = QStringList{ QStringLiteral("inbox") };
+
+ QSignalSpy changed(&model, &QAbstractItemModel::dataChanged);
+ model.reconcile({ readNow });
+
+ QCOMPARE(model.rowCount(), 1);
+ QVERIFY2(!model.threadAt(0).tags.contains(QStringLiteral("unread")),
+ "a surviving thread kept its stale tags");
+ QVERIFY2(!changed.isEmpty(),
+ "the row's new state was stored without repainting it");
+}
+
+void TestThreadListModel::reconcileOnAnEmptyModelFillsIt()
+{
+ // Item 35a's case, now reached through the same path as every other
+ // refresh rather than through a special one: read the view empty, cron
+ // indexes new mail, it appears.
+ ThreadListModel model;
+ QCOMPARE(model.rowCount(), 0);
+
+ model.reconcile({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("New")) });
+
+ QCOMPARE(model.rowCount(), 1);
+ QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t1"));
+}
+
+void TestThreadListModel::reconcileWithAnIdenticalResultChangesNothing()
+{
+ // The common case: the sync brought nothing this query cares about. It
+ // runs every ten minutes under a reader, so it must not churn rows, and
+ // must not emit a reset that would collapse an expanded thread.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")) });
+
+ const QPersistentModelIndex kept(model.index(1, 0));
+ QSignalSpy reset(&model, &QAbstractItemModel::modelReset);
+ QSignalSpy inserted(&model, &QAbstractItemModel::rowsInserted);
+ QSignalSpy removed(&model, &QAbstractItemModel::rowsRemoved);
+
+ model.reconcile({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")) });
+
+ QCOMPARE(model.rowCount(), 2);
+ QVERIFY2(reset.isEmpty(), "an unchanged result reset the model");
+ QVERIFY2(inserted.isEmpty(), "an unchanged result inserted rows");
+ QVERIFY2(removed.isEmpty(), "an unchanged result removed rows");
+ QVERIFY(kept.isValid());
+ QCOMPARE(kept.row(), 1);
+}
+
+void TestThreadListModel::reconcileMovesAThreadBumpedByANewReply()
+{
+ // The case the other reconcile tests all miss, and the commonest reordering
+ // there is: an old thread gets a new reply, so under newest-first the
+ // worker returns it at the FRONT although it was already on screen. It is
+ // neither an arrival nor a departure, and a reconcile that only handles
+ // those two leaves it where it was, showing an order the query does not
+ // agree with.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")),
+ makeThread(QStringLiteral("t3"),
+ QStringLiteral("Three")) });
+
+ // t3 was replied to and now sorts first.
+ model.reconcile({ makeThread(QStringLiteral("t3"),
+ QStringLiteral("Three")),
+ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")) });
+
+ QCOMPARE(model.rowCount(), 3);
+ QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t3"));
+ QCOMPARE(model.threadAt(1).threadId, QStringLiteral("t1"));
+ QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t2"));
+}
+
+void TestThreadListModel::reconcileKeepsAMovedRowsPersistentIndex()
+{
+ // A reordering seen from the VIEW's side rather than the data's.
+ //
+ // reconcile() places rows with beginMoveRows, and the assertions on
+ // threadAt() cannot tell a correct move from a broken one: QVector::move
+ // reorders the storage whatever Qt was told, so the data lands right even
+ // if the signal is wrong and only a persistent index reports the
+ // difference. A real view's selection rides on exactly that.
+ //
+ // Every move reconcile() makes is upwards, which is a property of the walk
+ // and not of this data: the result is walked front to back, so rows ahead
+ // of the target are already final and a misplaced survivor is always
+ // pulled forward. The model asserts that invariant.
+ ThreadListModel model;
+
+ // Fatal, and attached BEFORE the move. The tester is what actually checks
+ // the beginMoveRows arguments against the rows that end up moving; the
+ // assertions below read m_threads, which QVector::move reorders correctly
+ // whatever destination Qt was told. Without this a wrong destination
+ // corrupts only what the VIEW is told, and every assertion here still
+ // passes while a real view's selection lands on the wrong row.
+ QAbstractItemModelTester tester(
+ &model, QAbstractItemModelTester::FailureReportingMode::Fatal);
+ Q_UNUSED(tester);
+
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")),
+ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")),
+ makeThread(QStringLiteral("t3"),
+ QStringLiteral("Three")) });
+
+ const QPersistentModelIndex moved(model.index(0, 0));
+ QVERIFY(moved.isValid());
+
+ // t1 ends last. Reached by t2 and t3 each being pulled forward past it,
+ // which is what makes t1's persistent index the thing under test: it is
+ // displaced twice without ever being the row that moves.
+ model.reconcile({ makeThread(QStringLiteral("t2"),
+ QStringLiteral("Two")),
+ makeThread(QStringLiteral("t3"),
+ QStringLiteral("Three")),
+ makeThread(QStringLiteral("t1"),
+ QStringLiteral("One")) });
+
+ QCOMPARE(model.rowCount(), 3);
+ QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t2"));
+ QCOMPARE(model.threadAt(1).threadId, QStringLiteral("t3"));
+ QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t1"));
+
+ // The persistent index followed the row rather than being invalidated,
+ // which is what keeps a selection on a thread that reordered under it.
+ // This is the assertion the destination adjustment is answerable to: with
+ // the wrong destination the DATA still lands correctly (QVector::move does
+ // not care what Qt was told) and only this reports the difference.
+ QVERIFY2(moved.isValid(), "a moved row lost its persistent index");
+ QCOMPARE(moved.row(), 2);
+}
+
QTEST_MAIN(TestThreadListModel)
#include "test_threadlistmodel.moc"