summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_config.cpp73
-rw-r--r--tests/test_mainwindow.cpp348
2 files changed, 390 insertions, 31 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index 895495d..6c1815c 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -95,6 +95,7 @@ private slots:
void allSentQueryIsEmptyWhenNoAccountHasOne();
void allSentQuerySkipsAccountsWithoutTheKey();
void allSentQueryJoinsEveryConfiguredAccount();
+ void aStoredGeneratedQueryIsUnpinnedNotDropped();
void everyBuiltinFilterIsAKnownGenerator();
void aFilterAcrossAllAccountsIsTheUnscopedQuery();
void aTagFilterScopedToAnAccountCarriesThatAccountsPath();
@@ -1463,6 +1464,55 @@ void TestConfig::jsonWinsOnceItExists()
QCOMPARE(queries.at(0).name, QStringLiteral("FromTheJson"));
}
+void TestConfig::aStoredGeneratedQueryIsUnpinnedNotDropped()
+{
+ // An existing install carries a Sent entry in queries.json: 0.19.0 migrated
+ // the hardcoded button into one. Item 93 ships Sent as a built-in filter,
+ // so that stored entry is now a DUPLICATE and would put two Sent buttons on
+ // the row, one editable and one not.
+ //
+ // Unpinned rather than deleted. This file's whole design is that a reader
+ // preserves what it does not own, and the user's instruction for their own
+ // redundant queries was the same: fold them into the menu, do not drop
+ // them. An unpin is reversible from the UI; a delete is not.
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "sent=Sent\n"));
+ writeQueries(dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Sent", "generated": "sent", "pinned": true },
+ { "name": "Mine", "query": "tag:todo", "pinned": true }
+ ]
+ })"));
+
+ Config config;
+ config.load(path);
+
+ const QList<SavedQuery> queries = config.savedQueries();
+ QCOMPARE(queries.size(), 2);
+
+ bool sawSent = false;
+ for (const SavedQuery &query : queries) {
+ if (query.generated != QStringLiteral("sent"))
+ continue;
+ sawSent = true;
+ QVERIFY2(!query.pinned,
+ "the stored Sent entry is still a button beside the built-in "
+ "filter of the same name");
+ }
+ QVERIFY2(sawSent, "the stored Sent entry was DROPPED rather than unpinned");
+
+ // The user's own query is untouched: only the entry duplicating a built-in
+ // filter is unpinned.
+ for (const SavedQuery &query : queries) {
+ if (query.name == QStringLiteral("Mine"))
+ QVERIFY2(query.pinned, "an unrelated pinned query was unpinned");
+ }
+}
+
void TestConfig::malformedQueriesFileIsAProblemNotACrash()
{
QTemporaryDir dir;
@@ -1676,13 +1726,24 @@ void TestConfig::migrationAddsSentWhenAnAccountHasOne()
Config config;
config.load(path);
+ // The migration used to invent a generated Sent entry here, so the
+ // hardcoded button could be reordered, renamed or removed like any other
+ // row. Item 93 ships Sent as one of four BUILT-IN filters instead, so
+ // migrating one as well would put two Sent buttons on the row: one the
+ // user's to edit and one not.
+ //
+ // Nothing is lost. The built-in resolves through the same generator, so it
+ // still follows the accounts, and it now composes with the account dropdown
+ // rather than resetting it.
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);
+ QCOMPARE(queries.size(), 1);
+ QCOMPARE(queries.at(0).name, QStringLiteral("Inbox"));
+
+ for (const SavedQuery &query : queries) {
+ QVERIFY2(!query.isGenerated(),
+ "the migration invented a generated entry that now duplicates "
+ "a built-in filter");
+ }
}
/// Today the button is hidden entirely when no account configures a sent
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 228a0bc..4cc9954 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -169,6 +169,13 @@ private slots:
void narrowingAnEmptyQueryBarIsAPlainSearch();
void aMalformedAccountIsReportedWithoutBlockingTheConstructor();
void aWorkerBackedWindowReturnsRealThreads();
+ void theFourBuiltinFiltersAreOnTheRowInOrder();
+ void aFilterComposesWithTheSelectedAccount();
+ void aFilterAcrossAllAccountsIsUnscoped();
+ void aFilterDoesNotClearTheAccountSelection();
+ void aSavedQueryStillClearsTheAccountSelection();
+ void aFilterOffersNoEditOrDeleteActions();
+ void changingTheAccountRunsNothing();
void arrivingBatchesUpdateTheStatusBarWithTheCountSoFar();
void aRefreshsBatchesLeaveTheStatusBarAlone();
void selectingAThreadRootShowsItInTheMessagePane();
@@ -1596,7 +1603,11 @@ void TestMainWindow::replyRowsKeepTheirTextUnderTheThreadLine()
reply.depth = 1;
model->setThreadMessages(QStringLiteral("t1"), { first, reply });
- window.resize(1400, 300);
+ // 600, not 300. The query row carries four built-in filter buttons since
+ // item 93, and at 300 the reply row was pushed below the viewport: the
+ // pixel loop then ran zero times and reported "0 pixels, the row was
+ // painted over", which is a different defect from the one that existed.
+ window.resize(1400, 600);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
@@ -1609,6 +1620,21 @@ void TestMainWindow::replyRowsKeepTheirTextUnderTheThreadLine()
const QRect rect = view->visualRect(child);
QVERIFY2(rect.height() > 0, "the reply row is not on screen");
+ // visualRect reports a height for a row scrolled out of the viewport, so
+ // the check above passes while the loop below has nothing to walk. Assert
+ // the rect is really inside the image, or this measures nothing and says
+ // the row was painted over.
+ QVERIFY2(rect.top() >= 0
+ && rect.bottom() < view->viewport()->height()
+ && rect.left() >= 0
+ && rect.right() <= view->viewport()->width(),
+ qPrintable(QStringLiteral("the reply row at %1..%2 is outside the "
+ "%3px viewport, so the pixel count "
+ "below would measure nothing")
+ .arg(rect.top())
+ .arg(rect.bottom())
+ .arg(view->viewport()->height())));
+
QImage shot(view->viewport()->size(), QImage::Format_ARGB32);
shot.fill(Qt::transparent);
view->viewport()->render(&shot);
@@ -5464,6 +5490,19 @@ static void loadWithQueries(Config &config, QTemporaryDir &dir,
}
/// Buttons in the saved-query row, by label, in the order they are laid out.
+/// Whether this is one of the four shipped filters rather than a saved query.
+///
+/// By object name, not by label: the labels are translated, and a user may name
+/// their own query "Unread" too.
+static bool isBuiltinFilterButton(QPushButton *button)
+{
+ for (const SavedQuery &filter : Config::builtinFilters()) {
+ if (button->objectName() == filter.generated + QStringLiteral("Button"))
+ return true;
+ }
+ return false;
+}
+
static QStringList savedQueryButtonLabels(MainWindow &window)
{
QStringList labels;
@@ -5474,12 +5513,40 @@ static QStringList savedQueryButtonLabels(MainWindow &window)
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());
+ if (button->objectName() == QStringLiteral("savedQueryMenuButton"))
+ continue;
+ // Neither are the four built-in filters (item 93), which sit first on
+ // the row and are not in queries.json at all. Every caller of this
+ // helper is asking about the USER's queries, so counting the filters
+ // would make each of them assert on a number it does not care about.
+ if (isBuiltinFilterButton(button))
+ continue;
+ labels.append(button->text());
}
return labels;
}
+/// The user's own pinned button carrying `label`, or null.
+///
+/// Positional lookup does not work any more: the built-in filters occupy the
+/// first four places on the row, so row->findChild<QPushButton *>() returns
+/// Unread rather than the query a test means.
+static QPushButton *savedQueryButton(MainWindow &window, const QString &label)
+{
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ if (!row)
+ return nullptr;
+ const QList<QPushButton *> buttons =
+ row->findChildren<QPushButton *>(QString(), Qt::FindDirectChildrenOnly);
+ for (QPushButton *button : buttons) {
+ if (isBuiltinFilterButton(button))
+ continue;
+ if (button->text() == label)
+ return button;
+ }
+ return nullptr;
+}
+
void TestMainWindow::onlyPinnedQueriesBecomeButtons()
{
QTemporaryDir dir;
@@ -5625,7 +5692,7 @@ void TestMainWindow::aScopedSavedQuerySelectsItsAccount()
auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
QVERIFY(row);
- auto *button = row->findChild<QPushButton *>();
+ auto *button = savedQueryButton(window, QStringLiteral("Billing"));
QVERIFY(button);
button->click();
@@ -5667,7 +5734,7 @@ void TestMainWindow::anUnscopedSavedQueryClearsTheAccount()
auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
QVERIFY(row);
- auto *button = row->findChild<QPushButton *>();
+ auto *button = savedQueryButton(window, QStringLiteral("Everywhere"));
QVERIFY(button);
button->click();
@@ -5888,13 +5955,31 @@ void TestMainWindow::aRenamedSentEntryKeepsWorking()
})"), oneAccountWithSent());
MainWindow window(config);
- const QStringList labels = savedQueryButtonLabels(window);
- QCOMPARE(labels, QStringList{ QStringLiteral("Posta inviata") });
+ // The renamed entry is UNPINNED on load now, because item 93 ships Sent as
+ // a built-in filter and two Sent buttons on the row, one editable and one
+ // not, is worse than one of each in its own place. It keeps its name, it
+ // keeps working, and it is in the menu rather than on the row.
+ QVERIFY2(savedQueryButtonLabels(window).isEmpty(),
+ "a stored generated entry is still a button beside the built-in "
+ "filter that duplicates it");
+
+ bool found = false;
+ for (const SavedQuery &saved : config.savedQueries()) {
+ if (saved.name != QStringLiteral("Posta inviata"))
+ continue;
+ found = true;
+ QVERIFY2(saved.isGenerated(),
+ "the entry lost its generator when renamed");
+ QVERIFY2(!saved.pinned, "the entry was not unpinned");
+ }
+ QVERIFY2(found, "the renamed entry was DROPPED rather than unpinned");
+
+ // The built-in Sent still resolves the same query, so nothing the user
+ // could reach before became unreachable.
auto *button =
window.findChild<QPushButton *>(QStringLiteral("sentButton"));
- QVERIFY2(button, "the generated entry lost its identity when renamed");
-
+ QVERIFY(button);
auto *queryEdit =
window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
button->click();
@@ -5969,7 +6054,7 @@ void TestMainWindow::aSavedQueryButtonOffersEditUnpinAndDelete()
MainWindow window(config);
auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
QVERIFY(row);
- auto *button = row->findChild<QPushButton *>();
+ auto *button = savedQueryButton(window, QStringLiteral("Inbox"));
QVERIFY(button);
// A context menu, so the actions live on the widget itself.
@@ -6017,14 +6102,11 @@ void TestMainWindow::onlyAStoredQueryOffersToBecomeATaggingRule()
auto *generated = row->findChild<QPushButton *>(
QStringLiteral("sentButton"));
- QPushButton *stored = nullptr;
- const QList<QPushButton *> buttons = row->findChildren<QPushButton *>();
- for (QPushButton *button : buttons) {
- if (button->text().contains(QStringLiteral("Inbox"))) {
- stored = button;
- break;
- }
- }
+ // savedQueryButton(), not a scan for the label: item 93 puts a BUILT-IN
+ // Inbox filter on the row too, and it carries no context actions by design,
+ // so a scan finds that one and the assertion below fails against correct
+ // code.
+ QPushButton *stored = savedQueryButton(window, QStringLiteral("Inbox"));
QVERIFY2(stored, "no button was built for the stored query");
QVERIFY2(generated, "no button was built for the generated query");
@@ -6035,11 +6117,14 @@ void TestMainWindow::onlyAStoredQueryOffersToBecomeATaggingRule()
QStringLiteral("queryToRule")),
"a generated query must not: its query is a snapshot");
- // The guard proving the generated button HAS a menu, so the assertion
- // above is about this one action and not about a button with no actions.
- QVERIFY2(contextActionNamed(window, generated,
- QStringLiteral("deleteQuery")),
- "the generated button must still carry its other actions");
+ // The guard, and it has moved since item 93. It used to prove the generated
+ // button HAS a menu, so the assertion above was about one action rather
+ // than about a button with none. `generated` is now the BUILT-IN Sent
+ // filter, which correctly carries no actions at all, so proving the
+ // machinery works has to happen on the button that does have them.
+ QVERIFY2(contextActionNamed(window, stored, QStringLiteral("deleteQuery")),
+ "the stored button lost its other actions, so the assertion above "
+ "is not about queryToRule in particular");
}
void TestMainWindow::unpinningMovesAQueryToTheMenu()
@@ -6062,7 +6147,7 @@ void TestMainWindow::unpinningMovesAQueryToTheMenu()
QVERIFY(!window.findChild<QPushButton *>(
QStringLiteral("savedQueryMenuButton")));
- auto *button = row->findChild<QPushButton *>();
+ auto *button = savedQueryButton(window, QStringLiteral("Inbox"));
QVERIFY(button);
QAction *pin = contextActionNamed(window, button, QStringLiteral("pinQuery"));
QVERIFY(pin);
@@ -6100,7 +6185,7 @@ void TestMainWindow::deletingRemovesTheQueryFromTheFile()
MainWindow window(config);
auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
QVERIFY(row);
- auto *button = row->findChild<QPushButton *>();
+ auto *button = savedQueryButton(window, QStringLiteral("Doomed"));
QVERIFY(button);
QCOMPARE(button->text(), QStringLiteral("Doomed"));
@@ -6264,6 +6349,219 @@ void TestMainWindow::aWorkerBackedWindowReturnsRealThreads()
QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
}
+void TestMainWindow::theFourBuiltinFiltersAreOnTheRowInOrder()
+{
+ // Shipped, not pinned. Nothing in this config names a query, so a row with
+ // four buttons on it can only have got them from the built-in set: before
+ // item 93 a fresh install had an empty query row.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+
+ QStringList labels;
+ for (QPushButton *button : row->findChildren<QPushButton *>()) {
+ // The overflow menu is a control over the set, not a member of it.
+ if (button->objectName() == QStringLiteral("savedQueryMenuButton"))
+ continue;
+ labels.append(button->text());
+ }
+
+ QCOMPARE(labels, (QStringList{ QStringLiteral("Unread"),
+ QStringLiteral("Inbox"),
+ QStringLiteral("Flagged"),
+ QStringLiteral("Sent") }));
+}
+
+void TestMainWindow::aFilterComposesWithTheSelectedAccount()
+{
+ // Item 90, and the whole point of item 93. Select an account, hit Unread,
+ // and get that account's unread mail rather than everyone's.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ {QStringLiteral("personal"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ window.selectAccountForTesting(QStringLiteral("work"));
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+
+ auto *unread =
+ window.findChild<QPushButton *>(QStringLiteral("unreadButton"));
+ QVERIFY2(unread, "no built-in Unread button");
+ unread->click();
+
+ QCOMPARE(queryEdit->text(),
+ QStringLiteral("path:\"work/**\" and (tag:unread)"));
+
+ // Sent under the same account is the account's OWN folder, not the union
+ // wrapped in a scope. See the Config test of the same name for why a row
+ // count cannot tell the two apart.
+ auto *sent = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(sent);
+ sent->click();
+ QCOMPARE(queryEdit->text(), QStringLiteral("path:\"work/Sent/**\""));
+ QVERIFY2(!queryEdit->text().contains(QStringLiteral("personal")),
+ "another account's sent folder leaked into a scoped Sent filter");
+}
+
+void TestMainWindow::aFilterAcrossAllAccountsIsUnscoped()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ // "All accounts" is the default selection, so this is the fresh state.
+ QVERIFY(window.selectedAccountForTesting().isEmpty());
+
+ auto *unread =
+ window.findChild<QPushButton *>(QStringLiteral("unreadButton"));
+ QVERIFY(unread);
+ unread->click();
+
+ QCOMPARE(queryEdit->text(), QStringLiteral("tag:unread"));
+}
+
+void TestMainWindow::aFilterDoesNotClearTheAccountSelection()
+{
+ // The defect item 90 filed: the button used to reset the dropdown to "All
+ // accounts" before running, so the selection was gone before the query ran.
+ // A filter leaves it exactly where the user put it.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ window.selectAccountForTesting(QStringLiteral("work"));
+
+ auto *unread =
+ window.findChild<QPushButton *>(QStringLiteral("unreadButton"));
+ QVERIFY(unread);
+ unread->click();
+
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+}
+
+void TestMainWindow::aSavedQueryStillClearsTheAccountSelection()
+{
+ // The other half of the design, and the reason item 90 was not fixed in
+ // place. A saved query is a DESTINATION: it states its own scope, so an
+ // unscoped one clears the selection rather than inheriting it. That is the
+ // behaviour the rules preview depends on and it must survive item 93.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ const QString path = writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ });
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("queries"));
+ s.setValue(QStringLiteral("Mine"), QStringLiteral("tag:todo"));
+ s.endGroup();
+ s.sync();
+ }
+ config.load(path);
+
+ MainWindow window(config);
+ window.selectAccountForTesting(QStringLiteral("work"));
+
+ // The migrated [queries] entry is pinned, so it is a button beside the
+ // filters. Found by its label, since only the filters have stable object
+ // names.
+ QPushButton *mine = nullptr;
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ for (QPushButton *button : row->findChildren<QPushButton *>()) {
+ if (button->text() == QStringLiteral("Mine"))
+ mine = button;
+ }
+ QVERIFY2(mine, "the user's own pinned query is not on the row");
+
+ mine->click();
+ QVERIFY2(window.selectedAccountForTesting().isEmpty(),
+ "an unscoped saved query no longer clears the account selection");
+}
+
+void TestMainWindow::aFilterOffersNoEditOrDeleteActions()
+{
+ // A filter is not the user's to edit, rename or delete: it is shipped, and
+ // it is not in queries.json at all. Offering the actions would produce a
+ // dialog that writes an entry the row does not read.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *unread =
+ window.findChild<QPushButton *>(QStringLiteral("unreadButton"));
+ QVERIFY(unread);
+
+ for (QAction *action : unread->actions()) {
+ QVERIFY2(action->objectName() != QStringLiteral("editQuery"),
+ "a built-in filter offered Edit");
+ QVERIFY2(action->objectName() != QStringLiteral("pinQuery"),
+ "a built-in filter offered a pin toggle");
+ QVERIFY2(action->objectName() != QStringLiteral("deleteQuery"),
+ "a built-in filter offered Delete");
+ }
+}
+
+void TestMainWindow::changingTheAccountRunsNothing()
+{
+ // The user's decision, 2026-08-15: "changing the account should not run the
+ // query, hitting the button after changing the account is what queries."
+ // The dropdown selects scope; the button is the verb.
+ //
+ // Today m_accountBox has no signal connected at all, so this guards against
+ // wiring one up by reflex while making the filters compose.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ // A known starting point, so "nothing happened" is distinguishable from
+ // "it was empty all along", which would pass against a re-run that clears.
+ queryEdit->setText(QStringLiteral("tag:todo"));
+ const quint64 before = window.currentGenerationForTesting();
+
+ window.selectAccountForTesting(QStringLiteral("work"));
+
+ QCOMPARE(queryEdit->text(), QStringLiteral("tag:todo"));
+ QCOMPARE(window.currentGenerationForTesting(), before);
+}
+
void TestMainWindow::arrivingBatchesUpdateTheStatusBarWithTheCountSoFar()
{
// Item 74: "Searching..." was set once by runQuery and cleared only on