"""Tests for the background discovery watcher. We mock zeroconf and ``list_serial_ports`` so the tests run in milliseconds without touching the network or hardware. The mDNS path is exercised via the public state-change handler; the serial path is exercised via the internal one-shot poll method. """ from __future__ import annotations from unittest.mock import patch import pytest from zeroconf import ServiceStateChange from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher from ledgrab.core.devices.serial_transport import SerialPortInfo class _FakeDevice: def __init__(self, url: str) -> None: self.url = url class _FakeStore: def __init__(self, devices: list[_FakeDevice] | None = None) -> None: self._devices = devices or [] def get_all_devices(self): return list(self._devices) @pytest.fixture def captured_events(): events: list[dict] = [] return events @pytest.fixture def make_watcher(captured_events): def _factory(store: _FakeStore | None = None) -> DiscoveryWatcher: return DiscoveryWatcher( device_store=store or _FakeStore(), fire_event=captured_events.append, ) return _factory # ----------------------------------------------------------------------- # Serial polling # ----------------------------------------------------------------------- def test_seed_ports_do_not_emit_events(make_watcher, captured_events): """Ports already plugged in at startup must not generate notifications. Otherwise every server start would dump a "device discovered" toast for every COM port on the machine. """ watcher = make_watcher() seed = [SerialPortInfo(device="COM3", description="USB-Serial CH340")] # First-poll behaviour models the seed: the watcher's startup path # populates _serial_seen without firing events. We exercise that # behaviour by populating the dict directly and then running the # poll, asserting no events fire because nothing changed. from ledgrab.core.devices.discovery_watcher import _DiscoveredEntry for p in seed: watcher._serial_seen[p.device] = _DiscoveredEntry( key=p.device, url=p.device, name=p.description, device_type="serial" ) with patch( "ledgrab.core.devices.discovery_watcher.list_serial_ports", return_value=seed, ): watcher._poll_serial_once() assert captured_events == [] def test_new_serial_port_emits_discovered(make_watcher, captured_events): """A port appearing between polls fires device_discovered.""" watcher = make_watcher() # First poll: seed COM3. with patch( "ledgrab.core.devices.discovery_watcher.list_serial_ports", return_value=[SerialPortInfo(device="COM3", description="CH340")], ): watcher._poll_serial_once() captured_events.clear() # discovered for COM3 above is fine; we test the next change # Second poll: COM4 has appeared. with patch( "ledgrab.core.devices.discovery_watcher.list_serial_ports", return_value=[ SerialPortInfo(device="COM3", description="CH340"), SerialPortInfo(device="COM4", description="CP2102"), ], ): watcher._poll_serial_once() assert len(captured_events) == 1 evt = captured_events[0] assert evt["type"] == "device_discovered" assert evt["device_type"] == "serial" assert evt["url"] == "COM4" assert evt["name"] == "CP2102" def test_disappearing_serial_port_emits_lost(make_watcher, captured_events): """A port that vanishes between polls fires device_lost.""" watcher = make_watcher() with patch( "ledgrab.core.devices.discovery_watcher.list_serial_ports", return_value=[SerialPortInfo(device="COM4", description="CP2102")], ): watcher._poll_serial_once() captured_events.clear() with patch( "ledgrab.core.devices.discovery_watcher.list_serial_ports", return_value=[], ): watcher._poll_serial_once() assert len(captured_events) == 1 assert captured_events[0]["type"] == "device_lost" assert captured_events[0]["url"] == "COM4" def test_configured_devices_are_filtered_from_discovery(make_watcher, captured_events): """If a port matches a configured device URL, suppress discovery events. The user already gets device_health_changed for known devices; firing a redundant 'discovered' event the first time the port re-appears would be confusing noise. """ store = _FakeStore([_FakeDevice("COM5")]) watcher = make_watcher(store) # COM5 appears for the first time — but it's already configured, # so no event should fire. with patch( "ledgrab.core.devices.discovery_watcher.list_serial_ports", return_value=[SerialPortInfo(device="COM5", description="ESP32")], ): watcher._poll_serial_once() assert captured_events == [] def test_serial_enumeration_failure_is_swallowed(make_watcher, captured_events): """A blown enumeration call shouldn't crash the loop or fire bogus events.""" watcher = make_watcher() with patch( "ledgrab.core.devices.discovery_watcher.list_serial_ports", side_effect=OSError("device busy"), ): watcher._poll_serial_once() # must not raise assert captured_events == [] # ----------------------------------------------------------------------- # mDNS state-change dispatcher # ----------------------------------------------------------------------- def test_mdns_removed_emits_lost_only_for_unconfigured(make_watcher, captured_events): """ServiceStateChange.Removed → device_lost, but only when the URL isn't already in the user's device store.""" watcher = make_watcher() # Seed a previously-resolved entry. from ledgrab.core.devices.discovery_watcher import _DiscoveredEntry watcher._wled_seen["wled-foo._wled._tcp.local."] = _DiscoveredEntry( key="wled-foo._wled._tcp.local.", url="http://192.168.1.55", name="wled-foo", device_type="wled", ) watcher._on_wled_state_change( service_type="_wled._tcp.local.", name="wled-foo._wled._tcp.local.", state_change=ServiceStateChange.Removed, ) assert len(captured_events) == 1 assert captured_events[0]["type"] == "device_lost" assert captured_events[0]["device_type"] == "wled"