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
change rlwokrlyylurvkwtpwmytwsymwrnzwzx
commit 7b58141b628fb23524fa85db0fed2443737bf0a0
author Alpha Chen <alpha@kejadlen.dev>
date
parent ksmzouul
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