From 8a470019218ca079281cc977eb353d1816bf0c36 Mon Sep 17 00:00:00 2001 From: Stevenson Chittumuri Date: Mon, 22 Jun 2026 02:22:05 -0400 Subject: [PATCH] test: de-flake all discovery + ingest timing tests (poll, don't sleep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the two thread-timing flakes fixed for the release, sweep the rest of the fixed-sleep-then-assert pattern out of the suite. All 17 discovery tests started a background poll thread, slept a fixed window (0.1–0.6 s vs a 0.05–0.1 s poll interval — thin margin under CI load), then asserted on collected events; the RTSP reconnect test slept a fixed 1 s waiting for ≥2 opens. Replace each with a `_wait_until(predicate)` poll on the asserted condition, so the tests return as soon as the work lands and only wait long under genuine stalls. Two "unavailable" tests start no thread, so their sleeps are simply removed. Net effect: no behavioral change, more robust, and faster (discovery+ingest ~4.7 s vs ~8 s). Left as-is: sleeps inside fake read/open callbacks (simulated work), poll-loop bodies, the debounce-window test, and asserts that depend on stop()/shutdown rather than on the sleep. Co-Authored-By: Claude Opus 4.8 --- tests/test_discovery.py | 52 +++++++++++++++++++++++++---------------- tests/test_ingest.py | 6 ++++- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index d1ecd313..00e6b64e 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -16,6 +16,20 @@ from autoptz.engine.discovery.onvif import ONVIFDevice, ONVIFDiscovery from autoptz.engine.discovery.usb import USBDevice, USBDiscovery, enumerate_cameras + +def _wait_until(predicate, timeout: float = 3.0, interval: float = 0.01) -> bool: + """Poll ``predicate`` until true or ``timeout`` elapses; return its final value. + + Discovery runs a background poll thread, so waiting for the asserted condition + (rather than sleeping a fixed window) keeps these tests fast and immune to a + loaded CI runner stalling the thread. + """ + deadline = time.monotonic() + timeout + while not predicate() and time.monotonic() < deadline: + time.sleep(interval) + return predicate() + + # ── USBDiscovery ─────────────────────────────────────────────────────────────── @@ -41,7 +55,7 @@ def mock_probe(max_index: int = 10) -> set[int]: discovery = USBDiscovery(poll_interval=0.1) discovery.on_change(lambda ev, dev: events.append((ev, dev))) discovery.start() - time.sleep(0.3) + _wait_until(lambda: {0, 1} <= {e[1].index for e in events if e[0] == "added"}) discovery.stop() added_indices = {e[1].index for e in events if e[0] == "added"} @@ -64,7 +78,7 @@ def mock_probe(max_index: int = 10) -> set[int]: discovery = USBDiscovery(poll_interval=0.05) discovery.on_change(lambda ev, dev: events.append((ev, dev))) discovery.start() - time.sleep(0.4) + _wait_until(lambda: 2 in {e[1].index for e in events if e[0] == "added"}) discovery.stop() added = [e for e in events if e[0] == "added"] @@ -86,7 +100,7 @@ def mock_probe(max_index: int = 10) -> set[int]: discovery = USBDiscovery(poll_interval=0.05) discovery.on_change(lambda ev, dev: events.append((ev, dev))) discovery.start() - time.sleep(0.4) + _wait_until(lambda: any(e[0] == "removed" and e[1].index == 1 for e in events)) discovery.stop() removed = [e for e in events if e[0] == "removed"] @@ -99,7 +113,7 @@ def mock_probe(max_index: int = 10) -> set[int]: with patch("autoptz.engine.discovery.usb._probe_indices", mock_probe): discovery = USBDiscovery(poll_interval=0.05) discovery.start() - time.sleep(0.2) + _wait_until(lambda: {0, 1, 3} <= {d.index for d in discovery.devices}) devices = discovery.devices discovery.stop() @@ -116,7 +130,7 @@ def mock_probe(max_index: int = 10) -> set[int]: discovery = USBDiscovery(poll_interval=0.05) discovery.on_change(lambda ev, dev: events.append((ev, dev))) discovery.start() - time.sleep(0.3) + _wait_until(lambda: any(e[0] == "added" and e[1].index == 0 for e in events)) discovery.stop() # Only the initial "added" event for device 0, nothing else @@ -135,7 +149,7 @@ def mock_probe(max_index: int = 10) -> set[int]: discovery.on_change(lambda ev, _: events1.append(ev)) discovery.on_change(lambda ev, _: events2.append(ev)) discovery.start() - time.sleep(0.2) + _wait_until(lambda: events1 and events2) discovery.stop() assert len(events1) > 0 @@ -314,7 +328,7 @@ def test_new_source_fires_added_event(self) -> None: discovery = NDIDiscovery(poll_interval=0.05) discovery.on_change(lambda ev, src: events.append((ev, src))) discovery.start() - time.sleep(0.5) + _wait_until(lambda: any(e[0] == "added" and e[1].name == "LAPTOP (TEST)" for e in events)) discovery.stop() added = [e for e in events if e[0] == "added"] @@ -334,7 +348,7 @@ def test_removed_source_fires_removed_event(self) -> None: discovery = NDIDiscovery(poll_interval=0.05) discovery.on_change(lambda ev, src: events.append((ev, src))) discovery.start() - time.sleep(0.5) + _wait_until(lambda: any(e[0] == "removed" and e[1].name == "NDI_CAM_1" for e in events)) discovery.stop() removed = [e for e in events if e[0] == "removed"] @@ -350,7 +364,7 @@ def test_sources_property(self) -> None: discovery = NDIDiscovery(poll_interval=0.05) discovery.start() - time.sleep(0.3) + _wait_until(lambda: {"SOURCE_A", "SOURCE_B"} <= {s.name for s in discovery.sources}) sources = discovery.sources discovery.stop() @@ -363,9 +377,8 @@ def test_cyndilib_unavailable_does_not_raise(self) -> None: _remove_mock_cyndilib_for_discovery() # ensure cyndilib not in sys.modules discovery = NDIDiscovery(poll_interval=0.05) - # Should NOT raise even without cyndilib + # Should NOT raise even without cyndilib (no poll thread starts). discovery.start() - time.sleep(0.1) discovery.stop() assert discovery.sources == [] @@ -383,10 +396,10 @@ def test_no_events_when_sources_stable(self) -> None: discovery = NDIDiscovery(poll_interval=0.05) discovery.on_change(lambda ev, src: events.append((ev, src))) discovery.start() - time.sleep(0.3) + _wait_until(lambda: any(e[0] == "added" for e in events)) discovery.stop() - # "STABLE" should appear as added exactly once + # "STABLE" should appear as added exactly once (dedup holds across polls). added = [e for e in events if e[0] == "added"] removed = [e for e in events if e[0] == "removed"] assert len(added) == 1 @@ -449,7 +462,7 @@ def test_discovered_device_fires_added(self) -> None: discovery = ONVIFDiscovery(rescan_interval=60.0) discovery.on_change(lambda ev, dev: events.append((ev, dev))) discovery.start() - time.sleep(0.5) + _wait_until(lambda: any(e[0] == "added" for e in events)) discovery.stop() added = [e for e in events if e[0] == "added"] @@ -472,7 +485,7 @@ def test_device_removed_after_miss_threshold(self) -> None: discovery = ONVIFDiscovery(rescan_interval=0.05) discovery.on_change(lambda ev, dev: events.append((ev, dev))) discovery.start() - time.sleep(0.6) + _wait_until(lambda: any(e[0] == "removed" for e in events)) discovery.stop() added = [e for e in events if e[0] == "added"] @@ -495,10 +508,10 @@ def test_stable_device_not_re_reported(self) -> None: discovery = ONVIFDiscovery(rescan_interval=0.05) discovery.on_change(lambda ev, dev: events.append((ev, dev))) discovery.start() - time.sleep(0.4) + _wait_until(lambda: any(e[0] == "added" for e in events)) discovery.stop() - # Should only fire "added" once, not on every rescan + # Should only fire "added" once, not on every rescan (dedup holds). added = [e for e in events if e[0] == "added"] assert len(added) == 1 @@ -507,7 +520,6 @@ def test_wsdiscovery_unavailable_does_not_raise(self) -> None: discovery = ONVIFDiscovery(rescan_interval=0.05) discovery.start() - time.sleep(0.1) discovery.stop() assert discovery.devices == [] @@ -518,7 +530,7 @@ def test_devices_property(self) -> None: discovery = ONVIFDiscovery(rescan_interval=0.05) discovery.start() - time.sleep(0.3) + _wait_until(lambda: len(discovery.devices) >= 1) devices = discovery.devices discovery.stop() @@ -535,7 +547,7 @@ def test_multiple_callbacks_all_called(self) -> None: discovery.on_change(lambda ev, _: results1.append(ev)) discovery.on_change(lambda ev, _: results2.append(ev)) discovery.start() - time.sleep(0.5) + _wait_until(lambda: results1 and results2) discovery.stop() assert len(results1) > 0 diff --git a/tests/test_ingest.py b/tests/test_ingest.py index 6ae40ce4..e2f725ba 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -311,7 +311,11 @@ def fake_read(self: RTSPAdapter) -> np.ndarray | None: patch.object(RTSPAdapter, "_close", lambda self: None), ): adapter.start() - time.sleep(1.0) + # Poll for the stall-triggered reconnect (≥2 opens) instead of a fixed + # 1 s sleep, which flakes when a loaded CI runner is slow to reconnect. + deadline = time.monotonic() + 3.0 + while open_count < 2 and time.monotonic() < deadline: + time.sleep(0.01) adapter.stop() assert open_count >= 2, f"Expected ≥ 2 opens after stall, got {open_count}"