Просмотр исходного кода

Add unit tests for button, switch, and binary sensor platforms

Covers ForceRefreshButton, PromoteFromNextButton, GuestModeSwitch
(including deferred write and commit_state), ModeSyncSwitch, and
StaleSensor (manual vs. CalDAV mode, fresh/stale/missing timestamps).
ZiB 4 месяцев назад
Родитель
Сommit
828311c1a5
3 измененных файлов с 256 добавлено и 0 удалено
  1. 108 0
      tests/test_binary_sensor.py
  2. 42 0
      tests/test_buttons.py
  3. 106 0
      tests/test_switches.py

+ 108 - 0
tests/test_binary_sensor.py

@@ -0,0 +1,108 @@
+"""Unit tests for StaleSensor.
+
+Uses object.__new__ to bypass HA entity setup — valid because is_on only
+reads entry.options, coordinator.data timestamps, and dt_util.utcnow().
+"""
+from datetime import datetime, timedelta, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from custom_components.room_cal_ha.binary_sensor import StaleSensor
+from custom_components.room_cal_ha.coordinator import CoordinatorData
+from custom_components.room_cal_ha.const import (
+  CONF_REFRESH_INTERVAL,
+  CONF_SYNC_MODE,
+  DEFAULT_REFRESH_INTERVAL,
+  SYNC_MODE_CALDAV,
+  SYNC_MODE_MANUAL,
+)
+from custom_components.room_cal_ha.models import ReservationData
+
+
+_NOW = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc)
+
+
+def _sensor(sync_mode=SYNC_MODE_CALDAV, interval=DEFAULT_REFRESH_INTERVAL, current_ts=None, next_ts=None):
+  s = object.__new__(StaleSensor)
+  s._entry = MagicMock()
+  s._entry.options = {CONF_SYNC_MODE: sync_mode, CONF_REFRESH_INTERVAL: interval}
+  coord = MagicMock()
+  coord.data = CoordinatorData(
+    current=ReservationData(),
+    next=ReservationData(),
+    current_last_refresh=current_ts,
+    next_last_refresh=next_ts,
+  )
+  s.coordinator = coord
+  return s
+
+
+# ---------------------------------------------------------------------------
+# Manual mode — always None
+# ---------------------------------------------------------------------------
+
+def test_stale_sensor_manual_mode_returns_none():
+  s = _sensor(sync_mode=SYNC_MODE_MANUAL)
+  with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
+    assert s.is_on is None
+
+
+# ---------------------------------------------------------------------------
+# CalDAV mode — fresh timestamps
+# ---------------------------------------------------------------------------
+
+def test_stale_sensor_fresh_timestamps_returns_false():
+  recent = _NOW - timedelta(minutes=5)
+  s = _sensor(current_ts=recent, next_ts=recent, interval=15)
+  with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
+    assert s.is_on is False
+
+
+# ---------------------------------------------------------------------------
+# CalDAV mode — stale / missing timestamps
+# ---------------------------------------------------------------------------
+
+def test_stale_sensor_none_current_ts_returns_true():
+  recent = _NOW - timedelta(minutes=5)
+  s = _sensor(current_ts=None, next_ts=recent, interval=15)
+  with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
+    assert s.is_on is True
+
+
+def test_stale_sensor_none_next_ts_returns_true():
+  recent = _NOW - timedelta(minutes=5)
+  s = _sensor(current_ts=recent, next_ts=None, interval=15)
+  with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
+    assert s.is_on is True
+
+
+def test_stale_sensor_both_ts_none_returns_true():
+  s = _sensor(current_ts=None, next_ts=None, interval=15)
+  with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
+    assert s.is_on is True
+
+
+def test_stale_sensor_expired_current_ts_returns_true():
+  # interval=15 → threshold=30 min; set current 31 min ago
+  stale = _NOW - timedelta(minutes=31)
+  fresh = _NOW - timedelta(minutes=5)
+  s = _sensor(current_ts=stale, next_ts=fresh, interval=15)
+  with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
+    assert s.is_on is True
+
+
+def test_stale_sensor_exactly_at_threshold_is_not_stale():
+  # Exactly 2x interval is NOT stale (uses strict >)
+  threshold = _NOW - timedelta(minutes=30)
+  s = _sensor(current_ts=threshold, next_ts=threshold, interval=15)
+  with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
+    assert s.is_on is False
+
+
+def test_stale_sensor_custom_interval():
+  # interval=5 → threshold=10 min; set both 9 min ago → fresh
+  recent = _NOW - timedelta(minutes=9)
+  s = _sensor(current_ts=recent, next_ts=recent, interval=5)
+  with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
+    assert s.is_on is False

+ 42 - 0
tests/test_buttons.py

