test_sensors.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  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 SLOT_ACTIVE, STORE_CURRENT, STORE_NEXT
  20. # ---------------------------------------------------------------------------
  21. # Helpers
  22. # ---------------------------------------------------------------------------
  23. def _coordinator(
  24. active=None,
  25. current=None,
  26. nxt=None,
  27. current_ts=None,
  28. next_ts=None,
  29. current_updated=None,
  30. next_updated=None,
  31. active_ts=None,
  32. active_updated=None,
  33. api_status=None,
  34. ):
  35. coord = MagicMock()
  36. coord.data = CoordinatorData(
  37. active=active if active is not None else (current or ReservationData()),
  38. current=current or ReservationData(),
  39. next=nxt or ReservationData(),
  40. current_last_refresh=current_ts,
  41. next_last_refresh=next_ts,
  42. current_last_updated=current_updated,
  43. next_last_updated=next_updated,
  44. active_last_refresh=active_ts,
  45. active_last_updated=active_updated,
  46. api_status=api_status,
  47. )
  48. return coord
  49. def _sensor(cls, coordinator, slot=STORE_CURRENT, field="guest_name", **extra):
  50. """Create a sensor instance without running __init__."""
  51. s = object.__new__(cls)
  52. s.coordinator = coordinator
  53. s._slot = slot
  54. s._field = field
  55. for k, v in extra.items():
  56. setattr(s, k, v)
  57. return s
  58. SAMPLE = ReservationData(
  59. guest_name="Alice",
  60. check_in_date=date(2026, 7, 1),
  61. check_in_time=time(14, 0),
  62. check_out_date=date(2026, 7, 5),
  63. check_out_time=time(12, 0),
  64. wifi_ssid="MyNet",
  65. wifi_key="hunter2",
  66. door_code="0472",
  67. )
  68. # ---------------------------------------------------------------------------
  69. # ReservationStrSensor
  70. # ---------------------------------------------------------------------------
  71. def test_str_sensor_returns_field_value():
  72. coord = _coordinator(current=SAMPLE)
  73. s = _sensor(ReservationStrSensor, coord, field="guest_name")
  74. assert s.native_value == "Alice"
  75. def test_str_sensor_reads_next_slot():
  76. nxt = ReservationData(guest_name="Bob")
  77. coord = _coordinator(nxt=nxt)
  78. s = _sensor(ReservationStrSensor, coord, slot=STORE_NEXT, field="guest_name")
  79. assert s.native_value == "Bob"
  80. def test_str_sensor_none_when_field_is_none():
  81. res = ReservationData(guest_name=None)
  82. coord = _coordinator(current=res)
  83. s = _sensor(ReservationStrSensor, coord, field="guest_name")
  84. assert s.native_value is None
  85. def test_str_sensor_reads_active_slot():
  86. """Active sensors read the live/effective record — distinct from Current, which
  87. never reflects an in-progress override."""
  88. active = ReservationData(guest_name="Override Guest")
  89. coord = _coordinator(active=active, current=ReservationData(guest_name="Backend Guest"))
  90. s = _sensor(ReservationStrSensor, coord, slot=SLOT_ACTIVE, field="guest_name")
  91. assert s.native_value == "Override Guest"
  92. # ---------------------------------------------------------------------------
  93. # GuestNameSensor
  94. # ---------------------------------------------------------------------------
  95. def test_guest_name_single_word():
  96. coord = _coordinator(current=ReservationData(guest_name="Alice"))
  97. s = _sensor(GuestNameSensor, coord, field="guest_name")
  98. attrs = s.extra_state_attributes
  99. assert attrs["first_name"] == "Alice"
  100. assert attrs["first_abbr"] == "A"
  101. assert attrs["last_name"] is None
  102. assert attrs["last_abbr"] is None
  103. assert attrs["public"] == "Alice"
  104. def test_guest_name_two_words():
  105. coord = _coordinator(current=ReservationData(guest_name="ZiB Guest"))
  106. s = _sensor(GuestNameSensor, coord, field="guest_name")
  107. attrs = s.extra_state_attributes
  108. assert attrs["first_name"] == "ZiB"
  109. assert attrs["first_abbr"] == "Z"
  110. assert attrs["last_name"] == "Guest"
  111. assert attrs["last_abbr"] == "G"
  112. assert attrs["public"] == "ZiB G."
  113. def test_guest_name_three_words_last_is_final():
  114. coord = _coordinator(current=ReservationData(guest_name="ZiB Middle Guest"))
  115. s = _sensor(GuestNameSensor, coord, field="guest_name")
  116. attrs = s.extra_state_attributes
  117. assert attrs["first_name"] == "ZiB"
  118. assert attrs["last_name"] == "Guest"
  119. assert attrs["last_abbr"] == "G"
  120. assert attrs["public"] == "ZiB G."
  121. def test_guest_name_none_returns_no_attrs():
  122. coord = _coordinator(current=ReservationData(guest_name=None))
  123. s = _sensor(GuestNameSensor, coord, field="guest_name")
  124. assert s.extra_state_attributes is None
  125. # ---------------------------------------------------------------------------
  126. # ReservationTimeSensor
  127. # ---------------------------------------------------------------------------
  128. def test_time_sensor_returns_iso_string():
  129. coord = _coordinator(current=SAMPLE)
  130. s = _sensor(ReservationTimeSensor, coord, field="check_in_time")
  131. assert s.native_value == "14:00:00"
  132. def test_time_sensor_none_when_time_is_none():
  133. res = ReservationData(check_in_time=None)
  134. coord = _coordinator(current=res)
  135. s = _sensor(ReservationTimeSensor, coord, field="check_in_time")
  136. assert s.native_value is None
  137. # ---------------------------------------------------------------------------
  138. # ReservationDateTimeSensor
  139. # ---------------------------------------------------------------------------
  140. def test_datetime_sensor_native_value_is_date():
  141. coord = _coordinator(current=SAMPLE)
  142. s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
  143. s._time_field = "check_in_time"
  144. assert s.native_value == date(2026, 7, 1)
  145. def test_datetime_sensor_attrs_contain_expected_keys():
  146. coord = _coordinator(current=SAMPLE)
  147. s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
  148. s._time_field = "check_in_time"
  149. attrs = s.extra_state_attributes
  150. assert attrs is not None
  151. for key in ("datetime", "date", "weekday", "month", "time_abbr", "hour"):
  152. assert key in attrs, f"missing key: {key}"
  153. def test_datetime_sensor_attrs_none_when_date_missing():
  154. res = ReservationData(check_in_date=None, check_in_time=time(14, 0))
  155. coord = _coordinator(current=res)
  156. s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
  157. s._time_field = "check_in_time"
  158. assert s.extra_state_attributes is None
  159. def test_datetime_sensor_attrs_none_when_time_missing():
  160. res = ReservationData(check_in_date=date(2026, 7, 1), check_in_time=None)
  161. coord = _coordinator(current=res)
  162. s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
  163. s._time_field = "check_in_time"
  164. assert s.extra_state_attributes is None
  165. # ---------------------------------------------------------------------------
  166. # WiFiKeySensor
  167. # ---------------------------------------------------------------------------
  168. def test_wifi_key_sensor_native_value():
  169. coord = _coordinator(current=SAMPLE)
  170. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  171. assert s.native_value == "hunter2"
  172. def test_wifi_key_sensor_qr_attribute():
  173. coord = _coordinator(current=SAMPLE)
  174. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  175. attrs = s.extra_state_attributes
  176. assert attrs is not None
  177. assert attrs["qr"] == "WIFI:S:MyNet;T:WPA;P:hunter2;;"
  178. def test_wifi_key_sensor_qr_attribute_open_network():
  179. """An empty-string wifi_key (open network default) should produce a nopass QR string."""
  180. res = ReservationData(wifi_ssid="MyNet", wifi_key="")
  181. coord = _coordinator(current=res)
  182. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  183. attrs = s.extra_state_attributes
  184. assert attrs is not None
  185. assert attrs["qr"] == "WIFI:S:MyNet;T:nopass;;"
  186. def test_wifi_key_sensor_qr_attribute_escapes_special_chars():
  187. res = ReservationData(wifi_ssid="Guest;Net,\"A\"", wifi_key="p:a\\ss;w,ord")
  188. coord = _coordinator(current=res)
  189. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  190. attrs = s.extra_state_attributes
  191. assert attrs is not None
  192. assert attrs["qr"] == 'WIFI:S:Guest\\;Net\\,\\"A\\";T:WPA;P:p\\:a\\\\ss\\;w\\,ord;;'
  193. def test_wifi_key_sensor_no_attrs_when_ssid_missing():
  194. res = ReservationData(wifi_ssid=None, wifi_key="hunter2")
  195. coord = _coordinator(current=res)
  196. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  197. assert s.extra_state_attributes is None
  198. def test_wifi_key_sensor_no_attrs_when_key_missing():
  199. res = ReservationData(wifi_ssid="MyNet", wifi_key=None)
  200. coord = _coordinator(current=res)
  201. s = _sensor(WiFiKeySensor, coord, field="wifi_key")
  202. assert s.extra_state_attributes is None
  203. # ---------------------------------------------------------------------------
  204. # LastUpdatedSensor
  205. # ---------------------------------------------------------------------------
  206. def test_last_updated_sensor_current():
  207. ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
  208. coord = _coordinator(current_updated=ts)
  209. s = _sensor(LastUpdatedSensor, coord)
  210. s._slot = STORE_CURRENT
  211. assert s.native_value == ts
  212. def test_last_updated_sensor_next():
  213. ts = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
  214. coord = _coordinator(next_updated=ts)
  215. s = _sensor(LastUpdatedSensor, coord)
  216. s._slot = STORE_NEXT
  217. assert s.native_value == ts
  218. def test_last_updated_sensor_none_before_any_change():
  219. coord = _coordinator()
  220. s = _sensor(LastUpdatedSensor, coord)
  221. s._slot = STORE_CURRENT
  222. assert s.native_value is None
  223. def test_last_updated_sensor_exposes_last_refresh_as_attribute():
  224. refresh_ts = datetime(2026, 6, 22, 9, 0, tzinfo=timezone.utc)
  225. updated_ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
  226. coord = _coordinator(current_ts=refresh_ts, current_updated=updated_ts)
  227. s = _sensor(LastUpdatedSensor, coord)
  228. s._slot = STORE_CURRENT
  229. assert s.extra_state_attributes["last_refresh"] == refresh_ts
  230. def test_last_updated_sensor_last_refresh_attribute_none_when_not_yet_fetched():
  231. coord = _coordinator()
  232. s = _sensor(LastUpdatedSensor, coord)
  233. s._slot = STORE_CURRENT
  234. assert s.extra_state_attributes["last_refresh"] is None
  235. def test_last_updated_sensor_active():
  236. ts = datetime(2026, 6, 22, 11, 0, tzinfo=timezone.utc)
  237. coord = _coordinator(active_updated=ts)
  238. s = _sensor(LastUpdatedSensor, coord)
  239. s._slot = SLOT_ACTIVE
  240. assert s.native_value == ts
  241. def test_last_updated_sensor_active_exposes_active_last_refresh():
  242. refresh_ts = datetime(2026, 6, 22, 9, 30, tzinfo=timezone.utc)
  243. coord = _coordinator(active_ts=refresh_ts)
  244. s = _sensor(LastUpdatedSensor, coord)
  245. s._slot = SLOT_ACTIVE
  246. assert s.extra_state_attributes["last_refresh"] == refresh_ts
  247. # ---------------------------------------------------------------------------
  248. # ApiHealthSensor
  249. # ---------------------------------------------------------------------------
  250. def _api_sensor(coordinator):
  251. s = object.__new__(ApiHealthSensor)
  252. s.coordinator = coordinator
  253. return s
  254. _REAL_CHECKS = {
  255. "caldav_connection": {"ok": True},
  256. "calendar": {"ok": True},
  257. "credentials": {
  258. "airbnb": {"ok": True},
  259. "booking": {"ok": False},
  260. },
  261. }
  262. def test_api_health_ok():
  263. status = ApiStatusData(status="ok", checks=_REAL_CHECKS)
  264. assert _api_sensor(_coordinator(api_status=status)).native_value == "OK"
  265. def test_api_health_degraded_on_non_ok_status():
  266. status = ApiStatusData(status="error", checks=_REAL_CHECKS)
  267. assert _api_sensor(_coordinator(api_status=status)).native_value == "Degraded"
  268. def test_api_health_none_when_no_status():
  269. assert _api_sensor(_coordinator(api_status=None)).native_value is None
  270. def test_api_health_attrs_flatten_checks():
  271. from datetime import datetime
  272. ts = datetime(2026, 6, 23, 8, 0, 0)
  273. status = ApiStatusData(status="ok", checks=_REAL_CHECKS, stale=False, checked_at=ts)
  274. attrs = _api_sensor(_coordinator(api_status=status)).extra_state_attributes
  275. assert attrs["caldav_connection"] is True
  276. assert attrs["calendar"] is True
  277. assert attrs["airbnb"] is True
  278. assert attrs["booking"] is False
  279. assert attrs["stale"] is False
  280. assert attrs["checked_at"] == ts.isoformat()
  281. def test_api_health_attrs_present_with_empty_checks():
  282. status = ApiStatusData(status="ok", checks={}, stale=True)
  283. attrs = _api_sensor(_coordinator(api_status=status)).extra_state_attributes
  284. assert attrs == {"stale": True, "checked_at": None}
  285. def test_api_health_no_attrs_when_no_status():
  286. assert _api_sensor(_coordinator(api_status=None)).extra_state_attributes is None