summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-14 17:14:33 +0200
committerDanilo M. <danix@danix.xyz>2026-08-14 17:14:33 +0200
commita79725e055699524ec57d09bd484f274ea4a961e (patch)
tree8ca944300b3f8e2650940154df73aa8b561bb3b8 /tests
parente876e509b0770a243725b63ea55c9ccf3e41b1bf (diff)
parentbbf3c570215688c553fd70d8f372ae215725ca02 (diff)
downloadqtmaildir-a79725e055699524ec57d09bd484f274ea4a961e.tar.gz
qtmaildir-a79725e055699524ec57d09bd484f274ea4a961e.zip
Merge: searching from the message pane (item 85)
Five surfaces in the message pane offer a search built from what they show: the header's subject and date, its sender and recipients on a single-message thread, a tag chip, a body selection, and every header per message in the details dialog. Each offers Search for this, which replaces the query, and Add to search, which narrows it. The details dialog became labelled rows along the way, which the user wanted independently of this feature. Hand tested through every surface, including the case the parenthesising exists for: adding a sender to 'tag:inbox or tag:flagged' narrows it rather than widening it.
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt3
-rw-r--r--tests/test_mainwindow.cpp63
-rw-r--r--tests/test_messagedetailsdialog.cpp182
-rw-r--r--tests/test_messageview.cpp186
-rw-r--r--tests/test_mimeparser.cpp23
-rw-r--r--tests/test_searchterm.cpp146
-rw-r--r--tests/test_tagstrip.cpp108
7 files changed, 710 insertions, 1 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index e5142b6..5f7bd48 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -54,3 +54,6 @@ add_qtmaildir_test(querycompleter)
add_qtmaildir_test(tagdialog)
add_qtmaildir_test(tagrules)
add_qtmaildir_test(rulequery)
+add_qtmaildir_test(searchterm)
+add_qtmaildir_test(tagstrip)
+add_qtmaildir_test(messagedetailsdialog)
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 6e300bf..8b60e37 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -88,6 +88,9 @@ private slots:
void markReadTimerIsNotArmedForAReadThread();
void aConfirmedEditArmsTheAutoSync();
void autoSyncDebouncesABurstOfEdits();
+ void aSearchFromThePaneReplacesTheQuery();
+ void aSearchFromThePaneCanNarrowTheQuery();
+ void narrowingAnEmptyQueryBarIsAPlainSearch();
void autoSyncIsNotArmedWhenDisabledOrWithNothingPending();
void autoSyncSkipsWhileABackgroundSyncIsRunning();
void aSuccessfulSyncRefreshesRatherThanRerunningTheQuery();
@@ -3725,6 +3728,66 @@ void TestMainWindow::autoSyncDebouncesABurstOfEdits()
1);
}
+void TestMainWindow::aSearchFromThePaneReplacesTheQuery()
+{
+ const Config config;
+ MainWindow window(config);
+
+ QLineEdit *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY2(queryEdit, "no query bar: the window was never built");
+
+ MessageView *view = window.findChild<MessageView *>();
+ QVERIFY2(view, "no message view");
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ emit view->searchRequested(QStringLiteral("from:\"foo@example.org\""), false);
+
+ QCOMPARE(queryEdit->text(), QStringLiteral("from:\"foo@example.org\""));
+}
+
+void TestMainWindow::aSearchFromThePaneCanNarrowTheQuery()
+{
+ // The case the feature exists for: a query returning a thousand threads is
+ // narrowed by adding a condition. BOTH sides are parenthesised, because
+ // 'a or b AND c' binds as 'a or (b AND c)', which WIDENS a search the user
+ // asked to narrow, and notmuch reports no error for it.
+ const Config config;
+ MainWindow window(config);
+
+ QLineEdit *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY2(queryEdit, "no query bar: the window was never built");
+
+ MessageView *view = window.findChild<MessageView *>();
+ QVERIFY2(view, "no message view");
+
+ queryEdit->setText(QStringLiteral("tag:inbox or tag:flagged"));
+ emit view->searchRequested(QStringLiteral("from:\"foo@example.org\""), true);
+
+ QCOMPARE(queryEdit->text(),
+ QStringLiteral("(tag:inbox or tag:flagged) AND (from:\"foo@example.org\")"));
+}
+
+void TestMainWindow::narrowingAnEmptyQueryBarIsAPlainSearch()
+{
+ // Rather than "() AND (x)", which matches nothing.
+ const Config config;
+ MainWindow window(config);
+
+ QLineEdit *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY2(queryEdit, "no query bar: the window was never built");
+
+ MessageView *view = window.findChild<MessageView *>();
+ QVERIFY2(view, "no message view");
+
+ queryEdit->clear();
+ emit view->searchRequested(QStringLiteral("tag:inbox"), true);
+
+ QCOMPARE(queryEdit->text(), QStringLiteral("tag:inbox"));
+}
+
void TestMainWindow::autoSyncIsNotArmedWhenDisabledOrWithNothingPending()
{
// A negative delay is the switch that restores the pre-0.16.0 behaviour, so
diff --git a/tests/test_messagedetailsdialog.cpp b/tests/test_messagedetailsdialog.cpp
new file mode 100644
index 0000000..4e685ce
--- /dev/null
+++ b/tests/test_messagedetailsdialog.cpp
@@ -0,0 +1,182 @@
+/*
+ * 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 <QLabel>
+#include <QSignalSpy>
+#include <QtTest>
+
+#include "htmlbuilder.h"
+#include "messagedetailsdialog.h"
+
+/// The details dialog, which shows every header of every message in a thread.
+///
+/// Rows rather than one text box since item 85, so a value can carry its own
+/// context menu without anything parsing rendered text back into structure.
+class TestMessageDetailsDialog : public QObject
+{
+ Q_OBJECT
+private slots:
+ void showsEveryHeaderOfEveryMessage();
+ void valueLabelsCannotRenderMarkup();
+ void offersASearchForEachValue();
+ void omitsAnEmptyHeader();
+ void messageIdIsShownButNotSearchable();
+
+private:
+ /// One message, with every header populated. The date's weekday matches
+ /// the date: Qt::RFC2822Date validates the two against each other, and
+ /// 2026-08-14 is a Friday.
+ ThreadRenderItem oneMessage() const
+ {
+ ThreadRenderItem item;
+ item.message.ok = true;
+ item.message.subject = QStringLiteral("Quarterly report");
+ item.message.from = QStringLiteral("Sender <sender@example.org>");
+ item.message.to = QStringLiteral("Recipient <recipient@example.org>");
+ item.message.cc = QStringLiteral("Copied <copied@example.org>");
+ item.message.date = QStringLiteral("Fri, 14 Aug 2026 09:30:00 +0200");
+ item.message.messageId = QStringLiteral("<abc123@example.org>");
+ return item;
+ }
+};
+
+void TestMessageDetailsDialog::showsEveryHeaderOfEveryMessage()
+{
+ ThreadRenderItem second = oneMessage();
+ second.message.subject = QStringLiteral("Re: Quarterly report");
+
+ MessageDetailsDialog dialog({ oneMessage(), second });
+
+ const QList<HeaderRow> rows = dialog.rows();
+ QVERIFY2(!rows.isEmpty(), "no rows: the dialog was never populated");
+
+ // Both messages are represented, each row knowing which one it belongs to.
+ QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.messageIndex == 0;
+ }));
+ QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.messageIndex == 1;
+ }));
+
+ QStringList values;
+ for (const HeaderRow &row : rows)
+ values << row.value;
+ QVERIFY(values.contains(QStringLiteral("Sender <sender@example.org>")));
+ QVERIFY(values.contains(QStringLiteral("Re: Quarterly report")));
+ QVERIFY(values.contains(QStringLiteral("<abc123@example.org>")));
+}
+
+void TestMessageDetailsDialog::valueLabelsCannotRenderMarkup()
+{
+ // The QPlainTextEdit this replaced was plain by DESIGN, not by style:
+ // header values come from strangers and plain text cannot interpret
+ // markup. A QLabel guesses under Qt::AutoText, so every label states its
+ // format rather than relying on escaping, which is the same protection one
+ // mistake away from failing.
+ ThreadRenderItem hostile = oneMessage();
+ hostile.message.subject =
+ QStringLiteral("<b>bold</b><img src=x onerror=1>");
+
+ MessageDetailsDialog dialog({ hostile });
+
+ const QList<QLabel *> labels = dialog.findChildren<QLabel *>();
+ QVERIFY2(!labels.isEmpty(), "no labels: the dialog was never populated");
+
+ bool sawTheSubject = false;
+ for (const QLabel *label : labels) {
+ QCOMPARE(label->textFormat(), Qt::PlainText);
+ if (label->text().contains(QStringLiteral("<b>bold</b>")))
+ sawTheSubject = true;
+ }
+
+ // The markup survives AS TEXT, which is the proof it was not interpreted.
+ QVERIFY2(sawTheSubject, "the hostile subject never reached a label");
+}
+
+void TestMessageDetailsDialog::offersASearchForEachValue()
+{
+ MessageDetailsDialog dialog({ oneMessage() });
+
+ QSignalSpy spy(&dialog, &MessageDetailsDialog::searchRequested);
+ QVERIFY(spy.isValid());
+
+ const QList<HeaderRow> rows = dialog.rows();
+ const auto from = std::find_if(
+ rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.field == QStringLiteral("from");
+ });
+ QVERIFY2(from != rows.cend(), "no From row to search from");
+ QCOMPARE(from->query, QStringLiteral("from:\"Sender <sender@example.org>\""));
+
+ // The date becomes a one-day range rather than a text match on the header.
+ const auto date = std::find_if(
+ rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.field == QStringLiteral("date");
+ });
+ QVERIFY2(date != rows.cend(), "no Date row");
+ QCOMPARE(date->query, QStringLiteral("date:2026-08-14..2026-08-14"));
+
+ // Replacing and narrowing are both offered, and the flag distinguishes them.
+ dialog.requestSearch(*from, false);
+ dialog.requestSearch(*from, true);
+ QCOMPARE(spy.count(), 2);
+ QCOMPARE(spy.at(0).at(0).toString(), from->query);
+ QCOMPARE(spy.at(0).at(1).toBool(), false);
+ QCOMPARE(spy.at(1).at(1).toBool(), true);
+}
+
+void TestMessageDetailsDialog::omitsAnEmptyHeader()
+{
+ ThreadRenderItem noCc = oneMessage();
+ noCc.message.cc.clear();
+
+ MessageDetailsDialog dialog({ noCc });
+
+ const QList<HeaderRow> rows = dialog.rows();
+ // Guard first: an absence assertion alone passes against no implementation.
+ QVERIFY2(!rows.isEmpty(), "no rows: the dialog was never populated");
+ QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.field == QStringLiteral("from");
+ }));
+
+ for (const HeaderRow &row : rows)
+ QVERIFY(row.field != QStringLiteral("cc"));
+}
+
+void TestMessageDetailsDialog::messageIdIsShownButNotSearchable()
+{
+ // A message id names one message, and the thread holding it is already on
+ // screen, so there is nothing useful to search for. It is still shown.
+ MessageDetailsDialog dialog({ oneMessage() });
+
+ const QList<HeaderRow> rows = dialog.rows();
+ const auto id = std::find_if(
+ rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.value == QStringLiteral("<abc123@example.org>");
+ });
+ QVERIFY2(id != rows.cend(), "the message id is not shown at all");
+ QVERIFY(id->query.isEmpty());
+
+ // And asking to search it emits nothing rather than an empty query.
+ QSignalSpy spy(&dialog, &MessageDetailsDialog::searchRequested);
+ dialog.requestSearch(*id, false);
+ QCOMPARE(spy.count(), 0);
+}
+
+QTEST_MAIN(TestMessageDetailsDialog)
+#include "test_messagedetailsdialog.moc"
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index e94fb86..edcba03 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -24,6 +24,7 @@
#include <QtTest>
#include "htmlbuilder.h"
+#include "messagedetailsdialog.h"
#include "messageview.h"
#include "mimeparser.h"
@@ -49,6 +50,11 @@ private slots:
void detailsDialogIsOfferedForEveryThread();
void placeholderRendersAndReportsItself();
void aMessageBodyCannotRunAQuery();
+ void headerOffersSubjectDateAndSenderForOneMessage();
+ void headerOffersNoSenderForARealThread();
+ void headerOffersNothingForAnAbsentField();
+ void bodySelectionBecomesAQuotedSearch();
+ void aSearchFromTheDetailsDialogClosesIt();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -372,7 +378,7 @@ static ThreadRenderItem oneMessage()
message.to = QStringLiteral("Recipient <recipient@example.org>");
message.cc = QStringLiteral("Copied <copied@example.org>");
message.subject = QStringLiteral("Quarterly report");
- message.date = QStringLiteral("Mon, 4 Aug 2026 09:00:00 +0200");
+ message.date = QStringLiteral("Tue, 4 Aug 2026 09:00:00 +0200");
message.plainBody = QStringLiteral("body");
ThreadRenderItem item;
@@ -566,5 +572,183 @@ void TestMessageView::aMessageBodyCannotRunAQuery()
QVERIFY(queries.isEmpty());
}
+void TestMessageView::headerOffersSubjectDateAndSenderForOneMessage()
+{
+ MessageView view;
+ view.showThread({ oneMessage() });
+
+ const QList<SearchOffer> offers = view.headerSearchOffers();
+
+ QStringList queries;
+ for (const SearchOffer &offer : offers)
+ queries << offer.query;
+ const QString shown = queries.join(QStringLiteral(" | "));
+
+ QVERIFY2(queries.contains(QStringLiteral("subject:\"Quarterly report\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("from:\"Sender <sender@example.org>\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("to:\"Recipient <recipient@example.org>\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("cc:\"Copied <copied@example.org>\"")),
+ qPrintable(shown));
+
+ // The date is offered as a one-day range. Qt::RFC2822Date checks the
+ // weekday against the date, so a fixture with the wrong day silently
+ // produces no offer at all: 2026-08-04 is a Tuesday.
+ QVERIFY2(queries.contains(QStringLiteral("date:2026-08-04..2026-08-04")),
+ qPrintable(shown));
+
+ // Every offer carries a label the menu shows, naming the value so the user
+ // can see what they are about to search for.
+ for (const SearchOffer &offer : offers) {
+ QVERIFY(!offer.label.isEmpty());
+ QVERIFY(!offer.query.isEmpty());
+ }
+}
+
+void TestMessageView::headerOffersNoSenderForARealThread()
+{
+ // The header shows From/To/Cc only for a single-message thread, because a
+ // thread's recipient differs message to message. The menu shares that
+ // condition: it must never offer a value the header is not stating.
+ ThreadRenderItem first = oneMessage();
+ ThreadRenderItem second = oneMessage();
+ second.message.from = QStringLiteral("Recipient <recipient@example.org>");
+ second.message.to = QStringLiteral("Sender <sender@example.org>");
+
+ MessageView view;
+ view.showThread({ first, second });
+
+ QStringList queries;
+ for (const SearchOffer &offer : view.headerSearchOffers())
+ queries << offer.query;
+
+ // THE GUARD. A test asserting only that something is absent passes against
+ // no implementation whatever. Subject and date must still be offered,
+ // which proves the list was built before the absences below mean anything.
+ QVERIFY2(!queries.isEmpty(), "no offers at all: the list was never built");
+ QVERIFY(queries.contains(QStringLiteral("subject:\"Quarterly report\"")));
+ QVERIFY(queries.contains(QStringLiteral("date:2026-08-04..2026-08-04")));
+
+ for (const QString &query : queries) {
+ QVERIFY2(!query.startsWith(QStringLiteral("from:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ QVERIFY2(!query.startsWith(QStringLiteral("to:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ QVERIFY2(!query.startsWith(QStringLiteral("cc:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ }
+}
+
+void TestMessageView::headerOffersNothingForAnAbsentField()
+{
+ // cc:"" parses cleanly and matches nothing, so an entry built from an
+ // empty header would look enabled and silently do nothing.
+ ThreadRenderItem item = oneMessage();
+ item.message.cc.clear();
+
+ MessageView view;
+ view.showThread({ item });
+
+ QStringList queries;
+ for (const SearchOffer &offer : view.headerSearchOffers())
+ queries << offer.query;
+
+ // Guard first, then the absence.
+ QVERIFY2(!queries.isEmpty(), "no offers at all: the list was never built");
+ QVERIFY(queries.contains(QStringLiteral("subject:\"Quarterly report\"")));
+
+ for (const QString &query : queries)
+ QVERIFY(!query.startsWith(QStringLiteral("cc:")));
+}
+
+void TestMessageView::bodySelectionBecomesAQuotedSearch()
+{
+ // The selection reaches the query as ONE quoted term. Asserted on the
+ // constructed string: a query that lost its quoting is not an error to
+ // notmuch, it simply matches nothing, so nothing downstream would report
+ // this being wrong.
+ //
+ // Takes the text as an argument rather than reading the page, so the
+ // quoting is testable without a live web engine and a rendered document.
+ MessageView view;
+
+ QCOMPARE(view.selectionSearchOffer(QStringLiteral("invoice 4471")).query,
+ QStringLiteral("\"invoice 4471\""));
+
+ // A selection spanning paragraphs arrives full of newlines.
+ QCOMPARE(view.selectionSearchOffer(
+ QStringLiteral("first line\n\nsecond line")).query,
+ QStringLiteral("\"first line second line\""));
+
+ // Query syntax in the selection is data, not syntax: it is quoted, not
+ // interpreted, so a selection reading "a or b" searches for that phrase.
+ QCOMPARE(view.selectionSearchOffer(QStringLiteral("tag:inbox or x")).query,
+ QStringLiteral("\"tag:inbox or x\""));
+
+ // Nothing selected means no entry, rather than an entry searching for "".
+ QVERIFY(view.selectionSearchOffer(QString()).query.isEmpty());
+ QVERIFY(view.selectionSearchOffer(QStringLiteral(" \n ")).query.isEmpty());
+
+ // A usable offer always carries a label for the menu to show.
+ QVERIFY(!view.selectionSearchOffer(QStringLiteral("invoice 4471"))
+ .label.isEmpty());
+}
+
+void TestMessageView::aSearchFromTheDetailsDialogClosesIt()
+{
+ // The dialog is modal. Without closing it, the query runs and the thread
+ // list repaints BEHIND a window the user still has to dismiss, so the
+ // search looks like it did nothing. The dialog also describes m_items,
+ // which the new query is about to replace.
+ MessageView view;
+ view.showThread({ oneMessage() });
+
+ QSignalSpy spy(&view, &MessageView::searchRequested);
+ QVERIFY(spy.isValid());
+
+ // showDetailsDialog() blocks in exec(), so the dialog has to be driven
+ // from a timer once it is up.
+ bool foundTheDialog = false;
+ QTimer::singleShot(0, &view, [&view, &foundTheDialog]() {
+ auto *dialog = view.findChild<MessageDetailsDialog *>();
+ if (!dialog) {
+ // Never leave exec() spinning: a missing dialog must fail the test,
+ // not hang the suite.
+ QApplication::exit(1);
+ return;
+ }
+ foundTheDialog = true;
+
+ const QList<HeaderRow> rows = dialog->rows();
+ const auto from = std::find_if(
+ rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.field == QStringLiteral("from");
+ });
+ if (from == rows.cend()) {
+ dialog->reject();
+ return;
+ }
+
+ dialog->requestSearch(*from, false);
+ });
+
+ view.showDetailsDialog();
+
+ QVERIFY2(foundTheDialog, "the details dialog never appeared");
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toString(),
+ QStringLiteral("from:\"Sender <sender@example.org>\""));
+
+ // exec() returned, which is the assertion: the dialog closed on its own
+ // rather than waiting for the user to dismiss it.
+ QVERIFY(!view.findChild<MessageDetailsDialog *>()
+ || !view.findChild<MessageDetailsDialog *>()->isVisible());
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"
diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp
index bac71f6..af6307e 100644
--- a/tests/test_mimeparser.cpp
+++ b/tests/test_mimeparser.cpp
@@ -47,6 +47,7 @@ private slots:
void recipientSummarySurvivesUnusableInput();
void folderNameSurvivesATimezoneComment();
void savingABatchNeverOverwrites();
+ void parsesADateWithATimezoneComment();
private:
QString fixture(const QString &name) const
@@ -485,5 +486,27 @@ void TestMimeParser::recipientSummarySurvivesUnusableInput()
recipientSummary(QStringLiteral("\"unterminated <a@example.org>"));
}
+void TestMimeParser::parsesADateWithATimezoneComment()
+{
+ // Qt::RFC2822Date rejects the WHOLE string when a trailing comment is
+ // present (verified on Qt 6.11), and "+0200 (CEST)" is both legal and
+ // common. Without the comment stripped, every such message loses its date
+ // silently: the attachment folder loses its prefix, and a date search
+ // offers nothing with no indication why.
+ const QDateTime withComment = MimeParser::parseDate(
+ QStringLiteral("Fri, 14 Aug 2026 09:30:00 +0200 (CEST)"));
+ QVERIFY(withComment.isValid());
+ QCOMPARE(withComment.date(), QDate(2026, 8, 14));
+
+ const QDateTime plain = MimeParser::parseDate(
+ QStringLiteral("Fri, 14 Aug 2026 09:30:00 +0200"));
+ QVERIFY(plain.isValid());
+ QCOMPARE(plain.date(), QDate(2026, 8, 14));
+
+ // Nothing usable is an invalid QDateTime, never a guess.
+ QVERIFY(!MimeParser::parseDate(QStringLiteral("last Tuesday")).isValid());
+ QVERIFY(!MimeParser::parseDate(QString()).isValid());
+}
+
QTEST_MAIN(TestMimeParser)
#include "test_mimeparser.moc"
diff --git a/tests/test_searchterm.cpp b/tests/test_searchterm.cpp
new file mode 100644
index 0000000..e0bdb60
--- /dev/null
+++ b/tests/test_searchterm.cpp
@@ -0,0 +1,146 @@
+/*
+ * 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 "searchterm.h"
+
+/// The query grammar of the right-click search actions.
+///
+/// Asserted on the CONSTRUCTED STRING throughout, never on a query notmuch
+/// refuses: notmuch's parser rejects almost nothing, so `from:((((` parses
+/// cleanly and matches zero. A test expecting a failure would pass against
+/// correct code and against broken code alike.
+class TestSearchTerm : public QObject
+{
+ Q_OBJECT
+private slots:
+ void quotesAPlainValue();
+ void escapesEmbeddedQuotes();
+ void collapsesWhitespaceAndNewlines();
+ void rejectsEmptyAndWhitespaceOnly();
+ void capsAnOverlongSelection();
+ void buildsAFieldTerm();
+ void omitsAFieldWithNoValue();
+ void buildsADateRangeForOneDay();
+ void tagIsNotQuoted();
+ void extendParenthesisesBothSides();
+ void extendOntoAnEmptyQueryIsAReplace();
+};
+
+void TestSearchTerm::quotesAPlainValue()
+{
+ QCOMPARE(SearchTerm::quote(QStringLiteral("Quarterly report")),
+ QStringLiteral("\"Quarterly report\""));
+}
+
+void TestSearchTerm::escapesEmbeddedQuotes()
+{
+ // A selection is arbitrary prose and can carry a quote. Unescaped, it ends
+ // the quoted string early and the rest becomes stray query syntax, which
+ // notmuch accepts and matches nothing on.
+ QCOMPARE(SearchTerm::quote(QStringLiteral("say \"hello\" now")),
+ QStringLiteral("\"say \\\"hello\\\" now\""));
+}
+
+void TestSearchTerm::collapsesWhitespaceAndNewlines()
+{
+ QCOMPARE(SearchTerm::quote(QStringLiteral(" two\n\nlines\there ")),
+ QStringLiteral("\"two lines here\""));
+}
+
+void TestSearchTerm::rejectsEmptyAndWhitespaceOnly()
+{
+ // An empty term must yield an empty string, which is what every caller
+ // tests to decide whether to offer a menu entry at all.
+ QVERIFY(SearchTerm::quote(QString()).isEmpty());
+ QVERIFY(SearchTerm::quote(QStringLiteral(" \n\t ")).isEmpty());
+}
+
+void TestSearchTerm::capsAnOverlongSelection()
+{
+ // A multi-kilobyte selection is a mis-drag, not a query.
+ const QString huge(5000, QLatin1Char('x'));
+ const QString term = SearchTerm::quote(huge);
+ QVERIFY2(term.size() < 300,
+ qPrintable(QStringLiteral("term was %1 chars").arg(term.size())));
+ QVERIFY(term.startsWith(QStringLiteral("\"xxx")));
+ QVERIFY(term.endsWith(QLatin1Char('"')));
+}
+
+void TestSearchTerm::buildsAFieldTerm()
+{
+ QCOMPARE(SearchTerm::field(QStringLiteral("from"),
+ QStringLiteral("Foo <foo@example.org>")),
+ QStringLiteral("from:\"Foo <foo@example.org>\""));
+}
+
+void TestSearchTerm::omitsAFieldWithNoValue()
+{
+ // A message with no Cc must not offer cc:"" , which parses cleanly and
+ // matches nothing, so the entry would look enabled and do nothing.
+ QVERIFY(SearchTerm::field(QStringLiteral("cc"), QString()).isEmpty());
+}
+
+void TestSearchTerm::buildsADateRangeForOneDay()
+{
+ // notmuch's date: range is inclusive at both ends, so one day is the day
+ // named twice rather than the day and its successor.
+ const QDate day(2026, 8, 14);
+ QCOMPARE(SearchTerm::onDate(day),
+ QStringLiteral("date:2026-08-14..2026-08-14"));
+ QVERIFY(SearchTerm::onDate(QDate()).isEmpty());
+}
+
+void TestSearchTerm::tagIsNotQuoted()
+{
+ // A tag name is a token from a known vocabulary, not prose. Quoting one
+ // is not wrong but reads badly in the bar, and the user edits that text.
+ QCOMPARE(SearchTerm::tag(QStringLiteral("inbox")),
+ QStringLiteral("tag:inbox"));
+ // A tag containing a space is the exception and does need quoting.
+ QCOMPARE(SearchTerm::tag(QStringLiteral("to do")),
+ QStringLiteral("tag:\"to do\""));
+ QVERIFY(SearchTerm::tag(QString()).isEmpty());
+}
+
+void TestSearchTerm::extendParenthesisesBothSides()
+{
+ // THE case this exists for. The bar may hold a hand-written disjunction,
+ // and `a or b AND c` binds as `a or (b AND c)`: the result WIDENS a search
+ // the user asked to narrow. Both sides are wrapped so neither can rebind.
+ QCOMPARE(SearchTerm::extend(QStringLiteral("tag:inbox or tag:flagged"),
+ QStringLiteral("from:foo@example.org")),
+ QStringLiteral("(tag:inbox or tag:flagged) AND (from:foo@example.org)"));
+}
+
+void TestSearchTerm::extendOntoAnEmptyQueryIsAReplace()
+{
+ // Rather than "() AND (x)", which matches nothing.
+ QCOMPARE(SearchTerm::extend(QString(), QStringLiteral("tag:inbox")),
+ QStringLiteral("tag:inbox"));
+ QCOMPARE(SearchTerm::extend(QStringLiteral(" "),
+ QStringLiteral("tag:inbox")),
+ QStringLiteral("tag:inbox"));
+ // And an empty new term leaves the existing query alone.
+ QCOMPARE(SearchTerm::extend(QStringLiteral("tag:inbox"), QString()),
+ QStringLiteral("tag:inbox"));
+}
+
+QTEST_MAIN(TestSearchTerm)
+#include "test_searchterm.moc"
diff --git a/tests/test_tagstrip.cpp b/tests/test_tagstrip.cpp
new file mode 100644
index 0000000..636b24b
--- /dev/null
+++ b/tests/test_tagstrip.cpp
@@ -0,0 +1,108 @@
+/*
+ * 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 <QSignalSpy>
+#include <QtTest>
+
+#include "tagstrip.h"
+
+/// The chip hit test.
+///
+/// Asserted on rects from chipRectAt(), which is the SAME function paintEvent
+/// lays out from, so a drawn chip and a clickable chip cannot drift apart. Not
+/// asserted by rendering: a pixel probe cannot tell a chip that is drawn from a
+/// chip that is drawn and clickable, and both halves have been broken
+/// independently in this project before.
+class TestTagStrip : public QObject
+{
+ Q_OBJECT
+private slots:
+ void chipAtFindsEachVisibleTag();
+ void chipAtMissesTheGapAndTheEdges();
+ void chipAtIgnoresTheOverflowChip();
+};
+
+void TestTagStrip::chipAtFindsEachVisibleTag()
+{
+ TagStrip strip;
+ strip.resize(600, 30);
+ strip.setTags({ QStringLiteral("inbox"), QStringLiteral("unread") });
+
+ const QStringList visible = strip.visibleTags();
+ QCOMPARE(visible.size(), 2);
+
+ // The guard: the geometry this test depends on must exist before the test
+ // can mean anything. A zero-width chip would make every lookup below miss
+ // and the test would pass for the wrong reason.
+ for (int i = 0; i < visible.size(); ++i) {
+ const QRect rect = strip.chipRectAt(i);
+ QVERIFY2(rect.width() > 0 && rect.height() > 0,
+ qPrintable(QStringLiteral("chip %1 has an empty rect").arg(i)));
+ QCOMPARE(strip.chipAt(rect.center()), visible.at(i));
+ }
+}
+
+void TestTagStrip::chipAtMissesTheGapAndTheEdges()
+{
+ TagStrip strip;
+ strip.resize(600, 30);
+ strip.setTags({ QStringLiteral("inbox"), QStringLiteral("unread") });
+ QCOMPARE(strip.visibleTags().size(), 2);
+
+ const QRect first = strip.chipRectAt(0);
+ const QRect second = strip.chipRectAt(1);
+ QVERIFY2(second.left() > first.right() + 1,
+ "the two chips must not touch, or there is no gap to test");
+
+ // Between the chips: no tag, so no menu entry rather than the nearest one.
+ const QPoint gap((first.right() + second.left()) / 2, first.center().y());
+ QVERIFY(strip.chipAt(gap).isEmpty());
+
+ // Past the last chip, where the strip is empty space.
+ QVERIFY(strip.chipAt(QPoint(strip.width() - 1, first.center().y())).isEmpty());
+}
+
+void TestTagStrip::chipAtIgnoresTheOverflowChip()
+{
+ // The +N chip stands for a LIST of tags, not for a tag, so there is no
+ // single value a search could be built from.
+ TagStrip strip;
+ strip.resize(90, 30);
+ strip.setTags({ QStringLiteral("inbox"), QStringLiteral("unread"),
+ QStringLiteral("flagged"), QStringLiteral("attachment"),
+ QStringLiteral("replied") });
+
+ QVERIFY2(!strip.hiddenTags().isEmpty(),
+ "the strip must actually overflow, or this asserts nothing");
+ QVERIFY2(!strip.visibleTags().isEmpty(),
+ "the strip must show at least one chip to test against");
+
+ // Every point across the strip either finds a VISIBLE tag or nothing. The
+ // overflow chip sits after the visible ones and must yield nothing.
+ for (int x = 0; x < strip.width(); x += 3) {
+ const QString found = strip.chipAt(QPoint(x, strip.height() / 2));
+ if (!found.isEmpty())
+ QVERIFY(strip.visibleTags().contains(found));
+ }
+
+ const QRect last = strip.chipRectAt(strip.visibleTags().size() - 1);
+ QVERIFY(strip.chipAt(QPoint(last.right() + 5, strip.height() / 2)).isEmpty());
+}
+
+QTEST_MAIN(TestTagStrip)
+#include "test_tagstrip.moc"