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: 92.3% 373 / 0 / 404
Functions: 94.3% 33 / 0 / 35
Branches: 73.8% 502 / 25 / 705

controller/FolderFlagSync.cpp
Line Branch Exec Source
1 #include "controller/FolderFlagSync.h"
2
3 #include "data/MailCache.h"
4 #include "performance/BackgroundPerformanceTracker.h"
5 #include "performance/FolderPerformanceTracker.h"
6 #include "service/ImapService.h"
7 #include "ui/MailListModel.h"
8 #include "ui/MailThreadModel.h"
9 #include <QFutureWatcher>
10 #include <QLoggingCategory>
11 #include <QPointer>
12 #include <QTimer>
13 #include <QtConcurrentRun>
14
15 // Same category as MailController (defined there), see docs/LOGGING.md.
16 Q_DECLARE_LOGGING_CATEGORY(lcController)
17
18 static constexpr int POLLING_INTERVAL_MS = 60 * 1000; // 60 seconds
19
20 1050 FolderFlagSync::FolderFlagSync(ImapService *imap, ImapService *const &searchImap,
21 MailCache *cache, MailListModel *model,
22 MailThreadModel *const &threadModel, const FolderContext &folder,
23 PendingFlagIntents &flagIntents,
24 const QStringList &subscribedFolders,
25 const bool &folderPresentationEnabled,
26 1050 QElapsedTimer &folderSwitchStopwatch, Deps deps, QObject *parent)
27 1050 : QObject(parent), m_imap(imap), m_searchImap(searchImap), m_cache(cache), m_model(model),
28 1050 m_threadModel(threadModel), m_folder(folder), m_flagIntents(flagIntents),
29 1050 m_subscribedFolders(subscribedFolders),
30 1050 m_folderPresentationEnabled(folderPresentationEnabled),
31 1050 m_folderSwitchStopwatch(folderSwitchStopwatch), m_deps(std::move(deps)),
32
4/7
✓ Branch 5 → 6 taken 1050 times.
✓ Branch 6 → 7 taken 1050 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 1050 times.
✓ Branch 9 → 10 taken 1050 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
2100 m_pollingTimer(new QTimer(this)) {
33
1/1
✓ Branch 13 → 14 taken 1050 times.
1050 m_flagMembershipPool.setMaxThreadCount(2);
34 1050 }
35
36 // Formerly the end of the MailController constructor.
37 1050 void FolderFlagSync::connectTimers() {
38 // Polling timer
39 1050 m_pollingTimer->setInterval(POLLING_INTERVAL_MS);
40
1/1
✓ Branch 3 → 4 taken 1050 times.
1050 connect(m_pollingTimer, &QTimer::timeout, this, &FolderFlagSync::pollFolders);
41
42 // Expunge debounce timer: coalesces rapid IDLE expunge events into one fetchFlags
43
2/5
✓ Branch 6 → 7 taken 1050 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 1050 times.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
1050 m_expungeDebounceTimer = new QTimer(this);
44 1050 m_expungeDebounceTimer->setSingleShot(true);
45 1050 m_expungeDebounceTimer->setInterval(200);
46
1/1
✓ Branch 11 → 12 taken 1050 times.
1050 connect(m_expungeDebounceTimer, &QTimer::timeout, this, [this]() {
47 ✗ qCInfo(lcController) << "Debounced expunge: triggering flag sync"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; timer state remains measured.
48 ✗ m_imap->executeAfterIdle([this]() { m_imap->fetchFlags(); });
49 ✗ });
50 1050 }
51
52 1802 void FolderFlagSync::cancelMembershipRead() {
53 1802 ++m_flagMembershipSequence;
54
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 1802 times.
1802 if (m_flagMembershipCancelled) m_flagMembershipCancelled->store(true);
55 1802 m_flagMembershipCancelled.reset();
56 1802 }
57
58 1050 void FolderFlagSync::abortMembershipRead() {
59
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 1050 times.
1050 if (m_flagMembershipCancelled) m_flagMembershipCancelled->store(true);
60 1050 }
61
62
1/1
✓ Branch 3 → 4 taken 1050 times.
1050 void FolderFlagSync::waitForMembershipReads() { m_flagMembershipPool.waitForDone(); }
63
64 1043 void FolderFlagSync::stopPolling() { m_pollingTimer->stop(); }
65
66 759 void FolderFlagSync::restartPolling() {
67 759 m_pollingTimer->stop();
68
2/2
✓ Branch 4 → 5 taken 743 times.
✓ Branch 4 → 6 taken 16 times.
759 if (!m_subscribedFolders.isEmpty())
69 743 m_pollingTimer->start();
70 759 }
71
72 1043 void FolderFlagSync::resetPollingIndex() { m_pollingIndex = 0; }
73
74 588 void FolderFlagSync::clearChangedSinceFlags() { m_changedSinceFlags.clear(); }
75
76 5 bool FolderFlagSync::expungeRefreshPending() const {
77
3/4
✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 6 not taken.
✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 6 taken 2 times.
5 return m_expungeDebounceTimer && m_expungeDebounceTimer->isActive();
78 }
79
80 2 bool FolderFlagSync::pollingActive() const { return m_pollingTimer->isActive(); }
81
82 13 bool FolderFlagSync::idlePushBelongsToCurrentFolder() const {
83 // During a folder switch the controller already names the new folder while
84 // the connection still has the old one selected. A push in that window
85 // belongs to the old mailbox; applying it to the new one fetched the old
86 // folder's complete header range from the new folder's max UID (and wrote
87 // flags into the wrong folder). The new folder's SELECT syncs anyway.
88
1/2
✓ Branch 3 → 4 taken 10 times.
✗ Branch 3 → 10 not taken.
10 return m_folder.activeFolderGen == m_folder.folderGeneration && m_folder.currentFolderId > 0 &&
89
6/8
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 10 taken 3 times.
✓ Branch 5 → 6 taken 10 times.
✗ Branch 5 → 10 not taken.
✓ Branch 8 → 9 taken 10 times.
✗ Branch 8 → 10 not taken.
✓ Branch 12 → 13 taken 10 times.
✓ Branch 12 → 14 taken 3 times.
23 !m_folder.currentFolder.isEmpty() && m_imap->selectedFolder() == m_folder.currentFolder;
90 }
91
92 2 void FolderFlagSync::onIdleNewMessages(int newCount) {
93
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 17 taken 1 time.
2 if (!idlePushBelongsToCurrentFolder()) {
94
6/6
✓ Branch 4 → 5 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 15 → 6 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
2 qCInfo(lcController) << "IDLE: ignoring" << newCount
95
1/1
✓ Branch 11 → 12 taken 1 time.
1 << "new messages pushed during a folder switch";
96 1 return;
97 }
98
7/7
✓ Branch 17 → 18 taken 1 time.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 22 → 23 taken 1 time.
✓ Branch 23 → 24 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
✓ Branch 29 → 19 taken 1 time.
✓ Branch 29 → 30 taken 1 time.
2 qCInfo(lcController) << "IDLE:" << newCount << "new messages in"
99
1/1
✓ Branch 25 → 26 taken 1 time.
1 << m_folder.currentFolder;
100
101
1/1
✓ Branch 30 → 31 taken 1 time.
1 MailJD::Performance::BackgroundPerformanceTracker::instance()
102
1/1
✓ Branch 31 → 32 taken 1 time.
1 .idleDeliveryObserved(newCount);
103
104
1/1
✓ Branch 32 → 33 taken 1 time.
1 qint64 maxUid = m_cache->maxUid(m_folder.currentFolderId);
105
106 // executeAfterIdle: sends DONE, waits for OK, then fetches. The DONE may
107 // complete after another folder switch; recheck the identity then.
108 1 const quint64 generation = m_folder.folderGeneration;
109 1 const QString folder = m_folder.currentFolder;
110
2/2
✓ Branch 35 → 36 taken 1 time.
✓ Branch 36 → 37 taken 1 time.
1 m_imap->executeAfterIdle(
111 2 [this, maxUid, generation, folder]() {
112
3/6
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 7 not taken.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 7 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 1 time.
2 if (generation != m_folder.folderGeneration || folder != m_folder.currentFolder ||
113
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 1 time.
1 !idlePushBelongsToCurrentFolder()) return;
114
1/2
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 14 not taken.
1 if (m_deps.beginHeaderStream()) m_imap->fetchHeaders(maxUid + 1);
115 });
116
117
1/1
✓ Branch 44 → 45 taken 1 time.
1 m_deps.statusMessage(
118
3/3
✓ Branch 39 → 40 taken 1 time.
✓ Branch 41 → 42 taken 1 time.
✓ Branch 43 → 44 taken 1 time.
4 QString("%1 – %2 new mail(s)!").arg(m_folder.currentFolder).arg(newCount));
119 1 }
120
121 5 void FolderFlagSync::onIdleFlagsChanged(qint64 uid, quint32 flags) {
122 // This comes during IDLE (no stop needed – it's a push notification)
123
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 4 times.
5 if (!idlePushBelongsToCurrentFolder()) return; // old folder's UID space
124
8/8
✓ Branch 5 → 6 taken 4 times.
✓ Branch 9 → 10 taken 4 times.
✓ Branch 10 → 11 taken 4 times.
✓ Branch 11 → 12 taken 4 times.
✓ Branch 12 → 13 taken 4 times.
✓ Branch 13 → 14 taken 4 times.
✓ Branch 17 → 7 taken 4 times.
✓ Branch 17 → 18 taken 4 times.
8 qCInfo(lcController) << "IDLE: flags changed for UID" << uid << "→" << flags;
125 auto &backgroundTracker =
126 4 MailJD::Performance::BackgroundPerformanceTracker::instance();
127
3/5
✓ Branch 23 → 24 taken 4 times.
✓ Branch 26 → 27 taken 4 times.
✓ Branch 26 → 28 taken 4 times.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 67 not taken.
8 backgroundTracker.beginBackground(
128 4 {QStringLiteral("mutation_during_idle_refresh")}, -1, 1);
129
1/1
✓ Branch 32 → 33 taken 4 times.
4 backgroundTracker.backgroundPhaseStarted(QStringLiteral("idle_refresh"));
130
131 // T-201: Server confirmed the flag change → remove from pending set
132
1/3
✓ Branch 36 → 37 taken 4 times.
✗ Branch 86 → 87 not taken.
✗ Branch 86 → 88 not taken.
4 m_flagIntents.clearFlag(MailId{m_folder.accountId, m_folder.currentFolderId, uid});
133
134 4 m_cache->updateFlags(m_folder.currentFolderId, uid, flags);
135 4 m_model->updateFlags(uid, flags, m_folder.currentFolderId);
136
1/2
✓ Branch 40 → 41 taken 4 times.
✗ Branch 40 → 42 not taken.
4 if (m_threadModel)
137 4 m_threadModel->updateFlags(uid, flags, m_folder.currentFolderId);
138 // Mail outside the loaded window changes only the cache: its settled
139 // count is the folder total (one indexed point read on this push).
140
2/2
✓ Branch 43 → 44 taken 2 times.
✓ Branch 43 → 46 taken 2 times.
4 if (m_model->rowForUid(uid, m_folder.currentFolderId) < 0)
141 2 m_model->setAuthoritativeUnread(m_cache->unreadCount(m_folder.currentFolderId));
142 4 m_deps.unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
143 4 backgroundTracker.addBackgroundWork(1);
144
1/1
✓ Branch 52 → 53 taken 4 times.
4 backgroundTracker.backgroundPhaseFinished(QStringLiteral("idle_refresh"), 1);
145 4 backgroundTracker.backgroundFinished(true);
146 8 }
147
148 4 void FolderFlagSync::onIdleMessageExpunged(int seqNo) {
149
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 3 times.
4 if (!idlePushBelongsToCurrentFolder()) return; // old folder's sequence space
150
6/6
✓ Branch 5 → 6 taken 3 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 10 → 11 taken 3 times.
✓ Branch 11 → 12 taken 3 times.
✓ Branch 15 → 7 taken 3 times.
✓ Branch 15 → 16 taken 3 times.
6 qCInfo(lcController) << "IDLE: message expunged, seqNo" << seqNo;
151
152 // Debounce: coalesce rapid expunge events (e.g. bulk deletes)
153 // into a single fetchFlags call after 200ms of quiet.
154 3 m_expungeDebounceTimer->start();
155 }
156
157 1 void FolderFlagSync::onIdleFlagsNeedRefetch(int seqNo) {
158
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
1 if (!idlePushBelongsToCurrentFolder()) return; // old folder's sequence space
159
6/6
✓ Branch 5 → 6 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 15 → 7 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
2 qCInfo(lcController) << "IDLE: flag change without UID, seqNo" << seqNo;
160 // T-065: Stop IDLE, fetch UID+FLAGS for this sequence number,
161 // result flows through onFlagsReceived which restarts IDLE.
162
1/1
✓ Branch 17 → 18 taken 1 time.
1 m_imap->executeAfterIdle([this, seqNo]() {
163 1 m_imap->fetchUidForSeqNo(seqNo);
164 1 });
165 }
166
167 17 void FolderFlagSync::onFlagsReceived(
168 const QList<QPair<qint64, quint32>> &uidFlags) {
169 // T-113: Discard if folder changed
170
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 20 taken 16 times.
17 if (m_folder.activeFolderGen != m_folder.folderGeneration) {
171
1/1
✓ Branch 3 → 4 taken 1 time.
1 MailJD::Performance::FolderPerformanceTracker::instance()
172
1/1
✓ Branch 4 → 5 taken 1 time.
1 .staleGenerationDiscarded(m_folder.activeFolderGen);
173
6/6
✓ Branch 5 → 6 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 18 → 7 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
2 qCInfo(lcController) << "Discarding stale flags (gen" << m_folder.activeFolderGen
174
3/3
✓ Branch 12 → 13 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
1 << "vs" << m_folder.folderGeneration << ")";
175 6 return;
176 }
177
178
3/3
✓ Branch 20 → 21 taken 16 times.
✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 33 taken 14 times.
16 if (m_deps.pendingHeaderWrites() > 0) {
179
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 31 taken 2 times.
2 if (uidFlags.size() > (16LL * 1024 * 1024) / sizeof(QPair<qint64, quint32>)) {
180 ✗ m_deps.failHeaderWrite(QStringLiteral("Deferred flag snapshot exceeds its byte budget"));
181 ✗ return;
182 }
183 // This signal contains a complete FETCH FLAGS snapshot. Only its newest
184 // version is needed, and it cannot finish a sync before its headers commit.
185
1/1
✓ Branch 31 → 32 taken 2 times.
2 m_deps.deferHeaderFlags(uidFlags);
186 2 return;
187 }
188
189
7/7
✓ Branch 33 → 34 taken 14 times.
✓ Branch 37 → 38 taken 14 times.
✓ Branch 38 → 39 taken 14 times.
✓ Branch 40 → 41 taken 14 times.
✓ Branch 41 → 42 taken 14 times.
✓ Branch 45 → 35 taken 14 times.
✓ Branch 45 → 46 taken 14 times.
28 qCInfo(lcController) << "Flag sync:" << uidFlags.size() << "UID/flag pairs";
190 14 ++m_flagMembershipSequence;
191
1/2
✗ Branch 47 → 48 not taken.
✓ Branch 47 → 50 taken 14 times.
14 if (m_flagMembershipCancelled) m_flagMembershipCancelled->store(true);
192 14 m_flagMembershipCancelled.reset();
193 // F-66 (SPRINT-104): canonical membership must not depend on the flat
194 // model being complete. Whenever the model is filtered, windowed or
195 // delegates presentation to indexed rows (an indexed-flags provider is
196 // registered), the exact worker path reconciles cache membership — the
197 // small-folder immediate path would otherwise treat every off-model
198 // cached UID as missing and keep remotely expunged rows forever.
199 ✗ const bool modelOwnsEveryCachedRow = [this] {
200
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 14 times.
14 if (m_visibleIndexedFlags) return false;
201 14 int rows = 0;
202
3/3
✓ Branch 12 → 13 taken 118 times.
✓ Branch 13 → 6 taken 104 times.
✓ Branch 13 → 14 taken 14 times.
118 for (int r = 0; r < m_model->rowCount(); ++r) {
203 104 const auto *h = m_model->headerAt(r);
204
2/4
✓ Branch 7 → 8 taken 104 times.
✗ Branch 7 → 10 not taken.
✓ Branch 8 → 9 taken 104 times.
✗ Branch 8 → 10 not taken.
104 if (h && h->folderId == m_folder.currentFolderId) ++rows;
205 }
206 14 return rows >= m_cache->headerCount(m_folder.currentFolderId);
207
1/1
✓ Branch 51 → 52 taken 14 times.
14 }();
208 // The loaded folder's authoritative total can select the conservative
209 // worker path immediately. Only an apparently small snapshot needs the
210 // canonical count lookup to detect off-page cached rows.
211 14 if (uidFlags.size() > MailHeaderPageSnapshot::MaximumRows ||
212
2/4
✓ Branch 54 → 55 taken 13 times.
✗ Branch 54 → 57 not taken.
✓ Branch 56 → 57 taken 13 times.
✗ Branch 56 → 60 not taken.
26 (m_folderPresentationEnabled &&
213 13 m_model->totalHeaderCount() > MailHeaderPageSnapshot::MaximumRows) ||
214
6/7
✓ Branch 53 → 54 taken 13 times.
✓ Branch 53 → 60 taken 1 time.
✓ Branch 57 → 58 taken 13 times.
✓ Branch 58 → 59 taken 13 times.
✗ Branch 58 → 60 not taken.
✓ Branch 62 → 63 taken 3 times.
✓ Branch 62 → 67 taken 11 times.
40 m_cache->headerCount(m_folder.currentFolderId) > MailHeaderPageSnapshot::MaximumRows ||
215
2/2
✓ Branch 59 → 60 taken 2 times.
✓ Branch 59 → 61 taken 11 times.
13 !modelOwnsEveryCachedRow) {
216
1/1
✓ Branch 64 → 65 taken 3 times.
3 reconcileLargeFlagSnapshot(uidFlags);
217 3 return;
218 }
219
220 // Build server UID set for expunge detection
221 11 QSet<qint64> serverUids;
222 11 QList<QPair<qint64, quint32>> changed;
223
224
2/2
✓ Branch 89 → 70 taken 58 times.
✓ Branch 89 → 90 taken 11 times.
69 for (const auto &[uid, serverFlags] : uidFlags) {
225
1/1
✓ Branch 73 → 74 taken 58 times.
58 serverUids.insert(uid);
226 // T-201: Skip UIDs with pending optimistic flag updates
227 233 if (m_flagIntents.hasPendingFlag(
228
3/5
✓ Branch 75 → 76 taken 58 times.
✓ Branch 77 → 78 taken 1 time.
✓ Branch 77 → 79 taken 57 times.
✗ Branch 209 → 210 not taken.
✗ Branch 209 → 211 not taken.
58 MailId{m_folder.accountId, m_folder.currentFolderId, uid}))
229 1 continue;
230
1/1
✓ Branch 79 → 80 taken 57 times.
57 int row = m_model->rowForUid(uid, m_folder.currentFolderId);
231
2/2
✓ Branch 80 → 81 taken 51 times.
✓ Branch 80 → 87 taken 6 times.
57 if (row >= 0) {
232
1/1
✓ Branch 81 → 82 taken 51 times.
51 auto *h = m_model->headerAt(row);
233
3/4
✓ Branch 82 → 83 taken 51 times.
✗ Branch 82 → 87 not taken.
✓ Branch 83 → 84 taken 1 time.
✓ Branch 83 → 87 taken 50 times.
51 if (h && h->flags != serverFlags) {
234
1/1
✓ Branch 85 → 86 taken 1 time.
1 changed.append({uid, serverFlags});
235 }
236 }
237 }
238
239 // Detect expunged UIDs (in model but not on server)
240 11 QList<qint64> expunged;
241
3/3
✓ Branch 102 → 103 taken 64 times.
✓ Branch 103 → 91 taken 53 times.
✓ Branch 103 → 104 taken 11 times.
64 for (int r = 0; r < m_model->rowCount(); ++r) {
242
1/1
✓ Branch 91 → 92 taken 53 times.
53 auto *h = m_model->headerAt(r);
243
6/8
✓ Branch 92 → 93 taken 53 times.
✗ Branch 92 → 97 not taken.
✓ Branch 93 → 94 taken 53 times.
✗ Branch 93 → 97 not taken.
✓ Branch 95 → 96 taken 1 time.
✓ Branch 95 → 97 taken 52 times.
✓ Branch 98 → 99 taken 1 time.
✓ Branch 98 → 100 taken 52 times.
53 if (h && h->folderId == m_folder.currentFolderId && !serverUids.contains(h->uid)) {
244
1/1
✓ Branch 99 → 100 taken 1 time.
1 expunged.append(h->uid);
245 }
246 }
247
248 // Apply changes
249
2/2
✓ Branch 105 → 106 taken 1 time.
✓ Branch 105 → 133 taken 10 times.
11 if (!changed.isEmpty()) {
250
1/1
✓ Branch 106 → 107 taken 1 time.
1 m_cache->batchUpdateFlags(m_folder.currentFolderId, changed);
251
4/4
✓ Branch 107 → 108 taken 1 time.
✓ Branch 108 → 109 taken 1 time.
✓ Branch 118 → 110 taken 1 time.
✓ Branch 118 → 119 taken 1 time.
2 for (const auto &[uid, flags] : changed) {
252
1/1
✓ Branch 113 → 114 taken 1 time.
1 m_model->updateFlags(uid, flags, m_folder.currentFolderId);
253
1/2
✓ Branch 114 → 115 taken 1 time.
✗ Branch 114 → 116 not taken.
1 if (m_threadModel)
254
1/1
✓ Branch 115 → 116 taken 1 time.
1 m_threadModel->updateFlags(uid, flags, m_folder.currentFolderId);
255 }
256
7/7
✓ Branch 119 → 120 taken 1 time.
✓ Branch 123 → 124 taken 1 time.
✓ Branch 124 → 125 taken 1 time.
✓ Branch 126 → 127 taken 1 time.
✓ Branch 127 → 128 taken 1 time.
✓ Branch 131 → 121 taken 1 time.
✓ Branch 131 → 132 taken 1 time.
2 qCInfo(lcController) << "Flag sync: updated" << changed.size() << "flags";
257 }
258
259
4/4
✓ Branch 133 → 134 taken 11 times.
✓ Branch 134 → 135 taken 11 times.
✓ Branch 152 → 136 taken 1 time.
✓ Branch 152 → 153 taken 11 times.
12 for (qint64 uid : expunged) {
260
1/1
✓ Branch 137 → 138 taken 1 time.
1 m_cache->removeHeader(m_folder.currentFolderId, uid);
261
1/1
✓ Branch 138 → 139 taken 1 time.
1 m_model->removeByUid(uid, m_folder.currentFolderId);
262
6/6
✓ Branch 139 → 140 taken 1 time.
✓ Branch 143 → 144 taken 1 time.
✓ Branch 144 → 145 taken 1 time.
✓ Branch 145 → 146 taken 1 time.
✓ Branch 149 → 141 taken 1 time.
✓ Branch 149 → 150 taken 1 time.
2 qCInfo(lcController) << "Flag sync: removed expunged UID" << uid;
263 }
264
265 // Small folders retain the existing immediate path. The full page owns
266 // their cached rows, and synchronous controller tests keep their contract.
267 11 QSet<qint64> modelUids;
268
3/3
✓ Branch 162 → 163 taken 63 times.
✓ Branch 163 → 155 taken 52 times.
✓ Branch 163 → 164 taken 11 times.
63 for (int r = 0; r < m_model->rowCount(); ++r) {
269
1/1
✓ Branch 155 → 156 taken 52 times.
52 const auto *h = m_model->headerAt(r);
270
3/5
✓ Branch 156 → 157 taken 52 times.
✗ Branch 156 → 160 not taken.
✓ Branch 157 → 158 taken 52 times.
✗ Branch 157 → 160 not taken.
✓ Branch 158 → 159 taken 52 times.
52 if (h && h->folderId == m_folder.currentFolderId) modelUids.insert(h->uid);
271 }
272 11 QList<qint64> missingUids;
273
4/4
✓ Branch 164 → 165 taken 11 times.
✓ Branch 165 → 166 taken 11 times.
✓ Branch 173 → 167 taken 58 times.
✓ Branch 173 → 174 taken 11 times.
69 for (qint64 uid : serverUids)
274
3/3
✓ Branch 169 → 170 taken 6 times.
✓ Branch 169 → 171 taken 52 times.
✓ Branch 170 → 171 taken 6 times.
58 if (!modelUids.contains(uid)) missingUids.append(uid);
275
1/1
✓ Branch 176 → 177 taken 11 times.
11 m_deps.finishFlagSync(std::move(missingUids), false);
276 11 }
277
278 2 void FolderFlagSync::onChangedFlagsReceived(
279 const QList<QPair<qint64, quint32>> &uidFlags) {
280
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 2 times.
2 if (m_folder.activeFolderGen != m_folder.folderGeneration) return;
281 // Held until the pipelined UID membership arrives.
282 2 m_changedSinceFlags = uidFlags;
283 }
284
285 2 void FolderFlagSync::onUidMembershipReceived(const QList<qint64> &uids) {
286
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 5 taken 2 times.
2 if (m_folder.activeFolderGen != m_folder.folderGeneration) {
287 ✗ m_changedSinceFlags.clear();
288 ✗ return;
289 }
290
6/6
✓ Branch 5 → 6 taken 2 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 12 → 13 taken 2 times.
✓ Branch 20 → 7 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
4 qCInfo(lcController) << "CONDSTORE sync:" << m_changedSinceFlags.size()
291
3/3
✓ Branch 13 → 14 taken 2 times.
✓ Branch 15 → 16 taken 2 times.
✓ Branch 16 → 17 taken 2 times.
2 << "changed of" << uids.size() << "UIDs";
292 2 ++m_flagMembershipSequence;
293
1/2
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 25 taken 2 times.
2 if (m_flagMembershipCancelled) m_flagMembershipCancelled->store(true);
294 2 m_flagMembershipCancelled.reset();
295 // Always the worker path: a partial flag list can never be compared
296 // against the loaded page as if it were complete.
297
1/1
✓ Branch 29 → 30 taken 2 times.
2 reconcileLargeFlagSnapshot(std::exchange(m_changedSinceFlags, {}), uids);
298 }
299
300 5 void FolderFlagSync::reconcileLargeFlagSnapshot(
301 const QList<QPair<qint64, quint32>> &uidFlags,
302 std::optional<QList<qint64>> membership) {
303 // Only loaded rows need immediate visual updates. Snapshot their flags on
304 // the GUI thread; scan the potentially million-entry server set and cached
305 // membership on the read-only worker.
306 5 QHash<qint64, quint32> visibleFlags;
307
3/3
✓ Branch 9 → 10 taken 56 times.
✓ Branch 10 → 3 taken 51 times.
✓ Branch 10 → 11 taken 5 times.
56 for (int row = 0; row < m_model->rowCount(); ++row) {
308
1/1
✓ Branch 3 → 4 taken 51 times.
51 const auto *header = m_model->headerAt(row);
309
2/4
✓ Branch 4 → 5 taken 51 times.
✗ Branch 4 → 7 not taken.
✓ Branch 5 → 6 taken 51 times.
✗ Branch 5 → 7 not taken.
51 if (header && header->folderId == m_folder.currentFolderId)
310
1/1
✓ Branch 6 → 7 taken 51 times.
51 visibleFlags.insert(header->uid, header->flags);
311 }
312
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 24 taken 5 times.
5 if (m_visibleIndexedFlags) {
313 ✗ const auto indexed = m_visibleIndexedFlags(m_folder.currentFolderId);
314 ✗ for (auto it = indexed.cbegin(); it != indexed.cend(); ++it)
315 ✗ visibleFlags.insert(it.key(), it.value());
316 ✗ }
317 5 QSet<qint64> pendingFlags;
318
1/2
✗ Branch 39 → 28 not taken.
✓ Branch 39 → 40 taken 5 times.
5 for (const auto &id : m_flagIntents.pendingFlags())
319 ✗ if (id.accountId == m_folder.accountId && id.folderId == m_folder.currentFolderId)
320 ✗ pendingFlags.insert(id.uid);
321
322 5 const auto sequence = m_flagMembershipSequence;
323 5 const auto generation = m_folder.folderGeneration;
324 5 const auto folderId = m_folder.currentFolderId;
325 5 const auto folder = m_folder.currentFolder;
326 5 const auto account = m_folder.accountId;
327 // compareFlagSnapshot validates this selected epoch inside its read-only
328 // transaction; the GUI-side post-result check below still detects a later
329 // cache identity change before applying any delta.
330 5 const auto epoch = m_folder.activeFolderUidValidity;
331 5 const auto databasePath = m_cache->databasePath();
332
1/1
✓ Branch 43 → 44 taken 5 times.
5 auto cancelled = std::make_shared<std::atomic_bool>(false);
333 5 m_flagMembershipCancelled = cancelled;
334
3/6
✓ Branch 45 → 46 taken 5 times.
✓ Branch 46 → 47 taken 5 times.
✗ Branch 47 → 48 not taken.
✓ Branch 47 → 49 taken 5 times.
✗ Branch 85 → 86 not taken.
✗ Branch 85 → 87 not taken.
5 auto *watcher = new QFutureWatcher<MailCache::FlagSnapshotDiff>(this);
335 5 connect(watcher, &QFutureWatcher<MailCache::FlagSnapshotDiff>::finished,
336
1/5
✓ Branch 52 → 53 taken 5 times.
✗ Branch 90 → 91 not taken.
✗ Branch 90 → 92 not taken.
✗ Branch 93 → 94 not taken.
✗ Branch 93 → 95 not taken.
10 this, [this, watcher, sequence, generation, folderId, folder, account,
337 epoch, visibleFlags] {
338
1/1
✓ Branch 2 → 3 taken 5 times.
5 auto result = watcher->result();
339
1/1
✓ Branch 3 → 4 taken 5 times.
5 watcher->deleteLater();
340
1/2
✓ Branch 5 → 6 taken 5 times.
✗ Branch 5 → 12 not taken.
5 if (sequence != m_flagMembershipSequence || generation != m_folder.folderGeneration ||
341
2/4
✓ Branch 6 → 7 taken 5 times.
✗ Branch 6 → 12 not taken.
✓ Branch 8 → 9 taken 5 times.
✗ Branch 8 → 12 not taken.
5 folderId != m_folder.currentFolderId || folder != m_folder.currentFolder ||
342
4/8
✓ Branch 4 → 5 taken 5 times.
✗ Branch 4 → 12 not taken.
✓ Branch 10 → 11 taken 5 times.
✗ Branch 10 → 12 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 5 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 5 times.
10 account != m_folder.accountId || result.cancelled)
343 ✗ return;
344 5 m_flagMembershipCancelled.reset();
345
4/7
✓ Branch 17 → 18 taken 5 times.
✗ Branch 17 → 20 not taken.
✓ Branch 18 → 19 taken 5 times.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 5 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 25 taken 5 times.
5 if (result.stale || epoch != m_cache->uidValidity(folderId)) {
346 ✗ m_deps.resyncCurrentFolder();
347 ✗ return;
348 }
349
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 40 taken 5 times.
5 if (!result.error.isEmpty())
350 ✗ qCWarning(lcController) << "Exact cached UID membership unavailable:"
351 ✗ << result.error << "- using conservative backfill";
352
353 5 const auto currentIndexed = m_visibleIndexedFlags
354
1/3
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 5 times.
✗ Branch 42 → 44 not taken.
5 ? m_visibleIndexedFlags(folderId) : QHash<qint64, quint32>{};
355 5 QList<QPair<qint64, quint32>> changed;
356
4/4
✓ Branch 44 → 45 taken 5 times.
✓ Branch 45 → 46 taken 5 times.
✓ Branch 87 → 47 taken 1 time.
✓ Branch 87 → 88 taken 5 times.
6 for (const auto &[uid, flags] : result.visibleChanged) {
357
2/5
✓ Branch 51 → 52 taken 1 time.
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 55 taken 1 time.
✗ Branch 222 → 223 not taken.
✗ Branch 222 → 224 not taken.
1 if (m_flagIntents.hasPendingFlag(MailId{account, folderId, uid})) continue;
358
1/1
✓ Branch 55 → 56 taken 1 time.
1 const int row = m_model->rowForUid(uid, folderId);
359
1/1
✓ Branch 56 → 57 taken 1 time.
1 const auto *header = m_model->headerAt(row);
360 1 const auto expected = visibleFlags.constFind(uid);
361 1 const auto indexed = currentIndexed.constFind(uid);
362
2/6
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 66 taken 1 time.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 78 not taken.
✓ Branch 66 → 67 taken 1 time.
✗ Branch 66 → 69 not taken.
2 if (expected != visibleFlags.cend() && (header || indexed != currentIndexed.cend()) &&
363
2/4
✓ Branch 68 → 69 taken 1 time.
✗ Branch 68 → 78 not taken.
✗ Branch 71 → 72 not taken.
✓ Branch 71 → 75 taken 1 time.
2 (!header || header->flags == expected.value()) &&
364
2/6
✓ Branch 61 → 62 taken 1 time.
✗ Branch 61 → 78 not taken.
✗ Branch 74 → 75 not taken.
✗ Branch 74 → 78 not taken.
✓ Branch 79 → 80 taken 1 time.
✗ Branch 79 → 83 not taken.
3 (indexed == currentIndexed.cend() || indexed.value() == expected.value()) &&
365
1/2
✓ Branch 76 → 77 taken 1 time.
✗ Branch 76 → 78 not taken.
1 expected.value() != flags)
366
1/1
✓ Branch 81 → 82 taken 1 time.
1 changed.append({uid, flags});
367 }
368
2/2
✓ Branch 89 → 90 taken 1 time.
✓ Branch 89 → 117 taken 4 times.
5 if (!changed.isEmpty()) {
369
1/1
✓ Branch 90 → 91 taken 1 time.
1 m_cache->batchUpdateFlags(folderId, changed);
370
4/4
✓ Branch 91 → 92 taken 1 time.
✓ Branch 92 → 93 taken 1 time.
✓ Branch 102 → 94 taken 1 time.
✓ Branch 102 → 103 taken 1 time.
2 for (const auto &[uid, flags] : changed) {
371
1/1
✓ Branch 97 → 98 taken 1 time.
1 m_model->updateFlags(uid, flags, folderId);
372
2/3
✓ Branch 98 → 99 taken 1 time.
✗ Branch 98 → 100 not taken.
✓ Branch 99 → 100 taken 1 time.
1 if (m_threadModel) m_threadModel->updateFlags(uid, flags, folderId);
373 }
374
7/7
✓ Branch 103 → 104 taken 1 time.
✓ Branch 107 → 108 taken 1 time.
✓ Branch 108 → 109 taken 1 time.
✓ Branch 110 → 111 taken 1 time.
✓ Branch 111 → 112 taken 1 time.
✓ Branch 115 → 105 taken 1 time.
✓ Branch 115 → 116 taken 1 time.
2 qCInfo(lcController) << "Flag sync: updated" << changed.size() << "visible flags";
375 }
376
3/4
✓ Branch 117 → 118 taken 5 times.
✓ Branch 118 → 119 taken 5 times.
✗ Branch 145 → 120 not taken.
✓ Branch 145 → 146 taken 5 times.
5 for (qint64 uid : result.visibleExpunged) {
377 ✗ if (m_model->rowForUid(uid, folderId) < 0 && !currentIndexed.contains(uid)) continue;
378 ✗ m_cache->removeHeader(folderId, uid);
379 ✗ m_model->removeByUid(uid, folderId);
380 ✗ qCInfo(lcController) << "Flag sync: removed expunged UID" << uid;
381 }
382 // Cached mail outside the loaded rows (read, starred or deleted on
383 // another client). An intent that became pending while the worker ran
384 // keeps its local state; the cache signals update any indexed rows.
385 5 QList<QPair<qint64, quint32>> offPageChanged;
386
4/4
✓ Branch 146 → 147 taken 5 times.
✓ Branch 147 → 148 taken 5 times.
✓ Branch 161 → 149 taken 5 times.
✓ Branch 161 → 162 taken 5 times.
10 for (const auto &[uid, flags] : result.offPageChanged)
387
2/5
✓ Branch 153 → 154 taken 5 times.
✓ Branch 155 → 156 taken 5 times.
✗ Branch 155 → 159 not taken.
✗ Branch 243 → 244 not taken.
✗ Branch 243 → 245 not taken.
5 if (!m_flagIntents.hasPendingFlag(MailId{account, folderId, uid}))
388
1/1
✓ Branch 157 → 158 taken 5 times.
5 offPageChanged.append({uid, flags});
389
2/2
✓ Branch 163 → 164 taken 3 times.
✓ Branch 163 → 179 taken 2 times.
5 if (!offPageChanged.isEmpty()) {
390
1/1
✓ Branch 164 → 165 taken 3 times.
3 m_cache->batchUpdateFlags(folderId, offPageChanged);
391
7/7
✓ Branch 165 → 166 taken 3 times.
✓ Branch 169 → 170 taken 3 times.
✓ Branch 170 → 171 taken 3 times.
✓ Branch 172 → 173 taken 3 times.
✓ Branch 173 → 174 taken 3 times.
✓ Branch 177 → 167 taken 3 times.
✓ Branch 177 → 178 taken 3 times.
6 qCInfo(lcController) << "Flag sync: updated" << offPageChanged.size() << "off-page flags";
392 }
393
4/4
✓ Branch 180 → 181 taken 3 times.
✓ Branch 180 → 184 taken 2 times.
✓ Branch 185 → 186 taken 3 times.
✓ Branch 185 → 200 taken 2 times.
8 if (!result.offPageExpunged.isEmpty() &&
394
2/3
✓ Branch 181 → 182 taken 3 times.
✓ Branch 182 → 183 taken 3 times.
✗ Branch 182 → 184 not taken.
3 m_cache->removeHeaders(folderId, result.offPageExpunged))
395
6/6
✓ Branch 186 → 187 taken 3 times.
✓ Branch 190 → 191 taken 3 times.
✓ Branch 191 → 192 taken 3 times.
✓ Branch 193 → 194 taken 3 times.
✓ Branch 198 → 188 taken 3 times.
✓ Branch 198 → 199 taken 3 times.
6 qCInfo(lcController) << "Flag sync: removed" << result.offPageExpunged.size()
396
1/1
✓ Branch 194 → 195 taken 3 times.
3 << "off-page expunged UIDs";
397
1/1
✓ Branch 203 → 204 taken 5 times.
5 m_deps.finishFlagSync(std::move(result.missingUids), result.error.isEmpty());
398
1/2
✓ Branch 210 → 211 taken 5 times.
✗ Branch 210 → 213 not taken.
5 });
399
1/1
✓ Branch 67 → 68 taken 5 times.
5 watcher->setFuture(QtConcurrent::run(&m_flagMembershipPool,
400
1/15
✓ Branch 66 → 67 taken 5 times.
✗ Branch 102 → 103 not taken.
✗ Branch 102 → 104 not taken.
✗ Branch 105 → 106 not taken.
✗ Branch 105 → 107 not taken.
✗ Branch 108 → 109 not taken.
✗ Branch 108 → 110 not taken.
✗ Branch 111 → 112 not taken.
✗ Branch 111 → 113 not taken.
✗ Branch 114 → 115 not taken.
✗ Branch 114 → 116 not taken.
✗ Branch 117 → 118 not taken.
✗ Branch 117 → 119 not taken.
✗ Branch 120 → 121 not taken.
✗ Branch 120 → 122 not taken.
25 [databasePath, account, folder, folderId, epoch, uidFlags,
401 10 visibleFlags = std::move(visibleFlags), pendingFlags = std::move(pendingFlags),
402 5 membership = std::move(membership), cancelled]() mutable {
403
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 8 taken 5 times.
5 if (cancelled->load()) {
404 ✗ MailCache::FlagSnapshotDiff result;
405 ✗ result.cancelled = true;
406 ✗ return result;
407 ✗ }
408
1/1
✓ Branch 8 → 9 taken 5 times.
5 MailCache reader;
409
1/1
✓ Branch 9 → 10 taken 5 times.
5 const bool opened = reader.openReadOnly(databasePath);
410 5 auto result = reader.snapshots().compareFlagSnapshot(account, folder, folderId, epoch,
411 5 uidFlags, visibleFlags, pendingFlags, *cancelled,
412
3/3
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 14 taken 3 times.
✓ Branch 16 → 17 taken 5 times.
10 membership ? &*membership : nullptr);
413
3/12
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 22 taken 5 times.
✗ Branch 18 → 19 not taken.
✗ Branch 20 → 21 not taken.
✗ Branch 20 → 22 not taken.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 5 times.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 30 taken 5 times.
✗ Branch 26 → 27 not taken.
✗ Branch 36 → 37 not taken.
✗ Branch 36 → 38 not taken.
5 if (!opened && !reader.lastError().isEmpty()) result.error = reader.lastError();
414 5 return result;
415 5 }));
416 5 }
417
418 5 void FolderFlagSync::pollFolders() {
419
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 4 times.
5 if (m_subscribedFolders.isEmpty())
420 1 return;
421
422
6/6
✓ Branch 5 → 6 taken 4 times.
✓ Branch 9 → 10 taken 4 times.
✓ Branch 10 → 11 taken 4 times.
✓ Branch 12 → 13 taken 4 times.
✓ Branch 17 → 7 taken 4 times.
✓ Branch 17 → 18 taken 4 times.
8 qCInfo(lcController) << "Polling" << m_subscribedFolders.size()
423
1/1
✓ Branch 13 → 14 taken 4 times.
4 << "subscribed folders for unread counts";
424
425 4 m_pollingIndex = 0;
426
427 // T-206: Use search connection for polling to avoid interrupting IDLE
428 4 m_deps.ensureSearchConnection();
429
5/6
✓ Branch 19 → 20 taken 4 times.
✗ Branch 19 → 25 not taken.
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 24 taken 1 time.
✓ Branch 26 → 27 taken 1 time.
✓ Branch 26 → 38 taken 3 times.
11 if (m_searchImap &&
430
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 3 times.
7 (m_searchImap->state() == ImapService::State::Authenticated ||
431 3 m_searchImap->state() == ImapService::State::Selected)) {
432 2 qCInfo(lcController) << "T-206: Polling via search connection (IDLE preserved)"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; connection selection remains measured.
433 1 pollNextFolder();
434 } else {
435 // Fallback: pause IDLE → run all STATUS commands → re-IDLE
436
1/1
✓ Branch 39 → 40 taken 3 times.
6 m_imap->executeAfterIdle([this]() { pollNextFolder(); });
437 }
438 }
439
440 5 void FolderFlagSync::triggerPollNow() {
441 5 pollFolders();
442 5 }
443
444 22 void FolderFlagSync::pollNextFolder() {
445 // Skip current folder (already live via IDLE)
446
6/6
✓ Branch 5 → 6 taken 9 times.
✓ Branch 5 → 10 taken 16 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 6 times.
✓ Branch 11 → 3 taken 3 times.
✓ Branch 11 → 12 taken 22 times.
34 while (m_pollingIndex < m_subscribedFolders.size() &&
447 9 m_subscribedFolders.at(m_pollingIndex) == m_folder.currentFolder) {
448 3 m_pollingIndex++;
449 }
450
451
2/2
✓ Branch 13 → 14 taken 16 times.
✓ Branch 13 → 28 taken 6 times.
22 if (m_pollingIndex >= m_subscribedFolders.size()) {
452 // All folders polled → restart IDLE (only needed if using main connection)
453
5/5
✓ Branch 14 → 15 taken 16 times.
✓ Branch 18 → 19 taken 16 times.
✓ Branch 19 → 20 taken 16 times.
✓ Branch 23 → 16 taken 16 times.
✓ Branch 23 → 24 taken 16 times.
32 qCInfo(lcController) << "Polling complete";
454
2/2
✓ Branch 25 → 26 taken 12 times.
✓ Branch 25 → 27 taken 4 times.
16 if (!m_imap->isIdling()) {
455 12 startIdleIfPossible();
456 }
457 16 return;
458 }
459
460 // T-206: Use search connection for STATUS when available
461
6/6
✓ Branch 28 → 29 taken 5 times.
✓ Branch 28 → 34 taken 1 time.
✓ Branch 30 → 31 taken 4 times.
✓ Branch 30 → 33 taken 1 time.
✓ Branch 35 → 36 taken 2 times.
✓ Branch 35 → 38 taken 4 times.
15 if (m_searchImap &&
462
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 34 taken 3 times.
9 (m_searchImap->state() == ImapService::State::Authenticated ||
463 4 m_searchImap->state() == ImapService::State::Selected)) {
464 2 m_searchImap->statusFolder(m_subscribedFolders.at(m_pollingIndex));
465 } else {
466 4 m_imap->statusFolder(m_subscribedFolders.at(m_pollingIndex));
467 }
468 }
469
470 27 void FolderFlagSync::onFolderStatusReceived(const StatusResult &result) {
471
6/6
✓ Branch 2 → 3 taken 27 times.
✓ Branch 6 → 7 taken 27 times.
✓ Branch 7 → 8 taken 27 times.
✓ Branch 8 → 9 taken 27 times.
✓ Branch 16 → 4 taken 27 times.
✓ Branch 16 → 17 taken 27 times.
54 qCInfo(lcController) << "STATUS" << result.folderPath
472
2/2
✓ Branch 9 → 10 taken 27 times.
✓ Branch 10 → 11 taken 27 times.
27 << "messages:" << result.messages
473
2/2
✓ Branch 11 → 12 taken 27 times.
✓ Branch 12 → 13 taken 27 times.
27 << "unseen:" << result.unseen;
474
475 // A partial STATUS without UNSEEN never publishes a badge and needs no
476 // folder lookup. For the selected folder, reuse the ID admitted at SELECT.
477 // Both responses are common under NOTIFY and otherwise hit GUI SQLite.
478
2/2
✓ Branch 17 → 18 taken 17 times.
✓ Branch 17 → 25 taken 10 times.
44 const qint64 fid = !result.hasUnseen ? -1
479
1/2
✓ Branch 20 → 21 taken 13 times.
✗ Branch 20 → 22 not taken.
30 : result.folderPath == m_folder.currentFolder && m_folder.currentFolderId > 0
480
3/3
✓ Branch 19 → 20 taken 13 times.
✓ Branch 19 → 22 taken 4 times.
✓ Branch 22 → 23 taken 4 times.
30 ? m_folder.currentFolderId : m_cache->ensureFolder(m_folder.accountId, result.folderPath);
481 27 bool publishCount = result.hasUnseen;
482
4/4
✓ Branch 26 → 27 taken 10 times.
✓ Branch 26 → 29 taken 17 times.
✓ Branch 27 → 28 taken 8 times.
✓ Branch 27 → 29 taken 2 times.
27 bool refresh = !result.hasUnseen && result.requestStartedNs == 0;
483 27 auto fence = m_flagIntents.readbackFence(fid);
484
7/7
✓ Branch 32 → 33 taken 6 times.
✓ Branch 32 → 37 taken 21 times.
✓ Branch 34 → 35 taken 6 times.
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 37 taken 5 times.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 41 taken 26 times.
27 if (fence && fence->uidValidity != m_cache->uidValidity(fid)) {
485
1/1
✓ Branch 39 → 40 taken 1 time.
1 m_flagIntents.clearReadbackFence(fid);
486 1 fence.reset();
487 }
488
6/6
✓ Branch 42 → 43 taken 5 times.
✓ Branch 42 → 46 taken 22 times.
✓ Branch 44 → 45 taken 4 times.
✓ Branch 44 → 46 taken 1 time.
✓ Branch 47 → 48 taken 4 times.
✓ Branch 47 → 55 taken 23 times.
27 if (fence && result.requestStartedNs <= fence->completedNs) {
489 4 publishCount = false;
490
2/4
✓ Branch 48 → 49 taken 4 times.
✗ Branch 48 → 52 not taken.
✓ Branch 49 → 50 taken 4 times.
✗ Branch 49 → 53 not taken.
8 refresh = refresh || (result.hasUnseen &&
491
3/3
✓ Branch 50 → 51 taken 4 times.
✓ Branch 51 → 52 taken 3 times.
✓ Branch 51 → 53 taken 1 time.
4 m_flagIntents.lastPolledUnread(result.folderPath, -1) != result.unseen);
492 }
493 // Pending Seen intent needs complete membership evidence before an aggregate
494 // can replace the optimistic count; an omitted MESSAGES field is unknown.
495 27 int unread = result.unseen;
496
5/6
✓ Branch 55 → 56 taken 13 times.
✓ Branch 55 → 61 taken 14 times.
✓ Branch 56 → 57 taken 13 times.
✗ Branch 56 → 61 not taken.
✓ Branch 62 → 63 taken 3 times.
✓ Branch 62 → 73 taken 24 times.
40 if (publishCount && fid >= 0 &&
497
3/3
✓ Branch 58 → 59 taken 13 times.
✓ Branch 59 → 60 taken 3 times.
✓ Branch 59 → 61 taken 10 times.
13 m_cache->journal().hasPendingSeenMutation(m_folder.accountId, fid)) {
498
2/2
✓ Branch 63 → 64 taken 2 times.
✓ Branch 63 → 69 taken 1 time.
3 if (!result.hasMessages) {
499 2 publishCount = false;
500
3/4
✓ Branch 64 → 65 taken 2 times.
✗ Branch 64 → 66 not taken.
✓ Branch 65 → 66 taken 1 time.
✓ Branch 65 → 67 taken 1 time.
2 refresh = refresh || result.requestStartedNs == 0;
501
2/3
✓ Branch 69 → 70 taken 1 time.
✓ Branch 70 → 71 taken 1 time.
✗ Branch 70 → 73 not taken.
1 } else if (result.messages == m_cache->headerCount(fid)) {
502
1/1
✓ Branch 71 → 72 taken 1 time.
1 unread = m_cache->unreadCount(fid);
503 }
504 }
505
2/2
✓ Branch 73 → 74 taken 12 times.
✓ Branch 73 → 96 taken 15 times.
27 if (refresh) {
506 12 ImapService *service = m_imap;
507
6/6
✓ Branch 74 → 75 taken 3 times.
✓ Branch 74 → 80 taken 9 times.
✓ Branch 76 → 77 taken 2 times.
✓ Branch 76 → 79 taken 1 time.
✓ Branch 81 → 82 taken 2 times.
✓ Branch 81 → 83 taken 10 times.
17 if (m_searchImap &&
508
2/2
✓ Branch 78 → 79 taken 1 time.
✓ Branch 78 → 80 taken 1 time.
5 (m_searchImap->state() == ImapService::State::Authenticated ||
509 2 m_searchImap->state() == ImapService::State::Selected))
510 2 service = m_searchImap;
511
1/1
✓ Branch 83 → 84 taken 12 times.
12 const QPointer<ImapService> guarded = service;
512
1/1
✓ Branch 84 → 85 taken 12 times.
12 const QPointer<FolderFlagSync> owner = this;
513
2/8
✓ Branch 89 → 90 taken 12 times.
✓ Branch 90 → 91 taken 12 times.
✗ Branch 115 → 116 not taken.
✗ Branch 115 → 117 not taken.
✗ Branch 118 → 119 not taken.
✗ Branch 118 → 120 not taken.
✗ Branch 121 → 122 not taken.
✗ Branch 121 → 123 not taken.
24 service->executeAfterIdle([guarded, owner, account = m_folder.accountId,
514 12 folder = result.folderPath] {
515
8/8
✓ Branch 3 → 4 taken 11 times.
✓ Branch 3 → 10 taken 1 time.
✓ Branch 5 → 6 taken 10 times.
✓ Branch 5 → 10 taken 1 time.
✓ Branch 8 → 9 taken 9 times.
✓ Branch 8 → 10 taken 1 time.
✓ Branch 11 → 12 taken 9 times.
✓ Branch 11 → 14 taken 3 times.
12 if (guarded && owner && owner->m_folder.accountId == account)
516 9 guarded->statusFolder(folder);
517 12 });
518 12 }
519
2/2
✓ Branch 96 → 97 taken 11 times.
✓ Branch 96 → 102 taken 16 times.
27 if (publishCount) {
520
1/1
✓ Branch 97 → 98 taken 11 times.
11 m_flagIntents.setLastPolledUnread(result.folderPath, unread);
521
2/3
✓ Branch 98 → 99 taken 11 times.
✗ Branch 98 → 100 not taken.
✓ Branch 99 → 100 taken 11 times.
11 if (fid >= 0) m_cache->storeBadge(fid, unread);
522
1/1
✓ Branch 100 → 101 taken 11 times.
11 m_deps.cachedUnreadCountChanged(result.folderPath, unread);
523
1/1
✓ Branch 101 → 102 taken 11 times.
11 m_deps.unreadCountChanged(result.folderPath, unread);
524 }
525
526 // Only advance sequential polling when NOT using NOTIFY.
527 // With NOTIFY, STATUS pushes arrive asynchronously (no polling loop).
528
3/3
✓ Branch 102 → 103 taken 27 times.
✓ Branch 103 → 104 taken 14 times.
✓ Branch 103 → 105 taken 13 times.
27 if (!m_imap->isNotifying()) {
529 14 m_pollingIndex++;
530
1/1
✓ Branch 104 → 105 taken 14 times.
14 pollNextFolder();
531 }
532 27 }
533
534 1 void FolderFlagSync::onFolderStatusFailed(const QString &folder) {
535 // F-26 (SPRINT-104): one failing STATUS (deleted elsewhere, refused)
536 // ends only ITS folder's update — the unread counts of every later
537 // folder of the round still arrive. The failing folder retries with the
538 // next round; its counts stay as they were.
539
6/6
✓ Branch 2 → 3 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 13 → 4 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
2 qCInfo(lcController) << "STATUS failed for" << folder
540
1/1
✓ Branch 9 → 10 taken 1 time.
1 << "- continuing the polling round";
541
1/2
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 1 time.
1 if (m_imap->isNotifying()) return;
542 1 ++m_pollingIndex;
543 1 pollNextFolder();
544 }
545
546 28 void FolderFlagSync::startIdleIfPossible() {
547 // T-320: Prefer NOTIFY over IDLE — watches ALL folders on one connection
548 // (only where NOTIFY push is allowed, see ImapService::usesNotifyPush).
549
5/6
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 27 times.
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 7 not taken.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 63 taken 27 times.
29 if (m_imap->usesNotifyPush() &&
550 1 m_imap->state() == ImapService::State::Selected) {
551 1 m_imap->startNotify(m_subscribedFolders);
552 1 m_pollingTimer->stop(); // NOTIFY replaces polling entirely
553
554 // T-210: Log total folder-switch duration
555
1/2
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 44 not taken.
1 if (m_folderSwitchStopwatch.isValid()) {
556 1 qint64 elapsed = m_folderSwitchStopwatch.elapsed();
557 2 qCInfo(lcController) << "T-210: Folder switch total:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; timing path remains measured.
558
4/4
✓ Branch 20 → 21 taken 1 time.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 22 → 23 taken 1 time.
✓ Branch 23 → 24 taken 1 time.
1 << m_folder.currentFolder << "→" << elapsed << "ms";
559
1/2
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 43 taken 1 time.
1 if (elapsed > 2000) {
560 ✗ qCWarning(lcController) << "T-210: SLOW folder switch (>2s):" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; threshold decision remains measured.
561 ✗ << m_folder.currentFolder << elapsed << "ms";
562 }
563 1 m_folderSwitchStopwatch.invalidate();
564 }
565
566 1 m_deps.requestFolderCountStatus();
567
1/1
✓ Branch 47 → 48 taken 1 time.
2 MailJD::Performance::FolderPerformanceTracker::instance().pushRestarted(
568
1/1
✓ Branch 48 → 49 taken 1 time.
2 m_folder.currentFolderId, m_model->rowCount(), m_folder.folderGeneration);
569
2/2
✓ Branch 50 → 51 taken 1 time.
✓ Branch 51 → 52 taken 1 time.
1 m_deps.authoritativeFolderPresented(m_folder.currentFolderId, m_model->rowCount(),
570 1 m_folder.folderGeneration);
571 auto &backgroundTracker =
572 1 MailJD::Performance::BackgroundPerformanceTracker::instance();
573
1/1
✓ Branch 58 → 59 taken 1 time.
1 backgroundTracker.backgroundPhaseFinished(QStringLiteral("protocol_wait"),
574
1/1
✓ Branch 54 → 55 taken 1 time.
1 m_model->rowCount());
575 1 backgroundTracker.backgroundFinished(true);
576
577 // T-066: Trigger initial poll-equivalent via NOTIFY STATUS pushes
578 // (server sends STATUS for all watched folders after NOTIFY SET)
579 1 m_initialPollDone = true;
580 1 return;
581 }
582
583 // Fallback: IDLE + STATUS polling
584
6/6
✓ Branch 64 → 65 taken 2 times.
✓ Branch 64 → 68 taken 25 times.
✓ Branch 66 → 67 taken 1 time.
✓ Branch 66 → 68 taken 1 time.
✓ Branch 69 → 70 taken 1 time.
✓ Branch 69 → 128 taken 26 times.
29 if (m_imap->hasIdleCapability() &&
585 2 m_imap->state() == ImapService::State::Selected) {
586 1 m_imap->startIdle();
587
588 // T-210: Log total folder-switch duration
589
1/2
✓ Branch 72 → 73 taken 1 time.
✗ Branch 72 → 104 not taken.
1 if (m_folderSwitchStopwatch.isValid()) {
590 1 qint64 elapsed = m_folderSwitchStopwatch.elapsed();
591
5/5
✓ Branch 74 → 75 taken 1 time.
✓ Branch 78 → 79 taken 1 time.
✓ Branch 79 → 80 taken 1 time.
✓ Branch 87 → 76 taken 1 time.
✓ Branch 87 → 88 taken 1 time.
2 qCInfo(lcController) << "T-210: Folder switch total:"
592
4/4
✓ Branch 80 → 81 taken 1 time.
✓ Branch 81 → 82 taken 1 time.
✓ Branch 82 → 83 taken 1 time.
✓ Branch 83 → 84 taken 1 time.
1 << m_folder.currentFolder << "→" << elapsed << "ms";
593
1/2
✗ Branch 88 → 89 not taken.
✓ Branch 88 → 103 taken 1 time.
1 if (elapsed > 2000) {
594 ✗ qCWarning(lcController) << "T-210: SLOW folder switch (>2s):" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; threshold decision remains measured.
595 ✗ << m_folder.currentFolder << elapsed << "ms";
596 }
597 1 m_folderSwitchStopwatch.invalidate();
598 }
599
600 1 m_deps.requestFolderCountStatus();
601
1/1
✓ Branch 107 → 108 taken 1 time.
2 MailJD::Performance::FolderPerformanceTracker::instance().pushRestarted(
602
1/1
✓ Branch 108 → 109 taken 1 time.
2 m_folder.currentFolderId, m_model->rowCount(), m_folder.folderGeneration);
603
2/2
✓ Branch 110 → 111 taken 1 time.
✓ Branch 111 → 112 taken 1 time.
1 m_deps.authoritativeFolderPresented(m_folder.currentFolderId, m_model->rowCount(),
604 1 m_folder.folderGeneration);
605 auto &backgroundTracker =
606 1 MailJD::Performance::BackgroundPerformanceTracker::instance();
607
1/1
✓ Branch 118 → 119 taken 1 time.
1 backgroundTracker.backgroundPhaseFinished(QStringLiteral("protocol_wait"),
608
1/1
✓ Branch 114 → 115 taken 1 time.
1 m_model->rowCount());
609 1 backgroundTracker.backgroundFinished(true);
610
611 // Start polling timer for non-IDLE folders
612
1/2
✓ Branch 123 → 124 taken 1 time.
✗ Branch 123 → 128 not taken.
1 if (!m_subscribedFolders.isEmpty()) {
613 1 m_pollingTimer->start();
614
615 // T-066: On first IDLE start, trigger an immediate poll for all
616 // subscribed folders so unread badges appear within seconds.
617
1/2
✓ Branch 125 → 126 taken 1 time.
✗ Branch 125 → 128 not taken.
1 if (!m_initialPollDone) {
618 1 m_initialPollDone = true;
619 // Small delay to let IDLE establish before interrupting it
620
1/1
✓ Branch 126 → 127 taken 1 time.
1 QTimer::singleShot(2000, this, &FolderFlagSync::pollFolders);
621 }
622 }
623 }
624 }
625