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
require_relative "../../test_helper"
require "hegel"
require "pro_tacts/contact"
require "pro_tacts/import/match"
# The contacts an arriving card might be
# (docs/plans/2026-09-23-merging-on-import.md).
class ImportMatchTest < Minitest::Test
include Hegel::Syntax::Methods
Match = ProTacts::Import::Match
def contact(id, name, *lines)
card = ["BEGIN:VCARD", "VERSION:3.0", "FN:#{name}", *lines, "UID:#{id}", "END:VCARD", ""].join("\r\n")
ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(card), birthday: nil, inherited: [])
end
def test_the_distance_is_levenshteins
assert_equal 0, Match.distance("booles", "booles")
assert_equal 3, Match.distance("kitten", "sitting")
assert_equal 4, Match.distance("", "jane")
assert_equal 1, Match.distance("jon", "john")
end
def test_a_name_spelled_a_little_differently_is_offered
arriving = contact("0", "Jon Smith")
john = contact("john", "John Smith")
assert_equal [john], Match.candidates(arriving, [john, contact("sam", "Sam Booles")])
end
# The words of a name, not the order an export wrote them in.
def test_a_name_written_family_first_is_the_same_name
arriving = contact("0", "Booles, Jane")
jane = contact("jane", "Jane Booles")
assert_equal [jane], Match.candidates(arriving, [jane])
end
def test_an_email_address_is_enough_and_its_case_is_not_part_of_it
arriving = contact("0", "J. B.", "EMAIL:Jane@Example.com")
jane = contact("jane", "Jane Booles", "EMAIL;type=INTERNET:jane@example.com")
assert_equal [jane], Match.candidates(arriving, [jane])
end
# Two addresses are alike by the part before the @, and only at one
# domain: another domain is another mailbox, and a shared one says
# nothing about the people at it.
def test_an_address_is_alike_by_its_mailbox_at_the_same_domain
jane = contact("jane", "Jane Booles", "EMAIL:jane.booles@example.com")
ada = contact("ada", "Ada Lovelace", "EMAIL:ada@example.com")
assert_equal [jane], Match.candidates(contact("0", "J", "EMAIL:jane.boole@example.com"), [jane, ada])
assert_empty Match.candidates(contact("0", "J", "EMAIL:jane.booles@elsewhere.org"), [jane])
assert_empty Match.candidates(contact("0", "M", "EMAIL:mary@example.com"), [ada])
end
# A country code on one side and not the other is still one number.
def test_a_phone_number_is_enough_however_it_is_written
arriving = contact("0", "Mom", "TEL:+1 (555) 010-0200")
mother = contact("mother", "Ruth Booles", "TEL;type=CELL:555-010-0200")
assert_equal [mother], Match.candidates(arriving, [mother])
end
# A digit mistyped is one edit in ten.
def test_a_phone_number_a_digit_off_is_offered
arriving = contact("0", "Mom", "TEL:+44 7700 900461")
george = contact("george", "George Boole", "TEL:+44 7700 900467")
assert_equal [george], Match.candidates(arriving, [george, contact("mary", "Mary Boole", "TEL:+44 7700 900218")])
end
def test_nothing_alike_is_nothing_offered
arriving = contact("0", "Jane Booles", "TEL:555 0100", "EMAIL:jane@example.com")
assert_empty Match.candidates(arriving, [contact("x", "Theo Marsh", "TEL:212 867 5309", "EMAIL:theo@elsewhere.org")])
end
def test_the_closest_come_first_and_only_a_few_of_them
arriving = contact("0", "Jane Booles")
exact = contact("exact", "Jane Booles")
near = contact("near", "Jane Boole")
others = %w[Jane\ Bools Jane\ Bowles Jane\ Booler].each_with_index.map { |name, i| contact("o#{i}", name) }
offered = Match.candidates(arriving, [near, *others, exact])
assert_equal Match::LIMIT, offered.length
assert_equal "exact", offered.first.id
end
# One field is enough to be offered, and agreeing on more ranks
# higher: an address two contacts share puts the one with the name
# first.
def test_the_contact_agreeing_on_more_comes_first
arriving = contact("0", "Ada Lovelace", "EMAIL:ada@example.com")
shared = contact("emoji", "Emoji Contact", "EMAIL:ada@example.com")
ada = contact("ada", "Ada Lovelace", "EMAIL:ada@example.com")
assert_equal %w[ada emoji], Match.candidates(arriving, [shared, ada]).map(&:id)
end
def test_a_card_with_nothing_to_compare_matches_nothing
arriving = ProTacts::Contact.new(
id: "0", stored: ProTacts::VCard.new("BEGIN:VCARD\r\nVERSION:3.0\r\nEND:VCARD\r\n"), birthday: nil, inherited: [],
)
assert_empty Match.candidates(arriving, [contact("jane", "Jane Booles")])
end
## Property tests
# Strings over a two-letter alphabet share most of their characters,
# so the distances between them are small and varied; strings from
# all of Unicode are mostly as far apart as they are long.
def word = one_of(from_regex("[ab]{0,8}", fullmatch: true), text(max_size: 8))
# Levenshtein's distance is a metric, and the match ranks by it as
# one: the same in both directions ...
def test_the_distance_is_symmetric
Hegel.test(test_cases: 1_000) do |tc|
a, b = tc.draw(word), tc.draw(word)
assert_equal Match.distance(a, b), Match.distance(b, a)
end
end
# ... never shorter than a detour through a third string ...
def test_the_distance_obeys_the_triangle_inequality
Hegel.test(test_cases: 1_000) do |tc|
a, b, c = tc.draw(word), tc.draw(word), tc.draw(word)
assert_operator Match.distance(a, c), :<=, Match.distance(a, b) + Match.distance(b, c)
end
end
# ... and bounded by the strings themselves: at least the difference
# in their lengths, at most the longer one's length, and zero only
# between equals — which is what keeps a likeness between 0 and 1.
def test_the_distance_is_bounded_by_the_lengths
Hegel.test(test_cases: 1_000) do |tc|
a, b = tc.draw(word), tc.draw(word)
distance = Match.distance(a, b)
assert_includes (a.length - b.length).abs..[a.length, b.length].max, distance
assert_equal a == b, distance.zero?
end
end
# A number's punctuation and its country code are no part of it:
# whatever surrounds a number's digits, it compares as those digits'
# last ten.
def test_a_phone_compares_as_its_last_ten_digits_whatever_surrounds_them
Hegel.test(test_cases: 1_000) do |tc|
digits = tc.draw(from_regex("[0-9]{0,14}", fullmatch: true))
spelled = digits.chars.map { tc.draw(from_regex("[^0-9]{0,2}", fullmatch: true)) + it }.join
assert_equal digits.chars.last(Match::PHONE_DIGITS).join, Match.phone(spelled + tc.draw(from_regex("[^0-9]{0,3}", fullmatch: true)))
end
end
# Emails as a card might carry them: real addresses, ones sharing a
# local part or a domain, and the malformed — no @, nothing before it.
def email = one_of(emails, sampled_from(["ada@example.com", "ADA@example.com", "@example.com", "ada", ""]), text(max_size: 8, min_codepoint: 1))
def phone = one_of(sampled_from(["+1 555 0100", "(555) 0100", "n/a", ""]), text(max_size: 8, min_codepoint: 1))
# A likeness is a share, between 0 for nothing alike and 1 for a value
# both share, whatever the cards hold — and so the ranking over them
# always has numbers to sort.
def test_every_likeness_is_a_share
Hegel.test(test_cases: 1_000) do |tc|
lines = ->(name, values) { values.map { "#{name}:#{ProTacts::VCard.escape(it)}" } }
arriving = contact("ada", tc.draw(text(max_size: 8, min_codepoint: 1)).gsub(/[\r\n]/, ""), *lines.("EMAIL", tc.draw(arrays(email, max_size: 2))), *lines.("TEL", tc.draw(arrays(phone, max_size: 2))))
stored = contact("bob", tc.draw(text(max_size: 8, min_codepoint: 1)).gsub(/[\r\n]/, ""), *lines.("EMAIL", tc.draw(arrays(email, max_size: 2))), *lines.("TEL", tc.draw(arrays(phone, max_size: 2))))
assert Match.likeness(arriving, stored).all? { (0.0..1.0).cover?(it) }, Match.likeness(arriving, stored).inspect
end
end
end