Tests / test_and_lint (push) Successful in 10s
Reviewed-on: #10 Co-authored-by: Mathias Scheider <mathias2784@web.de>
277 lines
9.0 KiB
Python
277 lines
9.0 KiB
Python
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
|