| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- """Unit tests for sensor property logic.
- Bypasses HA entity setup by using object.__new__ and injecting a mock
- coordinator — valid because every tested property only reads self.coordinator.data.
- """
- from datetime import date, datetime, time, timezone
- from unittest.mock import MagicMock
- import pytest
- from custom_components.room_cal_ha.coordinator import CoordinatorData
- from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
- from custom_components.room_cal_ha.sensor import (
- ApiHealthSensor,
- GuestNameSensor,
- LastUpdatedSensor,
- ReservationDateTimeSensor,
- ReservationStrSensor,
- ReservationTimeSensor,
- WiFiKeySensor,
- )
- from custom_components.room_cal_ha.const import SLOT_ACTIVE, STORE_CURRENT, STORE_NEXT
- # ---------------------------------------------------------------------------
- # Helpers
- # ---------------------------------------------------------------------------
- def _coordinator(
- active=None,
- current=None,
- nxt=None,
- current_ts=None,
- next_ts=None,
- current_updated=None,
- next_updated=None,
- active_ts=None,
- active_updated=None,
- api_status=None,
- ):
- coord = MagicMock()
- coord.data = CoordinatorData(
- active=active if active is not None else (current or ReservationData()),
- current=current or ReservationData(),
- next=nxt or ReservationData(),
- current_last_refresh=current_ts,
- next_last_refresh=next_ts,
- current_last_updated=current_updated,
- next_last_updated=next_updated,
- active_last_refresh=active_ts,
- active_last_updated=active_updated,
- api_status=api_status,
- )
- return coord
- def _sensor(cls, coordinator, slot=STORE_CURRENT, field="guest_name", **extra):
- """Create a sensor instance without running __init__."""
- s = object.__new__(cls)
- s.coordinator = coordinator
- s._slot = slot
- s._field = field
- for k, v in extra.items():
- setattr(s, k, v)
- return s
- SAMPLE = ReservationData(
- guest_name="Alice",
- check_in_date=date(2026, 7, 1),
- check_in_time=time(14, 0),
- check_out_date=date(2026, 7, 5),
- check_out_time=time(12, 0),
- wifi_ssid="MyNet",
- wifi_key="hunter2",
- door_code="0472",
- )
- # ---------------------------------------------------------------------------
- # ReservationStrSensor
- # ---------------------------------------------------------------------------
- def test_str_sensor_returns_field_value():
- coord = _coordinator(current=SAMPLE)
- s = _sensor(ReservationStrSensor, coord, field="guest_name")
- assert s.native_value == "Alice"
- def test_str_sensor_reads_next_slot():
- nxt = ReservationData(guest_name="Bob")
- coord = _coordinator(nxt=nxt)
- s = _sensor(ReservationStrSensor, coord, slot=STORE_NEXT, field="guest_name")
- assert s.native_value == "Bob"
- def test_str_sensor_none_when_field_is_none():
- res = ReservationData(guest_name=None)
- coord = _coordinator(current=res)
- s = _sensor(ReservationStrSensor, coord, field="guest_name")
- assert s.native_value is None
- def test_str_sensor_reads_active_slot():
- """Active sensors read the live/effective record — distinct from Current, which
- never reflects an in-progress override."""
- active = ReservationData(guest_name="Override Guest")
- coord = _coordinator(active=active, current=ReservationData(guest_name="Backend Guest"))
- s = _sensor(ReservationStrSensor, coord, slot=SLOT_ACTIVE, field="guest_name")
- assert s.native_value == "Override Guest"
- # ---------------------------------------------------------------------------
- # GuestNameSensor
- # ---------------------------------------------------------------------------
- def test_guest_name_single_word():
- coord = _coordinator(current=ReservationData(guest_name="Alice"))
- s = _sensor(GuestNameSensor, coord, field="guest_name")
- attrs = s.extra_state_attributes
- assert attrs["first_name"] == "Alice"
- assert attrs["first_abbr"] == "A"
- assert attrs["last_name"] is None
- assert attrs["last_abbr"] is None
- assert attrs["public"] == "Alice"
- def test_guest_name_two_words():
- coord = _coordinator(current=ReservationData(guest_name="ZiB Guest"))
- s = _sensor(GuestNameSensor, coord, field="guest_name")
- attrs = s.extra_state_attributes
- assert attrs["first_name"] == "ZiB"
- assert attrs["first_abbr"] == "Z"
- assert attrs["last_name"] == "Guest"
- assert attrs["last_abbr"] == "G"
- assert attrs["public"] == "ZiB G."
- def test_guest_name_three_words_last_is_final():
- coord = _coordinator(current=ReservationData(guest_name="ZiB Middle Guest"))
- s = _sensor(GuestNameSensor, coord, field="guest_name")
- attrs = s.extra_state_attributes
- assert attrs["first_name"] == "ZiB"
- assert attrs["last_name"] == "Guest"
- assert attrs["last_abbr"] == "G"
- assert attrs["public"] == "ZiB G."
- def test_guest_name_none_returns_no_attrs():
- coord = _coordinator(current=ReservationData(guest_name=None))
- s = _sensor(GuestNameSensor, coord, field="guest_name")
- assert s.extra_state_attributes is None
- # ---------------------------------------------------------------------------
- # ReservationTimeSensor
- # ---------------------------------------------------------------------------
- def test_time_sensor_returns_iso_string():
- coord = _coordinator(current=SAMPLE)
- s = _sensor(ReservationTimeSensor, coord, field="check_in_time")
- assert s.native_value == "14:00:00"
- def test_time_sensor_none_when_time_is_none():
- res = ReservationData(check_in_time=None)
- coord = _coordinator(current=res)
- s = _sensor(ReservationTimeSensor, coord, field="check_in_time")
- assert s.native_value is None
- # ---------------------------------------------------------------------------
- # ReservationDateTimeSensor
- # ---------------------------------------------------------------------------
- def test_datetime_sensor_native_value_is_date():
- coord = _coordinator(current=SAMPLE)
- s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
- s._time_field = "check_in_time"
- assert s.native_value == date(2026, 7, 1)
- def test_datetime_sensor_attrs_contain_expected_keys():
- coord = _coordinator(current=SAMPLE)
- s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
- s._time_field = "check_in_time"
- attrs = s.extra_state_attributes
- assert attrs is not None
- for key in ("datetime", "date", "weekday", "month", "time_abbr", "hour"):
- assert key in attrs, f"missing key: {key}"
- def test_datetime_sensor_attrs_none_when_date_missing():
- res = ReservationData(check_in_date=None, check_in_time=time(14, 0))
- coord = _coordinator(current=res)
- s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
- s._time_field = "check_in_time"
- assert s.extra_state_attributes is None
- def test_datetime_sensor_attrs_none_when_time_missing():
- res = ReservationData(check_in_date=date(2026, 7, 1), check_in_time=None)
- coord = _coordinator(current=res)
- s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
- s._time_field = "check_in_time"
- assert s.extra_state_attributes is None
- # ---------------------------------------------------------------------------
- # WiFiKeySensor
- # ---------------------------------------------------------------------------
- def test_wifi_key_sensor_native_value():
- coord = _coordinator(current=SAMPLE)
- s = _sensor(WiFiKeySensor, coord, field="wifi_key")
- assert s.native_value == "hunter2"
- def test_wifi_key_sensor_qr_attribute():
- coord = _coordinator(current=SAMPLE)
- s = _sensor(WiFiKeySensor, coord, field="wifi_key")
- attrs = s.extra_state_attributes
- assert attrs is not None
- assert attrs["qr"] == "WIFI:S:MyNet;T:WPA;P:hunter2;;"
- def test_wifi_key_sensor_qr_attribute_open_network():
- """An empty-string wifi_key (open network default) should produce a nopass QR string."""
- res = ReservationData(wifi_ssid="MyNet", wifi_key="")
- coord = _coordinator(current=res)
- s = _sensor(WiFiKeySensor, coord, field="wifi_key")
- attrs = s.extra_state_attributes
- assert attrs is not None
- assert attrs["qr"] == "WIFI:S:MyNet;T:nopass;;"
- def test_wifi_key_sensor_qr_attribute_escapes_special_chars():
- res = ReservationData(wifi_ssid="Guest;Net,\"A\"", wifi_key="p:a\\ss;w,ord")
- coord = _coordinator(current=res)
- s = _sensor(WiFiKeySensor, coord, field="wifi_key")
- attrs = s.extra_state_attributes
- assert attrs is not None
- assert attrs["qr"] == 'WIFI:S:Guest\\;Net\\,\\"A\\";T:WPA;P:p\\:a\\\\ss\\;w\\,ord;;'
- def test_wifi_key_sensor_no_attrs_when_ssid_missing():
- res = ReservationData(wifi_ssid=None, wifi_key="hunter2")
- coord = _coordinator(current=res)
- s = _sensor(WiFiKeySensor, coord, field="wifi_key")
- assert s.extra_state_attributes is None
- def test_wifi_key_sensor_no_attrs_when_key_missing():
- res = ReservationData(wifi_ssid="MyNet", wifi_key=None)
- coord = _coordinator(current=res)
- s = _sensor(WiFiKeySensor, coord, field="wifi_key")
- assert s.extra_state_attributes is None
- # ---------------------------------------------------------------------------
- # LastUpdatedSensor
- # ---------------------------------------------------------------------------
- def test_last_updated_sensor_current():
- ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
- coord = _coordinator(current_updated=ts)
- s = _sensor(LastUpdatedSensor, coord)
- s._slot = STORE_CURRENT
- assert s.native_value == ts
- def test_last_updated_sensor_next():
- ts = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
- coord = _coordinator(next_updated=ts)
- s = _sensor(LastUpdatedSensor, coord)
- s._slot = STORE_NEXT
- assert s.native_value == ts
- def test_last_updated_sensor_none_before_any_change():
- coord = _coordinator()
- s = _sensor(LastUpdatedSensor, coord)
- s._slot = STORE_CURRENT
- assert s.native_value is None
- def test_last_updated_sensor_exposes_last_refresh_as_attribute():
- refresh_ts = datetime(2026, 6, 22, 9, 0, tzinfo=timezone.utc)
- updated_ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
- coord = _coordinator(current_ts=refresh_ts, current_updated=updated_ts)
- s = _sensor(LastUpdatedSensor, coord)
- s._slot = STORE_CURRENT
- assert s.extra_state_attributes["last_refresh"] == refresh_ts
- def test_last_updated_sensor_last_refresh_attribute_none_when_not_yet_fetched():
- coord = _coordinator()
- s = _sensor(LastUpdatedSensor, coord)
- s._slot = STORE_CURRENT
- assert s.extra_state_attributes["last_refresh"] is None
- def test_last_updated_sensor_active():
- ts = datetime(2026, 6, 22, 11, 0, tzinfo=timezone.utc)
- coord = _coordinator(active_updated=ts)
- s = _sensor(LastUpdatedSensor, coord)
- s._slot = SLOT_ACTIVE
- assert s.native_value == ts
- def test_last_updated_sensor_active_exposes_active_last_refresh():
- refresh_ts = datetime(2026, 6, 22, 9, 30, tzinfo=timezone.utc)
- coord = _coordinator(active_ts=refresh_ts)
- s = _sensor(LastUpdatedSensor, coord)
- s._slot = SLOT_ACTIVE
- assert s.extra_state_attributes["last_refresh"] == refresh_ts
- # ---------------------------------------------------------------------------
- # ApiHealthSensor
- # ---------------------------------------------------------------------------
- def _api_sensor(coordinator):
- s = object.__new__(ApiHealthSensor)
- s.coordinator = coordinator
- return s
- _REAL_CHECKS = {
- "caldav_connection": {"ok": True},
- "calendar": {"ok": True},
- "credentials": {
- "airbnb": {"ok": True},
- "booking": {"ok": False},
- },
- }
- def test_api_health_ok():
- status = ApiStatusData(status="ok", checks=_REAL_CHECKS)
- assert _api_sensor(_coordinator(api_status=status)).native_value == "OK"
- def test_api_health_degraded_on_non_ok_status():
- status = ApiStatusData(status="error", checks=_REAL_CHECKS)
- assert _api_sensor(_coordinator(api_status=status)).native_value == "Degraded"
- def test_api_health_none_when_no_status():
- assert _api_sensor(_coordinator(api_status=None)).native_value is None
- def test_api_health_attrs_flatten_checks():
- from datetime import datetime
- ts = datetime(2026, 6, 23, 8, 0, 0)
- status = ApiStatusData(status="ok", checks=_REAL_CHECKS, stale=False, checked_at=ts)
- attrs = _api_sensor(_coordinator(api_status=status)).extra_state_attributes
- assert attrs["caldav_connection"] is True
- assert attrs["calendar"] is True
- assert attrs["airbnb"] is True
- assert attrs["booking"] is False
- assert attrs["stale"] is False
- assert attrs["checked_at"] == ts.isoformat()
- def test_api_health_attrs_present_with_empty_checks():
- status = ApiStatusData(status="ok", checks={}, stale=True)
- attrs = _api_sensor(_coordinator(api_status=status)).extra_state_attributes
- assert attrs == {"stale": True, "checked_at": None}
- def test_api_health_no_attrs_when_no_status():
- assert _api_sensor(_coordinator(api_status=None)).extra_state_attributes is None
|