From 3303c7855cb22a6bb4f36aa1a17707187320f5d2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 18 Sep 2026 14:58:58 +0200 Subject: feat: add ContactStore, the vCard address-book parse Reads a vdirsyncer contacts directory of vCard 3.0 files into a QList 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. --- tests/CMakeLists.txt | 3 + tests/fixtures/contact_escaped.vcf | 5 + tests/fixtures/contact_extra.vcf | 9 + tests/fixtures/contact_folded.vcf | 7 + tests/fixtures/contact_lowercase.vcf | 5 + tests/fixtures/contact_noemail.vcf | 7 + tests/fixtures/contact_noname.vcf | 4 + tests/fixtures/contact_params.vcf | 6 + tests/fixtures/contact_plain.vcf | 6 + tests/fixtures/contact_twoemails.vcf | 6 + tests/test_contactstore.cpp | 358 +++++++++++++++++++++++++++++++++++ 11 files changed, 416 insertions(+) create mode 100644 tests/fixtures/contact_escaped.vcf create mode 100644 tests/fixtures/contact_extra.vcf create mode 100644 tests/fixtures/contact_folded.vcf create mode 100644 tests/fixtures/contact_lowercase.vcf create mode 100644 tests/fixtures/contact_noemail.vcf create mode 100644 tests/fixtures/contact_noname.vcf create mode 100644 tests/fixtures/contact_params.vcf create mode 100644 tests/fixtures/contact_plain.vcf create mode 100644 tests/fixtures/contact_twoemails.vcf create mode 100644 tests/test_contactstore.cpp (limited to 'tests') diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 24da10b..6cc92ab 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -69,6 +69,9 @@ add_qtmaildir_test(tagdialog) add_qtmaildir_test(tagrules) add_qtmaildir_test(rulequery) add_qtmaildir_test(searchterm) +add_qtmaildir_test(contactstore) +target_compile_definitions(test_contactstore PRIVATE + FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") add_qtmaildir_test(signatures) add_qtmaildir_test(busyindicator) add_qtmaildir_test(tagstrip) diff --git a/tests/fixtures/contact_escaped.vcf b/tests/fixtures/contact_escaped.vcf new file mode 100644 index 0000000..4467562 --- /dev/null +++ b/tests/fixtures/contact_escaped.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Rossi\, Mario +EMAIL:mario@example.org +END:VCARD diff --git a/tests/fixtures/contact_extra.vcf b/tests/fixtures/contact_extra.vcf new file mode 100644 index 0000000..1f5223a --- /dev/null +++ b/tests/fixtures/contact_extra.vcf @@ -0,0 +1,9 @@ +BEGIN:VCARD +VERSION:3.0 +N:Extra;Fields;;; +FN:Extra Fields +ADR;TYPE=HOME:;;2 Example Rd;Exampleton;;11111;Examplestan +TEL;TYPE=CELL:+1-555-0199 +PHOTO;ENCODING=b;TYPE=JPEG:iVBORw0KGgo= +EMAIL:extra@example.org +END:VCARD diff --git a/tests/fixtures/contact_folded.vcf b/tests/fixtures/contact_folded.vcf new file mode 100644 index 0000000..8ef8729 --- /dev/null +++ b/tests/fixtures/contact_folded.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Fold + ed Person +EMAIL:folded@exam + ple.org +END:VCARD diff --git a/tests/fixtures/contact_lowercase.vcf b/tests/fixtures/contact_lowercase.vcf new file mode 100644 index 0000000..9a2ce7b --- /dev/null +++ b/tests/fixtures/contact_lowercase.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +fn:Carol Example +email:carol@example.org +END:VCARD diff --git a/tests/fixtures/contact_noemail.vcf b/tests/fixtures/contact_noemail.vcf new file mode 100644 index 0000000..6fbd458 --- /dev/null +++ b/tests/fixtures/contact_noemail.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:3.0 +FN:No Address +TEL;TYPE=CELL:+1-555-0100 +ADR;TYPE=HOME:;;1 Example St;Exampleville;;00000;Examplestan +PHOTO;ENCODING=b;TYPE=JPEG:iVBORw0KGgo= +END:VCARD diff --git a/tests/fixtures/contact_noname.vcf b/tests/fixtures/contact_noname.vcf new file mode 100644 index 0000000..c9bf087 --- /dev/null +++ b/tests/fixtures/contact_noname.vcf @@ -0,0 +1,4 @@ +BEGIN:VCARD +VERSION:3.0 +EMAIL:noname@example.org +END:VCARD diff --git a/tests/fixtures/contact_params.vcf b/tests/fixtures/contact_params.vcf new file mode 100644 index 0000000..f935ce5 --- /dev/null +++ b/tests/fixtures/contact_params.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Bob Example +EMAIL;TYPE=WORK:bob@example.org +EMAIL;TYPE=INTERNET;LABEL="work: main":bob.work@example.org +END:VCARD diff --git a/tests/fixtures/contact_plain.vcf b/tests/fixtures/contact_plain.vcf new file mode 100644 index 0000000..7daaa01 --- /dev/null +++ b/tests/fixtures/contact_plain.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +N:Example;Alice;;; +FN:Alice Example +EMAIL:alice@example.org +END:VCARD diff --git a/tests/fixtures/contact_twoemails.vcf b/tests/fixtures/contact_twoemails.vcf new file mode 100644 index 0000000..41325ed --- /dev/null +++ b/tests/fixtures/contact_twoemails.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Dana Two +EMAIL:dana@example.org +EMAIL;TYPE=HOME:dana.home@example.org +END:VCARD 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. + * + * 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 +#include + +#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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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" -- cgit v1.2.3