Skip to content

Commit 7b0de9d

Browse files
Add timeline generator tests (34 tests)
Covers _parse_date, _add_months, and generate_timeline including edge cases for date clamping, leap years, deduplication, and chronological ordering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dc0a603 commit 7b0de9d

1 file changed

Lines changed: 350 additions & 0 deletions

File tree

tests/test_timeline_generator.py

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
"""Tests for core.timeline_generator."""
2+
3+
import pytest
4+
from datetime import date
5+
6+
from core.models import DeadlineRound, ProgramData
7+
from core.timeline_generator import (
8+
_parse_date,
9+
_add_months,
10+
generate_timeline,
11+
PRIORITY_CRITICAL,
12+
PRIORITY_HIGH,
13+
PRIORITY_MEDIUM,
14+
CAT_DEADLINE,
15+
CAT_APPLICATION,
16+
CAT_DECISION,
17+
CAT_TEST,
18+
CAT_INTERVIEW,
19+
)
20+
21+
22+
# ===================================================================
23+
# _parse_date
24+
# ===================================================================
25+
26+
class TestParseDate:
27+
"""Test YYYY-MM-DD date parsing."""
28+
29+
def test_valid_date(self) -> None:
30+
result = _parse_date("2026-01-15")
31+
assert result == date(2026, 1, 15)
32+
33+
def test_valid_date_single_digits(self) -> None:
34+
"""Even though format expects padding, int() conversion handles single digits."""
35+
result = _parse_date("2026-1-5")
36+
assert result == date(2026, 1, 5)
37+
38+
def test_invalid_date_bad_month(self) -> None:
39+
result = _parse_date("2026-13-01")
40+
assert result is None
41+
42+
def test_invalid_date_bad_day(self) -> None:
43+
result = _parse_date("2026-02-30")
44+
assert result is None
45+
46+
def test_invalid_format(self) -> None:
47+
result = _parse_date("not-a-date")
48+
assert result is None
49+
50+
def test_empty_string(self) -> None:
51+
result = _parse_date("")
52+
assert result is None
53+
54+
def test_partial_string(self) -> None:
55+
result = _parse_date("2026-01")
56+
assert result is None
57+
58+
def test_leap_year(self) -> None:
59+
result = _parse_date("2024-02-29")
60+
assert result == date(2024, 2, 29)
61+
62+
def test_non_leap_year_feb_29(self) -> None:
63+
result = _parse_date("2025-02-29")
64+
assert result is None
65+
66+
67+
# ===================================================================
68+
# _add_months
69+
# ===================================================================
70+
71+
class TestAddMonths:
72+
"""Test calendar-month addition with day clamping."""
73+
74+
def test_add_one_month(self) -> None:
75+
result = _add_months(date(2026, 1, 15), 1)
76+
assert result == date(2026, 2, 15)
77+
78+
def test_add_negative_months(self) -> None:
79+
result = _add_months(date(2026, 6, 15), -3)
80+
assert result == date(2026, 3, 15)
81+
82+
def test_cross_year_boundary_forward(self) -> None:
83+
result = _add_months(date(2025, 11, 15), 3)
84+
assert result == date(2026, 2, 15)
85+
86+
def test_cross_year_boundary_backward(self) -> None:
87+
result = _add_months(date(2026, 2, 15), -3)
88+
assert result == date(2025, 11, 15)
89+
90+
def test_day_clamping_jan31_plus_one(self) -> None:
91+
"""Jan 31 + 1 month -> Feb 28 (non-leap) due to clamping."""
92+
result = _add_months(date(2025, 1, 31), 1)
93+
assert result == date(2025, 2, 28)
94+
95+
def test_day_clamping_leap_year(self) -> None:
96+
"""Jan 31 + 1 month in a leap year -> Feb 29."""
97+
result = _add_months(date(2024, 1, 31), 1)
98+
assert result == date(2024, 2, 29)
99+
100+
def test_add_twelve_months(self) -> None:
101+
result = _add_months(date(2025, 6, 15), 12)
102+
assert result == date(2026, 6, 15)
103+
104+
def test_add_zero_months(self) -> None:
105+
d = date(2026, 3, 15)
106+
result = _add_months(d, 0)
107+
assert result == d
108+
109+
def test_day_clamping_march31_minus_one(self) -> None:
110+
"""March 31 - 1 month -> Feb 28 (non-leap year)."""
111+
result = _add_months(date(2025, 3, 31), -1)
112+
assert result == date(2025, 2, 28)
113+
114+
115+
# ===================================================================
116+
# generate_timeline
117+
# ===================================================================
118+
119+
class TestGenerateTimeline:
120+
"""Test the full timeline generation pipeline."""
121+
122+
def _make_program(
123+
self,
124+
name: str = "TestProg",
125+
rounds: list[dict] | None = None,
126+
interview_type: str = "",
127+
interview_format: str = "",
128+
) -> ProgramData:
129+
deadline_rounds = []
130+
if rounds:
131+
for r in rounds:
132+
deadline_rounds.append(DeadlineRound(
133+
round=r.get("round", 1),
134+
date=r.get("date", ""),
135+
decision_by=r.get("decision_by", ""),
136+
))
137+
return ProgramData(
138+
id=name.lower().replace(" ", "-"),
139+
name=name,
140+
deadline_rounds=deadline_rounds,
141+
interview_type=interview_type,
142+
interview_format=interview_format,
143+
)
144+
145+
def test_empty_programs(self) -> None:
146+
"""No programs -> no timeline items."""
147+
result = generate_timeline([], start_date=date(2026, 1, 1))
148+
assert result == []
149+
150+
def test_program_with_no_deadlines(self) -> None:
151+
"""A program with no deadline rounds produces no items."""
152+
prog = self._make_program("NoDeadlines", rounds=[])
153+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
154+
assert result == []
155+
156+
def test_basic_deadline_appears(self) -> None:
157+
"""A program deadline should appear as a critical deadline item."""
158+
prog = self._make_program("CMU MSCF", rounds=[
159+
{"round": 1, "date": "2026-11-01"},
160+
])
161+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
162+
163+
deadline_items = [
164+
i for i in result if i["category"] == CAT_DEADLINE
165+
]
166+
assert len(deadline_items) >= 1
167+
assert any("Round 1 application deadline" in i["action"] for i in deadline_items)
168+
169+
def test_submit_reminder_one_week_before(self) -> None:
170+
"""A submit reminder should appear ~7 days before the deadline."""
171+
prog = self._make_program("TestProg", rounds=[
172+
{"round": 1, "date": "2026-11-15"},
173+
])
174+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
175+
176+
reminders = [
177+
i for i in result
178+
if i["category"] == CAT_APPLICATION and "Finalise and submit" in i["action"]
179+
]
180+
assert len(reminders) >= 1
181+
# Should be dated 2026-11-08 (7 days before 2026-11-15)
182+
assert reminders[0]["date"] == "2026-11-08"
183+
184+
def test_decision_date_appears(self) -> None:
185+
"""If a round has a decision_by date, it should appear in the timeline."""
186+
prog = self._make_program("TestProg", rounds=[
187+
{"round": 1, "date": "2026-11-01", "decision_by": "2027-02-01"},
188+
])
189+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
190+
191+
decisions = [i for i in result if i["category"] == CAT_DECISION]
192+
assert len(decisions) >= 1
193+
assert any("decision expected" in i["action"] for i in decisions)
194+
195+
def test_preparation_milestones_included(self) -> None:
196+
"""GRE prep, TOEFL, essays, recommendations milestones should appear."""
197+
prog = self._make_program("TestProg", rounds=[
198+
{"round": 1, "date": "2026-12-01"},
199+
])
200+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
201+
202+
categories = {i["category"] for i in result}
203+
assert CAT_TEST in categories # GRE/TOEFL prep
204+
205+
actions = [i["action"] for i in result]
206+
action_text = " ".join(actions)
207+
assert "GRE" in action_text
208+
assert "essay" in action_text.lower()
209+
assert "recommender" in action_text.lower()
210+
211+
def test_chronological_order(self) -> None:
212+
"""All items should be sorted by date."""
213+
prog = self._make_program("TestProg", rounds=[
214+
{"round": 1, "date": "2026-11-01"},
215+
{"round": 2, "date": "2027-01-15"},
216+
])
217+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
218+
219+
dates = [i["date"] for i in result]
220+
assert dates == sorted(dates)
221+
222+
def test_no_duplicates(self) -> None:
223+
"""Items should be deduplicated by (date, action) key."""
224+
prog = self._make_program("TestProg", rounds=[
225+
{"round": 1, "date": "2026-11-01"},
226+
])
227+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
228+
229+
keys = [(i["date"], i["action"]) for i in result]
230+
assert len(keys) == len(set(keys))
231+
232+
def test_items_not_before_start_date(self) -> None:
233+
"""Preparation milestones should not appear before the start_date.
234+
235+
Note: deadline items themselves can be at any date (they are programme facts),
236+
but preparation reminders are clipped to start_date.
237+
"""
238+
prog = self._make_program("TestProg", rounds=[
239+
{"round": 1, "date": "2026-03-01"},
240+
])
241+
# Start date is very close to the deadline
242+
result = generate_timeline([prog], start_date=date(2026, 2, 15))
243+
244+
for item in result:
245+
# All items should be on or after start_date (except
246+
# deadline items which are fixed programme dates)
247+
if item["category"] not in (CAT_DEADLINE,):
248+
assert item["date"] >= "2026-02-15"
249+
250+
def test_interview_prep_appears(self) -> None:
251+
"""Programs with interview_type should generate interview prep items."""
252+
prog = self._make_program(
253+
"TestProg",
254+
rounds=[{"round": 1, "date": "2026-12-01"}],
255+
interview_type="virtual",
256+
interview_format="30-minute behavioral",
257+
)
258+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
259+
260+
interview_items = [i for i in result if i["category"] == CAT_INTERVIEW]
261+
assert len(interview_items) >= 1
262+
assert "virtual" in interview_items[0]["action"]
263+
assert "30-minute behavioral" in interview_items[0]["action"]
264+
265+
def test_no_interview_when_type_empty(self) -> None:
266+
"""Programs without interview_type should NOT generate interview items."""
267+
prog = self._make_program(
268+
"TestProg",
269+
rounds=[{"round": 1, "date": "2026-12-01"}],
270+
interview_type="",
271+
)
272+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
273+
274+
interview_items = [i for i in result if i["category"] == CAT_INTERVIEW]
275+
assert len(interview_items) == 0
276+
277+
def test_multiple_programs(self) -> None:
278+
"""Timeline should incorporate deadlines from multiple programs."""
279+
prog1 = self._make_program("Prog A", rounds=[
280+
{"round": 1, "date": "2026-11-01"},
281+
])
282+
prog2 = self._make_program("Prog B", rounds=[
283+
{"round": 1, "date": "2026-12-01"},
284+
])
285+
result = generate_timeline([prog1, prog2], start_date=date(2026, 1, 1))
286+
287+
deadline_items = [i for i in result if i["category"] == CAT_DEADLINE]
288+
program_names = {i["action"].split("]")[0].strip("[") for i in deadline_items}
289+
assert "Prog A" in program_names
290+
assert "Prog B" in program_names
291+
292+
def test_output_item_structure(self) -> None:
293+
"""Each item should have date, action, category, priority keys."""
294+
prog = self._make_program("TestProg", rounds=[
295+
{"round": 1, "date": "2026-11-01"},
296+
])
297+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
298+
299+
for item in result:
300+
assert "date" in item
301+
assert "action" in item
302+
assert "category" in item
303+
assert "priority" in item
304+
305+
def test_invalid_deadline_date_skipped(self) -> None:
306+
"""Rounds with unparseable dates should be silently skipped."""
307+
prog = self._make_program("TestProg", rounds=[
308+
{"round": 1, "date": "invalid-date"},
309+
{"round": 2, "date": "2026-12-01"},
310+
])
311+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
312+
313+
# Only round 2 should produce a deadline
314+
deadline_items = [i for i in result if i["category"] == CAT_DEADLINE]
315+
assert len(deadline_items) == 1
316+
assert "Round 2" in deadline_items[0]["action"]
317+
318+
def test_default_start_date_is_today(self) -> None:
319+
"""When start_date is None, it defaults to today."""
320+
prog = self._make_program("TestProg", rounds=[
321+
{"round": 1, "date": "2030-12-01"},
322+
])
323+
# Not passing start_date, so it defaults to date.today()
324+
result = generate_timeline([prog])
325+
# Should produce items without error
326+
assert isinstance(result, list)
327+
assert len(result) > 0
328+
329+
def test_priority_ordering_same_date(self) -> None:
330+
"""When items share a date, critical should come before high/medium."""
331+
prog = self._make_program("TestProg", rounds=[
332+
{"round": 1, "date": "2026-11-01"},
333+
])
334+
result = generate_timeline([prog], start_date=date(2026, 1, 1))
335+
336+
# Find items sharing a date
337+
from collections import Counter
338+
date_counts = Counter(i["date"] for i in result)
339+
for d, count in date_counts.items():
340+
if count > 1:
341+
items_on_date = [i for i in result if i["date"] == d]
342+
priority_order = {
343+
PRIORITY_CRITICAL: 0,
344+
PRIORITY_HIGH: 1,
345+
PRIORITY_MEDIUM: 2,
346+
}
347+
priorities = [
348+
priority_order.get(i["priority"], 9) for i in items_on_date
349+
]
350+
assert priorities == sorted(priorities)

0 commit comments

Comments
 (0)