diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-10 09:33:44 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-10 09:33:44 +0200 |
| commit | 01419de209c2b5e2ae7b996e6b5ff1baa2efb3da (patch) | |
| tree | 25718fd36e87eac721f41b02cca46b6fb07e94d9 /tests/test_notmuchworker.cpp | |
| parent | f72dba9f6c463c6823d85701e51d8be38dd22a62 (diff) | |
| parent | e1dba2987a9a1e87b92801959df9c9d4f1375d2f (diff) | |
| download | qtmaildir-01419de209c2b5e2ae7b996e6b5ff1baa2efb3da.tar.gz qtmaildir-01419de209c2b5e2ae7b996e6b5ff1baa2efb3da.zip | |
Merge branch 'card-list': the thread pane as a list of cards
Replaces the five-column grid with a single column of three-line cards. Item 53
recorded that the columns, not the cues drawn inside them, were what made the
list read as a table of records; item 20 had already shipped finished, tested
and green and been rejected on sight for exactly that reason.
A card is sender and date, subject with the flag, attachment and reply-count
marks, and tags, at one uniform height. Replies indent under a continuous spine
and show only the tags their thread does not carry. The account colour runs down
the card's left edge, replacing the chip that used to eat a third of every
subject line, with matching swatches in the account dropdown. Sorting newest or
oldest first is new and remembered.
Closes items 20, 51, 53 and 60.
The four defects that mattered were all found by rendering cards to an image and
looking at them, with the suite green through every one: a date clipped on unread
cards because bold is wider than the font the layout measured, an accent bar
painted in a colour identical to the background, an expander pill in a palette
role a theme had made equal to Base, and three separate faults from trusting
notmuch's reply depth to mean structure when it only means how notmuch happened
to thread the mail.
Diffstat (limited to 'tests/test_notmuchworker.cpp')
| -rw-r--r-- | tests/test_notmuchworker.cpp | 140 |
1 files changed, 137 insertions, 3 deletions
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index b419915..88dcf0b 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -39,6 +39,8 @@ private slots: void malformedQueryYieldsNoThreads(); void unreadableConfigEmitsError(); void queryPassesGenerationThrough(); + void oldestFirstReversesTheOrder(); + void theSortOrderCrossesAQueuedCall(); void loadThreadReturnsMessagesOldestFirst(); void loadThreadMarksMatchedMessages(); @@ -58,6 +60,11 @@ private slots: void requestAllTagsReturnsSortedTags(); void requestAllTagsOnUnreadableConfigEmitsError(); + void loadMessageReturnsOnlyThatMessage(); + void loadMessageOnAnUnknownIdReturnsNothing(); + void loadThreadTreeReportsReplyDepth(); + void loadThreadTreeCarriesTheFactsARowNeeds(); + void requestCountsAnswersOneCountPerQuery(); void requestCountsKeepsPositionOnAnInvalidQuery(); void requestDatabaseStatsCountsMessagesNotThreads(); @@ -68,7 +75,9 @@ private: QStringList tagsOf(const QString &messageId); QVector<MessageRef> messagesOfThread(const QString &threadId, const QString &matchQuery = QString()); - QVector<ThreadSummary> runQuery(const QString &query); + QVector<ThreadSummary> runQuery( + const QString &query, + NotmuchWorker::SortOrder sort = NotmuchWorker::NewestFirst); QString threadIdOf(const QString &subject); NotmuchFixture m_fixture; @@ -108,13 +117,14 @@ void TestNotmuchWorker::initTestCase() QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error())); } -QVector<ThreadSummary> TestNotmuchWorker::runQuery(const QString &query) +QVector<ThreadSummary> TestNotmuchWorker::runQuery( + const QString &query, NotmuchWorker::SortOrder sort) { NotmuchWorker worker(m_fixture.configPath()); QSignalSpy ready(&worker, &NotmuchWorker::threadsReady); QSignalSpy finished(&worker, &NotmuchWorker::queryFinished); - worker.runQuery(query, 1); + worker.runQuery(query, 1, sort); QVector<ThreadSummary> all; for (const QList<QVariant> &args : ready) @@ -158,6 +168,91 @@ QStringList TestNotmuchWorker::tagsOf(const QString &messageId) return {}; } +void TestNotmuchWorker::loadMessageReturnsOnlyThatMessage() +{ + // a2 is a reply in a two-message thread. Selecting a reply row must render + // that message alone; loadThread would hand back the whole thread and the + // pane would show the conversation the user was trying to look inside. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy loaded(&worker, &NotmuchWorker::messageLoaded); + worker.loadMessage(QStringLiteral("a2@example.org"), 1); + + QCOMPARE(loaded.count(), 1); + const auto messages = loaded.first().at(0).value<QVector<MessageRef>>(); + + QCOMPARE(messages.size(), 1); + QCOMPARE(messages.first().messageId, QStringLiteral("a2@example.org")); + QVERIFY(!messages.first().filePath.isEmpty()); + + // matched, so the pane renders it expanded rather than as a stub. The user + // asked for this message by clicking it, which is as matched as it gets. + QVERIFY(messages.first().matched); +} + +void TestNotmuchWorker::loadMessageOnAnUnknownIdReturnsNothing() +{ + // Empty rather than an error: a stale row after a reindex is an ordinary + // race, not a failure worth a message in the status bar. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy loaded(&worker, &NotmuchWorker::messageLoaded); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.loadMessage(QStringLiteral("nonexistent@example.org"), 1); + + QCOMPARE(loaded.count(), 1); + QVERIFY(loaded.first().at(0).value<QVector<MessageRef>>().isEmpty()); + QCOMPARE(errors.count(), 0); +} + +void TestNotmuchWorker::loadThreadTreeReportsReplyDepth() +{ + // Thread A is a root plus one reply carrying In-Reply-To, which is what + // notmuch threads on. Without that header the two would be separate threads + // and this test would assert nothing about depth. + const QString threadId = threadIdOf(QStringLiteral("Release notes")); + QVERIFY(!threadId.isEmpty()); + + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy loaded(&worker, &NotmuchWorker::threadTreeLoaded); + worker.loadThreadTree(threadId, QString(), 1); + + QCOMPARE(loaded.count(), 1); + const auto nodes = loaded.first().at(0).value<QVector<MessageNode>>(); + + QCOMPARE(nodes.size(), 2); + QCOMPARE(nodes.at(0).messageId, QStringLiteral("a1@example.org")); + QCOMPARE(nodes.at(0).depth, 0); + QCOMPARE(nodes.at(1).messageId, QStringLiteral("a2@example.org")); + QCOMPARE(nodes.at(1).depth, 1); +} + +void TestNotmuchWorker::loadThreadTreeCarriesTheFactsARowNeeds() +{ + // A row is drawn without opening the message, so the walk has to read the + // headers. loadThread does not, which is why a separate signal exists. + const QString threadId = threadIdOf(QStringLiteral("Release notes")); + QVERIFY(!threadId.isEmpty()); + + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy loaded(&worker, &NotmuchWorker::threadTreeLoaded); + worker.loadThreadTree(threadId, QString(), 1); + + QCOMPARE(loaded.count(), 1); + const auto nodes = loaded.first().at(0).value<QVector<MessageNode>>(); + QCOMPARE(nodes.size(), 2); + + const MessageNode &reply = nodes.at(1); + QVERIFY(reply.from.contains(QStringLiteral("bob@example.org"))); + QCOMPARE(reply.subject, QStringLiteral("Re: Release notes")); + QVERIFY(reply.date.isValid()); + QVERIFY(!reply.filePath.isEmpty()); + + // Every node names its thread, so a batch does not need the caller to keep + // track of which thread it asked about. + QCOMPARE(reply.threadId, threadId); + QCOMPARE(nodes.at(0).threadId, threadId); +} + void TestNotmuchWorker::queryReturnsAllThreads() { const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*")); @@ -234,6 +329,45 @@ void TestNotmuchWorker::queryPassesGenerationThrough() QCOMPARE(finished.first().at(1).value<quint64>(), quint64(42)); } +void TestNotmuchWorker::oldestFirstReversesTheOrder() +{ + const QVector<ThreadSummary> newest = runQuery(QStringLiteral("*")); + const QVector<ThreadSummary> oldest = + runQuery(QStringLiteral("*"), NotmuchWorker::OldestFirst); + + QCOMPARE(oldest.size(), newest.size()); + + // The guard: with fewer than two threads, or with every thread carrying + // the same date, a reversal is indistinguishable from no sorting at all + // and every assertion below would pass against a hardcoded order. + QVERIFY(newest.size() >= 2); + QVERIFY(newest.first().date != newest.last().date); + + QCOMPARE(oldest.first().threadId, newest.last().threadId); + QCOMPARE(oldest.last().threadId, newest.first().threadId); +} + +void TestNotmuchWorker::theSortOrderCrossesAQueuedCall() +{ + // MainWindow reaches the worker with invokeMethod(..., QueuedConnection) + // across a thread boundary, and a Q_ARG whose type the meta-object system + // does not know FAILS AT RUNTIME with a warning, not at compile time. So + // the enum's registration is asserted here rather than assumed from Q_ENUM. + QVERIFY2(QMetaType::fromName("NotmuchWorker::SortOrder").isValid(), + "SortOrder is not a registered metatype, so the queued runQuery " + "call will drop its sort argument at runtime"); + + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy ready(&worker, &NotmuchWorker::threadsReady); + + // The real call shape, invoked by NAME exactly as MainWindow does. + QVERIFY(QMetaObject::invokeMethod( + &worker, "runQuery", Qt::DirectConnection, + Q_ARG(QString, QStringLiteral("*")), Q_ARG(quint64, 1), + Q_ARG(NotmuchWorker::SortOrder, NotmuchWorker::OldestFirst))); + QCOMPARE(ready.size(), 1); +} + void TestNotmuchWorker::loadThreadReturnsMessagesOldestFirst() { const QString threadId = threadIdOf(QStringLiteral("Release notes")); |
