This commit is contained in:
Claude 2026-07-27 16:14:25 +00:00 committed by GitHub
commit f3f67066f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 4 deletions

View file

@ -408,6 +408,22 @@ def floor_to_quarter_hour(dt_value: datetime) -> datetime:
return dt_value.replace(minute=(dt_value.minute // INTERVAL_MINUTES) * INTERVAL_MINUTES, second=0, microsecond=0) return dt_value.replace(minute=(dt_value.minute // INTERVAL_MINUTES) * INTERVAL_MINUTES, second=0, microsecond=0)
def ceil_to_next_quarter_hour(dt_value: datetime) -> datetime:
"""Return the start of the next quarter-hour interval after dt_value.
Always advances past the interval that contains dt_value, even when
dt_value falls exactly on a boundary (in which case that interval is
considered "current" and is skipped).
This is used when include_current_interval=False to align the search start
with the first interval boundary in the pool index that has not yet started.
Without this, a raw ``now`` timestamp like ``14:47:00.167996`` would not
match any cached key (which are always on :00/:15/:30/:45) and would return
no intervals.
"""
return floor_to_quarter_hour(dt_value) + timedelta(minutes=INTERVAL_MINUTES)
def _resolve_time_with_day_offset( def _resolve_time_with_day_offset(
time_value: dt_time, time_value: dt_time,
day_offset: int, day_offset: int,
@ -451,7 +467,7 @@ def _resolve_scope(
tomorrow_start = today_start + timedelta(days=1) tomorrow_start = today_start + timedelta(days=1)
day_after_start = today_start + timedelta(days=2) day_after_start = today_start + timedelta(days=2)
rolling_start = floor_to_quarter_hour(now) if include_current else now rolling_start = floor_to_quarter_hour(now) if include_current else ceil_to_next_quarter_hour(now)
if scope == "today": if scope == "today":
return today_start, tomorrow_start return today_start, tomorrow_start
@ -608,8 +624,10 @@ def resolve_search_range(
search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"]) search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"])
if include_current: if include_current:
search_start = floor_to_quarter_hour(search_start) search_start = floor_to_quarter_hour(search_start)
else:
search_start = ceil_to_next_quarter_hour(search_start)
else: else:
search_start = floor_to_quarter_hour(now) if include_current else now search_start = floor_to_quarter_hour(now) if include_current else ceil_to_next_quarter_hour(now)
# --- Resolve end --- # --- Resolve end ---
if "search_end" in call_data: if "search_end" in call_data:

View file

@ -150,14 +150,17 @@ class TestResolveSearchRangeNegativeOffsetMinutes:
assert start.hour == 23 assert start.hour == 23
def test_search_scope_excludes_current_interval_when_disabled(self) -> None: def test_search_scope_excludes_current_interval_when_disabled(self) -> None:
"""Relative search scopes honor include_current_interval=false.""" """Relative search scopes ceil to next quarter boundary when include_current_interval=False."""
now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN) now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN)
call_data = { call_data = {
"search_scope": "next_24h", "search_scope": "next_24h",
"include_current_interval": False, "include_current_interval": False,
} }
start, end = resolve_search_range(call_data, now, BERLIN) start, end = resolve_search_range(call_data, now, BERLIN)
assert start == now # 14:37 is not on a quarter boundary → should ceil to 14:45
assert start.hour == 14
assert start.minute == 45
assert start.second == 0
assert end == now + timedelta(hours=24) assert end == now + timedelta(hours=24)
def test_search_scope_includes_current_interval_when_enabled(self) -> None: def test_search_scope_includes_current_interval_when_enabled(self) -> None:
@ -172,6 +175,54 @@ class TestResolveSearchRangeNegativeOffsetMinutes:
assert start.minute == 30 assert start.minute == 30
assert end == now + timedelta(hours=24) assert end == now + timedelta(hours=24)
def test_exclude_current_interval_with_sub_second_now(self) -> None:
"""Regression: microseconds in now caused no intervals to be returned.
When include_current_interval=False and now has sub-second precision
(e.g. 14:47:00.167996), the search_start must be ceiled to the next
quarter boundary (15:00) so it aligns with actual interval timestamps
in the pool index. Previously, raw now was used, which matched no
cached interval and returned no data.
"""
# Reproduce the exact scenario from the bug report: 14:47:00.167996+02:00
now = datetime(2026, 7, 27, 14, 47, 0, 167996, tzinfo=ZoneInfo("Europe/Berlin"))
call_data = {
"search_scope": "next_24h",
"include_current_interval": False,
}
start, end = resolve_search_range(call_data, now, ZoneInfo("Europe/Berlin"))
# 14:47:00.167996 → floor → 14:45 + 15min → 15:00
assert start.hour == 15
assert start.minute == 0
assert start.second == 0
assert start.microsecond == 0
def test_exclude_current_interval_already_on_boundary(self) -> None:
"""When now is exactly on a quarter boundary and include_current_interval=False,
the start is advanced to the NEXT boundary (the interval that hasn't started yet).
"""
# 14:45:00 exactly → the 14:45 interval is currently in progress
# → exclude it → ceil to 15:00
now = datetime(2026, 4, 11, 14, 45, 0, tzinfo=BERLIN)
call_data = {
"search_scope": "next_24h",
"include_current_interval": False,
}
start, end = resolve_search_range(call_data, now, BERLIN)
# 14:45:00 is exactly on a boundary → but the 14:45 interval is "current"
# The correct behaviour: include_current=False should start at 15:00
assert start.hour == 15
assert start.minute == 0
def test_exclude_current_default_no_scope_ceils_to_next_quarter(self) -> None:
"""Default path (no scope) with include_current_interval=False also ceils."""
now = datetime(2026, 4, 11, 14, 47, 30, tzinfo=BERLIN)
call_data = {"include_current_interval": False}
start, _end = resolve_search_range(call_data, now, BERLIN)
assert start.hour == 15
assert start.minute == 0
assert start.second == 0
# ============================================================================= # =============================================================================
# Schema validation: day_offset boundaries # Schema validation: day_offset boundaries