aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_notmuchworker.cpp77
-rw-r--r--tests/test_rulequery.cpp415
-rw-r--r--tests/test_tagrules.cpp700
4 files changed, 1193 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 9c65abd..e5142b6 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -53,3 +53,4 @@ add_qtmaildir_test(messageview)
add_qtmaildir_test(querycompleter)
add_qtmaildir_test(tagdialog)
add_qtmaildir_test(tagrules)
+add_qtmaildir_test(rulequery)
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 1db26c7..9068ca3 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -80,6 +80,9 @@ private slots:
void messageCountsCountMessagesNotThreads();
void messageCountsReportAnInvalidQueryAsMinusOne();
+ void requestFoldersListsEveryMaildirFolder();
+ void requestFoldersOnUnreadableConfigEmitsError();
+
private:
/// Tags of one message, read back through a fresh worker query.
QStringList tagsOf(const QString &messageId);
@@ -900,5 +903,79 @@ void TestNotmuchWorker::requestDatabaseStatsOnUnreadableConfigEmitsError()
QVERIFY(ready.isEmpty());
}
+void TestNotmuchWorker::requestFoldersListsEveryMaildirFolder()
+{
+ // Its own fixture rather than the shared one: this needs a NESTED folder,
+ // which is the shape a real account has (<account>/Drafts, not a flat
+ // "drafts"), and adding a message to the shared fixture would move seven
+ // count assertions in other tests for nothing.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"),
+ QStringLiteral("g1@example.org"),
+ QStringLiteral("Something"),
+ QStringLiteral("Alice <alice@example.org>"),
+ QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY(fixture.addMessage(QStringLiteral("work/Drafts"),
+ QStringLiteral("g2@example.org"),
+ QStringLiteral("Half written"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Tue, 2 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ // An EMPTY folder, created but never written to. mbsync makes these, and a
+ // list derived from indexed messages would not offer it. A rule may
+ // legitimately target a folder that has nothing in it yet.
+ QDir dir;
+ const QString empty = fixture.maildirPath() + QStringLiteral("/work/Archive");
+ QVERIFY(dir.mkpath(empty + QStringLiteral("/cur")));
+ QVERIFY(dir.mkpath(empty + QStringLiteral("/new")));
+ QVERIFY(dir.mkpath(empty + QStringLiteral("/tmp")));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::foldersReady);
+
+ worker.requestFolders();
+
+ QCOMPARE(ready.count(), 1);
+ const QStringList folders = ready.first().at(0).toStringList();
+
+ // Paths relative to the database root, which is what a Folder term
+ // compiles a path: against. Drafts is the whole point of the item: the
+ // dialog used to offer one entry per account and nothing below it.
+ QVERIFY(folders.contains(QStringLiteral("work/INBOX")));
+ QVERIFY(folders.contains(QStringLiteral("work/Drafts")));
+ QVERIFY(folders.contains(QStringLiteral("work/Archive")));
+
+ // Not the maildir plumbing, which is not a folder anyone files mail into,
+ // and not the account directory itself, which holds no cur/.
+ QVERIFY(!folders.contains(QStringLiteral("work/INBOX/cur")));
+ QVERIFY(!folders.contains(QStringLiteral("work/INBOX/new")));
+ QVERIFY(!folders.contains(QStringLiteral("work")));
+
+ // Sorted, so the dropdown does not reorder itself between openings with
+ // the same tree on disk. QDir's own order is filesystem order.
+ QStringList sorted = folders;
+ sorted.sort();
+ QCOMPARE(folders, sorted);
+}
+
+void TestNotmuchWorker::requestFoldersOnUnreadableConfigEmitsError()
+{
+ // Fails closed like every other entry point. The dialog then leaves the
+ // dropdown as it was rather than emptying it, since an empty list reads as
+ // "this account has no folders".
+ NotmuchWorker worker(QStringLiteral("/nonexistent/qtmaildir-test/config"));
+ QSignalSpy ready(&worker, &NotmuchWorker::foldersReady);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.requestFolders();
+
+ QCOMPARE(errors.size(), 1);
+ QVERIFY(ready.isEmpty());
+}
+
QTEST_MAIN(TestNotmuchWorker)
#include "test_notmuchworker.moc"
diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp
new file mode 100644
index 0000000..54f9477
--- /dev/null
+++ b/tests/test_rulequery.cpp
@@ -0,0 +1,415 @@
+/*
+ * 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 "rulequery.h"
+
+/// RuleQuery is a view over a string the notmuch post-new hook executes, so
+/// the risk is a query that compiles to something subtly wider than the rows
+/// say. These tests are about exact strings, not about whether notmuch would
+/// accept the result: notmuch accepts almost anything, including `from:((((`.
+class TestRuleQuery : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aSingleContainsTermCompiles();
+ void everyFieldCompilesToItsPrefix();
+ void isQuotesAndContainsDoesNot();
+ void negationPrefixesNot();
+ void aValueWithASpaceIsAlwaysQuoted();
+ void folderAppendsTheRecursiveSuffix();
+ void dateCompilesToAOneSidedRange();
+ void allJoinsWithAnd();
+ void anyJoinsWithOr();
+ void exclusionsAppendAsAndNot();
+ void anyIsParenthesisedOnlyWhenExclusionsFollow();
+ void anEmptyQueryCompilesToAnEmptyString();
+ void aFlatAndChainParses();
+ void aFlatOrChainParses();
+ void anEmptyQueryParsesToNoRows();
+ void quotedValuesLoseTheirQuotes();
+ void aNegatedTermParsesAsANegatedOperator();
+ void whatParsesCompilesBackUnchanged();
+ void anOrGroupWithExclusionsParses();
+ void anUnrepresentableQueryRejectsWhole();
+ void aParenBearingValueIsARowNotAShape();
+ void theRuleCorpusRoundTripsByteForByte();
+};
+
+void TestRuleQuery::aSingleContainsTermCompiles()
+{
+ RuleQuery q;
+ q.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("sender@example.org")});
+
+ QCOMPARE(q.compile(), QStringLiteral("from:sender@example.org"));
+}
+
+void TestRuleQuery::everyFieldCompilesToItsPrefix()
+{
+ const QVector<QPair<RuleTerm::Field, QString>> cases = {
+ {RuleTerm::From, QStringLiteral("from:x")},
+ {RuleTerm::To, QStringLiteral("to:x")},
+ {RuleTerm::Cc, QStringLiteral("cc:x")},
+ {RuleTerm::Subject, QStringLiteral("subject:x")},
+ };
+
+ for (const auto &c : cases) {
+ RuleQuery q;
+ q.terms.append({c.first, RuleTerm::Contains, QStringLiteral("x")});
+ QCOMPARE(q.compile(), c.second);
+ }
+}
+
+void TestRuleQuery::isQuotesAndContainsDoesNot()
+{
+ RuleQuery contains;
+ contains.terms.append({RuleTerm::Subject, RuleTerm::Contains,
+ QStringLiteral("receipt")});
+ QCOMPARE(contains.compile(), QStringLiteral("subject:receipt"));
+
+ RuleQuery is;
+ is.terms.append({RuleTerm::Subject, RuleTerm::Is,
+ QStringLiteral("receipt")});
+ QCOMPARE(is.compile(), QStringLiteral("subject:\"receipt\""));
+}
+
+void TestRuleQuery::negationPrefixesNot()
+{
+ RuleQuery q;
+ q.terms.append({RuleTerm::Subject, RuleTerm::ContainsNot,
+ QStringLiteral("receipt")});
+ QCOMPARE(q.compile(), QStringLiteral("not subject:receipt"));
+
+ RuleQuery tag;
+ tag.terms.append({RuleTerm::Tag, RuleTerm::IsNot,
+ QStringLiteral("inbox")});
+ QCOMPARE(tag.compile(), QStringLiteral("not tag:inbox"));
+
+ RuleQuery att;
+ att.terms.append({RuleTerm::Attachment, RuleTerm::HasNot,
+ QStringLiteral("pdf")});
+ QCOMPARE(att.compile(), QStringLiteral("not attachment:pdf"));
+}
+
+void TestRuleQuery::aValueWithASpaceIsAlwaysQuoted()
+{
+ // Unquoted, a space would end the term and the rest would become a
+ // separate bare word, silently widening the rule.
+ RuleQuery q;
+ q.terms.append({RuleTerm::Subject, RuleTerm::Contains,
+ QStringLiteral("your receipt")});
+ QCOMPARE(q.compile(), QStringLiteral("subject:\"your receipt\""));
+}
+
+void TestRuleQuery::folderAppendsTheRecursiveSuffix()
+{
+ // A path: without the suffix matches nothing, and notmuch reports no
+ // error when it happens.
+ RuleQuery q;
+ q.terms.append({RuleTerm::Folder, RuleTerm::Is,
+ QStringLiteral("account-one")});
+ QCOMPARE(q.compile(), QStringLiteral("path:\"account-one/**\""));
+}
+
+void TestRuleQuery::dateCompilesToAOneSidedRange()
+{
+ RuleQuery before;
+ before.terms.append({RuleTerm::Date, RuleTerm::Before,
+ QStringLiteral("2026-01-01")});
+ QCOMPARE(before.compile(), QStringLiteral("date:..2026-01-01"));
+
+ RuleQuery after;
+ after.terms.append({RuleTerm::Date, RuleTerm::After,
+ QStringLiteral("2026-01-01")});
+ QCOMPARE(after.compile(), QStringLiteral("date:2026-01-01.."));
+}
+
+void TestRuleQuery::allJoinsWithAnd()
+{
+ RuleQuery q;
+ q.join = RuleQuery::All;
+ q.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("vendor.example.org")});
+ q.terms.append({RuleTerm::Subject, RuleTerm::Contains,
+ QStringLiteral("receipt")});
+
+ QCOMPARE(q.compile(),
+ QStringLiteral("from:vendor.example.org and subject:receipt"));
+}
+
+void TestRuleQuery::anyJoinsWithOr()
+{
+ RuleQuery q;
+ q.join = RuleQuery::Any;
+ q.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("one.example.org")});
+ q.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("two.example.org")});
+
+ QCOMPARE(q.compile(),
+ QStringLiteral("from:one.example.org or from:two.example.org"));
+}
+
+void TestRuleQuery::exclusionsAppendAsAndNot()
+{
+ RuleQuery q;
+ q.join = RuleQuery::All;
+ q.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("vendor.example.org")});
+ q.exclusions.append({RuleTerm::Subject, RuleTerm::Contains,
+ QStringLiteral("receipt")});
+
+ QCOMPARE(q.compile(),
+ QStringLiteral("from:vendor.example.org "
+ "and not subject:receipt"));
+}
+
+void TestRuleQuery::anyIsParenthesisedOnlyWhenExclusionsFollow()
+{
+ // Without the parens this binds as (a or (b and not c)), which matches
+ // everything from the first sender regardless of the exclusion.
+ RuleQuery guarded;
+ guarded.join = RuleQuery::Any;
+ guarded.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("one.example.org")});
+ guarded.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("two.example.org")});
+ guarded.exclusions.append({RuleTerm::Subject, RuleTerm::Contains,
+ QStringLiteral("receipt")});
+
+ QCOMPARE(guarded.compile(),
+ QStringLiteral("(from:one.example.org or from:two.example.org) "
+ "and not subject:receipt"));
+
+ // No exclusion, no parens: they would be noise in the stored file.
+ RuleQuery bare;
+ bare.join = RuleQuery::Any;
+ bare.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("one.example.org")});
+ bare.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("two.example.org")});
+
+ QCOMPARE(bare.compile(),
+ QStringLiteral("from:one.example.org or from:two.example.org"));
+}
+
+void TestRuleQuery::anEmptyQueryCompilesToAnEmptyString()
+{
+ RuleQuery q;
+ QCOMPARE(q.compile(), QString());
+}
+
+void TestRuleQuery::aFlatAndChainParses()
+{
+ const RuleQuery q = RuleQuery::parse(
+ QStringLiteral("from:vendor.example.org and subject:receipt"));
+
+ QVERIFY(q.parsed);
+ QCOMPARE(q.join, RuleQuery::All);
+ QCOMPARE(q.terms.size(), 2);
+ QCOMPARE(q.terms.at(0).field, RuleTerm::From);
+ QCOMPARE(q.terms.at(0).op, RuleTerm::Contains);
+ QCOMPARE(q.terms.at(0).value, QStringLiteral("vendor.example.org"));
+ QCOMPARE(q.terms.at(1).field, RuleTerm::Subject);
+ QVERIFY(q.exclusions.isEmpty());
+}
+
+void TestRuleQuery::aFlatOrChainParses()
+{
+ const RuleQuery q = RuleQuery::parse(
+ QStringLiteral("from:one.example.org or from:two.example.org"));
+
+ QVERIFY(q.parsed);
+ QCOMPARE(q.join, RuleQuery::Any);
+ QCOMPARE(q.terms.size(), 2);
+}
+
+void TestRuleQuery::anEmptyQueryParsesToNoRows()
+{
+ // One shipped rule has an empty query. It must open in the builder ready
+ // to receive a row, not fall back to text mode.
+ const RuleQuery q = RuleQuery::parse(QString());
+
+ QVERIFY(q.parsed);
+ QVERIFY(q.terms.isEmpty());
+}
+
+void TestRuleQuery::quotedValuesLoseTheirQuotes()
+{
+ const RuleQuery q = RuleQuery::parse(
+ QStringLiteral("subject:\"your receipt\""));
+
+ QVERIFY(q.parsed);
+ QCOMPARE(q.terms.size(), 1);
+ QCOMPARE(q.terms.at(0).value, QStringLiteral("your receipt"));
+ QCOMPARE(q.terms.at(0).op, RuleTerm::Is);
+}
+
+void TestRuleQuery::aNegatedTermParsesAsANegatedOperator()
+{
+ const RuleQuery q = RuleQuery::parse(
+ QStringLiteral("from:vendor.example.org and not tag:inbox"));
+
+ QVERIFY(q.parsed);
+ // A trailing negation on an `and` chain becomes an exclusion: that is how
+ // the user describes these rules, and the design records the preference.
+ QCOMPARE(q.terms.size(), 1);
+ QCOMPARE(q.exclusions.size(), 1);
+ QCOMPARE(q.exclusions.at(0).field, RuleTerm::Tag);
+ QCOMPARE(q.exclusions.at(0).op, RuleTerm::Is);
+}
+
+void TestRuleQuery::whatParsesCompilesBackUnchanged()
+{
+ // The dialog decides whether to rewrite the stored string by comparing
+ // against what it parsed, so a compile that differs by so much as a quote
+ // would churn a file a second tool reads.
+ const QStringList queries = {
+ QStringLiteral("from:vendor.example.org"),
+ QStringLiteral("from:vendor.example.org and subject:receipt"),
+ QStringLiteral("from:one.example.org or from:two.example.org"),
+ QStringLiteral("subject:\"your receipt\""),
+ QStringLiteral("path:\"account-one/**\""),
+ QStringLiteral("tag:inbox"),
+ QStringLiteral("attachment:pdf"),
+ QStringLiteral("date:..2026-01-01"),
+ QStringLiteral("date:2026-01-01.."),
+ QStringLiteral("from:vendor.example.org and not tag:inbox"),
+ QStringLiteral("from:vendor.example.org and not subject:receipt "
+ "and not subject:refund"),
+ QStringLiteral("(from:one.example.org or from:two.example.org) "
+ "and not subject:receipt"),
+ };
+
+ for (const QString &query : queries) {
+ const RuleQuery parsed = RuleQuery::parse(query);
+ QVERIFY2(parsed.parsed, qPrintable(query));
+ QCOMPARE(parsed.compile(), query);
+ }
+}
+
+void TestRuleQuery::anOrGroupWithExclusionsParses()
+{
+ const RuleQuery q = RuleQuery::parse(
+ QStringLiteral("(from:vendor.example.org or from:vendor.example.net) "
+ "and not subject:receipt and not subject:refund"));
+
+ QVERIFY(q.parsed);
+ QCOMPARE(q.join, RuleQuery::Any);
+ QCOMPARE(q.terms.size(), 2);
+ QCOMPARE(q.exclusions.size(), 2);
+ QCOMPARE(q.exclusions.at(0).field, RuleTerm::Subject);
+ QCOMPARE(q.exclusions.at(0).op, RuleTerm::Contains);
+ QCOMPARE(q.exclusions.at(0).value, QStringLiteral("receipt"));
+
+ // The round trip is the point: this must come back as it went in.
+ QCOMPARE(q.compile(),
+ QStringLiteral("(from:vendor.example.org or "
+ "from:vendor.example.net) "
+ "and not subject:receipt and not subject:refund"));
+}
+
+void TestRuleQuery::anUnrepresentableQueryRejectsWhole()
+{
+ const QStringList unrepresentable = {
+ QStringLiteral("from:a.example.org or (from:b.example.org "
+ "or from:c.example.org)"), // nested or inside or
+ QStringLiteral("from:a.example.org and subject:x "
+ "or subject:y"), // mixed, unparenthesised
+ QStringLiteral("body:receipt"), // unrecognised prefix
+ QStringLiteral("folder:Inbox"), // not the prefix we emit
+ QStringLiteral("receipt"), // bare word, no prefix
+ QStringLiteral("path:\"account-one\""), // no /** suffix
+ QStringLiteral("from:a.example.org and"), // trailing operator
+ QStringLiteral("date:2026-01-01..2026-02-01"), // two-sided range
+ QStringLiteral("from:a.example.org xor subject:x"),
+ QStringLiteral("subject:\"unterminated"), // open quote
+ QStringLiteral("(from:a.example.org or from:b.example.org)"),
+ // group, nothing after it
+ QStringLiteral("(from:a.example.org and from:b.example.org) "
+ "and not subject:x"), // group joined by and
+ };
+
+ for (const QString &query : unrepresentable) {
+ const RuleQuery q = RuleQuery::parse(query);
+ QVERIFY2(!q.parsed, qPrintable(QStringLiteral("parsed: ") + query));
+ // Rejecting whole means keeping nothing: a half-parse is how a
+ // negation goes missing and a rule quietly matches more mail.
+ QVERIFY2(q.terms.isEmpty() && q.exclusions.isEmpty(),
+ qPrintable(QStringLiteral("kept rows: ") + query));
+ }
+}
+
+void TestRuleQuery::aParenBearingValueIsARowNotAShape()
+{
+ // notmuch reads these parens as characters to search for, not as
+ // grouping, so the query is meaningful and matches nothing. It parses
+ // here as a From row holding that literal text, which is what it means.
+ // The property to hold is the round trip, NOT a rejection: a test
+ // expecting an error here fails against correct code.
+ const RuleQuery q = RuleQuery::parse(QStringLiteral("from:(((("));
+
+ QVERIFY(q.parsed);
+ QCOMPARE(q.terms.size(), 1);
+ QCOMPARE(q.terms.at(0).value, QStringLiteral("(((("));
+ QCOMPARE(q.compile(), QStringLiteral("from:(((("));
+}
+
+void TestRuleQuery::theRuleCorpusRoundTripsByteForByte()
+{
+ // Every query SHAPE present in a real rules file, with placeholder
+ // values. Verified by hand against the live file when this was written:
+ // all seventeen rules parsed and compiled back byte for byte, none fell
+ // to text mode.
+ //
+ // A compile that differs by so much as a paren would rewrite the shared
+ // file on the next save, which the companion tool then sees as a diff
+ // nobody made.
+ const QStringList corpus = {
+ QString(), // a rule with no query yet
+ QStringLiteral("path:\"account-one/**\""),
+ QStringLiteral("path:\"account-two/Inbox/topic/**\""),
+ QStringLiteral("subject:\"[list-name]\""),
+ QStringLiteral("from:notifications@service.example.org"),
+ QStringLiteral("from:one@jobs.example.org or "
+ "from:two@jobs.example.org or "
+ "from:three@jobs.example.org"),
+ QStringLiteral("from:mail.vendor.example.org and "
+ "subject:\"Secure link\""),
+ QStringLiteral("(from:vendor.example.org or from:vendor.example.net) "
+ "and not subject:receipt and not subject:refund "
+ "and not subject:EUR"),
+ };
+
+ for (const QString &query : corpus) {
+ const RuleQuery parsed = RuleQuery::parse(query);
+ QVERIFY2(parsed.parsed, qPrintable(query));
+ QCOMPARE(parsed.compile(), query);
+
+ // And the value itself round-trips, which is what the dialog's
+ // "was this edited" comparison depends on.
+ QVERIFY2(RuleQuery::parse(parsed.compile()) == parsed,
+ qPrintable(query));
+ }
+}
+
+QTEST_MAIN(TestRuleQuery)
+#include "test_rulequery.moc"
diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp
index 8c7e1a9..ed3a06d 100644
--- a/tests/test_tagrules.cpp
+++ b/tests/test_tagrules.cpp
@@ -16,10 +16,15 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
+#include <QSettings>
#include <QTemporaryDir>
#include <QtTest>
+#include "config.h"
+#include "mainwindow.h"
+#include "rulequery.h"
#include "tagrules.h"
+#include "tagrulesdialog.h"
/// The risk in TagRules is the format, not painting: a field silently dropped
/// on save mis-tags real mail on the next sync, and does it quietly. These
@@ -38,6 +43,21 @@ private slots:
void aQueryWithQuotesRoundTrips();
void aMissingFileIsEmptyNotAnError();
void aNewerVersionIsRefused();
+ void openingARuleFillsTheBuilderRows();
+ void switchingRulesDoesNotLeakRowsBetweenThem();
+ void openingARuleWithoutEditingLeavesItByteIdentical();
+ void anUnrepresentableRuleOpensInTextMode();
+ void editingARowRewritesTheQuery();
+ void aTextModeRuleStaysTextWhenAnotherRuleIsVisited();
+ void leavingTextModeIsRefusedWhenTheQueryCannotBeShownAsRows();
+ void aFolderRowUsesTheDropdownAndKeepsItsSuffix();
+ void theTextModeToggleSurvivesBeingSwitchedOn();
+ void theWindowSizeAndColumnWidthsSurviveAReopen();
+ void theWindowSizeIsSavedOnEveryWayOutOfTheDialog();
+ void aReloadDoesNotDiscardARestoredColumnWidth();
+ void manyConditionRowsDoNotSqueezeTheRuleList();
+ void previewEmitsTheRuleQueryAsStored();
+ void previewClearsTheAccountScope();
private:
QString writeRules(const QString &json);
@@ -241,5 +261,685 @@ void TestTagRules::aNewerVersionIsRefused()
QCOMPARE(rules.warnings().size(), 1);
}
+void TestTagRules::openingARuleFillsTheBuilderRows()
+{
+ // The dialog reads the shared store from its default path, so point the
+ // whole process at a temporary one. XDG_CONFIG_HOME is what
+ // TagRules::defaultPath() honours.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json")));
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "vendor",
+ "query": "from:vendor.example.org and subject:receipt",
+ "add": ["vendor"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+
+ QCOMPARE(dialog.rowCountForTest(), 2);
+ QCOMPARE(dialog.queryLineForTest(),
+ QStringLiteral("from:vendor.example.org and subject:receipt"));
+}
+
+void TestTagRules::switchingRulesDoesNotLeakRowsBetweenThem()
+{
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json")));
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "one", "query": "from:one.example.org",
+ "add": ["one"], "stage": 50, "enabled": true},
+ {"id": "two",
+ "query": "from:two.example.org or from:three.example.org",
+ "add": ["two"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+
+ // The first rule is selected on open: one row, joined All by default.
+ QCOMPARE(dialog.rowCountForTest(), 1);
+ QCOMPARE(dialog.queryLineForTest(), QStringLiteral("from:one.example.org"));
+
+ dialog.selectRuleForTest(1);
+ QCOMPARE(dialog.rowCountForTest(), 2);
+ QCOMPARE(dialog.queryLineForTest(),
+ QStringLiteral("from:two.example.org or from:three.example.org"));
+
+ // And back, to prove the first rule was not overwritten by loading the
+ // second.
+ dialog.selectRuleForTest(0);
+ // The row widgets are created during the select and their size hints are
+ // not valid until the layout has run, which needs the event loop: without
+ // this the builder reports the same height for one row and for eight, and
+ // the test passes against the bug.
+ QCoreApplication::processEvents();
+ QCOMPARE(dialog.rowCountForTest(), 1);
+ QCOMPARE(dialog.queryLineForTest(), QStringLiteral("from:one.example.org"));
+}
+
+void TestTagRules::openingARuleWithoutEditingLeavesItByteIdentical()
+{
+ // Recompiling on open would rewrite the shared file for no reason, and
+ // the companion tool would see a diff the user never made. Semantically
+ // equal is not enough: the bytes must match.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "handwritten",
+ "query": "not subject:receipt and from:plain.example.net",
+ "add": ["handwritten"], "stage": 50, "enabled": true},
+ {"id": "vendor",
+ "query": "(from:vendor.example.org or from:vendor.example.net) and not subject:receipt",
+ "add": ["vendor"], "stage": 50, "enabled": true},
+ {"id": "plain", "query": "from:plain.example.org",
+ "add": ["plain"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ {
+ TagRulesDialog dialog;
+ dialog.selectRuleForTest(2);
+ dialog.selectRuleForTest(1);
+ dialog.selectRuleForTest(0);
+ dialog.saveForTest();
+ }
+
+ TagRules reloaded;
+ reloaded.load(stored);
+ QCOMPARE(reloaded.rules().size(), 3);
+ // Hand-written spacing and an exclusion ahead of the positive term. Both
+ // are things compile() normalises away, and this rule is deliberately the
+ // one left current at Save, since that is the only rule the save path
+ // writes at all. The two below round trip byte for byte on their own, so
+ // neither could catch a save path that recompiles regardless.
+ QCOMPARE(reloaded.rules().at(0).query,
+ QStringLiteral("not subject:receipt and "
+ "from:plain.example.net"));
+ QCOMPARE(reloaded.rules().at(1).query,
+ QStringLiteral("(from:vendor.example.org or "
+ "from:vendor.example.net) and not subject:receipt"));
+ QCOMPARE(reloaded.rules().at(2).query,
+ QStringLiteral("from:plain.example.org"));
+}
+
+void TestTagRules::anUnrepresentableRuleOpensInTextMode()
+{
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ // body: is a perfectly good notmuch prefix this builder does not model.
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "deep", "query": "body:receipt",
+ "add": ["deep"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ {
+ TagRulesDialog dialog;
+ QVERIFY(dialog.textModeForTest());
+ dialog.saveForTest();
+ }
+
+ // Unrepresentable is not invalid: it must survive a save untouched.
+ TagRules reloaded;
+ reloaded.load(stored);
+ QCOMPARE(reloaded.rules().size(), 1);
+ QCOMPARE(reloaded.rules().at(0).query, QStringLiteral("body:receipt"));
+}
+
+void TestTagRules::editingARowRewritesTheQuery()
+{
+ // The other half of the guarantee: when rows DO change, the stored query
+ // must follow.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "vendor", "query": "from:vendor.example.org",
+ "add": ["vendor"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ {
+ TagRulesDialog dialog;
+ dialog.setRowValueForTest(0, QStringLiteral("other.example.org"));
+ dialog.saveForTest();
+ }
+
+ TagRules reloaded;
+ reloaded.load(stored);
+ QCOMPARE(reloaded.rules().size(), 1);
+ QCOMPARE(reloaded.rules().at(0).query,
+ QStringLiteral("from:other.example.org"));
+}
+
+void TestTagRules::aTextModeRuleStaysTextWhenAnotherRuleIsVisited()
+{
+ // The cross-rule question, asked directly. Text mode and m_loadedQuery are
+ // per-rule state on a dialog that has one set of widgets, so visiting a
+ // representable rule and coming back must not leave the unrepresentable one
+ // holding the other rule's mode or its parsed query. Getting that wrong
+ // recompiles a query the builder never modelled, which is data loss.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "deep", "query": "body:receipt",
+ "add": ["deep"], "stage": 50, "enabled": true},
+ {"id": "plain", "query": "from:plain.example.org",
+ "add": ["plain"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ {
+ TagRulesDialog dialog;
+ QVERIFY(dialog.textModeForTest());
+
+ dialog.selectRuleForTest(1);
+ QVERIFY2(!dialog.textModeForTest(),
+ "a representable rule must return to the builder");
+ QCOMPARE(dialog.queryLineForTest(),
+ QStringLiteral("from:plain.example.org"));
+
+ dialog.selectRuleForTest(0);
+ QVERIFY2(dialog.textModeForTest(),
+ "coming back to an unrepresentable rule must be text again");
+ QCOMPARE(dialog.queryLineForTest(), QStringLiteral("body:receipt"));
+
+ dialog.saveForTest();
+ }
+
+ TagRules reloaded;
+ reloaded.load(stored);
+ QCOMPARE(reloaded.rules().size(), 2);
+ QCOMPARE(reloaded.rules().at(0).query, QStringLiteral("body:receipt"));
+ QCOMPARE(reloaded.rules().at(1).query,
+ QStringLiteral("from:plain.example.org"));
+}
+
+void TestTagRules::leavingTextModeIsRefusedWhenTheQueryCannotBeShownAsRows()
+{
+ // The refusal is the only path that can strand a user, so it is the one
+ // most worth pinning. It reports through the warning label rather than a
+ // modal, which is what lets this test exist at all: a modal would block
+ // here and the branch would ship unverified.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "vendor", "query": "from:vendor.example.org",
+ "add": ["vendor"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ QVERIFY(!dialog.textModeForTest());
+
+ dialog.setTextModeForTest(true);
+ QVERIFY(dialog.textModeForTest());
+
+ // Type something notmuch accepts and this builder does not model.
+ dialog.setQueryTextForTest(QStringLiteral("body:receipt"));
+ dialog.setTextModeForTest(false);
+
+ QVERIFY2(dialog.textModeForTest(),
+ "the checkbox must refuse to clear: no rows mean this query");
+ QVERIFY2(!dialog.warningTextForTest().isEmpty(),
+ "the refusal must say why, not fail silently");
+
+ // And a representable query lets the builder back, clearing the warning.
+ dialog.setQueryTextForTest(QStringLiteral("from:other.example.org"));
+ dialog.setTextModeForTest(false);
+
+ QVERIFY2(!dialog.textModeForTest(), "a representable query must return");
+ QCOMPARE(dialog.rowCountForTest(), 1);
+ QVERIFY2(dialog.warningTextForTest().isEmpty(),
+ "a stale refusal must not outlive the query that caused it");
+}
+
+void TestTagRules::aFolderRowUsesTheDropdownAndKeepsItsSuffix()
+{
+ // A path: without its suffix matches nothing and notmuch says nothing
+ // about it, so the suffix must never depend on the user typing it.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "account", "query": "path:\"account-one/**\"",
+ "add": ["account-one"], "stage": 10, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ dialog.setFolders({QStringLiteral("account-one"),
+ QStringLiteral("account-two")});
+
+ // The stored rule round-trips: the row holds the bare name, and the
+ // query keeps the suffix.
+ QCOMPARE(dialog.rowCountForTest(), 1);
+ QCOMPARE(dialog.queryLineForTest(),
+ QStringLiteral("path:\"account-one/**\""));
+
+ dialog.setRowValueForTest(0, QStringLiteral("account-two"));
+ QCOMPARE(dialog.queryLineForTest(),
+ QStringLiteral("path:\"account-two/**\""));
+
+ dialog.saveForTest();
+
+ TagRules reloaded;
+ reloaded.load(stored);
+ QCOMPARE(reloaded.rules().size(), 1);
+ QCOMPARE(reloaded.rules().at(0).query,
+ QStringLiteral("path:\"account-two/**\""));
+}
+
+void TestTagRules::theTextModeToggleSurvivesBeingSwitchedOn()
+{
+ // The toggle governs the builder, so it must not live INSIDE the builder:
+ // switching to text mode hides that widget, and a checkbox parented there
+ // disappears along with the rows, leaving no way back except closing the
+ // dialog. That shipped in the first draft and a user found it by hand.
+ //
+ // Asserting on the checked state alone passes against the bug, because a
+ // hidden widget still reports its state perfectly well. The question is
+ // reachability.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json")));
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "vendor", "query": "from:vendor.example.org",
+ "add": ["vendor"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ QVERIFY(dialog.textModeToggleIsReachableForTest());
+
+ dialog.setTextModeForTest(true);
+ QVERIFY2(dialog.textModeToggleIsReachableForTest(),
+ "the toggle must survive switching to text, or there is no "
+ "way back to the rows");
+
+ // And the round trip works, which is the behaviour the user wanted.
+ dialog.setTextModeForTest(false);
+ QVERIFY(!dialog.textModeForTest());
+ QVERIFY(dialog.textModeToggleIsReachableForTest());
+ QCOMPARE(dialog.rowCountForTest(), 1);
+ QCOMPARE(dialog.queryLineForTest(),
+ QStringLiteral("from:vendor.example.org"));
+}
+
+namespace {
+
+/// Writes a two-rule file under a throwaway XDG_CONFIG_HOME. Two rules rather
+/// than one because the column-width tests reload the list, and a list with a
+/// single row hides an off-by-one in the repopulate.
+void writeTwoRules(const QTemporaryDir &configHome)
+{
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+ QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json")));
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "vendor", "query": "from:vendor.example.org",
+ "add": ["vendor"], "stage": 50, "enabled": true},
+ {"id": "lists", "query": "to:list.example.org",
+ "add": ["lists"], "stage": 60, "enabled": true}
+ ]
+ })");
+ out.close();
+}
+
+} // namespace
+
+void TestTagRules::theWindowSizeAndColumnWidthsSurviveAReopen()
+{
+ // The window opened at 760x520 whatever size it was left at, and the
+ // columns reset to their computed widths on every open.
+ //
+ // XDG_STATE_HOME is redirected as well as XDG_CONFIG_HOME: the state file
+ // is where this writes, and a test must not touch the user's real
+ // ~/.local/state/qtmaildir/uistate.conf.
+ QTemporaryDir configHome;
+ QTemporaryDir stateHome;
+ QVERIFY(configHome.isValid());
+ QVERIFY(stateHome.isValid());
+ const QByteArray previousState = qgetenv("XDG_STATE_HOME");
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ qputenv("XDG_STATE_HOME", stateHome.path().toUtf8());
+ writeTwoRules(configHome);
+
+ {
+ TagRulesDialog dialog;
+ dialog.resize(900, 640);
+ dialog.setColumnWidthForTest(0, 123);
+ // CANCEL, not close(). The first version of this saved from
+ // closeEvent and asserted with close(), which passes while the real
+ // dialog forgets everything: Cancel calls reject() and Save calls
+ // accept(), and neither sends a QCloseEvent. Only the window
+ // manager's X button does, so the test exercised the one path the
+ // buttons never take. The user found it by hand in one try.
+ dialog.reject();
+ }
+
+ // Asserted on the stored VALUE, not on the reopened frame. Item 46: the
+ // offscreen platform does not honour a resize, so a frame comparison here
+ // would report a failure the code did not cause.
+ //
+ // And on a TILING compositor the frame is not the dialog's to restore at
+ // all. saveGeometry stores frameGeometry beside normalGeometry, and
+ // restoreGeometry restores the NORMAL one; under Hyprland the window is
+ // tiled to fill its slot, so the size the user drags belongs to the tile
+ // while normalGeometry stays at whatever the code last resize()d it to.
+ // Measured against the real state file: frame 2248x806, normal 760x664.
+ // Restoring 760 there is correct behaviour, not the bug it looks like.
+ QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat);
+ QCOMPARE(state.value(QStringLiteral("tagrules/geometry")).toByteArray()
+ .isEmpty(), false);
+
+ {
+ TagRulesDialog reopened;
+ QCOMPARE(reopened.columnWidthForTest(0), 123);
+ }
+
+ if (previousState.isEmpty())
+ qunsetenv("XDG_STATE_HOME");
+ else
+ qputenv("XDG_STATE_HOME", previousState);
+}
+
+void TestTagRules::theWindowSizeIsSavedOnEveryWayOutOfTheDialog()
+{
+ // There are three ways out and they take different code paths: Cancel
+ // calls reject(), Save calls accept(), and the window manager's X button
+ // sends a QCloseEvent. Saving from closeEvent alone covers only the
+ // third, which is how the first version of this shipped and forgot the
+ // size on both buttons. done(int) is the funnel the two buttons share and
+ // close() also reaches, so all three are asserted here rather than
+ // trusting one to stand for the others.
+ QTemporaryDir configHome;
+ QTemporaryDir stateHome;
+ QVERIFY(configHome.isValid());
+ QVERIFY(stateHome.isValid());
+ const QByteArray previousState = qgetenv("XDG_STATE_HOME");
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ qputenv("XDG_STATE_HOME", stateHome.path().toUtf8());
+ writeTwoRules(configHome);
+
+ const auto widthAfter = [&](int width, const char *how) {
+ QFile::remove(MainWindow::uiStatePath());
+ TagRulesDialog dialog;
+ // Shown, because QWidget::close() on a widget that was never visible
+ // returns early without reaching done(). The X button it stands for
+ // only exists on a window that is on screen, so testing the closed
+ // path from a hidden dialog proves nothing about it.
+ dialog.show();
+ dialog.setColumnWidthForTest(0, width);
+ if (qstrcmp(how, "reject") == 0)
+ dialog.reject();
+ else if (qstrcmp(how, "accept") == 0)
+ dialog.saveForTest();
+ else
+ dialog.close();
+
+ TagRulesDialog reopened;
+ return reopened.columnWidthForTest(0);
+ };
+
+ QCOMPARE(widthAfter(121, "reject"), 121);
+ QCOMPARE(widthAfter(122, "accept"), 122);
+ QCOMPARE(widthAfter(123, "close"), 123);
+
+ if (previousState.isEmpty())
+ qunsetenv("XDG_STATE_HOME");
+ else
+ qputenv("XDG_STATE_HOME", previousState);
+}
+
+void TestTagRules::aReloadDoesNotDiscardARestoredColumnWidth()
+{
+ // The width did not survive a close, and it did not survive an ADD or a
+ // DELETE either: reloadList called resizeColumnToContents on every
+ // repopulate, so a restore was undone by the first thing the user did in
+ // the window. Restoring on open and reverting on the next click is worse
+ // than never restoring at all, because it looks like the setting is
+ // broken rather than absent.
+ QTemporaryDir configHome;
+ QTemporaryDir stateHome;
+ QVERIFY(configHome.isValid());
+ QVERIFY(stateHome.isValid());
+ const QByteArray previousState = qgetenv("XDG_STATE_HOME");
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ qputenv("XDG_STATE_HOME", stateHome.path().toUtf8());
+ writeTwoRules(configHome);
+
+ TagRulesDialog dialog;
+ dialog.setColumnWidthForTest(0, 137);
+ dialog.reloadListForTest();
+ QCOMPARE(dialog.columnWidthForTest(0), 137);
+
+ if (previousState.isEmpty())
+ qunsetenv("XDG_STATE_HOME");
+ else
+ qputenv("XDG_STATE_HOME", previousState);
+}
+
+void TestTagRules::manyConditionRowsDoNotSqueezeTheRuleList()
+{
+ // A rule with eight senders left the rule list showing about one and a
+ // half rows: the list had stretch 1, but a stretch factor only shares out
+ // space ABOVE each widget's minimum, and the form below it has no ceiling,
+ // so every condition row added to the minimum the list had to give up.
+ //
+ // Measured as the height the layout demands below the list. A rule with
+ // many rows must not demand materially more than a rule with one; what it
+ // needs beyond that belongs in the builder's own scroll area.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json")));
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "one", "query": "from:a.example.org",
+ "add": ["x"], "stage": 50, "enabled": true},
+ {"id": "many", "query":
+ "from:a.example.org or from:b.example.org or from:c.example.org or from:d.example.org or from:e.example.org or from:f.example.org or from:g.example.org or from:h.example.org",
+ "add": ["y"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ dialog.show();
+
+ dialog.selectRuleForTest(0);
+ // The row widgets are created during the select and their size hints are
+ // not valid until the layout has run, which needs the event loop: without
+ // this the builder reports the same height for one row and for eight, and
+ // the test passes against the bug.
+ QCoreApplication::processEvents();
+ QCOMPARE(dialog.rowCountForTest(), 1);
+ const int withOneRow = dialog.heightDemandedBelowListForTest();
+
+ dialog.selectRuleForTest(1);
+ QCoreApplication::processEvents();
+ // Guard: the fixture must actually produce the many-row case, or this
+ // test passes by measuring the same rule twice.
+ QCOMPARE(dialog.rowCountForTest(), 8);
+ const int withEightRows = dialog.heightDemandedBelowListForTest();
+
+ // Seven extra rows at roughly 30px each would be over 200px of growth.
+ // A small increase is fine (the scroll area still has a minimum), a
+ // proportional one is the bug.
+ QVERIFY2(withEightRows - withOneRow < 100,
+ qPrintable(QStringLiteral("one row demands %1, eight demand %2")
+ .arg(withOneRow).arg(withEightRows)));
+
+ // And the rows are CAPPED, not merely allowed to grow inside a scroll
+ // area that has no ceiling. Asserted separately because removing the cap
+ // leaves the assertion above green: the editor's minimum stays flat
+ // either way, so only the visible height of the row area distinguishes
+ // them. Without a cap a thirty-sender rule fills the window again, this
+ // time scrolling instead of squeezing.
+ QVERIFY2(dialog.conditionAreaHeightForTest() <= 200,
+ qPrintable(QStringLiteral("condition area is %1px tall")
+ .arg(dialog.conditionAreaHeightForTest())));
+}
+
+void TestTagRules::previewEmitsTheRuleQueryAsStored()
+{
+ // The query goes out EXACTLY as stored: no tag:new, no wrapping
+ // parentheses. The hook adds both when it applies a rule, and a preview
+ // that copied it would show nothing at all outside a sync window, since
+ // tag:new is only set on mail that has just arrived.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json")));
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "promo", "query": "from:a.example.org or from:b.example.org",
+ "add": ["promo"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ QSignalSpy spy(&dialog, &TagRulesDialog::previewRequested);
+
+ dialog.selectRuleForTest(0);
+ dialog.previewForTest();
+
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.first().at(0).toString(),
+ QStringLiteral("from:a.example.org or from:b.example.org"));
+}
+
+void TestTagRules::previewClearsTheAccountScope()
+{
+ // runQuery() wraps the bar's text in the selected account's scope. A rule
+ // query usually names its own path already (path:"work/**"), so previewing
+ // one while an account is selected would scope it twice and show nothing,
+ // which reads as "the rule matches no mail" rather than as a UI fault.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+
+ // An account that can actually BE selected. With the default empty config
+ // the selector holds only "All accounts", so it sits at index 0 already
+ // and the assertion below passes whether or not the preview clears it:
+ // measured, the mutation removing the reset survived until this config
+ // was added.
+ const QString confPath = configHome.filePath(QStringLiteral("q.conf"));
+ QFile conf(confPath);
+ QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text));
+ conf.write("[account.work]\nmaildir=work-mail\n");
+ conf.close();
+
+ Config config;
+ config.load(confPath);
+ QCOMPARE(config.accounts().size(), 1);
+
+ MainWindow window(config);
+ window.selectAccountForTesting(QStringLiteral("work"));
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+
+ window.previewRuleQueryForTesting(QStringLiteral("from:a.example.org"));
+
+ QCOMPARE(window.queryTextForTesting(),
+ QStringLiteral("from:a.example.org"));
+ QVERIFY2(window.selectedAccountForTesting().isEmpty(),
+ "a preview must run unscoped, or an account-scoped rule query "
+ "is wrapped twice and matches nothing");
+}
+
QTEST_MAIN(TestTagRules)
#include "test_tagrules.moc"