aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
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"