Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions tests/unit/test_image_processor_branches.py
Original file line number Diff line number Diff line change
@@ -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)
46 changes: 46 additions & 0 deletions tests/unit/test_logging_config.py
Original file line number Diff line number Diff line change
@@ -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"
107 changes: 107 additions & 0 deletions tests/unit/test_vision_command_service_factory.py
Original file line number Diff line number Diff line change
@@ -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()
Loading