|
|
""" |
|
|
Tests for vision tool (multimodal image analysis) |
|
|
Author: @mangubee |
|
|
Date: 2026-01-02 |
|
|
|
|
|
Tests cover: |
|
|
- Image loading and encoding |
|
|
- Gemini vision analysis |
|
|
- Claude vision analysis |
|
|
- Fallback mechanism |
|
|
- Retry logic |
|
|
- Error handling |
|
|
""" |
|
|
|
|
|
import pytest |
|
|
from pathlib import Path |
|
|
from unittest.mock import Mock, patch, MagicMock |
|
|
from src.tools.vision import ( |
|
|
load_and_encode_image, |
|
|
analyze_image_gemini, |
|
|
analyze_image_claude, |
|
|
analyze_image, |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
FIXTURES_DIR = Path(__file__).parent / "fixtures" |
|
|
|
|
|
|
|
|
@pytest.fixture |
|
|
def test_image_path(): |
|
|
"""Path to test image""" |
|
|
return str(FIXTURES_DIR / "test_image.jpg") |
|
|
|
|
|
|
|
|
@pytest.fixture |
|
|
def mock_gemini_response(): |
|
|
"""Mock Gemini API response""" |
|
|
mock_response = Mock() |
|
|
mock_response.text = "This image shows a red square." |
|
|
return mock_response |
|
|
|
|
|
|
|
|
@pytest.fixture |
|
|
def mock_claude_response(): |
|
|
"""Mock Claude API response""" |
|
|
mock_content = Mock() |
|
|
mock_content.text = "The image contains a red colored square." |
|
|
|
|
|
mock_response = Mock() |
|
|
mock_response.content = [mock_content] |
|
|
return mock_response |
|
|
|
|
|
|
|
|
@pytest.fixture |
|
|
def mock_settings_gemini(): |
|
|
"""Mock Settings with Gemini API key""" |
|
|
with patch('src.tools.vision.Settings') as mock: |
|
|
settings_instance = Mock() |
|
|
settings_instance.google_api_key = "test_google_key" |
|
|
settings_instance.anthropic_api_key = None |
|
|
mock.return_value = settings_instance |
|
|
yield mock |
|
|
|
|
|
|
|
|
@pytest.fixture |
|
|
def mock_settings_claude(): |
|
|
"""Mock Settings with Claude API key""" |
|
|
with patch('src.tools.vision.Settings') as mock: |
|
|
settings_instance = Mock() |
|
|
settings_instance.google_api_key = None |
|
|
settings_instance.anthropic_api_key = "test_anthropic_key" |
|
|
mock.return_value = settings_instance |
|
|
yield mock |
|
|
|
|
|
|
|
|
@pytest.fixture |
|
|
def mock_settings_both(): |
|
|
"""Mock Settings with both API keys""" |
|
|
with patch('src.tools.vision.Settings') as mock: |
|
|
settings_instance = Mock() |
|
|
settings_instance.google_api_key = "test_google_key" |
|
|
settings_instance.anthropic_api_key = "test_anthropic_key" |
|
|
mock.return_value = settings_instance |
|
|
yield mock |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_and_encode_image_success(test_image_path): |
|
|
"""Test successful image loading and encoding""" |
|
|
result = load_and_encode_image(test_image_path) |
|
|
|
|
|
assert "data" in result |
|
|
assert "mime_type" in result |
|
|
assert result["mime_type"] == "image/jpeg" |
|
|
assert result["size_mb"] > 0 |
|
|
assert len(result["data"]) > 0 |
|
|
|
|
|
|
|
|
def test_load_image_file_not_found(): |
|
|
"""Test image loading with missing file""" |
|
|
with pytest.raises(FileNotFoundError): |
|
|
load_and_encode_image("nonexistent_image.jpg") |
|
|
|
|
|
|
|
|
def test_load_image_unsupported_format(tmp_path): |
|
|
"""Test image loading with unsupported format""" |
|
|
|
|
|
fake_video = tmp_path / "video.mp4" |
|
|
fake_video.write_text("not a real video") |
|
|
|
|
|
with pytest.raises(ValueError, match="Unsupported image format"): |
|
|
load_and_encode_image(str(fake_video)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_analyze_image_gemini_success(mock_settings_gemini, test_image_path, mock_gemini_response): |
|
|
"""Test successful Gemini vision analysis""" |
|
|
with patch('google.genai.Client') as mock_client_class: |
|
|
|
|
|
mock_client = Mock() |
|
|
mock_client.models.generate_content.return_value = mock_gemini_response |
|
|
mock_client_class.return_value = mock_client |
|
|
|
|
|
result = analyze_image_gemini(test_image_path, "What is in this image?") |
|
|
|
|
|
assert result["model"] == "gemini-2.0-flash" |
|
|
assert result["answer"] == "This image shows a red square." |
|
|
assert result["question"] == "What is in this image?" |
|
|
assert result["image_path"] == test_image_path |
|
|
|
|
|
|
|
|
def test_analyze_image_gemini_default_question(mock_settings_gemini, test_image_path, mock_gemini_response): |
|
|
"""Test Gemini with default question""" |
|
|
with patch('google.genai.Client') as mock_client_class: |
|
|
mock_client = Mock() |
|
|
mock_client.models.generate_content.return_value = mock_gemini_response |
|
|
mock_client_class.return_value = mock_client |
|
|
|
|
|
result = analyze_image_gemini(test_image_path) |
|
|
|
|
|
assert result["question"] == "Describe this image in detail." |
|
|
|
|
|
|
|
|
def test_analyze_image_gemini_missing_api_key(): |
|
|
"""Test Gemini with missing API key""" |
|
|
with patch('src.tools.vision.Settings') as mock_settings: |
|
|
settings_instance = Mock() |
|
|
settings_instance.google_api_key = None |
|
|
mock_settings.return_value = settings_instance |
|
|
|
|
|
with pytest.raises(ValueError, match="GOOGLE_API_KEY not configured"): |
|
|
analyze_image_gemini("test.jpg") |
|
|
|
|
|
|
|
|
def test_analyze_image_gemini_connection_error(mock_settings_gemini, test_image_path): |
|
|
"""Test Gemini with connection error (triggers retry)""" |
|
|
with patch('google.genai.Client') as mock_client_class: |
|
|
mock_client = Mock() |
|
|
mock_client.models.generate_content.side_effect = ConnectionError("Network error") |
|
|
mock_client_class.return_value = mock_client |
|
|
|
|
|
with pytest.raises(ConnectionError): |
|
|
analyze_image_gemini(test_image_path) |
|
|
|
|
|
|
|
|
assert mock_client.models.generate_content.call_count == 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_analyze_image_claude_success(mock_settings_claude, test_image_path, mock_claude_response): |
|
|
"""Test successful Claude vision analysis""" |
|
|
with patch('anthropic.Anthropic') as mock_anthropic_class: |
|
|
|
|
|
mock_client = Mock() |
|
|
mock_client.messages.create.return_value = mock_claude_response |
|
|
mock_anthropic_class.return_value = mock_client |
|
|
|
|
|
result = analyze_image_claude(test_image_path, "What is in this image?") |
|
|
|
|
|
assert result["model"] == "claude-sonnet-4.5" |
|
|
assert result["answer"] == "The image contains a red colored square." |
|
|
assert result["question"] == "What is in this image?" |
|
|
assert result["image_path"] == test_image_path |
|
|
|
|
|
|
|
|
def test_analyze_image_claude_default_question(mock_settings_claude, test_image_path, mock_claude_response): |
|
|
"""Test Claude with default question""" |
|
|
with patch('anthropic.Anthropic') as mock_anthropic_class: |
|
|
mock_client = Mock() |
|
|
mock_client.messages.create.return_value = mock_claude_response |
|
|
mock_anthropic_class.return_value = mock_client |
|
|
|
|
|
result = analyze_image_claude(test_image_path) |
|
|
|
|
|
assert result["question"] == "Describe this image in detail." |
|
|
|
|
|
|
|
|
def test_analyze_image_claude_missing_api_key(): |
|
|
"""Test Claude with missing API key""" |
|
|
with patch('src.tools.vision.Settings') as mock_settings: |
|
|
settings_instance = Mock() |
|
|
settings_instance.anthropic_api_key = None |
|
|
mock_settings.return_value = settings_instance |
|
|
|
|
|
with pytest.raises(ValueError, match="ANTHROPIC_API_KEY not configured"): |
|
|
analyze_image_claude("test.jpg") |
|
|
|
|
|
|
|
|
def test_analyze_image_claude_connection_error(mock_settings_claude, test_image_path): |
|
|
"""Test Claude with connection error (triggers retry)""" |
|
|
with patch('anthropic.Anthropic') as mock_anthropic_class: |
|
|
mock_client = Mock() |
|
|
mock_client.messages.create.side_effect = ConnectionError("Network error") |
|
|
mock_anthropic_class.return_value = mock_client |
|
|
|
|
|
with pytest.raises(ConnectionError): |
|
|
analyze_image_claude(test_image_path) |
|
|
|
|
|
|
|
|
assert mock_client.messages.create.call_count == 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_analyze_image_uses_gemini(mock_settings_both, test_image_path, mock_gemini_response): |
|
|
"""Test unified analysis prefers Gemini when both APIs available""" |
|
|
with patch('google.genai.Client') as mock_gemini_class: |
|
|
mock_client = Mock() |
|
|
mock_client.models.generate_content.return_value = mock_gemini_response |
|
|
mock_gemini_class.return_value = mock_client |
|
|
|
|
|
result = analyze_image(test_image_path, "What is this?") |
|
|
|
|
|
assert result["model"] == "gemini-2.0-flash" |
|
|
assert "red square" in result["answer"].lower() |
|
|
|
|
|
|
|
|
def test_analyze_image_fallback_to_claude(mock_settings_both, test_image_path, mock_claude_response): |
|
|
"""Test unified analysis falls back to Claude when Gemini fails""" |
|
|
with patch('google.genai.Client') as mock_gemini_class: |
|
|
with patch('anthropic.Anthropic') as mock_claude_class: |
|
|
|
|
|
mock_gemini_client = Mock() |
|
|
mock_gemini_client.models.generate_content.side_effect = Exception("Gemini error") |
|
|
mock_gemini_class.return_value = mock_gemini_client |
|
|
|
|
|
|
|
|
mock_claude_client = Mock() |
|
|
mock_claude_client.messages.create.return_value = mock_claude_response |
|
|
mock_claude_class.return_value = mock_claude_client |
|
|
|
|
|
result = analyze_image(test_image_path, "What is this?") |
|
|
|
|
|
assert result["model"] == "claude-sonnet-4.5" |
|
|
assert "red" in result["answer"].lower() |
|
|
|
|
|
|
|
|
def test_analyze_image_no_api_keys(): |
|
|
"""Test unified analysis with no API keys configured""" |
|
|
with patch('src.tools.vision.Settings') as mock_settings: |
|
|
settings_instance = Mock() |
|
|
settings_instance.google_api_key = None |
|
|
settings_instance.anthropic_api_key = None |
|
|
mock_settings.return_value = settings_instance |
|
|
|
|
|
with pytest.raises(ValueError, match="No vision API configured"): |
|
|
analyze_image("test.jpg") |
|
|
|
|
|
|
|
|
def test_analyze_image_both_fail(mock_settings_both, test_image_path): |
|
|
"""Test unified analysis when both APIs fail""" |
|
|
with patch('google.genai.Client') as mock_gemini_class: |
|
|
with patch('anthropic.Anthropic') as mock_claude_class: |
|
|
|
|
|
mock_gemini_client = Mock() |
|
|
mock_gemini_client.models.generate_content.side_effect = Exception("Gemini error") |
|
|
mock_gemini_class.return_value = mock_gemini_client |
|
|
|
|
|
mock_claude_client = Mock() |
|
|
mock_claude_client.messages.create.side_effect = Exception("Claude error") |
|
|
mock_claude_class.return_value = mock_claude_client |
|
|
|
|
|
with pytest.raises(Exception, match="both failed"): |
|
|
analyze_image(test_image_path) |
|
|
|