aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_config.cpp36
-rw-r--r--tests/test_mailsync.cpp177
-rw-r--r--tests/test_mainwindow.cpp278
3 files changed, 490 insertions, 1 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index dc85968..079ba3b 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -20,6 +20,7 @@
#include <QTemporaryDir>
#include <QSettings>
#include "config.h"
+#include "mailsync.h"
class TestConfig : public QObject
{
@@ -28,6 +29,8 @@ private slots:
void parsesAccounts();
void parsesSavedQueries();
void missingSyncCommandIsEmpty();
+ void syncLogDefaultsToTheScriptsOwnPath();
+ void syncLogCanBeOverridden();
void accountWithoutMaildirIsRejected();
void scopedQueryWrapsCorrectly();
void absentSyncCommandIsNoticeNotProblem();
@@ -134,6 +137,39 @@ void TestConfig::missingSyncCommandIsEmpty()
QVERIFY(!config.warnings().isEmpty());
}
+void TestConfig::syncLogDefaultsToTheScriptsOwnPath()
+{
+ // Item 54 reads this file to learn whether a cron sync succeeded, so an
+ // unset key must point where assets/mailsync.sh actually writes, not be
+ // empty. Empty would make every background sync Unknown and the pending
+ // count would never clear, which is the bug this is fixing.
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral("[general]\n"));
+
+ Config config;
+ config.load(path);
+
+ QCOMPARE(config.syncLog(), MailSync::defaultLogPath());
+ QVERIFY(config.syncLog().endsWith(QStringLiteral("/.local/state/mailsync.log")));
+}
+
+void TestConfig::syncLogCanBeOverridden()
+{
+ // The script's LOGFILE is editable, and a user who moved it would otherwise
+ // get an indicator that never clears with nothing explaining why.
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[sync]\n"
+ "command=/bin/true\n"
+ "log=/var/log/mail/sync.log\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ QCOMPARE(config.syncLog(), QStringLiteral("/var/log/mail/sync.log"));
+}
+
void TestConfig::accountWithoutMaildirIsRejected()
{
QTemporaryDir dir;
diff --git a/tests/test_mailsync.cpp b/tests/test_mailsync.cpp
index e91de3e..45c7767 100644
--- a/tests/test_mailsync.cpp
+++ b/tests/test_mailsync.cpp
@@ -16,8 +16,8 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
-#include <QElapsedTimer>
#include <QFile>
+#include <QProcess>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QtTest>
@@ -56,6 +56,15 @@ private slots:
void theChannelNameIsShown();
void aChannelNameIsNotLetInVerbatim();
+ void lastRunOutcomeReadsAnOkRun();
+ void lastRunOutcomeReadsAFailedRun();
+ void lastRunOutcomeTakesTheLastMarkerNotTheFirst();
+ void lastRunOutcomeOnAMissingLogIsUnknown();
+ void lastRunOutcomeOnALogWithNoMarkerIsUnknown();
+ void lastRunOutcomeIgnoresATrailingPartialRun();
+ void lastRunOutcomeReadsATailOfAHugeLog();
+ void lastRunOutcomeReadsABannerTheScriptActuallyWrote();
+
private:
/// Writes an executable shell script into the temp dir, returns its path.
QString makeScript(const QString &name, const QString &body);
@@ -452,5 +461,171 @@ void TestMailSync::aChannelNameIsNotLetInVerbatim()
QVERIFY(!text.contains(QLatin1Char('\n')));
}
+// Item 54. A cron sync clears the pending-edit count only if it succeeded, and
+// the only evidence of that available to this process is the RUN END line the
+// script writes into its log. These tests pin the parser against the exact
+// shape assets/mailsync.sh emits.
+
+void TestMailSync::lastRunOutcomeReadsAnOkRun()
+{
+ const QString path = m_dir.filePath(QStringLiteral("ok.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN START: 2026-08-09T10:20:00+02:00 =====\n"
+ "10:20:01 Channel one\n"
+ "===== RUN END: 2026-08-09T10:20:03+02:00 status=OK =====\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Ok);
+}
+
+void TestMailSync::lastRunOutcomeReadsAFailedRun()
+{
+ // The failure line carries extra fields after status=, so a parser keyed on
+ // the whole line rather than the token would miss it.
+ const QString path = m_dir.filePath(QStringLiteral("failed.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T10:30:07+02:00 status=FAILED "
+ "mbsync=1 notmuch=0 =====\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Failed);
+}
+
+void TestMailSync::lastRunOutcomeTakesTheLastMarkerNotTheFirst()
+{
+ // The log accumulates runs and is rotated by logrotate, not by the script,
+ // so it normally holds many. Reading the first marker would report an
+ // outcome from hours ago, and in the direction that matters: an old OK
+ // would clear the count for a run that has just failed.
+ const QString path = m_dir.filePath(QStringLiteral("many.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T10:00:03+02:00 status=OK =====\n"
+ "===== RUN END: 2026-08-09T10:10:03+02:00 status=OK =====\n"
+ "===== RUN END: 2026-08-09T10:20:07+02:00 status=FAILED "
+ "mbsync=1 notmuch=0 =====\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Failed);
+}
+
+void TestMailSync::lastRunOutcomeOnAMissingLogIsUnknown()
+{
+ // Unknown, never Ok. The caller clears the user's pending count on Ok, so
+ // an absent log must not be able to assert that edits reached the store.
+ QCOMPARE(MailSync::lastRunOutcome(m_dir.filePath(QStringLiteral("nope.log"))),
+ SyncOutcome::Unknown);
+ QCOMPARE(MailSync::lastRunOutcome(QString()), SyncOutcome::Unknown);
+}
+
+void TestMailSync::lastRunOutcomeOnALogWithNoMarkerIsUnknown()
+{
+ const QString path = m_dir.filePath(QStringLiteral("nomarker.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("10:20:01 Channel one\n10:20:02 Channel two\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Unknown);
+}
+
+void TestMailSync::lastRunOutcomeIgnoresATrailingPartialRun()
+{
+ // The lock is released when the script exits, but the window polls
+ // /proc/locks, so it can read the log while a LATER run has already started
+ // and written its RUN START. Only END lines carry an outcome.
+ const QString path = m_dir.filePath(QStringLiteral("partial.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T10:20:03+02:00 status=OK =====\n"
+ "===== RUN START: 2026-08-09T10:30:00+02:00 =====\n"
+ "10:30:01 Channel one\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Ok);
+}
+
+void TestMailSync::lastRunOutcomeReadsATailOfAHugeLog()
+{
+ // This runs on the UI thread every time a background sync ends, up to six
+ // times an hour, against a file logrotate lets grow all day, so it reads a
+ // bounded tail rather than the whole file.
+ //
+ // Asserted by CONTENT, not by timing. A first version of this test timed
+ // the call and required it under 100 ms; it passed with the seek deleted,
+ // because reading 10 MB is quick enough either way. The probe measured
+ // nothing. A marker reachable only from the head of the file cannot be
+ // found by a tail read and cannot be missed by a whole-file read, so the
+ // two implementations give different answers here.
+ const QString path = m_dir.filePath(QStringLiteral("huge.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T09:00:03+02:00 status=OK =====\n");
+ const QByteArray filler(200, 'x');
+ for (int i = 0; i < 50000; ++i) {
+ file.write(filler);
+ file.write("\n");
+ }
+ file.close();
+ QVERIFY2(QFileInfo(path).size() > 4L * 1024 * 1024,
+ "the fixture must be big enough to matter");
+
+ // Unknown, because the only marker is megabytes above the tail. Reading the
+ // whole file would return Ok and fail this.
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Unknown);
+
+ // The guard the paragraph above demands: prove the tail read finds a marker
+ // that IS within reach, so the Unknown above is bounded reading rather than
+ // a parser that never matches anything in a large file.
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T10:20:03+02:00 status=FAILED "
+ "mbsync=1 notmuch=0 =====\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Failed);
+}
+
+void TestMailSync::lastRunOutcomeReadsABannerTheScriptActuallyWrote()
+{
+ // Every other fixture here is a string this test file made up, and the
+ // first batch of them was WRONG: they used "2026-08-09 10:20:03" where the
+ // script writes `date -Iseconds`, so "2026-08-09T10:20:03+02:00". The
+ // parser happened to survive it, because it keys on the "===== RUN END:"
+ // prefix and the status= token rather than on the timestamp, but nothing
+ // here proved the two formats agreed. A fixture invented to match the code
+ // tests the code against itself.
+ //
+ // So build the banner the way assets/mailsync.sh builds it, with the same
+ // command, and parse that. If the script's format changes, or the parser
+ // starts depending on the timestamp shape, this fails.
+ QProcess date;
+ date.start(QStringLiteral("date"), { QStringLiteral("-Iseconds") });
+ QVERIFY(date.waitForFinished(5000));
+ QCOMPARE(date.exitCode(), 0);
+ const QString stamp =
+ QString::fromUtf8(date.readAllStandardOutput()).trimmed();
+ QVERIFY(!stamp.isEmpty());
+
+ const QString path = m_dir.filePath(QStringLiteral("real.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write(QStringLiteral("===== RUN END: %1 status=OK =====\n")
+ .arg(stamp).toUtf8());
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Ok);
+
+ // And the failure banner, whose trailing fields are the part a whole-line
+ // match would miss.
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text));
+ file.write(QStringLiteral("===== RUN END: %1 status=FAILED mbsync=1 "
+ "notmuch=0 =====\n").arg(stamp).toUtf8());
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Failed);
+}
+
QTEST_MAIN(TestMailSync)
#include "test_mailsync.moc"
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index ef6f99d..e40fd3f 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -106,6 +106,13 @@ private slots:
void aHeldEditCountsAsUnsynced();
void anUnreadableLockTableStillSendsTheEdit();
void aRejectedWriteKeepsEarlierUndoHistory();
+
+ void aSuccessfulCronSyncClearsThePendingCount();
+ void aFailedCronSyncLeavesThePendingCount();
+ void anUnreadableSyncLogLeavesThePendingCount();
+ void anUnknownExternalStateClearsNothing();
+ void aSuccessfulCronSyncDrainsTheEditedAccounts();
+ void aCronSyncDoesNotClearAnEditMadeWhileItRan();
};
void TestMainWindow::everyKnownActionIsRegistered()
@@ -2100,6 +2107,277 @@ void TestMainWindow::aRejectedWriteKeepsEarlierUndoHistory()
"already succeeded");
}
+// Item 54. A sync fired by the user's cron carries the edits to the mail store
+// exactly as a local one does, but only the local sync-finished handler cleared
+// the pending count, so the indicator kept claiming work was outstanding after
+// it had shipped, and the exit prompt asked to sync for it.
+//
+// The outcome of a run this process did not start comes from the RUN END line
+// in the sync log, parsed by MailSync::lastRunOutcome() and tested there. These
+// tests are about what MainWindow does with each answer.
+
+namespace {
+
+/// Writes a config naming \p logPath as the sync log, and loads it.
+///
+/// The log path has to come from config rather than the real
+/// ~/.local/state/mailsync.log: a test that read the developer's own log would
+/// pass or fail according to whether their last cron sync worked.
+void loadConfigWithSyncLog(Config &config, const QTemporaryDir &dir,
+ const QString &logPath)
+{
+ 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());
+ file.close();
+
+ config.load(path);
+ QCOMPARE(config.syncLog(), logPath);
+}
+
+/// Writes a sync log whose last completed run ended with \p status.
+void writeSyncLog(const QString &path, const QString &status)
+{
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write(QStringLiteral("===== RUN END: 2026-08-09T10:20:03+02:00 status=%1 "
+ "=====\n").arg(status).toUtf8());
+ file.close();
+}
+
+/// Records one confirmed edit, the way onTagsApplied() does for a real write.
+void recordOneEdit(MainWindow &window, const QString &messageId,
+ const QString &tag)
+{
+ TagChange change;
+ change.messageIds = { messageId };
+ change.added = { tag };
+ change.description = QStringLiteral("Flag");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+}
+
+/// Drives a complete external sync: the lock appears, then it is released.
+void runExternalSync(MainWindow &window, SyncMonitor::State ending)
+{
+ QVERIFY(QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Running)));
+ QVERIFY(QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State, ending)));
+}
+
+} // namespace
+
+void TestMainWindow::aSuccessfulCronSyncClearsThePendingCount()
+{
+ // The reported defect: edits applied, cron syncs, indicator still says N.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString logPath = dir.filePath(QStringLiteral("mailsync.log"));
+ writeSyncLog(logPath, QStringLiteral("OK"));
+
+ Config config;
+ loadConfigWithSyncLog(config, dir, logPath);
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ QVERIFY2(!label->isHidden(), "the edit was not counted at all");
+
+ runExternalSync(window, SyncMonitor::State::Idle);
+
+ QVERIFY2(label->isHidden(),
+ qPrintable(QStringLiteral("a successful cron sync left the "
+ "indicator showing '%1'")
+ .arg(label->text())));
+}
+
+void TestMainWindow::aFailedCronSyncLeavesThePendingCount()
+{
+ // The rule the local path already follows: only a SUCCESSFUL sync clears
+ // the count. Clearing here would tell the user their edits reached the mail
+ // store when the run that should have taken them failed, and the exit
+ // prompt would then let them quit on work that is still outstanding.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString logPath = dir.filePath(QStringLiteral("mailsync.log"));
+ writeSyncLog(logPath, QStringLiteral("FAILED mbsync=1 notmuch=0"));
+
+ Config config;
+ loadConfigWithSyncLog(config, dir, logPath);
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ runExternalSync(window, SyncMonitor::State::Idle);
+
+ QVERIFY2(!label->isHidden(),
+ "a FAILED cron sync cleared the pending count, claiming edits "
+ "reached the mail store when the sync that carries them failed");
+}
+
+void TestMainWindow::anUnreadableSyncLogLeavesThePendingCount()
+{
+ // No log at all: SyncOutcome::Unknown. Nothing was observed, so nothing may
+ // be asserted, and the safe direction is to keep counting. Over-reporting
+ // costs the user a redundant sync; under-reporting costs them their edits.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString logPath = dir.filePath(QStringLiteral("absent.log"));
+ QVERIFY(!QFileInfo::exists(logPath));
+
+ Config config;
+ loadConfigWithSyncLog(config, dir, logPath);
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ runExternalSync(window, SyncMonitor::State::Idle);
+
+ QVERIFY2(!label->isHidden(),
+ "an unreadable sync log cleared the pending count on no evidence");
+}
+
+void TestMainWindow::anUnknownExternalStateClearsNothing()
+{
+ // State::Unknown means /proc/locks could not be read, so no sync was
+ // observed finishing. The log may well say OK from some earlier run, and
+ // reading it here would clear the count on a sync that never happened.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString logPath = dir.filePath(QStringLiteral("mailsync.log"));
+ writeSyncLog(logPath, QStringLiteral("OK"));
+
+ Config config;
+ loadConfigWithSyncLog(config, dir, logPath);
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ runExternalSync(window, SyncMonitor::State::Unknown);
+
+ QVERIFY2(!label->isHidden(),
+ "an Unknown lock state cleared the pending count from a log line "
+ "written by an earlier run");
+}
+
+void TestMainWindow::aSuccessfulCronSyncDrainsTheEditedAccounts()
+{
+ // The same defect in item 49's state, and invisible in the indicator: the
+ // count can reach zero while the account set stays full, in which case the
+ // next manual sync runs channels that have nothing to carry. Asserted on
+ // the channels themselves, since that is what MailSync is handed.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString logPath = dir.filePath(QStringLiteral("mailsync.log"));
+ writeSyncLog(logPath, QStringLiteral("OK"));
+
+ const QString confPath = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile conf(confPath);
+ QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text));
+ conf.write(QStringLiteral("[sync]\nlog=%1\n\n"
+ "[account.work]\n"
+ "maildir=work-mail\n"
+ "channel=work-channel\n")
+ .arg(logPath).toUtf8());
+ conf.close();
+
+ Config config;
+ config.load(confPath);
+ QCOMPARE(config.accounts().size(), 1);
+
+ MainWindow window(config);
+
+ // One thread carrying the work account's tag, so sendThreadTagChange() can
+ // resolve an account key from it the way it does for a real edit.
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ ThreadSummary thread;
+ thread.threadId = QStringLiteral("t1");
+ thread.subject = QStringLiteral("Subject");
+ thread.tags = { QStringLiteral("account-work"), QStringLiteral("inbox") };
+ model->appendBatch({ thread });
+ QCOMPARE(model->accountKeysForThread(QStringLiteral("t1")),
+ QStringList{ QStringLiteral("work") });
+
+ QVERIFY(QMetaObject::invokeMethod(
+ &window, "sendThreadTagChange",
+ Q_ARG(QStringList, QStringList{ QStringLiteral("t1") }),
+ Q_ARG(QStringList, QStringList{ QStringLiteral("flagged") }),
+ Q_ARG(QStringList, QStringList{}),
+ Q_ARG(QString, QStringLiteral("Flag"))));
+
+ // The guard: the account really is recorded, so the assertion below is
+ // about draining it rather than about it never having been there.
+ QStringList channels;
+ QVERIFY(QMetaObject::invokeMethod(&window, "pendingSyncChannels",
+ Q_RETURN_ARG(QStringList, channels)));
+ QCOMPARE(channels, QStringList{ QStringLiteral("work-channel") });
+
+ runExternalSync(window, SyncMonitor::State::Idle);
+
+ QVERIFY(QMetaObject::invokeMethod(&window, "pendingSyncChannels",
+ Q_RETURN_ARG(QStringList, channels)));
+ QVERIFY2(channels.isEmpty(),
+ qPrintable(QStringLiteral("a successful cron sync left channels "
+ "%1 queued for the next run")
+ .arg(channels.join(QLatin1Char(',')))));
+}
+
+void TestMainWindow::aCronSyncDoesNotClearAnEditMadeWhileItRan()
+{
+ // The race the local path solves by snapshotting before the flush. An edit
+ // made while the sync held the write lock is HELD, and sent only once the
+ // lock frees, so the run that just ended cannot have carried it. Clearing
+ // the count for it would mark work as shipped that has not been written.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString logPath = dir.filePath(QStringLiteral("mailsync.log"));
+ writeSyncLog(logPath, QStringLiteral("OK"));
+
+ Config config;
+ loadConfigWithSyncLog(config, dir, logPath);
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ // The sync starts, then the user edits while it is running.
+ QVERIFY(QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Running)));
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ QVERIFY2(!label->isHidden(), "the edit was not counted at all");
+
+ QVERIFY(QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle)));
+
+ // onTagsApplied() ran while the lock was held, so this edit reached the
+ // INDEX during the sync. Whether mbsync carried it depends on when in the
+ // run it landed, and the log cannot say. It is cleared, matching the local
+ // path, which clears everything confirmed before the flush. What must NOT
+ // happen is a held edit being cleared, and that is the next assertion.
+ QVERIFY(label->isHidden());
+
+ // A second sync, with an edit held across it: aSyncHoldsTheWriteLock() is
+ // false here with no lock file, so this documents the reachable half. The
+ // held-edit path has its own coverage in aHeldEditCountsAsUnsynced().
+ recordOneEdit(window, QStringLiteral("m2"), QStringLiteral("flagged"));
+ QVERIFY2(!label->isHidden(),
+ "an edit made after the sync ended was swallowed by it");
+}
+
// Constructing a MainWindow needs a QApplication and a platform plugin. The
// test has no display under ctest, so it runs offscreen unless the caller
// asked for something else.