Take an EditedContact apart on asking, not on construction
EditedContact.new now only holds the submission and the contact
before it. EditedContact#decomposed does the birthday split and the
lent-line subtraction and answers the three parts, stored card,
birthday and group edits, which Store#put destructures. It is kept
after the first ask, so the arrival reports go to Sentry once.

The tests assert the whole triple, which also pins the parts a test
used to leave unchecked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi55bFkB6wo4jguNtsase4
change
commit 7f1a28ecf7d6ddd94bf9766111158e3a4ff603db
author Claude <noreply@anthropic.com>
date
parent ef78ab21
diff --git a/lib/pro_tacts/edited_contact.rb b/lib/pro_tacts/edited_contact.rb
index 342b0bb..3b06a44 100644
--- a/lib/pro_tacts/edited_contact.rb
+++ b/lib/pro_tacts/edited_contact.rb
@@ -27,9 +27,7 @@ module ProTacts
   class EditedContact
     # @rbs @before: Contact?
     # @rbs @submitted: VCard
-    # @rbs @stored: VCard
-    # @rbs @birthday: Birthday?
-    # @rbs @group_edits: Array[GroupEdit]
+    # @rbs @decomposed: [VCard, Birthday?, Array[GroupEdit]]
 
     # What one classified line asks of the group it came from: the row
     # to write, and the line that replaces it — or nil to remove the
@@ -62,24 +60,26 @@ module ProTacts
     def initialize(submitted, before:)
       @before = before
       @submitted = submitted
-      @birthday, rest = split_birthday
-      @stored, @group_edits = subtract_inherited(rest)
     end
 
     attr_reader :before
 
     attr_reader :submitted
 
-    # The card to store: the submission minus its birthday and minus
-    # what its groups lend it.
-    attr_reader :stored
-
-    # The model's new birthday, or nil to empty the model.
-    attr_reader :birthday
-
-    # The group rows the member's edits rewrite or remove, empty where
-    # every lent line came back untouched.
-    attr_reader :group_edits
+    # The submission taken back apart: the card to store, minus its
+    # birthday and minus what its groups lend it; the model's new
+    # birthday, or nil to empty the model; and the group rows the
+    # member's edits rewrite or remove, empty where every lent line
+    # came back untouched. Worked out on first ask and kept, so the
+    # arrival reports go to Sentry once however often it is read.
+    #: () -> [VCard, Birthday?, Array[GroupEdit]]
+    def decomposed
+      return @decomposed if defined?(@decomposed)
+
+      birthday, rest = split_birthday
+      stored, group_edits = subtract_inherited(rest)
+      @decomposed = [stored, birthday, group_edits]
+    end
 
     private
 
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 3b6eea3..d062457 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -439,7 +439,7 @@ module ProTacts
     #
     # What is stored is the card minus its birthday and minus what its
     # groups lend it, both composed back in on read; EditedContact
-    # reads the submission apart and this writes what it answers.
+    # takes the submission apart and this writes the parts.
     #
     # A card rather than its bytes, so the reading a caller already
     # made is the one EditedContact decides from: a PUT has asked
@@ -461,8 +461,7 @@ module ProTacts
       # it: the edit, which reads the submission against it, and the
       # change log's diff.
       before = contact(id)
-      edited = EditedContact.new(vcard, before:)
-      stored, birthday, edits = edited.stored, edited.birthday, edited.group_edits
+      stored, birthday, edits = EditedContact.new(vcard, before:).decomposed
 
       # The Contact this returns is the composed one — the lent lines
       # put back, read off membership as it stands at this write — and
diff --git a/test/pro_tacts/test_edited_contact.rb b/test/pro_tacts/test_edited_contact.rb
index b9b1182..818dccf 100644
--- a/test/pro_tacts/test_edited_contact.rb
+++ b/test/pro_tacts/test_edited_contact.rb
@@ -40,8 +40,10 @@ class EditedContactTest < Minitest::Test
     ProTacts::Contact.new(id: "aiden", stored: ProTacts::VCard.new(stored), birthday:, inherited:)
   end
 
