MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 82.6% 337 / 0 / 408
Functions: 100.0% 28 / 0 / 28
Branches: 59.0% 370 / 9 / 636

controller/MailMoveCommands.cpp
Line Branch Exec Source
1 #include "controller/MailMoveCommands.h"
2
3 #include <QLoggingCategory>
4 #include <QPointer>
5 #include <QTimer>
6 #include <memory>
7 #include <utility>
8 #include "controller/MailMutationPipeline.h"
9 #include "controller/UndoManager.h"
10 #include "data/MailCache.h"
11 #include "data/Models.h"
12 #include "performance/MutationPerformanceTracker.h"
13 #include "service/ImapService.h"
14 #include "service/MailSelectionMutationService.h"
15 #include "ui/MailListModel.h"
16 #include "ui/MailThreadModel.h"
17
18 // Same category as MailController (defined there), see docs/LOGGING.md.
19 Q_DECLARE_LOGGING_CATEGORY(lcController)
20
21 1050 MailMoveCommands::MailMoveCommands(
22 ImapService *imap, ImapService *const &bodyImap, QString &bodyImapSelectedFolder,
23 bool &bodyFetchSelect, MailCache *cache, MailListModel *model,
24 MailThreadModel *const &threadModel, FolderContext &folder, MailMutationPipeline &mutations,
25 const QPointer<UndoManager> &undoManager, const quint64 &selectionActionGeneration,
26 1050 Deps deps, QObject *parent)
27 1050 : QObject(parent), m_imap(imap), m_bodyImap(bodyImap),
28 1050 m_bodyImapSelectedFolder(bodyImapSelectedFolder), m_bodyFetchSelect(bodyFetchSelect),
29 1050 m_cache(cache), m_model(model), m_threadModel(threadModel), m_folder(folder),
30 1050 m_mutations(mutations), m_undoManager(undoManager),
31 1050 m_selectionActionGeneration(selectionActionGeneration), m_deps(std::move(deps)) {}
32
33 16 void MailMoveCommands::moveSelection(std::shared_ptr<const MailSelectionSnapshot> selection,
34 const QString &targetFolder, std::function<void(MailSelectionMoveResult)> callback) {
35
5/9
✓ Branch 3 → 4 taken 16 times.
✗ Branch 3 → 9 not taken.
✓ Branch 6 → 7 taken 16 times.
✗ Branch 6 → 9 not taken.
✓ Branch 7 → 8 taken 16 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 16 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 27 taken 16 times.
16 if (!selection || selection->accountId != m_folder.accountId || !m_cache->isOpen()) {
36 ✗ MailSelectionMoveResult rejected;
37 ✗ rejected.error = QStringLiteral("Selection does not belong to the active controller account");
38 ✗ if (callback) callback(std::move(rejected));
39 ✗ return;
40 ✗ }
41 16 const auto database = m_cache->databasePath();
42 16 const auto generation = m_selectionActionGeneration;
43
1/1
✓ Branch 28 → 29 taken 16 times.
16 const QPointer<MailMoveCommands> owner(this);
44 // The domain controller owns admission and replay, independently of any
45 // window that submitted the intent. A destroyed view only loses feedback.
46
2/2
✓ Branch 29 → 30 taken 16 times.
✓ Branch 39 → 40 taken 16 times.
32 MailSelectionMutationService::shared()->move(this, database, std::move(selection), targetFolder,
47
1/5
✓ Branch 34 → 35 taken 16 times.
✗ Branch 68 → 69 not taken.
✗ Branch 68 → 70 not taken.
✗ Branch 71 → 72 not taken.
✗ Branch 71 → 73 not taken.
32 [owner, database, generation, callback = std::move(callback)](MailSelectionMoveResult result) {
48
5/9
✓ Branch 5 → 6 taken 14 times.
✓ Branch 6 → 7 taken 14 times.
✗ Branch 6 → 19 not taken.
✓ Branch 10 → 11 taken 14 times.
✗ Branch 10 → 19 not taken.
✓ Branch 22 → 23 taken 11 times.
✓ Branch 22 → 30 taken 3 times.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
28 if (owner && owner->m_cache->isOpen() && owner->m_cache->databasePath() == database &&
49
6/10
✓ Branch 3 → 4 taken 14 times.
✗ Branch 3 → 19 not taken.
✓ Branch 11 → 12 taken 11 times.
✓ Branch 11 → 19 taken 3 times.
✓ Branch 13 → 14 taken 11 times.
✗ Branch 13 → 19 not taken.
✓ Branch 17 → 18 taken 11 times.
✗ Branch 17 → 19 not taken.
✓ Branch 20 → 21 taken 14 times.
✗ Branch 20 → 22 not taken.
39 result.outcome == MailSelectionMoveResult::Outcome::Journaled && result.selection &&
50 11 owner->m_folder.accountId == result.selection->accountId) {
51
2/2
✓ Branch 24 → 25 taken 10 times.
✓ Branch 24 → 27 taken 1 time.
11 if (owner->m_selectionActionGeneration == generation)
52 10 owner->adoptSelectionMove(result);
53 else
54 // Account reload invalidates transient Undo, not durable journal work.
55
1/1
✓ Branch 28 → 29 taken 1 time.
1 QTimer::singleShot(0, &owner->m_mutations, &MailMutationPipeline::replayMutationJournal);
56 }
57
2/3
✓ Branch 31 → 32 taken 14 times.
✗ Branch 31 → 37 not taken.
✓ Branch 34 → 35 taken 14 times.
14 if (callback) callback(std::move(result));
58 14 });
59 16 }
60
61 11 bool MailMoveCommands::adoptSelectionMove(const MailSelectionMoveResult &action) {
62 33 if (action.outcome != MailSelectionMoveResult::Outcome::Journaled ||
63
2/4
✓ Branch 4 → 5 taken 11 times.
✗ Branch 4 → 15 not taken.
✓ Branch 6 → 7 taken 11 times.
✗ Branch 6 → 15 not taken.
11 !action.selection || action.receipts.isEmpty() ||
64
1/2
✓ Branch 9 → 10 taken 11 times.
✗ Branch 9 → 15 not taken.
11 action.receipts.size() != action.selection->count ||
65
5/9
✓ Branch 2 → 3 taken 11 times.
✗ Branch 2 → 15 not taken.
✓ Branch 12 → 13 taken 11 times.
✗ Branch 12 → 15 not taken.
✓ Branch 13 → 14 taken 11 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 11 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 11 times.
22 action.selection->accountId != m_folder.accountId || !m_cache->isOpen())
66 ✗ return false;
67 11 const auto database = m_cache->databasePath();
68
1/1
✓ Branch 20 → 21 taken 11 times.
11 const QPointer<MailMoveCommands> owner(this);
69 11 qint64 retainedBytes = 2LL * (action.targetFolder.size() + action.selection->accountId.size() +
70 11 action.selection->folderPath.size());
71
2/2
✓ Branch 32 → 28 taken 2702 times.
✓ Branch 32 → 33 taken 11 times.
2713 for (const auto &receipt : action.receipts)
72 2702 retainedBytes += 128 + 2LL * receipt.messageId.size();
73
3/7
✓ Branch 34 → 35 taken 11 times.
✗ Branch 34 → 47 not taken.
✓ Branch 44 → 45 taken 11 times.
✓ Branch 45 → 46 taken 11 times.
✗ Branch 45 → 47 not taken.
✗ Branch 81 → 82 not taken.
✗ Branch 81 → 83 not taken.
33 const bool undoRetained = m_undoManager && m_undoManager->pushAsync(
74
4/10
✓ Branch 41 → 42 taken 11 times.
✓ Branch 43 → 44 taken 11 times.
✓ Branch 50 → 51 taken 11 times.
✗ Branch 50 → 52 not taken.
✓ Branch 52 → 53 taken 11 times.
✗ Branch 52 → 54 not taken.
✗ Branch 72 → 73 not taken.
✗ Branch 72 → 74 not taken.
✗ Branch 77 → 78 not taken.
✗ Branch 77 → 79 not taken.
22 tr("Move to %1 (%n message(s))", nullptr, action.receipts.size()).arg(action.targetFolder),
75
2/13
✓ Branch 39 → 40 taken 11 times.
✓ Branch 54 → 55 taken 11 times.
✗ Branch 54 → 56 not taken.
✗ Branch 85 → 86 not taken.
✗ Branch 85 → 87 not taken.
✗ Branch 89 → 90 not taken.
✗ Branch 89 → 92 not taken.
✗ Branch 90 → 91 not taken.
✗ Branch 90 → 92 not taken.
✗ Branch 93 → 94 not taken.
✗ Branch 93 → 96 not taken.
✗ Branch 94 → 95 not taken.
✗ Branch 94 → 96 not taken.
22 [owner, database, action](UndoManager::Completion complete) {
76
2/4
✓ Branch 3 → 4 taken 6 times.
✗ Branch 3 → 12 not taken.
✓ Branch 7 → 8 taken 6 times.
✗ Branch 7 → 12 not taken.
12 if (!owner || owner->m_folder.accountId != action.selection->accountId ||
77
3/6
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 6 times.
✓ Branch 14 → 15 taken 6 times.
✗ Branch 14 → 16 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 19 taken 6 times.
12 owner->m_cache->databasePath() != database) { complete(false); return; }
78
2/2
✓ Branch 20 → 21 taken 6 times.
✓ Branch 21 → 22 taken 6 times.
6 owner->m_deps.statusMessage(tr("Cancelling the queued move…"));
79
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 27 taken 6 times.
6 if (!owner) { complete(false); return; }
80
1/1
✓ Branch 37 → 38 taken 6 times.
12 MailSelectionMutationService::shared()->cancelQueuedMove(owner, database, action,
81
1/7
✓ Branch 33 → 34 taken 6 times.
✗ Branch 57 → 58 not taken.
✗ Branch 57 → 59 not taken.
✗ Branch 60 → 61 not taken.
✗ Branch 60 → 62 not taken.
✗ Branch 63 → 64 not taken.
✗ Branch 63 → 65 not taken.
12 [owner, database, action, complete = std::move(complete)](MailSelectionCancelResult result) {
82
2/4
✓ Branch 3 → 4 taken 6 times.
✗ Branch 3 → 12 not taken.
✓ Branch 7 → 8 taken 6 times.
✗ Branch 7 → 12 not taken.
12 if (!owner || owner->m_folder.accountId != action.selection->accountId ||
83
3/6
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 6 times.
✓ Branch 14 → 15 taken 6 times.
✗ Branch 14 → 16 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 19 taken 6 times.
12 owner->m_cache->databasePath() != database) { complete(false); return; }
84 6 const bool cancelled = result.outcome == MailSelectionCancelResult::Outcome::Cancelled;
85
2/2
✓ Branch 19 → 20 taken 4 times.
✓ Branch 19 → 37 taken 2 times.
6 if (cancelled) {
86
2/2
✓ Branch 28 → 22 taken 2460 times.
✓ Branch 28 → 29 taken 4 times.
2464 for (const auto &receipt : action.receipts)
87
1/1
✓ Branch 25 → 26 taken 2460 times.
2460 owner->m_mutations.scheduledForDispatch().remove(receipt.token);
88
1/1
✓ Branch 30 → 31 taken 4 times.
4 QTimer::singleShot(0, &owner->m_mutations, &MailMutationPipeline::replayMutationJournal);
89
2/2
✓ Branch 33 → 34 taken 4 times.
✓ Branch 34 → 35 taken 4 times.
4 owner->m_deps.statusMessage(tr("Cancelled move of %n message(s).", nullptr, action.receipts.size()));
90
1/2
✓ Branch 37 → 38 taken 2 times.
✗ Branch 37 → 43 not taken.
2 } else if (result.outcome == MailSelectionCancelResult::Outcome::Conflict) {
91
2/2
✓ Branch 39 → 40 taken 2 times.
✓ Branch 40 → 41 taken 2 times.
2 owner->m_deps.statusMessage(tr("Move has already started or changed. It was not undone."));
92 } else {
93 ✗ owner->m_deps.statusMessage(tr("Could not cancel the move. Retry."));
94 }
95 6 complete(cancelled);
96 });
97
1/2
✓ Branch 48 → 49 taken 11 times.
✗ Branch 48 → 50 not taken.
22 }, retainedBytes);
98 // Admission has already committed even if the bounded undo history cannot
99 // retain another receipt. Never report this as a rejected or unsent action.
100
1/2
✗ Branch 57 → 58 not taken.
✓ Branch 57 → 59 taken 11 times.
11 if (!owner) return true;
101
1/1
✓ Branch 59 → 60 taken 11 times.
11 QTimer::singleShot(0, &m_mutations, &MailMutationPipeline::replayMutationJournal);
102
1/1
✓ Branch 65 → 66 taken 11 times.
11 m_deps.statusMessage(undoRetained
103
2/4
✓ Branch 60 → 61 taken 11 times.
✗ Branch 60 → 63 not taken.
✓ Branch 62 → 65 taken 11 times.
✗ Branch 64 → 65 not taken.
22 ? tr("Moving %n message(s)…", nullptr, action.receipts.size())
104 ✗ : tr("Move of %n message(s) queued; undo history is unavailable.", nullptr, action.receipts.size()));
105 11 return true;
106 11 }
107
108 // Visual-first move: the rows leave the list in this frame; the journal is
109 // written on the serial durable writer (the same thread as Seen/Star
110 // intents), dispatch and Undo follow its receipt. The GUI thread never waits
111 // for the SQLite write lock; reads here are indexed point queries.
112 56 void MailMoveCommands::startMove(qint64 folderId, const QString &folderPath,
113 const QList<qint64> &uids, const QString &targetFolder) {
114 auto &performanceTracker =
115
1/1
✓ Branch 2 → 3 taken 56 times.
56 MailJD::Performance::MutationPerformanceTracker::instance();
116
3/5
✓ Branch 14 → 15 taken 56 times.
✓ Branch 17 → 18 taken 168 times.
✓ Branch 17 → 19 taken 56 times.
✗ Branch 145 → 146 not taken.
✗ Branch 145 → 147 not taken.
224 performanceTracker.beginMutation(
117 112 uids.size(), {QStringLiteral("move"), QStringLiteral("archive"),
118 56 QStringLiteral("delete")});
119
1/1
✓ Branch 22 → 23 taken 56 times.
56 const quint32 uidValidity = m_cache->uidValidity(folderId);
120 56 QList<MutationRecord> records;
121 56 QList<MailHeader> headers;
122
1/1
✓ Branch 24 → 25 taken 56 times.
56 records.reserve(uids.size());
123
1/1
✓ Branch 26 → 27 taken 56 times.
56 headers.reserve(uids.size());
124
1/1
✓ Branch 27 → 28 taken 56 times.
56 performanceTracker.cacheStarted();
125
2/2
✓ Branch 63 → 30 taken 143 times.
✓ Branch 63 → 64 taken 55 times.
198 for (qint64 uid : uids) {
126
1/1
✓ Branch 31 → 32 taken 143 times.
143 const auto header = m_deps.loadedHeader(folderId, uid);
127
2/2
✓ Branch 33 → 34 taken 1 time.
✓ Branch 33 → 44 taken 142 times.
143 if (!header) {
128
1/1
✓ Branch 34 → 35 taken 1 time.
1 performanceTracker.cacheFinished();
129
4/4
✓ Branch 35 → 36 taken 1 time.
✓ Branch 37 → 38 taken 1 time.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 39 → 40 taken 1 time.
2 m_deps.actionError(tr("Move not queued: %1").arg(tr("a message is no longer cached")));
130 1 return;
131 }
132 142 MutationRecord record;
133 142 record.mailId = MailId{m_folder.accountId, folderId, uid};
134 142 record.folderPath = folderPath;
135 142 record.kind = MutationKind::Move;
136 142 record.replayClass = MutationReplayClass::AmbiguousOperation;
137 142 record.phase = MutationPhase::Queued;
138 142 record.targetFolder = targetFolder;
139 142 record.messageId = header->messageId;
140 142 record.uidValidity = uidValidity;
141
1/1
✓ Branch 52 → 53 taken 142 times.
142 records.append(std::move(record));
142
1/1
✓ Branch 54 → 55 taken 142 times.
142 headers.append(*header);
143 143 }
144
1/1
✓ Branch 64 → 65 taken 55 times.
55 performanceTracker.cacheFinished();
145
146
8/8
✓ Branch 65 → 66 taken 55 times.
✓ Branch 69 → 70 taken 55 times.
✓ Branch 70 → 71 taken 55 times.
✓ Branch 72 → 73 taken 55 times.
✓ Branch 73 → 74 taken 55 times.
✓ Branch 74 → 75 taken 55 times.
✓ Branch 80 → 67 taken 55 times.
✓ Branch 80 → 81 taken 55 times.
110 qCInfo(lcController) << "Moving" << uids.size() << "UIDs from" << folderPath
147
2/2
✓ Branch 75 → 76 taken 55 times.
✓ Branch 76 → 77 taken 55 times.
55 << "to" << targetFolder;
148
1/1
✓ Branch 87 → 88 taken 55 times.
55 m_deps.statusMessage(
149
3/3
✓ Branch 81 → 82 taken 55 times.
✓ Branch 84 → 85 taken 55 times.
✓ Branch 86 → 87 taken 55 times.
220 QString("Moving %1 mail(s) to %2...").arg(uids.size()).arg(targetFolder));
150
151 // Visual commit. T-201: the pending move intent is set before any IMAP work so
152 // header refreshes never re-admit the rows while the move is outstanding.
153
1/1
✓ Branch 91 → 92 taken 55 times.
55 performanceTracker.modelBadgeStarted();
154
2/2
✓ Branch 104 → 94 taken 142 times.
✓ Branch 104 → 105 taken 55 times.
197 for (qint64 uid : uids) {
155
1/3
✓ Branch 97 → 98 taken 142 times.
✗ Branch 198 → 199 not taken.
✗ Branch 198 → 200 not taken.
142 m_mutations.intents().markMove(MailId{m_folder.accountId, folderId, uid});
156
1/1
✓ Branch 99 → 100 taken 142 times.
142 m_model->removeByUid(uid, folderId);
157
2/2
✓ Branch 100 → 101 taken 140 times.
✓ Branch 100 → 102 taken 2 times.
142 if (m_threadModel)
158
1/1
✓ Branch 101 → 102 taken 140 times.
140 m_threadModel->removeByUid(uid, folderId);
159 }
160
1/1
✓ Branch 105 → 106 taken 55 times.
55 m_deps.movesVisuallyPending(folderId, uids);
161
1/1
✓ Branch 106 → 107 taken 55 times.
55 performanceTracker.modelBadgeFinished();
162
1/1
✓ Branch 107 → 108 taken 55 times.
55 performanceTracker.perceivedCommitted();
163
164 // Durable commit on the writer thread.
165
1/1
✓ Branch 108 → 109 taken 55 times.
55 performanceTracker.journalStarted();
166 55 const QString database = m_cache->databasePath();
167 55 const QString account = m_folder.accountId;
168
1/1
✓ Branch 111 → 112 taken 55 times.
55 const QPointer<MailMoveCommands> owner(this);
169
2/2
✓ Branch 112 → 113 taken 55 times.
✓ Branch 123 → 124 taken 55 times.
110 MailSelectionMutationService::shared()->enqueueMoves(this, database, records,
170
1/13
✓ Branch 120 → 121 taken 55 times.
✗ Branch 213 → 214 not taken.
✗ Branch 213 → 215 not taken.
✗ Branch 216 → 217 not taken.
✗ Branch 216 → 218 not taken.
✗ Branch 219 → 220 not taken.
✗ Branch 219 → 221 not taken.
✗ Branch 222 → 223 not taken.
✗ Branch 222 → 224 not taken.
✗ Branch 225 → 226 not taken.
✗ Branch 225 → 227 not taken.
✗ Branch 228 → 229 not taken.
✗ Branch 228 → 230 not taken.
110 [owner, database, account, folderId, uids, headers, folderPath, targetFolder](
171 MailSelectionMutationService::MoveJournalResult result) {
172
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 51 times.
51 if (!owner) return;
173 51 auto &tracker = MailJD::Performance::MutationPerformanceTracker::instance();
174
2/2
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 50 times.
101 tracker.journalFinished(result.journaled,
175 101 result.tokens.isEmpty() ? 0 : result.tokens.constLast());
176
4/8
✓ Branch 15 → 16 taken 51 times.
✗ Branch 15 → 19 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 51 times.
✓ Branch 21 → 22 taken 51 times.
✗ Branch 21 → 23 not taken.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 36 taken 51 times.
51 if (owner->m_cache->databasePath() != database || owner->m_folder.accountId != account) {
177 // Another profile or account took over; durable work replays there.
178 // Consumers still drop their visual state for these identities.
179 ✗ owner->m_deps.movesVisuallySettled(folderId, uids);
180 ✗ if (owner && result.journaled)
181 ✗ QTimer::singleShot(0, &owner->m_mutations, &MailMutationPipeline::replayMutationJournal);
182 ✗ return;
183 }
184
2/2
✓ Branch 36 → 37 taken 1 time.
✓ Branch 36 → 40 taken 50 times.
51 if (!result.journaled) {
185 1 owner->rollbackVisualMove(folderId, headers, result.error);
186 1 return;
187 }
188 50 owner->m_deps.movesVisuallySettled(folderId, uids);
189
4/4
✓ Branch 42 → 43 taken 50 times.
✓ Branch 43 → 44 taken 50 times.
✓ Branch 57 → 45 taken 137 times.
✓ Branch 57 → 58 taken 50 times.
187 for (qint64 token : result.tokens) {
190
2/3
✓ Branch 48 → 49 taken 137 times.
✓ Branch 50 → 51 taken 137 times.
✗ Branch 50 → 54 not taken.
137 if (const auto record = owner->m_cache->journal().mutation(token))
191
1/1
✓ Branch 53 → 54 taken 137 times.
137 owner->m_mutations.dispatchMutation(*record);
192 }
193 // T-211: Undo moves back via Message-ID search.
194
3/6
✓ Branch 60 → 61 taken 50 times.
✗ Branch 60 → 64 not taken.
✓ Branch 62 → 63 taken 50 times.
✗ Branch 62 → 64 not taken.
✓ Branch 65 → 66 taken 50 times.
✗ Branch 65 → 86 not taken.
50 if (owner->m_undoManager && !headers.isEmpty()) {
195 50 MailMoveCommands *controller = owner;
196
1/1
✓ Branch 79 → 80 taken 50 times.
100 owner->m_undoManager->push(
197
3/3
✓ Branch 73 → 74 taken 50 times.
✓ Branch 75 → 76 taken 50 times.
✓ Branch 78 → 79 taken 50 times.
200 tr("Moved to %1 (%2 mails)").arg(targetFolder).arg(uids.size()),
198
1/5
✓ Branch 72 → 73 taken 50 times.
✗ Branch 107 → 108 not taken.
✗ Branch 107 → 109 not taken.
✗ Branch 110 → 111 not taken.
✗ Branch 110 → 112 not taken.
100 [controller, headers, folderPath, targetFolder]() {
199 2 controller->undoMove(headers, folderPath, targetFolder);
200 2 });
201 }
202 });
203 281 }
204
205 2 void MailMoveCommands::rollbackVisualMove(qint64 folderId,
206 const QList<MailHeader> &headers,
207 const QString &error) {
208 2 QList<qint64> uids;
209 2 QList<MailHeader> restore;
210
2/2
✓ Branch 15 → 4 taken 2 times.
✓ Branch 15 → 16 taken 2 times.
4 for (const auto &header : headers) {
211
1/1
✓ Branch 5 → 6 taken 2 times.
2 uids.append(header.uid);
212
1/3
✓ Branch 8 → 9 taken 2 times.
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 36 not taken.
2 m_mutations.intents().clearMove(MailId{m_folder.accountId, folderId, header.uid});
213
3/4
✓ Branch 10 → 11 taken 2 times.
✓ Branch 11 → 12 taken 2 times.
✗ Branch 11 → 13 not taken.
✓ Branch 12 → 13 taken 2 times.
2 if (m_model->rowForUid(header.uid, folderId) < 0) restore.append(header);
214 }
215
1/2
✓ Branch 17 → 18 taken 2 times.
✗ Branch 17 → 22 not taken.
2 if (!restore.isEmpty()) {
216
1/1
✓ Branch 18 → 19 taken 2 times.
2 m_model->appendHeaders(restore);
217
1/2
✓ Branch 19 → 20 taken 2 times.
✗ Branch 19 → 22 not taken.
2 if (m_threadModel)
218
1/1
✓ Branch 21 → 22 taken 2 times.
2 m_threadModel->setHeaders(m_model->allHeaders());
219 }
220
1/1
✓ Branch 22 → 23 taken 2 times.
2 m_deps.movesVisuallySettled(folderId, uids);
221
3/3
✓ Branch 23 → 24 taken 2 times.
✓ Branch 25 → 26 taken 2 times.
✓ Branch 26 → 27 taken 2 times.
4 m_deps.actionError(tr("Move not queued: %1").arg(error));
222 2 }
223
224 1 void MailMoveCommands::moveMailToFolder(qint64 uid, const QString &targetFolder) {
225
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 3 → 4 taken 1 time.
1 moveMailsToFolder({uid}, targetFolder);
226 1 }
227
228 52 void MailMoveCommands::moveMailsToFolder(const QList<qint64> &uids,
229 const QString &targetFolder) {
230
6/8
✓ Branch 3 → 4 taken 52 times.
✗ Branch 3 → 8 not taken.
✓ Branch 5 → 6 taken 52 times.
✗ Branch 5 → 8 not taken.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 51 times.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 22 taken 51 times.
104 if (uids.isEmpty() || targetFolder.isEmpty() ||
231 52 targetFolder == m_folder.currentFolder) {
232
5/5
✓ Branch 11 → 12 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 20 → 13 taken 1 time.
✓ Branch 20 → 21 taken 1 time.
2 qCInfo(lcController) << "Move: target is same as current folder, ignoring";
233 1 return;
234 }
235 51 startMove(m_folder.currentFolderId, m_folder.currentFolder, uids, targetFolder);
236 }
237
238 51 void MailMoveCommands::moveMailsToFolderFrom(const QList<qint64> &uids,
239 qint64 srcFolderId,
240 const QString &srcFolder,
241 const QString &targetFolder) {
242 // If source is the current folder, delegate to normal method
243
2/2
✓ Branch 2 → 3 taken 47 times.
✓ Branch 2 → 5 taken 4 times.
51 if (srcFolderId == m_folder.currentFolderId) {
244 47 moveMailsToFolder(uids, targetFolder);
245 47 return;
246 }
247
6/8
✓ Branch 6 → 7 taken 4 times.
✗ Branch 6 → 11 not taken.
✓ Branch 8 → 9 taken 4 times.
✗ Branch 8 → 11 not taken.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 3 times.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 25 taken 3 times.
4 if (uids.isEmpty() || targetFolder.isEmpty() || srcFolder == targetFolder) {
248
5/5
✓ Branch 14 → 15 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 23 → 16 taken 1 time.
✓ Branch 23 → 24 taken 1 time.
2 qCInfo(lcController) << "T-407: Move: target is same as source, ignoring";
249 1 return;
250 }
251 3 startMove(srcFolderId, srcFolder, uids, targetFolder);
252 }
253
254 2 void MailMoveCommands::crossFolderMove(const QString &srcFolder,
255 const QList<qint64> &uids,
256 const QString &targetFolder) {
257 2 m_deps.ensureBodyConnection();
258
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 2 times.
2 if (!m_bodyImap)
259 ✗ return;
260
261 // T-79.E2/M6: remember the source folder so the result handlers can
262 // clean (success) or restore (failure) the right rows.
263
2/2
✓ Branch 5 → 6 taken 2 times.
✓ Branch 7 → 8 taken 2 times.
2 m_pendingCrossFolderMoves.append({m_deps.resolveFolderId(srcFolder), uids});
264
265
2/2
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 1 time.
2 if (m_bodyImapSelectedFolder == srcFolder) {
266 1 m_bodyImap->moveMessages(uids, targetFolder);
267 } else {
268 auto conn = connect(
269 1 m_bodyImap, &ImapService::folderSelected, this,
270
0/4
✗ Branch 27 → 28 not taken.
✗ Branch 27 → 29 not taken.
✗ Branch 30 → 31 not taken.
✗ Branch 30 → 32 not taken.
2 [this, uids, targetFolder, srcFolder](const QString &path, int,
271 quint32, quint64) {
272
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 6 not taken.
1 if (path == srcFolder) {
273 1 m_bodyImapSelectedFolder = path;
274 1 m_bodyImap->moveMessages(uids, targetFolder);
275 }
276 1 },
277
1/1
✓ Branch 15 → 16 taken 1 time.
1 Qt::SingleShotConnection);
278 Q_UNUSED(conn);
279
1/1
✓ Branch 17 → 18 taken 1 time.
1 m_bodyImap->selectFolder(srcFolder);
280 1 }
281 }
282
283 // T-79.E2/M6: cross-folder move confirmed by the server (body connection).
284 // The model rows were already removed optimistically; clean the cache rows
285 // of the *source* folder so the stale copy cannot resurface.
286 1 void MailMoveCommands::onBodyImapMessagesMoved(const QList<qint64> &uids,
287 const QString &targetFolder) {
288
1/2
✗ Branch 44 → 3 not taken.
✓ Branch 44 → 45 taken 1 time.
1 for (int i = 0; i < m_pendingCrossFolderMoves.size(); ++i) {
289 ✗ const auto &entry = m_pendingCrossFolderMoves.at(i);
290 ✗ if (entry.second != uids)
291 ✗ continue;
292 ✗ qCInfo(lcController) << "T-79.E2: Cross-folder move confirmed:"
293 ✗ << uids.size() << "UIDs from folderId" << entry.first
294 ✗ << "→" << targetFolder;
295 ✗ for (qint64 uid : uids)
296 ✗ m_cache->removeHeader(entry.first, uid);
297 ✗ m_pendingCrossFolderMoves.removeAt(i);
298 ✗ m_deps.statusMessage(tr("Moved %1 mail(s) to %2")
299 ✗ .arg(uids.size())
300 ✗ .arg(targetFolder));
301 ✗ return;
302 }
303
304 // Sprint 84 moves are keyed by durable records rather than the legacy
305 // in-memory pair. Resolve the source folder from the full journal identity.
306 1 bool matched = false;
307
5/5
✓ Branch 46 → 47 taken 1 time.
✓ Branch 47 → 48 taken 1 time.
✓ Branch 48 → 49 taken 1 time.
✓ Branch 64 → 50 taken 1 time.
✓ Branch 64 → 65 taken 1 time.
2 for (const MutationRecord &record : m_cache->journal().pendingMutations(m_folder.accountId)) {
308 3 if (record.replayClass != MutationReplayClass::AmbiguousOperation ||
309
3/6
✓ Branch 51 → 52 taken 1 time.
✗ Branch 51 → 56 not taken.
✓ Branch 53 → 54 taken 1 time.
✗ Branch 53 → 56 not taken.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 60 taken 1 time.
2 record.targetFolder != targetFolder ||
310
1/2
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 57 taken 1 time.
1 !uids.contains(record.mailId.uid))
311 ✗ continue;
312
1/1
✓ Branch 60 → 61 taken 1 time.
1 m_cache->removeHeader(record.mailId.folderId, record.mailId.uid);
313 1 matched = true;
314 1 }
315
1/2
✓ Branch 66 → 67 taken 1 time.
✗ Branch 66 → 78 not taken.
1 if (matched) {
316
2/2
✓ Branch 67 → 68 taken 1 time.
✓ Branch 73 → 74 taken 1 time.
2 m_deps.statusMessage(tr("Moved %1 mail(s) to %2")
317
1/1
✓ Branch 70 → 71 taken 1 time.
2 .arg(uids.size())
318
1/1
✓ Branch 72 → 73 taken 1 time.
2 .arg(targetFolder));
319 1 return;
320 }
321 ✗ qCWarning(lcController)
322 ✗ << "messagesMoved on body connection without durable owner";
323 }
324
325 // T-79.E2/M6: cross-folder move failed — restore the optimistically
326 // removed model rows from the cache and tell the user.
327 3 void MailMoveCommands::onBodyImapMoveError(const QString &error) {
328 // The token-owned terminal handler requeues this transient pre-dispatch state.
329
1/2
✓ Branch 8 → 9 taken 3 times.
✗ Branch 8 → 10 not taken.
3 if (error == QStringLiteral("No folder selected")) return;
330 ✗ qCWarning(lcController) << "T-79.E2: Cross-folder move failed:" << error;
331 ✗ m_deps.actionError(tr("Move failed: %1").arg(error));
332
333 ✗ QList<MailHeader> restoreHeaders;
334 ✗ for (const auto &entry : std::as_const(m_pendingCrossFolderMoves)) {
335 ✗ for (qint64 uid : entry.second) {
336 ✗ auto h = m_cache->header(entry.first, uid);
337 ✗ if (h)
338 ✗ restoreHeaders.append(*h);
339 ✗ }
340 }
341 ✗ m_pendingCrossFolderMoves.clear();
342 ✗ for (const MutationRecord &record : m_cache->journal().pendingMutations(m_folder.accountId)) {
343 ✗ if (record.replayClass != MutationReplayClass::AmbiguousOperation ||
344 ✗ record.phase != MutationPhase::Dispatched)
345 ✗ continue;
346 ✗ auto header = m_cache->header(record.mailId.folderId, record.mailId.uid);
347 ✗ if (header)
348 ✗ restoreHeaders.append(*header);
349 ✗ }
350 ✗ if (!restoreHeaders.isEmpty()) {
351 ✗ qCInfo(lcController) << "T-79.E2: Restoring" << restoreHeaders.size()
352 ✗ << "mails after failed cross-folder move";
353 ✗ m_model->appendHeaders(restoreHeaders);
354 ✗ if (m_threadModel)
355 ✗ m_threadModel->setHeaders(m_model->allHeaders());
356 }
357 ✗ }
358
359 10 void MailMoveCommands::onMessageMoved(qint64 uid, const QString &targetFolder) {
360 // T-79.E4/M8: during undo-move the main connection moves UIDs of the
361 // *fromFolder* while m_folder.currentFolderId still points at the folder the
362 // user is viewing (undoMove bumps m_folder.folderGeneration, the undo flow's
363 // own SingleShot handlers reload afterwards). Removing by UID here
364 // would evict unrelated cached headers with coincident UIDs.
365
1/2
✓ Branch 2 → 3 taken 10 times.
✗ Branch 2 → 16 not taken.
10 if (m_folder.activeFolderGen != m_folder.folderGeneration) {
366
6/6
✓ Branch 3 → 4 taken 10 times.
✓ Branch 7 → 8 taken 10 times.
✓ Branch 8 → 9 taken 10 times.
✓ Branch 9 → 10 taken 10 times.
✓ Branch 14 → 5 taken 10 times.
✓ Branch 14 → 15 taken 10 times.
20 qCInfo(lcController) << "T-79.E4: Ignoring messageMoved for UID" << uid
367
1/1
✓ Branch 10 → 11 taken 10 times.
10 << "(stale folder generation)";
368 10 return;
369 }
370
371 ✗ qCInfo(lcController) << "Mail moved: UID" << uid << "→" << targetFolder;
372
373 // Remove from local model and cache
374 ✗ m_cache->removeHeader(m_folder.currentFolderId, uid);
375 ✗ m_model->removeByUid(uid, m_folder.currentFolderId);
376 }
377
378 4 void MailMoveCommands::onMessagesMoved(const QList<qint64> &uids,
379 const QString &targetFolder) {
380
6/6
✓ Branch 2 → 3 taken 4 times.
✓ Branch 6 → 7 taken 4 times.
✓ Branch 7 → 8 taken 4 times.
✓ Branch 9 → 10 taken 4 times.
✓ Branch 15 → 4 taken 4 times.
✓ Branch 15 → 16 taken 4 times.
8 qCInfo(lcController) << "Batch move complete:" << uids.size()
381
2/2
✓ Branch 10 → 11 taken 4 times.
✓ Branch 11 → 12 taken 4 times.
4 << "UIDs →" << targetFolder;
382
383 // S106-197: the pending-move intents are settled by the journal token's
384 // completion, which knows each record's own MailId. This aggregate names
385 // UIDs of the moving connection's selected mailbox, not of the viewed
386 // folder — clearing (viewed folder, UID) hit another mail or nothing.
387
388 4 m_deps.unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
389
1/1
✓ Branch 28 → 29 taken 4 times.
4 m_deps.statusMessage(
390
1/1
✓ Branch 18 → 19 taken 4 times.
4 QString("Moved %1 mail(s) to %2 – %3 mails")
391
1/1
✓ Branch 21 → 22 taken 4 times.
8 .arg(uids.size())
392
1/1
✓ Branch 23 → 24 taken 4 times.
8 .arg(targetFolder)
393
2/2
✓ Branch 26 → 27 taken 4 times.
✓ Branch 27 → 28 taken 4 times.
8 .arg(m_model->rowCount()));
394
395 // T-84.C1: ImapService owns post-command queue draining and push restart.
396 // A terminal observer can enqueue a STORE here; starting IDLE from the
397 // controller would race that reentrant command.
398 4 }
399
400 2 void MailMoveCommands::onMoveError(const QString &error) {
401 // Do not contradict the durable journal's retry outcome with a legacy failure.
402
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 1 time.
2 if (error == QStringLiteral("No folder selected")) return;
403
6/6
✓ Branch 10 → 11 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 20 → 12 taken 1 time.
✓ Branch 20 → 21 taken 1 time.
2 qCWarning(lcController) << "Move failed:" << error;
404
3/3
✓ Branch 21 → 22 taken 1 time.
✓ Branch 23 → 24 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
2 m_deps.actionError(QString("Move failed: %1").arg(error));
405
406 // Sprint 84: rollback belongs to the token-matched mutation terminal
407 // handler. This legacy aggregate signal cannot identify which of several
408 // serialized moves failed and must never restore unrelated MailIds.
409 }
410
411 8 void MailMoveCommands::undoMove(const QList<MailHeader> &headers,
412 const QString &sourceFolder,
413 const QString &fromFolder) {
414
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 8 times.
8 if (headers.isEmpty()) return;
415
416
4/6
✓ Branch 5 → 6 taken 8 times.
✓ Branch 14 → 15 taken 8 times.
✓ Branch 17 → 18 taken 16 times.
✓ Branch 17 → 19 taken 8 times.
✗ Branch 86 → 87 not taken.
✗ Branch 86 → 88 not taken.
32 MailJD::Performance::MutationPerformanceTracker::instance().beginMutation(
417 8 headers.size(), {QStringLiteral("undo_immediate"),
418 8 QStringLiteral("undo_pending")});
419
420
6/6
✓ Branch 21 → 22 taken 8 times.
✓ Branch 25 → 26 taken 8 times.
✓ Branch 26 → 27 taken 8 times.
✓ Branch 28 → 29 taken 8 times.
✓ Branch 36 → 23 taken 8 times.
✓ Branch 36 → 37 taken 8 times.
16 qCInfo(lcController) << "T-211: Undo move:" << headers.size()
421
4/4
✓ Branch 29 → 30 taken 8 times.
✓ Branch 30 → 31 taken 8 times.
✓ Branch 31 → 32 taken 8 times.
✓ Branch 32 → 33 taken 8 times.
8 << "mails from" << fromFolder << "back to" << sourceFolder;
422
1/1
✓ Branch 41 → 42 taken 8 times.
8 m_deps.statusMessage(
423
1/1
✓ Branch 38 → 39 taken 8 times.
8 tr("Undo: Moving %n message(s) back to %1…", nullptr, headers.size())
424
1/1
✓ Branch 40 → 41 taken 8 times.
16 .arg(sourceFolder));
425
426
427 // Collect Message-IDs for IMAP search in the target folder
428 8 QStringList messageIds;
429
2/2
✓ Branch 52 → 46 taken 93 times.
✓ Branch 52 → 53 taken 8 times.
101 for (const auto &h : headers) {
430
2/2
✓ Branch 48 → 49 taken 92 times.
✓ Branch 48 → 50 taken 1 time.
93 if (!h.messageId.isEmpty()) {
431
1/1
✓ Branch 49 → 50 taken 92 times.
92 messageIds.append(h.messageId);
432 }
433 }
434
435
1/2
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 69 taken 8 times.
8 if (messageIds.isEmpty()) {
436 ✗ qCWarning(lcController) << "T-211: No Message-IDs for undo move"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; empty-result guard remains measured.
437 ✗ m_deps.statusMessage(tr("Undo failed: No message IDs"));
438 ✗ return;
439 }
440
441 // Async IMAP flow: SELECT fromFolder → SEARCH Message-ID → MOVE back
442 // We use the main connection via executeAfterIdle.
443
1/1
✓ Branch 73 → 74 taken 8 times.
8 m_imap->executeAfterIdle(
444
1/5
✓ Branch 72 → 73 taken 8 times.
✗ Branch 127 → 128 not taken.
✗ Branch 127 → 129 not taken.
✗ Branch 130 → 131 not taken.
✗ Branch 130 → 132 not taken.
16 [this, messageIds, sourceFolder, fromFolder]() {
445 // T-211 fix: Invalidate all sync-pipeline handlers so that
446 // onSearchResultReceived etc. discard the undo-flow results.
447 8 ++m_folder.folderGeneration;
448
449 // We need to SELECT the target folder (where the mails are now)
450 // to search for them by Message-ID and move them back.
451 8 m_bodyFetchSelect = true; // Suppress sync pipeline
452 8 const QString capturedFolder = m_folder.currentFolder;
453 // S106-187: the chain is one guarded state object. The SELECT fence
454 // stays armed until fromFolder itself is selected (a foreign
455 // folderSelected used to consume the single-shot connection and lose
456 // the undo silently), every search result is matched by its sequence
457 // and must have run in fromFolder under the fenced UIDVALIDITY, and a
458 // watchdog ends a chain the server never completes.
459
1/1
✓ Branch 3 → 4 taken 8 times.
8 auto chain = std::make_shared<UndoSearchChain>();
460 8 chain->messageIds = messageIds;
461 21 auto finish = [this, chain, sourceFolder, fromFolder,
462 capturedFolder](const QString &failure) {
463
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 5 times.
5 if (chain->done)
464 2 return;
465 5 chain->done = true;
466
1/1
✓ Branch 7 → 8 taken 5 times.
5 QObject::disconnect(chain->selectConnection);
467
1/1
✓ Branch 9 → 10 taken 5 times.
5 QObject::disconnect(chain->searchConnection);
468
2/2
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 33 taken 3 times.
5 if (!failure.isEmpty()) {
469
6/6
✓ Branch 12 → 13 taken 2 times.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 22 → 14 taken 2 times.
✓ Branch 22 → 23 taken 2 times.
4 qCWarning(lcController) << "T-211: undo search chain aborted:" << failure;
470
3/3
✓ Branch 23 → 24 taken 2 times.
✓ Branch 25 → 26 taken 2 times.
✓ Branch 26 → 27 taken 2 times.
4 m_deps.statusMessage(tr("Undo failed: %1").arg(failure));
471
1/2
✓ Branch 30 → 31 taken 2 times.
✗ Branch 30 → 32 not taken.
2 if (!capturedFolder.isEmpty())
472
1/1
✓ Branch 31 → 32 taken 2 times.
2 m_deps.onFolderSelected(capturedFolder);
473 2 return;
474 }
475 3 QList<qint64> foundUids;
476 3 QStringList orderedIds;
477
2/2
✓ Branch 48 → 37 taken 3 times.
✓ Branch 48 → 49 taken 3 times.
6 for (const auto &outcome : std::as_const(chain->outcomes))
478
2/2
✓ Branch 45 → 40 taken 2 times.
✓ Branch 45 → 46 taken 3 times.
5 for (qint64 uid : outcome.uids) {
479
1/1
✓ Branch 41 → 42 taken 2 times.
2 foundUids.append(uid);
480
1/1
✓ Branch 42 → 43 taken 2 times.
2 orderedIds.append(outcome.messageId);
481 }
482 3 journalAndDispatchUndo(foundUids, orderedIds, sourceFolder,
483
1/1
✓ Branch 49 → 50 taken 3 times.
3 fromFolder, capturedFolder);
484 11 };
485 7 auto runNextSearch = [this, chain, finish]() {
486
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 7 times.
7 if (chain->done)
487 3 return;
488
2/2
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 14 taken 4 times.
7 if (chain->outcomes.size() >= chain->messageIds.size()) {
489
1/1
✓ Branch 11 → 12 taken 3 times.
3 finish({});
490 3 return;
491 }
492 4 const QString msgId = chain->messageIds.at(chain->outcomes.size());
493 // F-42: keep the (messageId, uids) pairing per search.
494 8 chain->outcomes.append({msgId, {}});
495
1/1
✓ Branch 23 → 24 taken 4 times.
4 chain->awaitedSequence = m_imap->searchByMessageId(msgId);
496
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 35 taken 4 times.
4 if (chain->awaitedSequence == 0)
497 ✗ finish(tr("the folder %1 is no longer selected").arg(chain->fencedFolder));
498
1/3
✓ Branch 21 → 22 taken 4 times.
✗ Branch 44 → 45 not taken.
✗ Branch 44 → 46 not taken.
16 };
499 16 chain->searchConnection = connect(
500 8 m_imap, &ImapService::messageIdSearchFinished, this,
501
1/5
✓ Branch 15 → 16 taken 8 times.
✗ Branch 40 → 41 not taken.
✗ Branch 40 → 42 not taken.
✗ Branch 43 → 44 not taken.
✗ Branch 43 → 45 not taken.
16 [chain, finish, runNextSearch](quint64 sequence, const QString &folder,
502 quint32 uidValidity, const QList<qint64> &uids,
503 bool ok) {
504
3/6
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 4 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 4 times.
4 if (chain->done || sequence != chain->awaitedSequence)
505 ✗ return; // another flow's search
506
5/6
✓ Branch 12 → 13 taken 4 times.
✗ Branch 12 → 15 not taken.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 3 times.
✓ Branch 17 → 18 taken 1 time.
✓ Branch 17 → 22 taken 3 times.
4 if (folder != chain->fencedFolder || uidValidity != chain->fencedUidValidity) {
507
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 19 → 20 taken 1 time.
1 finish(tr("the folder changed during the search"));
508 1 return;
509 }
510
1/2
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 27 taken 3 times.
3 if (!ok) {
511 ✗ finish(tr("the server rejected the search"));
512 ✗ return;
513 }
514 3 chain->outcomes.last().uids = uids;
515 3 runNextSearch();
516 8 });
517 16 chain->selectConnection = connect(
518 8 m_imap, &ImapService::folderSelected, this,
519
1/5
✓ Branch 23 → 24 taken 8 times.
✗ Branch 50 → 51 not taken.
✗ Branch 50 → 52 not taken.
✗ Branch 53 → 54 not taken.
✗ Branch 53 → 55 not taken.
16 [chain, fromFolder, runNextSearch](const QString &path, int,
520 quint32 uidValidity, quint64) {
521
5/6
✓ Branch 3 → 4 taken 5 times.
✗ Branch 3 → 6 not taken.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 4 times.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 4 times.
5 if (chain->done || path != fromFolder)
522 1 return; // a foreign SELECT — keep waiting for ours
523 4 QObject::disconnect(chain->selectConnection);
524 4 chain->fencedFolder = path;
525 4 chain->fencedUidValidity = uidValidity;
526 4 runNextSearch();
527 8 });
528
1/3
✓ Branch 30 → 31 taken 8 times.
✗ Branch 60 → 61 not taken.
✗ Branch 60 → 62 not taken.
8 QTimer::singleShot(m_undoChainTimeoutMs, this, [chain, finish] {
529
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 8 not taken.
1 if (!chain->done)
530
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
1 finish(tr("the server did not answer in time"));
531 1 });
532
1/1
✓ Branch 32 → 33 taken 8 times.
8 m_imap->selectFolder(fromFolder);
533 8 });
534 32 }
535
536 // undoMove step: journal one durable Undo per UID found in `fromFolder`
537 // and dispatch them; `capturedFolder` is reopened when nothing was found.
538 3 void MailMoveCommands::journalAndDispatchUndo(const QList<qint64> &foundUids,
539 const QStringList &messageIds,
540 const QString &sourceFolder,
541 const QString &fromFolder,
542 const QString &capturedFolder) {
543
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 22 taken 2 times.
3 if (foundUids.isEmpty()) {
544 2 qCWarning(lcController) // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; the owning failure branch remains measured.
545
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
1 << "T-211: No messages found in" << fromFolder;
546
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 16 → 17 taken 1 time.
1 m_deps.statusMessage(tr("Undo failed: messages not found"));
547 // Revert to original folder
548
1/2
✓ Branch 19 → 20 taken 1 time.
✗ Branch 19 → 21 not taken.
1 if (!capturedFolder.isEmpty())
549
1/1
✓ Branch 20 → 21 taken 1 time.
1 m_deps.onFolderSelected(capturedFolder);
550 1 return;
551 }
552
553 const qint64 fromFolderId =
554
1/1
✓ Branch 22 → 23 taken 2 times.
2 m_cache->ensureFolder(m_folder.accountId, fromFolder);
555 2 QList<qint64> tokens;
556
2/2
✓ Branch 71 → 24 taken 2 times.
✓ Branch 71 → 72 taken 2 times.
4 for (int i = 0; i < foundUids.size(); ++i) {
557 auto &performanceTracker =
558
1/1
✓ Branch 24 → 25 taken 2 times.
2 MailJD::Performance::MutationPerformanceTracker::instance();
559
1/1
✓ Branch 25 → 26 taken 2 times.
2 performanceTracker.journalStarted();
560 2 MutationRecord record;
561 4 record.mailId = MailId{m_folder.accountId, fromFolderId,
562 2 foundUids.at(i)};
563 2 record.folderPath = fromFolder;
564 2 record.kind = MutationKind::Undo;
565 2 record.replayClass =
566 MutationReplayClass::AmbiguousOperation;
567 2 record.phase = MutationPhase::Queued;
568 2 record.targetFolder = sourceFolder;
569
1/1
✓ Branch 32 → 33 taken 2 times.
2 record.messageId = messageIds.value(i); // F-42: one id per found UID
570
1/1
✓ Branch 35 → 36 taken 2 times.
2 record.uidValidity = m_cache->uidValidity(fromFolderId);
571
1/1
✓ Branch 37 → 38 taken 2 times.
2 const auto token = m_cache->journal().enqueueMutation(record);
572
1/2
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 60 taken 2 times.
2 if (!token) {
573 ✗ performanceTracker.journalFinished(false);
574 ✗ for (qint64 persisted : std::as_const(tokens))
575 ✗ m_cache->journal().removeMutation(persisted);
576 ✗ m_deps.statusMessage(tr("Undo not queued: %1")
577 ✗ .arg(m_cache->lastError()));
578 ✗ m_deps.resyncCurrentFolder();
579 ✗ return;
580 }
581
1/1
✓ Branch 61 → 62 taken 2 times.
2 performanceTracker.journalFinished(true, *token);
582
1/1
✓ Branch 63 → 64 taken 2 times.
2 tokens.append(*token);
583 2 }
584
5/5
✓ Branch 72 → 73 taken 2 times.
✓ Branch 76 → 77 taken 2 times.
✓ Branch 77 → 78 taken 2 times.
✓ Branch 85 → 74 taken 2 times.
✓ Branch 85 → 86 taken 2 times.
4 qCInfo(lcController) << "T-522: Dispatching"
585
1/1
✓ Branch 79 → 80 taken 2 times.
2 << tokens.size()
586
1/1
✓ Branch 80 → 81 taken 2 times.
2 << "durable undo moves to"
587
1/1
✓ Branch 81 → 82 taken 2 times.
2 << sourceFolder;
588 auto &performanceTracker =
589
1/1
✓ Branch 86 → 87 taken 2 times.
2 MailJD::Performance::MutationPerformanceTracker::instance();
590
1/1
✓ Branch 87 → 88 taken 2 times.
2 performanceTracker.cacheStarted();
591
1/1
✓ Branch 88 → 89 taken 2 times.
2 performanceTracker.cacheFinished();
592
1/1
✓ Branch 89 → 90 taken 2 times.
2 performanceTracker.modelBadgeStarted();
593
1/1
✓ Branch 90 → 91 taken 2 times.
2 performanceTracker.modelBadgeFinished();
594
1/1
✓ Branch 91 → 92 taken 2 times.
2 performanceTracker.perceivedCommitted();
595
2/2
✓ Branch 105 → 95 taken 2 times.
✓ Branch 105 → 106 taken 2 times.
4 for (qint64 token : std::as_const(tokens)) {
596
2/3
✓ Branch 97 → 98 taken 2 times.
✓ Branch 99 → 100 taken 2 times.
✗ Branch 99 → 102 not taken.
2 if (const auto record = m_cache->journal().mutation(token))
597
1/1
✓ Branch 101 → 102 taken 2 times.
2 m_mutations.dispatchMutation(*record);
598 }
599 // The undo SELECTed its source mailbox on the main
600 // connection; IDLE would stay there and every push for
601 // the open folder was ignored until the next folder
602 // switch. Reselect the open folder (queued after the
603 // dispatch above) so push and delta sync resume.
604
1/1
✓ Branch 106 → 107 taken 2 times.
2 m_deps.resyncCurrentFolder();
605 2 }
606