aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-31 11:26:52 +0100
committerDanilo M. <danix@danix.xyz>2026-10-31 11:26:52 +0100
commit467fa84fd851459d5d9c9e5297e2500e938f0a18 (patch)
tree392d0237cac273a6a07db11b120a8a453e8e8ff6 /tests
parent9ae9473acdb7cb3310c3028765cc4b30fedfce27 (diff)
downloadqtmaildir-467fa84fd851459d5d9c9e5297e2500e938f0a18.tar.gz
qtmaildir-467fa84fd851459d5d9c9e5297e2500e938f0a18.zip
fix: keep bulk tag actions linear, and say when they land
Select all on a 10k-row search, then Archive, froze the window with one CPU pinned. gdb on the live process showed the UI thread cubic before the write was ever sent: each optimistic row write emitted dataChanged, whose handler refreshed the action labels by walking the whole selection, and each step of that walk looked the message up with a linear scan of every thread. ThreadListModel now answers id lookups from a lazily rebuilt hash, invalidated by its own structural signals and by hand at the two in-place id changes. MainWindow::batchModelWrites() holds the label refresh while a loop of optimistic writes runs, then refreshes once. Two list membership tests became sets. The announcement also used to fade on a timer whether or not the write had landed. It now reads "applying" until a round trip through the worker's queue says the write has run, then "done". An error takes the label back from a late "done". Item 211. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp37
-rw-r--r--tests/test_threadlistmodel.cpp49
2 files changed, 86 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 2b7008d..71795fa 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -295,6 +295,7 @@ private slots:
void narrowingAnEmptyQueryBarIsAPlainSearch();
void aMalformedAccountIsReportedWithoutBlockingTheConstructor();
void aWorkerBackedWindowReturnsRealThreads();
+ void aWriteSaysItIsApplyingUntilTheWorkerHasRunIt();
void aPurgeTakesTheRowsOutOfTheViewWithoutARefresh();
void theDashboardFollowsAWriteToTheConversationItShows();
void anEditHeldByASyncSaysSoInsteadOfClaimingItLanded();
@@ -9892,6 +9893,42 @@ void TestMainWindow::aWorkerBackedWindowReturnsRealThreads()
QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
}
+void TestMainWindow::aWriteSaysItIsApplyingUntilTheWorkerHasRunIt()
+{
+ // Item 211, from the user: after archiving ten thousand rows, "how do I
+ // know if it worked? The UI doesn't give me a feedback". The announcement
+ // used to vanish on a timer whether or not the write had landed.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("root@example.org"),
+ QStringLiteral("A subject"), QStringLiteral("sender@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY2(backed.build(), qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(queryEdit && model && view && status);
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ window.findChild<QAction *>(QStringLiteral("archive"))->trigger();
+
+ // Synchronous: the worker's reply is posted to this thread and cannot be
+ // delivered before the event loop runs, so this cannot race.
+ QVERIFY2(status->text().contains(QStringLiteral("applying")),
+ qPrintable(status->text()));
+ QTRY_VERIFY2_WITH_TIMEOUT(status->text().contains(QStringLiteral("done")),
+ qPrintable(status->text()), 15000);
+}
+
/// Item 181, from the user: "the thread dashboard doesn't update live with the
/// modifications applied to the list pane. If I mark the thread as read, the
/// dash still reports N unread".
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 28d3a8b..76ac0e8 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -109,6 +109,7 @@ private slots:
void aConversationListsItsFirstMessageAsAChild();
void aMessageRowDoesNotListItselfBeneathItself();
void aMixedSelectionCarriesBothScopes();
+ void idLookupsFollowEveryChangeToTheRows();
};
static ThreadSummary makeThread(const QString &id, const QString &subject)
@@ -2355,5 +2356,53 @@ void TestThreadListModel::aMixedSelectionCarriesBothScopes()
QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t2") });
}
+void TestThreadListModel::idLookupsFollowEveryChangeToTheRows()
+{
+ // Item 211. Lookups by id go through a lazily rebuilt hash rather than a
+ // walk, so every way the rows can change has to invalidate it. Each step
+ // asks a question whose answer moved, so a stale index answers wrongly.
+ const QString a = QStringLiteral("a@example.org");
+ const QString c = QStringLiteral("c@example.org");
+ const QString d = QStringLiteral("d@example.org");
+ const QString e = QStringLiteral("e@example.org");
+ const QString t1 = QStringLiteral("t1");
+ const QString t2 = QStringLiteral("t2");
+
+ ThreadListModel model;
+ ThreadSummary one = makeThread(t1, QStringLiteral("one"));
+ one.firstMessageId = a;
+ ThreadSummary two = makeThread(t2, QStringLiteral("two"));
+ two.firstMessageId = c;
+ model.appendBatch({ one, two });
+ QCOMPARE(model.threadIdForMessage(a), t1); // builds the index
+
+ // Expansion adds ids the index has never seen.
+ MessageNode first = makeNode(c, 0);
+ first.threadId = t2;
+ MessageNode reply = makeNode(d, 1);
+ reply.threadId = t2;
+ model.setThreadMessages(t2, { first, reply });
+ QCOMPARE(model.threadIdForMessage(d), t2);
+
+ // A removal renumbers: t2 moves from row 1 to row 0.
+ model.applyTagChange(t1, {}, { QStringLiteral("inbox") });
+ model.removeThreadsWithoutTag({ t1 }, QStringLiteral("inbox"));
+ QVERIFY(!model.hasThread(t1));
+ QVERIFY(model.threadIdForMessage(a).isEmpty());
+ QCOMPARE(model.threadCountFor(t2), 2);
+ model.applyMessageTagChange(d, { QStringLiteral("x") }, {});
+ QVERIFY(model.messageById(d).tags.contains(QStringLiteral("x")));
+
+ // reconcile() replaces a summary IN PLACE, with no structural signal.
+ two.firstMessageId = e;
+ two.tags = QStringList{ QStringLiteral("inbox") };
+ model.reconcile({ two });
+ QCOMPARE(model.threadIdForMessage(e), t2);
+
+ model.clear();
+ QVERIFY(!model.hasThread(t2));
+ QVERIFY(model.messageById(d).messageId.isEmpty());
+}
+
QTEST_MAIN(TestThreadListModel)
#include "test_threadlistmodel.moc"