Refactor: PHASE 8: Testing & Integration

This commit is contained in:
ziesorx 2025-09-12 18:55:23 +07:00
parent af34f4fd08
commit 9e8c6804a7
32 changed files with 17128 additions and 0 deletions

View file

@ -0,0 +1,818 @@
"""
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])