diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-18 14:58:58 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-18 14:58:58 +0200 |
| commit | 3303c7855cb22a6bb4f36aa1a17707187320f5d2 (patch) | |
| tree | 6a2328cffaab820c8247f267785cc2782d77e154 /tests/test_contactstore.cpp | |
| parent | 7d4654536de5be4bd3555eb4b81a2e07c1d4148d (diff) | |
| download | qtmaildir-3303c7855cb22a6bb4f36aa1a17707187320f5d2.tar.gz qtmaildir-3303c7855cb22a6bb4f36aa1a17707187320f5d2.zip | |
feat: add ContactStore, the vCard address-book parse
Reads a vdirsyncer contacts directory of vCard 3.0 files into a
QList<Contact> for the completion work that follows. Pure over values,
no widget and no QCompleter, so the parse is testable without a window.
unfold() joins folded lines before any field is looked at; parseCard()
splits property from value on the first colon outside a quoted
parameter, unescapes FN, and yields one contact per EMAIL line;
loadDirectory() walks recursively, skips unreadable or addressless
cards, de-duplicates on the address case-insensitively, and sorts by
name then address. N, PHOTO, ADR and TEL are deliberately not used.
23 new tests. No user-facing strings, so no tr() change.
Diffstat (limited to 'tests/test_contactstore.cpp')
| -rw-r--r-- | tests/test_contactstore.cpp | 358 |
1 files changed, 358 insertions, 0 deletions
diff --git a/tests/test_contactstore.cpp b/tests/test_contactstore.cpp new file mode 100644 index 0000000..b36f424 --- /dev/null +++ b/tests/test_contactstore.cpp @@ -0,0 +1,358 @@ +/* + * 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 <QtTest> +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QTemporaryDir> + +#include "contactstore.h" + +namespace { + +QString readFixture(const QString &name) +{ + QFile file(QStringLiteral(FIXTURE_DIR) + QLatin1Char('/') + name); + if (!file.open(QIODevice::ReadOnly)) + return {}; + return QString::fromUtf8(file.readAll()); +} + +/// A minimal but complete vCard. Used by the loadDirectory cases, which build +/// their own tree in a QTemporaryDir rather than reaching for the shared +/// fixtures: a directory walk is about more than one card, and the flat +/// fixture directory has no subdirectories to walk into. +QString card(const QString &name, const QString &email) +{ + return QStringLiteral("BEGIN:VCARD\nVERSION:3.0\nFN:%1\nEMAIL:%2\nEND:VCARD\n") + .arg(name, email); +} + +void writeFile(const QString &path, const QString &content) +{ + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + QVERIFY2(file.open(QIODevice::WriteOnly | QIODevice::Truncate), + qPrintable(file.errorString())); + file.write(content.toUtf8()); +} + +} // namespace + +/// The vCard 3.0 parse behind the contact completion. +/// +/// Asserted on values, never on a rendered UI: ContactStore is a namespace of +/// free functions over values, which is what makes the parse testable without +/// a window at all. The loadDirectory cases each build a throwaway tree in a +/// QTemporaryDir, because the shared fixtures directory is flat. +class TestContactStore : public QObject +{ + Q_OBJECT +private slots: + void unfoldStripsTheFoldWhitespaceOnly(); + void unfoldJoinsAcrossSeveralFolds(); + void unfoldLeavesUnfoldedTextAlone(); + + void parsesNameAndAddress(); + void parsesAFoldedCard(); + void takesAContactPerEmailLine(); + void cardWithoutEmailYieldsNothing(); + void cardWithoutNameYieldsAnEmptyName(); + void ignoresPhotoAddressAndPhone(); + void acceptsParametersBeforeTheColon(); + void doesNotSplitOnAColonInsideAQuotedParameter(); + void propertyNamesAreCaseInsensitive(); + void unescapesTheFormattedName(); + void unescapesTheOtherTextEscapes(); + + void readsRecursively(); + void deduplicatesOnTheAddressCaseInsensitively(); + void sortsByNameThenAddress(); + void skipsFilesThatHoldNoCard(); + void skipsFilesThatAreNotVcf(); + void missingDirectoryIsEmptyAndQuiet(); + void emptyDirectoryIsEmptyAndQuiet(); +}; + +void TestContactStore::unfoldStripsTheFoldWhitespaceOnly() +{ + // The fold marker is CRLF plus ONE linear whitespace character. Removing + // the newline and that one character is the whole operation: the trailing + // space the writer left on the first line is content, not marker. + QCOMPARE(ContactStore::unfold(QStringLiteral("a \n b")), + QStringLiteral("a b")); + QCOMPARE(ContactStore::unfold(QStringLiteral("x\n\ty")), + QStringLiteral("xy")); +} + +void TestContactStore::unfoldJoinsAcrossSeveralFolds() +{ + // A fold may land anywhere, including mid-token, so every continuation + // line joins to the line that precedes it, not to the original. + QCOMPARE(ContactStore::unfold(QStringLiteral("EMAIL:folded@exam\n ple.org")), + QStringLiteral("EMAIL:folded@example.org")); + QCOMPARE(ContactStore::unfold(QStringLiteral("a\n b\n c")), + QStringLiteral("abc")); +} + +void TestContactStore::unfoldLeavesUnfoldedTextAlone() +{ + // No leading whitespace, no fold. The newlines are structure and survive. + QCOMPARE(ContactStore::unfold(QStringLiteral("FN:Alice\nEMAIL:a@example.org")), + QStringLiteral("FN:Alice\nEMAIL:a@example.org")); +} + +void TestContactStore::parsesNameAndAddress() +{ + const QList<Contact> contacts = + ContactStore::parseCard(readFixture(QStringLiteral("contact_plain.vcf"))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Alice Example")); + QCOMPARE(contacts.at(0).email, QStringLiteral("alice@example.org")); +} + +void TestContactStore::parsesAFoldedCard() +{ + // The pipeline loadDirectory uses: unfold first, because a fold is only + // legal before any field is looked at, then parse. + const QList<Contact> contacts = ContactStore::parseCard( + ContactStore::unfold(readFixture(QStringLiteral("contact_folded.vcf")))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Folded Person")); + QCOMPARE(contacts.at(0).email, QStringLiteral("folded@example.org")); +} + +void TestContactStore::takesAContactPerEmailLine() +{ + // A card with two addresses is two candidates, each carrying the one name. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_twoemails.vcf"))); + + QCOMPARE(contacts.size(), 2); + QCOMPARE(contacts.at(0).name, QStringLiteral("Dana Two")); + QCOMPARE(contacts.at(0).email, QStringLiteral("dana@example.org")); + QCOMPARE(contacts.at(1).name, QStringLiteral("Dana Two")); + QCOMPARE(contacts.at(1).email, QStringLiteral("dana.home@example.org")); +} + +void TestContactStore::cardWithoutEmailYieldsNothing() +{ + // Measured on the real store: most cards carry no address. That is the + // ordinary case, not a failure, and it must not produce a candidate. + QVERIFY(ContactStore::parseCard( + readFixture(QStringLiteral("contact_noemail.vcf"))) + .isEmpty()); +} + +void TestContactStore::cardWithoutNameYieldsAnEmptyName() +{ + // An address with no FN is still a usable candidate, on the address alone. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_noname.vcf"))); + + QCOMPARE(contacts.size(), 1); + QVERIFY(contacts.at(0).name.isEmpty()); + QCOMPARE(contacts.at(0).email, QStringLiteral("noname@example.org")); +} + +void TestContactStore::ignoresPhotoAddressAndPhone() +{ + // PHOTO, ADR and TEL are ignored entirely. They must not add candidates + // and must not disturb the one the EMAIL line produces. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_extra.vcf"))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Extra Fields")); + QCOMPARE(contacts.at(0).email, QStringLiteral("extra@example.org")); +} + +void TestContactStore::acceptsParametersBeforeTheColon() +{ + // EMAIL;TYPE=WORK:a@example.org. The parameters belong to the property + // name, so the value still parses. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_params.vcf"))); + + QCOMPARE(contacts.size(), 2); + QCOMPARE(contacts.at(0).email, QStringLiteral("bob@example.org")); +} + +void TestContactStore::doesNotSplitOnAColonInsideAQuotedParameter() +{ + // EMAIL;TYPE=INTERNET;LABEL="work: main":bob.work@example.org. Splitting + // on the FIRST colon would cut inside the quoted parameter and hand the + // parser a mangled value, so the split tracks the quotes. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_params.vcf"))); + + QCOMPARE(contacts.size(), 2); + QCOMPARE(contacts.at(1).email, QStringLiteral("bob.work@example.org")); +} + +void TestContactStore::propertyNamesAreCaseInsensitive() +{ + // RFC 2426 names are case-insensitive: `email:` is an EMAIL. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_lowercase.vcf"))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Carol Example")); + QCOMPARE(contacts.at(0).email, QStringLiteral("carol@example.org")); +} + +void TestContactStore::unescapesTheFormattedName() +{ + // FN is a TEXT value, so `\,` means a literal comma. Left escaped, the + // user sees the backslash in the completion popup. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_escaped.vcf"))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Rossi, Mario")); +} + +void TestContactStore::unescapesTheOtherTextEscapes() +{ + // The parser decodes the full TEXT escape set, so a name never reaches the + // UI half-decoded. The literal `\n` line is the one that would otherwise + // show two characters where the card described a line break. + const QList<Contact> contacts = ContactStore::parseCard(QStringLiteral( + "FN:Back\\\\slash\\; Semi\\nNext\nEMAIL:esc@example.org")); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, + QStringLiteral("Back\\slash; Semi\nNext")); +} + +void TestContactStore::readsRecursively() +{ + // A vdir keeps one subdirectory per collection, so the walk has to + // descend. The tree lives in a QTemporaryDir, not the shared fixtures. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/collection-a/one.vcf"), + card(QStringLiteral("One"), QStringLiteral("one@example.org"))); + writeFile(dir.path() + QStringLiteral("/collection-b/two.vcf"), + card(QStringLiteral("Two"), QStringLiteral("two@example.org"))); + writeFile(dir.path() + QStringLiteral("/three.vcf"), + card(QStringLiteral("Three"), QStringLiteral("three@example.org"))); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 3); +} + +void TestContactStore::deduplicatesOnTheAddressCaseInsensitively() +{ + // Two collections holding the same person is the ordinary case, not an + // error. The address is the identity, and it is compared ignoring case. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/a.vcf"), + card(QStringLiteral("Dup"), QStringLiteral("dup@example.org"))); + writeFile(dir.path() + QStringLiteral("/b.vcf"), + card(QStringLiteral("Dup"), QStringLiteral("DUP@Example.org"))); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 1); +} + +void TestContactStore::sortsByNameThenAddress() +{ + // Name first, case-insensitively; the address breaks a name tie. The + // lowercase "bob" is what a case-sensitive sort would get wrong. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/c.vcf"), + card(QStringLiteral("Charlie"), QStringLiteral("charlie@example.org"))); + writeFile(dir.path() + QStringLiteral("/a.vcf"), + card(QStringLiteral("Alice"), QStringLiteral("alice@example.org"))); + writeFile(dir.path() + QStringLiteral("/b.vcf"), + card(QStringLiteral("bob"), QStringLiteral("bob@example.org"))); + writeFile(dir.path() + QStringLiteral("/s1.vcf"), + card(QStringLiteral("Same"), QStringLiteral("b@example.org"))); + writeFile(dir.path() + QStringLiteral("/s2.vcf"), + card(QStringLiteral("Same"), QStringLiteral("a@example.org"))); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 5); + QCOMPARE(contacts.at(0).name, QStringLiteral("Alice")); + QCOMPARE(contacts.at(1).name, QStringLiteral("bob")); + QCOMPARE(contacts.at(2).name, QStringLiteral("Charlie")); + QCOMPARE(contacts.at(3).name, QStringLiteral("Same")); + QCOMPARE(contacts.at(3).email, QStringLiteral("a@example.org")); + QCOMPARE(contacts.at(4).email, QStringLiteral("b@example.org")); +} + +void TestContactStore::skipsFilesThatHoldNoCard() +{ + // One bad card must not cost the other 116. A file with no EMAIL line + // contributes nothing and must not stop the walk. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/good.vcf"), + card(QStringLiteral("Good"), QStringLiteral("good@example.org"))); + writeFile(dir.path() + QStringLiteral("/bad.vcf"), + QStringLiteral("this is not a vcard\nnot a property either\n")); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).email, QStringLiteral("good@example.org")); +} + +void TestContactStore::skipsFilesThatAreNotVcf() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/notes.txt"), + card(QStringLiteral("Ignored"), QStringLiteral("ignored@example.org"))); + writeFile(dir.path() + QStringLiteral("/keep.vcf"), + card(QStringLiteral("Kept"), QStringLiteral("kept@example.org"))); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).email, QStringLiteral("kept@example.org")); +} + +void TestContactStore::missingDirectoryIsEmptyAndQuiet() +{ + // A machine with no vdir is the ordinary case for anyone who is not this + // user, so a missing path must not warn. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + QTest::failOnWarning(); + QVERIFY(ContactStore::loadDirectory( + dir.path() + QStringLiteral("/does-not-exist")) + .isEmpty()); +} + +void TestContactStore::emptyDirectoryIsEmptyAndQuiet() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + QDir(dir.path()).mkpath(QStringLiteral("empty")); + QTest::failOnWarning(); + QVERIFY(ContactStore::loadDirectory(dir.path() + QStringLiteral("/empty")) + .isEmpty()); +} + +QTEST_MAIN(TestContactStore) +#include "test_contactstore.moc" |
