1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
require_relative "../../test_helper"
require "hegel"
require "pro_tacts/admin/format"
require "pro_tacts/contact"
require "pro_tacts/vcard"
class FormatTest < Minitest::Test
include Hegel::Syntax::Methods
CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
"UID:ada\r\nEND:VCARD\r\n"
# The production shapes, as Contact's class comment lays them out.
# Nothing on these screens is in a group, so inheritance is none.
def contact(bytes = CARD, id: "aiden", birthday: nil, inherited: [])
ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday:, inherited:)
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
# A blank FN is no name — empty values read as absent (see
# Contact) — so initials fall back to the id like any nameless card.
def test_initials_of_a_blank_name_fall_back_to_the_id
blank = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:\r\nUID:b\r\nEND:VCARD\r\n"
assert_equal "A", 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
# Search compares on the fold, not the spelling: an accent never
# hides the letter it sits on, and a letter another script spells
# stays — ASCII-only matching would make that name unfindable.
def test_search_key_folds_accents_and_keeps_whole_letters
assert_equal "emile munoz", ProTacts::Admin::Format.search_key("Émile Muñoz")
assert_equal "νικος", ProTacts::Admin::Format.search_key("Νίκος")
assert_equal "straße", ProTacts::Admin::Format.search_key("Straße")
end
def test_formats_the_birthday
birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact(CARD, birthday:))
end
# RFC 2426 section 3.1.5 lets BDAY carry a date-time, and a client is
# free to send one; the display reads only the date part of a bare
# value it meets — the same drop the write path makes on the way in
# (Birthday.from_property).
def test_a_birthday_with_a_time_component_still_formats
with_time = CARD.sub("END:VCARD\r\n", "BDAY:1985-12-10T00:00:00Z\r\nEND:VCARD\r\n")
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 model shape the served card carries.
def test_a_birthday_with_no_year_omits_one
birthday = ProTacts::Birthday.new(month: 12, day: 10)
assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(CARD, birthday:))
end
# A month-day spelling still in a card rather than the model: a write
# moves it out, and display reads it the way a write would
# (Birthday::MONTH_AND_DAY), not as the stored string.
def test_a_reduced_no_year_birthday_displays_its_day
reduced = CARD.sub("END:VCARD\r\n", "BDAY:--12-10\r\nEND:VCARD\r\n")
assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(reduced))
end
# The undashed variant of the reduced spelling.
def test_an_undashed_reduced_no_year_birthday_displays_its_day
basic = CARD.sub("END:VCARD\r\n", "BDAY:--1210\r\nEND:VCARD\r\n")
assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(basic))
end
# Calendar-nonsense in the reduced spelling, rendered as the day it
# names like the modeled one below.
def test_a_calendar_nonsense_reduced_no_year_birthday_renders_its_components
nonsense = CARD.sub("END:VCARD\r\n", "BDAY:--02-30\r\nEND:VCARD\r\n")
assert_equal "February 30", ProTacts::Admin::Format.birthday(contact(nonsense))
end
# A line stored before the model read VALUE=date still carries it,
# and displays as the date it names.
def test_a_stored_value_date_birthday_displays_its_date
stored = CARD.sub("END:VCARD\r\n", "BDAY;value=date:1985-12-10\r\nEND:VCARD\r\n")
assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact(stored))
end
def test_a_birthday_that_will_not_parse_is_shown_as_stored
unparseable = CARD.sub("END:VCARD\r\n", "BDAY:not-a-date\r\nEND:VCARD\r\n")
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, and rendering is calendar-free — the
# prose names the day it says rather than refusing to display.
def test_a_calendar_nonsense_birthday_renders_its_components
nonsense = ProTacts::Birthday.new(year: 1985, month: 2, day: 30)
assert_equal "February 30, 1985", ProTacts::Admin::Format.birthday(contact(CARD, birthday: nonsense))
end
# The partial shapes no client renders still display: every form
# the grammar admits has Birthday#to_s's one prose.
def test_the_partial_shapes_display_their_components
year_month = CARD.sub("END:VCARD\r\n", "BDAY:1985-10\r\nEND:VCARD\r\n")
year = CARD.sub("END:VCARD\r\n", "BDAY:1985\r\nEND:VCARD\r\n")
month = CARD.sub("END:VCARD\r\n", "BDAY:--07\r\nEND:VCARD\r\n")
day = CARD.sub("END:VCARD\r\n", "BDAY:---04\r\nEND:VCARD\r\n")
assert_equal "October 1985", ProTacts::Admin::Format.birthday(contact(year_month))
assert_equal "1985", ProTacts::Admin::Format.birthday(contact(year))
assert_equal "July", ProTacts::Admin::Format.birthday(contact(month))
assert_equal "4", ProTacts::Admin::Format.birthday(contact(day))
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_an_etag_shows_as_a_short_bare_hash
etag = %("#{"ab12cd34ef56" * 5}")
assert_equal "ab12cd34ef56…", ProTacts::Admin::Format.short_etag(etag)
end
def test_a_hash_no_longer_than_the_cut_keeps_every_digit
assert_equal "ab12cd34", ProTacts::Admin::Format.short_etag(%("ab12cd34"))
end
def test_a_stamp_drops_the_milliseconds_and_nothing_else
assert_equal "2026-09-04T20:35:22Z",
ProTacts::Admin::Format.stamp("2026-09-04T20:35:22.481Z")
end
def test_a_stamp_without_milliseconds_is_left_alone
assert_equal "2026-09-04T20:35:22Z",
ProTacts::Admin::Format.stamp("2026-09-04T20:35:22Z")
end
def test_time_ago_buckets_by_how_long_ago
now = Time.now.utc
assert_equal "just now", ProTacts::Admin::Format.time_ago(now.iso8601(3))
assert_equal "5m ago", ProTacts::Admin::Format.time_ago((now - 5 * 60).iso8601(3))
assert_equal "2h ago", ProTacts::Admin::Format.time_ago((now - 2 * 3600).iso8601(3))
assert_equal "3d ago", ProTacts::Admin::Format.time_ago((now - 3 * 86_400).iso8601(3))
assert_equal "2mo ago", ProTacts::Admin::Format.time_ago((now - 60 * 86_400).iso8601(3))
end
## Property tests
# A search key is already folded, so folding it again changes
# nothing: a query and a value both reach the comparison as keys,
# however many times either was folded on the way.
def test_a_search_key_is_its_own_key
Hegel.test(test_cases: 1_000) do |tc|
key = ProTacts::Admin::Format.search_key(tc.draw(text(max_size: 20)))
assert_equal key, ProTacts::Admin::Format.search_key(key)
end
end
## Eliding a line
# An elided line still reads as the line it stands for: its group,
# its name, and each parameter, a long value as its count and every
# other as it was — quoted where the grammar must quote it, so a
# parameter holding `:` or `,` is not cut apart on the way to the
# screen.
def test_an_elided_line_reads_as_its_line_with_long_values_counted
token = from_regex("[A-Za-z0-9-]{1,8}", fullmatch: true)
short = from_regex('[^\x00-\x08\x0a-\x1f\x7f"]{0,12}', fullmatch: true)
long = from_regex("[a-z:,;]{121,140}", fullmatch: true)
Hegel.test(test_cases: 1_000) do |tc|
property = ProTacts::VCard::Parser::Property.new(
group: tc.draw(optional(token)),
name: tc.draw(token),
parameters: tc.draw(arrays(tuples(token, one_of(short, long)), max_size: 3)),
value: tc.draw(one_of(short.map { it.delete(":;,\\") }, long)),
)
line = ProTacts::VCard.new("#{ProTacts::VCard.header_of(property)}#{property.value}\r\n").lines.fetch(0)
count = ->(value) { value.bytesize > ProTacts::Admin::Format::ELIDE_ABOVE ? "[#{value.bytesize} octets elided]" : value }
elided = ProTacts::VCard.new(ProTacts::Admin::Format.elide_line(line)).lines.fetch(0).property
assert_equal property.with(parameters: property.parameters.map { [it[0], count.(it[1])] }, value: count.(property.value)),
elided
end
end
end