Просмотр исходного кода

Fix API backend current/next selection to use datetime windows, not date keys

The previous implementation used data.get(str(today)) to select current and
a bare checkin_date > str(today) string comparison to select next. This caused
mode-sync transitions to fire at midnight on a guest's check-in date rather
than at their actual check-in time, because the date key rolled over even
though the outgoing guest's checkout_time hadn't been reached yet.

Both backends now use the same invariant: current = reservation whose window
is active (check_in_datetime <= now < check_out_datetime); next = first with
check_in_datetime > now. Multi-day stays that repeat across date keys are
deduplicated by (check_in_date, check_in_time) before window selection.

Tests updated to use time-independent date ranges so they pass regardless of
when in the day they run; added regression test pinning `now` to 09:00 to
verify the midnight-promotion bug is fixed.
ZiB 4 месяцев назад
Родитель
Сommit
49135d6701
2 измененных файлов с 80 добавлено и 20 удалено
  1. 32 13
      custom_components/room_cal_ha/backends/api.py
  2. 48 7
      tests/test_api_backend.py

+ 32 - 13
custom_components/room_cal_ha/backends/api.py

@@ -1,7 +1,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import logging
 import logging
-from datetime import date, time, timedelta
+from datetime import date, datetime, time, timedelta
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
 from homeassistant.helpers.aiohttp_client import async_get_clientsession
 from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -47,24 +47,43 @@ class RoomCalApiBackend(ReservationBackend):
     except Exception as exc:
     except Exception as exc:
       raise BackendConnectError(str(exc)) from exc
       raise BackendConnectError(str(exc)) from exc
 
 
-    current_raw = data.get(str(today))
+    now = datetime.now()  # naive local datetime for window comparisons
 
 
-    seen_checkins: set[str] = set()
-    if current_raw:
-      seen_checkins.add(current_raw.get("checkin_date", ""))
-
-    next_raw: dict | None = None
+    # Parse all distinct reservations (deduplicate by check-in datetime since multi-day
+    # stays appear once per date key in the API response).
+    seen_checkins: set[tuple] = set()
+    all_reservations: list[ReservationData] = []
     for d_str in sorted(data.keys()):
     for d_str in sorted(data.keys()):
       slot = data[d_str]
       slot = data[d_str]
       if slot is None:
       if slot is None:
         continue
         continue
-      checkin = slot.get("checkin_date", "")
-      if checkin > str(today) and checkin not in seen_checkins:
-        seen_checkins.add(checkin)
-        next_raw = slot
-        break
+      res = _parse_reservation(slot)
+      if res is None:
+        continue
+      key = (res.check_in_date, res.check_in_time)
+      if key in seen_checkins:
+        continue
+      seen_checkins.add(key)
+      all_reservations.append(res)
 
 
-    return _parse_reservation(current_raw), _parse_reservation(next_raw)
+    current: ReservationData | None = None
+    nxt: ReservationData | None = None
+
+    for res in all_reservations:
+      if not res.check_in_date or not res.check_in_time:
+        continue
+      check_in = datetime.combine(res.check_in_date, res.check_in_time)
+      check_out = (
+        datetime.combine(res.check_out_date, res.check_out_time)
+        if res.check_out_date and res.check_out_time
+        else None
+      )
+      if check_in <= now and (check_out is None or now < check_out):
+        current = res
+      elif check_in > now and nxt is None:
+        nxt = res
+
+    return current, nxt
 
 
   async def async_fetch_status(self) -> ApiStatusData:
   async def async_fetch_status(self) -> ApiStatusData:
     session = async_get_clientsession(self._hass)
     session = async_get_clientsession(self._hass)

+ 48 - 7
tests/test_api_backend.py

@@ -47,13 +47,13 @@ def _mock_session_raises(exc):
   return session
   return session
 
 
 
 
