/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * 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 #include #include class TestCardLayout : public QObject { Q_OBJECT private slots: void everyCardIsTheSameHeight(); void threeLinesStackWithoutOverlapping(); void replyIndentsByDepth(); 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::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"