aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_cardlayout.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_cardlayout.cpp')
-rw-r--r--tests/test_cardlayout.cpp383
1 files changed, 383 insertions, 0 deletions
diff --git a/tests/test_cardlayout.cpp b/tests/test_cardlayout.cpp
new file mode 100644
index 0000000..fdc18bf
--- /dev/null
+++ b/tests/test_cardlayout.cpp
@@ -0,0 +1,383 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "cardlayout.h"
+
+#include <QFont>
+#include <QLocale>
+#include <QTest>
+
+class TestCardLayout : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void everyCardIsTheSameHeight();
+ void threeLinesStackWithoutOverlapping();
+ void replyIndentsByDepth();
+ void aDepthZeroReplyStillIndents();
+ void indentStopsAtTheCap();
+ void expanderSitsOnTheSecondLine();
+ void expanderIsEmptyWithoutReplies();
+ void theExpanderReadsAsAPillWithAWord();
+ void dateIsFlushRight();
+ void threadCardCarriesAnAccentBar();
+ void replyCardCarriesNoAccentBar();
+ void theDateFitsWhenTheCardIsBold();
+ void theDateFollowsTheSystemLocale();
+};
+
+namespace {
+
+CardLayout::Input threadInput()
+{
+ CardLayout::Input in;
+ in.isMessage = false;
+ in.depth = 0;
+ in.replyCount = 3;
+ return in;
+}
+
+CardLayout::Input replyInput(int depth)
+{
+ CardLayout::Input in;
+ in.isMessage = true;
+ in.depth = depth;
+ in.replyCount = 0;
+ return in;
+}
+
+} // namespace
+
+void TestCardLayout::everyCardIsTheSameHeight()
+{
+ const QFont font;
+ const int thread = CardLayout::heightFor(font);
+
+ // The uniform height is the whole reason setUniformRowHeights(true)
+ // survives this design, so it is asserted directly rather than inferred
+ // from two cards happening to look alike.
+ //
+ // Note what is NOT varied here: the tag list. CardLayout reserves line 3
+ // unconditionally and never sees the tags, which is exactly the property
+ // being asserted. A version of this test that passed a tag list in would
+ // be testing a parameter that does not exist.
+ const CardLayout threadCard =
+ CardLayout::compute(threadInput(), QRect(0, 0, 400, thread), font);
+ const CardLayout deepReply =
+ CardLayout::compute(replyInput(3), QRect(0, 0, 400, thread), font);
+ CardLayout::Input noRepliesIn = threadInput();
+ noRepliesIn.replyCount = 0;
+ const CardLayout noReplies =
+ CardLayout::compute(noRepliesIn, QRect(0, 0, 400, thread), font);
+
+ QCOMPARE(threadCard.totalHeight, thread);
+ QCOMPARE(deepReply.totalHeight, thread);
+ QCOMPARE(noReplies.totalHeight, thread);
+
+ // The third line exists on every card, including one with nothing to put
+ // there. That blank band is the cost the uniform height was bought with.
+ QCOMPARE(noReplies.tagRect.height(), threadCard.tagRect.height());
+}
+
+void TestCardLayout::threeLinesStackWithoutOverlapping()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const CardLayout card =
+ CardLayout::compute(threadInput(), QRect(0, 0, 400, h), font);
+
+ QVERIFY(card.senderRect.height() > 0);
+ QVERIFY(card.subjectRect.height() > 0);
+ QVERIFY(card.tagRect.height() > 0);
+
+ // Guard: these must actually be three stacked bands. A layout that
+ // collapsed them all to the same rect would satisfy any assertion that
+ // only checked they exist.
+ QVERIFY(card.senderRect.bottom() <= card.subjectRect.top());
+ QVERIFY(card.subjectRect.bottom() <= card.tagRect.top());
+ QVERIFY(card.tagRect.bottom() <= h);
+}
+
+void TestCardLayout::replyIndentsByDepth()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const QRect rect(0, 0, 400, h);
+
+ const CardLayout root = CardLayout::compute(threadInput(), rect, font);
+ const CardLayout d1 = CardLayout::compute(replyInput(1), rect, font);
+ const CardLayout d2 = CardLayout::compute(replyInput(2), rect, font);
+
+ QVERIFY(d1.contentLeft > root.contentLeft);
+ QVERIFY(d2.contentLeft > d1.contentLeft);
+
+ // One spine per depth level, so the count is the depth itself.
+ QCOMPARE(root.spines.size(), 0);
+ QCOMPARE(d1.spines.size(), 1);
+ QCOMPARE(d2.spines.size(), 2);
+
+ // Each spine runs the full height of the card, which is what makes an
+ // expansion read as one continuous block rather than as dashes.
+ for (const QRect &spine : d2.spines) {
+ QCOMPARE(spine.top(), rect.top());
+ QCOMPARE(spine.bottom(), rect.bottom());
+ }
+}
+
+void TestCardLayout::aDepthZeroReplyStillIndents()
+{
+ // A reply in a FLAT thread carries depth 0, because notmuch reports every
+ // message of a thread with no usable In-Reply-To as a top-level message.
+ // It is still a reply: it is a child row under the root card, and it has
+ // to read as one.
+ //
+ // Treating depth 0 as "no nesting" left those replies flush against their
+ // thread with no spine, while a nested thread's replies indented normally,
+ // so the list showed two different shapes for the same relationship.
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const QRect rect(0, 0, 400, h);
+
+ CardLayout::Input flatReply;
+ flatReply.isMessage = true;
+ flatReply.depth = 0;
+
+ const CardLayout root = CardLayout::compute(threadInput(), rect, font);
+ const CardLayout reply = CardLayout::compute(flatReply, rect, font);
+
+ QVERIFY2(reply.contentLeft > root.contentLeft,
+ "a depth-0 reply sits flush with its thread, so a flat thread's "
+ "replies look like more threads");
+ QVERIFY2(!reply.spines.isEmpty(),
+ "a depth-0 reply has no spine, so nothing joins it to the thread "
+ "above it");
+
+ // And it lands at the same place a depth-1 reply does: the two are the
+ // same relationship and notmuch's numbering is the only difference.
+ const CardLayout nested = CardLayout::compute(replyInput(1), rect, font);
+ QCOMPARE(reply.contentLeft, nested.contentLeft);
+ QCOMPARE(reply.spines.size(), nested.spines.size());
+}
+
+void TestCardLayout::indentStopsAtTheCap()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const QRect rect(0, 0, 400, h);
+
+ const CardLayout d4 = CardLayout::compute(replyInput(4), rect, font);
+ const CardLayout d5 = CardLayout::compute(replyInput(5), rect, font);
+ const CardLayout d9 = CardLayout::compute(replyInput(9), rect, font);
+
+ QCOMPARE(d5.contentLeft, d4.contentLeft);
+ QCOMPARE(d9.contentLeft, d4.contentLeft);
+ QCOMPARE(d5.spines.size(), d4.spines.size());
+ QCOMPARE(d9.spines.size(), d4.spines.size());
+
+ // Guard: the cap must not be so low that it has already bitten at depth 3,
+ // which would make the three assertions above true for the wrong reason.
+ const CardLayout d3 = CardLayout::compute(replyInput(3), rect, font);
+ QVERIFY(d3.contentLeft < d4.contentLeft);
+}
+
+void TestCardLayout::expanderSitsOnTheSecondLine()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const CardLayout card =
+ CardLayout::compute(threadInput(), QRect(0, 0, 400, h), font);
+
+ QVERIFY(!card.expanderRect.isEmpty());
+ // It is the reply count, so it belongs on the line the reply count is on.
+ QVERIFY(card.expanderRect.top() >= card.subjectRect.top());
+ QVERIFY(card.expanderRect.bottom() <= card.subjectRect.bottom());
+ // And it is on the right, where the count is drawn, not in a left gutter.
+ QVERIFY(card.expanderRect.left() > 400 / 2);
+}
+
+void TestCardLayout::expanderIsEmptyWithoutReplies()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ CardLayout::Input in = threadInput();
+ in.replyCount = 0;
+
+ const CardLayout card = CardLayout::compute(in, QRect(0, 0, 400, h), font);
+ QVERIFY(card.expanderRect.isEmpty());
+}
+
+void TestCardLayout::theExpanderReadsAsAPillWithAWord()
+{
+ // A bare "3" beside the subject reads as an unexplained number and gives
+ // no hint that it can be clicked. The label carries the word, and the rect
+ // carries padding for the pill drawn behind it.
+ QCOMPARE(CardLayout::expanderLabel(3, false),
+ QStringLiteral("\u25b8 3 replies"));
+ QCOMPARE(CardLayout::expanderLabel(3, true),
+ QStringLiteral("\u25be 3 replies"));
+
+ // Singular, because "1 replies" is the kind of detail that makes an
+ // interface look unfinished.
+ QCOMPARE(CardLayout::expanderLabel(1, false),
+ QStringLiteral("\u25b8 1 reply"));
+
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const CardLayout card =
+ CardLayout::compute(threadInput(), QRect(0, 0, 400, h), font);
+ const QFontMetrics small(CardLayout::smallFont(font));
+
+ // The rect must hold the label AND its padding, or the pill's background
+ // is narrower than the text sitting on it.
+ QVERIFY2(card.expanderRect.width()
+ >= small.horizontalAdvance(CardLayout::expanderLabel(3, false))
+ + CardLayout::kPillPaddingX * 2,
+ "the expander rect is too narrow for its own label and padding");
+
+ // And it must NOT change width when the card opens: a pill that resized on
+ // click would shift the subject's elision under the pointer.
+ CardLayout::Input open = threadInput();
+ const CardLayout expanded =
+ CardLayout::compute(open, QRect(0, 0, 400, h), font);
+ QCOMPARE(expanded.expanderRect.width(), card.expanderRect.width());
+}
+
+void TestCardLayout::dateIsFlushRight()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const QRect rect(0, 0, 400, h);
+ const CardLayout card = CardLayout::compute(threadInput(), rect, font);
+
+ // Compared as exclusive edges. QRect::right() is inclusive (left + width -
+ // 1), so asserting card.dateRect.right() == rect.right() - kPaddingX
+ // demands a gap of kPaddingX - 1 pixels and is off by one against the
+ // padding the constant names.
+ QCOMPARE(card.dateRect.right() + 1, rect.right() + 1 - CardLayout::kPaddingX);
+ // The sender must stop before the date starts, or a long sender overwrites
+ // it. This is the assertion that fails if the two are laid out
+ // independently.
+ QVERIFY(card.senderRect.right() <= card.dateRect.left());
+}
+
+void TestCardLayout::threadCardCarriesAnAccentBar()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const QRect rect(0, 0, 400, h);
+ const CardLayout card = CardLayout::compute(threadInput(), rect, font);
+
+ QCOMPARE(card.accentRect.left(), rect.left());
+ QCOMPARE(card.accentRect.width(), CardLayout::kAccentWidth);
+ // Full height, so a run of cards from one account reads as a continuous
+ // edge rather than as dashes.
+ QCOMPARE(card.accentRect.top(), rect.top());
+ QCOMPARE(card.accentRect.bottom(), rect.bottom());
+
+ // Nothing may be drawn on top of the colour.
+ QVERIFY(card.contentLeft >= card.accentRect.right());
+}
+
+void TestCardLayout::replyCardCarriesNoAccentBar()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const CardLayout reply =
+ CardLayout::compute(replyInput(1), QRect(0, 0, 400, h), font);
+
+ // A reply's account is its thread's, stated once at the head. The spine
+ // carries the accent instead, so the gutter never holds two lines.
+ QVERIFY(reply.accentRect.isEmpty());
+ QCOMPARE(reply.spines.size(), 1);
+}
+
+void TestCardLayout::theDateFollowsTheSystemLocale()
+{
+ const QDateTime when(QDate(2025, 8, 10), QTime(6, 26));
+
+ // The system locale's own rendering, whatever it is. Asserting a specific
+ // string would only restate the hardcoded pattern this replaced, and would
+ // fail on any machine but the one that wrote it.
+ QCOMPARE(CardLayout::formatDate(when),
+ QLocale::system().toString(when, QLocale::ShortFormat));
+
+ // The specific fault: an ISO-looking pattern on a desktop that does not
+ // use one. Guarded so this test says nothing on a locale that genuinely
+ // formats that way.
+ if (QLocale::system().toString(when, QLocale::ShortFormat)
+ != QStringLiteral("2025-08-10 06:26")) {
+ QVERIFY2(CardLayout::formatDate(when)
+ != QStringLiteral("2025-08-10 06:26"),
+ "the date is hardcoded to yyyy-MM-dd hh:mm rather than "
+ "following the desktop's locale");
+ }
+
+ // And the reserved width has to follow the same formatter, or a locale
+ // whose dates are longer clips them exactly as the bold font did.
+ QFont font;
+ const int h = CardLayout::heightFor(font);
+ const CardLayout card =
+ CardLayout::compute(threadInput(), QRect(0, 0, 400, h), font);
+ QFont bold = font;
+ bold.setBold(true);
+ QVERIFY2(card.dateRect.width()
+ >= QFontMetrics(bold).horizontalAdvance(
+ CardLayout::formatDate(when)),
+ "the reserved date width is narrower than this locale's own "
+ "formatting of a date");
+}
+
+void TestCardLayout::theDateFitsWhenTheCardIsBold()
+{
+ // An UNREAD card draws BOLD, and bold is wider. The layout is computed from
+ // option.font, which is the view's regular font, while the text is painted
+ // with the font initStyleOption resolved from the model's Qt::FontRole. So
+ // a date measured regular and drawn bold overflows its rect: measured at
+ // 154px reserved against 170px needed, which clipped the leading digit of
+ // the year off every unread card.
+ //
+ // The fix is in the layout rather than in the delegate: it reserves the
+ // BOLD width whatever font it is handed, so the two can never disagree.
+ QFont regular;
+ regular.setBold(false);
+ QFont bold = regular;
+ bold.setBold(true);
+
+ const QString sample = QStringLiteral("2025-08-10 06:26");
+ const int boldWidth = QFontMetrics(bold).horizontalAdvance(sample);
+
+ // Guard: bold must actually be wider here, or this asserts nothing.
+ QVERIFY2(boldWidth > QFontMetrics(regular).horizontalAdvance(sample),
+ "bold is not wider than regular in this environment, so this test "
+ "cannot detect the overflow it exists for");
+
+ const int h = CardLayout::heightFor(regular);
+ const CardLayout card =
+ CardLayout::compute(threadInput(), QRect(0, 0, 400, h), regular);
+
+ QVERIFY2(card.dateRect.width() >= boldWidth,
+ qPrintable(QStringLiteral("a layout computed from the REGULAR "
+ "font reserves %1px, and the date needs "
+ "%2px when the card draws bold")
+ .arg(card.dateRect.width())
+ .arg(boldWidth)));
+}
+
+QTEST_MAIN(TestCardLayout)
+#include "test_cardlayout.moc"