test_cover.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. """Tests for the cover entity."""
  2. from unittest.mock import AsyncMock, MagicMock, Mock, patch
  3. import pytest
  4. from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature
  5. from pytest_homeassistant_custom_component.common import MockConfigEntry
  6. from custom_components.tuya_local.const import (
  7. CONF_DEVICE_ID,
  8. CONF_PROTOCOL_VERSION,
  9. CONF_TYPE,
  10. DOMAIN,
  11. )
  12. from custom_components.tuya_local.cover import TuyaLocalCover, async_setup_entry
  13. @pytest.mark.asyncio
  14. async def test_init_entry(hass):
  15. """Test the initialisation."""
  16. entry = MockConfigEntry(
  17. domain=DOMAIN,
  18. data={
  19. CONF_TYPE: "garage_door_opener",
  20. CONF_DEVICE_ID: "dummy",
  21. CONF_PROTOCOL_VERSION: "auto",
  22. },
  23. )
  24. m_add_entities = Mock()
  25. m_device = AsyncMock()
  26. hass.data[DOMAIN] = {
  27. "dummy": {
  28. "device": m_device,
  29. },
  30. }
  31. await async_setup_entry(hass, entry, m_add_entities)
  32. assert type(hass.data[DOMAIN]["dummy"]["cover_garage"]) is TuyaLocalCover
  33. m_add_entities.assert_called_once()
  34. @pytest.mark.asyncio
  35. async def test_init_entry_fails_if_device_has_no_cover(hass):
  36. """Test initialisation when device has no matching entity"""
  37. entry = MockConfigEntry(
  38. domain=DOMAIN,
  39. data={
  40. CONF_TYPE: "kogan_heater",
  41. CONF_DEVICE_ID: "dummy",
  42. CONF_PROTOCOL_VERSION: "auto",
  43. },
  44. )
  45. m_add_entities = Mock()
  46. m_device = AsyncMock()
  47. hass.data[DOMAIN] = {
  48. "dummy": {
  49. "device": m_device,
  50. },
  51. }
  52. try:
  53. await async_setup_entry(hass, entry, m_add_entities)
  54. assert False
  55. except ValueError:
  56. pass
  57. m_add_entities.assert_not_called()
  58. @pytest.mark.asyncio
  59. async def test_init_entry_fails_if_config_is_missing(hass):
  60. """Test initialisation when device has no matching entity"""
  61. entry = MockConfigEntry(
  62. domain=DOMAIN,
  63. data={
  64. CONF_TYPE: "non_existing",
  65. CONF_DEVICE_ID: "dummy",
  66. CONF_PROTOCOL_VERSION: "auto",
  67. },
  68. )
  69. # although async, the async_add_entities function passed to
  70. # async_setup_entry is called truly asynchronously. If we use
  71. # AsyncMock, it expects us to await the result.
  72. m_add_entities = Mock()
  73. m_device = AsyncMock()
  74. hass.data[DOMAIN] = {}
  75. hass.data[DOMAIN]["dummy"] = {}
  76. hass.data[DOMAIN]["dummy"]["device"] = m_device
  77. try:
  78. await async_setup_entry(hass, entry, m_add_entities)
  79. assert False
  80. except ValueError:
  81. pass
  82. m_add_entities.assert_not_called()
  83. def _make_cover(
  84. position=True,
  85. currentpos=False,
  86. tiltpos=False,
  87. control=True,
  88. action=True,
  89. open_dp=False,
  90. control_values=None,
  91. ):
  92. """Create a TuyaLocalCover with mocked internals."""
  93. if control_values is None:
  94. control_values = ["open", "close", "stop"]
  95. device = MagicMock()
  96. config = MagicMock()
  97. config.device_class = None
  98. config.name = "Test Cover"
  99. config.translation_key = None
  100. config.translation_only_key = None
  101. config.translation_placeholders = None
  102. config.entity_category = None
  103. dps = {}
  104. if position:
  105. dp = MagicMock()
  106. dp.name = "position"
  107. dps["position"] = dp
  108. if currentpos:
  109. dp = MagicMock()
  110. dp.name = "current_position"
  111. dps["current_position"] = dp
  112. if tiltpos:
  113. dp = MagicMock()
  114. dp.name = "tilt_position"
  115. dps["tilt_position"] = dp
  116. if control:
  117. dp = MagicMock()
  118. dp.name = "control"
  119. dp.values.return_value = control_values
  120. dps["control"] = dp
  121. if action:
  122. dp = MagicMock()
  123. dp.name = "action"
  124. dps["action"] = dp
  125. if open_dp:
  126. dp = MagicMock()
  127. dp.name = "open"
  128. dps["open"] = dp
  129. config.dps.return_value = list(dps.values())
  130. # Patch __init__ to avoid MRO issues with HA entity base classes
  131. cover = object.__new__(TuyaLocalCover)
  132. cover._device = device
  133. cover._config = config
  134. cover._attr_dps = []
  135. cover._attr_translation_key = None
  136. cover._attr_translation_placeholders = None
  137. cover._position_dp = dps.get("position")
  138. cover._currentpos_dp = dps.get("current_position")
  139. cover._tiltpos_dp = dps.get("tilt_position")
  140. cover._control_dp = dps.get("control")
  141. cover._action_dp = dps.get("action")
  142. cover._open_dp = dps.get("open")
  143. # Build support flags
  144. cover._support_flags = CoverEntityFeature(0)
  145. if cover._position_dp:
  146. cover._support_flags |= CoverEntityFeature.SET_POSITION
  147. if cover._control_dp:
  148. vals = cover._control_dp.values(device)
  149. if "stop" in vals:
  150. cover._support_flags |= CoverEntityFeature.STOP
  151. if "open" in vals:
  152. cover._support_flags |= CoverEntityFeature.OPEN
  153. if "close" in vals:
  154. cover._support_flags |= CoverEntityFeature.CLOSE
  155. if cover._tiltpos_dp:
  156. cover._support_flags |= CoverEntityFeature.SET_TILT_POSITION
  157. return cover
  158. class TestSupportedFeatures:
  159. def test_all_features(self):
  160. cover = _make_cover(position=True, control=True, tiltpos=True)
  161. flags = cover.supported_features
  162. assert flags & CoverEntityFeature.SET_POSITION
  163. assert flags & CoverEntityFeature.STOP
  164. assert flags & CoverEntityFeature.OPEN
  165. assert flags & CoverEntityFeature.CLOSE
  166. assert flags & CoverEntityFeature.SET_TILT_POSITION
  167. def test_position_only(self):
  168. cover = _make_cover(position=True, control=False, tiltpos=False, action=False)
  169. flags = cover.supported_features
  170. assert flags & CoverEntityFeature.SET_POSITION
  171. assert not (flags & CoverEntityFeature.STOP)
  172. def test_no_stop(self):
  173. cover = _make_cover(control=True, control_values=["open", "close"])
  174. flags = cover.supported_features
  175. assert not (flags & CoverEntityFeature.STOP)
  176. assert flags & CoverEntityFeature.OPEN
  177. assert flags & CoverEntityFeature.CLOSE
  178. class TestDeviceClass:
  179. def test_valid_class(self):
  180. cover = _make_cover()
  181. cover._config.device_class = "garage"
  182. assert cover.device_class == CoverDeviceClass.GARAGE
  183. def test_none_class(self):
  184. cover = _make_cover()
  185. cover._config.device_class = None
  186. assert cover.device_class is None
  187. def test_invalid_class_logs_warning(self):
  188. cover = _make_cover()
  189. cover._config.device_class = "invalid_class"
  190. with patch("custom_components.tuya_local.cover._LOGGER") as mock_logger:
  191. result = cover.device_class
  192. assert result is None
  193. mock_logger.warning.assert_called_once()
  194. class TestStateToPercent:
  195. def test_opened(self):
  196. cover = _make_cover()
  197. assert cover._state_to_percent("opened") == 100
  198. def test_closed(self):
  199. cover = _make_cover()
  200. assert cover._state_to_percent("closed") == 0
  201. def test_other(self):
  202. cover = _make_cover()
  203. assert cover._state_to_percent("opening") == 50
  204. class TestCurrentCoverPosition:
  205. def test_from_currentpos_dp(self):
  206. cover = _make_cover(currentpos=True)
  207. cover._currentpos_dp.get_value.return_value = 75
  208. assert cover.current_cover_position == 75
  209. def test_currentpos_none_falls_through(self):
  210. cover = _make_cover(currentpos=True, action=True)
  211. cover._currentpos_dp.get_value.return_value = None
  212. cover._action_dp.get_value.return_value = "opened"
  213. assert cover.current_cover_position == 100
  214. def test_from_open_dp_true(self):
  215. cover = _make_cover(
  216. currentpos=False, open_dp=True, action=False, control=False, position=False
  217. )
  218. cover._open_dp.get_value.return_value = True
  219. assert cover.current_cover_position == 100
  220. def test_from_open_dp_false(self):
  221. cover = _make_cover(
  222. currentpos=False, open_dp=True, action=False, control=False, position=False
  223. )
  224. cover._open_dp.get_value.return_value = False
  225. assert cover.current_cover_position == 0
  226. def test_from_action_dp(self):
  227. cover = _make_cover(
  228. currentpos=False, open_dp=False, action=True, control=False, position=False
  229. )
  230. cover._action_dp.get_value.return_value = "closed"
  231. assert cover.current_cover_position == 0
  232. def test_from_position_dp(self):
  233. cover = _make_cover(
  234. currentpos=False, open_dp=False, action=False, control=False, position=True
  235. )
  236. cover._position_dp.get_value.return_value = 42
  237. assert cover.current_cover_position == 42
  238. def test_none_when_no_dps(self):
  239. cover = _make_cover(
  240. currentpos=False,
  241. open_dp=False,
  242. action=False,
  243. control=False,
  244. position=False,
  245. )
  246. assert cover.current_cover_position is None
  247. class TestCurrentCoverTiltPosition:
  248. def test_with_range(self):
  249. cover = _make_cover(tiltpos=True)
  250. cover._tiltpos_dp.range.return_value = (0, 255)
  251. cover._tiltpos_dp.get_value.return_value = 128
  252. pos = cover.current_cover_tilt_position
  253. assert pos is not None
  254. assert 0 <= pos <= 100
  255. def test_without_range(self):
  256. cover = _make_cover(tiltpos=True)
  257. cover._tiltpos_dp.range.return_value = None
  258. cover._tiltpos_dp.get_value.return_value = 50
  259. assert cover.current_cover_tilt_position == 50
  260. def test_none_without_dp(self):
  261. cover = _make_cover(tiltpos=False)
  262. assert cover.current_cover_tilt_position is None
  263. class TestCurrentState:
  264. def test_action_opening(self):
  265. cover = _make_cover()
  266. cover._action_dp.get_value.return_value = "opening"
  267. assert cover._current_state == "opening"
  268. def test_action_closing(self):
  269. cover = _make_cover()
  270. cover._action_dp.get_value.return_value = "closing"
  271. assert cover._current_state == "closing"
  272. def test_action_opened(self):
  273. cover = _make_cover()
  274. cover._action_dp.get_value.return_value = "opened"
  275. assert cover._current_state == "opened"
  276. def test_action_closed(self):
  277. cover = _make_cover()
  278. cover._action_dp.get_value.return_value = "closed"
  279. assert cover._current_state == "closed"
  280. def test_action_unknown_falls_to_position(self):
  281. cover = _make_cover(currentpos=True, action=True)
  282. cover._action_dp.get_value.return_value = "idle"
  283. cover._currentpos_dp.get_value.return_value = 0
  284. assert cover._current_state == "closed"
  285. def test_position_low_is_closed(self):
  286. cover = _make_cover(action=False, currentpos=True)
  287. cover._currentpos_dp.get_value.return_value = 3
  288. assert cover._current_state == "closed"
  289. def test_position_high_is_opened(self):
  290. cover = _make_cover(action=False, currentpos=True)
  291. cover._currentpos_dp.get_value.return_value = 98
  292. assert cover._current_state == "opened"
  293. def test_mid_position_with_setpos_match_is_opened(self):
  294. cover = _make_cover(action=False, currentpos=True, position=True)
  295. cover._currentpos_dp.get_value.return_value = 50
  296. cover._position_dp.get_value.return_value = 50
  297. assert cover._current_state == "opened"
  298. def test_mid_position_near_setpos_is_opened(self):
  299. cover = _make_cover(action=False, currentpos=True, position=True)
  300. cover._currentpos_dp.get_value.return_value = 49
  301. cover._position_dp.get_value.return_value = 50
  302. assert cover._current_state == "opened"
  303. def test_mid_position_with_open_cmd_is_opening(self):
  304. cover = _make_cover(action=False, currentpos=True, position=True, control=True)
  305. cover._currentpos_dp.get_value.return_value = 50
  306. cover._position_dp.get_value.return_value = 80
  307. cover._control_dp.get_value.return_value = "open"
  308. assert cover._current_state == "opening"
  309. def test_mid_position_with_close_cmd_is_closing(self):
  310. cover = _make_cover(action=False, currentpos=True, position=True, control=True)
  311. cover._currentpos_dp.get_value.return_value = 50
  312. cover._position_dp.get_value.return_value = 20
  313. cover._control_dp.get_value.return_value = "close"
  314. assert cover._current_state == "closing"
  315. def test_none_when_no_position(self):
  316. cover = _make_cover(
  317. action=False,
  318. currentpos=False,
  319. position=False,
  320. control=False,
  321. open_dp=False,
  322. )
  323. assert cover._current_state is None
  324. class TestIsOpeningClosingClosed:
  325. def test_is_opening_true(self):
  326. cover = _make_cover()
  327. cover._action_dp.get_value.return_value = "opening"
  328. assert cover.is_opening is True
  329. def test_is_opening_false(self):
  330. cover = _make_cover()
  331. cover._action_dp.get_value.return_value = "closed"
  332. assert cover.is_opening is False
  333. def test_is_opening_none(self):
  334. cover = _make_cover(
  335. action=False,
  336. currentpos=False,
  337. position=False,
  338. control=False,
  339. open_dp=False,
  340. )
  341. assert cover.is_opening is None
  342. def test_is_closing_true(self):
  343. cover = _make_cover()
  344. cover._action_dp.get_value.return_value = "closing"
  345. assert cover.is_closing is True
  346. def test_is_closing_false(self):
  347. cover = _make_cover()
  348. cover._action_dp.get_value.return_value = "opened"
  349. assert cover.is_closing is False
  350. def test_is_closing_none(self):
  351. cover = _make_cover(
  352. action=False,
  353. currentpos=False,
  354. position=False,
  355. control=False,
  356. open_dp=False,
  357. )
  358. assert cover.is_closing is None
  359. def test_is_closed_true(self):
  360. cover = _make_cover()
  361. cover._action_dp.get_value.return_value = "closed"
  362. assert cover.is_closed is True
  363. def test_is_closed_false(self):
  364. cover = _make_cover()
  365. cover._action_dp.get_value.return_value = "opened"
  366. assert cover.is_closed is False
  367. def test_is_closed_none(self):
  368. cover = _make_cover(
  369. action=False,
  370. currentpos=False,
  371. position=False,
  372. control=False,
  373. open_dp=False,
  374. )
  375. assert cover.is_closed is None
  376. class TestAsyncOpenCover:
  377. @pytest.mark.asyncio
  378. async def test_open_with_control(self):
  379. cover = _make_cover()
  380. cover._control_dp.async_set_value = AsyncMock()
  381. await cover.async_open_cover()
  382. cover._control_dp.async_set_value.assert_awaited_once_with(
  383. cover._device, "open"
  384. )
  385. @pytest.mark.asyncio
  386. async def test_open_with_position(self):
  387. cover = _make_cover(control=False)
  388. cover._position_dp.async_set_value = AsyncMock()
  389. await cover.async_open_cover()
  390. cover._position_dp.async_set_value.assert_awaited_once_with(cover._device, 100)
  391. @pytest.mark.asyncio
  392. async def test_open_raises_not_implemented(self):
  393. cover = _make_cover(control=False, position=False, action=False)
  394. with pytest.raises(NotImplementedError):
  395. await cover.async_open_cover()
  396. class TestAsyncCloseCover:
  397. @pytest.mark.asyncio
  398. async def test_close_with_control(self):
  399. cover = _make_cover()
  400. cover._control_dp.async_set_value = AsyncMock()
  401. await cover.async_close_cover()
  402. cover._control_dp.async_set_value.assert_awaited_once_with(
  403. cover._device, "close"
  404. )
  405. @pytest.mark.asyncio
  406. async def test_close_with_position(self):
  407. cover = _make_cover(control=False)
  408. cover._position_dp.async_set_value = AsyncMock()
  409. await cover.async_close_cover()
  410. cover._position_dp.async_set_value.assert_awaited_once_with(cover._device, 0)
  411. @pytest.mark.asyncio
  412. async def test_close_raises_not_implemented(self):
  413. cover = _make_cover(control=False, position=False, action=False)
  414. with pytest.raises(NotImplementedError):
  415. await cover.async_close_cover()
  416. class TestAsyncSetCoverPosition:
  417. @pytest.mark.asyncio
  418. async def test_set_position(self):
  419. cover = _make_cover()
  420. cover._position_dp.async_set_value = AsyncMock()
  421. await cover.async_set_cover_position(position=50)
  422. cover._position_dp.async_set_value.assert_awaited_once_with(cover._device, 50)
  423. @pytest.mark.asyncio
  424. async def test_set_position_none_raises(self):
  425. cover = _make_cover()
  426. with pytest.raises(AttributeError):
  427. await cover.async_set_cover_position(position=None)
  428. @pytest.mark.asyncio
  429. async def test_set_position_no_dp_raises(self):
  430. cover = _make_cover(position=False, action=False)
  431. with pytest.raises(NotImplementedError):
  432. await cover.async_set_cover_position(position=50)
  433. class TestAsyncSetCoverTiltPosition:
  434. @pytest.mark.asyncio
  435. async def test_tilt_with_fixed_values(self):
  436. cover = _make_cover(tiltpos=True)
  437. cover._tiltpos_dp.values.return_value = [0, 50, 100]
  438. cover._tiltpos_dp.range.return_value = None
  439. cover._tiltpos_dp.async_set_value = AsyncMock()
  440. await cover.async_set_cover_tilt_position(tilt_position=60)
  441. # Should snap to 50 (closest)
  442. cover._tiltpos_dp.async_set_value.assert_awaited_once_with(cover._device, 50)
  443. @pytest.mark.asyncio
  444. async def test_tilt_with_range(self):
  445. cover = _make_cover(tiltpos=True)
  446. cover._tiltpos_dp.values.return_value = []
  447. cover._tiltpos_dp.range.return_value = (0, 255)
  448. cover._tiltpos_dp.async_set_value = AsyncMock()
  449. await cover.async_set_cover_tilt_position(tilt_position=50)
  450. cover._tiltpos_dp.async_set_value.assert_awaited_once()
  451. @pytest.mark.asyncio
  452. async def test_tilt_raw(self):
  453. cover = _make_cover(tiltpos=True)
  454. cover._tiltpos_dp.values.return_value = []
  455. cover._tiltpos_dp.range.return_value = None
  456. cover._tiltpos_dp.async_set_value = AsyncMock()
  457. await cover.async_set_cover_tilt_position(tilt_position=75)
  458. cover._tiltpos_dp.async_set_value.assert_awaited_once_with(cover._device, 75)
  459. @pytest.mark.asyncio
  460. async def test_tilt_no_dp_raises(self):
  461. cover = _make_cover(tiltpos=False)
  462. with pytest.raises(NotImplementedError):
  463. await cover.async_set_cover_tilt_position(tilt_position=50)
  464. class TestAsyncStopCover:
  465. @pytest.mark.asyncio
  466. async def test_stop_with_control(self):
  467. cover = _make_cover()
  468. cover._control_dp.async_set_value = AsyncMock()
  469. await cover.async_stop_cover()
  470. cover._control_dp.async_set_value.assert_awaited_once_with(
  471. cover._device, "stop"
  472. )
  473. @pytest.mark.asyncio
  474. async def test_stop_raises_not_implemented(self):
  475. cover = _make_cover(control=False, position=False, action=False)
  476. with pytest.raises(NotImplementedError):
  477. await cover.async_stop_cover()