diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
| commit | fabcf080652c6e5d57bf234be5e100769a9b965b (patch) | |
| tree | 0de4222c1e2aab58c38d34c9e0e3c37c68298cc8 /tests/test_config.cpp | |
| parent | c50bea78e036518ce1a2a3eb899bbb5e305affea (diff) | |
| parent | ddcae8d02ef46db522b3cf6c228196c7a66a6432 (diff) | |
| download | qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.tar.gz qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.zip | |
Merge branch 'compose-and-send': composing and sending mail
Item 123, built over 2026-08-20 to 2026-08-23 in thirteen tasks against
docs/superpowers/specs/2026-08-20-compose-and-send-design.md.
The application writes mail now. A composer window per message, markdown as
the body, drafts autosaving into the account's Maildir, and sending through a
per-account command on stdin rather than any network protocol of this
program's own. A countdown with an Undo stands between pressing Send and the
command running.
Two things came in alongside it. The notmuch auto-tagging hooks moved here
from the retiring `mailctl` project and learned that mail this application
files itself never arrived, so sent mail and drafts stop appearing in the
inbox. And the v1/v2 language is retired: semver on the user-visible surface
is the rule, and those labels described a split that composing made obsolete.
Hand tested against a fake send command rather than a real one, deliberately:
New, Reply and Forward all produce correct messages, a forwarded attachment
survives intact, and the sent copy is filed. That testing found the two
defects fixed on this branch, and both were invisible to the suite: a composer
orphaned by quitting the main window, and every sent message tagged `inbox`.
Twenty-two defects were found in the plan document's own draft code while
building it, which is why CLAUDE.md says to treat every code block in a plan
as a draft.
Diffstat (limited to 'tests/test_config.cpp')
| -rw-r--r-- | tests/test_config.cpp | 196 |
1 files changed, 196 insertions, 0 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp index ea5c363..a902425 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -121,6 +121,16 @@ private slots: void anAccountWithoutATrashFolderWarns(); void theTrashFilterComposesPerAccount(); void theTrashFilterMatchesNothingWithoutAFolder(); + void anAccountWithoutASendCommandIsReceiveOnly(); + void composeSettingsDefaultWhenTheSectionIsAbsent(); + void aZeroSendDelayIsHonouredRatherThanTreatedAsUnset(); + void aDefaultAccountThatCannotSendIsWarnedAbout(); + void anInstallationWhereNoAccountCanSendIsNotWarnedAbout(); + void garbageAutosaveIntervalIsRejectedNotZero(); + void garbageSendDelayIsRejectedNotZero(); + void garbageAttachmentWarnBytesIsRejectedNotZero(); + void zeroOrNegativeAutosaveIntervalIsClamped(); + void unrecognisedQuotePositionWarnsAndFallsBackToAbove(); }; static QString writeIni(const QTemporaryDir &dir, const QString &body) @@ -2313,5 +2323,191 @@ void TestConfig::aGeneratedEntryWritesNoRedundantKeys() "flat must come back from the generator, not from the file"); } +void TestConfig::anAccountWithoutASendCommandIsReceiveOnly() +{ + // The capability IS the command's presence, and nothing else expresses + // it: not a receive_only flag, not an empty-string special case. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\n" + "maildir=work\n" + "trash=Trash\n" + "send_command=msmtp -a work -t\n" + "\n" + "[account.listsonly]\n" + "maildir=listsonly\n" + "trash=Trash\n"))); + + const Account work = config.account(QStringLiteral("work")); + const Account listsonly = config.account(QStringLiteral("listsonly")); + QVERIFY2(work.canSend(), "an account with send_command must be able to send"); + QVERIFY2(!listsonly.canSend(), + "an account with no send_command must not report it can send"); + + const QList<Account> sending = config.sendingAccounts(); + QCOMPARE(sending.size(), 1); + QCOMPARE(sending.first().key, QStringLiteral("work")); +} + +void TestConfig::composeSettingsDefaultWhenTheSectionIsAbsent() +{ + // A config that has never heard of this feature must produce working + // defaults rather than zeros. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral("[general]\n"))); + + const ComposeSettings compose = config.compose(); + QVERIFY2(compose.quotePosition == ComposeSettings::QuotePosition::Above, + "default quote position must be Above"); + QVERIFY2(compose.sendHtml, "default send_html must be true"); + QCOMPARE(compose.autosaveIntervalMs, 30000); + QCOMPARE(compose.sendDelayMs, 5000); + QCOMPARE(compose.attachmentWarnBytes, qint64(26214400)); + QVERIFY(compose.defaultAccount.isEmpty()); +} + +void TestConfig::aZeroSendDelayIsHonouredRatherThanTreatedAsUnset() +{ + // Zero is a real setting meaning "send at once", and it is exactly the + // value an absent key would produce if the default were applied by + // testing for zero. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "send_delay_ms=0\n"))); + + QCOMPARE(config.compose().sendDelayMs, 0); + QVERIFY2(config.compose().sendDelayMs != 5000, + "zero send_delay_ms was replaced by the default"); +} + +void TestConfig::aDefaultAccountThatCannotSendIsWarnedAbout() +{ + // Follows the pattern that already warns about an unresolvable + // startup_query: the setting is not silently corrected because a user + // who named an account expects mail to come from it. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "default_account=listsonly\n" + "\n" + "[account.listsonly]\n" + "maildir=listsonly\n" + "trash=Trash\n"))); + + const QString joined = config.warnings().join(QLatin1Char('\n')); + QVERIFY2(joined.contains(QStringLiteral("listsonly")), + qPrintable(QStringLiteral("no warning named listsonly: %1").arg(joined))); +} + +void TestConfig::anInstallationWhereNoAccountCanSendIsNotWarnedAbout() +{ + // A read-only installation is VALID; warning about it would train the + // user to ignore warnings. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\n" + "maildir=work\n" + "trash=Trash\n"))); + + QVERIFY(config.sendingAccounts().isEmpty()); + for (const QString &warning : config.warnings()) { + QVERIFY2(!warning.contains(QStringLiteral("send"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("unexpected sending-related warning: %1") + .arg(warning))); + } +} + +void TestConfig::garbageAutosaveIntervalIsRejectedNotZero() +{ + // toInt() alone returns 0 on a parse failure, not the default, and 0 + // reaches a QTimer restarted on every keystroke: a typo here would have + // turned the debounce into a write per keystroke, uploaded by mbsync. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "autosave_interval_ms=oops\n"))); + + QCOMPARE(config.compose().autosaveIntervalMs, 30000); + QVERIFY2(!config.problems().isEmpty(), + "a garbage autosave_interval_ms was accepted silently"); +} + +void TestConfig::garbageSendDelayIsRejectedNotZero() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "send_delay_ms=soon\n"))); + + QCOMPARE(config.compose().sendDelayMs, 5000); + QVERIFY2(!config.problems().isEmpty(), + "a garbage send_delay_ms was accepted silently"); +} + +void TestConfig::garbageAttachmentWarnBytesIsRejectedNotZero() +{ + // Verified against the actual defect: attachment_warn_bytes=banana gave 0 + // via a bare toLongLong(), which would have warned about every attachment + // no matter how small. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "attachment_warn_bytes=banana\n"))); + + QCOMPARE(config.compose().attachmentWarnBytes, qint64(26214400)); + QVERIFY2(!config.problems().isEmpty(), + "a garbage attachment_warn_bytes was accepted silently"); +} + +void TestConfig::zeroOrNegativeAutosaveIntervalIsClamped() +{ + // Independent of the parse fix: a value that parses fine but is zero or + // negative must still not reach setInterval(), since nothing assigns a + // meaning to one, unlike mark_read_delay_ms's documented negative-means-off. + QTemporaryDir dir; + Config zero; + zero.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "autosave_interval_ms=0\n"))); + QVERIFY2(zero.compose().autosaveIntervalMs >= 1000, + qPrintable(QStringLiteral("zero autosave interval was not clamped: %1") + .arg(zero.compose().autosaveIntervalMs))); + + QTemporaryDir dir2; + Config negative; + negative.load(writeIni(dir2, QStringLiteral( + "[compose]\n" + "autosave_interval_ms=-500\n"))); + QVERIFY2(negative.compose().autosaveIntervalMs >= 1000, + qPrintable(QStringLiteral("negative autosave interval was not clamped: %1") + .arg(negative.compose().autosaveIntervalMs))); +} + +void TestConfig::unrecognisedQuotePositionWarnsAndFallsBackToAbove() +{ + // Matches the precedent set by sync_on_exit, language and date_format: + // the only silent fallbacks in this file are for ABSENT keys, never for + // malformed ones. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "quote_position=abov\n"))); + + QVERIFY2(config.compose().quotePosition == ComposeSettings::QuotePosition::Above, + "an unrecognised quote_position must still fall back to Above"); + QVERIFY2(!config.problems().isEmpty(), + "an unrecognised quote_position was accepted silently"); +} + QTEST_MAIN(TestConfig) #include "test_config.moc" |
