This commit is contained in:
Mrunmayee Daware 2026-10-21 02:41:07 +00:00 • committed by GitHub
commit 070bc45344
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 76 additions and 5 deletions

View file

@ -295,11 +295,14 @@ def _handle_hour_reset(current_time: datetime, base_midnight: datetime, value: i
else:
next_hour = current_hour + value - (current_hour % value) if current_hour % value != 0 else current_hour + value
# Handle overnight case
# Handle overnight case. Carry past midnight with real date arithmetic instead
# of wrapping mod 24: for durations of a day or more (e.g. "48h") the wrap
# collapses the reset onto the next midnight, so the budget window comes out
# far shorter than configured on every reset. Anchoring at today's midnight
# keeps sub-day grids wall-clock identical and gives day-plus durations the
# same "from the start of today" convention the day handler uses for Nd.
if next_hour >= 24:
next_hour = next_hour % 24
next_day: Final = base_midnight + timedelta(days=1)
return next_day.replace(hour=next_hour)
return base_midnight + timedelta(hours=next_hour)
return current_time.replace(hour=next_hour, minute=0, second=0, microsecond=0)

View file

@ -1,5 +1,5 @@
import unittest
from datetime import datetime, time, timezone
from datetime import datetime, time, timedelta, timezone
from typing import Final
from unittest.mock import patch
from zoneinfo import ZoneInfo
@ -407,3 +407,71 @@ def test_a_month_starting_now_matches_duration_in_seconds():
if __name__ == "__main__":
unittest.main()
class TestMultiDayHourDurations(unittest.TestCase):
"""Hour durations of a day or more must not collapse onto the next midnight.
The overnight branch used to wrap the next boundary hour mod 24 into the
following day, so "48h" reset at the next midnight (a <24h window) and
chained resets kept enforcing ~daily windows. Sub-day grids are unchanged:
for <24h values the carried boundary is wall-clock identical to the old wrap.
"""
def test_48h_resets_two_days_from_midnight_not_next_midnight(self):
base = datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc)
self.assertEqual(
get_next_standardized_reset_time("48h", base, "UTC"),
datetime(2023, 5, 17, 0, 0, tzinfo=timezone.utc),
)
def test_72h_from_late_evening(self):
base = datetime(2023, 5, 15, 23, 30, tzinfo=timezone.utc)
self.assertEqual(
get_next_standardized_reset_time("72h", base, "UTC"),
datetime(2023, 5, 18, 0, 0, tzinfo=timezone.utc),
)
def test_25h_carries_past_midnight(self):
base = datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc)
self.assertEqual(
get_next_standardized_reset_time("25h", base, "UTC"),
datetime(2023, 5, 16, 1, 0, tzinfo=timezone.utc),
)
def test_chained_48h_resets_stay_48_hours_apart(self):
first = get_next_standardized_reset_time("48h", datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc), "UTC")
second = get_next_standardized_reset_time("48h", first, "UTC")
self.assertEqual(second - first, timedelta(hours=48))
def test_window_start_matches_the_reset_schedule(self):
reset_at = get_next_standardized_reset_time("48h", datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc), "UTC")
self.assertEqual(get_budget_window_start("48h", reset_at), reset_at - timedelta(hours=48))
def test_multi_day_hours_respect_configured_timezone(self):
base = datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc)
reset_at = get_next_standardized_reset_time("48h", base, "Asia/Kolkata")
self.assertEqual((reset_at.utcoffset(),), (timedelta(hours=5, minutes=30),))
self.assertEqual((reset_at.hour, reset_at.minute), (0, 0))
def test_sub_day_hour_grids_unchanged(self):
# 5h at 20:30 crosses midnight onto 01:00, as before
self.assertEqual(
get_next_standardized_reset_time("5h", datetime(2023, 5, 15, 20, 30, tzinfo=timezone.utc), "UTC"),
datetime(2023, 5, 16, 1, 0, tzinfo=timezone.utc),
)
# 2h mid-afternoon
self.assertEqual(
get_next_standardized_reset_time("2h", datetime(2023, 5, 15, 15, 20, 30, tzinfo=timezone.utc), "UTC"),
datetime(2023, 5, 15, 16, 0, tzinfo=timezone.utc),
)
# 24h still lands on the next midnight
self.assertEqual(
get_next_standardized_reset_time("24h", datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc), "UTC"),
datetime(2023, 5, 16, 0, 0, tzinfo=timezone.utc),
)
# exact boundary rolls to the next grid point, as before
self.assertEqual(
get_next_standardized_reset_time("7h", datetime(2023, 5, 15, 14, 0, tzinfo=timezone.utc), "UTC"),
datetime(2023, 5, 15, 21, 0, tzinfo=timezone.utc),
)