| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343 |
- """Unit tests for Home Assistant sensors bound to a printer (#1148, #448).
- The alert rules decide three separate things — the pill colour on the card, a
- notification, and whether the queue holds — so they are tested directly rather
- than through any one of those consumers.
- The recurring theme is that "we could not read it" must never be mistaken for
- a reading. A door contact whose integration has dropped out reports
- "unavailable", not "closed", and treating that as closed would let a print
- start into an open enclosure; treating it as *open* would strand the queue.
- Neither: it is not a reading at all.
- """
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, patch
- import pytest
- from backend.app.services.ha_sensor_manager import (
- HASensorManager,
- SensorReading,
- describe_state,
- evaluate,
- )
- def _sensor(**overrides):
- """A sensor row as the poller sees it, without touching the DB."""
- base = {
- "id": 1,
- "printer_id": 4,
- "name": "Enclosure Door",
- "entity_id": "binary_sensor.enclosure_door",
- "kind": "binary",
- "device_class": "door",
- "unit": None,
- "alert_state": "on",
- "alert_above": None,
- "alert_below": None,
- "block_print": False,
- "notify_on_alert": False,
- "last_state": None,
- }
- base.update(overrides)
- return SimpleNamespace(**base)
- def _numeric(**overrides):
- base = {
- "entity_id": "sensor.enclosure_temp",
- "kind": "numeric",
- "device_class": "temperature",
- "unit": "\u00b0C",
- "alert_state": None,
- "name": "Enclosure Temp",
- }
- base.update(overrides)
- return _sensor(**base)
- class TestEvaluateBinary:
- def test_alerts_in_the_configured_state(self):
- reading = evaluate(_sensor(), {"state": "on"})
- assert reading == SensorReading(state="on", value=None, alerting=True, reachable=True)
- def test_quiet_in_the_other_state(self):
- assert evaluate(_sensor(), {"state": "off"}).alerting is False
- def test_alert_state_off_inverts_the_rule(self):
- """A "fan running" contact alarms when it stops, not when it starts."""
- sensor = _sensor(alert_state="off", name="Exhaust Fan")
- assert evaluate(sensor, {"state": "off"}).alerting is True
- assert evaluate(sensor, {"state": "on"}).alerting is False
- def test_no_alert_state_never_alerts(self):
- """Display-only sensors are the default — they just show a state."""
- sensor = _sensor(alert_state=None)
- assert evaluate(sensor, {"state": "on"}).alerting is False
- assert evaluate(sensor, {"state": "on"}).reachable is True
- def test_state_is_normalised_to_lower_case(self):
- """Some integrations report "ON"; the alert rule stores "on"."""
- assert evaluate(_sensor(), {"state": "ON"}).state == "on"
- assert evaluate(_sensor(), {"state": "ON"}).alerting is True
- class TestEvaluateNumeric:
- def test_above_threshold_alerts(self):
- assert evaluate(_numeric(alert_above=35), {"state": "41.2"}).alerting is True
- def test_below_threshold_alerts(self):
- assert evaluate(_numeric(alert_below=15), {"state": "12"}).alerting is True
- def test_inside_the_band_is_quiet(self):
- reading = evaluate(_numeric(alert_above=35, alert_below=15), {"state": "22.5"})
- assert reading.alerting is False
- assert reading.value == 22.5
- def test_exactly_on_the_threshold_is_not_an_alert(self):
- """Strict comparison, so a 35 °C limit does not alarm at exactly 35."""
- assert evaluate(_numeric(alert_above=35), {"state": "35"}).alerting is False
- def test_a_sensor_that_stops_reporting_numbers_does_not_alert(self):
- """Reachable, but no value to compare — so no verdict either way."""
- reading = evaluate(_numeric(alert_above=35), {"state": "calibrating"})
- assert reading.reachable is True
- assert reading.value is None
- assert reading.alerting is False
- class TestUnreadable:
- @pytest.mark.parametrize("state", ["unavailable", "unknown", None])
- def test_ha_non_states_are_not_readings(self, state):
- reading = evaluate(_sensor(), {"state": state})
- assert reading.reachable is False
- assert reading.alerting is False
- assert reading.state is None
- def test_a_failed_fetch_is_not_a_reading(self):
- """fetch_states maps an entity it could not read to None."""
- reading = evaluate(_sensor(), None)
- assert reading == SensorReading(state=None, value=None, alerting=False, reachable=False)
- class TestDescribeState:
- def test_binary_uses_the_raw_state(self):
- assert describe_state(_sensor(), evaluate(_sensor(), {"state": "on"})) == "on"
- def test_numeric_carries_its_unit(self):
- sensor = _numeric()
- assert describe_state(sensor, evaluate(sensor, {"state": "41.20"})) == "41.2 °C"
- def test_numeric_without_a_unit_is_bare(self):
- sensor = _numeric(unit=None)
- assert describe_state(sensor, evaluate(sensor, {"state": "7"})) == "7"
- class TestBlockedPrinters:
- """The interlock only ever reports a positive, current finding."""
- def _manager_with(self, sensors, readings):
- manager = HASensorManager()
- manager._readings = readings
- db = AsyncMock()
- db.execute.return_value = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: sensors))
- return manager, db
- @pytest.mark.asyncio
- async def test_reports_an_alerting_blocking_sensor(self):
- sensor = _sensor(block_print=True)
- manager, db = self._manager_with([sensor], {1: SensorReading("on", None, True, True)})
- assert await manager.blocked_printers(db) == {4: "Enclosure Door"}
- @pytest.mark.asyncio
- async def test_silent_when_not_alerting(self):
- sensor = _sensor(block_print=True)
- manager, db = self._manager_with([sensor], {1: SensorReading("off", None, False, True)})
- assert await manager.blocked_printers(db) == {}
- @pytest.mark.asyncio
- async def test_silent_when_home_assistant_is_unreachable(self):
- """The queue must keep running when HA is down, not seize up."""
- sensor = _sensor(block_print=True)
- manager, db = self._manager_with([sensor], {1: SensorReading(None, None, False, False)})
- assert await manager.blocked_printers(db) == {}
- @pytest.mark.asyncio
- async def test_silent_before_the_first_poll(self):
- """A cold cache is not evidence the door is open."""
- sensor = _sensor(block_print=True)
- manager, db = self._manager_with([sensor], {})
- assert await manager.blocked_printers(db) == {}
- @pytest.mark.asyncio
- async def test_names_every_blocking_sensor_on_a_printer(self):
- sensors = [
- _sensor(id=1, block_print=True, name="Front Door"),
- _sensor(id=2, block_print=True, name="Side Panel"),
- ]
- manager, db = self._manager_with(
- sensors,
- {
- 1: SensorReading("on", None, True, True),
- 2: SensorReading("on", None, True, True),
- },
- )
- assert await manager.blocked_printers(db) == {4: "Front Door, Side Panel"}
- class TestNotificationEdge:
- """Alerts fire on the transition into the alert state, not while it lasts."""
- async def _apply(self, manager, sensor, states, notify):
- db = AsyncMock()
- db.get.return_value = SimpleNamespace(name="X1C-1")
- with patch("backend.app.services.notification_service.notification_service", notify):
- await manager._apply(db, [sensor], states)
- @pytest.mark.asyncio
- async def test_fires_once_on_the_way_in(self):
- manager = HASensorManager()
- sensor = _sensor(notify_on_alert=True)
- notify = AsyncMock()
- # First poll seeds the cache; a door already open at startup has not
- # just been opened.
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
- assert notify.on_ha_sensor_alert.await_count == 0
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
- assert notify.on_ha_sensor_alert.await_count == 1
- # Still open on the next pass — no second alert.
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
- assert notify.on_ha_sensor_alert.await_count == 1
- @pytest.mark.asyncio
- async def test_silent_on_the_first_poll_after_a_restart(self):
- """Cold cache. Re-announcing every pre-existing alert on every restart
- is how users learn to ignore the alert."""
- manager = HASensorManager()
- sensor = _sensor(notify_on_alert=True)
- notify = AsyncMock()
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
- assert notify.on_ha_sensor_alert.await_count == 0
- assert manager.get_reading(sensor.id).alerting is True
- @pytest.mark.asyncio
- async def test_silent_when_the_sensor_opts_out(self):
- manager = HASensorManager()
- sensor = _sensor(notify_on_alert=False)
- notify = AsyncMock()
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
- assert notify.on_ha_sensor_alert.await_count == 0
- @pytest.mark.asyncio
- async def test_re_arms_after_the_alert_clears(self):
- manager = HASensorManager()
- sensor = _sensor(notify_on_alert=True)
- notify = AsyncMock()
- for state in ("off", "on", "off", "on"):
- await self._apply(manager, sensor, {sensor.entity_id: {"state": state}}, notify)
- assert notify.on_ha_sensor_alert.await_count == 2
- @pytest.mark.asyncio
- async def test_a_dropout_does_not_count_as_the_alert_clearing(self):
- """on -> unavailable -> on is one continuous alert, not two.
- Without this, a flaky Zigbee contact would notify on every reconnect.
- """
- manager = HASensorManager()
- sensor = _sensor(notify_on_alert=True)
- notify = AsyncMock()
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
- await self._apply(manager, sensor, {sensor.entity_id: None}, notify)
- await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
- assert notify.on_ha_sensor_alert.await_count == 1
- class TestLastStatePersistence:
- """What goes into last_state must fit its String(64) column.
- A numeric entity can start reporting free text — an enum, an error string
- from a template sensor — longer than the column. SQLite stores it anyway,
- but PostgreSQL rejects the row, and _apply commits the whole pass at once:
- one such entity would sink every printer sensor's update on every tick,
- which also freezes what the print interlock is looking at.
- The same rule as the storage-location poller, through the same helper.
- """
- async def _apply(self, manager, sensor, state):
- db = AsyncMock()
- with patch("backend.app.services.notification_service.notification_service", AsyncMock()):
- await manager._apply(db, [sensor], {sensor.entity_id: {"state": state}})
- @pytest.mark.asyncio
- async def test_a_long_text_state_is_cut_to_the_column_width(self):
- manager = HASensorManager()
- sensor = _numeric(last_changed=None, last_checked=None)
- long_state = "x" * 500
- await self._apply(manager, sensor, long_state)
- assert sensor.last_state == "x" * 64
- # The cache keeps the full state — only what is persisted is cut.
- assert manager.get_reading(sensor.id).state == long_state
- @pytest.mark.asyncio
- async def test_an_unchanged_long_state_is_not_a_change_on_every_poll(self):
- manager = HASensorManager()
- sensor = _numeric(last_changed=None, last_checked=None)
- long_state = "x" * 500
- await self._apply(manager, sensor, long_state)
- first_changed = sensor.last_changed
- await self._apply(manager, sensor, long_state)
- # Comparing the stored (cut) value against the raw state would read as
- # a difference on every poll and churn last_changed forever.
- assert sensor.last_changed == first_changed
- @pytest.mark.asyncio
- async def test_refresh_one_cuts_it_too(self):
- """The single-sensor path a create or an edit takes, not just the loop."""
- manager = HASensorManager()
- sensor = _numeric(last_changed=None, last_checked=None)
- db = AsyncMock()
- with (
- patch.object(manager, "_configure", AsyncMock(return_value=True)),
- patch(
- "backend.app.services.ha_sensor_manager.homeassistant_service.fetch_states",
- AsyncMock(return_value={sensor.entity_id: {"state": "y" * 300}}),
- ),
- ):
- await manager.refresh_one(db, sensor)
- assert sensor.last_state == "y" * 64
|