summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/config.cpp36
-rw-r--r--src/mainwindow.cpp62
-rw-r--r--src/mainwindow.h26
-rw-r--r--tests/test_config.cpp73
-rw-r--r--tests/test_mainwindow.cpp348
5 files changed, 486 insertions, 59 deletions
diff --git a/src/config.cpp b/src/config.cpp
index 0b65053..ece1fb9 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -482,22 +482,16 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings)
}
settings.endGroup();
- // Sent was a hardcoded button beside the saved queries and becomes an
- // ordinary row here, so it can be reordered, renamed, unpinned or
- // removed like any other. It stays GENERATED, so it still follows the
- // accounts. Appended last, where the button already sat.
+ // Sent is NOT migrated into queries.json any more. It used to become an
+ // ordinary saved query here, so the hardcoded button could be
+ // reordered, renamed or removed; item 93 makes it one of four built-in
+ // filters instead, which are shipped rather than stored. Migrating it
+ // as well would put two Sent buttons on the row, one of them the user's
+ // to edit and one not.
//
- // Only when an account actually configures a sent folder: the button
- // was hidden entirely otherwise, and migrating a row that always finds
- // nothing would be worse than what it replaces.
- if (!allSentQuery().isEmpty()) {
- SavedQuery sent;
- sent.name = QStringLiteral("Sent");
- sent.generated = QStringLiteral("sent");
- sent.pinned = true;
- sent.flat = true;
- m_savedQueries.append(sent);
- }
+ // Nothing is lost: the built-in Sent resolves through the same
+ // generator, so it still follows the accounts, and it now composes with
+ // the account dropdown rather than resetting it.
// Order is alphabetical here because childKeys() is genuinely all the
// INI knows. The user reorders once and it sticks from then on.
@@ -585,6 +579,18 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings)
continue;
}
+ // A stored entry naming a generator now duplicates a BUILT-IN filter of
+ // the same name, since item 93 ships all four rather than storing them.
+ // 0.19.0 migrated the hardcoded Sent button into exactly such an entry,
+ // so every existing install has one.
+ //
+ // Unpinned, never dropped: the row would otherwise carry two Sent
+ // buttons, one the user's to edit and one not. Deleting it would be
+ // data loss on a file whose readers are supposed to preserve what they
+ // do not own, and an unpin is reversible from the UI.
+ if (query.isGenerated() && isKnownGenerator(query.generated))
+ query.pinned = false;
+
for (auto it = object.begin(); it != object.end(); ++it) {
static const QStringList known = {
QStringLiteral("name"), QStringLiteral("query"),
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index c6df95c..6bd91d9 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1717,12 +1717,31 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
auto *box = new QHBoxLayout(row);
box->setContentsMargins(0, 0, 0, 0);
- // Sent is an ordinary row here, not a hardcoded button beside the others.
- // It is still GENERATED, so its query is composed from the accounts' `sent`
- // keys at click time and correcting a folder name stays a config edit and
- // nothing else; what changed is that the entry can now be reordered,
- // renamed, unpinned or removed like every other, instead of being the one
- // control on the row the user did not own.
+ // The built-in filters come first, in their own fixed order, and they are
+ // not saved queries: they are shipped, they are not in queries.json, and
+ // the user cannot edit or delete them (item 93). They are what the row is
+ // FOR; the pinned saved queries below them are the transitional half that
+ // item 94 removes.
+ for (const SavedQuery &filter : Config::builtinFilters()) {
+ // Sent with no account configuring a sent folder finds nothing by
+ // construction. Hidden rather than present and empty, which is what the
+ // hardcoded Sent button did and is worth keeping: a control that always
+ // returns nothing reads as broken rather than as absent.
+ if (m_config.resolvedQuery(filter, QString())
+ == Config::matchNothingQuery())
+ continue;
+
+ auto *button = new QPushButton(filter.name, row);
+ // A stable object name per filter, so a test finds the button without
+ // depending on the label, which is translated.
+ button->setObjectName(filter.generated + QStringLiteral("Button"));
+ connect(button, &QPushButton::clicked, this,
+ [this, filter]() { runFilter(filter); });
+ box->addWidget(button);
+ }
+
+ // The user's own saved queries. A pinned one is still a button, beside the
+ // filters, until item 94 makes the menu their only home.
QList<SavedQuery> unpinned;
for (const SavedQuery &saved : m_config.savedQueries()) {
// A generator whose accounts configure nothing produces a button that
@@ -1736,10 +1755,9 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
continue;
}
auto *button = new QPushButton(saved.name, row);
- // The generated entries keep a stable object name so a test can find
- // the sent button without depending on what the user renamed it to.
- if (saved.generated == QStringLiteral("sent"))
- button->setObjectName(QStringLiteral("sentButton"));
+ // No object name here any more. "sentButton" now belongs to the BUILT-IN
+ // Sent filter, and a migrated Sent entry claiming it too would give two
+ // buttons one name, so findChild() would return whichever came first.
connect(button, &QPushButton::clicked, this,
[this, saved]() { runSavedQuery(saved); });
addSavedQueryActions(button, saved);
@@ -1928,6 +1946,22 @@ void MainWindow::runSavedQuery(const SavedQuery &saved)
runQuery(saved.flat ? FlatResult::Yes : FlatResult::No);
}
+void MainWindow::runFilter(const SavedQuery &filter)
+{
+ // The account box is READ and never written. That is the whole difference
+ // from runSavedQuery(), and it is item 90's defect: a filter narrows what
+ // the user is already looking at, so the dropdown is its input rather than
+ // something it resets on the way past.
+ const QString accountKey = m_accountBox->currentData().toString();
+
+ // Resolved here, in the account's scope, and put in the bar so what ran is
+ // visible and editable. runQuery() is told not to scope it again.
+ m_queryEdit->setText(m_config.resolvedQuery(filter, accountKey));
+
+ runQuery(filter.flat ? FlatResult::Yes : FlatResult::No,
+ AccountScope::AlreadyScoped);
+}
+
void MainWindow::saveCurrentQuery()
{
const QString query = m_queryEdit->text().trimmed();
@@ -2007,7 +2041,7 @@ void MainWindow::rebuildSavedQueryRow()
}
}
-void MainWindow::runQuery(FlatResult flat)
+void MainWindow::runQuery(FlatResult flat, AccountScope scope)
{
// Set on EVERY run, not only when Yes. This is the line that stops flat
// mode leaking: any query that is not the Sent button restores the tree,
@@ -2017,8 +2051,12 @@ void MainWindow::runQuery(FlatResult flat)
QString query = m_queryEdit->text().trimmed();
+ // A built-in filter arrives already resolved in the selected account's
+ // scope, because a generator has to be asked for the account's own query
+ // rather than have its all-accounts query wrapped. Scoping again here would
+ // put path:"work/Sent/**" inside path:"work/**".
const QString accountKey = m_accountBox->currentData().toString();
- if (!accountKey.isEmpty())
+ if (scope == AccountScope::Apply && !accountKey.isEmpty())
query = m_config.account(accountKey).scopedQuery(query);
if (query.isEmpty())
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 80c7d2b..bb1ba75 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -274,12 +274,23 @@ public:
enum class FlatResult { No, Yes };
Q_ENUM(FlatResult)
+ /// Whether runQuery() applies the selected account's scope to the bar text.
+ ///
+ /// Apply is right for anything the user typed or a saved query put there.
+ /// AlreadyScoped is for a built-in filter, whose text was resolved through
+ /// Config::resolvedQuery(query, accountKey) and already carries the scope:
+ /// scoping it a second time would wrap path:"work/Sent/**" in
+ /// path:"work/**", which is the double scope item 93 exists to avoid.
+ enum class AccountScope { Apply, AlreadyScoped };
+ Q_ENUM(AccountScope)
+
private:
/// The real query runner. Kept off the slot list deliberately: a slot with
/// a defaulted argument does not satisfy QObject::connect, which matches
/// signal and slot arity at compile time, so the zero-argument slot below
/// is what widgets connect to.
- void runQuery(FlatResult flat);
+ void runQuery(FlatResult flat,
+ AccountScope scope = AccountScope::Apply);
/// Builds the row of saved-query buttons, the overflow menu and Sent.
///
@@ -295,6 +306,19 @@ private:
/// twice. Setting the dropdown also shows the user what scope they are in.
void runSavedQuery(const SavedQuery &saved);
+ /// Runs a built-in filter in whatever account scope is currently selected.
+ ///
+ /// The opposite of runSavedQuery() in the one way that matters: it does NOT
+ /// touch the account box. A filter narrows what the user is already looking
+ /// at, so the dropdown is its input rather than something it overwrites,
+ /// which is item 90's defect and item 93's design.
+ ///
+ /// The query text is resolved here rather than left to runQuery()'s own
+ /// scoping, because a generator must be asked for the account's own query:
+ /// Sent wrapped in a scope double-scopes and works only by accident of
+ /// path: being hierarchical. See Config::resolvedQuery(query, accountKey).
+ void runFilter(const SavedQuery &filter);
+
/// Names the current query and stores it in queries.json.
void saveCurrentQuery();
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