Pārlūkot izejas kodu

Add GuestStateDiff and reservation_diff() for differential guest-mode reconciliation

Pure data layer: given old/new ReservationData and a naive local now, computes
what check-in/checkout/wifi actions are needed. Designed to be wired into all
coordinator paths that currently lack reconciliation (backend fetch, service call,
promote, clear, end-override). Gaps tracked in memory for next session.
ZiB 4 mēneši atpakaļ
vecāks
revīzija
62df7a7f54
2 mainītis faili ar 196 papildinājumiem un 2 dzēšanām
  1. 71 0
      custom_components/room_cal_ha/models.py
  2. 125 2
      tests/test_models.py

+ 71 - 0
custom_components/room_cal_ha/models.py

@@ -5,6 +5,27 @@ from datetime import date, datetime, time
 from typing import Any
 from typing import Any
 
 
 
 
+@dataclass
+class GuestStateDiff:
+  """What actions are needed when transitioning from one reservation state to another.
+
+  checkout_needed  — run check-out actions for `departing`
+  checkin_needed   — run check-in actions for the incoming reservation
+  wifi_key_changed — same reservation window, WiFi key changed (re-push key only)
+  wifi_ssid_changed — same reservation window, SSID changed (re-enable + re-push key)
+  departing        — the reservation being vacated (set when checkout_needed is True)
+  """
+  checkout_needed: bool = False
+  checkin_needed: bool = False
+  wifi_key_changed: bool = False
+  wifi_ssid_changed: bool = False
+  departing: ReservationData | None = None
+
+  @property
+  def has_changes(self) -> bool:
+    return self.checkout_needed or self.checkin_needed or self.wifi_key_changed or self.wifi_ssid_changed
+
+
 @dataclass
 @dataclass
 class ApiStatusData:
 class ApiStatusData:
   status: str = "unknown"
   status: str = "unknown"
@@ -71,3 +92,53 @@ class ReservationData:
       else:
       else:
         current[k] = v
         current[k] = v
     return ReservationData.from_dict(current)
     return ReservationData.from_dict(current)
+
+
+def reservation_diff(
+  old: ReservationData | None,
+  new: ReservationData | None,
+  now: datetime,
+) -> GuestStateDiff:
+  """Compute what guest-mode actions are needed when moving from old to new reservation state.
+
+  `now` must be a naive local datetime. `old` is the reservation that drove the current
+  guest-mode state; `new` is what should be reflected after the state change.
+  """
+  old_active = _is_window_active(old, now)
+  new_active = _is_window_active(new, now)
+
+  if not old_active and not new_active:
+    return GuestStateDiff()
+
+  if old_active and not new_active:
+    return GuestStateDiff(checkout_needed=True, departing=old)
+
+  if not old_active and new_active:
+    return GuestStateDiff(checkin_needed=True)
+
+  # Both active — same reservation window if check-in datetime matches.
+  assert old is not None and new is not None
+  same_window = old.check_in_date == new.check_in_date and old.check_in_time == new.check_in_time
+  if not same_window:
+    return GuestStateDiff(checkout_needed=True, checkin_needed=True, departing=old)
+
+  return GuestStateDiff(
+    wifi_key_changed=old.wifi_key != new.wifi_key,
+    wifi_ssid_changed=old.wifi_ssid != new.wifi_ssid,
+  )
+
+
+def _is_window_active(res: ReservationData | None, now: datetime) -> bool:
+  """Return True if now falls within the reservation's check-in/check-out window.
+
+  Matches _reservation_window_state semantics: active once check_in is reached,
+  until check_out (if present) is reached.
+  """
+  if not res or not res.check_in_date or not res.check_in_time:
+    return False
+  if datetime.combine(res.check_in_date, res.check_in_time) > now:
+    return False
+  if res.check_out_date and res.check_out_time:
+    if datetime.combine(res.check_out_date, res.check_out_time) <= now:
+      return False
+  return True

+ 125 - 2
tests/test_models.py

@@ -1,8 +1,8 @@
-from datetime import date, time
+from datetime import date, datetime, time
 
 
 import pytest
 import pytest
 
 
-from custom_components.room_cal_ha.models import OverrideData, ReservationData
+from custom_components.room_cal_ha.models import GuestStateDiff, OverrideData, ReservationData, reservation_diff
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -171,3 +171,126 @@ def test_override_data_with_next():
   o = OverrideData(current=_RES_A, next=_RES_B)
   o = OverrideData(current=_RES_A, next=_RES_B)
   assert o.current.guest_name == "Alice"
   assert o.current.guest_name == "Alice"
   assert o.next.guest_name == "Bob"
   assert o.next.guest_name == "Bob"
