summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-18 15:25:14 +0200
committerDanilo M. <danix@danix.xyz>2026-09-18 15:25:14 +0200
commit9d4fb5444438bdf188a6149a6667788dc3fc7bc0 (patch)
tree10d4a63f18334ed68d65805b78e16fe1b0dfe9a5 /tests
parent729fd1465718ef1da5f61452bcb7eb23d02c9c9a (diff)
downloadqtmaildir-9d4fb5444438bdf188a6149a6667788dc3fc7bc0.tar.gz
qtmaildir-9d4fb5444438bdf188a6149a6667788dc3fc7bc0.zip
feat: complete contacts in the composer recipients
One shared QCompleter serves To, Cc and Bcc, attached with setWidget and never setCompleter, which resets the prefix to the whole field and stops matching after the first comma. The prefix is the comma-delimited token under the cursor, set by hand from textEdited; accepting replaces only that token and leaves the rest of the field alone. Candidates match the name and the address case-insensitively. A display name containing a comma is quoted on insertion, and splitRecipients() is now quote-aware so the quoted name survives as one recipient. Contacts reach the composer through setContacts() rather than a fourth constructor argument, so every existing three-argument construction and test stays as it was. An empty list leaves the fields behaving exactly as before completion existed.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_composewindow.cpp256
1 files changed, 256 insertions, 0 deletions
diff --git a/tests/test_composewindow.cpp b/tests/test_composewindow.cpp
index 49bb390..e2673b7 100644
--- a/tests/test_composewindow.cpp
+++ b/tests/test_composewindow.cpp
@@ -22,6 +22,8 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
+#include <QLineEdit>
+#include <QListView>
#include <QMenu>
#include <QPlainTextEdit>
#include <QSignalSpy>
@@ -40,8 +42,41 @@
#include "composecontext.h"
#include "composewindow.h"
#include "config.h"
+#include "contactstore.h"
#include "signatures.h"
+/// The popup QCompleter shows while it is offering contacts.
+///
+/// activePopupWidget, not a scan of every QListView in the process: the
+/// composer already owns an attachment QListWidget, which a scan would find
+/// and this must not.
+static QListView *contactPopup()
+{
+ return qobject_cast<QListView *>(QApplication::activePopupWidget());
+}
+
+/// The two contacts the completion tests offer. Names and addresses differ so a
+/// candidate matched by the wrong half of the string is visible in the result.
+static QList<Contact> twoContacts()
+{
+ return { { QStringLiteral("Alice Example"), QStringLiteral("alice@example.org") },
+ { QStringLiteral("Bob Example"), QStringLiteral("bob@example.org") } };
+}
+
+/// Accepts the top suggestion by clicking it, which is the route QCompleter
+/// reports as activated() without depending on how a keyboard layout delivers
+/// Return.
+static void acceptFirstPopupRow()
+{
+ QListView *popup = contactPopup();
+ QVERIFY(popup);
+ const QModelIndex row = popup->model()->index(0, 0);
+ QVERIFY(row.isValid());
+ popup->setCurrentIndex(row);
+ QTest::mouseClick(popup->viewport(), Qt::LeftButton, Qt::NoModifier,
+ popup->visualRect(row).center());
+}
+
class TestComposeWindow : public QObject
{
Q_OBJECT
@@ -74,6 +109,14 @@ private slots:
void theHtmlMenuItemTracksTheToolbarButton();
void theAgeLineFollowsTheClock();
+ void completionOffersAContactOnTheFirstRecipient();
+ void completionMatchesTheAddressAsWellAsTheName();
+ void completionStillWorksAfterAComma();
+ void insertingACommaNameQuotesItAndKeepsOneRecipient();
+ void insertingANameWithAQuoteEscapesIt();
+ void anEmptyNameInsertsTheBareAddress();
+ void noContactsLeavesTheBehaviourUnchanged();
+
private:
/// A config pointing at a signatures directory holding \p files, with one
/// account that can send.
@@ -1154,5 +1197,218 @@ void TestComposeWindow::theHtmlMenuItemTracksTheToolbarButton()
QCOMPARE(button->isChecked(), initial);
}
+/// Completion exists to save typing a contact the user already has. The test
+/// TYPES, never setText(): QLineEdit::setText does not drive a completer at
+/// all, so a test using it passes against the very bug this task exists to
+/// avoid and would endorse the mutation that reintroduces it.
+void TestComposeWindow::completionOffersAContactOnTheFirstRecipient()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setContacts(twoContacts());
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ QVERIFY(to);
+ to->setFocus();
+
+ QTest::keyClicks(to, QStringLiteral("Ali"));
+ QVERIFY2(contactPopup() && contactPopup()->isVisible(),
+ "typing a contact's name must offer it");
+ QCOMPARE(contactPopup()->model()->index(0, 0).data(Qt::DisplayRole).toString(),
+ QStringLiteral("Alice Example <alice@example.org>"));
+
+ acceptFirstPopupRow();
+ QCOMPARE(to->text(), QStringLiteral("Alice Example <alice@example.org>"));
+}
+
+/// The candidate is matched on the ADDRESS as well as the name. A single
+/// display string carrying both is what makes that true; matching only the
+/// prefix of the name would leave the address unusable as a query.
+void TestComposeWindow::completionMatchesTheAddressAsWellAsTheName()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setContacts(twoContacts());
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ QVERIFY(to);
+ to->setFocus();
+
+ // "alice@" occurs in the address and nowhere in either name.
+ QTest::keyClicks(to, QStringLiteral("alice@"));
+ QVERIFY2(contactPopup() && contactPopup()->isVisible(),
+ "typing an address must offer the contact");
+ QCOMPARE(contactPopup()->model()->index(0, 0).data(Qt::DisplayRole).toString(),
+ QStringLiteral("Alice Example <alice@example.org>"));
+
+ acceptFirstPopupRow();
+ QCOMPARE(to->text(), QStringLiteral("Alice Example <alice@example.org>"));
+}
+
+/// The case this whole task exists for: `setCompleter()` would set the
+/// completion prefix to the field's ENTIRE text on every keystroke, so after
+/// the first comma nothing matches and the popup never appears again. The
+/// prefix must be the comma-delimited token under the cursor.
+void TestComposeWindow::completionStillWorksAfterAComma()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setContacts(twoContacts());
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ QVERIFY(to);
+ to->setFocus();
+
+ QTest::keyClicks(to, QStringLiteral("alice@example.org, bo"));
+ QVERIFY2(contactPopup() && contactPopup()->isVisible(),
+ "completion stopped after the first recipient");
+ // Bob, not Alice: the prefix is the token after the comma, so Alice must
+ // not be offered any more.
+ QCOMPARE(contactPopup()->model()->index(0, 0).data(Qt::DisplayRole).toString(),
+ QStringLiteral("Bob Example <bob@example.org>"));
+
+ acceptFirstPopupRow();
+ QCOMPARE(to->text(),
+ QStringLiteral("alice@example.org, Bob Example <bob@example.org>"));
+}
+
+/// A display name containing a comma must be QUOTED on insertion, or
+/// splitRecipients() cuts it in half on the way to OutgoingMessage. The name is
+/// asserted as ONE entry, which is the property the quoting buys.
+void TestComposeWindow::insertingACommaNameQuotesItAndKeepsOneRecipient()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setContacts({ { QStringLiteral("Rossi, Mario"),
+ QStringLiteral("mario@example.org") } });
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ QVERIFY(to);
+ to->setFocus();
+
+ QTest::keyClicks(to, QStringLiteral("Rossi"));
+ QVERIFY(contactPopup() && contactPopup()->isVisible());
+ acceptFirstPopupRow();
+
+ QCOMPARE(to->text(),
+ QStringLiteral("\"Rossi, Mario\" <mario@example.org>"));
+
+ // The quoted name survives splitRecipients() as a single recipient.
+ const OutgoingMessage message = window.currentMessage();
+ QCOMPARE(message.to.size(), 1);
+ QCOMPARE(message.to.first(),
+ QStringLiteral("\"Rossi, Mario\" <mario@example.org>"));
+}
+
+/// A double quote in a display name is backslash-escaped inside the quoted
+/// string, so the header the user sees is valid. The backslash is escaped
+/// first, or escaping the quotes would double the backslashes just added.
+void TestComposeWindow::insertingANameWithAQuoteEscapesIt()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setContacts({ { QStringLiteral("He said \"hi\""),
+ QStringLiteral("q@example.org") } });
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ QVERIFY(to);
+ to->setFocus();
+
+ QTest::keyClicks(to, QStringLiteral("He"));
+ QVERIFY(contactPopup() && contactPopup()->isVisible());
+ acceptFirstPopupRow();
+
+ QCOMPARE(to->text(),
+ QStringLiteral("\"He said \\\"hi\\\"\" <q@example.org>"));
+
+ const OutgoingMessage message = window.currentMessage();
+ QCOMPARE(message.to.size(), 1);
+}
+
+/// A card with an address but no name completes on the address alone, with no
+/// empty angle brackets.
+void TestComposeWindow::anEmptyNameInsertsTheBareAddress()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setContacts({ { QString(), QStringLiteral("plain@example.org") } });
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ QVERIFY(to);
+ to->setFocus();
+
+ QTest::keyClicks(to, QStringLiteral("plai"));
+ QVERIFY(contactPopup() && contactPopup()->isVisible());
+ acceptFirstPopupRow();
+
+ QCOMPARE(to->text(), QStringLiteral("plain@example.org"));
+}
+
+/// The store is optional. With no contacts the fields behave exactly as they
+/// did before completion existed: text goes in and no popup appears.
+void TestComposeWindow::noContactsLeavesTheBehaviourUnchanged()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ QVERIFY(to);
+ to->setFocus();
+
+ QTest::keyClicks(to, QStringLiteral("Ali"));
+ QVERIFY2(!contactPopup() || !contactPopup()->isVisible(),
+ "a composer with no contacts must offer none");
+ QCOMPARE(to->text(), QStringLiteral("Ali"));
+}
+
QTEST_MAIN(TestComposeWindow)
#include "test_composewindow.moc"