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: 96.1% 682 / 0 / 710
Functions: 97.7% 42 / 0 / 43
Branches: 76.1% 1074 / 27 / 1439

data/FolderPredictor.cpp
Line Branch Exec Source
1 #include "data/FolderPredictor.h"
2 #include "data/DatabaseSecurity.h"
3 #include <QMutexLocker>
4 #include "data/MailCache.h"
5
6 #include <QLoggingCategory>
7 #include <QRegularExpression>
8 #include <QSqlError>
9 #include <QSqlQuery>
10 #include <QScopeGuard>
11 #include <QUuid>
12 #include <QtMath>
13
14 #include <algorithm>
15 #include <optional>
16
17
4/7
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 8 taken 31 times.
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 4 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
35 Q_LOGGING_CATEGORY(lcFolderPredictor, "mailjd.folderpredictor")
18
19 // German + English stop words (common words that don't help classification)
20 const QSet<QString> FolderPredictor::s_stopWords = {
21 // German
22 "der", "die", "das", "und", "oder", "ein", "eine", "ist", "sind", "war",
23 "hat", "haben", "wird", "werden", "mit", "von", "für", "auf", "aus", "bei",
24 "nach", "über", "unter", "vor", "zum", "zur", "dem", "den", "des", "sich",
25 "nicht", "auch", "noch", "nur", "wie", "aber", "als", "wenn", "ich", "wir",
26 "sie", "ihr", "uns", "mir", "dir", "mein", "dein", "sein", "kann", "sehr",
27 // English
28 "the", "a", "an", "and", "or", "is", "are", "was", "has", "have", "will",
29 "with", "from", "for", "on", "at", "by", "to", "in", "of", "not", "but",
30 "this", "that", "it", "be", "do", "if", "so", "no", "up", "out", "we",
31 "you", "he", "she", "my", "your", "his", "her", "our", "can",
32 // Common mail prefixes (already stripped, but just in case)
33 "re", "fwd", "aw", "wg",
34 };
35
36
1/1
✓ Branch 4 → 5 taken 1175 times.
1175 FolderPredictor::FolderPredictor(QObject *parent) : QObject(parent) {}
37
38 1184 FolderPredictor::~FolderPredictor() {
39 1175 close();
40 1184 }
41
42 861 bool FolderPredictor::open(const QString &dbPath) {
43
1/1
✓ Branch 2 → 3 taken 861 times.
861 close();
44
3/3
✓ Branch 3 → 4 taken 861 times.
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 17 taken 856 times.
861 if (!DatabaseSecurity::preparePath(dbPath)) {
45
4/4
✓ Branch 5 → 6 taken 5 times.
✓ Branch 9 → 10 taken 5 times.
✓ Branch 15 → 7 taken 5 times.
✓ Branch 15 → 16 taken 5 times.
10 qCWarning(lcFolderPredictor)
46
2/2
✓ Branch 10 → 11 taken 5 times.
✓ Branch 11 → 12 taken 5 times.
5 << "failed to create private database file" << dbPath;
47 5 return false;
48 }
49 m_connectionName =
50
3/3
✓ Branch 17 → 18 taken 856 times.
✓ Branch 18 → 19 taken 856 times.
✓ Branch 22 → 23 taken 856 times.
1712 QStringLiteral("FolderPredictor_") + QUuid::createUuid().toString();
51
2/2
✓ Branch 31 → 32 taken 856 times.
✓ Branch 32 → 33 taken 856 times.
1712 m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
52
1/1
✓ Branch 36 → 37 taken 856 times.
856 m_db.setDatabaseName(dbPath);
53
54
2/3
✓ Branch 37 → 38 taken 856 times.
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 57 taken 856 times.
856 if (!m_db.open()) {
55 ✗ qCWarning(lcFolderPredictor) << "failed to open" << dbPath << ':' // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; database result remains measured.
56 ✗ << m_db.lastError().text();
57 ✗ return false;
58 }
59
2/3
✓ Branch 57 → 58 taken 856 times.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 72 taken 856 times.
856 if (!DatabaseSecurity::restrictExistingFile(dbPath)) {
60 ✗ qCWarning(lcFolderPredictor) // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; security result remains measured.
61 ✗ << "failed to restrict database permissions for" << dbPath;
62 ✗ m_db.close();
63 ✗ return false;
64 }
65
66 // WAL for fast writes
67
1/1
✓ Branch 72 → 73 taken 856 times.
856 QSqlQuery q(m_db);
68
1/1
✓ Branch 76 → 77 taken 856 times.
856 q.exec(QStringLiteral("PRAGMA journal_mode=WAL"));
69
1/1
✓ Branch 82 → 83 taken 856 times.
856 q.exec(QStringLiteral("PRAGMA synchronous=NORMAL"));
70 // T-619/SEC-19: Prevent SQLITE_BUSY errors during concurrent access
71
1/1
✓ Branch 88 → 89 taken 856 times.
856 q.exec(QStringLiteral("PRAGMA busy_timeout=5000"));
72
73
1/1
✓ Branch 91 → 92 taken 856 times.
856 return createSchema();
74 856 }
75
76 317 bool FolderPredictor::openReadOnly(const QString &dbPath, std::shared_ptr<StatisticsCache> cache) {
77 317 close();
78
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 317 times.
317 if (dbPath.isEmpty()) return false;
79 317 m_path = dbPath;
80 317 m_statisticsCache = std::move(cache);
81
3/3
✓ Branch 9 → 10 taken 317 times.
✓ Branch 10 → 11 taken 317 times.
✓ Branch 14 → 15 taken 317 times.
634 m_connectionName = QStringLiteral("FolderPredictionRead_") + QUuid::createUuid().toString();
82
2/2
✓ Branch 23 → 24 taken 317 times.
✓ Branch 24 → 25 taken 317 times.
634 m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
83 317 m_db.setDatabaseName(dbPath);
84
1/1
✓ Branch 32 → 33 taken 317 times.
634 m_db.setConnectOptions(QStringLiteral("QSQLITE_OPEN_READONLY;QSQLITE_BUSY_TIMEOUT=250"));
85
2/2
✓ Branch 36 → 37 taken 1 time.
✓ Branch 36 → 39 taken 316 times.
317 if (!m_db.open()) { close(); return false; }
86 316 bool valid = false;
87 {
88
1/1
✓ Branch 39 → 40 taken 316 times.
316 QSqlQuery query(m_db);
89
1/6
✓ Branch 76 → 77 taken 316 times.
✗ Branch 76 → 78 not taken.
✗ Branch 133 → 134 not taken.
✗ Branch 133 → 135 not taken.
✗ Branch 137 → 138 not taken.
✗ Branch 137 → 139 not taken.
632 valid = query.exec(QStringLiteral("SELECT token,folder,count FROM token_counts LIMIT 0")) &&
90
5/11
✓ Branch 48 → 49 taken 315 times.
✓ Branch 49 → 50 taken 315 times.
✗ Branch 49 → 61 not taken.
✓ Branch 72 → 73 taken 315 times.
✓ Branch 72 → 74 taken 1 time.
✓ Branch 74 → 75 taken 316 times.
✗ Branch 74 → 76 not taken.
✗ Branch 125 → 126 not taken.
✗ Branch 125 → 127 not taken.
✗ Branch 129 → 130 not taken.
✗ Branch 129 → 131 not taken.
631 query.exec(QStringLiteral("SELECT folder,doc_count FROM folder_doc_counts LIMIT 0")) &&
91
9/14
✓ Branch 43 → 44 taken 316 times.
✓ Branch 44 → 45 taken 315 times.
✓ Branch 44 → 61 taken 1 time.
✓ Branch 53 → 54 taken 315 times.
✓ Branch 54 → 55 taken 315 times.
✗ Branch 54 → 61 not taken.
✓ Branch 68 → 69 taken 315 times.
✓ Branch 68 → 70 taken 1 time.
✓ Branch 70 → 71 taken 315 times.
✓ Branch 70 → 72 taken 1 time.
✗ Branch 117 → 118 not taken.
✗ Branch 117 → 119 not taken.
✗ Branch 121 → 122 not taken.
✗ Branch 121 → 123 not taken.
1578 query.exec(QStringLiteral("SELECT key,value FROM meta LIMIT 0")) &&
92
8/13
✓ Branch 58 → 59 taken 315 times.
✓ Branch 59 → 60 taken 315 times.
✗ Branch 59 → 61 not taken.
✓ Branch 62 → 63 taken 315 times.
✓ Branch 62 → 64 taken 1 time.
✓ Branch 64 → 65 taken 315 times.
✓ Branch 64 → 66 taken 1 time.
✓ Branch 66 → 67 taken 315 times.
✓ Branch 66 → 68 taken 1 time.
✗ Branch 109 → 110 not taken.
✗ Branch 109 → 111 not taken.
✗ Branch 113 → 114 not taken.
✗ Branch 113 → 115 not taken.
631 query.exec(QStringLiteral("BEGIN"));
93 316 }
94
2/2
✓ Branch 79 → 80 taken 1 time.
✓ Branch 79 → 81 taken 315 times.
316 if (!valid) close();
95 316 m_readOnly = valid;
96 316 return valid;
97 }
98
99 4318 FolderSuggestion FolderPredictor::suggestions(const MailHeader &header,
100 const QString &junkFolder, QString *error) const {
101 // F-75 (SPRINT-104): the same direct-evidence guard predict() applies —
102 // a sender no folder has a from-token for gets NO suggestion, instead of
103 // the least-trained folder with near-certain softmax confidence (the
104 // smoothing favours the folder with the fewest total tokens). Spam
105 // classification is server metadata, independent of token evidence: an
106 // unknown spam-flagged sender still suggests the Junk folder.
107 // S106-199: a mail without a readable sender address has no evidence at
108 // all — it used to skip the guard and get a suggestion from subject/to
109 // tokens alone (the exact F-75 mechanism, reachable with an empty From).
110
1/1
✓ Branch 2 → 3 taken 4318 times.
4318 const QString senderEmail = extractEmail(header.from);
111 4318 bool known = false;
112
2/2
✓ Branch 4 → 5 taken 4312 times.
✓ Branch 4 → 31 taken 6 times.
4318 if (!senderEmail.isEmpty()) {
113
1/1
✓ Branch 5 → 6 taken 4312 times.
4312 QSqlQuery evidence(m_db);
114
1/1
✓ Branch 9 → 10 taken 4312 times.
4312 evidence.prepare(QStringLiteral(
115 "SELECT 1 FROM token_counts WHERE token = ? LIMIT 1"));
116
2/2
✓ Branch 16 → 17 taken 4312 times.
✓ Branch 18 → 19 taken 4312 times.
4312 evidence.addBindValue(QStringLiteral("from:") + senderEmail);
117
5/6
✓ Branch 23 → 24 taken 4312 times.
✓ Branch 24 → 25 taken 4312 times.
✗ Branch 24 → 28 not taken.
✓ Branch 25 → 26 taken 4312 times.
✓ Branch 26 → 27 taken 2724 times.
✓ Branch 26 → 28 taken 1588 times.
4312 known = evidence.exec() && evidence.next();
118 4312 }
119
2/2
✓ Branch 31 → 32 taken 1594 times.
✓ Branch 31 → 44 taken 2724 times.
4318 if (!known) {
120
6/8
✓ Branch 32 → 33 taken 3 times.
✓ Branch 32 → 38 taken 1591 times.
✓ Branch 34 → 35 taken 3 times.
✗ Branch 34 → 38 not taken.
✓ Branch 36 → 37 taken 3 times.
✗ Branch 36 → 38 not taken.
✓ Branch 39 → 40 taken 3 times.
✓ Branch 39 → 43 taken 1591 times.
1594 if (header.isSpam && !junkFolder.isEmpty() && junkFolder.size() <= 4096)
121 3 return FolderSuggestion{{junkFolder, 0.95}, {}};
122 1591 return FolderSuggestion{};
123 }
124 2724 return FolderSuggestion::fromScores(predictTop(header.from, header.subject, header.to, 2, error),
125
2/2
✓ Branch 44 → 45 taken 2724 times.
✓ Branch 45 → 46 taken 2724 times.
2724 header.isSpam, junkFolder);
126 4318 }
127
128 2390 void FolderPredictor::close() {
129 2390 m_statistics = {};
130 2390 m_readOnly = false;
131 2390 m_path.clear();
132 2390 m_statisticsCache.reset();
133
2/2
✓ Branch 7 → 8 taken 1172 times.
✓ Branch 7 → 9 taken 1218 times.
2390 if (m_db.isOpen()) {
134 1172 m_db.close();
135 }
136
2/2
✓ Branch 10 → 11 taken 1173 times.
✓ Branch 10 → 19 taken 1217 times.
2390 if (!m_connectionName.isEmpty()) {
137 // Must drop the QSqlDatabase handle before removeDatabase —
138 // otherwise the member still holds a reference and Qt warns
139 // "connection still in use" (classic Qt gotcha).
140 1173 QString connName = m_connectionName;
141 1173 m_connectionName.clear();
142
2/2
✓ Branch 13 → 14 taken 1173 times.
✓ Branch 14 → 15 taken 1173 times.
1173 m_db = QSqlDatabase(); // Release reference
143
1/1
✓ Branch 16 → 17 taken 1173 times.
1173 QSqlDatabase::removeDatabase(connName);
144 1173 }
145 2390 }
146
147 6 bool FolderPredictor::isOpen() const {
148 6 return m_db.isOpen();
149 }
150
151 856 bool FolderPredictor::createSchema() {
152
1/1
✓ Branch 2 → 3 taken 856 times.
856 QSqlQuery q(m_db);
153
154 // Token counts: how often a token was seen in a specific folder
155
1/1
✓ Branch 6 → 7 taken 856 times.
856 bool ok = q.exec(QStringLiteral(
156 "CREATE TABLE IF NOT EXISTS token_counts ("
157 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
158 " token TEXT NOT NULL,"
159 " folder TEXT NOT NULL,"
160 " count INTEGER NOT NULL DEFAULT 1,"
161 " UNIQUE(token, folder)"
162 ")"));
163
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 26 taken 855 times.
856 if (!ok) {
164 2 qCWarning(lcFolderPredictor) // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; schema result remains measured.
165
4/4
✓ Branch 15 → 16 taken 1 time.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 17 → 18 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
1 << "create token_counts:" << q.lastError().text();
166 1 return false;
167 }
168
169
1/1
✓ Branch 29 → 30 taken 855 times.
855 q.exec(QStringLiteral(
170 "CREATE INDEX IF NOT EXISTS idx_token ON token_counts(token)"));
171
1/1
✓ Branch 35 → 36 taken 855 times.
855 q.exec(QStringLiteral(
172 "CREATE INDEX IF NOT EXISTS idx_folder ON token_counts(folder)"));
173
174
1/1
✓ Branch 41 → 42 taken 855 times.
855 ok = q.exec(QStringLiteral(
175 "CREATE TABLE IF NOT EXISTS trained_messages ("
176 " account TEXT NOT NULL,"
177 " folder TEXT NOT NULL,"
178 " uid INTEGER NOT NULL,"
179 " uid_validity INTEGER NOT NULL DEFAULT 0,"
180 " PRIMARY KEY(account, folder, uid)"
181 ")"));
182
1/2
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 61 taken 855 times.
855 if (!ok) {
183 ✗ qCWarning(lcFolderPredictor) // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; schema result remains measured.
184 ✗ << "create trained_messages:" << q.lastError().text();
185 ✗ return false;
186 }
187
188 // A defaulted column preserves the old ledger and learned counts without
189 // copying the table. Legacy identities adopt their first known epoch below.
190
2/3
✓ Branch 64 → 65 taken 855 times.
✗ Branch 67 → 68 not taken.
✓ Branch 67 → 69 taken 855 times.
855 if (!q.exec(QStringLiteral("PRAGMA table_info(trained_messages)"))) return false;
191 855 bool hasEpoch = false;
192
5/5
✓ Branch 73 → 74 taken 3419 times.
✓ Branch 74 → 75 taken 3419 times.
✓ Branch 81 → 82 taken 4274 times.
✓ Branch 82 → 70 taken 3419 times.
✓ Branch 82 → 83 taken 855 times.
4274 while (q.next()) hasEpoch |= q.value(1).toString() == QStringLiteral("uid_validity");
193
1/1
✓ Branch 83 → 84 taken 855 times.
855 q.finish();
194
9/15
✓ Branch 84 → 85 taken 1 time.
✓ Branch 84 → 91 taken 854 times.
✓ Branch 88 → 89 taken 1 time.
✗ Branch 89 → 90 not taken.
✓ Branch 89 → 91 taken 1 time.
✓ Branch 92 → 93 taken 1 time.
✓ Branch 92 → 94 taken 854 times.
✓ Branch 94 → 95 taken 1 time.
✓ Branch 94 → 96 taken 854 times.
✗ Branch 96 → 97 not taken.
✓ Branch 96 → 113 taken 855 times.
✗ Branch 316 → 317 not taken.
✗ Branch 316 → 318 not taken.
✗ Branch 320 → 321 not taken.
✗ Branch 320 → 322 not taken.
856 if (!hasEpoch && !q.exec(QStringLiteral(
195 "ALTER TABLE trained_messages ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0"))) {
196 ✗ qCWarning(lcFolderPredictor) << "Cannot add training mailbox epoch:" << q.lastError().text();
197 ✗ return false;
198 }
199
200
2/3
✓ Branch 116 → 117 taken 855 times.
✗ Branch 119 → 120 not taken.
✓ Branch 119 → 121 taken 855 times.
855 if (!q.exec(QStringLiteral(
201 "CREATE TABLE IF NOT EXISTS completed_training_folders ("
202 "account TEXT NOT NULL,folder TEXT NOT NULL,folder_id INTEGER NOT NULL,"
203 "uid_validity INTEGER NOT NULL,revision TEXT NOT NULL,"
204 ✗ "PRIMARY KEY(account,folder))"))) return false;
205
206 // Folder document counts: how many mails were moved to each folder
207
1/1
✓ Branch 124 → 125 taken 855 times.
855 ok = q.exec(QStringLiteral(
208 "CREATE TABLE IF NOT EXISTS folder_doc_counts ("
209 " folder TEXT PRIMARY KEY,"
210 " doc_count INTEGER NOT NULL DEFAULT 0"
211 ")"));
212
1/2
✗ Branch 127 → 128 not taken.
✓ Branch 127 → 144 taken 855 times.
855 if (!ok) {
213 ✗ qCWarning(lcFolderPredictor)
214 ✗ << "create folder_doc_counts:" << q.lastError().text();
215 ✗ return false;
216 }
217
218 // Metadata (total_docs, pretrained flag)
219
1/1
✓ Branch 147 → 148 taken 855 times.
855 ok = q.exec(QStringLiteral(
220 "CREATE TABLE IF NOT EXISTS meta ("
221 " key TEXT PRIMARY KEY,"
222 " value TEXT"
223 ")"));
224
1/2
✗ Branch 150 → 151 not taken.
✓ Branch 150 → 167 taken 855 times.
855 if (!ok) {
225 ✗ qCWarning(lcFolderPredictor) << "create meta:" << q.lastError().text(); // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; schema result remains measured.
226 ✗ return false;
227 }
228
229 // Initialize total_docs if not present
230
1/1
✓ Branch 170 → 171 taken 855 times.
855 q.exec(QStringLiteral(
231 "INSERT OR IGNORE INTO meta (key, value) VALUES ('total_docs', '0')"));
232 // F100-50: databases from before the statistics revision get one here;
233 // until then readers simply do not cache their statistics.
234
1/1
✓ Branch 176 → 177 taken 855 times.
855 q.prepare(QStringLiteral("INSERT OR IGNORE INTO meta (key, value) VALUES ('statistics_revision', ?)"));
235
3/3
✓ Branch 180 → 181 taken 855 times.
✓ Branch 181 → 182 taken 855 times.
✓ Branch 183 → 184 taken 855 times.
855 q.addBindValue(QUuid::createUuid().toString(QUuid::WithoutBraces));
236
1/1
✓ Branch 186 → 187 taken 855 times.
855 q.exec();
237
238 // Schema version check — if algorithm changed, force re-training
239 static constexpr int kSchemaVersion = 4;
240
1/1
✓ Branch 190 → 191 taken 855 times.
855 q.exec(QStringLiteral("SELECT value FROM meta WHERE key = 'schema_version'"));
241 855 int storedVersion = 0;
242
3/3
✓ Branch 193 → 194 taken 855 times.
✓ Branch 194 → 195 taken 52 times.
✓ Branch 194 → 199 taken 803 times.
855 if (q.next())
243
2/2
✓ Branch 195 → 196 taken 52 times.
✓ Branch 196 → 197 taken 52 times.
52 storedVersion = q.value(0).toInt();
244
245
2/2
✓ Branch 199 → 200 taken 804 times.
✓ Branch 199 → 250 taken 51 times.
855 if (storedVersion != kSchemaVersion) {
246 // Algorithm changed — reset all training data
247
1/1
✓ Branch 203 → 204 taken 804 times.
804 q.exec(QStringLiteral("DELETE FROM token_counts"));
248
1/1
✓ Branch 209 → 210 taken 804 times.
804 q.exec(QStringLiteral("DELETE FROM trained_messages"));
249
1/1
✓ Branch 215 → 216 taken 804 times.
804 q.exec(QStringLiteral("DELETE FROM completed_training_folders"));
250
1/1
✓ Branch 221 → 222 taken 804 times.
804 q.exec(QStringLiteral("DELETE FROM folder_doc_counts"));
251
1/1
✓ Branch 227 → 228 taken 804 times.
804 q.exec(QStringLiteral(
252 "UPDATE meta SET value = '0' WHERE key = 'total_docs'"));
253
1/1
✓ Branch 233 → 234 taken 804 times.
804 q.exec(QStringLiteral("DELETE FROM meta WHERE key = 'pretrained_account'"));
254
1/1
✓ Branch 236 → 237 taken 804 times.
804 touchStatisticsRevision();
255
1/1
✓ Branch 240 → 241 taken 804 times.
804 q.prepare(QStringLiteral(
256 "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)"));
257
2/2
✓ Branch 244 → 245 taken 804 times.
✓ Branch 246 → 247 taken 804 times.
804 q.bindValue(0, QString::number(kSchemaVersion));
258
1/1
✓ Branch 249 → 250 taken 804 times.
804 q.exec();
259 }
260
261 855 return true;
262 856 }
263
264 6 void FolderPredictor::resetDatabase() {
265
3/3
✓ Branch 2 → 3 taken 6 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 5 times.
6 if (!m_db.isOpen())
266 1 return;
267
268
1/1
✓ Branch 5 → 6 taken 5 times.
5 m_db.transaction();
269
1/1
✓ Branch 6 → 7 taken 5 times.
5 QSqlQuery q(m_db);
270
1/1
✓ Branch 10 → 11 taken 5 times.
5 q.exec(QStringLiteral("DELETE FROM token_counts"));
271
1/1
✓ Branch 16 → 17 taken 5 times.
5 q.exec(QStringLiteral("DELETE FROM trained_messages"));
272
1/1
✓ Branch 22 → 23 taken 5 times.
5 q.exec(QStringLiteral("DELETE FROM completed_training_folders"));
273
1/1
✓ Branch 28 → 29 taken 5 times.
5 q.exec(QStringLiteral("DELETE FROM folder_doc_counts"));
274
1/1
✓ Branch 34 → 35 taken 5 times.
5 q.exec(QStringLiteral(
275 "UPDATE meta SET value = '0' WHERE key = 'total_docs'"));
276
1/1
✓ Branch 40 → 41 taken 5 times.
5 q.exec(QStringLiteral("DELETE FROM meta WHERE key = 'pretrained_account'"));
277
1/1
✓ Branch 43 → 44 taken 5 times.
5 touchStatisticsRevision();
278
1/1
✓ Branch 44 → 45 taken 5 times.
5 m_db.commit();
279 5 }
280
281 5840 bool FolderPredictor::touchStatisticsRevision() {
282
1/1
✓ Branch 2 → 3 taken 5840 times.
5840 QSqlQuery q(m_db);
283
1/1
✓ Branch 6 → 7 taken 5840 times.
5840 q.prepare(QStringLiteral("INSERT OR REPLACE INTO meta (key, value) VALUES ('statistics_revision', ?)"));
284
3/3
✓ Branch 10 → 11 taken 5840 times.
✓ Branch 11 → 12 taken 5840 times.
✓ Branch 13 → 14 taken 5840 times.
5840 q.addBindValue(QUuid::createUuid().toString(QUuid::WithoutBraces));
285
3/3
✓ Branch 16 → 17 taken 5840 times.
✓ Branch 17 → 18 taken 5835 times.
✓ Branch 17 → 19 taken 5 times.
5840 if (q.exec()) return true;
286
8/8
✓ Branch 19 → 20 taken 5 times.
✓ Branch 23 → 24 taken 5 times.
✓ Branch 24 → 25 taken 5 times.
✓ Branch 25 → 26 taken 5 times.
✓ Branch 26 → 27 taken 5 times.
✓ Branch 27 → 28 taken 5 times.
✓ Branch 33 → 21 taken 5 times.
✓ Branch 33 → 34 taken 5 times.
10 qCWarning(lcFolderPredictor) << "statistics revision not stored:" << q.lastError().text();
287 5 return false;
288 5840 }
289
290 // T-287: Number of training documents for a specific folder
291 41 int FolderPredictor::documentsForFolder(const QString &folderPath) const {
292
3/3
✓ Branch 2 → 3 taken 41 times.
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 39 times.
41 if (!m_db.isOpen())
293 2 return 0;
294
295
1/1
✓ Branch 5 → 6 taken 39 times.
39 QSqlQuery q(m_db);
296
1/1
✓ Branch 9 → 10 taken 39 times.
39 q.prepare(QStringLiteral(
297 "SELECT doc_count FROM folder_doc_counts WHERE folder = :folder"));
298
1/1
✓ Branch 17 → 18 taken 39 times.
78 q.bindValue(QStringLiteral(":folder"), folderPath);
299
8/8
✓ Branch 21 → 22 taken 39 times.
✓ Branch 22 → 23 taken 36 times.
✓ Branch 22 → 26 taken 3 times.
✓ Branch 23 → 24 taken 36 times.
✓ Branch 24 → 25 taken 23 times.
✓ Branch 24 → 26 taken 13 times.
✓ Branch 27 → 28 taken 23 times.
✓ Branch 27 → 33 taken 16 times.
39 if (q.exec() && q.next())
300
2/2
✓ Branch 28 → 29 taken 23 times.
✓ Branch 29 → 30 taken 23 times.
23 return q.value(0).toInt();
301 16 return 0;
302 39 }
303
304 // T-287: Whether a folder has enough training data for reliable predictions
305 2 bool FolderPredictor::hasSufficientData(const QString &folderPath) const {
306 // Need at least 5 training documents for basic predictions
307 2 return documentsForFolder(folderPath) >= 5;
308 }
309
310 // T-287: Total unique tokens trained for a specific folder
311 15 int FolderPredictor::tokenCountForFolder(const QString &folderPath) const {
312
3/3
✓ Branch 2 → 3 taken 15 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 14 times.
15 if (!m_db.isOpen())
313 1 return 0;
314
315
1/1
✓ Branch 5 → 6 taken 14 times.
14 QSqlQuery q(m_db);
316
1/1
✓ Branch 9 → 10 taken 14 times.
14 q.prepare(QStringLiteral(
317 "SELECT COUNT(*) FROM token_counts WHERE folder = :folder"));
318
1/1
✓ Branch 17 → 18 taken 14 times.
28 q.bindValue(QStringLiteral(":folder"), folderPath);
319
7/8
✓ Branch 21 → 22 taken 14 times.
✓ Branch 22 → 23 taken 13 times.
✓ Branch 22 → 26 taken 1 time.
✓ Branch 23 → 24 taken 13 times.
✓ Branch 24 → 25 taken 13 times.
✗ Branch 24 → 26 not taken.
✓ Branch 27 → 28 taken 13 times.
✓ Branch 27 → 33 taken 1 time.
14 if (q.exec() && q.next())
320
2/2
✓ Branch 28 → 29 taken 13 times.
✓ Branch 29 → 30 taken 13 times.
13 return q.value(0).toInt();
321 1 return 0;
322 14 }
323
324 // T-287: Reset training data for a single folder (not the entire database)
325 7 void FolderPredictor::resetFolder(const QString &folderPath) {
326
3/3
✓ Branch 2 → 3 taken 7 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 6 times.
7 if (!m_db.isOpen())
327 1 return;
328
329
1/1
✓ Branch 5 → 6 taken 6 times.
6 m_db.transaction();
330
331
1/1
✓ Branch 6 → 7 taken 6 times.
6 QSqlQuery q(m_db);
332
333 // Get the document count for this folder to subtract from total
334
1/1
✓ Branch 7 → 8 taken 6 times.
6 int docCount = documentsForFolder(folderPath);
335
336 // Delete token counts for this folder
337
1/1
✓ Branch 11 → 12 taken 6 times.
6 q.prepare(QStringLiteral(
338 "DELETE FROM token_counts WHERE folder = :folder"));
339
1/1
✓ Branch 19 → 20 taken 6 times.
12 q.bindValue(QStringLiteral(":folder"), folderPath);
340
1/1
✓ Branch 23 → 24 taken 6 times.
6 q.exec();
341
342
1/1
✓ Branch 27 → 28 taken 6 times.
6 q.prepare(QStringLiteral(
343 "DELETE FROM trained_messages WHERE folder = :folder"));
344
1/1
✓ Branch 35 → 36 taken 6 times.
12 q.bindValue(QStringLiteral(":folder"), folderPath);
345
1/1
✓ Branch 39 → 40 taken 6 times.
6 q.exec();
346
347
1/1
✓ Branch 43 → 44 taken 6 times.
6 q.prepare(QStringLiteral("DELETE FROM completed_training_folders WHERE folder = :folder"));
348
1/1
✓ Branch 51 → 52 taken 6 times.
12 q.bindValue(QStringLiteral(":folder"), folderPath);
349
1/1
✓ Branch 55 → 56 taken 6 times.
6 q.exec();
350
351 // Delete folder document count
352
1/1
✓ Branch 59 → 60 taken 6 times.
6 q.prepare(QStringLiteral(
353 "DELETE FROM folder_doc_counts WHERE folder = :folder"));
354
1/1
✓ Branch 67 → 68 taken 6 times.
12 q.bindValue(QStringLiteral(":folder"), folderPath);
355
1/1
✓ Branch 71 → 72 taken 6 times.
6 q.exec();
356
357 // Subtract from total_docs
358
2/2
✓ Branch 72 → 73 taken 3 times.
✓ Branch 72 → 89 taken 3 times.
6 if (docCount > 0) {
359
1/1
✓ Branch 76 → 77 taken 3 times.
3 q.prepare(QStringLiteral(
360 "UPDATE meta SET value = CAST(MAX(0, CAST(value AS INTEGER) - :count) "
361 "AS TEXT) WHERE key = 'total_docs'"));
362
1/1
✓ Branch 84 → 85 taken 3 times.
6 q.bindValue(QStringLiteral(":count"), docCount);
363
1/1
✓ Branch 88 → 89 taken 3 times.
3 q.exec();
364 }
365
1/1
✓ Branch 89 → 90 taken 6 times.
6 touchStatisticsRevision();
366
367
1/1
✓ Branch 90 → 91 taken 6 times.
6 m_db.commit();
368 6 }
369
370 // T-287: Rename folder in training data (for folder rename/move operations).
371 // F-51 (SPRINT-104): the rename carries the folder AND its children, and
372 // the completed-training checkpoint table moves with it (a stale checkpoint
373 // under the old path kept the next background training re-scanning the
374 // renamed folder from scratch — noted by the audit's rules/search pass).
375 // S106-211: one checked transaction that MERGES into rows the new path may
376 // already have (a training job or an earlier rename can have written them):
377 // the former UPDATE failed on UNIQUE(token, folder) or on a busy writer
378 // lock without a word, and the rows stayed under the old path, where the
379 // next bootstrap's ledger no longer found them and counted the mail again.
380 // Lengths are code points like SQLite's substr() (S106-112).
381 7 bool FolderPredictor::renameFolderData(const QString &oldPath,
382 const QString &newPath,
383 const QString &delimiter) {
384 7 QMutexLocker locker(&m_mutex);
385
8/11
✓ Branch 3 → 4 taken 7 times.
✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 11 taken 1 time.
✓ Branch 6 → 7 taken 6 times.
✗ Branch 6 → 11 not taken.
✓ Branch 8 → 9 taken 6 times.
✗ Branch 8 → 11 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 6 times.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 6 times.
7 if (!m_db.isOpen() || oldPath.isEmpty() || newPath.isEmpty() || oldPath == newPath)
386 1 return false;
387
1/1
✓ Branch 15 → 16 taken 6 times.
6 QSqlQuery q(m_db);
388
2/3
✓ Branch 19 → 20 taken 6 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 39 taken 6 times.
6 if (!q.exec(QStringLiteral("BEGIN IMMEDIATE"))) {
389 ✗ qCWarning(lcFolderPredictor) << "Folder rename not applied:" << q.lastError().text();
390 ✗ return false;
391 }
392 7 auto rollback = qScopeGuard([this] { m_db.rollback(); });
393
1/1
✓ Branch 40 → 41 taken 6 times.
6 const QString prefix = oldPath + delimiter;
394
1/1
✓ Branch 41 → 42 taken 6 times.
6 const qsizetype oldLength = oldPath.toUcs4().size();
395 6 const QString matches = QStringLiteral("(folder = :oldPath OR substr(folder, 1, :oldLen) = :oldPrefix)");
396 6 const QString renamed = QStringLiteral(":newPath || substr(folder, :oldLen)");
397 40 const auto run = [&](const QString &sql) {
398
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 40 times.
40 if (!q.prepare(sql)) return false;
399 // Only the placeholders this statement has.
400
4/4
✓ Branch 8 → 9 taken 40 times.
✓ Branch 11 → 12 taken 20 times.
✓ Branch 11 → 22 taken 20 times.
✓ Branch 17 → 18 taken 20 times.
60 if (sql.contains(QStringLiteral(":newPath"))) q.bindValue(QStringLiteral(":newPath"), newPath);
401
1/1
✓ Branch 27 → 28 taken 40 times.
80 q.bindValue(QStringLiteral(":oldPath"), oldPath);
402
1/1
✓ Branch 36 → 37 taken 40 times.
80 q.bindValue(QStringLiteral(":oldLen"), oldLength + 1);
403
1/1
✓ Branch 45 → 46 taken 40 times.
80 q.bindValue(QStringLiteral(":oldPrefix"), prefix);
404 40 return q.exec();
405 6 };
406 // Each table: copy the matching rows with their new path aside, delete
407 // them, then merge the copies back. The new path may lie below the old
408 // one ("Projects" → "Projects/2026"), so a plain INSERT-then-DELETE would
409 // delete the rows it had just written.
410 21 const auto move = [&](const QString &table, const QString &columns, const QString &renamedColumns,
411 const QString &onConflict) {
412
1/6
✓ Branch 59 → 60 taken 21 times.
✗ Branch 59 → 61 not taken.
✗ Branch 111 → 112 not taken.
✗ Branch 111 → 113 not taken.
✗ Branch 115 → 116 not taken.
✗ Branch 115 → 117 not taken.
42 return q.exec(QStringLiteral("CREATE TEMP TABLE predictor_rename AS SELECT %1 FROM %2 WHERE 0")
413
2/5
✓ Branch 5 → 6 taken 21 times.
✓ Branch 57 → 58 taken 21 times.
✗ Branch 57 → 59 not taken.
✗ Branch 107 → 108 not taken.
✗ Branch 107 → 109 not taken.
42 .arg(columns, table)) &&
414
5/11
✓ Branch 12 → 13 taken 20 times.
✓ Branch 13 → 14 taken 20 times.
✗ Branch 13 → 32 not taken.
✓ Branch 53 → 54 taken 20 times.
✓ Branch 53 → 55 taken 1 time.
✓ Branch 55 → 56 taken 21 times.
✗ Branch 55 → 57 not taken.
✗ Branch 99 → 100 not taken.
✗ Branch 99 → 101 not taken.
✗ Branch 103 → 104 not taken.
✗ Branch 103 → 105 not taken.
81 run(QStringLiteral("INSERT INTO predictor_rename(%1) SELECT %2 FROM %3 WHERE %4")
415
3/5
✓ Branch 11 → 12 taken 20 times.
✓ Branch 51 → 52 taken 20 times.
✓ Branch 51 → 53 taken 1 time.
✗ Branch 95 → 96 not taken.
✗ Branch 95 → 97 not taken.
41 .arg(columns, renamedColumns, table, matches)) &&
416
9/16
✓ Branch 17 → 18 taken 20 times.
✓ Branch 18 → 19 taken 20 times.
✓ Branch 19 → 20 taken 20 times.
✗ Branch 19 → 32 not taken.
✓ Branch 45 → 46 taken 20 times.
✓ Branch 45 → 47 taken 1 time.
✓ Branch 47 → 48 taken 20 times.
✓ Branch 47 → 49 taken 1 time.
✓ Branch 49 → 50 taken 20 times.
✓ Branch 49 → 51 taken 1 time.
✗ Branch 83 → 84 not taken.
✗ Branch 83 → 85 not taken.
✗ Branch 87 → 88 not taken.
✗ Branch 87 → 89 not taken.
✗ Branch 91 → 92 not taken.
✗ Branch 91 → 93 not taken.
61 run(QStringLiteral("DELETE FROM %1 WHERE %2").arg(table, matches)) &&
417
6/11
✓ Branch 24 → 25 taken 20 times.
✓ Branch 25 → 26 taken 20 times.
✗ Branch 25 → 32 not taken.
✓ Branch 41 → 42 taken 20 times.
✓ Branch 41 → 43 taken 1 time.
✓ Branch 43 → 44 taken 20 times.
✓ Branch 43 → 45 taken 1 time.
✗ Branch 75 → 76 not taken.
✗ Branch 75 → 77 not taken.
✗ Branch 79 → 80 not taken.
✗ Branch 79 → 81 not taken.
81 q.exec(QStringLiteral("INSERT INTO %1(%2) SELECT %2 FROM predictor_rename WHERE true %3")
418
6/8
✓ Branch 6 → 7 taken 21 times.
✓ Branch 7 → 8 taken 20 times.
✓ Branch 7 → 32 taken 1 time.
✓ Branch 23 → 24 taken 20 times.
✓ Branch 39 → 40 taken 20 times.
✓ Branch 39 → 41 taken 1 time.
✗ Branch 71 → 72 not taken.
✗ Branch 71 → 73 not taken.
103 .arg(table, columns, onConflict)) &&
419
8/13
✓ Branch 29 → 30 taken 20 times.
✓ Branch 30 → 31 taken 20 times.
✗ Branch 30 → 32 not taken.
✓ Branch 33 → 34 taken 20 times.
✓ Branch 33 → 35 taken 1 time.
✓ Branch 35 → 36 taken 20 times.
✓ Branch 35 → 37 taken 1 time.
✓ Branch 37 → 38 taken 20 times.
✓ Branch 37 → 39 taken 1 time.
✗ Branch 63 → 64 not taken.
✗ Branch 63 → 65 not taken.
✗ Branch 67 → 68 not taken.
✗ Branch 67 → 69 not taken.
82 q.exec(QStringLiteral("DROP TABLE predictor_rename"));
420 6 };
421 const bool ok =
422
3/14
✓ Branch 174 → 175 taken 6 times.
✗ Branch 174 → 176 not taken.
✓ Branch 176 → 177 taken 6 times.
✗ Branch 176 → 178 not taken.
✓ Branch 178 → 179 taken 6 times.
✗ Branch 178 → 180 not taken.
✗ Branch 355 → 356 not taken.
✗ Branch 355 → 357 not taken.
✗ Branch 359 → 360 not taken.
✗ Branch 359 → 361 not taken.
✗ Branch 363 → 364 not taken.
✗ Branch 363 → 365 not taken.
✗ Branch 367 → 368 not taken.
✗ Branch 367 → 369 not taken.
18 move(QStringLiteral("token_counts"), QStringLiteral("token, folder, count"),
423
4/13
✓ Branch 59 → 60 taken 6 times.
✓ Branch 180 → 181 taken 6 times.
✗ Branch 180 → 182 not taken.
✓ Branch 182 → 183 taken 6 times.
✗ Branch 182 → 184 not taken.
✓ Branch 184 → 185 taken 6 times.
✗ Branch 184 → 186 not taken.
✗ Branch 371 → 372 not taken.
✗ Branch 371 → 373 not taken.
✗ Branch 376 → 377 not taken.
✗ Branch 376 → 378 not taken.
✗ Branch 380 → 381 not taken.
✗ Branch 380 → 382 not taken.
12 QStringLiteral("token, %1 AS folder, count").arg(renamed),
424
2/8
✓ Branch 186 → 187 taken 6 times.
✗ Branch 186 → 188 not taken.
✓ Branch 188 → 189 taken 6 times.
✗ Branch 188 → 190 not taken.
✗ Branch 384 → 385 not taken.
✗ Branch 384 → 386 not taken.
✗ Branch 388 → 389 not taken.
✗ Branch 388 → 390 not taken.
27 QStringLiteral("ON CONFLICT(token, folder) DO UPDATE SET count = count + excluded.count")) &&
425
8/17
✓ Branch 82 → 83 taken 5 times.
✓ Branch 83 → 84 taken 5 times.
✗ Branch 83 → 117 not taken.
✓ Branch 156 → 157 taken 5 times.
✓ Branch 156 → 158 taken 1 time.
✓ Branch 158 → 159 taken 5 times.
✓ Branch 158 → 160 taken 1 time.
✓ Branch 160 → 161 taken 5 times.
✓ Branch 160 → 162 taken 1 time.
✗ Branch 318 → 319 not taken.
✗ Branch 318 → 320 not taken.
✗ Branch 322 → 323 not taken.
✗ Branch 322 → 324 not taken.
✗ Branch 326 → 327 not taken.
✗ Branch 326 → 328 not taken.
✗ Branch 330 → 331 not taken.
✗ Branch 330 → 332 not taken.
16 move(QStringLiteral("folder_doc_counts"), QStringLiteral("folder, doc_count"),
426
7/13
✓ Branch 75 → 76 taken 5 times.
✓ Branch 162 → 163 taken 5 times.
✓ Branch 162 → 164 taken 1 time.
✓ Branch 164 → 165 taken 5 times.
✓ Branch 164 → 166 taken 1 time.
✓ Branch 166 → 167 taken 5 times.
✓ Branch 166 → 168 taken 1 time.
✗ Branch 334 → 335 not taken.
✗ Branch 334 → 336 not taken.
✗ Branch 339 → 340 not taken.
✗ Branch 339 → 341 not taken.
✗ Branch 343 → 344 not taken.
✗ Branch 343 → 345 not taken.
11 QStringLiteral("%1 AS folder, doc_count").arg(renamed),
427
5/10
✓ Branch 168 → 169 taken 5 times.
✓ Branch 168 → 170 taken 1 time.
✓ Branch 170 → 171 taken 5 times.
✓ Branch 170 → 172 taken 1 time.
✓ Branch 172 → 173 taken 6 times.
✗ Branch 172 → 174 not taken.
✗ Branch 347 → 348 not taken.
✗ Branch 347 → 349 not taken.
✗ Branch 351 → 352 not taken.
✗ Branch 351 → 353 not taken.
26 QStringLiteral("ON CONFLICT(folder) DO UPDATE SET doc_count = doc_count + excluded.doc_count")) &&
428
8/17
✓ Branch 98 → 99 taken 5 times.
✓ Branch 99 → 100 taken 5 times.
✗ Branch 99 → 117 not taken.
✓ Branch 138 → 139 taken 5 times.
✓ Branch 138 → 140 taken 1 time.
✓ Branch 140 → 141 taken 5 times.
✓ Branch 140 → 142 taken 1 time.
✓ Branch 142 → 143 taken 5 times.
✓ Branch 142 → 144 taken 1 time.
✗ Branch 281 → 282 not taken.
✗ Branch 281 → 283 not taken.
✗ Branch 285 → 286 not taken.
✗ Branch 285 → 287 not taken.
✗ Branch 289 → 290 not taken.
✗ Branch 289 → 291 not taken.
✗ Branch 293 → 294 not taken.
✗ Branch 293 → 295 not taken.
16 move(QStringLiteral("trained_messages"), QStringLiteral("account, folder, uid, uid_validity"),
429
7/13
✓ Branch 91 → 92 taken 5 times.
✓ Branch 144 → 145 taken 5 times.
✓ Branch 144 → 146 taken 1 time.
✓ Branch 146 → 147 taken 5 times.
✓ Branch 146 → 148 taken 1 time.
✓ Branch 148 → 149 taken 5 times.
✓ Branch 148 → 150 taken 1 time.
✗ Branch 297 → 298 not taken.
✗ Branch 297 → 299 not taken.
✗ Branch 302 → 303 not taken.
✗ Branch 302 → 304 not taken.
✗ Branch 306 → 307 not taken.
✗ Branch 306 → 308 not taken.
11 QStringLiteral("account, %1 AS folder, uid, uid_validity").arg(renamed),
430
9/13
✓ Branch 66 → 67 taken 6 times.
✓ Branch 67 → 68 taken 5 times.
✓ Branch 67 → 117 taken 1 time.
✓ Branch 150 → 151 taken 5 times.
✓ Branch 150 → 152 taken 1 time.
✓ Branch 152 → 153 taken 5 times.
✓ Branch 152 → 154 taken 1 time.
✓ Branch 154 → 155 taken 5 times.
✓ Branch 154 → 156 taken 1 time.
✗ Branch 310 → 311 not taken.
✗ Branch 310 → 312 not taken.
✗ Branch 314 → 315 not taken.
✗ Branch 314 → 316 not taken.
61 QStringLiteral("ON CONFLICT DO NOTHING")) &&
431
4/9
✓ Branch 114 → 115 taken 5 times.
✓ Branch 115 → 116 taken 5 times.
✗ Branch 115 → 117 not taken.
✓ Branch 120 → 121 taken 5 times.
✓ Branch 120 → 122 taken 1 time.
✗ Branch 244 → 245 not taken.
✗ Branch 244 → 246 not taken.
✗ Branch 248 → 249 not taken.
✗ Branch 248 → 250 not taken.
11 move(QStringLiteral("completed_training_folders"),
432
4/8
✓ Branch 122 → 123 taken 5 times.
✓ Branch 122 → 124 taken 1 time.
✓ Branch 124 → 125 taken 5 times.
✓ Branch 124 → 126 taken 1 time.
✗ Branch 252 → 253 not taken.
✗ Branch 252 → 254 not taken.
✗ Branch 256 → 257 not taken.
✗ Branch 256 → 258 not taken.
11 QStringLiteral("account, folder, folder_id, uid_validity, revision"),
433
7/13
✓ Branch 107 → 108 taken 5 times.
✓ Branch 126 → 127 taken 5 times.
✓ Branch 126 → 128 taken 1 time.
✓ Branch 128 → 129 taken 5 times.
✓ Branch 128 → 130 taken 1 time.
✓ Branch 130 → 131 taken 5 times.
✓ Branch 130 → 132 taken 1 time.
✗ Branch 260 → 261 not taken.
✗ Branch 260 → 262 not taken.
✗ Branch 265 → 266 not taken.
✗ Branch 265 → 267 not taken.
✗ Branch 269 → 270 not taken.
✗ Branch 269 → 271 not taken.
11 QStringLiteral("account, %1 AS folder, folder_id, uid_validity, revision").arg(renamed),
434
8/12
✓ Branch 118 → 119 taken 5 times.
✓ Branch 118 → 120 taken 1 time.
✓ Branch 132 → 133 taken 5 times.
✓ Branch 132 → 134 taken 1 time.
✓ Branch 134 → 135 taken 5 times.
✓ Branch 134 → 136 taken 1 time.
✓ Branch 136 → 137 taken 5 times.
✓ Branch 136 → 138 taken 1 time.
✗ Branch 273 → 274 not taken.
✗ Branch 273 → 275 not taken.
✗ Branch 277 → 278 not taken.
✗ Branch 277 → 279 not taken.
17 QStringLiteral("ON CONFLICT DO NOTHING"));
435
2/2
✓ Branch 190 → 191 taken 1 time.
✓ Branch 190 → 207 taken 5 times.
6 if (!ok)
436
8/8
✓ Branch 191 → 192 taken 1 time.
✓ Branch 195 → 196 taken 1 time.
✓ Branch 196 → 197 taken 1 time.
✓ Branch 197 → 198 taken 1 time.
✓ Branch 198 → 199 taken 1 time.
✓ Branch 199 → 200 taken 1 time.
✓ Branch 205 → 193 taken 1 time.
✓ Branch 205 → 206 taken 1 time.
2 qCWarning(lcFolderPredictor) << "Folder rename not applied:" << q.lastError().text();
437
8/10
✓ Branch 207 → 208 taken 5 times.
✓ Branch 207 → 212 taken 1 time.
✓ Branch 208 → 209 taken 5 times.
✓ Branch 209 → 210 taken 5 times.
✗ Branch 209 → 212 not taken.
✓ Branch 210 → 211 taken 5 times.
✗ Branch 211 → 212 not taken.
✓ Branch 211 → 213 taken 5 times.
✓ Branch 214 → 215 taken 1 time.
✓ Branch 214 → 216 taken 5 times.
6 if (!ok || !touchStatisticsRevision() || !m_db.commit())
438 1 return false;
439 5 rollback.dismiss();
440 5 return true;
441 7 }
442
443 // --- Tokenizer ---
444
445 29696 QString FolderPredictor::extractEmail(const QString &fromField) const {
446 // "Max Mustermann <max@example.com>" → "max@example.com"
447
3/3
✓ Branch 2 → 3 taken 354 times.
✓ Branch 2 → 12 taken 29342 times.
✓ Branch 7 → 8 taken 354 times.
30050 thread_local static QRegularExpression angleRe(QStringLiteral("<([^>]+)>"));
448
1/1
✓ Branch 13 → 14 taken 29696 times.
29696 auto match = angleRe.match(fromField);
449
3/3
✓ Branch 14 → 15 taken 29696 times.
✓ Branch 15 → 16 taken 6913 times.
✓ Branch 15 → 23 taken 22783 times.
29696 if (match.hasMatch()) {
450
3/3
✓ Branch 16 → 17 taken 6913 times.
✓ Branch 17 → 18 taken 6913 times.
✓ Branch 18 → 19 taken 6913 times.
6913 return match.captured(1).trimmed().toLower();
451 }
452
2/2
✓ Branch 23 → 24 taken 22783 times.
✓ Branch 24 → 25 taken 22783 times.
22783 return fromField.trimmed().toLower();
453 29696 }
454
455 13341 QStringList FolderPredictor::tokenize(const QString &from,
456 const QString &subject,
457 const QString &to) const {
458 13341 QStringList tokens;
459
460 // --- From tokens (boosted 3× — sender is strongest signal) ---
461
1/1
✓ Branch 2 → 3 taken 13341 times.
13341 QString email = extractEmail(from);
462
2/2
✓ Branch 4 → 5 taken 13332 times.
✓ Branch 4 → 52 taken 9 times.
13341 if (!email.isEmpty()) {
463 // Full email address (3× boost)
464
2/2
✓ Branch 15 → 6 taken 39996 times.
✓ Branch 15 → 16 taken 13332 times.
53328 for (int b = 0; b < 3; ++b)
465
2/2
✓ Branch 9 → 10 taken 39996 times.
✓ Branch 10 → 11 taken 39996 times.
39996 tokens.append(QStringLiteral("from:") + email);
466
467 13332 int atIdx = email.indexOf(QLatin1Char('@'));
468
2/2
✓ Branch 19 → 20 taken 13328 times.
✓ Branch 19 → 52 taken 4 times.
13332 if (atIdx > 0) {
469
1/1
✓ Branch 20 → 21 taken 13328 times.
13328 QString user = email.left(atIdx);
470
1/1
✓ Branch 21 → 22 taken 13328 times.
13328 QString domain = email.mid(atIdx + 1);
471
2/2
✓ Branch 25 → 26 taken 13328 times.
✓ Branch 26 → 27 taken 13328 times.
13328 tokens.append(QStringLiteral("from:") + user);
472 // Domain (2× boost — most mails from same domain go to same folder)
473
2/2
✓ Branch 48 → 31 taken 26656 times.
✓ Branch 48 → 49 taken 13328 times.
39984 for (int b = 0; b < 2; ++b) {
474
2/2
✓ Branch 34 → 35 taken 26656 times.
✓ Branch 35 → 36 taken 26656 times.
26656 tokens.append(QStringLiteral("from:") + domain);
475
2/2
✓ Branch 42 → 43 taken 26656 times.
✓ Branch 43 → 44 taken 26656 times.
26656 tokens.append(QStringLiteral("from_domain:") + domain);
476 }
477 13328 }
478 }
479
480 // --- Subject tokens ---
481 13341 QString subj = subject;
482 // Strip Re:/Fwd:/AW:/WG: prefixes
483 thread_local static QRegularExpression prefixRe(
484 264 QStringLiteral("^\\s*(Re|Fwd|AW|WG)\\s*:\\s*"),
485
3/3
✓ Branch 53 → 54 taken 132 times.
✓ Branch 53 → 63 taken 13209 times.
✓ Branch 58 → 59 taken 132 times.
13605 QRegularExpression::CaseInsensitiveOption);
486
3/3
✓ Branch 68 → 69 taken 14227 times.
✓ Branch 69 → 64 taken 886 times.
✓ Branch 69 → 70 taken 13341 times.
14227 while (subj.contains(prefixRe)) {
487
1/1
✓ Branch 65 → 66 taken 886 times.
886 subj.replace(prefixRe, QString());
488 }
489
490 // Split on whitespace + punctuation
491
3/3
✓ Branch 70 → 71 taken 132 times.
✓ Branch 70 → 80 taken 13209 times.
✓ Branch 75 → 76 taken 132 times.
13473 thread_local static QRegularExpression splitRe(QStringLiteral("[\\s,.;:!?()\\[\\]{}<>\"'/\\\\]+"));
492
1/1
✓ Branch 81 → 82 taken 13341 times.
13341 const QStringList words = subj.split(splitRe, Qt::SkipEmptyParts);
493
2/2
✓ Branch 105 → 84 taken 32691 times.
✓ Branch 105 → 106 taken 13341 times.
46032 for (const QString &word : words) {
494
1/1
✓ Branch 85 → 86 taken 32691 times.
32691 QString lower = word.toLower();
495
6/6
✓ Branch 87 → 88 taken 31065 times.
✓ Branch 87 → 91 taken 1626 times.
✓ Branch 89 → 90 taken 30865 times.
✓ Branch 89 → 91 taken 200 times.
✓ Branch 92 → 93 taken 30865 times.
✓ Branch 92 → 102 taken 1826 times.
32691 if (lower.size() >= 2 && !s_stopWords.contains(lower)) {
496
2/2
✓ Branch 96 → 97 taken 30865 times.
✓ Branch 97 → 98 taken 30865 times.
30865 tokens.append(QStringLiteral("subj:") + lower);
497 }
498 32691 }
499
500 // --- To tokens ---
501 // May contain multiple addresses separated by comma/semicolon
502
3/3
✓ Branch 106 → 107 taken 132 times.
✓ Branch 106 → 116 taken 13209 times.
✓ Branch 111 → 112 taken 132 times.
13473 thread_local static QRegularExpression addrSplitRe(QStringLiteral("[,;]+"));
503
1/1
✓ Branch 117 → 118 taken 13341 times.
13341 const QStringList toAddrs = to.split(addrSplitRe, Qt::SkipEmptyParts);
504
2/2
✓ Branch 143 → 120 taken 12019 times.
✓ Branch 143 → 144 taken 13341 times.
25360 for (const QString &addr : toAddrs) {
505
1/1
✓ Branch 121 → 122 taken 12019 times.
12019 QString toEmail = extractEmail(addr);
506
6/7
✓ Branch 123 → 124 taken 12019 times.
✗ Branch 123 → 129 not taken.
✓ Branch 126 → 127 taken 12019 times.
✓ Branch 127 → 128 taken 12016 times.
✓ Branch 127 → 129 taken 3 times.
✓ Branch 130 → 131 taken 12016 times.
✓ Branch 130 → 140 taken 3 times.
12019 if (!toEmail.isEmpty() && toEmail.contains(QLatin1Char('@'))) {
507
2/2
✓ Branch 134 → 135 taken 12016 times.
✓ Branch 135 → 136 taken 12016 times.
12016 tokens.append(QStringLiteral("to:") + toEmail);
508 }
509 12019 }
510
511 13341 return tokens;
512 13341 }
513
514 // --- Training ---
515
516 4765 void FolderPredictor::train(const QString &from, const QString &subject,
517 const QString &to, const QString &targetFolder) {
518 4765 QMutexLocker locker(&m_mutex);
519
7/7
✓ Branch 3 → 4 taken 4765 times.
✓ Branch 4 → 5 taken 4764 times.
✓ Branch 4 → 7 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 4763 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 11 taken 4763 times.
4765 if (!m_db.isOpen() || targetFolder.isEmpty())
520 2 return;
521
522
1/1
✓ Branch 11 → 12 taken 4763 times.
4763 const QStringList tokens = tokenize(from, subject, to);
523
2/2
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 4762 times.
4763 if (tokens.isEmpty())
524 1 return;
525
526
1/1
✓ Branch 15 → 16 taken 4762 times.
4762 QSqlQuery q(m_db);
527
1/1
✓ Branch 16 → 17 taken 4762 times.
4762 m_db.transaction();
528
529 // Update token counts
530
1/1
✓ Branch 20 → 21 taken 4762 times.
4762 q.prepare(QStringLiteral(
531 "INSERT INTO token_counts (token, folder, count) VALUES (?, ?, 1) "
532 "ON CONFLICT(token, folder) DO UPDATE SET count = count + 1"));
533
2/2
✓ Branch 37 → 25 taken 56313 times.
✓ Branch 37 → 38 taken 4762 times.
61075 for (const QString &token : tokens) {
534
1/1
✓ Branch 28 → 29 taken 56313 times.
56313 q.bindValue(0, token);
535
1/1
✓ Branch 32 → 33 taken 56313 times.
56313 q.bindValue(1, targetFolder);
536
1/1
✓ Branch 34 → 35 taken 56313 times.
56313 q.exec();
537 }
538
539 // Update folder document count
540
1/1
✓ Branch 41 → 42 taken 4762 times.
4762 q.prepare(QStringLiteral(
541 "INSERT INTO folder_doc_counts (folder, doc_count) VALUES (?, 1) "
542 "ON CONFLICT(folder) DO UPDATE SET doc_count = doc_count + 1"));
543
1/1
✓ Branch 46 → 47 taken 4762 times.
4762 q.bindValue(0, targetFolder);
544
1/1
✓ Branch 48 → 49 taken 4762 times.
4762 q.exec();
545
546 // Increment total docs
547
1/1
✓ Branch 52 → 53 taken 4762 times.
4762 q.exec(QStringLiteral(
548 "UPDATE meta SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) "
549 "WHERE key = 'total_docs'"));
550
1/1
✓ Branch 55 → 56 taken 4762 times.
4762 touchStatisticsRevision();
551
552
1/1
✓ Branch 56 → 57 taken 4762 times.
4762 m_db.commit();
553 4766 }
554
555 25 void FolderPredictor::untrain(const QString &from, const QString &subject,
556 const QString &to, const QString &wrongFolder) {
557 25 QMutexLocker locker(&m_mutex);
558
7/7
✓ Branch 3 → 4 taken 25 times.
✓ Branch 4 → 5 taken 24 times.
✓ Branch 4 → 7 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 23 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 11 taken 23 times.
25 if (!m_db.isOpen() || wrongFolder.isEmpty())
559 2 return;
560
561
1/1
✓ Branch 11 → 12 taken 23 times.
23 const QStringList tokens = tokenize(from, subject, to);
562
2/2
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 22 times.
23 if (tokens.isEmpty())
563 1 return;
564
565
1/1
✓ Branch 15 → 16 taken 22 times.
22 QSqlQuery q(m_db);
566
1/1
✓ Branch 16 → 17 taken 22 times.
22 m_db.transaction();
567
568 // Decrement token counts (don't go below 0)
569
1/1
✓ Branch 20 → 21 taken 22 times.
22 q.prepare(QStringLiteral(
570 "UPDATE token_counts SET count = MAX(count - 1, 0) "
571 "WHERE token = ? AND folder = ?"));
572
2/2
✓ Branch 37 → 25 taken 226 times.
✓ Branch 37 → 38 taken 22 times.
248 for (const QString &token : tokens) {
573
1/1
✓ Branch 28 → 29 taken 226 times.
226 q.bindValue(0, token);
574
1/1
✓ Branch 32 → 33 taken 226 times.
226 q.bindValue(1, wrongFolder);
575
1/1
✓ Branch 34 → 35 taken 226 times.
226 q.exec();
576 }
577
578 // Clean up zero-count entries
579
1/1
✓ Branch 41 → 42 taken 22 times.
22 q.exec(QStringLiteral("DELETE FROM token_counts WHERE count = 0"));
580
581 // Decrement folder doc count
582
1/1
✓ Branch 47 → 48 taken 22 times.
22 q.prepare(QStringLiteral(
583 "UPDATE folder_doc_counts SET doc_count = MAX(doc_count - 1, 0) "
584 "WHERE folder = ?"));
585
1/1
✓ Branch 52 → 53 taken 22 times.
22 q.bindValue(0, wrongFolder);
586
1/1
✓ Branch 54 → 55 taken 22 times.
22 q.exec();
587
588 // Decrement total docs
589
1/1
✓ Branch 58 → 59 taken 22 times.
22 q.exec(QStringLiteral(
590 "UPDATE meta SET value = CAST(MAX(CAST(value AS INTEGER) - 1, 0) AS TEXT) "
591 "WHERE key = 'total_docs'"));
592
1/1
✓ Branch 61 → 62 taken 22 times.
22 touchStatisticsRevision();
593
594
1/1
✓ Branch 62 → 63 taken 22 times.
22 m_db.commit();
595 26 }
596
597 // --- Pre-training from cache (T-167) ---
598
599 776 bool FolderPredictor::trainFromCache(MailCache *cache, const QString &account,
600 const QStringList &excludeFolders, QString *error,
601 const std::atomic_bool *cancelled,
602 int *trainedDocuments) {
603 776 QMutexLocker locker(&m_mutex);
604
2/2
✓ Branch 3 → 4 taken 767 times.
✓ Branch 3 → 5 taken 9 times.
776 if (error) error->clear();
605
2/2
✓ Branch 5 → 6 taken 763 times.
✓ Branch 5 → 7 taken 13 times.
776 if (trainedDocuments) *trainedDocuments = 0;
606 11 const auto fail = [error](const QString &message) {
607
2/2
✓ Branch 2 → 3 taken 8 times.
✓ Branch 2 → 4 taken 3 times.
11 if (error) *error = message;
608 11 return false;
609 776 };
610 2178 const auto interrupted = [cancelled] {
611
4/4
✓ Branch 2 → 3 taken 2006 times.
✓ Branch 2 → 6 taken 172 times.
✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 6 taken 2003 times.
2178 return cancelled && cancelled->load(std::memory_order_relaxed);
612 776 };
613
9/10
✓ Branch 7 → 8 taken 776 times.
✓ Branch 8 → 9 taken 774 times.
✓ Branch 8 → 12 taken 2 times.
✓ Branch 9 → 10 taken 773 times.
✓ Branch 9 → 12 taken 1 time.
✓ Branch 10 → 11 taken 773 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 773 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 14 → 22 taken 773 times.
776 if (!m_db.isOpen() || !cache || !cache->isOpen())
614 3 return fail(QStringLiteral("Predictor or training cache is not open"));
615
2/2
✓ Branch 23 → 24 taken 2 times.
✓ Branch 23 → 31 taken 771 times.
775 if (interrupted()) return fail(QStringLiteral("Predictor training cancelled"));
616
617
1/1
✓ Branch 31 → 32 taken 771 times.
771 QSqlQuery q(m_db);
618
619 // Get all folder paths
620 771 QString cacheError;
621
1/1
✓ Branch 32 → 33 taken 771 times.
771 QStringList folderPaths = cache->allFolderPaths(account, &cacheError);
622
2/2
✓ Branch 34 → 35 taken 1 time.
✓ Branch 34 → 36 taken 770 times.
771 if (!cacheError.isEmpty()) return fail(cacheError);
623
2/2
✓ Branch 37 → 38 taken 140 times.
✓ Branch 37 → 39 taken 630 times.
770 if (folderPaths.isEmpty())
624 140 return true;
625
626 // Filter excluded folders (case-insensitive match)
627 630 QStringList trainFolders;
628
4/4
✓ Branch 39 → 40 taken 630 times.
✓ Branch 40 → 41 taken 630 times.
✓ Branch 87 → 42 taken 1882 times.
✓ Branch 87 → 88 taken 630 times.
2512 for (const QString &path : folderPaths) {
629 1882 bool excluded = false;
630
2/2
✓ Branch 82 → 45 taken 8861 times.
✓ Branch 82 → 83 taken 1047 times.
9908 for (const QString &ex : excludeFolders) {
631 33790 if (path.compare(ex, Qt::CaseInsensitive) == 0 ||
632
6/8
✓ Branch 50 → 51 taken 8034 times.
✓ Branch 51 → 52 taken 8034 times.
✓ Branch 52 → 53 taken 8032 times.
✓ Branch 52 → 68 taken 2 times.
✓ Branch 78 → 79 taken 835 times.
✓ Branch 78 → 80 taken 8026 times.
✗ Branch 338 → 339 not taken.
✗ Branch 338 → 340 not taken.
16895 path.endsWith(QLatin1Char('.') + ex, Qt::CaseInsensitive) ||
633
6/8
✓ Branch 55 → 56 taken 8032 times.
✓ Branch 56 → 57 taken 8032 times.
✓ Branch 57 → 58 taken 8030 times.
✓ Branch 57 → 68 taken 2 times.
✓ Branch 76 → 77 taken 8034 times.
✓ Branch 76 → 78 taken 827 times.
✗ Branch 332 → 333 not taken.
✗ Branch 332 → 334 not taken.
16893 path.endsWith(QLatin1Char('/') + ex, Qt::CaseInsensitive) ||
634
8/10
✓ Branch 47 → 48 taken 8034 times.
✓ Branch 47 → 68 taken 827 times.
✓ Branch 60 → 61 taken 8030 times.
✓ Branch 61 → 62 taken 8030 times.
✓ Branch 62 → 63 taken 8028 times.
✓ Branch 62 → 68 taken 2 times.
✓ Branch 74 → 75 taken 8032 times.
✓ Branch 74 → 76 taken 829 times.
✗ Branch 326 → 327 not taken.
✗ Branch 326 → 328 not taken.
33784 path.startsWith(ex + QLatin1Char('.'), Qt::CaseInsensitive) ||
635
8/10
✓ Branch 65 → 66 taken 8028 times.
✓ Branch 66 → 67 taken 8028 times.
✓ Branch 67 → 68 taken 2 times.
✓ Branch 67 → 69 taken 8026 times.
✓ Branch 70 → 71 taken 8028 times.
✓ Branch 70 → 72 taken 833 times.
✓ Branch 72 → 73 taken 8030 times.
✓ Branch 72 → 74 taken 831 times.
✗ Branch 320 → 321 not taken.
✗ Branch 320 → 322 not taken.
16889 path.startsWith(ex + QLatin1Char('/'), Qt::CaseInsensitive)) {
636 835 excluded = true;
637 835 break;
638 }
639 }
640
2/2
✓ Branch 83 → 84 taken 1047 times.
✓ Branch 83 → 85 taken 835 times.
1882 if (!excluded)
641
1/1
✓ Branch 84 → 85 taken 1047 times.
1047 trainFolders.append(path);
642 }
643
644 // Count total headers for progress
645 630 int totalHeaders = 0;
646
4/4
✓ Branch 88 → 89 taken 630 times.
✓ Branch 89 → 90 taken 630 times.
✓ Branch 99 → 91 taken 1047 times.
✓ Branch 99 → 100 taken 630 times.
1677 for (const QString &folder : trainFolders) {
647
1/1
✓ Branch 93 → 94 taken 1047 times.
1047 totalHeaders += cache->payloadLimits().cachedHeaderCount(account, folder, &cacheError);
648
1/2
✗ Branch 95 → 96 not taken.
✓ Branch 95 → 97 taken 1047 times.
1047 if (!cacheError.isEmpty()) return fail(cacheError);
649 }
650
651
2/2
✓ Branch 100 → 102 taken 476 times.
✓ Branch 100 → 103 taken 154 times.
630 if (totalHeaders == 0)
652 476 return true;
653
654
1/1
✓ Branch 103 → 104 taken 154 times.
154 q.finish();
655 154 int processed = 0;
656
4/4
✓ Branch 104 → 105 taken 154 times.
✓ Branch 105 → 106 taken 154 times.
✓ Branch 284 → 107 taken 359 times.
✓ Branch 284 → 285 taken 144 times.
503 for (const QString &folder : trainFolders) {
657
1/2
✗ Branch 109 → 110 not taken.
✓ Branch 109 → 117 taken 359 times.
359 if (interrupted()) return fail(QStringLiteral("Predictor training cancelled"));
658
1/1
✓ Branch 118 → 119 taken 359 times.
359 const auto snapshot = cache->training().trainingSnapshot(account, folder);
659
1/2
✗ Branch 120 → 121 not taken.
✓ Branch 120 → 123 taken 359 times.
359 if (!snapshot.error.isEmpty()) return fail(snapshot.error);
660
1/1
✓ Branch 126 → 127 taken 359 times.
359 q.prepare(QStringLiteral("SELECT folder_id,uid_validity,revision FROM completed_training_folders "
661 "WHERE account=? AND folder=?"));
662
2/2
✓ Branch 131 → 132 taken 359 times.
✓ Branch 135 → 136 taken 359 times.
359 q.addBindValue(account); q.addBindValue(folder);
663
2/5
✓ Branch 137 → 138 taken 359 times.
✗ Branch 138 → 139 not taken.
✓ Branch 138 → 145 taken 359 times.
✗ Branch 139 → 140 not taken.
✗ Branch 140 → 141 not taken.
359 if (!q.exec()) return fail(q.lastError().text());
664
3/6
✓ Branch 147 → 148 taken 15 times.
✓ Branch 148 → 149 taken 15 times.
✓ Branch 149 → 150 taken 15 times.
✗ Branch 149 → 158 not taken.
✗ Branch 377 → 378 not taken.
✗ Branch 377 → 379 not taken.
30 const bool unchanged = q.next() && q.value(0).toLongLong() == snapshot.folderId &&
665
18/25
✓ Branch 145 → 146 taken 359 times.
✓ Branch 146 → 147 taken 15 times.
✓ Branch 146 → 158 taken 344 times.
✓ Branch 150 → 151 taken 15 times.
✓ Branch 151 → 152 taken 15 times.
✓ Branch 152 → 153 taken 15 times.
✗ Branch 152 → 158 not taken.
✓ Branch 153 → 154 taken 15 times.
✓ Branch 154 → 155 taken 15 times.
✓ Branch 156 → 157 taken 13 times.
✓ Branch 156 → 158 taken 2 times.
✓ Branch 159 → 160 taken 15 times.
✓ Branch 159 → 161 taken 344 times.
✓ Branch 161 → 162 taken 15 times.
✓ Branch 161 → 163 taken 344 times.
✓ Branch 163 → 164 taken 15 times.
✓ Branch 163 → 165 taken 344 times.
✓ Branch 165 → 166 taken 15 times.
✓ Branch 165 → 167 taken 344 times.
✗ Branch 365 → 366 not taken.
✗ Branch 365 → 367 not taken.
✗ Branch 369 → 370 not taken.
✗ Branch 369 → 371 not taken.
✗ Branch 373 → 374 not taken.
✗ Branch 373 → 375 not taken.
374 q.value(1).toUInt() == snapshot.uidValidity && q.value(2).toString() == snapshot.revision;
666
1/1
✓ Branch 167 → 168 taken 359 times.
359 q.finish();
667
2/2
✓ Branch 168 → 169 taken 13 times.
✓ Branch 168 → 170 taken 346 times.
359 if (unchanged) continue;
668 346 qint64 afterUid = 0;
669 346 std::optional<quint32> epoch;
670 while (true) {
671
2/2
✓ Branch 172 → 173 taken 1 time.
✓ Branch 172 → 180 taken 564 times.
566 if (interrupted()) return fail(QStringLiteral("Predictor training cancelled"));
672
1/1
✓ Branch 181 → 182 taken 564 times.
564 const auto page = cache->training().trainingPage(account, folder, afterUid);
673
2/2
✓ Branch 183 → 184 taken 2 times.
✓ Branch 183 → 186 taken 562 times.
564 if (!page.error.isEmpty()) return fail(page.error);
674
2/2
✓ Branch 187 → 188 taken 337 times.
✓ Branch 187 → 189 taken 225 times.
562 if (page.headers.isEmpty()) break;
675
5/8
✓ Branch 189 → 190 taken 225 times.
✗ Branch 189 → 195 not taken.
✓ Branch 190 → 191 taken 225 times.
✗ Branch 190 → 195 not taken.
✓ Branch 192 → 193 taken 25 times.
✓ Branch 192 → 196 taken 200 times.
✗ Branch 197 → 198 not taken.
✓ Branch 197 → 205 taken 225 times.
475 if (page.folderId != snapshot.folderId || page.uidValidity != snapshot.uidValidity ||
676
1/2
✗ Branch 194 → 195 not taken.
✓ Branch 194 → 196 taken 25 times.
250 (epoch && *epoch != page.uidValidity))
677 ✗ return fail(QStringLiteral("Predictor training mailbox identity changed"));
678 225 epoch = page.uidValidity;
679 225 int batchTrained = 0;
680
1/1
✓ Branch 207 → 208 taken 225 times.
225 if (!trainCachedBatch(account, folder, page.uidValidity, page.headers,
681
3/3
✓ Branch 206 → 207 taken 225 times.
✓ Branch 209 → 210 taken 6 times.
✓ Branch 209 → 211 taken 219 times.
675 [&] { return cache->training().trainingIdentityCurrent(account, page.folderId,
682 225 folder, page.uidValidity); },
683 6 error, cancelled, &batchTrained)) return false;
684
2/2
✓ Branch 211 → 212 taken 211 times.
✓ Branch 211 → 213 taken 8 times.
219 if (trainedDocuments) *trainedDocuments += batchTrained;
685 219 afterUid = page.headers.last().uid;
686 219 processed += page.headers.size();
687
1/1
✓ Branch 215 → 216 taken 219 times.
219 emit trainingProgress(processed, totalHeaders);
688 783 }
689
1/2
✗ Branch 223 → 224 not taken.
✓ Branch 223 → 231 taken 337 times.
337 if (interrupted()) return fail(QStringLiteral("Predictor training cancelled"));
690
2/3
✓ Branch 232 → 233 taken 337 times.
✗ Branch 233 → 234 not taken.
✓ Branch 233 → 241 taken 337 times.
337 if (!cache->training().trainingIdentityCurrent(account, snapshot.folderId, folder, snapshot.uidValidity))
691 ✗ return fail(QStringLiteral("Predictor training mailbox identity changed"));
692 // Save the starting revision, never a later one: arrivals below the paging
693 // cursor during this scan must remain discoverable on the next recovery.
694
1/1
✓ Branch 244 → 245 taken 337 times.
337 q.prepare(QStringLiteral("INSERT INTO completed_training_folders VALUES(?,?,?,?,?) "
695 "ON CONFLICT(account,folder) DO UPDATE SET folder_id=excluded.folder_id,"
696 "uid_validity=excluded.uid_validity,revision=excluded.revision"));
697
3/3
✓ Branch 249 → 250 taken 337 times.
✓ Branch 253 → 254 taken 337 times.
✓ Branch 257 → 258 taken 337 times.
337 q.addBindValue(account); q.addBindValue(folder); q.addBindValue(snapshot.folderId);
698
2/2
✓ Branch 261 → 262 taken 337 times.
✓ Branch 265 → 266 taken 337 times.
337 q.addBindValue(snapshot.uidValidity); q.addBindValue(snapshot.revision);
699
5/5
✓ Branch 267 → 268 taken 337 times.
✓ Branch 268 → 269 taken 1 time.
✓ Branch 268 → 275 taken 336 times.
✓ Branch 269 → 270 taken 1 time.
✓ Branch 270 → 271 taken 1 time.
337 if (!q.exec()) return fail(q.lastError().text());
700 359 }
701
1/2
✗ Branch 286 → 287 not taken.
✓ Branch 286 → 294 taken 144 times.
144 if (interrupted()) return fail(QStringLiteral("Predictor training cancelled"));
702
1/1
✓ Branch 297 → 298 taken 144 times.
144 q.prepare(QStringLiteral(
703 "INSERT OR REPLACE INTO meta (key, value) VALUES ('pretrained_account', ?)"));
704
1/1
✓ Branch 302 → 303 taken 144 times.
144 q.bindValue(0, account);
705
5/5
✓ Branch 304 → 305 taken 144 times.
✓ Branch 305 → 306 taken 1 time.
✓ Branch 305 → 312 taken 143 times.
✓ Branch 306 → 307 taken 1 time.
✓ Branch 307 → 308 taken 1 time.
144 if (!q.exec()) return fail(q.lastError().text());
706 143 return true;
707 776 }
708
709 44 bool FolderPredictor::trainCachedHeaders(
710 const QString &account, const QString &folder, quint32 uidValidity,
711 const QList<MailHeader> &headers, const std::function<bool()> &identityCurrent,
712 QString *error, const std::atomic_bool *cancelled, int *trainedDocuments) {
713 44 QMutexLocker locker(&m_mutex);
714
1/1
✓ Branch 3 → 4 taken 44 times.
44 return trainCachedBatch(account, folder, uidValidity, headers, identityCurrent,
715 88 error, cancelled, trainedDocuments);
716 44 }
717
718 269 bool FolderPredictor::trainCachedBatch(
719 const QString &account, const QString &folder, quint32 uidValidity,
720 const QList<MailHeader> &headers, const std::function<bool()> &identityCurrent,
721 QString *error, const std::atomic_bool *cancelled, int *trainedDocuments) {
722
2/2
✓ Branch 2 → 3 taken 264 times.
✓ Branch 2 → 4 taken 5 times.
269 if (error) error->clear();
723
2/2
✓ Branch 4 → 5 taken 267 times.
✓ Branch 4 → 6 taken 2 times.
269 if (trainedDocuments) *trainedDocuments = 0;
724 16 const auto fail = [error](const QString &message) {
725
1/2
✓ Branch 2 → 3 taken 16 times.
✗ Branch 2 → 4 not taken.
16 if (error) *error = message;
726 16 return false;
727 269 };
728
4/4
✓ Branch 2 → 3 taken 5389 times.
✓ Branch 2 → 6 taken 2224 times.
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 5388 times.
7613 const auto interrupted = [cancelled] { return cancelled && cancelled->load(); };
729
9/9
✓ Branch 6 → 7 taken 269 times.
✓ Branch 7 → 8 taken 268 times.
✓ Branch 7 → 12 taken 1 time.
✓ Branch 9 → 10 taken 267 times.
✓ Branch 9 → 12 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 266 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 14 → 22 taken 266 times.
269 if (!m_db.isOpen() || folder.isEmpty() || headers.size() > 128)
730 3 return fail(QStringLiteral("Predictor is closed or the training batch is invalid"));
731
2/2
✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 31 taken 265 times.
267 if (interrupted()) return fail(QStringLiteral("Predictor training cancelled"));
732
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 34 taken 264 times.
265 if (headers.isEmpty()) return true;
733
1/1
✓ Branch 34 → 35 taken 264 times.
264 QSqlQuery begin(m_db);
734
2/5
✓ Branch 38 → 39 taken 264 times.
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 48 taken 264 times.
✗ Branch 42 → 43 not taken.
✗ Branch 43 → 44 not taken.
264 if (!begin.exec(QStringLiteral("BEGIN IMMEDIATE"))) return fail(begin.lastError().text());
735 276 auto rollback = qScopeGuard([this] { m_db.rollback(); });
736 // Recheck the cache epoch after acquiring the predictor writer lock. A job
737 // delayed by another writer must not restore an obsolete ledger epoch.
738
6/7
✓ Branch 50 → 51 taken 264 times.
✗ Branch 50 → 53 not taken.
✓ Branch 51 → 52 taken 264 times.
✓ Branch 52 → 53 taken 1 time.
✓ Branch 52 → 54 taken 263 times.
✓ Branch 55 → 56 taken 1 time.
✓ Branch 55 → 63 taken 263 times.
264 if (!identityCurrent || !identityCurrent())
739 1 return fail(QStringLiteral("Predictor training mailbox identity changed"));
740 263 int trained = 0;
741
2/2
✓ Branch 263 → 65 taken 7095 times.
✓ Branch 263 → 264 taken 252 times.
7347 for (const MailHeader &header : headers) {
742
1/2
✗ Branch 67 → 68 not taken.
✓ Branch 67 → 75 taken 7095 times.
7095 if (interrupted()) return fail(QStringLiteral("Predictor training cancelled"));
743
7/9
✓ Branch 76 → 77 taken 7094 times.
✓ Branch 78 → 79 taken 7093 times.
✓ Branch 78 → 85 taken 1 time.
✓ Branch 81 → 82 taken 7092 times.
✓ Branch 81 → 85 taken 1 time.
✓ Branch 93 → 94 taken 4 times.
✓ Branch 93 → 101 taken 7091 times.
✗ Branch 320 → 321 not taken.
✗ Branch 320 → 322 not taken.
21282 if (header.uid <= 0 || header.from.toUtf8().size() > 8192 ||
744
12/16
✓ Branch 75 → 76 taken 7094 times.
✓ Branch 75 → 85 taken 1 time.
✓ Branch 79 → 80 taken 7093 times.
✓ Branch 82 → 83 taken 7092 times.
✓ Branch 84 → 85 taken 1 time.
✓ Branch 84 → 86 taken 7091 times.
✓ Branch 87 → 88 taken 7092 times.
✓ Branch 87 → 89 taken 3 times.
✓ Branch 89 → 90 taken 7093 times.
✓ Branch 89 → 91 taken 2 times.
✓ Branch 91 → 92 taken 7094 times.
✓ Branch 91 → 93 taken 1 time.
✗ Branch 312 → 313 not taken.
✗ Branch 312 → 314 not taken.
✗ Branch 316 → 317 not taken.
✗ Branch 316 → 318 not taken.
21282 header.subject.toUtf8().size() > 8192 || header.to.toUtf8().size() > 8192)
745 4 return fail(QStringLiteral("Predictor training header is invalid or oversized"));
746
1/1
✓ Branch 101 → 102 taken 7091 times.
7091 QSqlQuery seen(m_db);
747
1/1
✓ Branch 105 → 106 taken 7091 times.
7091 seen.prepare(QStringLiteral(
748 "SELECT uid_validity FROM trained_messages WHERE account=? AND folder=? AND uid=?"));
749
3/3
✓ Branch 110 → 111 taken 7091 times.
✓ Branch 114 → 115 taken 7091 times.
✓ Branch 118 → 119 taken 7091 times.
7091 seen.addBindValue(account); seen.addBindValue(folder); seen.addBindValue(header.uid);
750
2/5
✓ Branch 120 → 121 taken 7091 times.
✗ Branch 121 → 122 not taken.
✓ Branch 121 → 128 taken 7091 times.
✗ Branch 122 → 123 not taken.
✗ Branch 123 → 124 not taken.
7091 if (!seen.exec()) return fail(seen.lastError().text());
751
1/1
✓ Branch 128 → 129 taken 7091 times.
7091 const bool exists = seen.next();
752
6/8
✓ Branch 129 → 130 taken 1302 times.
✓ Branch 129 → 133 taken 5789 times.
✓ Branch 130 → 131 taken 1302 times.
✓ Branch 131 → 132 taken 1302 times.
✓ Branch 134 → 135 taken 1302 times.
✓ Branch 134 → 136 taken 5789 times.
✗ Branch 346 → 347 not taken.
✗ Branch 346 → 348 not taken.
7091 const quint32 previousEpoch = exists ? seen.value(0).toUInt() : 0;
753
1/1
✓ Branch 136 → 137 taken 7091 times.
7091 seen.finish();
754
4/4
✓ Branch 137 → 138 taken 1302 times.
✓ Branch 137 → 140 taken 5789 times.
✓ Branch 138 → 139 taken 1298 times.
✓ Branch 138 → 140 taken 4 times.
7091 if (exists && previousEpoch == uidValidity) continue;
755
1/1
✓ Branch 140 → 141 taken 5793 times.
5793 const auto tokens = tokenize(header.from, header.subject, header.to);
756
2/2
✓ Branch 142 → 143 taken 1 time.
✓ Branch 142 → 144 taken 5792 times.
5793 if (tokens.isEmpty()) continue;
757
1/1
✓ Branch 147 → 148 taken 5792 times.
5792 seen.prepare(QStringLiteral(
758 "INSERT INTO trained_messages(account,folder,uid,uid_validity) VALUES(?,?,?,?) "
759 "ON CONFLICT(account,folder,uid) DO UPDATE SET uid_validity=excluded.uid_validity"));
760
2/2
✓ Branch 152 → 153 taken 5792 times.
✓ Branch 156 → 157 taken 5792 times.
5792 seen.addBindValue(account); seen.addBindValue(folder);
761
2/2
✓ Branch 160 → 161 taken 5792 times.
✓ Branch 164 → 165 taken 5792 times.
5792 seen.addBindValue(header.uid); seen.addBindValue(uidValidity);
762
5/5
✓ Branch 166 → 167 taken 5792 times.
✓ Branch 167 → 168 taken 2 times.
✓ Branch 167 → 174 taken 5790 times.
✓ Branch 168 → 169 taken 2 times.
✓ Branch 169 → 170 taken 2 times.
5792 if (!seen.exec()) return fail(seen.lastError().text());
763 // Old ledgers have no reconstructible UIDVALIDITY. Adopt that identity
764 // once rather than double-counting all existing learned documents.
765
4/4
✓ Branch 174 → 175 taken 4 times.
✓ Branch 174 → 177 taken 5786 times.
✓ Branch 175 → 176 taken 2 times.
✓ Branch 175 → 177 taken 2 times.
5790 if (exists && previousEpoch == 0) continue;
766
1/1
✓ Branch 177 → 178 taken 5788 times.
5788 QSqlQuery update(m_db);
767
1/1
✓ Branch 181 → 182 taken 5788 times.
5788 update.prepare(QStringLiteral(
768 "INSERT INTO token_counts(token,folder,count) VALUES(?,?,1) "
769 "ON CONFLICT(token,folder) DO UPDATE SET count=count+1"));
770
2/2
✓ Branch 205 → 186 taken 59931 times.
✓ Branch 205 → 206 taken 5787 times.
65718 for (const auto &token : tokens) {
771
2/2
✓ Branch 189 → 190 taken 59931 times.
✓ Branch 193 → 194 taken 59931 times.
59931 update.bindValue(0, token); update.bindValue(1, folder);
772
5/5
✓ Branch 195 → 196 taken 59931 times.
✓ Branch 196 → 197 taken 1 time.
✓ Branch 196 → 203 taken 59930 times.
✓ Branch 197 → 198 taken 1 time.
✓ Branch 198 → 199 taken 1 time.
59931 if (!update.exec()) return fail(update.lastError().text());
773 }
774
1/1
✓ Branch 209 → 210 taken 5787 times.
5787 update.prepare(QStringLiteral(
775 "INSERT INTO folder_doc_counts(folder,doc_count) VALUES(?,1) "
776 "ON CONFLICT(folder) DO UPDATE SET doc_count=doc_count+1"));
777
1/1
✓ Branch 214 → 215 taken 5787 times.
5787 update.addBindValue(folder);
778
5/5
✓ Branch 216 → 217 taken 5787 times.
✓ Branch 217 → 218 taken 1 time.
✓ Branch 217 → 224 taken 5786 times.
✓ Branch 218 → 219 taken 1 time.
✓ Branch 219 → 220 taken 1 time.
5787 if (!update.exec()) return fail(update.lastError().text());
779
3/8
✓ Branch 235 → 236 taken 5786 times.
✗ Branch 235 → 237 not taken.
✓ Branch 237 → 238 taken 3 times.
✓ Branch 237 → 245 taken 5783 times.
✗ Branch 409 → 410 not taken.
✗ Branch 409 → 411 not taken.
✗ Branch 413 → 414 not taken.
✗ Branch 413 → 415 not taken.
11572 if (!update.exec(QStringLiteral(
780
4/5
✓ Branch 227 → 228 taken 5786 times.
✓ Branch 228 → 229 taken 5784 times.
✓ Branch 228 → 231 taken 2 times.
✓ Branch 233 → 234 taken 5786 times.
✗ Branch 233 → 235 not taken.
17356 "UPDATE meta SET value=CAST(CAST(value AS INTEGER)+1 AS TEXT) WHERE key='total_docs'")) ||
781
3/3
✓ Branch 229 → 230 taken 5784 times.
✓ Branch 230 → 231 taken 1 time.
✓ Branch 230 → 232 taken 5783 times.
5784 update.numRowsAffected() != 1)
782 3 return fail(QStringLiteral("Failed to update predictor document count"));
783 5783 ++trained;
784 7106 }
785
1/2
✗ Branch 265 → 266 not taken.
✓ Branch 265 → 273 taken 252 times.
252 if (interrupted()) return fail(QStringLiteral("Predictor training cancelled"));
786
5/7
✓ Branch 273 → 274 taken 236 times.
✓ Branch 273 → 277 taken 16 times.
✓ Branch 274 → 275 taken 236 times.
✗ Branch 275 → 276 not taken.
✓ Branch 275 → 277 taken 236 times.
✗ Branch 278 → 279 not taken.
✓ Branch 278 → 286 taken 252 times.
252 if (trained > 0 && !touchStatisticsRevision())
787 ✗ return fail(QStringLiteral("Failed to update the predictor statistics revision"));
788
2/5
✓ Branch 286 → 287 taken 252 times.
✗ Branch 287 → 288 not taken.
✓ Branch 287 → 294 taken 252 times.
✗ Branch 288 → 289 not taken.
✗ Branch 289 → 290 not taken.
252 if (!m_db.commit()) return fail(m_db.lastError().text());
789 252 rollback.dismiss();
790
2/2
✓ Branch 295 → 296 taken 251 times.
✓ Branch 295 → 297 taken 1 time.
252 if (trainedDocuments) *trainedDocuments = trained;
791 252 return true;
792 264 }
793
794 // --- Prediction ---
795
796 2759 QMap<QString, double> FolderPredictor::computeScores(
797 const QStringList &tokens, QString *error) const {
798 2759 QMap<QString, double> scores;
799
2/2
✓ Branch 2 → 3 taken 31 times.
✓ Branch 2 → 4 taken 2728 times.
2759 if (error) error->clear();
800 ✗ const auto fail = [error](const QSqlQuery &query) -> QMap<QString, double> {
801 ✗ if (error) *error = query.lastError().isValid() ? query.lastError().text()
802 ✗ : QStringLiteral("Folder predictor metadata is missing");
803 ✗ return {};
804 2759 };
805 2759 Statistics loaded;
806 2759 const Statistics *statistics = &m_statistics;
807
2/2
✓ Branch 4 → 5 taken 31 times.
✓ Branch 4 → 12 taken 2728 times.
2759 if (m_readOnly) {
808
6/7
✓ Branch 5 → 6 taken 31 times.
✗ Branch 5 → 9 not taken.
✓ Branch 6 → 7 taken 31 times.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 30 times.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 16 taken 30 times.
31 if (!m_statistics.valid && !loadSnapshotStatistics(error)) return {};
809 } else {
810
3/3
✓ Branch 12 → 13 taken 2728 times.
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 15 taken 2726 times.
2728 if (!readStatistics(&loaded, error)) return {};
811 2726 statistics = &loaded;
812 }
813 2756 const int totalDocs = statistics->totalDocs;
814
2/2
✓ Branch 16 → 17 taken 7 times.
✓ Branch 16 → 18 taken 2749 times.
2756 if (totalDocs < kMinDocsForPrediction)
815 7 return scores;
816 2749 const QMap<QString, int> &folderDocs = statistics->folderDocs;
817
3/3
✓ Branch 18 → 19 taken 2749 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 21 taken 2747 times.
2749 if (folderDocs.isEmpty())
818 2 return scores;
819 2747 const int vocabSize = statistics->vocabSize;
820 2747 const QMap<QString, int> &folderTotalTokens = statistics->folderTotalTokens;
821
822 // --- Batch query: load all matching token counts in one SQL call ---
823 // Build IN-clause placeholders
824 2747 QStringList uniqueTokens = tokens;
825
1/1
✓ Branch 22 → 23 taken 2747 times.
2747 uniqueTokens.removeDuplicates();
826 2747 QString placeholders;
827
2/2
✓ Branch 34 → 24 taken 21816 times.
✓ Branch 34 → 35 taken 2747 times.
24563 for (int i = 0; i < uniqueTokens.size(); ++i) {
828
2/2
✓ Branch 24 → 25 taken 19069 times.
✓ Branch 24 → 29 taken 2747 times.
21816 if (i > 0)
829
1/1
✓ Branch 27 → 28 taken 19069 times.
19069 placeholders += QLatin1Char(',');
830
1/1
✓ Branch 31 → 32 taken 21816 times.
21816 placeholders += QLatin1Char('?');
831 }
832
833
1/1
✓ Branch 35 → 36 taken 2747 times.
2747 QSqlQuery batchQ(m_db);
834
1/1
✓ Branch 41 → 42 taken 2747 times.
2747 batchQ.prepare(
835 5494 QStringLiteral("SELECT token, folder, count FROM token_counts "
836 "WHERE token IN (%1)")
837
1/1
✓ Branch 40 → 41 taken 2747 times.
5494 .arg(placeholders));
838
2/2
✓ Branch 53 → 46 taken 21816 times.
✓ Branch 53 → 54 taken 2747 times.
24563 for (int i = 0; i < uniqueTokens.size(); ++i) {
839
2/2
✓ Branch 47 → 48 taken 21816 times.
✓ Branch 49 → 50 taken 21816 times.
21816 batchQ.bindValue(i, uniqueTokens[i]);
840 }
841
2/4
✓ Branch 54 → 55 taken 2747 times.
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 58 taken 2747 times.
✗ Branch 56 → 57 not taken.
2747 if (!batchQ.exec()) return fail(batchQ);
842
843 // Build lookup map: (token, folder) → count
844 2747 QMap<QPair<QString, QString>, int> tokenCounts;
845
3/3
✓ Branch 74 → 75 taken 126980 times.
✓ Branch 75 → 59 taken 124233 times.
✓ Branch 75 → 76 taken 2747 times.
126980 while (batchQ.next()) {
846
4/4
✓ Branch 62 → 63 taken 124233 times.
✓ Branch 63 → 64 taken 124233 times.
✓ Branch 64 → 65 taken 124233 times.
✓ Branch 66 → 67 taken 124233 times.
124233 tokenCounts[{batchQ.value(0).toString(), batchQ.value(1).toString()}] =
847
3/3
✓ Branch 59 → 60 taken 124233 times.
✓ Branch 60 → 61 taken 124233 times.
✓ Branch 61 → 62 taken 124233 times.
248466 batchQ.value(2).toInt();
848 }
849
3/5
✓ Branch 76 → 77 taken 2747 times.
✓ Branch 77 → 78 taken 2747 times.
✗ Branch 79 → 80 not taken.
✓ Branch 79 → 82 taken 2747 times.
✗ Branch 80 → 81 not taken.
2747 if (batchQ.lastError().isValid()) return fail(batchQ);
850
851 // Compute log P(folder|tokens) for each folder
852 2747 constexpr double kAlpha = 0.1;
853
4/4
✓ Branch 82 → 83 taken 2747 times.
✓ Branch 100 → 101 taken 29755 times.
✓ Branch 102 → 84 taken 27008 times.
✓ Branch 102 → 103 taken 2747 times.
29755 for (auto it = folderDocs.constBegin(); it != folderDocs.constEnd(); ++it) {
854 27008 const QString &folder = it.key();
855 27008 int docCount = it.value();
856
857 // Prior: P(folder) = doc_count / total_docs
858 27008 double logPrior = qLn(static_cast<double>(docCount) / totalDocs);
859
860 // Likelihood: P(token|folder) for each token with Laplace smoothing
861 27008 double logLikelihood = 0.0;
862
1/1
✓ Branch 87 → 88 taken 27008 times.
27008 int totalTokensInFolder = folderTotalTokens.value(folder, 0);
863
864
2/2
✓ Branch 97 → 90 taken 323949 times.
✓ Branch 97 → 98 taken 27008 times.
350957 for (const QString &token : tokens) {
865
1/1
✓ Branch 92 → 93 taken 323949 times.
323949 int tokenCount = tokenCounts.value({token, folder}, 0);
866
867 323949 double prob = static_cast<double>(tokenCount + kAlpha) /
868 323949 (totalTokensInFolder + kAlpha * vocabSize);
869 323949 logLikelihood += qLn(prob);
870 }
871
872
1/1
✓ Branch 98 → 99 taken 27008 times.
27008 scores[folder] = logPrior + logLikelihood;
873 }
874
875 2747 return scores;
876 2759 }
877
878 31 bool FolderPredictor::loadSnapshotStatistics(QString *error) const {
879 31 QString revision;
880 {
881
1/1
✓ Branch 2 → 3 taken 31 times.
31 QSqlQuery q(m_db);
882
9/16
✓ Branch 6 → 7 taken 31 times.
✓ Branch 7 → 8 taken 31 times.
✗ Branch 7 → 11 not taken.
✓ Branch 8 → 9 taken 31 times.
✓ Branch 9 → 10 taken 29 times.
✓ Branch 9 → 11 taken 2 times.
✓ Branch 12 → 13 taken 31 times.
✗ Branch 12 → 14 not taken.
✓ Branch 14 → 15 taken 31 times.
✗ Branch 14 → 16 not taken.
✓ Branch 16 → 17 taken 29 times.
✓ Branch 16 → 23 taken 2 times.
✗ Branch 100 → 101 not taken.
✗ Branch 100 → 102 not taken.
✗ Branch 104 → 105 not taken.
✗ Branch 104 → 106 not taken.
62 if (q.exec(QStringLiteral("SELECT value FROM meta WHERE key='statistics_revision'")) && q.next())
883
2/2
✓ Branch 17 → 18 taken 29 times.
✓ Branch 18 → 19 taken 29 times.
29 revision = q.value(0).toString();
884 31 }
885
6/6
✓ Branch 25 → 26 taken 26 times.
✓ Branch 25 → 29 taken 5 times.
✓ Branch 27 → 28 taken 24 times.
✓ Branch 27 → 29 taken 2 times.
✓ Branch 30 → 31 taken 24 times.
✓ Branch 30 → 53 taken 7 times.
31 if (m_statisticsCache && !revision.isEmpty()) {
886 24 QMutexLocker locker(&m_statisticsCache->mutex);
887
6/6
✓ Branch 35 → 36 taken 11 times.
✓ Branch 35 → 42 taken 13 times.
✓ Branch 38 → 39 taken 7 times.
✓ Branch 38 → 42 taken 4 times.
✓ Branch 43 → 44 taken 7 times.
✓ Branch 43 → 47 taken 17 times.
31 if (m_statisticsCache->path == m_path && m_statisticsCache->revision == revision &&
888
1/2
✓ Branch 40 → 41 taken 7 times.
✗ Branch 40 → 42 not taken.
7 m_statisticsCache->statistics.valid) {
889 7 m_statistics = m_statisticsCache->statistics;
890 7 return true;
891 }
892 24 }
893 24 Statistics loaded;
894
3/3
✓ Branch 53 → 54 taken 24 times.
✓ Branch 54 → 55 taken 1 time.
✓ Branch 54 → 56 taken 23 times.
24 if (!readStatistics(&loaded, error)) return false;
895 23 m_statistics = loaded;
896
6/6
✓ Branch 58 → 59 taken 18 times.
✓ Branch 58 → 62 taken 5 times.
✓ Branch 60 → 61 taken 16 times.
✓ Branch 60 → 62 taken 2 times.
✓ Branch 63 → 64 taken 16 times.
✓ Branch 63 → 74 taken 7 times.
23 if (m_statisticsCache && !revision.isEmpty()) {
897 16 QMutexLocker locker(&m_statisticsCache->mutex);
898 16 m_statisticsCache->path = m_path;
899 16 m_statisticsCache->revision = revision;
900 16 m_statisticsCache->statistics = loaded;
901 16 }
902
8/8
✓ Branch 74 → 75 taken 23 times.
✓ Branch 78 → 79 taken 23 times.
✓ Branch 79 → 80 taken 23 times.
✓ Branch 80 → 81 taken 23 times.
✓ Branch 81 → 82 taken 23 times.
✓ Branch 82 → 83 taken 23 times.
✓ Branch 94 → 76 taken 23 times.
✓ Branch 94 → 95 taken 23 times.
46 qCDebug(lcFolderPredictor) << "statistics read docs" << loaded.totalDocs << "vocabulary" << loaded.vocabSize
903
6/6
✓ Branch 83 → 84 taken 23 times.
✓ Branch 85 → 86 taken 18 times.
✓ Branch 85 → 89 taken 5 times.
✓ Branch 87 → 88 taken 16 times.
✓ Branch 87 → 89 taken 2 times.
✓ Branch 90 → 91 taken 23 times.
23 << "cached" << (m_statisticsCache && !revision.isEmpty());
904 23 return true;
905 31 }
906
907 2752 bool FolderPredictor::readStatistics(Statistics *statistics, QString *error) const {
908 3 const auto fail = [error](const QSqlQuery &query) {
909
5/10
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 19 taken 2 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 8 taken 1 time.
✗ Branch 6 → 7 not taken.
✗ Branch 7 → 11 not taken.
✗ Branch 25 → 26 not taken.
✗ Branch 25 → 27 not taken.
6 if (error) *error = query.lastError().isValid() ? query.lastError().text()
910
2/6
✓ Branch 13 → 14 taken 1 time.
✗ Branch 13 → 15 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 1 time.
✗ Branch 21 → 22 not taken.
✗ Branch 21 → 23 not taken.
2 : QStringLiteral("Folder predictor metadata is missing");
911 3 return false;
912 2752 };
913
1/1
✓ Branch 2 → 3 taken 2752 times.
2752 QSqlQuery q(m_db);
914
11/17
✓ Branch 6 → 7 taken 2752 times.
✓ Branch 7 → 8 taken 2750 times.
✓ Branch 7 → 10 taken 2 times.
✓ Branch 8 → 9 taken 2750 times.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 11 taken 2749 times.
✓ Branch 12 → 13 taken 2752 times.
✗ Branch 12 → 14 not taken.
✓ Branch 14 → 15 taken 2752 times.
✗ Branch 14 → 16 not taken.
✓ Branch 16 → 17 taken 3 times.
✓ Branch 16 → 19 taken 2749 times.
✓ Branch 17 → 18 taken 3 times.
✗ Branch 100 → 101 not taken.
✗ Branch 100 → 102 not taken.
✗ Branch 104 → 105 not taken.
✗ Branch 104 → 106 not taken.
5504 if (!q.exec(QStringLiteral("SELECT value FROM meta WHERE key='total_docs'")) || !q.next()) return fail(q);
915
2/2
✓ Branch 19 → 20 taken 2749 times.
✓ Branch 20 → 21 taken 2749 times.
2749 statistics->totalDocs = q.value(0).toInt();
916
2/4
✓ Branch 25 → 26 taken 2749 times.
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 31 taken 2749 times.
✗ Branch 29 → 30 not taken.
2749 if (!q.exec(QStringLiteral("SELECT folder, doc_count FROM folder_doc_counts WHERE doc_count > 0"))) return fail(q);
917
8/8
✓ Branch 32 → 33 taken 27001 times.
✓ Branch 33 → 34 taken 27001 times.
✓ Branch 34 → 35 taken 27001 times.
✓ Branch 35 → 36 taken 27001 times.
✓ Branch 36 → 37 taken 27001 times.
✓ Branch 41 → 42 taken 29750 times.
✓ Branch 42 → 32 taken 27001 times.
✓ Branch 42 → 43 taken 2749 times.
29750 while (q.next()) statistics->folderDocs[q.value(0).toString()] = q.value(1).toInt();
918
3/5
✓ Branch 43 → 44 taken 2749 times.
✓ Branch 44 → 45 taken 2749 times.
✗ Branch 46 → 47 not taken.
✓ Branch 46 → 49 taken 2749 times.
✗ Branch 47 → 48 not taken.
2749 if (q.lastError().isValid()) return fail(q);
919 // Vocabulary size (distinct tokens), at least 1 against division by zero.
920
7/17
✓ Branch 52 → 53 taken 2749 times.
✓ Branch 53 → 54 taken 2749 times.
✗ Branch 53 → 56 not taken.
✓ Branch 54 → 55 taken 2749 times.
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 57 taken 2749 times.
✓ Branch 58 → 59 taken 2749 times.
✗ Branch 58 → 60 not taken.
✓ Branch 60 → 61 taken 2749 times.
✗ Branch 60 → 62 not taken.
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 65 taken 2749 times.
✗ Branch 63 → 64 not taken.
✗ Branch 129 → 130 not taken.
✗ Branch 129 → 131 not taken.
✗ Branch 133 → 134 not taken.
✗ Branch 133 → 135 not taken.
5498 if (!q.exec(QStringLiteral("SELECT COUNT(DISTINCT token) FROM token_counts")) || !q.next()) return fail(q);
921
2/2
✓ Branch 65 → 66 taken 2749 times.
✓ Branch 66 → 67 taken 2749 times.
2749 statistics->vocabSize = qMax(1, q.value(0).toInt());
922
2/4
✓ Branch 72 → 73 taken 2749 times.
✗ Branch 75 → 76 not taken.
✓ Branch 75 → 78 taken 2749 times.
✗ Branch 76 → 77 not taken.
2749 if (!q.exec(QStringLiteral("SELECT folder, SUM(count) FROM token_counts GROUP BY folder"))) return fail(q);
923
8/8
✓ Branch 79 → 80 taken 27002 times.
✓ Branch 80 → 81 taken 27002 times.
✓ Branch 81 → 82 taken 27002 times.
✓ Branch 82 → 83 taken 27002 times.
✓ Branch 83 → 84 taken 27002 times.
✓ Branch 88 → 89 taken 29751 times.
✓ Branch 89 → 79 taken 27002 times.
✓ Branch 89 → 90 taken 2749 times.
29751 while (q.next()) statistics->folderTotalTokens[q.value(0).toString()] = q.value(1).toInt();
924
3/5
✓ Branch 90 → 91 taken 2749 times.
✓ Branch 91 → 92 taken 2749 times.
✗ Branch 93 → 94 not taken.
✓ Branch 93 → 96 taken 2749 times.
✗ Branch 94 → 95 not taken.
2749 if (q.lastError().isValid()) return fail(q);
925 2749 statistics->valid = true;
926 2749 return true;
927 2752 }
928
929 28 QString FolderPredictor::predict(const QString &from, const QString &subject,
930 const QString &to,
931 double *confidence) const {
932 28 QMutexLocker locker(&m_mutex);
933
3/3
✓ Branch 3 → 4 taken 28 times.
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 8 taken 26 times.
28 if (!m_db.isOpen()) {
934
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 1 time.
2 if (confidence)
935 1 *confidence = 0.0;
936 2 return {};
937 }
938
939
1/1
✓ Branch 8 → 9 taken 26 times.
26 const QStringList tokens = tokenize(from, subject, to);
940
2/2
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 14 taken 24 times.
26 if (tokens.isEmpty()) {
941
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 1 time.
2 if (confidence)
942 1 *confidence = 0.0;
943 2 return {};
944 }
945
946
1/1
✓ Branch 14 → 15 taken 24 times.
24 QMap<QString, double> scores = computeScores(tokens);
947
3/3
✓ Branch 15 → 16 taken 24 times.
✓ Branch 16 → 17 taken 6 times.
✓ Branch 16 → 20 taken 18 times.
24 if (scores.isEmpty()) {
948
2/2
✓ Branch 17 → 18 taken 4 times.
✓ Branch 17 → 19 taken 2 times.
6 if (confidence)
949 4 *confidence = 0.0;
950 6 return {};
951 }
952
953 // Find best and second-best scores
954 18 QString bestFolder;
955 18 double bestScore = -std::numeric_limits<double>::infinity();
956 18 double secondBestScore = -std::numeric_limits<double>::infinity();
957
958
4/4
✓ Branch 20 → 21 taken 18 times.
✓ Branch 32 → 33 taken 50 times.
✓ Branch 34 → 22 taken 32 times.
✓ Branch 34 → 35 taken 18 times.
50 for (auto it = scores.constBegin(); it != scores.constEnd(); ++it) {
959
2/2
✓ Branch 23 → 24 taken 26 times.
✓ Branch 23 → 27 taken 6 times.
32 if (it.value() > bestScore) {
960 26 secondBestScore = bestScore;
961 26 bestScore = it.value();
962 26 bestFolder = it.key();
963
1/2
✓ Branch 28 → 29 taken 6 times.
✗ Branch 28 → 31 not taken.
6 } else if (it.value() > secondBestScore) {
964 6 secondBestScore = it.value();
965 }
966 }
967
968 // Direct-evidence check: does the best folder have actual from-token data?
969 // Without this, senders unknown to the model get nonsensical predictions.
970
1/1
✓ Branch 35 → 36 taken 18 times.
18 QString fullEmail = extractEmail(from);
971 // S106-199: no sender address, no evidence — no prediction.
972
2/2
✓ Branch 37 → 38 taken 2 times.
✓ Branch 37 → 41 taken 16 times.
18 if (fullEmail.isEmpty()) {
973
1/2
✓ Branch 38 → 39 taken 2 times.
✗ Branch 38 → 40 not taken.
2 if (confidence)
974 2 *confidence = 0.0;
975 2 return {};
976 }
977 {
978
1/1
✓ Branch 44 → 45 taken 16 times.
16 QString fullToken = QStringLiteral("from:") + fullEmail;
979
1/1
✓ Branch 47 → 48 taken 16 times.
16 QSqlQuery evQ(m_db);
980
1/1
✓ Branch 51 → 52 taken 16 times.
16 evQ.prepare(QStringLiteral(
981 "SELECT count FROM token_counts WHERE token = ? AND folder = ?"));
982
1/1
✓ Branch 56 → 57 taken 16 times.
16 evQ.bindValue(0, fullToken);
983
1/1
✓ Branch 60 → 61 taken 16 times.
16 evQ.bindValue(1, bestFolder);
984
1/1
✓ Branch 62 → 63 taken 16 times.
16 evQ.exec();
985
8/11
✓ Branch 63 → 64 taken 16 times.
✓ Branch 64 → 65 taken 11 times.
✓ Branch 64 → 69 taken 5 times.
✓ Branch 65 → 66 taken 11 times.
✓ Branch 66 → 67 taken 11 times.
✓ Branch 67 → 68 taken 11 times.
✗ Branch 67 → 69 not taken.
✓ Branch 70 → 71 taken 11 times.
✓ Branch 70 → 72 taken 5 times.
✗ Branch 147 → 148 not taken.
✗ Branch 147 → 149 not taken.
16 bool hasEvidence = evQ.next() && evQ.value(0).toInt() > 0;
986
987
2/2
✓ Branch 72 → 73 taken 5 times.
✓ Branch 72 → 94 taken 11 times.
16 if (!hasEvidence) {
988 // Check if ANY folder has this sender — if not, the model
989 // simply doesn't know this sender at all.
990
1/1
✓ Branch 75 → 76 taken 5 times.
5 evQ.bindValue(0, fullToken);
991 // Re-prepare for single-column query
992
1/1
✓ Branch 80 → 81 taken 5 times.
5 evQ.prepare(QStringLiteral(
993 "SELECT 1 FROM token_counts WHERE token = ? LIMIT 1"));
994
1/1
✓ Branch 85 → 86 taken 5 times.
5 evQ.bindValue(0, fullToken);
995
1/1
✓ Branch 87 → 88 taken 5 times.
5 evQ.exec();
996
3/3
✓ Branch 88 → 89 taken 5 times.
✓ Branch 89 → 90 taken 4 times.
✓ Branch 89 → 94 taken 1 time.
5 if (!evQ.next()) {
997 // Sender completely unknown — no prediction possible
998
2/2
✓ Branch 90 → 91 taken 3 times.
✓ Branch 90 → 92 taken 1 time.
4 if (confidence)
999 3 *confidence = 0.0;
1000 4 return {};
1001 }
1002 }
1003 20 }
1004
1005 // Compute confidence using softmax probability
1006
2/2
✓ Branch 102 → 104 taken 10 times.
✓ Branch 102 → 117 taken 2 times.
12 if (confidence) {
1007
3/3
✓ Branch 104 → 105 taken 10 times.
✓ Branch 105 → 106 taken 3 times.
✓ Branch 105 → 107 taken 7 times.
10 if (scores.size() <= 1) {
1008 3 *confidence = 1.0;
1009 } else {
1010 7 double sumExp = 0.0;
1011
4/4
✓ Branch 107 → 108 taken 7 times.
✓ Branch 112 → 113 taken 21 times.
✓ Branch 114 → 109 taken 14 times.
✓ Branch 114 → 115 taken 7 times.
21 for (auto it = scores.constBegin(); it != scores.constEnd(); ++it) {
1012 14 sumExp += qExp(it.value() - bestScore);
1013 }
1014 7 *confidence = 1.0 / sumExp;
1015
1/1
✓ Branch 115 → 116 taken 7 times.
7 *confidence = qBound(0.0, *confidence, 1.0);
1016 }
1017 }
1018
1019 12 return bestFolder;
1020 28 }
1021
1022 2738 QList<QPair<QString, double>> FolderPredictor::predictTop(
1023 const QString &from, const QString &subject, const QString &to,
1024 int n, QString *error) const {
1025 2738 QMutexLocker locker(&m_mutex);
1026 2738 QList<QPair<QString, double>> result;
1027
1028
2/2
✓ Branch 3 → 4 taken 32 times.
✓ Branch 3 → 5 taken 2706 times.
2738 if (error) error->clear();
1029
3/3
✓ Branch 5 → 6 taken 2738 times.
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 16 taken 2736 times.
2738 if (!m_db.isOpen()) {
1030
2/2
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 15 taken 1 time.
3 if (error) *error = QStringLiteral("Folder predictor is closed");
1031 2 return result;
1032 }
1033
1034
1/1
✓ Branch 16 → 17 taken 2736 times.
2736 const QStringList tokens = tokenize(from, subject, to);
1035
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 20 taken 2735 times.
2736 if (tokens.isEmpty())
1036 1 return result;
1037
1038
1/1
✓ Branch 20 → 21 taken 2735 times.
2735 QMap<QString, double> scores = computeScores(tokens, error);
1039
3/3
✓ Branch 21 → 22 taken 2735 times.
✓ Branch 22 → 23 taken 6 times.
✓ Branch 22 → 24 taken 2729 times.
2735 if (scores.isEmpty())
1040 6 return result;
1041
1042 // Convert to list and sort by score descending
1043
4/4
✓ Branch 24 → 25 taken 2729 times.
✓ Branch 32 → 33 taken 29705 times.
✓ Branch 34 → 26 taken 26976 times.
✓ Branch 34 → 35 taken 2729 times.
29705 for (auto it = scores.constBegin(); it != scores.constEnd(); ++it) {
1044
1/1
✓ Branch 29 → 30 taken 26976 times.
26976 result.append({it.key(), it.value()});
1045 }
1046
1047
3/3
✓ Branch 35 → 36 taken 2729 times.
✓ Branch 36 → 37 taken 2729 times.
✓ Branch 37 → 38 taken 2729 times.
2729 std::sort(result.begin(), result.end(),
1048 83040 [](const QPair<QString, double> &a,
1049 const QPair<QString, double> &b) {
1050 83040 return a.second > b.second;
1051 });
1052
1053 // Normalize scores to [0, 1] using softmax
1054
1/2
✓ Branch 39 → 40 taken 2729 times.
✗ Branch 39 → 58 not taken.
2729 if (!result.isEmpty()) {
1055
1/1
✓ Branch 40 → 41 taken 2729 times.
2729 double maxScore = result.first().second;
1056 2729 double sumExp = 0.0;
1057
4/4
✓ Branch 41 → 42 taken 2729 times.
✓ Branch 42 → 43 taken 2729 times.
✓ Branch 48 → 44 taken 26976 times.
✓ Branch 48 → 49 taken 2729 times.
29705 for (const auto &pair : result) {
1058 26976 sumExp += qExp(pair.second - maxScore);
1059 }
1060
4/4
✓ Branch 49 → 50 taken 2729 times.
✓ Branch 50 → 51 taken 2729 times.
✓ Branch 56 → 52 taken 26976 times.
✓ Branch 56 → 57 taken 2729 times.
29705 for (auto &pair : result) {
1061 26976 pair.second = qExp(pair.second - maxScore) / sumExp;
1062 }
1063 }
1064
1065 // Truncate to n entries
1066
2/2
✓ Branch 59 → 60 taken 2694 times.
✓ Branch 59 → 64 taken 35 times.
2729 if (result.size() > n) {
1067
1/1
✓ Branch 60 → 61 taken 2694 times.
2694 result = result.mid(0, n);
1068 }
1069
1070 2729 return result;
1071 2738 }
1072
1073 // --- Statistics ---
1074
1075 1574 int FolderPredictor::totalDocuments() const {
1076
3/3
✓ Branch 2 → 3 taken 1574 times.
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 1572 times.
1574 if (!m_db.isOpen())
1077 2 return 0;
1078
1079
1/1
✓ Branch 5 → 6 taken 1572 times.
1572 QSqlQuery q(m_db);
1080
1/1
✓ Branch 9 → 10 taken 1572 times.
1572 q.exec(QStringLiteral(
1081 "SELECT value FROM meta WHERE key = 'total_docs'"));
1082
3/3
✓ Branch 12 → 13 taken 1572 times.
✓ Branch 13 → 14 taken 1571 times.
✓ Branch 13 → 19 taken 1 time.
1572 if (q.next())
1083
2/2
✓ Branch 14 → 15 taken 1571 times.
✓ Branch 15 → 16 taken 1571 times.
1571 return q.value(0).toInt();
1084 1 return 0;
1085 1572 }
1086
1087 7 int FolderPredictor::folderCount() const {
1088
3/3
✓ Branch 2 → 3 taken 7 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 6 times.
7 if (!m_db.isOpen())
1089 1 return 0;
1090
1091
1/1
✓ Branch 5 → 6 taken 6 times.
6 QSqlQuery q(m_db);
1092
1/1
✓ Branch 9 → 10 taken 6 times.
6 q.exec(QStringLiteral(
1093 "SELECT COUNT(*) FROM folder_doc_counts WHERE doc_count > 0"));
1094
3/3
✓ Branch 12 → 13 taken 6 times.
✓ Branch 13 → 14 taken 5 times.
✓ Branch 13 → 19 taken 1 time.
6 if (q.next())
1095
2/2
✓ Branch 14 → 15 taken 5 times.
✓ Branch 15 → 16 taken 5 times.
5 return q.value(0).toInt();
1096 1 return 0;
1097 6 }
1098
1099 3 QMap<QString, int> FolderPredictor::folderStats() const {
1100 3 QMap<QString, int> stats;
1101
3/3
✓ Branch 2 → 3 taken 3 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 2 times.
3 if (!m_db.isOpen())
1102 1 return stats;
1103
1104
1/1
✓ Branch 5 → 6 taken 2 times.
2 QSqlQuery q(m_db);
1105
1/1
✓ Branch 9 → 10 taken 2 times.
2 q.exec(QStringLiteral(
1106 "SELECT folder, doc_count FROM folder_doc_counts "
1107 "WHERE doc_count > 0 ORDER BY doc_count DESC"));
1108
2/3
✓ Branch 22 → 23 taken 2 times.
✗ Branch 23 → 13 not taken.
✓ Branch 23 → 24 taken 2 times.
2 while (q.next()) {
1109 ✗ stats[q.value(0).toString()] = q.value(1).toInt();
1110 }
1111 2 return stats;
1112 2 }
1113