ソースを参照

Wire reservation_diff into all coordinator state-change paths

Closes the action-dispatch gaps identified in the path audit: backend
fetches, thin updates, promote-from-next, clear-current, check-out, and
end-override now all run checkout/checkin/wifi actions based on the diff
rather than ad-hoc mode-only calls.

Also normalises reschedule behaviour: _reschedule_mode_sync is now
called alongside every timestamp touch, so the next-guest check-in
handle is always rebuilt immediately after checkout or a store write.
ZiB 4 ヶ月 前
親
コミット
7b69a66ffb
2 ファイル変更、49 行追加、10 行削除
  1. 42 10
      custom_components/room_cal_ha/coordinator.py
  2. 7 0
      tests/test_coordinator.py

+ 42 - 10
custom_components/room_cal_ha/coordinator.py

@@ -23,7 +23,7 @@ from .const import (
   SYNC_MODE_API,
   SYNC_MODE_API,
   SYNC_MODE_CALDAV,
   SYNC_MODE_CALDAV,
 )
 )
-from .models import ApiStatusData, OverrideData, ReservationData
+from .models import ApiStatusData, OverrideData, ReservationData, reservation_diff
 from dataclasses import fields as dataclass_fields
 from dataclasses import fields as dataclass_fields
 from .store import ReservationStore
 from .store import ReservationStore
 
 
@@ -103,10 +103,18 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
 
     if backend_fetched:
     if backend_fetched:
       # Calendar is authoritative: write its result including None (clears the slot).
       # Calendar is authoritative: write its result including None (clears the slot).
+      old_current = self.store.get(STORE_CURRENT)
       await self.store.async_set(STORE_CURRENT, backend_current)
       await self.store.async_set(STORE_CURRENT, backend_current)
       await self.store.async_set(STORE_NEXT, backend_next)
       await self.store.async_set(STORE_NEXT, backend_next)
       self._current_last_refresh = now
       self._current_last_refresh = now
       self._next_last_refresh = now
       self._next_last_refresh = now
+      if self._override is None:
+        now_naive = dt_util.now().replace(tzinfo=None)
+        diff = reservation_diff(old_current, backend_current, now_naive)
+        if diff.checkout_needed:
+          await self._async_run_check_out_actions(diff.departing)
+        if diff.checkin_needed or diff.wifi_key_changed or diff.wifi_ssid_changed:
+          await self._async_run_check_in_actions(backend_current or self._empty_reservation())
 
 
     store_current = self._with_defaults(self.store.get(STORE_CURRENT))
     store_current = self._with_defaults(self.store.get(STORE_CURRENT))
     store_next = self._with_defaults(self.store.get(STORE_NEXT))
     store_next = self._with_defaults(self.store.get(STORE_NEXT))
@@ -291,7 +299,6 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
 
   async def _async_do_check_in(self) -> None:
   async def _async_do_check_in(self) -> None:
     await self.async_promote_next_to_current()
     await self.async_promote_next_to_current()
-    await self._async_run_check_in_actions(self.data.current)
 
 
   @callback
   @callback
   def _async_handle_check_out(self, _now: datetime) -> None:
   def _async_handle_check_out(self, _now: datetime) -> None:
@@ -301,29 +308,37 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     departing = self.store.get(STORE_CURRENT) or self._empty_reservation()
     departing = self.store.get(STORE_CURRENT) or self._empty_reservation()
     await self.store.async_set(STORE_CURRENT, None)
     await self.store.async_set(STORE_CURRENT, None)
     self._current_last_refresh = dt_util.utcnow()
     self._current_last_refresh = dt_util.utcnow()
+    nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
       current=self._empty_reservation(),
       current=self._empty_reservation(),
-      next=self.store.get(STORE_NEXT) or self._empty_reservation(),
+      next=nxt,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
       api_status=self.data.api_status if self.data else None,
       api_status=self.data.api_status if self.data else None,
     ))
     ))
     await self._async_run_check_out_actions(departing)
     await self._async_run_check_out_actions(departing)
+    self._reschedule_mode_sync(self._empty_reservation(), nxt)
 
 
   async def async_clear_current(self) -> None:
   async def async_clear_current(self) -> None:
     """Clear the Current slot in the store and notify listeners."""
     """Clear the Current slot in the store and notify listeners."""
+    departing = self.store.get(STORE_CURRENT)
     await self.store.async_set(STORE_CURRENT, None)
     await self.store.async_set(STORE_CURRENT, None)
     self._current_last_refresh = dt_util.utcnow()
     self._current_last_refresh = dt_util.utcnow()
+    nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
       current=self._empty_reservation(),
       current=self._empty_reservation(),
-      next=self.store.get(STORE_NEXT) or self._empty_reservation(),
+      next=nxt,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
       api_status=self.data.api_status if self.data else None,
       api_status=self.data.api_status if self.data else None,
     ))
     ))
+    if departing is not None:
+      await self._async_run_check_out_actions(departing)
+    self._reschedule_mode_sync(self._empty_reservation(), nxt)
 
 
   async def async_promote_next_to_current(self) -> None:
   async def async_promote_next_to_current(self) -> None:
     """Copy the Next slot into Current and notify listeners. Next is left unchanged."""
     """Copy the Next slot into Current and notify listeners. Next is left unchanged."""
