aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/config.cpp185
-rw-r--r--src/config.h65
-rw-r--r--tests/test_config.cpp331
3 files changed, 566 insertions, 15 deletions
diff --git a/src/config.cpp b/src/config.cpp
index b1f730c..8cd5e56 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -24,8 +24,14 @@
#include "messageview.h"
#include <QDateTime>
+#include <QDir>
+#include <QFile>
#include <QFileInfo>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
#include <QLocale>
+#include <QSaveFile>
#include <QSettings>
#include <QStandardPaths>
@@ -37,6 +43,14 @@ namespace {
constexpr int kMinToolbarIconSize = 16;
constexpr int kMaxToolbarIconSize = 64;
+/// queries.json format version. Bump only for a BREAKING change: an optional
+/// field needs no bump, because an older build preserves what it does not
+/// understand rather than dropping it.
+///
+/// Unlike rules.json this file has ONE implementation, so a bump here is not a
+/// two-repo change and no hook stops tagging if it is half-deployed.
+constexpr int kQueriesFormatVersion = 1;
+
} // namespace
QString Account::scopedQuery(const QString &query) const
@@ -397,17 +411,10 @@ void Config::load(const QString &path)
m_accounts.append(account);
}
- settings.beginGroup(QStringLiteral("queries"));
- // QSettings::childKeys() returns keys in alphabetical order, not file
- // order, so the saved-query button order in the UI is alphabetical too.
- // A hand-rolled parser would be needed to preserve file order; not
- // needed in v1.
- for (const QString &name : settings.childKeys())
- m_savedQueries.append({ name, settings.value(name).toString() });
- settings.endGroup();
-
- // Checked here rather than where startup_query is read: [queries] is not
- // parsed until now. Only a name the user actually wrote is worth a
+ loadSavedQueries(path, settings);
+
+ // Checked here rather than where startup_query is read: the saved queries
+ // are not parsed until now. Only a name the user actually wrote is worth a
// problem; the built-in default naming a query they never created is not
// something they got wrong.
if (m_startupQueryWasSet && !m_savedQueries.isEmpty()
@@ -419,6 +426,162 @@ void Config::load(const QString &path)
}
}
+QString Config::queriesPath(const QString &configPath)
+{
+ return QFileInfo(configPath).absolutePath()
+ + QStringLiteral("/queries.json");
+}
+
+void Config::loadSavedQueries(const QString &configPath, QSettings &settings)
+{
+ m_queriesPath = queriesPath(configPath);
+
+ QFile file(m_queriesPath);
+ if (!file.exists()) {
+ // Migration. Read [queries] once, write the JSON, and leave the INI
+ // section alone: stripping it would mean rewriting a hand-edited file
+ // with QSettings, which drops comments and key order across the WHOLE
+ // file. A few stale lines the user can delete by hand is the cheaper
+ // loss, and it keeps a downgrade working.
+ settings.beginGroup(QStringLiteral("queries"));
+ const QStringList names = settings.childKeys();
+ for (const QString &name : names) {
+ SavedQuery query;
+ query.name = name;
+ query.query = settings.value(name).toString();
+ // Pinned, because these are buttons today. A migration that left
+ // them unpinned would empty the query row on the first launch
+ // after an upgrade, which reads as data loss.
+ query.pinned = true;
+ m_savedQueries.append(query);
+ }
+ settings.endGroup();
+
+ // Order is alphabetical here because childKeys() is genuinely all the
+ // INI knows. The user reorders once and it sticks from then on.
+ if (!names.isEmpty() && !saveSavedQueries()) {
+ addProblem(QStringLiteral("Could not write saved queries to %1.")
+ .arg(m_queriesPath));
+ }
+ return;
+ }
+
+ if (!file.open(QIODevice::ReadOnly)) {
+ addProblem(QStringLiteral("Could not read %1: %2.")
+ .arg(m_queriesPath, file.errorString()));
+ m_queriesRefused = true;
+ return;
+ }
+
+ QJsonParseError error;
+ const QJsonDocument document =
+ QJsonDocument::fromJson(file.readAll(), &error);
+ file.close();
+
+ if (error.error != QJsonParseError::NoError || !document.isObject()) {
+ addProblem(QStringLiteral("%1 is not valid JSON: %2.")
+ .arg(m_queriesPath, error.errorString()));
+ m_queriesRefused = true;
+ return;
+ }
+
+ const QJsonObject root = document.object();
+ const int version =
+ root.value(QStringLiteral("version")).toInt(kQueriesFormatVersion);
+ if (version != kQueriesFormatVersion) {
+ // Refused rather than guessed at, and the refusal blocks the save:
+ // rewriting a newer document with this build's reading of it would
+ // destroy whatever the newer build stored.
+ addProblem(QStringLiteral("%1 has format version %2; this build "
+ "understands %3. Saved queries were not "
+ "loaded.")
+ .arg(m_queriesPath)
+ .arg(version)
+ .arg(kQueriesFormatVersion));
+ m_queriesRefused = true;
+ return;
+ }
+
+ for (auto it = root.begin(); it != root.end(); ++it) {
+ if (it.key() != QStringLiteral("version")
+ && it.key() != QStringLiteral("queries"))
+ m_queriesUnknown.insert(it.key(), it.value());
+ }
+
+ const QJsonArray array = root.value(QStringLiteral("queries")).toArray();
+ for (const QJsonValue &value : array) {
+ const QJsonObject object = value.toObject();
+ SavedQuery query;
+ query.name = object.value(QStringLiteral("name")).toString();
+ query.query = object.value(QStringLiteral("query")).toString();
+ query.pinned = object.value(QStringLiteral("pinned")).toBool(false);
+ query.account = object.value(QStringLiteral("account")).toString();
+
+ if (query.name.isEmpty()) {
+ addProblem(QStringLiteral("A saved query in %1 has no name and was "
+ "skipped.").arg(m_queriesPath));
+ continue;
+ }
+
+ for (auto it = object.begin(); it != object.end(); ++it) {
+ static const QStringList known = {
+ QStringLiteral("name"), QStringLiteral("query"),
+ QStringLiteral("pinned"), QStringLiteral("account")
+ };
+ if (!known.contains(it.key()))
+ query.unknown.insert(it.key(), it.value());
+ }
+
+ m_savedQueries.append(query);
+ }
+}
+
+bool Config::saveSavedQueries() const
+{
+ if (m_queriesPath.isEmpty() || m_queriesRefused)
+ return false;
+
+ QJsonArray array;
+ for (const SavedQuery &query : m_savedQueries) {
+ QJsonObject object;
+ object.insert(QStringLiteral("name"), query.name);
+ object.insert(QStringLiteral("query"), query.query);
+ if (query.pinned)
+ object.insert(QStringLiteral("pinned"), true);
+ if (!query.account.isEmpty())
+ object.insert(QStringLiteral("account"), query.account);
+ for (auto it = query.unknown.begin(); it != query.unknown.end(); ++it)
+ object.insert(it.key(), it.value());
+ array.append(object);
+ }
+
+ QJsonObject root = m_queriesUnknown;
+ root.insert(QStringLiteral("version"), kQueriesFormatVersion);
+ root.insert(QStringLiteral("queries"), array);
+
+ QDir().mkpath(QFileInfo(m_queriesPath).absolutePath());
+
+ // QSaveFile writes a temporary and renames on commit, so an interrupted
+ // write cannot leave a half-written file where the queries used to be.
+ QSaveFile file(m_queriesPath);
+ if (!file.open(QIODevice::WriteOnly))
+ return false;
+ file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
+ return file.commit();
+}
+
+QString Config::resolvedQuery(const SavedQuery &query) const
+{
+ if (query.account.isEmpty())
+ return query.query;
+
+ const Account scope = account(query.account);
+ if (!scope.isValid())
+ return query.query;
+
+ return scope.scopedQuery(query.query);
+}
+
SavedQuery Config::startupSavedQuery() const
{
if (m_savedQueries.isEmpty())
diff --git a/src/config.h b/src/config.h
index ea0b055..4211b0c 100644
--- a/src/config.h
+++ b/src/config.h
@@ -19,12 +19,15 @@
#pragma once
#include <QColor>
+#include <QJsonObject>
#include <QList>
#include <QString>
#include <QStringList>
#include "completionentry.h"
+class QSettings;
+
/// One mail account. notmuch has no concept of accounts; it sees a single flat
/// tree. An account is therefore a path prefix within that tree plus an
/// identity.
@@ -96,10 +99,32 @@ struct Account
QString draftsQuery() const;
};
+/// A named query, stored in queries.json.
+///
+/// Stored as an ORDERED array, which is the whole reason the storage moved out
+/// of `[queries]`: QSettings reads a section through childKeys(), which sorts
+/// alphabetically and cannot express the order the buttons appear in.
struct SavedQuery
{
QString name;
QString query;
+
+ /// Renders as a button in the query row; otherwise it lives in the menu.
+ /// The two tiers are the point of the flag: a row that shows every saved
+ /// query does not scale past a handful.
+ bool pinned = false;
+
+ /// Account KEY, the INI group suffix ("work" from [account.work]), and
+ /// empty for a query that spans every account.
+ ///
+ /// A key rather than a maildir path: the path already lives in the account
+ /// section, and storing a second copy here would go stale the moment the
+ /// user edits it. Resolve through Config::resolvedQuery().
+ QString account;
+
+ /// Keys this build does not understand, preserved verbatim so a file
+ /// written by a later version survives a save from this one.
+ QJsonObject unknown;
};
/// Reads ~/.config/qtmaildir/qtmaildir.conf.
@@ -117,7 +142,32 @@ public:
QList<Account> accounts() const { return m_accounts; }
Account account(const QString &key) const;
+
+ /// In document order, which IS the display order. Never sort this.
QList<SavedQuery> savedQueries() const { return m_savedQueries; }
+ void setSavedQueries(const QList<SavedQuery> &queries)
+ {
+ m_savedQueries = queries;
+ }
+
+ /// Path of queries.json, derived from the config file's own directory so a
+ /// test can point load() at a temporary tree and get both files there.
+ static QString queriesPath(const QString &configPath);
+
+ /// Writes queries.json. False when the file could not be written, or when
+ /// the loaded file had a version this build refuses: overwriting a
+ /// newer-format file with a lossy reading of it is the one outcome worth
+ /// preventing outright.
+ bool saveSavedQueries() const;
+
+ /// The query as it should be run: scoped to its account when it names one.
+ ///
+ /// Composes through Account::scopedQuery(), whose parentheses are
+ /// load-bearing. `path:... and a or b` binds as `(path:... and a) or b`, so
+ /// an unparenthesised disjunction escapes its scope and matches every
+ /// account. An account key naming nothing returns the bare query rather
+ /// than a scope built from an empty maildir, which would be path:"/**".
+ QString resolvedQuery(const SavedQuery &query) const;
/// Empty when unset; the caller disables the Sync button in that case.
QString syncCommand() const { return m_syncCommand; }
@@ -254,8 +304,23 @@ private:
/// Records a notice: nothing is wrong, a feature is simply not configured.
void addNotice(const QString &message);
+ /// Reads queries.json, or migrates [queries] when it is absent. Called by
+ /// load(), which has already parsed the INI by then.
+ void loadSavedQueries(const QString &configPath, QSettings &settings);
+
QList<Account> m_accounts;
QList<SavedQuery> m_savedQueries;
+
+ /// Where saveSavedQueries() writes, remembered from load().
+ QString m_queriesPath;
+
+ /// Top-level keys of queries.json this build does not understand.
+ QJsonObject m_queriesUnknown;
+
+ /// Set when the file was refused for its version. Blocks the save, so a
+ /// document from a newer build is never overwritten with less than it held.
+ bool m_queriesRefused = false;
+
QString m_syncCommand;
QString m_syncLog;
int m_toolbarIconSize = 24;
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index 95398e6..8024728 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -19,6 +19,9 @@
#include <QtTest>
#include <QTemporaryDir>
#include <QSettings>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
#include "config.h"
#include "mailsync.h"
@@ -48,6 +51,17 @@ private slots:
void startupQueryDefaultsToUnread();
void startupQueryHonoursTheConfiguredName();
void unknownStartupQueryFallsBackAndReports();
+ void savedQueriesKeepTheirDocumentOrder();
+ void savedQueryFieldsAreRead();
+ void unknownFieldsSurviveARoundTrip();
+ void savedQueriesRoundTripUnchanged();
+ void migrationWritesJsonAndLeavesTheIniByteIdentical();
+ void migrationPinsEveryEntry();
+ void jsonWinsOnceItExists();
+ void malformedQueriesFileIsAProblemNotACrash();
+ void futureVersionIsRefusedAndReported();
+ void startupQueryFallsBackToDocumentOrder();
+ void scopedSavedQueryParenthesisesADisjunction();
void generalSectionKeysAreActuallyRead();
void messageZoomDefaultsAndValidates();
void messageZoomOutOfRangeIsReported();
@@ -139,10 +153,10 @@ void TestConfig::parsesSavedQueries()
const QList<SavedQuery> queries = config.savedQueries();
QCOMPARE(queries.size(), 2);
- // QSettings::childKeys() returns keys alphabetically, not in file order,
- // so the UI button order is alphabetical. This assertion happens to hold
- // either way since "Inbox" < "Unread", but the ordering guarantee is
- // alphabetical, not "follows the file".
+ // Reached through migration since item 23: [queries] is read once, when
+ // queries.json is absent, and childKeys() is alphabetical, so the migrated
+ // order is too. From then on the JSON's own order wins, which is what
+ // savedQueriesKeepTheirDocumentOrder() covers.
QCOMPARE(queries.at(0).name, QStringLiteral("Inbox"));
QCOMPARE(queries.at(0).query, QStringLiteral("tag:inbox"));
}
@@ -1093,5 +1107,314 @@ void TestConfig::allDraftsQueryIsIndependentOfSent()
QVERIFY(!sent.contains(QStringLiteral("provider-b")));
}
+// ---------------------------------------------------------------------------
+// queries.json (item 23)
+// ---------------------------------------------------------------------------
+
+static QString writeQueries(const QTemporaryDir &dir, const QString &body)
+{
+ const QString path = dir.filePath(QStringLiteral("queries.json"));
+ QFile f(path);
+ f.open(QIODevice::WriteOnly);
+ f.write(body.toUtf8());
+ f.close();
+ return path;
+}
+
+/// The property the INI could not provide. "Zebra" is written first and must
+/// STAY first: alphabetical order would put it last, so this fails against any
+/// implementation that sorts, including the one being replaced.
+void TestConfig::savedQueriesKeepTheirDocumentOrder()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QString());
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Zebra", "query": "tag:zebra" },
+ { "name": "Apple", "query": "tag:apple" },
+ { "name": "Middle", "query": "tag:middle" }
+ ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ const QList<SavedQuery> queries = config.savedQueries();
+ QCOMPARE(queries.size(), 3);
+ QCOMPARE(queries.at(0).name, QStringLiteral("Zebra"));
+ QCOMPARE(queries.at(1).name, QStringLiteral("Apple"));
+ QCOMPARE(queries.at(2).name, QStringLiteral("Middle"));
+}
+
+void TestConfig::savedQueryFieldsAreRead()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QString());
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Billing", "query": "from:billing", "account": "work" }
+ ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ const QList<SavedQuery> queries = config.savedQueries();
+ QCOMPARE(queries.size(), 2);
+
+ QCOMPARE(queries.at(0).name, QStringLiteral("Inbox"));
+ QCOMPARE(queries.at(0).query, QStringLiteral("tag:inbox"));
+ QVERIFY(queries.at(0).pinned);
+ QVERIFY(queries.at(0).account.isEmpty());
+
+ // pinned defaults to false, which is what puts a query in the menu rather
+ // than on the row.
+ QVERIFY(!queries.at(1).pinned);
+ QCOMPARE(queries.at(1).account, QStringLiteral("work"));
+}
+
+/// A field written by a later build must survive an older build's save, or a
+/// downgrade silently strips config the user set.
+void TestConfig::unknownFieldsSurviveARoundTrip()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QString());
+ const QString queriesPath = writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "colour_scheme": "solarized",
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "icon": "mail-inbox" }
+ ]
+ })"));
+
+ Config config;
+ config.load(path);
+ QVERIFY(config.saveSavedQueries());
+
+ QFile f(queriesPath);
+ QVERIFY(f.open(QIODevice::ReadOnly));
+ const QJsonObject root = QJsonDocument::fromJson(f.readAll()).object();
+ f.close();
+
+ QCOMPARE(root.value(QStringLiteral("colour_scheme")).toString(),
+ QStringLiteral("solarized"));
+ const QJsonObject entry =
+ root.value(QStringLiteral("queries")).toArray().at(0).toObject();
+ QCOMPARE(entry.value(QStringLiteral("icon")).toString(),
+ QStringLiteral("mail-inbox"));
+}
+
+void TestConfig::savedQueriesRoundTripUnchanged()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QString());
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Zebra", "query": "tag:zebra", "pinned": true },
+ { "name": "Apple", "query": "from:a@example.org", "account": "work" }
+ ]
+ })"));
+
+ Config first;
+ first.load(path);
+ QVERIFY(first.saveSavedQueries());
+
+ Config second;
+ second.load(path);
+
+ const QList<SavedQuery> a = first.savedQueries();
+ const QList<SavedQuery> b = second.savedQueries();
+ QCOMPARE(b.size(), a.size());
+ for (int i = 0; i < a.size(); ++i) {
+ QCOMPARE(b.at(i).name, a.at(i).name);
+ QCOMPARE(b.at(i).query, a.at(i).query);
+ QCOMPARE(b.at(i).pinned, a.at(i).pinned);
+ QCOMPARE(b.at(i).account, a.at(i).account);
+ }
+}
+
+/// Asserts the INI is byte-identical, NOT that it still parses. Re-reading it
+/// through QSettings would pass against a rewrite that kept every value while
+/// dropping the comments and key order, which is the loss this design exists
+/// to avoid.
+void TestConfig::migrationWritesJsonAndLeavesTheIniByteIdentical()
+{
+ QTemporaryDir dir;
+ const QString ini = QStringLiteral(
+ "; a comment the user wrote and expects to keep\n"
+ "[queries]\n"
+ "Unread=tag:unread\n"
+ "Inbox=tag:inbox\n"
+ "\n"
+ "[general]\n"
+ "startup_query=Unread\n"
+ );
+ const QString path = writeIni(dir, ini);
+
+ QFile before(path);
+ QVERIFY(before.open(QIODevice::ReadOnly));
+ const QByteArray originalBytes = before.readAll();
+ before.close();
+
+ Config config;
+ config.load(path);
+
+ const QString queriesPath = dir.filePath(QStringLiteral("queries.json"));
+ QVERIFY2(QFile::exists(queriesPath), "migration did not write queries.json");
+
+ QFile after(path);
+ QVERIFY(after.open(QIODevice::ReadOnly));
+ const QByteArray afterBytes = after.readAll();
+ after.close();
+
+ QCOMPARE(afterBytes, originalBytes);
+
+ // The [queries] section is left in place, so an older build still works.
+ QVERIFY(afterBytes.contains("[queries]"));
+ QVERIFY(afterBytes.contains("; a comment the user wrote"));
+}
+
+/// A migrated query that was not pinned would vanish from the query row, which
+/// on the first launch after an upgrade looks like data loss.
+void TestConfig::migrationPinsEveryEntry()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[queries]\n"
+ "Inbox=tag:inbox\n"
+ "Unread=tag:unread\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ const QList<SavedQuery> queries = config.savedQueries();
+ QCOMPARE(queries.size(), 2);
+ for (const SavedQuery &query : queries)
+ QVERIFY2(query.pinned, qPrintable(
+ QStringLiteral("migrated query '%1' is not pinned").arg(query.name)));
+}
+
+/// Once the JSON exists, [queries] is dead. Two sources of truth was the
+/// option this design rejected.
+void TestConfig::jsonWinsOnceItExists()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[queries]\n"
+ "FromTheIni=tag:ini\n"
+ ));
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [ { "name": "FromTheJson", "query": "tag:json" } ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ const QList<SavedQuery> queries = config.savedQueries();
+ QCOMPARE(queries.size(), 1);
+ QCOMPARE(queries.at(0).name, QStringLiteral("FromTheJson"));
+}
+
+void TestConfig::malformedQueriesFileIsAProblemNotACrash()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QString());
+ writeQueries(dir, QStringLiteral("{ this is not json at all"));
+
+ Config config;
+ config.load(path);
+
+ QVERIFY(config.savedQueries().isEmpty());
+ QVERIFY2(!config.problems().isEmpty(),
+ "a malformed queries.json must be reported");
+}
+
+/// Refusing an unknown version is the same contract rules.json keeps: a file
+/// from a newer build is not silently reinterpreted, and above all is not
+/// overwritten with a lossy reading of itself.
+void TestConfig::futureVersionIsRefusedAndReported()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QString());
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 99,
+ "queries": [ { "name": "Inbox", "query": "tag:inbox" } ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ QVERIFY(config.savedQueries().isEmpty());
+ QVERIFY(!config.problems().isEmpty());
+}
+
+/// The fallback stops meaning "alphabetically first" and starts meaning "first
+/// in the user's own order". "Zebra" first proves it: alphabetical would pick
+/// "Apple".
+void TestConfig::startupQueryFallsBackToDocumentOrder()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "startup_query=NoSuchQuery\n"
+ ));
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Zebra", "query": "tag:zebra" },
+ { "name": "Apple", "query": "tag:apple" }
+ ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ QCOMPARE(config.startupSavedQuery().name, QStringLiteral("Zebra"));
+}
+
+/// The parentheses are load-bearing. Without them `path:... and a or b` binds
+/// as `(path:... and a) or b`, so a query saved with a disjunction escapes its
+/// account scope and matches every account.
+void TestConfig::scopedSavedQueryParenthesisesADisjunction()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ ));
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Either",
+ "query": "from:a@example.org or from:b@example.org",
+ "account": "work" }
+ ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ const QString scoped = config.resolvedQuery(config.savedQueries().at(0));
+ QCOMPARE(scoped, QStringLiteral(
+ "path:\"work-mail/**\" and "
+ "(from:a@example.org or from:b@example.org)"));
+
+ // An account key naming nothing resolves to the bare query rather than a
+ // scope built from an empty maildir, which would be path:"/**".
+ SavedQuery orphan;
+ orphan.name = QStringLiteral("Orphan");
+ orphan.query = QStringLiteral("tag:inbox");
+ orphan.account = QStringLiteral("deleted-account");
+ QCOMPARE(config.resolvedQuery(orphan), QStringLiteral("tag:inbox"));
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"