summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 17:07:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 17:07:16 +0200
commit4d84062e48ec35598b7c2ecdf8446939be6064c4 (patch)
treeab280755094321666d29d50694c48722809d953c /tests
parentdf6461fa9136f2110c8a24567a3e4a32432a9476 (diff)
parentb8de7ce746718c7193e40b0f70cd3e2178f4ed8a (diff)
downloadqtmaildir-4d84062e48ec35598b7c2ecdf8446939be6064c4.tar.gz
qtmaildir-4d84062e48ec35598b7c2ecdf8446939be6064c4.zip
Merge branch 'rule-builder': a row builder for the tagging rules
Item 76 replaces the four free-text fields with a row builder: field and operator dropdowns per condition, +/- to add and remove them, a match all/any choice, and a separate "but not" block. The stored format does not change, so mailctl needs no edit. The query string stays authoritative and remains visible, and a rule the builder cannot represent opens in a text mode every rule carries. Along the way, items 75, 77 and 80, and a data-loss defect released in 0.16.0 (item 79): opening the dialog and pressing Save destroyed the first rule with nothing edited. That one damaged a real rule in the user's own file, which was repaired by hand. Four defects in this work were found by hand rather than by the suite, and each is recorded where it was missed: a lost note, a one-way text mode toggle, a geometry save on a path neither button takes, and a rule list squeezed to one row by a long rule. Two Qt traps and one about the user's compositor went into CLAUDE.md.
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"