from pathlib import Path from unittest.mock import MagicMock, patch import pytest from PIL import Image from photools3.models import ( LlmResponseCaption, LlmResponseCaptionsAndKeywords, LlmResponseKeywords, ) from photools3.processing import ( ProcessingMode, _create_session, process_batch, process_photo, ) @pytest.fixture def image_path(tmp_path): path = tmp_path / "photo.png" Image.new("RGB", (20, 20)).save(path) return path @pytest.mark.parametrize( ("mode", "response", "expected_metadata"), [ ( ProcessingMode.KEYWORDS, LlmResponseKeywords(tags=["architektur"]), {"caption": None, "keywords": ["architektur"]}, ), ( ProcessingMode.CAPTIONS, LlmResponseCaption(caption="Ein Haus"), {"caption": "Ein Haus", "keywords": None}, ), ( ProcessingMode.BOTH, LlmResponseCaptionsAndKeywords(caption="Ein Haus", tags=["architektur"]), {"caption": "Ein Haus", "keywords": ["architektur"]}, ), ], ) def test_process_photo_writes_only_requested_metadata( image_path, mode, response, expected_metadata ): session = MagicMock() session.call.return_value = response with ( patch("photools3.processing._create_session", return_value=session), patch("photools3.processing.MetadataWriter") as writer_class, ): result = process_photo(image_path, mode) assert result == response session.call.assert_called_once() session.close.assert_called_once() writer_class.assert_called_once_with(image_path) writer_class.return_value.write.assert_called_once_with(**expected_metadata) def test_process_photo_does_not_close_a_caller_owned_session(image_path): session = MagicMock() session.call.return_value = LlmResponseCaption(caption="Ein Haus") with patch("photools3.processing.MetadataWriter"): process_photo(image_path, ProcessingMode.CAPTIONS, session=session) session.close.assert_not_called() @pytest.mark.parametrize( ("mode", "constructor"), [ (ProcessingMode.KEYWORDS, "LlmSessionKeywords"), (ProcessingMode.CAPTIONS, "LlmSessionCaption"), (ProcessingMode.BOTH, "LlmSessionCaptionsAndKeywords"), ], ) def test_create_session_selects_mode_specific_response_model(mode, constructor): with patch(f"photools3.processing.{constructor}") as session_class: assert _create_session(mode) is session_class.return_value session_class.assert_called_once_with() def test_process_batch_is_sequential_reuses_session_and_continues_after_error(): active_photos = 0 max_active_photos = 0 class TrackingPhoto: def __init__(self, path): self.path = path def __enter__(self): nonlocal active_photos, max_active_photos active_photos += 1 max_active_photos = max(max_active_photos, active_photos) return self def __exit__(self, exc_type, exc_value, traceback): nonlocal active_photos active_photos -= 1 session = MagicMock() response = LlmResponseCaptionsAndKeywords(caption="Ein Haus", tags=["architektur"]) session.call.side_effect = [response, RuntimeError("temporary error"), response] paths = [Path("one.jpg"), Path("two.jpg"), Path("three.jpg")] with ( patch("photools3.processing._create_session", return_value=session), patch("photools3.processing.Photo", TrackingPhoto), patch("photools3.processing.MetadataWriter") as writer_class, ): outcomes = process_batch(paths, ProcessingMode.BOTH) assert [outcome.succeeded for outcome in outcomes] == [True, False, True] assert [outcome.path for outcome in outcomes] == paths assert outcomes[1].error == "RuntimeError: temporary error" assert max_active_photos == 1 assert active_photos == 0 assert session.call.call_count == 3 assert all(len(call.args[0]) == 1 for call in session.call.call_args_list) assert writer_class.return_value.write.call_count == 2 session.close.assert_called_once()