summaryrefslogtreecommitdiffstats
path: root/tests/test_mainwindow.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-10 09:33:44 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 09:33:44 +0200
commit01419de209c2b5e2ae7b996e6b5ff1baa2efb3da (patch)
tree25718fd36e87eac721f41b02cca46b6fb07e94d9 /tests/test_mainwindow.cpp
parentf72dba9f6c463c6823d85701e51d8be38dd22a62 (diff)
parente1dba2987a9a1e87b92801959df9c9d4f1375d2f (diff)
downloadqtmaildir-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_mainwindow.cpp')
-rw-r--r--tests/test_mainwindow.cpp1059
1 files changed, 946 insertions, 113 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 5031ead..922705c 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -38,6 +38,7 @@
#include <QSplitter>
#include <QTableView>
#include <QToolBar>
+#include <QTreeView>
#include <QTimer>
#include "config.h"
@@ -45,8 +46,16 @@
#include "mainwindow.h"
#include "messageview.h"
#include "notmuchworker.h"
+#include "carddelegate.h"
+#include "cardlayout.h"
+
+#include <QImage>
+#include <QPainter>
+#include <QComboBox>
+#include <QScrollBar>
#include "tagchip.h"
#include "threadlistmodel.h"
+#include "threadlistview.h"
/// MainWindow is mostly wiring, and the parts that need a real database are
/// still verified manually. What is checked here is the action registry: the
@@ -93,7 +102,22 @@ private slots:
void anUnobservableLockTableLeavesTheSyncButtonUsable();
void theStatusBarFollowsTheSyncPhase();
void aSelectedReadThreadIsNotDimmedIntoTheHighlight();
- void thePillRowSpansTheWholeWidthNotOneColumn();
+ void childRowsAreIndentedUnderTheirThread();
+ void aThreadWithRepliesDrawsAVisibleExpander();
+ void cardsNeverScrollSideways();
+ void selectingARootCardKeepsItsThreadForMarkRead();
+ void nextThreadLeavesTheLastReply();
+ void altDownSkipsReplies();
+ void bothThreadStepBindingsReachTheAction();
+ void sortChoiceSurvivesRestart();
+ void accountEntriesCarryTheirColour();
+ void replyRowsKeepTheirTextUnderTheThreadLine();
+ void clickingTheExpanderTogglesTheThread();
+ void selectingAMessageRowTargetsThatMessageNotItsThread();
+ void selectingAThreadRowNamesHowManyMessagesItStandsFor();
+ void selectingAMessageRowReportsNoBulkCount();
+ void anActionOnAThreadRowSaysItHitTheWholeThread();
+ void anActionOnAMessageRowTagsThatMessageNotTheThread();
void markAllReadIsDisabledUntilTheQueryFinishes();
void markAllReadActsOnEveryRowAndUndoesInOneStep();
void markAllReadDoesNothingWhenNothingIsUnread();
@@ -439,11 +463,12 @@ void TestMainWindow::headerStateFromADifferentColumnLayoutIsDiscarded()
window.close();
}
- // Forge a state file from an older layout: same blob, wrong column count.
+ // Forge a state file from the five-column layout. Nothing reads these keys
+ // any more, and that is exactly what must be verified: a blob saved by an
+ // older version has to be ignored rather than applied to a one-column view.
{
QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat);
- state.setValue(QStringLiteral("threadlist/columns"),
- int(ThreadListModel::ColumnCount) - 1);
+ state.setValue(QStringLiteral("threadlist/columns"), 5);
state.setValue(QStringLiteral("threadlist/header"),
QByteArray("not a header this model could have saved"));
}
@@ -452,11 +477,9 @@ void TestMainWindow::headerStateFromADifferentColumnLayoutIsDiscarded()
const Config config;
MainWindow reopened(config);
- auto *view = reopened.findChild<QTableView *>();
+ auto *view = reopened.findChild<QTreeView *>();
QVERIFY(view);
- QCOMPARE(view->columnWidth(ThreadListModel::AttachmentColumn), 28);
- QCOMPARE(view->columnWidth(ThreadListModel::DateColumn), 130);
- QCOMPARE(view->columnWidth(ThreadListModel::SubjectColumn), 520);
+ QCOMPARE(view->model()->columnCount(), 1);
QFile::remove(MainWindow::uiStatePath());
QStandardPaths::setTestModeEnabled(false);
@@ -503,6 +526,27 @@ void TestMainWindow::returnInTheQueryBarRunsTheQueryNotOpenThread()
QVERIFY(!actionFired);
}
+/// Selects a top-level THREAD row, replacing QTableView::selectRow which a
+/// QTreeView does not have.
+///
+/// Not merely a rename: setCurrentIndex alone leaves the selection model empty,
+/// and select() alone leaves current invalid, so every test asserting on either
+/// would break in a different way. Both are set here, exactly as
+/// QTableView::selectRow did.
+static void selectThreadRow(QTreeView *view, int row)
+{
+ const QModelIndex index = view->model()->index(row, 0, QModelIndex());
+ view->selectionModel()->select(
+ index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ view->setCurrentIndex(index);
+}
+
+/// The height of a top-level row, replacing QTableView::rowHeight(int).
+static int threadRowHeight(QTreeView *view, int row)
+{
+ return view->visualRect(view->model()->index(row, 0, QModelIndex())).height();
+}
+
/// A thread summary carrying the tags a test needs. Enough to drive selection;
/// nothing here touches a database.
static ThreadSummary makeThread(const QString &id, const QStringList &tags)
@@ -515,81 +559,870 @@ static ThreadSummary makeThread(const QString &id, const QStringList &tags)
return thread;
}
-void TestMainWindow::thePillRowSpansTheWholeWidthNotOneColumn()
+void TestMainWindow::childRowsAreIndentedUnderTheirThread()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY2(view, "the thread list is not a QTreeView, so it cannot indent");
+
+ // With an account tag, so the thread row draws the chip that a reply row
+ // does not. That asymmetry is the whole reason the indent has to be wide,
+ // and a test against an untagged thread never sees it.
+ model->appendBatch({ makeThread(
+ QStringLiteral("t1"),
+ QStringList{ TagColors::tagForAccountKey(QStringLiteral("work")) }) });
+
+ MessageNode first;
+ first.messageId = QStringLiteral("m0@example.org");
+ first.threadId = QStringLiteral("t1");
+ first.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m1@example.org");
+ reply.threadId = QStringLiteral("t1");
+ reply.from = QStringLiteral("A Replier <replier@example.org>");
+ reply.depth = 1;
+ model->setThreadMessages(QStringLiteral("t1"), { first, reply });
+
+ window.resize(1400, 300);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ const QModelIndex root = model->index(0, 0, QModelIndex());
+ view->expand(root);
+ QApplication::processEvents();
+
+ // One column, and setIndentation(0): Qt indents nothing, CardLayout draws
+ // the indent itself. So visualRect reports the SAME rect for a thread and
+ // its reply, and the indent has to be read off the layout rather than off
+ // the geometry. That is the trap CLAUDE.md records in reverse: there,
+ // visualRect reported an indent the text did not have; here it reports
+ // none while the text is indented.
+ const QModelIndex rootCell = model->index(0, 0, QModelIndex());
+ const QModelIndex child = model->index(0, 0, root);
+ QVERIFY(child.isValid());
+
+ // Guards before the claim: a probe that cannot see both rows can report
+ // anything it likes about their relative position.
+ QVERIFY2(view->visualRect(rootCell).height() > 0,
+ "the thread row has no height, so nothing about it is measurable");
+ QVERIFY2(view->visualRect(child).height() > 0,
+ "the reply row has no height: it is collapsed or off-screen, and "
+ "an indent test against it would pass without drawing anything");
+
+ // The indent is NOT in the geometry. setIndentation(0) means visualRect
+ // reports the same left edge for both rows, deliberately: CardLayout draws
+ // the indent inside the card's own rect. Asserting on visualRect here
+ // would fail against a perfectly indented list, which is the mirror of the
+ // trap CLAUDE.md records for item 20, where visualRect reported an indent
+ // the text did not have.
+ //
+ // So the real property, as before: where the TEXT lands. It is read off
+ // the layout, which is what the delegate paints from.
+ CardLayout::Input threadIn;
+ threadIn.isMessage = false;
+ threadIn.depth = 0;
+ CardLayout::Input replyIn;
+ replyIn.isMessage = true;
+ replyIn.depth =
+ model->data(child, ThreadListModel::MessageDepthRole).toInt();
+ QVERIFY2(replyIn.depth > 0,
+ "the reply reports depth 0, so there is no nesting to measure");
+
+ const QRect rect = view->visualRect(rootCell);
+ const CardLayout threadCard =
+ CardLayout::compute(threadIn, rect, view->font());
+ const CardLayout replyCard =
+ CardLayout::compute(replyIn, rect, view->font());
+
+ QVERIFY2(replyCard.contentLeft > threadCard.contentLeft,
+ qPrintable(QStringLiteral("the reply's text starts at x=%1, not "
+ "right of the thread's at x=%2: the "
+ "nesting is invisible")
+ .arg(replyCard.contentLeft)
+ .arg(threadCard.contentLeft)));
+
+ // And the spine that makes the nesting read as one block rather than as an
+ // arbitrary offset.
+ QCOMPARE(replyCard.spines.size(), replyIn.depth);
+}
+
+namespace {
+
+/// Two threads, the first with one reply, expanded. The shared fixture for the
+/// two navigation tests below.
+struct NavFixture
+{
+ QTreeView *view = nullptr;
+ ThreadListModel *model = nullptr;
+ QModelIndex root;
+ QModelIndex reply;
+};
+
+NavFixture buildNavFixture(MainWindow &window)
+{
+ NavFixture f;
+ f.view = window.findChild<QTreeView *>();
+ f.model = window.findChild<ThreadListModel *>();
+
+ // unread, so a selection arms the mark-read timer: scheduleMarkRead()
+ // returns early for a thread that is already read, and a fixture without
+ // it would make a mark-read assertion pass for the wrong reason.
+ ThreadSummary first = makeThread(QStringLiteral("T1"),
+ QStringList{ QStringLiteral("inbox"),
+ QStringLiteral("unread") });
+ first.totalCount = 2;
+ ThreadSummary second = makeThread(QStringLiteral("T2"),
+ QStringList{ QStringLiteral("inbox") });
+ second.totalCount = 1;
+ f.model->appendBatch({ first, second });
+
+ MessageNode rootNode;
+ rootNode.messageId = QStringLiteral("M1");
+ rootNode.threadId = QStringLiteral("T1");
+ rootNode.depth = 0;
+ MessageNode replyNode;
+ replyNode.messageId = QStringLiteral("M2");
+ replyNode.threadId = QStringLiteral("T1");
+ replyNode.depth = 1;
+ f.model->setThreadMessages(QStringLiteral("T1"), { rootNode, replyNode });
+
+ f.root = f.model->index(0, 0);
+ f.view->expand(f.root);
+ f.reply = f.model->index(0, 0, f.root);
+ return f;
+}
+
+} // namespace
+
+void TestMainWindow::selectingARootCardKeepsItsThreadForMarkRead()
+{
+ // A root card is BOTH a message and a thread: it renders the thread's
+ // first message, and it is still the thread that gets marked read and
+ // repainted on a tag change. The message-row path deliberately clears the
+ // current thread id; doing that here too would silently disable mark-read
+ // and the tag-change repaint for every thread root in the list.
+ const Config config;
+ MainWindow window(config);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ const NavFixture f = buildNavFixture(window);
+ f.view->setCurrentIndex(f.root);
+ QApplication::processEvents();
+
+ // Guard: the fixture loads replies, so the root knows its own message and
+ // the branch under test is the one that runs.
+ QVERIFY2(!f.model->data(f.root, ThreadListModel::MessageIdRole)
+ .toString().isEmpty(),
+ "the root card does not know its first message, so this exercises "
+ "the fallback rather than the path it is written for");
+
+ // A mark-read timer armed for the thread is what proves the thread id
+ // survived: scheduleMarkRead() is only reached on the thread-row path.
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(timer);
+ QVERIFY2(timer->isActive(),
+ "no mark-read timer for a selected root card: its thread id was "
+ "cleared along with the switch to rendering one message");
+}
+
+void TestMainWindow::nextThreadLeavesTheLastReply()
+{
+ const Config config;
+ MainWindow window(config);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ const NavFixture f = buildNavFixture(window);
+ QVERIFY(f.reply.isValid());
+ QVERIFY2(f.view->isExpanded(f.root),
+ "the thread is collapsed, so this test would arrow down a flat "
+ "list and pass against the bug it exists to catch");
+
+ f.view->setCurrentIndex(f.reply);
+
+ // The defect (item 60): selectRow(current.row() + 1) asked for row 1 UNDER
+ // T1, which does not exist, so the action did nothing at all.
+ window.findChild<QAction *>(QStringLiteral("next_thread"))->trigger();
+
+ QCOMPARE(f.view->currentIndex().data(ThreadListModel::ThreadIdRole)
+ .toString(),
+ QStringLiteral("T2"));
+}
+
+void TestMainWindow::altDownSkipsReplies()
+{
+ const Config config;
+ MainWindow window(config);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ const NavFixture f = buildNavFixture(window);
+ QVERIFY(f.view->isExpanded(f.root));
+
+ // From the thread ROOT with its replies showing: one step must land on the
+ // next THREAD, not on the first reply. That is what makes the action mean
+ // thread-to-thread while plain Up/Down still steps message-to-message.
+ f.view->setCurrentIndex(f.root);
+ window.findChild<QAction *>(QStringLiteral("next_thread"))->trigger();
+
+ QCOMPARE(f.view->currentIndex().data(ThreadListModel::ThreadIdRole)
+ .toString(),
+ QStringLiteral("T2"));
+ QVERIFY(!f.view->currentIndex().data(ThreadListModel::IsMessageRole)
+ .toBool());
+
+ // And back, which is the mirror case the old arithmetic also failed.
+ window.findChild<QAction *>(QStringLiteral("prev_thread"))->trigger();
+ QCOMPARE(f.view->currentIndex().data(ThreadListModel::ThreadIdRole)
+ .toString(),
+ QStringLiteral("T1"));
+ QVERIFY(!f.view->currentIndex().data(ThreadListModel::IsMessageRole)
+ .toBool());
+}
+
+void TestMainWindow::bothThreadStepBindingsReachTheAction()
+{
+ const Config config;
+ MainWindow window(config);
+
+ // Two bindings per action, which needs setShortcuts rather than
+ // setShortcut: Ctrl+J/K for a neomutt hand, Alt+Up/Down for a mouse one.
+ // Alt because Shift+arrows is QTreeView's built-in extend-selection that
+ // multi-row tagging depends on, and a bare arrow cannot be a window
+ // shortcut without breaking every text field in the window.
+ for (const auto &pair : { std::pair<const char *, const char *>{
+ "next_thread", "Alt+Down" },
+ { "prev_thread", "Alt+Up" } }) {
+ auto *action =
+ window.findChild<QAction *>(QString::fromLatin1(pair.first));
+ QVERIFY2(action, pair.first);
+ const QList<QKeySequence> shortcuts = action->shortcuts();
+ QVERIFY2(shortcuts.size() >= 2,
+ qPrintable(QStringLiteral("%1 carries %2 shortcut(s), so the "
+ "second binding is unreachable")
+ .arg(QString::fromLatin1(pair.first))
+ .arg(shortcuts.size())));
+ QVERIFY2(shortcuts.contains(
+ QKeySequence(QString::fromLatin1(pair.second))),
+ pair.second);
+ }
+}
+
+void TestMainWindow::sortChoiceSurvivesRestart()
+{
+ QStandardPaths::setTestModeEnabled(true);
+ QFile::remove(MainWindow::uiStatePath());
+
+ {
+ const Config config;
+ MainWindow window(config);
+ auto *sort = window.findChild<QComboBox *>(QStringLiteral("sortOrder"));
+ QVERIFY(sort);
+ QCOMPARE(sort->count(), 2);
+ QCOMPARE(sort->currentIndex(), 0); // Newest first by default.
+ sort->setCurrentIndex(1);
+ window.close();
+ }
+
+ const Config config;
+ MainWindow second(config);
+ auto *sort = second.findChild<QComboBox *>(QStringLiteral("sortOrder"));
+ QVERIFY(sort);
+ QCOMPARE(sort->currentIndex(), 1);
+
+ // A stale or hand-edited file can hold anything, which is the lesson item
+ // 58 recorded: an out-of-range value must fall back rather than select a
+ // row that does not exist.
+ {
+ QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat);
+ state.setValue(QStringLiteral("threadlist/sortOrder"), 47);
+ }
+ MainWindow third(config);
+ auto *thirdSort = third.findChild<QComboBox *>(QStringLiteral("sortOrder"));
+ QCOMPARE(thirdSort->currentIndex(), 0);
+
+ QFile::remove(MainWindow::uiStatePath());
+ QStandardPaths::setTestModeEnabled(false);
+}
+
+void TestMainWindow::accountEntriesCarryTheirColour()
+{
+ // Its own config, not the environment's. Reading the real one made this
+ // SKIP wherever no accounts are configured, which is a test that asserts
+ // nothing while reporting success.
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("account.work"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ s.setValue(QStringLiteral("color"), QStringLiteral("#3d7fd1"));
+ s.endGroup();
+ s.beginGroup(QStringLiteral("account.personal"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("personal"));
+ s.endGroup();
+ }
+
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ MainWindow window(config);
+ auto *box = window.findChild<QComboBox *>(QStringLiteral("accountBox"));
+ QVERIFY(box);
+ QCOMPARE(box->count(), 3);
+
+ // "All accounts" is not an account and carries no swatch.
+ QVERIFY(!box->itemData(0, Qt::DecorationRole).isValid());
+
+ // Every real account does, including the one with no color= key:
+ // colourFor() never fails, deriving a stable colour from the tag name, so
+ // adding an account and forgetting to colour it degrades to something
+ // usable rather than to nothing.
+ QSet<QRgb> seen;
+ for (int i = 1; i < box->count(); ++i) {
+ const QVariant swatch = box->itemData(i, Qt::DecorationRole);
+ QVERIFY2(swatch.isValid(),
+ qPrintable(QStringLiteral("account %1 carries no swatch")
+ .arg(box->itemText(i))));
+ const QColor colour = swatch.value<QColor>();
+ QVERIFY(colour.isValid());
+ seen.insert(colour.rgb());
+ }
+
+ // Guard: two accounts sharing one colour would make the swatches useless
+ // as a key to the accent bars, and would let a broken lookup pass.
+ QCOMPARE(seen.size(), 2);
+}
+
+void TestMainWindow::cardsNeverScrollSideways()
+{
+ const Config config;
+ MainWindow window(config);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ // A long subject, so the guard below is not vacuous: this is exactly the
+ // content that used to make the subject column wider than the viewport.
+ model->appendBatch({ makeThread(
+ QStringLiteral("t1"),
+ QStringList{ QStringLiteral("inbox") }) });
+ QApplication::processEvents();
+
+ // Item 51: clicking a row used to scroll the list sideways, because the
+ // subject column was wider than the viewport and auto-scroll brought the
+ // clicked index fully into view. A card is exactly viewport width, so
+ // there is nowhere to scroll to.
+ QVERIFY2(view->visualRect(model->index(0, 0)).height() > 0,
+ "no card is drawn, so there is no layout to assert about");
+ QCOMPARE(view->horizontalScrollBar()->minimum(),
+ view->horizontalScrollBar()->maximum());
+}
+
+void TestMainWindow::aThreadWithRepliesDrawsAVisibleExpander()
{
- // The pills are a row-wide strip under the cells, not content of the
- // subject cell. Drawn from the subject column's delegate they stop at that
- // column's edge, so a thread with several tags loses the last of them; and
- // they inherit the column's left edge, which puts them under the subject
- // rather than under the row.
+ // The expander is the ONLY thing saying a thread can be opened, and it took
+ // four wrong attempts to get on screen before item 53, each of which looked
+ // correct in code and none of which a geometry or role assertion could see.
+ // So this counts painted pixels.
//
- // The property: pills appear to the LEFT of where the subject column
- // starts, which no per-cell delegate on that column could produce.
+ // Painted through the DELEGATE rather than through viewport()->render().
+ // The viewport render returns a blank image here: CLAUDE.md records that it
+ // does so in several ordinary situations, and this test proved it again,
+ // reporting zero ink over a card the delegate demonstrably paints 2183
+ // pixels into. A probe that sees nothing cannot report on anything.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- ThreadSummary thread = makeThread(QStringLiteral("t1"), {});
- thread.tags = QStringList{ QStringLiteral("mailing-list/SBo"),
- QStringLiteral("signed") };
- model->appendBatch({ thread });
+ // Two threads: one with replies, one without. The second is the control,
+ // and without it a test that counts ink would pass on any card.
+ ThreadSummary withReplies = makeThread(
+ QStringLiteral("t1"),
+ QStringList{ TagColors::tagForAccountKey(QStringLiteral("work")) });
+ withReplies.totalCount = 3;
+ ThreadSummary lone = makeThread(
+ QStringLiteral("t2"),
+ QStringList{ TagColors::tagForAccountKey(QStringLiteral("work")) });
+ lone.totalCount = 1;
+ model->appendBatch({ withReplies, lone });
+
+ const QModelIndex first = model->index(0, 0, QModelIndex());
+ const QModelIndex second = model->index(1, 0, QModelIndex());
+
+ // Guards: the model agrees about which thread has replies, and only that
+ // one is offered an expander at all.
+ QCOMPARE(model->data(first, ThreadListModel::ReplyCountRole).toInt(), 2);
+ QCOMPARE(model->data(second, ThreadListModel::ReplyCountRole).toInt(), 0);
+
+ const QFont font = view->font();
+ const int height = CardLayout::heightFor(font);
+
+ const auto inkInExpander = [&](const QModelIndex &index) {
+ QImage shot(400, height, QImage::Format_ARGB32);
+ shot.fill(Qt::white);
+ QPainter painter(&shot);
+ QStyleOptionViewItem option;
+ option.rect = QRect(0, 0, 400, height);
+ option.font = font;
+ option.palette = QApplication::palette();
+ option.state = QStyle::State_Enabled;
+ CardDelegate delegate;
+ delegate.paint(&painter, option, index);
+ painter.end();
+
+ const QRect rect = CardDelegate::expanderRectFor(option, index);
+ int found = 0;
+ for (int y = rect.top(); y <= rect.bottom() && y < shot.height(); ++y) {
+ for (int x = rect.left(); x <= rect.right() && x < shot.width();
+ ++x) {
+ if ((shot.pixel(x, y) | 0xff000000) != 0xffffffffu)
+ ++found;
+ }
+ }
+
+ // Guard on the probe itself: prove it can see the card's own text
+ // before trusting it about the expander. A probe that finds no ink
+ // anywhere reports "nothing was drawn" whatever the delegate did.
+ int anyInk = 0;
+ for (int y = 0; y < shot.height(); ++y)
+ for (int x = 0; x < shot.width(); ++x)
+ if ((shot.pixel(x, y) | 0xff000000) != 0xffffffffu)
+ ++anyInk;
+ return std::pair<int, int>(found, anyInk);
+ };
+
+ const auto [drawn, drawnAnywhere] = inkInExpander(first);
+ const auto [control, controlAnywhere] = inkInExpander(second);
+
+ QVERIFY2(drawnAnywhere > 0 && controlAnywhere > 0,
+ "the probe finds no ink on either card, so it cannot report on "
+ "the expander either");
+
+ QVERIFY2(drawn > 12,
+ qPrintable(QStringLiteral("only %1 pixels in the expander's rect: "
+ "the reply count is clipped or painted "
+ "over").arg(drawn)));
+ QVERIFY2(control == 0,
+ qPrintable(QStringLiteral("a thread with no replies drew %1 "
+ "pixels where an expander would go")
+ .arg(control)));
+}
+
+void TestMainWindow::selectingAThreadRowNamesHowManyMessagesItStandsFor()
+{
+ // With two kinds of row selectable, one selected row no longer says how
+ // much an action will touch. CLAUDE.md forbids a confirmation dialog for
+ // tag mutations, so the scope is made visible instead: this is the "before"
+ // half of that, and the count has to come from the thread's own total, not
+ // from whatever happens to be expanded.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+
+ ThreadSummary t = makeThread(QStringLiteral("t1"), {});
+ t.totalCount = 7;
+ model->appendBatch({ t });
+
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ // Guard: nothing is expanded, so a count taken from the loaded children
+ // would read 0 and this test would be measuring the wrong source.
+ QCOMPARE(model->rowCount(model->index(0, 0, QModelIndex())), 0);
+
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+
+ QVERIFY2(status->text().contains(QStringLiteral("7")),
+ qPrintable(QStringLiteral("the status bar says '%1', which does "
+ "not name the 7 messages the thread "
+ "stands for")
+ .arg(status->text())));
+}
+
+void TestMainWindow::selectingAMessageRowReportsNoBulkCount()
+{
+ // Reading one message is not a bulk action, so it gets no count. A message
+ // row reporting "1 thread selected" would be actively wrong about what an
+ // action would touch.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+
+ ThreadSummary t = makeThread(QStringLiteral("t1"), {});
+ t.totalCount = 3;
+ model->appendBatch({ t });
+
+ 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 });
+
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ const QModelIndex threadRow = model->index(0, 0, QModelIndex());
+ view->expand(threadRow);
+ QApplication::processEvents();
+
+ const QModelIndex messageRow = model->index(0, 0, threadRow);
+ QVERIFY(model->isMessageRow(messageRow));
+
+ view->selectionModel()->select(
+ messageRow,
+ QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ view->setCurrentIndex(messageRow);
+ QApplication::processEvents();
+
+ QVERIFY2(!status->text().contains(QStringLiteral("thread")),
+ qPrintable(QStringLiteral("a single message row reports '%1', "
+ "which claims a thread-wide scope it "
+ "does not have")
+ .arg(status->text())));
+}
+
+void TestMainWindow::anActionOnAThreadRowSaysItHitTheWholeThread()
+{
+ // The "after" half. Undo is the safety net this project chose over a
+ // confirmation dialog, and undo is only usable if the user can tell that
+ // something bigger than they intended just happened.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+
+ ThreadSummary t = makeThread(QStringLiteral("t1"), {});
+ t.totalCount = 7;
+ model->appendBatch({ t });
+
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+
+ auto *archive = window.findChild<QAction *>(QStringLiteral("archive"));
+ QVERIFY2(archive, "no archive action to trigger");
+ archive->trigger();
+
+ // Read BEFORE processEvents, deliberately. This binary has no worker
+ // (backlog item 36), so the queued applyTagsToThreads reaches a throwaway
+ // database that has never heard of thread t1 and answers with
+ // errorOccurred, which overwrites the status bar. Draining the event loop
+ // here would assert on that error rather than on the scope message, and
+ // the test would fail against correct code.
+ const QString message = status->text();
+
+ QVERIFY2(message.contains(QStringLiteral("7")),
+ qPrintable(QStringLiteral("after archiving a 7-message thread the "
+ "status bar says '%1', which does not "
+ "say how much was touched")
+ .arg(message)));
+
+ // And it must say the whole thread went, not merely how many messages: the
+ // count alone does not distinguish "7 messages you picked" from "7 messages
+ // because you picked their thread".
+ QVERIFY2(message.contains(QStringLiteral("whole thread")),
+ qPrintable(QStringLiteral("the status bar says '%1', which does "
+ "not say the action took the whole "
+ "thread")
+ .arg(message)));
+}
+
+void TestMainWindow::anActionOnAMessageRowTagsThatMessageNotTheThread()
+{
+ // The routing itself, which nothing else here can see. A message row sent
+ // down the THREAD path produces the same undo depth and the same status
+ // text while tagging every sibling in the conversation: a mutation that did
+ // exactly that passed the entire suite, so this test exists because that
+ // gap was found rather than because the path looked risky.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ ThreadSummary t = makeThread(QStringLiteral("t1"), {});
+ t.totalCount = 3;
+ model->appendBatch({ t });
+
+ 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 });
+
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ const QModelIndex threadRow = model->index(0, 0, QModelIndex());
+ view->expand(threadRow);
+ QApplication::processEvents();
+
+ const QModelIndex messageRow = model->index(0, 0, threadRow);
+ QVERIFY(model->isMessageRow(messageRow));
+
+ view->selectionModel()->select(
+ messageRow,
+ QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ view->setCurrentIndex(messageRow);
+ QApplication::processEvents();
+
+ auto *archive = window.findChild<QAction *>(QStringLiteral("archive"));
+ QVERIFY(archive);
+ archive->trigger();
+
+ // The change must carry the MESSAGE id and no thread id. Sent as a thread
+ // id it would archive the root and every other reply along with it.
+ QCOMPARE(window.pendingMessageIdsForTesting(),
+ QStringList{ QStringLiteral("m1@example.org") });
+ QVERIFY2(window.pendingThreadIdsForTesting().isEmpty(),
+ qPrintable(QStringLiteral("the action was sent for thread(s) %1: a "
+ "message row must not tag its siblings")
+ .arg(window.pendingThreadIdsForTesting()
+ .join(QStringLiteral(", ")))));
+
+ // And it is undoable, on its own terms rather than the thread's.
+ QCOMPARE(window.undoDepthForTesting(), 1);
+}
+
+void TestMainWindow::selectingAMessageRowTargetsThatMessageNotItsThread()
+{
+ // test_mainwindow has no worker (backlog item 36), so this cannot assert on
+ // what the pane renders. What it CAN assert is the decision the UI makes:
+ // a message row must stop tracking a current thread, or a reply arriving
+ // for either kind of selection cannot tell which one it belongs to.
+ //
+ // The trap this covers is specific. threadAt() takes a TOP-LEVEL row
+ // number, and a child's row number indexes its siblings, so handing a
+ // message row's number to it loads whichever thread happens to sit at that
+ // position in the list. Row 0 under a thread is a plausible-looking wrong
+ // answer, which is why the fixture puts the reply under the SECOND thread.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ ThreadSummary first = makeThread(QStringLiteral("t1"), {});
+ ThreadSummary second = makeThread(QStringLiteral("t2"), {});
+ second.totalCount = 2;
+ model->appendBatch({ first, second });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m0@example.org");
+ root.threadId = QStringLiteral("t2");
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m1@example.org");
+ reply.threadId = QStringLiteral("t2");
+ reply.depth = 1;
+ model->setThreadMessages(QStringLiteral("t2"), { root, reply });
window.resize(1400, 300);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ // Start on a thread row, so the transition to a message row is what is
+ // being observed rather than the initial state.
+ const QModelIndex threadRow = model->index(1, 0, QModelIndex());
+ selectThreadRow(view, 1);
QApplication::processEvents();
+ QCOMPARE(window.currentThreadId(), QStringLiteral("t2"));
- const int subjectLeft =
- view->columnViewportPosition(ThreadListModel::SubjectColumn);
- QVERIFY2(subjectLeft > 40,
- qPrintable(QStringLiteral("the subject column starts at x=%1, too "
- "close to the left edge to tell a "
- "row-wide strip from a subject-cell one")
- .arg(subjectLeft)));
- // The strip must have somewhere to paint that the subject cell does not
- // reach, or this test cannot fail.
- QVERIFY2(subjectLeft < view->viewport()->width(),
- qPrintable(QStringLiteral("the subject column is off-screen "
- "(x=%1, viewport %2), so nothing it "
- "draws is measurable")
- .arg(subjectLeft)
- .arg(view->viewport()->width())));
+ view->expand(threadRow);
+ QApplication::processEvents();
+
+ const QModelIndex messageRow = model->index(0, 0, threadRow);
+ QVERIFY(messageRow.isValid());
+ QVERIFY2(model->isMessageRow(messageRow),
+ "the fixture did not produce a message row, so this test would "
+ "assert nothing about one");
+
+ view->selectionModel()->select(
+ messageRow,
+ QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ view->setCurrentIndex(messageRow);
+ QApplication::processEvents();
+
+ // The thread is no longer what the pane is about. Left set, a late
+ // loadThread reply would repaint the whole conversation over the single
+ // message the user asked for.
+ QVERIFY2(window.currentThreadId().isEmpty(),
+ qPrintable(QStringLiteral("selecting a reply left the current "
+ "thread set to '%1': the pane is still "
+ "tracking the conversation")
+ .arg(window.currentThreadId())));
+}
+
+void TestMainWindow::clickingTheExpanderTogglesTheThread()
+{
+ // The glyph being VISIBLE and the glyph being CLICKABLE are separate
+ // properties, and the pixel test for the first passes happily against a
+ // triangle nothing can hit. Turning off rootIsDecorated to stop the style
+ // drawing its own dot under ours also removed the style's hit area, so the
+ // expander rendered perfectly and did nothing.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ ThreadSummary t = makeThread(
+ QStringLiteral("t1"),
+ QStringList{ TagColors::tagForAccountKey(QStringLiteral("work")) });
+ t.totalCount = 3;
+ model->appendBatch({ t });
+
+ window.resize(1400, 300);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+ QApplication::processEvents();
+
+ const QModelIndex root = model->index(0, 0, QModelIndex());
+ const QModelIndex subject =
+ model->index(0, 0, QModelIndex());
+ const QRect rect = view->visualRect(subject);
+
+ // Guards: the row is drawn, it claims to have replies, and it starts
+ // collapsed. Without the last one a toggle test can pass by doing nothing.
+ QVERIFY2(rect.height() > 0, "the thread row is not on screen");
+ QVERIFY(model->data(subject, ThreadListModel::HasRepliesRole).toBool());
+ QVERIFY(!view->isExpanded(root));
+
+ // Aimed at the rect the delegate reports, not at one reconstructed here:
+ // the drawn target and the clickable one cannot drift if both come from
+ // the same call.
+ QStyleOptionViewItem option;
+ option.rect = rect;
+ option.font = view->font();
+ const QRect expander = CardDelegate::expanderRectFor(option, subject);
+ QVERIFY2(!expander.isEmpty(), "the card offers no expander to click");
+ const QPoint hit = expander.center();
+
+ QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, hit);
+ QApplication::processEvents();
+ QVERIFY2(view->isExpanded(root),
+ "clicking the expander did not open the thread");
+
+ QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, hit);
+ QApplication::processEvents();
+ QVERIFY2(!view->isExpanded(root),
+ "clicking the expander again did not close the thread");
+}
+
+void TestMainWindow::replyRowsKeepTheirTextUnderTheThreadLine()
+{
+ // paintEvent runs AFTER the cells, so anything it fills across a reply row
+ // covers the text the delegate just drew. The tint and the thread line are
+ // both painted there, which makes this the obvious way to ship a block of
+ // blank rows.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ ThreadSummary t = makeThread(
+ QStringLiteral("t1"),
+ QStringList{ TagColors::tagForAccountKey(QStringLiteral("work")) });
+ t.totalCount = 2;
+ model->appendBatch({ t });
+
+ MessageNode first;
+ first.messageId = QStringLiteral("m0@example.org");
+ first.threadId = QStringLiteral("t1");
+ first.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m1@example.org");
+ reply.threadId = QStringLiteral("t1");
+ reply.from = QStringLiteral("A Replier <replier@example.org>");
+ reply.subject = QStringLiteral("Re: a subject");
+ reply.depth = 1;
+ model->setThreadMessages(QStringLiteral("t1"), { first, reply });
+
+ window.resize(1400, 300);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ const QModelIndex root = model->index(0, 0, QModelIndex());
+ view->expand(root);
+ QApplication::processEvents();
+
+ const QModelIndex child =
+ model->index(0, 0, root);
+ const QRect rect = view->visualRect(child);
+ QVERIFY2(rect.height() > 0, "the reply row is not on screen");
QImage shot(view->viewport()->size(), QImage::Format_ARGB32);
shot.fill(Qt::transparent);
view->viewport()->render(&shot);
- // Count pixels matching the tag colours EXACTLY, not "saturated" pixels.
- // A looser test counts the antialiased edge of the selection highlight
- // blending into the background, which is several hundred distinct
- // near-background colours and passes whatever the strip does. Both earlier
- // versions of this test did precisely that.
- QSet<QRgb> pillColours;
- const QVariantList colours =
- model->index(0, ThreadListModel::SubjectColumn)
- .data(ThreadListModel::PillColoursRole).toList();
- QVERIFY2(!colours.isEmpty(), "the model supplied no pill colours");
- for (const QVariant &colour : colours)
- pillColours.insert(colour.value<QColor>().rgb());
-
- const int rowHeight = view->rowHeight(0);
- QVERIFY(rowHeight > 0);
-
- int chipPixels = 0;
- for (int y = 0; y < qMin(rowHeight, shot.height()); ++y) {
- for (int x = 0; x < qMin(subjectLeft, shot.width()); ++x) {
- if (pillColours.contains(shot.pixel(x, y) | 0xff000000))
- ++chipPixels;
+ // Count pixels in the sender cell that differ from the row's own tint.
+ // Text is the only thing that can produce them.
+ const QRgb tint = ThreadListModel::replyBackground().rgb() | 0xff000000;
+ int textPixels = 0;
+ for (int y = rect.top(); y < qMin(rect.bottom(), shot.height()); ++y) {
+ for (int x = rect.left(); x < qMin(rect.right(), shot.width()); ++x) {
+ if ((shot.pixel(x, y) | 0xff000000) != tint)
+ ++textPixels;
}
}
- QVERIFY2(chipPixels > 0,
- "no pill-coloured pixels left of the subject column: the strip is "
- "still confined to that cell rather than spanning the row");
+ QVERIFY2(textPixels > 20,
+ qPrintable(QStringLiteral("only %1 non-background pixels in the "
+ "reply's sender cell: the row was "
+ "painted over after its text was drawn")
+ .arg(textPixels)));
}
void TestMainWindow::aSelectedReadThreadIsNotDimmedIntoTheHighlight()
@@ -607,7 +1440,7 @@ void TestMainWindow::aSelectedReadThreadIsNotDimmedIntoTheHighlight()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
// Both rows READ, so both are dimmed and neither is bold: the only thing
@@ -633,10 +1466,10 @@ void TestMainWindow::aSelectedReadThreadIsNotDimmedIntoTheHighlight()
// dimmed row switches it to the highlight's own text colour, so the two
// rows MUST differ; comparing two identically-styled rows would pass
// against a delegate that did nothing at all.
- view->selectRow(0);
+ selectThreadRow(view, 0);
QApplication::processEvents();
- const int rowHeight = view->rowHeight(0);
+ const int rowHeight = threadRowHeight(view, 0);
QVERIFY(rowHeight > 0);
QImage shot(view->viewport()->size(), QImage::Format_ARGB32);
@@ -657,15 +1490,15 @@ void TestMainWindow::aSelectedReadThreadIsNotDimmedIntoTheHighlight()
QVERIFY2(delegate, "the thread view has no styled delegate");
const QModelIndex index =
- model->index(0, ThreadListModel::SubjectColumn);
+ model->index(0, 0);
// initStyleOption is protected, so the resolved palette is reached the way
// the painter does: through a subclass that exposes it.
- struct Probe : SubjectDelegate {
- using SubjectDelegate::initStyleOption;
+ struct Probe : CardDelegate {
+ using CardDelegate::initStyleOption;
};
const auto *probe = static_cast<const Probe *>(
- static_cast<const SubjectDelegate *>(delegate));
+ static_cast<const CardDelegate *>(delegate));
probe->initStyleOption(&selected, index);
probe->initStyleOption(&unselected, index);
@@ -724,7 +1557,7 @@ void TestMainWindow::markAllReadActsOnEveryRowAndUndoesInOneStep()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("mark_all_read"));
QVERIFY(action);
@@ -746,7 +1579,7 @@ void TestMainWindow::markAllReadActsOnEveryRowAndUndoesInOneStep()
// One row selected, to prove the action ignores the selection rather than
// acting on it.
- view->selectRow(0);
+ selectThreadRow(view, 0);
action->trigger();
@@ -831,7 +1664,7 @@ void TestMainWindow::markReadTimerRestartsRatherThanStacking()
QVERIFY(model);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"),
@@ -841,13 +1674,13 @@ void TestMainWindow::markReadTimerRestartsRatherThanStacking()
makeThread(QStringLiteral("t3"),
{ QStringLiteral("unread") }) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
QVERIFY2(timer->isActive(), "no timer armed for an unread thread");
// Move on before it can fire. One timer stays armed, not three.
- view->selectRow(1);
+ selectThreadRow(view, 1);
QVERIFY(timer->isActive());
- view->selectRow(2);
+ selectThreadRow(view, 2);
QVERIFY(timer->isActive());
// Exactly one timer exists at all, which is what "restarted, not stacked"
@@ -867,7 +1700,7 @@ void TestMainWindow::markReadTimerIsNotArmedForAReadThread()
QVERIFY(model);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("read"),
@@ -875,16 +1708,16 @@ void TestMainWindow::markReadTimerIsNotArmedForAReadThread()
makeThread(QStringLiteral("unread"),
{ QStringLiteral("unread") }) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
QVERIFY2(!timer->isActive(), "armed a timer for an already-read thread");
// And the unread one still arms, so this is not "never arms".
- view->selectRow(1);
+ selectThreadRow(view, 1);
QVERIFY(timer->isActive());
// Moving back to a read thread disarms it again, rather than leaving the
// previous thread's timer running to fire against the wrong row.
- view->selectRow(0);
+ selectThreadRow(view, 0);
QVERIFY(!timer->isActive());
}
@@ -909,12 +1742,12 @@ void TestMainWindow::markReadCanBeDisabled()
QVERIFY(model);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
QVERIFY2(!timer->isActive(),
"a negative mark_read_delay_ms must disable the timer");
@@ -1091,7 +1924,7 @@ void TestMainWindow::selectAllIsBoundAndSelectsEveryRow()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
@@ -1120,7 +1953,7 @@ void TestMainWindow::aMultiRowSelectionDoesNotArmTheMarkReadTimer()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
@@ -1134,7 +1967,7 @@ void TestMainWindow::aMultiRowSelectionDoesNotArmTheMarkReadTimer()
// Sweep down as Shift+arrow does: current moves onto a row while the
// selection already spans more than one.
- view->selectRow(0);
+ selectThreadRow(view, 0);
view->selectionModel()->select(
model->index(1, 0),
QItemSelectionModel::Select | QItemSelectionModel::Rows);
@@ -1159,7 +1992,7 @@ void TestMainWindow::growingASelectionCancelsAnAlreadyArmedTimer()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
@@ -1169,7 +2002,7 @@ void TestMainWindow::growingASelectionCancelsAnAlreadyArmedTimer()
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
QVERIFY2(timer->isActive(), "no timer armed for a single unread thread");
// Extend to a second row, as Shift+click would.
@@ -1191,7 +2024,7 @@ void TestMainWindow::collapsingBackToOneRowLoadsThatThreadAgain()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
@@ -1205,7 +2038,7 @@ void TestMainWindow::collapsingBackToOneRowLoadsThatThreadAgain()
QVERIFY(!timer->isActive());
// Back to one row, as a plain click would leave it.
- view->selectRow(1);
+ selectThreadRow(view, 1);
QVERIFY2(timer->isActive(),
"collapsing back to one row did not resume mark-read");
@@ -1221,7 +2054,7 @@ void TestMainWindow::theStatusBarReportsAMultiRowSelection()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY2(status, "no status label to report into");
@@ -1246,7 +2079,7 @@ void TestMainWindow::clearSelectionBlanksThePaneAndDeselects()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
@@ -1256,7 +2089,7 @@ void TestMainWindow::clearSelectionBlanksThePaneAndDeselects()
// and what CLAUDE.md requires: selectAll() on a fresh view emits no
// currentRowChanged at all, so a test starting there passes against a
// missing guard.
- view->selectRow(0);
+ selectThreadRow(view, 0);
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
auto *action = window.findChild<QAction *>(QStringLiteral("clear_selection"));
@@ -1302,13 +2135,13 @@ void TestMainWindow::clearPaneLeavesTheSelectionAlone()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
auto *action = window.findChild<QAction *>(QStringLiteral("clear_pane"));
@@ -1421,7 +2254,7 @@ void TestMainWindow::theThreadListOffersAContextMenu()
const Config config;
MainWindow window(config);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
QCOMPARE(view->contextMenuPolicy(), Qt::CustomContextMenu);
@@ -1464,7 +2297,7 @@ void TestMainWindow::aSecondRowBlanksThePaneNotOnlyAThird()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
@@ -1477,7 +2310,7 @@ void TestMainWindow::aSecondRowBlanksThePaneNotOnlyAThird()
{ QStringLiteral("unread") }) });
// One row: ordinary reading, so a timer is armed and a thread is current.
- view->selectRow(0);
+ selectThreadRow(view, 0);
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
QVERIFY(timer->isActive());
@@ -1829,13 +2662,13 @@ void TestMainWindow::escapeBlanksTheMessagePane()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
QVERIFY2(!window.currentThreadId().isEmpty(),
"no thread was opened to blank");
@@ -1856,14 +2689,14 @@ void TestMainWindow::deleteTogglesOnAnAlreadyDeletedThread()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("deleted") }) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
action->trigger();
@@ -1884,7 +2717,7 @@ void TestMainWindow::deleteOnAMixedSelectionDeletesRatherThanSplittingIt()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
QVERIFY(action);
@@ -1935,7 +2768,7 @@ void TestMainWindow::theSelectionCountIsStateAndDoesNotExpire()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY(status);
@@ -2059,13 +2892,13 @@ void TestMainWindow::anEditDuringABackgroundSyncIsNotSentYet()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY2(action, "no flag action registered");
model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
// A cron sync takes the lock.
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
@@ -2090,13 +2923,13 @@ void TestMainWindow::aHeldEditIsSentWhenTheBackgroundSyncEnds()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
@@ -2125,7 +2958,7 @@ void TestMainWindow::aHeldEditCountsAsUnsynced()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(action);
@@ -2134,7 +2967,7 @@ void TestMainWindow::aHeldEditCountsAsUnsynced()
QVERIFY2(label->isHidden(), "the indicator starts hidden at zero");
model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
@@ -2162,7 +2995,7 @@ void TestMainWindow::anUnreadableLockTableStillSendsTheEdit()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(action);
@@ -2173,7 +3006,7 @@ void TestMainWindow::anUnreadableLockTableStillSendsTheEdit()
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
- view->selectRow(0);
+ selectThreadRow(view, 0);
action->trigger();
QVERIFY2(window.hasEditAwaitingSend(),
"the edit was not held during a running sync, so this test is not "
@@ -2189,7 +3022,7 @@ void TestMainWindow::anUnreadableLockTableStillSendsTheEdit()
"platform without /proc/locks");
// And a NEW edit is sent rather than held.
- view->selectRow(1);
+ selectThreadRow(view, 1);
action->trigger();
QVERIFY2(!window.hasEditAwaitingSend(),
"an unreadable lock table held a new edit, so writes never resume");
@@ -2206,7 +3039,7 @@ void TestMainWindow::aRejectedWriteKeepsEarlierUndoHistory()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *flag = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(flag);
@@ -2215,7 +3048,7 @@ void TestMainWindow::aRejectedWriteKeepsEarlierUndoHistory()
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("inbox") }) });
- view->selectRow(0);
+ selectThreadRow(view, 0);
// One edit that succeeds, so there is history worth keeping.
archive->trigger();
@@ -2609,7 +3442,7 @@ void TestMainWindow::theImportantActionStillWritesTheFlaggedTag()
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
- auto *view = window.findChild<QTableView *>();
+ auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"),
@@ -2619,7 +3452,7 @@ void TestMainWindow::theImportantActionStillWritesTheFlaggedTag()
// below would pass against an action that did nothing at all.
QVERIFY(!model->threadAt(0).isFlagged());
- view->selectRow(0);
+ selectThreadRow(view, 0);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(action);