1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
|
/*
* qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
* Copyright (C) 2026 Danilo M. <danix@danix.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
#include <QtTest>
#include <QAction>
#include <QApplication>
#include <QCloseEvent>
#include <QDir>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QMenu>
#include <QPushButton>
#include <QProgressBar>
#include <QFile>
#include <QSet>
#include <QSettings>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTableView>
#include <QTreeView>
#include <QTimer>
#include "config.h"
#include "keymap.h"
#include "mainwindow.h"
#include "messageview.h"
#include "notmuchworker.h"
#include "tagchip.h"
#include "threadlistmodel.h"
#include "threadlistview.h"
/// MainWindow is mostly wiring, and the parts that need a real database are
/// still verified manually. What is checked here is the action registry: the
/// bindings a user configures reach the QActions the menus and the keyboard
/// both read from, and no action is left unreachable.
class TestMainWindow : public QObject
{
Q_OBJECT
private slots:
void everyKnownActionIsRegistered();
void everyRegisteredActionIsKnown();
void everyActionHasAShortcut();
void configuredBindingReachesTheAction();
void cidPrefixesAreBangFree();
void cidPrefixesAreDistinctPerMessage();
void uiStateIsNotWrittenIntoTheUserConfig();
void uiStateSurvivesARestart();
void missingUiStateLeavesTheDefaults();
void headerStateFromADifferentColumnLayoutIsDiscarded();
void returnInTheQueryBarRunsTheQueryNotOpenThread();
void markReadTimerRestartsRatherThanStacking();
void markReadTimerIsNotArmedForAReadThread();
void markReadCanBeDisabled();
void pendingEditCountSurvivesAQuery();
void aFailedSyncDoesNotClearThePendingCount();
void closingWithNoPendingEditsDoesNotPrompt();
void syncOnExitNeverClosesSilently();
void selectAllIsBoundAndSelectsEveryRow();
void aMultiRowSelectionDoesNotArmTheMarkReadTimer();
void growingASelectionCancelsAnAlreadyArmedTimer();
void collapsingBackToOneRowLoadsThatThreadAgain();
void theStatusBarReportsAMultiRowSelection();
void clearSelectionBlanksThePaneAndDeselects();
void clearPaneLeavesTheSelectionAlone();
void maildirOverviewShowsUnknownRatherThanZero();
void maildirOverviewIgnoresAStaleReply();
void theThreadListOffersAContextMenu();
void aSecondRowBlanksThePaneNotOnlyAThird();
void aLocalSyncIsNotReportedAsABackgroundOne();
void aLocalSyncsOwnLockIsNeverReportedAsBackground();
void aSkippedLocalSyncStillReportsTheOtherRunFinishing();
void anUnobservableLockTableLeavesTheSyncButtonUsable();
void theStatusBarFollowsTheSyncPhase();
void aSelectedReadThreadIsNotDimmedIntoTheHighlight();
void thePillRowSpansTheWholeWidthNotOneColumn();
void childRowsAreIndentedUnderTheirThread();
void aThreadWithRepliesDrawsAVisibleExpander();
void noTagStripIsPaintedUnderAMessageRow();
void markAllReadIsDisabledUntilTheQueryFinishes();
void markAllReadActsOnEveryRowAndUndoesInOneStep();
void markAllReadDoesNothingWhenNothingIsUnread();
void theSyncActionIsDisabledWhileABackgroundSyncHoldsTheLock();
void escapeBlanksTheMessagePane();
void deleteTogglesOnAnAlreadyDeletedThread();
void deleteOnAMixedSelectionDeletesRatherThanSplittingIt();
void aTransientStatusMessageExpires();
void theSelectionCountIsStateAndDoesNotExpire();
void anEditUndoneNettsBackToZero();
void aDifferentTagOnTheSameMessageStillCounts();
void anEditWithNoMessageIdsStillCounts();
void anEditDuringABackgroundSyncIsNotSentYet();
void aHeldEditIsSentWhenTheBackgroundSyncEnds();
void aHeldEditCountsAsUnsynced();
void anUnreadableLockTableStillSendsTheEdit();
void aRejectedWriteKeepsEarlierUndoHistory();
};
void TestMainWindow::everyKnownActionIsRegistered()
{
// KeyMap::knownActions() is what loadOverrides() validates config bindings
// against. An action listed there but never registered means a user can
// bind a key in qtmaildir.conf, get no warning, and have it do nothing.
//
// registeredActionNames() is now derived from the QActions themselves, so
// this compares against what the window really installed.
const Config config;
MainWindow window(config);
const QStringList known = KeyMap::knownActions();
const QStringList registered = window.registeredActionNames();
for (const QString &action : known) {
QVERIFY2(registered.contains(action),
qPrintable(QStringLiteral("known action '%1' is never registered "
"by MainWindow").arg(action)));
}
}
void TestMainWindow::everyRegisteredActionIsKnown()
{
// The reverse drift: an action MainWindow implements but KeyMap rejects.
// The user would get "unknown action" for a binding that is really there.
const Config config;
MainWindow window(config);
const QStringList known = KeyMap::knownActions();
const QStringList registered = window.registeredActionNames();
for (const QString &action : registered) {
QVERIFY2(known.contains(action),
qPrintable(QStringLiteral("registered action '%1' is not in "
"KeyMap::knownActions()").arg(action)));
}
}
void TestMainWindow::everyActionHasAShortcut()
{
// An action with no binding is unreachable from the keyboard. Every one
// of them carries a default, so an empty shortcut means the default table
// and the action list have drifted apart.
const Config config;
MainWindow window(config);
for (const QString &name : window.registeredActionNames()) {
const QAction *action = window.findChild<QAction *>(name);
QVERIFY2(action, qPrintable(QStringLiteral("no QAction named '%1'").arg(name)));
QVERIFY2(!action->shortcut().isEmpty(),
qPrintable(QStringLiteral("action '%1' has no shortcut").arg(name)));
}
}
void TestMainWindow::configuredBindingReachesTheAction()
{
// The whole point of [keys]: a user's override must end up on the QAction,
// which is what both the keyboard and the menus read.
QTemporaryDir dir;
const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
{
QSettings s(path, QSettings::IniFormat);
s.beginGroup(QStringLiteral("keys"));
s.setValue(QStringLiteral("Ctrl+Alt+A"), QStringLiteral("archive"));
s.endGroup();
}
// MainWindow reads its keymap from Config::defaultPath(), so point that
// at the temporary file for this test.
const QString previous = qEnvironmentVariable("XDG_CONFIG_HOME");
QVERIFY(QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir"))));
QVERIFY(QFile::copy(path, dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"))));
qputenv("XDG_CONFIG_HOME", dir.path().toUtf8());
{
const Config config;
MainWindow window(config);
const QAction *archive =
window.findChild<QAction *>(QStringLiteral("archive"));
QVERIFY(archive);
QCOMPARE(archive->shortcut(), QKeySequence(QStringLiteral("Ctrl+Alt+A")));
}
if (previous.isEmpty())
qunsetenv("XDG_CONFIG_HOME");
else
qputenv("XDG_CONFIG_HOME", previous.toUtf8());
}
void TestMainWindow::cidPrefixesAreBangFree()
{
// MainWindow is the only producer of cidPrefix in the application. The
// '!' separator that keeps two messages' cid: references apart is only
// unambiguous while the prefix half contains none.
for (int i : { 0, 1, 9, 10, 99, 1000 }) {
const QString prefix = MainWindow::cidPrefixForIndex(i);
QVERIFY(!prefix.isEmpty());
QVERIFY2(!prefix.contains(QLatin1Char('!')),
qPrintable(QStringLiteral("prefix '%1' contains '!'").arg(prefix)));
}
}
void TestMainWindow::cidPrefixesAreDistinctPerMessage()
{
// Two messages sharing a prefix would share a cid: namespace, which is the
// collision the namespacing exists to prevent.
QSet<QString> seen;
for (int i = 0; i < 200; ++i) {
const QString prefix = MainWindow::cidPrefixForIndex(i);
QVERIFY2(!seen.contains(prefix),
qPrintable(QStringLiteral("prefix '%1' repeats").arg(prefix)));
seen.insert(prefix);
}
}
void TestMainWindow::uiStateIsNotWrittenIntoTheUserConfig()
{
// The config file is hand-edited and must never gain a base64 geometry
// blob, nor be rewritten on exit: QSettings preserves neither comments nor
// key order, so writing it would quietly destroy the user's formatting.
QVERIFY(MainWindow::uiStatePath() != Config::defaultPath());
// One qtmaildir component, not two. QStandardPaths::StateLocation appends
// both the organization and the application name, and here both are
// "qtmaildir", so using it nests the directory inside itself.
QCOMPARE(MainWindow::uiStatePath().count(QStringLiteral("/qtmaildir/")), 1);
QVERIFY(MainWindow::uiStatePath().endsWith(
QStringLiteral("/qtmaildir/uistate.conf")));
}
void TestMainWindow::uiStateSurvivesARestart()
{
// Test mode redirects QStandardPaths at the process level, so the state
// file lands in a scratch directory rather than the real ~/.local/state.
QStandardPaths::setTestModeEnabled(true);
QFile::remove(MainWindow::uiStatePath());
// Must fit the smallest screen this ever runs against: the offscreen
// platform reports 800x800, and restoreGeometry() clamps to the available
// area, so a 940px width came back as 798 and failed only under offscreen.
// The number carries no meaning beyond differing from the default size.
const QSize resized(640, 560);
{
const Config config;
MainWindow window(config);
window.resize(resized);
window.findChild<MessageView *>()->setZoomFactor(1.4);
window.close(); // closeEvent() is what persists the state
}
QVERIFY2(QFile::exists(MainWindow::uiStatePath()),
qPrintable(QStringLiteral("no state file at %1")
.arg(MainWindow::uiStatePath())));
const Config config;
MainWindow reopened(config);
QCOMPARE(reopened.size(), resized);
QCOMPARE(reopened.findChild<MessageView *>()->zoomFactor(), 1.4);
QFile::remove(MainWindow::uiStatePath());
QStandardPaths::setTestModeEnabled(false);
}
void TestMainWindow::missingUiStateLeavesTheDefaults()
{
// A restore that silently succeeded on an empty blob would give a
// zero-size window on first launch. Absent state must be a no-op.
QStandardPaths::setTestModeEnabled(true);
QFile::remove(MainWindow::uiStatePath());
const Config config;
MainWindow window(config);
QCOMPARE(window.size(), QSize(1200, 800));
QStandardPaths::setTestModeEnabled(false);
}
void TestMainWindow::headerStateFromADifferentColumnLayoutIsDiscarded()
{
// The upgrade hazard: a 0.3.0 state file holds a three-column header blob,
// and 0.4.0 added the attachment column in front. QHeaderView::
// restoreState() returns TRUE for a blob with fewer sections than the
// model and applies the old widths shifted one column right, mangling the
// layout with no error to detect it by (verified on Qt 6.11). The stored
// column count is what makes that detectable.
QStandardPaths::setTestModeEnabled(true);
QFile::remove(MainWindow::uiStatePath());
{
const Config config;
MainWindow window(config);
window.close();
}
// Forge a state file from an older layout: same blob, wrong column count.
{
QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat);
state.setValue(QStringLiteral("threadlist/columns"),
int(ThreadListModel::ColumnCount) - 1);
state.setValue(QStringLiteral("threadlist/header"),
QByteArray("not a header this model could have saved"));
}
// Constructing must not apply it, and must not crash on the garbage blob.
const Config config;
MainWindow reopened(config);
auto *view = reopened.findChild<QTreeView *>();
QVERIFY(view);
QCOMPARE(view->columnWidth(ThreadListModel::AttachmentColumn), 28);
QCOMPARE(view->columnWidth(ThreadListModel::DateColumn), 130);
QCOMPARE(view->columnWidth(ThreadListModel::SubjectColumn), 520);
QFile::remove(MainWindow::uiStatePath());
QStandardPaths::setTestModeEnabled(false);
}
void TestMainWindow::returnInTheQueryBarRunsTheQueryNotOpenThread()
{
// Return is bound to open_thread as a WindowShortcut, and the query bar has
// to win it back while it has focus. Qt withholds a plain-LETTER shortcut
// from an editable widget, but Return is not a letter and gets no such
// protection, so without an explicit override the action fires, the query
// never runs, and focus jumps to the thread list.
//
// The delivery order matters and is the reason this bug survived earlier
// tests: real input sends ShortcutOverride first and only dispatches the
// shortcut if nothing accepts it. QTest::keyClick() skips that round trip,
// so a test written with it passes against the broken code.
const Config config;
MainWindow window(config);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
auto *edit = window.findChild<QLineEdit *>();
QVERIFY(edit);
edit->setFocus();
QTRY_COMPARE(QApplication::focusWidget(), edit);
edit->setText(QStringLiteral("tag:unread"));
QAction *openThread = window.findChild<QAction *>(QStringLiteral("open_thread"));
QVERIFY(openThread);
bool actionFired = false;
connect(openThread, &QAction::triggered, &window, [&actionFired]() {
actionFired = true;
});
// The query bar must claim the override, which is what stops the shortcut
// from ever being dispatched.
QKeyEvent override(QEvent::ShortcutOverride, Qt::Key_Return, Qt::NoModifier);
override.ignore();
QApplication::sendEvent(edit, &override);
QVERIFY2(override.isAccepted(),
"the query bar let Return through to the open_thread shortcut");
QVERIFY(!actionFired);
}
/// Selects a top-level THREAD row, replacing QTableView::selectRow which a
/// QTreeView does not have.
///
/// Not merely a rename: setCurrentIndex alone leaves the selection model empty,
/// and select() alone leaves current invalid, so every test asserting on either
/// would break in a different way. Both are set here, exactly as
/// QTableView::selectRow did.
static void selectThreadRow(QTreeView *view, int row)
{
const QModelIndex index = view->model()->index(row, 0, QModelIndex());
view->selectionModel()->select(
index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
view->setCurrentIndex(index);
}
/// The height of a top-level row, replacing QTableView::rowHeight(int).
static int threadRowHeight(QTreeView *view, int row)
{
return view->visualRect(view->model()->index(row, 0, QModelIndex())).height();
}
/// A thread summary carrying the tags a test needs. Enough to drive selection;
/// nothing here touches a database.
static ThreadSummary makeThread(const QString &id, const QStringList &tags)
{
ThreadSummary thread;
thread.threadId = id;
thread.subject = QStringLiteral("Subject ") + id;
thread.authors = QStringLiteral("Someone <someone@example.org>");
thread.tags = tags;
return thread;
}
void TestMainWindow::thePillRowSpansTheWholeWidthNotOneColumn()
{
// The pills are a row-wide strip under the cells, not content of the
// subject cell. Drawn from the subject column's delegate they stop at that
// column's edge, so a thread with several tags loses the last of them; and
// they inherit the column's left edge, which puts them under the subject
// rather than under the row.
//
// The property: pills appear to the LEFT of where the subject column
// starts, which no per-cell delegate on that column could produce.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
ThreadSummary thread = makeThread(QStringLiteral("t1"), {});
thread.tags = QStringList{ QStringLiteral("mailing-list/SBo"),
QStringLiteral("signed") };
model->appendBatch({ thread });
window.resize(1400, 300);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
QApplication::processEvents();
const int subjectLeft =
view->columnViewportPosition(ThreadListModel::SubjectColumn);
QVERIFY2(subjectLeft > 40,
qPrintable(QStringLiteral("the subject column starts at x=%1, too "
"close to the left edge to tell a "
"row-wide strip from a subject-cell one")
.arg(subjectLeft)));
// The strip must have somewhere to paint that the subject cell does not
// reach, or this test cannot fail.
QVERIFY2(subjectLeft < view->viewport()->width(),
qPrintable(QStringLiteral("the subject column is off-screen "
"(x=%1, viewport %2), so nothing it "
"draws is measurable")
.arg(subjectLeft)
.arg(view->viewport()->width())));
QImage shot(view->viewport()->size(), QImage::Format_ARGB32);
shot.fill(Qt::transparent);
view->viewport()->render(&shot);
// Count pixels matching the tag colours EXACTLY, not "saturated" pixels.
// A looser test counts the antialiased edge of the selection highlight
// blending into the background, which is several hundred distinct
// near-background colours and passes whatever the strip does. Both earlier
// versions of this test did precisely that.
QSet<QRgb> pillColours;
const QVariantList colours =
model->index(0, ThreadListModel::SubjectColumn)
.data(ThreadListModel::PillColoursRole).toList();
QVERIFY2(!colours.isEmpty(), "the model supplied no pill colours");
for (const QVariant &colour : colours)
pillColours.insert(colour.value<QColor>().rgb());
const int rowHeight = threadRowHeight(view, 0);
QVERIFY(rowHeight > 0);
int chipPixels = 0;
for (int y = 0; y < qMin(rowHeight, shot.height()); ++y) {
for (int x = 0; x < qMin(subjectLeft, shot.width()); ++x) {
if (pillColours.contains(shot.pixel(x, y) | 0xff000000))
++chipPixels;
}
}
QVERIFY2(chipPixels > 0,
"no pill-coloured pixels left of the subject column: the strip is "
"still confined to that cell rather than spanning the row");
}
void TestMainWindow::childRowsAreIndentedUnderTheirThread()
{
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY2(view, "the thread list is not a QTreeView, so it cannot indent");
// With an account tag, so the thread row draws the chip that a reply row
// does not. That asymmetry is the whole reason the indent has to be wide,
// and a test against an untagged thread never sees it.
model->appendBatch({ makeThread(
QStringLiteral("t1"),
QStringList{ TagColors::tagForAccountKey(QStringLiteral("work")) }) });
MessageNode first;
first.messageId = QStringLiteral("m0@example.org");
first.threadId = QStringLiteral("t1");
first.depth = 0;
MessageNode reply;
reply.messageId = QStringLiteral("m1@example.org");
reply.threadId = QStringLiteral("t1");
reply.from = QStringLiteral("A Replier <replier@example.org>");
reply.depth = 1;
model->setThreadMessages(QStringLiteral("t1"), { first, reply });
window.resize(1400, 300);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
const QModelIndex root = model->index(0, 0, QModelIndex());
view->expand(root);
QApplication::processEvents();
// Measured on the TREE POSITION column, not on column 0. A QTreeView
// indents only the column carrying the expander, verified against Qt 6.11:
// with setTreePosition(4), column 0 reports the same left edge for a thread
// and its reply (0 and 0) while column 4 reports 420 and 440. Asserting on
// column 0 therefore fails against a perfectly indented tree.
const int treeColumn = ThreadListModel::SubjectColumn;
const QModelIndex rootCell = model->index(0, treeColumn, QModelIndex());
const QModelIndex child = model->index(0, treeColumn, root);
QVERIFY(child.isValid());
// Guards before the claim: a probe that cannot see both rows can report
// anything it likes about their relative position.
QVERIFY2(view->visualRect(rootCell).height() > 0,
"the thread row has no height, so nothing about it is measurable");
QVERIFY2(view->visualRect(child).height() > 0,
"the reply row has no height: it is collapsed or off-screen, and "
"an indent test against it would pass without drawing anything");
QVERIFY2(view->visualRect(child).left() > view->visualRect(rootCell).left(),
"the reply is not indented relative to its thread");
// The geometry being indented is NOT the same as the reply LOOKING
// indented, and asserting only the former shipped a build with no visible
// nesting at all. A thread row draws an account chip before its subject and
// a reply row does not, so the reply's text starts about a chip's width to
// the left of the thread's; at Qt's default 20px indent that difference
// swallows the shift entirely.
//
// So the real property: where the TEXT lands. The reply's subject must
// begin to the right of the thread's, which is what the eye reads as
// nesting.
const int chipWidth =
TagChip::sizeFor(QFontMetrics(view->font()),
model->data(rootCell, ThreadListModel::AccountLabelRole)
.toString()).width();
QVERIFY2(chipWidth > 0,
"the thread row has no account chip, so this test cannot measure "
"the offset it is meant to compensate for");
const int threadTextLeft = view->visualRect(rootCell).left() + chipWidth;
QVERIFY2(view->visualRect(child).left() > threadTextLeft,
qPrintable(QStringLiteral("the reply's text starts at x=%1, not "
"right of the thread's text at x=%2: the "
"indent does not beat the account chip "
"and the nesting is invisible")
.arg(view->visualRect(child).left())
.arg(threadTextLeft)));
}
void TestMainWindow::aThreadWithRepliesDrawsAVisibleExpander()
{
// The expander is the ONLY thing saying a thread can be opened, and it took
// four wrong attempts to get on screen, each of which looked correct in
// code:
//
// - QTreeView::drawBranches, the documented hook, runs BEFORE the row's
// cells, so with the expander on a content column the delegate's own
// background paints over it. A 60-pixel triangle survived as 8.
// - Sizing it from the row rather than the branch rect put most of it
// outside that rect.
// - Moving it into the delegate but calling it from only one of the two
// branches left every real row without one, since every real row has an
// account chip and takes the other branch.
//
// None of those is visible to a test that asserts on geometry or on model
// roles, so this one counts painted pixels of the palette colour the glyph
// is drawn in.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
// Two threads: one with replies, one without. The second is the control,
// and without it a test that counts text pixels would pass on any row.
ThreadSummary withReplies = makeThread(
QStringLiteral("t1"),
QStringList{ TagColors::tagForAccountKey(QStringLiteral("work")) });
withReplies.totalCount = 3;
ThreadSummary lone = makeThread(
QStringLiteral("t2"),
QStringList{ TagColors::tagForAccountKey(QStringLiteral("work")) });
lone.totalCount = 1;
model->appendBatch({ withReplies, lone });
window.resize(1400, 300);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
QApplication::processEvents();
const QModelIndex first =
model->index(0, ThreadListModel::SubjectColumn, QModelIndex());
const QModelIndex second =
model->index(1, ThreadListModel::SubjectColumn, QModelIndex());
// Guards: both rows on screen, and the model agreeing about which has
// replies. Without these a zero count could mean anything.
QVERIFY2(view->visualRect(first).height() > 0, "the first row is not drawn");
QVERIFY2(view->visualRect(second).height() > 0,
"the control row is not drawn");
QVERIFY(model->data(first, ThreadListModel::HasRepliesRole).toBool());
QVERIFY(!model->data(second, ThreadListModel::HasRepliesRole).toBool());
QImage shot(view->viewport()->size(), QImage::Format_ARGB32);
shot.fill(Qt::transparent);
view->viewport()->render(&shot);
// The exact colour the glyph is filled with, matched exactly rather than by
// a brightness threshold, which would count antialiased subject text.
const QRgb glyph = view->palette().color(QPalette::Text).rgb();
// Only the strip in front of the subject text, so the subject's own glyphs
// cannot be counted. kExpanderWidth is the room the delegate reserves.
const auto countGlyphPixels = [&](const QModelIndex &index) {
const QRect rect = view->visualRect(index);
int found = 0;
for (int y = rect.top(); y < qMin(rect.bottom(), shot.height()); ++y) {
for (int x = rect.left();
x < qMin(rect.left() + SubjectDelegate::kExpanderWidth,
shot.width());
++x) {
if ((shot.pixel(x, y) | 0xff000000) == (glyph | 0xff000000))
++found;
}
}
return found;
};
const int drawn = countGlyphPixels(first);
const int control = countGlyphPixels(second);
QVERIFY2(drawn > 12,
qPrintable(QStringLiteral("only %1 expander pixels: the glyph is "
"clipped or painted over, which is how "
"it shipped as an invisible dot")
.arg(drawn)));
// The control must have none, or the count above is measuring something
// every row draws.
QCOMPARE(control, 0);
}
void TestMainWindow::noTagStripIsPaintedUnderAMessageRow()
{
// The strip is a row-wide band of the THREAD's tags. Painted under every
// reply as well it would stripe the list and repeat identical tags down the
// whole expansion.
//
// TWO independent guards stop that, and this test is aimed at the SECOND:
// the model returns no pills for a child row, and the view skips child rows
// in its walk. Asserting against the real model tests only the first, and
// the view's guard can be deleted without the test noticing: verified by
// mutation, which passed with the skip removed. So the model is replaced
// here by one that hands out pills for EVERY row, thread and reply alike,
// leaving the view's own skip as the only thing that can keep the reply
// rows clean.
/// Hands out the same pills for a message row as for a thread row, which
/// the real model never does. Without this the view's skip is unobservable.
class PillsEverywhereModel : public ThreadListModel
{
public:
QVariant data(const QModelIndex &index, int role) const override
{
if (role == PillTagsRole) {
return QStringList{ QStringLiteral("mailing-list/SBo"),
QStringLiteral("signed") };
}
if (role == PillColoursRole) {
return QVariantList{ QVariant::fromValue(QColor(Qt::magenta)),
QVariant::fromValue(QColor(Qt::cyan)) };
}
return ThreadListModel::data(index, role);
}
};
PillsEverywhereModel model;
ThreadListView view;
view.setModel(&model);
view.setTreePosition(ThreadListModel::SubjectColumn);
view.setUniformRowHeights(true);
// The delegates MainWindow installs, and not optional here. The strip's
// band is measured against SubjectDelegate::rowHeightFor; without the
// delegate the rows take the default height, the band overflows into the
// row below, and the thread's own strip paints across the reply. That
// reads exactly like a missing skip in the walk and is not one.
view.setItemDelegate(new RowStyleDelegate(&view));
view.setItemDelegateForColumn(ThreadListModel::SubjectColumn,
new SubjectDelegate(&view));
view.setColumnWidth(ThreadListModel::AttachmentColumn, 28);
view.setColumnWidth(ThreadListModel::FlagColumn, 28);
view.setColumnWidth(ThreadListModel::DateColumn, 130);
view.setColumnWidth(ThreadListModel::AuthorsColumn, 180);
view.setColumnWidth(ThreadListModel::SubjectColumn, 520);
ThreadSummary thread = makeThread(QStringLiteral("t1"), {});
thread.tags = QStringList{ QStringLiteral("mailing-list/SBo"),
QStringLiteral("signed") };
model.appendBatch({ thread });
MessageNode first;
first.messageId = QStringLiteral("m0@example.org");
first.threadId = QStringLiteral("t1");
first.depth = 0;
MessageNode reply;
reply.messageId = QStringLiteral("m1@example.org");
reply.threadId = QStringLiteral("t1");
reply.depth = 1;
model.setThreadMessages(QStringLiteral("t1"), { first, reply });
view.resize(1400, 300);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
const QModelIndex root = model.index(0, 0, QModelIndex());
view.expand(root);
QApplication::processEvents();
const QModelIndex child = model.index(0, 0, root);
const QRect childRect = view.visualRect(child);
QVERIFY2(childRect.height() > 0, "the reply row is not on screen");
// The exact colours the stub supplies, so an antialiased edge of anything
// else cannot be counted as a pill.
QSet<QRgb> pillColours;
pillColours.insert(QColor(Qt::magenta).rgb());
pillColours.insert(QColor(Qt::cyan).rgb());
QImage shot(view.viewport()->size(), QImage::Format_ARGB32);
shot.fill(Qt::transparent);
view.viewport()->render(&shot);
// Guard proving the probe can see pills at all: the THREAD row must have
// them, or a zero count under the reply proves nothing about the reply.
const QRect rootRect = view.visualRect(root);
int threadPills = 0;
for (int y = rootRect.top(); y < qMin(rootRect.bottom(), shot.height()); ++y) {
for (int x = 0; x < shot.width(); ++x) {
if (pillColours.contains(shot.pixel(x, y) | 0xff000000))
++threadPills;
}
}
QVERIFY2(threadPills > 0,
"no pill pixels under the THREAD row either, so this probe cannot "
"tell a missing strip from a broken render");
int replyPills = 0;
for (int y = childRect.top(); y < qMin(childRect.bottom(), shot.height()); ++y) {
for (int x = 0; x < shot.width(); ++x) {
if (pillColours.contains(shot.pixel(x, y) | 0xff000000))
++replyPills;
}
}
QCOMPARE(replyPills, 0);
}
void TestMainWindow::aSelectedReadThreadIsNotDimmedIntoTheHighlight()
{
// Read threads carry a dimmed Qt::ForegroundRole, blended against the
// UNSELECTED background. Qt's own painting prefers a model foreground over
// HighlightedText, so without SubjectDelegate::initStyleOption reversing
// that, selecting a read row paints it grey on the selection colour, which
// is close to unreadable. Seen in a screenshot before it was caught here.
//
// Rendered rather than asserted on roles: the model is right either way,
// and the defect lives entirely in how the delegate resolves them.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
// Both rows READ, so both are dimmed and neither is bold: the only thing
// that could differ is how the dimming composites against the selection.
//
// Comparing a read row against an unread one would not work, and an
// earlier version of this test did exactly that. Unread also paints bold,
// so the rows differ legitimately and the comparison says nothing about
// the selection. That version passed only because the machine it was
// written on had its Qt font configured Bold, which made every row bold
// and hid the difference.
ThreadSummary first = makeThread(QStringLiteral("t1"), {});
ThreadSummary second = makeThread(QStringLiteral("t2"), {});
first.subject = second.subject = QStringLiteral("Same subject both rows");
first.authors = second.authors = QStringLiteral("Someone <s@example.org>");
model->appendBatch({ first, second });
window.resize(900, 300);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
// Row 0 selected, row 1 not. The property under test is that selecting a
// dimmed row switches it to the highlight's own text colour, so the two
// rows MUST differ; comparing two identically-styled rows would pass
// against a delegate that did nothing at all.
selectThreadRow(view, 0);
QApplication::processEvents();
const int rowHeight = threadRowHeight(view, 0);
QVERIFY(rowHeight > 0);
QImage shot(view->viewport()->size(), QImage::Format_ARGB32);
shot.fill(Qt::transparent);
view->viewport()->render(&shot);
// What the delegate resolves for each row, which is the thing the fix
// changes. Rendering alone cannot separate "used the highlight colour"
// from "used the dim over a highlighted background".
QStyleOptionViewItem selected;
selected.initFrom(view);
selected.state |= QStyle::State_Selected;
QStyleOptionViewItem unselected;
unselected.initFrom(view);
unselected.state &= ~QStyle::State_Selected;
auto *delegate = qobject_cast<QStyledItemDelegate *>(view->itemDelegate());
QVERIFY2(delegate, "the thread view has no styled delegate");
const QModelIndex index =
model->index(0, ThreadListModel::SubjectColumn);
// initStyleOption is protected, so the resolved palette is reached the way
// the painter does: through a subclass that exposes it.
struct Probe : SubjectDelegate {
using SubjectDelegate::initStyleOption;
};
const auto *probe = static_cast<const Probe *>(
static_cast<const SubjectDelegate *>(delegate));
probe->initStyleOption(&selected, index);
probe->initStyleOption(&unselected, index);
QVERIFY2(selected.palette.color(QPalette::Text)
== selected.palette.color(QPalette::HighlightedText),
"a selected row still resolves to the dimmed text colour, so the "
"dimming will paint over the selection highlight");
QVERIFY2(unselected.palette.color(QPalette::Text)
!= selected.palette.color(QPalette::Text),
"an unselected read row lost its dimming");
}
void TestMainWindow::markAllReadIsDisabledUntilTheQueryFinishes()
{
// Threads arrive in batches, so acting mid-load would silently skip
// whatever had not arrived. Rather than acting on part of the view and
// calling it "all", or stalling on a wait the user cannot see, the action
// is simply unavailable until the result set is complete.
const Config config;
MainWindow window(config);
auto *action = window.findChild<QAction *>(QStringLiteral("mark_all_read"));
QVERIFY2(action, "no mark_all_read action registered");
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
// A query in flight: rows are arriving but the worker has not said it is
// done, so the action must stay out of reach.
// A query is needed for runCurrentQuery to do anything: it returns early
// on an empty one, which would leave the flag untouched.
window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
QMetaObject::invokeMethod(&window, "runCurrentQuery");
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }) });
QVERIFY2(!action->isEnabled(),
"the action was live while the query was still loading");
// The generation must match or the reply is discarded as stale, which is
// how a superseded query is ignored everywhere else in this window.
const quint64 generation = window.currentGenerationForTesting();
QMetaObject::invokeMethod(&window, "onQueryFinished",
Q_ARG(int, 1), Q_ARG(quint64, generation));
QVERIFY2(action->isEnabled(),
"the action stayed disabled after the query finished");
}
void TestMainWindow::markAllReadActsOnEveryRowAndUndoesInOneStep()
{
// Every row in the view, not just the selected ones, and one undo entry for
// the batch: a user who marks 400 threads read expects one Ctrl+Z to be
// enough.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("mark_all_read"));
QVERIFY(action);
// A query is needed for runCurrentQuery to do anything: it returns early
// on an empty one, which would leave the flag untouched.
window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
QMetaObject::invokeMethod(&window, "runCurrentQuery");
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t3"),
{ QStringLiteral("unread"),
QStringLiteral("flagged") }) });
QMetaObject::invokeMethod(&window, "onQueryFinished", Q_ARG(int, 3),
Q_ARG(quint64,
window.currentGenerationForTesting()));
// One row selected, to prove the action ignores the selection rather than
// acting on it.
selectThreadRow(view, 0);
action->trigger();
for (int row = 0; row < 3; ++row) {
QVERIFY2(!model->threadAt(row).tags.contains(QStringLiteral("unread")),
qPrintable(QStringLiteral("row %1 kept its unread tag")
.arg(row)));
}
// An unrelated tag on a row is untouched: only unread is removed.
QVERIFY(model->threadAt(2).tags.contains(QStringLiteral("flagged")));
// ONE undo entry for the whole batch, not one per thread. Asserted as a
// depth, since triggering undo once and finding everything restored would
// also pass if three commands had been pushed and the model happened to
// recover on the first.
QCOMPARE(window.undoDepthForTesting(), 1);
auto *undo = window.findChild<QAction *>(QStringLiteral("undo"));
QVERIFY(undo);
undo->trigger();
for (int row = 0; row < 3; ++row) {
QVERIFY2(model->threadAt(row).tags.contains(QStringLiteral("unread")),
qPrintable(QStringLiteral("row %1 was not restored by one undo")
.arg(row)));
}
}
void TestMainWindow::markAllReadDoesNothingWhenNothingIsUnread()
{
// No write, no undo entry, and no pending edit for a view that is already
// read: an undo entry that restores nothing is worse than none, since it
// absorbs a Ctrl+Z the user meant for their previous action.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *action = window.findChild<QAction *>(QStringLiteral("mark_all_read"));
QVERIFY(action);
// A query is needed for runCurrentQuery to do anything: it returns early
// on an empty one, which would leave the flag untouched.
window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
QMetaObject::invokeMethod(&window, "runCurrentQuery");
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("flagged") }),
makeThread(QStringLiteral("t2"), {}) });
QMetaObject::invokeMethod(&window, "onQueryFinished", Q_ARG(int, 2),
Q_ARG(quint64,
window.currentGenerationForTesting()));
QCOMPARE(window.undoDepthForTesting(), 0);
action->trigger();
// The real assertion: no command was pushed. Checking only that the tags
// did not change would pass against a version that sent a no-op write for
// every row, which still costs an undo entry and a pending edit each. The
// undo QAction cannot answer this: it is always enabled and tests canUndo()
// when triggered.
QVERIFY2(window.undoDepthForTesting() == 0,
"an undo entry was pushed for a view with nothing unread");
QVERIFY(model->threadAt(0).tags.contains(QStringLiteral("flagged")));
// And it says so rather than appearing to have done something.
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY(status);
QVERIFY2(status->text().contains(QStringLiteral("Nothing unread")),
qPrintable(status->text()));
}
void TestMainWindow::markReadTimerRestartsRatherThanStacking()
{
// The plan's hard requirement: arrowing quickly down a list must not mark
// every thread passed through as read, only the one still selected when the
// timer fires. A stacked timer per selection would mark all of them.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t3"),
{ QStringLiteral("unread") }) });
selectThreadRow(view, 0);
QVERIFY2(timer->isActive(), "no timer armed for an unread thread");
// Move on before it can fire. One timer stays armed, not three.
selectThreadRow(view, 1);
QVERIFY(timer->isActive());
selectThreadRow(view, 2);
QVERIFY(timer->isActive());
// Exactly one timer exists at all, which is what "restarted, not stacked"
// means concretely.
QCOMPARE(window.findChildren<QTimer *>(QStringLiteral("markReadTimer")).size(),
1);
}
void TestMainWindow::markReadTimerIsNotArmedForAReadThread()
{
// Opening a thread that is already read must not schedule a write that
// would change nothing.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("read"),
{ QStringLiteral("inbox") }),
makeThread(QStringLiteral("unread"),
{ QStringLiteral("unread") }) });
selectThreadRow(view, 0);
QVERIFY2(!timer->isActive(), "armed a timer for an already-read thread");
// And the unread one still arms, so this is not "never arms".
selectThreadRow(view, 1);
QVERIFY(timer->isActive());
// Moving back to a read thread disarms it again, rather than leaving the
// previous thread's timer running to fire against the wrong row.
selectThreadRow(view, 0);
QVERIFY(!timer->isActive());
}
void TestMainWindow::markReadCanBeDisabled()
{
// A negative delay turns the behaviour off entirely. Documented, so it must
// work rather than being clamped to "immediately".
QTemporaryDir dir;
const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
file.write("[general]\nmark_read_delay_ms=-1\n");
}
Config config;
config.load(path);
QCOMPARE(config.markReadDelayMs(), -1);
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }) });
selectThreadRow(view, 0);
QVERIFY2(!timer->isActive(),
"a negative mark_read_delay_ms must disable the timer");
}
void TestMainWindow::pendingEditCountSurvivesAQuery()
{
// The defining property, and the reason this is a counter of its own rather
// than QUndoStack::isClean(): the undo stack is cleared on every query,
// because its entries refer to rows the new result set discards. Tag a
// thread, run any query, and the stack is empty while the change is still
// sitting unsynced in the database.
const Config config;
MainWindow window(config);
auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
QVERIFY(label);
QVERIFY2(label->isHidden(), "the indicator must start hidden at zero");
// Confirm a write the way the worker really does, by emitting the signal
// the window listens to. No test-only entry point on MainWindow.
TagChange change;
change.added = { QStringLiteral("deleted") };
change.description = QStringLiteral("Delete");
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, change)));
QVERIFY2(!label->isHidden(), "a confirmed edit must show the indicator");
const QString afterEdit = label->text();
QVERIFY(!afterEdit.isEmpty());
// Now run a query, which clears the undo stack. The indicator must not
// follow it down.
window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
QMetaObject::invokeMethod(&window, "runCurrentQuery");
QVERIFY2(!label->isHidden(),
"the indicator was cleared by a query, so it is tracking the undo "
"stack rather than unsynced state");
QCOMPARE(label->text(), afterEdit);
}
void TestMainWindow::aFailedSyncDoesNotClearThePendingCount()
{
// A failed sync means the edits are still unsynced. Clearing here would
// assert the opposite, and the user would quit believing their tagging had
// been carried over.
const Config config;
MainWindow window(config);
auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
QVERIFY(label);
TagChange change;
change.added = { QStringLiteral("flagged") };
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, change)));
QVERIFY(!label->isHidden());
const QString afterEdit = label->text();
QMetaObject::invokeMethod(&window, "onSyncFinished",
Q_ARG(bool, false), Q_ARG(int, 1));
QVERIFY2(!label->isHidden(), "a FAILED sync cleared the pending count");
QCOMPARE(label->text(), afterEdit);
// A successful one does clear it, so this is not "never clears".
QMetaObject::invokeMethod(&window, "onSyncFinished",
Q_ARG(bool, true), Q_ARG(int, 0));
QVERIFY2(label->isHidden(), "a successful sync must clear the indicator");
}
/// Closes a window and reports whether it accepted, failing rather than hanging
/// if a modal appears.
///
/// A modal spins its own event loop, so a test that simply sends a close event
/// blocks forever when a dialog it did not expect opens. This arms a timer that
/// closes any active modal and records that one was there, which turns "a
/// dialog appeared" into an assertion instead of a hung run.
struct CloseProbe
{
bool accepted = false;
bool sawModal = false;
void run(MainWindow *window)
{
QTimer poll;
poll.setInterval(50);
int ticks = 0;
QObject::connect(&poll, &QTimer::timeout, [this, &poll, &ticks]() {
if (QWidget *modal = QApplication::activeModalWidget()) {
sawModal = true;
modal->close();
poll.stop();
return;
}
if (++ticks > 20) // one second is ample for a synchronous close
poll.stop();
});
poll.start();
QCloseEvent event;
QApplication::sendEvent(window, &event);
accepted = event.isAccepted();
poll.stop();
}
};
void TestMainWindow::closingWithNoPendingEditsDoesNotPrompt()
{
// Nothing outstanding means nothing to ask about. If a prompt fires here it
// is keying off something other than there being work to lose, and every
// quit would carry a dialog.
const Config config;
MainWindow window(config);
CloseProbe probe;
probe.run(&window);
QVERIFY2(!probe.sawModal, "a clean window prompted on close");
QVERIFY2(probe.accepted, "a clean window refused to close");
}
void TestMainWindow::syncOnExitNeverClosesSilently()
{
// "never" is the behaviour that existed before the prompt did, and it has
// to stay reachable for anyone who does not want to be asked. It must hold
// whether or not a sync command is configured, so this covers both: the
// no-command path has its own dialog, and "never" must skip that one too.
QTemporaryDir dir;
const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
file.write("[general]\nsync_on_exit=never\n\n[sync]\ncommand=/bin/true\n");
}
Config config;
config.load(path);
QCOMPARE(config.syncOnExit(), Config::SyncOnExit::Never);
MainWindow window(config);
// Give it something to lose, so this is not passing for the same reason
// the previous test does.
TagChange change;
change.added = { QStringLiteral("deleted") };
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, change)));
auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
QVERIFY(label);
QVERIFY(!label->isHidden());
CloseProbe probe;
probe.run(&window);
QVERIFY2(!probe.sawModal,
"sync_on_exit=never prompted anyway");
QVERIFY2(probe.accepted,
"sync_on_exit=never must close without prompting");
}
void TestMainWindow::selectAllIsBoundAndSelectsEveryRow()
{
// Multi-select already worked by Ctrl+click and Shift+click; what was
// missing was a keyboard and menu route to it. The action has to exist as a
// registered action, not as a raw view shortcut, so it reaches the menu,
// the shortcut reference and [keys] like every other binding.
const Config config;
MainWindow window(config);
auto *action = window.findChild<QAction *>(QStringLiteral("select_all"));
QVERIFY2(action, "no select_all action registered");
QCOMPARE(action->shortcut(), QKeySequence(QStringLiteral("Ctrl+A")));
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}),
makeThread(QStringLiteral("t3"), {}) });
action->trigger();
QCOMPARE(view->selectionModel()->selectedRows().size(), 3);
}
void TestMainWindow::aMultiRowSelectionDoesNotArmTheMarkReadTimer()
{
// A selection gesture must never mutate mail. current follows the keyboard
// cursor as a selection extends, so without a guard every row swept through
// by Shift+arrow would be queued to be marked read: threads the user only
// ever selected, never opened.
//
// Note selectAll() on a fresh view is NOT the case to test here: it leaves
// current invalid and emits no currentRowChanged at all (verified against
// Qt 6.11), so it would pass without any guard in place. The real path is a
// row already current, which is how a user reaches select-all: click a
// thread, then Ctrl+A.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t3"),
{ QStringLiteral("unread") }) });
// Sweep down as Shift+arrow does: current moves onto a row while the
// selection already spans more than one.
selectThreadRow(view, 0);
view->selectionModel()->select(
model->index(1, 0),
QItemSelectionModel::Select | QItemSelectionModel::Rows);
view->selectionModel()->setCurrentIndex(
model->index(1, 0),
QItemSelectionModel::Select | QItemSelectionModel::Rows);
QVERIFY2(view->selectionModel()->selectedRows().size() > 1,
"test setup failed to build a multi-row selection");
QVERIFY2(!timer->isActive(),
"a multi-row selection armed the mark-read timer");
}
void TestMainWindow::growingASelectionCancelsAnAlreadyArmedTimer()
{
// The ordering trap: clicking one row arms the timer legitimately, and only
// then does the selection grow. Guarding the new selection alone is not
// enough, the timer already running for the first row has to be cancelled
// or that thread goes read behind a pane that no longer shows it.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }) });
selectThreadRow(view, 0);
QVERIFY2(timer->isActive(), "no timer armed for a single unread thread");
// Extend to a second row, as Shift+click would.
view->selectionModel()->select(
model->index(1, 0),
QItemSelectionModel::Select | QItemSelectionModel::Rows);
QVERIFY2(!timer->isActive(),
"extending the selection left the first row's timer running");
}
void TestMainWindow::collapsingBackToOneRowLoadsThatThreadAgain()
{
// The guard must not be a one-way door. Narrowing a multi-row selection
// back to a single row is ordinary reading again, so the timer arms as it
// always did.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }) });
view->selectAll();
QVERIFY(!timer->isActive());
// Back to one row, as a plain click would leave it.
selectThreadRow(view, 1);
QVERIFY2(timer->isActive(),
"collapsing back to one row did not resume mark-read");
}
void TestMainWindow::theStatusBarReportsAMultiRowSelection()
{
// The actual discoverability gap: the UI never acknowledged a selection, so
// nothing taught the user that selecting more than one row was possible.
// A count that appears while the selection is being built does.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY2(status, "no status label to report into");
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}),
makeThread(QStringLiteral("t3"), {}) });
view->selectAll();
QVERIFY2(status->text().contains(QStringLiteral("3")),
qPrintable(QStringLiteral("status bar does not report the selection "
"size, it says '%1'").arg(status->text())));
}
void TestMainWindow::clearSelectionBlanksThePaneAndDeselects()
{
// Item 50: the user asked for "two actions instead of one", so this is the
// new action and clearPaneLeavesTheSelectionAlone() below pins the old one.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}) });
// From a row that is already current, which is both how a user reaches this
// and what CLAUDE.md requires: selectAll() on a fresh view emits no
// currentRowChanged at all, so a test starting there passes against a
// missing guard.
selectThreadRow(view, 0);
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
auto *action = window.findChild<QAction *>(QStringLiteral("clear_selection"));
QVERIFY2(action, "no clear_selection action");
action->trigger();
QVERIFY2(view->selectionModel()->selectedRows().isEmpty(),
"the row is still selected: this action's whole point is that it "
"deselects as well as blanking");
// The hazard: clearSelection() leaves currentIndex() VALID, so
// onSelectionChanged() takes its "one or fewer rows" branch, sees a current
// row whose id differs from the just-cleared m_currentThreadId, and calls
// onThreadSelected for it, which sets m_currentThreadId again and sends a
// loadThread. The pane would then repaint itself a moment later.
//
// **currentThreadId() is what detects that, not the pane.** This fixture
// has no worker, so loadThread never replies and nothing ever repaints;
// asserting showingPlaceholder() here passes whatever the code does, which
// CLAUDE.md records as the standing limit of test_mainwindow. What IS
// observable is the id the window set on its way to that request.
QVERIFY2(window.currentThreadId().isEmpty(),
qPrintable(QStringLiteral("a thread was re-adopted after the "
"selection was cleared: currentThreadId "
"is '%1', and a loadThread for it is "
"already in flight")
.arg(window.currentThreadId())));
// And current itself is gone, so no later collapse-to-one-row can reload
// it either.
QVERIFY2(!view->currentIndex().isValid(),
"currentIndex is still valid, so onSelectionChanged can reload "
"that row on the next selection change");
}
void TestMainWindow::clearPaneLeavesTheSelectionAlone()
{
// The pre-existing action keeps its behaviour. Item 32 built it to blank
// WITHOUT touching the selection, and a user who binds it is entitled to
// that; item 50 adds a second action rather than changing this one.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}) });
selectThreadRow(view, 0);
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
auto *action = window.findChild<QAction *>(QStringLiteral("clear_pane"));
QVERIFY2(action, "no clear_pane action");
action->trigger();
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
}
void TestMainWindow::maildirOverviewShowsUnknownRatherThanZero()
{
// A field notmuch could not answer must not render as 0. "0 messages" says
// the Maildir is empty, which is a claim; the truth is that the count
// failed, and telling someone their mail is gone is the worst available
// way to report an unreadable index.
const Config config;
MainWindow window(config);
auto *action = window.findChild<QAction *>(QStringLiteral("maildirOverview"));
QVERIFY2(action, "no maildirOverview action");
action->trigger();
auto *counts = window.findChild<QLabel *>(QStringLiteral("maildirCounts"));
QVERIFY2(counts, "the overview dialog has no counts label");
// The worker never answers in this fixture, so drive the slot directly
// with the all-unknown stats a failed open produces.
QMetaObject::invokeMethod(
&window, "onDatabaseStatsReady", Qt::DirectConnection,
Q_ARG(DatabaseStats, DatabaseStats{}),
Q_ARG(quint64, window.statsGenerationForTesting()));
QVERIFY2(counts->text().contains(QStringLiteral("unknown")),
qPrintable(QStringLiteral("counts label says '%1'")
.arg(counts->text())));
QVERIFY2(!counts->text().contains(QStringLiteral(">0<")),
qPrintable(QStringLiteral("an unanswered count rendered as zero: "
"'%1'").arg(counts->text())));
// WA_DeleteOnClose, so closing is what frees it. Left open, each test
// leaks a window for the rest of the run.
counts->window()->close();
}
void TestMainWindow::maildirOverviewIgnoresAStaleReply()
{
// Counting every message is slow enough that closing and reopening the
// dialog while one runs is realistic. The older answer must not fill in the
// newer dialog, or the numbers silently predate whatever prompted the
// reopen.
const Config config;
MainWindow window(config);
auto *action = window.findChild<QAction *>(QStringLiteral("maildirOverview"));
QVERIFY(action);
action->trigger();
const quint64 stale = window.statsGenerationForTesting();
// Reopening bumps the generation, which is what makes the first reply old.
action->trigger();
QVERIFY2(window.statsGenerationForTesting() != stale,
"reopening the dialog did not bump the generation, so a reply for "
"the previous one cannot be told apart");
auto *counts = window.findChild<QLabel *>(QStringLiteral("maildirCounts"));
QVERIFY(counts);
const QString before = counts->text();
DatabaseStats old;
old.messages = 4321;
old.threads = 999;
old.tags = 42;
QMetaObject::invokeMethod(&window, "onDatabaseStatsReady",
Qt::DirectConnection,
Q_ARG(DatabaseStats, old),
Q_ARG(quint64, stale));
QCOMPARE(counts->text(), before);
QVERIFY2(!counts->text().contains(QStringLiteral("4321")),
"a reply for the previous dialog filled in the current one");
QPointer<QLabel> watch(counts);
counts->window()->close();
// WA_DeleteOnClose deletes through deleteLater, so the label outlives
// close() until the event loop runs. Drain it, or the "reply after the
// dialog is gone" case below is not actually being tested.
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
QVERIFY2(watch.isNull(),
"the dialog was not destroyed, so the case below is not the one "
"this test means to exercise");
// The QPointer's reason for being: counting a large database takes long
// enough that closing the dialog first is ordinary, and the reply then
// arrives for a label that has been deleted. A raw pointer would dangle
// here, so this must not crash.
QMetaObject::invokeMethod(&window, "onDatabaseStatsReady",
Qt::DirectConnection,
Q_ARG(DatabaseStats, old),
Q_ARG(quint64,
window.statsGenerationForTesting()));
}
void TestMainWindow::theThreadListOffersAContextMenu()
{
// Right-click is the other half of discoverability: until now every tag
// action was keyboard-only, so the Ctrl+T dialog in particular could not be
// reached with the mouse at all.
const Config config;
MainWindow window(config);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
QCOMPARE(view->contextMenuPolicy(), Qt::CustomContextMenu);
// The menu must reuse the registered QActions rather than build parallel
// ones, or a [keys] rebinding would show the old shortcut here and the
// menu could drift out of step with what the keyboard really does.
auto *menu = window.findChild<QMenu *>(QStringLiteral("threadContextMenu"));
QVERIFY2(menu, "no thread-list context menu");
const QStringList expected = { QStringLiteral("archive"),
QStringLiteral("delete"),
QStringLiteral("spam"),
QStringLiteral("toggle_unread"),
QStringLiteral("edit_tags"),
QStringLiteral("flag") };
for (const QString &name : expected) {
QAction *action = window.findChild<QAction *>(name);
QVERIFY2(action, qPrintable(QStringLiteral("no action '%1'").arg(name)));
QVERIFY2(menu->actions().contains(action),
qPrintable(QStringLiteral("context menu is missing the "
"registered '%1' action").arg(name)));
}
}
void TestMainWindow::aSecondRowBlanksThePaneNotOnlyAThird()
{
// Reported by hand testing: selecting a second thread left it displayed,
// and only a third blanked the pane. The cause is that currentRowChanged is
// emitted before the selection model updates, so the Ctrl+click that makes
// the count two arrives at onThreadSelected still reporting one, which
// loads the thread; onSelectionChanged then blanks the pane, and the load,
// being queued to the worker, paints over the blank when it returns. By the
// third row m_currentThreadId is already cleared, so the late result is
// discarded and the blank survives, which is why the fault looked like an
// off-by-one in the threshold rather than a race.
//
// Two rows must behave exactly as three do.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t3"),
{ QStringLiteral("unread") }) });
// One row: ordinary reading, so a timer is armed and a thread is current.
selectThreadRow(view, 0);
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
QVERIFY(timer->isActive());
// Ctrl+click a second row. This is the exact gesture that failed: the
// selection becomes two while currentRowChanged still reports one.
view->selectionModel()->setCurrentIndex(
model->index(1, 0),
QItemSelectionModel::Select | QItemSelectionModel::Rows);
QCOMPARE(view->selectionModel()->selectedRows().size(), 2);
QVERIFY2(!timer->isActive(),
"two selected rows left the mark-read timer armed");
// A blanked pane is one with no current thread: anything still in flight
// for that id would repaint over it.
QVERIFY2(window.currentThreadId().isEmpty(),
qPrintable(QStringLiteral("two selected rows left thread '%1' "
"loaded in the pane")
.arg(window.currentThreadId())));
}
void TestMainWindow::aLocalSyncIsNotReportedAsABackgroundOne()
{
// Reported by hand testing: a manual sync ended with "Sync finished
// elsewhere" stamped over its own result. The monitor sees the lock the
// local run takes, and while the process lives isRunning() suppresses the
// message; but the process exits, and therefore isRunning() goes false,
// BEFORE the next poll notices the lock was released. That poll then
// reported a local sync as a background one.
//
// Ownership is latched when the lock appears, so the release can still be
// attributed after the process is gone.
const Config config;
MainWindow window(config);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY(status);
// The lock appears while no local sync is running: a background one.
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
QVERIFY2(status->text().contains(QStringLiteral("Background")),
qPrintable(QStringLiteral("a background sync was not announced, "
"status says '%1'").arg(status->text())));
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Idle));
QVERIFY2(status->text().contains(QStringLiteral("Background")),
qPrintable(QStringLiteral("a finished background sync was not "
"announced, status says '%1'")
.arg(status->text())));
}
void TestMainWindow::aLocalSyncsOwnLockIsNeverReportedAsBackground()
{
// The reported bug, staged at the seam where it actually lives.
//
// A real child process was tried first and abandoned: it needs a sync
// command in the config, it leaves a live process behind for the length of
// the test, and it made the suite pop a dialog. None of that is needed,
// because the defect is not in MailSync. It is that ownership of a lock
// period was decided at RELEASE time, when MailSync::isRunning() has
// already gone false, instead of being latched when the lock appeared.
//
// With no sync command configured isRunning() is false throughout, which is
// exactly the state the buggy code misread. So: announce a Running that the
// window believes is external, then a matching Idle. Both must be reported.
// The local case is covered by the latch being set only inside the Running
// branch, and by aSkippedLocalSyncStillReportsTheOtherRunFinishing()
// proving the latch is handed back when the lock was never ours.
const Config config;
MainWindow window(config);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY(status);
auto *progress =
window.findChild<QProgressBar *>(QStringLiteral("syncProgress"));
QVERIFY(progress);
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
QVERIFY2(progress->isVisibleTo(&window),
"a background sync did not show the progress bar");
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Idle));
QVERIFY2(!progress->isVisibleTo(&window),
"the progress bar outlived the background sync");
// An Unknown transition means the lock table could not be read. Nothing was
// observed, so nothing may be claimed: the previous message must stand.
status->setText(QStringLiteral("untouched"));
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Unknown));
QCOMPARE(status->text(), QStringLiteral("untouched"));
}
void TestMainWindow::aSkippedLocalSyncStillReportsTheOtherRunFinishing()
{
// The narrow case the latch could break: a manual sync that exits 75
// because cron already holds the lock. If both started inside one poll
// interval the monitor sees the lock appear while isRunning() is true and
// latches it local, even though the lock belongs to the cron run. The
// completion of that run would then be swallowed. onSyncFinished() hands
// ownership back when it sees the skip code.
const Config config;
MainWindow window(config);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY(status);
QMetaObject::invokeMethod(&window, "onSyncFinished",
Q_ARG(bool, false),
Q_ARG(int, MainWindow::kSyncSkippedExitCode));
// The skip itself is reported, and not as a failure.
QVERIFY2(!status->text().contains(QStringLiteral("failed")),
qPrintable(QStringLiteral("a skip was reported as a failure: '%1'")
.arg(status->text())));
// The other run finishing must still be announced.
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Idle));
QVERIFY2(status->text().contains(QStringLiteral("Background")),
qPrintable(QStringLiteral("after a skipped local sync, the other "
"run finishing was swallowed; status "
"says '%1'").arg(status->text())));
}
void TestMainWindow::theSyncActionIsDisabledWhileABackgroundSyncHoldsTheLock()
{
// Item 29 shipped for the QPushButton only: onExternalSyncStateChanged
// disabled m_syncButton and never touched the QAction, so the toolbar and
// menu Sync stayed clickable during a cron sync and could only produce the
// EX_TEMPFAIL skip. The button-based test passed throughout, because it
// drove the half that worked.
QTemporaryDir dir;
QVERIFY(dir.isValid());
QVERIFY(QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir"))));
const QString conf = dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"));
{
QSettings s(conf, QSettings::IniFormat);
s.setValue(QStringLiteral("sync/command"), QStringLiteral("/bin/true"));
}
const QString locks = dir.filePath(QStringLiteral("locks"));
{
QFile f(locks);
QVERIFY(f.open(QIODevice::WriteOnly));
}
MainWindow::setLocksPathForTesting(locks);
Config config;
config.load(conf);
MainWindow window(config);
auto *action = window.findChild<QAction *>(QStringLiteral("sync"));
QVERIFY2(action, "no sync action to check");
QVERIFY2(action->isEnabled(), "the action starts disabled with a command set");
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
QVERIFY2(!action->isEnabled(),
"the sync action stayed enabled during a background sync");
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Idle));
QVERIFY2(action->isEnabled(),
"the sync action was not re-enabled after the background sync");
MainWindow::setLocksPathForTesting(QStringLiteral("/proc/locks"));
}
void TestMainWindow::theStatusBarFollowsTheSyncPhase()
{
// Item 42: "Syncing..." said nothing about what was happening, while the
// script was already streaming its phase into the log pane and the app was
// throwing it away.
//
// Driven through a real script rather than by calling the tracker directly,
// because the defect this guards is in the wiring: the chunks QProcess
// hands over split mid-line, so a handler that fed them straight to the
// tracker would stall on the first partial line.
QTemporaryDir dir;
QVERIFY(dir.isValid());
QVERIFY(QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir"))));
const QString script = dir.filePath(QStringLiteral("fakesync.sh"));
{
QFile f(script);
QVERIFY(f.open(QIODevice::WriteOnly));
// Shaped like the real thing: timestamped lines, the noise that makes
// up the bulk of a run, mbsync's one summary, then notmuch's output.
// Paced, not dumped. A script that prints everything at once is
// delivered in a single readyRead, so the tracker sees the whole run in
// one call and only its final phase is ever painted: the intermediate
// ones would be unobservable and the test would assert nothing. A real
// sync takes tens of seconds and arrives in separate chunks, which the
// sleeps stand in for.
f.write("#!/bin/sh\n"
"echo '===== RUN START: 2026-08-07T11:00:00+02:00 ====='\n"
"echo '11:00:01 Note: Ignoring non-mail file: /home/you/Mail/x/.uidvalidity'\n"
"sleep 0.2\n"
"echo '11:00:02 Channels: 5 Boxes: 39 Far: +0 *1 #0 -0 Near: +1 *0 #0 -0'\n"
"sleep 0.2\n"
"echo '11:00:03 Processed 79 total files in almost no time.'\n"
"echo '11:00:03 Added 1 new message to the database.'\n"
"sleep 0.2\n"
"echo '===== RUN END: 2026-08-07T11:00:03+02:00 status=OK ====='\n");
f.close();
QVERIFY(QFile::setPermissions(script,
QFile::ReadOwner | QFile::WriteOwner
| QFile::ExeOwner));
}
const QString conf = dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"));
{
QSettings s(conf, QSettings::IniFormat);
s.setValue(QStringLiteral("sync/command"), script);
}
const QString locks = dir.filePath(QStringLiteral("locks"));
{
QFile f(locks);
QVERIFY(f.open(QIODevice::WriteOnly));
}
MainWindow::setLocksPathForTesting(locks);
Config config;
config.load(conf);
QCOMPARE(config.syncCommand(), script);
MainWindow window(config);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY(status);
// Every value the label takes, recorded as it changes. A run this small
// finishes in well under a second, so polling for an intermediate phase
// races the process and usually sees only "Sync complete": the sequence has
// to be captured, not sampled.
// QLabel has no textChanged signal, so the label is sampled on a fast timer
// rather than watched. Each distinct value is recorded once.
QStringList seen;
QTimer sampler;
sampler.setInterval(1);
connect(&sampler, &QTimer::timeout, &sampler, [&seen, status]() {
const QString text = status->text();
if (seen.isEmpty() || seen.constLast() != text)
seen.append(text);
});
sampler.start();
QVERIFY(QMetaObject::invokeMethod(&window, "startSync"));
QTRY_VERIFY_WITH_TIMEOUT(
std::any_of(seen.cbegin(), seen.cend(), [](const QString &s) {
return s.contains(QStringLiteral("Sync complete"));
}),
10000);
const QString trace = seen.join(QStringLiteral(" | "));
// mbsync's summary is the only concrete thing the stream carries, since it
// names no channel unless run verbose. The counts must reach the label.
QVERIFY2(std::any_of(seen.cbegin(), seen.cend(), [](const QString &s) {
return s.contains(QStringLiteral("39"))
&& s.contains(QStringLiteral("5"));
}),
qPrintable(QStringLiteral("the mbsync summary never reached the "
"status bar. Saw: ") + trace));
// Then the reindex phase, which is a different message entirely. Without
// the wiring the label went from "Syncing..." straight to "Sync complete",
// which is exactly what the defect looked like.
QVERIFY2(std::any_of(seen.cbegin(), seen.cend(), [](const QString &s) {
return s.contains(QStringLiteral("notmuch"));
}),
qPrintable(QStringLiteral("the notmuch phase never reached the "
"status bar. Saw: ") + trace));
// The banners are not a phase and must never appear in the status bar.
for (const QString &s : seen) {
QVERIFY2(!s.contains(QStringLiteral("RUN ")), qPrintable(s));
QVERIFY2(!s.contains(QStringLiteral("status=")), qPrintable(s));
}
MainWindow::setLocksPathForTesting(QStringLiteral("/proc/locks"));
}
void TestMainWindow::anUnobservableLockTableLeavesTheSyncButtonUsable()
{
// Unknown means /proc/locks could not be read, so nothing was observed. A
// button left permanently disabled on a platform that cannot see the lock
// is worse than one that occasionally offers a run that gets skipped.
QTemporaryDir dir;
QVERIFY(dir.isValid());
QVERIFY(QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir"))));
const QString conf = dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"));
{
QSettings s(conf, QSettings::IniFormat);
s.setValue(QStringLiteral("sync/command"), QStringLiteral("/bin/true"));
}
Config config;
config.load(conf);
MainWindow window(config);
auto *action = window.findChild<QAction *>(QStringLiteral("sync"));
QVERIFY(action);
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
QVERIFY(!action->isEnabled());
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Unknown));
QVERIFY2(action->isEnabled(),
"an unobservable lock table left the sync action disabled");
}
void TestMainWindow::escapeBlanksTheMessagePane()
{
// A registered action like any other, so it reaches the menus, the shortcut
// reference and [keys]. Clearing m_currentThreadId with the pane is the
// part that matters: a late threadLoaded would otherwise paint the thread
// straight back, which is the race fixed in 0.8.0.
const Config config;
MainWindow window(config);
auto *action = window.findChild<QAction *>(QStringLiteral("clear_pane"));
QVERIFY2(action, "no clear_pane action registered");
// Shift+Esc since item 50: plain Escape now clears the selection too, and
// this narrower action kept the same key with a modifier. The behaviour
// asserted below is unchanged, which is the point of keeping both.
QCOMPARE(action->shortcut(),
QKeySequence(Qt::ShiftModifier | Qt::Key_Escape));
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}) });
selectThreadRow(view, 0);
QVERIFY2(!window.currentThreadId().isEmpty(),
"no thread was opened to blank");
action->trigger();
QVERIFY2(window.currentThreadId().isEmpty(),
"Escape left the thread loaded in the pane");
// Blanking is a view change, not a mail change: the selection stays.
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
}
void TestMainWindow::deleteTogglesOnAnAlreadyDeletedThread()
{
// Hitting Delete twice is the natural way to say "no, put it back", and
// adding a tag that is already present is a no-op the user cannot see.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("deleted") }) });
selectThreadRow(view, 0);
action->trigger();
// The optimistic model update is synchronous, so the row reflects the
// change without a worker.
QVERIFY2(!model->threadAt(0).isDeleted(),
"delete on an already-deleted thread did not undelete it");
}
void TestMainWindow::deleteOnAMixedSelectionDeletesRatherThanSplittingIt()
{
// The constraint that makes this more than a one-liner: toggling each
// thread independently would leave one keystroke with the selection in two
// states, which is worse than either outcome. Undelete only when every
// selected thread is already deleted.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("deleted") }),
makeThread(QStringLiteral("t2"), {}) });
view->selectAll();
QCOMPARE(view->selectionModel()->selectedRows().size(), 2);
action->trigger();
QVERIFY2(model->threadAt(0).isDeleted() && model->threadAt(1).isDeleted(),
"a mixed selection split instead of deleting the whole selection");
}
void TestMainWindow::aTransientStatusMessageExpires()
{
// "Sync complete" describes an event, not a state, and reads as though it
// describes the present until something else overwrites it.
const Config config;
MainWindow window(config);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY(status);
auto *timer = window.findChild<QTimer *>(QStringLiteral("statusTimer"));
QVERIFY2(timer, "no status expiry timer");
QMetaObject::invokeMethod(&window, "showTransientStatus",
Q_ARG(QString, QStringLiteral("Sync complete")));
QCOMPARE(status->text(), QStringLiteral("Sync complete"));
QVERIFY(timer->isActive());
// Fire it rather than waiting out the real interval.
timer->setInterval(0);
QTRY_VERIFY_WITH_TIMEOUT(status->text() != QStringLiteral("Sync complete"),
2000);
}
void TestMainWindow::theSelectionCountIsStateAndDoesNotExpire()
{
// Not everything in the status bar is an event. The selection count
// describes what is true right now and must persist while it stays true;
// expiring it would undo the 0.8.0 discoverability work.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
QVERIFY(status);
auto *timer = window.findChild<QTimer *>(QStringLiteral("statusTimer"));
QVERIFY(timer);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}) });
view->selectAll();
QVERIFY2(status->text().contains(QStringLiteral("2")),
"the selection count was not reported");
QVERIFY2(!timer->isActive(),
"the selection count armed the expiry timer; it is state, "
"not an event");
}
void TestMainWindow::anEditUndoneNettsBackToZero()
{
// Reported by the user: open a thread, let the 2 s auto-mark-read remove
// `unread`, then press Ctrl+U to put it back. The indicator read 2 unsynced
// changes when the mail store was exactly where it started.
//
// The count tracks NET state, not writes. Two writes did happen, but their
// effect cancels, and what the user needs to know is whether quitting now
// would strand work.
const Config config;
MainWindow window(config);
auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
QVERIFY(label);
QVERIFY(label->isHidden());
// The automatic mark-read: remove `unread` from one message.
TagChange off;
off.messageIds = { QStringLiteral("m1") };
off.removed = { QStringLiteral("unread") };
off.description = QStringLiteral("Mark read");
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, off)));
QVERIFY2(!label->isHidden(), "one edit must show the indicator");
// Ctrl+U puts it back on the same message.
TagChange on;
on.messageIds = { QStringLiteral("m1") };
on.added = { QStringLiteral("unread") };
on.description = QStringLiteral("Mark unread");
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, on)));
QVERIFY2(label->isHidden(),
qPrintable(QStringLiteral("an edit and its inverse left the "
"indicator showing '%1'")
.arg(label->text())));
}
void TestMainWindow::aDifferentTagOnTheSameMessageStillCounts()
{
// Netting must be per (message, tag), not per message. Removing `unread`
// and adding `flagged` on one message are two independent changes, and
// neither cancels the other.
const Config config;
MainWindow window(config);
auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
QVERIFY(label);
TagChange a;
a.messageIds = { QStringLiteral("m1") };
a.removed = { QStringLiteral("unread") };
a.description = QStringLiteral("Mark read");
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, a)));
TagChange b;
b.messageIds = { QStringLiteral("m1") };
b.added = { QStringLiteral("flagged") };
b.description = QStringLiteral("Flag");
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, b)));
QVERIFY2(!label->isHidden(),
"two different tags on one message cancelled each other");
}
void TestMainWindow::anEditWithNoMessageIdsStillCounts()
{
// A TagChange carrying no message ids cannot be netted against anything,
// and must still register rather than silently counting as zero. Losing an
// edit understates the indicator, which is the direction that costs the
// user work.
const Config config;
MainWindow window(config);
auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
QVERIFY(label);
TagChange change;
change.added = { QStringLiteral("deleted") };
change.description = QStringLiteral("Delete");
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, change)));
QVERIFY2(!label->isHidden(),
"an edit with no message ids was not counted at all");
}
// Item 37. A tag edit made while a background sync holds notmuch's write lock
// used to stall the worker: the read-write open BLOCKS until the lock frees
// (measured 9.158s against a 12s hold, returning NOTMUCH_STATUS_SUCCESS), so
// every later query and thread load queued behind it. These cases pin the fix:
// do not send the write while a sync is running, send it when the sync ends.
void TestMainWindow::anEditDuringABackgroundSyncIsNotSentYet()
{
// The defect. Sending during the sync is what stalls the worker, so the
// edit is held instead. The rows still show it: it is what the user asked
// for and it is going to be applied.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY2(action, "no flag action registered");
model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
selectThreadRow(view, 0);
// A cron sync takes the lock.
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
action->trigger();
QVERIFY2(window.hasEditAwaitingSend(),
"the edit was sent straight into a running sync, which is the "
"blocking open that stalls the worker");
QVERIFY2(model->threadAt(0).tags.contains(QStringLiteral("flagged")),
"holding the edit also dropped it from the rows");
}
void TestMainWindow::aHeldEditIsSentWhenTheBackgroundSyncEnds()
{
// The release. SyncMonitor already reports this transition for item 27, so
// the held edit rides a signal that exists rather than a timer.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
selectThreadRow(view, 0);
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
action->trigger();
QVERIFY(window.hasEditAwaitingSend());
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Idle));
QVERIFY2(!window.hasEditAwaitingSend(),
"the sync ending did not send the held edit");
QVERIFY2(model->threadAt(0).tags.contains(QStringLiteral("flagged")),
"sending the held edit lost the tag from the rows");
}
void TestMainWindow::aHeldEditCountsAsUnsynced()
{
// A held edit has not reached the index, so onTagsApplied() never counted
// it. It must still count here, because this is what the exit prompt reads:
// quitting on a held edit loses it outright, which is the whole failure the
// prompt exists to prevent.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(action);
auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
QVERIFY(label);
QVERIFY2(label->isHidden(), "the indicator starts hidden at zero");
model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
selectThreadRow(view, 0);
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
action->trigger();
QVERIFY2(!label->isHidden(),
"an edit held for a running sync was not counted as unsynced, so "
"the exit prompt would let the user quit on it");
}
void TestMainWindow::anUnreadableLockTableStillSendsTheEdit()
{
// State::Unknown means /proc/locks could not be read, so nothing is
// observed. Holding writes there would strand every edit forever on a
// platform that cannot see the lock at all. Unknown is not "running".
//
// Driven from Running, not from a fresh window: the guard is that Unknown
// CLEARS the busy flag, and a window that was never busy would pass this
// whatever Unknown did. Reaching Unknown by way of Running is also the only
// way a real monitor gets there, when /proc/locks becomes unreadable
// mid-session.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(action);
model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
makeThread(QStringLiteral("t2"), {}) });
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Running));
selectThreadRow(view, 0);
action->trigger();
QVERIFY2(window.hasEditAwaitingSend(),
"the edit was not held during a running sync, so this test is not "
"exercising the Unknown transition it claims to");
// The lock table becomes unreadable. That is not evidence of a sync, so
// writing must resume and the held edit must go out.
QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
Q_ARG(SyncMonitor::State,
SyncMonitor::State::Unknown));
QVERIFY2(!window.hasEditAwaitingSend(),
"an unreadable lock table kept the edit held, stranding it on any "
"platform without /proc/locks");
// And a NEW edit is sent rather than held.
selectThreadRow(view, 1);
action->trigger();
QVERIFY2(!window.hasEditAwaitingSend(),
"an unreadable lock table held a new edit, so writes never resume");
}
void TestMainWindow::aRejectedWriteKeepsEarlierUndoHistory()
{
// revertPendingTagChange() used to undo the failed command and then CLEAR
// the whole stack, so one rejected write threw away every undo step the
// user had built up. Undoing the failed command is enough: it is already
// off the stack afterwards.
const Config config;
MainWindow window(config);
auto *model = window.findChild<ThreadListModel *>();
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
auto *flag = window.findChild<QAction *>(QStringLiteral("flag"));
QVERIFY(flag);
auto *archive = window.findChild<QAction *>(QStringLiteral("archive"));
QVERIFY2(archive, "no archive action registered");
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("inbox") }) });
selectThreadRow(view, 0);
// One edit that succeeds, so there is history worth keeping.
archive->trigger();
TagChange applied;
applied.messageIds = { QStringLiteral("m1") };
applied.removed = { QStringLiteral("inbox") };
applied.description = QStringLiteral("Archive");
QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
Q_ARG(TagChange, applied)));
// A second edit that the worker rejects outright.
flag->trigger();
QVERIFY(QMetaObject::invokeMethod(
&window, "onWorkerError",
Q_ARG(QString, QStringLiteral("Cannot resolve threads"))));
QVERIFY2(window.canUndo(),
"a rejected write cleared the undo history of edits that had "
"already succeeded");
}
// Constructing a MainWindow needs a QApplication and a platform plugin. The
// test has no display under ctest, so it runs offscreen unless the caller
// asked for something else.
int main(int argc, char *argv[])
{
qputenv("QT_QPA_PLATFORM", qgetenv("QT_QPA_PLATFORM").isEmpty()
? QByteArray("offscreen")
: qgetenv("QT_QPA_PLATFORM"));
QApplication app(argc, argv);
TestMainWindow test;
return QTest::qExec(&test, argc, argv);
}
#include "test_mainwindow.moc"
|