test_ha_sensor_manager_1148.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """Unit tests for Home Assistant sensors bound to a printer (#1148, #448).
  2. The alert rules decide three separate things — the pill colour on the card, a
  3. notification, and whether the queue holds — so they are tested directly rather
  4. than through any one of those consumers.
  5. The recurring theme is that "we could not read it" must never be mistaken for
  6. a reading. A door contact whose integration has dropped out reports
  7. "unavailable", not "closed", and treating that as closed would let a print
  8. start into an open enclosure; treating it as *open* would strand the queue.
  9. Neither: it is not a reading at all.
  10. """
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, patch
  13. import pytest
  14. from backend.app.services.ha_sensor_manager import (
  15. HASensorManager,
  16. SensorReading,
  17. describe_state,
  18. evaluate,
  19. )
  20. def _sensor(**overrides):
  21. """A sensor row as the poller sees it, without touching the DB."""
  22. base = {
  23. "id": 1,
  24. "printer_id": 4,
  25. "name": "Enclosure Door",
  26. "entity_id": "binary_sensor.enclosure_door",
  27. "kind": "binary",
  28. "device_class": "door",
  29. "unit": None,
  30. "alert_state": "on",
  31. "alert_above": None,
  32. "alert_below": None,
  33. "block_print": False,
  34. "notify_on_alert": False,
  35. "last_state": None,
  36. }
  37. base.update(overrides)
  38. return SimpleNamespace(**base)
  39. def _numeric(**overrides):
  40. base = {
  41. "entity_id": "sensor.enclosure_temp",
  42. "kind": "numeric",
  43. "device_class": "temperature",
  44. "unit": "\u00b0C",
  45. "alert_state": None,
  46. "name": "Enclosure Temp",
  47. }
  48. base.update(overrides)
  49. return _sensor(**base)
  50. class TestEvaluateBinary:
  51. def test_alerts_in_the_configured_state(self):
  52. reading = evaluate(_sensor(), {"state": "on"})
  53. assert reading == SensorReading(state="on", value=None, alerting=True, reachable=True)
  54. def test_quiet_in_the_other_state(self):
  55. assert evaluate(_sensor(), {"state": "off"}).alerting is False
  56. def test_alert_state_off_inverts_the_rule(self):
  57. """A "fan running" contact alarms when it stops, not when it starts."""
  58. sensor = _sensor(alert_state="off", name="Exhaust Fan")
  59. assert evaluate(sensor, {"state": "off"}).alerting is True
  60. assert evaluate(sensor, {"state": "on"}).alerting is False
  61. def test_no_alert_state_never_alerts(self):
  62. """Display-only sensors are the default — they just show a state."""
  63. sensor = _sensor(alert_state=None)
  64. assert evaluate(sensor, {"state": "on"}).alerting is False
  65. assert evaluate(sensor, {"state": "on"}).reachable is True
  66. def test_state_is_normalised_to_lower_case(self):
  67. """Some integrations report "ON"; the alert rule stores "on"."""
  68. assert evaluate(_sensor(), {"state": "ON"}).state == "on"
  69. assert evaluate(_sensor(), {"state": "ON"}).alerting is True
  70. class TestEvaluateNumeric:
  71. def test_above_threshold_alerts(self):
  72. assert evaluate(_numeric(alert_above=35), {"state": "41.2"}).alerting is True
  73. def test_below_threshold_alerts(self):
  74. assert evaluate(_numeric(alert_below=15), {"state": "12"}).alerting is True
  75. def test_inside_the_band_is_quiet(self):
  76. reading = evaluate(_numeric(alert_above=35, alert_below=15), {"state": "22.5"})
  77. assert reading.alerting is False
  78. assert reading.value == 22.5
  79. def test_exactly_on_the_threshold_is_not_an_alert(self):
  80. """Strict comparison, so a 35 °C limit does not alarm at exactly 35."""
  81. assert evaluate(_numeric(alert_above=35), {"state": "35"}).alerting is False
  82. def test_a_sensor_that_stops_reporting_numbers_does_not_alert(self):
  83. """Reachable, but no value to compare — so no verdict either way."""
  84. reading = evaluate(_numeric(alert_above=35), {"state": "calibrating"})
  85. assert reading.reachable is True
  86. assert reading.value is None
  87. assert reading.alerting is False
  88. class TestUnreadable:
  89. @pytest.mark.parametrize("state", ["unavailable", "unknown", None])
  90. def test_ha_non_states_are_not_readings(self, state):
  91. reading = evaluate(_sensor(), {"state": state})
  92. assert reading.reachable is False
  93. assert reading.alerting is False
  94. assert reading.state is None
  95. def test_a_failed_fetch_is_not_a_reading(self):
  96. """fetch_states maps an entity it could not read to None."""
  97. reading = evaluate(_sensor(), None)
  98. assert reading == SensorReading(state=None, value=None, alerting=False, reachable=False)
  99. class TestDescribeState:
  100. def test_binary_uses_the_raw_state(self):
  101. assert describe_state(_sensor(), evaluate(_sensor(), {"state": "on"})) == "on"
  102. def test_numeric_carries_its_unit(self):
  103. sensor = _numeric()
  104. assert describe_state(sensor, evaluate(sensor, {"state": "41.20"})) == "41.2 °C"
  105. def test_numeric_without_a_unit_is_bare(self):
  106. sensor = _numeric(unit=None)
  107. assert describe_state(sensor, evaluate(sensor, {"state": "7"})) == "7"
  108. class TestBlockedPrinters:
  109. """The interlock only ever reports a positive, current finding."""
  110. def _manager_with(self, sensors, readings):
  111. manager = HASensorManager()
  112. manager._readings = readings
  113. db = AsyncMock()
  114. db.execute.return_value = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: sensors))
  115. return manager, db
  116. @pytest.mark.asyncio
  117. async def test_reports_an_alerting_blocking_sensor(self):
  118. sensor = _sensor(block_print=True)
  119. manager, db = self._manager_with([sensor], {1: SensorReading("on", None, True, True)})
  120. assert await manager.blocked_printers(db) == {4: "Enclosure Door"}
  121. @pytest.mark.asyncio
  122. async def test_silent_when_not_alerting(self):
  123. sensor = _sensor(block_print=True)
  124. manager, db = self._manager_with([sensor], {1: SensorReading("off", None, False, True)})
  125. assert await manager.blocked_printers(db) == {}
  126. @pytest.mark.asyncio
  127. async def test_silent_when_home_assistant_is_unreachable(self):
  128. """The queue must keep running when HA is down, not seize up."""
  129. sensor = _sensor(block_print=True)
  130. manager, db = self._manager_with([sensor], {1: SensorReading(None, None, False, False)})
  131. assert await manager.blocked_printers(db) == {}
  132. @pytest.mark.asyncio
  133. async def test_silent_before_the_first_poll(self):
  134. """A cold cache is not evidence the door is open."""
  135. sensor = _sensor(block_print=True)
  136. manager, db = self._manager_with([sensor], {})
  137. assert await manager.blocked_printers(db) == {}
  138. @pytest.mark.asyncio
  139. async def test_names_every_blocking_sensor_on_a_printer(self):
  140. sensors = [
  141. _sensor(id=1, block_print=True, name="Front Door"),
  142. _sensor(id=2, block_print=True, name="Side Panel"),
  143. ]
  144. manager, db = self._manager_with(
  145. sensors,
  146. {
  147. 1: SensorReading("on", None, True, True),
  148. 2: SensorReading("on", None, True, True),
  149. },
  150. )
  151. assert await manager.blocked_printers(db) == {4: "Front Door, Side Panel"}
  152. class TestNotificationEdge:
  153. """Alerts fire on the transition into the alert state, not while it lasts."""
  154. async def _apply(self, manager, sensor, states, notify):
  155. db = AsyncMock()
  156. db.get.return_value = SimpleNamespace(name="X1C-1")
  157. with patch("backend.app.services.notification_service.notification_service", notify):
  158. await manager._apply(db, [sensor], states)
  159. @pytest.mark.asyncio
  160. async def test_fires_once_on_the_way_in(self):
  161. manager = HASensorManager()
  162. sensor = _sensor(notify_on_alert=True)
  163. notify = AsyncMock()
  164. # First poll seeds the cache; a door already open at startup has not
  165. # just been opened.
  166. await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
  167. assert notify.on_ha_sensor_alert.await_count == 0
  168. await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
  169. assert notify.on_ha_sensor_alert.await_count == 1
  170. # Still open on the next pass — no second alert.
  171. await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
  172. assert notify.on_ha_sensor_alert.await_count == 1
  173. @pytest.mark.asyncio
  174. async def test_silent_on_the_first_poll_after_a_restart(self):
  175. """Cold cache. Re-announcing every pre-existing alert on every restart
  176. is how users learn to ignore the alert."""
  177. manager = HASensorManager()
  178. sensor = _sensor(notify_on_alert=True)
  179. notify = AsyncMock()
  180. await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
  181. assert notify.on_ha_sensor_alert.await_count == 0
  182. assert manager.get_reading(sensor.id).alerting is True
  183. @pytest.mark.asyncio
  184. async def test_silent_when_the_sensor_opts_out(self):
  185. manager = HASensorManager()
  186. sensor = _sensor(notify_on_alert=False)
  187. notify = AsyncMock()
  188. await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
  189. await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
  190. assert notify.on_ha_sensor_alert.await_count == 0
  191. @pytest.mark.asyncio
  192. async def test_re_arms_after_the_alert_clears(self):
  193. manager = HASensorManager()
  194. sensor = _sensor(notify_on_alert=True)
  195. notify = AsyncMock()
  196. for state in ("off", "on", "off", "on"):
  197. await self._apply(manager, sensor, {sensor.entity_id: {"state": state}}, notify)
  198. assert notify.on_ha_sensor_alert.await_count == 2
  199. @pytest.mark.asyncio
  200. async def test_a_dropout_does_not_count_as_the_alert_clearing(self):
  201. """on -> unavailable -> on is one continuous alert, not two.
  202. Without this, a flaky Zigbee contact would notify on every reconnect.
  203. """
  204. manager = HASensorManager()
  205. sensor = _sensor(notify_on_alert=True)
  206. notify = AsyncMock()
  207. await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
  208. await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
  209. await self._apply(manager, sensor, {sensor.entity_id: None}, notify)
  210. await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
  211. assert notify.on_ha_sensor_alert.await_count == 1