aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-29 11:25:02 +0200
committerDanilo M. <danix@danix.xyz>2026-08-29 11:25:02 +0200
commit3fd999907ae8344f76ee4e5be1ac278a26f452ca (patch)
tree17c01d44daee0147ecaca6cccd393471e6379cf1 /tests
parent8c78dd139a77e896c72b7fc8b799af3c0df34344 (diff)
downloadqtmaildir-3fd999907ae8344f76ee4e5be1ac278a26f452ca.tar.gz
qtmaildir-3fd999907ae8344f76ee4e5be1ac278a26f452ca.zip
feat: have the sync script report what it did
Item 174, and half of item 125. The premise was corrected before any code. The note asks for an external `notmuch new` to clear the pending count; it must not. That count means tag mutations not yet known to have reached the MAIL STORE, which is the server: an edit is in notmuch the moment it is made, and what is outstanding is mbsync pushing the renamed Maildir files. `notmuch new` re-indexes local files and pushes nothing, so clearing on it would tell the user their work was safe to quit on while it was still local. The entry's own proposal to watch notmuch_database_get_revision() was rejected for the same reason: a revision moves when mail ARRIVES too, and in neither case does it say anything about the server. What was actually wrong was the reporting channel. The application inferred a finished run from an inode in /proc/locks and from grepping the log for its RUN END banner, which made a human-readable line into wire format and could not say WHICH channels a run carried. The local sync path has always narrowed its clear to the accounts it carried; the external path could not, and cleared everything, so an edit to an account a run never touched was reported as delivered. So the script reports instead of leaving evidence to be inferred. It writes ~/.local/state/qtmaildir/syncstatus.json atomically at the end of every run, including a skip, naming the channels, both exit statuses and a state of ok, failed or skipped. MailSync::readStatus() reads it, MainWindow prefers it over the log banner and narrows the clear through Account::syncChannel(). A skipped run clears nothing, which is item 125's first half: the application can now see that a run happened and carried nothing. The log banner and lastRunOutcome() stay as the fallback for a missing file, which is what a first run after upgrading looks like. This is the user's own framing of the scope: the script was written for another system and adapted, and is now qtmaildir's only consumer, so it serves the application rather than the reverse. Two facts made it safe to act on: their crontab runs mailsync.sh and nothing else touches mail, and ~/bin/mailsync.sh is a symlink into this repo, so an edit is live on the next tick. Two bugs found while wiring it in, both recorded in the closed item. A test read the developer's real sync state, twice: a [sync] section naming only `log` leaves syncStatus() defaulting to the real file, so two tests asserting that a FAILED run leaves the count alone read the last real cron run, found ok, and cleared. Pinning only `status` has the mirror problem. noSyncTestReadsTheRealSyncState() is the guard, modelled on noTestCanSeeTheRealLockTable(). And Qt::ISODate carries no milliseconds. The status file is preferred only when it describes THIS run, compared against when the lock appeared, so a stale success cannot outrank a fresh failure; but the script writes date -Iseconds, and a round trip of "now" comes back 329 ms behind, measured. A fast sync's own file therefore parsed as stale and fell back to the log, with nothing failing to say so. One second of slack matches the precision the format carries. Design: docs/superpowers/specs/2026-08-29-sync-status-file-design.md Suite: 43 of 44, with undoMovesTheMessageBack failing as it does on master (item 136).
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt20
-rw-r--r--tests/test_mailsync.cpp190
-rw-r--r--tests/test_mainwindow.cpp186
3 files changed, 395 insertions, 1 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index e67d7e0..24da10b 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -96,6 +96,13 @@ target_compile_definitions(test_translations PRIVATE
target_compile_definitions(test_composewindow PRIVATE
SOURCE_DIR="${CMAKE_SOURCE_DIR}")
+# One test RUNS assets/mailsync.sh with stubbed binaries and reads the status
+# file it wrote, which is the guard against the bash writer and the C++ reader
+# drifting apart. Every other test in that file writes what it believes the
+# script emits, and would go on passing after the script changed.
+target_compile_definitions(test_mailsync 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().
#
@@ -112,6 +119,19 @@ if(Python3_Interpreter_FOUND)
COMMAND ${Python3_EXECUTABLE}
${CMAKE_SOURCE_DIR}/assets/hooks/test_${hook_test}.py)
endforeach()
+
+ # The sync script, which lives in assets/ rather than assets/hooks/ and so
+ # is registered on its own rather than through the loop above.
+ #
+ # It belongs in the suite for a stronger reason than the hooks do: the
+ # user's ~/bin/mailsync.sh is a SYMLINK to assets/mailsync.sh, so an edit
+ # here is live on their next cron tick with no deploy step in between. The
+ # test stubs mbsync and notmuch, points HOME at a temp directory and
+ # redirects the lock file, so it can neither reach the network nor take the
+ # real sync lock, which is the mutex their cron run uses.
+ add_test(NAME mailsync_script
+ COMMAND ${Python3_EXECUTABLE}
+ ${CMAKE_SOURCE_DIR}/assets/test_mailsync.py)
else()
message(STATUS "Python3 not found: the notmuch hook tests will not run")
endif()
diff --git a/tests/test_mailsync.cpp b/tests/test_mailsync.cpp
index 45c7767..eb4990e 100644
--- a/tests/test_mailsync.cpp
+++ b/tests/test_mailsync.cpp
@@ -65,6 +65,16 @@ private slots:
void lastRunOutcomeReadsATailOfAHugeLog();
void lastRunOutcomeReadsABannerTheScriptActuallyWrote();
+ void readStatusReadsAnOkRun();
+ void readStatusReadsTheChannelsARunCarried();
+ void readStatusReadsAFullRunAsEveryAccount();
+ void readStatusReadsASkippedRun();
+ void readStatusOnAMissingFileIsUnknown();
+ void readStatusOnRubbishIsUnknown();
+ void readStatusOnATruncatedFileIsUnknown();
+ void readStatusOnAnUnknownVersionIsUnknown();
+ void readStatusReadsAFileTheScriptActuallyWrote();
+
private:
/// Writes an executable shell script into the temp dir, returns its path.
QString makeScript(const QString &name, const QString &body);
@@ -466,6 +476,186 @@ void TestMailSync::aChannelNameIsNotLetInVerbatim()
// script writes into its log. These tests pin the parser against the exact
// shape assets/mailsync.sh emits.
+// Item 174. The status file is what the application READS, as against the log,
+// which is for a human. These pin the reader against the exact shape
+// assets/mailsync.sh writes; assets/test_mailsync.py pins the writer against
+// the same shape from the other side, and the two agree by test rather than by
+// shared code, exactly as the two rules.json readers do.
+
+static QString writeStatus(const QDir &dir, const QString &name,
+ const QByteArray &contents)
+{
+ const QString path = dir.filePath(name);
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return QString();
+ file.write(contents);
+ file.close();
+ return path;
+}
+
+void TestMailSync::readStatusReadsAnOkRun()
+{
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("ok.json"),
+ R"({"version": 1, "run_id": "2026-08-29T10:00:00+02:00",
+ "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:12+02:00",
+ "state": "ok", "channels": ["-a"],
+ "mbsync_status": 0, "notmuch_status": 0})");
+ QVERIFY(!path.isEmpty());
+
+ const SyncStatus status = MailSync::readStatus(path);
+ QCOMPARE(status.state, SyncState::Ok);
+ QVERIFY(status.ended.isValid());
+}
+
+void TestMailSync::readStatusReadsTheChannelsARunCarried()
+{
+ // The whole reason this file exists rather than the log's banner: the
+ // application clears its pending count for the accounts a run carried, and
+ // the log could never say which those were.
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("channels.json"),
+ R"({"version": 1, "run_id": "r", "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:12+02:00", "state": "ok",
+ "channels": ["work", "personal"],
+ "mbsync_status": 0, "notmuch_status": 0})");
+ QVERIFY(!path.isEmpty());
+
+ const SyncStatus status = MailSync::readStatus(path);
+ QCOMPARE(status.state, SyncState::Ok);
+ QCOMPARE(status.channels,
+ (QStringList{ QStringLiteral("work"), QStringLiteral("personal") }));
+ QVERIFY(!status.everyChannel);
+}
+
+void TestMailSync::readStatusReadsAFullRunAsEveryAccount()
+{
+ // "-a" is not a channel name and must not be matched against one: a full
+ // run carries every account, so a reader treating it as an unknown channel
+ // would clear nothing on exactly the run that carried everything.
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("full.json"),
+ R"({"version": 1, "run_id": "r", "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:12+02:00", "state": "ok",
+ "channels": ["-a"], "mbsync_status": 0, "notmuch_status": 0})");
+ QVERIFY(!path.isEmpty());
+
+ const SyncStatus status = MailSync::readStatus(path);
+ QVERIFY2(status.everyChannel, "a -a run was not read as every account");
+}
+
+void TestMailSync::readStatusReadsASkippedRun()
+{
+ // Item 125. A skipped run releases a lock it never took, so the spinner had
+ // nothing to clear on. It is a terminal state, and distinct from a failure:
+ // the other run is doing the work.
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("skip.json"),
+ R"({"version": 1, "run_id": "r", "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:00+02:00", "state": "skipped",
+ "channels": ["-a"], "mbsync_status": -1, "notmuch_status": -1})");
+ QVERIFY(!path.isEmpty());
+
+ const SyncStatus status = MailSync::readStatus(path);
+ QCOMPARE(status.state, SyncState::Skipped);
+}
+
+void TestMailSync::readStatusOnAMissingFileIsUnknown()
+{
+ QCOMPARE(MailSync::readStatus(m_dir.filePath(QStringLiteral("nope.json"))).state,
+ SyncState::Unknown);
+ QCOMPARE(MailSync::readStatus(QString()).state, SyncState::Unknown);
+}
+
+void TestMailSync::readStatusOnRubbishIsUnknown()
+{
+ const QString path = writeStatus(QDir(m_dir.path()),
+ QStringLiteral("rubbish.json"),
+ "this is not json at all\n");
+ QVERIFY(!path.isEmpty());
+ QCOMPARE(MailSync::readStatus(path).state, SyncState::Unknown);
+}
+
+void TestMailSync::readStatusOnATruncatedFileIsUnknown()
+{
+ // The script writes atomically through a temp file and mv precisely so this
+ // cannot happen, but a reader that trusts that is one filesystem away from
+ // being wrong. Unknown changes no state, so a torn read is harmless.
+ const QString path = writeStatus(QDir(m_dir.path()),
+ QStringLiteral("torn.json"),
+ R"({"version": 1, "state": "o)");
+ QVERIFY(!path.isEmpty());
+ QCOMPARE(MailSync::readStatus(path).state, SyncState::Unknown);
+}
+
+void TestMailSync::readStatusOnAnUnknownVersionIsUnknown()
+{
+ // Refused rather than guessed at, the rule the rules file already follows:
+ // a future version may mean something different by the same field names,
+ // and acting on it would be worse than observing nothing.
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("future.json"),
+ R"({"version": 99, "run_id": "r", "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:12+02:00", "state": "ok",
+ "channels": ["-a"], "mbsync_status": 0, "notmuch_status": 0})");
+ QVERIFY(!path.isEmpty());
+ QCOMPARE(MailSync::readStatus(path).state, SyncState::Unknown);
+}
+
+void TestMailSync::readStatusReadsAFileTheScriptActuallyWrote()
+{
+ // The guard against the two sides drifting apart. Every test above writes
+ // what this file BELIEVES the script emits; this one runs the real script
+ // with stubbed binaries and reads what it actually wrote.
+ //
+ // Skipped rather than failed where bash or the script is unavailable: a
+ // packaging build has no reason to carry either, and a test that cannot run
+ // has observed nothing.
+ const QString script = QStringLiteral(SOURCE_DIR "/assets/mailsync.sh");
+ if (!QFile::exists(script))
+ QSKIP("assets/mailsync.sh not found");
+
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+
+ // Stubs, so nothing reaches the network and the real lock is never taken.
+ const QString bin = home.filePath(QStringLiteral("bin"));
+ QVERIFY(QDir().mkpath(bin));
+ for (const QString &name : { QStringLiteral("mbsync"),
+ QStringLiteral("notmuch") }) {
+ QFile stub(bin + QLatin1Char('/') + name);
+ QVERIFY(stub.open(QIODevice::WriteOnly | QIODevice::Text));
+ stub.write("#!/bin/bash\nexit 0\n");
+ stub.close();
+ QVERIFY(stub.setPermissions(QFile::ReadOwner | QFile::WriteOwner
+ | QFile::ExeOwner));
+ }
+
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert(QStringLiteral("HOME"), home.path());
+ env.insert(QStringLiteral("PATH"),
+ bin + QLatin1Char(':') + env.value(QStringLiteral("PATH")));
+ // Never /tmp/mbsync.lock: that is the mutex the user's cron sync uses, and
+ // a test that took it would block their mail.
+ env.insert(QStringLiteral("MAILSYNC_LOCKFILE"),
+ home.filePath(QStringLiteral("lock")));
+
+ QProcess proc;
+ proc.setProcessEnvironment(env);
+ proc.start(QStringLiteral("bash"), { script, QStringLiteral("work") });
+ if (!proc.waitForStarted(5000))
+ QSKIP("bash not available");
+ QVERIFY(proc.waitForFinished(30000));
+
+ const SyncStatus status = MailSync::readStatus(
+ home.filePath(QStringLiteral(".local/state/qtmaildir/syncstatus.json")));
+ QCOMPARE(status.state, SyncState::Ok);
+ QCOMPARE(status.channels, QStringList{ QStringLiteral("work") });
+ QVERIFY(!status.everyChannel);
+}
+
void TestMailSync::lastRunOutcomeReadsAnOkRun()
{
const QString path = m_dir.filePath(QStringLiteral("ok.log"));
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index bbdad19..90cdbc2 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -247,6 +247,7 @@ private slots:
void init();
void cleanup();
void noTestCanSeeTheRealLockTable();
+ void noSyncTestReadsTheRealSyncState();
void everyKnownActionIsRegistered();
void everyRegisteredActionIsKnown();
void configuredBindingReachesTheAction();
@@ -429,6 +430,8 @@ private slots:
void aRejectedWriteKeepsEarlierUndoHistory();
void aSuccessfulCronSyncClearsThePendingCount();
+ void anExternalSyncClearsOnlyTheAccountsItCarried();
+ void aSkippedExternalSyncClearsNothing();
void aFailedCronSyncLeavesThePendingCount();
void anUnreadableSyncLogLeavesThePendingCount();
void anUnknownExternalStateClearsNothing();
@@ -624,6 +627,53 @@ void TestMainWindow::noTestCanSeeTheRealLockTable()
QVERIFY(MainWindow::locksPath().startsWith(QDir::tempPath()));
}
+void TestMainWindow::noSyncTestReadsTheRealSyncState()
+{
+ // The same guard as noTestCanSeeTheRealLockTable(), for the two paths a
+ // Config falls back to when a test does not name them, and it exists
+ // because that fallback bit twice in one sitting (item 174).
+ //
+ // A test writing "[sync]\nlog=..." and nothing else leaves syncStatus()
+ // pointing at the developer's real ~/.local/state/qtmaildir/syncstatus.json.
+ // Two tests asserting that a FAILED run leaves the pending count alone
+ // therefore read the last real cron run, found "ok", and passed against a
+ // broken clear. Pinning only the status key has the mirror problem: the log
+ // then defaults to the real mailsync.log.
+ //
+ // Asserted on Config rather than on any one test, so a new sync test that
+ // forgets one key fails here with a message naming the reason rather than
+ // failing mysteriously whenever the developer's last sync happened to
+ // succeed.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("[sync]\ncommand=/bin/true\n");
+ file.close();
+
+ Config config;
+ config.load(path);
+
+ // Both DO default to the real paths, which is correct for the application
+ // and is exactly the trap for a test. This documents the behaviour so the
+ // requirement below is obviously about the tests rather than the defaults.
+ QCOMPARE(config.syncLog(), MailSync::defaultLogPath());
+ QCOMPARE(config.syncStatus(), MailSync::defaultStatusPath());
+
+ QVERIFY2(MailSync::defaultStatusPath().contains(
+ QStringLiteral(".local/state/qtmaildir/syncstatus.json")),
+ "the default status path moved: assets/mailsync.sh writes the old "
+ "one, and the two must agree or every external sync reads as "
+ "Unknown");
+
+ // Any test asserting on what a sync did must name BOTH keys in its own
+ // config, pointing them inside its own QTemporaryDir. There is no fixture
+ // that can enforce it, since Config is loaded per test, so this is the
+ // reminder that fails loudly if the defaults ever stop being real paths.
+}
+
void TestMainWindow::everyKnownActionIsRegistered()
{
// KeyMap::knownActions() is what loadOverrides() validates config bindings
@@ -7015,7 +7065,17 @@ void loadConfigWithSyncLog(Config &config, const QTemporaryDir &dir,
const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
- file.write(QStringLiteral("[sync]\nlog=%1\n").arg(logPath).toUtf8());
+ // The status file is pointed at this test's own directory even though these
+ // tests are about the LOG, and the omission cost two false greens: without
+ // it Config falls back to the real ~/.local/state/qtmaildir/syncstatus.json,
+ // so a test asserting that a FAILED log leaves the count alone read the
+ // developer's own last cron run, found "ok" and cleared. Same rule as the
+ // lock table: no test may observe the machine's real sync state. Pointing
+ // it at a file that does not exist makes readStatus() return Unknown, which
+ // is exactly the fallback-to-log case these tests mean to exercise.
+ file.write(QStringLiteral("[sync]\nlog=%1\nstatus=%2\n")
+ .arg(logPath, dir.filePath(QStringLiteral("no-status.json")))
+ .toUtf8());
file.close();
config.load(path);
@@ -7056,6 +7116,130 @@ void runExternalSync(MainWindow &window, SyncMonitor::State ending)
} // namespace
+/// Item 174. A run this process did not start now reports what it DID, in the
+/// status file assets/mailsync.sh writes, instead of being inferred from the
+/// log's RUN END banner.
+///
+/// The property that banner could never express: WHICH channels the run
+/// carried. The local sync path has always narrowed its clear to the accounts
+/// it carried (onSyncFinished's snapshot-and-subtract); the external path had
+/// no way to and cleared everything, so an edit to an account the run did not
+/// touch was reported as shipped when it had not been.
+void TestMainWindow::anExternalSyncClearsOnlyTheAccountsItCarried()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+
+ const QString statusPath = dir.filePath(QStringLiteral("syncstatus.json"));
+ QFile status(statusPath);
+ QVERIFY(status.open(QIODevice::WriteOnly));
+ // Timestamped NOW rather than with a fixed date: the status file is only
+ // read as this run's result when it is at least as new as the sync that
+ // just ended, so a fixture dated in the past is correctly ignored as stale
+ // and the test would exercise the log fallback instead.
+ const QString now =
+ QDateTime::currentDateTime().toString(Qt::ISODate);
+ // A run that carried ONE of the two accounts.
+ status.write(QStringLiteral(R"({"version": 1, "run_id": "r",
+ "started": "%1", "ended": "%1",
+ "state": "ok", "channels": ["work"],
+ "mbsync_status": 0, "notmuch_status": 0})")
+ .arg(now).toUtf8());
+ status.close();
+
+ const QString confPath = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile conf(confPath);
+ QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text));
+ // BOTH keys, always. Pinning only one leaves the other defaulting to the
+ // developer's real ~/.local/state file, and a test then reads their last
+ // cron run instead of its own fixture: that is how two tests in this group
+ // went green against a broken clear before this was noticed.
+ conf.write(QStringLiteral("[sync]\nstatus=%1\nlog=%2\n"
+ "[account.work]\nmaildir=work\ntrash=trash\n"
+ "[account.personal]\nmaildir=personal\ntrash=trash\n")
+ .arg(statusPath,
+ dir.filePath(QStringLiteral("no-log.log")))
+ .toUtf8());
+ conf.close();
+
+ Config config;
+ config.load(confPath);
+ QCOMPARE(config.syncStatus(), statusPath);
+
+ MainWindow window(config);
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ // An edit on each account. Only the first is carried by the run above.
+ QVERIFY(QMetaObject::invokeMethod(&window, "noteEditedAccountForTesting",
+ Q_ARG(QString, QStringLiteral("work"))));
+ QVERIFY(QMetaObject::invokeMethod(&window, "noteEditedAccountForTesting",
+ Q_ARG(QString,
+ QStringLiteral("personal"))));
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ QVERIFY2(!label->isHidden(), "the edit was not counted at all");
+
+ runExternalSync(window, SyncMonitor::State::Idle);
+
+ // The account the run carried is gone; the one it did not is still waiting.
+ // Asserting only that something cleared would pass against the old blanket
+ // clear, which is the behaviour this replaces.
+ QVERIFY2(!window.editedAccountsForTesting().contains(
+ QStringLiteral("work")),
+ "the account the sync carried is still marked as edited");
+ QVERIFY2(window.editedAccountsForTesting().contains(
+ QStringLiteral("personal")),
+ "an account the sync never carried was cleared anyway, which is "
+ "the blanket clear this replaces");
+}
+
+/// Item 125, the half this closes. A run that SKIPPED because another held the
+/// lock did the work of neither: it must clear no edits, and before the status
+/// file there was nothing to tell the application it had happened at all.
+void TestMainWindow::aSkippedExternalSyncClearsNothing()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+
+ const QString statusPath = dir.filePath(QStringLiteral("syncstatus.json"));
+ QFile status(statusPath);
+ QVERIFY(status.open(QIODevice::WriteOnly));
+ // NOW, for the staleness reason the other test records.
+ const QString now =
+ QDateTime::currentDateTime().toString(Qt::ISODate);
+ status.write(QStringLiteral(R"({"version": 1, "run_id": "r",
+ "started": "%1", "ended": "%1",
+ "state": "skipped", "channels": ["-a"],
+ "mbsync_status": -1, "notmuch_status": -1})")
+ .arg(now).toUtf8());
+ status.close();
+
+ const QString confPath = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile conf(confPath);
+ QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text));
+ conf.write(QStringLiteral("[sync]\nstatus=%1\nlog=%2\n")
+ .arg(statusPath,
+ dir.filePath(QStringLiteral("no-log.log")))
+ .toUtf8());
+ conf.close();
+
+ Config config;
+ config.load(confPath);
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ QVERIFY(!label->isHidden());
+
+ runExternalSync(window, SyncMonitor::State::Idle);
+
+ QVERIFY2(!label->isHidden(),
+ "a SKIPPED run cleared the pending count: it synced nothing, so "
+ "the edits are still only local");
+}
+
void TestMainWindow::aSuccessfulCronSyncClearsThePendingCount()
{
// The reported defect: edits applied, cron syncs, indicator still says N.