|
|
@@ -0,0 +1,146 @@
|
|
|
+"""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)
|