summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
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"