-def _reservation_slot(guest, checkin, checkout):
+def _reservation_slot(guest, checkin, checkout, checkin_time="14:00", checkout_time="11:00"):
   return {
   return {
     "guest_name": guest,
     "guest_name": guest,
     "checkin_date": str(checkin),
     "checkin_date": str(checkin),
-    "checkin_time": "14:00",
+    "checkin_time": checkin_time,
     "checkout_date": str(checkout),
     "checkout_date": str(checkout),
-    "checkout_time": "11:00",
+    "checkout_time": checkout_time,
     "wifi_ssid": "GuestNet",
     "wifi_ssid": "GuestNet",
     "wifi_key": "wifikey",
     "wifi_key": "wifikey",
     "door_code": "1234",
     "door_code": "1234",
@@ -105,9 +105,11 @@ def test_parse_reservation_missing_dates_gives_none_fields():
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
 async def test_fetch_current_today_no_next(hass: HomeAssistant):
 async def test_fetch_current_today_no_next(hass: HomeAssistant):
+  # checkin yesterday at 14:00, checkout tomorrow at 11:00 — window always active
   today = date.today()
   today = date.today()
+  yesterday = today - timedelta(days=1)
   tomorrow = today + timedelta(days=1)
   tomorrow = today + timedelta(days=1)
-  response = {str(today): _reservation_slot("Alice", today, tomorrow)}
+  response = {str(today): _reservation_slot("Alice", yesterday, tomorrow)}
 
 
   session = _mock_session(200, response)
   session = _mock_session(200, response)
   entry = _make_entry()
   entry = _make_entry()
@@ -142,12 +144,15 @@ async def test_fetch_no_current_next_upcoming(hass: HomeAssistant):
 
 
 
 
 async def test_fetch_both_current_and_next(hass: HomeAssistant):
 async def test_fetch_both_current_and_next(hass: HomeAssistant):
+  # Alice: window always active (checked in yesterday, out tomorrow)
+  # Bob: future (5 days from now)
   today = date.today()
   today = date.today()
+  yesterday = today - timedelta(days=1)
   tomorrow = today + timedelta(days=1)
   tomorrow = today + timedelta(days=1)
   future = today + timedelta(days=5)
   future = today + timedelta(days=5)
   future_end = today + timedelta(days=8)
   future_end = today + timedelta(days=8)
   response = {
   response = {
-    str(today): _reservation_slot("Alice", today, tomorrow),
+    str(today): _reservation_slot("Alice", yesterday, tomorrow),
     str(future): _reservation_slot("Bob", future, future_end),
     str(future): _reservation_slot("Bob", future, future_end),
   }
   }
 
 
@@ -162,10 +167,12 @@ async def test_fetch_both_current_and_next(hass: HomeAssistant):
 
 
 
 
 async def test_fetch_multiday_current_not_duplicated_as_next(hass: HomeAssistant):
 async def test_fetch_multiday_current_not_duplicated_as_next(hass: HomeAssistant):
-  """A reservation spanning multiple days should produce one current, not also be treated as next."""
+  """A multi-day stay appearing on multiple date keys should produce one current, not also next."""
+  # checkin yesterday, checkout in 3 days — window always active regardless of test time
   today = date.today()
   today = date.today()
+  yesterday = today - timedelta(days=1)
   checkout = today + timedelta(days=3)
   checkout = today + timedelta(days=3)
-  slot = _reservation_slot("Alice", today, checkout)
+  slot = _reservation_slot("Alice", yesterday, checkout)
   # Same reservation appears on each day it spans
   # Same reservation appears on each day it spans
   response = {
   response = {
     str(today): slot,
     str(today): slot,
@@ -183,6 +190,40 @@ async def test_fetch_multiday_current_not_duplicated_as_next(hass: HomeAssistant
   assert nxt is None
   assert nxt is None
 
 
 
 
+async def test_fetch_current_not_promoted_before_checkin_time(hass: HomeAssistant):
+  """Next guest's slot should not become current just because their check-in DATE is today."""
+  # Guest A: checked in 2 days ago, checks out today at 23:59 — window active
+  # Guest B: checks in today at 14:00 — should be next, not current
+  # This covers the midnight-transition bug where a date-key lookup would wrongly
+  # promote Guest B to current at midnight of their check-in date.
+  from datetime import datetime as _dt, time as _time
+  today = date.today()
+  two_days_ago = today - timedelta(days=2)
+  tomorrow = today + timedelta(days=1)
+  response = {
+    str(today): _reservation_slot("Alice", two_days_ago, today, checkout_time="23:59"),
+    str(tomorrow): _reservation_slot("Bob", today, tomorrow, checkin_time="14:00"),
+  }
+
+  # Pin now to 09:00 — before Alice's checkout AND before Bob's check-in
+  fake_now = _dt.combine(today, _time(9, 0))
+  session = _mock_session(200, response)
+  backend = RoomCalApiBackend(hass, _make_entry())
+
+  with (
+    patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
+    patch("custom_components.room_cal_ha.backends.api.datetime") as mock_dt,
+  ):
+    mock_dt.now.return_value = fake_now
+    mock_dt.combine = _dt.combine
+    current, nxt = await backend.async_fetch()
+
+  assert current is not None
+  assert current.guest_name == "Alice"
+  assert nxt is not None
+  assert nxt.guest_name == "Bob"
+
+
 async def test_fetch_empty_response(hass: HomeAssistant):
 async def test_fetch_empty_response(hass: HomeAssistant):
   session = _mock_session(200, {})
   session = _mock_session(200, {})
   backend = RoomCalApiBackend(hass, _make_entry())
   backend = RoomCalApiBackend(hass, _make_entry())