aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 20:16:43 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 20:16:43 +0200
commitc9eb6fc86c947aea0075c1e9d279695cd9f9b7ee (patch)
tree7e5339fbb7e71d29498405f9d4b1c4188f5ab4c3 /tests
parentf389db3aad498d46c95c2a95b4280ffb541043b2 (diff)
parent9be1b13b91188cf44a40c6786a83de034988cdbd (diff)
downloadqtmaildir-c9eb6fc86c947aea0075c1e9d279695cd9f9b7ee.tar.gz
qtmaildir-c9eb6fc86c947aea0075c1e9d279695cd9f9b7ee.zip
Merge branch 'saved-queries': saved queries in a file of their own
Items 23 and 82. Saved queries move out of the [queries] INI section into ~/.config/qtmaildir/queries.json, gaining the three things the INI could not express: an order, a pinned flag, and a per-query account scope. They can now be created, edited, reordered, unpinned and deleted from the UI rather than only by hand-editing a config file. The INI could not carry order at all: QSettings reads a section through childKeys(), which sorts alphabetically and never follows the file, so the buttons could not be arranged. Migration reads the old section once, marks every entry pinned so nothing moves on the first launch, and leaves the config file byte-identical, since rewriting it with QSettings would drop the user's comments and key order. Sent stops being a hardcoded button and becomes an ordinary entry carrying "generated": "sent". Its query is still composed from the accounts every time it runs, so correcting a folder name still needs no edit here, but the row now follows one rule instead of having one member the user could not move, rename or remove. Item 82 was found by hand-testing item 23 and fixed on the same branch: saving worked and nothing else did, so a saved query could be created and never changed. It also uncovered a defect that predated it, where rebuilding the row with deleteLater() left the stale row answering findChild(), which was already reachable from the save path. Hand-tested throughout: the migration against the real config, saving, unpinning, deleting, reordering, the read-only query field on a generated entry, and an unpin surviving a restart.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_config.cpp537
-rw-r--r--tests/test_mainwindow.cpp710
2 files changed, 1243 insertions, 4 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index 95398e6..3b094ba 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,23 @@ 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 aGeneratedQueryResolvesFromTheAccounts();
+ void aGeneratedQueryTracksAConfigChange();
+ void anUnknownGeneratorResolvesToNothingAndReports();
+ void migrationAddsSentWhenAnAccountHasOne();
+ void migrationAddsNoSentWithoutTheKey();
+ void aGeneratedEntryWritesNoRedundantKeys();
void generalSectionKeysAreActuallyRead();
void messageZoomDefaultsAndValidates();
void messageZoomOutOfRangeIsReported();
@@ -139,10 +159,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 +1113,514 @@ 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"));
+}
+
+// ---------------------------------------------------------------------------
+// Generated saved queries
+// ---------------------------------------------------------------------------
+
+static QString twoAccountsWithSent()
+{
+ return QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ "sent=Sent\n"
+ "\n"
+ "[account.personal]\n"
+ "name=Test User\n"
+ "address=me@example.net\n"
+ "maildir=personal\n"
+ "sent=[Provider]/Posta inviata\n"
+ );
+}
+
+void TestConfig::aGeneratedQueryResolvesFromTheAccounts()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, twoAccountsWithSent());
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Sent", "generated": "sent", "pinned": true }
+ ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ const SavedQuery sent = config.savedQueries().at(0);
+ QVERIFY(sent.isGenerated());
+ // The stored query is empty; the text comes from the accounts.
+ QVERIFY(sent.query.isEmpty());
+ QCOMPARE(config.resolvedQuery(sent), config.allSentQuery());
+ QVERIFY(config.resolvedQuery(sent).contains(
+ QStringLiteral("path:\"work-mail/Sent/**\"")));
+ // The quotes matter: "[" and "]" are Xapian syntax and an unquoted term
+ // is parsed rather than matched.
+ QVERIFY(config.resolvedQuery(sent).contains(
+ QStringLiteral("path:\"personal/[Provider]/Posta inviata/**\"")));
+
+ // Flat, not threaded: a sent view lists messages, and that property has to
+ // travel with the entry or it is lost the moment Sent is a stored row.
+ QVERIFY(sent.flat);
+}
+
+/// The whole reason Sent is generated rather than stored. A stored copy would
+/// keep naming an account that has been renamed or a folder that has moved.
+void TestConfig::aGeneratedQueryTracksAConfigChange()
+{
+ QTemporaryDir dir;
+ const QString queries = QStringLiteral(R"({
+ "version": 1,
+ "queries": [ { "name": "Sent", "generated": "sent" } ]
+ })");
+
+ const QString before = writeIni(dir, twoAccountsWithSent());
+ writeQueries(dir, queries);
+ Config first;
+ first.load(before);
+ const QString firstResolved = first.resolvedQuery(first.savedQueries().at(0));
+
+ // The user corrects a folder name. Nothing in queries.json changes.
+ QTemporaryDir second;
+ const QString after = writeIni(second, QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ "sent=Sent Items\n"
+ ));
+ writeQueries(second, queries);
+ Config later;
+ later.load(after);
+ const QString laterResolved = later.resolvedQuery(later.savedQueries().at(0));
+
+ QVERIFY(firstResolved != laterResolved);
+ QVERIFY(laterResolved.contains(QStringLiteral("Sent Items")));
+}
+
+void TestConfig::anUnknownGeneratorResolvesToNothingAndReports()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, twoAccountsWithSent());
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [ { "name": "Future", "generated": "not_a_generator" } ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ // Kept rather than dropped: a later build may know this generator, and
+ // silently deleting the row on save would lose it.
+ QCOMPARE(config.savedQueries().size(), 1);
+ QVERIFY(config.resolvedQuery(config.savedQueries().at(0)).isEmpty());
+ QVERIFY2(!config.problems().isEmpty(),
+ "an unknown generator must be reported, not silently inert");
+}
+
+void TestConfig::migrationAddsSentWhenAnAccountHasOne()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, twoAccountsWithSent()
+ + QStringLiteral(
+ "\n[queries]\n"
+ "Inbox=tag:inbox\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ const QList<SavedQuery> queries = config.savedQueries();
+ QCOMPARE(queries.size(), 2);
+ // Last, where the button already sat: after the saved queries.
+ QCOMPARE(queries.at(1).name, QStringLiteral("Sent"));
+ QVERIFY(queries.at(1).isGenerated());
+ QVERIFY(queries.at(1).pinned);
+ QVERIFY(queries.at(1).flat);
+}
+
+/// Today the button is hidden entirely when no account configures a sent
+/// folder, rather than offering one that always finds nothing. The migration
+/// must not invent a row that would do exactly that.
+void TestConfig::migrationAddsNoSentWithoutTheKey()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ "\n"
+ "[queries]\n"
+ "Inbox=tag:inbox\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ const QList<SavedQuery> queries = config.savedQueries();
+ QCOMPARE(queries.size(), 1);
+ QCOMPARE(queries.at(0).name, QStringLiteral("Inbox"));
+}
+
+/// The file is meant to be hand-edited, so a key that carries no information
+/// is a key the reader has to skip past. `query` says nothing on a generated
+/// entry, and `flat` is implied by the sent generator.
+void TestConfig::aGeneratedEntryWritesNoRedundantKeys()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, twoAccountsWithSent());
+ const QString queriesPath = writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Sent", "generated": "sent", "pinned": true },
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true }
+ ]
+ })"));
+
+ Config config;
+ config.load(path);
+ QVERIFY(config.saveSavedQueries());
+
+ QFile f(queriesPath);
+ QVERIFY(f.open(QIODevice::ReadOnly));
+ const QJsonArray array = QJsonDocument::fromJson(f.readAll())
+ .object()
+ .value(QStringLiteral("queries"))
+ .toArray();
+ f.close();
+
+ const QJsonObject sent = array.at(0).toObject();
+ QCOMPARE(sent.value(QStringLiteral("generated")).toString(),
+ QStringLiteral("sent"));
+ QVERIFY2(!sent.contains(QStringLiteral("query")),
+ "a generated entry has no query of its own to store");
+ QVERIFY2(!sent.contains(QStringLiteral("flat")),
+ "the sent generator implies flat; storing it says nothing");
+
+ // The ordinary entry is untouched by any of that.
+ const QJsonObject inbox = array.at(1).toObject();
+ QCOMPARE(inbox.value(QStringLiteral("query")).toString(),
+ QStringLiteral("tag:inbox"));
+
+ // And it all still reads back the same.
+ Config reloaded;
+ reloaded.load(path);
+ QCOMPARE(reloaded.savedQueries().size(), 2);
+ QVERIFY(reloaded.savedQueries().at(0).isGenerated());
+ QVERIFY2(reloaded.savedQueries().at(0).flat,
+ "flat must come back from the generator, not from the file");
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 4c1c5d9..d090016 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -52,6 +52,8 @@
#include <QImage>
#include <QPainter>
+#include <QToolButton>
+#include <QHBoxLayout>
#include <QComboBox>
#include <QScrollBar>
#include "tagchip.h"
@@ -188,6 +190,25 @@ private slots:
void flatModeDoesNotSurviveTheNextQuery();
void noTwoActionsShareAnIcon();
+ void onlyPinnedQueriesBecomeButtons();
+ void unpinnedQueriesReachTheMenu();
+ void pinnedButtonsFollowTheDocumentOrder();
+ void theSavedQueryMenuIsHiddenWhenEveryQueryIsPinned();
+ void aScopedSavedQuerySelectsItsAccount();
+ void anUnscopedSavedQueryClearsTheAccount();
+ void theSaveQueryActionIsDisabledOnAnEmptyQuery();
+ void thereIsASaveButtonBesideTheQueryBar();
+ void theMenuIsRightAlignedAwayFromTheButtons();
+ void theRowSurvivesWithNothingButUnpinnedQueries();
+ void aStoredGeneratedQueryRunsFlatAndComposed();
+ void aRenamedSentEntryKeepsWorking();
+ void aGeneratedQueryWithNothingToShowIsSkipped();
+ void aSavedQueryButtonOffersEditUnpinAndDelete();
+ void unpinningMovesAQueryToTheMenu();
+ void deletingRemovesTheQueryFromTheFile();
+ void anEditedQueryKeepsItsUnknownFields();
+ void renamingReplacesRatherThanDuplicating();
+
private:
/// Owns the throwaway lock table init() points every test at. A pointer
/// rather than a value because it is rebuilt per test, and QTemporaryDir
@@ -5265,4 +5286,693 @@ int main(int argc, char *argv[])
return QTest::qExec(&test, argc, argv);
}
+// ---------------------------------------------------------------------------
+// Saved queries in the query row (item 23)
+// ---------------------------------------------------------------------------
+
+/// Writes a config plus a queries.json beside it, and loads both.
+static void loadWithQueries(Config &config, QTemporaryDir &dir,
+ const QString &queriesJson,
+ const QString &iniExtra = {})
+{
+ QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir")));
+ const QString conf =
+ dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"));
+ QFile ini(conf);
+ ini.open(QIODevice::WriteOnly | QIODevice::Text);
+ ini.write(iniExtra.toUtf8());
+ ini.close();
+
+ QFile json(dir.filePath(QStringLiteral("qtmaildir/queries.json")));
+ json.open(QIODevice::WriteOnly);
+ json.write(queriesJson.toUtf8());
+ json.close();
+
+ config.load(conf);
+}
+
+/// Buttons in the saved-query row, by label, in the order they are laid out.
+static QStringList savedQueryButtonLabels(MainWindow &window)
+{
+ QStringList labels;
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ if (!row)
+ return labels;
+ const QList<QPushButton *> buttons =
+ row->findChildren<QPushButton *>(QString(), Qt::FindDirectChildrenOnly);
+ for (QPushButton *button : buttons) {
+ // The menu button is not a saved query and must not be counted as one.
+ if (button->objectName() != QStringLiteral("savedQueryMenuButton"))
+ labels.append(button->text());
+ }
+ return labels;
+}
+
+void TestMainWindow::onlyPinnedQueriesBecomeButtons()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Buried", "query": "tag:buried" }
+ ]
+ })"));
+
+ MainWindow window(config);
+ const QStringList labels = savedQueryButtonLabels(window);
+
+ QVERIFY2(labels.contains(QStringLiteral("Inbox")),
+ "a pinned query must have a button");
+ QVERIFY2(!labels.contains(QStringLiteral("Buried")),
+ "an unpinned query must NOT have a button");
+}
+
+void TestMainWindow::unpinnedQueriesReachTheMenu()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Buried", "query": "tag:buried" }
+ ]
+ })"));
+
+ MainWindow window(config);
+ auto *menuButton =
+ window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton"));
+ QVERIFY2(menuButton, "an unpinned query needs a menu to live in");
+ QVERIFY(menuButton->menu());
+
+ QStringList entries;
+ const QList<QAction *> actions = menuButton->menu()->actions();
+ for (QAction *action : actions)
+ entries.append(action->text());
+
+ QVERIFY2(entries.contains(QStringLiteral("Buried")),
+ "the unpinned query is missing from the menu");
+ // A pinned query is already a button; listing it twice is the duplicate
+ // this asserts against.
+ QVERIFY2(!entries.contains(QStringLiteral("Inbox")),
+ "a pinned query must not also appear in the menu");
+}
+
+/// The property the whole storage change was made for. "Zebra" is written
+/// first and must stay first; alphabetical order would put it last.
+void TestMainWindow::pinnedButtonsFollowTheDocumentOrder()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Zebra", "query": "tag:zebra", "pinned": true },
+ { "name": "Apple", "query": "tag:apple", "pinned": true }
+ ]
+ })"));
+
+ MainWindow window(config);
+ const QStringList labels = savedQueryButtonLabels(window);
+
+ QCOMPARE(labels.size(), 2);
+ QCOMPARE(labels.at(0), QStringLiteral("Zebra"));
+ QCOMPARE(labels.at(1), QStringLiteral("Apple"));
+}
+
+void TestMainWindow::theSavedQueryMenuIsHiddenWhenEveryQueryIsPinned()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true }
+ ]
+ })"));
+
+ MainWindow window(config);
+
+ // The guard the assertion below needs. Asserting only that the menu button
+ // is absent passed against NO implementation at all, before any of this
+ // was built, so it has to prove first that the row it is looking in was
+ // populated and that a button was found.
+ const QStringList labels = savedQueryButtonLabels(window);
+ QCOMPARE(labels, QStringList{ QStringLiteral("Inbox") });
+
+ auto *menuButton =
+ window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton"));
+ QVERIFY2(!menuButton,
+ "an empty menu button is a control that always does nothing");
+}
+
+/// The scope goes through the account dropdown rather than being baked into
+/// the query text. runQuery() already scopes by that dropdown, so pre-scoping
+/// the text would apply the path twice, and the selection would be invisible.
+void TestMainWindow::aScopedSavedQuerySelectsItsAccount()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Billing", "query": "from:billing",
+ "account": "work", "pinned": true }
+ ]
+ })"), QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ "\n"
+ "[account.personal]\n"
+ "name=Test User\n"
+ "address=me@example.net\n"
+ "maildir=personal\n"
+ ));
+
+ MainWindow window(config);
+ auto *accountBox =
+ window.findChild<QComboBox *>(QStringLiteral("accountBox"));
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(accountBox);
+ QVERIFY(queryEdit);
+
+ // Start somewhere else, so a passing result cannot be the default.
+ accountBox->setCurrentIndex(accountBox->findData(
+ QStringLiteral("personal")));
+ QCOMPARE(accountBox->currentData().toString(), QStringLiteral("personal"));
+
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ auto *button = row->findChild<QPushButton *>();
+ QVERIFY(button);
+ button->click();
+
+ QCOMPARE(accountBox->currentData().toString(), QStringLiteral("work"));
+ // The text is the bare query. The path scope is applied once, by
+ // runQuery(), from the dropdown this just set.
+ QCOMPARE(queryEdit->text(), QStringLiteral("from:billing"));
+ QVERIFY2(!queryEdit->text().contains(QStringLiteral("path:")),
+ "the scope must not be baked into the query text");
+}
+
+/// A query with no account must CLEAR the dropdown, not inherit whatever the
+/// last one left there. Confirmed against the same defect in the rules
+/// preview, where an already-selected account survived the click.
+void TestMainWindow::anUnscopedSavedQueryClearsTheAccount()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Everywhere", "query": "tag:inbox", "pinned": true }
+ ]
+ })"), QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ ));
+
+ MainWindow window(config);
+ auto *accountBox =
+ window.findChild<QComboBox *>(QStringLiteral("accountBox"));
+ QVERIFY(accountBox);
+
+ accountBox->setCurrentIndex(accountBox->findData(QStringLiteral("work")));
+ QCOMPARE(accountBox->currentData().toString(), QStringLiteral("work"));
+
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ auto *button = row->findChild<QPushButton *>();
+ QVERIFY(button);
+ button->click();
+
+ QVERIFY2(accountBox->currentData().toString().isEmpty(),
+ "an unscoped saved query must clear the account selection");
+}
+
+void TestMainWindow::theSaveQueryActionIsDisabledOnAnEmptyQuery()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1, "queries": []
+ })"));
+
+ MainWindow window(config);
+ auto *save = window.findChild<QAction *>(QStringLiteral("save_query"));
+ QVERIFY2(save, "there is no way to save a query");
+
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ queryEdit->clear();
+ QVERIFY2(!save->isEnabled(),
+ "saving an empty query would store a query that matches nothing");
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ QVERIFY2(save->isEnabled(), "a real query must be savable");
+
+ // Whitespace is not a query. setText does not drive a completer, but it
+ // does emit textChanged, which is what the enabling is hung on.
+ queryEdit->setText(QStringLiteral(" "));
+ QVERIFY(!save->isEnabled());
+}
+
+/// A menu entry and a shortcut are not a button. The spec asks for one beside
+/// the query bar, and the user went looking for it there and did not find it.
+void TestMainWindow::thereIsASaveButtonBesideTheQueryBar()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1, "queries": []
+ })"));
+
+ MainWindow window(config);
+ auto *button =
+ window.findChild<QAbstractButton *>(QStringLiteral("saveQueryButton"));
+ QVERIFY2(button, "no Save query button beside the query bar");
+
+ // Icon AND text. An icon alone was the first version and read as
+ // ambiguous: "save" is a familiar shape whose meaning is always "save
+ // what?".
+ auto *toolButton = qobject_cast<QToolButton *>(button);
+ QVERIFY(toolButton);
+ QCOMPARE(toolButton->toolButtonStyle(), Qt::ToolButtonTextBesideIcon);
+ QVERIFY2(!button->icon().isNull(), "the button has no icon");
+ QVERIFY2(!button->text().isEmpty(), "the button has no text");
+
+ // Button phrasing, not the menu's: no accelerator ampersand, and no
+ // ellipsis. setDefaultAction copies the action's text, so this asserts the
+ // override survived it.
+ QVERIFY2(!button->text().contains(QLatin1Char('&')),
+ "the menu accelerator leaked onto the button");
+ QVERIFY2(!button->text().contains(QStringLiteral("...")),
+ "the menu's ellipsis leaked onto the button");
+
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ // In the query row itself, not somewhere else in the window that a
+ // findChild would also reach.
+ QCOMPARE(button->parentWidget(), queryEdit->parentWidget());
+
+ // Follows the action, so it cannot offer to save an empty query while the
+ // menu entry correctly refuses.
+ queryEdit->clear();
+ QVERIFY2(!button->isEnabled(),
+ "the button must follow the action's enabled state");
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ QVERIFY(button->isEnabled());
+}
+
+/// Right-aligned, meaning a stretch sits between the buttons and the menu.
+/// Asserted on the layout rather than on x coordinates: the offscreen platform
+/// lays out widgets, but a geometry assertion here would also pass for a row
+/// that simply ran out of width.
+void TestMainWindow::theMenuIsRightAlignedAwayFromTheButtons()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Buried", "query": "tag:buried" }
+ ]
+ })"));
+
+ MainWindow window(config);
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ auto *box = qobject_cast<QHBoxLayout *>(row->layout());
+ QVERIFY(box);
+
+ auto *menuButton =
+ window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton"));
+ QVERIFY(menuButton);
+
+ int menuIndex = -1;
+ int stretchIndex = -1;
+ for (int i = 0; i < box->count(); ++i) {
+ QLayoutItem *item = box->itemAt(i);
+ if (item->widget() == menuButton)
+ menuIndex = i;
+ else if (!item->widget() && item->spacerItem())
+ stretchIndex = i;
+ }
+
+ QVERIFY2(stretchIndex >= 0, "the row has no stretch to align against");
+ QVERIFY2(menuIndex > stretchIndex,
+ "the menu must come AFTER the stretch to sit at the right edge");
+}
+
+/// The row must not vanish when every saved query is unpinned: the menu is
+/// then the only way to reach any of them, and hiding the row buries it.
+void TestMainWindow::theRowSurvivesWithNothingButUnpinnedQueries()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Buried", "query": "tag:buried" },
+ { "name": "AlsoBuried", "query": "tag:also" }
+ ]
+ })"));
+
+ MainWindow window(config);
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ QVERIFY2(!row->isHidden(),
+ "the row was hidden, so the only route to these queries is gone");
+
+ auto *menuButton =
+ window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton"));
+ QVERIFY(menuButton);
+ QCOMPARE(menuButton->menu()->actions().size(), 2);
+}
+
+static QString oneAccountWithSent()
+{
+ return QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ "sent=Sent\n"
+ );
+}
+
+/// The existing Sent tests reach the generated entry through MIGRATION, since
+/// their configs have no queries.json. This one starts from a stored file, so
+/// it covers the path a user is on from the second launch onwards.
+void TestMainWindow::aStoredGeneratedQueryRunsFlatAndComposed()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Sent", "generated": "sent", "pinned": true }
+ ]
+ })"), oneAccountWithSent());
+
+ MainWindow window(config);
+ auto *button =
+ window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ QVERIFY2(!model->flatMode(), "the model starts threaded");
+
+ button->click();
+
+ // Composed from the account, not read from the file: the entry stores no
+ // query at all.
+ QCOMPARE(queryEdit->text(), config.allSentQuery());
+ QVERIFY(queryEdit->text().contains(
+ QStringLiteral("path:\"work-mail/Sent/**\"")));
+ QVERIFY2(model->flatMode(),
+ "a sent view must be flat, or replies fold back into the thread");
+}
+
+/// The point of the change: Sent is the user's row now. Renaming it must not
+/// break it, which it would if anything keyed on the literal name "Sent".
+void TestMainWindow::aRenamedSentEntryKeepsWorking()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Posta inviata", "generated": "sent", "pinned": true }
+ ]
+ })"), oneAccountWithSent());
+
+ MainWindow window(config);
+ const QStringList labels = savedQueryButtonLabels(window);
+ QCOMPARE(labels, QStringList{ QStringLiteral("Posta inviata") });
+
+ auto *button =
+ window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY2(button, "the generated entry lost its identity when renamed");
+
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ button->click();
+ QCOMPARE(queryEdit->text(), config.allSentQuery());
+}
+
+/// The hardcoded button was hidden entirely when no account configured a sent
+/// folder, rather than offering one that always finds nothing. A stored row
+/// must behave the same way.
+void TestMainWindow::aGeneratedQueryWithNothingToShowIsSkipped()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Sent", "generated": "sent", "pinned": true }
+ ]
+ })"), QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ ));
+
+ MainWindow window(config);
+
+ // The guard: the row was built and the other entry did get a button, so a
+ // missing Sent means it was skipped rather than that nothing was built.
+ QCOMPARE(savedQueryButtonLabels(window),
+ QStringList{ QStringLiteral("Inbox") });
+ QVERIFY2(!window.findChild<QPushButton *>(QStringLiteral("sentButton")),
+ "a generated query with nothing to show must not get a button");
+}
+
+/// Reads queries.json back from disk, which is what "it was saved" means.
+static QJsonArray storedQueries(const QTemporaryDir &dir)
+{
+ QFile f(dir.filePath(QStringLiteral("qtmaildir/queries.json")));
+ if (!f.open(QIODevice::ReadOnly))
+ return {};
+ const QJsonObject root = QJsonDocument::fromJson(f.readAll()).object();
+ return root.value(QStringLiteral("queries")).toArray();
+}
+
+static QAction *contextActionNamed(MainWindow &window, QWidget *target,
+ const QString &objectName)
+{
+ const QList<QAction *> actions = target->actions();
+ for (QAction *action : actions) {
+ if (action->objectName() == objectName)
+ return action;
+ }
+ Q_UNUSED(window);
+ return nullptr;
+}
+
+void TestMainWindow::aSavedQueryButtonOffersEditUnpinAndDelete()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true }
+ ]
+ })"));
+
+ MainWindow window(config);
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ auto *button = row->findChild<QPushButton *>();
+ QVERIFY(button);
+
+ // A context menu, so the actions live on the widget itself.
+ QCOMPARE(button->contextMenuPolicy(), Qt::ActionsContextMenu);
+ QVERIFY(contextActionNamed(window, button, QStringLiteral("editQuery")));
+ QVERIFY(contextActionNamed(window, button, QStringLiteral("pinQuery")));
+ QVERIFY(contextActionNamed(window, button, QStringLiteral("deleteQuery")));
+}
+
+void TestMainWindow::unpinningMovesAQueryToTheMenu()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Other", "query": "tag:other", "pinned": true }
+ ]
+ })"));
+
+ MainWindow window(config);
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ QCOMPARE(savedQueryButtonLabels(window).size(), 2);
+ QVERIFY(!window.findChild<QPushButton *>(
+ QStringLiteral("savedQueryMenuButton")));
+
+ auto *button = row->findChild<QPushButton *>();
+ QVERIFY(button);
+ QAction *pin = contextActionNamed(window, button, QStringLiteral("pinQuery"));
+ QVERIFY(pin);
+ pin->trigger();
+
+ // Off the row, into the menu, and written to the file: an unpin that only
+ // redrew would come back pinned on the next launch.
+ QCOMPARE(savedQueryButtonLabels(window), QStringList{ QStringLiteral("Other") });
+ auto *menuButton =
+ window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton"));
+ QVERIFY(menuButton);
+ QCOMPARE(menuButton->menu()->actions().size(), 1);
+
+ const QJsonArray stored = storedQueries(dir);
+ QCOMPARE(stored.size(), 2);
+ QCOMPARE(stored.at(0).toObject().value(QStringLiteral("name")).toString(),
+ QStringLiteral("Inbox"));
+ QVERIFY2(!stored.at(0).toObject().contains(QStringLiteral("pinned")),
+ "the unpin did not reach the file");
+}
+
+void TestMainWindow::deletingRemovesTheQueryFromTheFile()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Doomed", "query": "tag:doomed", "pinned": true },
+ { "name": "Keeper", "query": "tag:keeper", "pinned": true }
+ ]
+ })"));
+
+ MainWindow window(config);
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ auto *button = row->findChild<QPushButton *>();
+ QVERIFY(button);
+ QCOMPARE(button->text(), QStringLiteral("Doomed"));
+
+ QAction *del =
+ contextActionNamed(window, button, QStringLiteral("deleteQuery"));
+ QVERIFY(del);
+ // Destructive and not on the undo stack, so it confirms. Suppressed here
+ // rather than driven through the modal dialog, which would hang the test.
+ window.setConfirmDeleteForTesting(false);
+ del->trigger();
+
+ QCOMPARE(savedQueryButtonLabels(window),
+ QStringList{ QStringLiteral("Keeper") });
+
+ const QJsonArray stored = storedQueries(dir);
+ QCOMPARE(stored.size(), 1);
+ QCOMPARE(stored.at(0).toObject().value(QStringLiteral("name")).toString(),
+ QStringLiteral("Keeper"));
+}
+
+/// A field a later build wrote must survive an edit here, or upgrading and
+/// downgrading silently strips config the user set.
+void TestMainWindow::anEditedQueryKeepsItsUnknownFields()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true,
+ "icon": "mail-inbox" }
+ ]
+ })"));
+
+ MainWindow window(config);
+
+ // Through the EDIT path, with a replacement carrying no unknown fields of
+ // its own, which is exactly what SaveQueryDialog returns. Driving this
+ // through unpin instead proved nothing: unpin copies the stored entry, so
+ // it carries `unknown` along by itself and the merge is never exercised.
+ // That version passed with the merge deleted.
+ SavedQuery edited;
+ edited.name = QStringLiteral("Inbox");
+ edited.query = QStringLiteral("tag:inbox and not tag:muted");
+ edited.pinned = true;
+ QVERIFY(edited.unknown.isEmpty());
+ window.replaceSavedQueryForTesting(QStringLiteral("Inbox"), edited);
+
+ const QJsonArray stored = storedQueries(dir);
+ QCOMPARE(stored.size(), 1);
+ const QJsonObject entry = stored.at(0).toObject();
+ // The edit landed...
+ QCOMPARE(entry.value(QStringLiteral("query")).toString(),
+ QStringLiteral("tag:inbox and not tag:muted"));
+ // ...and did not take the unknown field down with it.
+ QCOMPARE(entry.value(QStringLiteral("icon")).toString(),
+ QStringLiteral("mail-inbox"));
+}
+
+/// Renaming must match on the name the dialog OPENED with. Matching on the
+/// returned name leaves the original in place and adds a second entry.
+void TestMainWindow::renamingReplacesRatherThanDuplicating()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Old", "query": "tag:old", "pinned": true }
+ ]
+ })"));
+
+ MainWindow window(config);
+
+ SavedQuery renamed;
+ renamed.name = QStringLiteral("New");
+ renamed.query = QStringLiteral("tag:old");
+ renamed.pinned = true;
+ window.replaceSavedQueryForTesting(QStringLiteral("Old"), renamed);
+
+ const QJsonArray stored = storedQueries(dir);
+ QCOMPARE(stored.size(), 1);
+ QCOMPARE(stored.at(0).toObject().value(QStringLiteral("name")).toString(),
+ QStringLiteral("New"));
+ QCOMPARE(savedQueryButtonLabels(window), QStringList{ QStringLiteral("New") });
+}
+
#include "test_mainwindow.moc"