Read a submission as an EditedContact of the contact before it
Store#put already read the contact it replaces, in three pieces: the
stored card, the birthday, and the lent rows. The one thing a Contact
lacked was the position of each inherited line's group_properties row,
which a member's edit writes back to. Contact::Inherited now carries
it, so Lent and Contact::Inherited stop being the same fact twice.
Lent, lent_of and lent_from go, along with stored_card, which had no
other caller.
EditedContact.new(submitted, before:) holds the submission and the
contact before it (nil for a create), and answers stored, birthday and
group_edits. The birthday split and the lent-line subtraction are its
private methods, moved as they were. It composes the edited contact by
holding one rather than by being one: its groups would be the ones
from before the edit, and put still builds the contact it returns
after the fan-out and the sync:* join.
BirthdayLine goes back to what main has. test_birthday_line.rb and
test_lent.rb become test_edited_contact.rb.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi55bFkB6wo4jguNtsase4
diff --git a/AGENTS.md b/AGENTS.md
index 28c8c4a..4eefbd0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,11 +80,11 @@ lib/pro_tacts/
│ # group change log, and the derived index
├── contact.rb # the one model of a contact: id, vCard, etag,
│ # and the structured accessors over the card
+├── edited_contact.rb # A submitted card read against the contact it
+│ # replaces: the card, birthday, and group edits
+│ # Store#put writes
├── birthday.rb # A partial date, the one fact no card can hold
-├── birthday_line.rb # What a submitted BDAY line is, and the split
-│ # of a card's birthday from it, for Store#put
-├── lent.rb # A line a group lends a card, and the subtraction
-│ # that takes it back out of a submitted one
+├── birthday_line.rb # What a submitted BDAY line is, for EditedContact
├── card_diff.rb # The lines a write added and removed
├── sync_token.rb # The token a syncing client carries: minted
│ # at a ctag for a book, and read back
diff --git a/lib/pro_tacts/birthday_line.rb b/lib/pro_tacts/birthday_line.rb
index 9aa95c9..35c6218 100644
--- a/lib/pro_tacts/birthday_line.rb
+++ b/lib/pro_tacts/birthday_line.rb
@@ -1,14 +1,11 @@
-require "sentry-ruby"
-
require "pro_tacts/birthday"
-require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
module ProTacts
- # What one BDAY line in a submitted card is, and the split Store#put
- # makes on it (docs/plans/2026-09-11-every-birthday-in-the-model.md).
- # Match with case/when, not case/in: Steep does not check the bodies
- # of case/in branches, and these branches are the ones worth checking.
+ # What one BDAY line in a submitted card is, for EditedContact to act on
+ # (docs/plans/2026-09-11-every-birthday-in-the-model.md). Match with
+ # case/when, not case/in: Steep does not check the bodies of case/in
+ # branches, and these branches are the ones worth checking.
#
# The variants are signed in sig/pro_tacts/birthday_line.rbs, for
# Birthday's reason.
@@ -40,77 +37,5 @@ module ProTacts
birthday = Birthday.from_property(property)
birthday ? Modeled.new(birthday:) : Unrecognized.new
end
-
- # The birthday half of the split a write makes: the model's new
- # birthday, and the card to store beside it. A card holds at most
- # one BDAY, and one that reads as a birthday leaves the card for the
- # model. Any other stays in the card byte for byte, reported, with
- # the model emptied so nothing composes a second BDAY beside it
- # (RFC 6352 section 6.3.2.2).
- #
- # No BDAY is a client that was sent the birthday removing it, or
- # one that was never sent it and so cannot have. A BDAY line left
- # in the stored card goes with the write, which is worth saying.
- #
- # case/when for the module's reason, which is what the last arm is
- # for.
- #: (VCard vcard, Birthday? existing, VCard? stored_before) -> [Birthday?, VCard]
- def self.split(vcard, existing, stored_before)
- bdays, rest = vcard.extract("BDAY")
- if bdays.empty?
- report_lost(stored_before)
- return [existing && !existing.served? ? existing : nil, vcard]
- end
-
- if bdays.length > 1
- report_many(bdays.length)
- return [nil, vcard]
- end
-
- line = read(bdays.fetch(0))
- case line
- when Modeled
- [line.birthday, rest]
- when Unrecognized
- report_unrecognized
- [nil, vcard]
- when Unreadable
- [nil, vcard]
- else
- raise "no arm for #{line.class}"
- end
- end
-
- # The arrival report: a BDAY the model does not take is unexpected
- # input, and storing it verbatim would be the last anyone heard of
- # it. The message carries no card content
- # (the line config.ru draws for Sentry); the value stays on the
- # machine, where the admin view shows it raw.
- #: () -> void
- def self.report_unrecognized
- Sentry.capture_message("a submitted card carried a BDAY line the model does not take", level: :warning)
- end
-
- # A contact has one birthday, so a card with several cannot say
- # which it means, whatever each line holds.
- #: (Integer count) -> void
- def self.report_many(count)
- Sentry.capture_message("a submitted card carried #{count} BDAY lines", level: :warning)
- end
-
- # The loss report, the write's half of the arrival one: a BDAY line
- # still in the stored card is one the model did not take, and a
- # write without it is about to drop it with nobody told.
- #: (VCard? stored_before) -> void
- def self.report_lost(stored_before)
- return if stored_before.nil?
-
- lines, = stored_before.extract("BDAY")
- return if lines.empty?
-
- Sentry.capture_message("a write dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
- end
-
- private_class_method :report_unrecognized, :report_many, :report_lost
end
end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index bc7f271..273faa1 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -77,9 +77,11 @@ module ProTacts
# One line a group lends this contact, and the group that lends it,
# whole (Store#inheritance). Not its name, because two groups may
# share one, and a screen marking the line opens the one group that
- # lent it (docs/DESIGN.md, "Relationships are navigable").
+ # lent it (docs/DESIGN.md, "Relationships are navigable"). The
+ # position is the group_properties row the line came from, which a
+ # member's edit to it writes back to (EditedContact::GroupEdit).
# @rbs skip
- Inherited = Data.define(:group, :line)
+ Inherited = Data.define(:group, :position, :line)
# @rbs skip
Address = Data.define(
:po_box,
@@ -140,6 +142,11 @@ module ProTacts
# #properties' job.
attr_reader :birthday
+ # The lines this contact's groups lend it (Inherited), which a
+ # submitted card is measured against to tell a member's edit to a
+ # shared line from a line of its own (EditedContact).
+ attr_reader :inherited
+
# The card to serve: the stored one with the inherited lines and
# the birthday composed back in, in that order, each immediately
# before END:VCARD — stored + inherited + birthday, the card a
@@ -342,7 +349,7 @@ module ProTacts
# Matched by the line's bytes, which is the same blindness the
# editor's digests have (VCard::Parser::Line#digest): a stored
# line whose bytes are a group's line reads as the group's here.
- # No write makes that state — Lent.subtract takes the
+ # No write makes that state — EditedContact takes the
# group's copy back out of a submission — but a member whose own
# card spells the line exactly as its group does still has one, and
# naming the group over both copies says something true about the
diff --git a/lib/pro_tacts/lent.rb b/lib/pro_tacts/edited_contact.rb
similarity index 60%
rename from lib/pro_tacts/lent.rb
rename to lib/pro_tacts/edited_contact.rb
index c0c37dc..342b0bb 100644
--- a/lib/pro_tacts/lent.rb
+++ b/lib/pro_tacts/edited_contact.rb
@@ -1,29 +1,43 @@
require "sentry-ruby"
+require "pro_tacts/birthday"
+require "pro_tacts/birthday_line"
+require "pro_tacts/contact"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
module ProTacts
- # One line a group lends one card, with the row it is lent from —
- # the write path's reading of what Store#inherited_of answers as the
- # model's. A member's edit has to reach the group_properties row the
- # line came from, which takes the position as well as the group.
+ # A card a client submitted over a contact, read back into what the
+ # store keeps: the card to store, the birthday to hold beside it, and
+ # what the member's edits to its groups' lines ask of those groups.
+ # The inverse of what Contact composes — served is stored plus
+ # inherited plus birthday, so stored is submitted minus both
+ # (docs/plans/2026-08-31-partial-birthdays.md,
+ # docs/plans/2026-08-24-vcard-storage-and-groups.md).
#
- # The signature lives in sig/pro_tacts/lent.rbs, this being a Data
- # class.
- # @rbs skip
- Lent = Data.define(:group_id, :position, :line)
-
- # Reopened rather than defined in the block above, for the reason
- # VCard::Parser::Property is.
- class Lent
+ # An edit of a contact rather than a kind of one: the contact after
+ # the write is Store#put's to compose, because the group edits move
+ # the very groups `before` holds, and a card a client creates can
+ # join `sync:*` on the way in. Nothing here reads or writes the
+ # database; the reports go to Sentry from here because this is the
+ # arrival (AGENTS.md, "The one exception is the arrival reports").
+ #
+ # GroupEdit's signature is in sig/pro_tacts/edited_contact.rbs, it
+ # being a Data class.
+ class EditedContact
+ # @rbs @before: Contact?
+ # @rbs @submitted: VCard
+ # @rbs @stored: VCard
+ # @rbs @birthday: Birthday?
+ # @rbs @group_edits: 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
# row, which is what a member's deletion of a shared line means
# (docs/plans/2026-09-09-group-edits-propagate.md, "The open
# question, decided").
# @rbs skip
- Edit = Data.define(:group_id, :position, :line)
+ GroupEdit = Data.define(:group_id, :position, :line)
# The property names whose value is structured rather than free
# text (RFC 2426 section 3.2.1), among the two a group may lend:
@@ -41,15 +55,120 @@ module ProTacts
# group cannot hold").
SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
- # The submitted card with the lines its groups lend it taken back
- # out, and the edits the lines that came back changed ask of those
- # groups: served is stored plus inherited, so stored is submitted
- # minus inherited and a client that PUTs back what it downloaded
- # stores what it started with
- # (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Groups
- # compose into cards"). Which of four shapes a lent line came back
- # as, and what each asks of its group, is .classify — argued in
- # that plan's "Classifying what came back" and in
+ # `before` is the contact this write replaces, nil for one being
+ # created: its stored card, its birthday, and the lines its groups
+ # lend it are what the submission is read against.
+ #: (VCard submitted, before: Contact?) -> void
+ 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
+
+ private
+
+ # The card as it stood before this write, nil for one being created.
+ #: () -> VCard?
+ def stored_before
+ @before&.stored
+ end
+
+ # The birthday half (docs/plans/2026-09-11-every-birthday-in-the-model.md):
+ # the model's new birthday, and the card to store beside it. A card
+ # holds at most one BDAY, and one that reads as a birthday leaves
+ # the card for the model. Any other stays in the card byte for
+ # byte, reported, with the model emptied so nothing composes a
+ # second BDAY beside it (RFC 6352 section 6.3.2.2).
+ #
+ # No BDAY is a client that was sent the birthday removing it, or
+ # one that was never sent it and so cannot have. A BDAY line left
+ # in the stored card goes with the write, which is worth saying.
+ #
+ # case/when rather than case/in, BirthdayLine's own rule: Steep
+ # does not check the bodies of case/in branches, and these are the
+ # ones worth checking — which is what the last arm is for.
+ #: () -> [Birthday?, VCard]
+ def split_birthday
+ bdays, rest = @submitted.extract("BDAY")
+ if bdays.empty?
+ report_lost_bday_lines
+ existing = @before&.birthday
+ return [existing && !existing.served? ? existing : nil, @submitted]
+ end
+
+ if bdays.length > 1
+ report_many_bday_lines(bdays.length)
+ return [nil, @submitted]
+ end
+
+ line = BirthdayLine.read(bdays.fetch(0))
+ case line
+ when BirthdayLine::Modeled
+ [line.birthday, rest]
+ when BirthdayLine::Unrecognized
+ report_unrecognized_bday_line
+ [nil, @submitted]
+ when BirthdayLine::Unreadable
+ [nil, @submitted]
+ else
+ raise "no arm for #{line.class}"
+ end
+ end
+
+ # The arrival report: a BDAY the model does not take is unexpected
+ # input, and storing it verbatim would be the last anyone heard of
+ # it. The message carries no card content
+ # (the line config.ru draws for Sentry); the value stays on the
+ # machine, where the admin view shows it raw.
+ #: () -> void
+ def report_unrecognized_bday_line
+ Sentry.capture_message("a submitted card carried a BDAY line the model does not take", level: :warning)
+ end
+
+ # A contact has one birthday, so a card with several cannot say
+ # which it means, whatever each line holds.
+ #: (Integer count) -> void
+ def report_many_bday_lines(count)
+ Sentry.capture_message("a submitted card carried #{count} BDAY lines", level: :warning)
+ end
+
+ # The loss report, the write's half of the arrival one: a BDAY line
+ # still in the stored card is one the model did not take, and a
+ # write without it is about to drop it with nobody told.
+ #: () -> void
+ def report_lost_bday_lines
+ stored = stored_before
+ return if stored.nil?
+
+ lines, = stored.extract("BDAY")
+ return if lines.empty?
+
+ Sentry.capture_message("a write dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
+ end
+
+ # The card with the lines its groups lend it taken back out: served
+ # is stored plus inherited, so stored is submitted minus inherited
+ # and a client that PUTs back what it downloaded stores what it
+ # started with (docs/plans/2026-08-24-vcard-storage-and-groups.md,
+ # "Groups compose into cards"). Which of four shapes a lent line
+ # came back as, and what each asks of its group, is #classify —
+ # argued in that plan's "Classifying what came back" and in
# docs/plans/2026-09-09-group-edits-propagate.md.
#
# Two passes, because one lent line must not be attributed a line
@@ -66,11 +185,12 @@ module ProTacts
# this leaves keep their positions, so a submission that was the
# served card round-trips to the bytes it was composed from and the
# PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
- #: (VCard vcard, Array[Lent] lent, VCard? stored_before) -> [VCard, Array[Edit]]
- def self.subtract(vcard, lent, stored_before)
+ #: (VCard vcard) -> [VCard, Array[GroupEdit]]
+ def subtract_inherited(vcard)
+ lent = @before&.inherited || [] #: Array[Contact::Inherited]
return [vcard, []] if lent.empty?
- unaccounted = unaccounted_lines(vcard, stored_before)
+ unaccounted = unaccounted_lines(vcard)
taken = [] #: Array[VCard::Parser::Line]
moved = lent.reject { |row|
@@ -90,19 +210,19 @@ module ProTacts
match
}
- edits = [] #: Array[Edit]
+ edits = [] #: Array[GroupEdit]
ambiguous = 0
unshareable = 0
moved.each do |row|
case classify(row, unaccounted, taken)
- in Edit => edit then edits << edit
+ in GroupEdit => edit then edits << edit
in :ambiguous then ambiguous += 1
in :unshareable then unshareable += 1
end
end
- report_ambiguous(ambiguous)
- report_unshareable(unshareable)
+ report_ambiguous_inherited_lines(ambiguous)
+ report_unshareable_lines(unshareable)
[taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }, edits]
end
@@ -110,31 +230,31 @@ module ProTacts
# edit the one candidate of its name is, or the deletion no
# candidate at all is. The two refusals ask nothing — more than one
# candidate cannot be attributed, and a candidate the group could
- # not hold must not be tried (.shareable?) — and both leave the
+ # not hold must not be tried (#shareable?) — and both leave the
# line in the member's own card, where an edit at least is not
# lost.
#
# An edit's line is struck from the pool and carried by the group
# from here on, so `taken` grows the way it does for an untouched
# one.
- #: (Lent row, Array[VCard::Parser::Line] unaccounted, Array[VCard::Parser::Line] taken) -> (Edit | :ambiguous | :unshareable)
- def self.classify(row, unaccounted, taken)
+ #: (Contact::Inherited row, Array[VCard::Parser::Line] unaccounted, Array[VCard::Parser::Line] taken) -> (GroupEdit | :ambiguous | :unshareable)
+ def classify(row, unaccounted, taken)
candidates = unaccounted.select { it.names?(property_name(row.line)) }
- return Edit.new(group_id: row.group_id, position: row.position, line: nil) if candidates.empty?
+ return GroupEdit.new(group_id: row.group.id, position: row.position, line: nil) if candidates.empty?
return :ambiguous unless candidates.length == 1
edited = candidates.fetch(0)
return :unshareable unless shareable?(edited)
taken << strike(unaccounted, edited)
- Edit.new(group_id: row.group_id, position: row.position, line: edited.content)
+ GroupEdit.new(group_id: row.group.id, position: row.position, line: edited.content)
end
# The line out of the pool, so no second lent line is attributed
# it. `index` rather than `delete`, which would take every copy of
# a line a card carries twice.
#: (Array[VCard::Parser::Line] unaccounted, VCard::Parser::Line line) -> VCard::Parser::Line
- def self.strike(unaccounted, line)
+ def strike(unaccounted, line)
unaccounted.delete_at(
unaccounted.index(line) #: Integer
)
@@ -146,7 +266,7 @@ module ProTacts
# the CHECK's two clauses, read off the parse rather than matched
# as bytes. A line that would not read is not one to share.
#: (VCard::Parser::Line line) -> bool
- def self.shareable?(line)
+ def shareable?(line)
property = line.property
return false if property.nil?
@@ -156,7 +276,7 @@ module ProTacts
# A lent line as the parser reads it: one logical line, the group
# schema admitting no other shape (db/migrations/004_groups.rb).
#: (String line) -> VCard::Parser::Line
- def self.parsed_line(line)
+ def parsed_line(line)
VCard.new(line).lines.fetch(0)
end
@@ -176,7 +296,7 @@ module ProTacts
# A line that will not read has no value to compare and falls back
# to its bytes, which still recognize the line nobody touched.
#: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
- def self.unedited?(line, lent, pool)
+ def unedited?(line, lent, pool)
value = value_of(lent)
return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
@@ -195,7 +315,7 @@ module ProTacts
# the line's types and `ADR;TYPE=dom` coming back as `item1.ADR`
# with `item1.X-ABLabel:dom` is unedited.
#: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
- def self.kept_types?(line, lent, pool)
+ def kept_types?(line, lent, pool)
label = label_of(line, pool)&.property
types = types_of(line)
types = (types + [label.text.downcase]).uniq.sort if label
@@ -203,10 +323,10 @@ module ProTacts
end
# The `X-ABLabel` sharing a property group with `line`, the other
- # half of the pair .kept_types? reads as one. Nil for an ungrouped
+ # half of the pair #kept_types? reads as one. Nil for an ungrouped
# line, or a group the pool holds no label for.
#: (VCard::Parser::Line line, Array[VCard::Parser::Line] pool) -> VCard::Parser::Line?
- def self.label_of(line, pool)
+ def label_of(line, pool)
group = line.property&.group
return if group.nil?
@@ -221,7 +341,7 @@ module ProTacts
# 3.2.1) and a NOTE as its unescaped text (section 2.4.2). Nil for
# a line that would not read, which has no value at all.
#: (VCard::Parser::Line line) -> Array[String]?
- def self.value_of(line)
+ def value_of(line)
property = line.property
return nil if property.nil?
@@ -234,7 +354,7 @@ module ProTacts
# way). Empty for a line that would not read, which has no
# parameters to compare.
#: (VCard::Parser::Line line) -> Array[String]
- def self.types_of(line)
+ def types_of(line)
property = line.property
return [] if property.nil?
@@ -248,9 +368,9 @@ module ProTacts
# client wrote beside it, which is the pool a lent line is
# attributed from. Everything for a card being created, which has
# no stored lines to explain anything.
- #: (VCard vcard, VCard? stored_before) -> Array[VCard::Parser::Line]
- def self.unaccounted_lines(vcard, stored_before)
- stored = stored_before ? stored_before.lines.map { it.verbatim.chomp } : [] #: Array[String]
+ #: (VCard vcard) -> Array[VCard::Parser::Line]
+ def unaccounted_lines(vcard)
+ stored = stored_before&.lines&.map { it.verbatim.chomp } || [] #: Array[String]
vcard.lines.reject { |line|
index = stored.index(line.verbatim.chomp)
stored.delete_at(index) if index
@@ -264,7 +384,7 @@ module ProTacts
# group's, which carry no `item1.` prefix to strip — the schema
# refuses one (db/migrations/004_groups.rb).
#: (String line) -> String
- def self.property_name(line)
+ def property_name(line)
line[/\A[^;:]*/].to_s
end
@@ -273,9 +393,9 @@ module ProTacts
# attribute — two lines of that name arrived that the member's card
# does not explain, and calling either the edit would be a guess.
# The card is stored as it arrived and the line is news, the same
- # bargain BirthdayLine.report_unrecognized makes.
+ # bargain report_unrecognized_bday_line makes.
#: (Integer count) -> void
- def self.report_ambiguous(count)
+ def report_ambiguous_inherited_lines(count)
return if count.zero?
Sentry.capture_message(
@@ -289,11 +409,11 @@ module ProTacts
# keeps what it lent. The known cause is an edit that lands in a
# type Contacts cannot model — a relabel to one, or a new value
# under one — which comes back as a property group with an
- # `X-ABLabel` beside it (.kept_types?), and the news is worth
+ # `X-ABLabel` beside it (#kept_types?), and the news is worth
# having because that member now serves the line twice — its own
# copy and the group's — until someone reconciles them.
#: (Integer count) -> void
- def self.report_unshareable(count)
+ def report_unshareable_lines(count)
return if count.zero?
Sentry.capture_message(
@@ -301,9 +421,5 @@ module ProTacts
level: :warning,
)
end
-
- private_class_method :classify, :strike, :shareable?, :parsed_line, :unedited?, :kept_types?, :label_of,
- :value_of, :types_of, :unaccounted_lines, :property_name, :report_ambiguous,
- :report_unshareable
end
end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index ca2916a..3b6eea3 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -4,11 +4,10 @@ require "pathname"
require "sequel"
require "pro_tacts/birthday"
-require "pro_tacts/birthday_line"
require "pro_tacts/card_diff"
require "pro_tacts/change_id"
require "pro_tacts/contact"
-require "pro_tacts/lent"
+require "pro_tacts/edited_contact"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -34,8 +33,7 @@ module ProTacts
# Every Contact this store hands out is composed, never the stored
# card alone: a birthday and the lines a contact inherits from its
# groups are both subtracted out of a card on the way in
- # (BirthdayLine.split, Lent.subtract), and Contact composes them back
- # in on read — so the vcard and the etag a caller sees, and the etag
+ # (EditedContact), and Contact composes them back in on read — so the vcard and the etag a caller sees, and the etag
# the change log records, describe the card a client downloads, not
# the bytes on disk.
#
@@ -440,11 +438,11 @@ module ProTacts
# transaction, because the log entry cannot be rebuilt from anything.
#
# What is stored is the card minus its birthday and minus what its
- # groups lend it, both composed back in on read
- # (BirthdayLine.split, Lent.subtract).
+ # groups lend it, both composed back in on read; EditedContact
+ # reads the submission apart and this writes what it answers.
#
# A card rather than its bytes, so the reading a caller already
- # made is the one BirthdayLine.split decides from: a PUT has asked
+ # made is the one EditedContact decides from: a PUT has asked
# whether the bytes are a card at all and whose UID they carry
# before it gets here (Web#write_card).
#
@@ -459,29 +457,19 @@ module ProTacts
# put over a card that exists joins nothing.
#: (String id, VCard vcard, ?client: bool) -> Contact
def put(id, vcard, client: false)
- existing = birthday_of(id)
- # Read once for the three that want it: the birthday split, the
- # subtraction after it, and the card this write replaces.
- stored_before = stored_card(id)
- birthday, stored = BirthdayLine.split(vcard, existing, stored_before)
-
- # The other half of the split, the same shape as the birthday's:
- # what the groups lend comes back out of the submission before it
- # is stored, so a write cannot materialize a group's lines into
- # the member's own card.
- #
- # The Contact this returns is the composed one — those lines put
- # back, read off membership as it stands at this write — and the
- # logged etag is its hash, so a client's token describes the card
- # it downloads.
- lent = lent_of(id)
- inherited = inheritance(lent)
- # Composed from the parts already read here rather than through
- # #contact, which would read all three again. Membership cannot
- # move during a put, so the inheritance either side of it is the
- # one read above.
- replaced = stored_before && Contact.new(id:, stored: stored_before, birthday: existing, inherited:)
- stored, edits = Lent.subtract(stored, lent, stored_before)
+ # The contact this write replaces, read once for the two that want
+ # 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
+
+ # The Contact this returns is the composed one — the lent lines
+ # put back, read off membership as it stands at this write — and
+ # the logged etag is its hash, so a client's token describes the
+ # card it downloads. Membership cannot move during a put, so the
+ # inheritance either side of it is the one read above.
+ inherited = before&.inherited || [] #: Array[Contact::Inherited]
# Built before the transaction as well as inside it, because its
# constructor is where an id that could not be served is refused
# and a refusal from inside a transaction reaches the caller
@@ -496,14 +484,14 @@ module ProTacts
# read of every other member either side of them.
unless edits.empty?
fan_out(member_ids(edits.map(&:group_id).uniq), except: id) { apply_group_edits(edits) }
- contact = Contact.new(id:, stored:, birthday:, inherited: inheritance(lent_of(id)))
+ contact = Contact.new(id:, stored:, birthday:, inherited: inherited_of(id))
end
upsert_card(contact.id, stored)
# After the card, which the membership names by foreign key, and
# before the log entry, which records the card composed with
# whatever the group lends. The put's entry is the card's arrival
# in the book, so the join writes none of its own.
- if client && stored_before.nil?
+ if client && before.nil?
group_members.insert(group_id: everyone_group_id, card_id: contact.id)
contact = Contact.new(id:, stored:, birthday:, inherited: inherited_of(contact.id))
end
@@ -512,7 +500,7 @@ module ProTacts
contact.id,
action: Action::PUT,
etag: contact.etag,
- diff: CardDiff.between(replaced&.vcard, contact.vcard),
+ diff: CardDiff.between(before&.vcard, contact.vcard),
)
reindex(contact.id, stored)
contact
@@ -831,7 +819,7 @@ module ProTacts
# #delete's shape.
#: (String id) -> bool
def delete_group(id)
- # A plain `first` read, #stored_card's reason: id is the primary
+ # A plain `first` read, #birthday_of's reason: id is the primary
# key, so there is no ambiguity for `sole` to catch. Read here
# rather than rescued around the whole method, which would have
# swallowed a NoMatchingRow raised from inside the transaction
@@ -1036,7 +1024,7 @@ module ProTacts
# what it lent. A removal leaves a gap in the positions, which
# costs nothing — position orders a group's lines and is not
# counted.
- #: (Array[Lent::Edit] edits) -> void
+ #: (Array[EditedContact::GroupEdit] edits) -> void
def apply_group_edits(edits)
edits.each do |edit|
row = group_properties.where(group_id: edit.group_id, position: edit.position)
@@ -1153,15 +1141,6 @@ module ProTacts
Sequel::Migrator.run(@database, MIGRATIONS.to_s)
end
- # The card currently stored for an id, or nil for a card being
- # created. A plain `first` read: the primary key leaves `sole`
- # nothing to catch, and no row is the ordinary answer.
- #: (String id) -> VCard?
- def stored_card(id)
- row = cards.where(id:).first
- row && VCard.new(row.fetch(:vcard).to_s)
- end
-
# The birthday travels beside the card rather than inside it, and
# Contact composes the served one — its vcard, its etag, everything
# a caller reads — with the inherited lines beside it, so all
@@ -1232,15 +1211,7 @@ module ProTacts
# composed card is the same bytes every read.
#: (String id) -> Array[Contact::Inherited]
def inherited_of(id)
- inheritance(lent_of(id))
- end
-
- # The same read as it stands for a write: the rows a member's edit
- # has to reach back to, before #inheritance drops them to what the
- # model wants (see Lent).
- #: (String id) -> Array[Lent]
- def lent_of(id)
- inherited_rows.where(card_id: id).map { lent_from(it) }
+ inheritance(inherited_rows.where(card_id: id).all)
end
# Every contact's inheritance in one pass, keyed by card, for the
@@ -1254,15 +1225,20 @@ module ProTacts
lenders = groups_by_id(rows.map { it.fetch(:group_id).to_s })
rows
.group_by { it.fetch(:card_id).to_s }
- .transform_values { |mine| inheritance(mine.map { lent_from(it) }, lenders) }
- end
-
- # What a model asks of a group's rows: the group lending each,
- # whole, and the line itself. Which group_properties row lent it is
- # the write path's business and stops here (Contact#group_of).
- #: (Array[Lent] lent, ?Hash[String, Group] lenders) -> Array[Contact::Inherited]
- def inheritance(lent, lenders = groups_by_id(lent.map(&:group_id)))
- lent.map { Contact::Inherited.new(group: lenders.fetch(it.group_id), line: it.line) }
+ .transform_values { inheritance(it, lenders) }
+ end
+
+ # Inherited rows as the model reads them: the group lending each,
+ # whole, and the row's position and line.
+ #: (Array[Hash[Symbol, untyped]] rows, ?Hash[String, Group] lenders) -> Array[Contact::Inherited]
+ def inheritance(rows, lenders = groups_by_id(rows.map { it.fetch(:group_id).to_s }))
+ rows.map {
+ Contact::Inherited.new(
+ group: lenders.fetch(it.fetch(:group_id).to_s),
+ position: it.fetch(:position).to_i,
+ line: it.fetch(:line).to_s,
+ )
+ }
end
# The groups these ids name, whole and keyed by id; no reads at all
@@ -1308,15 +1284,6 @@ module ProTacts
)
end
- #: (Hash[Symbol, untyped] row) -> Lent
- def lent_from(row)
- Lent.new(
- group_id: row.fetch(:group_id).to_s,
- position: row.fetch(:position).to_i,
- line: row.fetch(:line).to_s,
- )
- end
-
# A birthday row read as the model. The shape was validated on the
# way in, so a row that no longer parses is corruption to raise on
# rather than quietly drop.
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 702e9ef..db24f36 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -32,9 +32,10 @@ module ProTacts
class Inherited
attr_reader group: Store::Group
+ attr_reader position: Integer
attr_reader line: String
- def self.new: (group: Store::Group, line: String) -> instance
+ def self.new: (group: Store::Group, position: Integer, line: String) -> instance
end
class Photo
diff --git a/sig/pro_tacts/edited_contact.rbs b/sig/pro_tacts/edited_contact.rbs
new file mode 100644
index 0000000..217cc90
--- /dev/null
+++ b/sig/pro_tacts/edited_contact.rbs
@@ -0,0 +1,15 @@
+# EditedContact::GroupEdit is a Data class, whose members the inline
+# parser cannot read (see sig/pro_tacts/contact.rbs). EditedContact
+# itself is an ordinary class and its types are inline in
+# edited_contact.rb.
+module ProTacts
+ class EditedContact
+ class GroupEdit
+ attr_reader group_id: String
+ attr_reader position: Integer
+ attr_reader line: String?
+
+ def self.new: (group_id: String, position: Integer, line: String?) -> instance
+ end
+ end
+end
diff --git a/sig/pro_tacts/lent.rbs b/sig/pro_tacts/lent.rbs
deleted file mode 100644
index 59ae447..0000000
--- a/sig/pro_tacts/lent.rbs
+++ /dev/null
@@ -1,21 +0,0 @@
-# Lent and Lent::Edit are Data classes, whose members the inline parser
-# cannot read (see sig/pro_tacts/contact.rbs). Both are marked
-# `@rbs skip` in lent.rb and signed here instead; the methods reopened
-# onto Lent carry their own inline types.
-module ProTacts
- class Lent
- attr_reader group_id: String
- attr_reader position: Integer
- attr_reader line: String
-
- def self.new: (group_id: String, position: Integer, line: String) -> instance
-
- class Edit
- attr_reader group_id: String
- attr_reader position: Integer
- attr_reader line: String?
-
- def self.new: (group_id: String, position: Integer, line: String?) -> instance
- end
- end
-end
diff --git a/test/pro_tacts/test_birthday_line.rb b/test/pro_tacts/test_birthday_line.rb
deleted file mode 100644
index b668951..0000000
--- a/test/pro_tacts/test_birthday_line.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-require_relative "../test_helper"
-
-require "pro_tacts/birthday"
-require "pro_tacts/birthday_line"
-require "pro_tacts/vcard"
-
-# The birthday half of a write on its own, with no store behind it.
-# test_store.rb holds the same rules through Store#put, where the
-# birthdays row and the served card are what is checked.
-class BirthdayLineTest < Minitest::Test
- include Sentry::TestHelper
- include SentryMessages
-
- AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
- APRIL_12 = ProTacts::Birthday.new(year: 1985, month: 4, day: 12)
-
- def setup
- setup_sentry
- end
-
- def teardown
- teardown_sentry_test
- end
-
- def vcard(bytes) = ProTacts::VCard.new(bytes)
-
- def with_lines(*lines) = AIDEN.sub("END:VCARD\r\n", lines.map { "#{it}\r\n" }.join + "END:VCARD\r\n")
-
- def split(bytes, existing: nil, stored_before: nil)
- ProTacts::BirthdayLine.split(vcard(bytes), existing, stored_before && vcard(stored_before))
- end
-
- def test_a_modeled_birthday_leaves_the_card_for_the_model
- birthday, rest = split(with_lines("BDAY:1985-04-12"))
-
- assert_equal APRIL_12, birthday
- assert_equal AIDEN, rest.to_s
- 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, split(AIDEN, existing: month_only).first
- end
-
- def test_a_card_without_a_bday_drops_a_birthday_the_client_was_sent
- assert_nil split(AIDEN, existing: APRIL_12).first
- end
-
- def test_an_unrecognized_bday_stays_in_the_card_and_is_reported
- submitted = with_lines("BDAY:1985-13")
- birthday, rest = split(submitted)
-
- assert_nil birthday
- assert_equal submitted, rest.to_s
- 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")
- birthday, rest = split(submitted)
-
- assert_nil birthday
- assert_equal submitted, rest.to_s
- 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
- split(AIDEN, stored_before: with_lines("BDAY:1985-13"))
-
- assert_equal ["a write dropped 1 BDAY line(s) the model did not take"], sentry_messages
- end
-end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index daa675c..87b653c 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -30,7 +30,7 @@ class ContactTest < Minitest::Test
GROUP = ProTacts::Store::Group.new(id: "nous", name: "Booles", label: "Booles", lines: [], members: ["aiden"]) #: ProTacts::Store::Group
def contact(bytes = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
- lent = inherited.map { ProTacts::Contact::Inherited.new(group:, line: it) }
+ lent = inherited.each_with_index.map { |line, position| ProTacts::Contact::Inherited.new(group:, position:, line:) }
ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday:, inherited: lent)
end
diff --git a/test/pro_tacts/test_edited_contact.rb b/test/pro_tacts/test_edited_contact.rb
new file mode 100644
index 0000000..b9b1182
--- /dev/null
+++ b/test/pro_tacts/test_edited_contact.rb
@@ -0,0 +1,164 @@
+require_relative "../test_helper"
+
+require "pro_tacts/birthday"
+require "pro_tacts/contact"
+require "pro_tacts/edited_contact"
+require "pro_tacts/store"
+require "pro_tacts/vcard"
+
+# The edit on its own, with no store behind it: what Store#put writes
+# is decided here from the submission and the contact before it, so
+# each branch can be read off its inputs. test_store.rb holds the same
+# rules through a write, where the rows and the served card are what
+# is checked.
+class EditedContactTest < Minitest::Test
+ include Sentry::TestHelper
+ include SentryMessages
+
+ AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+ APRIL_12 = ProTacts::Birthday.new(year: 1985, month: 4, day: 12)
+ HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
+ EDITED_ADDRESS = HOUSEHOLD_ADDRESS.sub("7 Calculus", "8 Calculus") #: String
+ RESERIALIZED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=HOME;type=pref:") #: String
+ OWN_ADDRESS = "ADR:;;1 Long Road;;;;" #: String
+ HOUSEHOLD = ProTacts::Store::Group.new(
+ id: "kkkk", name: "Household", label: "Household", lines: [HOUSEHOLD_ADDRESS], members: ["aiden"],
+ )
+ LENT = [ProTacts::Contact::Inherited.new(group: HOUSEHOLD, position: 3, line: HOUSEHOLD_ADDRESS)].freeze
+
+ def setup
+ setup_sentry
+ end
+
+ def teardown
+ teardown_sentry_test
+ end
+
+ def with_lines(*lines) = AIDEN.sub("END:VCARD\r\n", lines.map { "#{it}\r\n" }.join + "END:VCARD\r\n")
+
+ def before(stored = AIDEN, birthday: nil, inherited: [])
+ 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:)
+ end
+
+ def group_edit(line) = ProTacts::EditedContact::GroupEdit.new(group_id: "kkkk", position: 3, line:)
+
+ ## 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_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
+ 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
+ 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 ["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 ["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")))
+
+ assert_equal ["a write dropped 1 BDAY line(s) the model did not take"], sentry_messages
+ 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
+ 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_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
+ 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
+ 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
+ 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
+ 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 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 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
+ end
+end
diff --git a/test/pro_tacts/test_lent.rb b/test/pro_tacts/test_lent.rb
deleted file mode 100644
index 475a061..0000000
--- a/test/pro_tacts/test_lent.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-require_relative "../test_helper"
-
-require "pro_tacts/lent"
-require "pro_tacts/vcard"
-
-# The subtraction on its own, with no store behind it: what the lines
-# a member's groups lend come back as, read off the card and what
-# stood before it. test_store.rb holds the same rules through
-# Store#put, where the group rows and the served card are what is
-# checked.
-class LentTest < Minitest::Test
- include Sentry::TestHelper
- include SentryMessages
-
- AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
- HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
- EDITED_ADDRESS = HOUSEHOLD_ADDRESS.sub("7 Calculus", "8 Calculus") #: String
- RESERIALIZED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=HOME;type=pref:") #: String
- OWN_ADDRESS = "ADR:;;1 Long Road;;;;" #: String
- HOUSEHOLD = ProTacts::Lent.new(group_id: "kkkk", position: 0, line: HOUSEHOLD_ADDRESS)
-
- def setup
- setup_sentry
- end
-
- def teardown
- teardown_sentry_test
- end
-
- def vcard(bytes) = ProTacts::VCard.new(bytes)
-
- def with_lines(*lines) = AIDEN.sub("END:VCARD\r\n", lines.map { "#{it}\r\n" }.join + "END:VCARD\r\n")
-
- def subtract(bytes, stored_before: AIDEN, lent: [HOUSEHOLD])
- stored, edits = ProTacts::Lent.subtract(vcard(bytes), lent, stored_before && vcard(stored_before))
- [stored.to_s, edits]
- end
-
- def test_a_card_with_nothing_lent_is_stored_as_it_arrived
- submitted = with_lines(OWN_ADDRESS)
-
- assert_equal [submitted, []], subtract(submitted, stored_before: nil, lent: [])
- end
-
- def test_an_untouched_lent_line_is_subtracted_and_asks_nothing
- assert_equal [AIDEN, []], subtract(with_lines(HOUSEHOLD_ADDRESS))
- assert_empty sentry_messages
- end
-
- def test_a_reserialized_lent_line_is_still_untouched
- assert_equal [AIDEN, []], subtract(with_lines(RESERIALIZED_ADDRESS))
- 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, []], subtract(submitted, stored_before: own)
- end
-
- def test_an_edited_lent_line_is_an_edit_of_its_row
- edit = ProTacts::Lent::Edit.new(group_id: "kkkk", position: 0, line: EDITED_ADDRESS)
-
- assert_equal [AIDEN, [edit]], subtract(with_lines(EDITED_ADDRESS))
- end
-
- def test_a_deleted_lent_line_is_a_removal_of_its_row
- removal = ProTacts::Lent::Edit.new(group_id: "kkkk", position: 0, line: nil)
-
- assert_equal [AIDEN, [removal]], subtract(AIDEN)
- end
-
- def test_two_candidates_for_one_lent_line_stay_in_the_card_and_are_reported
- submitted = with_lines(EDITED_ADDRESS, OWN_ADDRESS)
-
- assert_equal [submitted, []], subtract(submitted)
- 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")
-
- assert_equal [submitted, []], subtract(submitted)
- assert_equal 1, sentry_messages.length
- end
-end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index d3b5324..3fc2585 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -948,7 +948,7 @@ class StoreTest < Minitest::Test
# component out of range (month 13, day 32), a spelling its grammar
# does not take (undashed, unpadded — see Birthday's patterns), and a
# BDAY the card hangs off a property group. Why it reports rather
- # than stores quietly is BirthdayLine.report_unrecognized.
+ # than stores quietly is EditedContact#report_unrecognized_bday_line.
def test_an_unrecognized_bday_arriving_is_reported
["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4", "item1.BDAY:1985-04-12"].each do |line|
with_store({}) do |store|
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index b1a743e..c9937fb 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -392,7 +392,7 @@ class WebTest < Minitest::Test
# back is subtracted before storage and composed again on read, so
# the resource serves the submitted bytes exactly and the tag is
# honest — even though what was stored is fewer bytes than arrived
- # (Lent.subtract).
+ # (EditedContact).
def test_a_members_put_of_its_served_card_keeps_the_strong_etag
address = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"