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
require_relative "../../test_helper"

require "hegel"

require "pro_tacts/contact"
require "pro_tacts/import/merge"

# An arriving card folded into a contact the book already has
# (docs/plans/2026-09-23-merging-on-import.md).
class ImportMergeTest < Minitest::Test
  include Hegel::Syntax::Methods

  Merge = ProTacts::Import::Merge

  STORED = <<~CARD.gsub("\n", "\r\n")
    BEGIN:VCARD
    VERSION:3.0
    N:Booles;Jane;;;
    FN:Jane Booles
    TEL;type=CELL:555-010-0100
    item1.EMAIL;type=INTERNET:jane@example.com
    item1.X-ABLabel:_$!<Home>!$_
    NOTE:Met at work
    UID:jane
    END:VCARD
  CARD

  ARRIVING = <<~CARD.gsub("\n", "\r\n")
    BEGIN:VCARD
    VERSION:3.0
    N:Booles;Janet;;;
    FN:Janet Booles
    TEL;type=CELL:+1 (555) 010-0100
    TEL;type=WORK:555 010 0199
    item1.EMAIL:jane@work.example
    item1.X-ABLabel:_$!<Work>!$_
    NICKNAME:JB
    NOTE:From the school list
    BDAY:1985-04-12
    UID:ABC-123
    END:VCARD
  CARD

  def jane(card = STORED, birthday: nil)
    ProTacts::Contact.new(id: "jane", stored: ProTacts::VCard.new(card), birthday:, inherited: [])
  end

  def merge(into = jane, arriving = ARRIVING) = Merge.new(into, ProTacts::VCard.new(arriving))

  # The contact's own card is the base, byte for byte.
  def test_the_contact_keeps_every_line_it_had
    folded = merge.contact.stored.to_s

    STORED.each_line.reject { it.start_with?("END:") }.each do |line|
      assert_includes folded, line
    end
    assert_equal "jane", merge.contact.id
    refute_includes folded, "ABC-123"
  end

  # A number already on the contact, written another way, stays one
  # number; a new one comes in beside it.
  def test_a_number_it_lacks_comes_in_and_one_it_has_does_not
    phones = merge.contact.phones.map(&:value)

    assert_equal ["555-010-0100", "555 010 0199"], phones
  end

  # A handle is one of many, like an email: a new one comes in, and
  # one the contact has does not come in twice.
  def test_a_handle_it_lacks_comes_in_and_one_it_has_does_not
    signal = "IMPP;X-SERVICE-TYPE=signal:x-apple:jb.01\r\n"
    into = jane(STORED.sub("UID:", "#{signal}UID:"))
    arriving = ARRIVING.sub("UID:", "#{signal}IMPP;X-SERVICE-TYPE=signal:x-apple:janet.02\r\nUID:")

    assert_equal ["jb.01", "janet.02"], merge(into, arriving).contact.handles.map(&:value)
  end

  # Apple numbers `item` groups per card, so the arriving email's
  # item1 would collide with the contact's own: it is renumbered, and
  # its label goes with it.
  def test_a_grouped_line_is_renumbered_with_its_label
    folded = merge.contact.stored.to_s

    assert_includes folded, "item2.EMAIL:jane@work.example\r\nitem2.X-ABLabel:_$!<Work>!$_\r\n"
    assert_includes folded, "item1.X-ABLabel:_$!<Home>!$_\r\n"
  end

  # What a contact holds one of fills a gap and does not overwrite.
  def test_the_books_own_name_and_note_stand_and_a_missing_nickname_comes_in
    folded = merge.contact

    assert_equal "Jane Booles", folded.name
    assert_equal ["Met at work"], folded.notes.map(&:value)
    assert_equal "JB", folded.nickname
  end

  # A contact holds one note, so several arriving fill the gap as one
  # value — a blank line between, the shape the write path joins
  # foreign input into and the sections read beside
  # (docs/plans/2026-09-25-one-note-per-contact.md).
  def test_several_arriving_notes_join_into_the_one_gap
    bare = <<~CARD.gsub("\n", "\r\n")
      BEGIN:VCARD
      VERSION:3.0
      N:Booles;Janet;;;
      FN:Janet Booles
      NOTE:From the school list
      NOTE:Chair of the residents' association
      UID:ABC-123
      END:VCARD
    CARD
    folded = merge(jane(STORED.sub("NOTE:Met at work\r\n", "")), bare).contact

    assert_equal ["From the school list\n\nChair of the residents' association"], folded.notes.map(&:value)
    assert_includes folded.stored.to_s, "NOTE:From the school list\\n\\nChair of the residents' association\r\n"
  end

  # A birthday the contact has none of goes into the model, the split
  # a stored contact is held in.
  def test_a_birthday_the_contact_lacks_comes_into_the_model
    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), merge.contact.birthday
    refute_includes merge.contact.stored.to_s, "BDAY"
  end

  def test_the_books_own_birthday_stands
    ruth = ProTacts::Birthday.new(month: 4, day: 12)

    assert_equal ruth, merge(jane(birthday: ruth)).contact.birthday
  end

  # What the fold leaves behind is what the card as exported strikes:
  # the name and the note the contact's own stood in for, and nothing
  # the contact already had.
  def test_what_is_left_behind_is_what_differed
    left = merge(jane(birthday: ProTacts::Birthday.new(month: 1, day: 2))).left.map { it.verbatim.chomp }

    assert_equal ["N:Booles;Janet;;;", "FN:Janet Booles", "NOTE:From the school list", "BDAY:1985-04-12"], left
  end

  def test_folding_in_the_same_card_changes_nothing
    same = merge(jane, STORED)

    assert_equal STORED, same.contact.stored.to_s
    assert_empty same.left
  end

  ## Which numbers the contact already has

  def with_tel(value) = STORED.sub("TEL;type=CELL:555-010-0100", "TEL:#{value}")

  def arriving_tel(value) = "BEGIN:VCARD\r\nVERSION:3.0\r\nTEL:#{value}\r\nEND:VCARD\r\n"

  # Whether the arriving number came in beside the contact's own.
  def came_in?(ours, theirs)
    merge(jane(with_tel(ours)), arriving_tel(theirs)).contact.phones.length == 2
  end

  # One number in a spelling a card might carry it in: formatted any
  # way, with its country code, behind a trunk zero, or bare.
  def spelling(tc, country, national)
    prefix = tc.draw(sampled_from(["+#{country} ", "00#{country} ", "0", ""]))
    digits = national.chars.flat_map { [it, tc.draw(sampled_from(["", "", " ", "-"]))] }.join
    "#{prefix}#{digits}"
  end

  # Two spellings of one number are one number — a country code or a
  # trunk zero on one side and not the other included — and two
  # countries' numbers that share their last ten digits are two: the
  # one arriving is held exactly when no country code sets it apart
  # from the contact's (docs/plans/2026-09-23-merging-on-import.md).
  # A number held is a number dropped, so the reject direction is the
  # one a loss turns on.
  def test_a_number_is_held_exactly_when_no_country_sets_it_apart
    Hegel.test(test_cases: 1_000) do |tc|
      national = tc.draw(from_regex("[1-9][0-9]{9}", fullmatch: true))
      ours = spelling(tc, tc.draw(sampled_from(%w[1 44 33])), national)
      theirs = spelling(tc, tc.draw(sampled_from(%w[1 44 33])), national)
      country = ->(spelt) { spelt[/\A(?:\+|00)(\d+) /, 1] }
      apart = !country.(ours).nil? && !country.(theirs).nil? && country.(ours) != country.(theirs)

      assert_equal apart, came_in?(ours, theirs), "#{ours.inspect} beside #{theirs.inspect}"
    end
  end

  # A value with no digits is no number, and holds only its own text:
  # `ask mom` is not `n/a`.
  def test_a_value_without_digits_is_held_only_by_its_own_text
    Hegel.test(test_cases: 1_000) do |tc|
      ours, theirs = Array.new(2) { tc.draw(from_regex("[a-z/ ]{0,6}[a-z]", fullmatch: true)) }

      assert_equal ours.casecmp?(theirs), !came_in?(ours, theirs)
    end
  end
end