import base64 from io import BytesIO import pytest from PIL import Image from photools3.photo import Photo from photools3.settings import DNG_EXAMPLES_DIR PATHS_DNG_EXAMPLES = [ DNG_EXAMPLES_DIR / "example_dng_01.dng", DNG_EXAMPLES_DIR / "example_dng_02.dng", DNG_EXAMPLES_DIR / "example_dng_03.dng", DNG_EXAMPLES_DIR / "example_dng_04.dng", DNG_EXAMPLES_DIR / "example_dng_05.dng", ] # (PIL mode) -> file format used to persist the test image MODE_TO_FORMAT = { "RGB": "PNG", "L": "PNG", "P": "PNG", "RGBA": "PNG", "LA": "PNG", "CMYK": "TIFF", "1": "PNG", "I": "TIFF", "F": "TIFF", } ALL_MODES = list(MODE_TO_FORMAT) JPEG_COMPATIBLE_MODES = ["RGB", "L", "CMYK", "1"] JPEG_INCOMPATIBLE_MODES = ["P", "RGBA", "LA", "I", "F"] def _make_image_file(tmp_path, mode: str, size=(64, 48)): """Create an image in the given mode, save it to disk and return its path.""" fmt = MODE_TO_FORMAT[mode] img = Image.new(mode, size) path = tmp_path / f"test_{mode}.{fmt.lower()}" img.save(path, format=fmt) return path def test_load_photo(): photo = Photo(PATHS_DNG_EXAMPLES[0]) assert photo assert photo.path == PATHS_DNG_EXAMPLES[0] assert isinstance(photo.image, Image.Image) def test_resize_method_exists(): """Test that resize method exists and returns a Photo instance""" photo = Photo(PATHS_DNG_EXAMPLES[0]) resized_photo = photo.resize() assert isinstance(resized_photo, Photo) assert resized_photo is photo # Should return self for chaining def test_resize_with_custom_max_size(): """Test resize with a custom maximum size""" photo = Photo(PATHS_DNG_EXAMPLES[0]) original_width, original_height = photo.image.size assert original_width > 100 or original_height > 100 # Assume it's larger resized_photo = photo.resize(image_max_size=50) new_width, new_height = resized_photo.image.size max_dim = max(new_width, new_height) assert max_dim <= 50, f"Max dimension {max_dim} exceeds limit of 50" def test_resize_maintains_aspect_ratio(): """Test that resize maintains the original aspect ratio""" photo = Photo(PATHS_DNG_EXAMPLES[0]) original_width, original_height = photo.image.size resized_photo = photo.resize(image_max_size=100) new_width, new_height = resized_photo.image.size original_ratio = original_width / original_height resized_ratio = new_width / new_height assert ( abs(original_ratio - resized_ratio) < 0.01 ), f"Aspect ratio changed: {original_ratio} -> {resized_ratio}" def test_resize_image_thumbnail_called(): """Test that the image was actually resized using thumbnail""" photo = Photo(PATHS_DNG_EXAMPLES[0]) original_size = photo.image.size # Resize to a smaller dimension resized_photo = photo.resize(image_max_size=10) new_size = resized_photo.image.size assert ( new_size != original_size ), "Image should be resized when max_size is smaller than original" def test_resize_chaining(): """Test that resize returns self for method chaining""" photo = Photo(PATHS_DNG_EXAMPLES[0]) # Should be able to chain multiple operations result = photo.resize().resize(image_max_size=50) assert result is photo def test_resize_default_parameter(): """Test resize with default parameter (no arguments)""" photo = Photo(PATHS_DNG_EXAMPLES[0]) resized_photo = photo.resize() # Should return a valid Photo instance assert isinstance(resized_photo, Photo) assert resized_photo.path == photo.path # --- to_rgb() --- @pytest.mark.parametrize("mode", ALL_MODES) def test_to_rgb_converts_to_rgb_mode(tmp_path, mode): """to_rgb() must leave the image in RGB mode regardless of the initial mode""" path = _make_image_file(tmp_path, mode) photo = Photo(path) result = photo.to_rgb() assert result.image.mode == "RGB" @pytest.mark.parametrize("mode", ALL_MODES) def test_to_rgb_returns_self_for_chaining(tmp_path, mode): """to_rgb() returns self so it can be chained""" path = _make_image_file(tmp_path, mode) photo = Photo(path) assert photo.to_rgb() is photo def test_to_rgb_keeps_rgb_image_unmodified(tmp_path): """RGB images are not re-converted: the same image object is kept""" path = _make_image_file(tmp_path, "RGB") photo = Photo(path) original_image = photo.image photo.to_rgb() assert photo.image is original_image @pytest.mark.parametrize("mode", ["L", "P", "RGBA", "LA", "CMYK", "1", "I", "F"]) def test_to_rgb_replaces_non_rgb_image(tmp_path, mode): """Non-RGB images are converted: a new image object is assigned""" path = _make_image_file(tmp_path, mode) photo = Photo(path) original_image = photo.image photo.to_rgb() assert photo.image is not original_image assert photo.image.mode == "RGB" @pytest.mark.parametrize("mode", ["RGBA", "LA"]) def test_to_rgb_drops_alpha_channel(tmp_path, mode): """Converting alpha-bearing modes to RGB drops the alpha band""" path = _make_image_file(tmp_path, mode) photo = Photo(path) assert "A" in photo.image.getbands() photo.to_rgb() assert photo.image.getbands() == ("R", "G", "B") def test_to_rgb_on_dng_examples(tmp_path): """DNG examples are already RGB; to_rgb() is a safe no-op on every file""" for path in PATHS_DNG_EXAMPLES: photo = Photo(path) original_image = photo.image assert photo.to_rgb() is photo assert photo.image is original_image assert photo.image.mode == "RGB" # --- get_encoded() --- @pytest.mark.parametrize("mode", JPEG_COMPATIBLE_MODES) def test_get_encoded_returns_valid_base64(tmp_path, mode): """get_encoded() returns a non-empty, decodable base64 string""" path = _make_image_file(tmp_path, mode) photo = Photo(path) encoded = photo.get_encoded() assert isinstance(encoded, str) assert len(encoded) > 0 raw = base64.b64decode(encoded, validate=True) assert len(raw) > 0 @pytest.mark.parametrize("mode", JPEG_COMPATIBLE_MODES) def test_get_encoded_produces_openable_jpeg(tmp_path, mode): """The decoded payload must be a valid JPEG with the original dimensions""" path = _make_image_file(tmp_path, mode) photo = Photo(path) original_size = photo.image.size encoded = photo.get_encoded() img = Image.open(BytesIO(base64.b64decode(encoded))) assert img.format == "JPEG" assert img.size == original_size def test_get_encoded_starts_with_jpeg_magic_bytes(tmp_path): """JPEG files start with the FFD8FF magic bytes""" path = _make_image_file(tmp_path, "RGB") photo = Photo(path) raw = base64.b64decode(photo.get_encoded()) assert raw[:3] == b"\xff\xd8\xff" def test_get_encoded_reencodes_instead_of_passing_raw_file_bytes(tmp_path): """The output must be a fresh JPEG encoding, not the source file bytes""" path = _make_image_file(tmp_path, "RGB") photo = Photo(path) raw = base64.b64decode(photo.get_encoded()) source_bytes = path.read_bytes() assert raw != source_bytes assert source_bytes[:4] == b"\x89PNG" # source is a PNG... assert raw[:3] == b"\xff\xd8\xff" # ...but the encoding is a JPEG def test_get_encoded_is_deterministic(tmp_path): """Encoding the same image twice yields identical base64""" path = _make_image_file(tmp_path, "RGB") assert Photo(path).get_encoded() == Photo(path).get_encoded() @pytest.mark.parametrize("mode", JPEG_INCOMPATIBLE_MODES) def test_get_encoded_raises_for_non_jpeg_modes(tmp_path, mode): """Modes JPEG cannot store (palette, alpha, integer/float) must raise OSError""" path = _make_image_file(tmp_path, mode) photo = Photo(path) with pytest.raises(OSError): photo.get_encoded() @pytest.mark.parametrize("mode", JPEG_INCOMPATIBLE_MODES) def test_to_rgb_then_get_encoded_works_around_unsupported_modes(tmp_path, mode): """Chaining to_rgb().get_encoded() fixes modes that JPEG cannot store""" path = _make_image_file(tmp_path, mode) photo = Photo(path) with pytest.raises(OSError): photo.get_encoded() encoded = photo.to_rgb().get_encoded() assert isinstance(encoded, str) img = Image.open(BytesIO(base64.b64decode(encoded))) assert img.format == "JPEG" @pytest.mark.parametrize("path", PATHS_DNG_EXAMPLES) def test_get_encoded_works_on_all_dng_examples(path): """Every DNG example (all different files) encodes to a valid JPEG""" photo = Photo(path) encoded = photo.get_encoded() assert isinstance(encoded, str) raw = base64.b64decode(encoded, validate=True) assert raw[:3] == b"\xff\xd8\xff" img = Image.open(BytesIO(raw)) assert img.format == "JPEG" assert img.size == photo.image.size def test_full_chain_resize_to_rgb_get_encoded(tmp_path): """The intended workflow: resize -> to_rgb -> get_encoded""" path = _make_image_file(tmp_path, "RGBA") photo = Photo(path) photo.resize(32).to_rgb() assert photo.image.mode == "RGB" img = Image.open(BytesIO(base64.b64decode(photo.get_encoded()))) assert img.format == "JPEG" assert max(img.size) <= 32