birthday: validate shapes by pattern matching
One case/in clause per shape the grammar admits, with each component's
range standing where the component stands — the pattern is the grammar
rather than a description of it. The ranges also refuse a String
component the truthiness checks met with a NoMethodError.
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 86046e6..9ac906f 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -64,24 +64,27 @@ module ProTacts
Split = Data.define(:card, :birthday, :bday_kept)
# The only constructor, so no birthday exists in a shape the grammar
- # rejects. Components are not calendar-validated beyond their ranges:
- # a partial date has no whole date to be wrong about, and a nonsense
- # but well-shaped one round-trips rather than 500s a write.
+ # rejects: one `in` clause per shape RFC 6350 section 4.3.1 admits,
+ # each component's range standing where the component stands, so the
+ # pattern is the grammar rather than a prose description of it. A
+ # range admits any Comparable in its bounds, so the check is
+ # Integer-grade only in practice: the two producers — SQLite's
+ # integer columns and #to_i — cannot yield anything else, and a
+ # String or nil-shaped miss falls to the else. Components are not
+ # calendar-validated beyond their ranges: a partial date has no
+ # whole date to be wrong about, and a nonsense but well-shaped one
+ # round-trips rather than 500s a write.
def self.new(year: nil, month: nil, day: nil)
- if year.nil? && month.nil? && day.nil?
- raise ArgumentError, "a birthday needs at least one of year, month, and day"
- end
- if year && month.nil? && day
- raise ArgumentError, "year with day but no month is not a shape RFC 6350 section 4.3.1 admits"
- end
- if month && !month.between?(1, 12)
- raise ArgumentError, "month out of range: #{month}"
- end
- if day && !day.between?(1, 31)
- raise ArgumentError, "day out of range: #{day}"
- end
- if year && !year.between?(0, 9999)
- raise ArgumentError, "year out of range: #{year}"
+ case [year, month, day]
+ in [0..9999, 1..12, 1..31]
+ in [0..9999, 1..12, nil]
+ in [0..9999, nil, nil]
+ in [nil, 1..12, 1..31]
+ in [nil, 1..12, nil]
+ in [nil, nil, 1..31]
+ else
+ raise ArgumentError,
+ "not a shape RFC 6350 section 4.3.1 admits: year=#{year.inspect}, month=#{month.inspect}, day=#{day.inspect}"
end
super
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 964cd49..1bf1ec5 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -29,6 +29,16 @@ class BirthdayTest < Minitest::Test
assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 10_000, month: 4, day: 12) }
end
+ # The shape pattern matches by range, and a range admits any
+ # Comparable within its bounds — so a String component is refused as a
+ # shape problem. A non-Integer numeric would pass; no producer can
+ # make one (SQLite integer columns and #to_i are Integers), which the
+ # constructor's comment records as the boundary of the check.
+ def test_components_that_are_not_integers_are_refused
+ assert_raises(ArgumentError) { ProTacts::Birthday.new(year: "1985", month: 4, day: 12) }
+ assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, month: "4", day: 12) }
+ end
+
def test_the_wire_forms
assert_equal "BDAY:1985-04-12", ProTacts::Birthday.new(year: 1985, month: 4, day: 12).to_line
assert_equal "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12", ProTacts::Birthday.new(month: 4, day: 12).to_line