From c72d96d6428f99f06c04868a70cb09c4a9180f98 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 21:17:34 +0200 Subject: feat(compose): the send popup and its undo window, item 123 Three rows in every state so nothing reflows and the window never jumps. The bar changes MODE rather than place: determinate while the countdown drains, because a countdown has measurable progress, and indeterminate once the command starts, because a send does not. That is the pairing item 134's widget was extracted to serve. The delay is where cancelling is safe and it is the only place it is. Nothing has reached a server during the countdown, so Undo means genuinely nothing happened; killing send_command once it runs leaves an UNKNOWN send, which is worse than either clean outcome. Undo therefore disables itself the moment the command starts, and stays visible while disabled: a control that vanishes re-lays out the popup mid-operation, and a greyed Undo says why cancelling is no longer possible where an absent one looks like it was never offered. The test for this asserts the NEGATIVE property, that committed() never fires after Undo, including after the original countdown would have elapsed. Asserting only that undone() fired would pass against a design that ran the command and threw the result away, which is the whole failure the delay exists to prevent. Removing the close BUTTON is not the same as closing the code path, and the first draft did only the former while its comments claimed otherwise. Escape still reached QDialog::reject(), and close() during the countdown hid the window while leaving the timer running, so the send committed with nothing on screen and the only cancel control destroyed: measured, committed=1 on a dialog the user had dismissed. A never-shown dialog did the same, since close() returns early without reaching done(). That is CLAUDE.md's done(int) trap in the one place it costs mail rather than state. Dismissal is REFUSED before commit rather than treated as an implicit Undo, at the user's decision: a close that silently means cancel overloads one gesture with two meanings, while a refusal leaves Undo as the only way out, which is what the popup's single control already says. done(int) refuses pre-commit and forces Accepted after, closeEvent covers the never-shown route done() cannot see, and Undo passes through both. Task 12 needs no special entry point, since it closes after the send finishes and that is post-commit by definition. A refusal must not read as a hang, so the label says how to leave. Making the hint silent was a mutation that SURVIVED, because the text was written in two places and neutering one was masked by the other; extracting it to one function exposed a real defect behind the wrong green, in that the next tick overwrote the hint 100ms later and the refusal was effectively silent anyway. It is held for 1500ms now, with a test that it survives a tick and still releases. setStage is public and Task 12 passes values into it, so it refuses to wind back to CountingDown after commit rather than trusting its caller with an invariant this class documents as inviolable; the label read "Sending in 0..." and the bar returned to determinate. Both m_committed guards carry tests: removing them left the suite green, so two deliberate safety additions rested on reasoning alone. Every route out is asserted, per the rule that a test using close() while the user uses Cancel covers one route of three: close() shown, close() never-shown, Escape bare and with Shift and Ctrl, reject() direct, and Undo, which must still work or the popup is a trap. The status label is sized to the longest string it can hold in the current language rather than to its content: Italian 'Rimozione della bozza...' is longer than 'Removing draft...', and a label sized to content resizes the popup between stages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FXF741wz4SY7j5dqvAxMU5 --- src/CMakeLists.txt | 1 + src/senddialog.cpp | 318 +++++++++++++++++++++++++++ src/senddialog.h | 145 +++++++++++++ tests/CMakeLists.txt | 1 + tests/test_senddialog.cpp | 468 ++++++++++++++++++++++++++++++++++++++++ translations/qtmaildir_it_IT.ts | 31 +++ 6 files changed, 964 insertions(+) create mode 100644 src/senddialog.cpp create mode 100644 src/senddialog.h create mode 100644 tests/test_senddialog.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 501c276..83981b2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -19,6 +19,7 @@ add_library(qtmaildir_lib STATIC formattoolbar.cpp tagchip.cpp tagcolors.cpp + senddialog.cpp savequerydialog.cpp tagdialog.cpp tagrules.cpp diff --git a/src/senddialog.cpp b/src/senddialog.cpp new file mode 100644 index 0000000..4a36b7b --- /dev/null +++ b/src/senddialog.cpp @@ -0,0 +1,318 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include "senddialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "busyindicator.h" + +namespace { + +// How often the countdown repaints: smooth enough for a draining bar without +// being a busy loop. It is also the resolution of the countdown itself, since +// tick() subtracts exactly this much rather than consulting a clock. Two +// consequences, both deliberate: a delay that is not a multiple of 100 rounds +// UP to one (250 runs for 300ms), and timer slack accumulates rather than +// being corrected against a clock. Drift is irrelevant at this scale, where +// the number is a courtesy pause and nothing downstream measures it. +constexpr int kTickMs = 100; + +// How long the refused-dismissal hint holds the status label. Longer than a +// tick, or the countdown would overwrite it before it could be read and the +// refusal would be silent in practice; short enough that the countdown the +// user is waiting on is not hidden for any meaningful part of its life. +constexpr qint64 kHintMs = 1500; + +} // namespace + +SendDialog::SendDialog(int delayMs, QWidget *parent) + : QDialog(parent) + , m_remainingMs(qMax(0, delayMs)) + , m_totalMs(qMax(0, delayMs)) +{ + setWindowTitle(tr("Sending")); + + // Modal to the composer, not to the application. Sending from one composer + // must not freeze a second composer or the main window. + setWindowModality(Qt::WindowModal); + + // No close button: during the countdown a bare dismissal is ambiguous, + // since it could equally mean "cancel" or "send now", so Undo is the only + // control that states which. This removes the AFFORDANCE only. Escape, + // close() and the window manager all still reach done(), and that override + // is what actually makes a dismissal safe; keyPressEvent() below merely + // spares the user an Escape that would silently undo. Reasoning about this + // flag alone is what left close() committing a send with no window up. + setWindowFlags((windowFlags() | Qt::CustomizeWindowHint) + & ~Qt::WindowCloseButtonHint); + + auto *layout = new QVBoxLayout(this); + + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("sendStatus")); + + // Sized to the LONGEST string it can hold in the current language, not to + // its content. Italian "Rimozione della bozza..." is longer than "Removing + // draft...", so a label sized to whatever it happens to be showing resizes + // the popup between stages. Computed from tr() results at construction, so + // it is correct in whatever language is loaded AT THAT MOMENT. That is + // sufficient here and not in general: main() installs the QTranslator on + // its own stack before any window exists, so no dialog can outlive a + // language change. A runtime language switch would need this recomputed. + const QFontMetrics metrics(m_status->font()); + // The refusal hint is in this list too. It replaces the countdown text in + // the same label, so leaving it out would resize the popup at exactly the + // moment the user is being told the window will not close, which is the + // worst possible time for it to jump. + const QStringList candidates{ + tr("Sending in %1...").arg(99), + tr("Sending..."), + tr("Filing sent copy..."), + tr("Removing draft..."), + tr("Press Undo to stop sending."), + }; + int widest = 0; + for (const QString &candidate : candidates) + widest = qMax(widest, metrics.horizontalAdvance(candidate)); + m_status->setMinimumWidth(widest); + layout->addWidget(m_status); + + m_indicator = new BusyIndicator(this); + m_indicator->setObjectName(QStringLiteral("sendProgress")); + layout->addWidget(m_indicator); + + // Three rows in every state, so nothing reflows: Undo keeps its place and + // its size after it disables rather than vanishing. + auto *buttons = new QHBoxLayout; + buttons->addStretch(); + m_undo = new QPushButton(tr("Undo"), this); + m_undo->setObjectName(QStringLiteral("undoSend")); + buttons->addWidget(m_undo); + layout->addLayout(buttons); + + // Built BEFORE the Undo connection below, which stops it. The lambda would + // read a null m_timer otherwise, and only because nothing can click a + // button mid-constructor does the reverse order happen to survive. + m_timer = new QTimer(this); + m_timer->setObjectName(QStringLiteral("sendCountdown")); + m_timer->setInterval(kTickMs); + connect(m_timer, &QTimer::timeout, this, &SendDialog::tick); + + // Both the button and done() funnel into one place, so the two dismissal + // routes cannot drift into disagreeing about what a cancel does. + connect(m_undo, &QPushButton::clicked, this, [this] { undo(); }); + + if (m_totalMs == 0) { + // Queued rather than immediate, so a caller that connects to + // committed() AFTER constructing the dialog still hears it. Emitting + // from the constructor would send to nobody. + QTimer::singleShot(0, this, &SendDialog::commit); + } else { + setStage(Stage::CountingDown); + m_timer->start(); + } +} + +bool SendDialog::undo() +{ + // Undo is disabled at commit, but a disabled button is a UI property and + // not an invariant. This is the ONE place that can report "nothing was + // sent", so it refuses outright once the command is running rather than + // trusting the button's state. + // + // m_undone is the second half and is NOT redundant: it makes undone() + // fire exactly once however many times this is reached. + if (m_committed || m_undone) + return false; + m_undone = true; + + // The timer stops FIRST. A timer left running commits after the dialog has + // already reported that nothing was sent, which is the one outcome the + // whole delay exists to make impossible. + m_timer->stop(); + m_undo->setEnabled(false); + emit undone(); + + // Undo is the ONE route out before commit, so it is the one caller allowed + // through done()'s refusal. The flag is what distinguishes it from every + // other reject(); it is never cleared, because the dialog is finished. + m_undoing = true; + reject(); + return true; +} + +void SendDialog::refuseDismissal() +{ + // A window that ignores a close reads as a hang, so the refusal says where + // the exit is rather than doing nothing at all. One function because both + // done() and closeEvent() refuse, and two copies of this meant neutering + // either one left the other still setting the text, hiding the regression. + // + // Held for kHintMs, because the countdown's next tick is only kTickMs away + // and would otherwise overwrite the hint before it could be read, leaving + // the refusal effectively silent after all. setStage() honours the hold + // rather than this scheduling a restore, so the countdown keeps running + // underneath and there is no second timer to get out of step. + m_hintUntil = QDateTime::currentMSecsSinceEpoch() + kHintMs; + m_status->setText(tr("Press Undo to stop sending.")); + m_undo->setFocus(); +} + +void SendDialog::keyPressEvent(QKeyEvent *event) +{ + // QDialog maps Escape to reject(). Swallowed WITH ANY MODIFIER: Shift and + // Ctrl variants are the same keystroke as far as intent goes, and letting + // one through would be an undocumented back door to the same dismissal. + // done() would treat it safely as an Undo either way; this just spares the + // user a cancel they did not ask for by reflex. + if (event->key() == Qt::Key_Escape) { + event->accept(); + return; + } + QDialog::keyPressEvent(event); +} + +void SendDialog::done(int result) +{ + // Every dismissal route arrives here, which is the point: close(), the + // window manager, Escape and QDialog's own reject() all converge on + // done(), and guarding any one of them individually leaves the others + // open. Which routes are permitted, and when: + // + // BEFORE COMMIT, nothing closes the dialog except Undo. A close is + // REFUSED, not silently reinterpreted as a cancel: "close means undo" is + // confusing, because the user cannot tell whether dismissing the window + // stopped the send or merely hid it, and the two answers differ by whether + // their mail goes out. The popup carries exactly one control and it says + // what it does. Undo reaches QDialog::done() through m_undoing below. + // + // AFTER COMMIT, the send is in flight and there is nothing left to cancel, + // so any close is honoured. It is forced to Accepted so a caller reading + // result() cannot mistake a running send for a cancelled one. + // + // TASK 12 closes this dialog when the send finishes, and it does so after + // commit by definition, so the ordinary accept()/close() works and needs + // no special entry point. A stray reject() cannot reach the pre-commit + // state at all, which is the property this refusal buys. + if (m_committed) { + QDialog::done(QDialog::Accepted); + return; + } + + if (m_undoing) { + QDialog::done(QDialog::Rejected); + return; + } + + refuseDismissal(); +} + +void SendDialog::closeEvent(QCloseEvent *event) +{ + // Measured against a standalone Qt program, not assumed: close() on a + // dialog that was NEVER SHOWN reaches closeEvent() but returns BEFORE + // done(), so done()'s refusal alone would let that one route through. A + // shown dialog reaches both, and ignoring the event here stops it before + // done() is consulted. + if (!m_committed && !m_undoing) { + event->ignore(); + refuseDismissal(); + return; + } + QDialog::closeEvent(event); +} + +void SendDialog::tick() +{ + m_remainingMs -= kTickMs; + if (m_remainingMs <= 0) { + commit(); + return; + } + setStage(Stage::CountingDown); +} + +void SendDialog::commit() +{ + // Idempotent: a stray tick racing the singleShot must not emit twice. + if (m_committed) + return; + + m_timer->stop(); + m_committed = true; + + // Disabled, never hidden. A greyed Undo says why cancelling is no longer + // possible; an absent one only looks like it was never offered. + m_undo->setEnabled(false); + + setStage(Stage::Sending); + emit committed(); +} + +void SendDialog::setStage(Stage stage) +{ + // The enum is documented "in order", so the class enforces that rather + // than trusting its caller: Task 12 passes values from this public enum, + // and winding back would relabel a running send "Sending in 0..." and + // redraw a full countdown bar under it, offering a cancel that no longer + // exists. Only the backwards step is refused; the forward stages are the + // caller's to drive. + if (m_committed && stage == Stage::CountingDown) + return; + + // The refusal hint outranks the countdown text for as long as it is held. + // Only the countdown is suppressed: a stage change is a real event and + // must always be shown, and commit() clears the hold anyway. + if (stage == Stage::CountingDown + && QDateTime::currentMSecsSinceEpoch() < m_hintUntil) { + m_indicator->setProgress(m_remainingMs, m_totalMs); + return; + } + + switch (stage) { + case Stage::CountingDown: + // Rounded up, so a countdown with 1ms left still reads "1" rather than + // sitting on "0" for a tick. + m_status->setText(tr("Sending in %1...") + .arg((m_remainingMs + 999) / 1000)); + m_indicator->setProgress(m_remainingMs, m_totalMs); + return; + case Stage::Sending: + m_status->setText(tr("Sending...")); + break; + case Stage::FilingSentCopy: + m_status->setText(tr("Filing sent copy...")); + break; + case Stage::RemovingDraft: + m_status->setText(tr("Removing draft...")); + break; + } + + // Everything past the countdown: the duration stops being knowable, so the + // same widget switches from a fraction to an animation. + m_indicator->setBusy(true); +} diff --git a/src/senddialog.h b/src/senddialog.h new file mode 100644 index 0000000..a930c3d --- /dev/null +++ b/src/senddialog.h @@ -0,0 +1,145 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#pragma once + +#include +#include + +class BusyIndicator; +class QLabel; +class QCloseEvent; +class QKeyEvent; +class QPushButton; +class QTimer; + +/// Owns a send from the cancellable countdown through to completion. +/// +/// The delay is where cancelling is SAFE and it is the only place it is. +/// Nothing has reached a server during the countdown, so Undo means genuinely +/// nothing happened. Killing send_command once it runs leaves an UNKNOWN send: +/// the message may have reached the server in full before the kill, which is +/// worse than either clean outcome. So there is no cancel after commit, and +/// isCommitted() is the line between the two. +/// +/// Three rows in every state, so nothing reflows and the window never jumps: +/// a status label, the bar, and Undo. +/// +/// The bar CHANGES MODE, it does not change place. Determinate while the +/// countdown drains, because a countdown has measurable progress; +/// indeterminate once the command starts, because a send does not. +/// +/// Modal to the composer, NOT to the application: sending from one composer +/// must not freeze a second composer or the main window. +/// +/// DISMISSAL IS A THIRD ROUTE TO THE SAME FAILURE, and removing the close +/// button only removes the affordance. Escape, the window manager, close() and +/// QDialog's own machinery all still reach done(); see done() and closeEvent() +/// below, which are the two places that cover them. An earlier revision +/// reasoned about Escape and the titlebar button alone and left close() +/// committing a send with no window on screen. +/// +/// Before commit, Undo is the ONLY way out and every other route is refused. +class SendDialog : public QDialog +{ + Q_OBJECT + +public: + /// \p delayMs of zero skips the countdown and sends at once. + explicit SendDialog(int delayMs, QWidget *parent = nullptr); + + /// The stages, in order. Each sets the label; every stage after the + /// countdown leaves the bar indeterminate. + enum class Stage { CountingDown, Sending, FilingSentCopy, RemovingDraft }; + Q_ENUM(Stage) + + void setStage(Stage stage); + + /// True once the countdown has elapsed and the command has started, after + /// which cancelling is no longer possible. + bool isCommitted() const { return m_committed; } + +signals: + /// The countdown elapsed or was skipped: the caller should start sending. + void committed(); + + /// Undo was pressed during the countdown. NOTHING has been sent. + void undone(); + +protected: + /// Swallows Escape, with any modifiers. QDialog maps it to reject(), and + /// during the countdown a bare dismissal is ambiguous in exactly the way + /// the constructor describes; Undo is the control that says which it means. + void keyPressEvent(QKeyEvent *event) override; + + /// The single choke point for every dismissal route, which is why the + /// close button's removal was not enough on its own: QDialog reaches + /// reject() from the window manager, from close(), and from its own + /// machinery, and all of them arrive here. + /// + /// During the countdown a close is REFUSED. "Close means undo" is + /// confusing: the user cannot tell whether dismissing the window stopped + /// the send or merely hid it, and the two answers differ by whether their + /// mail goes out. Undo is the only way out, which is what the popup's + /// single control already says. After commit any close is honoured, since + /// there is nothing left to cancel, and it is forced to Accepted so a + /// caller reading result() cannot mistake a running send for a cancelled + /// one. Task 12 closes the dialog after the send finishes, which is + /// post-commit by definition and so needs no special entry point. + void done(int result) override; + + /// CLAUDE.md's companion trap: close() on a widget that was never shown + /// returns early WITHOUT reaching done(), so done()'s refusal alone would + /// let exactly that one route through. Refuses on the same terms. + void closeEvent(QCloseEvent *event) override; + +private: + /// The one place that can report "nothing was sent". Returns false, and + /// does nothing at all, once the send has committed. Both the Undo button + /// and every dismissal route funnel through it. + bool undo(); + + /// Shows the hint that Undo is the only way out, and holds it long enough + /// to be read. One function because both refusal sites call it. + void refuseDismissal(); + + void tick(); + void commit(); + + QLabel *m_status = nullptr; + BusyIndicator *m_indicator = nullptr; + QPushButton *m_undo = nullptr; + QTimer *m_timer = nullptr; + + int m_remainingMs = 0; + int m_totalMs = 0; + bool m_committed = false; + + /// Set by the first undo(), so undone() is emitted exactly once however + /// many dismissal routes fire. A shown dialog's close() reaches BOTH + /// closeEvent() and done(). + bool m_undone = false; + + /// Deadline until which the refusal hint holds the status label against + /// the countdown's own text. Zero when no hint is showing. + qint64 m_hintUntil = 0; + + /// Set only by undo(), and what lets that one route through done()'s + /// pre-commit refusal. Every other reject() is turned away. + bool m_undoing = false; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b28bf6f..fc19b01 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,7 @@ add_qtmaildir_test(draftstore) add_qtmaildir_test(messagesender) add_qtmaildir_test(composecontext) add_qtmaildir_test(formattoolbar) +add_qtmaildir_test(senddialog) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up diff --git a/tests/test_senddialog.cpp b/tests/test_senddialog.cpp new file mode 100644 index 0000000..ac8c234 --- /dev/null +++ b/tests/test_senddialog.cpp @@ -0,0 +1,468 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include + +#include +#include +#include + +#include "busyindicator.h" +#include "senddialog.h" + +class TestSendDialog : public QObject +{ + Q_OBJECT + +private slots: + void theBarIsDeterminateWhileCountingDown(); + void theCountdownCommitsWhenItElapses(); + void aZeroDelayCommitsImmediately(); + void undoDuringTheCountdownEmitsUndoneAndNeverCommits(); + void undoDisablesItselfOnceTheCommandStarts(); + void theBarBecomesIndeterminateWhenSending(); + void undoStaysVisibleAfterItDisables(); + void theStatusLabelIsWideEnoughForEveryStage(); + void closingDuringTheCountdownIsRefused(); + void closingADialogThatWasNeverShownIsAlsoRefused(); + void theRefusalHintSurvivesTheNextCountdownTick(); + void rejectDuringTheCountdownIsRefused(); + void escapeDuringTheCountdownIsRefused(); + void undoIsTheOneRouteThatClosesBeforeCommit(); + void closingAfterCommitReportsAcceptedAndDoesNotUndo(); + void undoAfterCommitIsRefused(); + void everyStageSetsItsOwnLabelAndLeavesTheBarBusy(); + void windingBackToCountingDownAfterCommitIsRefused(); +}; + +void TestSendDialog::theBarIsDeterminateWhileCountingDown() +{ + // A countdown has measurable progress, so the bar drains rather than + // animating. This is the half of BusyIndicator MainWindow never uses: the + // status bar's sync indicator is indeterminate for its whole life. + // + // A generous delay so the assertion cannot race the countdown's own end, + // which would flip the bar to indeterminate for a legitimate reason and + // report a defect that is not there. + SendDialog dialog(5000); + dialog.show(); + + auto *indicator = dialog.findChild( + QStringLiteral("sendProgress")); + QVERIFY2(indicator, "the dialog has no BusyIndicator named sendProgress"); + QVERIFY2(indicator->isDeterminate(), + "the bar was animating during a countdown that has a known end"); +} + +void TestSendDialog::theCountdownCommitsWhenItElapses() +{ + // A short delay rather than waiting out the shipped default: what is being + // tested is that the countdown ends in a commit, not how long it is. + SendDialog dialog(150); + QSignalSpy spy(&dialog, &SendDialog::committed); + dialog.show(); + + QVERIFY2(spy.wait(3000), "the countdown never committed"); + QCOMPARE(spy.count(), 1); + QVERIFY(dialog.isCommitted()); +} + +void TestSendDialog::aZeroDelayCommitsImmediately() +{ + // send_delay_ms = 0 sends at once, for anyone who finds the delay + // irritating. It must still be a queued commit rather than one inside the + // constructor, or a caller connecting to committed() after constructing the + // dialog would never hear it. + SendDialog dialog(0); + QSignalSpy spy(&dialog, &SendDialog::committed); + dialog.show(); + + QVERIFY2(spy.wait(1000), "a zero delay never committed"); + QCOMPARE(spy.count(), 1); + QVERIFY(dialog.isCommitted()); +} + +void TestSendDialog::undoDuringTheCountdownEmitsUndoneAndNeverCommits() +{ + // THE test for this feature, and the property that matters is the NEGATIVE + // one. A test asserting only that undone() fired would pass against a + // design that started the send anyway and threw the result away, which is + // the whole failure the delay exists to prevent. Nothing has reached a + // server during the countdown, so Undo must mean that nothing happened. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY2(undo, "the dialog has no button named undoSend"); + QVERIFY2(undo->isEnabled(), "Undo was dead during the countdown"); + + undo->click(); + + QCOMPARE(undoneSpy.count(), 1); + QCOMPARE(committedSpy.count(), 0); + + // Past the original deadline. A timer left running would commit here, after + // the dialog has already reported that nothing was sent. + QTest::qWait(2500); + QVERIFY2(committedSpy.count() == 0, + "the countdown committed after Undo was pressed"); +} + +void TestSendDialog::undoDisablesItselfOnceTheCommandStarts() +{ + // There is no cancel after commit. Killing send_command once it runs leaves + // an UNKNOWN send: the message may have reached the server in full before + // the kill, which is worse than either clean outcome. + SendDialog dialog(100); + dialog.show(); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY(undo); + + QSignalSpy spy(&dialog, &SendDialog::committed); + QVERIFY2(spy.wait(3000), "the countdown never committed"); + + QVERIFY2(!undo->isEnabled(), + "Undo was still live after the send command started"); +} + +void TestSendDialog::theBarBecomesIndeterminateWhenSending() +{ + // The bar CHANGES MODE, it does not change place: a send has no measurable + // progress, so the same widget stops drawing a fraction and starts + // animating, and nothing in the popup reflows. + SendDialog dialog(100); + dialog.show(); + + auto *indicator = dialog.findChild( + QStringLiteral("sendProgress")); + QVERIFY(indicator); + QVERIFY(indicator->isDeterminate()); + + dialog.setStage(SendDialog::Stage::Sending); + QVERIFY2(!indicator->isDeterminate(), + "the bar kept the countdown's fraction while sending"); +} + +void TestSendDialog::undoStaysVisibleAfterItDisables() +{ + // A control that vanishes re-lays out the popup mid-operation, and a greyed + // Undo says WHY cancelling is no longer possible where an absent one only + // looks like it was never offered. + SendDialog dialog(100); + dialog.show(); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY(undo); + + QSignalSpy spy(&dialog, &SendDialog::committed); + QVERIFY2(spy.wait(3000), "the countdown never committed"); + + QVERIFY2(undo->isVisibleTo(&dialog), + "Undo disappeared instead of greying out"); +} + +void TestSendDialog::theStatusLabelIsWideEnoughForEveryStage() +{ + // The label is sized to the LONGEST string it can hold in the current + // language, not to its content, so the popup does not resize between + // stages. Asserted against the metrics of the strings themselves rather + // than a constant, so it holds in whatever language is loaded. + SendDialog dialog(2000); + dialog.show(); + + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + QVERIFY2(status, "the dialog has no label named sendStatus"); + + const QFontMetrics metrics(status->font()); + const QStringList candidates{ + SendDialog::tr("Sending in %1...").arg(99), + SendDialog::tr("Sending..."), + SendDialog::tr("Filing sent copy..."), + SendDialog::tr("Removing draft..."), + SendDialog::tr("Press Undo to stop sending."), + }; + int widest = 0; + for (const QString &candidate : candidates) + widest = qMax(widest, metrics.horizontalAdvance(candidate)); + + QVERIFY2(status->minimumWidth() >= widest, + "the status label was sized to its content, so the popup will " + "resize when a longer stage name arrives"); +} + +void TestSendDialog::closingDuringTheCountdownIsRefused() +{ + // The same failure as the Undo test, reached by a different door. Removing + // the close BUTTON removes the visual affordance, not the code path: the + // window manager, close() and QDialog's own machinery all still reach + // done(). Left unguarded, close() hides the window and leaves the timer + // running, so the send starts with no window on screen and the only cancel + // control destroyed. + // + // The close is REFUSED rather than reinterpreted as an Undo, at the user's + // call: "close means undo is confusing", because a dismissed window cannot + // tell you whether it stopped the send or merely hid it. So the dialog + // stays up, the send stays scheduled, and Undo remains the only way out. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + QVERIFY2(!dialog.close(), "close() during the countdown was accepted"); + + QVERIFY2(dialog.isVisible(), + "the dialog vanished on a close it was supposed to refuse"); + QVERIFY2(undoneSpy.count() == 0, + "a refused close silently undid the send anyway"); + + // Refusing must not be silent: a window that ignores a close reads as a + // hang, so the popup has to say where the exit is. + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + QVERIFY(status); + QVERIFY2(status->text().contains(QStringLiteral("Undo")), + "a refused close gave the user no hint that Undo is the way out"); + + // The send was never cancelled, so it still goes out. That is the whole + // point of refusing rather than undoing. + QVERIFY2(committedSpy.wait(3000), + "the refused close cancelled the send after all"); +} + +void TestSendDialog::closingADialogThatWasNeverShownIsAlsoRefused() +{ + // CLAUDE.md's documented companion trap: close() on a widget that was + // never shown returns early WITHOUT reaching done(), so a refusal written + // only in done() would miss this one route entirely. The countdown is + // running either way, because it starts in the constructor rather than on + // show(). Refused on the same terms as the shown case. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + + QVERIFY2(!dialog.close(), + "close() on an unshown dialog slipped past the refusal"); + QVERIFY2(undoneSpy.count() == 0, + "closing an unshown dialog undid the send"); + + QVERIFY2(committedSpy.wait(3000), + "the unshown dialog's send was cancelled by a refused close"); +} + +void TestSendDialog::theRefusalHintSurvivesTheNextCountdownTick() +{ + // Without a hold the hint lives for one tick, which is 100ms, and the + // countdown text overwrites it before it can be read. A refusal the user + // cannot see is a window that ignores them, which reads as a hang, so the + // hold is what makes the refusal honest rather than decorative. + SendDialog dialog(5000); + dialog.show(); + + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + QVERIFY(status); + + dialog.close(); + const QString hint = status->text(); + QVERIFY2(hint.contains(QStringLiteral("Undo")), "no hint on refusal"); + + // Several ticks later, well past the point the countdown would have + // reclaimed the label. + QTest::qWait(500); + QCOMPARE(status->text(), hint); + + // And it does eventually give the label back, or the countdown would be + // hidden for the rest of its life. + QTest::qWait(1500); + QVERIFY2(status->text() != hint, + "the hint never released the label back to the countdown"); +} + +void TestSendDialog::rejectDuringTheCountdownIsRefused() +{ + // reject() is the route neither close() nor Escape goes through directly, + // and it is the one a caller reaches for. CLAUDE.md's rule is that every + // route out gets asserted: "a test used close() and the user used Cancel" + // is the documented way one of three gets missed. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + dialog.reject(); + + QVERIFY2(dialog.isVisible(), "reject() dismissed the countdown"); + QVERIFY2(undoneSpy.count() == 0, "reject() undid the send"); + QVERIFY2(committedSpy.wait(3000), "reject() cancelled the send after all"); +} + +void TestSendDialog::escapeDuringTheCountdownIsRefused() +{ + // Escape is QDialog's built-in reject(), and swallowing it in + // keyPressEvent is only the first line: done() refuses it too, so the + // dialog is safe even if the key handler is ever removed. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + QTest::keyClick(&dialog, Qt::Key_Escape); + QVERIFY2(dialog.isVisible(), "Escape dismissed the countdown"); + + // With modifiers too, so neither is an undocumented back door. + QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ShiftModifier); + QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ControlModifier); + QVERIFY2(dialog.isVisible(), "a modified Escape dismissed the countdown"); + + QVERIFY2(undoneSpy.count() == 0, "Escape undid the send"); + QVERIFY2(committedSpy.wait(3000), "Escape cancelled the send after all"); +} + +void TestSendDialog::undoIsTheOneRouteThatClosesBeforeCommit() +{ + // The counterpart to the four refusals above: having refused every other + // way out, the one remaining control must actually work, or the popup is + // a trap with no exit at all. + SendDialog dialog(2000); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY(undo); + undo->click(); + + QCOMPARE(undoneSpy.count(), 1); + QVERIFY2(!dialog.isVisible(), "Undo did not close the dialog"); + QCOMPARE(dialog.result(), int(QDialog::Rejected)); +} + +void TestSendDialog::closingAfterCommitReportsAcceptedAndDoesNotUndo() +{ + // After commit there is nothing to undo, so closing is permitted. What it + // must NOT do is report Rejected: a caller inspecting result() would read + // a send that is running as one that was cancelled, and undone() must stay + // silent because the message is on its way. + SendDialog dialog(100); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + dialog.show(); + + QVERIFY2(committedSpy.wait(3000), "the countdown never committed"); + QVERIFY(dialog.isCommitted()); + + dialog.close(); + + QCOMPARE(undoneSpy.count(), 0); + QVERIFY2(dialog.result() != QDialog::Rejected, + "closing a committed dialog reported the send as cancelled"); +} + +void TestSendDialog::undoAfterCommitIsRefused() +{ + // Undo is disabled at commit, but a disabled button is a UI property, not + // an invariant. This asserts the handler's own guard, so a future change + // that re-enables the button cannot turn it back into a claim that nothing + // was sent while send_command is already running. + SendDialog dialog(100); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + QVERIFY2(committedSpy.wait(3000), "the countdown never committed"); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY(undo); + + // Deliberately re-enabled, to reach the handler that the disabled state + // would otherwise hide. This is the mutation a future edit could make by + // accident; the guard behind it is what this test is for. + undo->setEnabled(true); + undo->click(); + + QVERIFY2(undoneSpy.count() == 0, + "Undo claimed nothing was sent after the send command started"); +} + +void TestSendDialog::everyStageSetsItsOwnLabelAndLeavesTheBarBusy() +{ + // Walks all four, because a break accidentally deleted from one case would + // fall through to the next and nothing else would notice. FilingSentCopy + // and RemovingDraft are also the two whose Italian strings drove the whole + // label-width design, so leaving them unexercised would test the sizing of + // strings nothing ever displays. + SendDialog dialog(2000); + dialog.show(); + + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + auto *indicator = dialog.findChild( + QStringLiteral("sendProgress")); + QVERIFY(status); + QVERIFY(indicator); + + const QString countingDown = status->text(); + QVERIFY2(!countingDown.isEmpty(), "the countdown showed no text"); + QVERIFY(indicator->isDeterminate()); + + QStringList seen; + const QVector stages{ + SendDialog::Stage::Sending, + SendDialog::Stage::FilingSentCopy, + SendDialog::Stage::RemovingDraft, + }; + for (SendDialog::Stage stage : stages) { + dialog.setStage(stage); + QVERIFY2(!status->text().isEmpty(), "a stage set no text at all"); + QVERIFY2(!indicator->isDeterminate(), + "a post-countdown stage left the bar drawing a fraction"); + seen << status->text(); + } + + // Distinct from each other and from the countdown: a fallthrough would + // show the following stage's text and collapse two of these into one. + seen << countingDown; + QCOMPARE(QSet(seen.begin(), seen.end()).size(), seen.size()); +} + +void TestSendDialog::windingBackToCountingDownAfterCommitIsRefused() +{ + // setStage() is public and Task 12 passes values from the public enum. The + // enum is documented "in order", so the class enforces that itself rather + // than trusting its caller: winding back would relabel a running send + // "Sending in 0..." and redraw a full countdown bar under it, offering a + // cancel that no longer exists. + SendDialog dialog(100); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + dialog.show(); + + QVERIFY2(committedSpy.wait(3000), "the countdown never committed"); + + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + auto *indicator = dialog.findChild( + QStringLiteral("sendProgress")); + const QString sending = status->text(); + + dialog.setStage(SendDialog::Stage::CountingDown); + + QCOMPARE(status->text(), sending); + QVERIFY2(!indicator->isDeterminate(), + "the bar drew a countdown fraction over a running send"); +} + +QTEST_MAIN(TestSendDialog) +#include "test_senddialog.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index a45b4f2..83ac087 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -1380,6 +1380,37 @@ Esiste già una ricerca salvata di nome '%1' e verrà sostituita. + + SendDialog + + Sending + Invio in corso + + + Sending in %1... + Invio tra %1... + + + Sending... + Invio in corso... + + + Filing sent copy... + Archiviazione della copia inviata... + + + Removing draft... + Rimozione della bozza... + + + Press Undo to stop sending. + Premi Annulla per fermare l'invio. + + + Undo + Annulla + + SyncPhaseTracker -- cgit v1.2.3