diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-18 15:25:14 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-18 15:25:14 +0200 |
| commit | 9d4fb5444438bdf188a6149a6667788dc3fc7bc0 (patch) | |
| tree | 10d4a63f18334ed68d65805b78e16fe1b0dfe9a5 /tests/test_composewindow.cpp | |
| parent | 729fd1465718ef1da5f61452bcb7eb23d02c9c9a (diff) | |
| download | qtmaildir-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/test_composewindow.cpp')
| -rw-r--r-- | tests/test_composewindow.cpp | 256 |
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" |
