aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-15 10:50:59 +0200
committerDanilo M. <danix@danix.xyz>2026-08-15 10:50:59 +0200
commitde0d3aaa63be2867d127304d55d34259eb6e30d6 (patch)
tree3911cb7563d36aad65de8e6afb6c845555c023c9 /tests
parente6e18849bd5aa49dbdd299982d39a92e7360d0aa (diff)
downloadqtmaildir-de0d3aaa63be2867d127304d55d34259eb6e30d6.tar.gz
qtmaildir-de0d3aaa63be2867d127304d55d34259eb6e30d6.zip
feat(filters): resolve built-in filters per account
Item 93, the Config half. Four built-in filters, Unread, Inbox, Flagged and Sent, as generated entries in kQueryGenerators, which was already a closed set validated on load for Sent alone. resolvedQuery() gains an overload taking an account key, and that is what makes a filter compose with the account dropdown instead of fighting it. A generator is asked for the account's OWN query rather than having its all-accounts query wrapped in a scope: wrapping gives path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**") which returns the right rows only because path: is hierarchical, so a row-count test passes against it. The tests assert on the query string for that reason, and the mutation putting the wrap back fails two of them. An ordinary saved query ignores the account key and keeps resolving through its own stored account, which is the behaviour item 90 leaves alone. matchNothingQuery() exists because an empty query means "match everything" to notmuch: an account configuring no sent folder would otherwise give a button labelled Sent that shows the entire Maildir. Config gains Q_DECLARE_TR_FUNCTIONS for the filter names, which are button labels. The generator names are not translated: they are matched against the closed set and stored in queries.json, so translating them would make a file written in one locale unreadable in another. No UI yet, and no migration: the query row still builds from pinned saved queries.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_config.cpp136
1 files changed, 136 insertions, 0 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index 3b094ba..895495d 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -95,6 +95,12 @@ private slots:
void allSentQueryIsEmptyWhenNoAccountHasOne();
void allSentQuerySkipsAccountsWithoutTheKey();
void allSentQueryJoinsEveryConfiguredAccount();
+ void everyBuiltinFilterIsAKnownGenerator();
+ void aFilterAcrossAllAccountsIsTheUnscopedQuery();
+ void aTagFilterScopedToAnAccountCarriesThatAccountsPath();
+ void sentScopedToAnAccountIsThatAccountsSentFolderAlone();
+ void sentScopedToAnAccountWithNoSentFolderMatchesNothing();
+ void aFilterKeepsItsViewMode();
void draftsQueryIsEmptyWithoutTheKey();
void draftsQuerySurvivesABracketedPath();
void allDraftsQuerySkipsAccountsWithoutTheKey();
@@ -1020,6 +1026,136 @@ void TestConfig::allSentQueryJoinsEveryConfiguredAccount()
QCOMPARE(all.count(QStringLiteral(" or ")), 1);
}
+/// Two accounts, one with a sent folder and one without. The second is the
+/// case that matters most: folderQuery() returns empty for an unset folder and
+/// an empty query means "match everything" to notmuch, so a filter that falls
+/// back to it silently shows the whole Maildir.
+static QString writeTwoAccounts(const QTemporaryDir &dir)
+{
+ return writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "sent=Sent\n"
+ "\n"
+ "[account.personal]\n"
+ "maildir=personal\n"));
+}
+
+void TestConfig::everyBuiltinFilterIsAKnownGenerator()
+{
+ // The guard for every case below. A filter whose generator is not in the
+ // closed set loads with a reported problem and resolves to an empty query,
+ // which means "match everything": the assertions that follow would then be
+ // measuring a typo rather than the design.
+ Config config;
+ const QList<SavedQuery> filters = config.builtinFilters();
+
+ QCOMPARE(filters.size(), 4);
+
+ QStringList names;
+ for (const SavedQuery &filter : filters) {
+ QVERIFY2(filter.isGenerated(),
+ qPrintable(QStringLiteral("filter '%1' stores a query instead "
+ "of naming a generator")
+ .arg(filter.name)));
+ QVERIFY2(Config::isKnownGenerator(filter.generated),
+ qPrintable(QStringLiteral("filter '%1' names the unknown "
+ "generator '%2'")
+ .arg(filter.name, filter.generated)));
+ names.append(filter.name);
+ }
+
+ // The order is the row's order, left to right, and is fixed rather than
+ // configurable: item 94 removes the mixed row entirely, so a settings
+ // surface for this would be built and deleted inside two items.
+ QCOMPARE(names, (QStringList{ QStringLiteral("Unread"),
+ QStringLiteral("Inbox"),
+ QStringLiteral("Flagged"),
+ QStringLiteral("Sent") }));
+}
+
+void TestConfig::aFilterAcrossAllAccountsIsTheUnscopedQuery()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeTwoAccounts(dir));
+
+ // An empty account key is "All accounts", which is what the dropdown holds
+ // by default.
+ const SavedQuery unread = config.builtinFilter(QStringLiteral("unread"));
+ QCOMPARE(config.resolvedQuery(unread, QString()),
+ QStringLiteral("tag:unread"));
+}
+
+void TestConfig::aTagFilterScopedToAnAccountCarriesThatAccountsPath()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeTwoAccounts(dir));
+
+ // A tag filter has no path of its own, so scoping it is exactly what
+ // Account::scopedQuery() does and nothing more is needed.
+ const SavedQuery unread = config.builtinFilter(QStringLiteral("unread"));
+ QCOMPARE(config.resolvedQuery(unread, QStringLiteral("work")),
+ QStringLiteral("path:\"work/**\" and (tag:unread)"));
+}
+
+void TestConfig::sentScopedToAnAccountIsThatAccountsSentFolderAlone()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeTwoAccounts(dir));
+
+ const SavedQuery sent = config.builtinFilter(QStringLiteral("sent"));
+ const QString scoped = config.resolvedQuery(sent, QStringLiteral("work"));
+
+ // The whole point of a per-account generator. Wrapping the all-accounts
+ // query instead would give
+ // path:"work/**" and (path:"work/Sent/**" or path:"personal/Sent/**")
+ // which returns the RIGHT ROWS, because path: is hierarchical and the
+ // personal half cannot match inside work. It is still wrong to build: it
+ // double-scopes and works by accident of the path syntax rather than by
+ // saying what is meant. A row-count assertion passes against it, which is
+ // why this asserts on the string.
+ QCOMPARE(scoped, QStringLiteral("path:\"work/Sent/**\""));
+ QVERIFY2(!scoped.contains(QStringLiteral("personal")),
+ "another account's sent folder leaked into a scoped Sent filter");
+ QCOMPARE(scoped.count(QStringLiteral("path:")), 1);
+}
+
+void TestConfig::sentScopedToAnAccountWithNoSentFolderMatchesNothing()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeTwoAccounts(dir));
+
+ // `personal` configures no sent folder, so folderQuery() gives an empty
+ // string. Returned as-is that is "match everything" to notmuch, so Sent
+ // under this account would show the entire Maildir: the worst possible
+ // answer for a button labelled Sent.
+ const SavedQuery sent = config.builtinFilter(QStringLiteral("sent"));
+ const QString scoped =
+ config.resolvedQuery(sent, QStringLiteral("personal"));
+
+ QVERIFY2(!scoped.isEmpty(),
+ "an account with no sent folder resolved to an empty query, "
+ "which notmuch reads as 'match everything'");
+ QCOMPARE(scoped, Config::matchNothingQuery());
+}
+
+void TestConfig::aFilterKeepsItsViewMode()
+{
+ Config config;
+
+ // Sent lists MESSAGES, the other three list threads. Not a detail to
+ // unify: a thread would fold the user's sent message back into the
+ // conversation it belongs to, which is item 63's finding.
+ QVERIFY(config.builtinFilter(QStringLiteral("sent")).flat);
+ QVERIFY(!config.builtinFilter(QStringLiteral("unread")).flat);
+ QVERIFY(!config.builtinFilter(QStringLiteral("inbox")).flat);
+ QVERIFY(!config.builtinFilter(QStringLiteral("flagged")).flat);
+}
+
void TestConfig::draftsQueryIsEmptyWithoutTheKey()
{
// Optional for the same reason `sent` is, and more often absent: an