test_coordinator.py 12 KB

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