diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f08a0e9..8faf145 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,9 +46,10 @@ jobs: - name: Run tests with pytest run: | - pytest --cov=src --cov-report=xml --cov-report=term -v + python -m pytest --cov=src --cov-report=xml --cov-report=term -v env: DISPLAY: ':99' + PYTHONPATH: . - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/tests/unit/test_image_processor_branches.py b/tests/unit/test_image_processor_branches.py new file mode 100644 index 0000000..b25339d --- /dev/null +++ b/tests/unit/test_image_processor_branches.py @@ -0,0 +1,102 @@ +"""Additional branch coverage tests for PillowImageProcessor.""" + +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from src.lib.exceptions import ImageProcessingError +from src.models.entities import PrivacyZone, Screenshot +from src.services.image_processor import PillowImageProcessor +from src.services.temp_file_manager import TempFileManager + + +def _build_screenshot(path: Path, image_format: str = "jpeg") -> Screenshot: + size = path.stat().st_size + return Screenshot( + id=uuid4(), + timestamp=datetime.now(tz=UTC), + file_path=path, + format=image_format, + original_size_bytes=size, + optimized_size_bytes=size, + resolution=(1200, 800), + source_monitor=0, + capture_method="test", + privacy_zones_applied=False, + ) + + +@pytest.mark.parametrize("image_format", ["png", "webp"]) +def test_apply_privacy_zones_supports_png_and_webp_branches(monkeypatch, tmp_path, image_format: str) -> None: + source_path = tmp_path / f"source.{image_format}" + source_path.write_bytes(b"source") + + temp_manager = TempFileManager(temp_dir=str(tmp_path / "temp")) + processor = PillowImageProcessor(temp_manager=temp_manager) + screenshot = _build_screenshot(source_path, image_format=image_format) + zones = [PrivacyZone(name="redact", x=10, y=10, width=50, height=50, monitor=None)] + + fake_image = SimpleNamespace(save=lambda out_path, **_kwargs: Path(out_path).write_bytes(b"processed")) + monkeypatch.setattr("src.services.image_processor.Image.open", lambda _path: fake_image) + monkeypatch.setattr( + "src.services.image_processor.ImageDraw.Draw", + lambda _image: SimpleNamespace(rectangle=lambda *_args, **_kwargs: None), + ) + + processed = processor.apply_privacy_zones(screenshot, zones) + + assert processed.file_path.exists() + assert processed.file_path.suffix == f".{image_format}" + assert processed.privacy_zones_applied is True + + +def test_optimize_image_enters_resize_branch_when_size_not_reduced(monkeypatch, tmp_path) -> None: + source_path = tmp_path / "source.jpg" + source_path.write_bytes(b"x" * 300_000) + + temp_manager = TempFileManager(temp_dir=str(tmp_path / "temp")) + processor = PillowImageProcessor(temp_manager=temp_manager) + screenshot = _build_screenshot(source_path, image_format="jpeg") + + class FakeImage: + def __init__(self, size): + self.size = size + self.resize_calls = 0 + + def resize(self, size, _resampling): + self.resize_calls += 1 + return FakeImage(size) + + def save(self, out_path, **_kwargs): + Path(out_path).write_bytes(b"x" * 300_000) + + first_open = FakeImage((1400, 900)) + final_open = FakeImage((640, 480)) + images = [first_open, final_open] + monkeypatch.setattr("src.services.image_processor.Image.open", lambda _path: images.pop(0)) + + optimized = processor.optimize_image(screenshot, max_size_mb=0.0001) + + assert first_open.resize_calls > 0 + assert optimized.resolution == (640, 480) + assert optimized.file_path.exists() + + +def test_calculate_image_hash_wraps_file_read_errors(monkeypatch, tmp_path) -> None: + source_path = tmp_path / "source.jpg" + source_path.write_bytes(b"content") + + temp_manager = TempFileManager(temp_dir=str(tmp_path / "temp")) + processor = PillowImageProcessor(temp_manager=temp_manager) + screenshot = _build_screenshot(source_path, image_format="jpeg") + + def _failing_open(*_args, **_kwargs): + raise OSError("read failure") + + monkeypatch.setattr("builtins.open", _failing_open) + + with pytest.raises(ImageProcessingError, match="Failed to calculate image hash"): + processor.calculate_image_hash(screenshot) diff --git a/tests/unit/test_logging_config.py b/tests/unit/test_logging_config.py new file mode 100644 index 0000000..9c49057 --- /dev/null +++ b/tests/unit/test_logging_config.py @@ -0,0 +1,46 @@ +"""Unit tests for logging configuration helpers.""" + +import logging +import logging.handlers + +from src.lib.logging_config import get_logger, setup_logging + + +def test_setup_logging_console_only_defaults_to_info_for_invalid_level() -> None: + logger = setup_logging(level="not-a-level") + + assert logger.level == logging.INFO + assert len(logger.handlers) == 1 + assert isinstance(logger.handlers[0], logging.StreamHandler) + + +def test_setup_logging_creates_rotating_file_handler(tmp_path) -> None: + log_file = tmp_path / "logs" / "vision.log" + + logger = setup_logging(level="debug", log_file=str(log_file), max_size_mb=2, backup_count=5) + + file_handlers = [h for h in logger.handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + assert len(file_handlers) == 1 + file_handler = file_handlers[0] + assert file_handler.maxBytes == 2 * 1024 * 1024 + assert file_handler.backupCount == 5 + assert log_file.parent.exists() + + +def test_setup_logging_clears_existing_handlers() -> None: + base_logger = logging.getLogger("claude_code_vision") + base_logger.handlers = [logging.NullHandler()] + + logger = setup_logging(level="INFO") + + assert not any(isinstance(h, logging.NullHandler) for h in logger.handlers) + + +def test_get_logger_returns_prefixed_name_when_module_given() -> None: + logger = get_logger("src.some_module") + assert logger.name == "claude_code_vision.src.some_module" + + +def test_get_logger_returns_base_logger_when_name_omitted() -> None: + logger = get_logger() + assert logger.name == "claude_code_vision" diff --git a/tests/unit/test_vision_command_service_factory.py b/tests/unit/test_vision_command_service_factory.py new file mode 100644 index 0000000..a4e95d8 --- /dev/null +++ b/tests/unit/test_vision_command_service_factory.py @@ -0,0 +1,107 @@ +"""Unit tests for get_vision_service dependency wiring.""" + +from unittest.mock import Mock + +import pytest + +from src.lib.exceptions import VisionCommandError +from src.models.entities import Configuration + + +@pytest.fixture() +def vision_command_module(): + from src.cli import vision_command + + return vision_command + + +def _build_config(provider: str) -> Configuration: + config = Configuration() + config.ai_provider.provider = provider + config.gemini.api_key = "gemini-key" + return config + + +def _patch_core_dependencies(mocker, vision_command_module, config: Configuration): + config_manager = Mock() + config_manager.load_config.return_value = config + mocker.patch.object(vision_command_module, "ConfigurationManager", return_value=config_manager) + mocker.patch.object(vision_command_module, "setup_logging") + temp_manager = object() + capture = object() + processor = object() + mocker.patch.object(vision_command_module, "TempFileManager", return_value=temp_manager) + mocker.patch.object(vision_command_module.ScreenshotCaptureFactory, "create", return_value=capture) + mocker.patch.object(vision_command_module, "PillowImageProcessor", return_value=processor) + return config_manager, temp_manager, capture, processor + + +def test_get_vision_service_prefers_gemini_when_configured(mocker, vision_command_module) -> None: + config = _build_config(provider="gemini") + config_manager, temp_manager, capture, processor = _patch_core_dependencies( + mocker, vision_command_module, config + ) + claude_client = object() + gemini_client = object() + mocker.patch.object(vision_command_module, "AnthropicAPIClient", return_value=claude_client) + mocker.patch.object(vision_command_module, "GeminiAPIClient", return_value=gemini_client) + service_instance = object() + vision_service = mocker.patch.object(vision_command_module, "VisionService", return_value=service_instance) + + result = vision_command_module.get_vision_service() + + assert result is service_instance + assert vision_service.call_args.kwargs["config_manager"] is config_manager + assert vision_service.call_args.kwargs["temp_manager"] is temp_manager + assert vision_service.call_args.kwargs["capture"] is capture + assert vision_service.call_args.kwargs["processor"] is processor + assert vision_service.call_args.kwargs["api_client"] is gemini_client + assert vision_service.call_args.kwargs["gemini_client"] is gemini_client + + +def test_get_vision_service_prefers_claude_when_configured(mocker, vision_command_module) -> None: + config = _build_config(provider="claude") + _patch_core_dependencies(mocker, vision_command_module, config) + claude_client = object() + gemini_client = object() + mocker.patch.object(vision_command_module, "AnthropicAPIClient", return_value=claude_client) + mocker.patch.object(vision_command_module, "GeminiAPIClient", return_value=gemini_client) + vision_service = mocker.patch.object(vision_command_module, "VisionService", return_value=object()) + + vision_command_module.get_vision_service() + + assert vision_service.call_args.kwargs["api_client"] is claude_client + assert vision_service.call_args.kwargs["gemini_client"] is gemini_client + + +def test_get_vision_service_falls_back_to_available_client(mocker, vision_command_module) -> None: + config = _build_config(provider="claude") + _patch_core_dependencies(mocker, vision_command_module, config) + claude_error = RuntimeError("claude unavailable") + gemini_client = object() + mocker.patch.object(vision_command_module, "AnthropicAPIClient", side_effect=claude_error) + mocker.patch.object(vision_command_module, "GeminiAPIClient", return_value=gemini_client) + vision_service = mocker.patch.object(vision_command_module, "VisionService", return_value=object()) + + vision_command_module.get_vision_service() + + assert vision_service.call_args.kwargs["api_client"] is gemini_client + + +def test_get_vision_service_raises_when_no_client_available(mocker, vision_command_module) -> None: + config = _build_config(provider="gemini") + _patch_core_dependencies(mocker, vision_command_module, config) + mocker.patch.object(vision_command_module, "AnthropicAPIClient", side_effect=RuntimeError("claude unavailable")) + mocker.patch.object(vision_command_module, "GeminiAPIClient", side_effect=RuntimeError("gemini unavailable")) + + with pytest.raises(VisionCommandError, match="No API client configured"): + vision_command_module.get_vision_service() + + +def test_get_vision_service_wraps_unexpected_creation_errors(mocker, vision_command_module) -> None: + failing_manager = Mock() + failing_manager.load_config.side_effect = ValueError("bad config data") + mocker.patch.object(vision_command_module, "ConfigurationManager", return_value=failing_manager) + + with pytest.raises(VisionCommandError, match="Failed to initialize vision service: bad config data"): + vision_command_module.get_vision_service()