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
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
require_relative "../../test_helper"

require "hegel"

require "pro_tacts/import/vcf"

# Reading an uploaded .vcf: the split into cards, and the survey of
# what this address book does not show
# (docs/plans/2026-09-21-import-a-vcf.md).
class ImportVcfTest < Minitest::Test
  include Hegel::Syntax::Methods

  Vcf = ProTacts::Import::Vcf

  JANE = <<~CARD.gsub("\n", "\r\n")
    BEGIN:VCARD
    VERSION:3.0
    N:Booles;Jane;;;
    FN:Jane Booles
    TEL;type=CELL;type=VOICE;type=pref:+1 555 0100
    item1.ADR;type=HOME:;;1 Main St;Springfield;;;
    item1.X-ABLabel:_$!<Home>!$_
    item2.X-ABRELATEDNAMES:Sam Booles
    item2.X-ABLabel:_$!<Spouse>!$_
    X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
    UID:ABC-123
    END:VCARD
  CARD

  SAM = <<~CARD.gsub("\n", "\r\n")
    BEGIN:VCARD
    VERSION:3.0
    N:Booles;Sam;;;
    FN:Sam Booles
    X-SOCIALPROFILE;type=facebook:https://facebook.com/sb
    PRODID:-//Apple Inc.//macOS 15.0//EN
    UID:DEF-456
    END:VCARD
  CARD

  def test_a_file_reads_as_the_cards_it_holds_byte_for_byte
    cards = Vcf.cards(JANE + SAM)

    assert_equal [JANE, SAM], cards.map(&:to_s)
  end

  # Contacts writes one card after another with nothing between them,
  # but an export edited by hand or joined with `cat` has blank lines,
  # and they are not a card's business.
  def test_blank_lines_between_cards_are_not_cards
    cards = Vcf.cards("#{JANE}\r\n\r\n#{SAM}")

    assert_equal [JANE, SAM], cards.map(&:to_s)
  end

  def test_a_line_outside_a_card_refuses_the_whole_file
    error = assert_raises(Vcf::Invalid) { Vcf.cards("FN:Stray\r\n#{JANE}") }

    assert_includes error.message, "FN:Stray"
  end

  def test_a_card_with_no_end_refuses_the_whole_file
    error = assert_raises(Vcf::Invalid) { Vcf.cards(JANE.sub("END:VCARD\r\n", "")) }

    assert_includes error.message, "no END:VCARD"
  end

  def test_a_file_with_no_cards_in_it_refuses
    error = assert_raises(Vcf::Invalid) { Vcf.cards("\r\n") }

    assert_includes error.message, "holds no vCards"
  end

  # BEGIN, VERSION and END are what makes a vCard (RFC 2426 section 4),
  # and VCard#card? is what says so — the same judgment a PUT makes.
  def test_something_shaped_like_a_card_without_a_version_refuses
    error = assert_raises(Vcf::Invalid) { Vcf.cards(JANE.sub("VERSION:3.0\r\n", "")) }

    assert_includes error.message, "are not vCards"
  end

  def test_the_survey_names_what_no_screen_here_shows
    unknown = Vcf.unknown(Vcf.cards(JANE + SAM))

    assert_equal %w[X-ABRELATEDNAMES X-SOCIALPROFILE], unknown.map(&:name)
  end

  # The exporter's own name is not a loss to report: every file
  # carries one, and a line in every summary for it would say
  # nothing.
  def test_the_survey_says_nothing_about_the_noise
    refute_includes Vcf.unknown(Vcf.cards(SAM)).map(&:name), "PRODID"
  end

  # It is still not imported, though, and the card as exported still
  # shows it struck: #losses is what a reader is asked to look at,
  # not what comes in.
  def test_the_noise_is_dropped_like_anything_else_unknown
    reading = Vcf.read(Vcf.cards(SAM).fetch(0))

    refute_includes reading.card.to_s, "PRODID"
    assert_includes reading.dropped.map { |line| line.property&.name }, "PRODID"
    refute_includes Vcf.losses(reading.dropped).map { |line| line.property&.name }, "PRODID"
  end

  # The envelope, the fields a screen shows, and the label that names a
  # row are all read here, so none of them is a decision.
  def test_the_survey_asks_nothing_about_a_property_this_book_reads
    names = Vcf.unknown(Vcf.cards(JANE + SAM)).map(&:name)

    refute_includes names, "TEL"
    refute_includes names, "X-ABLABEL"
    refute_includes names, "UID"
  end

  # The three choices read the same whatever parameters a line wears,
  # so a property is one decision however many spellings it arrives in.
  def test_parameters_do_not_make_a_second_decision
    social = Vcf.unknown(Vcf.cards(JANE + SAM)).find { it.name == "X-SOCIALPROFILE" }

    assert_equal 2, social.count
    assert_equal ["X-SOCIALPROFILE;type=twitter:https://twitter.com/jb",
                  "X-SOCIALPROFILE;type=facebook:https://facebook.com/sb"],
                 social.examples
  end

  # A choice is made looking at the book's own data, so the examples
  # are real lines — but no more of them than a list can be read at,
  # and never a picture's payload.
  def test_the_examples_are_a_few_real_lines_and_stop_short_of_a_payload
    cards = Vcf.cards((1..5).map { |n| JANE.sub("jb", "jb#{n}") }.join)

    examples = Vcf.unknown(cards).find { it.name == "X-SOCIALPROFILE" }.examples

    assert_equal Vcf::EXAMPLES, examples.length

    long = JANE.sub("https://twitter.com/jb", "x" * 400)

    example = Vcf.unknown(Vcf.cards(long)).find { it.name == "X-SOCIALPROFILE" }.examples.fetch(0)

    assert_includes example, "characters)"
    assert_operator example.length, :<, 200
  end

  # A line's value arrives folded across physical lines (RFC 2426
  # section 2.6); what it says is one line, and that is what the
  # screen shows.
  def test_an_example_is_unfolded
    folded = JANE.sub("X-SOCIALPROFILE;type=twitter:https://twitter.com/jb\r\n",
                      "X-SOCIALPROFILE;type=twitter:https://twitter.com\r\n /jb\r\n")

    example = Vcf.unknown(Vcf.cards(folded)).find { it.name == "X-SOCIALPROFILE" }.examples.fetch(0)

    assert_equal "X-SOCIALPROFILE;type=twitter:https://twitter.com/jb", example
  end
  # One card as this book will hold it: the lines it reads, in the
  # bytes and the order they arrived.
  def test_reading_a_card_keeps_what_a_screen_here_shows
    revised = Vcf.read(Vcf.cards(JANE).fetch(0)).card.to_s

    assert_includes revised, "N:Booles;Jane;;;\r\n"
    assert_includes revised, "TEL;type=CELL;type=VOICE;type=pref:+1 555 0100\r\n"
    assert_includes revised, "item1.ADR;type=HOME:;;1 Main St;Springfield;;;\r\n"
    assert_includes revised, "UID:ABC-123\r\n"
    # Still a card: the envelope is read like anything else.
    assert Vcf.cards(revised).fetch(0).card?
  end

  # A Signal handle, and any other IMPP, is a row a screen here shows
  # (docs/plans/2026-10-18-signal-handles.md).
  def test_reading_a_card_keeps_its_handles
    handle = JANE.sub("UID:", "IMPP;X-SERVICE-TYPE=signal:x-apple:jb.01\r\nUID:")
    read = Vcf.read(Vcf.cards(handle).fetch(0))

    assert_includes read.card.to_s, "IMPP;X-SERVICE-TYPE=signal:x-apple:jb.01\r\n"
    refute(read.dropped.any? { it.verbatim.start_with?("IMPP") })
  end

  # A company card is read by its flag and its ORG
  # (docs/plans/2026-09-24-company-cards.md), so both come in and
  # neither is a decision; without them it would arrive as a person.
  def test_reading_a_company_card_keeps_what_makes_it_one
    company = <<~CARD.gsub("\n", "\r\n")
      BEGIN:VCARD
      VERSION:3.0
      N:;;;;
      FN:Bletchley Park
      ORG:Bletchley Park Trust;
      X-ABShowAs:COMPANY
      UID:GHI-789
      END:VCARD
    CARD
    cards = Vcf.cards(company)

    reading = Vcf.read(cards.fetch(0))

    assert_equal company, reading.card.to_s
    assert_empty reading.dropped
    assert_empty Vcf.unknown(cards)
  end

  def test_reading_a_card_leaves_behind_what_no_screen_shows
    reading = Vcf.read(Vcf.cards(JANE).fetch(0))

    refute_includes reading.card.to_s, "X-SOCIALPROFILE"
    refute_includes reading.card.to_s, "X-ABRELATEDNAMES"
    assert_includes reading.dropped.map { Vcf.summary(it) },
                    "X-SOCIALPROFILE;type=twitter:https://twitter.com/jb"
  end

  # Apple hangs a row's label off the line it names rather than
  # inside it, so a label whose line is leaving has nothing left to
  # name — while the label on a line that is coming in comes with it.
  def test_a_label_goes_wherever_the_line_it_names_goes
    reading = Vcf.read(Vcf.cards(JANE).fetch(0))

    assert_includes reading.card.to_s, "item1.X-ABLabel:_$!<Home>!$_\r\n"
    refute_includes reading.card.to_s, "_$!<Spouse>!$_"
    assert_includes reading.dropped.map { Vcf.summary(it) }, "item2.X-ABLabel:_$!<Spouse>!$_"
  end

  # A label alone in its group names nothing at all, so it stays
  # behind like one whose line is leaving: the case the property below
  # shrank to.
  def test_a_label_with_no_line_in_its_group_stays_behind
    reading = Vcf.read(Vcf.cards(JANE.sub("END:VCARD", "item9.X-ABLabel:orphan\r\nEND:VCARD")).fetch(0))

    refute_includes reading.card.to_s, "orphan"
  end

  # There is no property name to have shown on a screen and nothing
  # anyone could have decided about it, so it rides along rather than
  # being thrown away unnamed.
  def test_a_line_that_will_not_read_is_kept
    broken = JANE.sub("FN:Jane Booles\r\n", "FN:Jane Booles\r\nnot a content line\r\n")

    reading = Vcf.read(Vcf.cards(broken).fetch(0))

    assert_includes reading.card.to_s, "not a content line\r\n"
    assert_empty reading.dropped.select { it.property.nil? }
  end

  # A company card comes in whole (docs/plans/2026-09-24-company-cards.md):
  # its editor reads the name out of ORG, and the flag says how to show it.
  def test_a_company_comes_in_with_its_organization_and_its_flag
    card = Vcf.cards(<<~CARD.gsub("\n", "\r\n")).fetch(0)
      BEGIN:VCARD
      VERSION:3.0
      N:;;;;
      FN:Acme
      ORG:Acme;
      X-ABShowAs:COMPANY
      END:VCARD
    CARD
    reading = Vcf.read(card)

    assert_includes reading.card.to_s, "ORG:Acme;\r\n"
    assert_includes reading.card.to_s, "X-ABShowAs:COMPANY\r\n"
    assert_empty reading.dropped
  end

  # Where a card came from and when it last changed there, which Monica
  # writes on every card: the export's facts, dropped without remark.
  def test_source_and_rev_are_noise
    card = Vcf.cards(<<~CARD.gsub("\n", "\r\n")).fetch(0)
      BEGIN:VCARD
      VERSION:4.0
      FN:Jane Booles
      SOURCE:https://monica.example.com/people/h:abc
      REV:2026-09-01T00:00:00Z
      END:VCARD
    CARD
    reading = Vcf.read(card)

    assert_equal %w[SOURCE REV], reading.dropped.map { it.property&.name }
    assert_empty Vcf.losses(reading.dropped)
  end

  # Tags come in as groups, so the line that carried them is neither a
  # property to survey nor a loss to count.
  def test_categories_are_read_as_group_names_and_counted_as_no_loss
    card = Vcf.cards(<<~CARD.gsub("\n", "\r\n")).fetch(0)
      BEGIN:VCARD
      VERSION:3.0
      FN:Jane Booles
      CATEGORIES:school,book club,school
      CATEGORIES:Smith\\, Jones
      END:VCARD
    CARD

    assert_equal ["school", "book club", "Smith, Jones"], Vcf.categories(card)
    assert_empty Vcf.losses(Vcf.read(card).dropped)
    assert_empty Vcf.unknown([card])
  end

  # A tag ending in a backslash escapes it, and the comma after that
  # escaped backslash still separates.
  def test_a_tag_ending_in_a_backslash_ends_at_the_comma
    card = Vcf.cards("BEGIN:VCARD\r\nVERSION:3.0\r\nCATEGORIES:C:\\\\,school\r\nEND:VCARD\r\n").fetch(0)

    assert_equal ["C:\\", "school"], Vcf.categories(card)
  end

  ## Property tests

  # Lines from an Apple export's vocabulary: rows this book reads and
  # rows it does not, labels hung off both by group, a label whose
  # group holds nothing else, and the noise and tags read apart.
  LINES = [
    "N:Booles;Jane;;;", "FN:Jane Booles", "TEL:+1 555 0100", "item1.TEL:+1 555 0101",
    "item1.X-ABLabel:_$!<Mobile>!$_", "item2.X-ABRELATEDNAMES:Sam", "item2.X-ABLabel:_$!<Spouse>!$_",
    "item3.X-ABLabel:orphan", "item4.ADR:;;1 Main St;;;;", "item4.X-ABRELATEDNAMES:Pat",
    "item4.X-ABLabel:_$!<Home>!$_", "X-SOCIALPROFILE:x", "PRODID:-//Apple", "CATEGORIES:Family",
    "item5.TEL;=:+1 555 0102", "item5.X-ABLabel:unreadable",
  ].freeze #: Array[String]

  # Each line's group spelt in either case, as a client may write it
  # on one line and not the next: a group name compares without case.
  def card_text
    line = tuples(sampled_from(LINES), sampled_from(%i[itself upcase capitalize])).map { |text, spelling|
      text.sub(/\A\w+\./) { it.public_send(spelling) }
    }
    arrays(line, max_size: 8).map { |lines|
      ["BEGIN:VCARD", "VERSION:3.0", *lines, "END:VCARD"].map { "#{it}\r\n" }.join
    }
  end

  # A file is the cards it holds, one after another, maybe with blank
  # lines between them, and reads as those cards byte for byte.
  def test_a_file_reads_as_the_cards_it_was_made_of
    Hegel.test(test_cases: 1_000) do |tc|
      cards = tc.draw(arrays(card_text, min_size: 1, max_size: 4))
      file = cards.map { it + tc.draw(sampled_from(["", "\r\n", "\n", "\r\n\r\n"])) }.join

      assert_equal cards, Vcf.cards(file).map(&:to_s)
    end
  end

  # A line anywhere outside a card, between two or after the last,
  # refuses the whole file rather than bringing in the cards around it.
  def test_a_line_outside_a_card_refuses_the_file
    Hegel.test(test_cases: 1_000) do |tc|
      cards = tc.draw(arrays(card_text, min_size: 1, max_size: 4))
      stray = tc.draw(one_of(sampled_from(LINES), from_regex("[A-Za-z][^\r\n]{0,10}", fullmatch: true)))
      tc.assume(!stray.match?(/\ABEGIN/i))
      at = tc.draw(integers(min_value: 1, max_value: cards.length))

      assert_raises(Vcf::Invalid) { Vcf.cards(cards.insert(at, "#{stray}\r\n").join) }
    end
  end

  # A BEGIN inside a card, anywhere between its BEGIN and END, refuses
  # the file: that card never ended, and reading on would fold it into
  # the next.
  def test_a_card_begun_inside_another_refuses_the_file
    Hegel.test(test_cases: 1_000) do |tc|
      cards = tc.draw(arrays(card_text, min_size: 1, max_size: 3))
      which = tc.draw(integers(min_value: 0, max_value: cards.length - 1))
      lines = cards.fetch(which).lines
      at = tc.draw(integers(min_value: 1, max_value: lines.length - 1))
      cards[which] = lines.insert(at, tc.draw(sampled_from(["BEGIN:VCARD\r\n", "begin:vcard\r\n"]))).join

      assert_raises(Vcf::Invalid) { Vcf.cards(cards.join) }
    end
  end

  # The groups a reading's card keeps a label in, and the groups it
  # keeps anything else in — a line that will not read included, its
  # group read off its bytes.
  def group_of(line) = (line.property&.group || line.verbatim[/\A([A-Za-z0-9-]+)\./, 1])&.downcase

  def label?(line) = line.property&.name&.casecmp?(Vcf::LABEL)

  def labeled_groups(card) = card.lines.select { label?(it) }.map { group_of(it) }.uniq

  def anchored_groups(card) = card.lines.reject { label?(it) }.filter_map { group_of(it) }.uniq

  # A label names the line beside it, so none comes in without one.
  def test_no_label_comes_in_without_a_line_to_name
    Hegel.test(test_cases: 1_000) do |tc|
      card = Vcf.read(Vcf.cards(tc.draw(card_text)).fetch(0)).card

      assert_empty labeled_groups(card) - anchored_groups(card)
    end
  end

  # And the other half: a row coming in brings its label.
  def test_a_row_coming_in_brings_its_label
    Hegel.test(test_cases: 1_000) do |tc|
      original = Vcf.cards(tc.draw(card_text)).fetch(0)
      card = Vcf.read(original).card

      assert_equal labeled_groups(original) & anchored_groups(card), labeled_groups(card)
    end
  end

  # Tags written into a CATEGORIES line, each escaped as RFC 2426
  # section 2.4.2 has a text value escaped, read back as the tags they
  # were — blank ones and repeats aside, which name no group.
  def test_categories_read_back_as_the_tags_written
    tag = one_of(text(max_size: 10, min_codepoint: 1), arrays(sampled_from([",", "\\", ";", " ", "a"]), max_size: 5).map(&:join))
    Hegel.test(test_cases: 1_000) do |tc|
      tags = tc.draw(arrays(tag, min_size: 1, max_size: 5))
      line = "CATEGORIES:#{tags.map { ProTacts::VCard.escape(it) }.join(",")}"
      card = Vcf.cards("BEGIN:VCARD\r\nVERSION:3.0\r\n#{line}\r\nEND:VCARD\r\n").fetch(0)

      assert_equal tags.map { it.gsub(/\r\n|\r/, "\n").strip }.reject(&:empty?).uniq, Vcf.categories(card)
    end
  end
end