python-detector-worker/tests/unit/streams/test_stream_manager.py
2025-09-12 18:55:23 +07:00

818 lines
No EOL
30 KiB
Python

"""
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])