from datetime import date, time import pytest from timesheets.status import ( DayStatus, WeekStatus, _hours_to_time, _parse_hhmm, _time_to_hours, compute_day_status, compute_week_status, projected_hours_for_day, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class TestParseHhmm: def test_valid(self): assert _parse_hhmm("08:30") == time(8, 30) def test_single_digit_hour(self): assert _parse_hhmm("9:05") == time(9, 5) def test_invalid(self): assert _parse_hhmm("bad") is None assert _parse_hhmm("") is None def test_none_input(self): assert _parse_hhmm(None) is None def test_strips_whitespace(self): assert _parse_hhmm(" 08:30 ") == time(8, 30) class TestHoursToTime: def test_basic(self): assert _hours_to_time(8.5) == time(8, 30) def test_rounds_minutes(self): assert _hours_to_time(8.0 + 10 / 60) == time(8, 10) def test_clamps_hours_over_23(self): # 25h = 25:00 → clamped to 23:00 assert _hours_to_time(25.0) == time(23, 0) def test_clamps_with_minutes(self): # 24h 30min → hours clamped to 23, minutes stay as 30 → 23:30 assert _hours_to_time(24.0 + 30 / 60) == time(23, 30) # --------------------------------------------------------------------------- # compute_day_status # --------------------------------------------------------------------------- def _row( start: str, end: str | None, hours: float | None, project: str = "bugs" ) -> dict: return { "start": start, "end": end, "duration_hours": hours, "project": project, "story": "", "note": "", } def _closed(start: str, end: str, hours: float) -> dict: return _row(start, end, hours) def _open(start: str) -> dict: return _row(start, None, None) TODAY = date(2026, 5, 22) class TestComputeDayStatus: def test_empty_rows(self): s = compute_day_status([], TODAY) assert s.logged_hours == 0.0 assert s.remaining_hours == 8.0 assert s.has_open_entry is False assert s.day_start is None assert s.expected_end is None def test_all_closed_no_remaining(self): rows = [_closed("08:00", "16:00", 8.0)] s = compute_day_status(rows, TODAY) assert s.logged_hours == 8.0 assert s.remaining_hours == 0.0 assert s.expected_end == time(16, 0) def test_all_closed_with_remaining(self): rows = [_closed("08:00", "12:00", 4.0)] s = compute_day_status(rows, TODAY) assert s.remaining_hours == 4.0 # expected end = 12:00 + 4:00 = 16:00 assert s.expected_end == time(16, 0) def test_remaining_floored_at_zero(self): rows = [_closed("08:00", "17:00", 9.0)] s = compute_day_status(rows, TODAY) assert s.remaining_hours == 0.0 def test_custom_daily_target(self): rows = [_closed("08:00", "12:00", 4.0)] s = compute_day_status(rows, TODAY, daily_target=6.0) assert s.remaining_hours == 2.0 # --- Open entry as last entry --- def test_open_entry_last_expected_end(self): # 2h logged before open, open starts at 10:00 # remaining = 8 - 2 = 6h → end = 10:00 + 6:00 = 16:00 rows = [_closed("08:00", "10:00", 2.0), _open("10:00")] s = compute_day_status(rows, TODAY) assert s.has_open_entry is True assert s.open_entry_start == time(10, 0) assert s.remaining_hours == 6.0 assert s.expected_end == time(16, 0) def test_open_entry_only(self): # Nothing logged yet, open entry at 08:15 # remaining = 8h → end = 08:15 + 8:00 = 16:15 rows = [_open("08:15")] s = compute_day_status(rows, TODAY) assert s.logged_hours == 0.0 assert s.remaining_hours == 8.0 assert s.expected_end == time(16, 15) # --- Open entry with future closed entries after it --- def test_open_entry_with_future_entries(self): # 2h logged, open at 10:00, meeting 16:00-17:30 (1.5h) pre-logged # remaining = 8 - 2 - 1.5 = 4.5h # gap 10:00->16:00 = 6h, budget 4.5 fits -> expected_end = 14:30 # latest_end = 17:30 > 14:30 -> clamped to 17:30, flag set rows = [ _closed("08:00", "10:00", 2.0), _open("10:00"), _closed("16:00", "17:30", 1.5), ] s = compute_day_status(rows, TODAY) assert s.has_open_entry is True assert s.future_hours == 1.5 assert s.logged_hours == 3.5 assert s.remaining_hours == 4.5 assert s.latest_end == time(17, 30) assert s.expected_end == time(17, 30) assert s.latest_end_exceeds_expected is True def test_open_entry_gap_not_enough_extends_past_last_entry(self): # 4h logged, open at 13:00, meeting 14:00-15:00 (1h) pre-logged # remaining = 8 - 4 - 1 = 3h # Slots: gap 13:00->14:00 = 1h (budget 3->2), entry ends 15:00, then 2h left → 15:00+2 = 17:00 rows = [ _closed("08:00", "13:00", 4.0), _open("13:00"), _closed("14:00", "15:00", 1.0), ] s = compute_day_status(rows, TODAY) assert s.remaining_hours == 3.0 assert s.expected_end == time(17, 0) def test_open_entry_future_entries_cover_remaining(self): # 4h logged, open at 12:00, meeting 13:00-17:00 (4h) pre-logged # remaining = 8 - 4 - 4 = 0 → expected end = 17:00 rows = [ _closed("08:00", "12:00", 4.0), _open("12:00"), _closed("13:00", "17:00", 4.0), ] s = compute_day_status(rows, TODAY) assert s.remaining_hours == 0.0 assert s.expected_end == time(17, 0) def test_day_start_from_earliest(self): rows = [_closed("09:00", "10:00", 1.0), _closed("08:00", "09:00", 1.0)] s = compute_day_status(rows, TODAY) assert s.day_start == time(8, 0) # --------------------------------------------------------------------------- # compute_week_status # --------------------------------------------------------------------------- MON = date(2026, 5, 18) TUE = date(2026, 5, 19) WED = date(2026, 5, 20) THU = date(2026, 5, 21) FRI = date(2026, 5, 22) def _day_section(d: date, hours: float) -> tuple[date, list[dict]]: return (d, [_closed("08:00", "16:00", hours)]) # --------------------------------------------------------------------------- # projected_hours_for_day # --------------------------------------------------------------------------- class TestProjectedHoursForDay: def test_all_closed(self): rows = [_closed("08:00", "12:00", 4.0), _closed("13:00", "17:00", 4.0)] assert projected_hours_for_day(rows) == 8.0 def test_open_entry_with_next(self): rows = [ _closed("08:00", "13:00", 5.0), _open("13:00"), _closed("16:00", "17:30", 1.5), ] assert projected_hours_for_day(rows) == 5.0 + 3.0 + 1.5 def test_open_entry_last_contributes_zero(self): rows = [_closed("08:00", "13:00", 5.0), _open("13:00")] assert projected_hours_for_day(rows) == 5.0 def test_empty(self): assert projected_hours_for_day([]) == 0.0 # --------------------------------------------------------------------------- # compute_week_status # --------------------------------------------------------------------------- class TestComputeWeekStatus: def test_all_closed(self): sections = [_day_section(MON, 8.0), _day_section(TUE, 8.0)] s = compute_week_status(sections, FRI) assert s.total_logged == 16.0 assert s.projected_total == 16.0 assert s.on_track is False def test_full_week_on_track(self): sections = [_day_section(d, 8.0) for d in [MON, TUE, WED, THU, FRI]] s = compute_week_status(sections, FRI) assert s.projected_total == 40.0 assert s.on_track is True def test_open_entry_projected_with_next(self): # Fri: 5h closed before open, open at 13:00, next at 16:00 (3h gap), then 1.5h # projected for Fri = 5 + 3 + 1.5 = 9.5 fri_rows = [ _closed("08:00", "13:00", 5.0), _open("13:00"), _closed("16:00", "17:30", 1.5), ] sections = [_day_section(d, 8.0) for d in [MON, TUE, WED, THU]] + [ (FRI, fri_rows) ] s = compute_week_status(sections, FRI) assert s.projected_total == 32.0 + 9.5 def test_remaining_floored_at_zero(self): sections = [_day_section(d, 9.0) for d in [MON, TUE, WED, THU, FRI]] s = compute_week_status(sections, FRI) assert s.total_logged == 45.0 assert s.remaining_hours == 0.0 def test_empty_week(self): s = compute_week_status([], FRI) assert s.total_logged == 0.0 assert s.days_worked == 0 assert s.daily_average is None assert s.projected_total == 0.0 def test_open_entry_excluded_from_logged_total(self): sections = [(MON, [_closed("08:00", "10:00", 2.0), _open("10:00")])] s = compute_week_status(sections, MON) # logged only counts closed entries assert s.total_logged == 2.0 # projected: open entry last -> contributes 0, so projected = 2.0 assert s.projected_total == 2.0 def test_week_bounds(self): sections = [_day_section(WED, 8.0)] s = compute_week_status(sections, WED) assert s.week_start == MON assert s.week_end == FRI