""" Unit tests for action execution functionality. """ import pytest import asyncio import json import base64 import numpy as np from unittest.mock import Mock, MagicMock, patch, AsyncMock from datetime import datetime, timedelta from detector_worker.pipeline.action_executor import ( ActionExecutor, ActionResult, ActionType, RedisAction, PostgreSQLAction, FileAction ) from detector_worker.detection.detection_result import DetectionResult, BoundingBox from detector_worker.core.exceptions import ActionError, RedisError, DatabaseError class TestActionResult: """Test action execution result.""" def test_creation_success(self): """Test successful action result creation.""" result = ActionResult( action_type=ActionType.REDIS_SAVE, success=True, execution_time=0.05, metadata={"key": "saved_image_key", "expiry": 600} ) assert result.action_type == ActionType.REDIS_SAVE assert result.success is True assert result.execution_time == 0.05 assert result.metadata["key"] == "saved_image_key" assert result.error is None def test_creation_failure(self): """Test failed action result creation.""" result = ActionResult( action_type=ActionType.POSTGRESQL_INSERT, success=False, error="Database connection failed", execution_time=0.02 ) assert result.action_type == ActionType.POSTGRESQL_INSERT assert result.success is False assert result.error == "Database connection failed" assert result.metadata == {} class TestRedisAction: """Test Redis action implementations.""" def test_creation(self): """Test Redis action creation.""" action_config = { "type": "redis_save_image", "region": "car", "key": "inference:{display_id}:{timestamp}:{session_id}", "expire_seconds": 600 } action = RedisAction(action_config) assert action.action_type == ActionType.REDIS_SAVE assert action.region == "car" assert action.key_template == "inference:{display_id}:{timestamp}:{session_id}" assert action.expire_seconds == 600 def test_resolve_key_template(self): """Test key template resolution.""" action_config = { "type": "redis_save_image", "region": "car", "key": "inference:{display_id}:{timestamp}:{session_id}:{filename}", "expire_seconds": 600 } action = RedisAction(action_config) context = { "display_id": "display_001", "timestamp": "1640995200000", "session_id": "session_123", "filename": "detection.jpg" } resolved_key = action.resolve_key(context) expected_key = "inference:display_001:1640995200000:session_123:detection.jpg" assert resolved_key == expected_key def test_resolve_key_missing_variable(self): """Test key resolution with missing variable.""" action_config = { "type": "redis_save_image", "region": "car", "key": "inference:{display_id}:{missing_var}", "expire_seconds": 600 } action = RedisAction(action_config) context = {"display_id": "display_001"} with pytest.raises(ActionError): action.resolve_key(context) class TestPostgreSQLAction: """Test PostgreSQL action implementations.""" def test_creation_insert(self): """Test PostgreSQL insert action creation.""" action_config = { "type": "postgresql_insert", "table": "detections", "fields": { "camera_id": "{camera_id}", "session_id": "{session_id}", "detection_class": "{class}", "confidence": "{confidence}", "bbox_x1": "{bbox.x1}", "created_at": "NOW()" } } action = PostgreSQLAction(action_config) assert action.action_type == ActionType.POSTGRESQL_INSERT assert action.table == "detections" assert len(action.fields) == 6 assert action.key_field is None def test_creation_update(self): """Test PostgreSQL update action creation.""" action_config = { "type": "postgresql_update_combined", "table": "car_info", "key_field": "session_id", "fields": { "car_brand": "{car_brand_cls.brand}", "car_body_type": "{car_bodytype_cls.body_type}", "updated_at": "NOW()" }, "waitForBranches": ["car_brand_cls", "car_bodytype_cls"] } action = PostgreSQLAction(action_config) assert action.action_type == ActionType.POSTGRESQL_UPDATE assert action.table == "car_info" assert action.key_field == "session_id" assert action.wait_for_branches == ["car_brand_cls", "car_bodytype_cls"] def test_resolve_field_values(self): """Test field value resolution.""" action_config = { "type": "postgresql_insert", "table": "detections", "fields": { "camera_id": "{camera_id}", "detection_class": "{class}", "confidence": "{confidence}", "brand": "{car_brand_cls.brand}" } } action = PostgreSQLAction(action_config) context = { "camera_id": "camera_001", "class": "car", "confidence": 0.85 } branch_results = { "car_brand_cls": {"brand": "Toyota", "confidence": 0.78} } resolved_fields = action.resolve_field_values(context, branch_results) assert resolved_fields["camera_id"] == "camera_001" assert resolved_fields["detection_class"] == "car" assert resolved_fields["confidence"] == 0.85 assert resolved_fields["brand"] == "Toyota" class TestFileAction: """Test file action implementations.""" def test_creation(self): """Test file action creation.""" action_config = { "type": "save_image", "path": "/tmp/detections/{camera_id}_{timestamp}.jpg", "region": "car", "format": "jpeg", "quality": 85 } action = FileAction(action_config) assert action.action_type == ActionType.SAVE_IMAGE assert action.path_template == "/tmp/detections/{camera_id}_{timestamp}.jpg" assert action.region == "car" assert action.format == "jpeg" assert action.quality == 85 def test_resolve_path_template(self): """Test path template resolution.""" action_config = { "type": "save_image", "path": "/tmp/detections/{camera_id}/{date}/{timestamp}.jpg" } action = FileAction(action_config) context = { "camera_id": "camera_001", "timestamp": "1640995200000", "date": "2022-01-01" } resolved_path = action.resolve_path(context) expected_path = "/tmp/detections/camera_001/2022-01-01/1640995200000.jpg" assert resolved_path == expected_path class TestActionExecutor: """Test action execution functionality.""" def test_initialization(self): """Test action executor initialization.""" executor = ActionExecutor() assert executor.redis_client is None assert executor.db_manager is None assert executor.max_concurrent_actions == 10 assert executor.action_timeout == 30.0 def test_initialization_with_clients(self, mock_redis_client, mock_database_connection): """Test initialization with client instances.""" executor = ActionExecutor( redis_client=mock_redis_client, db_manager=mock_database_connection ) assert executor.redis_client is mock_redis_client assert executor.db_manager is mock_database_connection @pytest.mark.asyncio async def test_execute_actions_empty_list(self): """Test executing empty action list.""" executor = ActionExecutor() context = { "camera_id": "camera_001", "session_id": "session_123" } results = await executor.execute_actions([], {}, context) assert results == [] @pytest.mark.asyncio async def test_execute_redis_save_action(self, mock_redis_client, mock_frame): """Test executing Redis save image action.""" executor = ActionExecutor(redis_client=mock_redis_client) actions = [ { "type": "redis_save_image", "region": "car", "key": "inference:{camera_id}:{session_id}", "expire_seconds": 600 } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.9, "detection": DetectionResult("car", 0.9, BoundingBox(100, 200, 300, 400), 1001) } } context = { "camera_id": "camera_001", "session_id": "session_123", "frame_data": mock_frame } # Mock successful Redis operations mock_redis_client.set.return_value = True mock_redis_client.expire.return_value = True results = await executor.execute_actions(actions, regions, context) assert len(results) == 1 assert results[0].success is True assert results[0].action_type == ActionType.REDIS_SAVE # Verify Redis calls mock_redis_client.set.assert_called_once() mock_redis_client.expire.assert_called_once() @pytest.mark.asyncio async def test_execute_postgresql_insert_action(self, mock_database_connection): """Test executing PostgreSQL insert action.""" # Mock database manager mock_db_manager = Mock() mock_db_manager.execute_query = AsyncMock(return_value=True) executor = ActionExecutor(db_manager=mock_db_manager) actions = [ { "type": "postgresql_insert", "table": "detections", "fields": { "camera_id": "{camera_id}", "session_id": "{session_id}", "detection_class": "{class}", "confidence": "{confidence}", "created_at": "NOW()" } } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.9, "detection": DetectionResult("car", 0.9, BoundingBox(100, 200, 300, 400), 1001) } } context = { "camera_id": "camera_001", "session_id": "session_123", "class": "car", "confidence": 0.9 } results = await executor.execute_actions(actions, regions, context) assert len(results) == 1 assert results[0].success is True assert results[0].action_type == ActionType.POSTGRESQL_INSERT # Verify database call mock_db_manager.execute_query.assert_called_once() call_args = mock_db_manager.execute_query.call_args[0] assert "INSERT INTO detections" in call_args[0] @pytest.mark.asyncio async def test_execute_postgresql_update_action(self, mock_database_connection): """Test executing PostgreSQL update action.""" mock_db_manager = Mock() mock_db_manager.execute_query = AsyncMock(return_value=True) executor = ActionExecutor(db_manager=mock_db_manager) actions = [ { "type": "postgresql_update_combined", "table": "car_info", "key_field": "session_id", "fields": { "car_brand": "{car_brand_cls.brand}", "car_body_type": "{car_bodytype_cls.body_type}", "updated_at": "NOW()" }, "waitForBranches": ["car_brand_cls", "car_bodytype_cls"] } ] regions = {} context = { "session_id": "session_123" } branch_results = { "car_brand_cls": {"brand": "Toyota"}, "car_bodytype_cls": {"body_type": "Sedan"} } results = await executor.execute_actions(actions, regions, context, branch_results) assert len(results) == 1 assert results[0].success is True assert results[0].action_type == ActionType.POSTGRESQL_UPDATE # Verify database call mock_db_manager.execute_query.assert_called_once() call_args = mock_db_manager.execute_query.call_args[0] assert "UPDATE car_info SET" in call_args[0] assert "WHERE session_id" in call_args[0] @pytest.mark.asyncio async def test_execute_file_save_action(self, mock_frame): """Test executing file save action.""" executor = ActionExecutor() actions = [ { "type": "save_image", "path": "/tmp/test_{camera_id}_{timestamp}.jpg", "region": "car", "format": "jpeg", "quality": 85 } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.9, "detection": DetectionResult("car", 0.9, BoundingBox(100, 200, 300, 400), 1001) } } context = { "camera_id": "camera_001", "timestamp": "1640995200000", "frame_data": mock_frame } with patch('cv2.imwrite') as mock_imwrite: mock_imwrite.return_value = True results = await executor.execute_actions(actions, regions, context) assert len(results) == 1 assert results[0].success is True assert results[0].action_type == ActionType.SAVE_IMAGE # Verify file save call mock_imwrite.assert_called_once() call_args = mock_imwrite.call_args assert "/tmp/test_camera_001_1640995200000.jpg" in call_args[0][0] @pytest.mark.asyncio async def test_execute_actions_parallel(self, mock_redis_client): """Test parallel execution of multiple actions.""" executor = ActionExecutor(redis_client=mock_redis_client) # Multiple Redis actions actions = [ { "type": "redis_save_image", "region": "car", "key": "inference:car:{session_id}", "expire_seconds": 600 }, { "type": "redis_publish", "channel": "detections", "message": "{camera_id}:car_detected" } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.9, "detection": DetectionResult("car", 0.9, BoundingBox(100, 200, 300, 400), 1001) } } context = { "camera_id": "camera_001", "session_id": "session_123", "frame_data": np.zeros((480, 640, 3), dtype=np.uint8) } # Mock Redis operations mock_redis_client.set.return_value = True mock_redis_client.expire.return_value = True mock_redis_client.publish.return_value = 1 import time start_time = time.time() results = await executor.execute_actions(actions, regions, context) execution_time = time.time() - start_time assert len(results) == 2 assert all(result.success for result in results) # Should execute in parallel (faster than sequential) assert execution_time < 0.1 # Allow some overhead @pytest.mark.asyncio async def test_execute_actions_error_handling(self, mock_redis_client): """Test error handling in action execution.""" executor = ActionExecutor(redis_client=mock_redis_client) actions = [ { "type": "redis_save_image", "region": "car", "key": "inference:{session_id}", "expire_seconds": 600 }, { "type": "redis_save_image", # This one will fail "region": "truck", # Region not detected "key": "inference:truck:{session_id}", "expire_seconds": 600 } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.9, "detection": DetectionResult("car", 0.9, BoundingBox(100, 200, 300, 400), 1001) } # No truck region } context = { "session_id": "session_123", "frame_data": np.zeros((480, 640, 3), dtype=np.uint8) } # Mock Redis operations mock_redis_client.set.return_value = True mock_redis_client.expire.return_value = True results = await executor.execute_actions(actions, regions, context) assert len(results) == 2 assert results[0].success is True # Car action succeeds assert results[1].success is False # Truck action fails assert "Region 'truck' not found" in results[1].error @pytest.mark.asyncio async def test_execute_actions_timeout(self, mock_redis_client): """Test action execution timeout.""" config = {"action_timeout": 0.001} # Very short timeout executor = ActionExecutor(redis_client=mock_redis_client, config=config) def slow_redis_operation(*args, **kwargs): import time time.sleep(1) # Longer than timeout return True mock_redis_client.set.side_effect = slow_redis_operation actions = [ { "type": "redis_save_image", "region": "car", "key": "inference:{session_id}", "expire_seconds": 600 } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.9, "detection": DetectionResult("car", 0.9, BoundingBox(100, 200, 300, 400), 1001) } } context = { "session_id": "session_123", "frame_data": np.zeros((480, 640, 3), dtype=np.uint8) } results = await executor.execute_actions(actions, regions, context) assert len(results) == 1 assert results[0].success is False assert "timeout" in results[0].error.lower() @pytest.mark.asyncio async def test_execute_redis_publish_action(self, mock_redis_client): """Test executing Redis publish action.""" executor = ActionExecutor(redis_client=mock_redis_client) actions = [ { "type": "redis_publish", "channel": "detections:{camera_id}", "message": { "camera_id": "{camera_id}", "detection_class": "{class}", "confidence": "{confidence}", "timestamp": "{timestamp}" } } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.9, "detection": DetectionResult("car", 0.9, BoundingBox(100, 200, 300, 400), 1001) } } context = { "camera_id": "camera_001", "class": "car", "confidence": 0.9, "timestamp": "1640995200000" } mock_redis_client.publish.return_value = 1 results = await executor.execute_actions(actions, regions, context) assert len(results) == 1 assert results[0].success is True assert results[0].action_type == ActionType.REDIS_PUBLISH # Verify publish call mock_redis_client.publish.assert_called_once() call_args = mock_redis_client.publish.call_args assert call_args[0][0] == "detections:camera_001" # Channel # Message should be JSON message = call_args[0][1] parsed_message = json.loads(message) assert parsed_message["camera_id"] == "camera_001" assert parsed_message["detection_class"] == "car" @pytest.mark.asyncio async def test_execute_conditional_action(self): """Test executing conditional actions.""" executor = ActionExecutor() actions = [ { "type": "conditional", "condition": "{confidence} > 0.8", "actions": [ { "type": "log", "message": "High confidence detection: {class} ({confidence})" } ] } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.95, # High confidence "detection": DetectionResult("car", 0.95, BoundingBox(100, 200, 300, 400), 1001) } } context = { "class": "car", "confidence": 0.95 } with patch('logging.info') as mock_log: results = await executor.execute_actions(actions, regions, context) assert len(results) == 1 assert results[0].success is True # Should have logged the message mock_log.assert_called_once() log_message = mock_log.call_args[0][0] assert "High confidence detection: car (0.95)" in log_message def test_crop_region_from_frame(self, mock_frame): """Test cropping region from frame.""" executor = ActionExecutor() detection = DetectionResult("car", 0.9, BoundingBox(100, 200, 300, 400), 1001) cropped = executor._crop_region_from_frame(mock_frame, detection.bbox) assert cropped.shape == (200, 200, 3) # 400-200, 300-100 def test_encode_image_base64(self, mock_frame): """Test encoding image to base64.""" executor = ActionExecutor() # Crop a small region cropped_frame = mock_frame[200:400, 100:300] # 200x200 region with patch('cv2.imencode') as mock_imencode: # Mock successful encoding mock_imencode.return_value = (True, np.array([1, 2, 3, 4], dtype=np.uint8)) encoded = executor._encode_image_base64(cropped_frame, format="jpeg") # Should return base64 string assert isinstance(encoded, str) assert len(encoded) > 0 # Verify encoding call mock_imencode.assert_called_once() assert mock_imencode.call_args[0][0] == '.jpg' def test_build_insert_query(self): """Test building INSERT SQL query.""" executor = ActionExecutor() table = "detections" fields = { "camera_id": "camera_001", "detection_class": "car", "confidence": 0.9, "created_at": "NOW()" } query, values = executor._build_insert_query(table, fields) assert "INSERT INTO detections" in query assert "camera_id, detection_class, confidence, created_at" in query assert "VALUES (%s, %s, %s, NOW())" in query assert values == ["camera_001", "car", 0.9] def test_build_update_query(self): """Test building UPDATE SQL query.""" executor = ActionExecutor() table = "car_info" fields = { "car_brand": "Toyota", "car_body_type": "Sedan", "updated_at": "NOW()" } key_field = "session_id" key_value = "session_123" query, values = executor._build_update_query(table, fields, key_field, key_value) assert "UPDATE car_info SET" in query assert "car_brand = %s" in query assert "car_body_type = %s" in query assert "updated_at = NOW()" in query assert "WHERE session_id = %s" in query assert values == ["Toyota", "Sedan", "session_123"] def test_evaluate_condition(self): """Test evaluating conditional expressions.""" executor = ActionExecutor() context = { "confidence": 0.85, "class": "car", "area": 40000 } # Simple comparisons assert executor._evaluate_condition("{confidence} > 0.8", context) is True assert executor._evaluate_condition("{confidence} < 0.8", context) is False assert executor._evaluate_condition("{confidence} >= 0.85", context) is True assert executor._evaluate_condition("{confidence} == 0.85", context) is True # String comparisons assert executor._evaluate_condition("{class} == 'car'", context) is True assert executor._evaluate_condition("{class} != 'truck'", context) is True # Complex conditions assert executor._evaluate_condition("{confidence} > 0.8 and {area} > 30000", context) is True assert executor._evaluate_condition("{confidence} > 0.9 or {area} > 30000", context) is True assert executor._evaluate_condition("{confidence} > 0.9 and {area} < 30000", context) is False def test_validate_action_config(self): """Test action configuration validation.""" executor = ActionExecutor() # Valid Redis action valid_redis = { "type": "redis_save_image", "region": "car", "key": "inference:{session_id}", "expire_seconds": 600 } assert executor._validate_action_config(valid_redis) is True # Invalid action (missing required fields) invalid_action = { "type": "redis_save_image" # Missing region and key } with pytest.raises(ActionError): executor._validate_action_config(invalid_action) # Unknown action type unknown_action = { "type": "unknown_action_type", "some_field": "value" } with pytest.raises(ActionError): executor._validate_action_config(unknown_action) class TestActionExecutorIntegration: """Integration tests for action execution.""" @pytest.mark.asyncio async def test_complete_detection_workflow(self, mock_redis_client, mock_frame): """Test complete detection workflow with multiple actions.""" # Mock database manager mock_db_manager = Mock() mock_db_manager.execute_query = AsyncMock(return_value=True) executor = ActionExecutor( redis_client=mock_redis_client, db_manager=mock_db_manager ) # Complete action workflow actions = [ # Save cropped image to Redis { "type": "redis_save_image", "region": "car", "key": "inference:{camera_id}:{timestamp}:{session_id}:car", "expire_seconds": 600 }, # Insert initial detection record { "type": "postgresql_insert", "table": "car_detections", "fields": { "camera_id": "{camera_id}", "session_id": "{session_id}", "detection_class": "{class}", "confidence": "{confidence}", "bbox_x1": "{bbox.x1}", "bbox_y1": "{bbox.y1}", "bbox_x2": "{bbox.x2}", "bbox_y2": "{bbox.y2}", "created_at": "NOW()" } }, # Publish detection event { "type": "redis_publish", "channel": "detections:{camera_id}", "message": { "event": "car_detected", "camera_id": "{camera_id}", "session_id": "{session_id}", "timestamp": "{timestamp}" } } ] regions = { "car": { "bbox": [100, 200, 300, 400], "confidence": 0.92, "detection": DetectionResult("car", 0.92, BoundingBox(100, 200, 300, 400), 1001) } } context = { "camera_id": "camera_001", "session_id": "session_123", "timestamp": "1640995200000", "class": "car", "confidence": 0.92, "bbox": {"x1": 100, "y1": 200, "x2": 300, "y2": 400}, "frame_data": mock_frame } # Mock all Redis operations mock_redis_client.set.return_value = True mock_redis_client.expire.return_value = True mock_redis_client.publish.return_value = 1 results = await executor.execute_actions(actions, regions, context) # All actions should succeed assert len(results) == 3 assert all(result.success for result in results) # Verify all operations were called mock_redis_client.set.assert_called_once() # Image save mock_redis_client.expire.assert_called_once() # Set expiry mock_redis_client.publish.assert_called_once() # Publish event mock_db_manager.execute_query.assert_called_once() # Database insert @pytest.mark.asyncio async def test_branch_dependent_actions(self, mock_database_connection): """Test actions that depend on branch results.""" mock_db_manager = Mock() mock_db_manager.execute_query = AsyncMock(return_value=True) executor = ActionExecutor(db_manager=mock_db_manager) # Action that waits for classification branches actions = [ { "type": "postgresql_update_combined", "table": "car_info", "key_field": "session_id", "fields": { "car_brand": "{car_brand_cls.brand}", "car_body_type": "{car_bodytype_cls.body_type}", "car_color": "{car_color_cls.color}", "confidence_brand": "{car_brand_cls.confidence}", "confidence_bodytype": "{car_bodytype_cls.confidence}", "updated_at": "NOW()" }, "waitForBranches": ["car_brand_cls", "car_bodytype_cls", "car_color_cls"] } ] regions = {} context = { "session_id": "session_123" } # Simulated branch results branch_results = { "car_brand_cls": {"brand": "Toyota", "confidence": 0.87}, "car_bodytype_cls": {"body_type": "Sedan", "confidence": 0.82}, "car_color_cls": {"color": "Red", "confidence": 0.79} } results = await executor.execute_actions(actions, regions, context, branch_results) assert len(results) == 1 assert results[0].success is True assert results[0].action_type == ActionType.POSTGRESQL_UPDATE # Verify database call with all branch data mock_db_manager.execute_query.assert_called_once() call_args = mock_db_manager.execute_query.call_args query = call_args[0][0] values = call_args[0][1] assert "UPDATE car_info SET" in query assert "car_brand = %s" in query assert "car_body_type = %s" in query assert "car_color = %s" in query assert "WHERE session_id = %s" in query assert "Toyota" in values assert "Sedan" in values assert "Red" in values assert "session_123" in values