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
|
/*
* 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 <QProgressBar>
#include <QFile>
#include <QSettings>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTableView>
#include <QTimer>
#include "config.h"
#include "keymap.h"
#include "mainwindow.h"
#include "messageview.h"
#include "notmuchworker.h"
#include "threadlistmodel.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 theThreadListOffersAContextMenu();
void aSecondRowBlanksThePaneNotOnlyAThird();
void aLocalSyncIsNotReportedAsABackgroundOne();
void aLocalSyncsOwnLockIsNeverReportedAsBackground();
void aSkippedLocalSyncStillReportsTheOtherRunFinishing();
};
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());
const QSize resized(940, 620);
{
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<QTableView *>();
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);
}
/// 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::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<QTableView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t3"),
{ QStringLiteral("unread") }) });
view->selectRow(0);
QVERIFY2(timer->isActive(), "no timer armed for an unread thread");
// Move on before it can fire. One timer stays armed, not three.
view->selectRow(1);
QVERIFY(timer->isActive());
view->selectRow(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<QTableView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("read"),
{ QStringLiteral("inbox") }),
makeThread(QStringLiteral("unread"),
{ QStringLiteral("unread") }) });
view->selectRow(0);
QVERIFY2(!timer->isActive(), "armed a timer for an already-read thread");
// And the unread one still arms, so this is not "never arms".
view->selectRow(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.
view->selectRow(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<QTableView *>();
QVERIFY(view);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }) });
view->selectRow(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<QTableView *>();
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<QTableView *>();
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.
view->selectRow(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<QTableView *>();
QVERIFY(view);
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
model->appendBatch({ makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") }),
makeThread(QStringLiteral("t2"),
{ QStringLiteral("unread") }) });
view->selectRow(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<QTableView *>();
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.
view->selectRow(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<QTableView *>();
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::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<QTableView *>();
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<QTableView *>();
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.
view->selectRow(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())));
}
// 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"
|