aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
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"