aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_mainwindow.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-11 12:41:14 +0200
committerDanilo M. <danix@danix.xyz>2026-08-11 12:41:14 +0200
commit44d62143a83af8acbd1c1d14653d39da37e5de4a (patch)
tree360b8fae46487055fbd65bb1601f78345db7e27f /tests/test_mainwindow.cpp
parent694ec02eb652fcfdbf65c27f68f4607f88615f76 (diff)
downloadqtmaildir-44d62143a83af8acbd1c1d14653d39da37e5de4a.tar.gz
qtmaildir-44d62143a83af8acbd1c1d14653d39da37e5de4a.zip
feat(sent): add a Sent view, flat and by recipient
Adds a `sent` key to [account.*] naming that account's sent folder, and a Sent button beside the saved queries that composes its query from every account carrying one. An account without the key is omitted silently, as a real account may keep no sent mail locally. With no account selected the button spans all of them; selecting one narrows it through the existing scope wrap rather than a second path. Composed at run time rather than shipped as a [queries] entry. A saved query is one fixed string: it cannot narrow to the selected account, and it goes stale the moment an account is added or a provider renames a folder. The design and the measurements behind it are in docs/superpowers/specs/2026-08-11-sent-mail-design.md. Three things there are worth repeating here. The composed path is QUOTED, and that is load-bearing. A real provider nests its sent folder under a bracketed parent, and "[" and "]" are Xapian syntax: unquoted, the query parses rather than matches and returns nothing while looking entirely plausible. Composition happens in one place so there is one chance to get it right, and a bracketed path is pinned in a test. Recipients are opt-in per query, which is a performance contract rather than a preference. notmuch_message_get_header(m, "To") is not served from the index, it reads the message file: folding every thread of a 4411-thread inbox took 38.2 seconds against 251 ms for the 601-thread sent view. The worker skips the walk entirely unless asked, and the refresh path carries the same flag so a background sync cannot blank the column mid-read. Always folding is mutation-tested: the data would be right and only the cost wrong, which nothing else here would notice. The messages reached through the thread are owned by it and freed with it, so recipientsOf() holds them raw and finishes while the thread is alive, exactly as walkReplies does. An NmMessage wrapper there is a double-free. Sent mail is presented flat, and the pane follows. A message you sent otherwise drags in the replies you received, so a view labelled Sent shows conversations rather than what you sent. ThreadListModel::setFlatMode() makes hasChildren() and ReplyCountRole answer differently and changes nothing else; runQuery() sets it on EVERY run, so any other query restores the tree on its way through and the flag cannot outlive the button that set it. The pane needed its own fix for the same reason: the single-message path depends on a field only filled when a thread is expanded, which never happens in a flat list, so loadThread() gained matchedOnly and drops the messages that did not match instead of rendering them as stubs. Recipients replace the sender through the existing SendersRole rather than a new one, so the delegate needs no branch and cannot disagree with the model about which name a row shows. It falls back to the sender when a To header is absent or unparseable, since a blank where a name belongs reads as a rendering fault. Address parsing uses GMime: a display name may contain a comma, so "Rossi, Mario" <m@example.org>, info@example.net is two addresses and splitting reports three. internet_address_list_parse returns NULL for an empty string, which is a crash if unguarded. Backlog item 63.
Diffstat (limited to 'tests/test_mainwindow.cpp')
-rw-r--r--tests/test_mainwindow.cpp147
1 files changed, 147 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 504c10c..faa8481 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -170,6 +170,10 @@ private slots:
void theImportantActionIsLabelledImportant();
void theImportantActionStillWritesTheFlaggedTag();
void theToolbarUsesTheConfiguredIconSize();
+ void thereIsNoSentButtonWithoutASentKey();
+ void theSentButtonRunsEveryConfiguredAccount();
+ void theSentButtonSurvivesABracketedPath();
+ void flatModeDoesNotSurviveTheNextQuery();
void noTwoActionsShareAnIcon();
};
@@ -4540,6 +4544,149 @@ void TestMainWindow::theToolbarUsesTheConfiguredIconSize()
QCOMPARE(toolBar->iconSize(), QSize(40, 40));
}
+namespace {
+
+/// A config whose accounts carry the given maildir/sent pairs. An empty `sent`
+/// writes no key at all, which is the account-without-a-sent-folder case.
+QString writeSentConfig(const QTemporaryDir &dir,
+ const QList<QPair<QString, QString>> &accounts)
+{
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QSettings s(path, QSettings::IniFormat);
+ for (const auto &account : accounts) {
+ s.beginGroup(QStringLiteral("account.") + account.first);
+ s.setValue(QStringLiteral("maildir"), account.first);
+ if (!account.second.isEmpty())
+ s.setValue(QStringLiteral("sent"), account.second);
+ s.endGroup();
+ }
+ s.sync();
+ return path;
+}
+
+} // namespace
+
+void TestMainWindow::thereIsNoSentButtonWithoutASentKey()
+{
+ // Hidden entirely rather than present and finding nothing. An account may
+ // legitimately keep no sent mail locally, and a button that always returns
+ // an empty list reads as a broken feature rather than an absent one.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {{QStringLiteral("provider-c"), {}}}));
+ QVERIFY(config.allSentQuery().isEmpty());
+
+ MainWindow window(config);
+ QVERIFY(!window.findChild<QPushButton *>(QStringLiteral("sentButton")));
+}
+
+void TestMainWindow::theSentButtonRunsEveryConfiguredAccount()
+{
+ // The button composes its query rather than storing one, which is the whole
+ // reason it is not a [queries] entry: a saved query is a fixed string and
+ // would not gain the third account here without the user editing it.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("webmail-primary"), QStringLiteral("Sent")},
+ {QStringLiteral("provider-c"), {}},
+ {QStringLiteral("webmail-secondary"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ button->click();
+ const QString query = queryEdit->text();
+
+ QVERIFY(query.contains(QStringLiteral("webmail-primary/Sent")));
+ QVERIFY(query.contains(QStringLiteral("webmail-secondary/Sent")));
+
+ // The account with no key contributes nothing, and leaves no bare "or"
+ // behind: notmuch accepts that and silently returns a different result.
+ QVERIFY(!query.contains(QStringLiteral("provider-c")));
+ QVERIFY(!query.contains(QStringLiteral("or or")));
+ QCOMPARE(query.count(QStringLiteral(" or ")), 1);
+}
+
+void TestMainWindow::theSentButtonSurvivesABracketedPath()
+{
+ // A real provider nests its sent folder under a bracketed parent, and "["
+ // and "]" are Xapian syntax. The quoting has to survive the trip from the
+ // config through Account::sentQuery() into the query bar; unquoted, the
+ // query looks plausible and matches nothing.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("provider-a"), QStringLiteral("[Provider]/Posta inviata")},
+ }));
+
+ MainWindow window(config);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ button->click();
+ QCOMPARE(queryEdit->text(),
+ QStringLiteral("path:\"provider-a/[Provider]/Posta inviata/**\""));
+}
+
+void TestMainWindow::flatModeDoesNotSurviveTheNextQuery()
+{
+ // The condition the user set for this feature: a flat Sent list is fine, a
+ // flat anything-else is not. Asserted at the window rather than the model,
+ // because the leak this guards against is in the WIRING, not in the model:
+ // setFlatMode(true) from the button with no matching false anywhere else
+ // passes every model test and flattens the app from the first Sent click
+ // until it restarts.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("webmail-primary"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ QVERIFY2(!model->flatMode(), "the model starts flat");
+
+ button->click();
+ QVERIFY2(model->flatMode(), "the Sent button did not flatten the list");
+
+ // Any other query restores the tree. Typed by hand rather than through a
+ // saved-query button, since that is the route with no flag of its own and
+ // therefore the one most likely to be forgotten.
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ QVERIFY2(!model->flatMode(),
+ "flat mode survived into an ordinary query, so every view after "
+ "one Sent click lost its replies");
+
+ // And back, so the button still works after the round trip.
+ button->click();
+ QVERIFY(model->flatMode());
+
+ // Even the SAME query typed by hand comes back as a tree: the flag follows
+ // the button, not the text, which is the rule the user chose.
+ queryEdit->setText(config.allSentQuery());
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ QVERIFY(!model->flatMode());
+}
+
void TestMainWindow::noTwoActionsShareAnIcon()
{
// Reported by the user against the icons shipped in 0.12.0: Archive and