aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-07 17:51:52 +0200
committerDanilo M. <danix@danix.xyz>2026-08-07 17:51:52 +0200
commit334b510e2673a6ab3875ffa7a4c5b3b2dd09a369 (patch)
tree59b38af32af41d629a0c02ecd8e0fcfcddbb60e6 /tests
parent6003bab2d4c491a857b7a728f2f23c719723ef1a (diff)
downloadqtmaildir-334b510e2673a6ab3875ffa7a4c5b3b2dd09a369.tar.gz
qtmaildir-334b510e2673a6ab3875ffa7a4c5b3b2dd09a369.zip
feat(ui): fill the blank message pane with a branded placeholder
An empty right pane said nothing, and multi-select made it a routine sight. It now carries the wordmark, thread counts that run their query when clicked, and a sync line that appears only when something needs attention. Rendered into the existing web view as a third document shape, so there is one document path and one set of security rules. The brand palette is a deliberate exception to deriving colours from the desktop theme, since a logo is brand rather than chrome; the theme still picks which of the two sets is used. Counts refresh when the pane is about to show rather than in the background: one goes stale the moment a tag is edited, and refreshing one nobody is looking at is work for nothing. A generation counter discards a superseded reply, and a late answer cannot repaint over an opened thread. The helper lines are real links because JavaScript is off in this profile. The handler is gated on the placeholder actually being displayed, so the same URL inside a message body is dropped: a stranger's mail must not drive the thread list, even to run a harmless query. Three defects found while building, all silent: - Every CSS percentage was invalid. QString::arg does not collapse "%%" into "%", so the document carried "50%%" and the browser dropped each declaration holding one, disabling the mask, the glow and both radial gradients while still rendering something plausible. Substitution is by named token now, which cannot collide with a percent sign. - A geometry probe endorsed the layout while that was live, because it measured only properties without percentages. - The font test passed against a build with one face missing, since the other satisfied both of its checks on its own. The mockup's light values needed correcting against a real pane: the grid vanished at a 2% luminance step on white, and the glow subtracts light there rather than adding it, washing the pane. Strength only, not hue.
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt8
-rw-r--r--tests/test_htmlbuilder.cpp171
-rw-r--r--tests/test_messageview.cpp74
-rw-r--r--tests/test_notmuchworker.cpp51
4 files changed, 303 insertions, 1 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 0f6ec5a..c09ef78 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,6 +1,12 @@
# add_qtmaildir_test(<name>) builds tests/test_<name>.cpp and registers it.
+#
+# resources.qrc is compiled into each test binary for the same reason the app
+# compiles it rather than the static library: a qrc inside a .a registers from
+# a global initialiser the linker then drops. Library code reads :/fonts/ when
+# it builds the placeholder pane, so a test that never links the qrc would
+# exercise only the missing-resource fallback and pass against a broken build.
function(add_qtmaildir_test name)
- add_executable(test_${name} test_${name}.cpp)
+ add_executable(test_${name} test_${name}.cpp ${CMAKE_SOURCE_DIR}/src/resources.qrc)
target_link_libraries(test_${name} PRIVATE qtmaildir_lib Qt6::Test)
add_test(NAME ${name} COMMAND test_${name})
endfunction()
diff --git a/tests/test_htmlbuilder.cpp b/tests/test_htmlbuilder.cpp
index 7fa45a2..fdb57c5 100644
--- a/tests/test_htmlbuilder.cpp
+++ b/tests/test_htmlbuilder.cpp
@@ -48,6 +48,15 @@ private slots:
// Theming.
void aDarkPaletteProducesADarkDocument();
void everyColourComesFromThePalette();
+
+ // The placeholder pane (item 30).
+ void placeholderPicksTheBrandSetFromTheDesktopTheme();
+ void placeholderHelperBecomesALinkOnItsQuery();
+ void placeholderHelperWithoutAQueryIsNotALink();
+ void placeholderEscapesHelperText();
+ void placeholderEmbedsItsFontsRatherThanFetchingThem();
+ void placeholderReferencesNoRemoteResource();
+ void placeholderStyleHasNoUnsubstitutedTokens();
void theBodyAlwaysGetsABackground();
void aSendersOwnHtmlIsNotRecoloured();
};
@@ -357,6 +366,168 @@ void TestHtmlBuilder::everyColourComesFromThePalette()
}
}
+void TestHtmlBuilder::placeholderPicksTheBrandSetFromTheDesktopTheme()
+{
+ // The brand colours are fixed, deliberately: this is the one place where
+ // the desktop palette does NOT supply the values. What the desktop decides
+ // is which of the two sets is used, and getting that backwards is the
+ // failure the item warns about, a light lockup on a dark desktop.
+ const HtmlBuilder::BrandPalette dark = HtmlBuilder::brandPaletteFrom(
+ makeTestPalette(QColor(0x1a, 0x1a, 0x1a), QColor(0xee, 0xee, 0xee)));
+ const HtmlBuilder::BrandPalette light = HtmlBuilder::brandPaletteFrom(
+ makeTestPalette(QColor(0xff, 0xff, 0xff), QColor(0x11, 0x11, 0x11)));
+
+ QCOMPARE(dark.background.name(), QStringLiteral("#060b10"));
+ QCOMPARE(light.background.name(), QStringLiteral("#ffffff"));
+
+ // Not merely different: the right way round. A set whose background is
+ // darker than its text is the dark set, whichever values it holds.
+ QVERIFY(dark.background.lightnessF() < dark.title.lightnessF());
+ QVERIFY(light.background.lightnessF() > light.title.lightnessF());
+}
+
+void TestHtmlBuilder::placeholderHelperBecomesALinkOnItsQuery()
+{
+ // JavaScript is off in this profile, so a helper can only be actionable by
+ // being a real link that the page's navigation handler intercepts.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("12 unread"), QStringLiteral("tag:unread") } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(html.contains(QStringLiteral("href=\"qtmaildir-query:tag%3Aunread\"")));
+ QVERIFY(html.contains(QStringLiteral("12 unread")));
+}
+
+void TestHtmlBuilder::placeholderHelperWithoutAQueryIsNotALink()
+{
+ // The sync line reports a state rather than naming a search, so clicking it
+ // must not run an empty query and wipe the thread list.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("3 edits waiting to sync"), QString() } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(html.contains(QStringLiteral("3 edits waiting to sync")));
+ QVERIFY(!html.contains(QStringLiteral("qtmaildir-query:")));
+}
+
+void TestHtmlBuilder::placeholderEscapesHelperText()
+{
+ // A helper label carries a count this code produced, but the query half is
+ // built from configuration and a saved query is user-written. Neither may
+ // reach the document unescaped, and the query is doubly encoded: percent
+ // for the URL, then HTML for the attribute.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("<script>alert(1)</script>"),
+ QStringLiteral("tag:\"a\"><script>") } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(!html.contains(QStringLiteral("<script>")));
+ QVERIFY(html.contains(QStringLiteral("&lt;script&gt;")));
+}
+
+void TestHtmlBuilder::placeholderEmbedsItsFontsRatherThanFetchingThem()
+{
+ // The mockup @imports Google Fonts, which the interceptor blocks by design.
+ // The fonts ship in the binary and must arrive as data: URIs, or the pane
+ // silently falls back to a system font and stops looking like the brand.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ {}, QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(!html.contains(QStringLiteral("fonts.googleapis.com")));
+
+ // BOTH faces, each with a real payload. Counting @font-face rules or
+ // checking the document's total size passes with one face missing, since
+ // the other is large enough on its own to carry either check: a mutation
+ // pointing one src at a nonexistent resource survived exactly that test.
+ // A missing resource yields an empty src, so the length is what catches it.
+ static const QRegularExpression src(
+ QStringLiteral("src: url\\('data:font/woff2;base64,([^']*)'\\)"));
+ auto it = src.globalMatch(html);
+ int faces = 0;
+ while (it.hasNext()) {
+ ++faces;
+ QVERIFY(it.next().captured(1).size() > 1000);
+ }
+ QCOMPARE(faces, 2);
+}
+
+void TestHtmlBuilder::placeholderReferencesNoRemoteResource()
+{
+ // The load-bearing security check, asserted as a negative for the same
+ // reason everyColourComesFromThePalette is: one leftover reference is the
+ // entire defect, and it would be invisible because the interceptor blocks
+ // it and the pane just renders slightly wrong.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("12 unread"), QStringLiteral("tag:unread") } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(!html.contains(QStringLiteral("//fonts")));
+ QVERIFY(!html.contains(QStringLiteral("@import")));
+
+ // The one http: URL is the SVG namespace, which is an identifier and never
+ // fetched. Asserting its exact value rather than excluding the scheme
+ // wholesale: a second http: URL appearing later would be a real resource.
+ static const QRegularExpression http(QStringLiteral("http://[^\"' ]*"));
+ auto plain = http.globalMatch(html);
+ while (plain.hasNext()) {
+ QCOMPARE(plain.next().captured(0),
+ QStringLiteral("http://www.w3.org/2000/svg"));
+ }
+
+ // Every https: URL must be the footer's website link, which is a link the
+ // user clicks and not a resource the document fetches.
+ static const QRegularExpression https(QStringLiteral("https://[^\"' ]*"));
+ auto it = https.globalMatch(html);
+ while (it.hasNext()) {
+ QCOMPARE(it.next().captured(0),
+ QStringLiteral("https://danix.xyz/qtmaildir"));
+ }
+}
+
+void TestHtmlBuilder::placeholderStyleHasNoUnsubstitutedTokens()
+{
+ // The defect this exists for shipped once and was invisible. The template
+ // used QString::arg with "%%" for every CSS percentage, and arg() does NOT
+ // collapse "%%" into "%", so the stylesheet reached the browser carrying
+ // "50%%". Each declaration holding one was dropped as invalid, which
+ // silently disabled the grid mask, the glow and both radial gradients. The
+ // pane still rendered, still looked plausible, and a geometry probe that
+ // happened to measure only percentage-free properties reported it correct.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("12 unread"), QStringLiteral("tag:unread") } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ const qsizetype start = html.indexOf(QStringLiteral("<style>"));
+ const qsizetype end = html.indexOf(QStringLiteral("</style>"));
+ QVERIFY(start >= 0 && end > start);
+ const QString style = html.mid(start, end - start);
+
+ // No doubled percent survives into the document.
+ QVERIFY2(!style.contains(QStringLiteral("%%")),
+ "the stylesheet carries '%%', which CSS rejects: every rule "
+ "containing one is silently dropped");
+
+ // No token went unreplaced. A renamed colour would otherwise leave
+ // '@ACCENT@' sitting in the CSS as a dropped declaration.
+ static const QRegularExpression token(QStringLiteral("@[A-Z_]+@"));
+ const QRegularExpressionMatch leftover = token.match(style);
+ QVERIFY2(!leftover.hasMatch(),
+ qPrintable(QStringLiteral("unsubstituted token '%1' in the "
+ "stylesheet").arg(leftover.captured(0))));
+
+ // The three effects the bug disabled, each asserted by name so that
+ // deleting one is a test failure rather than a silent visual regression.
+ QVERIFY(style.contains(QStringLiteral("mask-image")));
+ QVERIFY(style.contains(QStringLiteral("radial-gradient")));
+ QVERIFY(style.contains(QStringLiteral("aspect-ratio")));
+}
+
void TestHtmlBuilder::theBodyAlwaysGetsABackground()
{
// The original CSS set no background at all, which is why the pane was
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index 57d7b42..e94fb86 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -47,6 +47,8 @@ private slots:
void headerEscapesUntrustedValues();
void headerOmitsAnAbsentCc();
void detailsDialogIsOfferedForEveryThread();
+ void placeholderRendersAndReportsItself();
+ void aMessageBodyCannotRunAQuery();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -492,5 +494,77 @@ void TestMessageView::detailsDialogIsOfferedForEveryThread()
QVERIFY(!button || !button->isVisible());
}
+void TestMessageView::placeholderRendersAndReportsItself()
+{
+ MessageView view;
+ QWebEngineView *web = webViewOf(&view);
+ QVERIFY(web);
+
+ QSignalSpy loaded(web, &QWebEngineView::loadFinished);
+ view.showPlaceholder({ { QStringLiteral("7 unread"),
+ QStringLiteral("tag:unread") } });
+
+ QVERIFY2(loaded.wait(15000), "the placeholder document never loaded");
+ QVERIFY2(loaded.last().at(0).toBool(),
+ "loadFinished reported failure: the navigation was rejected, "
+ "which is what a base-URL mismatch looks like");
+
+ // Rendered, not merely loaded. The wordmark is split across elements by
+ // the accent span, so the helper line is what proves the content arrived.
+ QString text;
+ bool done = false;
+ web->page()->toPlainText([&](const QString &result) {
+ text = result;
+ done = true;
+ });
+ QTRY_VERIFY_WITH_TIMEOUT(done, 15000);
+ QVERIFY2(text.contains(QStringLiteral("7 unread")), qPrintable(text));
+
+ QVERIFY(view.showingPlaceholder());
+}
+
+void TestMessageView::aMessageBodyCannotRunAQuery()
+{
+ // The gate behind queryRequested(). A message body is attacker-controlled
+ // HTML and can carry a qtmaildir-query: link; the view only honours one
+ // while the placeholder is what is displayed, so this asserts the state
+ // that decides it rather than synthesising a click, which would need the
+ // page's protected navigation handler.
+ MessageView view;
+ QSignalSpy queries(&view, &MessageView::queryRequested);
+
+ view.showPlaceholder({ { QStringLiteral("7 unread"),
+ QStringLiteral("tag:unread") } });
+ QVERIFY(view.showingPlaceholder());
+
+ ParsedMessage message;
+ message.ok = true;
+ message.from = QStringLiteral("Mallory <mallory@example.org>");
+ message.subject = QStringLiteral("Click me");
+ message.htmlBody = QStringLiteral(
+ "<a href=\"qtmaildir-query:tag%3Adeleted\">a link</a>");
+
+ ThreadRenderItem item;
+ item.message = message;
+ item.cidPrefix = QStringLiteral("m0");
+ item.expanded = true;
+
+ view.showThread({ item });
+
+ // Showing any message closes the gate, so a link in that message's own
+ // body has nothing to reach.
+ QVERIFY2(!view.showingPlaceholder(),
+ "the gate stayed open while a message was displayed: a link in a "
+ "message body could run a query");
+
+ view.clear();
+ QVERIFY(!view.showingPlaceholder());
+
+ view.showError(QStringLiteral("broken"), QStringLiteral("/tmp/x"));
+ QVERIFY(!view.showingPlaceholder());
+
+ QVERIFY(queries.isEmpty());
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 488b630..be62ad3 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -58,6 +58,9 @@ private slots:
void requestAllTagsReturnsSortedTags();
void requestAllTagsOnUnreadableConfigEmitsError();
+ void requestCountsAnswersOneCountPerQuery();
+ void requestCountsKeepsPositionOnAnInvalidQuery();
+
private:
/// Tags of one message, read back through a fresh worker query.
QStringList tagsOf(const QString &messageId);
@@ -472,5 +475,53 @@ void TestNotmuchWorker::requestAllTagsOnUnreadableConfigEmitsError()
QVERIFY(ready.isEmpty());
}
+void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::countsReady);
+
+ worker.requestCounts({ QStringLiteral("tag:unread"),
+ QStringLiteral("tag:inbox"),
+ QStringLiteral("tag:flagged") }, 9);
+
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(1).value<quint64>(), quint64(9));
+
+ // Threads, not messages: thread A holds two messages and must count once,
+ // which is the number the pane's "N in inbox" line claims to be showing.
+ const QVector<int> counts = spy.at(0).at(0).value<QVector<int>>();
+ QCOMPARE(counts, QVector<int>({ 1, 3, 0 }));
+}
+
+void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
+{
+ // The caller pairs answers with its own labels by index, so every query
+ // must produce exactly one entry at its own position. Dropping one would
+ // shift every later count onto the wrong label, and the pane would show a
+ // real number against the wrong name rather than visibly breaking.
+ //
+ // **notmuch's query parser rejects almost nothing.** malformedQuery...
+ // above records the same finding: an unbalanced quote parses and matches
+ // nothing. `((((` behaves the same way and counts 0 rather than failing,
+ // which is why this asserts the positional contract rather than a -1 that
+ // no query string can actually provoke. The -1 branch remains for a
+ // notmuch_query_create allocation failure, which a test cannot reach.
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::countsReady);
+
+ worker.requestCounts({ QStringLiteral("tag:unread"),
+ QStringLiteral("(((("),
+ QStringLiteral("tag:inbox") }, 1);
+
+ QCOMPARE(spy.count(), 1);
+ const QVector<int> counts = spy.at(0).at(0).value<QVector<int>>();
+ QCOMPARE(counts.size(), 3);
+
+ // The queries either side keep their own answers, which is the property
+ // the pane depends on.
+ QCOMPARE(counts.at(0), 1);
+ QCOMPARE(counts.at(2), 3);
+}
+
QTEST_MAIN(TestNotmuchWorker)
#include "test_notmuchworker.moc"