"""Tests for InferenceManager — drop-if-busy, restart counter, model validation.""" from __future__ import annotations import sys from unittest.mock import MagicMock, patch import pytest from PySide6.QtWidgets import QApplication from app.inference.worker_manager import InferenceManager # Ensure a QApplication exists for tests that create Qt objects _app = QApplication.instance() or QApplication(sys.argv) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_manager() -> InferenceManager: """Return an InferenceManager without starting any process.""" mgr = InferenceManager.__new__(InferenceManager) mgr._model_path = None mgr._process = None mgr._input_queue = None mgr._output_queue = None mgr._stop_event = None mgr._busy = False mgr._frame_id = 0 mgr._restart_count = 0 mgr._last_result_time = 0.0 mgr._paused = False return mgr # --------------------------------------------------------------------------- # Model path validation # --------------------------------------------------------------------------- class TestModelPathValidation: def test_start_emits_error_for_missing_file(self, tmp_path) -> None: """start() with non-existent path must NOT spawn a process.""" mgr = InferenceManager() errors: list[str] = [] mgr.inference_error.connect(errors.append) mgr.start(str(tmp_path / "nonexistent.pt")) assert errors, "Expected inference_error signal" assert mgr._process is None def test_start_does_not_raise_for_existing_file(self, tmp_path) -> None: """start() with existing file should attempt to start (we mock _start_worker).""" model_file = tmp_path / "model.pt" model_file.write_bytes(b"fake") mgr = InferenceManager() with patch.object(mgr, "_start_worker") as mock_start: mgr.start(str(model_file)) mock_start.assert_called_once() # --------------------------------------------------------------------------- # Drop-if-busy logic # --------------------------------------------------------------------------- class TestDropIfBusy: def test_submit_frame_drops_when_busy(self) -> None: """submit_frame must not enqueue when _busy is True.""" mgr = _make_manager() mgr._busy = True mgr._process = MagicMock() mgr._process.is_alive.return_value = True mgr._input_queue = MagicMock() frame = MagicMock() frame.isValid.return_value = True mgr.submit_frame(frame) mgr._input_queue.put_nowait.assert_not_called() def test_submit_frame_drops_when_paused(self) -> None: mgr = _make_manager() mgr._paused = True mgr._process = MagicMock() mgr._process.is_alive.return_value = True mgr._input_queue = MagicMock() frame = MagicMock() frame.isValid.return_value = True mgr.submit_frame(frame) mgr._input_queue.put_nowait.assert_not_called() def test_submit_frame_drops_when_not_running(self) -> None: mgr = _make_manager() mgr._process = None mgr._input_queue = MagicMock() frame = MagicMock() frame.isValid.return_value = True mgr.submit_frame(frame) mgr._input_queue.put_nowait.assert_not_called() def test_submit_frame_drops_invalid_frame(self) -> None: mgr = _make_manager() mgr._process = MagicMock() mgr._process.is_alive.return_value = True mgr._input_queue = MagicMock() frame = MagicMock() frame.isValid.return_value = False mgr.submit_frame(frame) mgr._input_queue.put_nowait.assert_not_called() # --------------------------------------------------------------------------- # Pause / resume # --------------------------------------------------------------------------- class TestPauseResume: def test_pause_sets_flag(self) -> None: mgr = _make_manager() assert mgr._paused is False mgr.pause() assert mgr._paused is True def test_resume_clears_flag(self) -> None: mgr = _make_manager() mgr.pause() mgr.resume() assert mgr._paused is False def test_is_paused_property(self) -> None: mgr = _make_manager() assert mgr.is_paused is False mgr.pause() assert mgr.is_paused is True # --------------------------------------------------------------------------- # Restart counter # --------------------------------------------------------------------------- class TestRestartCounter: def test_handle_crash_increments_counter(self) -> None: mgr = InferenceManager() mgr._model_path = "fake.pt" mgr._restart_count = 0 with ( patch.object(mgr, "_start_worker"), patch.object(mgr._poll_timer, "stop"), patch.object(mgr._watchdog_timer, "stop"), ): mgr._handle_crash("test crash") assert mgr._restart_count == 1 def test_handle_crash_emits_error_after_max_restarts(self) -> None: from app.config import INFERENCE_MAX_RESTARTS mgr = InferenceManager() mgr._model_path = "fake.pt" mgr._restart_count = INFERENCE_MAX_RESTARTS errors: list[str] = [] mgr.inference_error.connect(errors.append) with ( patch.object(mgr, "_start_worker") as mock_start, patch.object(mgr._poll_timer, "stop"), patch.object(mgr._watchdog_timer, "stop"), ): mgr._handle_crash("final crash") assert errors, "Expected inference_error signal after max restarts" mock_start.assert_not_called() def test_stop_resets_restart_count(self) -> None: mgr = InferenceManager() mgr._restart_count = 2 with patch.object(mgr, "_stop_worker"): mgr.stop() assert mgr._restart_count == 0 # --------------------------------------------------------------------------- # is_running property # --------------------------------------------------------------------------- class TestIsRunning: def test_not_running_when_process_is_none(self) -> None: mgr = _make_manager() assert mgr.is_running is False def test_not_running_when_process_dead(self) -> None: mgr = _make_manager() proc = MagicMock() proc.is_alive.return_value = False mgr._process = proc assert mgr.is_running is False def test_running_when_process_alive(self) -> None: mgr = _make_manager() proc = MagicMock() proc.is_alive.return_value = True mgr._process = proc assert mgr.is_running is True # --------------------------------------------------------------------------- # Worker data structures # --------------------------------------------------------------------------- class TestWorkerDataStructures: def test_frame_packet_is_immutable(self) -> None: from app.inference.worker import FramePacket pkt = FramePacket(1, b"", 640, 480, 3) with pytest.raises(AttributeError): pkt.frame_id = 2 # type: ignore[misc] def test_result_packet_is_immutable(self) -> None: from app.inference.worker import ResultPacket pkt = ResultPacket(1, [], 640, 480) with pytest.raises(AttributeError): pkt.frame_id = 2 # type: ignore[misc] def test_select_device_returns_string(self) -> None: from app.inference.worker import _select_device device = _select_device() assert isinstance(device, str) assert device in ("cpu", "mps", "cuda")