aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_mainwindow.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-22 10:32:44 +0200
committerDanilo M. <danix@danix.xyz>2026-08-22 10:49:07 +0200
commit141bc9b98213b9c4ba9f3bcba041c3e3108c12a3 (patch)
tree2ed6f867723ed201631696d089df206868852cba /tests/test_mainwindow.cpp
parentd5ee2ab07d5313d153f62ce2ed8dcd027c677a09 (diff)
downloadqtmaildir-141bc9b98213b9c4ba9f3bcba041c3e3108c12a3.tar.gz
qtmaildir-141bc9b98213b9c4ba9f3bcba041c3e3108c12a3.zip
feat(compose): the composer window, item 123
A separate top-level QMainWindow, one per draft, several open at once. A modal dialog cannot consult another message while writing, which is most of what replying is, and taking over the message pane fights the pane that exists to show what is being replied to. No geometry save and no restore, deliberately. Under a tiling compositor saveGeometry stores normalGeometry while the compositor owns the tile, so the restore is correct and looks broken; a whole session went into that once. Autosave is a debounce AND a dirty check: an unchanged message writes no file and provokes no sync. The check is on a fingerprint of the OutgoingMessage, NOT on the built bytes as the plan drafted. GMime is given a fresh Date and Message-ID on every build, so two builds of an unchanged message never compare equal; a check on the bytes would have read as working while writing a file, and an mbsync upload, on every debounce. Checking before the build also skips the blocking build for the no-change case, which is the common one. closeEvent writes the draft when the buffer is dirty. Without it the debounce is a hole rather than a delay: typing a paragraph and pressing the window manager's X inside the interval loses it silently, since WA_DeleteOnClose destroys the window immediately afterwards. A failed save there does NOT refuse the close, because a window that will not close because it cannot save is worse than one that closes having raised the banner, which is what the quit path reads. One flag covers a send, countdown included. An earlier revision had two, and the narrower "committed and running" one reads as the honest thing to guard a live SMTP conversation with. It is not: a close during the countdown destroys the parented SendDialog, committed() never fires, and the user pressed Send, watched a countdown, and believes the mail went. The narrow flag was also written in three places and read in none. A failed draft write raises a persistent banner rather than a modal or a fading status line. A modal mid-sentence is hostile while the user is typing, but the warning must survive until it is dealt with, because the quit path escalates exactly this state to a dialog on the way out. An account with no drafts folder reports success rather than failure: nothing was written and nothing failed, and a false there would make the quit path offer a retry no retry can change. A failed send saves the draft before reporting. send() builds from the widgets without saving, so the revision on disk is whatever the last debounce wrote: edit, send, fail, close, and the user gets the older text back, having watched their correction be sent. A failed sent copy after a successful send is a modal, and never a send failure: the message went, and reporting otherwise makes someone send it twice. It is the one failure here that silently diverges what the recipient received from what the local archive shows, and nobody discovers a missing sent copy by noticing a line that appeared for a few seconds. The formatting toolbar applies its edits through a QTextCursor document replacement inside one edit block, NOT setPlainText as the plan drafted. Measured against a real widget: setPlainText destroys the document's undo stack and resets the cursor to 0, so every toolbar press would throw away everything the user could undo. The cursor route leaves undo available, collapses to a single undo step, and emits textChanged once. The seeded quote is cleared off the undo stack afterwards, since it is not an edit the user made and one Ctrl+Z on a fresh composer must not wipe it. The per-send connect carries Qt::SingleShotConnection. MessageSender is a long-lived member, so a bare connect accumulates a permanent receiver per send and the second result runs both lambdas, the first still holding the first message's bytes: it files a sent copy of the wrong message and acts on a dialog it already destroyed. Covered by a test that sends, fails, corrects and sends again; without the flag it segfaults in QLabel::setText on the destroyed dialog. Its companion disconnect takes the specific connection handle rather than every finished receiver on this object, so a later observer cannot be killed silently. The attachment warning states sizes with a decimal and a stepped unit. Integer MB division read as "'x' is 0 MB. Many mail servers refuse messages above about 0 MB." for any attachment_warn_bytes below a megabyte, in both halves of one sentence. The autosave timer is created before buildUi(), which is load-bearing: buildUi connects every field to markDirty and seeding then fills those fields, so markDirty runs during construction. Created afterwards it is a null dereference on the first seeded field, which is every composer. Twenty-six cases in test_mainwindow, each mutation-checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_mainwindow.cpp')
-rw-r--r--tests/test_mainwindow.cpp1154
1 files changed, 1154 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 0ad45ee..cab6eae 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -50,6 +50,13 @@
#include "messageview.h"
#include "notmuchworker.h"
#include "carddelegate.h"
+#include "composewindow.h"
+#include "senddialog.h"
+#include "messagesender.h"
+#include <QCheckBox>
+#include <QPlainTextEdit>
+#include <QPointer>
+#include <QListWidget>
#include "cardlayout.h"
#include <QImage>
@@ -392,6 +399,37 @@ private slots:
void theRefreshAfterARestoreLeavesUndoIntact();
void deletingOutsideTheTrashViewLeavesTheRowInPlace();
+ // ComposeWindow, item 123. These need a window but no worker: the composer
+ // never touches NotmuchWorker, it reads its context from the value struct
+ // MainWindow hands it, so a Config written to a temporary INI is the whole
+ // fixture.
+ void aComposerOpensClean();
+ void typingMarksTheComposerDirty();
+ void anAutosaveWritesADraftAndClearsTheDirtyFlag();
+ void anUnwritableDraftsFolderRaisesThePersistentBanner();
+ void aSuccessfulSaveClearsTheBanner();
+ void anAccountWithoutADraftsFolderReportsNoFailure();
+ void aRewrittenDraftUnlinksThePreviousRevision();
+ void theComposerBuildsTheMessageItsWidgetsShow();
+ void theFromDropdownDecidesWhichAccountSends();
+ void aFormatEditPreservesTheUndoStack();
+ void aFormatEditRestoresTheSelectionItAsksFor();
+ void aFormatEditOnAnEmptySelectionLandsBetweenTheTokens();
+ void theAttachmentWarningRespectsTheConfiguredThreshold();
+ void aDisabledAttachmentWarningWarnsAboutNothing();
+ void theQuotePositionDecidesWhereTheQuoteLands();
+ void theSeededQuoteIsNotAnUndoStep();
+ void aReplySeedsTheHtmlToggleFromTheOriginal();
+ void aNewMessageSeedsTheHtmlToggleFromConfig();
+ void disablingInputsCoversEveryFieldAndTheToolbar();
+ void aFailedSendCanBeRetriedWithoutFilingTheWrongCopy();
+ void anUnchangedMessageIsNotWrittenAgain();
+ void closingInsideTheDebounceStillSavesTheDraft();
+ void closingAfterASendWritesNoFurtherDraft();
+ void aCloseDuringTheCountdownIsRefused();
+ void aFailedSendKeepsTheTextThatFailedToGo();
+ void aSmallSizeLimitIsNotDescribedAsZeroMegabytes();
+
private:
/// Owns the throwaway lock table init() points every test at. A pointer
/// rather than a value because it is rebuilt per test, and QTemporaryDir
@@ -10770,4 +10808,1120 @@ void TestMainWindow::deletingOutsideTheTrashViewLeavesTheRowInPlace()
QCOMPARE(model->rowCount(QModelIndex()), 1);
}
+// ---------------------------------------------------------------------------
+// ComposeWindow, item 123.
+//
+// The composer owns widgets and nothing else here does, which is why its cases
+// live in this file. What is asserted is deliberately NOT what it looks like:
+// the autosave dirty check, the banner state, the message its widgets produce,
+// the format edits and the seeding rules, all of which are observable without
+// a painter. CLAUDE.md's "Rendering probes lie" section covers why counting
+// pixels here would prove nothing.
+// ---------------------------------------------------------------------------
+
+namespace {
+
+/// A Config written to a temporary INI, plus a Maildir root to write into.
+///
+/// No notmuch database and no worker: the composer never touches
+/// NotmuchWorker, so building one would only cost every case a `notmuch new`.
+/// The mail root is passed to ComposeWindow explicitly, exactly as MainWindow
+/// passes what the worker reported (item 124: it is NOT database.path).
+class ComposeFixture
+{
+public:
+ /// `drafts` and `sent` are written only when non-empty, so a test can
+ /// build the account-without-a-drafts-folder case by passing an empty
+ /// string rather than by needing a second fixture.
+ /// `secondAccount` writes a SECOND sending account, which is what makes
+ /// the From dropdown have something to choose between. Off by default:
+ /// every other case here wants exactly one, so a two-account fixture
+ /// everywhere would let a test pass by picking the only entry there is.
+ bool build(const QString &drafts = QStringLiteral("Drafts"),
+ const QString &sent = QStringLiteral("Sent"),
+ const QString &extraCompose = QString(),
+ bool secondAccount = false)
+ {
+ if (!m_confDir.isValid() || !m_mailDir.isValid())
+ return false;
+
+ const QString path = m_confDir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
+ return false;
+ {
+ QTextStream out(&file);
+ // QSettings reads `/` in a section name as a group separator, so
+ // the section is [account.acct], never [account/acct].
+ out << "[account.acct]\n"
+ << "name=Test User\n"
+ << "address=user@example.org\n"
+ << "maildir=acct\n"
+ << "trash=Trash\n";
+ if (!drafts.isEmpty())
+ out << "drafts=" << drafts << "\n";
+ if (!sent.isEmpty())
+ out << "sent=" << sent << "\n";
+ // A command that exists and does nothing. canSend() is what the
+ // From dropdown filters on, so an account without this one line
+ // would not appear in it at all.
+ out << "send_command=/bin/true\n";
+ if (secondAccount) {
+ out << "\n[account.other]\n"
+ << "name=Other User\n"
+ << "address=other@example.org\n"
+ << "maildir=other\n"
+ << "trash=Trash\n"
+ << "drafts=Drafts\n"
+ << "sent=Sent\n"
+ << "send_command=/bin/true\n";
+ }
+ out << "\n[compose]\n";
+ if (!extraCompose.isEmpty())
+ out << extraCompose << "\n";
+ }
+ file.close();
+
+ m_config.load(path);
+ return true;
+ }
+
+ const Config &config() const { return m_config; }
+ QString mailRoot() const { return m_mailDir.path(); }
+
+ /// The account's drafts folder, as the composer will resolve it.
+ QString draftsCur() const
+ {
+ return m_mailDir.path() + QStringLiteral("/acct/Drafts/cur");
+ }
+
+ /// The second account's drafts folder.
+ QString otherDraftsCur() const
+ {
+ return m_mailDir.path() + QStringLiteral("/other/Drafts/cur");
+ }
+
+ /// How many message files sit in the drafts folder.
+ int draftCount() const
+ {
+ return QDir(draftsCur(), {}, QDir::Name, QDir::Files).count();
+ }
+
+private:
+ QTemporaryDir m_confDir;
+ QTemporaryDir m_mailDir;
+ Config m_config;
+};
+
+/// A minimal New-message context for the fixture's one account.
+ComposeContext newContext()
+{
+ ComposeContext context;
+ context.accountKey = QStringLiteral("acct");
+ context.kind = ComposeContext::Kind::New;
+ return context;
+}
+
+} // namespace
+
+void TestMainWindow::aComposerOpensClean()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // Seeding fills every field, which emits every field's change signal. A
+ // composer that counted those as edits would autosave a draft nobody
+ // asked for, and would tell the quit path there is unsaved work in a
+ // window the user opened and closed without typing.
+ QVERIFY(!window.hasUnsavedEdits());
+ QVERIFY(!window.lastSaveFailed());
+
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY2(timer, "no autosave timer: the window was never built");
+ QVERIFY2(!timer->isActive(),
+ "seeding armed the autosave timer, so a untouched composer writes");
+}
+
+void TestMainWindow::typingMarksTheComposerDirty()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ QVERIFY(!window.hasUnsavedEdits());
+ body->setPlainText(QStringLiteral("Some text."));
+ QVERIFY(window.hasUnsavedEdits());
+
+ // The subject is part of the message as much as the body is: a draft that
+ // saved the body but not the address it was going to would be worse than
+ // none.
+ ComposeWindow second(newContext(), fixture.config(), fixture.mailRoot());
+ auto *subject = second.findChild<QLineEdit *>(QStringLiteral("subject"));
+ QVERIFY(subject);
+ QVERIFY(!second.hasUnsavedEdits());
+ subject->setText(QStringLiteral("A subject"));
+ QVERIFY(second.hasUnsavedEdits());
+}
+
+void TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Draft body."));
+ QVERIFY(window.hasUnsavedEdits());
+
+ QVERIFY2(window.saveDraftNow(), "the draft write reported failure");
+
+ QCOMPARE(fixture.draftCount(), 1);
+ QVERIFY2(!window.hasUnsavedEdits(),
+ "the flag survived a successful save, so the quit path would ask");
+ QVERIFY(!window.lastSaveFailed());
+
+ // The bytes really are the message, not an empty file: the draft is
+ // byte-identical to what would be sent, which is the property the one
+ // built message exists for.
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("Draft body."), "the draft does not carry the body");
+ // Written with the Maildir draft flag, not left bare.
+ QVERIFY2(files.first().endsWith(QStringLiteral(":2,D")),
+ qPrintable(QStringLiteral("wrong maildir flags: ") + files.first()));
+}
+
+void TestMainWindow::anUnwritableDraftsFolderRaisesThePersistentBanner()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Draft body."));
+
+ // A FILE where the folder must go. mkpath then fails, which is a real
+ // failure mode and needs no permission games that root would defeat.
+ const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct");
+ QVERIFY(QDir().mkpath(accountDir));
+ QFile blocker(accountDir + QStringLiteral("/Drafts"));
+ QVERIFY(blocker.open(QIODevice::WriteOnly));
+ blocker.write("not a directory");
+ blocker.close();
+
+ QVERIFY2(!window.saveDraftNow(), "an unwritable folder reported success");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY2(banner, "no banner widget");
+ QVERIFY2(!banner->text().isEmpty(), "the banner says nothing");
+ QVERIFY2(window.lastSaveFailed(),
+ "lastSaveFailed() is false after a failed write, so the quit "
+ "path would let the text go");
+ QVERIFY2(window.hasUnsavedEdits(),
+ "a failed save cleared the dirty flag, which claims the text is "
+ "safe on disk when it is not");
+}
+
+void TestMainWindow::aSuccessfulSaveClearsTheBanner()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("First."));
+
+ const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct");
+ QVERIFY(QDir().mkpath(accountDir));
+ QFile blocker(accountDir + QStringLiteral("/Drafts"));
+ QVERIFY(blocker.open(QIODevice::WriteOnly));
+ blocker.close();
+
+ QVERIFY(!window.saveDraftNow());
+ QVERIFY(window.lastSaveFailed());
+
+ // Remove the obstruction and save again. The banner must go: a warning
+ // that stays after the thing it warned about is fixed teaches the user to
+ // ignore warnings, which is the second lesson in the TagRules entry.
+ QVERIFY(QFile::remove(accountDir + QStringLiteral("/Drafts")));
+ body->setPlainText(QStringLiteral("Second."));
+
+ QVERIFY2(window.saveDraftNow(), "the retry failed");
+ QVERIFY2(!window.lastSaveFailed(), "lastSaveFailed() stayed set");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY(banner);
+ QVERIFY2(banner->isHidden(), "the banner is still up after a good save");
+}
+
+void TestMainWindow::anAccountWithoutADraftsFolderReportsNoFailure()
+{
+ ComposeFixture fixture;
+ // No drafts key at all: a real configuration, warned about at startup.
+ QVERIFY(fixture.build(QString()));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Nowhere to save this."));
+
+ // Nothing was written and nothing failed. Reporting a failure here would
+ // make the quit path offer a retry for a state no retry can change.
+ QVERIFY2(window.saveDraftNow(),
+ "a missing drafts folder was reported as a save failure");
+ QVERIFY2(!window.lastSaveFailed(), "the banner state was set");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY(banner);
+ QVERIFY(banner->isHidden());
+}
+
+void TestMainWindow::aRewrittenDraftUnlinksThePreviousRevision()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("Revision one."));
+ QVERIFY(window.saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("Revision two."));
+ QVERIFY(window.saveDraftNow());
+
+ // ONE file, not two. Maildir has no in-place edit, so a draft rewritten
+ // every thirty seconds would otherwise accumulate one file per pause, and
+ // every one of them is a message mbsync uploads.
+ QCOMPARE(fixture.draftCount(), 1);
+
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("Revision two."), "the surviving file is the old one");
+}
+
+void TestMainWindow::theComposerBuildsTheMessageItsWidgetsShow()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.inReplyTo = QStringLiteral("original@example.org");
+ context.references = { QStringLiteral("root@example.org"),
+ QStringLiteral("original@example.org") };
+ context.to = { QStringLiteral("one@example.org") };
+ context.subject = QStringLiteral("Re: a subject");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+
+ auto *cc = window.findChild<QLineEdit *>(QStringLiteral("cc"));
+ auto *bcc = window.findChild<QLineEdit *>(QStringLiteral("bcc"));
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(cc && bcc && body);
+
+ // A field the user typed, split on commas. That is wrong for a RAW header
+ // and right here: this is the composer's own rendering, which joins with
+ // ", ".
+ cc->setText(QStringLiteral("two@example.org, three@example.org"));
+ bcc->setText(QStringLiteral(" four@example.org "));
+ body->setPlainText(QStringLiteral("The body."));
+
+ const OutgoingMessage message = window.currentMessage();
+ QCOMPARE(message.accountKey, QStringLiteral("acct"));
+ QCOMPARE(message.to, QStringList{ QStringLiteral("one@example.org") });
+ QCOMPARE(message.cc, (QStringList{ QStringLiteral("two@example.org"),
+ QStringLiteral("three@example.org") }));
+ // Trimmed, or the whitespace reaches the wire as part of the address.
+ QCOMPARE(message.bcc, QStringList{ QStringLiteral("four@example.org") });
+ QCOMPARE(message.subject, QStringLiteral("Re: a subject"));
+ QCOMPARE(message.markdownBody, QStringLiteral("The body."));
+
+ // NOT optional. Without them a reply appears as an orphan thread in the
+ // sender's own client, which is invisible locally.
+ QCOMPARE(message.inReplyTo, QStringLiteral("original@example.org"));
+ QCOMPARE(message.references.size(), 2);
+ QCOMPARE(message.references.last(), QStringLiteral("original@example.org"));
+}
+
+void TestMainWindow::theFromDropdownDecidesWhichAccountSends()
+{
+ // TWO sending accounts, because a dropdown with one entry cannot be
+ // changed and a test against it passes whether the code reads the dropdown
+ // or the context. The first revision of this test did exactly that: it
+ // asserted count() == 1 and then re-asserted a property another case
+ // already covers, and a mutation making currentAccount() read
+ // m_context.accountKey survived it.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QString(), /*secondAccount=*/true));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *from = window.findChild<QComboBox *>(QStringLiteral("from"));
+ QVERIFY2(from, "no From dropdown");
+
+ // Both sending accounts are offered, seeded to the context's.
+ QCOMPARE(from->count(), 2);
+ QCOMPARE(from->currentData().toString(), QStringLiteral("acct"));
+ QCOMPARE(window.currentMessage().accountKey, QStringLiteral("acct"));
+
+ // Now change it. The dropdown is the authority once the window is open:
+ // reading the context here would send from the seeded account while the
+ // interface said otherwise.
+ const int other = from->findData(QStringLiteral("other"));
+ QVERIFY2(other >= 0, "the second account is not in the dropdown");
+ from->setCurrentIndex(other);
+
+ QCOMPARE(window.currentMessage().accountKey, QStringLiteral("other"));
+
+ // And the choice reaches the DRAFT's destination, not just the value:
+ // a draft is written into the sending account's own folder, so a composer
+ // that read the context would file it under the wrong account.
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("From the other account."));
+ QVERIFY(window.saveDraftNow());
+
+ QCOMPARE(QDir(fixture.otherDraftsCur(), {}, QDir::Name, QDir::Files).count(),
+ 1u);
+ QCOMPARE(QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).count(), 0u);
+}
+
+void TestMainWindow::aFormatEditPreservesTheUndoStack()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ // Typed through a cursor, which is what makes it an undoable edit;
+ // setPlainText() would not be one.
+ QTextCursor typing = body->textCursor();
+ typing.insertText(QStringLiteral("hello"));
+ QVERIFY(body->document()->isUndoAvailable());
+
+ QTextCursor selection = body->textCursor();
+ selection.setPosition(0);
+ selection.setPosition(5, QTextCursor::KeepAnchor);
+ body->setTextCursor(selection);
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY2(bold, "no bold action");
+ bold->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("**hello**"));
+
+ // The property the plan's setPlainText() draft would have lost. Measured
+ // in a standalone probe: setPlainText() takes isUndoAvailable from true to
+ // false, so every toolbar press would throw away everything the user could
+ // undo.
+ QVERIFY2(body->document()->isUndoAvailable(),
+ "the format edit destroyed the undo stack");
+
+ // And it is ONE undo step, not one per character: a whole-document
+ // replacement inside an edit block collapses to a single entry, so one
+ // Ctrl+Z takes the tokens off and leaves the typed word.
+ body->undo();
+ QCOMPARE(body->toPlainText(), QStringLiteral("hello"));
+}
+
+void TestMainWindow::aFormatEditRestoresTheSelectionItAsksFor()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("hello world"));
+
+ // A BACKWARDS selection, anchor after the cursor, which is what a
+ // right-to-left drag produces and an ordinary gesture. Measured against a
+ // real widget: selectionStart()/selectionEnd() come back normalised even
+ // then, so the anchor's side does not reach MarkdownFormat.
+ QTextCursor selection = body->textCursor();
+ selection.setPosition(5);
+ selection.setPosition(0, QTextCursor::KeepAnchor);
+ body->setTextCursor(selection);
+ QCOMPARE(body->textCursor().selectionStart(), 0);
+ QCOMPARE(body->textCursor().selectionEnd(), 5);
+
+ auto *italic = window.findChild<QAction *>(QStringLiteral("format_italic"));
+ QVERIFY(italic);
+ italic->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("*hello* world"));
+
+ // The selection is preserved precisely so a second press can apply a
+ // SECOND token to the same words, bold then italic without reselecting.
+ QCOMPARE(body->textCursor().selectedText(), QStringLiteral("hello"));
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY(bold);
+ bold->trigger();
+ QCOMPARE(body->toPlainText(), QStringLiteral("***hello*** world"));
+}
+
+void TestMainWindow::aFormatEditOnAnEmptySelectionLandsBetweenTheTokens()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("ab"));
+
+ QTextCursor cursor = body->textCursor();
+ cursor.setPosition(1);
+ body->setTextCursor(cursor);
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY(bold);
+ bold->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("a****b"));
+
+ // The property a user notices immediately when it is wrong, and the one
+ // invisible to a test that only compares the resulting text: typing must
+ // continue INSIDE the pair, not after it.
+ QCOMPARE(body->textCursor().position(), 3);
+ QVERIFY(!body->textCursor().hasSelection());
+
+ QTextCursor typing = body->textCursor();
+ typing.insertText(QStringLiteral("x"));
+ QCOMPARE(body->toPlainText(), QStringLiteral("a**x**b"));
+}
+
+void TestMainWindow::theAttachmentWarningRespectsTheConfiguredThreshold()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("attachment_warn_bytes=1000")));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // The threshold, not the modal. The question itself needs a user, so what
+ // is asserted is the predicate that decides whether to ask.
+ QVERIFY2(!window.attachmentNeedsWarning(999), "warned below the limit");
+ QVERIFY2(!window.attachmentNeedsWarning(1000),
+ "warned AT the limit, which is not above it");
+ QVERIFY2(window.attachmentNeedsWarning(1001), "did not warn above the limit");
+}
+
+void TestMainWindow::aDisabledAttachmentWarningWarnsAboutNothing()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("attachment_warn_bytes=0")));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // Zero means off, not "warn about everything". Read as a threshold it
+ // would question an empty file, which is the opposite of what turning a
+ // warning off means.
+ QVERIFY(!window.attachmentNeedsWarning(0));
+ QVERIFY(!window.attachmentNeedsWarning(1));
+ QVERIFY(!window.attachmentNeedsWarning(100LL * 1024 * 1024));
+}
+
+void TestMainWindow::theQuotePositionDecidesWhereTheQuoteLands()
+{
+ const QString quote = QStringLiteral("> the original");
+
+ {
+ ComposeFixture above;
+ QVERIFY(above.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=above")));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, above.config(), above.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY2(body->toPlainText().startsWith(quote),
+ "quote_position=above did not put the quote first");
+ }
+
+ {
+ ComposeFixture below;
+ QVERIFY(below.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=below")));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, below.config(), below.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY2(body->toPlainText().endsWith(quote),
+ "quote_position=below did not put the quote last");
+ QVERIFY2(!body->toPlainText().startsWith(quote),
+ "the quote is at the top under quote_position=below");
+ }
+}
+
+void TestMainWindow::theSeededQuoteIsNotAnUndoStep()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = QStringLiteral("> the original");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY(!body->toPlainText().isEmpty());
+
+ // The seeded quote is not an edit the user made. One Ctrl+Z on a fresh
+ // composer must not wipe it, which reads as the buffer losing its content.
+ //
+ // Worth knowing before judging this test dead weight: removing
+ // clearUndoRedoStacks() alone leaves it GREEN, because setPlainText()
+ // already leaves undo unavailable. The line it guards becomes load-bearing
+ // the moment seedBody() stops using setPlainText, which is a change with
+ // reasons to happen: applyEdit() switched to a QTextCursor replacement for
+ // exactly the undo-stack property this asserts, and a later revision
+ // seeding the quote the same way would put it on the stack. The combined
+ // mutation (seed through a cursor AND drop the clear) does kill this.
+ QVERIFY2(!body->document()->isUndoAvailable(),
+ "the seeded quote is on the undo stack");
+}
+
+void TestMainWindow::aReplySeedsTheHtmlToggleFromTheOriginal()
+{
+ ComposeFixture fixture;
+ // Config says yes; the original says no. The original wins for a reply:
+ // an HTML part in it is a fact about the sender's software, not a guess
+ // about their taste.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=true")));
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.seedHtml = false;
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY2(toggle, "no send-html toggle");
+ QVERIFY2(!toggle->isChecked(),
+ "a reply seeded from config rather than from the original");
+
+ // And the other way round, so the test cannot pass by always reading
+ // false: a plain-text config with an HTML original still offers HTML.
+ ComposeFixture plain;
+ QVERIFY(plain.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=false")));
+ ComposeContext htmlReply = newContext();
+ htmlReply.kind = ComposeContext::Kind::ReplyAll;
+ htmlReply.seedHtml = true;
+
+ ComposeWindow second(htmlReply, plain.config(), plain.mailRoot());
+ auto *secondToggle =
+ second.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(secondToggle);
+ QVERIFY2(secondToggle->isChecked(),
+ "a reply-all ignored an HTML original");
+}
+
+void TestMainWindow::aNewMessageSeedsTheHtmlToggleFromConfig()
+{
+ ComposeFixture off;
+ QVERIFY(off.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=false")));
+
+ // seedHtml is deliberately TRUE here and must be ignored: a New message
+ // has no original to take evidence from, so a composer reading it would be
+ // reading a field nothing filled in.
+ ComposeContext context = newContext();
+ context.seedHtml = true;
+
+ ComposeWindow window(context, off.config(), off.mailRoot());
+ auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(toggle);
+ QVERIFY2(!toggle->isChecked(), "a New message ignored [compose] send_html");
+
+ ComposeFixture on;
+ QVERIFY(on.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=true")));
+ ComposeContext forward = newContext();
+ forward.kind = ComposeContext::Kind::Forward;
+ forward.seedHtml = false;
+
+ ComposeWindow second(forward, on.config(), on.mailRoot());
+ auto *secondToggle =
+ second.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(secondToggle);
+ QVERIFY2(secondToggle->isChecked(),
+ "a Forward seeded from the original rather than from config");
+}
+
+void TestMainWindow::disablingInputsCoversEveryFieldAndTheToolbar()
+{
+ ComposeFixture fixture;
+ // Zero delay: the countdown is skipped and the send commits at once, which
+ // is the state the inputs must already be disabled in.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ // Heap-allocated and tracked with a QPointer, because ComposeWindow sets
+ // WA_DeleteOnClose and this case really does complete a send: the window
+ // deletes itself on the way out, so a stack instance would be destroyed
+ // twice. Every other case here stays on the stack, since none of them
+ // closes.
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Text."));
+
+ auto *toolbar = window->findChild<QToolBar *>(QStringLiteral("formatToolbar"));
+ auto *to = window->findChild<QLineEdit *>(QStringLiteral("to"));
+ auto *subject = window->findChild<QLineEdit *>(QStringLiteral("subject"));
+ auto *from = window->findChild<QComboBox *>(QStringLiteral("from"));
+ auto *toggle = window->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(toolbar && to && subject && from && toggle);
+
+ QVERIFY(to->isEnabled());
+ QVERIFY(!body->isReadOnly());
+
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY2(sendAction, "no send action");
+ sendAction->trigger();
+
+ // The message must not change between pressing Send and the bytes being
+ // built, so every input goes down for the WHOLE operation, countdown
+ // included. The body is made read-only rather than disabled, so its text
+ // stays selectable and legible while the send runs.
+ QVERIFY2(!to->isEnabled(), "the To field is still editable during a send");
+ QVERIFY2(!subject->isEnabled(), "the subject is still editable");
+ QVERIFY2(!from->isEnabled(), "the account can still be changed");
+ QVERIFY2(!toggle->isEnabled(), "the HTML toggle can still be flipped");
+ QVERIFY2(body->isReadOnly(), "the body is still writable during a send");
+ QVERIFY2(!toolbar->isEnabled(), "the formatting toolbar is still live");
+ auto *attachments =
+ window->findChild<QListWidget *>(QStringLiteral("attachments"));
+ QVERIFY(attachments);
+ QVERIFY2(!attachments->isEnabled(),
+ "the attachment list is still live during a send");
+
+ // /bin/true is the fixture's send command, so the send succeeds and the
+ // composer closes itself: the message went, and holding a composer open
+ // for a message already sent invites sending it twice. Waited on rather
+ // than asserted immediately, since the process is handed to the event loop
+ // and nothing here blocks on it. WA_DeleteOnClose then destroys the
+ // window, which is what the QPointer observes.
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // And the sent copy really was filed, which is the stage after the send
+ // and the one whose failure the design treats as the worst outcome here.
+ const QString sentCur =
+ fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+}
+
+void TestMainWindow::aFailedSendCanBeRetriedWithoutFilingTheWrongCopy()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ // A stub whose outcome is switched by a sentinel file, so ONE configured
+ // command can fail and then succeed. It appends its stdin to a log, which
+ // is what makes the delivery count observable: the defect this guards
+ // against files a sent copy of the FIRST message when the second finishes,
+ // and a receiver count is the only thing that shows it.
+ QTemporaryDir stubDir;
+ QVERIFY(stubDir.isValid());
+ const QString sentinel = stubDir.filePath(QStringLiteral("succeed"));
+ const QString stub = stubDir.filePath(QStringLiteral("send.sh"));
+ {
+ QFile script(stub);
+ QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&script);
+ out << "#!/bin/sh\n"
+ << "cat >> " << stubDir.filePath(QStringLiteral("stdin.log")) << "\n"
+ << "[ -f " << sentinel << " ] || { echo 'refused' >&2; exit 1; }\n"
+ << "exit 0\n";
+ }
+ QVERIFY(QFile::setPermissions(
+ stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner
+ | QFileDevice::ExeOwner));
+
+ // A FRESH Config, not a copy of the fixture's reloaded: Config::load()
+ // does not clear what a previous load put there, so a copy keeps the
+ // fixture's /bin/true and this test would silently exercise a command that
+ // always succeeds. Measured, and it produced a green nothing.
+ Config config;
+ {
+ const QString path = QStringLiteral("%1/retry.conf").arg(stubDir.path());
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "name=Test User\n"
+ << "address=user@example.org\n"
+ << "maildir=acct\n"
+ << "trash=Trash\n"
+ << "drafts=Drafts\n"
+ << "sent=Sent\n"
+ << "send_command=" << stub << "\n"
+ << "\n[compose]\n"
+ << "send_delay_ms=0\n";
+ file.close();
+ config.load(path);
+ }
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, config, fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("FIRST attempt."));
+ sendAction->trigger();
+
+ // The failure re-enables the composer intact and shows the stderr; the
+ // window stays open and the draft stays.
+ auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane"));
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000);
+
+ QVERIFY2(!window.isNull(), "a failed send closed the composer");
+ QVERIFY2(body->isEnabled() && !body->isReadOnly(),
+ "a failed send left the composer disabled");
+
+ // Correct the message and send again, this time succeeding. Without
+ // Qt::SingleShotConnection on the per-send connect, the first send's
+ // lambda is still attached: the second result runs BOTH, and the first
+ // still holds the FIRST message's bytes, so it files a sent copy of the
+ // wrong message and acts on a dialog it already destroyed.
+ QFile marker(sentinel);
+ QVERIFY(marker.open(QIODevice::WriteOnly));
+ marker.close();
+
+ body->setPlainText(QStringLiteral("SECOND attempt."));
+ sendAction->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // Exactly ONE sent copy, and it is the second message. Two files, or one
+ // carrying the first attempt, is the accumulated-receiver defect.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ const QStringList filed =
+ QDir(sentCur, {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(filed.size(), 1);
+
+ QFile copy(sentCur + QLatin1Char('/') + filed.first());
+ QVERIFY(copy.open(QIODevice::ReadOnly));
+ const QByteArray bytes = copy.readAll();
+ QVERIFY2(bytes.contains("SECOND attempt."),
+ "the filed copy is not the message that was sent");
+ QVERIFY2(!bytes.contains("FIRST attempt."),
+ "the filed copy is the FIRST message, which never went");
+}
+
+void TestMainWindow::anUnchangedMessageIsNotWrittenAgain()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("Once."));
+ QVERIFY(window.saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ const QStringList first =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(first.size(), 1);
+
+ // Nothing has changed, so nothing is written. Every autosave produces a
+ // Maildir write that mbsync uploads, so this check and the debounce
+ // together are what keep a message to a few revisions rather than dozens.
+ //
+ // The FILENAME is what shows it: DraftStore always generates a fresh name
+ // and unlinks the previous one, so a redundant write leaves exactly one
+ // file too, and a count alone cannot tell a skipped write from a repeated
+ // one. Two runs of this test asserting only on the count would pass
+ // against no check at all.
+ QVERIFY2(window.saveDraftNow(), "the redundant save reported failure");
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList second =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(second, first);
+
+ // And a real change still writes: a check that skipped everything would
+ // pass the assertion above and lose the user's text.
+ body->setPlainText(QStringLiteral("Twice."));
+ QVERIFY(window.saveDraftNow());
+ const QStringList third =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(third.size(), 1);
+ QVERIFY2(third != first, "a changed message was not written");
+}
+
+void TestMainWindow::closingInsideTheDebounceStillSavesTheDraft()
+{
+ ComposeFixture fixture;
+ // A debounce far longer than this test, so the timer provably never fires
+ // and the only thing that can write is the close itself.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("autosave_interval_ms=600000")));
+
+ // Heap-allocated: WA_DeleteOnClose destroys the window on the way out, so
+ // a stack instance would be destroyed twice.
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("A paragraph typed and not yet saved."));
+ QVERIFY(window->hasUnsavedEdits());
+
+ // The timer has NOT fired. Asserted rather than assumed: if it had, the
+ // draft below would prove nothing about the close path.
+ auto *timer = window->findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY(timer);
+ QVERIFY2(timer->isActive(), "the debounce is not running");
+ QCOMPARE(fixture.draftCount(), 0);
+
+ // The window manager's X button, which is the route that reaches
+ // closeEvent. Typing a paragraph and pressing it inside the debounce
+ // interval must not lose the text.
+ window->close();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 5000);
+
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ QVERIFY2(written.readAll().contains("A paragraph typed and not yet saved."),
+ "the close wrote a draft that is not the text that was typed");
+}
+
+void TestMainWindow::closingAfterASendWritesNoFurtherDraft()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("Text that is about to be sent."));
+
+ // A draft on disk first, so the send's removal of it is observable and the
+ // close-path save has something it could wrongly put back.
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ // Now edit again WITHOUT saving, so m_dirty is true at the moment the
+ // send completes. This is what makes the m_finished guard load-bearing:
+ // without it the close that follows a successful send would write a draft
+ // for a message already sent, restoring the file the send just unlinked.
+ body->setPlainText(QStringLiteral("Text that is about to be sent, edited."));
+ QVERIFY(window->hasUnsavedEdits());
+
+ sendAction->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // The message went, so the drafts folder is EMPTY. A draft left behind is
+ // a message the user sees waiting to be finished when it has already been
+ // delivered.
+ QCOMPARE(fixture.draftCount(), 0);
+
+ // And the sent copy is there, so this is a completed send rather than a
+ // send that never happened leaving nothing behind either way.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+}
+
+void TestMainWindow::aCloseDuringTheCountdownIsRefused()
+{
+ ComposeFixture fixture;
+ // A countdown long enough to close inside. The default is 5000; this is
+ // the window the guard exists for and it must be provably still open when
+ // the close is attempted.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=30000")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+ body->setPlainText(QStringLiteral("Sent after a countdown."));
+
+ sendAction->trigger();
+
+ // Still counting down: the popup is up and nothing has been sent. The
+ // sent folder is the evidence, since it is written only after the command
+ // succeeds.
+ auto *dialog = window->findChild<SendDialog *>();
+ QVERIFY2(dialog, "no send popup");
+ QVERIFY2(!dialog->isCommitted(), "the countdown already committed");
+
+ // Close during the countdown. Refused: accepting it would destroy this
+ // window, take the parented SendDialog down with it, and committed() would
+ // never fire. The user pressed Send, watched a countdown, and would
+ // believe the mail went.
+ window->close();
+
+ // Given a moment for a deletion event to be delivered if one was posted,
+ // then asserted still alive. An immediate check would pass against a
+ // deleteLater() already queued.
+ QTest::qWait(300);
+ QVERIFY2(!window.isNull(),
+ "the close was accepted during the countdown, so the send was "
+ "silently abandoned after the user pressed Send");
+ QVERIFY2(window->isVisible() || !window.isNull(), "the window went away");
+
+ // The send never happened, which is the point: nothing was filed.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 0u);
+
+ // Cleaned up by hand, since the window refuses to close while the popup is
+ // up and the test must not leak it into the next case.
+ delete window;
+}
+
+void TestMainWindow::aFailedSendKeepsTheTextThatFailedToGo()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ QTemporaryDir stubDir;
+ QVERIFY(stubDir.isValid());
+ const QString stub = stubDir.filePath(QStringLiteral("fail.sh"));
+ {
+ QFile script(stub);
+ QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&script);
+ out << "#!/bin/sh\ncat > /dev/null\necho 'refused' >&2\nexit 1\n";
+ }
+ QVERIFY(QFile::setPermissions(
+ stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner
+ | QFileDevice::ExeOwner));
+
+ Config config;
+ {
+ const QString path = stubDir.filePath(QStringLiteral("fail.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "name=Test User\naddress=user@example.org\n"
+ << "maildir=acct\ntrash=Trash\ndrafts=Drafts\nsent=Sent\n"
+ << "send_command=" << stub << "\n"
+ << "\n[compose]\nsend_delay_ms=0\n";
+ file.close();
+ config.load(path);
+ }
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, config, fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ // An OLD revision on disk, then an edit that is not saved. send() builds
+ // from the widgets without saving, so without the fix the file left behind
+ // after the failure is the old text: the user watches their correction be
+ // sent, sees it fail, and gets the uncorrected version back.
+ body->setPlainText(QStringLiteral("The ORIGINAL text."));
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("The CORRECTED text."));
+ sendAction->trigger();
+
+ auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane"));
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000);
+ QVERIFY2(!window.isNull(), "a failed send closed the composer");
+
+ // Exactly one draft, and it is the text that was attempted.
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("The CORRECTED text."),
+ "the draft kept after a failed send is not what was attempted");
+ QVERIFY2(!bytes.contains("The ORIGINAL text."),
+ "the draft kept after a failed send is the PRE-EDIT revision");
+
+ delete window;
+}
+
+void TestMainWindow::aSmallSizeLimitIsNotDescribedAsZeroMegabytes()
+{
+ // Integer MB division made every figure under a megabyte read as "0 MB",
+ // in BOTH halves of the same sentence: "'x' is 0 MB. Many mail servers
+ // refuse messages above about 0 MB."
+ QVERIFY2(!ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("0 MB")),
+ "half a megabyte is described as 0 MB");
+ QVERIFY2(!ComposeWindow::humanSize(1000).contains(QStringLiteral("0 MB")),
+ "a kilobyte is described as 0 MB");
+
+ // The unit steps down rather than reporting zero of a larger one.
+ QVERIFY(ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("KB")));
+ QVERIFY(ComposeWindow::humanSize(512).contains(QStringLiteral("bytes")));
+
+ // A decimal while the figure is small enough for it to say something, so
+ // 26 MB and 26.2 MB are not the same string.
+ QVERIFY(ComposeWindow::humanSize(26214400).contains(QStringLiteral("MB")));
+ QVERIFY2(ComposeWindow::humanSize(1024 * 1024 * 3 / 2)
+ .contains(QStringLiteral(".")),
+ "1.5 MB lost its decimal");
+}
+
#include "test_mainwindow.moc"