aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 20:07:31 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 20:07:31 +0200
commita7872cf56b7f313881f0d5e50d548ffdb5ba68b9 (patch)
tree569a295cc9332b13d19543faf8342c2a461db806 /tests
parent97c81f8cad571ce9ce724ddab8269e911df05a7c (diff)
downloadqtmaildir-a7872cf56b7f313881f0d5e50d548ffdb5ba68b9.tar.gz
qtmaildir-a7872cf56b7f313881f0d5e50d548ffdb5ba68b9.zip
feat(queries): edit, pin and delete a saved query from the UI
Item 82. Saving a query worked and nothing else did: changing one field meant retyping the whole query under the same name, and deleting one meant editing the file by hand. An action that creates something the UI cannot then change or remove is incomplete, and the user hit it within minutes of the first hand test. Right-clicking a saved query, on its button or its menu entry, now offers Edit, Move to menu / Show as a button, and Delete. Every path funnels through one replaceSavedQuery(), which matches on the name the dialog was OPENED with rather than the one it returns, so a rename replaces the entry instead of leaving the original behind beside a new one, and which merges the stored entry's unknown fields in a single place rather than in three. Delete confirms first: the rule against confirmation dialogs covers tag mutations, which the undo stack can take back, and this writes user config that it cannot. Two cases the item did not anticipate. A generated entry has no query to edit, so the dialog shows its composed query read-only rather than offering a field that changes nothing, and carries `generated` and `flat` through an edit rather than letting it decay into a plain entry holding a snapshot of what it resolved to today. And the overwrite notice had to learn to ignore the entry being edited, since warning that "Inbox" already exists while editing Inbox is noise. This also fixes a defect that predated it and was already reachable from the save path. rebuildSavedQueryRow() called deleteLater() on the old row, which defers destruction to the event loop, so the stale row went on answering findChild() and every lookup after a rebuild reported the state from before the edit. Nothing looked wrong on screen, which is why it surfaced only as three tests failing against a row that had in fact been rebuilt correctly. Five tests, three mutations. Matching on the returned name fails two, never writing the file fails three, and dropping the unknown-field merge fails one. That last one initially proved nothing: it drove UNPIN, which copies the stored entry and so carries `unknown` along by itself, and passed with the merge deleted. It now goes through the edit path with a replacement that has none, which is what the dialog actually returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp200
1 files changed, 200 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 883e9a7..d090016 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -203,6 +203,11 @@ private slots:
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
@@ -5775,4 +5780,199 @@ void TestMainWindow::aGeneratedQueryWithNothingToShowIsSkipped()
"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"