test_finish_photo_from_timelapse.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """Tests for _capture_finish_photo_from_timelapse (#1397).
  2. The polling helper runs in parallel with _scan_for_timelapse_with_retries —
  3. it waits for archive.timelapse_path to land in the DB, then extracts the
  4. last frame as the finish photo. These tests exercise the four shapes the
  5. helper has to handle correctly:
  6. 1. timelapse never lands within timeout → return None (caller falls back)
  7. 2. timelapse lands, extraction succeeds → return filename
  8. 3. timelapse lands, extraction fails → return None (caller falls back)
  9. 4. timelapse_path is set but the file doesn't exist on disk → keep polling
  10. DB access is patched at the session-maker boundary so these tests run in
  11. ~50ms each without standing up a real engine.
  12. """
  13. from contextlib import asynccontextmanager
  14. from pathlib import Path
  15. from types import SimpleNamespace
  16. from unittest.mock import AsyncMock, patch
  17. import pytest
  18. from backend.app import main as main_module
  19. from backend.app.main import _capture_finish_photo_from_timelapse
  20. @asynccontextmanager
  21. async def _fake_session(archive):
  22. """A fake session whose execute().scalar_one_or_none() returns `archive`.
  23. `archive` is mutated by the test mid-poll to simulate the real flow:
  24. the timelapse-attach background task setting `timelapse_path` after a
  25. few poll cycles.
  26. """
  27. result = SimpleNamespace(scalar_one_or_none=lambda: archive)
  28. session = SimpleNamespace(execute=AsyncMock(return_value=result))
  29. yield session
  30. @pytest.fixture
  31. def fake_archive():
  32. """Mutable archive stand-in. Tests flip `.timelapse_path` to simulate
  33. the timelapse-attach task writing to the DB."""
  34. return SimpleNamespace(id=42, timelapse_path=None)
  35. @pytest.fixture(autouse=True)
  36. def _fast_poll(monkeypatch):
  37. """Shrink poll interval + timeout so tests don't sleep for real."""
  38. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.01)
  39. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 0.2)
  40. @pytest.fixture
  41. def patched_session(fake_archive, monkeypatch):
  42. """Patch main.async_session so the helper reads our fake archive."""
  43. monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_archive))
  44. return fake_archive
  45. async def test_returns_none_when_timelapse_never_lands(tmp_path: Path, patched_session):
  46. """Print finished without a timelapse — bail after timeout so the caller
  47. falls back to the live-camera grab."""
  48. result, pending = await _capture_finish_photo_from_timelapse(
  49. archive_id=42,
  50. archive_dir=tmp_path,
  51. )
  52. assert result is None
  53. # Ran out of time rather than concluded: the video may still be on its way,
  54. # which is what tells the caller to schedule a background upgrade (#2704).
  55. assert pending is True
  56. async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_session, monkeypatch):
  57. """Simulate the timelapse landing after one poll cycle and extraction
  58. succeeding — should return a filename matching the finish_*.jpg pattern."""
  59. # Lay down a stub timelapse file relative to base_dir so the path
  60. # join works the way the helper expects.
  61. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  62. video_relpath = Path("archive/1/print/timelapse.mp4")
  63. video_abspath = tmp_path / video_relpath
  64. video_abspath.parent.mkdir(parents=True, exist_ok=True)
  65. video_abspath.write_bytes(b"x" * 100) # non-empty so the size check passes
  66. # Patch extraction to succeed unconditionally — the actual ffmpeg
  67. # codepath has its own test file.
  68. async def fake_extract(src, dst):
  69. dst.write_bytes(b"\xff\xd8" + b"\x00" * 50) # JPEG SOI
  70. return True
  71. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
  72. # Flip the archive into the "timelapse landed" state before the first
  73. # poll — the helper picks it up on its initial read.
  74. patched_session.timelapse_path = str(video_relpath)
  75. with patch(
  76. "backend.app.services.camera.extract_video_last_frame",
  77. new=fake_extract,
  78. ):
  79. result, pending = await _capture_finish_photo_from_timelapse(
  80. archive_id=42,
  81. archive_dir=tmp_path / "archive_dir",
  82. )
  83. assert result is not None
  84. assert result.startswith("finish_")
  85. assert result.endswith(".jpg")
  86. assert (tmp_path / "archive_dir" / "photos" / result).exists()
  87. assert pending is False
  88. async def test_returns_none_when_extraction_fails(tmp_path: Path, patched_session, monkeypatch):
  89. """Timelapse landed but ffmpeg said no — we don't keep retrying on the
  90. same broken file; return None so the caller falls back."""
  91. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  92. video_relpath = Path("archive/1/print/timelapse.mp4")
  93. video_abspath = tmp_path / video_relpath
  94. video_abspath.parent.mkdir(parents=True, exist_ok=True)
  95. video_abspath.write_bytes(b"x" * 100)
  96. async def fake_extract_fails(src, dst):
  97. return False
  98. patched_session.timelapse_path = str(video_relpath)
  99. with patch(
  100. "backend.app.services.camera.extract_video_last_frame",
  101. new=fake_extract_fails,
  102. ):
  103. result, pending = await _capture_finish_photo_from_timelapse(
  104. archive_id=42,
  105. archive_dir=tmp_path / "archive_dir",
  106. )
  107. assert result is None
  108. # The video arrived and ffmpeg refused it — waiting longer cannot help, so
  109. # this must NOT ask for a background retry.
  110. assert pending is False
  111. async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeypatch):
  112. """timelapse_path is set, but the file isn't on disk yet (the attach
  113. background task hasn't finished writing). Should keep polling — and
  114. succeed once the file materialises."""
  115. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  116. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.05)
  117. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 1.0)
  118. video_relpath = Path("archive/1/print/timelapse.mp4")
  119. patched_session.timelapse_path = str(video_relpath)
  120. # File not present yet. Schedule it to land after ~150ms.
  121. import asyncio
  122. async def materialise_later():
  123. await asyncio.sleep(0.15)
  124. video_abspath = tmp_path / video_relpath
  125. video_abspath.parent.mkdir(parents=True, exist_ok=True)
  126. video_abspath.write_bytes(b"x" * 100)
  127. async def fake_extract(src, dst):
  128. dst.write_bytes(b"\xff\xd8")
  129. return True
  130. materialise = asyncio.create_task(materialise_later())
  131. try:
  132. with patch(
  133. "backend.app.services.camera.extract_video_last_frame",
  134. new=fake_extract,
  135. ):
  136. result, pending = await _capture_finish_photo_from_timelapse(
  137. archive_id=42,
  138. archive_dir=tmp_path / "archive_dir",
  139. )
  140. finally:
  141. materialise.cancel()
  142. assert result is not None
  143. assert result.startswith("finish_")
  144. async def test_extracted_frame_is_rotated_when_configured(tmp_path: Path, patched_session, monkeypatch):
  145. """#2708: this source hands a path to ffmpeg and never holds the bytes, so
  146. it was the one finish-photo source that ignored camera_rotation entirely.
  147. A built-in-camera print with a timelapse prefers this source over the live
  148. grab, so leaving it out meant the orientation depended on which source won.
  149. """
  150. import io
  151. from PIL import Image
  152. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  153. video_relpath = Path("archive/1/print/timelapse.mp4")
  154. video_abspath = tmp_path / video_relpath
  155. video_abspath.parent.mkdir(parents=True, exist_ok=True)
  156. video_abspath.write_bytes(b"x" * 100)
  157. async def fake_extract(src, dst):
  158. buf = io.BytesIO()
  159. Image.new("RGB", (64, 32), (0, 0, 255)).save(buf, format="JPEG")
  160. dst.write_bytes(buf.getvalue())
  161. return True
  162. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
  163. patched_session.timelapse_path = str(video_relpath)
  164. with patch("backend.app.services.camera.extract_video_last_frame", new=fake_extract):
  165. result, _ = await _capture_finish_photo_from_timelapse(
  166. archive_id=42,
  167. archive_dir=tmp_path / "archive_dir",
  168. rotation=90,
  169. )
  170. assert result is not None
  171. written = tmp_path / "archive_dir" / "photos" / result
  172. # 64x32 turned a quarter turn: the file on disk is the rotated one, not
  173. # what ffmpeg wrote.
  174. assert Image.open(io.BytesIO(written.read_bytes())).size == (32, 64)
  175. async def test_extracted_frame_is_untouched_without_a_rotation(tmp_path: Path, patched_session, monkeypatch):
  176. """The default path must not decode and re-encode ffmpeg's output for
  177. nothing — that would cost a generation of JPEG quality on every print."""
  178. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  179. video_relpath = Path("archive/1/print/timelapse.mp4")
  180. video_abspath = tmp_path / video_relpath
  181. video_abspath.parent.mkdir(parents=True, exist_ok=True)
  182. video_abspath.write_bytes(b"x" * 100)
  183. extracted = b"\xff\xd8" + b"\x00" * 50
  184. async def fake_extract(src, dst):
  185. dst.write_bytes(extracted)
  186. return True
  187. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
  188. patched_session.timelapse_path = str(video_relpath)
  189. with patch("backend.app.services.camera.extract_video_last_frame", new=fake_extract):
  190. result, _ = await _capture_finish_photo_from_timelapse(
  191. archive_id=42,
  192. archive_dir=tmp_path / "archive_dir",
  193. )
  194. assert (tmp_path / "archive_dir" / "photos" / result).read_bytes() == extracted