-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtest_notes_panel_widget.cpp
More file actions
4046 lines (3789 loc) · 199 KB
/
Copy pathtest_notes_panel_widget.cpp
File metadata and controls
4046 lines (3789 loc) · 199 KB
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
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Integration test for the top-level NotesPanel widget — v0.1.95 two-pane
// shape. Exercises construction, public-API roundtrip, and the markdown +
// checkbox eventFilter behavior that the unit tests can't reach.
//
// Headless via QT_QPA_PLATFORM=offscreen. Notes root is redirected to a
// QTemporaryDir via $HOME override so ~/Documents/Notepatra/Noter is
// untouched.
#include "notes.h"
#include "notes_extract_apply.h"
#include "notes_popout.h"
#include "notes_storage.h"
#include "notes_reminder.h"
#include "notes_sweep_dialog.h"
#include "notes_sweep_prompt.h"
#include "notes_todos.h"
#include "config.h"
#include <QAbstractButton>
#include <QAction>
#include <QApplication>
#include <QComboBox>
#include <QCompleter>
#include <QAbstractItemView>
#include <QMenu>
#include <QMessageBox>
#include <QCalendarWidget>
#include <QDateTimeEdit>
#include <QDialog>
#include <QDir>
#include <QFile>
#include <QTimer>
#include <QKeyEvent>
#include <QCheckBox>
#include <QFont>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QPushButton>
#include <QRegExp>
#include <QRegularExpression>
#include <QSplitter>
#include <QStackedWidget>
#include <QToolButton>
#include <QHostAddress>
#include <QStandardPaths>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTemporaryDir>
#include <QTest>
#include <QtTest/QSignalSpy>
#include <QTextBlock>
#include <QTextCursor>
#include <QTextDocument>
#include <QTextEdit>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <cstdio>
#include <cstdlib>
#include <memory>
// A7 — every "watchdog: never hang" singleShot(1500) used to be
// fire-and-forget: it rejected EVERY visible QDialog 1.5s after arming,
// whether or not its own section was still waiting. With ~7 such timers
// armed across the suite, any shift in section pacing (here: the A7
// conflict-guard hashing) could land a stale watchdog INSIDE a later
// section's modal exec and kill that dialog before its acceptor ran
// (observed: section 15's watchdog rejecting section 32's Extract
// dialog). Each watchdog now carries a section-done flag and no-ops once
// its section completed — same anti-hang rescue, zero cross-section
// interference.
static std::shared_ptr<bool> armDialogWatchdog(int ms = 1500) {
auto done = std::make_shared<bool>(false);
QTimer::singleShot(ms, [done]() {
if (*done) return; // section finished — leave later dialogs alone
for (QWidget *w : QApplication::topLevelWidgets())
if (auto *d = qobject_cast<QDialog *>(w)) d->reject();
});
return done;
}
// win-noter-segfault (v0.1.113): Qt's "offscreen" QPA plugin on Windows
// SIGSEGVs inside a QMessageBox::exec() modal loop — confirmed by CI run
// 27084261134 (test #13 at the static QMessageBox::warning notes.cpp:3517;
// test #14 §22b at the static QMessageBox::information notes.cpp:3676, on a
// stack with NO network/teardown state, so it is the static helper's own modal
// loop the plugin chokes on, not a UAF). By Qt internals QMessageBox::warning/
// information() themselves just build a stack QMessageBox and exec() it, so the
// constructed-instance form is in the SAME crash class — the real fault line is
// QMessageBox(any)-vs-plain-QDialog (§22 NoterSweepDialog QDialog::exec passes).
// The SHIPPED binary uses the REAL Windows platform plugin, where these
// confirmations render fine (used throughout Notepatra, shipped many releases),
// so this is a CI-harness-only artifact, never a user crash. Under
// offscreen-Windows we SKIP the modal DRIVES (loud, never silent); the refusal/
// confirm BEHAVIOURS are platform-independent logic and stay fully covered on
// Linux Debug / ASan / Release.
static bool winOffscreenModalUnsafe() {
#if defined(Q_OS_WIN)
return QGuiApplication::platformName()
.compare(QLatin1String("offscreen"), Qt::CaseInsensitive) == 0;
#else
return false;
#endif
}
// win-noter-segfault sibling (filesystem semantics): Windows does NOT honour
// POSIX-style read-only INJECTION the way these tests rely on — the read-only
// attribute is IGNORED on directories (so creating/renaming files inside a
// "read-only" dir still succeeds) and an admin owner (the CI runner) bypasses
// file ACLs, so QFile::setPermissions() cannot make a save/read/rename FAIL.
// Sections that INDUCE a failure that way (§27 failed-save, §28b unreadable
// file, §36 failed-rename) would see the operation SUCCEED on Windows and so
// their failure-handling assertions become vacuous. The behaviours are
// platform-independent and stay covered on Linux Debug/ASan/Release; skip the
// injection on Windows. (These sections never ran on Windows pre-v0.1.113 — the
// binary crashed at §22b first — so this is a latent POSIX-only test limit
// exposed by the QMessageBox-skip fix, not a product or regression bug.)
static bool winReadOnlyUnenforced() {
#if defined(Q_OS_WIN)
return true;
#else
return false;
#endif
}
static int g_pass = 0, g_fail = 0;
#define EXPECT(label, cond) \
do { if (cond) { ++g_pass; std::printf(" [PASS] %s\n", label); } \
else { ++g_fail; std::printf(" [FAIL] %s\n", label); } } while (0)
#define EXPECT_STR_EQ(label, got, want) \
do { const QString _g = (got); const QString _w = (want); \
if (_g == _w) { ++g_pass; std::printf(" [PASS] %s\n", label); } \
else { ++g_fail; std::printf(" [FAIL] %s — got '%s', want '%s'\n", \
label, qPrintable(_g), qPrintable(_w)); } } while (0)
static QString readAll(const QString &path) {
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) return {};
return QString::fromUtf8(f.readAll());
}
int main(int argc, char *argv[]) {
// DIAG (win-noter-segfault): unbuffered stdout so the LAST section header
// printed before a hard crash is captured by ctest --output-on-failure,
// pinpointing the exact failing section on Windows. No behaviour change.
setvbuf(stdout, nullptr, _IONBF, 0);
QApplication app(argc, argv);
QTemporaryDir tmpHome;
qputenv("HOME", tmpHome.path().toUtf8());
qputenv("XDG_DOCUMENTS_DIR", (tmpHome.path() + "/Documents").toUtf8());
QStandardPaths::setTestModeEnabled(true);
QDir().mkpath(tmpHome.path() + "/Documents");
std::printf("[test_notes_panel_widget] tmpHome=%s\n",
qPrintable(tmpHome.path()));
NotesPanel panel;
// ── 1. Construction + folder layout ────────────────────────────
std::printf("\n--- 1. construction ---\n");
EXPECT("notesRoot non-empty", !panel.notesRoot().isEmpty());
EXPECT("inboxFolder under root",
panel.inboxFolder().startsWith(panel.notesRoot()));
EXPECT("trashFolder under root",
panel.trashFolder().startsWith(panel.notesRoot()));
EXPECT("inbox folder created on construct",
QDir(panel.inboxFolder()).exists());
EXPECT("trash folder created on construct",
QDir(panel.trashFolder()).exists());
// The two-pane shape — QSplitter with at least two children.
auto *splitter = panel.findChild<QSplitter *>();
EXPECT("has a QSplitter", splitter != nullptr);
if (splitter) EXPECT("splitter has >=2 children", splitter->count() >= 2);
// Sidebar widgets.
EXPECT("has search QLineEdit",
panel.findChild<QLineEdit *>() != nullptr);
EXPECT("has sidebar QTreeWidget (v0.1.97 tree shape)",
panel.findChild<QTreeWidget *>(QStringLiteral("noterSidebarTree")) != nullptr);
QList<QPushButton *> buttons = panel.findChildren<QPushButton *>();
EXPECT("has at least 2 QPushButtons (+ Noter / Extract)",
buttons.size() >= 2);
bool hasNoterBtn = false;
for (auto *b : buttons)
if (b->text() == QStringLiteral("+ Noter")) hasNoterBtn = true;
EXPECT("the create button is labelled '+ Noter' (todos dropped)", hasNoterBtn);
// ── 2. newMeetingNote creates a file ───────────────────────────
std::printf("\n--- 2. newMeetingNote ---\n");
const int beforeCount = QDir(panel.inboxFolder())
.entryList(QStringList() << "*.html",
QDir::Files).size();
panel.newMeetingNote();
QApplication::processEvents();
const QStringList after = QDir(panel.inboxFolder())
.entryList(QStringList() << "*.html",
QDir::Files);
EXPECT("inbox file count incremented", after.size() == beforeCount + 1);
// The file we just created — newest by mtime.
QDir d(panel.inboxFolder());
QFileInfoList all = d.entryInfoList(QStringList() << "*.html",
QDir::Files, QDir::Time);
QString newPath;
if (!all.isEmpty()) newPath = all.first().absoluteFilePath();
EXPECT("new path resolved", !newPath.isEmpty());
// ── 3. Editor is alive + accepts text ──────────────────────────
std::printf("\n--- 3. editor write ---\n");
QTextEdit *editor = panel.findChild<QTextEdit *>();
EXPECT("QTextEdit alive after newMeeting", editor != nullptr);
if (editor) {
// Move cursor to end + insert text.
QTextCursor cur(editor->document());
cur.movePosition(QTextCursor::End);
editor->setTextCursor(cur);
editor->insertPlainText(QStringLiteral("\nhello noter v95"));
QApplication::processEvents();
EXPECT("editor body contains inserted text",
editor->toPlainText().contains("hello noter v95"));
}
// ── 4. saveCurrentNote persists ───────────────────────────────
std::printf("\n--- 4. save roundtrip ---\n");
panel.saveCurrentNote();
QApplication::processEvents();
const QString diskHtml = readAll(newPath);
EXPECT("disk content non-empty after save", !diskHtml.isEmpty());
EXPECT("disk has user-inserted text",
diskHtml.contains("hello noter v95"));
// ── 5. openNoteFile re-loads + roundtrip ──────────────────────
std::printf("\n--- 5. openNoteFile roundtrip ---\n");
// Modify the body, reopen the file → should show original disk content
// (modified buffer should auto-save first).
if (editor) {
editor->insertPlainText(QStringLiteral(" — second edit"));
QApplication::processEvents();
}
panel.saveCurrentNote();
QApplication::processEvents();
// Construct a second panel, open the same file — verifies the round-trip.
NotesPanel panel2;
panel2.openNoteFile(newPath);
QApplication::processEvents();
QTextEdit *editor2 = panel2.findChild<QTextEdit *>();
EXPECT("second panel has editor", editor2 != nullptr);
if (editor2) {
EXPECT("second panel sees first panel's text",
editor2->toPlainText().contains("hello noter v95"));
EXPECT("second panel sees the second edit",
editor2->toPlainText().contains("— second edit"));
}
// ── 6. Markdown shortcut '- [ ]' + Space → ☐ ──────────────────
std::printf("\n--- 6. markdown shortcut ---\n");
if (editor) {
// Move to a fresh line and type the prefix
QTextCursor cur(editor->document());
cur.movePosition(QTextCursor::End);
editor->setTextCursor(cur);
editor->insertPlainText(QStringLiteral("\n"));
QApplication::processEvents();
// Type "- [ ]" character by character so the eventFilter sees each
// QKeyEvent. We use QTest::keyClicks for the prefix, then a single
// explicit Space key to trigger the rewrite.
editor->setFocus();
QTest::keyClicks(editor, QStringLiteral("- [ ]"));
QApplication::processEvents();
QTest::keyClick(editor, Qt::Key_Space);
QApplication::processEvents();
const QString tail = editor->toPlainText().section('\n', -1, -1);
EXPECT("line ends with '☐ ' after '- [ ]' + Space",
tail == QStringLiteral("☐ "));
}
// ── 7. F4 toggles checkbox on current line ─────────────────────
std::printf("\n--- 7. F4 toggle ---\n");
if (editor) {
// Append text to the ☐ line, then press F4 — should swap to ✓
editor->insertPlainText(QStringLiteral("review PR"));
QApplication::processEvents();
QTest::keyClick(editor, Qt::Key_F4);
QApplication::processEvents();
const QString tail = editor->toPlainText().section('\n', -1, -1);
EXPECT("F4 turned ☐ into ✓",
tail.startsWith(QStringLiteral("✓ review PR")));
// v0.1.98 — a checked line's text is struck through.
{
QTextCursor c = editor->textCursor();
c.movePosition(QTextCursor::End);
c.movePosition(QTextCursor::StartOfBlock);
c.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, 3);
EXPECT("checked (✓) line text is struck through",
c.charFormat().fontStrikeOut());
}
// Press F4 again to toggle back
QTest::keyClick(editor, Qt::Key_F4);
QApplication::processEvents();
const QString tail2 = editor->toPlainText().section('\n', -1, -1);
EXPECT("F4 again turned ✓ back to ☐",
tail2.startsWith(QStringLiteral("☐ review PR")));
// v0.1.98 — reopening removes the strike-through.
{
QTextCursor c = editor->textCursor();
c.movePosition(QTextCursor::End);
c.movePosition(QTextCursor::StartOfBlock);
c.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, 3);
EXPECT("reopened (☐) line text is NOT struck through",
!c.charFormat().fontStrikeOut());
}
}
// ── 8. Enter on a non-empty ☐ line creates a new ☐ ────────────
std::printf("\n--- 8. Enter continues checkbox list ---\n");
if (editor) {
// Cursor is at end of "☐ review PR"
QTest::keyClick(editor, Qt::Key_Return);
QApplication::processEvents();
const QString last = editor->toPlainText().section('\n', -1, -1);
EXPECT("Enter on ☐ line produced new '☐ ' below",
last == QStringLiteral("☐ "));
}
// ── 9. Enter on EMPTY ☐ line breaks out (clears the marker) ───
std::printf("\n--- 9. Enter on empty ☐ breaks out ---\n");
if (editor) {
// Cursor is on a fresh "☐ " line (empty after the marker)
QTest::keyClick(editor, Qt::Key_Return);
QApplication::processEvents();
const QString last = editor->toPlainText().section('\n', -1, -1);
EXPECT("Enter on empty ☐ left an empty line (no new ☐)",
last.isEmpty() || !last.startsWith(QStringLiteral("☐")));
}
// ── 10-12. Delegate row-button interaction (v0.1.97) ──────────────
// These exercise the NoterRowDelegate paint+editorEvent path that
// REPLACED the broken setItemWidget rows. The buttons are painted on
// top of the native item; clicking their pixel region must fire the
// rename / trash / restore action while a click on the text region
// still selects + opens. Geometry mirrors NoterRowDelegate exactly:
// right = rect.right() - 4
// ✕ (slot0) center_x = right - 11 → rect.right() - 15
// pencil/↺ (slot1) center_x = right - 35 → rect.right() - 39
auto *tree = panel.findChild<QTreeWidget *>(QStringLiteral("noterSidebarTree"));
EXPECT("sidebar tree resolved for interaction", tree != nullptr);
// Give the viewport real geometry so visualItemRect() is valid.
panel.resize(1000, 700);
panel.show();
QApplication::processEvents();
auto findLeaf = [&](const QString &kind) -> QTreeWidgetItem * {
if (!tree) return nullptr;
tree->expandAll();
QApplication::processEvents();
QTreeWidgetItemIterator it(tree);
while (*it) {
if ((*it)->data(0, Qt::UserRole).toString() == kind) return *it;
++it;
}
return nullptr;
};
auto inboxCount = [&]() {
return QDir(panel.inboxFolder())
.entryList(QStringList() << "*.html", QDir::Files).size();
};
auto trashCount = [&]() {
return QDir(panel.trashFolder())
.entryList(QStringList() << ".trashed-*", QDir::Files | QDir::Hidden)
.size();
};
// ── 10. pencil (slot 1) click opens the inline rename editor ──────
std::printf("\n--- 10. pencil click → rename editor ---\n");
if (tree) {
QTreeWidgetItem *leaf = findLeaf(QStringLiteral("meeting"));
EXPECT("found a meeting leaf to rename", leaf != nullptr);
if (leaf) {
// Baseline: editItem() (what double-click / context-menu use)
// must produce a discoverable inline editor — proves our
// detection method and that the item is editable.
tree->editItem(leaf, 0);
for (int i = 0; i < 4; ++i) QApplication::processEvents();
EXPECT("editItem() opens a discoverable inline editor",
tree->findChild<QLineEdit *>() != nullptr);
if (auto *le0 = tree->findChild<QLineEdit *>()) {
QTest::keyClick(le0, Qt::Key_Escape);
for (int i = 0; i < 3; ++i) QApplication::processEvents();
}
const QRect r = tree->visualItemRect(leaf);
EXPECT("meeting leaf has a non-empty visual rect", !r.isEmpty());
const QPoint pencil(r.right() - 39, r.center().y());
QTest::mouseClick(tree->viewport(), Qt::LeftButton,
Qt::NoModifier, pencil);
// Drain the deferred edit() + the nested selectAll singleShot.
for (int i = 0; i < 6; ++i) QApplication::processEvents();
// The inline editor is created as a QLineEdit while editing —
// search the whole tree (not just viewport: Qt parents item
// editors to the viewport, but be liberal). The sidebar search
// box is a sibling of the tree, so it won't match here.
QLineEdit *inlineEditor = tree->findChild<QLineEdit *>();
EXPECT("pencil click opened the inline rename editor",
inlineEditor != nullptr);
// Bail out of editing so it doesn't leak into the next section.
if (inlineEditor) {
QTest::keyClick(inlineEditor, Qt::Key_Escape);
QApplication::processEvents();
}
}
}
// ── 11. ✕ (slot 0) click moves the meeting to Trash ──────────────
std::printf("\n--- 11. ✕ click → move to Trash ---\n");
if (tree) {
QTreeWidgetItem *leaf = findLeaf(QStringLiteral("meeting"));
EXPECT("found a meeting leaf to trash", leaf != nullptr);
if (leaf) {
const int inboxBefore = inboxCount();
const int trashBefore = trashCount();
const QRect r = tree->visualItemRect(leaf);
const QPoint x(r.right() - 15, r.center().y());
QTest::mouseClick(tree->viewport(), Qt::LeftButton,
Qt::NoModifier, x);
QApplication::processEvents();
EXPECT("inbox lost one meeting after ✕", inboxCount() == inboxBefore - 1);
EXPECT("trash gained the trashed meeting after ✕",
trashCount() > trashBefore);
}
}
// ── 12. ↺ (slot 1) click restores a trashed meeting ──────────────
std::printf("\n--- 12. ↺ click → restore from Trash ---\n");
if (tree) {
QTreeWidgetItem *leaf = findLeaf(QStringLiteral("trashed_meeting"));
EXPECT("found a trashed_meeting leaf to restore", leaf != nullptr);
if (leaf) {
const int inboxBefore = inboxCount();
const QRect r = tree->visualItemRect(leaf);
const QPoint restore(r.right() - 39, r.center().y());
QTest::mouseClick(tree->viewport(), Qt::LeftButton,
Qt::NoModifier, restore);
QApplication::processEvents();
EXPECT("inbox regained the meeting after ↺ restore",
inboxCount() == inboxBefore + 1);
}
}
// ── 13. Click SEMANTICS — the regression that shipped broken ──────
// The bug users hit: itemClicked (single click) was wired to OPEN, and
// Qt emits clicked() BEFORE the delegate consumes a button hit — so
// clicking a pencil/✕ button ALSO opened the file, and a plain click
// opened instead of selecting. Contract now: single click SELECTS,
// double click OPENS, a button click fires ONLY its action (never
// opens). These asserts observe the open document via the editor body.
std::printf("\n--- 13. click semantics (select vs open vs button) ---\n");
if (tree) {
QTextEdit *ed = panel.findChild<QTextEdit *>();
EXPECT("editor present for semantics test", ed != nullptr);
auto newestHtml = [&]() {
const QFileInfoList l = QDir(panel.inboxFolder())
.entryInfoList(QStringList() << "*.html", QDir::Files, QDir::Time);
return l.isEmpty() ? QString() : l.first().absoluteFilePath();
};
// Two fresh meetings with unique, identifiable bodies.
panel.newMeetingNote(); QApplication::processEvents();
const QString pathA = newestHtml();
if (ed) ed->setPlainText(QStringLiteral("ALPHA_BODY_13"));
panel.saveCurrentNote(); QApplication::processEvents();
panel.newMeetingNote(); QApplication::processEvents();
const QString pathB = newestHtml();
if (ed) ed->setPlainText(QStringLiteral("BRAVO_BODY_13"));
panel.saveCurrentNote(); QApplication::processEvents();
auto leafForPath = [&](const QString &p) -> QTreeWidgetItem * {
tree->expandAll(); QApplication::processEvents();
QTreeWidgetItemIterator it(tree);
while (*it) {
if ((*it)->data(0, Qt::UserRole).toString() == QLatin1String("meeting") &&
(*it)->data(0, Qt::UserRole + 1).toString() == p) return *it;
++it;
}
return nullptr;
};
auto bodyPoint = [&](QTreeWidgetItem *leaf) {
const QRect r = tree->visualItemRect(leaf);
return QPoint(r.left() + 45, r.center().y()); // text area, left of buttons
};
EXPECT("found leaf A", leafForPath(pathA) != nullptr);
EXPECT("found leaf B", leafForPath(pathB) != nullptr);
// NOTE on method: QTest::mouseDClick is not recognized as a real
// double-click by item views under QT_QPA_PLATFORM=offscreen (the
// synthetic event never reaches mouseDoubleClickEvent), so the OPEN
// contracts are driven by emitting itemDoubleClicked directly —
// that exercises the production connection
// (itemDoubleClicked → onSidebarItemActivated) deterministically.
// The two ACTUAL regressions (single-click must not open, button
// must not open) use real QTest::mouseClick, which DOES deliver.
// (a) double-click (signal) on A → opens A. Editor shows ALPHA.
if (QTreeWidgetItem *leafA = leafForPath(pathA); leafA && ed) {
emit tree->itemDoubleClicked(leafA, 0);
for (int i = 0; i < 4; ++i) QApplication::processEvents();
EXPECT("double-click A opened A's body",
ed->toPlainText().contains(QStringLiteral("ALPHA_BODY_13")));
}
// (b) single-click B body → SELECTS B, does NOT open (editor stays
// ALPHA). THE regression: this used to open on single click.
if (QTreeWidgetItem *leafB = leafForPath(pathB); leafB && ed) {
QTest::mouseClick(tree->viewport(), Qt::LeftButton,
Qt::NoModifier, bodyPoint(leafB));
for (int i = 0; i < 4; ++i) QApplication::processEvents();
EXPECT("single-click B selected B (currentItem)",
tree->currentItem() == leafB);
EXPECT("single-click B did NOT open it (editor still ALPHA)",
ed->toPlainText().contains(QStringLiteral("ALPHA_BODY_13")));
}
// (c) real click on ✕ of B → trashes B, editor STILL ALPHA (the
// button click must NOT also open B). THE other regression.
if (QTreeWidgetItem *leafB = leafForPath(pathB); leafB && ed) {
const int inboxBefore = inboxCount();
const QRect r = tree->visualItemRect(leafB);
QTest::mouseClick(tree->viewport(), Qt::LeftButton, Qt::NoModifier,
QPoint(r.right() - 15, r.center().y()));
for (int i = 0; i < 4; ++i) QApplication::processEvents();
EXPECT("✕ on B trashed it", inboxCount() == inboxBefore - 1);
EXPECT("✕ on B did NOT open it (editor still ALPHA)",
ed->toPlainText().contains(QStringLiteral("ALPHA_BODY_13")));
}
// (d) double-click (signal) on a TRASHED meeting → must NOT open
// (editor stays ALPHA). Trash is a holding area; restore first.
if (ed) {
tree->expandAll(); QApplication::processEvents();
QTreeWidgetItem *trashLeaf = nullptr;
QTreeWidgetItemIterator it(tree);
while (*it) {
if ((*it)->data(0, Qt::UserRole).toString() == QLatin1String("trashed_meeting"))
{ trashLeaf = *it; break; }
++it;
}
EXPECT("found a trashed_meeting leaf", trashLeaf != nullptr);
if (trashLeaf) {
emit tree->itemDoubleClicked(trashLeaf, 0);
for (int i = 0; i < 4; ++i) QApplication::processEvents();
EXPECT("double-click on TRASH did NOT open it (editor still ALPHA)",
ed->toPlainText().contains(QStringLiteral("ALPHA_BODY_13")));
}
}
}
// ── 14. auto-collapse the left sidebar each time Noter is shown ───
// User-requested 2026-05-24: opening the Noter tab must NOT greet you
// with a wall of expanded meetings / 23 trashed rows. showEvent
// collapses every root so all section labels are visible; the user
// expands whichever section they want.
std::printf("\n--- 14. auto-collapse left sidebar on show ---\n");
if (tree) {
tree->expandAll();
QApplication::processEvents();
bool anyExpandedBefore = false;
for (int i = 0; i < tree->topLevelItemCount(); ++i)
if (tree->topLevelItem(i)->isExpanded()) anyExpandedBefore = true;
EXPECT("baseline: a root is expanded after expandAll", anyExpandedBefore);
// Re-show the panel → showEvent must collapse every root.
panel.hide();
QApplication::processEvents();
panel.show();
for (int i = 0; i < 4; ++i) QApplication::processEvents();
bool allRootsCollapsed = true;
for (int i = 0; i < tree->topLevelItemCount(); ++i)
if (tree->topLevelItem(i)->isExpanded()) allRootsCollapsed = false;
EXPECT("re-showing Noter collapsed every sidebar root", allRootsCollapsed);
EXPECT("sidebar still has its two roots after collapse (Notes + Trash)",
tree->topLevelItemCount() >= 2);
}
// ── 15. reminder dialog opens + dismisses without crashing ────────
// v0.1.98 — right-click → "Set reminder" crashed in the field. Drive
// the modal under a watchdog: if construction faults, this section
// crashes the suite (and a debugger pinpoints the line).
std::printf("\n--- 15. reminder dialog opens without crashing ---\n");
{
bool dismissed = false;
QTimer::singleShot(150, [&dismissed]() {
for (QWidget *w : QApplication::topLevelWidgets())
if (auto *d = qobject_cast<QDialog *>(w))
if (d->isVisible()) { d->reject(); dismissed = true; }
});
auto done15a = armDialogWatchdog(); // watchdog: never hang
panel.promptReminderForNote(QStringLiteral("/tmp/repro-noter-01.html"),
QStringLiteral("Noter 01"));
*done15a = true;
EXPECT("reminder dialog (no existing) opened without crashing", true);
EXPECT("watchdog saw + dismissed the modal", dismissed);
// Now with an EXISTING reminder so the "Clear reminder" button path
// is constructed too.
QTimer::singleShot(150, [&]() {
for (QWidget *w : QApplication::topLevelWidgets())
if (auto *d = qobject_cast<QDialog *>(w))
if (d->isVisible()) d->reject();
});
auto done15b = armDialogWatchdog();
// Seed a reminder via the same public path (accept would set one, but
// we just need noteReminderAt to be valid — reuse promptReminder is
// overkill; instead schedule directly is private, so accept once).
panel.promptReminderForNote(QStringLiteral("/tmp/repro-noter-01.html"),
QStringLiteral("Noter 01"));
*done15b = true;
EXPECT("reminder dialog (2nd open) opened without crashing", true);
}
// ── 16. Insert subheader drops a heading + a ☐ bullet ─────────────
// v0.1.98 — editor right-click → Insert subheader. Contract: the heading
// text lands AND the section is seeded with a checkbox bullet (user:
// "always a checkbox as bullet point").
std::printf("\n--- 16. insert subheader + checkbox bullet ---\n");
{
panel.newMeetingNote(); // ensure an editable note is open
QApplication::processEvents();
QTextEdit *ed = panel.findChild<QTextEdit *>();
EXPECT("editor present for subheader test", ed != nullptr);
if (ed) {
ed->clear();
QApplication::processEvents();
panel.insertSubheader(QStringLiteral("Action Items"));
QApplication::processEvents();
const QString txt = ed->toPlainText();
EXPECT("subheader text inserted",
txt.contains(QStringLiteral("Action Items")));
bool hasBullet = false;
for (const QString &ln : txt.split('\n'))
if (ln.startsWith(QStringLiteral("☐ "))) hasBullet = true;
EXPECT("subheader seeds a ☐ checkbox bullet", hasBullet);
}
}
// ── 17. Extract dialog: only non-empty sections (action items first) ─
// v0.1.98 — user: "keep all, basically action items most of the time."
// The dialog must NOT render empty preset buckets; with only actions
// present, the Decisions/Questions/Risks section headings must be absent.
std::printf("\n--- 17. extract dialog hides empty sections ---\n");
{
NoterSweepPrompt::SweepResult r;
NoterSweepPrompt::SweepResult::Item a;
a.text = QStringLiteral("Ship the release");
r.actions.append(a); // ONLY actions
NoterSweepDialog dlg(r);
QApplication::processEvents();
bool hasActionHeading = false, hasEmptyHeading = false;
for (QLabel *lbl : dlg.findChildren<QLabel *>()) {
const QString t = lbl->text();
if (t.startsWith(QStringLiteral("Action Items"))) hasActionHeading = true;
if (t.startsWith(QStringLiteral("Decisions")) ||
t.startsWith(QStringLiteral("Questions")) ||
t.startsWith(QStringLiteral("Risks")))
hasEmptyHeading = true;
}
EXPECT("extract dialog shows the Action Items section", hasActionHeading);
EXPECT("extract dialog hides empty Decisions/Questions/Risks headings",
!hasEmptyHeading);
}
// ── 18. Strike-through survives close + reopen (persistence) ──────
// v0.1.98 deep-dive fix — the ✓ marker survives save/reload but the rich
// strike-through format is sanitized out of the HTML, so checked items
// looked un-checked after closing + reopening. restyleChecklistLines()
// re-derives it on load. Verified in a FRESH panel reading from disk.
std::printf("\n--- 18. strike-through persists across reopen ---\n");
{
panel.newMeetingNote();
QApplication::processEvents();
QString persistPath;
{
QFileInfoList l = QDir(panel.inboxFolder())
.entryInfoList(QStringList() << "*.html", QDir::Files, QDir::Time);
if (!l.isEmpty()) persistPath = l.first().absoluteFilePath();
}
QTextEdit *ed = panel.findChild<QTextEdit *>();
if (ed && !persistPath.isEmpty()) {
ed->clear();
QApplication::processEvents();
ed->insertPlainText(QStringLiteral("☐ persist me"));
QApplication::processEvents();
QTest::keyClick(ed, Qt::Key_F4); // → "✓ persist me", struck
QApplication::processEvents();
panel.saveCurrentNote();
QApplication::processEvents();
NotesPanel reopened; // fresh panel reads disk
reopened.openNoteFile(persistPath);
QApplication::processEvents();
QTextEdit *ed2 = reopened.findChild<QTextEdit *>();
EXPECT("reopened panel has editor", ed2 != nullptr);
if (ed2) {
bool foundLine = false, foundStruck = false;
for (QTextBlock b = ed2->document()->begin(); b.isValid();
b = b.next()) {
if (!b.text().startsWith(QStringLiteral("✓ persist me")))
continue;
foundLine = true;
QTextCursor c(ed2->document());
c.setPosition(b.position() + 3); // into the text
if (c.charFormat().fontStrikeOut()) foundStruck = true;
}
EXPECT("reopened note still has the ✓ line", foundLine);
EXPECT("strike-through survives close + reopen", foundStruck);
}
}
}
// ── 19. New note has no "00:00" timer + editor toolbar present ────
// v0.1.98 — dropped the legacy meeting timer; added a top icon toolbar
// (Header preset menu + Insert checkbox) mirroring the right-click menu.
std::printf("\n--- 19. timer removed + editor toolbar ---\n");
{
panel.newMeetingNote();
QApplication::processEvents();
QTextEdit *ed = panel.findChild<QTextEdit *>();
if (ed) {
bool hasTimer = false;
for (const QString &ln : ed->toPlainText().split('\n'))
if (ln.trimmed() == QStringLiteral("00:00")) hasTimer = true;
EXPECT("new note has no 00:00 timer line", !hasTimer);
}
bool hasActionItems = false, hasWhatIPlan = false,
hasToDos = false, hasCheckboxBtn = false;
for (QToolButton *b : panel.findChildren<QToolButton *>()) {
const QString tt = b->toolTip();
if (tt.contains(QStringLiteral("Action Items"))) hasActionItems = true;
if (tt.contains(QStringLiteral("What I plan"))) hasWhatIPlan = true;
if (tt.contains(QStringLiteral("To-dos"))) hasToDos = true;
// M6 — tooltip now carries the F4 shortcut hint, so prefix-match.
if (tt.startsWith(QStringLiteral("Insert checkbox"))) hasCheckboxBtn = true;
}
EXPECT("toolbar has a standalone Action Items icon", hasActionItems);
EXPECT("toolbar has a standalone What I plan icon", hasWhatIPlan);
EXPECT("toolbar has a standalone To-dos icon", hasToDos);
EXPECT("editor toolbar has an Insert checkbox button", hasCheckboxBtn);
}
// ── 20. Noter AI model dropdown populates from the backend ────────
// User reported "the dropdown is not working". Construct the panel, let
// the async listModels() round-trip to the backend, and print what the
// combo ends up with so we can see loading/empty/populated.
std::printf("\n--- 20. model dropdown populates ---\n");
{
QComboBox *mc = panel.findChild<QComboBox *>(QStringLiteral("noterModelCombo"));
EXPECT("model combo exists", mc != nullptr);
if (mc) {
for (int i = 0; i < 70; ++i) { // up to ~7s for the round-trip
QTest::qWait(100);
if (mc->count() >= 2) break;
}
std::printf(" combo (%d items): ", mc->count());
for (int i = 0; i < mc->count(); ++i)
std::printf("[%s] ", qPrintable(mc->itemText(i)));
std::printf("\n");
const QString first = mc->count() ? mc->itemText(0) : QString();
EXPECT("combo is not stuck on (loading…)",
first != QStringLiteral("(loading…)"));
// Backend reachability is environment-dependent — CI has no Ollama /
// OpenRouter, so the combo correctly resolves to the explicit
// "(no models — check AI panel backend)" placeholder. Accept EITHER
// real models (backend up) OR that placeholder (offline); the only
// real failure is being stuck on "(loading…)" or empty (the
// "dropdown not working" bug). Don't hard-require a live backend or
// this test fails every CI run. (v0.1.97 — was red in CI.)
const bool hasRealModel =
mc->count() >= 1 && !first.startsWith(QStringLiteral("("));
const bool noBackend = first.startsWith(QStringLiteral("(no models"));
EXPECT("combo resolved to real models, or '(no models)' when offline",
hasRealModel || noBackend);
std::printf(hasRealModel ? " → backend reachable: %d model(s)\n"
: " → no backend in this env (placeholder shown)\n",
mc->count());
// v0.1.98 — type-to-filter + refresh.
EXPECT("model combo is editable (type-to-filter)", mc->isEditable());
EXPECT("model combo has a contains-match completer",
mc->completer() &&
mc->completer()->filterMode() == Qt::MatchContains);
EXPECT("model refresh button exists",
panel.findChild<QToolButton *>(
QStringLiteral("noterModelRefresh")) != nullptr);
}
}
// ── 21. Central Reminders root (v0.1.98) — additive, ordered, populated
// Contract: a "Reminders" root sits between Notes and Trash (the meeting
// view is untouched), and scheduling a reminder makes it appear there.
std::printf("\n--- 21. central Reminders root ---\n");
{
auto *tree = panel.findChild<QTreeWidget *>(QStringLiteral("noterSidebarTree"));
EXPECT("sidebar tree present", tree != nullptr);
// (a) Root present + correctly ordered (Notes · Reminders · Trash).
int notesIdx = -1, remIdx = -1, trashIdx = -1;
if (tree)
for (int i = 0; i < tree->topLevelItemCount(); ++i) {
const QString t = tree->topLevelItem(i)->text(0);
if (t.startsWith(QStringLiteral("Notes"))) notesIdx = i;
if (t.startsWith(QStringLiteral("Reminders"))) remIdx = i;
if (t.startsWith(QStringLiteral("Trash"))) trashIdx = i;
}
EXPECT("Notes root still present (meeting view unchanged)", notesIdx >= 0);
EXPECT("Reminders root present", remIdx >= 0);
EXPECT("Trash root still present", trashIdx >= 0);
EXPECT("Reminders sits between Notes and Trash",
notesIdx >= 0 && remIdx > notesIdx && trashIdx > remIdx);
// (b) ACCEPT the picker → schedules a reminder (default time is
// tomorrow 9am, in the future) → the root populates with a leaf.
QTimer::singleShot(150, [&]() {
for (QWidget *w : QApplication::topLevelWidgets())
if (auto *d = qobject_cast<QDialog *>(w))
if (d->isVisible()) d->accept();
});
auto done21 = armDialogWatchdog(); // watchdog: never hang
const QString remNote = panel.inboxFolder() + "/rem-test-note.html";
panel.promptReminderForNote(remNote, QStringLiteral("Ship the build"));
*done21 = true;
QApplication::processEvents();
int remCount = -1; bool sawLeaf = false;
if (tree)
for (int i = 0; i < tree->topLevelItemCount(); ++i) {
auto *root = tree->topLevelItem(i);
if (!root->text(0).startsWith(QStringLiteral("Reminders"))) continue;
const QString t = root->text(0);
const int op = t.indexOf('(');
if (op >= 0) remCount = t.mid(op + 1, t.indexOf(')') - op - 1).toInt();
for (int s = 0; s < root->childCount(); ++s) {
auto *sect = root->child(s);
for (int l = 0; l < sect->childCount(); ++l)
if (sect->child(l)->text(0).contains(QStringLiteral("Ship the build")))
sawLeaf = true;
}
}
EXPECT("Reminders root count >= 1 after scheduling", remCount >= 1);
EXPECT("Reminders root lists a leaf for the scheduled reminder", sawLeaf);
}
// ── 22. Extract result dialog opens + CANCELS without crashing (v0.1.98)
// The user-reported crash was cancelling the Extract output. The root cause
// (a nested modal loop on the reply-teardown stack) is fixed by deferring
// showExtractResult off the finished signal; this drives that dialog path
// directly (auto-reject) to guard its construction (summary header +
// per-action QDateTimeEdit rows) and the cancel path.
std::printf("\n--- 22. Extract result dialog cancel ---\n");
{
panel.newMeetingNote();
QApplication::processEvents();
QTextEdit *ed = panel.findChild<QTextEdit *>();
const QString before = ed ? ed->toPlainText() : QString();
const QString fakeResponse = QStringLiteral(
"{\"summary\":\"Quick sync about the build and follow-ups.\","
"\"actions\":[{\"text\":\"Ship the build\",\"owner\":\"@prateek\","
"\"due\":\"2026-12-25T10:00\"}],"
"\"decisions\":[],\"questions\":[],\"risks\":[]}");
bool rejected = false;
QTimer::singleShot(150, [&rejected]() {
for (QWidget *w : QApplication::topLevelWidgets())
if (auto *d = qobject_cast<QDialog *>(w))
if (d->isVisible()) { d->reject(); rejected = true; }
});
auto done22 = armDialogWatchdog(); // watchdog: never hang
panel.showExtractResult(fakeResponse, QStringLiteral("test-model"));
*done22 = true;
QApplication::processEvents();
EXPECT("Extract dialog opened + cancelled without crashing", true);
EXPECT("watchdog saw + rejected the Extract dialog", rejected);
EXPECT("cancel left the note body unchanged",
!ed || ed->toPlainText() == before);
}
// ── 22b. Extract applies to the LAUNCH note, never a switched-to note ─
// H5 — the finished lambda captured no path, so a reply that lands after
// the user switches notes wrote note A's extract into note B's body +
// file. Contract: an extract launched on A, answered while B is current,
// is refused (not applied to B). The fix pins launchPath through the apply.
std::printf("\n--- 22b. Extract applies to launch note, not switched-to note ---\n");
{
QTextEdit *ed = panel.findChild<QTextEdit *>();
auto newestHtml = [&]() {
const QFileInfoList l = QDir(panel.inboxFolder())
.entryInfoList(QStringList() << "*.html", QDir::Files, QDir::Time);
return l.isEmpty() ? QString() : l.first().absoluteFilePath();
};
panel.newMeetingNote(); QApplication::processEvents();
if (ed) ed->setPlainText(QStringLiteral("NOTE_A_BODY"));
panel.saveCurrentNote(); QApplication::processEvents();
const QString pathA = newestHtml();
panel.newMeetingNote(); QApplication::processEvents();
if (ed) ed->setPlainText(QStringLiteral("NOTE_B_BODY"));
panel.saveCurrentNote(); QApplication::processEvents();
const QString pathB = newestHtml(); // B is now the CURRENT note
EXPECT("two distinct notes created", pathA != pathB && !pathA.isEmpty());
const QString fakeResponse = QStringLiteral(
"{\"summary\":\"A's sync.\",\"actions\":[{\"text\":\"WRONG_NOTE_ACTION\","
"\"owner\":\"@p\",\"due\":\"2026-12-25T10:00\"}],"
"\"decisions\":[],\"questions\":[],\"risks\":[]}");
if (winOffscreenModalUnsafe()) {
std::printf(" [SKIP] §22b switched-note refusal drive — offscreen+"
"Windows SIGSEGVs in the static QMessageBox::information "
"(notes.cpp:3676, win-noter-segfault); H5 refusal is "
"covered on Linux Debug/ASan/Release.\n");
} else {
// The launch-note mismatch raises a QMessageBox::information; dismiss it.
bool sawRefusal = false;
QTimer::singleShot(150, [&sawRefusal]() {
for (QWidget *w : QApplication::topLevelWidgets())
if (auto *mb = qobject_cast<QMessageBox *>(w))
if (mb->isVisible()) {
sawRefusal = true;
mb->accept();
}
});
auto done22b = armDialogWatchdog();
// The model answering for A while B is current.
panel.showExtractResult(fakeResponse, QStringLiteral("test-model"),
0, 0, pathA);
*done22b = true;
QApplication::processEvents();
EXPECT("mid-flight note switch was refused (info box shown)", sawRefusal);
EXPECT("A's extract did NOT land in B's editor (FAILS on HEAD)",
ed && !ed->toPlainText().contains(QStringLiteral("WRONG_NOTE_ACTION")));
EXPECT("A's extract did NOT land in B's file",
!readAll(pathB).contains(QStringLiteral("WRONG_NOTE_ACTION")));
}
}
// ── 22c. Extract pre-flight re-entrancy: one flight, no leaked cursor (H4) ─
// The pre-flight isAvailable() probe spins a nested QEventLoop; a 2nd
// Extract trigger delivered DURING it used to re-enter endMeetingSweep
// (m_extractBusy still false) and stack a 2nd probe + a 2nd override
// cursor — one wait cursor leaked app-wide forever. The m_extractStarting
// latch must no-op the re-entrant call. Contract: the probe runs ONCE and
// the override-cursor stack returns to baseline (no stuck wait cursor).
std::printf("\n--- 22c. extract re-entry during pre-flight probe ---\n");
{
QTextEdit *ed = panel.findChild<QTextEdit *>();
QTcpServer server;
int probeHits = 0;
QObject::connect(&server, &QTcpServer::newConnection, &server, [&]() {
while (QTcpSocket *sock = server.nextPendingConnection()) {
QObject::connect(sock, &QTcpSocket::readyRead, sock,
[sock, &probeHits]() {
const QByteArray req = sock->readAll();
if (req.contains("/api/tags")) ++probeHits; // count ONLY the probe
sock->write("HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\n"
"{\"models\":[]}");
sock->flush();
});
}
});
const bool listening = server.listen(QHostAddress::LocalHost, 0);
EXPECT("test probe server listening", listening);
if (listening) {
const QString savedBackend = Config::instance().aiBackend;
const QString savedUrl = Config::instance().aiBaseUrl;
Config::instance().aiBackend = QStringLiteral("Ollama");
Config::instance().aiBaseUrl =
QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort());
panel.newMeetingNote(); QApplication::processEvents();
if (ed) ed->setPlainText(QStringLiteral("EXTRACT_REENTRY_BODY"));
QApplication::processEvents();
// Pre-arm a re-entry that fires INSIDE the outer probe's loop.exec()
// (the only event loop between m_extractStarting=true and the reply).
QTimer::singleShot(0, &panel, [&]() { panel.endMeetingSweep(); });
panel.endMeetingSweep(); // outer flight: probes, then starts
for (int i = 0; i < 5; ++i) QApplication::processEvents();
panel.cancelExtract(); // settle: abort + restore cursor
for (int i = 0; i < 3; ++i) QApplication::processEvents();