Contact: the one model of a contact
The admin UI re-parsed the served card through ContactFields; it now
reads structured accessors that parse lazily, so the serving paths pay
nothing. docs/plans/2026-09-01-contact-is-the-model.md has the shape.
Assisted-by: GLM-5.3 via pi
diff --git a/AGENTS.md b/AGENTS.md
index 3609ad6..465e979 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,7 +35,8 @@ is already `pro-tacts` — don't pass `--backlog` or verify it.
lib/pro_tacts/
├── web.rb # The Roda app: every route and response body
├── store.rb # Sequel over SQLite: cards, change log, derived index
-├── contact.rb # id and vCard, with the etag derived from the card
+├── contact.rb # the one model of a contact: id, vCard, etag,
+│ # and the structured accessors over the card
├── vcard.rb # vCard 3.0 escaping and folding, and what a card is
├── vcard/parser.rb # One card's bytes into properties
├── config.rb # Every environment read in the app
@@ -93,7 +94,7 @@ not a fixture; edit a `.vcf` to change what the replay serves.
rescuing it locally "to be safe" makes it neither. Rescue only what's an
ordinary, anticipated case with a real fallback to show (e.g. a birthday
whose stored value is well-shaped but calendar-nonsense — see
- `ContactFields#format_birthday`), never "this might fail, better catch
+ `Admin::Format.birthday`), never "this might fail, better catch
it."
- `config.ru` must stay ASCII-only; a test enforces it, because boot crashes
under a C locale otherwise. Watch for em dashes in comments.
diff --git a/lib/pro_tacts/admin/contact_fields.rb b/lib/pro_tacts/admin/contact_fields.rb
deleted file mode 100644
index cbdba5b..0000000
--- a/lib/pro_tacts/admin/contact_fields.rb
+++ /dev/null
@@ -1,112 +0,0 @@
-require "date"
-
-require "pro_tacts/admin/format"
-require "pro_tacts/birthday"
-require "pro_tacts/vcard"
-require "pro_tacts/vcard/parser"
-
-module ProTacts
- module Admin
- # The read-only fields an admin screen shows for a contact, parsed
- # fresh from its stored vCard on every read. Deliberately shallow,
- # like VCard::Parser itself: a card can carry properties this has no
- # field for, and CardDAV still serves every one of them — this is
- # only what one screen knows how to display.
- class ContactFields
- Phone = Data.define(:value, :type)
- Email = Data.define(:value, :type)
- Address = Data.define(:lines, :type)
-
- attr_reader :name, :initials, :phones, :emails, :addresses, :birthday, :notes
-
- # Lets VCard::ParseError raise rather than showing an empty card:
- # a stored card that fails to parse here is not the ordinary case
- # #format_birthday's rescue handles (a well-shaped but nonsense
- # value that round-trips on purpose) — every PUT already checks
- # VCard.card? before the store accepts a card, so one reaching
- # this unparseable is a bug or a corrupt row, and Sentry (already
- # wired into every request, see ProTacts::Web) is where that
- # should surface, not a silently blank screen.
- #: (Contact contact) -> ContactFields
- def self.from(contact)
- properties = VCard::Parser.parse(contact.vcard)
- new(properties)
- end
-
- #: (Array[VCard::Property] properties) -> void
- def initialize(properties)
- by_name = properties.group_by { it.name.upcase }
- @name = by_name.fetch("FN", []).first&.value
- @initials = initials_from(by_name.fetch("N", []).first) || (@name && Format.initials(@name))
- @phones = by_name.fetch("TEL", []).map { |p| Phone.new(value: VCard.unescape(p.value), type: type_of(p)) }
- @emails = by_name.fetch("EMAIL", []).map { |p| Email.new(value: VCard.unescape(p.value), type: type_of(p)) }
- @addresses = by_name.fetch("ADR", []).map { |p| address_from(p) }
- @birthday = format_birthday(by_name.fetch("BDAY", []).first)
- note = by_name.fetch("NOTE", []).first&.value
- @notes = note && VCard.unescape(note)
- end
-
- private
-
- # RFC 2426 section 3.1.2: N is Family;Given;Additional;Prefixes;
- # Suffixes. A given and family name are structured data — reading
- # them is the real answer to "what are this contact's initials,"
- # rather than guessing at where a first/last split falls in the
- # free-text FN. A card with no N at all (an organization's own
- # address-book entry, say, with a name but no person to split in
- # two) falls back to Format.initials' single first letter.
- #: (VCard::Property? property) -> String?
- def initials_from(property)
- return nil if property.nil?
-
- family, given = VCard.split_components(property.value)
- letters = [given, family].filter_map { |part| part[0] unless part.to_s.empty? }
- letters.empty? ? nil : letters.join.upcase
- end
-
- # RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
- # comma-list (TYPE=work,voice) — either arrives here as one
- # parameter per value, so the first is the one this shows.
- #: (VCard::Property property) -> String?
- def type_of(property)
- property.parameters.find { |name, _| name.casecmp?("TYPE") }&.last&.downcase
- end
-
- # ADR's components, RFC 2426 section 3.2.1: post office box,
- # extended address, street, locality, region, postal code,
- # country. Displayed as two lines — street, then everything after
- # it — the same shape the design's own address rows settled on.
- #: (VCard::Property property) -> Address
- def address_from(property)
- _po_box, extended, street, city, region, postal_code, country = VCard.split_components(property.value)
- lines = [
- [extended, street].reject { |s| s.nil? || s.empty? }.join(" "),
- [city, region, postal_code].reject { |s| s.nil? || s.empty? }.join(", "),
- country,
- ].reject { |s| s.nil? || s.empty? }
- Address.new(lines:, type: type_of(property))
- end
-
- # Reuses ProTacts::Birthday, which knows the two BDAY spellings a
- # served card actually carries (see lib/pro_tacts/birthday.rb): a
- # plain full date, and Apple's 1604-sentinel no-year form. A BDAY
- # in any other shape is one the app's model doesn't recompose —
- # kept in the card verbatim on write — so it's shown as stored.
- #: (VCard::Property? property) -> String?
- def format_birthday(property)
- return nil if property.nil?
-
- birthday = Birthday.from_property(property)
- return property.value if birthday.nil?
-
- if birthday.year
- Date.new(birthday.year, birthday.month, birthday.day).strftime("%B %-d, %Y")
- else
- Date.new(2000, birthday.month, birthday.day).strftime("%B %-d")
- end
- rescue ArgumentError
- property.value
- end
- end
- end
-end
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 28b587a..d3621cd 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -1,6 +1,5 @@
require "phlex"
-require "pro_tacts/admin/contact_fields"
require "pro_tacts/admin/format"
require "pro_tacts/admin/layout"
@@ -13,13 +12,13 @@ module ProTacts
class ContactsIndex < Phlex::HTML
RECENT_LIMIT = 10
- Row = Data.define(:contact, :fields, :updated_at)
+ Row = Data.define(:contact, :updated_at)
private_constant :Row
#: (recent: Array[Store::RecentContact], query: String?) -> void
def initialize(recent:, query:)
@query = query.to_s.strip
- rows = recent.map { Row.new(contact: it.contact, fields: ContactFields.from(it.contact), updated_at: it.updated_at) }
+ rows = recent.map { Row.new(contact: it.contact, updated_at: it.updated_at) }
@rows = @query.empty? ? rows.first(RECENT_LIMIT) : rows.select { matches?(it, @query) }
end
@@ -43,9 +42,9 @@ module ProTacts
#: (Row row, String query) -> bool
def matches?(row, query)
q = query.downcase
- return true if row.fields.name&.downcase&.include?(q)
- return true if row.fields.phones.any? { it.value.downcase.include?(q) }
- return true if row.fields.emails.any? { it.value.downcase.include?(q) }
+ return true if row.contact.name&.downcase&.include?(q)
+ return true if row.contact.phones.any? { it.value.downcase.include?(q) }
+ return true if row.contact.emails.any? { it.value.downcase.include?(q) }
false
end
@@ -54,9 +53,9 @@ module ProTacts
def render_row(row)
li do
a(href: "/contacts/#{row.contact.id}") do
- span(class: "avatar") { row.fields.initials || Format.initials(row.contact.id) }
+ span(class: "avatar") { Format.initials(row.contact) }
div(style: "flex: 1; min-width: 0;") do
- div(style: "font-weight: 550;") { row.fields.name || row.contact.id }
+ div(style: "font-weight: 550;") { row.contact.name || row.contact.id }
end
span(class: "type-label") { Format.time_ago(row.updated_at) }
end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index d91d196..64dc1ba 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -1,6 +1,5 @@
require "phlex"
-require "pro_tacts/admin/contact_fields"
require "pro_tacts/admin/format"
require "pro_tacts/admin/layout"
@@ -20,17 +19,17 @@ module ProTacts
#: (Contact contact) -> void
def initialize(contact:)
@contact = contact
- @fields = ContactFields.from(contact)
+ @birthday = Format.birthday(contact)
end
def view_template
- render Layout.new(title: @fields.name || @contact.id) do
+ render Layout.new(title: @contact.name || @contact.id) do
a(href: "/", class: "type-label") { "‹ contacts" }
div(class: "card") do
div(class: "card-body") do
div(class: "detail-header") do
- span(class: "avatar", data_size: "lg") { @fields.initials || Format.initials(@contact.id) }
- h1(class: "type-h2", style: "margin: 0;") { @fields.name || @contact.id }
+ span(class: "avatar", data_size: "lg") { Format.initials(@contact) }
+ h1(class: "type-h2", style: "margin: 0;") { @contact.name || @contact.id }
end
# Only rendered when there's something to show: an empty
# <dl> would still take up the gap card-body puts between
@@ -46,16 +45,16 @@ module ProTacts
#: () -> bool
def has_data?
- @fields.phones.any? || @fields.emails.any? || @fields.addresses.any? ||
- !@fields.birthday.nil? || !@fields.notes.nil?
+ @contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
+ !@birthday.nil? || !@contact.notes.nil?
end
def rows
- @fields.phones.each { |phone| row(phone.type, phone.value) }
- @fields.emails.each { |email| row(email.type, email.value) }
- @fields.addresses.each { |address| row(address.type || "address", address.lines) }
- row("birthday", @fields.birthday) if @fields.birthday
- row("notes", @fields.notes) if @fields.notes
+ @contact.phones.each { |phone| row(phone.type, phone.value) }
+ @contact.emails.each { |email| row(email.type, email.value) }
+ @contact.addresses.each { |address| row(address.type || "address", address_lines(address)) }
+ row("birthday", @birthday) if @birthday
+ row("notes", @contact.notes) if @contact.notes
end
#: (String? type, String | Array[String] value) -> void
@@ -64,6 +63,18 @@ module ProTacts
dd(class: "type-body-sm") { render_value(value) }
end
+ # ADR's components as two display lines — street, then everything
+ # after it — the same shape the design's own address rows settled
+ # on. Presentation: the components themselves are Contact's.
+ #: (Contact::Address address) -> Array[String]
+ def address_lines(address)
+ [
+ [address.extended, address.street].reject { |s| s.nil? || s.empty? }.join(" "),
+ [address.locality, address.region, address.postal_code].reject { |s| s.nil? || s.empty? }.join(", "),
+ address.country,
+ ].reject { |s| s.nil? || s.empty? }
+ end
+
#: (String | Array[String] value) -> void
def render_value(value)
if value.is_a?(Array)
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index e558fb8..6dc45bf 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -1,16 +1,53 @@
+require "date"
require "time"
module ProTacts
module Admin
- # Small display helpers shared by the admin views.
+ # Small display helpers shared by the admin views. Presentation, not
+ # structure: these read a Contact but decide only how it looks.
module Format
- # The first letter of a name with nothing more structured to go
- # on — an id, or a display name with no N property behind it
- # (see ContactFields#initials_from). One letter rather than a
- # guess at where a first/last split would fall in free text.
- #: (String name) -> String
- def self.initials(name)
- name.to_s.strip[0].to_s.upcase
+ # A contact's initials. N's given and family names (RFC 2426
+ # section 3.1.2) are structured data and the real answer to "what
+ # are this contact's initials" — falling back, when the card
+ # carries no N (an organization's own entry, say, with a name but
+ # 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
+ def self.initials(contact)
+ family, given = contact.name_components || []
+ letters = [given, family].filter_map { it[0] unless it.to_s.empty? }
+ return letters.join.upcase unless letters.empty?
+
+ (contact.name || contact.id).to_s.strip[0].to_s.upcase
+ end
+
+ # The birthday a card carries, for display: the model's date when
+ # the model holds one, the raw property when it does not — a BDAY
+ # in a spelling the model doesn't recompose is kept in the card
+ # verbatim on write, so it's shown as stored.
+ #: (Contact contact) -> String?
+ def self.birthday(contact)
+ birthday = contact.birthday
+ return raw_birthday(contact) if birthday.nil?
+
+ if birthday.year
+ Date.new(birthday.year, birthday.month, birthday.day).strftime("%B %-d, %Y")
+ else
+ Date.new(2000, birthday.month, birthday.day).strftime("%B %-d")
+ end
+ rescue ArgumentError
+ # A birthday whose stored value is well-shaped but
+ # calendar-nonsense: the model checks component ranges, not the
+ # calendar, so February 30 parses and only Date.new refuses it.
+ # The ordinary anticipated case, shown as stored rather than
+ # raised.
+ raw_birthday(contact)
+ end
+
+ #: (Contact contact) -> String?
+ def self.raw_birthday(contact)
+ contact.properties.find { it.name.casecmp?("BDAY") }&.value
end
# A card's updated_at, UTC ISO 8601 to the millisecond (see
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index d380aa2..5ab9674 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -1,8 +1,14 @@
require "digest"
+require "pro_tacts/birthday"
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
module ProTacts
- # One contact: its id, the vCard bytes served for it, and the etag over
- # those bytes. The id is the vCard's UID and the name in its href (see
+ # One contact: its id, the vCard bytes served for it, the etag over
+ # those bytes, and the structured read of them — the one model of a
+ # contact (docs/plans/2026-09-01-contact-is-the-model.md). The id is
+ # the vCard's UID and the name in its href (see
# docs/plans/2026-01-12-carddav-architecture.md).
#
# The etag hashes the card that goes out, so it changes exactly when
@@ -13,29 +19,164 @@ module ProTacts
# in the entity-tag's quoted form (RFC 7232 section 2.3), which is what
# both the ETag header and getetag properties carry.
#
- # Stored and served are the same bytes today. Once a group contributes
- # properties at render time they will not be, and this becomes the hash
- # of the composed card — still derived, from more inputs.
+ # The structured accessors are that idea applied to the card's
+ # contents: everything derives from the bytes, lazily, on the first
+ # structured read. The CardDAV paths never take one — they answer in
+ # bytes — and ctag and the listing reads build a contact per row
+ # without reading structure, so deferring the parse keeps every
+ # serving path as cheap as it was without the accessors. A regular
+ # class rather than a Data one because the memoized parse needs an
+ # ivar and Data freezes its instances.
#
- # The signature lives in sig/pro_tacts/contact.rbs: a Data class has
- # no constant super class for the inline syntax to read.
- # @rbs skip
- class Contact < Data.define(:id, :vcard, :etag)
+ # The accessors are deliberately shallow, like the parser: a card can
+ # carry properties no accessor knows, and CardDAV still serves every
+ # one. #properties is the read for those.
+ class Contact
+ # @rbs @id: String
+ # @rbs @vcard: String
+ # @rbs @etag: String
+ # @rbs @properties: Array[VCard::Property]
+
# Ids end up in paths and arrive from client-supplied hrefs, so an id
# outside this charset cannot be served.
- ID_FORMAT = /\A[\w-]+\z/
+ ID_FORMAT = /\A[\w-]+\z/ #: Regexp
+
+ # The structured shapes the accessors return: text value and type
+ # for TEL (RFC 2426 section 3.3.1) and EMAIL (section 3.3.2), and
+ # ADR's seven components (section 3.2.1: post office box, extended
+ # address, street, locality, region, postal code, country — nil
+ # where the card's value stopped short of the position). Data
+ # classes, whose members the inline syntax cannot read; the
+ # signatures live in sig/pro_tacts/contact.rbs.
+ # @rbs skip
+ Phone = Data.define(:value, :type)
+ # @rbs skip
+ Email = Data.define(:value, :type)
+ # @rbs skip
+ Address = Data.define(
+ :po_box,
+ :extended,
+ :street,
+ :locality,
+ :region,
+ :postal_code,
+ :country,
+ :type,
+ )
# A contact from its id and its card. The only way to make one: an
# etag that came from anywhere but the card in hand is an etag that
# can be wrong.
+ #: (id: String, vcard: String) -> Contact
def self.for(id:, vcard:)
raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
- new(id:, vcard:, etag: etag_for(vcard))
+ new(id, vcard, etag_for(vcard))
end
+ #: (String vcard) -> String
def self.etag_for(vcard)
%("#{Digest::SHA256.hexdigest(vcard)}")
end
+
+ #: (String id, String vcard, String etag) -> void
+ def initialize(id, vcard, etag)
+ @id = id
+ @vcard = vcard
+ @etag = etag
+ end
+
+ attr_reader :id
+
+ attr_reader :vcard
+
+ attr_reader :etag
+
+ # The card's properties, parsed once and memoized — the substrate
+ # the typed accessors sit on, and the read for what none of them
+ # models. Lets VCard::ParseError raise rather than returning an
+ # empty card: PUT checks VCard.card? before the store accepts a
+ # card, so one reaching this unparseable is a bug or a corrupt row,
+ # and Sentry (already wired into every request, see ProTacts::Web)
+ # is where that should surface, not a silently blank screen.
+ #: () -> Array[VCard::Property]
+ def properties
+ return @properties if defined?(@properties)
+
+ @properties = VCard::Parser.parse(@vcard)
+ end
+
+ # FN's value (RFC 2426 section 3.1.1), in text form.
+ #: () -> String?
+ def name
+ property = properties.find { it.name.casecmp?("FN") }
+ property && VCard.unescape(property.value)
+ end
+
+ # N's components (RFC 2426 section 3.1.2: family; given; additional;
+ # prefixes; suffixes), split and unescaped, or nil with no N at all.
+ # Structured data — reading initials off it is the real answer to
+ # where a first/last split falls, rather than guessing in the
+ # free-text FN.
+ #: () -> Array[String]?
+ def name_components
+ property = properties.find { it.name.casecmp?("N") }
+ property && VCard.split_components(property.value)
+ end
+
+ #: () -> Array[Phone]
+ def phones
+ of_name("TEL").map { Phone.new(value: VCard.unescape(it.value), type: type_of(it)) }
+ end
+
+ #: () -> Array[Email]
+ def emails
+ of_name("EMAIL").map { Email.new(value: VCard.unescape(it.value), type: type_of(it)) }
+ end
+
+ #: () -> Array[Address]
+ def addresses
+ of_name("ADR").map { address_of(it) }
+ end
+
+ # The birthday the served card carries — the model the store
+ # composed into it (see Birthday), parsed back out because the two
+ # forms Birthday serves are exactly the two from_property accepts.
+ # Nil when the card carries no BDAY the model recomposes; an
+ # unmodeled spelling stays in the card and is #properties' to show.
+ #: () -> Birthday?
+ def birthday
+ property = properties.find { it.name.casecmp?("BDAY") }
+ property && Birthday.from_property(property)
+ end
+
+ # The card's NOTE (RFC 2426 section 3.6.2), in text form.
+ #: () -> String?
+ def notes
+ property = properties.find { it.name.casecmp?("NOTE") }
+ property && VCard.unescape(property.value)
+ end
+
+ private
+
+ #: (String name) -> Array[VCard::Property]
+ def of_name(name)
+ properties.select { it.name.casecmp?(name) }
+ end
+
+ #: (VCard::Property property) -> Address
+ def address_of(property)
+ po_box, extended, street, locality, region, postal_code, country =
+ VCard.split_components(property.value)
+ Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, type: type_of(property))
+ end
+
+ # RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
+ # comma-list (TYPE=work,voice) — either arrives here as one
+ # parameter per value, so the first is the one this carries.
+ #: (VCard::Property property) -> String?
+ def type_of(property)
+ property.parameters.find { |name, _| name.casecmp?("TYPE") }&.last&.downcase
+ end
end
end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 6f80dd3..1d85267 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -1,19 +1,44 @@
-# `class Contact < Data.define(...)` has no constant for a super class,
-# which is what inline RBS needs to read one (RBS docs/data_and_struct.md
-# covers the pattern). The class is marked `@rbs skip` in contact.rb and
-# its signature written out here instead.
+# Contact::Phone, ::Email, and ::Address are Data classes, whose
+# members the inline parser cannot read (see sig/pro_tacts/birthday.rbs
+# for the same limit). Each is marked `@rbs skip` in contact.rb and
+# signed here; Contact itself is a plain class and carries its
+# signature inline.
module ProTacts
class Contact
- ID_FORMAT: Regexp
+ class Phone
+ attr_reader value: String
+ attr_reader type: String?
- attr_reader id: String
- attr_reader vcard: String
- attr_reader etag: String
+ def self.new: (value: String, type: String?) -> instance
+ end
- def self.new: (id: String, vcard: String, etag: String) -> instance
+ class Email
+ attr_reader value: String
+ attr_reader type: String?
- def self.for: (id: String, vcard: String) -> Contact
+ def self.new: (value: String, type: String?) -> instance
+ end
- def self.etag_for: (String vcard) -> String
+ class Address
+ attr_reader po_box: String?
+ attr_reader extended: String?
+ attr_reader street: String?
+ attr_reader locality: String?
+ attr_reader region: String?
+ attr_reader postal_code: String?
+ attr_reader country: String?
+ attr_reader type: String?
+
+ def self.new: (
+ po_box: String?,
+ extended: String?,
+ street: String?,
+ locality: String?,
+ region: String?,
+ postal_code: String?,
+ country: String?,
+ type: String?
+ ) -> instance
+ end
end
end
diff --git a/test/pro_tacts/admin/test_contact_fields.rb b/test/pro_tacts/admin/test_contact_fields.rb
deleted file mode 100644
index afd416a..0000000
--- a/test/pro_tacts/admin/test_contact_fields.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-require_relative "../../test_helper"
-
-require "pro_tacts/admin/contact_fields"
-require "pro_tacts/contact"
-
-class ContactFieldsTest < Minitest::Test
- def fields(vcard)
- ProTacts::Admin::ContactFields.from(ProTacts::Contact.for(id: "aiden", vcard:))
- end
-
- CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
- "TEL;TYPE=mobile:+1-555-0100\r\nTEL;TYPE=work:+1-555-0199\r\n" \
- "EMAIL;TYPE=home:ada@example.com\r\n" \
- "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
- "BDAY:1985-12-10\r\nNOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
-
- def test_reads_the_name
- assert_equal "Ada Lovelace", fields(CARD).name
- end
-
- # Initials come from the structured N property (given + family),
- # not a guess at word boundaries in the free-text FN.
- def test_initials_come_from_the_structured_name
- assert_equal "AL", fields(CARD).initials
- end
-
- # A card with no N at all — an organization's own entry, say — falls
- # back to Format.initials' single first letter of FN.
- def test_initials_fall_back_to_the_display_name_with_no_n_property
- no_n = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Riverside Elementary\r\nUID:office\r\nEND:VCARD\r\n"
-
- assert_equal "R", fields(no_n).initials
- end
-
- # N with only one of given/family present (a mononym, or a card that
- # only bothered with a family name) still gives one initial rather
- # than raising on the missing half.
- def test_initials_from_a_partial_n_property
- family_only = CARD.sub("N:Lovelace;Ada;;;", "N:Lovelace;;;;")
-
- assert_equal "L", fields(family_only).initials
- end
-
- def test_reads_every_phone_with_its_type
- assert_equal [["+1-555-0100", "mobile"], ["+1-555-0199", "work"]],
- fields(CARD).phones.map { [it.value, it.type] }
- end
-
- def test_reads_email
- emails = fields(CARD).emails
- assert_equal 1, emails.length
- assert_equal "ada@example.com", emails.fetch(0).value
- assert_equal "home", emails.fetch(0).type
- end
-
- def test_reads_address_as_two_lines
- addresses = fields(CARD).addresses
- assert_equal 1, addresses.length
- address = addresses.fetch(0)
- assert_equal ["12 Analytical Way", "London, England, NW1 1AA", "United Kingdom"], address.lines
- assert_equal "home", address.type
- end
-
- def test_formats_the_birthday
- assert_equal "December 10, 1985", fields(CARD).birthday
- end
-
- def test_unescapes_notes
- assert_equal "Countess, mathematician.", fields(CARD).notes
- end
-
- def test_attributes_with_no_data_are_nil_or_empty
- bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bare\r\nUID:bare\r\nEND:VCARD\r\n"
- fields = fields(bare)
-
- assert_empty fields.phones
- assert_empty fields.emails
- assert_empty fields.addresses
- assert_nil fields.birthday
- assert_nil fields.notes
- end
-
- # Unlike Store#rebuild_index, which fails a card open because a
- # write already guarantees every stored card parses (VCard.card? is
- # checked before a PUT is accepted) — a card that reaches here and
- # still won't parse is a bug or a corrupt row, not the ordinary case,
- # and it should raise into Sentry rather than render a blank card.
- def test_a_card_that_will_not_parse_raises
- assert_raises(ProTacts::VCard::ParseError) { fields("not a vCard at all") }
- end
-
- # RFC 2426 section 3.1.5 lets BDAY carry a date-time, and a client is
- # free to send one; the display only reads the date part of it.
- def test_a_birthday_with_a_time_component_still_formats
- with_time = CARD.sub("BDAY:1985-12-10", "BDAY:1985-12-10T00:00:00Z")
-
- assert_equal "December 10, 1985", fields(with_time).birthday
- end
-
- # Apple's 1604-sentinel spelling for "no year on file" (see
- # ProTacts::Birthday), the shape a served card actually carries.
- def test_a_birthday_with_no_year_omits_one
- no_year = CARD.sub("BDAY:1985-12-10", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-12-10")
-
- assert_equal "December 10", fields(no_year).birthday
- end
-
- def test_a_birthday_that_will_not_parse_is_shown_as_stored
- unparseable = CARD.sub("BDAY:1985-12-10", "BDAY:not-a-date")
-
- assert_equal "not-a-date", fields(unparseable).birthday
- end
-end
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index c8710b3..1df9088 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -1,16 +1,90 @@
require_relative "../../test_helper"
require "pro_tacts/admin/format"
+require "pro_tacts/contact"
class FormatTest < Minitest::Test
- def test_initials_is_the_first_letter_of_the_name
- assert_equal "A", ProTacts::Admin::Format.initials("Ada Lovelace")
- assert_equal "A", ProTacts::Admin::Format.initials("Ada")
- assert_equal "R", ProTacts::Admin::Format.initials("Riverside Elementary Front Office")
+ CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
+ "BDAY:1985-12-10\r\nUID:ada\r\nEND:VCARD\r\n"
+
+ def contact(vcard = CARD, id: "aiden")
+ ProTacts::Contact.for(id:, vcard:)
+ end
+
+ # Initials come from the structured N property (given + family),
+ # not a guess at word boundaries in the free-text FN.
+ def test_initials_come_from_the_structured_name
+ assert_equal "AL", ProTacts::Admin::Format.initials(contact)
+ end
+
+ # A card with no N at all — an organization's own entry, say — falls
+ # back to one first letter of FN.
+ def test_initials_fall_back_to_the_display_name_with_no_n_property
+ no_n = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Riverside Elementary\r\nUID:office\r\nEND:VCARD\r\n"
+
+ assert_equal "R", ProTacts::Admin::Format.initials(contact(no_n))
+ end
+
+ def test_initials_fall_back_to_the_id_when_there_is_no_name_at_all
+ bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:zz9\r\nEND:VCARD\r\n"
+
+ assert_equal "Z", ProTacts::Admin::Format.initials(contact(bare, id: "znorth"))
end
def test_initials_of_a_blank_name_is_blank
- assert_equal "", ProTacts::Admin::Format.initials("")
+ blank = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:\r\nUID:b\r\nEND:VCARD\r\n"
+
+ assert_equal "", ProTacts::Admin::Format.initials(contact(blank))
+ end
+
+ # N with only one of given/family present (a mononym, or a card that
+ # only bothered with a family name) still gives one initial rather
+ # than raising on the missing half.
+ def test_initials_from_a_partial_n_property
+ family_only = CARD.sub("N:Lovelace;Ada;;;", "N:Lovelace;;;;")
+
+ assert_equal "L", ProTacts::Admin::Format.initials(contact(family_only))
+ end
+
+ def test_formats_the_birthday
+ assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact)
+ end
+
+ # RFC 2426 section 3.1.5 lets BDAY carry a date-time, and a client is
+ # free to send one; the display only reads the date part of it.
+ def test_a_birthday_with_a_time_component_still_formats
+ with_time = CARD.sub("BDAY:1985-12-10", "BDAY:1985-12-10T00:00:00Z")
+
+ assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact(with_time))
+ end
+
+ # Apple's 1604-sentinel spelling for "no year on file" (see
+ # ProTacts::Birthday), the shape a served card actually carries.
+ def test_a_birthday_with_no_year_omits_one
+ no_year = CARD.sub("BDAY:1985-12-10", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-12-10")
+
+ assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(no_year))
+ end
+
+ def test_a_birthday_that_will_not_parse_is_shown_as_stored
+ unparseable = CARD.sub("BDAY:1985-12-10", "BDAY:not-a-date")
+
+ assert_equal "not-a-date", ProTacts::Admin::Format.birthday(contact(unparseable))
+ end
+
+ # Well-shaped but calendar-nonsense: the model checks component
+ # ranges, not the calendar, so February 30 parses and only Date.new
+ # refuses it. The anticipated case, shown as stored rather than raised.
+ def test_a_calendar_nonsense_birthday_is_shown_as_stored
+ nonsense = CARD.sub("BDAY:1985-12-10", "BDAY:1985-02-30")
+
+ assert_equal "1985-02-30", ProTacts::Admin::Format.birthday(contact(nonsense))
+ end
+
+ def test_a_card_with_no_birthday_formats_none
+ bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:bare\r\nEND:VCARD\r\n"
+
+ assert_nil ProTacts::Admin::Format.birthday(contact(bare))
end
def test_time_ago_buckets_by_how_long_ago
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index b4ae63c..8344257 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -7,6 +7,16 @@ require "pro_tacts/contact"
class ContactTest < Minitest::Test
CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
+ STRUCTURED = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
+ "TEL;TYPE=mobile:+1-555-0100\r\nTEL;TYPE=work:+1-555-0199\r\n" \
+ "EMAIL;TYPE=home:ada@example.com\r\n" \
+ "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
+ "BDAY:1985-12-10\r\nNOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
+
+ def contact(vcard)
+ ProTacts::Contact.for(id: "aiden", vcard:)
+ end
+
def test_the_etag_hashes_the_card
contact = ProTacts::Contact.for(id: "aiden", vcard: CARD)
@@ -42,4 +52,109 @@ class ContactTest < Minitest::Test
assert_equal id, ProTacts::Contact.for(id:, vcard: CARD).id
end
end
+
+ # The parse is lazy so the serving paths never pay it: ctag and the
+ # listings build a contact per row without reading structure, and a
+ # card that will not parse is still served byte for byte.
+ def test_making_a_contact_never_parses_the_card
+ assert_equal "aiden", contact("not a vCard at all").id
+ end
+
+ def test_reads_the_name
+ assert_equal "Ada Lovelace", contact(STRUCTURED).name
+ end
+
+ def test_reads_the_name_components
+ assert_equal ["Lovelace", "Ada", "", "", ""], contact(STRUCTURED).name_components
+ end
+
+ def test_values_come_back_unescaped
+ escaped = STRUCTURED.sub("FN:Ada Lovelace", "FN:Ada\\, Countess of Lovelace")
+
+ assert_equal "Ada, Countess of Lovelace", contact(escaped).name
+ end
+
+ def test_reads_every_phone_with_its_type
+ assert_equal [["+1-555-0100", "mobile"], ["+1-555-0199", "work"]],
+ contact(STRUCTURED).phones.map { [it.value, it.type] }
+ end
+
+ def test_reads_email
+ emails = contact(STRUCTURED).emails
+ assert_equal 1, emails.length
+ assert_equal "ada@example.com", emails.fetch(0).value
+ assert_equal "home", emails.fetch(0).type
+ end
+
+ def test_reads_an_address_as_its_seven_components
+ address = contact(STRUCTURED).addresses.fetch(0)
+ assert_equal "", address.po_box
+ assert_equal "", address.extended
+ assert_equal "12 Analytical Way", address.street
+ assert_equal "London", address.locality
+ assert_equal "England", address.region
+ assert_equal "NW1 1AA", address.postal_code
+ assert_equal "United Kingdom", address.country
+ assert_equal "home", address.type
+ end
+
+ # Components the card's value stopped short of are nil, not empty —
+ # only a delimiter the value actually carried makes an empty one.
+ def test_address_components_beyond_the_value_are_nil
+ short = STRUCTURED.sub(
+ "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+ "ADR:;;12 Analytical Way",
+ )
+ address = contact(short).addresses.fetch(0)
+
+ assert_nil address.locality
+ assert_nil address.region
+ assert_nil address.postal_code
+ assert_nil address.country
+ end
+
+ def test_the_birthday_is_the_model_the_store_composed
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 12, day: 10), contact(STRUCTURED).birthday
+ end
+
+ # A BDAY in a spelling the model does not recompose stays in the card
+ # verbatim; the accessor reads nil, and showing it as stored is the
+ # substrate's job (Format.birthday).
+ def test_an_unmodeled_birthday_spelling_reads_nil
+ unmodeled = STRUCTURED.sub("BDAY:1985-12-10", "BDAY:not-a-date")
+
+ assert_nil contact(unmodeled).birthday
+ end
+
+ def test_unescapes_notes
+ assert_equal "Countess, mathematician.", contact(STRUCTURED).notes
+ end
+
+ def test_properties_are_the_parsed_card
+ properties = contact(STRUCTURED).properties
+
+ assert_equal "BEGIN", properties.fetch(0).name
+ assert properties.any? { it.name.casecmp?("TEL") }
+ end
+
+ def test_attributes_with_no_data_are_nil_or_empty
+ bare = contact("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:bare\r\nEND:VCARD\r\n")
+
+ assert_nil bare.name
+ assert_nil bare.name_components
+ assert_empty bare.phones
+ assert_empty bare.emails
+ assert_empty bare.addresses
+ assert_nil bare.birthday
+ assert_nil bare.notes
+ end
+
+ # Unlike Store#rebuild_index, which fails a card open because a
+ # write already guarantees every stored card parses (VCard.card? is
+ # checked before a PUT is accepted) — a card that reaches here and
+ # still won't parse is a bug or a corrupt row, not the ordinary case,
+ # and it should raise into Sentry rather than render a blank card.
+ def test_a_card_that_will_not_parse_raises_from_the_accessors
+ assert_raises(ProTacts::VCard::ParseError) { contact("not a vCard at all").phones }
+ end
end