test_finish_photo_from_timelapse.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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 = await _capture_finish_photo_from_timelapse(
  49. archive_id=42,
  50. archive_dir=tmp_path,
  51. )
  52. assert result is None
  53. async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_session, monkeypatch):
  54. """Simulate the timelapse landing after one poll cycle and extraction
  55. succeeding — should return a filename matching the finish_*.jpg pattern."""
  56. # Lay down a stub timelapse file relative to base_dir so the path
  57. # join works the way the helper expects.
  58. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  59. video_relpath = Path("archive/1/print/timelapse.mp4")
  60. video_abspath = tmp_path / video_relpath
  61. video_abspath.parent.mkdir(parents=True, exist_ok=True)
  62. video_abspath.write_bytes(b"x" * 100) # non-empty so the size check passes
  63. # Patch extraction to succeed unconditionally — the actual ffmpeg
  64. # codepath has its own test file.
  65. async def fake_extract(src, dst):
  66. dst.write_bytes(b"\xff\xd8" + b"\x00" * 50) # JPEG SOI
  67. return True
  68. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
  69. # Flip the archive into the "timelapse landed" state before the first
  70. # poll — the helper picks it up on its initial read.
  71. patched_session.timelapse_path = str(video_relpath)
  72. with patch(
  73. "backend.app.services.camera.extract_video_last_frame",
  74. new=fake_extract,
  75. ):
  76. result = await _capture_finish_photo_from_timelapse(
  77. archive_id=42,
  78. archive_dir=tmp_path / "archive_dir",
  79. )
  80. assert result is not None
  81. assert result.startswith("finish_")
  82. assert result.endswith(".jpg")
  83. assert (tmp_path / "archive_dir" / "photos" / result).exists()
  84. async def test_returns_none_when_extraction_fails(tmp_path: Path, patched_session, monkeypatch):
  85. """Timelapse landed but ffmpeg said no — we don't keep retrying on the
  86. same broken file; return None so the caller falls back."""
  87. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  88. video_relpath = Path("archive/1/print/timelapse.mp4")
  89. video_abspath = tmp_path / video_relpath
  90. video_abspath.parent.mkdir(parents=True, exist_ok=True)
  91. video_abspath.write_bytes(b"x" * 100)
  92. async def fake_extract_fails(src, dst):
  93. return False
  94. patched_session.timelapse_path = str(video_relpath)
  95. with patch(
  96. "backend.app.services.camera.extract_video_last_frame",
  97. new=fake_extract_fails,
  98. ):
  99. result = await _capture_finish_photo_from_timelapse(
  100. archive_id=42,
  101. archive_dir=tmp_path / "archive_dir",
  102. )
  103. assert result is None
  104. async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeypatch):
  105. """timelapse_path is set, but the file isn't on disk yet (the attach
  106. background task hasn't finished writing). Should keep polling — and
  107. succeed once the file materialises."""
  108. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  109. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.05)
  110. monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 1.0)
  111. video_relpath = Path("archive/1/print/timelapse.mp4")
  112. patched_session.timelapse_path = str(video_relpath)
  113. # File not present yet. Schedule it to land after ~150ms.
  114. import asyncio
  115. async def materialise_later():
  116. await asyncio.sleep(0.15)
  117. video_abspath = tmp_path / video_relpath
  118. video_abspath.parent.mkdir(parents=True, exist_ok=True)
  119. video_abspath.write_bytes(b"x" * 100)
  120. async def fake_extract(src, dst):
  121. dst.write_bytes(b"\xff\xd8")
  122. return True
  123. materialise = asyncio.create_task(materialise_later())
  124. try:
  125. with patch(
  126. "backend.app.services.camera.extract_video_last_frame",
  127. new=fake_extract,
  128. ):
  129. result = await _capture_finish_photo_from_timelapse(
  130. archive_id=42,
  131. archive_dir=tmp_path / "archive_dir",
  132. )
  133. finally:
  134. materialise.cancel()
  135. assert result is not None
  136. assert result.startswith("finish_")