vcard, match: read \N as a line break, and an empty likeness as alike
Two more the adversarial pass found, each now a property. RFC 2426
section 4's ESCAPED-CHAR spells the line-break escape "\n" or "\N",
and unescape read only the first, so a "\N" came back as a backslash
and an N, which a re-escape then doubled. And Match's likeness of two
empty values was 0/0: two cards sharing an email with nothing before
its @ (or no @ at all) made a NaN that sorting the candidates raised
on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit b91ec8afab018b6abae232e4fc98fed5731dd97b
author Claude <noreply@anthropic.com>
date
parent 81e498ca
diff --git a/lib/pro_tacts/import/match.rb b/lib/pro_tacts/import/match.rb
index 0c9b89d..21d8fc0 100644
--- a/lib/pro_tacts/import/match.rb
+++ b/lib/pro_tacts/import/match.rb
@@ -82,8 +82,13 @@ module ProTacts
         ours.product(theirs).map { |a, b| likeness_of(a, b) }.max || 0.0
       end
 
+      # A value they share is wholly alike, the empty one included: two
+      # addresses with nothing before the @ at one domain are one
+      # address, not a 0/0.
       #: (String a, String b) -> Float
       def self.likeness_of(a, b)
+        return 1.0 if a == b
+
         1.0 - distance(a, b).fdiv([a.length, b.length].max.to_i)
       end
 
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index a02aaa8..d8df766 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -41,13 +41,14 @@ module ProTacts
     end
 
     # escape's inverse: unescapes backslash, the component separators, and
-    # the `\n` line-break escape (RFC 2426 section 2.4.2). Nothing that
+    # the line-break escape in either case, `\n` or `\N` (RFC 2426
+    # section 4's ESCAPED-CHAR; escape writes the first). Nothing that
     # serves a card needs this — a served card is the stored bytes going
     # out unparsed — but a reader of values does (Parser::Property#text
     # and #components).
     #: (String text) -> String
     def self.unescape(text)
-      text.gsub(/\\[\\;,n]/) { it == "\\n" ? "\n" : it[1..].to_s }
+      text.gsub(/\\[\\;,nN]/) { it.casecmp?("\\n") ? "\n" : it[1..].to_s }
     end
 
     # Takes Apple's own label wrapping back off: the vocabulary Contacts
diff --git a/test/pro_tacts/import/test_match.rb b/test/pro_tacts/import/test_match.rb
index 2d0dd04..1860532 100644
--- a/test/pro_tacts/import/test_match.rb
+++ b/test/pro_tacts/import/test_match.rb
@@ -161,4 +161,23 @@ class ImportMatchTest < Minitest::Test
       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))
+
+  def phone = one_of(sampled_from(["+1 555 0100", "(555) 0100", "n/a", ""]), text(max_size: 8))
+
+  # 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)).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)).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
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 28cb73d..256263e 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -103,6 +103,18 @@ class VCardTest < Minitest::Test
     end
   end
 
+  # RFC 2426 section 4 spells the line-break escape two ways, "\n" and
+  # "\N", and a value read back is the text written whichever spelling
+  # each line break arrived in.
+  def test_either_spelling_of_the_line_break_escape_reads_as_a_line_break
+    Hegel.test(test_cases: 1_000) do |tc|
+      value = tc.draw(escapey_text).gsub(/\r\n|\r/, "\n")
+      escaped = value.chars.map { it == "\n" ? tc.draw(sampled_from(["\\n", "\\N"])) : ProTacts::VCard.escape(it) }.join
+
+      assert_equal value, ProTacts::VCard.unescape(escaped)
+    end
+  end
+
   # The byte-stability a splice relies on: any value, escapes the
   # writer never makes included, joins back from its raw components as
   # the bytes it was.