Przeglądaj źródła

Park Active during an override instead of letting it free-run

A scheduled Next-check-in was promoting into an active override and
silently clobbering it, since _reschedule_mode_sync armed check-in/
check-out handles regardless of override state. Now it schedules no
handles while an override is active; ending the override re-arms them
from the Store.
ZiB 2 miesięcy temu
rodzic
commit
860f196e6c

+ 8 - 0
custom_components/room_cal_ha/coordinator.py

@@ -276,6 +276,14 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     if not self._is_mode_sync_on():
       return
 
+    # While an override is active, Active is parked: the only scheduled transition
+    # that should touch it is the override's own end_at timer (tracked separately via
+    # _override_end_handle), not the normal Store-driven check-in/check-out handles.
+    # Those are re-armed automatically once the override ends and _async_publish()
+    # calls back into this method with override cleared.
+    if self._override is not None:
+      return
+
     now = dt_util.now().replace(tzinfo=None)
 
     # At next reservation's check-in: promote next → active, then activate guest mode.

+ 36 - 23
tests/test_coordinator.py

@@ -830,13 +830,13 @@ async def test_start_override_empty_current_triggers_checkout_not_checkin(
   assert coordinator.data.override_active is True
 
 
-async def test_override_active_checks_out_on_schedule_while_override_stays_active(
+async def test_override_active_schedules_no_mode_sync_handles(
   hass: HomeAssistant, coordinator: RoomCalCoordinator
 ):
-  """With Mode Sync on, an override's own active record schedules a real check-out
-  handle; firing it clears the override's active (not the store's), runs checkout
-  actions, override_active stays True, and a subsequent Next check-in keeps writing
-  into the override rather than the store."""
+  """While an override is active, no Store-driven check-in/check-out handles are
+  scheduled — an override parks Active until it's ended manually or its own end_at
+  timer fires. Next's check-in time elapsing (Store empty -> occupied) must not
+  overwrite the override."""
   override_guest = ReservationData(
     guest_name="Override Guest",
     check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
@@ -847,39 +847,52 @@ async def test_override_active_checks_out_on_schedule_while_override_stays_activ
   mock_switch.async_turn_off = AsyncMock()
   coordinator._room_state_switch = mock_switch
 
+  await coordinator.store.async_set(STORE_NEXT, ReservationData(
+    guest_name="Next Guest",
+    check_in_date=date(2026, 7, 1), check_in_time=time(13, 0),
+  ))
+
   with (
     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),
   ):
     await coordinator.async_start_override(OverrideData(active=override_guest))
 
-  assert len(coordinator._mode_sync_handles) >= 1
+  assert coordinator._mode_sync_handles == []
   assert coordinator.data.override_active is True
+  assert coordinator.data.active.guest_name == "Override Guest"
 
-  with (
-    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),
-  ):
-    await coordinator._async_do_check_out()
-  coordinator.cancel_mode_sync()
 
-  mock_switch.async_turn_off.assert_awaited_once()
-  assert coordinator.data.override_active is True
-  assert coordinator.data.active.guest_name is None
-  assert coordinator.store.get(STORE_CURRENT) is None
+async def test_ending_override_reschedules_mode_sync_from_store(
+  hass: HomeAssistant, coordinator: RoomCalCoordinator
+):
+  """Ending an override re-arms the normal check-in/check-out handles from whatever
+  the Store currently holds — the same reconcile-and-reschedule path used at startup."""
+  override_guest = ReservationData(
+    guest_name="Override Guest",
+    check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
+    check_out_date=date(2026, 7, 5), check_out_time=time(12, 0),
+  )
+  coordinator._room_state_switch = None
+  await coordinator.store.async_set(STORE_NEXT, ReservationData(
+    guest_name="Next Guest",
+    check_in_date=date(2026, 7, 1), check_in_time=time(15, 0),
+  ))
 
-  await coordinator.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest"))
   with (
     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),
   ):
-    await coordinator._async_do_check_in()
-  coordinator.cancel_mode_sync()
+    await coordinator.async_start_override(OverrideData(active=override_guest))
+    assert coordinator._mode_sync_handles == []
 
-  assert coordinator.data.override_active is True
-  assert coordinator.data.active.guest_name == "Next Guest"
-  # The real store's own Current is untouched — the promoted value went into the override.
-  assert coordinator.store.get(STORE_CURRENT) is None
+    await coordinator.async_end_override()
+
+  assert coordinator.data.override_active is False
+  # The check-in handle for Next Guest (15:00, still in the future relative to
+  # _AT_CHECK_IN) is re-armed now that the override no longer owns Active.
+  assert len(coordinator._mode_sync_handles) >= 1
+  coordinator.cancel_mode_sync()
 
 
 # ---------------------------------------------------------------------------