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