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
change
commit f45b8c3acc7311555e6a64c4ce3781698f9446ee
author Claude <noreply@anthropic.com>
date
parent 7bf61b62
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