test_discovery_callback.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. from __future__ import annotations
  2. from dataclasses import dataclass
  3. from unittest.mock import MagicMock, patch
  4. import pytest
  5. import switchbot.discovery as discovery_module
  6. from switchbot.discovery import GetSwitchbotDevices
  7. from switchbot.models import SwitchBotAdvertisement
  8. @dataclass
  9. class _FakeBleakScanner:
  10. detection_callback: object
  11. adapter: str
  12. async def start(self) -> None:
  13. # detection_callback signature: (device, advertisement_data)
  14. self.detection_callback(object(), object())
  15. self.detection_callback(object(), object())
  16. async def stop(self) -> None:
  17. return
  18. @pytest.mark.asyncio
  19. async def test_discover_fires_callback_for_each_packet(
  20. monkeypatch: pytest.MonkeyPatch,
  21. ) -> None:
  22. calls: list[SwitchBotAdvertisement] = []
  23. # Patch parse_advertisement_data to return a different parsed object per invocation.
  24. parsed: list[SwitchBotAdvertisement] = [
  25. SwitchBotAdvertisement(
  26. address="aa:bb:cc:dd:ee:ff",
  27. data={"model": "c", "modelName": "Curtain", "data": {"position": 10}},
  28. device=object(),
  29. rssi=-80,
  30. active=True,
  31. ),
  32. SwitchBotAdvertisement(
  33. address="aa:bb:cc:dd:ee:ff",
  34. data={"model": "c", "modelName": "Curtain", "data": {"position": 20}},
  35. device=object(),
  36. rssi=-70,
  37. active=True,
  38. ),
  39. ]
  40. def _fake_parse(_device: object, _advertisement_data: object):
  41. return parsed.pop(0)
  42. async def _fake_sleep(_seconds: float) -> None:
  43. return
  44. def _fake_bleak_scanner(*, detection_callback, adapter: str):
  45. return _FakeBleakScanner(detection_callback=detection_callback, adapter=adapter)
  46. monkeypatch.setattr(discovery_module, "parse_advertisement_data", _fake_parse)
  47. monkeypatch.setattr(discovery_module.asyncio, "sleep", _fake_sleep)
  48. monkeypatch.setattr(discovery_module.bleak, "BleakScanner", _fake_bleak_scanner)
  49. scanner = GetSwitchbotDevices(callback=calls.append)
  50. result = await scanner.discover(scan_timeout=60)
  51. assert len(calls) == 2
  52. assert calls[0].data["data"]["position"] == 10
  53. assert calls[1].data["data"]["position"] == 20
  54. # discover() retains backwards compatibility by still returning accumulated data.
  55. assert result["aa:bb:cc:dd:ee:ff"].data["data"]["position"] == 20
  56. @pytest.mark.asyncio
  57. async def test_callback_exception_does_not_break_discovery(
  58. monkeypatch: pytest.MonkeyPatch,
  59. ) -> None:
  60. adv = SwitchBotAdvertisement(
  61. address="11:22:33:44:55:66",
  62. data={"model": "H", "modelName": "Bot", "data": {}},
  63. device=object(),
  64. rssi=-50,
  65. active=True,
  66. )
  67. def _fake_parse(_device: object, _advertisement_data: object):
  68. return adv
  69. async def _fake_sleep(_seconds: float) -> None:
  70. return
  71. def _fake_bleak_scanner(*, detection_callback, adapter: str):
  72. class _S:
  73. async def start(self) -> None:
  74. detection_callback(object(), object())
  75. async def stop(self) -> None:
  76. return
  77. return _S()
  78. def _boom(_adv: SwitchBotAdvertisement) -> None:
  79. raise RuntimeError("boom")
  80. monkeypatch.setattr(discovery_module, "parse_advertisement_data", _fake_parse)
  81. monkeypatch.setattr(discovery_module.asyncio, "sleep", _fake_sleep)
  82. monkeypatch.setattr(discovery_module.bleak, "BleakScanner", _fake_bleak_scanner)
  83. scanner = GetSwitchbotDevices(callback=_boom)
  84. result = await scanner.discover(scan_timeout=1)
  85. assert result["11:22:33:44:55:66"] == adv
  86. @pytest.mark.asyncio
  87. async def test_callback_exception_is_logged_and_suppressed(
  88. caplog: pytest.LogCaptureFixture,
  89. ) -> None:
  90. """
  91. Test that exceptions raised in the user callback are caught,
  92. logged, and do not crash the discovery process.
  93. """
  94. mock_callback = MagicMock(side_effect=RuntimeError("Boom!"))
  95. scanner = GetSwitchbotDevices(callback=mock_callback)
  96. adv = SwitchBotAdvertisement(
  97. address="aa:bb:cc:dd:ee:ff",
  98. data={"model": "H", "modelName": "Bot", "data": {}},
  99. device=MagicMock(),
  100. rssi=-80,
  101. active=True,
  102. )
  103. with patch("switchbot.discovery.parse_advertisement_data", return_value=adv):
  104. scanner.detection_callback(MagicMock(), MagicMock())
  105. mock_callback.assert_called_once()
  106. assert "Error in discovery callback" in caplog.text
  107. assert "Boom!" in caplog.text # Exception message should also be in the log