+    old_current = self.store.get(STORE_CURRENT)
     next_data = self.store.get(STORE_NEXT) or self._empty_reservation()
     next_data = self.store.get(STORE_NEXT) or self._empty_reservation()
     await self.store.async_set(STORE_CURRENT, next_data)
     await self.store.async_set(STORE_CURRENT, next_data)
     self._current_last_refresh = dt_util.utcnow()
     self._current_last_refresh = dt_util.utcnow()
@@ -336,10 +351,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       api_status=self.data.api_status if self.data else None,
       api_status=self.data.api_status if self.data else None,
     ))
     ))
     self._reschedule_mode_sync(next_data, new_next)
     self._reschedule_mode_sync(next_data, new_next)
-    # If mode_sync is on and the newly imported record's check-in has already passed,
-    # activate guest mode immediately (the scheduled handle already fired or was missed).
     if self._reservation_window_state(next_data) == "active":
     if self._reservation_window_state(next_data) == "active":
-      await self._async_set_guest_mode(True)
+      await self._async_run_check_in_actions(next_data)
 
 
   async def async_start_override(self, override: OverrideData) -> None:
   async def async_start_override(self, override: OverrideData) -> None:
     """Activate a temporary override. Runs the full check-in sequence (WiFi + guest mode)."""
     """Activate a temporary override. Runs the full check-in sequence (WiFi + guest mode)."""
@@ -361,6 +374,14 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
 
   async def async_end_override(self) -> None:
   async def async_end_override(self) -> None:
     """Clear the active override and reconcile room state from the store."""
     """Clear the active override and reconcile room state from the store."""
+    if self._override is not None:
+      now_naive = dt_util.now().replace(tzinfo=None)
+      diff = reservation_diff(self._override.current, self.store.get(STORE_CURRENT), now_naive)
+      if diff.checkout_needed:
+        await self._async_run_check_out_actions(diff.departing)
+      if diff.checkin_needed or diff.wifi_key_changed or diff.wifi_ssid_changed:
+        store_current = self.store.get(STORE_CURRENT)
+        await self._async_run_check_in_actions(store_current or self._empty_reservation())
     self._override = None
     self._override = None
     await self.async_reconcile_from_store()
     await self.async_reconcile_from_store()
 
 
@@ -404,7 +425,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       return
       return
 
 
     stored = self.store.get(target) or self._empty_reservation()
     stored = self.store.get(target) or self._empty_reservation()
-    await self.store.async_set(target, stored.apply_partial(partial))
+    new_stored = stored.apply_partial(partial)
+    await self.store.async_set(target, new_stored)
     if target == STORE_CURRENT:
     if target == STORE_CURRENT:
       self._current_last_refresh = now
       self._current_last_refresh = now
     else:
     else:
@@ -425,13 +447,23 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         api_status=self.data.api_status if self.data else None,
         api_status=self.data.api_status if self.data else None,
       ))
       ))
     else:
     else:
+      store_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
+      store_next = self.store.get(STORE_NEXT) or self._empty_reservation()
       self.async_set_updated_data(CoordinatorData(
       self.async_set_updated_data(CoordinatorData(
-        current=self.store.get(STORE_CURRENT) or self._empty_reservation(),
-        next=self.store.get(STORE_NEXT) or self._empty_reservation(),
+        current=store_current,
+        next=store_next,
         current_last_refresh=self._current_last_refresh,
         current_last_refresh=self._current_last_refresh,
         next_last_refresh=self._next_last_refresh,
         next_last_refresh=self._next_last_refresh,
         api_status=self.data.api_status if self.data else None,
         api_status=self.data.api_status if self.data else None,
       ))
       ))
+      self._reschedule_mode_sync(store_current, store_next)
+      if target == STORE_CURRENT:
+        now_naive = dt_util.now().replace(tzinfo=None)
+        diff = reservation_diff(stored, new_stored, now_naive)
+        if diff.checkout_needed:
+          await self._async_run_check_out_actions(diff.departing)
+        if diff.checkin_needed or diff.wifi_key_changed or diff.wifi_ssid_changed:
+          await self._async_run_check_in_actions(new_stored)
 
 
   def cancel_mode_sync(self) -> None:
   def cancel_mode_sync(self) -> None:
     for cancel in self._mode_sync_handles:
     for cancel in self._mode_sync_handles:

+ 7 - 0
tests/test_coordinator.py

@@ -281,6 +281,10 @@ _WIFI_NEXT = ReservationData(
   wifi_key="securepass",
   wifi_key="securepass",
 )
 )
 
 
+# Freeze time at the check-in moment so the window registers as active during check-in tests.
+_AT_CHECK_IN = MagicMock()
+_AT_CHECK_IN.replace.return_value = datetime(2026, 7, 1, 14, 0, 0)
+
 
 
 async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCalCoordinator):
 async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCalCoordinator):
   await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
   await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
@@ -290,6 +294,7 @@ async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCal
 
 
   with (
   with (
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
   ):
   ):
@@ -330,6 +335,7 @@ async def test_check_in_skips_wifi_when_use_unifi_off(hass: HomeAssistant, coord
   with (
   with (
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator, "_is_use_unifi_on", return_value=False),
     patch.object(coordinator, "_is_use_unifi_on", return_value=False),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
   ):
   ):
@@ -349,6 +355,7 @@ async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomC
 
 
   with (
   with (
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock),
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock),
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")),
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")),
   ):
   ):