|
|
@@ -21,7 +21,7 @@ from custom_components.room_cal_ha.const import (
|
|
|
SYNC_MODE_MANUAL,
|
|
|
)
|
|
|
from custom_components.room_cal_ha.coordinator import RoomCalCoordinator
|
|
|
-from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
|
|
|
+from custom_components.room_cal_ha.models import ApiStatusData, OverrideData, ReservationData
|
|
|
|
|
|
|
|
|
SAMPLE = ReservationData(
|
|
|
@@ -341,3 +341,240 @@ async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomC
|
|
|
await coordinator._async_do_check_in()
|
|
|
|
|
|
mock_switch.async_turn_on.assert_awaited_once()
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# async_start_override / async_end_override
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+_OVERRIDE_RES = ReservationData(guest_name="Override Guest", door_code="1111")
|
|
|
+_WIFI_OVERRIDE = ReservationData(guest_name="WiFi Guest", wifi_ssid="GuestNet", wifi_key="secret")
|
|
|
+
|
|
|
+
|
|
|
+async def test_start_override_sets_current_in_data(coordinator: RoomCalCoordinator):
|
|
|
+ coordinator._guest_mode_switch = None
|
|
|
+ await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
|
|
|
+ assert coordinator.data.current.guest_name == "Override Guest"
|
|
|
+
|
|
|
+
|
|
|
+async def test_start_override_sets_override_active_flag(coordinator: RoomCalCoordinator):
|
|
|
+ coordinator._guest_mode_switch = None
|
|
|
+ await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
|
|
|
+ assert coordinator.data.override_active is True
|
|
|
+
|
|
|
+
|
|
|
+async def test_start_override_cancels_mode_sync(coordinator: RoomCalCoordinator):
|
|
|
+ cancel1, cancel2 = MagicMock(), MagicMock()
|
|
|
+ coordinator._mode_sync_handles = [cancel1, cancel2]
|
|
|
+ coordinator._guest_mode_switch = None
|
|
|
+ await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
|
|
|
+ cancel1.assert_called_once()
|
|
|
+ cancel2.assert_called_once()
|
|
|
+ assert coordinator._mode_sync_handles == []
|
|
|
+
|
|
|
+
|
|
|
+async def test_start_override_turns_on_guest_mode(coordinator: RoomCalCoordinator):
|
|
|
+ mock_switch = MagicMock()
|
|
|
+ mock_switch.async_turn_on = AsyncMock()
|
|
|
+ coordinator._guest_mode_switch = mock_switch
|
|
|
+ await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
|
|
|
+ mock_switch.async_turn_on.assert_awaited_once()
|
|
|
+
|
|
|
+
|
|
|
+async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator):
|
|
|
+ coordinator._guest_mode_switch = MagicMock()
|
|
|
+ coordinator._guest_mode_switch.async_turn_on = AsyncMock()
|
|
|
+ with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
|
|
|
+ await coordinator.async_start_override(OverrideData(current=_WIFI_OVERRIDE))
|
|
|
+ mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
|
|
|
+
|
|
|
+
|
|
|
+async def test_start_override_skips_wifi_when_ssid_missing(coordinator: RoomCalCoordinator):
|
|
|
+ coordinator._guest_mode_switch = None
|
|
|
+ no_ssid = ReservationData(wifi_ssid=None, wifi_key="secret")
|
|
|
+ with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
|
|
|
+ await coordinator.async_start_override(OverrideData(current=no_ssid))
|
|
|
+ mock_wifi.assert_not_awaited()
|
|
|
+
|
|
|
+
|
|
|
+async def test_start_override_uses_store_next_when_override_next_is_none(coordinator: RoomCalCoordinator):
|
|
|
+ await coordinator.store.async_set(STORE_NEXT, SAMPLE)
|
|
|
+ coordinator._guest_mode_switch = None
|
|
|
+ await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
|
|
|
+ assert coordinator.data.next.guest_name == "Alice"
|
|
|
+
|
|
|
+
|
|
|
+async def test_start_override_uses_override_next_when_provided(coordinator: RoomCalCoordinator):
|
|
|
+ override_next = ReservationData(guest_name="Override Next")
|
|
|
+ coordinator._guest_mode_switch = None
|
|
|
+ await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES, next=override_next))
|
|
|
+ assert coordinator.data.next.guest_name == "Override Next"
|
|
|
+
|
|
|
+
|
|
|
+async def test_end_override_clears_override_active(coordinator: RoomCalCoordinator):
|
|
|
+ coordinator._guest_mode_switch = None
|
|
|
+ coordinator._override = OverrideData(current=_OVERRIDE_RES)
|
|
|
+ await coordinator.async_end_override()
|
|
|
+ assert coordinator.data.override_active is False
|
|
|
+
|
|
|
+
|
|
|
+async def test_end_override_restores_store_current(coordinator: RoomCalCoordinator):
|
|
|
+ await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
|
|
|
+ coordinator._guest_mode_switch = None
|
|
|
+ coordinator._override = OverrideData(current=_OVERRIDE_RES)
|
|
|
+ await coordinator.async_end_override()
|
|
|
+ assert coordinator.data.current.guest_name == "Alice"
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Thin updates during override
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+async def test_thin_update_current_during_override_updates_override_not_store(coordinator: RoomCalCoordinator):
|
|
|
+ await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
|
|
|
+ await coordinator.async_refresh()
|
|
|
+ coordinator._override = OverrideData(current=_OVERRIDE_RES)
|
|
|
+
|
|
|
+ await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
|
|
|
+
|
|
|
+ assert coordinator.data.current.door_code == "9999"
|
|
|
+ # Store is unchanged
|
|
|
+ assert coordinator.store.get(STORE_CURRENT).door_code == "0472"
|
|
|
+
|
|
|
+
|
|
|
+async def test_thin_update_current_during_override_data_still_shows_override_active(coordinator: RoomCalCoordinator):
|
|
|
+ await coordinator.async_refresh()
|
|
|
+ coordinator._override = OverrideData(current=_OVERRIDE_RES)
|
|
|
+ await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
|
|
|
+ assert coordinator.data.override_active is True
|
|
|
+
|
|
|
+
|
|
|
+async def test_thin_update_next_during_override_falls_through_to_store(coordinator: RoomCalCoordinator):
|
|
|
+ coordinator._override = OverrideData(current=_OVERRIDE_RES) # override.next is None
|
|
|
+ await coordinator.async_refresh()
|
|
|
+
|
|
|
+ await coordinator.async_apply_update(STORE_NEXT, {"guest_name": "Next Guest"})
|
|
|
+
|
|
|
+ assert coordinator.store.get(STORE_NEXT).guest_name == "Next Guest"
|
|
|
+ assert coordinator.data.override_active is True
|
|
|
+ assert coordinator.data.current.guest_name == "Override Guest"
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Backend fetch during override preserves override display
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+async def test_backend_fetch_during_override_updates_store_but_preserves_override(hass: HomeAssistant):
|
|
|
+ entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
|
|
|
+ coord = RoomCalCoordinator(hass, entry)
|
|
|
+ await coord.async_setup()
|
|
|
+ coord._override = OverrideData(current=_OVERRIDE_RES)
|
|
|
+
|
|
|
+ backend_data = ReservationData(guest_name="Backend Alice")
|
|
|
+ with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
|
|
|
+ MockBackend.return_value.async_fetch = AsyncMock(return_value=(backend_data, None))
|
|
|
+ await coord.async_refresh()
|
|
|
+
|
|
|
+ # Store was written by the backend
|
|
|
+ assert coord.store.get(STORE_CURRENT).guest_name == "Backend Alice"
|
|
|
+ # But displayed data still shows the override
|
|
|
+ assert coord.data.current.guest_name == "Override Guest"
|
|
|
+ assert coord.data.override_active is True
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# async_reconcile_from_store (shared helper)
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+_ACTIVE = ReservationData(
|
|
|
+ guest_name="Active Guest",
|
|
|
+ check_in_date=date(2026, 6, 20),
|
|
|
+ check_in_time=time(14, 0),
|
|
|
+ check_out_date=date(2026, 6, 25),
|
|
|
+ check_out_time=time(12, 0),
|
|
|
+)
|
|
|
+
|
|
|
+_ELAPSED = ReservationData(
|
|
|
+ guest_name="Past Guest",
|
|
|
+ check_in_date=date(2026, 6, 1),
|
|
|
+ check_in_time=time(14, 0),
|
|
|
+ check_out_date=date(2026, 6, 5),
|
|
|
+ check_out_time=time(12, 0),
|
|
|
+)
|
|
|
+
|
|
|
+_FUTURE = ReservationData(
|
|
|
+ guest_name="Future 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),
|
|
|
+)
|
|
|
+
|
|
|
+# Freeze time at 2026-06-22 12:00 local (naive)
|
|
|
+_RECONCILE_NOW = MagicMock()
|
|
|
+_RECONCILE_NOW.replace.return_value = datetime(2026, 6, 22, 12, 0, 0)
|
|
|
+
|
|
|
+
|
|
|
+async def test_reconcile_active_current_turns_on_guest_mode(coordinator: RoomCalCoordinator):
|
|
|
+ await coordinator.store.async_set(STORE_CURRENT, _ACTIVE)
|
|
|
+ mock_switch = MagicMock()
|
|
|
+ mock_switch.async_turn_on = AsyncMock()
|
|
|
+ mock_switch.async_turn_off = AsyncMock()
|
|
|
+ coordinator._guest_mode_switch = mock_switch
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch.object(coordinator, "_is_mode_sync_on", return_value=True),
|
|
|
+ patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
|
|
|
+ ):
|
|
|
+ await coordinator.async_reconcile_from_store()
|
|
|
+ coordinator.cancel_mode_sync()
|
|
|
+
|
|
|
+ mock_switch.async_turn_on.assert_awaited_once()
|
|
|
+ mock_switch.async_turn_off.assert_not_awaited()
|
|
|
+
|
|
|
+
|
|
|
+async def test_reconcile_elapsed_current_clears_store_and_turns_off_guest_mode(coordinator: RoomCalCoordinator):
|
|
|
+ await coordinator.store.async_set(STORE_CURRENT, _ELAPSED)
|
|
|
+ mock_switch = MagicMock()
|
|
|
+ mock_switch.async_turn_on = AsyncMock()
|
|
|
+ mock_switch.async_turn_off = AsyncMock()
|
|
|
+ coordinator._guest_mode_switch = mock_switch
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch.object(coordinator, "_is_mode_sync_on", return_value=True),
|
|
|
+ patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
|
|
|
+ ):
|
|
|
+ await coordinator.async_reconcile_from_store()
|
|
|
+
|
|
|
+ assert coordinator.store.get(STORE_CURRENT) is None
|
|
|
+ mock_switch.async_turn_off.assert_awaited_once()
|
|
|
+
|
|
|
+
|
|
|
+async def test_reconcile_promotes_next_when_check_in_elapsed(coordinator: RoomCalCoordinator):
|
|
|
+ await coordinator.store.async_set(STORE_NEXT, _ACTIVE)
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch.object(coordinator, "_is_mode_sync_on", return_value=True),
|
|
|
+ patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
|
|
|
+ ):
|
|
|
+ await coordinator.async_reconcile_from_store()
|
|
|
+ coordinator.cancel_mode_sync()
|
|
|
+
|
|
|
+ assert coordinator.store.get(STORE_CURRENT).guest_name == "Active Guest"
|
|
|
+
|
|
|
+
|
|
|
+async def test_reconcile_does_not_touch_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator):
|
|
|
+ await coordinator.store.async_set(STORE_CURRENT, _ACTIVE)
|
|
|
+ mock_switch = MagicMock()
|
|
|
+ mock_switch.async_turn_on = AsyncMock()
|
|
|
+ mock_switch.async_turn_off = AsyncMock()
|
|
|
+ coordinator._guest_mode_switch = mock_switch
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch.object(coordinator, "_is_mode_sync_on", return_value=False),
|
|
|
+ patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
|
|
|
+ ):
|
|
|
+ await coordinator.async_reconcile_from_store()
|
|
|
+
|
|
|
+ mock_switch.async_turn_on.assert_not_awaited()
|
|
|
+ mock_switch.async_turn_off.assert_not_awaited()
|