aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 10:49:02 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:43 +0200
commite19e2318fc637d50b86f663b21bd97494057232d (patch)
tree28cd5e183511959abdc4a49ab19afda458c18d4a /tests
parentf762e4ca0051a7a34123b5a526696f2feb5c6e03 (diff)
downloadqtmaildir-e19e2318fc637d50b86f663b21bd97494057232d.tar.gz
qtmaildir-e19e2318fc637d50b86f663b21bd97494057232d.zip
feat(threads): mark an opened thread read after a delay
Opening a thread left it tagged unread, so the unread count never matched what had actually been read and the app was quietly wrong every day it was used. Item 6 of the usability backlog. A single-shot timer, armed when a thread is selected and restarted rather than stacked, so arrowing down a list marks only the thread still selected when it fires and not every one passed through. Configurable through mark_read_delay_ms in [general], defaulting to 2000: zero marks read at once, and any negative value disables the behaviour, which is why the value is neither clamped nor warned about at either end. The automatic change deliberately does NOT go on the undo stack. It routes through sendThreadTagChange() rather than tagSelected(), because undoing an action the user never took is worse than leaving a thread read, and toggle_unread already gives them a direct way back. It still funnels through the single applyTags path; what differs is only whether the inverse is pushed, which is a window-level decision above the worker. An explicit toggle_unread cancels any pending timer, or marking a thread unread by hand would be reversed a moment later and the key would look broken. Two guards beyond the plan, both from asking what happens when a timer outlives the thread it was armed for. Arming is skipped for a thread that is not unread, so no write is scheduled that would change nothing, and the handler re-checks that its thread is still selected and still unread before writing, so a stale timer does nothing rather than tagging the wrong thread. The plan expected the rapid-arrow case to need a database and a manual check. It needs neither: ThreadListModel takes threads through appendBatch(), so the case is unit-tested. All three tests were confirmed to fail against deliberately broken versions, one arming for read threads and one creating a timer per selection instead of restarting one. Item 7 is closed in the same pass. The user verified against real mail that HTML messages already open as HTML, which is what the item asked for, so it is recorded as done with no code changed. The prefer_html key it floated was not added: nobody has asked to default to plain text, and Ctrl+H already switches a thread by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_config.cpp58
-rw-r--r--tests/test_mainwindow.cpp117
2 files changed, 175 insertions, 0 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index 451eb43..a09738b 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -41,6 +41,10 @@ private slots:
void messageZoomDefaultsAndValidates();
void completionOnFocusDefaultsToFalse();
void completionOnFocusIsActuallyRead();
+ void markReadDelayDefaultsToTwoSeconds();
+ void markReadDelayIsActuallyRead();
+ void markReadDelayAcceptsZeroAndNegative();
+ void markReadDelayRejectsGarbage();
void extraMimetypesAppendToBuiltins();
void extraMimetypeDescriptionMayContainComma();
void malformedExtraMimetypeIsSkipped();
@@ -371,6 +375,60 @@ void TestConfig::completionOnFocusIsActuallyRead()
QCOMPARE(config.completionOnFocus(), true);
}
+void TestConfig::markReadDelayDefaultsToTwoSeconds()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n")));
+ QCOMPARE(config.markReadDelayMs(), 2000);
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::markReadDelayIsActuallyRead()
+{
+ // Round-trip a value that is not the default, which is what proves the key
+ // is really read: a "general/mark_read_delay_ms" lookup matches nothing and
+ // would still pass a test that only checked the default.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n"
+ "mark_read_delay_ms=500\n")));
+ QCOMPARE(config.markReadDelayMs(), 500);
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::markReadDelayAcceptsZeroAndNegative()
+{
+ // Both are documented settings, not mistakes: 0 marks read immediately and
+ // a negative value disables the behaviour entirely. Neither may be
+ // clamped away or warned about.
+ QTemporaryDir dir;
+ Config zero;
+ zero.load(writeIni(dir, QStringLiteral("[general]\n"
+ "mark_read_delay_ms=0\n")));
+ QCOMPARE(zero.markReadDelayMs(), 0);
+ QVERIFY(zero.problems().isEmpty());
+
+ QTemporaryDir otherDir;
+ Config never;
+ never.load(writeIni(otherDir, QStringLiteral("[general]\n"
+ "mark_read_delay_ms=-1\n")));
+ QCOMPARE(never.markReadDelayMs(), -1);
+ QVERIFY(never.problems().isEmpty());
+}
+
+void TestConfig::markReadDelayRejectsGarbage()
+{
+ // Absent is silent, but present-and-unparseable means the user asked for
+ // something and is not getting it, which warns rather than passing quietly.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n"
+ "mark_read_delay_ms=soon\n")));
+ QCOMPARE(config.markReadDelayMs(), 2000);
+ QVERIFY(!config.problems().isEmpty());
+}
+
void TestConfig::extraMimetypesAppendToBuiltins()
{
QTemporaryDir dir;
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index a66e1c2..fb33462 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -29,6 +29,7 @@
#include <QTemporaryDir>
#include <QTableView>
+#include <QTimer>
#include "config.h"
#include "keymap.h"
@@ -55,6 +56,9 @@ private slots:
void missingUiStateLeavesTheDefaults();
void headerStateFromADifferentColumnLayoutIsDiscarded();
void returnInTheQueryBarRunsTheQueryNotOpenThread();
+ void markReadTimerRestartsRatherThanStacking();
+ void markReadTimerIsNotArmedForAReadThread();
+ void markReadCanBeDisabled();
};
void TestMainWindow::everyKnownActionIsRegistered()
@@ -311,6 +315,119 @@ void TestMainWindow::returnInTheQueryBarRunsTheQueryNotOpenThread()
QVERIFY(!actionFired);
}
+/// A thread summary carrying the tags a test needs. Enough to drive selection;
+/// nothing here touches a database.
+static ThreadSummary makeThread(const QString &id, const QStringList &tags)
+{
+ ThreadSummary thread;
+ thread.threadId = id;
+ thread.subject = QStringLiteral("Subject ") + id;
+ thread.authors = QStringLiteral("Someone <someone@example.org>");
+ thread.tags = tags;
+ return thread;
+}
+
+void TestMainWindow::markReadTimerRestartsRatherThanStacking()
+{
+ // The plan's hard requirement: arrowing quickly down a list must not mark
+ // every thread passed through as read, only the one still selected when the
+ // timer fires. A stacked timer per selection would mark all of them.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(timer);
+ auto *view = window.findChild<QTableView *>();
+ QVERIFY(view);
+
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") }),
+ makeThread(QStringLiteral("t2"),
+ { QStringLiteral("unread") }),
+ makeThread(QStringLiteral("t3"),
+ { QStringLiteral("unread") }) });
+
+ view->selectRow(0);
+ QVERIFY2(timer->isActive(), "no timer armed for an unread thread");
+
+ // Move on before it can fire. One timer stays armed, not three.
+ view->selectRow(1);
+ QVERIFY(timer->isActive());
+ view->selectRow(2);
+ QVERIFY(timer->isActive());
+
+ // Exactly one timer exists at all, which is what "restarted, not stacked"
+ // means concretely.
+ QCOMPARE(window.findChildren<QTimer *>(QStringLiteral("markReadTimer")).size(),
+ 1);
+}
+
+void TestMainWindow::markReadTimerIsNotArmedForAReadThread()
+{
+ // Opening a thread that is already read must not schedule a write that
+ // would change nothing.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(timer);
+ auto *view = window.findChild<QTableView *>();
+ QVERIFY(view);
+
+ model->appendBatch({ makeThread(QStringLiteral("read"),
+ { QStringLiteral("inbox") }),
+ makeThread(QStringLiteral("unread"),
+ { QStringLiteral("unread") }) });
+
+ view->selectRow(0);
+ QVERIFY2(!timer->isActive(), "armed a timer for an already-read thread");
+
+ // And the unread one still arms, so this is not "never arms".
+ view->selectRow(1);
+ QVERIFY(timer->isActive());
+
+ // Moving back to a read thread disarms it again, rather than leaving the
+ // previous thread's timer running to fire against the wrong row.
+ view->selectRow(0);
+ QVERIFY(!timer->isActive());
+}
+
+void TestMainWindow::markReadCanBeDisabled()
+{
+ // A negative delay turns the behaviour off entirely. Documented, so it must
+ // work rather than being clamped to "immediately".
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("[general]\nmark_read_delay_ms=-1\n");
+ }
+
+ Config config;
+ config.load(path);
+ QCOMPARE(config.markReadDelayMs(), -1);
+
+ MainWindow window(config);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(timer);
+ auto *view = window.findChild<QTableView *>();
+ QVERIFY(view);
+
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") }) });
+ view->selectRow(0);
+
+ QVERIFY2(!timer->isActive(),
+ "a negative mark_read_delay_ms must disable the timer");
+}
+
// 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.