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
change
commit 6d457e7cf86bedf35085cb98e2df5469aa8ccb70
author Claude <noreply@anthropic.com>
date
parent dc2787bd
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