"""gui.qt_workers: the shared queue/poll worker base.""" import threading import time import pytest from PyQt6.QtCore import QThread from PyQt6.QtWidgets import QApplication from gui.qt_workers import PollingQueueWorker, QueueWorker @pytest.fixture(scope="module") def qapp(): yield QApplication.instance() or QApplication([]) class _Recorder(QueueWorker): def __init__(self): super().__init__() self.seen = [] self.stopped = threading.Event() self._handlers.update({ "note": self._note, "boom": self._boom, }) def _note(self, value): self.seen.append(value) def _boom(self): raise RuntimeError("handler failed") def _on_stop(self): self.stopped.set() def _run_until(worker, predicate, timeout=5.0): """Run the worker loop on a plain thread until predicate() is true. Pumps the Qt event loop while waiting: signals emitted from the worker thread are delivered as queued events on this (main) thread. """ app = QApplication.instance() t = threading.Thread(target=worker.run, daemon=True) t.start() deadline = time.monotonic() + timeout while not predicate() and time.monotonic() < deadline: app.processEvents() time.sleep(0.01) app.processEvents() return t def test_commands_dispatch_in_order(qapp): w = _Recorder() for i in range(5): w._enqueue("note", value=i) t = _run_until(w, lambda: len(w.seen) == 5) w.stop_worker() t.join(timeout=5) assert w.seen == [0, 1, 2, 3, 4] assert w.stopped.is_set() def test_handler_exception_is_reported_not_fatal(qapp): w = _Recorder() errors = [] w.error_occurred.connect(errors.append) w._enqueue("boom") w._enqueue("note", value="after") t = _run_until(w, lambda: w.seen == ["after"]) w.stop_worker() t.join(timeout=5) assert w.seen == ["after"], "loop died on a failing handler" assert errors and "handler failed" in errors[0] def test_unknown_command_reported(qapp): w = _Recorder() errors = [] w.error_occurred.connect(errors.append) w._enqueue("nope") t = _run_until(w, lambda: bool(errors)) w.stop_worker() t.join(timeout=5) assert errors and "Unknown command" in errors[0] def test_idle_worker_does_not_spin(qapp): """The loop must block on the queue, not poll it on a timeout.""" w = _Recorder() t = threading.Thread(target=w.run, daemon=True) t.start() time.sleep(0.3) # idle cpu_before = time.process_time() time.sleep(0.5) # still idle cpu_used = time.process_time() - cpu_before w.stop_worker() t.join(timeout=5) # A 10–20 Hz timeout-poll loop burns measurable CPU here; blocking uses ~0. assert cpu_used < 0.05, f"idle worker used {cpu_used:.3f}s CPU" class _Poller(PollingQueueWorker): def __init__(self): super().__init__(poll_interval_s=0.02) self.polls = 0 self.in_flight = 0 self.overlaps = 0 self.is_connected = True def _poll_once(self): self.in_flight += 1 if self.in_flight > 1: self.overlaps += 1 time.sleep(0.05) # deliberately slower than the poll interval self.polls += 1 self.in_flight -= 1 def test_polling_never_overlaps_or_backs_up(qapp): """A device slower than the interval must not accumulate stale polls.""" w = _Poller() t = threading.Thread(target=w.run, daemon=True) t.start() w.start_polling() time.sleep(0.6) w.stop_polling() time.sleep(0.15) queued = w._cmd_q.qsize() w.stop_worker() t.join(timeout=5) assert w.polls >= 3, "polling did not run" assert w.overlaps == 0, "polls overlapped" # Self-rescheduling means at most one poll is ever pending. assert queued <= 1, f"{queued} stale polls queued up" class _YieldingPoller(PollingQueueWorker): """A poll made of several reads that gives up as soon as work arrives.""" def __init__(self): super().__init__(poll_interval_s=0.02) self.reads = 0 self.handled = [] self.is_connected = True self._handlers["click"] = self._click def _click(self, value): self.handled.append(value) def _poll_once(self): for _ in range(6): if self._work_pending(): return time.sleep(0.02) self.reads += 1 def test_a_queued_command_interrupts_a_poll(qapp): """A button pressed mid-poll should not wait out the whole sweep. The Helios sweep is eight serial queries; before this, a command queued behind one waited for every last read to finish. """ w = _YieldingPoller() t = threading.Thread(target=w.run, daemon=True) t.start() w.start_polling() time.sleep(0.03) # a poll is now in progress pressed = time.monotonic() w._enqueue("click", value="set current") deadline = pressed + 2.0 while not w.handled and time.monotonic() < deadline: time.sleep(0.002) waited = time.monotonic() - pressed w.stop_polling() w.stop_worker() t.join(timeout=5) assert w.handled == ["set current"] # A full sweep is 6 x 20 ms; the command must not have waited for it. assert waited < 0.08, f"command waited {waited * 1000:.0f} ms for the poll" def test_stop_polling_halts_the_cycle(qapp): w = _Poller() t = threading.Thread(target=w.run, daemon=True) t.start() w.start_polling() time.sleep(0.2) w.stop_polling() time.sleep(0.2) settled = w.polls time.sleep(0.2) w.stop_worker() t.join(timeout=5) assert w.polls == settled, "polling continued after stop_polling()" def test_worker_runs_on_its_qthread(qapp): """Sanity check the intended usage: run() executes on the QThread.""" w = _Recorder() thread = QThread() w.moveToThread(thread) thread.started.connect(w.run) ids = [] w._handlers["note"] = lambda value: ids.append(threading.get_ident()) thread.start() w._enqueue("note", value=None) deadline = time.monotonic() + 5 while not ids and time.monotonic() < deadline: qapp.processEvents() time.sleep(0.01) w.stop_worker() thread.quit() assert thread.wait(5000) assert ids and ids[0] != threading.get_ident()