This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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
|
||||
Reference in New Issue
Block a user