| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146 |
- """Tests for the shared camera-rotation helpers (#2708).
- Every other test of a rotating path patches ``apply_camera_rotation`` out and
- asserts the call, which proves the wiring but not the rotation. These drive
- the real PIL round trip, so a flipped sign or a dropped ``expand=True`` fails
- here rather than shipping.
- """
- import io
- import logging
- import pytest
- from PIL import Image
- from backend.app.services.camera import apply_camera_rotation, apply_camera_rotation_to_file
- logger = logging.getLogger(__name__)
- def _jpeg(width: int, height: int, corner: tuple[int, int, int] = (255, 0, 0)) -> bytes:
- """A JPEG with one distinctly coloured pixel block in the top-left corner,
- so which way it turned is observable and not just the dimensions."""
- img = Image.new("RGB", (width, height), (0, 0, 255))
- for x in range(min(8, width)):
- for y in range(min(8, height)):
- img.putpixel((x, y), corner)
- buf = io.BytesIO()
- img.save(buf, format="JPEG", quality=95)
- return buf.getvalue()
- def _open(data: bytes) -> Image.Image:
- return Image.open(io.BytesIO(data))
- def _brightest_corner(img: Image.Image) -> str:
- """Which corner holds the red block, sampled a few pixels in to stay clear
- of JPEG ringing at the edges."""
- w, h = img.size
- probes = {
- "top-left": (3, 3),
- "top-right": (w - 4, 3),
- "bottom-left": (3, h - 4),
- "bottom-right": (w - 4, h - 4),
- }
- return max(probes, key=lambda name: img.getpixel(probes[name])[0] - img.getpixel(probes[name])[2])
- class TestApplyCameraRotation:
- def test_zero_rotation_returns_the_input_object(self):
- """Not merely equal — identity. apply_camera_rotation_to_file uses this
- to decide there is nothing to write back."""
- src = _jpeg(64, 32)
- assert apply_camera_rotation(src, 0, logger) is src
- def test_90_degrees_turns_clockwise(self):
- """camera_rotation is documented as degrees *clockwise*, and PIL's
- rotate() is counter-clockwise — the helper negates to compensate. A
- lost negation would send the corner to bottom-right instead."""
- src = _jpeg(64, 32)
- assert _brightest_corner(_open(src)) == "top-left"
- out = _open(apply_camera_rotation(src, 90, logger))
- assert out.size == (32, 64) # expand=True, so the frame is not cropped
- assert _brightest_corner(out) == "top-right"
- def test_270_degrees_turns_the_other_way(self):
- out = _open(apply_camera_rotation(_jpeg(64, 32), 270, logger))
- assert out.size == (32, 64)
- assert _brightest_corner(out) == "bottom-left"
- def test_180_degrees_keeps_the_dimensions_and_flips_the_corner(self):
- out = _open(apply_camera_rotation(_jpeg(64, 32), 180, logger))
- assert out.size == (64, 32)
- assert _brightest_corner(out) == "bottom-right"
- def test_applying_180_twice_is_the_bug_that_was_fixed(self):
- """The regression this guards: two rotations cancel out and the photo
- is upside-down again. Kept as a test so the invariant that
- _stage22_finish_frames holds exactly one rotation has a stated reason.
- """
- src = _jpeg(64, 32)
- once = apply_camera_rotation(src, 180, logger)
- twice = apply_camera_rotation(once, 180, logger)
- assert _brightest_corner(_open(once)) == "bottom-right"
- assert _brightest_corner(_open(twice)) == "top-left" # back to the original
- def test_undecodable_bytes_return_unchanged(self):
- """A capture path must not lose a frame because the rotate failed —
- an unrotated photo beats no photo."""
- junk = b"not a jpeg at all"
- assert apply_camera_rotation(junk, 90, logger) is junk
- def test_a_failed_rotate_is_logged_as_a_warning(self, caplog):
- with caplog.at_level(logging.WARNING, logger=__name__):
- apply_camera_rotation(b"not a jpeg at all", 90, logger)
- assert any("Failed to apply camera rotation" in r.message for r in caplog.records)
- def test_a_successful_rotate_does_not_log_at_info(self, caplog):
- """Layer-timelapse calls this once per layer; at INFO a tall print
- would bury the log."""
- with caplog.at_level(logging.INFO, logger=__name__):
- apply_camera_rotation(_jpeg(64, 32), 90, logger)
- assert caplog.records == []
- class TestApplyCameraRotationToFile:
- """The two finish-photo sources that let ffmpeg write the file and never
- hold the bytes: capture_finish_photo and the timelapse last-frame extract."""
- @pytest.mark.asyncio
- async def test_rotates_in_place(self, tmp_path):
- path = tmp_path / "finish.jpg"
- path.write_bytes(_jpeg(64, 32))
- await apply_camera_rotation_to_file(path, 90, logger)
- out = _open(path.read_bytes())
- assert out.size == (32, 64)
- assert _brightest_corner(out) == "top-right"
- @pytest.mark.asyncio
- async def test_zero_rotation_leaves_the_file_untouched(self, tmp_path):
- path = tmp_path / "finish.jpg"
- original = _jpeg(64, 32)
- path.write_bytes(original)
- await apply_camera_rotation_to_file(path, 0, logger)
- assert path.read_bytes() == original
- @pytest.mark.asyncio
- async def test_a_file_that_cannot_be_rotated_is_left_intact(self, tmp_path):
- """Not truncated, not deleted — the caller's unrotated photo survives."""
- path = tmp_path / "finish.jpg"
- path.write_bytes(b"not a jpeg at all")
- await apply_camera_rotation_to_file(path, 90, logger)
- assert path.read_bytes() == b"not a jpeg at all"
- @pytest.mark.asyncio
- async def test_a_missing_file_does_not_raise(self, tmp_path):
- """Best-effort: this runs after the capture reported success, and must
- not turn a delivered photo into a failed one."""
- await apply_camera_rotation_to_file(tmp_path / "gone.jpg", 90, logger)
|