test_coordinator.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. """Tests for ZLockCoordinator: slot fetch, alarm event handling, name/event persistence."""
  2. import pytest
  3. from unittest.mock import patch
  4. from pytest_homeassistant_custom_component.common import flush_store
  5. from custom_components.zlock_slots.coordinator import ZLockCoordinator
  6. from custom_components.zlock_slots.const import (
  7. DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
  8. )
  9. from tests.conftest import (
  10. LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY,
  11. FakeUserData, FakeNode,
  12. )
  13. _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
  14. @pytest.fixture
  15. async def coord(hass, config_entry, mock_node):
  16. config_entry.add_to_hass(hass)
  17. with patch(_ZWAVE_HELPER, return_value=mock_node):
  18. c = ZLockCoordinator(hass, config_entry)
  19. await c.async_setup()
  20. await hass.async_block_till_done()
  21. yield c
  22. c.async_teardown()
  23. # ---------------------------------------------------------------------------
  24. # Slot fetch
  25. # ---------------------------------------------------------------------------
  26. async def test_slot_fetch_populates_data(coord):
  27. slots = coord.data[LOCK_ENTITY_ID].slots
  28. assert set(slots) == {1, 2}
  29. async def test_slot_enabled_status(coord):
  30. assert coord.data[LOCK_ENTITY_ID].slots[1].status == "enabled"
  31. assert coord.data[LOCK_ENTITY_ID].slots[2].status == "disabled"
  32. async def test_slot_0_excluded(hass, config_entry):
  33. node = FakeNode(users=[FakeUserData(0), FakeUserData(1)])
  34. config_entry.add_to_hass(hass)
  35. with patch(_ZWAVE_HELPER, return_value=node):
  36. c = ZLockCoordinator(hass, config_entry)
  37. await c.async_setup()
  38. assert 0 not in c.data[LOCK_ENTITY_ID].slots
  39. assert 1 in c.data[LOCK_ENTITY_ID].slots
  40. c.async_teardown()
  41. async def test_slot_name_applied_during_fetch(hass, config_entry, mock_node):
  42. config_entry.add_to_hass(hass)
  43. with patch(_ZWAVE_HELPER, return_value=mock_node):
  44. c = ZLockCoordinator(hass, config_entry)
  45. # Pre-seed a name before setup fetches slots.
  46. c._names[f"{LOCK_ENTITY_ID}:1"] = "Alice"
  47. await c.async_setup()
  48. assert c.data[LOCK_ENTITY_ID].slots[1].name == "Alice"
  49. assert c.data[LOCK_ENTITY_ID].slots[2].name is None
  50. c.async_teardown()
  51. async def test_fetch_failure_returns_empty(hass, config_entry):
  52. config_entry.add_to_hass(hass)
  53. with patch(_ZWAVE_HELPER, side_effect=Exception("zwave unavailable")):
  54. c = ZLockCoordinator(hass, config_entry)
  55. await c.async_setup()
  56. assert c.data[LOCK_ENTITY_ID].slots == {}
  57. c.async_teardown()
  58. # ---------------------------------------------------------------------------
  59. # Name persistence
  60. # ---------------------------------------------------------------------------
  61. async def test_set_name_stores_and_retrieves(coord):
  62. await coord.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
  63. assert coord.get_name(LOCK_ENTITY_ID, 1) == "Alice"
  64. async def test_set_name_empty_clears_entry(coord):
  65. await coord.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
  66. await coord.async_set_name(LOCK_ENTITY_ID, 1, "")
  67. assert coord.get_name(LOCK_ENTITY_ID, 1) is None
  68. async def test_set_name_updates_cached_slot(coord):
  69. await coord.async_set_name(LOCK_ENTITY_ID, 1, "Bob")
  70. assert coord.data[LOCK_ENTITY_ID].slots[1].name == "Bob"
  71. async def test_set_name_persists_to_store(hass, config_entry, mock_node):
  72. """Names survive a coordinator restart via the Store."""
  73. config_entry.add_to_hass(hass)
  74. with patch(_ZWAVE_HELPER, return_value=mock_node):
  75. c1 = ZLockCoordinator(hass, config_entry)
  76. await c1.async_setup()
  77. await c1.async_set_name(LOCK_ENTITY_ID, 1, "Persisted")
  78. c1.async_teardown()
  79. c2 = ZLockCoordinator(hass, config_entry)
  80. await c2.async_setup()
  81. assert c2.get_name(LOCK_ENTITY_ID, 1) == "Persisted"
  82. c2.async_teardown()
  83. # ---------------------------------------------------------------------------
  84. # Alarm event handling
  85. # ---------------------------------------------------------------------------
  86. async def _fire_alarm(hass, alarm_type: str, alarm_level: str = "0"):
  87. hass.states.async_set(ALARM_LEVEL_ENTITY, alarm_level)
  88. hass.states.async_set(ALARM_TYPE_ENTITY, alarm_type)
  89. await hass.async_block_till_done()
  90. async def _flush_delayed_save(coord):
  91. """Alarm-driven Store writes are debounced; force pending writes to flush."""
  92. await flush_store(coord._store)
  93. async def test_keypad_lock_sets_action_and_user(hass, coord):
  94. hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
  95. hass.states.async_set(ALARM_TYPE_ENTITY, "18")
  96. await hass.async_block_till_done()
  97. assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
  98. assert coord.data[LOCK_ENTITY_ID].last_user == "1"
  99. async def test_keypad_lock_uses_slot_name(hass, coord):
  100. await coord.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
  101. hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
  102. hass.states.async_set(ALARM_TYPE_ENTITY, "18")
  103. await hass.async_block_till_done()
  104. assert coord.data[LOCK_ENTITY_ID].last_user == "Alice"
  105. async def test_keypad_unlock(hass, coord):
  106. await _fire_alarm(hass, "19", alarm_level="2")
  107. assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
  108. assert coord.data[LOCK_ENTITY_ID].last_user == "2"
  109. async def test_manual_lock(hass, coord):
  110. await _fire_alarm(hass, "21")
  111. assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
  112. assert coord.data[LOCK_ENTITY_ID].last_user == "<Manual>"
  113. async def test_manual_unlock(hass, coord):
  114. await _fire_alarm(hass, "22")
  115. assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
  116. assert coord.data[LOCK_ENTITY_ID].last_user == "<Manual>"
  117. async def test_radio_lock(hass, coord):
  118. await _fire_alarm(hass, "24")
  119. assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
  120. assert coord.data[LOCK_ENTITY_ID].last_user == "<Radio>"
  121. async def test_radio_unlock(hass, coord):
  122. await _fire_alarm(hass, "25")
  123. assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
  124. assert coord.data[LOCK_ENTITY_ID].last_user == "<Radio>"
  125. async def test_unknown_alarm_type_ignored(hass, coord):
  126. await _fire_alarm(hass, "99")
  127. assert coord.data[LOCK_ENTITY_ID].last_action is None
  128. assert coord.data[LOCK_ENTITY_ID].last_user is None
  129. async def test_non_integer_alarm_state_ignored(hass, coord):
  130. hass.states.async_set(ALARM_TYPE_ENTITY, "unavailable")
  131. await hass.async_block_till_done()
  132. assert coord.data[LOCK_ENTITY_ID].last_action is None
  133. # ---------------------------------------------------------------------------
  134. # alarm_type / alarm_level arrival ordering
  135. # ---------------------------------------------------------------------------
  136. async def test_alarm_type_before_level_resolves_when_level_arrives(hass, coord):
  137. """Reproduces the reported bug: type fires first with stale level in state machine."""
  138. # Fire alarm_type with NO prior alarm_level update — nothing should emit yet.
  139. hass.states.async_set(ALARM_TYPE_ENTITY, "19")
  140. await hass.async_block_till_done()
  141. assert coord.data[LOCK_ENTITY_ID].last_action is None
  142. # Now alarm_level arrives — should resolve the pending action.
  143. hass.states.async_set(ALARM_LEVEL_ENTITY, "87")
  144. await hass.async_block_till_done()
  145. assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
  146. assert coord.data[LOCK_ENTITY_ID].last_user == "87"
  147. async def test_alarm_type_before_level_uses_name(hass, coord):
  148. await coord.async_set_name(LOCK_ENTITY_ID, 87, "Grace")
  149. hass.states.async_set(ALARM_TYPE_ENTITY, "18")
  150. await hass.async_block_till_done()
  151. hass.states.async_set(ALARM_LEVEL_ENTITY, "87")
  152. await hass.async_block_till_done()
  153. assert coord.data[LOCK_ENTITY_ID].last_user == "Grace"
  154. async def test_stale_alarm_level_not_used(hass, coord):
  155. """An alarm_level older than the window must not be consumed by a later alarm_type."""
  156. with patch("custom_components.zlock_slots.coordinator.time") as mock_time:
  157. mock_time.monotonic.return_value = 0.0
  158. hass.states.async_set(ALARM_LEVEL_ENTITY, "3") # level=3 at t=0
  159. await hass.async_block_till_done()
  160. mock_time.monotonic.return_value = 10.0 # 10 s later — outside window
  161. hass.states.async_set(ALARM_TYPE_ENTITY, "19")
  162. await hass.async_block_till_done()
  163. # Stale level rejected; event goes pending — nothing emitted yet.
  164. assert coord.data[LOCK_ENTITY_ID].last_action is None
  165. # Fresh level arrives and resolves the pending action.
  166. hass.states.async_set(ALARM_LEVEL_ENTITY, "87")
  167. await hass.async_block_till_done()
  168. assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
  169. assert coord.data[LOCK_ENTITY_ID].last_user == "87"
  170. async def test_new_alarm_type_clears_pending_slot_alarm(hass, coord):
  171. """A second alarm_type before level arrives should replace the first pending."""
  172. hass.states.async_set(ALARM_TYPE_ENTITY, "18") # lock pending
  173. await hass.async_block_till_done()
  174. hass.states.async_set(ALARM_TYPE_ENTITY, "19") # unlock replaces it
  175. await hass.async_block_till_done()
  176. hass.states.async_set(ALARM_LEVEL_ENTITY, "5")
  177. await hass.async_block_till_done()
  178. assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
  179. # ---------------------------------------------------------------------------
  180. # last_events persistence and restore
  181. # ---------------------------------------------------------------------------
  182. async def test_last_events_persisted_after_alarm(hass, config_entry, mock_node):
  183. config_entry.add_to_hass(hass)
  184. with patch(_ZWAVE_HELPER, return_value=mock_node):
  185. c = ZLockCoordinator(hass, config_entry)
  186. await c.async_setup()
  187. hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
  188. hass.states.async_set(ALARM_TYPE_ENTITY, "18")
  189. await hass.async_block_till_done()
  190. await _flush_delayed_save(c)
  191. stored = await c._store.async_load()
  192. assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "locked"
  193. assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_user"] == "1"
  194. c.async_teardown()
  195. async def test_last_events_restored_on_startup(hass, config_entry, mock_node):
  196. """last_action/last_user are non-None immediately after a cold start."""
  197. config_entry.add_to_hass(hass)
  198. with patch(_ZWAVE_HELPER, return_value=mock_node):
  199. c1 = ZLockCoordinator(hass, config_entry)
  200. await c1.async_setup()
  201. hass.states.async_set(ALARM_LEVEL_ENTITY, "2")
  202. hass.states.async_set(ALARM_TYPE_ENTITY, "22")
  203. await hass.async_block_till_done()
  204. await _flush_delayed_save(c1)
  205. c1.async_teardown()
  206. c2 = ZLockCoordinator(hass, config_entry)
  207. await c2.async_setup()
  208. assert c2.data[LOCK_ENTITY_ID].last_action == "unlocked"
  209. assert c2.data[LOCK_ENTITY_ID].last_user == "<Manual>"
  210. c2.async_teardown()
  211. async def test_names_still_present_after_alarm_save(hass, config_entry, mock_node):
  212. """_async_save merges names + last_events; names must not be clobbered."""
  213. config_entry.add_to_hass(hass)
  214. with patch(_ZWAVE_HELPER, return_value=mock_node):
  215. c = ZLockCoordinator(hass, config_entry)
  216. await c.async_setup()
  217. await c.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
  218. await _fire_alarm(hass, "18", "1")
  219. await _flush_delayed_save(c)
  220. stored = await c._store.async_load()
  221. assert stored["names"][f"{LOCK_ENTITY_ID}:1"] == "Alice"
  222. assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "locked"
  223. c.async_teardown()
  224. async def test_alarm_saves_are_debounced(hass, coord):
  225. """Back-to-back alarms coalesce into a single delayed Store write."""
  226. calls = []
  227. real_data_to_save = coord._data_to_save
  228. def counting_data_to_save():
  229. calls.append(1)
  230. return real_data_to_save()
  231. coord._data_to_save = counting_data_to_save
  232. await _fire_alarm(hass, "21")
  233. await _fire_alarm(hass, "22")
  234. assert not calls # nothing written yet
  235. await _flush_delayed_save(coord)
  236. assert len(calls) == 1 # single coalesced write
  237. stored = await coord._store.async_load()
  238. assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "unlocked"
  239. # ---------------------------------------------------------------------------
  240. # Group state
  241. # ---------------------------------------------------------------------------
  242. @pytest.fixture
  243. def group_config_entry():
  244. from pytest_homeassistant_custom_component.common import MockConfigEntry
  245. return MockConfigEntry(
  246. domain=DOMAIN,
  247. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
  248. options={
  249. CONF_GROUPS: [{
  250. CONF_GROUP_NAME: "All Locks",
  251. CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID],
  252. }]
  253. },
  254. entry_id="test_entry_group",
  255. )
  256. async def test_group_state_updated_on_alarm(hass, group_config_entry, mock_node):
  257. group_config_entry.add_to_hass(hass)
  258. with patch(_ZWAVE_HELPER, return_value=mock_node):
  259. c = ZLockCoordinator(hass, group_config_entry)
  260. await c.async_setup()
  261. await _fire_alarm(hass, "19", "1")
  262. assert c.group_data["All Locks"].last_action == "unlocked"
  263. assert c.group_data["All Locks"].last_user == "1"
  264. c.async_teardown()
  265. async def test_group_last_events_restored(hass, group_config_entry, mock_node):
  266. group_config_entry.add_to_hass(hass)
  267. with patch(_ZWAVE_HELPER, return_value=mock_node):
  268. c1 = ZLockCoordinator(hass, group_config_entry)
  269. await c1.async_setup()
  270. await _fire_alarm(hass, "24")
  271. await _flush_delayed_save(c1)
  272. c1.async_teardown()
  273. c2 = ZLockCoordinator(hass, group_config_entry)
  274. await c2.async_setup()
  275. assert c2.group_data["All Locks"].last_action == "locked"
  276. assert c2.group_data["All Locks"].last_user == "<Radio>"
  277. c2.async_teardown()