Photo basics (#10)
Tests / test_and_lint (push) Successful in 10s

Reviewed-on: #10
Co-authored-by: Mathias Scheider <mathias2784@web.de>
This commit was merged in pull request #10.
This commit is contained in:
2026-09-30 21:01:20 +00:00
committed by mathias2784
parent d9758c598d
commit 0c33153c98
11 changed files with 390 additions and 9 deletions
+276
View File
@@ -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
+32 -1
View File
@@ -1,7 +1,38 @@
from photools3.settings import Settings
from pathlib import Path
from photools3.settings import (
DNG_EXAMPLES_DIR,
EXAMPLES_DIR,
PHOTOOLS3_DIR,
ROOT_DIR,
SRC_DIR,
Settings,
)
settings = Settings()
def test_photools3_dir():
assert PHOTOOLS3_DIR == Path(__file__).parent.parent.resolve() / "src" / "photools3"
def test_src_dir():
assert SRC_DIR == Path(__file__).parent.parent.resolve() / "src"
def test_root_dir():
assert ROOT_DIR == Path(__file__).parent.parent.resolve()
def test_examples_dir():
assert EXAMPLES_DIR == Path(__file__).parent.parent.resolve() / "examples"
def test_dng_examples_dir():
assert (
DNG_EXAMPLES_DIR == Path(__file__).parent.parent.resolve() / "examples" / "dng"
)
def test_settings_base():
assert settings.llm_api_base_url