"""Tests for BboxOverlay — coordinate mapping and state management.""" from __future__ import annotations from unittest.mock import MagicMock import pytest from PySide6.QtCore import QRect, QSize from app.inference.bbox_overlay import BboxOverlay, Detection class TestDetection: def test_namedtuple_fields(self) -> None: d = Detection(x1=10.0, y1=20.0, x2=100.0, y2=200.0, conf=0.87, label="label") assert d.x1 == 10.0 assert d.label == "label" assert d.conf == pytest.approx(0.87) def test_immutable(self) -> None: d = Detection(0, 0, 1, 1, 0.5, "x") with pytest.raises(AttributeError): d.conf = 0.9 # type: ignore[misc] class TestBboxOverlayState: def setup_method(self) -> None: self.overlay = BboxOverlay() def test_initially_no_detections(self) -> None: assert self.overlay._detections == [] def test_initially_source_size_empty(self) -> None: assert self.overlay._source_size.isEmpty() def test_on_detections_stores_data(self) -> None: dets = [Detection(0, 0, 100, 100, 0.9, "label")] self.overlay.on_detections(dets, (640, 480)) assert self.overlay._detections == dets assert self.overlay._source_size == QSize(640, 480) def test_clear_removes_detections(self) -> None: self.overlay.on_detections([Detection(0, 0, 10, 10, 0.5, "x")], (100, 100)) self.overlay.clear() assert self.overlay._detections == [] def test_visible_by_default(self) -> None: assert self.overlay.visible is True def test_multiple_detections_stored(self) -> None: dets = [ Detection(0, 0, 50, 50, 0.9, "label"), Detection(100, 100, 200, 200, 0.75, "label"), ] self.overlay.on_detections(dets, (640, 480)) assert len(self.overlay._detections) == 2 def test_replace_detections_on_new_call(self) -> None: self.overlay.on_detections([Detection(0, 0, 10, 10, 0.5, "x")], (100, 100)) self.overlay.on_detections([], (100, 100)) assert self.overlay._detections == [] class TestBboxOverlayCoordinateMapping: """ Verify that BboxOverlay correctly maps source-frame pixel coordinates onto the letterboxed video_rect when painting. We don't test actual QPainter output — instead we verify that the QRect values passed to painter.drawRect() correspond to the expected scaled coordinates. """ def setup_method(self) -> None: self.overlay = BboxOverlay() def _make_painter_mock(self): painter = MagicMock() fm = MagicMock() fm.height.return_value = 14 fm.ascent.return_value = 11 fm.horizontalAdvance.return_value = 60 painter.fontMetrics.return_value = fm return painter def test_paint_skips_when_no_detections(self) -> None: painter = self._make_painter_mock() self.overlay.paint(painter, QRect(0, 0, 640, 480)) painter.drawRect.assert_not_called() def test_paint_skips_when_source_size_empty(self) -> None: # detections present but source_size not set self.overlay._detections = [Detection(0, 0, 100, 100, 0.9, "label")] painter = self._make_painter_mock() self.overlay.paint(painter, QRect(0, 0, 640, 480)) painter.drawRect.assert_not_called() def test_bbox_scaled_to_full_video_rect(self) -> None: """ Source: 640×480, covers full frame. video_rect: 640×480 at origin. Detection: full-frame box → should map 1:1. """ self.overlay.on_detections( [Detection(0.0, 0.0, 640.0, 480.0, 0.99, "label")], (640, 480), ) painter = self._make_painter_mock() video_rect = QRect(0, 0, 640, 480) self.overlay.paint(painter, video_rect) # First drawRect call = the bounding box first_call_rect: QRect = painter.drawRect.call_args_list[0][0][0] assert first_call_rect.x() == 0 assert first_call_rect.y() == 0 assert first_call_rect.width() == 640 assert first_call_rect.height() == 480 def test_bbox_scaled_with_half_size_video_rect(self) -> None: """ Source: 640×480, video_rect: 320×240 at origin (0.5× scale). Detection at (64, 48)→(128, 96) should map to (32, 24)→(64, 48). """ self.overlay.on_detections( [Detection(64.0, 48.0, 128.0, 96.0, 0.8, "label")], (640, 480), ) painter = self._make_painter_mock() video_rect = QRect(0, 0, 320, 240) self.overlay.paint(painter, video_rect) first_call_rect: QRect = painter.drawRect.call_args_list[0][0][0] assert first_call_rect.x() == 32 assert first_call_rect.y() == 24 assert first_call_rect.width() == 32 # (128-64) * 0.5 assert first_call_rect.height() == 24 # (96-48) * 0.5 def test_bbox_offset_by_video_rect_origin(self) -> None: """ video_rect at (100, 50) — letterboxed with margins. Detection at origin of source should map to (100, 50). """ self.overlay.on_detections( [Detection(0.0, 0.0, 100.0, 100.0, 0.9, "label")], (640, 480), ) painter = self._make_painter_mock() # video_rect 320×240 starting at (100, 50) video_rect = QRect(100, 50, 320, 240) self.overlay.paint(painter, video_rect) first_call_rect: QRect = painter.drawRect.call_args_list[0][0][0] # x: 100 + int(0 * 320/640) = 100 # y: 50 + int(0 * 240/480) = 50 assert first_call_rect.x() == 100 assert first_call_rect.y() == 50 class TestBboxOverlayWorkerPacket: """Test FramePacket and ResultPacket data structures.""" def test_frame_packet_fields(self) -> None: from app.inference.worker import FramePacket pkt = FramePacket( frame_id=1, raw_bytes=b"\x00" * 12, width=2, height=2, channels=3, ) assert pkt.frame_id == 1 assert pkt.width == 2 assert pkt.channels == 3 def test_result_packet_fields(self) -> None: from app.inference.worker import ResultPacket pkt = ResultPacket(frame_id=5, detections=[], width=640, height=480) assert pkt.frame_id == 5 assert pkt.detections == [] assert pkt.width == 640