listings: read contacts from the index rather than their cards
The contacts, search, group and group-members pages and the dashboard
parsed every card in the book to show names, a picture's whole
payload included. Store#listed_contacts writes each card back out of
its indexed lines without the PHOTO and reads it as a Contact does,
inside a ListedContact that answers the readings a list asks and not
the bytes or the etag. Whether there is a picture comes from the
first characters of the indexed PHOTO.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNVLyiMrZJ7sZctEYq6WCM
diff --git a/lib/pro_tacts/admin/avatar.rb b/lib/pro_tacts/admin/avatar.rb
index c6f9770..24f3dd0 100644
--- a/lib/pro_tacts/admin/avatar.rb
+++ b/lib/pro_tacts/admin/avatar.rb
@@ -17,10 +17,10 @@ module ProTacts
# the contact's name is adjacent text in every slot, so the image
# is decoration, not information.
class Avatar < Phlex::HTML
- # @rbs @contact: Contact
+ # @rbs @contact: Contact | ListedContact
# @rbs @size: String?
- #: (contact: Contact, ?size: String?) -> void
+ #: (contact: Contact | ListedContact, ?size: String?) -> void
def initialize(contact:, size: nil)
@contact = contact
@size = size
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 9b93286..97b7227 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -18,11 +18,11 @@ module ProTacts
# alone; a filter here would be a second, weaker match that could
# only disagree with it. This page is for reading the book by letter.
class ContactsIndex < Phlex::HTML
- # @rbs @rows: Array[Contact]
+ # @rbs @rows: Array[ListedContact]
# @rbs @login: String
# @rbs @groups_of: Hash[String, Array[Group]]
- #: (contacts: Array[Contact], groups: Array[Group], login: String) -> void
+ #: (contacts: Array[ListedContact], groups: Array[Group], login: String) -> void
def initialize(contacts:, groups:, login:)
@login = login
@rows = contacts.sort_by { [Format.sort_key(it), it.id] }
@@ -60,7 +60,7 @@ module ProTacts
private
- #: (Contact contact) -> void
+ #: (ListedContact contact) -> void
def render_row(contact)
# Not a ListItem, whose one anchor wraps the whole row, because
# the chips are links too and an anchor cannot hold one. The
@@ -88,14 +88,14 @@ module ProTacts
# and anything that is no letter at all files under "#", after
# Z. The rows arrive sorted, and group_by keeps their order
# within each letter.
- #: () -> Array[[String, Array[Contact]]]
+ #: () -> Array[[String, Array[ListedContact]]]
def letter_groups
@rows
.group_by { letter_of(it) }
.sort_by { |letter, _| [letter == "#" ? 1 : 0, letter] }
end
- #: (Contact contact) -> String
+ #: (ListedContact contact) -> String
def letter_of(contact)
base = Format.sort_key(contact).unicode_normalize(:nfd)[0].to_s
base.match?(/[a-z]/) ? base.upcase : "#"
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index ebb7949..bafd033 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -14,7 +14,7 @@ module ProTacts
# composition is the listing screens', not Contact's: the card
# holds the two facts independently, and other screens (the
# detail card, the birthdays column) show the plain name.
- #: (Contact contact) -> String
+ #: (Contact | ListedContact contact) -> String
def self.name_label(contact)
nickname = contact.nickname
name = contact.name
@@ -29,7 +29,7 @@ module ProTacts
# case-insensitive, so the card's spelling of a family name does
# not move it. Every listing of contacts sorts by it, the id
# breaking a tie.
- #: (Contact contact) -> String
+ #: (Contact | ListedContact contact) -> String
def self.sort_key(contact)
family, = contact.name_components || []
(family || contact.name || contact.id).to_s.downcase
@@ -56,7 +56,7 @@ module ProTacts
# no person to split in two), to one first letter of FN rather
# than a guess at where a first/last split would fall in free
# text, and of the id when the card carries no name at all.
- #: (Contact contact) -> String
+ #: (Contact | ListedContact contact) -> String
def self.initials(contact)
family, given = contact.name_components || []
letters = [given, family].filter_map { it[0] if it }
@@ -68,7 +68,7 @@ module ProTacts
# A contact's birthday, for display: the model's, including the
# shapes no client is sent, then a BDAY still in the card — read the
# way a write reads one, else shown raw. The prose is Birthday#to_s.
- #: (Contact contact) -> String?
+ #: (Contact | ListedContact contact) -> String?
def self.birthday(contact)
birthday = contact.birthday
return birthday.to_s if birthday
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
index a7247a0..c4e2f66 100644
--- a/lib/pro_tacts/admin/groups_members.rb
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -31,14 +31,14 @@ module ProTacts
class GroupsMembers < Phlex::HTML
# @rbs @group: Group
# @rbs @login: String
- # @rbs @contacts: Array[Contact]
+ # @rbs @contacts: Array[ListedContact]
# @rbs @capped: Array[String]
# The form's id, for the footer's Save outside it.
FORM = "members-form" #: String
private_constant :FORM
- #: (group: Group, contacts: Array[Contact], login: String) -> void
+ #: (group: Group, contacts: Array[ListedContact], login: String) -> void
def initialize(group:, contacts:, login:)
@group = group
@login = login
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 04fc736..3b899ff 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -24,11 +24,11 @@ module ProTacts
# @rbs @group: Group
# @rbs @login: String
# @rbs @reading: Contact
- # @rbs @contacts: Array[Contact]
- # @rbs @members: Array[Contact]
+ # @rbs @contacts: Array[ListedContact]
+ # @rbs @members: Array[ListedContact]
# @rbs @changes: Array[Store::GroupChange]
- #: (group: Group, contacts: Array[Contact], changes: Array[Store::GroupChange], login: String) -> void
+ #: (group: Group, contacts: Array[ListedContact], changes: Array[Store::GroupChange], login: String) -> void
def initialize(group:, contacts:, changes:, login:)
@group = group
@login = login
diff --git a/lib/pro_tacts/admin/search.rb b/lib/pro_tacts/admin/search.rb
index b33f32a..7ba2455 100644
--- a/lib/pro_tacts/admin/search.rb
+++ b/lib/pro_tacts/admin/search.rb
@@ -54,7 +54,7 @@ module ProTacts
end
private_class_method :words
- #: (Contact contact, Array[String] words, Array[String] labels) -> bool
+ #: (ListedContact contact, Array[String] words, Array[String] labels) -> bool
def self.matches?(contact, words, labels)
texts = text_fields(contact, labels).map { Format.search_key(it) }
phones = contact.phones.map { digits(it.value) }
@@ -69,7 +69,7 @@ module ProTacts
# its names, organization, emails, addresses, notes, and groups.
# An address is its components run together, so "main st" finds
# a street.
- #: (Contact contact, Array[String] labels) -> Array[String]
+ #: (ListedContact contact, Array[String] labels) -> Array[String]
def self.text_fields(contact, labels)
[
contact.name,
@@ -83,7 +83,7 @@ module ProTacts
end
private_class_method :text_fields
- #: (Contact contact, Array[String] words) -> bool
+ #: (ListedContact contact, Array[String] words) -> bool
def self.name_hit?(contact, words)
name_words = [contact.name, contact.nickname].compact.flat_map { Format.search_key(it).split }
words.all? { |word| name_words.any? { it.start_with?(word) } }
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 94d01c8..3080471 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -273,26 +273,33 @@ module ProTacts
end
# Whether the card carries a picture an avatar should ask the photo
- # route for: #photo's answer read off the first bytes alone, the
- # magic number being all the sniff looks at. The rest of the
- # payload is not decoded, so a PHOTO broken past its first bytes
- # renders as an image the route then refuses — the price of not
- # decoding every picture on a page to draw a list of them.
+ # route for: #photo's answer read off the first bytes alone
+ # (Contact.photo_head?).
#: () -> bool
def photo?
value = vcard.property("PHOTO")&.value
- return false if value.nil?
+ !value.nil? && Contact.photo_head?(value)
+ end
+
+ # Base64 characters enough to decode past the longest signature:
+ # twelve bytes, four characters to every three.
+ PHOTO_HEAD = 16 #: Integer
+ # Whether a PHOTO value's first characters decode to an image's
+ # magic bytes, the only bytes the sniff looks at. The rest of the
+ # payload is not decoded, so a PHOTO broken past them renders as an
+ # image the photo route then refuses: the price of not decoding
+ # every picture on a page to draw a list of them. A value cut to
+ # PHOTO_HEAD reads the same, which is what lets the index hand over
+ # no more of one (Store#listed_contacts).
+ #: (String value) -> bool
+ def self.photo_head?(value)
head = Base64.strict_decode64(value[0, PHOTO_HEAD].to_s)
PHOTO_SIGNATURES.any? { head.start_with?(it.first) }
rescue ArgumentError
false
end
- # Base64 characters enough to decode past the longest signature:
- # twelve bytes, four characters to every three.
- PHOTO_HEAD = 16 #: Integer
-
# Bytes as a picture a browser can show, named by their magic
# bytes (PHOTO_SIGNATURES), or nil for a format the list does not
# cover. A card's PHOTO and a group's upload are judged alike.
diff --git a/lib/pro_tacts/listed_contact.rb b/lib/pro_tacts/listed_contact.rb
new file mode 100644
index 0000000..b46f06d
--- /dev/null
+++ b/lib/pro_tacts/listed_contact.rb
@@ -0,0 +1,72 @@
+require "pro_tacts/contact"
+require "pro_tacts/vcard"
+
+module ProTacts
+ # A contact as a list shows it: what a row, a sort, or a search reads
+ # off one, built from the index (Store#listed_contacts) rather than
+ # from the card's bytes. A list of contacts would otherwise parse
+ # every card it draws, a picture's whole payload included, to show a
+ # name beside an avatar.
+ #
+ # Its readings are Contact's own, over a card written back out of the
+ # index's rows with the PHOTO left out, so a row and the contact's
+ # page cannot disagree about a name, a phone, or a lent address. That
+ # card is not the stored one: its bytes and its etag describe nothing
+ # a client is served, so this answers neither, and nothing that
+ # writes or guards a write takes one. Whether there is a picture is
+ # read from the first bytes the index holds of it (Contact#photo?).
+ class ListedContact
+ # @rbs @contact: Contact
+ # @rbs @photo: bool
+
+ #: (Contact contact, photo: bool) -> void
+ def initialize(contact, photo:)
+ @contact = contact
+ @photo = photo
+ end
+
+ #: () -> String
+ def id = @contact.id
+
+ #: () -> String?
+ def name = @contact.name
+
+ #: () -> Array[String?]?
+ def name_components = @contact.name_components
+
+ #: () -> String?
+ def nickname = @contact.nickname
+
+ #: () -> String?
+ def organization = @contact.organization
+
+ #: () -> bool
+ def company? = @contact.company?
+
+ #: () -> Birthday?
+ def birthday = @contact.birthday
+
+ #: () -> Array[VCard::Parser::Property]
+ def properties = @contact.properties
+
+ #: () -> Array[Contact::Phone]
+ def phones = @contact.phones
+
+ #: () -> Array[Contact::Email]
+ def emails = @contact.emails
+
+ #: () -> Array[Contact::Address]
+ def addresses = @contact.addresses
+
+ #: () -> Array[Contact::Note]
+ def notes = @contact.notes
+
+ #: () -> bool
+ def photo? = @photo
+
+ #: () -> String
+ def inspect
+ "#<#{self.class.name} id=#{id.inspect}#{" name=#{name.inspect}" if name}>"
+ end
+ end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 1487fa2..09aab1b 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -10,6 +10,7 @@ require "pro_tacts/change_id"
require "pro_tacts/contact"
require "pro_tacts/edited_contact"
require "pro_tacts/group"
+require "pro_tacts/listed_contact"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -253,20 +254,23 @@ module ProTacts
cards.order(:id).select_map(:id).map(&:to_s)
end
- # Every contact paired with its card's updated_at, newest first — the
- # read behind any "recently updated" surface. Same shape and cost as
- # #contacts, ordered by the column SQLite already stamps rather than
+ # Every contact, or only those of these ids, as a list shows it
+ # (ListedContact), ordered by id like #contacts: the read behind
+ # every screen that lists contacts rather than opening one.
+ #: (?Array[String]? ids) -> Array[ListedContact]
+ def listed_contacts(ids = nil)
+ listed_by_id(ids).values
+ end
+
+ # Every contact as a list shows it, paired with its card's
+ # updated_at, newest first — the read behind any "recently updated"
+ # surface, ordered by the column SQLite already stamps rather than
# by id.
#: () -> Array[RecentContact]
def contacts_by_recency
- birthdays = birthdays_by_id
- inherited = inherited_by_id
- cards.order(Sequel.desc(:updated_at)).map {
- id = it.fetch(:id).to_s
- RecentContact.new(
- contact: contact_from(it, birthdays[id], inherited.fetch(id, [])),
- updated_at: it.fetch(:updated_at).to_s,
- )
+ listed = listed_by_id(nil)
+ cards.order(Sequel.desc(:updated_at)).select(:id, :updated_at).map {
+ RecentContact.new(contact: listed.fetch(it.fetch(:id).to_s), updated_at: it.fetch(:updated_at).to_s)
}
end
@@ -313,13 +317,13 @@ module ProTacts
# table rather than scanning cards for BDAY.
#: (Integer limit, ?today: Date) -> Array[UpcomingBirthday]
def upcoming_birthdays(limit, today: Date.today)
- inherited = inherited_by_id
- birthdays
- .join(:cards, id: :card_id)
- .map { upcoming_from(it, today, inherited.fetch(it.fetch(:card_id).to_s, [])) }
- .compact
- .sort_by { [it.occurs_on, it.contact.id] }
- .first(limit)
+ coming = birthdays.all.filter_map { |row|
+ occurs_on = birthday_from(row).next_on(today)
+ pair = [row.fetch(:card_id).to_s, occurs_on] #: [String, Date]
+ pair if occurs_on
+ }.sort_by { |id, occurs_on| [occurs_on, id] }.first(limit)
+ listed = listed_by_id(coming.map(&:first))
+ coming.map { |id, occurs_on| UpcomingBirthday.new(contact: listed.fetch(id), occurs_on:) }
end
# `sole` rather than `first`: the id is the primary key, so a second
@@ -1244,6 +1248,48 @@ module ProTacts
}
end
+ # The index's reading of these cards, or of every card, keyed by id
+ # in id order: each card written back out of its indexed lines with
+ # the PHOTO left out, and composed as #contacts composes the stored
+ # one, so a list reads the same names, numbers and lent lines a
+ # contact's page does without reading a picture to show a name. The
+ # index holds what #reindex read off the stored card, and a line
+ # that would not read is in neither. Of a PHOTO, the first
+ # characters alone, which is all Contact.photo_head? decodes.
+ #: (Array[String]? ids) -> Hash[String, ListedContact]
+ def listed_by_id(ids)
+ scope = ids ? card_properties.where(card_id: ids) : card_properties
+ lines = scope.exclude(name: "PHOTO").order(:card_id, :position).all.group_by { it.fetch(:card_id).to_s }
+ parameters = (ids ? card_parameters.where(card_id: ids) : card_parameters)
+ .order(:card_id, :position, Sequel.lit("rowid")).all
+ .group_by { "#{it.fetch(:card_id)} #{it.fetch(:position)}" }
+ # Latest position first, so the to_h below keeps each card's
+ # first PHOTO: the one Contact#photo reads.
+ heads = scope.where(name: "PHOTO").order(:card_id, Sequel.desc(:position))
+ .select(:card_id, Sequel.function(:substr, :value, 1, Contact::PHOTO_HEAD).as(:head)).all
+ .to_h {
+ [it.fetch(:card_id).to_s, it.fetch(:head).to_s] #: [String, String]
+ }
+ birthdays = birthdays_by_id
+ inherited = inherited_by_id
+ (ids ? cards.where(id: ids) : cards).order(:id).select_map(:id).to_h { |raw|
+ id = raw.to_s
+ text = lines.fetch(id, []).map { |row|
+ pairs = parameters.fetch("#{id} #{row.fetch(:position)}", []).map {
+ [it.fetch(:name).to_s, it.fetch(:value).to_s] #: [String, String]
+ }
+ property = VCard::Parser::Property.new(
+ group: row.fetch(:property_group)&.to_s, name: row.fetch(:name).to_s, parameters: pairs, value: row.fetch(:value).to_s,
+ )
+ "#{VCard.header_of(property)}#{property.value}\r\n"
+ }.join
+ contact = Contact.new(id:, stored: VCard.new(text), birthday: birthdays[id], inherited: inherited.fetch(id, []))
+ head = heads[id]
+ listed = ListedContact.new(contact, photo: !head.nil? && Contact.photo_head?(head))
+ [id, listed] #: [String, ListedContact]
+ }
+ end
+
# Every birthday, keyed by card, for the listing reads that compose
# a whole collection in one pass.
#: () -> Hash[String, Birthday]
@@ -1358,17 +1404,6 @@ module ProTacts
Birthday.new(year: row[:year], month: row[:month], day: row[:day])
end
- # One joined birthday-and-card row as an UpcomingBirthday, or nil
- # for a shape that lands on no calendar day (Birthday#next_on).
- #: (Hash[Symbol, untyped] row, Date today, Array[Contact::Inherited] inherited) -> UpcomingBirthday?
- def upcoming_from(row, today, inherited)
- birthday = birthday_from(row)
- occurs_on = birthday.next_on(today)
- return if occurs_on.nil?
-
- UpcomingBirthday.new(contact: contact_from(row, birthday, inherited), occurs_on:)
- end
-
#: (Hash[Symbol, untyped] row) -> Change
def change_from(row)
Change.new(
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index c61e1ff..d5a6110 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -23,7 +23,7 @@ module ProTacts
# contacts (docs/DESIGN.md, "The core idea").
r.get do
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::ContactsIndex.call(contacts: store.contacts, groups: store.all_groups, login: @login)
+ Admin::ContactsIndex.call(contacts: store.listed_contacts, groups: store.all_groups, login: @login)
end
# The browser's create, from the dashboard's dialog: POST is
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index e013526..6c5ac49 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -91,7 +91,7 @@ module ProTacts
Admin::GroupsShow.call(
group:,
login: @login,
- contacts: store.contacts,
+ contacts: store.listed_contacts,
changes: store.group_changes_of(id),
)
end
@@ -199,7 +199,7 @@ module ProTacts
#: (Group group) -> String
def members_screen(group)
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::GroupsMembers.call(group:, contacts: store.contacts, login: @login)
+ Admin::GroupsMembers.call(group:, contacts: store.listed_contacts, login: @login)
end
end
end
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 502cca5..ed0d8ba 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -88,6 +88,7 @@ module Sequel
def self.register_extension: (Symbol name, Module extension) -> void
def where: (*untyped conditions) -> Dataset
+ def exclude: (*untyped conditions) -> Dataset
def order: (*untyped columns) -> Dataset
def distinct: (*untyped columns) -> Dataset
# The columns a joined dataset comes back with, named rather than
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 958903f..528a813 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -32,7 +32,7 @@ module ProTacts
end
class ContactsIndex
- def self.call: (contacts: Array[Contact], groups: Array[Group], login: String) -> String
+ def self.call: (contacts: Array[ListedContact], groups: Array[Group], login: String) -> String
end
class GroupsIndex
@@ -40,7 +40,7 @@ module ProTacts
end
class GroupsShow
- def self.call: (group: Group, contacts: Array[Contact], changes: Array[Store::GroupChange], login: String) -> String
+ def self.call: (group: Group, contacts: Array[ListedContact], changes: Array[Store::GroupChange], login: String) -> String
end
class GroupsEdit
@@ -48,7 +48,7 @@ module ProTacts
end
class GroupsMembers
- def self.call: (group: Group, contacts: Array[Contact], login: String) -> String
+ def self.call: (group: Group, contacts: Array[ListedContact], login: String) -> String
end
class DeviceSetup
@@ -85,7 +85,7 @@ module ProTacts
# contacts are ordered (Web#walk_order), and its search_key is the
# fold Search matches on.
module Format
- def self.sort_key: (Contact contact) -> String
+ def self.sort_key: (Contact | ListedContact contact) -> String
def self.search_key: (String value) -> String
end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 2250e7f..1b23018 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -26,17 +26,17 @@ module ProTacts
end
class RecentContact
- attr_reader contact: Contact
+ attr_reader contact: ListedContact
attr_reader updated_at: String
- def self.new: (contact: Contact, updated_at: String) -> instance
+ def self.new: (contact: ListedContact, updated_at: String) -> instance
end
class UpcomingBirthday
- attr_reader contact: Contact
+ attr_reader contact: ListedContact
attr_reader occurs_on: Date
- def self.new: (contact: Contact, occurs_on: Date) -> instance
+ def self.new: (contact: ListedContact, occurs_on: Date) -> instance
end
class GroupChoice
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 519034b..8ecf9eb 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1154,7 +1154,36 @@ class StoreTest < Minitest::Test
assert_equal composed, store.contact("aiden").vcard.to_s
assert_equal composed, store.contacts.find { it.id == "aiden" }.vcard.to_s
- assert_equal composed, store.contacts_by_recency.find { it.contact.id == "aiden" }.contact.vcard.to_s
+ listed = store.contacts_by_recency.find { it.contact.id == "aiden" }.contact
+ assert_equal store.contact("aiden").addresses.map(&:street), listed.addresses.map(&:street)
+ assert_equal store.contact("aiden").notes.map(&:value), listed.notes.map(&:value)
+ end
+ end
+
+ # A listed contact is the index's reading of the card, and reads the
+ # same as the contact itself does: names, a labelled phone, quoted
+ # and repeated parameters, an email, the birthday, and whether there
+ # is a picture, without the picture's payload.
+ def test_a_listed_contact_reads_as_the_contact_does
+ card = PhotoCard.photo("aiden", bytes: 64).sub(
+ "END:VCARD\r\n",
+ "N:North;Aiden;;;\r\nNICKNAME:Ade\r\nORG:Calculus\\, Ltd\r\n" \
+ "item1.TEL;TYPE=CELL;TYPE=pref:+44 20 7946 0000\r\nitem1.X-ABLabel:_$!<Mobile>!$_\r\n" \
+ "EMAIL;TYPE=\"work,internet\":a@example.com\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n",
+ )
+
+ with_store({"aiden" => card, "znorth" => ZED}) do |store|
+ contact = store.contact("aiden")
+ listed = store.listed_contacts(["aiden"]).fetch(0)
+
+ %i[id name name_components nickname organization company? birthday photo?].each do |reading|
+ assert_equal contact.public_send(reading), listed.public_send(reading), reading
+ end
+ assert_equal contact.phones.map { [it.value, it.label, it.types] }, listed.phones.map { [it.value, it.label, it.types] }
+ assert_equal contact.emails.map { [it.value, it.types] }, listed.emails.map { [it.value, it.types] }
+ assert_predicate listed, :photo?
+ refute_predicate store.listed_contacts(["znorth"]).fetch(0), :photo?
+ assert_equal %w[aiden znorth], store.listed_contacts.map(&:id)
end
end