import merge: two countries' numbers, or two texts, are two phones
Merge#held? keyed a number by its last ten digits alone, so an
arriving `+1 207 946 0958` was dropped beside `+44 20 7946 0958`, and
any two values without digits (`ask mom`, `n/a`) keyed alike as "".
A held line goes to neither the contact nor the struck side, so each
was lost without a trace.
The match's ten-digit comparison stands, a country code or trunk zero
on one side only still being one number, but two country codes that
differ now make two numbers, and a value with no digits compares as
text.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
diff --git a/lib/pro_tacts/import/merge.rb b/lib/pro_tacts/import/merge.rb
index 2629e4d..31a9e05 100644
--- a/lib/pro_tacts/import/merge.rb
+++ b/lib/pro_tacts/import/merge.rb
@@ -114,22 +114,44 @@ module ProTacts
# an address a group lends it is an address it has.
#: (VCard::Parser::Property property) -> bool
def held?(property)
+ if property.name.casecmp?("TEL")
+ return @into.properties.any? { it.name.casecmp?("TEL") && same_number?(it.text, property.text) }
+ end
+
key = key(property)
@into.properties.any? { it.name.casecmp?(property.name) && key(it) == key }
end
- # A value as two spellings of it compare: a number by its digits
- # (Match.phone), an address by its components, the rest without
- # case or the space around it.
+ # A value as two spellings of it compare: an address by its
+ # components, the rest without case or the space around it.
#: (VCard::Parser::Property property) -> String
def key(property)
case property.name.upcase
- when "TEL" then Match.phone(property.text)
when "ADR" then property.components.map { it.strip.downcase }.join(";")
else property.text.strip.downcase
end
end
+ # Two spellings of one number, the match's comparison
+ # (Match.phone): the same last ten digits, a country code or a
+ # trunk prefix on one side and not the other being one number
+ # (docs/plans/2026-09-23-merging-on-import.md, "Finding them").
+ # What the match can only score, the fold must decide — a number
+ # it calls held is one it drops — so two country codes that
+ # differ are two numbers, `+44 20 7946 0958` and `+1 207 946
+ # 0958` both kept; the leading zeros off a prefix are a trunk,
+ # which names no country. A value with no digits at all is no
+ # number, and compares as text.
+ #: (String ours, String theirs) -> bool
+ def same_number?(ours, theirs)
+ digits = [ours, theirs].map { it.delete("^0-9") }
+ return ours.strip.casecmp?(theirs.strip) if digits.any?(&:empty?)
+ return false unless Match.phone(ours) == Match.phone(theirs)
+
+ prefixes = digits.map { it[0...-Match::PHONE_DIGITS].to_s.sub(/\A0+/, "") }
+ prefixes.any?(&:empty?) || prefixes.uniq.length == 1
+ end
+
# A line's bytes as they join the contact's card, with the label
# hung off it. Apple numbers a card's `item1.` groups per card,
# so an arriving one can name a group the contact's card already
diff --git a/test/pro_tacts/import/test_merge.rb b/test/pro_tacts/import/test_merge.rb
index f1dc169..8039469 100644
--- a/test/pro_tacts/import/test_merge.rb
+++ b/test/pro_tacts/import/test_merge.rb
@@ -1,11 +1,15 @@
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")
@@ -130,4 +134,52 @@ class ImportMergeTest < Minitest::Test
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