test_coordinator.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158
  1. from datetime import date, datetime, time, timedelta
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from homeassistant.core import HomeAssistant
  5. from homeassistant.exceptions import HomeAssistantError
  6. from pytest_homeassistant_custom_component.common import MockConfigEntry
  7. from custom_components.room_cal_ha.backends import BackendAuthError, BackendConnectError
  8. from custom_components.room_cal_ha.const import (
  9. CONF_API_BASE_URL,
  10. CONF_CALDAV_URL,
  11. CONF_CALENDAR_NAME,
  12. CONF_RESERVATION_DEFAULTS,
  13. CONF_SYNC_MODE,
  14. DOMAIN,
  15. STORE_CURRENT,
  16. STORE_NEXT,
  17. SYNC_MODE_API,
  18. SYNC_MODE_CALDAV,
  19. SYNC_MODE_MANUAL,
  20. )
  21. from custom_components.room_cal_ha.coordinator import RoomCalCoordinator
  22. from custom_components.room_cal_ha.models import OverrideData, ReservationData
  23. SAMPLE = ReservationData(
  24. guest_name="Alice",
  25. check_in_date=date(2026, 7, 1),
  26. check_in_time=time(14, 0),
  27. check_out_date=date(2026, 7, 5),
  28. check_out_time=time(12, 0),
  29. wifi_key="stone-angel-market",
  30. door_code="0472",
  31. )
  32. _BASE_DATA = {
  33. "room_state_name": "Room State",
  34. "current_name": "Current",
  35. "next_name": "Next",
  36. }
  37. def _make_entry(hass, options: dict) -> MockConfigEntry:
  38. entry = MockConfigEntry(domain=DOMAIN, data=_BASE_DATA, options=options)
  39. entry.add_to_hass(hass)
  40. return entry
  41. @pytest.fixture
  42. async def coordinator(hass: HomeAssistant, mock_entry):
  43. mock_entry.add_to_hass(hass)
  44. coord = RoomCalCoordinator(hass, mock_entry)
  45. await coord.async_setup()
  46. return coord
  47. # ---------------------------------------------------------------------------
  48. # Manual mode
  49. # ---------------------------------------------------------------------------
  50. async def test_manual_mode_serves_empty(coordinator: RoomCalCoordinator):
  51. await coordinator.async_refresh()
  52. assert coordinator.data.current == ReservationData()
  53. assert coordinator.data.next == ReservationData()
  54. assert coordinator.data.current_last_refresh is None
  55. assert coordinator.data.next_last_refresh is None
  56. # ---------------------------------------------------------------------------
  57. # CalDAV mode
  58. # ---------------------------------------------------------------------------
  59. async def test_caldav_fetch_writes_store_and_timestamps(hass: HomeAssistant):
  60. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  61. coord = RoomCalCoordinator(hass, entry)
  62. await coord.async_setup()
  63. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  64. MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None))
  65. await coord.async_refresh()
  66. assert coord.data.current.guest_name == "Alice"
  67. assert coord.data.current.door_code == "0472"
  68. assert coord.data.next == ReservationData()
  69. assert coord.data.current_last_refresh is not None
  70. assert coord.data.next_last_refresh is not None
  71. async def test_caldav_empty_result_clears_store(hass: HomeAssistant):
  72. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  73. coord = RoomCalCoordinator(hass, entry)
  74. await coord.async_setup()
  75. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  76. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  77. MockBackend.return_value.async_fetch = AsyncMock(return_value=(None, None))
  78. await coord.async_refresh()
  79. assert coord.data.current == ReservationData()
  80. assert coord.store.get(STORE_CURRENT) is None
  81. async def test_caldav_connect_error_preserves_store(hass: HomeAssistant):
  82. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  83. coord = RoomCalCoordinator(hass, entry)
  84. await coord.async_setup()
  85. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  86. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  87. MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
  88. await coord.async_refresh()
  89. assert coord.data.current.guest_name == "Alice"
  90. assert coord.data.current_last_refresh is None
  91. async def test_caldav_auth_error_triggers_reauth(hass: HomeAssistant):
  92. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  93. coord = RoomCalCoordinator(hass, entry)
  94. await coord.async_setup()
  95. with (
  96. patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend,
  97. patch.object(entry, "async_start_reauth") as mock_reauth,
  98. ):
  99. MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendAuthError)
  100. await coord.async_refresh()
  101. mock_reauth.assert_called_once_with(hass)
  102. # ---------------------------------------------------------------------------
  103. # API mode
  104. # ---------------------------------------------------------------------------
  105. async def test_api_fetch_writes_store(hass: HomeAssistant):
  106. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  107. coord = RoomCalCoordinator(hass, entry)
  108. await coord.async_setup()
  109. with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend:
  110. instance = MockBackend.return_value
  111. instance.async_fetch = AsyncMock(return_value=(SAMPLE, None))
  112. await coord.async_refresh()
  113. assert coord.data.current.guest_name == "Alice"
  114. assert coord.data.current_last_refresh is not None
  115. async def test_api_connect_error_preserves_store(hass: HomeAssistant):
  116. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  117. coord = RoomCalCoordinator(hass, entry)
  118. await coord.async_setup()
  119. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  120. with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend:
  121. instance = MockBackend.return_value
  122. instance.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
  123. await coord.async_refresh()
  124. assert coord.data.current.guest_name == "Alice"
  125. assert coord.data.current_last_refresh is None
  126. async def test_api_auth_error_triggers_reauth(hass: HomeAssistant):
  127. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  128. coord = RoomCalCoordinator(hass, entry)
  129. await coord.async_setup()
  130. with (
  131. patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend,
  132. patch.object(entry, "async_start_reauth") as mock_reauth,
  133. ):
  134. instance = MockBackend.return_value
  135. instance.async_fetch = AsyncMock(side_effect=BackendAuthError)
  136. await coord.async_refresh()
  137. mock_reauth.assert_called_once_with(hass)
  138. # ---------------------------------------------------------------------------
  139. # Thin updates
  140. # ---------------------------------------------------------------------------
  141. async def test_thin_update_overlays_fields(coordinator: RoomCalCoordinator):
  142. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  143. await coordinator.async_refresh()
  144. await coordinator.async_apply_update({"wifi_ssid": "GuestNet", "door_code": "9999"})
  145. assert coordinator.data.active.wifi_ssid == "GuestNet"
  146. assert coordinator.data.active.door_code == "9999"
  147. assert coordinator.data.active.guest_name == "Alice"
  148. assert coordinator.data.active.wifi_key == "stone-angel-market"
  149. # Current (pure backend mirror) is unaffected by the patch.
  150. assert coordinator.data.current.door_code == "0472"
  151. async def test_thin_update_starts_an_override(coordinator: RoomCalCoordinator):
  152. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  153. await coordinator.async_refresh()
  154. assert coordinator.data.override_active is False
  155. await coordinator.async_apply_update({"door_code": "1234"})
  156. assert coordinator.data.override_active is True
  157. async def test_thin_update_updates_active_last_refresh(coordinator: RoomCalCoordinator):
  158. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  159. await coordinator.async_refresh()
  160. assert coordinator.data.active_last_refresh is None
  161. await coordinator.async_apply_update({"door_code": "1234"})
  162. assert coordinator.data.active_last_refresh is not None
  163. # ---------------------------------------------------------------------------
  164. # last_updated
  165. # ---------------------------------------------------------------------------
  166. async def test_last_updated_set_when_backend_data_changes(hass: HomeAssistant):
  167. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  168. coord = RoomCalCoordinator(hass, entry)
  169. await coord.async_setup()
  170. next_sample = ReservationData(guest_name="Next")
  171. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  172. MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, next_sample))
  173. await coord.async_refresh()
  174. assert coord.data.current_last_updated is not None
  175. assert coord.data.next_last_updated is not None
  176. async def test_last_updated_not_set_when_backend_data_unchanged(hass: HomeAssistant):
  177. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  178. coord = RoomCalCoordinator(hass, entry)
  179. await coord.async_setup()
  180. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  181. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  182. MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None))
  183. # First refresh populates self.data
  184. await coord.async_refresh()
  185. ts_after_first = coord.data.current_last_updated
  186. # Second refresh returns same data — last_updated must not change
  187. await coord.async_refresh()
  188. assert coord.data.current_last_updated == ts_after_first
  189. async def test_last_updated_set_on_thin_update_with_change(coordinator: RoomCalCoordinator):
  190. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  191. await coordinator.async_refresh()
  192. await coordinator.async_apply_update({"door_code": "9999"})
  193. assert coordinator.data.active_last_updated is not None
  194. async def test_last_updated_not_set_on_thin_update_with_same_value(coordinator: RoomCalCoordinator):
  195. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  196. await coordinator.async_refresh()
  197. # First update sets last_updated
  198. await coordinator.async_apply_update({"door_code": "9999"})
  199. ts = coordinator.data.active_last_updated
  200. # Second update with the same value must not advance last_updated
  201. await coordinator.async_apply_update({"door_code": "9999"})
  202. assert coordinator.data.active_last_updated == ts
  203. async def test_last_updated_set_when_override_starts(coordinator: RoomCalCoordinator):
  204. await coordinator.async_refresh()
  205. coordinator._room_state_switch = None
  206. assert coordinator.data.active_last_updated is None
  207. await coordinator.async_start_override(OverrideData(active=ReservationData(guest_name="Override")))
  208. assert coordinator.data.active_last_updated is not None
  209. async def test_last_updated_set_when_override_ends(coordinator: RoomCalCoordinator):
  210. coordinator._room_state_switch = None
  211. await coordinator.async_start_override(OverrideData(active=ReservationData(guest_name="Override")))
  212. ts_start = coordinator.data.active_last_updated
  213. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  214. coordinator._override = OverrideData(active=ReservationData(guest_name="Override"))
  215. await coordinator.async_end_override()
  216. assert coordinator.data.active_last_updated != ts_start
  217. async def test_last_updated_persists_across_reload(hass: HomeAssistant, mock_entry):
  218. mock_entry.add_to_hass(hass)
  219. coord = RoomCalCoordinator(hass, mock_entry)
  220. await coord.async_setup()
  221. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  222. await coord.store.async_set_last_updated(STORE_CURRENT, datetime(2026, 1, 1, 12, 0, tzinfo=None))
  223. coord2 = RoomCalCoordinator(hass, mock_entry)
  224. await coord2.async_setup()
  225. loaded = coord2.store.get_last_updated(STORE_CURRENT)
  226. assert loaded is not None
  227. assert loaded.year == 2026
  228. # ---------------------------------------------------------------------------
  229. # Store persistence
  230. # ---------------------------------------------------------------------------
  231. async def test_store_persists_across_reload(hass: HomeAssistant, mock_entry):
  232. mock_entry.add_to_hass(hass)
  233. coord = RoomCalCoordinator(hass, mock_entry)
  234. await coord.async_setup()
  235. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  236. coord2 = RoomCalCoordinator(hass, mock_entry)
  237. await coord2.async_setup()
  238. loaded = coord2.store.get(STORE_CURRENT)
  239. assert loaded is not None
  240. assert loaded.guest_name == "Alice"
  241. assert loaded.door_code == "0472"
  242. # ---------------------------------------------------------------------------
  243. # _reservation_window_state
  244. # ---------------------------------------------------------------------------
  245. DATED = ReservationData(
  246. check_in_date=date(2026, 7, 1),
  247. check_in_time=time(14, 0),
  248. check_out_date=date(2026, 7, 5),
  249. check_out_time=time(12, 0),
  250. )
  251. def _state_at(coordinator, res, dt_str):
  252. """Freeze dt_util.now() to a naive local datetime and call _reservation_window_state."""
  253. frozen = MagicMock()
  254. frozen.replace.return_value = datetime.fromisoformat(dt_str) # naive, no TZ
  255. with patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=frozen):
  256. return coordinator._reservation_window_state(res)
  257. async def test_window_state_active(coordinator):
  258. assert _state_at(coordinator, DATED, "2026-07-03T10:00:00") == "active"
  259. async def test_window_state_pending(coordinator):
  260. assert _state_at(coordinator, DATED, "2026-06-30T10:00:00") == "pending"
  261. async def test_window_state_elapsed(coordinator):
  262. assert _state_at(coordinator, DATED, "2026-07-10T12:00:00") == "elapsed"
  263. async def test_window_state_at_check_in_is_active(coordinator):
  264. assert _state_at(coordinator, DATED, "2026-07-01T14:00:00") == "active"
  265. async def test_window_state_at_check_out_is_elapsed(coordinator):
  266. assert _state_at(coordinator, DATED, "2026-07-05T12:00:00") == "elapsed"
  267. async def test_window_state_no_dates_returns_none(coordinator):
  268. assert coordinator._reservation_window_state(ReservationData()) is None
  269. # ---------------------------------------------------------------------------
  270. # Check-in WiFi key behavior
  271. # ---------------------------------------------------------------------------
  272. _WIFI_NEXT = ReservationData(
  273. guest_name="Bob",
  274. check_in_date=date(2026, 7, 1),
  275. check_in_time=time(14, 0),
  276. check_out_date=date(2026, 7, 5),
  277. check_out_time=time(12, 0),
  278. wifi_ssid="GuestNet",
  279. wifi_key="securepass",
  280. )
  281. # Freeze time at the check-in moment so the window registers as active during check-in tests.
  282. _AT_CHECK_IN = MagicMock()
  283. _AT_CHECK_IN.replace.return_value = datetime(2026, 7, 1, 14, 0, 0)
  284. # Freeze time at (i.e. slightly after) the scheduled check-out moment — this is what
  285. # `dt_util.now()` actually reads when a natural-checkout handler runs in production, and
  286. # is the case that exposed the bug where checkout actions require re-deriving "was old
  287. # active" from wall-clock time (see reservation_diff in models.py).
  288. _AT_CHECK_OUT = MagicMock()
  289. _AT_CHECK_OUT.replace.return_value = datetime(2026, 7, 5, 12, 0, 0)
  290. async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  291. await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
  292. mock_switch = MagicMock()
  293. mock_switch.async_turn_on = AsyncMock()
  294. coordinator._room_state_switch = mock_switch
  295. with (
  296. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  297. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  298. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
  299. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  300. ):
  301. await coordinator._async_do_check_in()
  302. coordinator.cancel_mode_sync()
  303. mock_ssid.assert_awaited_once_with(hass, "GuestNet", True)
  304. mock_wifi.assert_awaited_once_with(hass, "GuestNet", "securepass")
  305. mock_switch.async_turn_on.assert_awaited_once()
  306. async def test_check_in_skips_wifi_when_ssid_none(coordinator: RoomCalCoordinator):
  307. await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid=None, wifi_key="securepass"))
  308. coordinator._room_state_switch = None
  309. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  310. await coordinator._async_do_check_in()
  311. mock_wifi.assert_not_awaited()
  312. async def test_check_in_skips_wifi_when_key_none(coordinator: RoomCalCoordinator):
  313. await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid="GuestNet", wifi_key=None))
  314. coordinator._room_state_switch = None
  315. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  316. await coordinator._async_do_check_in()
  317. mock_wifi.assert_not_awaited()
  318. async def test_check_in_skips_wifi_when_key_empty_string(coordinator: RoomCalCoordinator):
  319. """An open-network default (wifi_key="") should not push a WPA password to UniFi."""
  320. await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid="GuestNet", wifi_key=""))
  321. coordinator._room_state_switch = None
  322. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  323. await coordinator._async_do_check_in()
  324. mock_wifi.assert_not_awaited()
  325. async def test_check_in_skips_wifi_when_use_unifi_off(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  326. await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
  327. mock_switch = MagicMock()
  328. mock_switch.async_turn_on = AsyncMock()
  329. coordinator._room_state_switch = mock_switch
  330. with (
  331. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  332. patch.object(coordinator, "_is_use_unifi_on", return_value=False),
  333. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  334. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  335. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
  336. ):
  337. await coordinator._async_do_check_in()
  338. coordinator.cancel_mode_sync()
  339. mock_ssid.assert_not_awaited()
  340. mock_wifi.assert_not_awaited()
  341. mock_switch.async_turn_on.assert_awaited_once()
  342. async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomCalCoordinator):
  343. await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
  344. mock_switch = MagicMock()
  345. mock_switch.async_turn_on = AsyncMock()
  346. coordinator._room_state_switch = mock_switch
  347. with (
  348. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  349. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  350. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock),
  351. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")),
  352. ):
  353. await coordinator._async_do_check_in()
  354. coordinator.cancel_mode_sync()
  355. mock_switch.async_turn_on.assert_awaited_once()
  356. # ---------------------------------------------------------------------------
  357. # Check-out SSID disable behavior
  358. # ---------------------------------------------------------------------------
  359. async def test_check_out_disables_ssid(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  360. await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
  361. mock_switch = MagicMock()
  362. mock_switch.async_turn_off = AsyncMock()
  363. coordinator._room_state_switch = mock_switch
  364. with (
  365. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  366. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_OUT),
  367. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  368. ):
  369. await coordinator._async_do_check_out()
  370. mock_ssid.assert_awaited_once_with(hass, "GuestNet", False)
  371. mock_switch.async_turn_off.assert_awaited_once()
  372. async def test_check_out_skips_ssid_when_ssid_none(coordinator: RoomCalCoordinator):
  373. await coordinator.store.async_set(STORE_CURRENT, ReservationData(wifi_ssid=None))
  374. coordinator._room_state_switch = None
  375. with patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid:
  376. await coordinator._async_do_check_out()
  377. mock_ssid.assert_not_awaited()
  378. async def test_check_out_skips_ssid_when_use_unifi_off(coordinator: RoomCalCoordinator):
  379. await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
  380. coordinator._room_state_switch = None
  381. with (
  382. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  383. patch.object(coordinator, "_is_use_unifi_on", return_value=False),
  384. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  385. ):
  386. await coordinator._async_do_check_out()
  387. mock_ssid.assert_not_awaited()
  388. async def test_check_out_ssid_error_still_deactivates_guest_mode(coordinator: RoomCalCoordinator):
  389. await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
  390. mock_switch = MagicMock()
  391. mock_switch.async_turn_off = AsyncMock()
  392. coordinator._room_state_switch = mock_switch
  393. with (
  394. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  395. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_OUT),
  396. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", side_effect=HomeAssistantError("hub down")),
  397. ):
  398. await coordinator._async_do_check_out()
  399. mock_switch.async_turn_off.assert_awaited_once()
  400. # ---------------------------------------------------------------------------
  401. # async_start_override / async_end_override
  402. # ---------------------------------------------------------------------------
  403. _OVERRIDE_RES = ReservationData(guest_name="Override Guest", door_code="1111")
  404. _WIFI_OVERRIDE = ReservationData(guest_name="WiFi Guest", wifi_ssid="GuestNet", wifi_key="secret")
  405. async def test_start_override_sets_active_in_data(coordinator: RoomCalCoordinator):
  406. coordinator._room_state_switch = None
  407. await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
  408. assert coordinator.data.active.guest_name == "Override Guest"
  409. # Current (pure backend mirror) is unaffected by the override.
  410. assert coordinator.data.current.guest_name is None
  411. async def test_start_override_sets_override_active_flag(coordinator: RoomCalCoordinator):
  412. coordinator._room_state_switch = None
  413. await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
  414. assert coordinator.data.override_active is True
  415. async def test_start_override_cancels_mode_sync(coordinator: RoomCalCoordinator):
  416. cancel1, cancel2 = MagicMock(), MagicMock()
  417. coordinator._mode_sync_handles = [cancel1, cancel2]
  418. coordinator._room_state_switch = None
  419. await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
  420. cancel1.assert_called_once()
  421. cancel2.assert_called_once()
  422. assert coordinator._mode_sync_handles == []
  423. async def test_start_override_turns_on_guest_mode(coordinator: RoomCalCoordinator):
  424. mock_switch = MagicMock()
  425. mock_switch.async_turn_on = AsyncMock()
  426. coordinator._room_state_switch = mock_switch
  427. active_override = ReservationData(
  428. guest_name=_OVERRIDE_RES.guest_name, door_code=_OVERRIDE_RES.door_code,
  429. check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
  430. check_out_date=date(2026, 7, 5), check_out_time=time(12, 0),
  431. )
  432. with (
  433. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  434. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  435. ):
  436. await coordinator.async_start_override(OverrideData(active=active_override))
  437. coordinator.cancel_mode_sync()
  438. mock_switch.async_turn_on.assert_awaited_once()
  439. async def test_start_override_does_not_turn_on_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator):
  440. mock_switch = MagicMock()
  441. mock_switch.async_turn_on = AsyncMock()
  442. coordinator._room_state_switch = mock_switch
  443. with patch.object(coordinator, "_is_mode_sync_on", return_value=False):
  444. await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
  445. mock_switch.async_turn_on.assert_not_awaited()
  446. async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  447. coordinator._room_state_switch = MagicMock()
  448. coordinator._room_state_switch.async_turn_on = AsyncMock()
  449. active_wifi_override = ReservationData(
  450. guest_name=_WIFI_OVERRIDE.guest_name, wifi_ssid=_WIFI_OVERRIDE.wifi_ssid, wifi_key=_WIFI_OVERRIDE.wifi_key,
  451. check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
  452. check_out_date=date(2026, 7, 5), check_out_time=time(12, 0),
  453. )
  454. with (
  455. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  456. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  457. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
  458. ):
  459. await coordinator.async_start_override(OverrideData(active=active_wifi_override))
  460. coordinator.cancel_mode_sync()
  461. mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
  462. async def test_start_override_skips_wifi_when_mode_sync_off(coordinator: RoomCalCoordinator):
  463. coordinator._room_state_switch = None
  464. with (
  465. patch.object(coordinator, "_is_mode_sync_on", return_value=False),
  466. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
  467. ):
  468. await coordinator.async_start_override(OverrideData(active=_WIFI_OVERRIDE))
  469. mock_wifi.assert_not_awaited()
  470. async def test_start_override_skips_wifi_when_ssid_missing(coordinator: RoomCalCoordinator):
  471. coordinator._room_state_switch = None
  472. no_ssid = ReservationData(wifi_ssid=None, wifi_key="secret")
  473. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  474. await coordinator.async_start_override(OverrideData(active=no_ssid))
  475. mock_wifi.assert_not_awaited()
  476. async def test_start_override_next_always_mirrors_store(coordinator: RoomCalCoordinator):
  477. """There's no such thing as an override-owned Next — Next always mirrors the
  478. store's own feed, regardless of any override on active."""
  479. await coordinator.store.async_set(STORE_NEXT, SAMPLE)
  480. coordinator._room_state_switch = None
  481. await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
  482. assert coordinator.data.next.guest_name == "Alice"
  483. async def test_start_override_applies_defaults_to_current(hass: HomeAssistant):
  484. entry = MockConfigEntry(
  485. domain=DOMAIN,
  486. data={},
  487. options={
  488. CONF_SYNC_MODE: SYNC_MODE_MANUAL,
  489. CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID", "check_out_time": "11:00:00"},
  490. },
  491. )
  492. entry.add_to_hass(hass)
  493. coord = RoomCalCoordinator(hass, entry)
  494. await coord.async_setup()
  495. coord._guest_mode_switch = None
  496. partial_override = ReservationData(guest_name="Override Guest")
  497. await coord.async_start_override(OverrideData(active=partial_override))
  498. assert coord.data.active.guest_name == "Override Guest"
  499. assert coord.data.active.wifi_ssid == "DefaultSSID"
  500. assert coord.data.active.check_out_time == time(11, 0)
  501. async def test_start_override_defaults_do_not_overwrite_explicit_fields(hass: HomeAssistant):
  502. entry = MockConfigEntry(
  503. domain=DOMAIN,
  504. data={},
  505. options={
  506. CONF_SYNC_MODE: SYNC_MODE_MANUAL,
  507. CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"},
  508. },
  509. )
  510. entry.add_to_hass(hass)
  511. coord = RoomCalCoordinator(hass, entry)
  512. await coord.async_setup()
  513. coord._guest_mode_switch = None
  514. explicit_override = ReservationData(guest_name="Override Guest", wifi_ssid="ExplicitSSID")
  515. await coord.async_start_override(OverrideData(active=explicit_override))
  516. assert coord.data.active.wifi_ssid == "ExplicitSSID"
  517. async def test_refresh_during_override_applies_defaults_to_current(hass: HomeAssistant):
  518. entry = MockConfigEntry(
  519. domain=DOMAIN,
  520. data={},
  521. options={
  522. CONF_SYNC_MODE: SYNC_MODE_MANUAL,
  523. CONF_RESERVATION_DEFAULTS: {"door_code": "9999"},
  524. },
  525. )
  526. entry.add_to_hass(hass)
  527. coord = RoomCalCoordinator(hass, entry)
  528. await coord.async_setup()
  529. coord._guest_mode_switch = None
  530. partial_override = ReservationData(guest_name="Override Guest")
  531. await coord.async_start_override(OverrideData(active=partial_override))
  532. await coord.async_refresh()
  533. assert coord.data.active.guest_name == "Override Guest"
  534. assert coord.data.active.door_code == "9999"
  535. async def test_start_override_applies_defaults_to_store_next(hass: HomeAssistant):
  536. entry = MockConfigEntry(
  537. domain=DOMAIN,
  538. data={},
  539. options={
  540. CONF_SYNC_MODE: SYNC_MODE_MANUAL,
  541. CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"},
  542. },
  543. )
  544. entry.add_to_hass(hass)
  545. coord = RoomCalCoordinator(hass, entry)
  546. await coord.async_setup()
  547. coord._guest_mode_switch = None
  548. await coord.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest"))
  549. await coord.async_start_override(OverrideData(active=_OVERRIDE_RES))
  550. assert coord.data.next.guest_name == "Next Guest"
  551. assert coord.data.next.wifi_ssid == "DefaultSSID"
  552. async def test_end_override_clears_override_active(coordinator: RoomCalCoordinator):
  553. coordinator._room_state_switch = None
  554. coordinator._override = OverrideData(active=_OVERRIDE_RES)
  555. await coordinator.async_end_override()
  556. assert coordinator.data.override_active is False
  557. async def test_end_override_restores_store_current(coordinator: RoomCalCoordinator):
  558. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  559. coordinator._room_state_switch = None
  560. coordinator._override = OverrideData(active=_OVERRIDE_RES)
  561. await coordinator.async_end_override()
  562. assert coordinator.data.active.guest_name == "Alice"
  563. async def test_start_override_with_end_at_schedules_and_ends_override(coordinator: RoomCalCoordinator):
  564. coordinator._room_state_switch = None
  565. override = OverrideData(active=ReservationData(guest_name="Timed Guest"))
  566. with (
  567. patch.object(coordinator, "_is_mode_sync_on", return_value=False),
  568. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  569. ):
  570. await coordinator.async_start_override(override, end_at=datetime(2026, 7, 1, 14, 5, 0))
  571. scheduled_handle = coordinator._override_end_handle
  572. assert scheduled_handle is not None
  573. assert coordinator.data.override_active is True
  574. # Simulate the scheduled call_later firing, then cancel the real (never-fired) handle
  575. # so it doesn't leak into the next test.
  576. await coordinator._async_do_override_timeout()
  577. scheduled_handle()
  578. assert coordinator._override_end_handle is None
  579. assert coordinator.data.override_active is False
  580. async def test_stale_override_timeout_does_not_end_a_replacing_override(coordinator: RoomCalCoordinator):
  581. """A timer from an override that was already manually ended/replaced must not end
  582. whatever override is active by the time it fires."""
  583. coordinator._room_state_switch = None
  584. override = OverrideData(active=ReservationData(guest_name="Timed Guest"))
  585. with (
  586. patch.object(coordinator, "_is_mode_sync_on", return_value=False),
  587. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  588. ):
  589. await coordinator.async_start_override(override, end_at=datetime(2026, 7, 1, 14, 5, 0))
  590. stale_target = coordinator._override_end_target
  591. await coordinator.async_end_override()
  592. assert coordinator._override is None
  593. new_override = OverrideData(active=ReservationData(guest_name="New Guest"))
  594. with patch.object(coordinator, "_is_mode_sync_on", return_value=False):
  595. await coordinator.async_start_override(new_override) # no end_at — no new timer
  596. # Simulate the original (stale) timer's callback firing late, after the replacement started.
  597. coordinator._override_end_target = stale_target
  598. await coordinator._async_do_override_timeout()
  599. assert coordinator._override is new_override
  600. assert coordinator.data.override_active is True
  601. async def test_start_override_empty_current_triggers_checkout_not_checkin(
  602. hass: HomeAssistant, coordinator: RoomCalCoordinator
  603. ):
  604. """Overriding Current to empty while a real guest is checked in must run checkout
  605. actions (SSID disable, guest-mode off) — not try to check in an empty record."""
  606. checked_in_guest = ReservationData(
  607. guest_name="Real Guest",
  608. check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
  609. check_out_date=date(2026, 7, 5), check_out_time=time(12, 0),
  610. wifi_ssid="GuestNet", wifi_key="secret",
  611. )
  612. await coordinator.store.async_set(STORE_CURRENT, checked_in_guest)
  613. mock_switch = MagicMock()
  614. mock_switch.async_turn_off = AsyncMock()
  615. mock_switch.async_turn_on = AsyncMock()
  616. coordinator._room_state_switch = mock_switch
  617. with (
  618. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  619. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  620. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  621. ):
  622. await coordinator.async_start_override(OverrideData(active=ReservationData()))
  623. coordinator.cancel_mode_sync()
  624. mock_ssid.assert_awaited_once_with(hass, "GuestNet", False)
  625. mock_switch.async_turn_off.assert_awaited_once()
  626. mock_switch.async_turn_on.assert_not_awaited()
  627. assert coordinator.data.override_active is True
  628. async def test_override_active_schedules_no_mode_sync_handles(
  629. hass: HomeAssistant, coordinator: RoomCalCoordinator
  630. ):
  631. """While an override is active, no Store-driven check-in/check-out handles are
  632. scheduled — an override parks Active until it's ended manually or its own end_at
  633. timer fires. Next's check-in time elapsing (Store empty -> occupied) must not
  634. overwrite the override."""
  635. override_guest = ReservationData(
  636. guest_name="Override Guest",
  637. check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
  638. check_out_date=date(2026, 7, 5), check_out_time=time(12, 0),
  639. )
  640. mock_switch = MagicMock()
  641. mock_switch.async_turn_on = AsyncMock()
  642. mock_switch.async_turn_off = AsyncMock()
  643. coordinator._room_state_switch = mock_switch
  644. await coordinator.store.async_set(STORE_NEXT, ReservationData(
  645. guest_name="Next Guest",
  646. check_in_date=date(2026, 7, 1), check_in_time=time(13, 0),
  647. ))
  648. with (
  649. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  650. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  651. ):
  652. await coordinator.async_start_override(OverrideData(active=override_guest))
  653. assert coordinator._mode_sync_handles == []
  654. assert coordinator.data.override_active is True
  655. assert coordinator.data.active.guest_name == "Override Guest"
  656. async def test_ending_override_reschedules_mode_sync_from_store(
  657. hass: HomeAssistant, coordinator: RoomCalCoordinator
  658. ):
  659. """Ending an override re-arms the normal check-in/check-out handles from whatever
  660. the Store currently holds — the same reconcile-and-reschedule path used at startup."""
  661. override_guest = ReservationData(
  662. guest_name="Override Guest",
  663. check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
  664. check_out_date=date(2026, 7, 5), check_out_time=time(12, 0),
  665. )
  666. coordinator._room_state_switch = None
  667. await coordinator.store.async_set(STORE_NEXT, ReservationData(
  668. guest_name="Next Guest",
  669. check_in_date=date(2026, 7, 1), check_in_time=time(15, 0),
  670. ))
  671. with (
  672. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  673. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  674. ):
  675. await coordinator.async_start_override(OverrideData(active=override_guest))
  676. assert coordinator._mode_sync_handles == []
  677. await coordinator.async_end_override()
  678. assert coordinator.data.override_active is False
  679. # The check-in handle for Next Guest (15:00, still in the future relative to
  680. # _AT_CHECK_IN) is re-armed now that the override no longer owns Active.
  681. assert len(coordinator._mode_sync_handles) >= 1
  682. coordinator.cancel_mode_sync()
  683. # ---------------------------------------------------------------------------
  684. # Thin updates during override
  685. # ---------------------------------------------------------------------------
  686. async def test_thin_update_during_override_updates_override_not_store(coordinator: RoomCalCoordinator):
  687. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  688. await coordinator.async_refresh()
  689. coordinator._override = OverrideData(active=_OVERRIDE_RES)
  690. await coordinator.async_apply_update({"door_code": "9999"})
  691. assert coordinator.data.active.door_code == "9999"
  692. # Store is unchanged
  693. assert coordinator.store.get(STORE_CURRENT).door_code == "0472"
  694. async def test_thin_update_during_override_data_still_shows_override_active(coordinator: RoomCalCoordinator):
  695. await coordinator.async_refresh()
  696. coordinator._override = OverrideData(active=_OVERRIDE_RES)
  697. await coordinator.async_apply_update({"door_code": "9999"})
  698. assert coordinator.data.override_active is True
  699. async def test_thin_update_with_no_override_running_starts_one(coordinator: RoomCalCoordinator):
  700. """Patching with no override active must snapshot `active` as an override first,
  701. so the patch survives the next backend poll instead of being silently overwritten."""
  702. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  703. await coordinator.async_refresh()
  704. assert coordinator.data.override_active is False
  705. await coordinator.async_apply_update({"guest_name": "Patched Guest"})
  706. assert coordinator.data.override_active is True
  707. assert coordinator.data.active.guest_name == "Patched Guest"
  708. # Store's own current is untouched by the patch.
  709. assert coordinator.store.get(STORE_CURRENT).guest_name == "Alice"
  710. # ---------------------------------------------------------------------------
  711. # Backend fetch during override preserves override display
  712. # ---------------------------------------------------------------------------
  713. async def test_backend_fetch_during_override_updates_store_but_preserves_override(hass: HomeAssistant):
  714. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  715. coord = RoomCalCoordinator(hass, entry)
  716. await coord.async_setup()
  717. coord._override = OverrideData(active=_OVERRIDE_RES)
  718. backend_data = ReservationData(guest_name="Backend Alice")
  719. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  720. MockBackend.return_value.async_fetch = AsyncMock(return_value=(backend_data, None))
  721. await coord.async_refresh()
  722. # Store was written by the backend
  723. assert coord.store.get(STORE_CURRENT).guest_name == "Backend Alice"
  724. # But displayed active still shows the override; current mirrors the fresh backend write.
  725. assert coord.data.active.guest_name == "Override Guest"
  726. assert coord.data.current.guest_name == "Backend Alice"
  727. assert coord.data.override_active is True
  728. # ---------------------------------------------------------------------------
  729. # async_reconcile_from_store (shared helper)
  730. # ---------------------------------------------------------------------------
  731. _ACTIVE = ReservationData(
  732. guest_name="Active Guest",
  733. check_in_date=date(2026, 6, 20),
  734. check_in_time=time(14, 0),
  735. check_out_date=date(2026, 6, 25),
  736. check_out_time=time(12, 0),
  737. )
  738. _ELAPSED = ReservationData(
  739. guest_name="Past Guest",
  740. check_in_date=date(2026, 6, 1),
  741. check_in_time=time(14, 0),
  742. check_out_date=date(2026, 6, 5),
  743. check_out_time=time(12, 0),
  744. )
  745. _FUTURE = ReservationData(
  746. guest_name="Future Guest",
  747. check_in_date=date(2026, 7, 1),
  748. check_in_time=time(14, 0),
  749. check_out_date=date(2026, 7, 5),
  750. check_out_time=time(12, 0),
  751. )
  752. # Freeze time at 2026-06-22 12:00 local (naive)
  753. _RECONCILE_NOW = MagicMock()
  754. _RECONCILE_NOW.replace.return_value = datetime(2026, 6, 22, 12, 0, 0)
  755. async def test_reconcile_active_current_turns_on_guest_mode(coordinator: RoomCalCoordinator):
  756. await coordinator.store.async_set(STORE_CURRENT, _ACTIVE)
  757. mock_switch = MagicMock()
  758. mock_switch.async_turn_on = AsyncMock()
  759. mock_switch.async_turn_off = AsyncMock()
  760. coordinator._room_state_switch = mock_switch
  761. with (
  762. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  763. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  764. ):
  765. await coordinator.async_reconcile_from_store()
  766. coordinator.cancel_mode_sync()
  767. mock_switch.async_turn_on.assert_awaited_once()
  768. mock_switch.async_turn_off.assert_not_awaited()
  769. async def test_reconcile_elapsed_current_clears_store_and_turns_off_guest_mode(coordinator: RoomCalCoordinator):
  770. await coordinator.store.async_set(STORE_CURRENT, _ELAPSED)
  771. mock_switch = MagicMock()
  772. mock_switch.async_turn_on = AsyncMock()
  773. mock_switch.async_turn_off = AsyncMock()
  774. coordinator._room_state_switch = mock_switch
  775. with (
  776. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  777. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  778. ):
  779. await coordinator.async_reconcile_from_store()
  780. assert coordinator.store.get(STORE_CURRENT) is None
  781. mock_switch.async_turn_off.assert_awaited_once()
  782. async def test_reconcile_promotes_next_when_check_in_elapsed(coordinator: RoomCalCoordinator):
  783. await coordinator.store.async_set(STORE_NEXT, _ACTIVE)
  784. with (
  785. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  786. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  787. ):
  788. await coordinator.async_reconcile_from_store()
  789. coordinator.cancel_mode_sync()
  790. assert coordinator.store.get(STORE_CURRENT).guest_name == "Active Guest"
  791. async def test_reconcile_turns_off_guest_mode_when_store_already_cleared(coordinator: RoomCalCoordinator):
  792. """Store was cleared (e.g. checkout write ran) but guest-mode-off action never ran."""
  793. # No current reservation in store — it was already cleared before HA was disabled.
  794. mock_switch = MagicMock()
  795. mock_switch.async_turn_on = AsyncMock()
  796. mock_switch.async_turn_off = AsyncMock()
  797. coordinator._room_state_switch = mock_switch
  798. with (
  799. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  800. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  801. ):
  802. await coordinator.async_reconcile_from_store()
  803. coordinator.cancel_mode_sync()
  804. mock_switch.async_turn_off.assert_awaited_once()
  805. mock_switch.async_turn_on.assert_not_awaited()
  806. async def test_reconcile_does_not_touch_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator):
  807. await coordinator.store.async_set(STORE_CURRENT, _ACTIVE)
  808. mock_switch = MagicMock()
  809. mock_switch.async_turn_on = AsyncMock()
  810. mock_switch.async_turn_off = AsyncMock()
  811. coordinator._room_state_switch = mock_switch
  812. with (
  813. patch.object(coordinator, "_is_mode_sync_on", return_value=False),
  814. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  815. ):
  816. await coordinator.async_reconcile_from_store()
  817. mock_switch.async_turn_on.assert_not_awaited()
  818. mock_switch.async_turn_off.assert_not_awaited()
  819. # ---------------------------------------------------------------------------
  820. # _diff_with_unifi_check
  821. # ---------------------------------------------------------------------------
  822. _ACTIVE_WIFI_RES = ReservationData(
  823. check_in_date=date(2026, 7, 1),
  824. check_in_time=time(14, 0),
  825. check_out_date=date(2026, 7, 5),
  826. check_out_time=time(12, 0),
  827. wifi_ssid="GuestNet",
  828. wifi_key="correct-key",
  829. )
  830. _DURING_WINDOW = datetime(2026, 7, 3, 12, 0, 0)
  831. def _unifi_diff(coordinator, old, new, now, *, enabled, key):
  832. with (
  833. patch.object(coordinator, "_is_use_unifi_on", return_value=True),
  834. patch("custom_components.room_cal_ha.wifi.get_wlan_state", return_value=(enabled, key)),
  835. ):
  836. return coordinator._diff_with_unifi_check(old, new, now)
  837. async def test_unifi_diff_ssid_disabled_adds_checkin_needed(coordinator):
  838. diff = _unifi_diff(coordinator, None, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="correct-key")
  839. assert diff.checkin_needed
  840. async def test_unifi_diff_key_mismatch_adds_wifi_key_changed(coordinator):
  841. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="wrong-key")
  842. assert diff.wifi_key_changed
  843. async def test_unifi_diff_ssid_disabled_and_key_wrong_sets_both(coordinator):
  844. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="wrong-key")
  845. assert diff.checkin_needed
  846. assert diff.wifi_key_changed
  847. async def test_unifi_diff_state_matches_no_extra_flags(coordinator):
  848. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
  849. assert not diff.has_changes
  850. async def test_unifi_diff_skipped_when_use_unifi_off(coordinator):
  851. with (
  852. patch.object(coordinator, "_is_use_unifi_on", return_value=False),
  853. patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get,
  854. ):
  855. diff = coordinator._diff_with_unifi_check(_ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW)
  856. mock_get.assert_not_called()
  857. assert not diff.has_changes # base diff: same record, no changes; unifi check skipped
  858. async def test_unifi_diff_skipped_when_window_not_active(coordinator):
  859. # Unchanged record (no occupancy transition) isolates the UniFi drift-check gate itself:
  860. # even with use_unifi on, no wall-clock-active window means no drift check is made.
  861. before_window = datetime(2026, 6, 30, 12, 0, 0)
  862. with (
  863. patch.object(coordinator, "_is_use_unifi_on", return_value=True),
  864. patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get,
  865. ):
  866. diff = coordinator._diff_with_unifi_check(_ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, before_window)
  867. mock_get.assert_not_called()
  868. assert not diff.has_changes
  869. async def test_unifi_diff_skipped_when_hub_unavailable(coordinator):
  870. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=None, key=None)
  871. assert not diff.has_changes
  872. async def test_unifi_diff_key_check_skipped_when_actual_key_none(coordinator):
  873. # Controller doesn't expose passphrase — only enabled state checked.
  874. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key=None)
  875. assert not diff.has_changes
  876. async def test_unifi_diff_preserves_original_diff_flags(coordinator):
  877. # Even when UniFi state is clean, original checkin_needed from the base diff is kept.
  878. old_inactive = ReservationData(wifi_ssid="GuestNet", wifi_key="correct-key") # no dates → not active
  879. diff = _unifi_diff(coordinator, old_inactive, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
  880. assert diff.checkin_needed # from reservation_diff: old not active, new is active