diff options
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 124 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 50 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 282 |
5 files changed, 458 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5198429..5f01128 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(qtmaildir_lib STATIC htmlbuilder.cpp cidschemehandler.cpp notmuchworker.cpp + threadlistmodel.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp new file mode 100644 index 0000000..5acfe0e --- /dev/null +++ b/src/threadlistmodel.cpp @@ -0,0 +1,124 @@ +#include "threadlistmodel.h" + +#include <QFont> + +ThreadListModel::ThreadListModel(QObject *parent) + : QAbstractTableModel(parent) +{ +} + +int ThreadListModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_threads.size(); +} + +int ThreadListModel::columnCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : ColumnCount; +} + +QVariant ThreadListModel::data(const QModelIndex &index, int role) const +{ + // A stale index from a view that has not caught up with a clear() can carry + // any row or column, so both bounds are checked rather than trusted. + if (!index.isValid() || index.row() < 0 || index.row() >= m_threads.size() + || index.column() < 0 || index.column() >= ColumnCount) { + return {}; + } + + const ThreadSummary &thread = m_threads.at(index.row()); + + if (role == ThreadIdRole) + return thread.threadId; + + if (role == Qt::DisplayRole) { + switch (index.column()) { + case DateColumn: + return thread.date.toString(QStringLiteral("yyyy-MM-dd hh:mm")); + case AuthorsColumn: + return thread.authors; + case SubjectColumn: + return thread.totalCount > 1 + ? QStringLiteral("%1 (%2)").arg(thread.subject) + .arg(thread.totalCount) + : thread.subject; + case TagsColumn: + return thread.tags.join(QLatin1Char(' ')); + default: + return {}; + } + } + + if (role == Qt::FontRole && thread.isUnread()) { + QFont font; + font.setBold(true); + return font; + } + + return {}; +} + +QVariant ThreadListModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) + return {}; + + switch (section) { + case DateColumn: return QStringLiteral("Date"); + case AuthorsColumn: return QStringLiteral("From"); + case SubjectColumn: return QStringLiteral("Subject"); + case TagsColumn: return QStringLiteral("Tags"); + default: return {}; + } +} + +void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch) +{ + // beginInsertRows with an empty range violates Qt's contract, so the guard + // has to come before the signal, not inside it. + if (batch.isEmpty()) + return; + + const int first = m_threads.size(); + beginInsertRows({}, first, first + batch.size() - 1); + m_threads.append(batch); + endInsertRows(); +} + +void ThreadListModel::clear() +{ + beginResetModel(); + m_threads.clear(); + endResetModel(); +} + +ThreadSummary ThreadListModel::threadAt(int row) const +{ + if (row < 0 || row >= m_threads.size()) + return {}; + return m_threads.at(row); +} + +void ThreadListModel::applyTagChange(const QString &threadId, + const QStringList &added, + const QStringList &removed) +{ + for (int row = 0; row < m_threads.size(); ++row) { + if (m_threads.at(row).threadId != threadId) + continue; + + QStringList &tags = m_threads[row].tags; + for (const QString &tag : removed) + tags.removeAll(tag); + for (const QString &tag : added) { + if (!tags.contains(tag)) + tags.append(tag); + } + + // The whole row repaints: unread state drives the font of every column, + // not just the tags one. + emit dataChanged(index(row, 0), index(row, ColumnCount - 1)); + return; + } +} diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h new file mode 100644 index 0000000..2cf8d2e --- /dev/null +++ b/src/threadlistmodel.h @@ -0,0 +1,50 @@ +#pragma once + +#include <QAbstractTableModel> +#include <QVector> + +#include "types.h" + +/// Table model over query results, filled in batches so a large query paints +/// its first screenful immediately. +class ThreadListModel : public QAbstractTableModel +{ + Q_OBJECT +public: + enum Column { + DateColumn = 0, + AuthorsColumn, + SubjectColumn, + TagsColumn, + ColumnCount, + }; + + enum Role { + /// The thread id behind a row. Views hand out QModelIndexes, but the + /// worker speaks thread ids, so the mapping belongs on the model + /// rather than in every caller. + ThreadIdRole = Qt::UserRole + 1, + }; + + explicit ThreadListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + int columnCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role) const override; + + void appendBatch(const QVector<ThreadSummary> &batch); + void clear(); + + ThreadSummary threadAt(int row) const; + + /// Applies a tag change locally so the UI updates before the worker + /// confirms. To revert a failed write, call again with added and removed + /// swapped. + void applyTagChange(const QString &threadId, const QStringList &added, + const QStringList &removed); + +private: + QVector<ThreadSummary> m_threads; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a24a60b..f970385 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,3 +13,4 @@ target_compile_definitions(test_mimeparser PRIVATE add_qtmaildir_test(interceptor) add_qtmaildir_test(htmlbuilder) add_qtmaildir_test(notmuchworker) +add_qtmaildir_test(threadlistmodel) diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp new file mode 100644 index 0000000..f69147c --- /dev/null +++ b/tests/test_threadlistmodel.cpp @@ -0,0 +1,282 @@ +#include <QAbstractItemModelTester> +#include <QSignalSpy> +#include <QtTest> + +#include "threadlistmodel.h" + +class TestThreadListModel : public QObject +{ + Q_OBJECT +private slots: + void startsEmpty(); + void appendsBatches(); + void appendingEmptyBatchIsNoOp(); + void clearResetsModel(); + void reportsSubjectAndAuthors(); + void subjectShowsMessageCountOnlyForRealThreads(); + void unreadThreadsRenderBold(); + void threadIdIsReachableFromAnIndex(); + void invalidIndexesReturnNothing(); + void threadAtOutOfRangeIsSafe(); + void updatesTagsForMessage(); + void tagChangeIsIdempotent(); + void tagChangeSignalsExactlyTheChangedRow(); + void tagChangeForUnknownThreadIsIgnored(); + void tagChangeRoundTripsForRevert(); + void modelPassesQtTester(); +}; + +static ThreadSummary makeThread(const QString &id, const QString &subject) +{ + ThreadSummary t; + t.threadId = id; + t.subject = subject; + t.authors = QStringLiteral("Alice"); + t.date = QDateTime::fromSecsSinceEpoch(1750000000); + t.totalCount = 2; + t.matchedCount = 1; + t.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") }; + return t; +} + +void TestThreadListModel::startsEmpty() +{ + ThreadListModel model; + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.columnCount(), ThreadListModel::ColumnCount); +} + +void TestThreadListModel::appendsBatches() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + QCOMPARE(model.rowCount(), 1); + + model.appendBatch({ makeThread(QStringLiteral("t2"), QStringLiteral("two")), + makeThread(QStringLiteral("t3"), QStringLiteral("three")) }); + QCOMPARE(model.rowCount(), 3); + QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t3")); +} + +void TestThreadListModel::appendingEmptyBatchIsNoOp() +{ + ThreadListModel model; + QSignalSpy inserted(&model, &QAbstractItemModel::rowsInserted); + + model.appendBatch({}); + + QCOMPARE(model.rowCount(), 0); + // An empty beginInsertRows(first, first - 1) range is a Qt contract + // violation, so the guard must come before the signal. + QVERIFY(inserted.isEmpty()); +} + +void TestThreadListModel::clearResetsModel() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + + QSignalSpy spy(&model, &QAbstractItemModel::modelReset); + model.clear(); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(spy.count(), 1); +} + +void TestThreadListModel::reportsSubjectAndAuthors() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("hello")) }); + + const QModelIndex authors = model.index(0, ThreadListModel::AuthorsColumn); + QCOMPARE(model.data(authors, Qt::DisplayRole).toString(), + QStringLiteral("Alice")); + + const QModelIndex date = model.index(0, ThreadListModel::DateColumn); + QVERIFY(!model.data(date, Qt::DisplayRole).toString().isEmpty()); + + const QModelIndex tags = model.index(0, ThreadListModel::TagsColumn); + QCOMPARE(model.data(tags, Qt::DisplayRole).toString(), + QStringLiteral("inbox unread")); +} + +void TestThreadListModel::subjectShowsMessageCountOnlyForRealThreads() +{ + ThreadListModel model; + + ThreadSummary single = makeThread(QStringLiteral("t1"), QStringLiteral("alone")); + single.totalCount = 1; + ThreadSummary multi = makeThread(QStringLiteral("t2"), QStringLiteral("group")); + multi.totalCount = 4; + model.appendBatch({ single, multi }); + + QCOMPARE(model.data(model.index(0, ThreadListModel::SubjectColumn), + Qt::DisplayRole).toString(), + QStringLiteral("alone")); + QCOMPARE(model.data(model.index(1, ThreadListModel::SubjectColumn), + Qt::DisplayRole).toString(), + QStringLiteral("group (4)")); +} + +void TestThreadListModel::unreadThreadsRenderBold() +{ + ThreadListModel model; + ThreadSummary read = makeThread(QStringLiteral("t1"), QStringLiteral("read")); + read.tags = QStringList{ QStringLiteral("inbox") }; + model.appendBatch({ read, makeThread(QStringLiteral("t2"), QStringLiteral("unread")) }); + + const QVariant readFont = + model.data(model.index(0, ThreadListModel::SubjectColumn), Qt::FontRole); + QVERIFY(!readFont.isValid()); + + const QVariant unreadFont = + model.data(model.index(1, ThreadListModel::SubjectColumn), Qt::FontRole); + QVERIFY(unreadFont.isValid()); + QVERIFY(unreadFont.value<QFont>().bold()); +} + +void TestThreadListModel::threadIdIsReachableFromAnIndex() +{ + // The view hands MainWindow a QModelIndex; the worker needs a thread id. + // Without a role for it, every caller has to reach around the model. + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")), + makeThread(QStringLiteral("t2"), QStringLiteral("two")) }); + + const QModelIndex index = model.index(1, ThreadListModel::SubjectColumn); + QCOMPARE(model.data(index, ThreadListModel::ThreadIdRole).toString(), + QStringLiteral("t2")); +} + +void TestThreadListModel::invalidIndexesReturnNothing() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + + QVERIFY(!model.data(QModelIndex(), Qt::DisplayRole).isValid()); + + // Qt refuses to hand out an out-of-range index at all, so data() cannot be + // reached with one through the public API. Verified empirically: index() + // returns invalid for these, and a QPersistentModelIndex is invalidated by + // the reset in clear() before data() ever sees it. data() still checks its + // own bounds, but that guard is unreachable defence, not something these + // assertions can falsify. + QVERIFY(!model.index(0, ThreadListModel::ColumnCount).isValid()); + QVERIFY(!model.index(5, 0).isValid()); + QVERIFY(!model.index(-1, 0).isValid()); + + // A child index must yield nothing: this is a table, not a tree. + const QModelIndex child = model.index(0, 0, model.index(0, 0)); + QVERIFY(!child.isValid()); + QVERIFY(!model.data(child, Qt::DisplayRole).isValid()); +} + +void TestThreadListModel::threadAtOutOfRangeIsSafe() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + + QVERIFY(model.threadAt(-1).threadId.isEmpty()); + QVERIFY(model.threadAt(99).threadId.isEmpty()); + QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t1")); +} + +void TestThreadListModel::updatesTagsForMessage() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + QVERIFY(model.threadAt(0).isUnread()); + + // Optimistic UI: the model changes before the worker confirms. + model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("unread") }); + QVERIFY(!model.threadAt(0).isUnread()); + + model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("flagged") }, {}); + QVERIFY(model.threadAt(0).isFlagged()); +} + +void TestThreadListModel::tagChangeIsIdempotent() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + + // Adding a tag twice must not duplicate it: the tag list is shown joined, + // and "inbox inbox" is visible garbage. + model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("inbox") }, {}); + QCOMPARE(model.threadAt(0).tags.count(QStringLiteral("inbox")), 1); + + // Removing an absent tag is equally harmless. + model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("nosuchtag") }); + QCOMPARE(model.threadAt(0).tags.count(QStringLiteral("inbox")), 1); +} + +void TestThreadListModel::tagChangeSignalsExactlyTheChangedRow() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")), + makeThread(QStringLiteral("t2"), QStringLiteral("two")), + makeThread(QStringLiteral("t3"), QStringLiteral("three")) }); + + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + model.applyTagChange(QStringLiteral("t2"), {}, { QStringLiteral("unread") }); + + QCOMPARE(changed.size(), 1); + const QModelIndex topLeft = changed.first().at(0).value<QModelIndex>(); + const QModelIndex bottomRight = changed.first().at(1).value<QModelIndex>(); + QCOMPARE(topLeft.row(), 1); + QCOMPARE(bottomRight.row(), 1); + QCOMPARE(topLeft.column(), 0); + // The whole row repaints: unread state changes the font of every column. + QCOMPARE(bottomRight.column(), ThreadListModel::ColumnCount - 1); +} + +void TestThreadListModel::tagChangeForUnknownThreadIsIgnored() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + model.applyTagChange(QStringLiteral("nosuchthread"), { QStringLiteral("x") }, {}); + + QVERIFY(changed.isEmpty()); + QCOMPARE(model.threadAt(0).tags, (QStringList{ QStringLiteral("inbox"), + QStringLiteral("unread") })); +} + +void TestThreadListModel::tagChangeRoundTripsForRevert() +{ + // MainWindow reverts a failed write by re-applying the change with add and + // remove swapped. That only restores the original state if the round trip + // is exact. + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + const QStringList before = model.threadAt(0).tags; + + const QStringList add{ QStringLiteral("flagged") }; + const QStringList remove{ QStringLiteral("inbox") }; + + model.applyTagChange(QStringLiteral("t1"), add, remove); + QVERIFY(model.threadAt(0).tags != before); + + model.applyTagChange(QStringLiteral("t1"), remove, add); + const QStringList after = model.threadAt(0).tags; + QCOMPARE(after.size(), before.size()); + for (const QString &tag : before) + QVERIFY(after.contains(tag)); +} + +void TestThreadListModel::modelPassesQtTester() +{ + ThreadListModel model; + // Catches signal/rowCount contract violations that hand-written tests miss. + QAbstractItemModelTester tester(&model, + QAbstractItemModelTester::FailureReportingMode::QtTest); + + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")), + makeThread(QStringLiteral("t2"), QStringLiteral("two")) }); + model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("unread") }); + model.clear(); +} + +QTEST_MAIN(TestThreadListModel) +#include "test_threadlistmodel.moc" |
