aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-26 19:16:37 +0200
committerDanilo M. <danix@danix.xyz>2026-08-26 19:16:37 +0200
commit6ee94127510862139e89e8d653cd4994e27e5ffe (patch)
treee8fefe65eac56d126b78240e7dc5a1af1eddaf6d /tests
parent06b0435830daaed49a2d5231dcb6f02ff0124d5d (diff)
downloadqtmaildir-6ee94127510862139e89e8d653cd4994e27e5ffe.tar.gz
qtmaildir-6ee94127510862139e89e8d653cd4994e27e5ffe.zip
feat: snapshot the pending changes as rows
Item 119, first half: the data the list behind the unsynced-changes count is built from, with no dialog and no worker, so the rules it has to follow are testable on their own. pendingChangeSnapshot() gathers the three queues the count sums into PendingChange rows. Two properties are the whole point. Scope follows the ACTION, not the storage. A held thread edit stays one thread row, because a `*_thread` action made it and reporting its messages instead would claim the user acted on each one; a netted tag edit and a held move are message rows. The queues already encode that distinction, so nothing is expanded and nothing is escalated. The rows are grouped by id, so a message with several outstanding actions appears once with its actions beneath it, which is the layout the user asked for. The sort is stable, so those actions keep the order they were made in; QHash has none of its own, and without it the list would reshuffle between openings. A snapshot, taken once and frozen. Subjects are empty here and filled by the resolve step to come. m_pendingTagEdits gains the action name beside the direction it already kept. The direction alone was enough to count with; a list has to say what each change was, and only the action that made it knows. It is carried from TagChange::description rather than derived from the tag, so there is no second table of tag names to labels to drift from the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp85
1 files changed, 85 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index a120ce6..be19652 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -416,6 +416,8 @@ private slots:
void anEditUndoneNettsBackToZero();
void aDifferentTagOnTheSameMessageStillCounts();
void everyPendingChangeCanNameItsMessages();
+ void theSnapshotGroupsActionsUnderTheirMessage();
+ void theSnapshotKeepsAThreadActionThreadScoped();
void anEditDuringABackgroundSyncIsNotSentYet();
void aHeldEditIsSentWhenTheBackgroundSyncEnds();
void aHeldEditCountsAsUnsynced();
@@ -6489,6 +6491,89 @@ void TestMainWindow::everyPendingChangeCanNameItsMessages()
"an edit and its inverse left the indicator claiming work");
}
+void TestMainWindow::theSnapshotGroupsActionsUnderTheirMessage()
+{
+ // The layout the user asked for: a message appears ONCE with its actions
+ // beneath it. That is a property of the row ORDER, so it is asserted on
+ // the snapshot rather than on a rendered dialog.
+ const Config config;
+ MainWindow window(config);
+
+ // Two actions on one message, one on another, interleaved so a snapshot
+ // that simply reported insertion order would fail.
+ const auto apply = [&window](const QString &id, const QString &tag,
+ const QString &description) {
+ TagChange change;
+ change.messageIds = { id };
+ change.added = { tag };
+ change.description = description;
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+ };
+ apply(QStringLiteral("b@example.org"), QStringLiteral("flagged"),
+ QStringLiteral("Mark important"));
+ apply(QStringLiteral("a@example.org"), QStringLiteral("deleted"),
+ QStringLiteral("Delete"));
+ apply(QStringLiteral("b@example.org"), QStringLiteral("spam"),
+ QStringLiteral("Mark spam"));
+
+ const QVector<PendingChange> rows = window.pendingChangeSnapshot();
+ QCOMPARE(rows.size(), 3);
+
+ // One message per contiguous run: b's two actions are adjacent, so the
+ // dialog can draw the subject once and the actions under it.
+ QCOMPARE(rows.at(0).id, QStringLiteral("a@example.org"));
+ QCOMPARE(rows.at(1).id, QStringLiteral("b@example.org"));
+ QCOMPARE(rows.at(2).id, QStringLiteral("b@example.org"));
+
+ // Each row says what the user did, in the words the action itself used.
+ QCOMPARE(rows.at(0).action, QStringLiteral("Delete"));
+ QVERIFY(rows.at(1).action != rows.at(2).action);
+
+ // And every row here is message-scoped: none of these was a thread action.
+ for (const PendingChange &row : rows)
+ QVERIFY(!row.isThread);
+}
+
+void TestMainWindow::theSnapshotKeepsAThreadActionThreadScoped()
+{
+ // Scope follows the ACTION, not the storage. A held thread edit stays one
+ // thread row: reporting its messages instead would claim the user acted on
+ // each one, and the count they clicked would disagree with the list.
+ //
+ // Driven through the held queue because that is the only thing that
+ // carries thread ids; a confirmed edit is message-scoped by construction.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(model && view);
+ model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
+ selectThreadRow(view, 0);
+
+ // A cron sync takes the lock, which is what makes the edit HELD rather
+ // than sent, and a held edit is the only thing that carries thread ids.
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Running));
+
+ auto *action = window.findChild<QAction *>(QStringLiteral("flag_thread"));
+ QVERIFY(action);
+ action->trigger();
+ QVERIFY(window.hasEditAwaitingSend());
+
+ const QVector<PendingChange> rows = window.pendingChangeSnapshot();
+ QCOMPARE(rows.size(), 1);
+ QVERIFY2(rows.at(0).isThread,
+ "a thread action was reported as a message change");
+ QCOMPARE(rows.at(0).id, QStringLiteral("t1"));
+
+ // The count and the list agree, which is the property the whole dialog
+ // rests on.
+ QCOMPARE(rows.size(), window.pendingEditCount());
+}
+
// Item 37. A tag edit made while a background sync holds notmuch's write lock
// used to stall the worker: the read-write open BLOCKS until the lock frees
// (measured 9.158s against a 12s hold, returning NOTMUCH_STATUS_SUCCESS), so