diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_config.cpp | 73 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 348 |
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 |
