"""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