diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-11 19:02:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-11 19:02:59 +0200 |
| commit | 72812c0ac053a1a841f3864c9e4cce5cb6dab04a (patch) | |
| tree | 26285a20ab686b1f4bb1e51e0dfbfa16e4307d1b /tests | |
| parent | ddce3f3b198b733bd1815cd11c083f0d5ae0beb7 (diff) | |
| download | qtmaildir-72812c0ac053a1a841f3864c9e4cce5cb6dab04a.tar.gz qtmaildir-72812c0ac053a1a841f3864c9e4cce5cb6dab04a.zip | |
feat(placeholder): count sent mail and drafts on the blank pane
Item 67. The pane counted unread, flagged and inbox from three fixed
tag: queries. Sent and drafts cannot join that list as tags: tag:draft
counts 0 against a real database and no draft-ish tag exists in it at
all, so a tag-based line would be a permanent zero that reads as working
code. Both are composed from each account's folder keys instead, the
same way the Sent view already composes its query.
The drafts key was parsed and documented as unused in v1. Composing
drafts is still v2; counting them is not, so Account::draftsQuery() and
Config::allDraftsQuery() now mirror the sent pair. The shared body moved
into folderQuery() and joinAccountQueries(), so the load-bearing quoting
(a provider nests both folders under a bracketed parent, and [ and ] are
Xapian syntax) and the bare-"or" guard exist once rather than once per
folder type.
The fixed array is gone rather than extended. It held queries and labels
in two lists indexed in parallel, which is a hazard that grows with the
list: an entry inserted in one and not the other prints a real number
against the wrong name and looks entirely plausible. placeholderLines()
carries each query beside the callable that labels it, so the two cannot
drift, and the count reply stays paired by position as the worker
requires.
A line is omitted when no account configures that folder rather than
shown as 0, following item 63: a missing folder is a real configuration,
and "0 sent" claims the user has sent nothing.
Measured against the real config: 4 sent terms over 601 threads, 5
drafts terms over 3, the extra drafts term coming from the one account
that configures drafts and no sent, which is what proves the two are
collected independently.
Four tests here and four in test_config, mutation-checked at three
points: dropping the drafts line, an off-by-one in the label pairing,
and removing the -1 guard for an uncountable query. Each mutation fails
a test.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_config.cpp | 97 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 197 |
2 files changed, 294 insertions, 0 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 60ed071..dad4ecd 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -71,6 +71,10 @@ private slots: void allSentQueryIsEmptyWhenNoAccountHasOne(); void allSentQuerySkipsAccountsWithoutTheKey(); void allSentQueryJoinsEveryConfiguredAccount(); + void draftsQueryIsEmptyWithoutTheKey(); + void draftsQuerySurvivesABracketedPath(); + void allDraftsQuerySkipsAccountsWithoutTheKey(); + void allDraftsQueryIsIndependentOfSent(); }; static QString writeIni(const QTemporaryDir &dir, const QString &body) @@ -916,5 +920,98 @@ void TestConfig::allSentQueryJoinsEveryConfiguredAccount() QCOMPARE(all.count(QStringLiteral(" or ")), 1); } +void TestConfig::draftsQueryIsEmptyWithoutTheKey() +{ + // Optional for the same reason `sent` is, and more often absent: an + // account that composes elsewhere keeps no local drafts folder at all. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.provider-c]\n" + "maildir = provider-c\n"))); + + QCOMPARE(config.accounts().size(), 1); + QVERIFY(config.accounts().at(0).draftsQuery().isEmpty()); + QVERIFY(config.problems().isEmpty()); +} + +void TestConfig::draftsQuerySurvivesABracketedPath() +{ + // The same quoting trap sentQuery() exists for, and it bites harder here: + // a real provider's drafts folder is BOTH bracketed and localised + // ("[Provider]/Bozze"). Unquoted, "[" and "]" are Xapian syntax and the + // term is parsed rather than matched, so the count reads 0 and looks like + // an empty drafts folder rather than a broken query. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.provider-a]\n" + "maildir = provider-a\n" + "drafts = [Provider]/Bozze\n"))); + + QCOMPARE(config.accounts().at(0).draftsQuery(), + QStringLiteral("path:\"provider-a/[Provider]/Bozze/**\"")); +} + +void TestConfig::allDraftsQuerySkipsAccountsWithoutTheKey() +{ + // The bare-"or" defect allSentQuerySkipsAccountsWithoutTheKey() records, + // asserted again rather than assumed to be inherited: the two compositions + // are separate functions and a rewrite of one does not carry the other. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.webmail-primary]\n" + "maildir = webmail-primary\n" + "drafts = Drafts\n" + "\n" + "[account.provider-c]\n" + "maildir = provider-c\n" + "\n" + "[account.webmail-secondary]\n" + "maildir = webmail-secondary\n" + "drafts = Drafts\n"))); + + const QString all = config.allDraftsQuery(); + + QVERIFY2(!all.contains(QStringLiteral("or or")), + "an account without a drafts key left a bare 'or' in the query"); + QVERIFY2(!all.trimmed().endsWith(QStringLiteral("or")), + "the query ends in a dangling 'or'"); + QVERIFY2(!all.trimmed().startsWith(QStringLiteral("or")), + "the query starts with a dangling 'or'"); + QVERIFY(!all.contains(QStringLiteral("provider-c"))); + + QCOMPARE(all.count(QStringLiteral("path:")), 2); + QCOMPARE(all.count(QStringLiteral(" or ")), 1); +} + +void TestConfig::allDraftsQueryIsIndependentOfSent() +{ + // The two keys are independent, and one real account proves it: it + // configures `drafts` and has no `sent` whatsoever. A composition that + // walked the accounts once and emitted both from the same loop iteration + // would either drop this account's drafts or invent a sent term for it. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.webmail-primary]\n" + "maildir = webmail-primary\n" + "sent = Sent\n" + "\n" + "[account.provider-b]\n" + "maildir = provider-b\n" + "drafts = [Provider]/Bozze\n"))); + + const QString drafts = config.allDraftsQuery(); + const QString sent = config.allSentQuery(); + + // One term each, from DIFFERENT accounts. + QCOMPARE(drafts, QStringLiteral("path:\"provider-b/[Provider]/Bozze/**\"")); + QCOMPARE(sent, QStringLiteral("path:\"webmail-primary/Sent/**\"")); + QVERIFY(!drafts.contains(QStringLiteral("webmail-primary"))); + QVERIFY(!sent.contains(QStringLiteral("provider-b"))); +} + QTEST_MAIN(TestConfig) #include "test_config.moc" diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index faa8481..818e5b4 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -173,6 +173,10 @@ private slots: void thereIsNoSentButtonWithoutASentKey(); void theSentButtonRunsEveryConfiguredAccount(); void theSentButtonSurvivesABracketedPath(); + void placeholderCountsSkipSentAndDraftsWithoutTheKeys(); + void placeholderCountsCarrySentAndDrafts(); + void placeholderLabelsStayPairedWithTheirQueries(); + void placeholderCountsDropAnUncountableQuery(); void flatModeDoesNotSurviveTheNextQuery(); void noTwoActionsShareAnIcon(); }; @@ -4639,6 +4643,199 @@ void TestMainWindow::theSentButtonSurvivesABracketedPath() QStringLiteral("path:\"provider-a/[Provider]/Posta inviata/**\"")); } +namespace { + +/// Writes a config whose accounts carry a sent folder, a drafts folder, or +/// neither. Separate from writeSentConfig() because the two keys are +/// independent: the interesting cases here are exactly the ones where an +/// account has one and not the other. +struct FolderAccount { + QString maildir; + QString sent; + QString drafts; +}; + +QString writeFolderConfig(const QTemporaryDir &dir, + const QList<FolderAccount> &accounts) +{ + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + QSettings s(path, QSettings::IniFormat); + for (const FolderAccount &account : accounts) { + s.beginGroup(QStringLiteral("account.") + account.maildir); + s.setValue(QStringLiteral("maildir"), account.maildir); + if (!account.sent.isEmpty()) + s.setValue(QStringLiteral("sent"), account.sent); + if (!account.drafts.isEmpty()) + s.setValue(QStringLiteral("drafts"), account.drafts); + s.endGroup(); + } + s.sync(); + return path; +} + +/// The label of the helper whose query is `query`, or a null string. +QString labelForQuery(const QList<HtmlBuilder::PlaceholderHelper> &helpers, + const QString &query) +{ + for (const HtmlBuilder::PlaceholderHelper &helper : helpers) { + if (helper.query == query) + return helper.label; + } + return QString(); +} + +} // namespace + +void TestMainWindow::placeholderCountsSkipSentAndDraftsWithoutTheKeys() +{ + // The tag: lines are unconditional, the folder lines are not. An account + // with no sent or drafts folder must contribute no line at all rather than + // a line reading 0: item 63 established that a missing folder is a real + // configuration, and "0 sent" claims the user has sent nothing. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + config.load(writeFolderConfig(dir, {{QStringLiteral("provider-c"), {}, {}}})); + + MainWindow window(config); + + const QStringList queries = window.placeholderQueriesForTesting(); + QCOMPARE(queries.size(), 3); + QVERIFY(queries.contains(QStringLiteral("tag:unread"))); + QVERIFY(queries.contains(QStringLiteral("tag:flagged"))); + QVERIFY(queries.contains(QStringLiteral("tag:inbox"))); +} + +void TestMainWindow::placeholderCountsCarrySentAndDrafts() +{ + // Both composed from the folder keys rather than from a tag. `tag:draft` + // counts 0 against the user's real database (measured 2026-08-11) and no + // draft-ish tag exists in it at all, so a tag-based drafts line would be a + // permanent zero that looks like working code. + // + // The account layout is the real one's shape: one account with both keys, + // one with drafts and NO sent, which is what proves the two are collected + // independently rather than per-account in one pass. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + config.load(writeFolderConfig(dir, { + {QStringLiteral("webmail-primary"), QStringLiteral("Sent"), + QStringLiteral("Drafts")}, + {QStringLiteral("provider-a"), {}, QStringLiteral("[Provider]/Bozze")}, + })); + + MainWindow window(config); + + const QStringList queries = window.placeholderQueriesForTesting(); + QCOMPARE(queries.size(), 5); + + // The quoting survives the trip, which is the trap this whole composition + // exists for: unquoted, "[" and "]" are Xapian syntax and the count reads + // 0 while looking like an empty folder. + QVERIFY2(queries.contains(config.allDraftsQuery()), + qPrintable(QStringLiteral("drafts query missing, got: %1") + .arg(queries.join(QStringLiteral(" | "))))); + QVERIFY(queries.contains(config.allSentQuery())); + QVERIFY(config.allDraftsQuery().contains( + QStringLiteral("path:\"provider-a/[Provider]/Bozze/**\""))); + + // The sent term comes from the account that has one, and the drafts terms + // from both. An implementation that emitted a folder line per account + // would produce four folder queries instead of two. + QCOMPARE(config.allSentQuery().count(QStringLiteral("path:")), 1); + QCOMPARE(config.allDraftsQuery().count(QStringLiteral("path:")), 2); +} + +void TestMainWindow::placeholderLabelsStayPairedWithTheirQueries() +{ + // The defect this guards is the one the old fixed array invited: the + // labels were written positionally against a separate query array, so + // inserting an entry in one and not the other put a real number against + // the wrong name. Asserting the PAIRING rather than the order is what + // survives a later reshuffle. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + config.load(writeFolderConfig(dir, { + {QStringLiteral("webmail-primary"), QStringLiteral("Sent"), + QStringLiteral("Drafts")}, + })); + + MainWindow window(config); + + const QStringList queries = window.placeholderQueriesForTesting(); + QCOMPARE(queries.size(), 5); + + // Distinct counts, so a label reading the wrong index cannot coincide with + // the right answer. Positional against the queries the window just asked + // for, which is exactly the contract requestCounts() replies under. + QVector<int> counts; + for (int i = 0; i < queries.size(); ++i) + counts.append((i + 1) * 10); + + QMetaObject::invokeMethod(&window, "onCountsReady", + Q_ARG(QVector<int>, counts), + Q_ARG(quint64, window.countsGenerationForTesting())); + + const QList<HtmlBuilder::PlaceholderHelper> helpers = + window.placeholderHelpersForTesting(); + + for (int i = 0; i < queries.size(); ++i) { + const QString label = labelForQuery(helpers, queries.at(i)); + QVERIFY2(!label.isNull(), + qPrintable(QStringLiteral("no helper for query '%1'") + .arg(queries.at(i)))); + QVERIFY2(label.contains(QString::number(counts.at(i))), + qPrintable(QStringLiteral("query '%1' was labelled '%2', which " + "does not carry its own count %3") + .arg(queries.at(i), label) + .arg(counts.at(i)))); + } + + // And the folder lines say what they are, not "in inbox". + QVERIFY(labelForQuery(helpers, config.allSentQuery()) + .contains(QStringLiteral("sent"))); + QVERIFY(labelForQuery(helpers, config.allDraftsQuery()) + .contains(QStringLiteral("draft"))); +} + +void TestMainWindow::placeholderCountsDropAnUncountableQuery() +{ + // The worker answers -1 for a query it could not count, rather than + // skipping the entry, precisely so the positional pairing holds. The pane + // must then drop that LINE rather than print a negative number, and drop + // only that one. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + config.load(writeFolderConfig(dir, { + {QStringLiteral("webmail-primary"), QStringLiteral("Sent"), + QStringLiteral("Drafts")}, + })); + + MainWindow window(config); + + const QStringList queries = window.placeholderQueriesForTesting(); + QVector<int> counts; + for (int i = 0; i < queries.size(); ++i) + counts.append(i == 0 ? -1 : (i + 1) * 10); + + QMetaObject::invokeMethod(&window, "onCountsReady", + Q_ARG(QVector<int>, counts), + Q_ARG(quint64, window.countsGenerationForTesting())); + + const QList<HtmlBuilder::PlaceholderHelper> helpers = + window.placeholderHelpersForTesting(); + + QVERIFY(labelForQuery(helpers, queries.at(0)).isNull()); + for (int i = 1; i < queries.size(); ++i) { + QVERIFY2(!labelForQuery(helpers, queries.at(i)).isNull(), + qPrintable(QStringLiteral("an uncountable query took '%1' " + "down with it").arg(queries.at(i)))); + } +} + void TestMainWindow::flatModeDoesNotSurviveTheNextQuery() { // The condition the user set for this feature: a flat Sent list is fine, a |
