aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-08 10:24:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 08:23:06 +0200
commitc80c060a593fb802f7149223d0d2f8495bc1f443 (patch)
tree752e073bc845eed021b57719350b60d20a9ae488 /tests
parent879a117cba62df57dfcb0c0dfd4383308fa07f13 (diff)
downloadqtmaildir-c80c060a593fb802f7149223d0d2f8495bc1f443.tar.gz
qtmaildir-c80c060a593fb802f7149223d0d2f8495bc1f443.zip
feat(model): resolve action scope from the selected row kind
ActionScope is what an action is about to touch, resolved from the selection in one place so no call site reinvents the mapping. A thread root contributes the whole thread, a message row contributes one message, and messageCount is what the status bar reports. The count comes from totalCount, not from the loaded children. A thread that was never expanded still has all of its messages, and counting only the rows that happen to be on screen would understate what the action does: mutation-checked, and the wrong version reports 1 message where 7 are about to be tagged. A mixed selection is honoured as given rather than escalated to thread scope or narrowed to message scope. Silently widening it would defeat the reason the scope is shown at all.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_threadlistmodel.cpp83
1 files changed, 83 insertions, 0 deletions
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index b4be527..74b8dd1 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -32,6 +32,9 @@ private slots:
void repliesBecomeChildRowsUnderTheirThread();
void messageRowsShowTheirOwnSenderAndSubject();
void reloadingAThreadReplacesItsRepliesRatherThanRepeatingThem();
+ void scopeFollowsTheSelectedRowKind();
+ void scopeCountsEveryMessageOfAnUnexpandedThread();
+ void scopeHonoursAMixedSelectionWithoutEscalating();
void startsEmpty();
void accountKeysComeFromTheAccountTags();
void accountKeysCoverAThreadSpanningTwoAccounts();
@@ -193,6 +196,86 @@ void TestThreadListModel::reloadingAThreadReplacesItsRepliesRatherThanRepeatingT
Q_UNUSED(tester);
}
+void TestThreadListModel::scopeFollowsTheSelectedRowKind()
+{
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ QStringLiteral("A subject"));
+ t.totalCount = 3;
+ model.appendBatch({ t });
+ model.setThreadMessages(QStringLiteral("t1"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ makeNode(QStringLiteral("m1@example.org"), 1) });
+
+ const QModelIndex root = model.index(0, 0, QModelIndex());
+ const QModelIndex child = model.index(0, 0, root);
+
+ // A thread root acts on the whole thread, and reports every message it
+ // stands for so the status bar can say so.
+ const ActionScope threadScope = model.scopeFor({ root });
+ QCOMPARE(threadScope.threadIds, QStringList{ QStringLiteral("t1") });
+ QVERIFY(threadScope.messageIds.isEmpty());
+ QCOMPARE(threadScope.messageCount, 3);
+ QVERIFY(threadScope.wholeThread);
+
+ // A message row acts on that message alone.
+ const ActionScope messageScope = model.scopeFor({ child });
+ QVERIFY(messageScope.threadIds.isEmpty());
+ QCOMPARE(messageScope.messageIds,
+ QStringList{ QStringLiteral("m1@example.org") });
+ QCOMPARE(messageScope.messageCount, 1);
+ QVERIFY(!messageScope.wholeThread);
+}
+
+void TestThreadListModel::scopeCountsEveryMessageOfAnUnexpandedThread()
+{
+ // totalCount, not the loaded children. A thread that was never expanded
+ // still has all of its messages, and counting only what happens to be on
+ // screen would understate what the action is about to do.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ QStringLiteral("A subject"));
+ t.totalCount = 7;
+ model.appendBatch({ t });
+
+ const QModelIndex root = model.index(0, 0, QModelIndex());
+ QCOMPARE(model.rowCount(root), 0); // guard: nothing expanded
+
+ const ActionScope scope = model.scopeFor({ root });
+ QCOMPARE(scope.messageCount, 7);
+}
+
+void TestThreadListModel::scopeHonoursAMixedSelectionWithoutEscalating()
+{
+ // Selecting a thread root and an unrelated reply acts on that whole thread
+ // AND that one message. Nothing is escalated to thread scope or narrowed to
+ // message scope silently, which is the point of the scope being visible.
+ ThreadListModel model;
+ ThreadSummary t1 = makeThread(QStringLiteral("t1"), QStringLiteral("One"));
+ t1.totalCount = 2;
+ ThreadSummary t2 = makeThread(QStringLiteral("t2"), QStringLiteral("Two"));
+ t2.totalCount = 5;
+ model.appendBatch({ t1, t2 });
+
+ MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1);
+ reply.threadId = QStringLiteral("t2");
+ model.setThreadMessages(QStringLiteral("t2"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ reply });
+
+ const QModelIndex firstRoot = model.index(0, 0, QModelIndex());
+ const QModelIndex secondRoot = model.index(1, 0, QModelIndex());
+ const QModelIndex reply1 = model.index(0, 0, secondRoot);
+
+ const ActionScope scope = model.scopeFor({ firstRoot, reply1 });
+ QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t1") });
+ QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m1@example.org") });
+
+ // 2 from the whole thread plus 1 for the lone message.
+ QCOMPARE(scope.messageCount, 3);
+ QVERIFY(scope.wholeThread);
+}
+
void TestThreadListModel::accountKeysComeFromTheAccountTags()
{
// Item 49 reads this to decide which mbsync channels a sync needs. Only