aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 18:22:32 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 18:22:32 -0500
commit80ebb74e4f3f35c2204ba5fee8c48bf5d18f0559 (patch)
treec2dec1849876aaab4384e2607321150351400e19 /.ai/scripts/tests
parent6e487146470381424e520fd00dbe155856656e3f (diff)
downloadrulesets-80ebb74e4f3f35c2204ba5fee8c48bf5d18f0559.tar.gz
rulesets-80ebb74e4f3f35c2204ba5fee8c48bf5d18f0559.zip
feat(daily-prep): add upcoming-birthdays block from contacts.org
New stdlib-only upcoming_birthdays.py reads ~/sync/org/contacts.org for :BIRTHDAY: properties and prints a daily-prep block: name, date, days-away, and the age turned when the birth year is known (1900 is the org-contacts unknown-year placeholder, rendered date-only). Birthdays inside 7 days come back flagged so a gift or plan gets prompted. 19 pytest cases cover the window boundaries, leap-day fallback to Feb 28, the placeholder year, and the CLI. daily-prep gains a Phase A source that runs the script and a Heads-Up line that folds in the block, so contact birthdays the calendar doesn't carry still surface. Built and tested in the home project. Promoted here because the daily-prep hook lives in this synced workflow and the contacts file is present on every machine.
Diffstat (limited to '.ai/scripts/tests')
-rw-r--r--.ai/scripts/tests/test_upcoming_birthdays.py168
1 files changed, 168 insertions, 0 deletions
diff --git a/.ai/scripts/tests/test_upcoming_birthdays.py b/.ai/scripts/tests/test_upcoming_birthdays.py
new file mode 100644
index 0000000..1e15183
--- /dev/null
+++ b/.ai/scripts/tests/test_upcoming_birthdays.py
@@ -0,0 +1,168 @@
+"""Tests for upcoming_birthdays.py — the daily-prep upcoming-birthdays block.
+
+Pure core:
+ parse_birthdays(text) -> [Birthday(name, month, day, year|None), ...]
+ upcoming(birthdays, today, window=30) -> [Upcoming(name, date, days_away, age|None), ...]
+ format_block(items, window, callout_days=7) -> str
+
+Birth year 1900 is the placeholder org-contacts uses when the real year is
+unknown; those entries carry year=None and render date-only (no age).
+"""
+
+import datetime as dt
+import subprocess
+import sys
+from pathlib import Path
+
+SCRIPTS = Path(__file__).parent.parent
+SCRIPT = SCRIPTS / "upcoming_birthdays.py"
+sys.path.insert(0, str(SCRIPTS))
+
+import upcoming_birthdays as ub # noqa: E402
+
+
+# --- parse_birthdays --------------------------------------------------------
+
+def test_parse_reads_name_month_day_year():
+ text = "** Jane Doe\n:PROPERTIES:\n:BIRTHDAY: 1970-08-05\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays == [ub.Birthday("Jane Doe", 8, 5, 1970)]
+
+
+def test_parse_placeholder_year_1900_becomes_none():
+ text = "** John Smith\n:PROPERTIES:\n:BIRTHDAY: 1900-07-14\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays == [ub.Birthday("John Smith", 7, 14, None)]
+
+
+def test_parse_skips_contacts_without_birthday():
+ text = (
+ "** No Birthday\n:PROPERTIES:\n:PHONE: 555\n:END:\n"
+ "** Has Birthday\n:PROPERTIES:\n:BIRTHDAY: 1990-03-02\n:END:\n"
+ )
+ bdays = ub.parse_birthdays(text)
+ assert [b.name for b in bdays] == ["Has Birthday"]
+
+
+def test_parse_strips_heading_stars_and_tags():
+ text = "*** Bob Jones :friend:\n:PROPERTIES:\n:BIRTHDAY: 1980-01-01\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays[0].name == "Bob Jones"
+
+
+def test_parse_ignores_malformed_birthday_lines():
+ text = "** Bad Date\n:PROPERTIES:\n:BIRTHDAY: not-a-date\n:END:\n"
+ assert ub.parse_birthdays(text) == []
+
+
+# --- upcoming ---------------------------------------------------------------
+
+TODAY = dt.date(2026, 7, 18)
+
+
+def test_upcoming_birthday_today_is_zero_days():
+ bdays = [ub.Birthday("Today Person", 7, 18, 1990)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].days_away == 0
+ assert got[0].date == dt.date(2026, 7, 18)
+
+
+def test_upcoming_includes_within_window():
+ bdays = [ub.Birthday("Soon", 7, 23, 1990)]
+ got = ub.upcoming(bdays, TODAY, window=30)
+ assert got[0].days_away == 5
+
+
+def test_upcoming_excludes_beyond_window():
+ bdays = [ub.Birthday("Far", 9, 1, 1990)] # 45 days out
+ assert ub.upcoming(bdays, TODAY, window=30) == []
+
+
+def test_upcoming_boundary_day_30_included_day_31_excluded():
+ on = [ub.Birthday("On", 8, 17, 1990)] # exactly 30 days
+ off = [ub.Birthday("Off", 8, 18, 1990)] # 31 days
+ assert ub.upcoming(on, TODAY, window=30)[0].days_away == 30
+ assert ub.upcoming(off, TODAY, window=30) == []
+
+
+def test_upcoming_uses_next_year_when_this_years_passed():
+ # today is 2026-07-18; a Jan 5 birthday recurs on 2027-01-05
+ today = dt.date(2026, 12, 27)
+ bdays = [ub.Birthday("New Year", 1, 5, 1990)]
+ got = ub.upcoming(bdays, today, window=30)
+ assert got[0].date == dt.date(2027, 1, 5)
+ assert got[0].days_away == 9
+
+
+def test_upcoming_age_is_occurrence_year_minus_birth_year():
+ bdays = [ub.Birthday("Ager", 7, 23, 1970)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].age == 56 # 2026 - 1970
+
+
+def test_upcoming_age_none_for_placeholder():
+ bdays = [ub.Birthday("Placeholder", 7, 23, None)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].age is None
+
+
+def test_upcoming_sorted_by_days_away():
+ bdays = [
+ ub.Birthday("Later", 8, 10, 1990),
+ ub.Birthday("Sooner", 7, 20, 1990),
+ ]
+ got = ub.upcoming(bdays, TODAY)
+ assert [u.name for u in got] == ["Sooner", "Later"]
+
+
+def test_upcoming_leap_day_maps_to_feb_28_in_non_leap_year():
+ today = dt.date(2027, 2, 1) # 2027 is not a leap year
+ bdays = [ub.Birthday("Leapling", 2, 29, 2000)]
+ got = ub.upcoming(bdays, today, window=30)
+ assert got[0].date == dt.date(2027, 2, 28)
+
+
+# --- format_block -----------------------------------------------------------
+
+def test_format_block_empty_reports_none():
+ out = ub.format_block([], window=30)
+ assert "No birthdays" in out
+
+
+def test_format_block_callout_marks_within_seven_days():
+ items = [ub.Upcoming("Soon", dt.date(2026, 7, 22), 4, 40)]
+ out = ub.format_block(items, window=30, callout_days=7)
+ assert "⚠" in out
+ assert "Soon" in out
+ assert "40" in out # age shown
+
+
+def test_format_block_beyond_callout_is_not_flagged():
+ items = [ub.Upcoming("Later", dt.date(2026, 8, 10), 23, 30)]
+ out = ub.format_block(items, window=30, callout_days=7)
+ assert "⚠" not in out
+
+
+def test_format_block_placeholder_shows_date_only_no_age():
+ items = [ub.Upcoming("NoYear", dt.date(2026, 7, 25), 7, None)]
+ out = ub.format_block(items, window=30)
+ assert "NoYear" in out
+ assert "turns" not in out
+
+
+# --- CLI --------------------------------------------------------------------
+
+def test_cli_runs_against_a_fixture_file(tmp_path):
+ contacts = tmp_path / "contacts.org"
+ contacts.write_text(
+ "** Alice\n:PROPERTIES:\n:BIRTHDAY: 1990-07-20\n:END:\n"
+ "** Bob\n:PROPERTIES:\n:BIRTHDAY: 1900-12-01\n:END:\n"
+ )
+ res = subprocess.run(
+ [sys.executable, str(SCRIPT), "--file", str(contacts),
+ "--today", "2026-07-18", "--window", "30"],
+ capture_output=True, text=True,
+ )
+ assert res.returncode == 0
+ assert "Alice" in res.stdout
+ assert "Bob" not in res.stdout # Dec 1 is outside the 30-day window