/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #include #include #include #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 tagsAreTheFirstColumnAndSubjectTheLast(); void accountTagBecomesAChipLabel(); void unreadStylingSurvivesAnAccountChip(); void accountChipUsesTheConfiguredColour(); void deletedThreadsAreRedAndStruckThrough(); void attachmentColumnIsFirstAndMarksOnlyTaggedThreads(); void spamThreadsAreOrangeAndStruckThrough(); void doomedStylingCoversEveryColumn(); void ordinaryThreadsCarryNoRowColour(); 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()); // Tags are no longer a column; they reach the strip under the message // pane through a role instead. const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); QCOMPARE(model.data(subject, ThreadListModel::TagsRole).toStringList(), QStringList({ QStringLiteral("inbox"), QStringLiteral("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().bold()); } void TestThreadListModel::tagsAreTheFirstColumnAndSubjectTheLast() { // Subject stretches to fill the view, so whatever sits after it is pushed // off-screen. Tags used to be there, which is why acting on a thread // looked like it did nothing: the only column that changed was invisible. QCOMPARE(ThreadListModel::SubjectColumn, ThreadListModel::ColumnCount - 1); ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("hello")) }); QCOMPARE(model.headerData(ThreadListModel::SubjectColumn, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("Subject")); // No tags column at all: spelling out a dozen tags per row consumed most // of the list's width and was unreadable. for (int column = 0; column < ThreadListModel::ColumnCount; ++column) { QVERIFY(model.headerData(column, Qt::Horizontal, Qt::DisplayRole) .toString() != QStringLiteral("Tags")); } } void TestThreadListModel::accountTagBecomesAChipLabel() { // The account tag is a different taxonomy from a functional one: which // mailbox the thread arrived in. It renders as a chip in front of the // subject, so the model exposes its label and colour separately. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); thread.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("account-gmail-danixland") }; model.appendBatch({ thread }); const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); QCOMPARE(model.data(subject, ThreadListModel::AccountLabelRole).toString(), QStringLiteral("gmail-danixland")); QVERIFY(model.data(subject, ThreadListModel::AccountColourRole) .value().isValid()); // A thread with no account tag gets no chip rather than an empty one. ThreadListModel plain; ThreadSummary untagged = makeThread(QStringLiteral("t2"), QStringLiteral("hi")); untagged.tags = QStringList{ QStringLiteral("inbox") }; plain.appendBatch({ untagged }); QVERIFY(plain.data(plain.index(0, ThreadListModel::SubjectColumn), ThreadListModel::AccountLabelRole).toString().isEmpty()); } void TestThreadListModel::unreadStylingSurvivesAnAccountChip() { // The subject cell is drawn by a delegate when the thread has an account // chip. The delegate paints the text itself, so it has to keep honouring // the model's font: otherwise an unread thread stops rendering bold for // exactly those threads that carry an account tag, which is all of them. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); thread.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread"), QStringLiteral("account-gmail-danixland") }; model.appendBatch({ thread }); const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); QVERIFY(!model.data(subject, ThreadListModel::AccountLabelRole) .toString().isEmpty()); const QVariant font = model.data(subject, Qt::FontRole); QVERIFY2(font.isValid(), "unread thread with an account tag has no font"); QVERIFY2(font.value().bold(), "unread thread is not bold"); } void TestThreadListModel::accountChipUsesTheConfiguredColour() { // The colour comes from the account's own stanza, so a configured one must // reach the chip rather than the generated fallback. TagColors colours; colours.setAccountColour(QStringLiteral("gmail-danixland"), QColor(QStringLiteral("#cc0000"))); ThreadListModel model; model.setTagColors(&colours); ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); thread.tags = QStringList{ QStringLiteral("account-gmail-danixland") }; model.appendBatch({ thread }); QCOMPARE(model.data(model.index(0, ThreadListModel::SubjectColumn), ThreadListModel::AccountColourRole).value(), QColor(QStringLiteral("#cc0000"))); } void TestThreadListModel::deletedThreadsAreRedAndStruckThrough() { ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("doomed")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); QVERIFY(!model.data(subject, Qt::BackgroundRole).isValid()); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); const QVariant background = model.data(subject, Qt::BackgroundRole); QVERIFY(background.isValid()); QCOMPARE(background.value().color(), ThreadListModel::deletedColour()); // White text on the fill, and struck through so the state reads even in a // screenshot with the colours stripped. QCOMPARE(model.data(subject, Qt::ForegroundRole).value().color(), QColor(Qt::white)); QVERIFY(model.data(subject, Qt::FontRole).value().strikeOut()); } void TestThreadListModel::spamThreadsAreOrangeAndStruckThrough() { ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("junk")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("spam") }, {}); const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); QCOMPARE(model.data(subject, Qt::BackgroundRole).value().color(), ThreadListModel::spamColour()); QVERIFY(model.data(subject, Qt::FontRole).value().strikeOut()); // Spam and deleted must be distinguishable, not two shades of one colour. QVERIFY(ThreadListModel::spamColour() != ThreadListModel::deletedColour()); } void TestThreadListModel::doomedStylingCoversEveryColumn() { // A cue on one column would vanish the moment that column scrolled out of // view, which is the bug this whole change exists to fix. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("doomed")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); for (int column = 0; column < ThreadListModel::ColumnCount; ++column) { const QModelIndex index = model.index(0, column); QVERIFY2(model.data(index, Qt::BackgroundRole).isValid(), qPrintable(QStringLiteral("column %1 has no background").arg(column))); QVERIFY2(model.data(index, Qt::FontRole).value().strikeOut(), qPrintable(QStringLiteral("column %1 is not struck through").arg(column))); } } void TestThreadListModel::ordinaryThreadsCarryNoRowColour() { // Undo has to restore the plain look, not merely drop the tag. ThreadListModel model; ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("normal")); thread.tags = QStringList{ QStringLiteral("inbox") }; model.appendBatch({ thread }); model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("deleted") }); const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); QVERIFY(!model.data(subject, Qt::BackgroundRole).isValid()); QVERIFY(!model.data(subject, Qt::ForegroundRole).isValid()); const QVariant font = model.data(subject, Qt::FontRole); QVERIFY(!font.isValid() || !font.value().strikeOut()); } 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(); const QModelIndex bottomRight = changed.first().at(1).value(); 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(); } void TestThreadListModel::attachmentColumnIsFirstAndMarksOnlyTaggedThreads() { // Leftmost, and narrow: the point is to see an attachment without opening // the thread, which only works if the column is never scrolled away. QCOMPARE(ThreadListModel::AttachmentColumn, 0); ThreadSummary plain = makeThread(QStringLiteral("t1"), QStringLiteral("no attachment")); ThreadSummary withFile = makeThread(QStringLiteral("t2"), QStringLiteral("has one")); // notmuch applies this tag itself while indexing, so no MIME parsing and // no extra worker query are involved. withFile.tags.append(QStringLiteral("attachment")); ThreadListModel model; model.appendBatch({ plain, withFile }); const QModelIndex plainCell = model.index(0, ThreadListModel::AttachmentColumn); const QModelIndex fileCell = model.index(1, ThreadListModel::AttachmentColumn); QVERIFY(model.data(plainCell, Qt::DisplayRole).toString().isEmpty()); QCOMPARE(model.data(fileCell, Qt::DisplayRole).toString(), ThreadListModel::attachmentGlyph()); // The glyph must be something a font can draw. An unrenderable codepoint // shows as a tofu box, which reads as breakage rather than as a marker. QVERIFY(!ThreadListModel::attachmentGlyph().isEmpty()); // Only the marked thread gets a tooltip, or an empty cell would claim to // have an attachment on hover. QVERIFY(model.data(plainCell, Qt::ToolTipRole).toString().isEmpty()); QVERIFY(!model.data(fileCell, Qt::ToolTipRole).toString().isEmpty()); // The header carries no text: a label would set a minimum width far wider // than the icon and defeat the narrow column. QVERIFY(model.headerData(ThreadListModel::AttachmentColumn, Qt::Horizontal, Qt::DisplayRole).toString().isEmpty()); } QTEST_MAIN(TestThreadListModel) #include "test_threadlistmodel.moc"