test_sensors.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. """Unit tests for sensor property logic.
  2. Bypasses HA entity setup by using object.__new__ and injecting a mock
  3. coordinator — valid because every tested property only reads self.coordinator.data.
  4. """
  5. from datetime import date, datetime, time, timezone
  6. from unittest.mock import MagicMock
  7. import pytest
  8. from custom_components.room_cal_ha.coordinator import CoordinatorData
  9. from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
  10. from custom_components.room_cal_ha.sensor import (
  11. ApiHealthSensor,
  12. GuestNameSensor,
  13. LastUpdatedSensor,
  14. ReservationDateTimeSensor,
  15. ReservationStrSensor,
  16. ReservationTimeSensor,
  17. WiFiKeySensor,
  18. )
  19. from custom_components.room_cal_ha.const import STORE_CURRENT, STORE_NEXT
  20. # ---------------------------------------------------------------------------
  21. # Helpers
  22. # ---------------------------------------------------------------------------
  23. def _coordinator(
  24. current=None,
  25. nxt=None,
  26. current_ts=None,
  27. next_ts=None,
  28. current_updated=None,
  29. next_updated=None,
  30. api_status=None,
  31. ):
  32. coord = MagicMock()
  33. coord.data = CoordinatorData(
  34. current=current or ReservationData(),
  35. next=nxt or ReservationData(),
  36. current_last_refresh=current_ts,
  37. next_last_refresh=next_ts,
  38. current_last_updated=current_updated,
  39. next_last_updated=next_updated,
  40. api_status=api_status,
  41. )
  42. return coord
  43. def _sensor(cls, coordinator, slot=STORE_CURRENT, field="guest_name", **extra):
  44. """Create a sensor instance without running __init__."""
  45. s = object.__new__(cls)
  46. s.coordinator = coordinator
  47. s._slot = slot
  48. s._field = field
  49. for k, v in extra.items():
  50. setattr(s, k, v)
  51. return s
  52. SAMPLE = ReservationData(
  53. guest_name="Alice",
  54. check_in_date=date(2026, 7, 1),
  55. check_in_time=time(14, 0),
  56. check_out_date=date(2026, 7, 5),
  57. check_out_time=time(12, 0),
  58. wifi_ssid="MyNet",
  59. wifi_key="hunter2",
  60. door_code="0472",
  61. )
  62. # ---------------------------------------------------------------------------
  63. # ReservationStrSensor
  64. # ---------------------------------------------------------------------------
  65. def test_str_sensor_returns_field_value():
  66. coord = _coordinator(current=SAMPLE)
  67. s = _sensor(ReservationStrSensor, coord, field="guest_name")
  68. assert s.native_value == "Alice"
  69. def test_str_sensor_reads_next_slot():
  70. nxt = ReservationData(guest_name="Bob")
  71. coord = _coordinator(nxt=nxt)
  72. s = _sensor(ReservationStrSensor, coord, slot=STORE_NEXT, field="guest_name")
  73. assert s.native_value == "Bob"
  74. def test_str_sensor_none_when_field_is_none():
  75. res = ReservationData(guest_name=None)
  76. coord = _coordinator(current=res)
  77. s = _sensor(ReservationStrSensor, coord, field="guest_name")
  78. assert s.native_value is None
  79. # ---------------------------------------------------------------------------
  80. # GuestNameSensor
  81. # ---------------------------------------------------------------------------
  82. def test_guest_name_single_word():
  83. coord = _coordinator(current=ReservationData(guest_name="Alice"))
  84. s = _sensor(GuestNameSensor, coord, field="guest_name")
  85. attrs = s.extra_state_attributes
  86. assert attrs["first_name"] == "Alice"
  87. assert attrs["first_abbr"] == "A"
  88. assert attrs["last_name"] is None
  89. assert attrs["last_abbr"] is None
  90. assert attrs["public"] == "Alice"
  91. def test_guest_name_two_words():
  92. coord = _coordinator(current=ReservationData(guest_name="ZiB Guest"))
  93. s = _sensor(GuestNameSensor, coord, field="guest_name")
  94. attrs = s.extra_state_attributes
  95. assert attrs["first_name"] == "ZiB"
  96. assert attrs["first_abbr"] == "Z"
  97. assert attrs["last_name"] == "Guest"
  98. assert attrs["last_abbr"] == "G"
  99. assert attrs["public"] == "ZiB G."
  100. def test_guest_name_three_words_last_is_final():
  101. coord = _coordinator(current=ReservationData(guest_name="ZiB Middle Guest"))
  102. s = _sensor(GuestNameSensor, coord, field="guest_name")
  103. attrs = s.extra_state_attributes
  104. assert attrs["first_name"] == "ZiB Middle"
  105. assert attrs["last_name"] == "Guest"
  106. assert attrs["last_abbr"] == "G"
  107. assert attrs["public"] == "ZiB Middle G."
  108. def test_guest_name_none_returns_no_attrs():
  109. coord = _coordinator(current=ReservationData(guest_name=None))
  110. s = _sensor(GuestNameSensor, coord, field="guest_name")
  111. assert s.extra_state_attributes is None
  112. # ---------------------------------------------------------------------------
  113. # ReservationTimeSensor
  114. # ---------------------------------------------------------------------------
  115. def test_time_sensor_returns_iso_string():
  116. coord = _coordinator(current=SAMPLE)
  117. s = _sensor(ReservationTimeSensor, coord, field="check_in_time")
  118. assert s.native_value == "14:00:00"
  119. def test_time_sensor_none_when_time_is_none():
  120. res = ReservationData(check_in_time=None)
  121. coord = _coordinator(current=res)
  122. s = _sensor(ReservationTimeSensor, coord, field="check_in_time")
  123. assert s.native_value is None
  124. # ---------------------------------------------------------------------------
  125. # ReservationDateTimeSensor
  126. # ---------------------------------------------------------------------------
  127. def test_datetime_sensor_native_value_is_date():
  128. coord = _coordinator(current=SAMPLE)
  129. s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
  130. s._time_field = "check_in_time"
  131. assert s.native_value == date(2026, 7, 1)
  132. def test_datetime_sensor_attrs_contain_expected_keys():
  133. coord = _coordinator(current=SAMPLE)
  134. s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
  135. s._time_field = "check_in_time"
  136. attrs = s.extra_state_attributes
  137. assert attrs is not None
  138. for key in ("datetime", "date", "weekday", "month", "time_abbr", "hour"):
  139. assert key in attrs, f"missing key: {key}"
  140. def test_datetime_sensor_attrs_none_when_date_missing():
  141. res = ReservationData(check_in_date=None, check_in_time=time(14, 0))
  142. coord = _coordinator(current=res)
  143. s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
  144. s._time_field = "check_in_time"
  145. assert s.extra_state_attributes is None
  146. def test_datetime_sensor_attrs_none_when_time_missing():
  147. res = ReservationData(check_in_date=date(2026, 7, 1), check_in_time=None)
  148. coord = _coordinator(current=res)
  149. s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
  150. s._time_field = "check_in_time"
  151. assert s.extra_state_attributes is None
  152. # ---------------------------------------------------------------------------
  153. # WiFiKeySensor
  154. # ---------------------------------------------------------------------------
  155. def test_wifi_key_sensor_native_value():
  156. coord = _coordinator(current=SAMPLE)
  157. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  158. assert s.native_value == "hunter2"
  159. def test_wifi_key_sensor_qr_attribute():
  160. coord = _coordinator(current=SAMPLE)
  161. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  162. attrs = s.extra_state_attributes
  163. assert attrs is not None
  164. assert attrs["qr"] == "WIFI:S:MyNet;T:WPA;P:hunter2;;"
  165. def test_wifi_key_sensor_no_attrs_when_ssid_missing():
  166. res = ReservationData(wifi_ssid=None, wifi_key="hunter2")
  167. coord = _coordinator(current=res)
  168. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  169. assert s.extra_state_attributes is None
  170. def test_wifi_key_sensor_no_attrs_when_key_missing():
  171. res = ReservationData(wifi_ssid="MyNet", wifi_key=None)
  172. coord = _coordinator(current=res)
  173. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  174. assert s.extra_state_attributes is None
  175. # ---------------------------------------------------------------------------
  176. # LastUpdatedSensor
  177. # ---------------------------------------------------------------------------
  178. def test_last_updated_sensor_current():
  179. ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
  180. coord = _coordinator(current_updated=ts)
  181. s = _sensor(LastUpdatedSensor, coord)
  182. s._slot = STORE_CURRENT
  183. assert s.native_value == ts
  184. def test_last_updated_sensor_next():
  185. ts = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
  186. coord = _coordinator(next_updated=ts)
  187. s = _sensor(LastUpdatedSensor, coord)
  188. s._slot = STORE_NEXT
  189. assert s.native_value == ts
  190. def test_last_updated_sensor_none_before_any_change():
  191. coord = _coordinator()
  192. s = _sensor(LastUpdatedSensor, coord)
  193. s._slot = STORE_CURRENT
  194. assert s.native_value is None
  195. def test_last_updated_sensor_exposes_last_refresh_as_attribute():
  196. refresh_ts = datetime(2026, 6, 22, 9, 0, tzinfo=timezone.utc)
  197. updated_ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
  198. coord = _coordinator(current_ts=refresh_ts, current_updated=updated_ts)
  199. s = _sensor(LastUpdatedSensor, coord)
  200. s._slot = STORE_CURRENT
  201. assert s.extra_state_attributes["last_refresh"] == refresh_ts
  202. def test_last_updated_sensor_last_refresh_attribute_none_when_not_yet_fetched():
  203. coord = _coordinator()
  204. s = _sensor(LastUpdatedSensor, coord)
  205. s._slot = STORE_CURRENT
  206. assert s.extra_state_attributes["last_refresh"] is None
  207. # ---------------------------------------------------------------------------
  208. # ApiHealthSensor
  209. # ---------------------------------------------------------------------------
  210. def _api_sensor(coordinator):
  211. s = object.__new__(ApiHealthSensor)
  212. s.coordinator = coordinator
  213. return s
  214. _REAL_CHECKS = {
  215. "caldav_connection": {"ok": True},
  216. "calendar": {"ok": True},
  217. "credentials": {
  218. "airbnb": {"ok": True},
  219. "booking": {"ok": False},
  220. },
  221. }
  222. def test_api_health_ok():
  223. status = ApiStatusData(status="ok", checks=_REAL_CHECKS)
  224. assert _api_sensor(_coordinator(api_status=status)).native_value == "OK"
  225. def test_api_health_degraded_on_non_ok_status():
  226. status = ApiStatusData(status="error", checks=_REAL_CHECKS)
  227. assert _api_sensor(_coordinator(api_status=status)).native_value == "Degraded"
  228. def test_api_health_none_when_no_status():
  229. assert _api_sensor(_coordinator(api_status=None)).native_value is None
  230. def test_api_health_attrs_flatten_checks():
  231. from datetime import datetime
  232. ts = datetime(2026, 6, 23, 8, 0, 0)
  233. status = ApiStatusData(status="ok", checks=_REAL_CHECKS, stale=False, checked_at=ts)
  234. attrs = _api_sensor(_coordinator(api_status=status)).extra_state_attributes
  235. assert attrs["caldav_connection"] is True
  236. assert attrs["calendar"] is True
  237. assert attrs["airbnb"] is True
  238. assert attrs["booking"] is False
  239. assert attrs["stale"] is False
  240. assert attrs["checked_at"] == ts.isoformat()
  241. def test_api_health_attrs_present_with_empty_checks():
  242. status = ApiStatusData(status="ok", checks={}, stale=True)
  243. attrs = _api_sensor(_coordinator(api_status=status)).extra_state_attributes
  244. assert attrs == {"stale": True, "checked_at": None}
  245. def test_api_health_no_attrs_when_no_status():
  246. assert _api_sensor(_coordinator(api_status=None)).extra_state_attributes is None