4
0

test_entity.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. """Tests for the TuyaLocalEntity base class and unit_from_ascii helper."""
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from homeassistant.const import (
  5. CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
  6. UnitOfArea,
  7. UnitOfTemperature,
  8. )
  9. from homeassistant.helpers.entity import EntityCategory
  10. from custom_components.tuya_local.entity import (
  11. BLACKLISTED_ATTRIBUTES,
  12. TuyaLocalEntity,
  13. unit_from_ascii,
  14. )
  15. class DummySuper:
  16. """Simulates a HA entity base with a name and icon property."""
  17. @property
  18. def name(self):
  19. return "translated_name"
  20. @property
  21. def icon(self):
  22. return "mdi:default-icon"
  23. class DummyEntity(TuyaLocalEntity, DummySuper):
  24. """Concrete subclass so we can instantiate TuyaLocalEntity."""
  25. def _default_to_device_class_name(self):
  26. return False
  27. @pytest.fixture
  28. def mock_device():
  29. device = MagicMock()
  30. device.has_returned_state = True
  31. device.unique_id = "test_device_123"
  32. device.name = "Test Device"
  33. device.device_info = {"identifiers": {("tuya_local", "test_device_123")}}
  34. device.register_entity = MagicMock()
  35. device.async_unregister_entity = AsyncMock()
  36. device.async_refresh = AsyncMock()
  37. return device
  38. @pytest.fixture
  39. def mock_config():
  40. config = MagicMock()
  41. config.name = "Test Entity"
  42. config.translation_key = None
  43. config.translation_only_key = None
  44. config.translation_placeholders = None
  45. config.entity_category = None
  46. config.deprecated = False
  47. config.deprecation_message = ""
  48. config.config_id = "test_config"
  49. config.device_class = None
  50. dp1 = MagicMock()
  51. dp1.name = "state"
  52. dp1.hidden = False
  53. dp1.optional = False
  54. dp1.get_value.return_value = "on"
  55. dp2 = MagicMock()
  56. dp2.name = "temperature"
  57. dp2.hidden = False
  58. dp2.optional = False
  59. dp2.get_value.return_value = 25
  60. dp3 = MagicMock()
  61. dp3.name = "available"
  62. dp3.hidden = False
  63. dp3.optional = False
  64. dp3.get_value.return_value = True
  65. config.dps.return_value = [dp1, dp2, dp3]
  66. config.icon.return_value = None
  67. config.available.return_value = True
  68. config.unique_id.return_value = "tuya_local_test_device_123_test"
  69. config.enabled_by_default.return_value = True
  70. return config
  71. @pytest.fixture
  72. def entity(mock_device, mock_config):
  73. e = DummyEntity()
  74. dps = e._init_begin(mock_device, mock_config)
  75. e._init_end(dps)
  76. return e
  77. class TestInitBeginEnd:
  78. def test_init_begin_sets_device_and_config(self, mock_device, mock_config):
  79. e = DummyEntity()
  80. dps = e._init_begin(mock_device, mock_config)
  81. assert e._device is mock_device
  82. assert e._config is mock_config
  83. assert isinstance(dps, dict)
  84. def test_init_begin_returns_dps_dict(self, mock_device, mock_config):
  85. e = DummyEntity()
  86. dps = e._init_begin(mock_device, mock_config)
  87. assert "state" in dps
  88. assert "temperature" in dps
  89. assert "available" in dps
  90. def test_init_begin_with_translation_key(self, mock_device, mock_config):
  91. mock_config.translation_key = "my_key"
  92. e = DummyEntity()
  93. e._init_begin(mock_device, mock_config)
  94. assert e._attr_translation_key == "my_key"
  95. def test_init_begin_with_translation_only_key(self, mock_device, mock_config):
  96. mock_config.translation_key = None
  97. mock_config.translation_only_key = "only_key"
  98. e = DummyEntity()
  99. e._init_begin(mock_device, mock_config)
  100. assert e._attr_translation_key == "only_key"
  101. def test_init_begin_with_placeholders(self, mock_device, mock_config):
  102. mock_config.translation_placeholders = {"x": "1"}
  103. e = DummyEntity()
  104. e._init_begin(mock_device, mock_config)
  105. assert e._attr_translation_placeholders == {"x": "1"}
  106. def test_init_end_excludes_blacklisted(self, mock_device, mock_config):
  107. e = DummyEntity()
  108. dps = e._init_begin(mock_device, mock_config)
  109. e._init_end(dps)
  110. attr_names = [d.name for d in e._attr_dps]
  111. for bl in BLACKLISTED_ATTRIBUTES:
  112. assert bl not in attr_names
  113. def test_init_end_excludes_hidden(self, mock_device, mock_config):
  114. dp = MagicMock()
  115. dp.name = "visible"
  116. dp.hidden = True
  117. dp.optional = False
  118. mock_config.dps.return_value = [dp]
  119. e = DummyEntity()
  120. dps = e._init_begin(mock_device, mock_config)
  121. e._init_end(dps)
  122. assert len(e._attr_dps) == 0
  123. def test_init_end_includes_non_blacklisted_non_hidden(
  124. self, mock_device, mock_config
  125. ):
  126. e = DummyEntity()
  127. dps = e._init_begin(mock_device, mock_config)
  128. e._init_end(dps)
  129. attr_names = [d.name for d in e._attr_dps]
  130. assert "temperature" in attr_names
  131. class TestProperties:
  132. def test_should_poll(self, entity):
  133. assert entity.should_poll is False
  134. def test_available_when_device_returned_state(self, entity):
  135. assert entity.available is True
  136. def test_available_false_when_no_state(self, entity, mock_device):
  137. mock_device.has_returned_state = False
  138. assert entity.available is False
  139. def test_available_false_when_config_unavailable(self, entity, mock_config):
  140. mock_config.available.return_value = False
  141. assert entity.available is False
  142. def test_has_entity_name(self, entity):
  143. assert entity.has_entity_name is True
  144. def test_name_returns_config_name(self, entity):
  145. assert entity.name == "Test Entity"
  146. def test_name_falls_back_to_super(self, entity, mock_config):
  147. mock_config.name = None
  148. mock_config.translation_key = "some_key"
  149. # When use_device_name is False (has translation_key), name calls super
  150. assert entity.name is not None
  151. def test_name_uses_device_name_when_no_own_name(self, entity, mock_config):
  152. mock_config.name = None
  153. mock_config.translation_key = None
  154. mock_config.device_class = None
  155. assert entity.use_device_name is True
  156. def test_use_device_name_false_with_name(self, entity):
  157. assert entity.use_device_name is False
  158. def test_use_device_name_false_with_translation_key(self, entity, mock_config):
  159. mock_config.name = None
  160. mock_config.translation_key = "some_key"
  161. assert entity.use_device_name is False
  162. def test_unique_id(self, entity):
  163. assert entity.unique_id == "tuya_local_test_device_123_test"
  164. def test_device_info(self, entity, mock_device):
  165. assert entity.device_info is mock_device.device_info
  166. def test_entity_category_none(self, entity):
  167. assert entity.entity_category is None
  168. def test_entity_category_config(self, entity, mock_config):
  169. mock_config.entity_category = "config"
  170. assert entity.entity_category == EntityCategory.CONFIG
  171. def test_entity_category_diagnostic(self, entity, mock_config):
  172. mock_config.entity_category = "diagnostic"
  173. assert entity.entity_category == EntityCategory.DIAGNOSTIC
  174. def test_icon_from_config(self, entity, mock_config):
  175. mock_config.icon.return_value = "mdi:custom-icon"
  176. assert entity.icon == "mdi:custom-icon"
  177. def test_icon_falls_back_to_super(self, entity, mock_config):
  178. mock_config.icon.return_value = None
  179. assert entity.icon == "mdi:default-icon"
  180. def test_extra_state_attributes(self, entity):
  181. attrs = entity.extra_state_attributes
  182. assert "temperature" in attrs
  183. assert attrs["temperature"] == 25
  184. def test_extra_state_attributes_skips_none_optional(self, mock_device, mock_config):
  185. dp = MagicMock()
  186. dp.name = "opt_attr"
  187. dp.hidden = False
  188. dp.optional = True
  189. dp.get_value.return_value = None
  190. mock_config.dps.return_value = [dp]
  191. e = DummyEntity()
  192. dps = e._init_begin(mock_device, mock_config)
  193. e._init_end(dps)
  194. attrs = e.extra_state_attributes
  195. assert "opt_attr" not in attrs
  196. def test_extra_state_attributes_includes_none_required(
  197. self, mock_device, mock_config
  198. ):
  199. dp = MagicMock()
  200. dp.name = "req_attr"
  201. dp.hidden = False
  202. dp.optional = False
  203. dp.get_value.return_value = None
  204. mock_config.dps.return_value = [dp]
  205. e = DummyEntity()
  206. dps = e._init_begin(mock_device, mock_config)
  207. e._init_end(dps)
  208. attrs = e.extra_state_attributes
  209. assert "req_attr" in attrs
  210. assert attrs["req_attr"] is None
  211. def test_entity_registry_enabled_default(self, entity, mock_config):
  212. assert entity.entity_registry_enabled_default is True
  213. mock_config.enabled_by_default.return_value = False
  214. assert entity.entity_registry_enabled_default is False
  215. class TestAsyncMethods:
  216. @pytest.mark.asyncio
  217. async def test_async_update(self, entity, mock_device):
  218. await entity.async_update()
  219. mock_device.async_refresh.assert_awaited_once()
  220. @pytest.mark.asyncio
  221. async def test_async_added_to_hass(self, entity, mock_device):
  222. await entity.async_added_to_hass()
  223. mock_device.register_entity.assert_called_once_with(entity)
  224. @pytest.mark.asyncio
  225. async def test_async_added_to_hass_logs_deprecation(
  226. self, entity, mock_device, mock_config
  227. ):
  228. mock_config.deprecated = True
  229. mock_config.deprecation_message = "This entity is deprecated"
  230. with patch("custom_components.tuya_local.entity._LOGGER") as mock_logger:
  231. await entity.async_added_to_hass()
  232. mock_logger.warning.assert_called_with("This entity is deprecated")
  233. @pytest.mark.asyncio
  234. async def test_async_will_remove_from_hass(self, entity, mock_device):
  235. await entity.async_will_remove_from_hass()
  236. mock_device.async_unregister_entity.assert_awaited_once_with(entity)
  237. def test_on_receive_does_nothing(self, entity):
  238. # Default implementation is a no-op
  239. entity.on_receive({}, False)
  240. class TestUnitFromAscii:
  241. def test_celsius(self):
  242. assert unit_from_ascii("C") == UnitOfTemperature.CELSIUS.value
  243. def test_fahrenheit(self):
  244. assert unit_from_ascii("F") == UnitOfTemperature.FAHRENHEIT.value
  245. def test_micrograms(self):
  246. assert unit_from_ascii("ugm3") == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
  247. def test_square_meters(self):
  248. assert unit_from_ascii("m2") == UnitOfArea.SQUARE_METERS
  249. def test_passthrough_unknown(self):
  250. assert unit_from_ascii("km/h") == "km/h"
  251. def test_passthrough_empty(self):
  252. assert unit_from_ascii("") == ""