""" Unit tests for stream management functionality. """ import pytest import asyncio import threading import time from unittest.mock import Mock, AsyncMock, patch, MagicMock import numpy as np import cv2 from detector_worker.streams.stream_manager import ( StreamManager, StreamInfo, StreamConfig, StreamReader, StreamError, ConnectionError as StreamConnectionError ) from detector_worker.streams.frame_reader import FrameReader from detector_worker.core.exceptions import ConfigurationError class TestStreamConfig: """Test stream configuration.""" def test_creation_rtsp(self): """Test creating RTSP stream config.""" config = StreamConfig( stream_url="rtsp://example.com/stream1", stream_type="rtsp", target_fps=15, reconnect_interval=5.0, max_retries=3 ) assert config.stream_url == "rtsp://example.com/stream1" assert config.stream_type == "rtsp" assert config.target_fps == 15 assert config.reconnect_interval == 5.0 assert config.max_retries == 3 def test_creation_http_snapshot(self): """Test creating HTTP snapshot config.""" config = StreamConfig( stream_url="http://example.com/snapshot.jpg", stream_type="http_snapshot", snapshot_interval=1.0, timeout=10.0 ) assert config.stream_url == "http://example.com/snapshot.jpg" assert config.stream_type == "http_snapshot" assert config.snapshot_interval == 1.0 assert config.timeout == 10.0 def test_from_dict(self): """Test creating config from dictionary.""" config_dict = { "stream_url": "rtsp://camera.example.com/live", "stream_type": "rtsp", "target_fps": 20, "reconnect_interval": 3.0, "max_retries": 5, "crop_region": [100, 200, 300, 400], "unknown_field": "ignored" } config = StreamConfig.from_dict(config_dict) assert config.stream_url == "rtsp://camera.example.com/live" assert config.target_fps == 20 assert config.crop_region == [100, 200, 300, 400] def test_validation(self): """Test config validation.""" # Valid config valid_config = StreamConfig( stream_url="rtsp://example.com/stream", stream_type="rtsp" ) assert valid_config.is_valid() is True # Invalid config (empty URL) invalid_config = StreamConfig( stream_url="", stream_type="rtsp" ) assert invalid_config.is_valid() is False class TestStreamInfo: """Test stream information.""" def test_creation(self): """Test stream info creation.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") info = StreamInfo( stream_id="stream_001", config=config, camera_id="camera_001" ) assert info.stream_id == "stream_001" assert info.config == config assert info.camera_id == "camera_001" assert info.status == "inactive" assert info.reference_count == 0 assert info.created_at <= time.time() def test_increment_reference(self): """Test incrementing reference count.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") info = StreamInfo("stream_001", config, "camera_001") assert info.reference_count == 0 info.increment_reference() assert info.reference_count == 1 info.increment_reference() assert info.reference_count == 2 def test_decrement_reference(self): """Test decrementing reference count.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") info = StreamInfo("stream_001", config, "camera_001") info.reference_count = 3 assert info.decrement_reference() == 2 assert info.reference_count == 2 assert info.decrement_reference() == 1 assert info.decrement_reference() == 0 # Should not go below 0 assert info.decrement_reference() == 0 def test_update_status(self): """Test updating stream status.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") info = StreamInfo("stream_001", config, "camera_001") info.update_status("connecting") assert info.status == "connecting" assert info.last_update <= time.time() info.update_status("active", frame_count=100) assert info.status == "active" assert info.frame_count == 100 def test_get_stats(self): """Test getting stream statistics.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") info = StreamInfo("stream_001", config, "camera_001") info.frame_count = 1000 info.error_count = 5 info.reference_count = 2 stats = info.get_stats() assert stats["stream_id"] == "stream_001" assert stats["status"] == "inactive" assert stats["frame_count"] == 1000 assert stats["error_count"] == 5 assert stats["reference_count"] == 2 assert "uptime" in stats class TestStreamReader: """Test stream reader functionality.""" def test_creation(self): """Test stream reader creation.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") reader = StreamReader("stream_001", config) assert reader.stream_id == "stream_001" assert reader.config == config assert reader.is_running is False assert reader.latest_frame is None assert reader.frame_queue.qsize() == 0 @pytest.mark.asyncio async def test_start_rtsp_stream(self): """Test starting RTSP stream.""" config = StreamConfig("rtsp://example.com/stream", "rtsp", target_fps=10) reader = StreamReader("stream_001", config) # Mock cv2.VideoCapture with patch('cv2.VideoCapture') as mock_cap: mock_cap_instance = Mock() mock_cap.return_value = mock_cap_instance mock_cap_instance.isOpened.return_value = True mock_cap_instance.read.return_value = (True, np.zeros((480, 640, 3), dtype=np.uint8)) await reader.start() assert reader.is_running is True assert reader.capture is not None mock_cap.assert_called_once_with("rtsp://example.com/stream") @pytest.mark.asyncio async def test_start_rtsp_connection_failure(self): """Test RTSP connection failure.""" config = StreamConfig("rtsp://invalid.com/stream", "rtsp") reader = StreamReader("stream_001", config) with patch('cv2.VideoCapture') as mock_cap: mock_cap_instance = Mock() mock_cap.return_value = mock_cap_instance mock_cap_instance.isOpened.return_value = False with pytest.raises(StreamConnectionError): await reader.start() @pytest.mark.asyncio async def test_start_http_snapshot(self): """Test starting HTTP snapshot stream.""" config = StreamConfig("http://example.com/snapshot.jpg", "http_snapshot", snapshot_interval=1.0) reader = StreamReader("stream_001", config) with patch('requests.get') as mock_get: mock_response = Mock() mock_response.status_code = 200 mock_response.content = b"fake_image_data" mock_get.return_value = mock_response with patch('cv2.imdecode') as mock_decode: mock_decode.return_value = np.zeros((480, 640, 3), dtype=np.uint8) await reader.start() assert reader.is_running is True mock_get.assert_called_once() @pytest.mark.asyncio async def test_stop_stream(self): """Test stopping stream.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") reader = StreamReader("stream_001", config) # Simulate running state reader.is_running = True reader.capture = Mock() reader.capture.release = Mock() reader._reader_task = Mock() reader._reader_task.cancel = Mock() await reader.stop() assert reader.is_running is False reader.capture.release.assert_called_once() reader._reader_task.cancel.assert_called_once() def test_get_latest_frame(self): """Test getting latest frame.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") reader = StreamReader("stream_001", config) test_frame = np.ones((480, 640, 3), dtype=np.uint8) * 128 reader.latest_frame = test_frame frame = reader.get_latest_frame() assert np.array_equal(frame, test_frame) def test_get_frame_from_queue(self): """Test getting frame from queue.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") reader = StreamReader("stream_001", config) test_frame = np.ones((480, 640, 3), dtype=np.uint8) * 128 reader.frame_queue.put(test_frame) frame = reader.get_frame(timeout=0.1) assert np.array_equal(frame, test_frame) def test_get_frame_timeout(self): """Test getting frame with timeout.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") reader = StreamReader("stream_001", config) # Queue is empty, should timeout frame = reader.get_frame(timeout=0.1) assert frame is None def test_get_stats(self): """Test getting reader statistics.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") reader = StreamReader("stream_001", config) reader.frame_count = 500 reader.error_count = 2 stats = reader.get_stats() assert stats["stream_id"] == "stream_001" assert stats["frame_count"] == 500 assert stats["error_count"] == 2 assert stats["is_running"] is False class TestStreamManager: """Test stream manager functionality.""" def test_initialization(self): """Test stream manager initialization.""" manager = StreamManager() assert len(manager.streams) == 0 assert len(manager.readers) == 0 assert manager.max_streams == 10 assert manager.default_timeout == 30.0 def test_initialization_with_config(self): """Test initialization with custom configuration.""" config = { "max_streams": 20, "default_timeout": 60.0, "frame_buffer_size": 5 } manager = StreamManager(config) assert manager.max_streams == 20 assert manager.default_timeout == 60.0 assert manager.frame_buffer_size == 5 @pytest.mark.asyncio async def test_create_stream_new(self): """Test creating new stream.""" manager = StreamManager() config = StreamConfig("rtsp://example.com/stream", "rtsp") with patch.object(StreamReader, 'start', new_callable=AsyncMock): stream_info = await manager.create_stream("camera_001", config, "sub_001") assert "camera_001" in manager.streams assert manager.streams["camera_001"].reference_count == 1 assert manager.streams["camera_001"].camera_id == "camera_001" @pytest.mark.asyncio async def test_create_stream_shared(self): """Test creating shared stream (same URL).""" manager = StreamManager() config = StreamConfig("rtsp://example.com/stream", "rtsp") with patch.object(StreamReader, 'start', new_callable=AsyncMock): # Create first stream stream_info1 = await manager.create_stream("camera_001", config, "sub_001") # Create second stream with same URL stream_info2 = await manager.create_stream("camera_001", config, "sub_002") assert stream_info1 == stream_info2 # Should be same stream assert manager.streams["camera_001"].reference_count == 2 @pytest.mark.asyncio async def test_create_stream_max_limit(self): """Test creating stream when at max limit.""" manager = StreamManager({"max_streams": 1}) config1 = StreamConfig("rtsp://example.com/stream1", "rtsp") config2 = StreamConfig("rtsp://example.com/stream2", "rtsp") with patch.object(StreamReader, 'start', new_callable=AsyncMock): # Create first stream (should succeed) await manager.create_stream("camera_001", config1, "sub_001") # Try to create second stream (should fail) with pytest.raises(StreamError) as exc_info: await manager.create_stream("camera_002", config2, "sub_002") assert "maximum number of streams" in str(exc_info.value).lower() @pytest.mark.asyncio async def test_remove_stream_single_reference(self): """Test removing stream with single reference.""" manager = StreamManager() config = StreamConfig("rtsp://example.com/stream", "rtsp") with patch.object(StreamReader, 'start', new_callable=AsyncMock): with patch.object(StreamReader, 'stop', new_callable=AsyncMock): # Create stream await manager.create_stream("camera_001", config, "sub_001") # Remove stream removed = await manager.remove_stream("camera_001", "sub_001") assert removed is True assert "camera_001" not in manager.streams @pytest.mark.asyncio async def test_remove_stream_multiple_references(self): """Test removing stream with multiple references.""" manager = StreamManager() config = StreamConfig("rtsp://example.com/stream", "rtsp") with patch.object(StreamReader, 'start', new_callable=AsyncMock): # Create shared stream await manager.create_stream("camera_001", config, "sub_001") await manager.create_stream("camera_001", config, "sub_002") assert manager.streams["camera_001"].reference_count == 2 # Remove one reference removed = await manager.remove_stream("camera_001", "sub_001") assert removed is True assert "camera_001" in manager.streams # Still exists assert manager.streams["camera_001"].reference_count == 1 def test_get_stream_info(self): """Test getting stream information.""" manager = StreamManager() config = StreamConfig("rtsp://example.com/stream", "rtsp") stream_info = StreamInfo("camera_001", config, "camera_001") manager.streams["camera_001"] = stream_info retrieved_info = manager.get_stream_info("camera_001") assert retrieved_info == stream_info def test_get_nonexistent_stream_info(self): """Test getting info for non-existent stream.""" manager = StreamManager() info = manager.get_stream_info("nonexistent_camera") assert info is None def test_get_latest_frame(self): """Test getting latest frame from stream.""" manager = StreamManager() # Create mock reader mock_reader = Mock() test_frame = np.ones((480, 640, 3), dtype=np.uint8) * 128 mock_reader.get_latest_frame.return_value = test_frame manager.readers["camera_001"] = mock_reader frame = manager.get_latest_frame("camera_001") assert np.array_equal(frame, test_frame) mock_reader.get_latest_frame.assert_called_once() def test_get_frame_from_nonexistent_stream(self): """Test getting frame from non-existent stream.""" manager = StreamManager() frame = manager.get_latest_frame("nonexistent_camera") assert frame is None def test_list_active_streams(self): """Test listing active streams.""" manager = StreamManager() # Add streams config1 = StreamConfig("rtsp://example.com/stream1", "rtsp") config2 = StreamConfig("rtsp://example.com/stream2", "rtsp") stream1 = StreamInfo("camera_001", config1, "camera_001") stream1.update_status("active") stream2 = StreamInfo("camera_002", config2, "camera_002") stream2.update_status("inactive") manager.streams["camera_001"] = stream1 manager.streams["camera_002"] = stream2 active_streams = manager.list_active_streams() assert len(active_streams) == 1 assert active_streams[0]["camera_id"] == "camera_001" assert active_streams[0]["status"] == "active" @pytest.mark.asyncio async def test_stop_all_streams(self): """Test stopping all streams.""" manager = StreamManager() # Add mock streams mock_reader1 = Mock() mock_reader1.stop = AsyncMock() mock_reader2 = Mock() mock_reader2.stop = AsyncMock() manager.readers["camera_001"] = mock_reader1 manager.readers["camera_002"] = mock_reader2 stopped_count = await manager.stop_all_streams() assert stopped_count == 2 mock_reader1.stop.assert_called_once() mock_reader2.stop.assert_called_once() assert len(manager.readers) == 0 assert len(manager.streams) == 0 def test_get_stream_statistics(self): """Test getting stream statistics.""" manager = StreamManager() # Add streams config = StreamConfig("rtsp://example.com/stream", "rtsp") stream1 = StreamInfo("camera_001", config, "camera_001") stream1.update_status("active") stream1.frame_count = 1000 stream1.reference_count = 2 stream2 = StreamInfo("camera_002", config, "camera_002") stream2.update_status("error") stream2.error_count = 5 manager.streams["camera_001"] = stream1 manager.streams["camera_002"] = stream2 stats = manager.get_stream_statistics() assert stats["total_streams"] == 2 assert stats["active_streams"] == 1 assert stats["error_streams"] == 1 assert stats["total_references"] == 2 assert "status_breakdown" in stats @pytest.mark.asyncio async def test_reconnect_stream(self): """Test reconnecting failed stream.""" manager = StreamManager() config = StreamConfig("rtsp://example.com/stream", "rtsp") stream_info = StreamInfo("camera_001", config, "camera_001") stream_info.update_status("error") manager.streams["camera_001"] = stream_info # Mock reader mock_reader = Mock() mock_reader.start = AsyncMock() mock_reader.stop = AsyncMock() manager.readers["camera_001"] = mock_reader result = await manager.reconnect_stream("camera_001") assert result is True mock_reader.stop.assert_called_once() mock_reader.start.assert_called_once() assert stream_info.status != "error" @pytest.mark.asyncio async def test_health_check_streams(self): """Test health check of all streams.""" manager = StreamManager() # Add streams with different states config = StreamConfig("rtsp://example.com/stream", "rtsp") stream1 = StreamInfo("camera_001", config, "camera_001") stream1.update_status("active") stream2 = StreamInfo("camera_002", config, "camera_002") stream2.update_status("error") manager.streams["camera_001"] = stream1 manager.streams["camera_002"] = stream2 # Mock readers mock_reader1 = Mock() mock_reader1.is_running = True mock_reader2 = Mock() mock_reader2.is_running = False manager.readers["camera_001"] = mock_reader1 manager.readers["camera_002"] = mock_reader2 health_report = await manager.health_check() assert health_report["total_streams"] == 2 assert health_report["healthy_streams"] == 1 assert health_report["unhealthy_streams"] == 1 assert len(health_report["unhealthy_stream_ids"]) == 1 class TestStreamManagerIntegration: """Integration tests for stream manager.""" @pytest.mark.asyncio async def test_multiple_subscribers_same_stream(self): """Test multiple subscribers to same stream.""" manager = StreamManager() config = StreamConfig("rtsp://example.com/shared_stream", "rtsp") with patch.object(StreamReader, 'start', new_callable=AsyncMock): # Multiple subscribers to same stream stream1 = await manager.create_stream("camera_001", config, "sub_001") stream2 = await manager.create_stream("camera_001", config, "sub_002") stream3 = await manager.create_stream("camera_001", config, "sub_003") # All should reference same stream assert stream1 == stream2 == stream3 assert manager.streams["camera_001"].reference_count == 3 assert len(manager.readers) == 1 # Only one actual reader # Remove subscribers one by one with patch.object(StreamReader, 'stop', new_callable=AsyncMock) as mock_stop: await manager.remove_stream("camera_001", "sub_001") # ref_count = 2 await manager.remove_stream("camera_001", "sub_002") # ref_count = 1 # Stream should still exist assert "camera_001" in manager.streams mock_stop.assert_not_called() await manager.remove_stream("camera_001", "sub_003") # ref_count = 0 # Now stream should be stopped and removed assert "camera_001" not in manager.streams mock_stop.assert_called_once() @pytest.mark.asyncio async def test_stream_failure_and_recovery(self): """Test stream failure and recovery workflow.""" manager = StreamManager() config = StreamConfig("rtsp://unreliable.com/stream", "rtsp", max_retries=2) # Mock reader that fails initially then succeeds with patch.object(StreamReader, 'start', new_callable=AsyncMock) as mock_start: mock_start.side_effect = [ StreamConnectionError("Connection failed"), # First attempt fails None # Second attempt succeeds ] # First attempt should fail with pytest.raises(StreamConnectionError): await manager.create_stream("camera_001", config, "sub_001") # Retry should succeed stream_info = await manager.create_stream("camera_001", config, "sub_001") assert stream_info is not None assert mock_start.call_count == 2 @pytest.mark.asyncio async def test_concurrent_stream_operations(self): """Test concurrent stream operations.""" manager = StreamManager() configs = [ StreamConfig(f"rtsp://example.com/stream{i}", "rtsp") for i in range(5) ] with patch.object(StreamReader, 'start', new_callable=AsyncMock): with patch.object(StreamReader, 'stop', new_callable=AsyncMock): # Create streams concurrently create_tasks = [ manager.create_stream(f"camera_{i}", configs[i], f"sub_{i}") for i in range(5) ] results = await asyncio.gather(*create_tasks) assert len(results) == 5 assert len(manager.streams) == 5 # Remove streams concurrently remove_tasks = [ manager.remove_stream(f"camera_{i}", f"sub_{i}") for i in range(5) ] remove_results = await asyncio.gather(*remove_tasks) assert all(remove_results) assert len(manager.streams) == 0 @pytest.mark.asyncio async def test_memory_management_large_scale(self): """Test memory management with many streams.""" manager = StreamManager({"max_streams": 50}) # Create many streams with patch.object(StreamReader, 'start', new_callable=AsyncMock): for i in range(30): config = StreamConfig(f"rtsp://example.com/stream{i}", "rtsp") await manager.create_stream(f"camera_{i}", config, f"sub_{i}") # Verify memory usage is reasonable stats = manager.get_stream_statistics() assert stats["total_streams"] == 30 assert stats["active_streams"] <= 30 # Test bulk cleanup with patch.object(StreamReader, 'stop', new_callable=AsyncMock): stopped_count = await manager.stop_all_streams() assert stopped_count == 30 assert len(manager.streams) == 0 assert len(manager.readers) == 0 class TestFrameReaderIntegration: """Integration tests for frame reader.""" @pytest.mark.asyncio async def test_rtsp_frame_processing(self): """Test RTSP frame processing pipeline.""" config = StreamConfig( stream_url="rtsp://example.com/stream", stream_type="rtsp", target_fps=10, crop_region=[100, 100, 400, 300] ) reader = StreamReader("test_stream", config) # Mock cv2.VideoCapture with patch('cv2.VideoCapture') as mock_cap: mock_cap_instance = Mock() mock_cap.return_value = mock_cap_instance mock_cap_instance.isOpened.return_value = True # Mock frame sequence test_frame = np.ones((480, 640, 3), dtype=np.uint8) * 128 mock_cap_instance.read.side_effect = [ (True, test_frame), # First frame (True, test_frame * 0.8), # Second frame (False, None), # Connection lost (True, test_frame * 1.2), # Reconnected ] await reader.start() # Let reader process some frames await asyncio.sleep(0.1) # Verify frame processing latest_frame = reader.get_latest_frame() assert latest_frame is not None assert latest_frame.shape == (480, 640, 3) await reader.stop() @pytest.mark.asyncio async def test_http_snapshot_processing(self): """Test HTTP snapshot processing.""" config = StreamConfig( stream_url="http://camera.example.com/snapshot.jpg", stream_type="http_snapshot", snapshot_interval=0.5, timeout=5.0 ) reader = StreamReader("snapshot_stream", config) with patch('requests.get') as mock_get: # Mock HTTP responses mock_response = Mock() mock_response.status_code = 200 mock_response.content = b"fake_jpeg_data" mock_get.return_value = mock_response with patch('cv2.imdecode') as mock_decode: test_frame = np.ones((480, 640, 3), dtype=np.uint8) * 200 mock_decode.return_value = test_frame await reader.start() # Wait for snapshot capture await asyncio.sleep(0.6) # Verify snapshot processing latest_frame = reader.get_latest_frame() assert latest_frame is not None assert np.array_equal(latest_frame, test_frame) await reader.stop() def test_frame_queue_management(self): """Test frame queue management and buffering.""" config = StreamConfig("rtsp://example.com/stream", "rtsp") reader = StreamReader("queue_test", config, frame_buffer_size=3) # Add frames to queue frames = [ np.ones((100, 100, 3), dtype=np.uint8) * i for i in range(50, 250, 50) # 4 different frames ] for frame in frames[:3]: # Fill buffer reader._add_frame_to_queue(frame) assert reader.frame_queue.qsize() == 3 # Add one more (should drop oldest) reader._add_frame_to_queue(frames[3]) assert reader.frame_queue.qsize() == 3 # Verify frame order (oldest should be dropped) retrieved_frames = [] while not reader.frame_queue.empty(): retrieved_frames.append(reader.get_frame(timeout=0.1)) assert len(retrieved_frames) == 3 # First frame should have been dropped, so we should have frames 1,2,3 assert not np.array_equal(retrieved_frames[0], frames[0])