diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 77 | ||||
| -rw-r--r-- | tests/test_rulequery.cpp | 415 | ||||
| -rw-r--r-- | tests/test_tagrules.cpp | 700 |
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" |