-  def edit(bytes, before: nil)
-    ProTacts::EditedContact.new(ProTacts::VCard.new(bytes), before:)
+  # The parts with the card as its bytes, so a test compares strings.
+  def decompose(bytes, before: nil)
+    stored, birthday, group_edits = ProTacts::EditedContact.new(ProTacts::VCard.new(bytes), before:).decomposed
+    [stored.to_s, birthday, group_edits]
   end
 
   def group_edit(line) = ProTacts::EditedContact::GroupEdit.new(group_id: "kkkk", position: 3, line:)
@@ -49,116 +51,96 @@ class EditedContactTest < Minitest::Test
   ## The birthday
 
   def test_a_modeled_birthday_leaves_the_card_for_the_model
-    edited = edit(with_lines("BDAY:1985-04-12"))
-
-    assert_equal APRIL_12, edited.birthday
-    assert_equal AIDEN, edited.stored.to_s
+    assert_equal [AIDEN, APRIL_12, []], decompose(with_lines("BDAY:1985-04-12"))
     assert_empty sentry_messages
   end
 
   def test_a_card_without_a_bday_keeps_a_birthday_no_client_was_sent
     month_only = ProTacts::Birthday.new(month: 4)
 
-    assert_equal month_only, edit(AIDEN, before: before(birthday: month_only)).birthday
+    assert_equal [AIDEN, month_only, []], decompose(AIDEN, before: before(birthday: month_only))
   end
 
   def test_a_card_without_a_bday_drops_a_birthday_the_client_was_sent
-    assert_nil edit(AIDEN, before: before(birthday: APRIL_12)).birthday
+    assert_equal [AIDEN, nil, []], decompose(AIDEN, before: before(birthday: APRIL_12))
   end
 
   def test_an_unrecognized_bday_stays_in_the_card_and_is_reported
     submitted = with_lines("BDAY:1985-13")
-    edited = edit(submitted)
 
-    assert_nil edited.birthday
-    assert_equal submitted, edited.stored.to_s
+    assert_equal [submitted, nil, []], decompose(submitted)
     assert_equal ["a submitted card carried a BDAY line the model does not take"], sentry_messages
   end
 
   def test_several_bday_lines_stay_in_the_card_and_are_reported
     submitted = with_lines("BDAY:1985-04-12", "BDAY:1986-05-13")
-    edited = edit(submitted)
 
-    assert_nil edited.birthday
-    assert_equal submitted, edited.stored.to_s
+    assert_equal [submitted, nil, []], decompose(submitted)
     assert_equal ["a submitted card carried 2 BDAY lines"], sentry_messages
   end
 
   def test_a_write_dropping_a_bday_the_model_did_not_take_is_reported
-    edit(AIDEN, before: before(with_lines("BDAY:1985-13")))
+    decompose(AIDEN, before: before(with_lines("BDAY:1985-13")))
 
     assert_equal ["a write dropped 1 BDAY line(s) the model did not take"], sentry_messages
   end
 
+  def test_the_reports_go_once_however_often_it_is_decomposed
+    edited = ProTacts::EditedContact.new(ProTacts::VCard.new(with_lines("BDAY:1985-13")), before: nil)
+    2.times { edited.decomposed }
+
+    assert_equal 1, sentry_messages.length
+  end
+
   ## The lent lines
 
   def test_a_contact_in_no_group_is_stored_as_it_arrived
     submitted = with_lines(OWN_ADDRESS)
-    edited = edit(submitted, before: before)
 
-    assert_equal submitted, edited.stored.to_s
-    assert_empty edited.group_edits
+    assert_equal [submitted, nil, []], decompose(submitted, before: before)
   end
 
   def test_an_untouched_lent_line_is_subtracted_and_asks_nothing
