aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt6
-rw-r--r--tests/test_composewindow.cpp196
2 files changed, 202 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 5938aeb..830e2b2 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -85,6 +85,12 @@ add_qtmaildir_test(translations)
target_compile_definitions(test_translations PRIVATE
TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations")
+# One test reads composewindow.cpp itself, to assert that m_dirty has a single
+# writer. The property is structural, so no runtime probe can see it: a
+# mutation restoring a direct assignment in the send path left the suite green.
+target_compile_definitions(test_composewindow PRIVATE
+ SOURCE_DIR="${CMAKE_SOURCE_DIR}")
+
# The notmuch hooks (assets/hooks/), which are Python rather than C++ and are
# therefore registered directly rather than through add_qtmaildir_test().
#
diff --git a/tests/test_composewindow.cpp b/tests/test_composewindow.cpp
index 472c103..68ec4d4 100644
--- a/tests/test_composewindow.cpp
+++ b/tests/test_composewindow.cpp
@@ -23,7 +23,10 @@
#include <QMenu>
#include <QPlainTextEdit>
#include <QSignalSpy>
+#include <QLabel>
+#include <QRegularExpression>
#include <QTemporaryDir>
+#include <QTimer>
#include <QTextStream>
#include <QToolButton>
@@ -49,6 +52,11 @@ private slots:
void changingTheAccountStopsFollowingOnceTheSwitchIsUsed();
void aResumedDraftDoesNotReseedOnAnAccountChange();
void savingADraftEmitsItsPathAndTheReplacedOne();
+ void anUnsavedEditIsAnnouncedInBothPlaces();
+ void aSavedDraftReportsItAndClearsTheCue();
+ void aSentMessageLeavesNoUnsavedCue();
+ void onlyTheSetterWritesTheDirtyFlag();
+ void theAgeLineFollowsTheClock();
private:
/// A config pointing at a signatures directory holding \p files, with one
@@ -62,6 +70,9 @@ private:
/// non-void function. A void helper keeps the check and sidesteps that.
void writeFile(const QString &path, const QString &content);
+ /// A config whose one account has a drafts folder, so a save can write.
+ Config configWithDrafts();
+
QTemporaryDir *m_dir = nullptr;
QString m_signatureDir;
};
@@ -447,5 +458,190 @@ void TestComposeWindow::savingADraftEmitsItsPathAndTheReplacedOne()
QVERIFY2(second != first, "a rewrite reused the old filename");
}
+/// A helper for the status-bar tests: a config whose account has a drafts
+/// folder, which makeConfig() deliberately does not set.
+Config TestComposeWindow::configWithDrafts()
+{
+ const QString confPath = m_dir->path() + QStringLiteral("/qtmaildir.conf");
+ QString conf;
+ {
+ QTextStream out(&conf);
+ out << "[account.work]\n"
+ << "name = Someone\n"
+ << "address = someone@example.org\n"
+ << "maildir = work\n"
+ << "drafts = Drafts\n"
+ << "send_command = /bin/cat\n";
+ }
+ writeFile(confPath, conf);
+
+ Config config;
+ config.load(confPath);
+ return config;
+}
+
+/// Both cues answer the same question and must agree. The status label is
+/// what the user reads while typing; the title marker is what they see when
+/// the composer is behind another window.
+void TestComposeWindow::anUnsavedEditIsAnnouncedInBothPlaces()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+
+ // Seeding is not an edit: the constructor clears the flag after filling
+ // the fields, so a composer nobody has typed into is clean.
+ auto *unsaved = window.findChild<QLabel *>(QStringLiteral("unsavedCue"));
+ QVERIFY(unsaved);
+ QVERIFY2(unsaved->isHidden(), "a freshly opened composer is not dirty");
+ QVERIFY2(!window.isWindowModified(),
+ "a freshly opened composer must not claim unsaved edits");
+
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Something typed."));
+
+ QVERIFY2(!unsaved->isHidden(), "the status cue must appear on an edit");
+ QVERIFY2(window.isWindowModified(),
+ "the title marker must appear on an edit");
+}
+
+/// The gap item 160 exists to close: a successful save said nothing at all.
+void TestComposeWindow::aSavedDraftReportsItAndClearsTheCue()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ auto *unsaved = window.findChild<QLabel *>(QStringLiteral("unsavedCue"));
+ auto *age = window.findChild<QLabel *>(QStringLiteral("draftAge"));
+ QVERIFY(unsaved);
+ QVERIFY(age);
+
+ QVERIFY2(age->text().isEmpty(),
+ "nothing has been saved yet, so there is no age to report");
+
+ body->setPlainText(QStringLiteral("First revision."));
+ QVERIFY(window.saveDraftNow());
+
+ QVERIFY2(unsaved->isHidden(), "a save must clear the unsaved cue");
+ QVERIFY2(!window.isWindowModified(),
+ "a save must clear the title marker");
+ QVERIFY2(!age->text().isEmpty(), "a save must be reported");
+}
+
+/// The send path clears the flag WITHOUT saving a draft, and it is one of the
+/// four sites that write it. A cue hung off the save alone would leave a sent
+/// message claiming unsaved edits on the way out.
+void TestComposeWindow::aSentMessageLeavesNoUnsavedCue()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Outgoing."));
+ QVERIFY(window.isWindowModified());
+
+ // What the send handler does on success, without running a real send.
+ window.markClean();
+
+ auto *unsaved = window.findChild<QLabel *>(QStringLiteral("unsavedCue"));
+ QVERIFY(unsaved);
+ QVERIFY2(unsaved->isHidden(), "a sent message has no unsaved edits");
+ QVERIFY2(!window.isWindowModified(),
+ "a sent message must not claim unsaved edits");
+}
+
+/// The test above drives markClean() directly, which proves what the SETTER
+/// does and nothing about whether the send path calls it: a mutation putting
+/// `m_dirty = false` back into the send handler left the whole suite green,
+/// measured. That is CLAUDE.md's "a probe pointed at the wrong object".
+///
+/// The property that actually matters is structural, so it is asserted
+/// structurally: m_dirty has ONE writer. Four of the seven sites that used to
+/// assign it clear it, and only two of those are a save, so a cue hung off
+/// the save path alone silently missed the constructor and the send.
+void TestComposeWindow::onlyTheSetterWritesTheDirtyFlag()
+{
+ QFile source(QStringLiteral(SOURCE_DIR "/src/composewindow.cpp"));
+ QVERIFY2(source.open(QIODevice::ReadOnly | QIODevice::Text),
+ qPrintable(source.errorString()));
+ const QStringList lines =
+ QString::fromUtf8(source.readAll()).split(QLatin1Char('\n'));
+ source.close();
+
+ // A guard against the probe itself rotting: if the member is ever
+ // renamed, this test must fail rather than quietly verify nothing.
+ QVERIFY2(lines.join(QLatin1Char('\n')).contains(QStringLiteral("m_dirty")),
+ "m_dirty is gone; this test needs updating, not deleting");
+
+ QStringList offenders;
+ for (int i = 0; i < lines.size(); ++i) {
+ const QString line = lines.at(i);
+ // An assignment, not a read: `m_dirty =` but not `m_dirty ==`.
+ static const QRegularExpression assignment(
+ QStringLiteral("\\bm_dirty\\s*=[^=]"));
+ if (!assignment.match(line).hasMatch())
+ continue;
+ // The one legitimate writer.
+ if (line.contains(QStringLiteral("m_dirty = dirty")))
+ continue;
+ offenders.append(QStringLiteral("%1: %2").arg(i + 1).arg(line.trimmed()));
+ }
+
+ QVERIFY2(offenders.isEmpty(),
+ qPrintable(QStringLiteral(
+ "m_dirty must only be written by setDirty(), or the status "
+ "cue and the title marker drift from it. Offending lines:\n%1")
+ .arg(offenders.join(QLatin1Char('\n')))));
+}
+
+/// The age changes with no edit to drive it, so it needs a tick of its own.
+/// Asserting on the TEXT changing rather than on a wording, which is
+/// translated and would pin the test to one locale.
+void TestComposeWindow::theAgeLineFollowsTheClock()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("First revision."));
+ QVERIFY(window.saveDraftNow());
+
+ auto *age = window.findChild<QLabel *>(QStringLiteral("draftAge"));
+ QVERIFY(age);
+ const QString justSaved = age->text();
+ QVERIFY(!justSaved.isEmpty());
+
+ // Driven rather than waited for: a real wait would put seconds into the
+ // suite for a label that reads in tens of them.
+ auto *tick = window.findChild<QTimer *>(QStringLiteral("draftAgeTick"));
+ QVERIFY2(tick, "the age needs a tick of its own; an edit cannot drive it");
+ QVERIFY(tick->isActive());
+
+ window.reportDraftAgeFor(90);
+ QVERIFY2(age->text() != justSaved,
+ "the age line must move as the clock does");
+}
+
QTEST_MAIN(TestComposeWindow)
#include "test_composewindow.moc"