contact, import: a group name compares without case
`item1.TEL` beside `ITEM1.X-ABLabel` read no label on the contact and
lost the label on import, while EditedContact#label_of and Merge
already paired them without case. Contact#labels_by_group and
Vcf.orphaned_groups now key a group by its lowercased name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit 90dd3ebcd512cade490af1d49ce999f755a80354
author Claude <noreply@anthropic.com>
date
parent e2bbb368
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 0b99194..e1c7762 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -368,7 +368,7 @@ module ProTacts
         # by default, so it names none.
         Phone.new(
           value:,
-          label: group && labels[group],
+          label: group && labels[group.downcase],
           types: property.types - %w[voice],
           line:,
           lent_by:,
@@ -489,7 +489,9 @@ module ProTacts
     # describes. Read off the composed card: a group lends only bare
     # `ADR` and `NOTE` (db/migrations/004_groups.rb), so an inherited
     # line can never collide with a member's own `item` numbering
-    # (docs/plans/2026-09-18-phone-labels.md, "Reading").
+    # (docs/plans/2026-09-18-phone-labels.md, "Reading"). Keyed
+    # lowercased, a group name comparing without case as a property
+    # name does and as EditedContact#label_of pairs them.
     #: () -> Hash[String, String]
     def labels_by_group
       properties
@@ -500,7 +502,7 @@ module ProTacts
           text = text_of(property)
           next if text.nil?
 
-          [group, text] #: [String, String]
+          [group.downcase, text] #: [String, String]
         }
         .to_h
     end
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index e524c71..cc98d8a 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -174,7 +174,7 @@ module ProTacts
         orphaned = orphaned_groups(lines)
         kept, dropped = lines.partition { |line|
           property = line.property
-          property.nil? || (known?(property.name) && !orphaned.include?(property.group))
+          property.nil? || (known?(property.name) && !orphaned.include?(property.group&.downcase))
         }
         Reading.new(card: VCard.of(kept), dropped:)
       end
@@ -199,7 +199,11 @@ module ProTacts
           # A label is in its group without anchoring it, so a group
           # holding nothing else is one with nothing but a label in it.
           anchors = !property.name.casecmp?(LABEL) && known?(property.name)
-          anchored[group] = anchored.fetch(group, false) || anchors
+          # Without case, as a property name compares and as
+          # EditedContact#label_of and Merge pair a label with its
+          # line: `ITEM1.X-ABLabel` names `item1.TEL`'s group.
+          key = group.downcase
+          anchored[key] = anchored.fetch(key, false) || anchors
         end
         anchored.reject { |_group, kept| kept }.keys
       end
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index de25279..ca1c1f9 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -303,8 +303,13 @@ class ImportVcfTest < Minitest::Test
     "item4.X-ABLabel:_$!<Home>!$_", "X-SOCIALPROFILE:x", "PRODID:-//Apple", "CATEGORIES:Family",
   ].freeze #: Array[String]
 
+  # Each line's group spelt in either case, as a client may write it
+  # on one line and not the next: a group name compares without case.
   def card_text
-    arrays(sampled_from(LINES), max_size: 8).map { |lines|
+    line = tuples(sampled_from(LINES), sampled_from(%i[itself upcase capitalize])).map { |text, spelling|
+      text.sub(/\A\w+\./) { it.public_send(spelling) }
+    }
+    arrays(line, max_size: 8).map { |lines|
       ["BEGIN:VCARD", "VERSION:3.0", *lines, "END:VCARD"].map { "#{it}\r\n" }.join
     }
   end
@@ -335,9 +340,9 @@ class ImportVcfTest < Minitest::Test
 
   # The groups a reading's card keeps a label in, and the groups it
   # keeps anything else in.
-  def labeled_groups(card) = card.properties.select { it.name.casecmp?(Vcf::LABEL) }.map(&:group).uniq
+  def labeled_groups(card) = card.properties.select { it.name.casecmp?(Vcf::LABEL) }.map { it.group&.downcase }.uniq
 
-  def anchored_groups(card) = card.properties.reject { it.name.casecmp?(Vcf::LABEL) }.filter_map(&:group).uniq
+  def anchored_groups(card) = card.properties.reject { it.name.casecmp?(Vcf::LABEL) }.filter_map { it.group&.downcase }.uniq
 
   # A label names the line beside it, so none comes in without one.
   def test_no_label_comes_in_without_a_line_to_name
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 84368ff..3ef1a7c 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -168,6 +168,17 @@ class ContactTest < Minitest::Test
     assert_equal %w[work], phones.fetch(1).types
   end
 
+  # A group name compares without case, so a label spelt `ITEM1.` names
+  # `item1.TEL`, as the editor and the import pair them.
+  def test_a_phones_label_reads_whatever_case_its_group_is_spelt_in
+    labeled = STRUCTURED.sub(
+      "TEL;TYPE=work:+1-555-0199\r\n",
+      "item1.TEL;TYPE=work:+1-555-0199\r\nITEM1.X-ABLabel:Google Voice\r\n",
+    )
+
+    assert_equal "Google Voice", contact(labeled).phones.fetch(1).label
+  end
+
   def test_a_phone_in_a_group_with_no_labeling_line_reads_no_label
     grouped = STRUCTURED.sub("TEL;TYPE=work:", "item2.TEL;TYPE=work:")