From 1b2539c4818f5606f923b2d80ba4e5b0b212e7da Mon Sep 17 00:00:00 2001 From: Patrik Date: Fri, 13 Feb 2026 16:32:07 +0100 Subject: [PATCH 1/2] test: harden screenshot factory coverage and typing --- pyproject.toml | 1 + src/lib/exceptions.py | 52 +++++----- src/models/entities.py | 4 +- tests/unit/test_screenshot_capture_factory.py | 99 +++++++++++++++++++ 4 files changed, 132 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4ddeee..c4f2937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ ignore = ["E501", "PLR0913", "PLR2004"] [tool.ruff.per-file-ignores] "__init__.py" = ["F401"] "tests/**/*.py" = ["S101", "PLR2004"] +"src/models/entities.py" = ["A003"] [tool.mypy] python_version = "3.8" diff --git a/src/lib/exceptions.py b/src/lib/exceptions.py index 9bb55d5..0b06af7 100644 --- a/src/lib/exceptions.py +++ b/src/lib/exceptions.py @@ -6,6 +6,8 @@ and troubleshooting guidance. """ +from typing import Optional + class VisionError(Exception): """ @@ -17,7 +19,7 @@ class VisionError(Exception): - Suggested fixes """ - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: """ Initialize VisionError. @@ -48,7 +50,7 @@ def _default_troubleshooting(self) -> str: class ScreenshotCaptureError(VisionError): """Screenshot capture failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Verify screenshot tool is installed:\n" @@ -65,7 +67,9 @@ def __init__(self, message: str, troubleshooting: str = None): class DisplayNotAvailableError(VisionError): """No display available (headless environment).""" - def __init__(self, message: str = "No display available", troubleshooting: str = None): + def __init__( + self, message: str = "No display available", troubleshooting: Optional[str] = None + ) -> None: if troubleshooting is None: troubleshooting = ( "• Verify you're running in a graphical environment (not SSH without X forwarding)\n" @@ -81,7 +85,7 @@ def __init__(self, message: str = "No display available", troubleshooting: str = class MonitorNotFoundError(VisionError): """Specified monitor not found.""" - def __init__(self, monitor_id: int, available_count: int): + def __init__(self, monitor_id: int, available_count: int) -> None: message = f"Monitor {monitor_id} not found. Available monitors: {available_count}" troubleshooting = ( f"• List available monitors: claude-vision --list-monitors\n" @@ -95,7 +99,7 @@ def __init__(self, monitor_id: int, available_count: int): class InvalidRegionError(VisionError): """Capture region out of bounds or invalid.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Verify region coordinates are within screen bounds\n" @@ -111,7 +115,7 @@ def __init__(self, message: str, troubleshooting: str = None): class ImageProcessingError(VisionError): """Image processing operation failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Verify PIL/Pillow is installed: pip install Pillow\n" @@ -127,7 +131,7 @@ def __init__(self, message: str, troubleshooting: str = None): class RegionSelectionCancelledError(VisionError): """User cancelled region selection.""" - def __init__(self): + def __init__(self) -> None: message = "Region selection cancelled by user" troubleshooting = ( "• Region selection was cancelled\n" @@ -141,7 +145,9 @@ def __init__(self): class SelectionToolNotFoundError(VisionError): """Graphical selection tool not installed.""" - def __init__(self, message: str = "Graphical selection tool not found", troubleshooting: str = None): + def __init__( + self, message: str = "Graphical selection tool not found", troubleshooting: Optional[str] = None + ) -> None: if troubleshooting is None: troubleshooting = ( "• Install region selection tool:\n" @@ -158,7 +164,7 @@ def __init__(self, message: str = "Graphical selection tool not found", troubles class AuthenticationError(VisionError): """OAuth authentication failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Verify Claude Code OAuth token is valid\n" @@ -173,7 +179,9 @@ def __init__(self, message: str, troubleshooting: str = None): class APIError(VisionError): """Claude API call failed.""" - def __init__(self, message: str, status_code: int = None, troubleshooting: str = None): + def __init__( + self, message: str, status_code: Optional[int] = None, troubleshooting: Optional[str] = None + ) -> None: self.status_code = status_code if troubleshooting is None: troubleshooting = ( @@ -190,14 +198,14 @@ def __init__(self, message: str, status_code: int = None, troubleshooting: str = class PayloadTooLargeError(VisionError): """Screenshot payload exceeds API limits.""" - def __init__(self, size_mb: float, limit_mb: float = 5.0): + def __init__(self, size_mb: float, limit_mb: float = 5.0) -> None: message = f"Screenshot too large: {size_mb:.2f} MB (limit: {limit_mb} MB)" troubleshooting = ( - f"• Reduce screenshot quality in config (current may be too high)\n" - f"• Reduce max_size_mb in config to trigger more aggressive compression\n" - f"• Use /vision.area to capture smaller region instead of full screen\n" - f"• Lower screen resolution if possible\n" - f"• Edit config: claude-vision --validate-config" + "• Reduce screenshot quality in config (current may be too high)\n" + "• Reduce max_size_mb in config to trigger more aggressive compression\n" + "• Use /vision.area to capture smaller region instead of full screen\n" + "• Lower screen resolution if possible\n" + "• Edit config: claude-vision --validate-config" ) super().__init__(message, troubleshooting) @@ -205,7 +213,7 @@ def __init__(self, size_mb: float, limit_mb: float = 5.0): class OAuthConfigNotFoundError(VisionError): """Claude Code OAuth config not found.""" - def __init__(self, message: str = None, troubleshooting: str = None): + def __init__(self, message: Optional[str] = None, troubleshooting: Optional[str] = None) -> None: if message is None: message = "Claude Code OAuth configuration not found" if troubleshooting is None: @@ -223,7 +231,7 @@ def __init__(self, message: str = None, troubleshooting: str = None): class ConfigurationError(VisionError): """Configuration invalid or load/save failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Validate config: claude-vision --validate-config\n" @@ -239,7 +247,7 @@ def __init__(self, message: str, troubleshooting: str = None): class SessionAlreadyActiveError(VisionError): """Monitoring session already active.""" - def __init__(self, session_id: str = None): + def __init__(self, session_id: Optional[str] = None) -> None: message = f"Monitoring session already active{f': {session_id}' if session_id else ''}" troubleshooting = ( "• Stop current session: /vision.stop\n" @@ -253,7 +261,7 @@ def __init__(self, session_id: str = None): class SessionNotFoundError(VisionError): """Monitoring session not found.""" - def __init__(self, session_id: str = None): + def __init__(self, session_id: Optional[str] = None) -> None: message = f"Monitoring session not found{f': {session_id}' if session_id else ''}" troubleshooting = ( "• No active monitoring session to stop\n" @@ -268,7 +276,7 @@ def __init__(self, session_id: str = None): class TempFileError(VisionError): """Temporary file operation failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Check disk space: df -h\n" @@ -284,7 +292,7 @@ def __init__(self, message: str, troubleshooting: str = None): class VisionCommandError(VisionError): """High-level command execution failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Run diagnostics: claude-vision --doctor\n" diff --git a/src/models/entities.py b/src/models/entities.py index 06391c4..98b3975 100644 --- a/src/models/entities.py +++ b/src/models/entities.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Optional, List +from typing import List, Optional, Tuple from uuid import UUID @@ -62,7 +62,7 @@ class Screenshot: format: str # 'jpeg' | 'png' | 'webp' original_size_bytes: int optimized_size_bytes: int - resolution: tuple[int, int] # (width, height) + resolution: Tuple[int, int] # (width, height) source_monitor: int capture_method: str # 'scrot' | 'grim' | 'import' privacy_zones_applied: bool diff --git a/tests/unit/test_screenshot_capture_factory.py b/tests/unit/test_screenshot_capture_factory.py index 86a1900..16d6ea3 100644 --- a/tests/unit/test_screenshot_capture_factory.py +++ b/tests/unit/test_screenshot_capture_factory.py @@ -2,6 +2,10 @@ from __future__ import annotations +import builtins +import sys +from types import ModuleType + import pytest from src.lib.desktop_detector import DesktopType @@ -16,6 +20,13 @@ def __init__(self, temp_manager=None, **kwargs): self.kwargs = kwargs +def _install_fake_capture_module(monkeypatch: pytest.MonkeyPatch, module_name: str, class_name: str) -> None: + fake_module = ModuleType(module_name) + fake_class = type(class_name, (), {"__init__": _DummyCapture.__init__}) + setattr(fake_module, class_name, fake_class) + monkeypatch.setitem(sys.modules, module_name, fake_module) + + def test_get_tool_from_name_maps_supported_values() -> None: assert ScreenshotCaptureFactory._get_tool_from_name("scrot") == ScreenshotTool.SCROT assert ScreenshotCaptureFactory._get_tool_from_name("grim") == ScreenshotTool.GRIM @@ -133,3 +144,91 @@ def test_create_screenshot_capture_convenience_function_delegates(monkeypatch: p result = create_screenshot_capture(image_format="png", quality=88, preferred_tool="grim") assert result == {"image_format": "png", "quality": 88, "preferred_tool": "grim"} + + +def test_create_implementation_scrot_branch(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_capture_module( + monkeypatch, + "src.services.screenshot_capture.x11_capture", + "X11ScreenshotCapture", + ) + + instance = ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.SCROT, + desktop_type=DesktopType.X11, + temp_manager=object(), + image_format="png", + quality=85, + ) + + assert instance.kwargs["image_format"] == "png" + assert instance.kwargs["quality"] == 85 + + +def test_create_implementation_grim_branch(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_capture_module( + monkeypatch, + "src.services.screenshot_capture.wayland_capture", + "WaylandScreenshotCapture", + ) + + instance = ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.GRIM, + desktop_type=DesktopType.WAYLAND, + temp_manager=object(), + image_format="jpeg", + quality=70, + ) + + assert instance.kwargs["image_format"] == "jpeg" + assert instance.kwargs["quality"] == 70 + + +def test_create_implementation_import_branch(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_capture_module( + monkeypatch, + "src.services.screenshot_capture.imagemagick_capture", + "ImageMagickScreenshotCapture", + ) + + instance = ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.IMPORT, + desktop_type=DesktopType.X11, + temp_manager=object(), + image_format="webp", + quality=60, + ) + + assert instance.kwargs["image_format"] == "webp" + assert instance.kwargs["quality"] == 60 + + +def test_create_implementation_unsupported_tool_raises() -> None: + with pytest.raises(ScreenshotCaptureError, match="Unsupported screenshot tool"): + ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.UNKNOWN, + desktop_type=DesktopType.X11, + temp_manager=object(), + image_format="png", + quality=90, + ) + + +def test_create_implementation_import_error_is_wrapped(monkeypatch: pytest.MonkeyPatch) -> None: + original_import = builtins.__import__ + + def _raising_import(name, *args, **kwargs): + if name == "src.services.screenshot_capture.x11_capture": + raise ImportError("missing module") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _raising_import) + + with pytest.raises(ScreenshotCaptureError, match="Failed to import screenshot capture implementation"): + ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.SCROT, + desktop_type=DesktopType.X11, + temp_manager=object(), + image_format="png", + quality=90, + ) From 940cf157dfc6791fec3d5723327f6627c256233f Mon Sep 17 00:00:00 2001 From: Patrik Date: Fri, 13 Feb 2026 16:38:08 +0100 Subject: [PATCH 2/2] test: boost coverage and stabilize CI test env --- .github/workflows/ci.yml | 3 +- tests/unit/test_image_processor_branches.py | 102 +++++++++++++++++ tests/unit/test_logging_config.py | 46 ++++++++ .../test_vision_command_service_factory.py | 107 ++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_image_processor_branches.py create mode 100644 tests/unit/test_logging_config.py create mode 100644 tests/unit/test_vision_command_service_factory.py 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()