1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
|
/*
* 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 <QLabel>
#include <QSignalSpy>
#include <QtTest>
#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();
void excludeIsOfferedOnlyWithAQueryToExcludeFrom();
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 <sender@example.org>");
item.message.to = QStringLiteral("Recipient <recipient@example.org>");
item.message.cc = QStringLiteral("Copied <copied@example.org>");
item.message.date = QStringLiteral("Fri, 14 Aug 2026 09:30:00 +0200");
item.message.messageId = QStringLiteral("<abc123@example.org>");
return item;
}
};
void TestMessageDetailsDialog::showsEveryHeaderOfEveryMessage()
{
ThreadRenderItem second = oneMessage();
second.message.subject = QStringLiteral("Re: Quarterly report");
MessageDetailsDialog dialog({ oneMessage(), second });
const QList<HeaderRow> 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 <sender@example.org>")));
QVERIFY(values.contains(QStringLiteral("Re: Quarterly report")));
QVERIFY(values.contains(QStringLiteral("<abc123@example.org>")));
}
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("<b>bold</b><img src=x onerror=1>");
MessageDetailsDialog dialog({ hostile });
const QList<QLabel *> labels = dialog.findChildren<QLabel *>();
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("<b>bold</b>")))
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<HeaderRow> 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 <sender@example.org>\""));
// 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 mode distinguishes them.
dialog.requestSearch(*from, SearchTerm::SearchMode::Replace);
dialog.requestSearch(*from, SearchTerm::SearchMode::Narrow);
QCOMPARE(spy.count(), 2);
QCOMPARE(spy.at(0).at(0).toString(), from->query);
QCOMPARE(spy.at(0).at(1).value<SearchTerm::SearchMode>(),
SearchTerm::SearchMode::Replace);
QCOMPARE(spy.at(1).at(1).value<SearchTerm::SearchMode>(),
SearchTerm::SearchMode::Narrow);
}
void TestMessageDetailsDialog::omitsAnEmptyHeader()
{
ThreadRenderItem noCc = oneMessage();
noCc.message.cc.clear();
MessageDetailsDialog dialog({ noCc });
const QList<HeaderRow> 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<HeaderRow> rows = dialog.rows();
const auto id = std::find_if(
rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
return row.value == QStringLiteral("<abc123@example.org>");
});
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, SearchTerm::SearchMode::Replace);
QCOMPARE(spy.count(), 0);
}
void TestMessageDetailsDialog::excludeIsOfferedOnlyWithAQueryToExcludeFrom()
{
// Excluding from an empty query bar would mean the whole Maildir minus one
// value: a legitimate query, and an implausible thing to have meant by
// right-clicking a value in a fresh window.
MessageDetailsDialog withQuery({ oneMessage() }, true);
MessageDetailsDialog withoutQuery({ oneMessage() }, false);
// The menu is built inside a customContextMenuRequested lambda and cannot
// be popped without a real context-menu event, so assert on the property
// its enabled state is derived from.
QVERIFY(withQuery.canExcludeFromSearch());
QVERIFY(!withoutQuery.canExcludeFromSearch());
const QList<HeaderRow> rows = withoutQuery.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");
// The emit refuses too, so the guard does not rest on the menu alone.
QSignalSpy blocked(&withoutQuery,
&MessageDetailsDialog::searchRequested);
QVERIFY(blocked.isValid());
withoutQuery.requestSearch(*from, SearchTerm::SearchMode::Exclude);
QCOMPARE(blocked.count(), 0);
// And with a query it goes through, so the guard is not simply refusing
// every exclude.
QSignalSpy allowed(&withQuery, &MessageDetailsDialog::searchRequested);
QVERIFY(allowed.isValid());
withQuery.requestSearch(*from, SearchTerm::SearchMode::Exclude);
QCOMPARE(allowed.count(), 1);
QCOMPARE(allowed.at(0).at(1).value<SearchTerm::SearchMode>(),
SearchTerm::SearchMode::Exclude);
}
QTEST_MAIN(TestMessageDetailsDialog)
#include "test_messagedetailsdialog.moc"
|