1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
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
|