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: 81.5% 539 / 0 / 661
Functions: 80.0% 44 / 0 / 55
Branches: 69.9% 588 / 0 / 841

ui/MailListModel.cpp
Line Branch Exec Source
1 #include "MailListModel.h"
2
3 #include <QColor>
4 #include <QDataStream>
5 #include <QFont>
6 #include <QIODevice>
7 #include <QLocale>
8 #include <QLoggingCategory>
9 #include <QPointer>
10 #include <QScopedValueRollback>
11 #include <QTimer>
12 #include <QSet>
13 #include <algorithm>
14
15 #include "service/ImapResponseParser.h"
16 #include "ui/ThemeManager.h"
17
18
4/7
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 8 taken 4 times.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 1 time.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
5 Q_LOGGING_CATEGORY(lcMailModel, "mailjd.mailmodel")
19
20 1265 MailListModel::MailListModel(QObject *parent) : QAbstractTableModel(parent) {}
21
22 1600872 int MailListModel::rowCount(const QModelIndex &parent) const {
23
2/2
✓ Branch 3 → 4 taken 307 times.
✓ Branch 3 → 5 taken 1600565 times.
1600872 return parent.isValid() ? 0 : m_headers.size();
24 }
25
26 1599605 int MailListModel::columnCount(const QModelIndex &parent) const {
27
2/2
✓ Branch 3 → 4 taken 16 times.
✓ Branch 3 → 5 taken 1599589 times.
1599605 return parent.isValid() ? 0 : ColumnCount;
28 }
29
30 41536 QVariant MailListModel::data(const QModelIndex &index, int role) const {
31
3/6
✓ Branch 3 → 4 taken 41536 times.
✗ Branch 3 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 41536 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 41536 times.
41536 if (!index.isValid() || index.row() >= m_headers.size())
32 ✗ return {};
33
34 41536 const auto &h = m_headers.at(index.row());
35
36
2/2
✓ Branch 13 → 14 taken 698 times.
✓ Branch 13 → 82 taken 40838 times.
41536 if (role == Qt::DisplayRole) {
37
3/8
✓ Branch 15 → 16 taken 40 times.
✗ Branch 15 → 31 not taken.
✓ Branch 15 → 32 taken 331 times.
✗ Branch 15 → 62 not taken.
✓ Branch 15 → 73 taken 327 times.
✗ Branch 15 → 74 not taken.
✗ Branch 15 → 78 not taken.
✗ Branch 15 → 82 not taken.
698 switch (index.column()) {
38 40 case Star:
39
6/6
✓ Branch 17 → 18 taken 2 times.
✓ Branch 17 → 21 taken 38 times.
✓ Branch 26 → 27 taken 38 times.
✓ Branch 26 → 28 taken 2 times.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 28 → 30 taken 38 times.
80 return h.isFlagged() ? QStringLiteral("★") : QStringLiteral("☆");
40 ✗ case Attachment:
41 ✗ return {}; // painted by dedicated delegate
42 331 case Subject: {
43
1/3
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 331 times.
✗ Branch 34 → 36 not taken.
331 QString subj = h.subject.isEmpty() ? tr("(No Subject)") : h.subject;
44 // T-79.E5/M16: search-mode folder badge is prepended for display
45 // only — MailHeader::subject stays clean for replies/forwards.
46 331 const QString badge = m_folderBadges.value(h.folderId);
47
1/2
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 49 taken 331 times.
331 if (!badge.isEmpty())
48 ✗ subj.prepend(QStringLiteral("[%1] ").arg(badge));
49
3/3
✓ Branch 50 → 51 taken 1 time.
✓ Branch 50 → 58 taken 330 times.
✓ Branch 54 → 55 taken 1 time.
332 if (h.isAnswered()) subj.prepend(QStringLiteral("↩ "));
50 331 return subj;
51 331 }
52 ✗ case Suggestion: {
53 ✗ auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
54 ✗ return it != m_suggestionCache.constEnd() ? it->text : QString();
55 }
56 327 case From:
57 327 return h.from;
58 ✗ case Date:
59 ✗ return formatDate(h.date);
60 ✗ case Size:
61 ✗ return formatSize(h.size);
62 }
63 }
64
65 // Star column: gold for flagged, light gray for unflagged
66
5/6
✓ Branch 82 → 83 taken 22 times.
✓ Branch 82 → 86 taken 40816 times.
✓ Branch 84 → 85 taken 22 times.
✗ Branch 84 → 86 not taken.
✓ Branch 87 → 88 taken 22 times.
✓ Branch 87 → 116 taken 40816 times.
40838 if (role == Qt::ForegroundRole && index.column() == Star) {
67 22 auto &tm = ThemeManager::instance();
68
7/13
✓ Branch 90 → 91 taken 1 time.
✓ Branch 90 → 96 taken 21 times.
✓ Branch 94 → 95 taken 1 time.
✓ Branch 111 → 112 taken 1 time.
✓ Branch 111 → 113 taken 21 times.
✓ Branch 113 → 114 taken 1 time.
✓ Branch 113 → 115 taken 21 times.
✗ Branch 265 → 266 not taken.
✗ Branch 265 → 267 not taken.
✗ Branch 269 → 270 not taken.
✗ Branch 269 → 271 not taken.
✗ Branch 273 → 274 not taken.
✗ Branch 273 → 275 not taken.
66 return h.isFlagged() ? QColor(tm.color(QStringLiteral("@star_active")))
69
10/16
✓ Branch 99 → 100 taken 21 times.
✓ Branch 101 → 102 taken 22 times.
✓ Branch 103 → 104 taken 21 times.
✓ Branch 103 → 105 taken 1 time.
✓ Branch 105 → 106 taken 21 times.
✓ Branch 105 → 107 taken 1 time.
✓ Branch 107 → 108 taken 21 times.
✓ Branch 107 → 109 taken 1 time.
✓ Branch 109 → 110 taken 1 time.
✓ Branch 109 → 111 taken 21 times.
✗ Branch 253 → 254 not taken.
✗ Branch 253 → 255 not taken.
✗ Branch 257 → 258 not taken.
✗ Branch 257 → 259 not taken.
✗ Branch 261 → 262 not taken.
✗ Branch 261 → 263 not taken.
43 : QColor(tm.color(QStringLiteral("@star_inactive")));
70 }
71
72 // T-232: Suggestion column color based on confidence (shared palette)
73
2/6
✗ Branch 116 → 117 not taken.
✓ Branch 116 → 120 taken 40816 times.
✗ Branch 118 → 119 not taken.
✗ Branch 118 → 120 not taken.
✗ Branch 121 → 122 not taken.
✓ Branch 121 → 134 taken 40816 times.
40816 if (role == Qt::ForegroundRole && index.column() == Suggestion) {
74 ✗ auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
75 ✗ return ThemeManager::confidenceColor(
76 ✗ it != m_suggestionCache.constEnd() ? it->confidence : 0.0);
77 }
78
79 // Tooltip for Star column
80
5/6
✓ Branch 134 → 135 taken 20 times.
✓ Branch 134 → 138 taken 40796 times.
✓ Branch 136 → 137 taken 20 times.
✗ Branch 136 → 138 not taken.
✓ Branch 139 → 140 taken 20 times.
✓ Branch 139 → 147 taken 40796 times.
40816 if (role == Qt::ToolTipRole && index.column() == Star) {
81
2/4
✗ Branch 141 → 142 not taken.
✓ Branch 141 → 143 taken 20 times.
✗ Branch 142 → 144 not taken.
✓ Branch 143 → 144 taken 20 times.
20 return h.isFlagged() ? tr("Remove Flag") : tr("Flag");
82 }
83
84 // Tooltip for Subject column: show full subject on hover
85
2/6
✗ Branch 147 → 148 not taken.
✓ Branch 147 → 151 taken 40796 times.
✗ Branch 149 → 150 not taken.
✗ Branch 149 → 151 not taken.
✗ Branch 152 → 153 not taken.
✓ Branch 152 → 154 taken 40796 times.
40796 if (role == Qt::ToolTipRole && index.column() == Subject) {
86 ✗ return h.subject;
87 }
88
89 // Tooltip for Attachment column
90
2/6
✗ Branch 154 → 155 not taken.
✓ Branch 154 → 158 taken 40796 times.
✗ Branch 156 → 157 not taken.
✗ Branch 156 → 158 not taken.
✗ Branch 159 → 160 not taken.
✓ Branch 159 → 166 taken 40796 times.
40796 if (role == Qt::ToolTipRole && index.column() == Attachment) {
91 ✗ return h.hasAttachments ? tr("Has attachment") : QString();
92 }
93
94 // 67.B4: unread = medium weight (paired with the accent unread dot;
95 // replaces the former bold-only signal) — applied to all columns
96
6/6
✓ Branch 166 → 167 taken 20 times.
✓ Branch 166 → 170 taken 40776 times.
✓ Branch 168 → 169 taken 15 times.
✓ Branch 168 → 170 taken 5 times.
✓ Branch 171 → 172 taken 15 times.
✓ Branch 171 → 178 taken 40781 times.
40796 if (role == Qt::FontRole && !h.isSeen()) {
97
1/1
✓ Branch 172 → 173 taken 15 times.
15 QFont font;
98
1/1
✓ Branch 173 → 174 taken 15 times.
15 font.setWeight(QFont::DemiBold);
99
1/1
✓ Branch 174 → 175 taken 15 times.
15 return font;
100 15 }
101
102 // Text alignment: Star column centered
103
5/6
✓ Branch 178 → 179 taken 20 times.
✓ Branch 178 → 182 taken 40761 times.
✓ Branch 180 → 181 taken 20 times.
✗ Branch 180 → 182 not taken.
✓ Branch 183 → 184 taken 20 times.
✓ Branch 183 → 185 taken 40761 times.
40781 if (role == Qt::TextAlignmentRole && index.column() == Star) {
104 20 return static_cast<int>(Qt::AlignCenter);
105 }
106
107 // Sort role: return raw values for proper sorting
108
2/2
✓ Branch 185 → 186 taken 4 times.
✓ Branch 185 → 206 taken 40757 times.
40761 if (role == SortRole) {
109
1/5
✓ Branch 187 → 188 taken 4 times.
✗ Branch 187 → 193 not taken.
✗ Branch 187 → 194 not taken.
✗ Branch 187 → 195 not taken.
✗ Branch 187 → 205 not taken.
4 switch (index.column()) {
110 4 case Star:
111
2/2
✓ Branch 189 → 190 taken 2 times.
✓ Branch 189 → 191 taken 2 times.
4 return h.isFlagged() ? 1 : 0;
112 ✗ case Date:
113 ✗ return h.date;
114 ✗ case Size:
115 ✗ return h.size;
116 ✗ case Suggestion: {
117 ✗ auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
118 ✗ return it != m_suggestionCache.constEnd() ? it->confidence : 0.0;
119 }
120 ✗ default:
121 ✗ return data(index, Qt::DisplayRole);
122 }
123 }
124
125 // Custom FlagsRole: return the raw flags bitmask (for filtering)
126
1/2
✗ Branch 206 → 207 not taken.
✓ Branch 206 → 208 taken 40757 times.
40757 if (role == FlagsRole) {
127 ✗ return h.flags;
128 }
129
130 // HasAttachmentsRole: from MailHeader (populated by cache/body parser)
131
2/2
✓ Branch 208 → 209 taken 2 times.
✓ Branch 208 → 210 taken 40755 times.
40757 if (role == HasAttachmentsRole) {
132 2 return h.hasAttachments;
133 }
134
135 // LabelsRole: IMAP keywords parsed from FLAGS
136 // Filter internal keywords (e.g. NonJunk) at display time for cached data
137
2/2
✓ Branch 210 → 211 taken 1 time.
✓ Branch 210 → 223 taken 40754 times.
40755 if (role == LabelsRole) {
138 1 QStringList filtered;
139
2/2
✓ Branch 219 → 213 taken 2 times.
✓ Branch 219 → 220 taken 1 time.
3 for (const auto &label : h.labels) {
140
2/3
✓ Branch 214 → 215 taken 2 times.
✓ Branch 215 → 216 taken 2 times.
✗ Branch 215 → 217 not taken.
2 if (!ImapResponseParser::isInternalKeyword(label)) {
141
1/1
✓ Branch 216 → 217 taken 2 times.
2 filtered.append(label);
142 }
143 }
144 1 return filtered;
145 1 }
146
147
2/2
✓ Branch 223 → 224 taken 40589 times.
✓ Branch 223 → 228 taken 165 times.
40754 if (role == FolderBadgeRole)
148 40589 return m_folderBadges.value(h.folderId);
149
150 165 return {};
151 }
152
153 1 QVariant MailListModel::headerData(int section, Qt::Orientation orientation,
154 int role) const {
155
2/4
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
1 if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
156 ✗ return {};
157
158
1/8
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 13 not taken.
✗ Branch 5 → 20 not taken.
✗ Branch 5 → 24 not taken.
✗ Branch 5 → 31 not taken.
✗ Branch 5 → 35 not taken.
✗ Branch 5 → 39 not taken.
✗ Branch 5 → 43 not taken.
1 switch (section) {
159 1 case Star:
160 1 return QStringLiteral(""); // narrow column, no text header
161 ✗ case Attachment:
162 ✗ return QStringLiteral(""); // icon-only column
163 ✗ case Subject:
164 ✗ return tr("Subject");
165 ✗ case Suggestion:
166 ✗ return QStringLiteral("→"); // arrow symbol
167 ✗ case From:
168 ✗ return tr("From");
169 ✗ case Date:
170 ✗ return tr("Date");
171 ✗ case Size:
172 ✗ return tr("Size");
173 }
174 ✗ return {};
175 }
176
177 // Sprint 76 (T-76.B3): models do not receive LanguageChange events, so the
178 // language-switch path (MainWindow) calls this to make views re-read the
179 // translated headers via headerData().
180 ✗ void MailListModel::retranslateUi() {
181 ✗ if (columnCount() > 0)
182 ✗ emit headerDataChanged(Qt::Horizontal, 0, columnCount() - 1);
183 ✗ }
184
185 // ═══════════════════════════════════════════════════════
186 // Drag & Drop (T-102)
187 // ═══════════════════════════════════════════════════════
188
189 26 Qt::ItemFlags MailListModel::flags(const QModelIndex &index) const {
190
1/1
✓ Branch 2 → 3 taken 26 times.
26 auto defaultFlags = QAbstractTableModel::flags(index);
191
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 26 times.
26 if (index.isValid())
192 ✗ return defaultFlags | Qt::ItemIsDragEnabled;
193 26 return defaultFlags;
194 }
195
196 26 QStringList MailListModel::mimeTypes() const {
197 ✗ return {QStringLiteral("application/x-mailjd-mail-ids"),
198
2/4
✓ Branch 10 → 11 taken 52 times.
✓ Branch 10 → 12 taken 26 times.
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 19 not taken.
78 QStringLiteral("application/x-mailjd-uids")};
199 78 }
200
201 1 QMimeData *MailListModel::mimeData(const QModelIndexList &indexes) const {
202 // Collect unique mail identities from the selected rows. UIDs are only
203 // unique within a folder, so drag payloads must carry the source folderId.
204 1 QList<MailIdentity> mails;
205 1 QSet<qint64> uidSet;
206 1 QSet<QString> identitySet;
207
2/2
✓ Branch 43 → 6 taken 2 times.
✓ Branch 43 → 44 taken 1 time.
3 for (const auto &idx : indexes) {
208
3/6
✓ Branch 8 → 9 taken 2 times.
✗ Branch 8 → 12 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 2 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 2 times.
2 if (!idx.isValid() || idx.row() >= m_headers.size())
209 ✗ continue;
210 2 const auto &header = m_headers.at(idx.row());
211 const QString key =
212
2/2
✓ Branch 22 → 23 taken 2 times.
✓ Branch 24 → 25 taken 2 times.
6 QStringLiteral("%1:%2").arg(header.folderId).arg(header.uid);
213
1/2
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 2 times.
2 if (identitySet.contains(key))
214 ✗ continue;
215
1/1
✓ Branch 31 → 32 taken 2 times.
2 identitySet.insert(key);
216
1/1
✓ Branch 32 → 33 taken 2 times.
2 mails.append({QString(), header.folderId, header.uid});
217
1/1
✓ Branch 34 → 35 taken 2 times.
2 uidSet.insert(header.uid);
218 2 }
219
220 1 QByteArray identityEncoded;
221
1/1
✓ Branch 45 → 46 taken 1 time.
1 QDataStream identityStream(&identityEncoded, QIODevice::WriteOnly);
222
4/4
✓ Branch 46 → 47 taken 1 time.
✓ Branch 47 → 48 taken 1 time.
✓ Branch 55 → 49 taken 2 times.
✓ Branch 55 → 56 taken 1 time.
3 for (const auto &mail : mails)
223
3/3
✓ Branch 50 → 51 taken 2 times.
✓ Branch 51 → 52 taken 2 times.
✓ Branch 52 → 53 taken 2 times.
2 identityStream << mail.account << mail.folderId << mail.uid;
224
225 1 QByteArray encoded;
226
1/1
✓ Branch 57 → 58 taken 1 time.
1 QDataStream stream(&encoded, QIODevice::WriteOnly);
227
4/4
✓ Branch 58 → 59 taken 1 time.
✓ Branch 59 → 60 taken 1 time.
✓ Branch 65 → 61 taken 1 time.
✓ Branch 65 → 66 taken 1 time.
2 for (qint64 uid : uidSet)
228
1/1
✓ Branch 62 → 63 taken 1 time.
1 stream << uid;
229
230
3/6
✓ Branch 66 → 67 taken 1 time.
✓ Branch 67 → 68 taken 1 time.
✗ Branch 68 → 69 not taken.
✓ Branch 68 → 70 taken 1 time.
✗ Branch 122 → 123 not taken.
✗ Branch 122 → 124 not taken.
1 auto *mimeData = new QMimeData;
231
1/1
✓ Branch 73 → 74 taken 1 time.
2 mimeData->setData(QStringLiteral("application/x-mailjd-mail-ids"),
232 identityEncoded);
233
1/1
✓ Branch 79 → 80 taken 1 time.
2 mimeData->setData(QStringLiteral("application/x-mailjd-uids"), encoded);
234
235 // Set human-readable text for drag tooltip
236
1/1
✓ Branch 86 → 87 taken 1 time.
1 mimeData->setText(
237
2/2
✓ Branch 82 → 83 taken 1 time.
✓ Branch 85 → 86 taken 1 time.
3 QString("%1 Mail(s)").arg(mails.size()));
238
239 1 return mimeData;
240 1 }
241
242 ✗ Qt::DropActions MailListModel::supportedDragActions() const {
243 ✗ return Qt::MoveAction;
244 }
245
246 450 void MailListModel::setHeaders(const QList<MailHeader> &headers) {
247 450 ++m_pageGeneration;
248 450 m_pagePending = false;
249 450 m_pageFailed = false;
250 450 m_previousPageCursor = {};
251 450 beginResetModel();
252 450 m_headers = headers;
253 450 m_totalHeaderCount = headers.size();
254 450 m_pageLoader = {};
255 450 m_previousPageLoader = {};
256 450 m_pageCursor = {};
257 450 m_suggestionCache.clear(); // T-232
258 450 m_retainedBytes = 0;
259
5/5
✓ Branch 14 → 15 taken 450 times.
✓ Branch 15 → 16 taken 450 times.
✓ Branch 18 → 19 taken 29987 times.
✓ Branch 21 → 17 taken 29987 times.
✓ Branch 21 → 22 taken 450 times.
30437 for (const auto &header : m_headers) m_retainedBytes += headerCharge(header);
260 450 rebuildUidIndex();
261 450 ++m_unreadBasis; // the total is now the loaded rows' (S106-184)
262 450 endResetModel();
263 450 }
264
265 ✗ void MailListModel::setPagedHeaders(const QList<MailHeader> &firstPage,
266 int totalCount, int totalUnread,
267 PageLoader loader, PageLoader previousLoader) {
268 ✗ const auto adapt = [](PageLoader source, bool previous) -> AsyncPageLoader {
269 ✗ if (!source) return {};
270 ✗ return [source = std::move(source), previous](const PageCursor &anchor, int limit, PageCallback done) {
271 ✗ MailHeader header;
272 ✗ header.uid = anchor.uid;
273 ✗ header.date = anchor.date;
274 ✗ PageResult result;
275 ✗ result.headers = source(header, limit);
276 ✗ result.endOfData = result.headers.isEmpty();
277 ✗ if (!result.endOfData)
278 ✗ result.cursor = previous ? result.headers.constFirst() : result.headers.constLast();
279 ✗ done(std::move(result));
280 ✗ };
281 };
282 ✗ setAsyncPagedHeaders(firstPage, totalCount, totalUnread,
283 ✗ adapt(std::move(loader), false), adapt(std::move(previousLoader), true));
284 ✗ }
285
286 710 void MailListModel::setAsyncPagedHeaders(const QList<MailHeader> &firstPage,
287 int totalCount, int totalUnread, AsyncPageLoader loader, AsyncPageLoader previousLoader) {
288 710 ++m_pageGeneration;
289 710 m_pagePending = false;
290 710 m_pageFailed = false;
291
2/2
✓ Branch 3 → 4 taken 85 times.
✓ Branch 3 → 5 taken 625 times.
710 m_previousPageCursor = firstPage.isEmpty() ? PageCursor{} : PageCursor(firstPage.constFirst());
292
8/8
✓ Branch 10 → 11 taken 100 times.
✓ Branch 10 → 16 taken 610 times.
✓ Branch 12 → 13 taken 77 times.
✓ Branch 12 → 16 taken 23 times.
✓ Branch 14 → 15 taken 30 times.
✓ Branch 14 → 16 taken 47 times.
✓ Branch 17 → 18 taken 30 times.
✓ Branch 17 → 39 taken 680 times.
710 if (!m_headers.isEmpty() && !firstPage.isEmpty() && applyPageDiff(firstPage)) {
293 30 m_totalHeaderCount = std::max(totalCount, static_cast<int>(firstPage.size()));
294 30 resetUnread(totalUnread);
295 30 m_pageLoader = std::move(loader);
296 30 m_previousPageLoader = std::move(previousLoader);
297 30 m_pageCursor = PageCursor(firstPage.constLast());
298 30 m_suggestionCache.clear();
299 30 m_retainedBytes = 0;
300
5/5
✓ Branch 30 → 31 taken 30 times.
✓ Branch 31 → 32 taken 30 times.
✓ Branch 34 → 35 taken 479 times.
✓ Branch 37 → 33 taken 479 times.
✓ Branch 37 → 38 taken 30 times.
509 for (const auto &header : m_headers) m_retainedBytes += headerCharge(header);
301 30 return;
302 }
303 680 beginResetModel();
304 680 m_headers = firstPage;
305 680 m_totalHeaderCount = std::max(totalCount, static_cast<int>(firstPage.size()));
306 680 resetUnread(totalUnread);
307 680 m_pageLoader = std::move(loader);
308 680 m_previousPageLoader = std::move(previousLoader);
309
2/2
✓ Branch 49 → 50 taken 85 times.
✓ Branch 49 → 51 taken 595 times.
680 m_pageCursor = firstPage.isEmpty() ? PageCursor{} : PageCursor(firstPage.constLast());
310 680 m_suggestionCache.clear();
311 680 m_retainedBytes = 0;
312
5/5
✓ Branch 56 → 57 taken 680 times.
✓ Branch 57 → 58 taken 680 times.
✓ Branch 60 → 61 taken 19571 times.
✓ Branch 63 → 59 taken 19571 times.
✓ Branch 63 → 64 taken 680 times.
20251 for (const auto &header : m_headers) m_retainedBytes += headerCharge(header);
313 680 m_uidIndex.clear();
314
2/2
✓ Branch 71 → 66 taken 19571 times.
✓ Branch 71 → 72 taken 680 times.
20251 for (int i = 0; i < m_headers.size(); ++i) {
315 19571 const auto &header = m_headers.at(i);
316
1/1
✓ Branch 68 → 69 taken 19571 times.
19571 m_uidIndex.insert(MailKey{header.folderId, header.uid}, i);
317 }
318 680 endResetModel();
319 }
320
321 // A refreshed first page that keeps the relative order of the rows it shares
322 // with the current window (the common case after a sync: new mail on top,
323 // streamed rows beyond the page dropped) is applied as row removals, bounded
324 // single-row moves and insertions, so views keep their delegates instead of
325 // rebuilding the whole list. Returns false when a reset is needed.
326 91 bool MailListModel::applyPageDiff(const QList<MailHeader> &page) {
327 14363 const auto keyOf = [](const MailHeader &header) { return MailKey{header.folderId, header.uid}; };
328 91 QSet<MailKey> pageKeys, oldKeys;
329
1/1
✓ Branch 5 → 6 taken 91 times.
91 pageKeys.reserve(page.size());
330
3/3
✓ Branch 10 → 11 taken 1896 times.
✓ Branch 13 → 8 taken 1896 times.
✓ Branch 13 → 14 taken 91 times.
1987 for (const auto &header : page) pageKeys.insert(keyOf(header));
331 91 QList<MailKey> survivingOld;
332
2/2
✓ Branch 28 → 17 taken 4151 times.
✓ Branch 28 → 29 taken 91 times.
4242 for (const auto &header : std::as_const(m_headers)) {
333
1/1
✓ Branch 19 → 20 taken 4151 times.
4151 oldKeys.insert(keyOf(header));
334
3/3
✓ Branch 22 → 23 taken 505 times.
✓ Branch 22 → 26 taken 3646 times.
✓ Branch 24 → 25 taken 505 times.
4151 if (pageKeys.contains(keyOf(header))) survivingOld.append(keyOf(header));
335 }
336
2/2
✓ Branch 30 → 31 taken 47 times.
✓ Branch 30 → 32 taken 44 times.
91 if (survivingOld.isEmpty()) return false;
337 44 QList<MailKey> survivingNew;
338
2/2
✓ Branch 43 → 34 taken 524 times.
✓ Branch 43 → 44 taken 44 times.
568 for (const auto &header : page)
339
3/3
✓ Branch 37 → 38 taken 505 times.
✓ Branch 37 → 41 taken 19 times.
✓ Branch 39 → 40 taken 505 times.
524 if (oldKeys.contains(keyOf(header))) survivingNew.append(keyOf(header));
340 44 int moves = 0;
341
2/2
✓ Branch 57 → 45 taken 505 times.
✓ Branch 57 → 58 taken 44 times.
549 for (int at = 0; at < survivingNew.size(); ++at) {
342
2/2
✓ Branch 48 → 49 taken 496 times.
✓ Branch 48 → 50 taken 9 times.
505 if (survivingOld.at(at) == survivingNew.at(at)) continue;
343
1/1
✓ Branch 52 → 53 taken 9 times.
9 survivingOld.move(survivingOld.indexOf(survivingNew.at(at), at + 1), at);
344
1/2
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 55 taken 9 times.
9 if (++moves > MaximumPageDiffMoves) return false;
345 }
346
2/2
✓ Branch 80 → 60 taken 516 times.
✓ Branch 80 → 81 taken 44 times.
560 for (int row = m_headers.size() - 1; row >= 0;) {
347
2/2
✓ Branch 63 → 64 taken 505 times.
✓ Branch 63 → 65 taken 11 times.
516 if (pageKeys.contains(keyOf(m_headers.at(row)))) { --row; continue; }
348 11 const int last = row;
349
6/6
✓ Branch 67 → 68 taken 1078 times.
✓ Branch 67 → 73 taken 6 times.
✓ Branch 71 → 72 taken 1073 times.
✓ Branch 71 → 73 taken 5 times.
✓ Branch 74 → 66 taken 1073 times.
✓ Branch 74 → 75 taken 11 times.
1084 while (row >= 0 && !pageKeys.contains(keyOf(m_headers.at(row)))) --row;
350
1/1
✓ Branch 76 → 77 taken 11 times.
11 beginRemoveRows({}, row + 1, last);
351
1/1
✓ Branch 77 → 78 taken 11 times.
11 m_headers.remove(row + 1, last - row);
352
1/1
✓ Branch 78 → 79 taken 11 times.
11 rebuildUidIndex();
353
1/1
✓ Branch 79 → 80 taken 11 times.
11 endRemoveRows();
354 }
355
2/2
✓ Branch 98 → 82 taken 505 times.
✓ Branch 98 → 99 taken 44 times.
549 for (int at = 0; at < survivingNew.size(); ++at) {
356
2/2
✓ Branch 86 → 87 taken 496 times.
✓ Branch 86 → 88 taken 9 times.
505 if (keyOf(m_headers.at(at)) == survivingNew.at(at)) continue;
357 9 const int from = m_uidIndex.value(survivingNew.at(at), -1);
358
1/1
✓ Branch 92 → 93 taken 9 times.
9 beginMoveRows({}, from, from, {}, at);
359
1/1
✓ Branch 93 → 94 taken 9 times.
9 m_headers.move(from, at);
360
1/1
✓ Branch 94 → 95 taken 9 times.
9 rebuildUidIndex();
361
1/1
✓ Branch 95 → 96 taken 9 times.
9 endMoveRows();
362 }
363
2/2
✓ Branch 127 → 100 taken 517 times.
✓ Branch 127 → 128 taken 44 times.
561 for (int at = 0; at < page.size();) {
364
2/2
✓ Branch 103 → 104 taken 505 times.
✓ Branch 103 → 105 taken 12 times.
517 if (oldKeys.contains(keyOf(page.at(at)))) { ++at; continue; }
365 12 int end = at;
366
6/6
✓ Branch 108 → 109 taken 15 times.
✓ Branch 108 → 114 taken 4 times.
✓ Branch 112 → 113 taken 7 times.
✓ Branch 112 → 114 taken 8 times.
✓ Branch 115 → 106 taken 7 times.
✓ Branch 115 → 116 taken 12 times.
19 while (end + 1 < page.size() && !oldKeys.contains(keyOf(page.at(end + 1)))) ++end;
367
1/1
✓ Branch 117 → 118 taken 12 times.
12 beginInsertRows({}, at, end);
368
3/3
✓ Branch 120 → 121 taken 19 times.
✓ Branch 122 → 119 taken 19 times.
✓ Branch 122 → 123 taken 12 times.
31 for (int row = at; row <= end; ++row) m_headers.insert(row, page.at(row));
369
1/1
✓ Branch 123 → 124 taken 12 times.
12 rebuildUidIndex();
370
1/1
✓ Branch 124 → 125 taken 12 times.
12 endInsertRows();
371 12 at = end + 1;
372 }
373 // Identities now match the page; publish its (possibly changed) content.
374 // The caller restores the authoritative unread total afterwards.
375 44 m_headers = page;
376
1/2
✓ Branch 130 → 131 taken 44 times.
✗ Branch 130 → 140 not taken.
44 if (!m_headers.isEmpty())
377
3/3
✓ Branch 134 → 135 taken 44 times.
✓ Branch 136 → 137 taken 44 times.
✓ Branch 137 → 138 taken 44 times.
44 emit dataChanged(index(0, 0), index(m_headers.size() - 1, ColumnCount - 1));
378 44 return true;
379 91 }
380
381 35 void MailListModel::setAuthoritativeCounts(int totalCount, int totalUnread) {
382 35 m_totalHeaderCount = std::max(totalCount, static_cast<int>(m_headers.size()));
383 35 resetUnread(std::max(0, totalUnread));
384 35 }
385
386 5256 bool MailListModel::canFetchMore(const QModelIndex &parent) const {
387
6/8
✓ Branch 3 → 4 taken 5256 times.
✗ Branch 3 → 11 not taken.
✓ Branch 5 → 6 taken 2878 times.
✓ Branch 5 → 11 taken 2378 times.
✓ Branch 6 → 7 taken 2864 times.
✓ Branch 6 → 11 taken 14 times.
✓ Branch 7 → 8 taken 2864 times.
✗ Branch 7 → 11 not taken.
8120 return !parent.isValid() && m_pageLoader && !m_pagePending && !m_pageFailed &&
388
2/2
✓ Branch 9 → 10 taken 283 times.
✓ Branch 9 → 11 taken 2581 times.
8120 m_headers.size() < m_totalHeaderCount;
389 }
390
391 161 void MailListModel::fetchMore(const QModelIndex &parent) {
392
2/3
✓ Branch 3 → 4 taken 161 times.
✗ Branch 3 → 8 not taken.
✓ Branch 5 → 6 taken 161 times.
161 if (!parent.isValid()) requestPage(false);
393 161 }
394
395 19 bool MailListModel::canFetchPrevious() const {
396 // A seek leaves an empty window with a valid anchor cursor: it is still
397 // fetchable (T-87.D1 random seek).
398
3/4
✓ Branch 4 → 5 taken 18 times.
✓ Branch 4 → 12 taken 1 time.
✓ Branch 5 → 6 taken 18 times.
✗ Branch 5 → 12 not taken.
38 return m_previousPageLoader && !m_pagePending && !m_pageFailed &&
399
2/6
✓ Branch 3 → 4 taken 19 times.
✗ Branch 3 → 12 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 18 times.
✗ Branch 8 → 9 not taken.
✗ Branch 8 → 12 not taken.
56 (!m_headers.isEmpty() || m_pageCursor.uid > 0) &&
400
2/2
✓ Branch 10 → 11 taken 15 times.
✓ Branch 10 → 12 taken 3 times.
37 m_headers.size() < m_totalHeaderCount;
401 }
402
403 5 void MailListModel::fetchPrevious(std::function<void()> completed) {
404
1/1
✓ Branch 4 → 5 taken 5 times.
5 requestPage(true, std::move(completed));
405 5 }
406
407 ✗ void MailListModel::seekToUid(qint64 uid, std::function<void()> completed) {
408 // A seek needs both directions so the anchored window can grow around the
409 // target; a uid-only anchor (invalid date) uses the keyset's uid fallback.
410 ✗ if (!m_pageLoader || !m_previousPageLoader || m_pagePending || uid <= 0)
411 ✗ return;
412 ✗ MailHeader anchor;
413 ✗ anchor.uid = uid;
414 ✗ anchor.folderId = m_headers.isEmpty() ? 0 : m_headers.constFirst().folderId;
415 ✗ ++m_pageGeneration; // invalidate any in-flight page
416 ✗ m_pagePending = false;
417 ✗ m_pageFailed = false;
418 ✗ m_previousPageCursor = PageCursor(anchor);
419 ✗ m_pageCursor = PageCursor(anchor);
420 ✗ m_pageCursor.includeAnchor = true; // seek target must appear in the list
421 ✗ beginResetModel();
422 ✗ m_headers.clear();
423 ✗ const int unread = m_unreadCount;
424 ✗ rebuildUidIndex();
425 ✗ m_unreadCount = unread;
426 ✗ m_retainedBytes = 0;
427 ✗ endResetModel();
428 ✗ requestPage(true, [this, completed = std::move(completed)]() mutable {
429 ✗ requestPage(false, std::move(completed));
430 ✗ });
431 ✗ }
432
433 166 void MailListModel::requestPage(bool previous, std::function<void()> completed) {
434
3/4
✓ Branch 2 → 3 taken 47 times.
✓ Branch 2 → 4 taken 119 times.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 47 times.
166 const bool hasAnchor = m_pageCursor.uid > 0 || m_previousPageCursor.uid > 0;
435
5/6
✓ Branch 7 → 8 taken 38 times.
✓ Branch 7 → 10 taken 128 times.
✓ Branch 8 → 9 taken 38 times.
✗ Branch 8 → 10 not taken.
✓ Branch 11 → 12 taken 38 times.
✓ Branch 11 → 13 taken 128 times.
189 if (m_headers.isEmpty() && !hasAnchor) return;
436
5/5
✓ Branch 13 → 14 taken 5 times.
✓ Branch 13 → 16 taken 123 times.
✓ Branch 17 → 18 taken 123 times.
✓ Branch 19 → 20 taken 23 times.
✓ Branch 19 → 21 taken 105 times.
128 if (!(previous ? canFetchPrevious() : canFetchMore({}))) return;
437 105 const auto generation = m_pageGeneration;
438
2/2
✓ Branch 21 → 22 taken 5 times.
✓ Branch 21 → 23 taken 100 times.
105 const auto anchor = previous ? m_previousPageCursor : m_pageCursor;
439
3/3
✓ Branch 25 → 26 taken 5 times.
✓ Branch 25 → 27 taken 100 times.
✓ Branch 28 → 29 taken 105 times.
105 const auto loader = previous ? m_previousPageLoader : m_pageLoader;
440 105 m_pagePending = true;
441
1/1
✓ Branch 29 → 30 taken 105 times.
105 const QPointer<MailListModel> guard(this);
442
2/6
✓ Branch 34 → 35 taken 105 times.
✓ Branch 35 → 36 taken 105 times.
✗ Branch 49 → 50 not taken.
✗ Branch 49 → 51 not taken.
✗ Branch 52 → 53 not taken.
✗ Branch 52 → 54 not taken.
105 loader(anchor, PageSize, [guard, generation, previous, anchor, completed = std::move(completed)]
443 (PageResult result) mutable {
444
3/6
✓ Branch 3 → 4 taken 96 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 96 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 96 times.
105 if (!guard || generation != guard->m_pageGeneration) return;
445 96 auto &page = result.headers;
446 96 auto &error = result.error;
447 96 const auto cursor = result.cursor;
448
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 20 taken 96 times.
96 if (page.size() > PageSize) error = QStringLiteral("Cached page exceeds its row budget");
449
6/6
✓ Branch 20 → 21 taken 88 times.
✓ Branch 20 → 26 taken 8 times.
✓ Branch 21 → 22 taken 87 times.
✓ Branch 21 → 25 taken 1 time.
✓ Branch 27 → 28 taken 1 time.
✓ Branch 27 → 35 taken 95 times.
183 if (!result.endOfData && (cursor.uid <= 0 ||
450
1/5
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 26 taken 87 times.
✗ Branch 23 → 24 not taken.
✗ Branch 24 → 25 not taken.
✗ Branch 24 → 26 not taken.
87 (cursor.uid == anchor.uid && cursor.date == anchor.date)))
451 1 error = QStringLiteral("Cached page did not advance its anchor");
452
2/2
✓ Branch 36 → 37 taken 1 time.
✓ Branch 36 → 50 taken 95 times.
96 if (!error.isEmpty()) {
453 1 guard->m_pagePending = false;
454 1 guard->m_pageFailed = true;
455
1/1
✓ Branch 40 → 41 taken 1 time.
1 emit guard->pageLoadFailed(error);
456
3/7
✓ Branch 42 → 43 taken 1 time.
✗ Branch 42 → 46 not taken.
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 1 time.
✗ Branch 47 → 48 not taken.
✓ Branch 47 → 49 taken 1 time.
✗ Branch 48 → 49 not taken.
1 if (guard && completed) completed();
457 1 return;
458 }
459
5/6
✓ Branch 50 → 51 taken 8 times.
✓ Branch 50 → 54 taken 87 times.
✓ Branch 52 → 53 taken 8 times.
✗ Branch 52 → 54 not taken.
✓ Branch 55 → 56 taken 8 times.
✓ Branch 55 → 73 taken 87 times.
95 if (result.endOfData && page.isEmpty()) {
460
1/2
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 59 taken 8 times.
8 if (previous) guard->m_previousPageLoader = {};
461 else {
462 8 guard->m_pageLoader = {};
463
1/2
✗ Branch 63 → 64 not taken.
✓ Branch 63 → 68 taken 8 times.
8 if (!guard->m_previousPageLoader) guard->m_totalHeaderCount = guard->m_headers.size();
464 }
465 8 guard->m_pagePending = false;
466
1/3
✗ Branch 70 → 71 not taken.
✓ Branch 70 → 72 taken 8 times.
✗ Branch 71 → 72 not taken.
8 if (completed) completed();
467 8 return;
468 }
469
2/2
✓ Branch 73 → 74 taken 5 times.
✓ Branch 73 → 76 taken 82 times.
87 if (previous) guard->m_previousPageCursor = cursor;
470 82 else guard->m_pageCursor = cursor;
471 87 QList<MailHeader> fresh;
472 87 QSet<MailKey> seen;
473
4/4
✓ Branch 79 → 80 taken 87 times.
✓ Branch 80 → 81 taken 87 times.
✓ Branch 97 → 82 taken 15233 times.
✓ Branch 97 → 98 taken 87 times.
15320 for (auto &header : page) {
474 15233 const MailKey key{header.folderId, header.uid};
475
3/6
✓ Branch 86 → 87 taken 15233 times.
✗ Branch 86 → 90 not taken.
✓ Branch 88 → 89 taken 15233 times.
✗ Branch 88 → 90 not taken.
✓ Branch 91 → 92 taken 15233 times.
✗ Branch 91 → 95 not taken.
15233 if (!guard->m_uidIndex.contains(key) && !seen.contains(key)) {
476
2/2
✓ Branch 92 → 93 taken 15233 times.
✓ Branch 94 → 95 taken 15233 times.
15233 seen.insert(key); fresh.append(std::move(header));
477 }
478 }
479
1/2
✗ Branch 99 → 100 not taken.
✓ Branch 99 → 108 taken 87 times.
87 if (fresh.isEmpty()) {
480 ✗ guard->m_pagePending = false;
481 ✗ QTimer::singleShot(0, guard, [guard, generation, previous, completed = std::move(completed)]() mutable {
482 ✗ if (!guard || generation != guard->m_pageGeneration) return;
483 ✗ if (previous ? guard->canFetchPrevious() : guard->canFetchMore({}))
484 ✗ guard->requestPage(previous, std::move(completed));
485 ✗ else if (completed) completed();
486 });
487 ✗ return;
488 }
489
2/2
✓ Branch 108 → 109 taken 5 times.
✓ Branch 108 → 110 taken 82 times.
87 const int first = previous ? 0 : int(guard->m_headers.size());
490
1/1
✓ Branch 116 → 117 taken 87 times.
87 guard->beginInsertRows({}, first, first + fresh.size() - 1);
491
2/2
✓ Branch 117 → 118 taken 5 times.
✓ Branch 117 → 137 taken 82 times.
87 if (previous) {
492
1/1
✓ Branch 119 → 120 taken 5 times.
5 guard->m_headers = fresh + guard->m_headers;
493
5/5
✓ Branch 123 → 124 taken 5 times.
✓ Branch 124 → 125 taken 5 times.
✓ Branch 127 → 128 taken 900 times.
✓ Branch 131 → 126 taken 900 times.
✓ Branch 131 → 132 taken 5 times.
905 for (const auto &header : fresh) guard->m_retainedBytes += headerCharge(header);
494 5 const int unread = guard->m_unreadCount;
495
1/1
✓ Branch 134 → 135 taken 5 times.
5 guard->rebuildUidIndex();
496 5 guard->m_unreadCount = unread;
497 } else {
498
1/1
✓ Branch 138 → 139 taken 82 times.
82 guard->m_headers.append(fresh);
499
5/5
✓ Branch 139 → 140 taken 82 times.
✓ Branch 140 → 141 taken 82 times.
✓ Branch 143 → 144 taken 14333 times.
✓ Branch 147 → 142 taken 14333 times.
✓ Branch 147 → 148 taken 82 times.
14415 for (const auto &header : fresh) guard->m_retainedBytes += headerCharge(header);
500
2/2
✓ Branch 156 → 149 taken 14333 times.
✓ Branch 156 → 157 taken 82 times.
14415 for (int i = 0; i < fresh.size(); ++i)
501
3/3
✓ Branch 150 → 151 taken 14333 times.
✓ Branch 151 → 152 taken 14333 times.
✓ Branch 153 → 154 taken 14333 times.
14333 guard->m_uidIndex.insert({fresh[i].folderId, fresh[i].uid}, first + i);
502 }
503 87 guard->m_pagePending = false;
504
1/1
✓ Branch 159 → 160 taken 87 times.
87 guard->endInsertRows();
505 // T-87.D1: slide the retained window so paging cannot grow the resident
506 // row set without bound. Only the side opposite the fetch direction is
507 // evicted, keeping the retained range contiguous and both cursors valid.
508
1/1
✓ Branch 161 → 162 taken 87 times.
87 guard->evictDistantRetainedRows(!previous);
509
7/9
✓ Branch 163 → 164 taken 87 times.
✗ Branch 163 → 169 not taken.
✓ Branch 165 → 166 taken 87 times.
✗ Branch 165 → 169 not taken.
✓ Branch 167 → 168 taken 2 times.
✓ Branch 167 → 169 taken 85 times.
✓ Branch 170 → 171 taken 2 times.
✓ Branch 170 → 172 taken 85 times.
✓ Branch 171 → 172 taken 2 times.
87 if (guard && generation == guard->m_pageGeneration && completed) completed();
510
4/6
✓ Branch 174 → 175 taken 87 times.
✗ Branch 174 → 176 not taken.
✓ Branch 178 → 179 taken 87 times.
✗ Branch 178 → 180 not taken.
✓ Branch 182 → 183 taken 87 times.
✓ Branch 182 → 185 taken 9 times.
96 });
511 105 }
512
513 // Conservative decoded-size charge mirroring the writer's header-batch
514 // budget: decoded text at UTF-16 width plus container entries.
515 68451 qint64 MailListModel::headerCharge(const MailHeader &header) {
516 68451 qint64 bytes = 256 + 2LL * (header.subject.size() + header.from.size() +
517 68451 header.to.size() + header.messageId.size() +
518 68451 header.inReplyTo.size());
519
2/2
✓ Branch 20 → 12 taken 205353 times.
✓ Branch 20 → 21 taken 68451 times.
479157 for (const auto &list : {header.labels, header.references, header.inReplyToIds})
520
2/2
✓ Branch 18 → 14 taken 4929 times.
✓ Branch 18 → 19 taken 205353 times.
210282 for (const auto &value : list)
521
2/2
✓ Branch 22 → 23 taken 205353 times.
✓ Branch 22 → 26 taken 68451 times.
278733 bytes += 32 + 2LL * value.size();
522 68451 return bytes;
523 }
524
525 87 void MailListModel::evictDistantRetainedRows(bool fetchedForward) {
526 // Never evict a side that cannot be re-fetched afterwards: an exhausted
527 // (cleared) loader would strand the window's edge permanently.
528
4/6
✓ Branch 2 → 3 taken 82 times.
✓ Branch 2 → 6 taken 5 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 82 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 87 times.
169 if (fetchedForward && !m_previousPageLoader) return;
529
6/6
✓ Branch 9 → 10 taken 5 times.
✓ Branch 9 → 13 taken 82 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 13 taken 3 times.
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 16 taken 85 times.
87 if (!fetchedForward && !m_pageLoader) return;
530 85 qint64 freed = 0;
531 85 int evict = 0;
532 85 const int rows = m_headers.size();
533
2/2
✓ Branch 23 → 24 taken 1015 times.
✓ Branch 23 → 26 taken 70 times.
1085 while (rows - evict > MinimumRetainedRows &&
534
3/4
✓ Branch 24 → 18 taken 1000 times.
✓ Branch 24 → 25 taken 15 times.
✗ Branch 25 → 18 not taken.
✓ Branch 25 → 26 taken 15 times.
1015 (rows - evict > MaximumRetainedRows || m_retainedBytes - freed > MaximumRetainedBytes)) {
535
3/3
✓ Branch 18 → 19 taken 600 times.
✓ Branch 18 → 20 taken 400 times.
✓ Branch 21 → 22 taken 1000 times.
1400 freed += headerCharge(fetchedForward ? m_headers.at(evict)
536 400 : m_headers.at(rows - 1 - evict));
537 1000 ++evict;
538 }
539
2/2
✓ Branch 26 → 27 taken 80 times.
✓ Branch 26 → 28 taken 5 times.
85 if (evict <= 0) return;
540 5 const QScopedValueRollback<bool> eviction(m_evictingRetainedRows, true);
541 5 const int unread = m_unreadCount; // folder totals stay authoritative in paged mode
542
2/2
✓ Branch 29 → 30 taken 3 times.
✓ Branch 29 → 45 taken 2 times.
5 if (fetchedForward) {
543
1/1
✓ Branch 30 → 31 taken 3 times.
3 emit windowSlid(0, evict - 1);
544
1/1
✓ Branch 32 → 33 taken 3 times.
3 beginRemoveRows({}, 0, evict - 1);
545
4/4
✓ Branch 33 → 34 taken 3 times.
✓ Branch 34 → 35 taken 3 times.
✓ Branch 36 → 37 taken 3 times.
✓ Branch 38 → 39 taken 3 times.
3 m_headers.erase(m_headers.begin(), m_headers.begin() + evict);
546 3 m_retainedBytes -= freed;
547
1/1
✓ Branch 39 → 40 taken 3 times.
3 rebuildUidIndex();
548 3 m_unreadCount = unread;
549 3 m_previousPageCursor = PageCursor(m_headers.constFirst());
550
1/1
✓ Branch 44 → 64 taken 3 times.
3 endRemoveRows();
551 } else {
552
1/1
✓ Branch 47 → 48 taken 2 times.
2 emit windowSlid(m_headers.size() - evict, m_headers.size() - 1);
553
1/1
✓ Branch 51 → 52 taken 2 times.
2 beginRemoveRows({}, m_headers.size() - evict, m_headers.size() - 1);
554
4/4
✓ Branch 52 → 53 taken 2 times.
✓ Branch 54 → 55 taken 2 times.
✓ Branch 55 → 56 taken 2 times.
✓ Branch 57 → 58 taken 2 times.
2 m_headers.erase(m_headers.end() - evict, m_headers.end());
555 2 m_retainedBytes -= freed;
556
1/1
✓ Branch 58 → 59 taken 2 times.
2 rebuildUidIndex();
557 2 m_unreadCount = unread;
558 2 m_pageCursor = PageCursor(m_headers.constLast());
559
1/1
✓ Branch 63 → 64 taken 2 times.
2 endRemoveRows();
560 }
561
1/2
✗ Branch 64 → 65 not taken.
✓ Branch 64 → 81 taken 5 times.
5 if (m_retainedBytes > MaximumRetainedBytes)
562 ✗ qCWarning(lcMailModel) << "Retained window exceeds its byte budget at the liveness floor:"
563 ✗ << m_retainedBytes << "bytes in" << m_headers.size() << "rows";
564 else
565
7/7
✓ Branch 81 → 82 taken 5 times.
✓ Branch 85 → 86 taken 5 times.
✓ Branch 86 → 87 taken 5 times.
✓ Branch 87 → 88 taken 5 times.
✓ Branch 88 → 89 taken 5 times.
✓ Branch 97 → 83 taken 5 times.
✓ Branch 97 → 98 taken 5 times.
10 qCInfo(lcMailModel) << "Retained window slid by" << evict << "rows;"
566
4/4
✓ Branch 90 → 91 taken 5 times.
✓ Branch 91 → 92 taken 5 times.
✓ Branch 92 → 93 taken 5 times.
✓ Branch 93 → 94 taken 5 times.
5 << m_headers.size() << "rows," << m_retainedBytes << "bytes retained";
567 5 }
568
569 33 void MailListModel::appendHeaders(const QList<MailHeader> &headers) {
570
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 33 times.
33 if (headers.isEmpty())
571 ✗ return;
572
573
1/1
✓ Branch 9 → 10 taken 33 times.
33 beginInsertRows({}, m_headers.size(), m_headers.size() + headers.size() - 1);
574 33 int baseRow = m_headers.size();
575 33 m_headers.append(headers);
576 33 m_totalHeaderCount += headers.size();
577
3/3
✓ Branch 16 → 17 taken 1705 times.
✓ Branch 19 → 15 taken 1705 times.
✓ Branch 19 → 20 taken 33 times.
1738 for (const auto &header : headers) m_retainedBytes += headerCharge(header);
578 // T-514: extend composite UID index for new rows
579
2/2
✓ Branch 26 → 21 taken 1705 times.
✓ Branch 26 → 27 taken 33 times.
1738 for (int i = 0; i < headers.size(); ++i) {
580 1705 const auto &hdr = headers.at(i);
581
1/1
✓ Branch 23 → 24 taken 1705 times.
1705 m_uidIndex.insert(MailKey{hdr.folderId, hdr.uid}, baseRow + i);
582 }
583 // T-116: update unread count for new headers
584
2/2
✓ Branch 35 → 29 taken 1705 times.
✓ Branch 35 → 36 taken 33 times.
1738 for (const auto &h : headers) {
585
2/2
✓ Branch 31 → 32 taken 324 times.
✓ Branch 31 → 33 taken 1381 times.
1705 if (!h.isSeen())
586 324 ++m_unreadCount;
587 }
588 33 endInsertRows();
589 }
590
591 8 int MailListModel::appendStreamedHeaders(const QList<MailHeader> &headers) {
592 8 const int rowBudget = qMax(0, MaximumRetainedRows - int(m_headers.size()));
593 8 int accepted = 0;
594 8 qint64 bytes = m_retainedBytes;
595
2/2
✓ Branch 19 → 6 taken 16 times.
✓ Branch 19 → 20 taken 8 times.
24 for (const auto &header : headers) {
596
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 16 times.
16 if (accepted >= rowBudget) break;
597
1/1
✓ Branch 9 → 10 taken 16 times.
16 const qint64 charge = headerCharge(header);
598 // Keep the same liveness floor as page eviction: unusually large
599 // headers must not leave a short, still-visible list empty.
600
2/4
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 14 taken 16 times.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 16 times.
16 if (bytes + charge > MaximumRetainedBytes &&
601 ✗ m_headers.size() + accepted >= MinimumRetainedRows) break;
602 16 bytes += charge;
603 16 ++accepted;
604 }
605
3/4
✓ Branch 20 → 21 taken 8 times.
✗ Branch 20 → 25 not taken.
✓ Branch 21 → 22 taken 8 times.
✓ Branch 22 → 23 taken 8 times.
8 if (accepted > 0) insertStreamedHeaders(headers.first(accepted));
606 8 return accepted;
607 }
608
609 // Streamed batches arrive in fetch order (newest chunk first, ascending UIDs
610 // inside a chunk). Inserting each run at its page-order position keeps the
611 // window sorted like a cached page, so the rows neither reorder at the end of
612 // a sync nor force a model reset when the first page is refreshed.
613 8 void MailListModel::insertStreamedHeaders(QList<MailHeader> batch) {
614 8 markKeptWindowChanged();
615 // The cache page order of the current sort (MailSort mirrors the SQL).
616 45 const auto pageOrderBefore = [this](const MailHeader &left, const MailHeader &right) {
617 45 return MailSort::listedBefore(left, right, m_sort);
618 8 };
619
3/3
✓ Branch 3 → 4 taken 8 times.
✓ Branch 4 → 5 taken 8 times.
✓ Branch 5 → 6 taken 8 times.
8 std::stable_sort(batch.begin(), batch.end(), pageOrderBefore);
620 8 qsizetype from = 0;
621
2/2
✓ Branch 47 → 7 taken 10 times.
✓ Branch 47 → 48 taken 8 times.
18 while (from < batch.size()) {
622
1/1
✓ Branch 11 → 12 taken 10 times.
10 const qsizetype position = std::upper_bound(m_headers.cbegin(), m_headers.cend(),
623 20 batch.at(from), pageOrderBefore) - m_headers.cbegin();
624 10 qsizetype to = from + 1;
625
6/6
✓ Branch 16 → 17 taken 8 times.
✓ Branch 16 → 24 taken 8 times.
✓ Branch 18 → 19 taken 5 times.
✓ Branch 18 → 23 taken 3 times.
✓ Branch 25 → 14 taken 6 times.
✓ Branch 25 → 26 taken 10 times.
29 while (to < batch.size() &&
626
3/3
✓ Branch 21 → 22 taken 5 times.
✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 24 taken 2 times.
13 (position >= m_headers.size() || pageOrderBefore(batch.at(to), m_headers.at(position))))
627 6 ++to;
628 10 const qsizetype count = to - from;
629
1/1
✓ Branch 27 → 28 taken 10 times.
10 beginInsertRows({}, int(position), int(position + count - 1));
630
2/2
✓ Branch 36 → 29 taken 16 times.
✓ Branch 36 → 37 taken 10 times.
26 for (qsizetype i = 0; i < count; ++i) {
631 16 const auto &header = batch.at(from + i);
632
1/1
✓ Branch 30 → 31 taken 16 times.
16 m_headers.insert(position + i, header);
633
1/1
✓ Branch 31 → 32 taken 16 times.
16 m_retainedBytes += headerCharge(header);
634
1/2
✓ Branch 33 → 34 taken 16 times.
✗ Branch 33 → 35 not taken.
16 if (!header.isSeen()) ++m_unreadCount;
635 }
636 10 m_totalHeaderCount += int(count);
637
2/2
✓ Branch 43 → 38 taken 56 times.
✓ Branch 43 → 44 taken 10 times.
66 for (qsizetype row = position; row < m_headers.size(); ++row) {
638 56 const auto &header = m_headers.at(row);
639
1/1
✓ Branch 40 → 41 taken 56 times.
56 m_uidIndex.insert(MailKey{header.folderId, header.uid}, int(row));
640 }
641
1/1
✓ Branch 44 → 45 taken 10 times.
10 endInsertRows();
642 10 from = to;
643 }
644 8 }
645
646 32 void MailListModel::keepUnfilteredWindow() {
647
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 32 times.
32 if (m_unfilteredWindow) return;
648
0/4
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 19 not taken.
64 m_unfilteredWindow = WindowState{m_headers, m_totalHeaderCount, m_unreadCount,
649
2/2
✓ Branch 6 → 7 taken 32 times.
✓ Branch 7 → 8 taken 32 times.
32 m_pageLoader, m_previousPageLoader,
650 32 m_pageCursor, m_previousPageCursor};
651 }
652
653 24 bool MailListModel::restoreUnfilteredWindow(bool *changed) {
654
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 24 times.
24 if (!m_unfilteredWindow) return false;
655 24 auto saved = std::move(*m_unfilteredWindow);
656
1/2
✓ Branch 8 → 9 taken 24 times.
✗ Branch 8 → 10 not taken.
24 if (changed) *changed = saved.dataChanged;
657 24 m_unfilteredWindow.reset();
658
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 24 times.
24 if (saved.headers.isEmpty()) return false;
659 24 ++m_pageGeneration;
660 24 m_pagePending = false;
661 24 m_pageFailed = false;
662
6/7
✓ Branch 15 → 16 taken 14 times.
✓ Branch 15 → 18 taken 10 times.
✓ Branch 16 → 17 taken 14 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 14 times.
✓ Branch 20 → 21 taken 10 times.
✓ Branch 20 → 33 taken 14 times.
24 if (m_headers.isEmpty() || !applyPageDiff(saved.headers)) {
663
1/1
✓ Branch 21 → 22 taken 10 times.
10 beginResetModel();
664 10 m_headers = saved.headers;
665 10 m_uidIndex.clear();
666
2/2
✓ Branch 31 → 25 taken 27 times.
✓ Branch 31 → 32 taken 10 times.
37 for (int i = 0; i < m_headers.size(); ++i)
667
1/1
✓ Branch 28 → 29 taken 27 times.
27 m_uidIndex.insert(MailKey{m_headers.at(i).folderId, m_headers.at(i).uid}, i);
668
1/1
✓ Branch 32 → 33 taken 10 times.
10 endResetModel();
669 }
670 24 m_totalHeaderCount = saved.totalCount;
671 24 resetUnread(saved.unreadCount);
672 24 m_pageLoader = std::move(saved.pageLoader);
673 24 m_previousPageLoader = std::move(saved.previousPageLoader);
674 24 m_pageCursor = saved.pageCursor;
675 24 m_previousPageCursor = saved.previousPageCursor;
676 24 m_suggestionCache.clear();
677 24 m_retainedBytes = 0;
678
5/5
✓ Branch 41 → 42 taken 24 times.
✓ Branch 42 → 43 taken 24 times.
✓ Branch 45 → 46 taken 72 times.
✓ Branch 48 → 44 taken 72 times.
✓ Branch 48 → 49 taken 24 times.
96 for (const auto &header : m_headers) m_retainedBytes += headerCharge(header);
679 24 return true;
680 24 }
681
682 1049 void MailListModel::clear() {
683 1049 m_unfilteredWindow.reset();
684 1049 ++m_pageGeneration;
685 1049 m_pagePending = false;
686 1049 m_pageFailed = false;
687 1049 m_previousPageCursor = {};
688 1049 beginResetModel();
689 1049 m_headers.clear();
690 1049 m_uidIndex.clear();
691 1049 resetUnread(0);
692 1049 m_totalHeaderCount = 0;
693 1049 m_pageLoader = {};
694 1049 m_previousPageLoader = {};
695 1049 m_pageCursor = {};
696 1049 m_suggestionCache.clear(); // T-232
697 1049 m_retainedBytes = 0;
698 1049 endResetModel();
699 1049 }
700
701 758 void MailListModel::updateFlags(qint64 uid, quint32 newFlags, qint64 folderId) {
702 758 markKeptWindowChanged();
703 758 int row = rowForUid(uid, folderId);
704
2/2
✓ Branch 4 → 5 taken 74 times.
✓ Branch 4 → 6 taken 684 times.
758 if (row < 0)
705 87 return;
706
2/2
✓ Branch 7 → 8 taken 13 times.
✓ Branch 7 → 9 taken 671 times.
684 if (m_headers.at(row).flags == newFlags)
707 13 return;
708
709 // T-116: adjust unread count based on Seen flag transition
710 671 bool wasSeen = m_headers.at(row).isSeen();
711 // Only the bitmask changes; keyword labels remain on the loaded header.
712
1/1
✓ Branch 11 → 12 taken 671 times.
671 m_headers[row].flags = newFlags;
713 671 bool nowSeen = m_headers.at(row).isSeen();
714
4/4
✓ Branch 14 → 15 taken 431 times.
✓ Branch 14 → 17 taken 240 times.
✓ Branch 15 → 16 taken 30 times.
✓ Branch 15 → 17 taken 401 times.
671 if (wasSeen && !nowSeen)
715 30 ++m_unreadCount;
716
4/4
✓ Branch 17 → 18 taken 240 times.
✓ Branch 17 → 20 taken 401 times.
✓ Branch 18 → 19 taken 111 times.
✓ Branch 18 → 20 taken 129 times.
641 else if (!wasSeen && nowSeen)
717 111 --m_unreadCount;
718
719
1/1
✓ Branch 21 → 22 taken 671 times.
671 auto topLeft = index(row, 0);
720
1/1
✓ Branch 23 → 24 taken 671 times.
671 auto bottomRight = index(row, ColumnCount - 1);
721
2/2
✓ Branch 24 → 25 taken 671 times.
✓ Branch 25 → 26 taken 671 times.
671 emit dataChanged(topLeft, bottomRight,
722 {Qt::FontRole, Qt::DisplayRole, Qt::DecorationRole,
723 FlagsRole});
724 }
725
726 10 void MailListModel::setHasAttachments(qint64 uid, qint64 folderId, bool has) {
727 10 markKeptWindowChanged();
728 10 int row = rowForUid(uid, folderId);
729
2/2
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 6 taken 5 times.
10 if (row < 0)
730 5 return;
731
2/2
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 3 times.
5 if (m_headers.at(row).hasAttachments == has)
732 2 return;
733 3 m_headers[row].hasAttachments = has;
734
4/4
✓ Branch 10 → 11 taken 3 times.
✓ Branch 12 → 13 taken 3 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 15 → 16 taken 3 times.
3 emit dataChanged(index(row, 0), index(row, ColumnCount - 1),
735 {HasAttachmentsRole});
736 }
737
738 271 void MailListModel::removeByUid(qint64 uid, qint64 folderId) {
739 271 markKeptWindowChanged();
740 271 int row = rowForUid(uid, folderId);
741
2/2
✓ Branch 4 → 5 taken 13 times.
✓ Branch 4 → 6 taken 258 times.
271 if (row < 0)
742 13 return;
743
744 // T-116: adjust unread count if removed mail was unread
745
2/2
✓ Branch 8 → 9 taken 70 times.
✓ Branch 8 → 10 taken 188 times.
258 if (!m_headers.at(row).isSeen())
746 70 --m_unreadCount;
747 258 m_totalHeaderCount = std::max(0, m_totalHeaderCount - 1);
748 258 m_retainedBytes -= headerCharge(m_headers.at(row));
749 258 const int authoritativeUnread = m_unreadCount;
750 258 const bool paged = static_cast<bool>(m_pageLoader);
751
752
1/1
✓ Branch 15 → 16 taken 258 times.
258 beginRemoveRows({}, row, row);
753 258 m_headers.removeAt(row);
754
755 // T-115: rebuild index from scratch (rows shift after remove)
756 // Cost: O(n), but removeByUid is rare compared to rowForUid
757 258 rebuildUidIndex();
758
2/2
✓ Branch 18 → 19 taken 131 times.
✓ Branch 18 → 20 taken 127 times.
258 if (paged)
759 131 m_unreadCount = authoritativeUnread;
760
761 258 endRemoveRows();
762 }
763
764 // T-525/Cx8: Batch remove — single reset instead of per-UID rebuild
765 10 void MailListModel::removeByUids(const QList<qint64> &uids, qint64 folderId) {
766 10 markKeptWindowChanged();
767
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 9 times.
10 if (uids.isEmpty())
768 1 return;
769
770
1/1
✓ Branch 8 → 9 taken 9 times.
9 QSet<qint64> removeSet(uids.begin(), uids.end());
771 9 const bool paged = static_cast<bool>(m_pageLoader);
772 9 const int oldSize = m_headers.size();
773 9 int removedUnread = 0;
774
2/2
✓ Branch 26 → 14 taken 1064 times.
✓ Branch 26 → 27 taken 9 times.
1073 for (const MailHeader &header : std::as_const(m_headers)) {
775
5/6
✓ Branch 15 → 16 taken 1064 times.
✗ Branch 15 → 21 not taken.
✓ Branch 17 → 18 taken 114 times.
✓ Branch 17 → 21 taken 950 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 24 taken 1062 times.
1178 if (header.folderId == folderId && removeSet.contains(header.uid) &&
776
2/2
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 21 taken 112 times.
114 !header.isSeen())
777 2 ++removedUnread;
778 }
779 9 const int authoritativeUnread = m_unreadCount - removedUnread;
780
1/1
✓ Branch 27 → 28 taken 9 times.
9 beginResetModel();
781
2/2
✓ Branch 42 → 31 taken 1064 times.
✓ Branch 42 → 43 taken 9 times.
1073 for (const MailHeader &header : std::as_const(m_headers)) {
782
5/6
✓ Branch 32 → 33 taken 1064 times.
✗ Branch 32 → 36 not taken.
✓ Branch 34 → 35 taken 114 times.
✓ Branch 34 → 36 taken 950 times.
✓ Branch 37 → 38 taken 114 times.
✓ Branch 37 → 40 taken 950 times.
1064 if (header.folderId == folderId && removeSet.contains(header.uid))
783
1/1
✓ Branch 38 → 39 taken 114 times.
114 m_retainedBytes -= headerCharge(header);
784 }
785
5/5
✓ Branch 43 → 44 taken 9 times.
✓ Branch 45 → 46 taken 9 times.
✓ Branch 46 → 47 taken 9 times.
✓ Branch 47 → 48 taken 9 times.
✓ Branch 49 → 50 taken 9 times.
9 m_headers.erase(
786 std::remove_if(m_headers.begin(), m_headers.end(),
787 1064 [&removeSet, folderId](const MailHeader &h) {
788
3/4
✓ Branch 2 → 3 taken 1064 times.
✗ Branch 2 → 6 not taken.
✓ Branch 4 → 5 taken 114 times.
✓ Branch 4 → 6 taken 950 times.
2128 return h.folderId == folderId &&
789 2128 removeSet.contains(h.uid);
790 }),
791 m_headers.end());
792
1/1
✓ Branch 50 → 51 taken 9 times.
9 rebuildUidIndex();
793 9 const int removed = oldSize - m_headers.size();
794 9 m_totalHeaderCount = std::max(0, m_totalHeaderCount - removed);
795
2/2
✓ Branch 53 → 54 taken 7 times.
✓ Branch 53 → 55 taken 2 times.
9 if (paged) {
796 // Cache/controller authoritative badge updates follow bulk removals; keep
797 // the pre-reset total until that signal arrives instead of replacing it
798 // with the loaded-page count.
799 7 m_unreadCount = authoritativeUnread;
800 }
801
1/1
✓ Branch 55 → 56 taken 9 times.
9 endResetModel();
802 9 }
803
804 1591938 const MailHeader *MailListModel::headerAt(int row) const {
805
5/6
✓ Branch 2 → 3 taken 1591897 times.
✓ Branch 2 → 5 taken 41 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 1591897 times.
✓ Branch 7 → 8 taken 41 times.
✓ Branch 7 → 9 taken 1591897 times.
1591938 if (row < 0 || row >= m_headers.size())
806 41 return nullptr;
807 1591897 return &m_headers.at(row);
808 }
809
810 62 MailHeader *MailListModel::mutableHeaderAt(int row) {
811
5/6
✓ Branch 2 → 3 taken 47 times.
✓ Branch 2 → 5 taken 15 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 47 times.
✓ Branch 7 → 8 taken 15 times.
✓ Branch 7 → 9 taken 47 times.
62 if (row < 0 || row >= m_headers.size())
812 15 return nullptr;
813 47 return &m_headers[row];
814 }
815
816 39 qint64 MailListModel::uidAt(int row) const {
817 39 auto *h = headerAt(row);
818
1/2
✓ Branch 3 → 4 taken 39 times.
✗ Branch 3 → 5 not taken.
39 return h ? h->uid : -1;
819 }
820
821 32735 int MailListModel::rowForUid(qint64 uid, qint64 folderId) const {
822 // T-514: O(1) composite hash lookup
823 32735 auto it = m_uidIndex.constFind(MailKey{folderId, uid});
824
2/2
✓ Branch 6 → 7 taken 30177 times.
✓ Branch 6 → 9 taken 2558 times.
32735 return it != m_uidIndex.constEnd() ? it.value() : -1;
825 }
826
827 2421 std::optional<quint32> MailListModel::flagsFor(qint64 uid, qint64 folderId) const {
828 2421 const int row = rowForUid(uid, folderId);
829
2/2
✓ Branch 3 → 4 taken 352 times.
✓ Branch 3 → 7 taken 2069 times.
2421 if (row < 0) return std::nullopt;
830 2069 const auto *header = headerAt(row);
831
1/2
✓ Branch 8 → 9 taken 2069 times.
✗ Branch 8 → 10 not taken.
2069 return header ? std::optional<quint32>(header->flags) : std::nullopt;
832 }
833
834 237 int MailListModel::unreadCount() const {
835 // T-116: O(1) cached count
836 237 return m_unreadCount;
837 }
838
839 759 void MailListModel::rebuildUidIndex() {
840 759 m_uidIndex.clear();
841 759 m_uidIndex.reserve(m_headers.size());
842 759 m_unreadCount = 0;
843
2/2
✓ Branch 14 → 6 taken 130885 times.
✓ Branch 14 → 15 taken 759 times.
131644 for (int i = 0; i < m_headers.size(); ++i) {
844 130885 const auto &h = m_headers.at(i);
845
1/1
✓ Branch 8 → 9 taken 130885 times.
130885 m_uidIndex.insert(MailKey{h.folderId, h.uid}, i);
846
2/2
✓ Branch 10 → 11 taken 44177 times.
✓ Branch 10 → 12 taken 86708 times.
130885 if (!h.isSeen())
847 44177 ++m_unreadCount;
848 }
849 759 }
850
851 // 67.B4: humanized date column (DESIGN.md "MailList date format"), with
852 // the time since F100-52: today "14:32" · yesterday "Yesterday 14:32" ·
853 // this week "Mon 14:32" · this year "12. May 14:32" · older "12.05.2024"
854 40702 QString MailListModel::formatDate(const QDateTime &dt) {
855
3/3
✓ Branch 2 → 3 taken 40702 times.
✓ Branch 3 → 4 taken 56 times.
✓ Branch 3 → 5 taken 40646 times.
40702 if (!dt.isValid())
856 56 return {};
857
858 // F3.5: headers are stored in server (often UTC) time; format in local time
859 // so a same-day message shows the local HH:mm instead of a stale date, and
860 // the list matches the reader's local-time header.
861
1/1
✓ Branch 5 → 6 taken 40646 times.
40646 const QDateTime local = dt.toLocalTime();
862
1/1
✓ Branch 6 → 7 taken 40646 times.
40646 const QDate today = QDate::currentDate();
863
1/1
✓ Branch 7 → 8 taken 40646 times.
40646 const QDate date = local.date();
864
865
2/2
✓ Branch 9 → 10 taken 196 times.
✓ Branch 9 → 19 taken 40450 times.
40646 if (date == today)
866
2/2
✓ Branch 10 → 11 taken 196 times.
✓ Branch 14 → 15 taken 196 times.
392 return local.time().toString(QStringLiteral("HH:mm"));
867
2/2
✓ Branch 19 → 20 taken 40450 times.
✓ Branch 23 → 24 taken 40450 times.
80900 const QString time = local.time().toString(QStringLiteral("HH:mm"));
868
3/3
✓ Branch 26 → 27 taken 40450 times.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 38 taken 40449 times.
40450 if (date == today.addDays(-1))
869
3/3
✓ Branch 31 → 32 taken 1 time.
✓ Branch 32 → 33 taken 1 time.
✓ Branch 33 → 34 taken 1 time.
1 return tr("Yesterday") + QLatin1Char(' ') + time;
870
3/7
✓ Branch 38 → 39 taken 40449 times.
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 46 taken 40449 times.
✗ Branch 44 → 45 not taken.
✗ Branch 44 → 46 not taken.
✗ Branch 47 → 48 not taken.
✓ Branch 47 → 60 taken 40449 times.
40449 if (date > today.addDays(-7) && date < today)
871 ✗ return QLocale().dayName(date.dayOfWeek(), QLocale::ShortFormat) + QLatin1Char(' ') + time;
872
4/4
✓ Branch 60 → 61 taken 40449 times.
✓ Branch 61 → 62 taken 40449 times.
✓ Branch 62 → 63 taken 26051 times.
✓ Branch 62 → 79 taken 14398 times.
40449 if (date.year() == today.year())
873
4/4
✓ Branch 65 → 66 taken 26051 times.
✓ Branch 69 → 70 taken 26051 times.
✓ Branch 70 → 71 taken 26051 times.
✓ Branch 71 → 72 taken 26051 times.
52102 return QLocale().toString(date, QStringLiteral("d. MMM")) + QLatin1Char(' ') + time;
874
1/1
✓ Branch 82 → 83 taken 14398 times.
14398 return date.toString(QStringLiteral("dd.MM.yyyy"));
875 40646 }
876
877 ✗ QString MailListModel::formatSize(qint64 bytes) {
878 ✗ if (bytes < 1024)
879 ✗ return QString::number(bytes) + " B";
880 ✗ if (bytes < 1024 * 1024)
881 ✗ return QString::number(bytes / 1024.0, 'f', 1) + " KB";
882 ✗ return QString::number(bytes / (1024.0 * 1024.0), 'f', 1) + " MB";
883 }
884
885 // T-232: Set suggestion text + confidence for a specific UID
886 ✗ void MailListModel::setSuggestion(qint64 uid, qint64 folderId,
887 const QString &text,
888 double confidence) {
889 ✗ m_suggestionCache[MailKey{folderId, uid}] = {text, confidence};
890 ✗ int row = rowForUid(uid, folderId);
891 ✗ if (row >= 0) {
892 ✗ auto idx = index(row, Suggestion);
893 ✗ emit dataChanged(idx, idx, {Qt::DisplayRole, Qt::ForegroundRole, SortRole});
894 }
895 ✗ }
896
897 // T-232: Clear all cached suggestions
898 ✗ void MailListModel::clearSuggestions() {
899 ✗ if (m_suggestionCache.isEmpty())
900 ✗ return;
901 ✗ m_suggestionCache.clear();
902 ✗ if (!m_headers.isEmpty()) {
903 ✗ emit dataChanged(index(0, Suggestion),
904 ✗ index(m_headers.size() - 1, Suggestion),
905 {Qt::DisplayRole, Qt::ForegroundRole, SortRole});
906 }
907 }
908
909 // T-79.E5/M16: register a search-mode folder badge (display-only prefix).
910 45879 void MailListModel::setFolderBadge(qint64 folderId, const QString &folderName) {
911 45879 m_folderBadges.insert(folderId, folderName);
912 45879 }
913
914 198 void MailListModel::clearFolderBadges() {
915
2/2
✓ Branch 3 → 4 taken 7 times.
✓ Branch 3 → 5 taken 191 times.
198 if (m_folderBadges.isEmpty())
916 7 return;
917 191 m_folderBadges.clear();
918
2/2
✓ Branch 7 → 8 taken 90 times.
✓ Branch 7 → 17 taken 101 times.
191 if (!m_headers.isEmpty()) {
919
4/4
✓ Branch 8 → 9 taken 90 times.
✓ Branch 11 → 12 taken 90 times.
✓ Branch 13 → 14 taken 90 times.
✓ Branch 14 → 15 taken 90 times.
90 emit dataChanged(index(0, Subject), index(m_headers.size() - 1, Subject),
920 {Qt::DisplayRole});
921 }
922 }
923