+
+
+# ---------------------------------------------------------------------------
+# reservation_diff
+# ---------------------------------------------------------------------------
+
+def _res(checkin_h: int, checkout_h: int, *, wifi_key="key", wifi_ssid="ssid") -> ReservationData:
+  """Build a reservation on 2026-07-01 with given check-in/out hours."""
+  return ReservationData(
+    guest_name="Guest",
+    check_in_date=date(2026, 7, 1),
+    check_in_time=time(checkin_h, 0),
+    check_out_date=date(2026, 7, 1),
+    check_out_time=time(checkout_h, 0),
+    wifi_ssid=wifi_ssid,
+    wifi_key=wifi_key,
+  )
+
+
+_DURING = datetime(2026, 7, 1, 15, 0)   # 15:00 — inside a 14:00–12:00 window
+_BEFORE = datetime(2026, 7, 1, 12, 0)   # 12:00 — before 14:00 check-in
+_AFTER  = datetime(2026, 7, 1, 22, 0)   # 22:00 — after typical check-out
+
+
+def test_diff_both_inactive_returns_no_changes():
+  diff = reservation_diff(None, None, _DURING)
+  assert not diff.has_changes
+
+
+def test_diff_old_none_new_active_returns_checkin():
+  res = _res(14, 20)
+  diff = reservation_diff(None, res, _DURING)
+  assert diff.checkin_needed
+  assert not diff.checkout_needed
+  assert diff.departing is None
+
+
+def test_diff_old_active_new_none_returns_checkout():
+  res = _res(14, 20)
+  diff = reservation_diff(res, None, _DURING)
+  assert diff.checkout_needed
+  assert not diff.checkin_needed
+  assert diff.departing is res
+
+
+def test_diff_both_elapsed_returns_no_changes():
+  # Window already passed for both — time-based handler owns this transition, not diff.
+  old = _res(14, 20)
+  diff = reservation_diff(old, old, _AFTER)
+  assert not diff.has_changes
+
+
+def test_diff_backend_clears_active_reservation_returns_checkout():
+  # Backend returns None while we're still inside the window → checkout needed.
+  old = _res(14, 20)
+  now = datetime(2026, 7, 1, 16, 0)
+  diff = reservation_diff(old, None, now)
+  assert diff.checkout_needed
+  assert not diff.checkin_needed
+  assert diff.departing is old
+
+
+def test_diff_same_window_no_changes_returns_empty():
+  res = _res(14, 20)
+  diff = reservation_diff(res, res, _DURING)
+  assert not diff.has_changes
+
+
+def test_diff_same_window_wifi_key_changed():
+  old = _res(14, 20, wifi_key="old-key")
+  new = _res(14, 20, wifi_key="new-key")
+  diff = reservation_diff(old, new, _DURING)
+  assert diff.wifi_key_changed
+  assert not diff.wifi_ssid_changed
+  assert not diff.checkin_needed
+  assert not diff.checkout_needed
+
+
+def test_diff_same_window_wifi_ssid_changed():
+  old = _res(14, 20, wifi_ssid="OldNet")
+  new = _res(14, 20, wifi_ssid="NewNet")
+  diff = reservation_diff(old, new, _DURING)
+  assert diff.wifi_ssid_changed
+  assert not diff.wifi_key_changed
+
+
+def test_diff_different_window_both_active_returns_checkout_and_checkin():
+  old = ReservationData(
+    check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
+    check_out_date=date(2026, 7, 2), check_out_time=time(12, 0),
+    wifi_ssid="s", wifi_key="k",
+  )
+  new = ReservationData(
+    check_in_date=date(2026, 7, 1), check_in_time=time(10, 0),
+    check_out_date=date(2026, 7, 2), check_out_time=time(12, 0),
+    wifi_ssid="s", wifi_key="k",
+  )
+  now = datetime(2026, 7, 1, 15, 0)  # after both check-ins
+  diff = reservation_diff(old, new, now)
+  assert diff.checkout_needed
+  assert diff.checkin_needed
+  assert diff.departing is old
+
+
+def test_diff_inactive_old_becomes_active_new_returns_checkin():
+  res = _res(14, 20)
+  diff = reservation_diff(res, res, _BEFORE)   # same record, but we're before check-in
+  # old is inactive (before window), new is inactive too → no changes
+  assert not diff.has_changes
+
+
+def test_diff_no_checkin_time_treated_as_inactive():
+  res = ReservationData(check_in_date=date(2026, 7, 1))  # no time fields
+  diff = reservation_diff(None, res, _DURING)
+  assert not diff.has_changes
+
+
+def test_guest_state_diff_has_changes_false_when_empty():
+  assert not GuestStateDiff().has_changes
+
+
+def test_guest_state_diff_has_changes_true_for_checkin():
+  assert GuestStateDiff(checkin_needed=True).has_changes