summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 16:23:43 +0200
committerDanilo M. <danix@danix.xyz>2026-08-03 16:23:43 +0200
commit212048782be680e2f99341db6c6460e59c708e7c (patch)
tree2a963993682c71e17d9dc1d165ac7ae1aa9e572c /tests
parente6ea00dce8c74bf0d48e769734958a741ca46ef9 (diff)
downloadqtmaildir-212048782be680e2f99341db6c6460e59c708e7c.tar.gz
qtmaildir-212048782be680e2f99341db6c6460e59c708e7c.zip
feat: own the message-pane zoom and persist it
Zoom was Chromium's, not the application's: the web view handled the keys natively and never told anyone, so there was no value to save. qtmaildir now owns it. Zoom in, out and reset are real actions, in the View menu and rebindable through [keys], and the factor is persisted to the UI state file. Ctrl+wheel over the body zooms and Ctrl+middle-click resets, both filtered by ancestry from an application-level filter: the events are delivered to an internal QQuickWidget the web view creates lazily, so a filter on the view itself never sees them. The factor is clamped to 0.5 - 3.0, and NaN, infinity, zero and negative values fall back to 1.0, since a corrupt state file must not be able to leave the pane unreadable with no visible way back. Both risks the plan flagged turned out not to exist, verified by probe rather than assumed. The application QAction wins over the web view's native zoom key, so the tracked factor cannot diverge from what is on screen. And the factor survives setHtml(), so the web view is the single source of truth and needs no reapply per render. A third finding is worth recording because it produced a wrong fix first. A probe using QTest::keyClick() reported Ctrl++ as a dead binding, and a test was written asserting that. Both were wrong: Ctrl++ is exactly what the '+' key emits on an Italian layout, confirmed against the real keyboard, and it is the shipped default. Whether a symbol needs Shift is a property of the layout, not of Qt, and keyClick() reproduces neither. The test now only checks that every default parses, and the comment in defaultBindings() says not to re-derive this from synthetic input. Ctrl+= is a second binding for reset, skipped when [keys] gives it to something else. Also fixes a pre-existing bug the new config key exposed. [general] entries were read as "general/<key>", which matches nothing: QSettings' INI backend treats a section literally named [general] as its own fallback section and strips the prefix. notmuch_config had therefore never worked. Both keys are now read without it; the file format is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_config.cpp56
-rw-r--r--tests/test_keymap.cpp20
-rw-r--r--tests/test_mainwindow.cpp3
-rw-r--r--tests/test_messageview.cpp55
4 files changed, 134 insertions, 0 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index e492480..367643c 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -34,6 +34,8 @@ private slots:
void brokenSyncCommandIsAProblem();
void malformedAccountIsAProblem();
void validConfigHasNoProblems();
+ void generalSectionKeysAreActuallyRead();
+ void messageZoomDefaultsAndValidates();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -222,5 +224,59 @@ void TestConfig::validConfigHasNoProblems()
QVERIFY(config.warnings().isEmpty());
}
+void TestConfig::generalSectionKeysAreActuallyRead()
+{
+ // QSettings' INI backend treats a section literally named [general] as its
+ // own fallback section and strips the prefix, so a "general/<key>" lookup
+ // matches nothing. notmuch_config was read that way and had never worked.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "notmuch_config=/somewhere/notmuch-config\n"
+ "\n"
+ "[sync]\n"
+ "command=/bin/true\n")));
+
+ QCOMPARE(config.notmuchConfig(),
+ QStringLiteral("/somewhere/notmuch-config"));
+}
+
+void TestConfig::messageZoomDefaultsAndValidates()
+{
+ // A QTemporaryDir per case, not one shared: writeIni() always uses the
+ // same file name, and QSettings caches by path, so a second load of the
+ // same path would return the first case's contents.
+
+ // Absent: 1.0, silently. Nothing the user asked for is being ignored.
+ {
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n")));
+ QCOMPARE(config.messageZoom(), 1.0);
+ QVERIFY(config.problems().isEmpty());
+ }
+
+ {
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n"
+ "message_zoom=1.25\n")));
+ QCOMPARE(config.messageZoom(), 1.25);
+ QVERIFY(config.problems().isEmpty());
+ }
+
+ // Present but unparseable is a problem: the user asked for something and
+ // is not getting it, which is the line addProblem() draws.
+ {
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n"
+ "message_zoom=huge\n")));
+ QCOMPARE(config.messageZoom(), 1.0);
+ QCOMPARE(config.problems().size(), 1);
+ }
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"
diff --git a/tests/test_keymap.cpp b/tests/test_keymap.cpp
index c81eeb0..0fb4f57 100644
--- a/tests/test_keymap.cpp
+++ b/tests/test_keymap.cpp
@@ -36,8 +36,28 @@ private slots:
void userBindingWinsOverDefaultInMenus();
void defaultsDoNotCollide();
void everyDefaultIsAKnownAction();
+ void everyDefaultParses();
};
+void TestKeyMap::everyDefaultParses()
+{
+ // A default that does not parse is a dead binding, the failure mode
+ // bareCapitalMatchesShiftedPress() covers for user-written keys.
+ //
+ // This deliberately does NOT try to decide which keys a keyboard can
+ // deliver. Whether a symbol needs Shift is a layout property, not a Qt
+ // one: Ctrl++ is exactly what the '+' key emits on an Italian layout and
+ // is unreachable on a US one, and QTest::keyClick() cannot reproduce
+ // either faithfully. A test asserting reachability from synthetic input
+ // would encode one layout's habits as a rule for all of them.
+ for (const auto &binding : KeyMap::defaultBindings()) {
+ const QKeySequence sequence = KeyMap::normalizeSequence(binding.first);
+ QVERIFY2(!sequence.isEmpty(),
+ qPrintable(QStringLiteral("default '%1' for %2 does not parse")
+ .arg(binding.first, binding.second)));
+ }
+}
+
void TestKeyMap::defaultsAreLoaded()
{
KeyMap map;
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 628472b..b4d9e4a 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -28,6 +28,7 @@
#include "config.h"
#include "keymap.h"
#include "mainwindow.h"
+#include "messageview.h"
/// MainWindow is mostly wiring, and the parts that need a real database are
/// still verified manually. What is checked here is the action registry: the
@@ -190,6 +191,7 @@ void TestMainWindow::uiStateSurvivesARestart()
const Config config;
MainWindow window(config);
window.resize(resized);
+ window.findChild<MessageView *>()->setZoomFactor(1.4);
window.close(); // closeEvent() is what persists the state
}
@@ -200,6 +202,7 @@ void TestMainWindow::uiStateSurvivesARestart()
const Config config;
MainWindow reopened(config);
QCOMPARE(reopened.size(), resized);
+ QCOMPARE(reopened.findChild<MessageView *>()->zoomFactor(), 1.4);
QFile::remove(MainWindow::uiStatePath());
QStandardPaths::setTestModeEnabled(false);
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index 0c3353d..4b6bc4c 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -36,6 +36,8 @@ private slots:
void documentActuallyLoads();
void threadContentReachesThePage();
void dataUrlSubResourceStillBlocked();
+ void zoomIsClampedToARenderableRange();
+ void zoomSurvivesANewDocument();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -175,5 +177,58 @@ void TestMessageView::dataUrlSubResourceStillBlocked()
QVERIFY(text.contains(QStringLiteral("visible-text")));
}
+void TestMessageView::zoomIsClampedToARenderableRange()
+{
+ // A factor outside the range leaves the pane unreadable, and the only way
+ // back is a menu entry the user can no longer read. A corrupt state file
+ // reaching setZoomFactor() must not be able to do that.
+ QCOMPARE(MessageView::clampZoom(100.0), MessageView::kMaxZoom);
+ QCOMPARE(MessageView::clampZoom(0.01), MessageView::kMinZoom);
+
+ // A missing or non-numeric state value converts to 0.0, and a hand-edited
+ // one can hold NaN or an infinity. None of those may reach the web view.
+ QCOMPARE(MessageView::clampZoom(0.0), MessageView::kDefaultZoom);
+ QCOMPARE(MessageView::clampZoom(-2.0), MessageView::kDefaultZoom);
+ QCOMPARE(MessageView::clampZoom(qQNaN()), MessageView::kDefaultZoom);
+ QCOMPARE(MessageView::clampZoom(qInf()), MessageView::kDefaultZoom);
+
+ // In-range values pass through untouched.
+ QCOMPARE(MessageView::clampZoom(1.4), 1.4);
+
+ MessageView view;
+ view.setZoomFactor(50.0);
+ QCOMPARE(view.zoomFactor(), MessageView::kMaxZoom);
+}
+
+void TestMessageView::zoomSurvivesANewDocument()
+{
+ // MainWindow persists whatever zoomFactor() reports and never reapplies it
+ // per render, which is only correct if the web view keeps the factor
+ // across setHtml(). Verified rather than assumed.
+ MessageView view;
+ QWebEngineView *web = webViewOf(&view);
+ QVERIFY(web);
+
+ view.setZoomFactor(1.5);
+
+ QSignalSpy loaded(web, &QWebEngineView::loadFinished);
+
+ ParsedMessage message;
+ message.ok = true;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.subject = QStringLiteral("Zoom");
+ message.plainBody = QStringLiteral("body text");
+
+ ThreadRenderItem item;
+ item.message = message;
+ item.cidPrefix = QStringLiteral("m0");
+ item.expanded = true;
+
+ view.showThread({ item });
+ QVERIFY(loaded.wait(15000));
+
+ QCOMPARE(view.zoomFactor(), 1.5);
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"