@@ -0,0 +1,42 @@
+"""Unit tests for ForceRefreshButton and PromoteFromNextButton.
+
+Uses object.__new__ to bypass HA entity setup — valid because async_press
+only calls coordinator methods, not HA lifecycle hooks.
+"""
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from custom_components.room_cal_ha.button import ForceRefreshButton, PromoteFromNextButton
+
+
+def _button(cls):
+  btn = object.__new__(cls)
+  btn._coordinator = MagicMock()
+  btn._coordinator.async_refresh = AsyncMock()
+  btn._coordinator.async_promote_next_to_current = AsyncMock()
+  return btn
+
+
+async def test_force_refresh_calls_coordinator_refresh():
+  btn = _button(ForceRefreshButton)
+  await btn.async_press()
+  btn._coordinator.async_refresh.assert_awaited_once()
+
+
+async def test_promote_from_next_calls_coordinator_promote():
+  btn = _button(PromoteFromNextButton)
+  await btn.async_press()
+  btn._coordinator.async_promote_next_to_current.assert_awaited_once()
+
+
+async def test_force_refresh_does_not_call_promote():
+  btn = _button(ForceRefreshButton)
+  await btn.async_press()
+  btn._coordinator.async_promote_next_to_current.assert_not_called()
+
+
+async def test_promote_does_not_call_refresh():
+  btn = _button(PromoteFromNextButton)
+  await btn.async_press()
+  btn._coordinator.async_refresh.assert_not_called()

+ 106 - 0
tests/test_switches.py

@@ -0,0 +1,106 @@
+"""Unit tests for GuestModeSwitch and ModeSyncSwitch.
+
+Uses object.__new__ to bypass HA entity setup. async_write_ha_state is
+replaced with a MagicMock to avoid needing a real HA instance.
+"""
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from custom_components.room_cal_ha.switch import GuestModeSwitch, ModeSyncSwitch
+
+
+def _guest_mode():
+  s = object.__new__(GuestModeSwitch)
+  s._state = False
+  s._deferred = True
+  s._coordinator = MagicMock()
+  s.async_write_ha_state = MagicMock()
+  return s
+
+
+def _mode_sync():
+  s = object.__new__(ModeSyncSwitch)
+  s._state = False
+  s._coordinator = MagicMock()
+  s._coordinator.cancel_mode_sync = MagicMock()
+  s.async_write_ha_state = MagicMock()
+  return s
+
+
+# ---------------------------------------------------------------------------
+# GuestModeSwitch — basic state
+# ---------------------------------------------------------------------------
+
+async def test_guest_mode_initial_state_is_false():
+  s = _guest_mode()
+  assert s.is_on is False
+
+
+async def test_guest_mode_turn_on_sets_state():
+  s = _guest_mode()
+  s._deferred = False
+  await s.async_turn_on()
+  assert s.is_on is True
+
+
+async def test_guest_mode_turn_off_sets_state():
+  s = _guest_mode()
+  s._state = True
+  s._deferred = False
+  await s.async_turn_off()
+  assert s.is_on is False
+
+
+# ---------------------------------------------------------------------------
+# GuestModeSwitch — deferred write behavior
+# ---------------------------------------------------------------------------
+
+async def test_guest_mode_deferred_suppresses_write():
+  s = _guest_mode()
+  assert s._deferred is True
+  s.async_write_ha_state()  # should be a no-op when deferred
+  # The real async_write_ha_state is replaced; calling it when deferred returns early.
+  # We verify by re-implementing the check:
+  with patch.object(type(s).__bases__[0], "async_write_ha_state") as mock_parent:
+    s.async_write_ha_state = GuestModeSwitch.async_write_ha_state.__get__(s, type(s))
+    s.async_write_ha_state()
+    mock_parent.assert_not_called()
+
+
+def test_guest_mode_commit_state_unblocks_and_writes():
+  s = _guest_mode()
+  s._state = True
+  real_write = MagicMock()
+
+  with patch(
+    "homeassistant.helpers.entity.Entity.async_write_ha_state",
+    real_write,
+  ):
+    s.commit_state()
+
+  assert s._deferred is False
+  real_write.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# ModeSyncSwitch — turn off cancels mode sync
+# ---------------------------------------------------------------------------
+
+async def test_mode_sync_turn_off_cancels_sync():
+  s = _mode_sync()
+  s._state = True
+  await s.async_turn_off()
+  assert s.is_on is False
+  s._coordinator.cancel_mode_sync.assert_called_once()
+
+
+async def test_mode_sync_turn_on_does_not_cancel_sync():
+  s = _mode_sync()
+  await s.async_turn_on()
+  s._coordinator.cancel_mode_sync.assert_not_called()
+
+
+async def test_mode_sync_initial_state_is_false():
+  s = _mode_sync()
+  assert s.is_on is False