-    edited = edit(with_lines(HOUSEHOLD_ADDRESS), before: before(inherited: LENT))
-
-    assert_equal AIDEN, edited.stored.to_s
-    assert_empty edited.group_edits
+    assert_equal [AIDEN, nil, []], decompose(with_lines(HOUSEHOLD_ADDRESS), before: before(inherited: LENT))
     assert_empty sentry_messages
   end
 
   def test_a_reserialized_lent_line_is_still_untouched
-    edited = edit(with_lines(RESERIALIZED_ADDRESS), before: before(inherited: LENT))
-
-    assert_equal AIDEN, edited.stored.to_s
-    assert_empty edited.group_edits
+    assert_equal [AIDEN, nil, []], decompose(with_lines(RESERIALIZED_ADDRESS), before: before(inherited: LENT))
   end
 
   def test_a_members_own_line_keeps_its_place_beside_a_lent_one
     own = AIDEN.sub("FN:Aiden\r\n", "#{OWN_ADDRESS}\r\nFN:Aiden\r\n")
     submitted = own.sub("END:VCARD\r\n", "#{HOUSEHOLD_ADDRESS}\r\nEND:VCARD\r\n")
 
-    assert_equal own, edit(submitted, before: before(own, inherited: LENT)).stored.to_s
+    assert_equal [own, nil, []], decompose(submitted, before: before(own, inherited: LENT))
   end
 
   def test_an_edited_lent_line_is_an_edit_of_its_row
-    edited = edit(with_lines(EDITED_ADDRESS), before: before(inherited: LENT))
-
-    assert_equal AIDEN, edited.stored.to_s
-    assert_equal [group_edit(EDITED_ADDRESS)], edited.group_edits
+    assert_equal [AIDEN, nil, [group_edit(EDITED_ADDRESS)]],
+                 decompose(with_lines(EDITED_ADDRESS), before: before(inherited: LENT))
   end
 
   def test_a_deleted_lent_line_is_a_removal_of_its_row
-    edited = edit(AIDEN, before: before(inherited: LENT))
-
-    assert_equal AIDEN, edited.stored.to_s
-    assert_equal [group_edit(nil)], edited.group_edits
+    assert_equal [AIDEN, nil, [group_edit(nil)]], decompose(AIDEN, before: before(inherited: LENT))
   end
 
   def test_two_candidates_for_one_lent_line_stay_in_the_card_and_are_reported
     submitted = with_lines(EDITED_ADDRESS, OWN_ADDRESS)
-    edited = edit(submitted, before: before(inherited: LENT))
 
-    assert_equal submitted, edited.stored.to_s
-    assert_empty edited.group_edits
+    assert_equal [submitted, nil, []], decompose(submitted, before: before(inherited: LENT))
     assert_equal 1, sentry_messages.length
   end
 
   def test_an_edit_no_group_may_hold_stays_in_the_card_and_is_reported
     submitted = with_lines(HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "item1.ADR:"), "item1.X-ABLabel:dom")
-    edited = edit(submitted, before: before(inherited: LENT))
 
-    assert_equal submitted, edited.stored.to_s
-    assert_empty edited.group_edits
+    assert_equal [submitted, nil, []], decompose(submitted, before: before(inherited: LENT))
     assert_equal 1, sentry_messages.length
   end
 
   def test_the_birthday_and_the_lent_lines_leave_together
-    edited = edit(with_lines(HOUSEHOLD_ADDRESS, "BDAY:1985-04-12"), before: before(inherited: LENT))
-
-    assert_equal AIDEN, edited.stored.to_s
-    assert_equal APRIL_12, edited.birthday
-    assert_empty edited.group_edits
+    assert_equal [AIDEN, APRIL_12, []],
+                 decompose(with_lines(HOUSEHOLD_ADDRESS, "BDAY:1985-04-12"), before: before(inherited: LENT))
   end
 end