diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_senddialog.cpp | 468 |
2 files changed, 469 insertions, 0 deletions
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. <danix@danix.xyz> + * + * 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 <QtTest> + +#include <QLabel> +#include <QPushButton> +#include <QSet> + +#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<BusyIndicator *>( + 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<QPushButton *>(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<QPushButton *>(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<BusyIndicator *>( + 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<QPushButton *>(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<QLabel *>(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<QLabel *>(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<QLabel *>(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<QPushButton *>(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<QPushButton *>(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<QLabel *>(QStringLiteral("sendStatus")); + auto *indicator = dialog.findChild<BusyIndicator *>( + 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<SendDialog::Stage> 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<QString>(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<QLabel *>(QStringLiteral("sendStatus")); + auto *indicator = dialog.findChild<BusyIndicator *>( + 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" |
