| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121 |
- from datetime import date, datetime, time, timedelta
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from homeassistant.core import HomeAssistant
- from homeassistant.exceptions import HomeAssistantError
- from pytest_homeassistant_custom_component.common import MockConfigEntry
- from custom_components.room_cal_ha.backends import BackendAuthError, BackendConnectError
- from custom_components.room_cal_ha.const import (
- CONF_API_BASE_URL,
- CONF_CALDAV_URL,
- CONF_CALENDAR_NAME,
- CONF_RESERVATION_DEFAULTS,
- CONF_SYNC_MODE,
- DOMAIN,
- STORE_CURRENT,
- STORE_NEXT,
- SYNC_MODE_API,
- SYNC_MODE_CALDAV,
- SYNC_MODE_MANUAL,
- )
- from custom_components.room_cal_ha.coordinator import RoomCalCoordinator
- from custom_components.room_cal_ha.models import OverrideData, ReservationData
- SAMPLE = ReservationData(
- guest_name="Alice",
- 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),
- wifi_key="stone-angel-market",
- door_code="0472",
- )
- _BASE_DATA = {
- "room_state_name": "Room State",
- "current_name": "Current",
- "next_name": "Next",
- }
- def _make_entry(hass, options: dict) -> MockConfigEntry:
- entry = MockConfigEntry(domain=DOMAIN, data=_BASE_DATA, options=options)
- entry.add_to_hass(hass)
- return entry
- @pytest.fixture
- async def coordinator(hass: HomeAssistant, mock_entry):
- mock_entry.add_to_hass(hass)
- coord = RoomCalCoordinator(hass, mock_entry)
- await coord.async_setup()
- return coord
- # ---------------------------------------------------------------------------
- # Manual mode
- # ---------------------------------------------------------------------------
- async def test_manual_mode_serves_empty(coordinator: RoomCalCoordinator):
- await coordinator.async_refresh()
- assert coordinator.data.current == ReservationData()
- assert coordinator.data.next == ReservationData()
- assert coordinator.data.current_last_refresh is None
- assert coordinator.data.next_last_refresh is None
- # ---------------------------------------------------------------------------
- # CalDAV mode
- # ---------------------------------------------------------------------------
- async def test_caldav_fetch_writes_store_and_timestamps(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()
- with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
- MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None))
- await coord.async_refresh()
- assert coord.data.current.guest_name == "Alice"
- assert coord.data.current.door_code == "0472"
- assert coord.data.next == ReservationData()
- assert coord.data.current_last_refresh is not None
- assert coord.data.next_last_refresh is not None
- async def test_caldav_empty_result_clears_store(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()
- await coord.store.async_set(STORE_CURRENT, SAMPLE)
- with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
- MockBackend.return_value.async_fetch = AsyncMock(return_value=(None, None))
- await coord.async_refresh()
- assert coord.data.current == ReservationData()
- assert coord.store.get(STORE_CURRENT) is None
- async def test_caldav_connect_error_preserves_store(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()
- await coord.store.async_set(STORE_CURRENT, SAMPLE)
- with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
- MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
- await coord.async_refresh()
- assert coord.data.current.guest_name == "Alice"
- assert coord.data.current_last_refresh is None
- async def test_caldav_auth_error_triggers_reauth(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()
- with (
- patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend,
- patch.object(entry, "async_start_reauth") as mock_reauth,
- ):
- MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendAuthError)
- await coord.async_refresh()
- mock_reauth.assert_called_once_with(hass)
- # ---------------------------------------------------------------------------
- # API mode
- # ---------------------------------------------------------------------------
- async def test_api_fetch_writes_store(hass: HomeAssistant):
- entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
- coord = RoomCalCoordinator(hass, entry)
- await coord.async_setup()
- with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend:
- instance = MockBackend.return_value
- instance.async_fetch = AsyncMock(return_value=(SAMPLE, None))
- await coord.async_refresh()
- assert coord.data.current.guest_name == "Alice"
- assert coord.data.current_last_refresh is not None
- async def test_api_connect_error_preserves_store(hass: HomeAssistant):
- entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
- coord = RoomCalCoordinator(hass, entry)
- await coord.async_setup()
- await coord.store.async_set(STORE_CURRENT, SAMPLE)
- with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend:
- instance = MockBackend.return_value
- instance.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
- await coord.async_refresh()
- assert coord.data.current.guest_name == "Alice"
- assert coord.data.current_last_refresh is None
- async def test_api_auth_error_triggers_reauth(hass: HomeAssistant):
- entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
- coord = RoomCalCoordinator(hass, entry)
- await coord.async_setup()
- with (
- patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend,
- patch.object(entry, "async_start_reauth") as mock_reauth,
- ):
- instance = MockBackend.return_value
- instance.async_fetch = AsyncMock(side_effect=BackendAuthError)
- await coord.async_refresh()
- mock_reauth.assert_called_once_with(hass)
- # ---------------------------------------------------------------------------
- # Thin updates
- # ---------------------------------------------------------------------------
- async def test_thin_update_overlays_fields(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
- await coordinator.async_refresh()
- await coordinator.async_apply_update(STORE_CURRENT, {"wifi_ssid": "GuestNet", "door_code": "9999"})
- assert coordinator.data.current.wifi_ssid == "GuestNet"
- assert coordinator.data.current.door_code == "9999"
- assert coordinator.data.current.guest_name == "Alice"
- assert coordinator.data.current.wifi_key == "stone-angel-market"
- async def test_thin_update_updates_last_refresh(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
- await coordinator.async_refresh()
- assert coordinator.data.current_last_refresh is None
- await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "1234"})
- assert coordinator.data.current_last_refresh is not None
- # ---------------------------------------------------------------------------
- # last_updated
- # ---------------------------------------------------------------------------
- async def test_last_updated_set_when_backend_data_changes(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()
- next_sample = ReservationData(guest_name="Next")
- with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
- MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, next_sample))
- await coord.async_refresh()
- assert coord.data.current_last_updated is not None
- assert coord.data.next_last_updated is not None
- async def test_last_updated_not_set_when_backend_data_unchanged(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()
- await coord.store.async_set(STORE_CURRENT, SAMPLE)
- with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
- MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None))
- # First refresh populates self.data
- await coord.async_refresh()
- ts_after_first = coord.data.current_last_updated
- # Second refresh returns same data — last_updated must not change
- await coord.async_refresh()
- assert coord.data.current_last_updated == ts_after_first
- async def test_last_updated_set_on_thin_update_with_change(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
- await coordinator.async_refresh()
- await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
- assert coordinator.data.current_last_updated is not None
- async def test_last_updated_not_set_on_thin_update_with_same_value(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
- await coordinator.async_refresh()
- # First update sets last_updated
- await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
- ts = coordinator.data.current_last_updated
- # Second update with the same value must not advance last_updated
- await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
- assert coordinator.data.current_last_updated == ts
- async def test_last_updated_set_when_override_starts(coordinator: RoomCalCoordinator):
- await coordinator.async_refresh()
- coordinator._room_state_switch = None
- assert coordinator.data.current_last_updated is None
- await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override")))
- assert coordinator.data.current_last_updated is not None
- async def test_last_updated_set_when_override_ends(coordinator: RoomCalCoordinator):
- coordinator._room_state_switch = None
- await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override")))
- ts_start = coordinator.data.current_last_updated
- await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
- coordinator._override = OverrideData(current=ReservationData(guest_name="Override"))
- await coordinator.async_end_override()
- assert coordinator.data.current_last_updated != ts_start
- async def test_last_updated_persists_across_reload(hass: HomeAssistant, mock_entry):
- mock_entry.add_to_hass(hass)
- coord = RoomCalCoordinator(hass, mock_entry)
- await coord.async_setup()
- await coord.store.async_set(STORE_CURRENT, SAMPLE)
- await coord.store.async_set_last_updated(STORE_CURRENT, datetime(2026, 1, 1, 12, 0, tzinfo=None))
- coord2 = RoomCalCoordinator(hass, mock_entry)
- await coord2.async_setup()
- loaded = coord2.store.get_last_updated(STORE_CURRENT)
- assert loaded is not None
- assert loaded.year == 2026
- # ---------------------------------------------------------------------------
- # Store persistence
- # ---------------------------------------------------------------------------
- async def test_store_persists_across_reload(hass: HomeAssistant, mock_entry):
- mock_entry.add_to_hass(hass)
- coord = RoomCalCoordinator(hass, mock_entry)
- await coord.async_setup()
- await coord.store.async_set(STORE_CURRENT, SAMPLE)
- coord2 = RoomCalCoordinator(hass, mock_entry)
- await coord2.async_setup()
- loaded = coord2.store.get(STORE_CURRENT)
- assert loaded is not None
- assert loaded.guest_name == "Alice"
- assert loaded.door_code == "0472"
- # ---------------------------------------------------------------------------
- # _reservation_window_state
- # ---------------------------------------------------------------------------
- DATED = ReservationData(
- 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),
- )
- def _state_at(coordinator, res, dt_str):
- """Freeze dt_util.now() to a naive local datetime and call _reservation_window_state."""
- frozen = MagicMock()
- frozen.replace.return_value = datetime.fromisoformat(dt_str) # naive, no TZ
- with patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=frozen):
- return coordinator._reservation_window_state(res)
- async def test_window_state_active(coordinator):
- assert _state_at(coordinator, DATED, "2026-07-03T10:00:00") == "active"
- async def test_window_state_pending(coordinator):
- assert _state_at(coordinator, DATED, "2026-06-30T10:00:00") == "pending"
- async def test_window_state_elapsed(coordinator):
- assert _state_at(coordinator, DATED, "2026-07-10T12:00:00") == "elapsed"
- async def test_window_state_at_check_in_is_active(coordinator):
- assert _state_at(coordinator, DATED, "2026-07-01T14:00:00") == "active"
- async def test_window_state_at_check_out_is_elapsed(coordinator):
- assert _state_at(coordinator, DATED, "2026-07-05T12:00:00") == "elapsed"
- async def test_window_state_no_dates_returns_none(coordinator):
- assert coordinator._reservation_window_state(ReservationData()) is None
- # ---------------------------------------------------------------------------
- # Check-in WiFi key behavior
- # ---------------------------------------------------------------------------
- _WIFI_NEXT = ReservationData(
- guest_name="Bob",
- 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),
- wifi_ssid="GuestNet",
- 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)
- # Freeze time at (i.e. slightly after) the scheduled check-out moment — this is what
- # `dt_util.now()` actually reads when a natural-checkout handler runs in production, and
- # is the case that exposed the bug where checkout actions require re-deriving "was old
- # active" from wall-clock time (see reservation_diff in models.py).
- _AT_CHECK_OUT = MagicMock()
- _AT_CHECK_OUT.replace.return_value = datetime(2026, 7, 5, 12, 0, 0)
- async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
- mock_switch = MagicMock()
- mock_switch.async_turn_on = AsyncMock()
- coordinator._room_state_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=_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_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
- ):
- await coordinator._async_do_check_in()
- coordinator.cancel_mode_sync()
- mock_ssid.assert_awaited_once_with(hass, "GuestNet", True)
- mock_wifi.assert_awaited_once_with(hass, "GuestNet", "securepass")
- mock_switch.async_turn_on.assert_awaited_once()
- async def test_check_in_skips_wifi_when_ssid_none(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid=None, wifi_key="securepass"))
- coordinator._room_state_switch = None
- with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
- await coordinator._async_do_check_in()
- mock_wifi.assert_not_awaited()
- async def test_check_in_skips_wifi_when_key_none(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid="GuestNet", wifi_key=None))
- coordinator._room_state_switch = None
- with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
- await coordinator._async_do_check_in()
- mock_wifi.assert_not_awaited()
- async def test_check_in_skips_wifi_when_use_unifi_off(hass: HomeAssistant, coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
- mock_switch = MagicMock()
- mock_switch.async_turn_on = AsyncMock()
- coordinator._room_state_switch = mock_switch
- with (
- patch.object(coordinator, "_is_mode_sync_on", return_value=True),
- 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_wifi_key", new_callable=AsyncMock) as mock_wifi,
- ):
- await coordinator._async_do_check_in()
- coordinator.cancel_mode_sync()
- mock_ssid.assert_not_awaited()
- mock_wifi.assert_not_awaited()
- mock_switch.async_turn_on.assert_awaited_once()
- async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
- mock_switch = MagicMock()
- mock_switch.async_turn_on = AsyncMock()
- coordinator._room_state_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=_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_wifi_key", side_effect=HomeAssistantError("hub down")),
- ):
- await coordinator._async_do_check_in()
- coordinator.cancel_mode_sync()
- mock_switch.async_turn_on.assert_awaited_once()
- # ---------------------------------------------------------------------------
- # Check-out SSID disable behavior
- # ---------------------------------------------------------------------------
- async def test_check_out_disables_ssid(hass: HomeAssistant, coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
- mock_switch = MagicMock()
- mock_switch.async_turn_off = AsyncMock()
- coordinator._room_state_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=_AT_CHECK_OUT),
- patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
- ):
- await coordinator._async_do_check_out()
- mock_ssid.assert_awaited_once_with(hass, "GuestNet", False)
- mock_switch.async_turn_off.assert_awaited_once()
- async def test_check_out_skips_ssid_when_ssid_none(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_CURRENT, ReservationData(wifi_ssid=None))
- coordinator._room_state_switch = None
- with patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid:
- await coordinator._async_do_check_out()
- mock_ssid.assert_not_awaited()
- async def test_check_out_skips_ssid_when_use_unifi_off(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
- coordinator._room_state_switch = None
- with (
- patch.object(coordinator, "_is_mode_sync_on", return_value=True),
- patch.object(coordinator, "_is_use_unifi_on", return_value=False),
- patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
- ):
- await coordinator._async_do_check_out()
- mock_ssid.assert_not_awaited()
- async def test_check_out_ssid_error_still_deactivates_guest_mode(coordinator: RoomCalCoordinator):
- await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
- mock_switch = MagicMock()
- mock_switch.async_turn_off = AsyncMock()
- coordinator._room_state_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=_AT_CHECK_OUT),
- patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", side_effect=HomeAssistantError("hub down")),
- ):
- await coordinator._async_do_check_out()
- mock_switch.async_turn_off.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._room_state_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._room_state_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._room_state_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._room_state_switch = mock_switch
- active_override = ReservationData(
- guest_name=_OVERRIDE_RES.guest_name, door_code=_OVERRIDE_RES.door_code,
- 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),
- )
- 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(current=active_override))
- coordinator.cancel_mode_sync()
- mock_switch.async_turn_on.assert_awaited_once()
- async def test_start_override_does_not_turn_on_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator):
- mock_switch = MagicMock()
- mock_switch.async_turn_on = AsyncMock()
- coordinator._room_state_switch = mock_switch
- with patch.object(coordinator, "_is_mode_sync_on", return_value=False):
- await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
- mock_switch.async_turn_on.assert_not_awaited()
- async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator):
- coordinator._room_state_switch = MagicMock()
- coordinator._room_state_switch.async_turn_on = AsyncMock()
- active_wifi_override = ReservationData(
- guest_name=_WIFI_OVERRIDE.guest_name, wifi_ssid=_WIFI_OVERRIDE.wifi_ssid, wifi_key=_WIFI_OVERRIDE.wifi_key,
- 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),
- )
- 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),
- patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
- ):
- await coordinator.async_start_override(OverrideData(current=active_wifi_override))
- coordinator.cancel_mode_sync()
- mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
- async def test_start_override_skips_wifi_when_mode_sync_off(coordinator: RoomCalCoordinator):
- coordinator._room_state_switch = None
- with (
- patch.object(coordinator, "_is_mode_sync_on", return_value=False),
- 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_not_awaited()
- async def test_start_override_skips_wifi_when_ssid_missing(coordinator: RoomCalCoordinator):
- coordinator._room_state_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._room_state_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._room_state_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_start_override_applies_defaults_to_current(hass: HomeAssistant):
- entry = MockConfigEntry(
- domain=DOMAIN,
- data={},
- options={
- CONF_SYNC_MODE: SYNC_MODE_MANUAL,
- CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID", "check_out_time": "11:00:00"},
- },
- )
- entry.add_to_hass(hass)
- coord = RoomCalCoordinator(hass, entry)
- await coord.async_setup()
- coord._guest_mode_switch = None
- partial_override = ReservationData(guest_name="Override Guest")
- await coord.async_start_override(OverrideData(current=partial_override))
- assert coord.data.current.guest_name == "Override Guest"
- assert coord.data.current.wifi_ssid == "DefaultSSID"
- assert coord.data.current.check_out_time == time(11, 0)
- async def test_start_override_defaults_do_not_overwrite_explicit_fields(hass: HomeAssistant):
- entry = MockConfigEntry(
- domain=DOMAIN,
- data={},
- options={
- CONF_SYNC_MODE: SYNC_MODE_MANUAL,
- CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"},
- },
- )
- entry.add_to_hass(hass)
- coord = RoomCalCoordinator(hass, entry)
- await coord.async_setup()
- coord._guest_mode_switch = None
- explicit_override = ReservationData(guest_name="Override Guest", wifi_ssid="ExplicitSSID")
- await coord.async_start_override(OverrideData(current=explicit_override))
- assert coord.data.current.wifi_ssid == "ExplicitSSID"
- async def test_refresh_during_override_applies_defaults_to_current(hass: HomeAssistant):
- entry = MockConfigEntry(
- domain=DOMAIN,
- data={},
- options={
- CONF_SYNC_MODE: SYNC_MODE_MANUAL,
- CONF_RESERVATION_DEFAULTS: {"door_code": "9999"},
- },
- )
- entry.add_to_hass(hass)
- coord = RoomCalCoordinator(hass, entry)
- await coord.async_setup()
- coord._guest_mode_switch = None
- partial_override = ReservationData(guest_name="Override Guest")
- await coord.async_start_override(OverrideData(current=partial_override))
- await coord.async_refresh()
- assert coord.data.current.guest_name == "Override Guest"
- assert coord.data.current.door_code == "9999"
- async def test_start_override_applies_defaults_to_store_next(hass: HomeAssistant):
- entry = MockConfigEntry(
- domain=DOMAIN,
- data={},
- options={
- CONF_SYNC_MODE: SYNC_MODE_MANUAL,
- CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"},
- },
- )
- entry.add_to_hass(hass)
- coord = RoomCalCoordinator(hass, entry)
- await coord.async_setup()
- coord._guest_mode_switch = None
- await coord.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest"))
- await coord.async_start_override(OverrideData(current=_OVERRIDE_RES))
- assert coord.data.next.guest_name == "Next Guest"
- assert coord.data.next.wifi_ssid == "DefaultSSID"
- async def test_end_override_clears_override_active(coordinator: RoomCalCoordinator):
- coordinator._room_state_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._room_state_switch = None
- coordinator._override = OverrideData(current=_OVERRIDE_RES)
- await coordinator.async_end_override()
- assert coordinator.data.current.guest_name == "Alice"
- async def test_start_override_with_end_at_schedules_and_ends_override(coordinator: RoomCalCoordinator):
- coordinator._room_state_switch = None
- override = OverrideData(current=ReservationData(guest_name="Timed Guest"))
- with (
- patch.object(coordinator, "_is_mode_sync_on", return_value=False),
- patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
- ):
- await coordinator.async_start_override(override, end_at=datetime(2026, 7, 1, 14, 5, 0))
- scheduled_handle = coordinator._override_end_handle
- assert scheduled_handle is not None
- assert coordinator.data.override_active is True
- # Simulate the scheduled call_later firing, then cancel the real (never-fired) handle
- # so it doesn't leak into the next test.
- await coordinator._async_do_override_timeout()
- scheduled_handle()
- assert coordinator._override_end_handle is None
- assert coordinator.data.override_active is False
- async def test_stale_override_timeout_does_not_end_a_replacing_override(coordinator: RoomCalCoordinator):
- """A timer from an override that was already manually ended/replaced must not end
- whatever override is active by the time it fires."""
- coordinator._room_state_switch = None
- override = OverrideData(current=ReservationData(guest_name="Timed Guest"))
- with (
- patch.object(coordinator, "_is_mode_sync_on", return_value=False),
- patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
- ):
- await coordinator.async_start_override(override, end_at=datetime(2026, 7, 1, 14, 5, 0))
- stale_target = coordinator._override_end_target
- await coordinator.async_end_override()
- assert coordinator._override is None
- new_override = OverrideData(current=ReservationData(guest_name="New Guest"))
- with patch.object(coordinator, "_is_mode_sync_on", return_value=False):
- await coordinator.async_start_override(new_override) # no end_at — no new timer
- # Simulate the original (stale) timer's callback firing late, after the replacement started.
- coordinator._override_end_target = stale_target
- await coordinator._async_do_override_timeout()
- assert coordinator._override is new_override
- assert coordinator.data.override_active is True
- async def test_start_override_empty_current_triggers_checkout_not_checkin(
- hass: HomeAssistant, coordinator: RoomCalCoordinator
- ):
- """Overriding Current to empty while a real guest is checked in must run checkout
- actions (SSID disable, guest-mode off) — not try to check in an empty record."""
- checked_in_guest = ReservationData(
- guest_name="Real 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),
- wifi_ssid="GuestNet", wifi_key="secret",
- )
- await coordinator.store.async_set(STORE_CURRENT, checked_in_guest)
- mock_switch = MagicMock()
- mock_switch.async_turn_off = AsyncMock()
- mock_switch.async_turn_on = AsyncMock()
- coordinator._room_state_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=_AT_CHECK_IN),
- patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
- ):
- await coordinator.async_start_override(OverrideData(current=ReservationData()))
- coordinator.cancel_mode_sync()
- mock_ssid.assert_awaited_once_with(hass, "GuestNet", False)
- mock_switch.async_turn_off.assert_awaited_once()
- mock_switch.async_turn_on.assert_not_awaited()
- assert coordinator.data.override_active is True
- async def test_override_current_checks_out_on_schedule_while_override_stays_active(
- hass: HomeAssistant, coordinator: RoomCalCoordinator
- ):
- """With Mode Sync on, an override's own Current schedules a real check-out handle;
- firing it clears the override's Current (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."""
- 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),
- )
- mock_switch = MagicMock()
- mock_switch.async_turn_on = AsyncMock()
- mock_switch.async_turn_off = AsyncMock()
- coordinator._room_state_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=_AT_CHECK_IN),
- ):
- await coordinator.async_start_override(OverrideData(current=override_guest))
- assert len(coordinator._mode_sync_handles) >= 1
- assert coordinator.data.override_active is True
- 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.current.guest_name is None
- assert coordinator.store.get(STORE_CURRENT) is None
- 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()
- assert coordinator.data.override_active is True
- assert coordinator.data.current.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
- # ---------------------------------------------------------------------------
- # 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._room_state_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._room_state_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_turns_off_guest_mode_when_store_already_cleared(coordinator: RoomCalCoordinator):
- """Store was cleared (e.g. checkout write ran) but guest-mode-off action never ran."""
- # No current reservation in store — it was already cleared before HA was disabled.
- mock_switch = MagicMock()
- mock_switch.async_turn_on = AsyncMock()
- mock_switch.async_turn_off = AsyncMock()
- coordinator._room_state_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_off.assert_awaited_once()
- mock_switch.async_turn_on.assert_not_awaited()
- 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._room_state_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()
- # ---------------------------------------------------------------------------
- # _diff_with_unifi_check
- # ---------------------------------------------------------------------------
- _ACTIVE_WIFI_RES = ReservationData(
- 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),
- wifi_ssid="GuestNet",
- wifi_key="correct-key",
- )
- _DURING_WINDOW = datetime(2026, 7, 3, 12, 0, 0)
- def _unifi_diff(coordinator, old, new, now, *, enabled, key):
- with (
- patch.object(coordinator, "_is_use_unifi_on", return_value=True),
- patch("custom_components.room_cal_ha.wifi.get_wlan_state", return_value=(enabled, key)),
- ):
- return coordinator._diff_with_unifi_check(old, new, now)
- async def test_unifi_diff_ssid_disabled_adds_checkin_needed(coordinator):
- diff = _unifi_diff(coordinator, None, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="correct-key")
- assert diff.checkin_needed
- async def test_unifi_diff_key_mismatch_adds_wifi_key_changed(coordinator):
- diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="wrong-key")
- assert diff.wifi_key_changed
- async def test_unifi_diff_ssid_disabled_and_key_wrong_sets_both(coordinator):
- diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="wrong-key")
- assert diff.checkin_needed
- assert diff.wifi_key_changed
- async def test_unifi_diff_state_matches_no_extra_flags(coordinator):
- diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
- assert not diff.has_changes
- async def test_unifi_diff_skipped_when_use_unifi_off(coordinator):
- with (
- patch.object(coordinator, "_is_use_unifi_on", return_value=False),
- patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get,
- ):
- diff = coordinator._diff_with_unifi_check(_ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW)
- mock_get.assert_not_called()
- assert not diff.has_changes # base diff: same record, no changes; unifi check skipped
- async def test_unifi_diff_skipped_when_window_not_active(coordinator):
- # Unchanged record (no occupancy transition) isolates the UniFi drift-check gate itself:
- # even with use_unifi on, no wall-clock-active window means no drift check is made.
- before_window = datetime(2026, 6, 30, 12, 0, 0)
- with (
- patch.object(coordinator, "_is_use_unifi_on", return_value=True),
- patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get,
- ):
- diff = coordinator._diff_with_unifi_check(_ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, before_window)
- mock_get.assert_not_called()
- assert not diff.has_changes
- async def test_unifi_diff_skipped_when_hub_unavailable(coordinator):
- diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=None, key=None)
- assert not diff.has_changes
- async def test_unifi_diff_key_check_skipped_when_actual_key_none(coordinator):
- # Controller doesn't expose passphrase — only enabled state checked.
- diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key=None)
- assert not diff.has_changes
- async def test_unifi_diff_preserves_original_diff_flags(coordinator):
- # Even when UniFi state is clean, original checkin_needed from the base diff is kept.
- old_inactive = ReservationData(wifi_ssid="GuestNet", wifi_key="correct-key") # no dates → not active
- diff = _unifi_diff(coordinator, old_inactive, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
- assert diff.checkin_needed # from reservation_diff: old not active, new is active
|