From 4a9bbff4cb157538106b588bc84f39dd7fd38289 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:46:16 +0200 Subject: feat(rules): a folder dropdown, so the path suffix is never typed --- src/mainwindow.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 331354f..f622036 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1313,6 +1313,17 @@ void MainWindow::showTagRulesDialog() auto *dialog = new TagRulesDialog(this); dialog->setAttribute(Qt::WA_DeleteOnClose); + + // The Folder row's dropdown. Config holds the account subdirectories and + // the dialog holds no Config, so they are handed over here. Account::maildir + // is the subtree name the Folder term compiles a path: against; the account + // key is a config section name and would match nothing. + QStringList folders; + for (const Account &account : m_config.accounts()) { + if (!account.maildir.isEmpty() && !folders.contains(account.maildir)) + folders.append(account.maildir); + } + dialog->setFolders(folders); m_tagRulesDialog = dialog; connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() { -- cgit v1.2.3 From a52e4e41c2bc8fc8eb23c4bc99f9b8724212bd23 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 16:07:18 +0200 Subject: feat(rules): every Maildir folder in the Folder dropdown The dropdown was built from config, which names one subtree per account and nothing below it, so it offered five entries and no way to say Drafts or Sent. A rule wants to target those as often as a whole account. NotmuchWorker gains requestFolders/foldersReady, walking the tree from notmuch_database_get_path() and listing every directory holding cur/. It belongs there because the database root is notmuch's database.path and the worker owns the only handle that can answer for it; putting the root in config would be the second source of truth the design refuses. From the disk rather than from the index: a folder mbsync created and nothing has landed in yet is still a folder a rule may target, and a list derived from indexed message paths would not offer it. The two tests build their own fixture rather than extending the shared one, which needs a nested folder and would otherwise move seven count assertions in unrelated tests. Mutation-checked: flattening the walk to non-recursive fails the listing test. Co-Authored-By: Claude Opus 5 --- src/mainwindow.cpp | 27 ++++++++++------ src/notmuchworker.cpp | 37 +++++++++++++++++++++ src/notmuchworker.h | 17 ++++++++++ tests/test_notmuchworker.cpp | 77 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 10 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index f622036..e8b4dda 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1314,18 +1314,16 @@ void MainWindow::showTagRulesDialog() auto *dialog = new TagRulesDialog(this); dialog->setAttribute(Qt::WA_DeleteOnClose); - // The Folder row's dropdown. Config holds the account subdirectories and - // the dialog holds no Config, so they are handed over here. Account::maildir - // is the subtree name the Folder term compiles a path: against; the account - // key is a config section name and would match nothing. - QStringList folders; - for (const Account &account : m_config.accounts()) { - if (!account.maildir.isEmpty() && !folders.contains(account.maildir)) - folders.append(account.maildir); - } - dialog->setFolders(folders); m_tagRulesDialog = dialog; + // The Folder row's dropdown, filled from the Maildir tree on disk rather + // than from config. Config names one subtree per account and nothing + // below it, so the dropdown offered five entries and no way to say Drafts + // or Sent, which is a folder a rule wants to target as often as a whole + // account. The answer comes back queued, after the dialog is already up; + // setFolders refills the rows that exist by then. + QMetaObject::invokeMethod(m_worker, "requestFolders", Qt::QueuedConnection); + connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() { QMetaObject::invokeMethod( m_worker, "requestMessageCounts", Qt::QueuedConnection, @@ -1425,6 +1423,15 @@ void MainWindow::wireWorker() connect(m_worker, &NotmuchWorker::messageCountsReady, this, &MainWindow::onRuleCountsReady); + // The rules dialog is the only consumer, and it may have been closed while + // the scan was in flight. No generation counter: the tree on disk does not + // change under a query, so a late answer is still the right one. + connect(m_worker, &NotmuchWorker::foldersReady, this, + [this](const QStringList &folders) { + if (m_tagRulesDialog) + m_tagRulesDialog->setFolders(folders); + }); + // A confirmed write clears the pending revert: without this, a later // unrelated error would roll back a change that actually succeeded. connect(m_worker, &NotmuchWorker::tagsApplied, diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index 94fb79c..6aae397 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -20,6 +20,9 @@ #include +#include +#include +#include #include #include @@ -678,3 +681,37 @@ void NotmuchWorker::requestMessageCounts(const QStringList &queries, emit messageCountsReady(counts, generation); } + +void NotmuchWorker::requestFolders() +{ + if (!openReadOnly()) + return; + + const QString root = QString::fromUtf8(notmuch_database_get_path(m_db)); + if (root.isEmpty()) { + emit errorOccurred( + QStringLiteral("notmuch reports no database path.")); + return; + } + + // A Maildir folder is a directory holding cur/. Testing for that rather + // than listing every directory keeps the plumbing (cur, new, tmp) and an + // account's container directory out of the list; neither is somewhere mail + // is filed. Hidden directories are skipped, which is what excludes + // .notmuch itself. + QStringList folders; + QDirIterator it(root, QDir::Dirs | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + const QDir rootDir(root); + while (it.hasNext()) { + const QString path = it.next(); + if (!QFileInfo::exists(path + QStringLiteral("/cur"))) + continue; + folders.append(rootDir.relativeFilePath(path)); + } + + // Sorted, so the dropdown keeps one order across openings. QDirIterator + // walks in filesystem order, which is neither stable nor alphabetical. + folders.sort(); + emit foldersReady(folders); +} diff --git a/src/notmuchworker.h b/src/notmuchworker.h index b3fbed3..9736ab8 100644 --- a/src/notmuchworker.h +++ b/src/notmuchworker.h @@ -151,6 +151,18 @@ public slots: /// called when the dialog is opened and never on a timer. void requestDatabaseStats(quint64 generation); + /// Every Maildir folder under the database root, as paths relative to it. + /// + /// From the DISK, not from the index: a folder mbsync created and nothing + /// has landed in yet is still a folder a tagging rule may target, and one + /// derived from indexed message paths would not offer it. + /// + /// Here rather than in MainWindow because the database root is + /// notmuch's `database.path` and this class owns the only handle that can + /// answer for it. Duplicating the path into config is exactly the second + /// source of truth the design refuses. + void requestFolders(); + signals: void threadsReady(const QVector &threads, quint64 generation); void queryFinished(int totalThreads, quint64 generation); @@ -175,6 +187,11 @@ signals: /// renders as unknown rather than as zero. void databaseStatsReady(const DatabaseStats &stats, quint64 generation); + /// Maildir folders relative to the database root, sorted. No generation: + /// the tree on disk does not change under a query, and the one consumer + /// asks once when its dialog opens. + void foldersReady(const QStringList &folders); + void errorOccurred(const QString &message); private: diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index 1db26c7..9068ca3 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -80,6 +80,9 @@ private slots: void messageCountsCountMessagesNotThreads(); void messageCountsReportAnInvalidQueryAsMinusOne(); + void requestFoldersListsEveryMaildirFolder(); + void requestFoldersOnUnreadableConfigEmitsError(); + private: /// Tags of one message, read back through a fresh worker query. QStringList tagsOf(const QString &messageId); @@ -900,5 +903,79 @@ void TestNotmuchWorker::requestDatabaseStatsOnUnreadableConfigEmitsError() QVERIFY(ready.isEmpty()); } +void TestNotmuchWorker::requestFoldersListsEveryMaildirFolder() +{ + // Its own fixture rather than the shared one: this needs a NESTED folder, + // which is the shape a real account has (/Drafts, not a flat + // "drafts"), and adding a message to the shared fixture would move seven + // count assertions in other tests for nothing. + NotmuchFixture fixture; + QVERIFY(fixture.isValid()); + QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"), + QStringLiteral("g1@example.org"), + QStringLiteral("Something"), + QStringLiteral("Alice "), + QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"), false)); + QVERIFY(fixture.addMessage(QStringLiteral("work/Drafts"), + QStringLiteral("g2@example.org"), + QStringLiteral("Half written"), + QStringLiteral("You "), + QStringLiteral("Tue, 2 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"), false)); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + // An EMPTY folder, created but never written to. mbsync makes these, and a + // list derived from indexed messages would not offer it. A rule may + // legitimately target a folder that has nothing in it yet. + QDir dir; + const QString empty = fixture.maildirPath() + QStringLiteral("/work/Archive"); + QVERIFY(dir.mkpath(empty + QStringLiteral("/cur"))); + QVERIFY(dir.mkpath(empty + QStringLiteral("/new"))); + QVERIFY(dir.mkpath(empty + QStringLiteral("/tmp"))); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy ready(&worker, &NotmuchWorker::foldersReady); + + worker.requestFolders(); + + QCOMPARE(ready.count(), 1); + const QStringList folders = ready.first().at(0).toStringList(); + + // Paths relative to the database root, which is what a Folder term + // compiles a path: against. Drafts is the whole point of the item: the + // dialog used to offer one entry per account and nothing below it. + QVERIFY(folders.contains(QStringLiteral("work/INBOX"))); + QVERIFY(folders.contains(QStringLiteral("work/Drafts"))); + QVERIFY(folders.contains(QStringLiteral("work/Archive"))); + + // Not the maildir plumbing, which is not a folder anyone files mail into, + // and not the account directory itself, which holds no cur/. + QVERIFY(!folders.contains(QStringLiteral("work/INBOX/cur"))); + QVERIFY(!folders.contains(QStringLiteral("work/INBOX/new"))); + QVERIFY(!folders.contains(QStringLiteral("work"))); + + // Sorted, so the dropdown does not reorder itself between openings with + // the same tree on disk. QDir's own order is filesystem order. + QStringList sorted = folders; + sorted.sort(); + QCOMPARE(folders, sorted); +} + +void TestNotmuchWorker::requestFoldersOnUnreadableConfigEmitsError() +{ + // Fails closed like every other entry point. The dialog then leaves the + // dropdown as it was rather than emptying it, since an empty list reads as + // "this account has no folders". + NotmuchWorker worker(QStringLiteral("/nonexistent/qtmaildir-test/config")); + QSignalSpy ready(&worker, &NotmuchWorker::foldersReady); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.requestFolders(); + + QCOMPARE(errors.size(), 1); + QVERIFY(ready.isEmpty()); +} + QTEST_MAIN(TestNotmuchWorker) #include "test_notmuchworker.moc" -- cgit v1.2.3 From b8de7ce746718c7193e40b0f70cd3e2178f4ed8a Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 17:00:55 +0200 Subject: feat(rules): preview a rule's mail in the thread list Item 77. The dialog could say how many messages a rule matched and not which ones. A Preview in list button now runs the selected rule's query in the main window; the dialog stays open, since comparing the rule against its results is the point. Two constraints from the backlog entry, both now asserted and both mutation-checked. The query runs exactly as stored, with no tag:new and no wrapping parentheses. The post-new hook adds those when it applies a rule, and a preview that copied them would match nothing outside a sync window, since tag:new is set only on mail that has just arrived. The account selector is cleared first. runQuery() wraps the bar's text in the selected account's scope, and a rule query usually names its own path already, so previewing one with an account selected would scope it twice and show an empty list, which reads as "this rule collects no mail". The second mutation only fails once the test's config has an account to select: with the default empty config the selector sits on "All accounts" anyway, and asserting that a preview leaves it there passed against the mutation. Recorded in the test. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 ++ .../plans/2026-08-03-post-0.1.0-usability.md | 14 +++- src/mainwindow.cpp | 43 +++++++++++++ src/mainwindow.h | 26 ++++++++ src/tagrulesdialog.cpp | 24 +++++++ src/tagrulesdialog.h | 14 ++++ tests/test_tagrules.cpp | 74 ++++++++++++++++++++++ 7 files changed, 200 insertions(+), 1 deletion(-) (limited to 'src/mainwindow.cpp') diff --git a/CHANGELOG.md b/CHANGELOG.md index e46584a..901734d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ point at which they are stable. on disk, so a folder that exists but has no mail in it yet is still offered. It stays editable, so a folder in the rules file that is no longer on disk still opens and still saves. +- A **Preview in list** button in the tagging rules dialog runs the selected + rule's query in the main window, so you can see which mail a rule collects + rather than only how many messages it matches. The dialog stays open. The + query runs exactly as stored, without the `tag:new` scope the hook adds, and + the account selector is cleared first, since a rule query that names its own + folder would otherwise be scoped twice and match nothing. - The rule list and the rule editor are now divided by a draggable splitter, and the condition rows scroll instead of growing without limit. A rule with eight senders used to squeeze the list to about one visible row, since the diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 110938d..eec544e 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -133,7 +133,7 @@ taking that too literally. | 74 | "Searching..." keeps claiming a query is running while rows are already arriving | feedback | XS | open; cause measured 2026-08-11, the delay itself is the cold page cache and is not fixable here | | 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13 on `rule-builder`, unreleased. The window-kind question is left open, see below | | 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13 on `rule-builder`, unreleased. See `specs/2026-08-13-rule-builder-design.md` | -| 77 | No way to see what a rule would collect, in the thread list | workflow | S | open; the dialog counts matches, it cannot show them | +| 77 | No way to see what a rule would collect, in the thread list | workflow | S | **done** 2026-08-13 on `rule-builder`, unreleased | | 78 | No way to build a rule from something visible in a message | workflow | M | open; wants 76 first, so the created rule lands in a form that can hold it | | 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13 on `rule-builder`, unreleased. Follows item 76 | | 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand | @@ -4891,6 +4891,18 @@ means the preview discards whatever thread load was in flight. **Size: S.** +**Done 2026-08-13** on `rule-builder`, unreleased. A **Preview in list** +button emits `previewRequested(query)`; `MainWindow::onRulePreviewRequested` +clears the account selector, puts the query in the bar and runs it, then +raises itself. The dialog stays open, which is the point. + +Both constraints above became assertions, and BOTH mutations were needed: a +test that emitted the hook's `tag:new and (...)` wrapping fails, and one that +skips the account reset fails. The second only bites once the test config +actually has an account to select, since the default empty config leaves the +selector on "All accounts" already and the assertion passed against the +mutation until that was fixed. + ## 78. No way to build a rule from something visible in a message **Observed.** The user would like to select an address or another piece of a diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e8b4dda..e2df6de 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1324,6 +1324,9 @@ void MainWindow::showTagRulesDialog() // setFolders refills the rows that exist by then. QMetaObject::invokeMethod(m_worker, "requestFolders", Qt::QueuedConnection); + connect(dialog, &TagRulesDialog::previewRequested, + this, &MainWindow::onRulePreviewRequested); + connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() { QMetaObject::invokeMethod( m_worker, "requestMessageCounts", Qt::QueuedConnection, @@ -1570,6 +1573,46 @@ void MainWindow::onCountsReady(const QVector &counts, quint64 generation) m_messageView->showPlaceholder(placeholderHelpers()); } +QString MainWindow::queryTextForTesting() const +{ + return m_queryEdit->text(); +} + +QString MainWindow::selectedAccountForTesting() const +{ + return m_accountBox->currentData().toString(); +} + +void MainWindow::selectAccountForTesting(const QString &key) +{ + const int index = m_accountBox->findData(key); + if (index >= 0) + m_accountBox->setCurrentIndex(index); +} + +void MainWindow::onRulePreviewRequested(const QString &query) +{ + // Unscoped, deliberately. runQuery() wraps the bar's text in the selected + // account's scope, and a rule query usually names its own path already + // (path:"work/**" is what every account rule looks like), so previewing + // one with an account selected would scope it twice and match nothing. + // That reads as "this rule collects no mail", which is the opposite of + // what the preview is for. + m_accountBox->setCurrentIndex(0); + + // Through the query bar, like onPlaceholderQueryRequested: the bar then + // shows what is on screen and the user can edit the rule's query there + // before deciding to change the rule itself. + m_queryEdit->setText(query); + runCurrentQuery(); + + // The dialog is a separate window and may be covering this one or sitting + // beside it. Raising makes the result visible either way, and the dialog + // stays open so the two can be compared. + raise(); + activateWindow(); +} + void MainWindow::onPlaceholderQueryRequested(const QString &query) { // Through the query bar rather than straight to the worker, so the bar diff --git a/src/mainwindow.h b/src/mainwindow.h index adf63b0..18fbbb4 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -185,6 +185,27 @@ public: /// The generation the next counts reply must carry to be accepted. quint64 countsGenerationForTesting() const { return m_countsGeneration; } + /// The query bar's text, and the account the selector is scoped to + /// (empty for "All accounts"). Both are what a rule preview writes: the + /// bar so the user can see and edit what ran, and the selector because + /// runQuery() wraps the text in the selected account's scope, which would + /// double-scope a rule query that already names its own path. + QString queryTextForTesting() const; + QString selectedAccountForTesting() const; + + /// Scopes the view to one account, as choosing it in the selector does. + /// A test for the rule preview needs this: with no account selected the + /// box already sits at "All accounts", so asserting that a preview leaves + /// it there passes whether or not the preview clears it. + void selectAccountForTesting(const QString &key); + + /// Runs a rule preview without the dialog, which the offscreen platform + /// cannot click a button in. + void previewRuleQueryForTesting(const QString &query) + { + onRulePreviewRequested(query); + } + protected: void closeEvent(QCloseEvent *event) override; @@ -293,6 +314,11 @@ private slots: /// Runs a query the user clicked on the placeholder pane. void onPlaceholderQueryRequested(const QString &query); + /// Runs one tagging rule's query in the thread list, so the user can see + /// which mail it collects. The rules dialog stays open; the point is to + /// compare the rule against its results. + void onRulePreviewRequested(const QString &query); + /// Opens the auto-tagging rules editor, or raises the one already open. void showTagRulesDialog(); diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp index 285043a..4fca971 100644 --- a/src/tagrulesdialog.cpp +++ b/src/tagrulesdialog.cpp @@ -234,6 +234,11 @@ TagRulesDialog::TagRulesDialog(QWidget *parent) buttons->addWidget(copyButton); buttons->addWidget(deleteButton); buttons->addStretch(); + m_previewButton = new QPushButton(tr("&Preview in list"), this); + m_previewButton->setToolTip( + tr("Run this rule's query in the main window, to see which mail it " + "collects. This does not tag anything.")); + buttons->addWidget(m_previewButton); buttons->addWidget(refreshButton); layout->addLayout(buttons); @@ -251,6 +256,8 @@ TagRulesDialog::TagRulesDialog(QWidget *parent) this, &TagRulesDialog::onCopyRule); connect(deleteButton, &QPushButton::clicked, this, &TagRulesDialog::onDeleteRule); + connect(m_previewButton, &QPushButton::clicked, + this, &TagRulesDialog::previewForTest); connect(refreshButton, &QPushButton::clicked, this, &TagRulesDialog::countsRequested); connect(box, &QDialogButtonBox::accepted, @@ -369,6 +376,23 @@ int TagRulesDialog::heightDemandedBelowListForTest() const return m_splitter->widget(1)->minimumSizeHint().height(); } +void TagRulesDialog::previewForTest() +{ + // Flush any half-typed edit first, so previewing shows what the rule + // says NOW rather than what it said when the row was selected. + applyEditsToCurrentRule(); + + const int index = currentIndex(); + if (index < 0 || index >= m_working.size()) + return; + + const QString query = m_working.at(index).query; + if (query.isEmpty()) + return; + + emit previewRequested(query); +} + int TagRulesDialog::conditionAreaHeightForTest() const { // The CAP itself, not a qMin against the scroll area's own size hint: a diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h index de35a55..e8979fe 100644 --- a/src/tagrulesdialog.h +++ b/src/tagrulesdialog.h @@ -109,6 +109,10 @@ public: /// the width the user had dragged. void reloadListForTest(); + /// Presses Preview, which is otherwise reachable only through a click on + /// a button whose geometry the offscreen platform does not guarantee. + void previewForTest(); + /// The height the condition-row editor asks for. This is what squeezed /// the rule list: a stretch factor only shares out space ABOVE each /// widget's minimum, so every row added here came out of the list. @@ -133,6 +137,15 @@ signals: /// Asks the owner to run countQueries() through the worker. void countsRequested(); + /// Asks the owner to run one rule's query in the main window, so the user + /// can see WHICH mail a rule collects rather than how much. + /// + /// The query goes out exactly as stored: no `tag:new`, and no wrapping + /// parentheses. The post-new hook adds both when it applies a rule, and a + /// preview that copied them would match nothing outside a sync window, + /// since `tag:new` is set only on mail that has just arrived. + void previewRequested(const QString &query); + public slots: /// Corpus counts, positionally paired with countQueries(). void setCounts(const QVector &counts); @@ -241,6 +254,7 @@ private: /// still squeezed, because a stretch factor only shares out space above /// each widget's minimum and the form's grew with every condition row. QSplitter *m_splitter = nullptr; + QPushButton *m_previewButton = nullptr; QVBoxLayout *m_rowsLayout = nullptr; QVBoxLayout *m_exclusionsLayout = nullptr; QLabel *m_exclusionsHeader = nullptr; diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp index 268c41f..ed3a06d 100644 --- a/tests/test_tagrules.cpp +++ b/tests/test_tagrules.cpp @@ -20,6 +20,7 @@ #include #include +#include "config.h" #include "mainwindow.h" #include "rulequery.h" #include "tagrules.h" @@ -55,6 +56,8 @@ private slots: void theWindowSizeIsSavedOnEveryWayOutOfTheDialog(); void aReloadDoesNotDiscardARestoredColumnWidth(); void manyConditionRowsDoNotSqueezeTheRuleList(); + void previewEmitsTheRuleQueryAsStored(); + void previewClearsTheAccountScope(); private: QString writeRules(const QString &json); @@ -867,5 +870,76 @@ void TestTagRules::manyConditionRowsDoNotSqueezeTheRuleList() .arg(dialog.conditionAreaHeightForTest()))); } +void TestTagRules::previewEmitsTheRuleQueryAsStored() +{ + // The query goes out EXACTLY as stored: no tag:new, no wrapping + // parentheses. The hook adds both when it applies a rule, and a preview + // that copied it would show nothing at all outside a sync window, since + // tag:new is only set on mail that has just arrived. + QTemporaryDir configHome; + QVERIFY(configHome.isValid()); + qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8()); + QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules")))); + + QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json"))); + QVERIFY(out.open(QIODevice::WriteOnly)); + out.write(R"({ + "version": 1, + "rules": [ + {"id": "promo", "query": "from:a.example.org or from:b.example.org", + "add": ["promo"], "stage": 50, "enabled": true} + ] + })"); + out.close(); + + TagRulesDialog dialog; + QSignalSpy spy(&dialog, &TagRulesDialog::previewRequested); + + dialog.selectRuleForTest(0); + dialog.previewForTest(); + + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.first().at(0).toString(), + QStringLiteral("from:a.example.org or from:b.example.org")); +} + +void TestTagRules::previewClearsTheAccountScope() +{ + // runQuery() wraps the bar's text in the selected account's scope. A rule + // query usually names its own path already (path:"work/**"), so previewing + // one while an account is selected would scope it twice and show nothing, + // which reads as "the rule matches no mail" rather than as a UI fault. + QTemporaryDir configHome; + QVERIFY(configHome.isValid()); + qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8()); + + // An account that can actually BE selected. With the default empty config + // the selector holds only "All accounts", so it sits at index 0 already + // and the assertion below passes whether or not the preview clears it: + // measured, the mutation removing the reset survived until this config + // was added. + const QString confPath = configHome.filePath(QStringLiteral("q.conf")); + QFile conf(confPath); + QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text)); + conf.write("[account.work]\nmaildir=work-mail\n"); + conf.close(); + + Config config; + config.load(confPath); + QCOMPARE(config.accounts().size(), 1); + + MainWindow window(config); + window.selectAccountForTesting(QStringLiteral("work")); + QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work")); + + window.previewRuleQueryForTesting(QStringLiteral("from:a.example.org")); + + QCOMPARE(window.queryTextForTesting(), + QStringLiteral("from:a.example.org")); + QVERIFY2(window.selectedAccountForTesting().isEmpty(), + "a preview must run unscoped, or an account-scoped rule query " + "is wrapped twice and matches nothing"); +} + QTEST_MAIN(TestTagRules) #include "test_tagrules.moc" -- cgit v1.2.3