From 2f124b22920fb290684206a2c905996f1c378fd7 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 14 Aug 2026 13:01:04 +0200 Subject: feat(details): rebuild the message details dialog as rows A text box could not carry a per-value context menu without parsing displayed text back into structure, and the user did not want a text box. Each row now holds its own value, its message index and its query, built from the parsed message. Every value label states Qt::PlainText. The QPlainTextEdit this replaced was plain by design rather than by style: header values come from strangers, and a QLabel guesses the format under AutoText. --- tests/test_messagedetailsdialog.cpp | 182 ++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/test_messagedetailsdialog.cpp (limited to 'tests/test_messagedetailsdialog.cpp') diff --git a/tests/test_messagedetailsdialog.cpp b/tests/test_messagedetailsdialog.cpp new file mode 100644 index 0000000..4e685ce --- /dev/null +++ b/tests/test_messagedetailsdialog.cpp @@ -0,0 +1,182 @@ +/* + * 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 +#include +#include + +#include "htmlbuilder.h" +#include "messagedetailsdialog.h" + +/// The details dialog, which shows every header of every message in a thread. +/// +/// Rows rather than one text box since item 85, so a value can carry its own +/// context menu without anything parsing rendered text back into structure. +class TestMessageDetailsDialog : public QObject +{ + Q_OBJECT +private slots: + void showsEveryHeaderOfEveryMessage(); + void valueLabelsCannotRenderMarkup(); + void offersASearchForEachValue(); + void omitsAnEmptyHeader(); + void messageIdIsShownButNotSearchable(); + +private: + /// One message, with every header populated. The date's weekday matches + /// the date: Qt::RFC2822Date validates the two against each other, and + /// 2026-08-14 is a Friday. + ThreadRenderItem oneMessage() const + { + ThreadRenderItem item; + item.message.ok = true; + item.message.subject = QStringLiteral("Quarterly report"); + item.message.from = QStringLiteral("Sender "); + item.message.to = QStringLiteral("Recipient "); + item.message.cc = QStringLiteral("Copied "); + item.message.date = QStringLiteral("Fri, 14 Aug 2026 09:30:00 +0200"); + item.message.messageId = QStringLiteral(""); + return item; + } +}; + +void TestMessageDetailsDialog::showsEveryHeaderOfEveryMessage() +{ + ThreadRenderItem second = oneMessage(); + second.message.subject = QStringLiteral("Re: Quarterly report"); + + MessageDetailsDialog dialog({ oneMessage(), second }); + + const QList rows = dialog.rows(); + QVERIFY2(!rows.isEmpty(), "no rows: the dialog was never populated"); + + // Both messages are represented, each row knowing which one it belongs to. + QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) { + return row.messageIndex == 0; + })); + QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) { + return row.messageIndex == 1; + })); + + QStringList values; + for (const HeaderRow &row : rows) + values << row.value; + QVERIFY(values.contains(QStringLiteral("Sender "))); + QVERIFY(values.contains(QStringLiteral("Re: Quarterly report"))); + QVERIFY(values.contains(QStringLiteral(""))); +} + +void TestMessageDetailsDialog::valueLabelsCannotRenderMarkup() +{ + // The QPlainTextEdit this replaced was plain by DESIGN, not by style: + // header values come from strangers and plain text cannot interpret + // markup. A QLabel guesses under Qt::AutoText, so every label states its + // format rather than relying on escaping, which is the same protection one + // mistake away from failing. + ThreadRenderItem hostile = oneMessage(); + hostile.message.subject = + QStringLiteral("bold"); + + MessageDetailsDialog dialog({ hostile }); + + const QList labels = dialog.findChildren(); + QVERIFY2(!labels.isEmpty(), "no labels: the dialog was never populated"); + + bool sawTheSubject = false; + for (const QLabel *label : labels) { + QCOMPARE(label->textFormat(), Qt::PlainText); + if (label->text().contains(QStringLiteral("bold"))) + sawTheSubject = true; + } + + // The markup survives AS TEXT, which is the proof it was not interpreted. + QVERIFY2(sawTheSubject, "the hostile subject never reached a label"); +} + +void TestMessageDetailsDialog::offersASearchForEachValue() +{ + MessageDetailsDialog dialog({ oneMessage() }); + + QSignalSpy spy(&dialog, &MessageDetailsDialog::searchRequested); + QVERIFY(spy.isValid()); + + const QList rows = dialog.rows(); + const auto from = std::find_if( + rows.cbegin(), rows.cend(), [](const HeaderRow &row) { + return row.field == QStringLiteral("from"); + }); + QVERIFY2(from != rows.cend(), "no From row to search from"); + QCOMPARE(from->query, QStringLiteral("from:\"Sender \"")); + + // The date becomes a one-day range rather than a text match on the header. + const auto date = std::find_if( + rows.cbegin(), rows.cend(), [](const HeaderRow &row) { + return row.field == QStringLiteral("date"); + }); + QVERIFY2(date != rows.cend(), "no Date row"); + QCOMPARE(date->query, QStringLiteral("date:2026-08-14..2026-08-14")); + + // Replacing and narrowing are both offered, and the flag distinguishes them. + dialog.requestSearch(*from, false); + dialog.requestSearch(*from, true); + QCOMPARE(spy.count(), 2); + QCOMPARE(spy.at(0).at(0).toString(), from->query); + QCOMPARE(spy.at(0).at(1).toBool(), false); + QCOMPARE(spy.at(1).at(1).toBool(), true); +} + +void TestMessageDetailsDialog::omitsAnEmptyHeader() +{ + ThreadRenderItem noCc = oneMessage(); + noCc.message.cc.clear(); + + MessageDetailsDialog dialog({ noCc }); + + const QList rows = dialog.rows(); + // Guard first: an absence assertion alone passes against no implementation. + QVERIFY2(!rows.isEmpty(), "no rows: the dialog was never populated"); + QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) { + return row.field == QStringLiteral("from"); + })); + + for (const HeaderRow &row : rows) + QVERIFY(row.field != QStringLiteral("cc")); +} + +void TestMessageDetailsDialog::messageIdIsShownButNotSearchable() +{ + // A message id names one message, and the thread holding it is already on + // screen, so there is nothing useful to search for. It is still shown. + MessageDetailsDialog dialog({ oneMessage() }); + + const QList rows = dialog.rows(); + const auto id = std::find_if( + rows.cbegin(), rows.cend(), [](const HeaderRow &row) { + return row.value == QStringLiteral(""); + }); + QVERIFY2(id != rows.cend(), "the message id is not shown at all"); + QVERIFY(id->query.isEmpty()); + + // And asking to search it emits nothing rather than an empty query. + QSignalSpy spy(&dialog, &MessageDetailsDialog::searchRequested); + dialog.requestSearch(*id, false); + QCOMPARE(spy.count(), 0); +} + +QTEST_MAIN(TestMessageDetailsDialog) +#include "test_messagedetailsdialog.moc" -- cgit v1.2.3