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: 100.0% 17 / 0 / 17
Functions: 100.0% 15 / 0 / 15
Branches: 100.0% 2 / 0 / 2

ui/MailListModel.h
Line Branch Exec Source
1 #pragma once
2
3 #include <QAbstractTableModel>
4 #include <QHash>
5 #include <QList>
6 #include <QMimeData>
7 #include <QPair>
8 #include <functional>
9 #include <optional>
10
11 #include "data/MailSort.h"
12 #include "data/Models.h"
13
14 // MailListModel displays mail headers in a table format (Star, Subject, From,
15 // Date, Size). Designed for use with QSortFilterProxyModel for sorting. Unread
16 // (unseen) mails are rendered in bold (FontRole). Star column shows ⭐ for
17 // flagged mails.
18 class MailListModel : public QAbstractTableModel {
19 21 Q_OBJECT
20
21 public:
22 // T-514: Composite identity key (folderId, uid) — UIDs are only unique per folder
23 using MailKey = QPair<qint64, qint64>;
24 // Column order: Star/status column first (narrow icon), then attachment
25 // indicator, then content.
26 enum Column { Star = 0, Attachment, Subject, Suggestion, From, Date, Size, ColumnCount };
27
28 // Custom roles for filtering/sorting beyond Qt::UserRole
29 enum Role {
30 SortRole = Qt::UserRole, // Raw values for proper sorting
31 FlagsRole = Qt::UserRole + 1, // quint32 flags bitmask
32 HasAttachmentsRole = Qt::UserRole + 2, // bool: has attachments
33 LabelsRole = Qt::UserRole + 3, // QStringList: IMAP keywords/labels
34 // T-79.E5/M16: search-mode folder badge (display-only, per folderId)
35 FolderBadgeRole = Qt::UserRole + 4,
36 SuggestionConfRole = Qt::UserRole + 10, // double: suggestion confidence
37 };
38
39 explicit MailListModel(QObject *parent = nullptr);
40
41 // QAbstractTableModel interface
42 int rowCount(const QModelIndex &parent = {}) const override;
43 int columnCount(const QModelIndex &parent = {}) const override;
44 bool canFetchMore(const QModelIndex &parent) const override;
45 void fetchMore(const QModelIndex &parent) override;
46 QVariant data(const QModelIndex &index,
47 int role = Qt::DisplayRole) const override;
48 QVariant headerData(int section, Qt::Orientation orientation,
49 int role = Qt::DisplayRole) const override;
50
51 // Sprint 76 (T-76.B3): re-emit header data so translated column headers
52 // (Subject/From/Date/Size/Has attachment) refresh on a live language switch.
53 void retranslateUi();
54
55 // T-102: Drag & Drop support
56 Qt::ItemFlags flags(const QModelIndex &index) const override;
57 QStringList mimeTypes() const override;
58 QMimeData *mimeData(const QModelIndexList &indexes) const override;
59 Qt::DropActions supportedDragActions() const override;
60
61 // --- Data management ---
62
63 // Replace all headers (initial load from cache).
64 void setHeaders(const QList<MailHeader> &headers);
65
66 using PageLoader =
67 std::function<QList<MailHeader>(const MailHeader &, int)>;
68 struct PageCursor {
69 qint64 uid = 0;
70 QDateTime date;
71 // T-87.D1 random seek: forward pages anchored by a seek include the
72 // anchor uid itself (inclusive keyset) so the seek target is visible.
73 bool includeAnchor = false;
74 // F100-66: what a non-date sort keys on (MailSort::key).
75 QString from, to, subject;
76 qint64 size = 0;
77 quint32 flags = 0;
78 5794 PageCursor() = default;
79 1342 PageCursor(const MailHeader &header)
80 2684 : uid(header.uid), date(header.date), from(header.from), to(header.to),
81 2684 subject(header.subject), size(header.size), flags(header.flags) {}
82 };
83 struct PageResult {
84 QList<MailHeader> headers;
85 PageCursor cursor;
86 QString error;
87 bool endOfData = false;
88 };
89 using PageCallback = std::function<void(PageResult)>;
90 using AsyncPageLoader = std::function<void(const PageCursor &, int, PageCallback)>;
91 // Flat quick filter: the window about to be replaced by filtered pages is
92 // kept once, so clearing the filter restores it at once (as a diff)
93 // instead of showing an empty list until the unfiltered page is read.
94 void keepUnfilteredWindow();
95 // `changed` (optional) reports whether mail data changed while the window
96 // was kept (new mail, flags, removals): only then is a catch-up read of
97 // the first page needed after the restore.
98 bool restoreUnfilteredWindow(bool *changed = nullptr);
99 704 void discardUnfilteredWindow() { m_unfilteredWindow.reset(); }
100 void setAsyncPagedHeaders(const QList<MailHeader> &firstPage, int totalCount,
101 int totalUnread, AsyncPageLoader loader,
102 AsyncPageLoader previousLoader = {});
103 void setPagedHeaders(const QList<MailHeader> &firstPage, int totalCount,
104 int totalUnread, PageLoader loader, PageLoader previousLoader = {});
105 bool canFetchPrevious() const;
106 // F100-66: the order of the pages the loaders deliver; streamed rows are
107 // inserted at their position in it. Set before the pages of a new order.
108 701 void setSortOrder(const MailSortOrder &order) { m_sort = order; }
109 const MailSortOrder &sortOrder() const { return m_sort; }
110 void fetchPrevious(std::function<void()> completed = {});
111 // T-87.D1 random seek: anchor the retained window at the given uid (date
112 // unknown — the keyset falls back to a uid-only comparison) and load one
113 // window around it. No-op without loaders or while a page is pending.
114 void seekToUid(qint64 uid, std::function<void()> completed = {});
115 void setAuthoritativeCounts(int totalCount, int totalUnread);
116 // The folder's unread total for mail outside the loaded window: a visual
117 // Seen commit on an unloaded row adjusts it by one, and settled cache
118 // counts (IDLE flag pushes, flag sync) replace it. Never below zero.
119 10 void adjustUnreadCount(int delta) { m_unreadCount = std::max(0, m_unreadCount + delta); }
120 12 void setAuthoritativeUnread(int unread) { resetUnread(std::max(0, unread)); }
121 // S106-184: changes whenever the unread total is set anew (a page, count
122 // or window reload) — a later correction of an earlier ±1 adjustment
123 // applies only while the basis it adjusted is still the current one.
124 164 quint64 unreadBasis() const { return m_unreadBasis; }
125
126 // Append new headers (incremental sync from IMAP).
127 void appendHeaders(const QList<MailHeader> &headers);
128 // Keep the live IMAP stream within the retained-window row/byte budget.
129 // The caller owns canonical counts and reloads the cache page if a suffix
130 // could not be published. Other appendHeaders callers retain their semantics.
131 int appendStreamedHeaders(const QList<MailHeader> &headers);
132
133 // Clear all data.
134 void clear();
135
136 // Update flags for a specific UID. Emits dataChanged for the row.
137 // T-514: folderId required for composite key lookup.
138 void updateFlags(qint64 uid, quint32 newFlags, qint64 folderId);
139
140 // T-71: Update attachment status for a row (called after storeAttachments).
141 void setHasAttachments(qint64 uid, qint64 folderId, bool has);
142
143 // Remove a mail by UID. Uses beginRemoveRows/endRemoveRows.
144 // T-514: folderId required for composite key lookup.
145 void removeByUid(qint64 uid, qint64 folderId);
146
147 // T-525/Cx8: Batch remove — single rebuild instead of O(N²)
148 // T-514: folderId required (all UIDs must be from the same folder).
149 void removeByUids(const QList<qint64> &uids, qint64 folderId);
150
151 // Get the header at a given row. Returns nullptr if out of range.
152 const MailHeader *headerAt(int row) const;
153 MailHeader *mutableHeaderAt(int row);
154
155 // Get the UID at a given row. Returns -1 if out of range.
156 qint64 uidAt(int row) const;
157
158 // Find the row index for a given UID. Returns -1 if not found.
159 // T-514: folderId required for composite key lookup.
160 int rowForUid(qint64 uid, qint64 folderId) const;
161
162 // P90.15: flags of a loaded row without any SQL. Engaged when the caller
163 // needs to decide a seen/unseen toggle from the loaded projection and
164 // commit the visual state before the durable journal write.
165 std::optional<quint32> flagsFor(qint64 uid, qint64 folderId) const;
166
167 // Count of unseen (unread) mails in the current model.
168 int unreadCount() const;
169 43 int totalHeaderCount() const { return m_totalHeaderCount; }
170 // T-87.D1: conservative decoded-byte charge of the retained window.
171 qint64 retainedBytes() const { return m_retainedBytes; }
172 // Row-removal observers must distinguish window eviction from message deletion.
173 // True only while the retained-window removal signals are being delivered.
174 151 bool isEvictingRetainedRows() const { return m_evictingRetainedRows; }
175 // Rows a filtered first page leaves out are hidden, not deleted: removal
176 // observers must not move a selection off them. Set by the controller only
177 // while it applies a page read with an active flat filter.
178 147 bool isHidingFilteredRows() const { return m_hidingFilteredRows; }
179 1404 void setHidingFilteredRows(bool hiding) { m_hidingFilteredRows = hiding; }
180
181 // Access the full header list for thread model population.
182 23 const QList<MailHeader> &allHeaders() const { return m_headers; }
183
184 // T-87.D1 bounded retained window. Forward pages append older rows at the
185 // tail and evict the newest rows at the head; backward pages prepend newer
186 // rows at the head and evict the oldest rows at the tail. The retained set
187 // therefore always stays contiguous, both keyset cursors remain valid, and
188 // evicted ranges are re-fetched on demand when scrolling back.
189 static constexpr int MaximumRetainedRows = 1000; // 5 x PageSize
190 static constexpr int MaximumPageDiffMoves = 64;
191 // Liveness floor: never evict below this even when the byte budget is
192 // exceeded, so pathological fat headers cannot drain the visible window.
193 static constexpr int MinimumRetainedRows = 400; // 2 x PageSize
194 static constexpr qint64 MaximumRetainedBytes = 8LL * 1024 * 1024;
195
196 // T-232: Set/clear folder suggestion for a specific UID.
197 // T-514: folderId required for composite key lookup.
198 void setSuggestion(qint64 uid, qint64 folderId, const QString &text, double confidence);
199 void clearSuggestions();
200
201 // T-79.E5/M16: search-mode folder badges. Register before populating the
202 // model with search results; clear when leaving search mode.
203 void setFolderBadge(qint64 folderId, const QString &folderName);
204 void clearFolderBadges();
205
206 public:
207 // 67.B4: humanized date for the Date column (shared with
208 // MailThreadModel). Today "14:32" · yesterday "Yesterday" · this week
209 // "Mon" · this year "12. May" · older "12.05.2024".
210 static QString formatDate(const QDateTime &dt);
211
212 signals:
213 void pageLoadFailed(const QString &error);
214 // T-87.D1: emitted before the retained window drops rows [first,last]
215 // (head side when sliding down, tail side when sliding up) so selection
216 // owners can capture identities ahead of the removal.
217 void windowSlid(int first, int last);
218
219 private:
220 void insertStreamedHeaders(QList<MailHeader> batch);
221 bool applyPageDiff(const QList<MailHeader> &page);
222 void requestPage(bool previous, std::function<void()> completed = {});
223
224 // T-87.D1: slide the retained window after a page insert. fetchedForward
225 // evicts from the head, fetchedBackward (previous) evicts from the tail.
226 void evictDistantRetainedRows(bool fetchedForward);
227
228 // Conservative decoded-size charge shared with the writer's batch budget.
229 static qint64 headerCharge(const MailHeader &header);
230
231 // Format size for display: "1.2 KB", "3.4 MB"
232 static QString formatSize(qint64 bytes);
233
234 // T-115: Rebuild the UID→row index from m_headers.
235 void rebuildUidIndex();
236
237 QList<MailHeader> m_headers;
238
239 // T-514: O(1) (folderId,UID)→row lookup index.
240 // Maintained in sync with m_headers by all mutating methods.
241 QHash<MailKey, int> m_uidIndex;
242
243 // T-116: Cached unread (unseen) count — avoids O(n) scan on each query.
244 // Maintained by all mutating methods (setHeaders, appendHeaders,
245 // updateFlags, removeByUid, clear).
246 int m_unreadCount = 0;
247 quint64 m_unreadBasis = 0;
248 1830 void resetUnread(int unread) { m_unreadCount = unread; ++m_unreadBasis; }
249 int m_totalHeaderCount = 0;
250 AsyncPageLoader m_pageLoader;
251 AsyncPageLoader m_previousPageLoader;
252 quint64 m_pageGeneration = 0;
253 bool m_pagePending = false;
254 bool m_pageFailed = false;
255 PageCursor m_previousPageCursor;
256 PageCursor m_pageCursor;
257 MailSortOrder m_sort;
258 qint64 m_retainedBytes = 0;
259 struct WindowState {
260 QList<MailHeader> headers;
261 int totalCount = 0;
262 int unreadCount = 0;
263 AsyncPageLoader pageLoader, previousPageLoader;
264 PageCursor pageCursor, previousPageCursor;
265 bool dataChanged = false;
266 };
267 // Marks the kept unfiltered window as outdated (see restoreUnfilteredWindow).
268
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 6 taken 1055 times.
1057 void markKeptWindowChanged() { if (m_unfilteredWindow) m_unfilteredWindow->dataChanged = true; }
269 std::optional<WindowState> m_unfilteredWindow;
270 bool m_evictingRetainedRows = false;
271 bool m_hidingFilteredRows = false;
272 static constexpr int PageSize = 200;
273
274 // T-232: Suggestion cache — (folderId,UID) → display text + confidence
275 struct SuggestionData {
276 QString text; // e.g. "→ Projekte 96%"
277 double confidence; // for color coding
278 };
279 QHash<MailKey, SuggestionData> m_suggestionCache;
280
281 // T-79.E5/M16: search-mode folder badges (folderId → display name).
282 // Painted as a "[folder] " prefix in the Subject column; the underlying
283 // MailHeader::subject stays clean so replies/forwards never carry it.
284 QHash<qint64, QString> m_folderBadges;
285 };
286