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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
require_relative "../../test_helper"
require "json"
require "pro_tacts/import/monica"
require "pro_tacts/import/vcf"
# Reading Monica 4's JSON export as the .vcf the walk already imports
# (docs/plans/2026-09-25-importing-from-monica.md). The export here is
# spelled the way Monica's export resources write one
# (app/ExportResources on its 4.x branch): a record's columns, its
# `properties`, and its `data` as `{count, type, values}` collections.
class ImportMonicaTest < Minitest::Test
Monica = ProTacts::Import::Monica
Vcf = ProTacts::Import::Vcf
PHONE = "type-phone"
EMAIL = "type-email"
TWITTER = "type-twitter"
SIGNAL = "type-signal"
# A red pixel, as the data URL Monica's Photo resource writes.
PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
def collection(type, values)
{"count" => values.length, "type" => type, "values" => values}
end
def jane(**properties)
{
"uuid" => "jane-uuid",
"properties" => {
"first_name" => "Jane", "last_name" => "Booles", "nickname" => "JB",
"company" => "Acme", "job" => "Welder",
"description" => "Met at the co-op.",
"food_preferences" => "No cilantro",
"tags" => ["school", "book club"],
"avatar" => {"avatar_source" => "photo", "avatar_photo" => "photo-uuid"},
"birthdate" => {"is_age_based" => false, "is_year_unknown" => false,
"date" => "1980-05-02T00:00:00.000000Z"},
"first_met_through" => "sam-uuid",
**properties,
},
"data" => [
collection("contact_field", [
{"uuid" => "f1", "properties" => {"data" => "+1 555 0100", "type" => PHONE}},
{"uuid" => "f2", "properties" => {"data" => "jane@example.com", "type" => EMAIL}},
{"uuid" => "f3", "properties" => {"data" => "janeb", "type" => TWITTER}},
]),
collection("note", [
{"uuid" => "n1", "properties" => {"body" => "Likes jigsaws."}},
{"uuid" => "n2", "properties" => {"body" => "Owes us a casserole dish."}},
]),
collection("address", [
{"uuid" => "a1", "properties" => {"name" => "Home", "street" => "1 Main St", "city" => "Springfield",
"province" => "IL", "postal_code" => "62701", "country" => "US"}},
]),
collection("pet", [{"uuid" => "p1", "properties" => {"name" => "Rex", "category" => "dog"}}]),
collection("reminder", [{"uuid" => "r1", "properties" => {"title" => "Call about the dish",
"initial_date" => "2026-10-01T00:00:00Z"}}]),
collection("activity", ["act-uuid"]),
],
}
end
def sam
{"uuid" => "sam-uuid", "properties" => {"first_name" => "Sam", "last_name" => "Booles"}}
end
# Known by name only, from Jane's page: a relative, not a contact.
def aunt
{"uuid" => "aunt-uuid", "properties" => {"first_name" => "Edna", "is_partial" => true}}
end
def export(*contacts, relationships: [])
JSON.generate({
"version" => "1.0-preview.1",
"account" => {
"uuid" => "account",
"data" => [
collection("contact", contacts),
collection("relationship", relationships),
collection("photo", [{"uuid" => "photo-uuid",
"properties" => {"dataUrl" => "data:image/png;base64,#{PIXEL}"}}]),
collection("activity", [{"uuid" => "act-uuid",
"properties" => {"summary" => "Picnic", "happened_at" => "2026-06-01T00:00:00Z"}}]),
],
"instance" => {
"contact_field_types" => [
{"uuid" => PHONE, "properties" => {"name" => "Phone", "type" => "phone"}},
{"uuid" => EMAIL, "properties" => {"name" => "Email", "type" => "email"}},
{"uuid" => TWITTER, "properties" => {"name" => "Twitter"}},
{"uuid" => SIGNAL, "properties" => {"name" => "Signal"}},
],
},
},
})
end
def relationship(from, type, to)
{"uuid" => "#{from}-#{to}", "properties" => {"type" => type, "contact_is" => from, "of_contact" => to}}
end
def card_for(bytes, name)
Vcf.cards(Monica.vcf(bytes)).find { it.property("FN")&.text == name }
end
def test_an_export_is_told_from_a_vcf_by_its_first_character
assert Monica.export?("\n {\"account\": {}}")
refute Monica.export?("BEGIN:VCARD\r\n")
end
def test_each_contact_becomes_a_card_the_walk_reads
cards = Vcf.cards(Monica.vcf(export(jane, sam)))
assert_equal ["Jane Booles", "Sam Booles"], cards.map { it.property("FN").text }
assert(cards.all?(&:card?))
end
def test_the_fields_this_book_shows_come_in
card = Vcf.read(card_for(export(jane, sam), "Jane Booles")).card
assert_equal "Booles;Jane;;;", card.property("N").value
assert_equal "JB", card.property("NICKNAME").text
assert_equal "Acme", card.property("ORG").text
assert_equal "1980-05-02", card.property("BDAY").value
assert_equal "+1 555 0100", card.property("TEL").text
assert_equal "jane@example.com", card.property("EMAIL").text
assert_equal ";;1 Main St;Springfield;IL;62701;US", card.property("ADR").value
assert_equal "Home", card.property("X-ABLabel").text
assert_equal PIXEL, card.property("PHOTO").value
end
# A social profile is not written at all: no screen here shows one,
# and the import says nothing of it.
def test_a_social_profile_is_not_written
refute_includes Monica.vcf(export(jane)), "janeb"
end
# Monica ships no Signal type, so a handle is filed under one the
# account made, and comes in as the line the editor writes
# (docs/plans/2026-10-18-signal-handles.md).
def test_a_field_filed_under_signal_comes_in_as_a_handle
kim = {
"uuid" => "kim-uuid",
"properties" => {"first_name" => "Kim"},
"data" => [collection("contact_field", [{"uuid" => "f9", "properties" => {"data" => "kim.07", "type" => SIGNAL}}])],
}
card = Vcf.read(card_for(export(kim), "Kim")).card
assert_includes card.to_s, "IMPP;X-SERVICE-TYPE=signal:x-apple:kim.07\r\n"
end
# A company Monica kept as a vCard's raw ORG loses the trailing
# semicolons, which escaped as text would be part of its name; one
# that was nothing else is no company.
def test_a_companys_trailing_semicolons_are_dropped
assert_equal "Acme", card_for(export(jane("company" => "Acme; ")), "Jane Booles").property("ORG").value
assert_equal "Acme", card_for(export(jane("company" => "Acme;;")), "Jane Booles").property("ORG").text
assert_nil card_for(export(jane("company" => ";")), "Jane Booles").property("ORG")
end
# One NOTE, the invariant a contact keeps
# (docs/plans/2026-09-25-one-note-per-contact.md): the prose first,
# then what Monica keeps as records, spelled out.
def test_the_prose_joins_into_the_one_note
rels = [relationship("jane-uuid", "spouse", "sam-uuid"), relationship("jane-uuid", "bestfriend", "aunt-uuid")]
card = card_for(export(jane, sam, aunt, relationships: rels), "Jane Booles")
notes = card.properties.select { it.name == "NOTE" }
assert_equal 1, notes.length
assert_equal <<~NOTE.chomp, notes.fetch(0).text
Met at the co-op.
Likes jigsaws.
Owes us a casserole dish.
Spouse: Sam Booles
Best friend: Edna
Pet: Rex (dog)
Food preferences: No cilantro
Met through Sam Booles
NOTE
end
def test_a_relative_known_only_by_name_is_not_a_card
names = Vcf.cards(Monica.vcf(export(jane, aunt))).map { it.property("FN").text }
assert_equal ["Jane Booles"], names
end
# What no screen here shows is on the card as exported, to be struck
# there and copied by hand where it is worth it.
def test_what_this_book_does_not_show_is_left_behind_in_view
dropped = Vcf.read(card_for(export(jane), "Jane Booles")).dropped.map { Vcf.summary(it) }
assert_includes dropped, "TITLE:Welder"
assert_includes dropped, "X-MONICA-REMINDER:2026-10-01 — Call about the dish"
assert_includes dropped, "X-MONICA-ACTIVITY:2026-06-01 — Picnic"
end
# A composed card says who wrote it, which is how the walk knows to
# tick it into the monica group; a card from anywhere else is not.
def test_a_composed_card_is_marked_for_the_monica_group
card = card_for(export(jane), "Jane Booles")
assert_equal Monica::PRODID, card.property("PRODID").text
assert_equal ["monica"], Monica.groups(card)
assert_empty Monica.groups(ProTacts::VCard.new("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Sam\r\nEND:VCARD\r\n"))
end
def test_tags_arrive_as_the_card_s_categories
card = card_for(export(jane), "Jane Booles")
assert_equal ["school", "book club"], Vcf.categories(card)
end
def test_a_birthday_without_its_year_is_one_a_card_can_spell
card = card_for(export(jane("birthdate" => {"is_year_unknown" => true, "date" => "2020-05-02T00:00:00Z"})),
"Jane Booles")
assert_equal "1604-05-02", card.property("BDAY").value
end
# Worked out from an age: a year Monica guessed and a day it made up.
def test_a_birthday_monica_guessed_from_an_age_is_left_behind
card = card_for(export(jane("birthdate" => {"is_age_based" => true, "date" => "1980-01-01T00:00:00Z"})),
"Jane Booles")
assert_nil card.property("BDAY")
assert_equal "about 1980", card.property("X-MONICA-AGE-BASED-BIRTHDAY").text
end
# A date no month and day reads as: left behind as it came, the
# module's answer to any part with nowhere to go, rather than gone.
def test_a_birthday_that_will_not_read_is_left_behind
["1980-00-12T00:00:00Z", "12/03/1980"].each do |date|
card = card_for(export(jane("birthdate" => {"date" => date})), "Jane Booles")
assert_nil card.property("BDAY")
assert_equal date, card.property("X-MONICA-BIRTHDATE").text
end
end
def test_a_field_whose_type_the_export_does_not_hold_is_left_behind
contact = jane
contact.fetch("data").fetch(0)["values"] = [{"uuid" => "f9", "properties" => {"data" => "+1 555 0199", "type" => "gone"}}]
card = card_for(export(contact), "Jane Booles")
assert_nil card.property("X-SOCIALPROFILE")
assert_equal "+1 555 0199", card.property("X-MONICA-CONTACT-FIELD").text
end
def test_a_relationship_to_someone_the_export_does_not_name_is_left_behind
card = card_for(export(jane, relationships: [relationship("jane-uuid", "partner", "nobody-uuid")]), "Jane Booles")
refute_includes card.property("NOTE").text, "Significant other"
assert_equal "Significant other", card.property("X-MONICA-RELATIONSHIP").text
end
def test_an_avatar_naming_a_photo_the_export_does_not_hold_is_left_behind
card = card_for(export(jane("avatar" => {"avatar_source" => "photo", "avatar_photo" => "gone-uuid"})),
"Jane Booles")
assert_nil card.property("PHOTO")
assert_equal "not in the export", card.property("X-MONICA-PHOTO").text
end
def test_json_that_is_not_a_monica_export_is_refused
error = assert_raises(Vcf::Invalid) { Monica.vcf("{\"contacts\": []}") }
assert_includes error.message, "not a Monica export"
end
def test_a_file_that_will_not_parse_is_refused
assert_raises(Vcf::Invalid) { Monica.vcf("{not json") }
end
def test_an_export_of_nobody_is_refused
error = assert_raises(Vcf::Invalid) { Monica.vcf(export(aunt)) }
assert_includes error.message, "no contacts"
end
end