summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 20:05:01 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 20:05:01 +0200
commit8485283bbc30c886e28722516dc6f0b11c3bf82d (patch)
tree1caf649b37b8ac693991f4bdf6d12247f9a0eefa /tests
parentbd1b907ba22464bb869bcc10cc9327ed74c44791 (diff)
downloadqtmaildir-8485283bbc30c886e28722516dc6f0b11c3bf82d.tar.gz
qtmaildir-8485283bbc30c886e28722516dc6f0b11c3bf82d.zip
fix(sync): count unsynced edits as net state, not as writes
Item 28, reported by the user: open a thread, let the automatic mark-read remove `unread`, then press Ctrl+U to put it back. The indicator read "2 unsynced change(s)" with the mail store exactly where it started. The counter incremented per confirmed write and never decremented, so any add-then-remove of the same tag inflated it. Mark-read is simply the path that fires without being asked, which is why it surfaced there. The user's call was net state: an edit and its inverse are zero outstanding changes, because what the indicator answers is whether quitting now would strand work. A QHash keyed "<messageId>\n<tag>" replaces the int, and a pair that reverts is erased rather than stored with the new direction, so the map cannot grow without bound across a long session of tagging and untagging. Keyed per (message, tag) rather than per message: removing `unread` and adding `flagged` on one message are independent changes and must not cancel each other. A change carrying no message ids cannot be netted against anything and is counted separately, since dropping it would understate the indicator, which is the direction that costs work. Both properties item 18 established still hold: a successful sync clears everything, a failed one clears nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp93
1 files changed, 93 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index f791d8f..d133cfb 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -86,6 +86,9 @@ private slots:
void deleteOnAMixedSelectionDeletesRatherThanSplittingIt();
void aTransientStatusMessageExpires();
void theSelectionCountIsStateAndDoesNotExpire();
+ void anEditUndoneNettsBackToZero();
+ void aDifferentTagOnTheSameMessageStillCounts();
+ void anEditWithNoMessageIdsStillCounts();
};
void TestMainWindow::everyKnownActionIsRegistered()
@@ -1180,6 +1183,96 @@ void TestMainWindow::theSelectionCountIsStateAndDoesNotExpire()
"not an event");
}
+void TestMainWindow::anEditUndoneNettsBackToZero()
+{
+ // Reported by the user: open a thread, let the 2 s auto-mark-read remove
+ // `unread`, then press Ctrl+U to put it back. The indicator read 2 unsynced
+ // changes when the mail store was exactly where it started.
+ //
+ // The count tracks NET state, not writes. Two writes did happen, but their
+ // effect cancels, and what the user needs to know is whether quitting now
+ // would strand work.
+ const Config config;
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+ QVERIFY(label->isHidden());
+
+ // The automatic mark-read: remove `unread` from one message.
+ TagChange off;
+ off.messageIds = { QStringLiteral("m1") };
+ off.removed = { QStringLiteral("unread") };
+ off.description = QStringLiteral("Mark read");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, off)));
+ QVERIFY2(!label->isHidden(), "one edit must show the indicator");
+
+ // Ctrl+U puts it back on the same message.
+ TagChange on;
+ on.messageIds = { QStringLiteral("m1") };
+ on.added = { QStringLiteral("unread") };
+ on.description = QStringLiteral("Mark unread");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, on)));
+
+ QVERIFY2(label->isHidden(),
+ qPrintable(QStringLiteral("an edit and its inverse left the "
+ "indicator showing '%1'")
+ .arg(label->text())));
+}
+
+void TestMainWindow::aDifferentTagOnTheSameMessageStillCounts()
+{
+ // Netting must be per (message, tag), not per message. Removing `unread`
+ // and adding `flagged` on one message are two independent changes, and
+ // neither cancels the other.
+ const Config config;
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ TagChange a;
+ a.messageIds = { QStringLiteral("m1") };
+ a.removed = { QStringLiteral("unread") };
+ a.description = QStringLiteral("Mark read");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, a)));
+
+ TagChange b;
+ b.messageIds = { QStringLiteral("m1") };
+ b.added = { QStringLiteral("flagged") };
+ b.description = QStringLiteral("Flag");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, b)));
+
+ QVERIFY2(!label->isHidden(),
+ "two different tags on one message cancelled each other");
+}
+
+void TestMainWindow::anEditWithNoMessageIdsStillCounts()
+{
+ // A TagChange carrying no message ids cannot be netted against anything,
+ // and must still register rather than silently counting as zero. Losing an
+ // edit understates the indicator, which is the direction that costs the
+ // user work.
+ const Config config;
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ TagChange change;
+ change.added = { QStringLiteral("deleted") };
+ change.description = QStringLiteral("Delete");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+
+ QVERIFY2(!label->isHidden(),
+ "an edit with no message ids was not counted at all");
+}
+
// 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.