aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 17:14:21 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 17:14:21 +0200
commit3524c2eb1267baf44301ea888abc784ecf85e76a (patch)
tree28cb95387a1426c1d2fe5feec765378376b774ef /tests
parent0402b5b683c7412daace2414f4b903b677b5405c (diff)
downloadqtmaildir-3524c2eb1267baf44301ea888abc784ecf85e76a.tar.gz
qtmaildir-3524c2eb1267baf44301ea888abc784ecf85e76a.zip
feat: apply the launch selectors to the window
One entry point, called both at startup and by the socket handler when a later launch arrives. Two paths would drift, which is the lesson this file has already learned from every other pair. The account moves first, because a built-in filter composes with the dropdown and a query run before it would carry the old scope. The thread case reuses recoverStaleThread() whole, as item 91's double-click already does. A message id resolves to its thread first through the worker: opening a message means opening its conversation with that message selected. A thread id must be hex or it is treated as a miss and never reaches notmuch. recoverStaleThread() builds thread:<id> unquoted, which is safe for ids notmuch handed out and not for ones from another program's command line, where "0000 or tag:inbox" would widen the query to the whole inbox. The check uses anchoredPattern() because a PCRE $ also matches before a trailing newline. An empty selector set changes nothing. A bare launch against a running window means raise yourself, and a raise is not a navigation. Also drops an empty public: label left in notmuchworker.h by the previous commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp252
1 files changed, 252 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index f008b10..e115939 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -49,6 +49,7 @@
#include "calendarwindow.h"
#include "config.h"
#include "keymap.h"
+#include "launchselectors.h"
#include "mainwindow.h"
#include "pendingchangesdialog.h"
#include "messageview.h"
@@ -311,6 +312,13 @@ private slots:
void aStartupAccountScopesTheStartupQuery();
void aStartupAccountAlsoScopesASavedStartupQuery();
void aGeneratedStartupQueryActuallyRuns();
+ void theSocketPathIsUnderTheStateDirectory();
+ void anAccountSelectorMovesTheDropdown();
+ void anUnknownAccountSelectorLeavesTheDropdownAlone();
+ void aThreadSelectorOpensThatThread();
+ void aMessageSelectorOpensItsThread();
+ void aThreadSelectorThatIsNotHexIsRefused();
+ void anEmptySelectorSetChangesNothing();
void everyBuiltinFilterButtonCarriesAnIconAndItsText();
void theDraftsButtonIsAbsentWithoutADraftsFolder();
void aQueryInTheMenuCanActuallyBeRun();
@@ -17812,4 +17820,248 @@ void TestMainWindow::notSpamThreadMovesEveryMessageHome()
15000);
}
+void TestMainWindow::theSocketPathIsUnderTheStateDirectory()
+{
+ // Beside uistate.conf, and built the same way: GenericStateLocation, not
+ // StateLocation, because the latter appends both the organization and the
+ // application name and both are "qtmaildir".
+ const QString socket = MainWindow::singleInstanceSocketPath();
+ const QString state = MainWindow::uiStatePath();
+
+ QVERIFY(!socket.isEmpty());
+ QCOMPARE(QFileInfo(socket).absolutePath(),
+ QFileInfo(state).absolutePath());
+ QVERIFY2(!socket.endsWith(QStringLiteral("/qtmaildir/qtmaildir")),
+ "StateLocation was used: the path doubles the application name");
+}
+
+void TestMainWindow::anAccountSelectorMovesTheDropdown()
+{
+ // --account work, with the config's own startup account being something
+ // else. The selector wins, which is what "open this account's view" means.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ // [general] keys are read WITHOUT the prefix: QSettings' INI backend
+ // treats a section literally named [general] as its own fallback.
+ s.setValue(QStringLiteral("startup_query"), QStringLiteral("Inbox"));
+ s.setValue(QStringLiteral("startup_account"),
+ QStringLiteral("personal"));
+ s.beginGroup(QStringLiteral("account.work"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ s.endGroup();
+ s.beginGroup(QStringLiteral("account.personal"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("personal"));
+ s.endGroup();
+ s.sync();
+ }
+ Config config;
+ config.load(path);
+
+ MainWindow window(config);
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("personal"));
+
+ LaunchSelectors selectors;
+ selectors.account = QStringLiteral("work");
+ window.applySelectors(selectors);
+
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+}
+
+void TestMainWindow::anUnknownAccountSelectorLeavesTheDropdownAlone()
+{
+ // The miss path. The window opens on its configured view and says so in
+ // the status bar; it does not clear the dropdown, and it does not refuse
+ // to start.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.setValue(QStringLiteral("startup_account"), QStringLiteral("work"));
+ s.beginGroup(QStringLiteral("account.work"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ s.endGroup();
+ s.sync();
+ }
+ Config config;
+ config.load(path);
+
+ MainWindow window(config);
+
+ LaunchSelectors selectors;
+ selectors.account = QStringLiteral("nosuchaccount");
+ window.applySelectors(selectors);
+
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+
+ // "statusMessage" is the object name buildUi() gives the status label.
+ // There is no widget named "statusLabel", and findChild would return null
+ // and assert nothing.
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+ QVERIFY2(status->text().contains(QStringLiteral("nosuchaccount")),
+ "the miss must name the value, so a stale caller can be debugged");
+}
+
+void TestMainWindow::aThreadSelectorOpensThatThread()
+{
+ // --thread, against a real database. The row the selector names is the row
+ // that ends up current.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("one@example.org"),
+ QStringLiteral("First subject"), QStringLiteral("a@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`. Qt::RFC2822Date
+ // validates the weekday against the date.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body one.")));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("two@example.org"),
+ QStringLiteral("Second subject"), QStringLiteral("b@example.org"),
+ // Saturday, verified with `date -d 2026-08-15 +%A`.
+ QStringLiteral("Sat, 15 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body two.")));
+ QVERIFY2(backed.build(), qPrintable(backed.error()));
+
+ // The thread id is not knowable in advance, so it is read back from the
+ // index the same way the CLI's caller would have obtained it.
+ NotmuchWorker probe(backed.config().notmuchConfig());
+ const QString threadId =
+ probe.threadIdForTesting(QStringLiteral("id:two@example.org"));
+ QVERIFY(!threadId.isEmpty());
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ LaunchSelectors selectors;
+ selectors.threadId = threadId;
+ window.applySelectors(selectors);
+
+ // QTRY, never qWait: the worker is on another thread and a fixed sleep
+ // passes when the result never arrives at all.
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+
+ // Asserted on the thread the row STANDS FOR, through threadFor(), never
+ // threadAt(index.row()): a tree numbers rows per parent.
+ QTRY_VERIFY_WITH_TIMEOUT(view->currentIndex().isValid(), 15000);
+ QCOMPARE(model->threadFor(view->currentIndex()).threadId, threadId);
+}
+
+void TestMainWindow::aMessageSelectorOpensItsThread()
+{
+ // --message, through the worker's resolve and back. The resolved thread is
+ // the one that ends up current, which proves the reply signal is wired.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("one@example.org"),
+ QStringLiteral("First subject"), QStringLiteral("a@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body one.")));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("two@example.org"),
+ QStringLiteral("Second subject"), QStringLiteral("b@example.org"),
+ // Saturday, verified with `date -d 2026-08-15 +%A`.
+ QStringLiteral("Sat, 15 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body two.")));
+ QVERIFY2(backed.build(), qPrintable(backed.error()));
+
+ NotmuchWorker probe(backed.config().notmuchConfig());
+ const QString threadId =
+ probe.threadIdForTesting(QStringLiteral("id:two@example.org"));
+ QVERIFY(!threadId.isEmpty());
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ LaunchSelectors selectors;
+ selectors.messageId = QStringLiteral("two@example.org");
+ window.applySelectors(selectors);
+
+ QTRY_VERIFY_WITH_TIMEOUT(view->currentIndex().isValid()
+ && model->threadFor(view->currentIndex())
+ .threadId
+ == threadId,
+ 15000);
+ QCOMPARE(model->rowCount(QModelIndex()), 1);
+}
+
+void TestMainWindow::aThreadSelectorThatIsNotHexIsRefused()
+{
+ // The thread id now comes from another program's command line or the
+ // socket, and recoverStaleThread() builds thread:<id> from it unquoted.
+ // notmuch thread ids are hex, so anything else is a miss and never reaches
+ // a query: "0000 or tag:inbox" would otherwise widen thread: into the
+ // whole inbox.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("account.work"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ s.endGroup();
+ s.sync();
+ }
+ Config config;
+ config.load(path);
+
+ MainWindow window(config);
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:flagged"));
+
+ LaunchSelectors selectors;
+ selectors.threadId = QStringLiteral("0000 or tag:inbox");
+ window.applySelectors(selectors);
+
+ QCOMPARE(queryEdit->text(), QStringLiteral("tag:flagged"));
+
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+ QVERIFY2(status->text().contains(QStringLiteral("0000 or tag:inbox")),
+ "the refusal must be reported as a miss naming the value");
+}
+
+void TestMainWindow::anEmptySelectorSetChangesNothing()
+{
+ // A bare `qtmaildir` against a running window means "raise yourself". It
+ // must not re-run a query or move the selection: the user is looking at
+ // something, and a raise is not a navigation.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.setValue(QStringLiteral("startup_account"), QStringLiteral("work"));
+ s.beginGroup(QStringLiteral("account.work"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ s.endGroup();
+ s.sync();
+ }
+ Config config;
+ config.load(path);
+
+ MainWindow window(config);
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:flagged"));
+
+ window.applySelectors(LaunchSelectors());
+
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+ QCOMPARE(queryEdit->text(), QStringLiteral("tag:flagged"));
+}
+
#include "test_mainwindow.moc"