test_timelapse_per_run.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. """Each print of an archive keeps its own timelapse and photos (archive redesign P1b).
  2. Plates of one archive printed on two printers are two runs. Each run's scan,
  3. layer stitch and finish photo find its own video by the run's id, and the
  4. archive shows its newest run's. A reprint of a plate still replaces that
  5. plate's earlier video, as when the archive held the only one.
  6. """
  7. from __future__ import annotations
  8. import logging
  9. from datetime import datetime, timedelta
  10. from types import SimpleNamespace
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  14. from backend.app.core.config import settings as app_settings
  15. from backend.app.models.archive import PrintArchive
  16. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
  17. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  18. T1 = datetime(2026, 10, 30, 9)
  19. T2 = T1 + timedelta(hours=1)
  20. ARCHIVE_DIR = "archives/test"
  21. logger = logging.getLogger(__name__)
  22. @pytest.fixture
  23. def files(tmp_path, monkeypatch):
  24. """The data directory, in tmp_path rather than the real one."""
  25. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  26. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  27. (tmp_path / ARCHIVE_DIR).mkdir(parents=True)
  28. return tmp_path
  29. @pytest.fixture
  30. def sessions(test_engine):
  31. """The sessions main and the services open, on the test database."""
  32. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  33. with (
  34. patch("backend.app.main.async_session", maker),
  35. patch("backend.app.core.database.async_session", maker),
  36. patch("backend.app.services.archive._announce_timelapse", AsyncMock()),
  37. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
  38. ):
  39. yield maker
  40. @pytest.fixture
  41. async def two_plates(db_session, printer_factory, files, sessions):
  42. """One archive: plate 1 printed on A, then plate 2 started on B since.
  43. Neither has a video yet."""
  44. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  45. archive = PrintArchive(
  46. printer_id=b.id,
  47. filename="two.gcode.3mf",
  48. file_path=f"{ARCHIVE_DIR}/two.gcode.3mf",
  49. file_size=1,
  50. print_name="two",
  51. status="completed",
  52. started_at=T2,
  53. )
  54. db_session.add(archive)
  55. await db_session.flush()
  56. first = PrintLogEntry(
  57. archive_id=archive.id, printer_id=a.id, plate_id=1, status="completed", started_at=T1, photos=[]
  58. )
  59. second = PrintLogEntry(
  60. archive_id=archive.id, printer_id=b.id, plate_id=2, status="completed", started_at=T2, photos=[]
  61. )
  62. db_session.add_all([first, second])
  63. await db_session.commit()
  64. return SimpleNamespace(archive=archive.id, a=a.id, b=b.id, first=first.id, second=second.id, files=files)
  65. async def _row(db_session, model, row_id):
  66. db_session.expire_all()
  67. return await db_session.get(model, row_id)
  68. async def _set(db_session, model, row_id, **values):
  69. row = await _row(db_session, model, row_id)
  70. for name, value in values.items():
  71. setattr(row, name, value)
  72. await db_session.commit()
  73. async def _attach(archive_id, data, name, **kwargs):
  74. from backend.app.core.database import async_session
  75. from backend.app.services.archive import ArchiveService
  76. async with async_session() as db:
  77. return await ArchiveService(db).attach_timelapse(archive_id, data, name, **kwargs)
  78. class TestAttach:
  79. async def test_an_earlier_plates_video_is_its_runs_only(self, db_session, two_plates):
  80. assert await _attach(two_plates.archive, b"one", "video_1.mp4", plate_id=1, run_id=two_plates.first)
  81. first = await _row(db_session, PrintLogEntry, two_plates.first)
  82. assert first.timelapse_path == f"{ARCHIVE_DIR}/video_1.mp4"
  83. archive = await _row(db_session, PrintArchive, two_plates.archive)
  84. assert archive.timelapse_path is None
  85. assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path is None
  86. async def test_the_newest_runs_video_is_the_archives_too(self, db_session, two_plates):
  87. assert await _attach(two_plates.archive, b"two", "video_2.mp4", plate_id=2, run_id=two_plates.second)
  88. archive = await _row(db_session, PrintArchive, two_plates.archive)
  89. assert (archive.timelapse_path, archive.timelapse_plate_id) == (f"{ARCHIVE_DIR}/video_2.mp4", 2)
  90. assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
  91. async def test_a_printing_run_is_the_newest(self, db_session, two_plates):
  92. """A print starting clears the archive's video: plate 2's landing
  93. after plate 3 started is not the archive's."""
  94. db_session.add(
  95. PrintLogEntry(
  96. archive_id=two_plates.archive,
  97. printer_id=two_plates.a,
  98. status=RUN_OPEN,
  99. started_at=T2 + timedelta(hours=1),
  100. )
  101. )
  102. await db_session.commit()
  103. assert await _attach(two_plates.archive, b"two", "video_2.mp4", run_id=two_plates.second)
  104. assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path is None
  105. async def test_one_attached_by_hand_is_the_newest_runs(self, db_session, two_plates):
  106. assert await _attach(two_plates.archive, b"hand", "picked.mp4")
  107. assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path == f"{ARCHIVE_DIR}/picked.mp4"
  108. assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/picked.mp4"
  109. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
  110. async def test_a_gone_run_gets_nothing(self, db_session, two_plates):
  111. assert not await _attach(two_plates.archive, b"x", "video_9.mp4", run_id=999999)
  112. assert not (two_plates.files / ARCHIVE_DIR / "video_9.mp4").exists()
  113. async def test_does_not_write_over_another_runs_video(self, db_session, two_plates):
  114. """The external-camera stitch always names its output layer_timelapse.mp4."""
  115. assert await _attach(two_plates.archive, b"one", "layer_timelapse.mp4", run_id=two_plates.first)
  116. assert await _attach(two_plates.archive, b"two", "layer_timelapse.mp4", run_id=two_plates.second)
  117. assert (two_plates.files / ARCHIVE_DIR / "layer_timelapse.mp4").read_bytes() == b"one"
  118. second = await _row(db_session, PrintLogEntry, two_plates.second)
  119. assert second.timelapse_path == f"{ARCHIVE_DIR}/layer_timelapse_2.mp4"
  120. async def test_an_earlier_runs_stitch_keeps_the_archives_video(self, db_session, two_plates):
  121. """Plate 2's stitch is the archive's; plate 1's, done later under the
  122. same name, is plate 1's own file."""
  123. assert await _attach(two_plates.archive, b"two", "layer_timelapse.mp4", run_id=two_plates.second)
  124. assert await _attach(two_plates.archive, b"one", "layer_timelapse.mp4", run_id=two_plates.first)
  125. assert (two_plates.files / ARCHIVE_DIR / "layer_timelapse.mp4").read_bytes() == b"two"
  126. first = await _row(db_session, PrintLogEntry, two_plates.first)
  127. assert first.timelapse_path == f"{ARCHIVE_DIR}/layer_timelapse_2.mp4"
  128. class TestConversion:
  129. async def _convert(self, archive_id, source):
  130. from backend.app.services.archive import _convert_timelapse_to_mp4
  131. process = AsyncMock(returncode=0)
  132. process.communicate = AsyncMock(return_value=(b"", b""))
  133. source.with_suffix(".mp4").write_bytes(b"mp4")
  134. with (
  135. patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  136. patch("asyncio.create_subprocess_exec", AsyncMock(return_value=process)),
  137. ):
  138. await _convert_timelapse_to_mp4(archive_id, source)
  139. async def test_an_earlier_runs_video_is_converted_for_it(self, db_session, two_plates):
  140. source = two_plates.files / ARCHIVE_DIR / "video_1.avi"
  141. source.write_bytes(b"avi")
  142. await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.avi")
  143. await self._convert(two_plates.archive, source)
  144. first = await _row(db_session, PrintLogEntry, two_plates.first)
  145. assert first.timelapse_path == f"{ARCHIVE_DIR}/video_1.mp4"
  146. assert (two_plates.files / ARCHIVE_DIR / "video_1.mp4").exists()
  147. assert not source.exists()
  148. assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path is None
  149. async def test_the_archive_and_its_run_both_move(self, db_session, two_plates):
  150. source = two_plates.files / ARCHIVE_DIR / "video_2.avi"
  151. source.write_bytes(b"avi")
  152. await _set(db_session, PrintLogEntry, two_plates.second, timelapse_path=f"{ARCHIVE_DIR}/video_2.avi")
  153. await _set(db_session, PrintArchive, two_plates.archive, timelapse_path=f"{ARCHIVE_DIR}/video_2.avi")
  154. await self._convert(two_plates.archive, source)
  155. assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
  156. assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
  157. class TestClaimed:
  158. async def test_another_plate_of_the_archive_holds_its_video(self, db_session, two_plates):
  159. from backend.app.services.print_log import claimed_timelapse_stems
  160. await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.mp4")
  161. third = PrintLogEntry(archive_id=two_plates.archive, printer_id=two_plates.a, status="completed", started_at=T2)
  162. db_session.add(third)
  163. await db_session.commit()
  164. claimed = await claimed_timelapse_stems(
  165. db_session, two_plates.a, archive_id=two_plates.archive, run_id=third.id
  166. )
  167. assert claimed == {"video_1"}
  168. own = await claimed_timelapse_stems(
  169. db_session, two_plates.a, archive_id=two_plates.archive, run_id=two_plates.first
  170. )
  171. assert own == set()
  172. async def test_without_a_run_the_archives_runs_are_its_own(self, db_session, two_plates):
  173. from backend.app.services.print_log import claimed_timelapse_stems
  174. await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.mp4")
  175. assert (
  176. await claimed_timelapse_stems(db_session, two_plates.a, archive_id=two_plates.archive, run_id=None) == set()
  177. )
  178. other = PrintArchive(filename="o.3mf", file_path="o/o.3mf", file_size=1, printer_id=two_plates.b)
  179. db_session.add(other)
  180. await db_session.commit()
  181. assert await claimed_timelapse_stems(db_session, two_plates.a, archive_id=other.id, run_id=None) == {"video_1"}
  182. def _video(name, size=3):
  183. return {"name": name, "size": size, "path": f"/timelapse/{name}"}
  184. class TestScan:
  185. async def _scan(self, plates, listings, *, baseline=None, on_list=None):
  186. from backend.app.main import _scan_for_timelapse_with_retries
  187. listed_printers = []
  188. async def list_videos(printer):
  189. listed_printers.append(printer.id)
  190. if on_list is not None:
  191. await on_list(len(listed_printers))
  192. return listings[min(len(listed_printers), len(listings)) - 1], "/timelapse"
  193. async def download(*_args, expected_size=None, **_kwargs):
  194. return b"v" * expected_size
  195. with (
  196. patch("backend.app.main._list_timelapse_videos", side_effect=list_videos),
  197. patch("backend.app.main.asyncio.sleep", AsyncMock()),
  198. patch("backend.app.main._timelapse_scan_max_attempts", return_value=3),
  199. patch("backend.app.services.bambu_ftp.download_file_bytes_async", side_effect=download),
  200. patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
  201. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
  202. ):
  203. await _scan_for_timelapse_with_retries(plates.archive, baseline, plate_id=1, run_id=plates.first)
  204. return listed_printers
  205. async def test_scans_the_printer_the_run_printed_on(self, db_session, two_plates):
  206. printers = await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
  207. assert printers == [two_plates.a]
  208. first = await _row(db_session, PrintLogEntry, two_plates.first)
  209. assert first.timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
  210. async def test_the_next_plates_start_does_not_end_the_scan(self, db_session, two_plates):
  211. async def next_plate_starts(round_no):
  212. if round_no == 1:
  213. await _set(db_session, PrintArchive, two_plates.archive, started_at=T2 + timedelta(hours=1))
  214. await self._scan(two_plates, [[], [_video("new.mp4")]], baseline=set(), on_list=next_plate_starts)
  215. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
  216. async def test_reads_the_baseline_stored_on_its_run(self, db_session, two_plates):
  217. """After a restart: the archive's baseline is plate 2's start's."""
  218. await _set(
  219. db_session, PrintLogEntry, two_plates.first, timelapse_baseline=["old.mp4"], timelapse_baseline_unread=False
  220. )
  221. await _set(db_session, PrintArchive, two_plates.archive, timelapse_baseline=["old.mp4", "new.mp4"])
  222. await self._scan(two_plates, [[_video("old.mp4"), _video("new.mp4")]])
  223. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
  224. async def test_another_plates_video_on_the_archive_does_not_stop_it(self, db_session, two_plates):
  225. await _set(db_session, PrintArchive, two_plates.archive, timelapse_path=f"{ARCHIVE_DIR}/plate2.mp4")
  226. await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
  227. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
  228. async def _later_print(self, db_session, plates, *, printer_id, plate_id):
  229. db_session.add(
  230. PrintLogEntry(
  231. archive_id=plates.archive,
  232. printer_id=printer_id,
  233. plate_id=plate_id,
  234. status=RUN_OPEN,
  235. started_at=T2 + timedelta(hours=1),
  236. )
  237. )
  238. await db_session.commit()
  239. async def test_a_reprint_of_its_plate_ends_it(self, db_session, two_plates):
  240. """The reprint's video is the plate's one (it replaces this one)."""
  241. await self._later_print(db_session, two_plates, printer_id=two_plates.b, plate_id=1)
  242. await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
  243. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
  244. async def test_the_next_print_on_its_printer_ends_it(self, db_session, two_plates):
  245. """A video landing now can be that print's."""
  246. await self._later_print(db_session, two_plates, printer_id=two_plates.a, plate_id=3)
  247. await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
  248. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
  249. async def test_an_earlier_print_of_its_plate_does_not_end_it(self, db_session, two_plates):
  250. db_session.add(
  251. PrintLogEntry(
  252. archive_id=two_plates.archive,
  253. printer_id=two_plates.a,
  254. plate_id=1,
  255. status="completed",
  256. started_at=T1 - timedelta(hours=1),
  257. )
  258. )
  259. await db_session.commit()
  260. await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
  261. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
  262. async def test_another_plate_on_another_printer_does_not_end_it(self, db_session, two_plates):
  263. await self._later_print(db_session, two_plates, printer_id=two_plates.b, plate_id=3)
  264. await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
  265. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
  266. async def test_stops_once_its_run_has_a_video(self, db_session, two_plates):
  267. await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/kept.mp4")
  268. printers = await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
  269. assert printers == []
  270. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/kept.mp4"
  271. class TestBaseline:
  272. async def test_print_start_writes_it_on_the_run(self, db_session, two_plates):
  273. from backend.app.main import _capture_timelapse_baseline_at_start
  274. run = PrintLogEntry(archive_id=two_plates.archive, printer_id=two_plates.b, status=RUN_OPEN, started_at=T2)
  275. db_session.add(run)
  276. await db_session.commit()
  277. run_id = run.id
  278. with patch("backend.app.main._read_timelapse_baseline", AsyncMock(return_value=({"a.mp4"}, True))):
  279. await _capture_timelapse_baseline_at_start(MagicMock(), two_plates.b, logger, archive_id=two_plates.archive)
  280. db_session.expire_all()
  281. run = await db_session.get(PrintLogEntry, run_id, execution_options={INCLUDE_OPEN_RUNS: True})
  282. assert (run.timelapse_baseline, run.timelapse_baseline_unread) == (["a.mp4"], False)
  283. assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_baseline == ["a.mp4"]
  284. async def test_a_reread_fills_in_its_run(self, db_session, two_plates):
  285. from backend.app.main import _reread_unread_timelapse_baseline
  286. await _set(db_session, PrintLogEntry, two_plates.first, timelapse_baseline=[], timelapse_baseline_unread=True)
  287. with (
  288. patch("backend.app.main._TIMELAPSE_BASELINE_REREAD_INTERVAL_SECONDS", 0),
  289. patch("backend.app.main._unread_baseline_round", return_value=True),
  290. patch("backend.app.main._read_timelapse_baseline", AsyncMock(return_value=({"a.mp4"}, True))),
  291. ):
  292. await _reread_unread_timelapse_baseline(two_plates.a, set(), two_plates.archive, T1, two_plates.first)
  293. first = await _row(db_session, PrintLogEntry, two_plates.first)
  294. assert (first.timelapse_baseline, first.timelapse_baseline_unread) == (["a.mp4"], False)
  295. class TestLayerTimelapse:
  296. async def test_lands_on_its_run_after_the_next_plate_started(self, db_session, two_plates):
  297. from backend.app.main import _attach_layer_timelapse
  298. printer_video = two_plates.files / ARCHIVE_DIR / "video_1.mp4"
  299. printer_video.write_bytes(b"printer")
  300. await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.mp4")
  301. stitched = two_plates.files / "stitched.mp4"
  302. stitched.write_bytes(b"camera")
  303. await _attach_layer_timelapse(two_plates.archive, stitched, 1, T1, run_id=two_plates.first)
  304. first = await _row(db_session, PrintLogEntry, two_plates.first)
  305. assert first.timelapse_path == f"{ARCHIVE_DIR}/layer_timelapse.mp4"
  306. assert not printer_video.exists()
  307. assert not stitched.exists()
  308. assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path is None
  309. async def test_a_reprint_of_its_plate_drops_it(self, db_session, two_plates):
  310. from backend.app.main import _attach_layer_timelapse
  311. db_session.add(
  312. PrintLogEntry(
  313. archive_id=two_plates.archive,
  314. printer_id=two_plates.b,
  315. plate_id=1,
  316. status=RUN_OPEN,
  317. started_at=T2 + timedelta(hours=1),
  318. )
  319. )
  320. await db_session.commit()
  321. stitched = two_plates.files / "stitched.mp4"
  322. stitched.write_bytes(b"camera")
  323. await _attach_layer_timelapse(two_plates.archive, stitched, 1, T1, run_id=two_plates.first)
  324. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
  325. assert not stitched.exists()
  326. class TestFinishPhoto:
  327. async def test_waits_for_its_runs_video_not_the_archives(self, db_session, two_plates):
  328. from backend.app.main import _capture_finish_photo_from_timelapse
  329. (two_plates.files / ARCHIVE_DIR / "video_2.mp4").write_bytes(b"plate 2")
  330. await _set(db_session, PrintArchive, two_plates.archive, timelapse_path=f"{ARCHIVE_DIR}/video_2.mp4")
  331. extract = AsyncMock(return_value=True)
  332. with patch("backend.app.services.camera.extract_video_last_frame", extract):
  333. photo, pending = await _capture_finish_photo_from_timelapse(
  334. two_plates.archive, two_plates.files / ARCHIVE_DIR, timeout=0, run_id=two_plates.first
  335. )
  336. assert (photo, pending) == (None, True)
  337. extract.assert_not_awaited()
  338. async def test_the_upgrade_leads_its_runs_photos(self, db_session, two_plates):
  339. from backend.app.main import _upgrade_finish_photo_from_timelapse
  340. await _set(db_session, PrintLogEntry, two_plates.first, photos=["live.jpg"])
  341. await _set(db_session, PrintArchive, two_plates.archive, photos=["live.jpg", "plate2.jpg"])
  342. with patch(
  343. "backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=("frame.jpg", False))
  344. ) as capture:
  345. await _upgrade_finish_photo_from_timelapse(two_plates.archive, two_plates.files, run_id=two_plates.first)
  346. assert capture.await_args.kwargs["run_id"] == two_plates.first
  347. assert (await _row(db_session, PrintLogEntry, two_plates.first)).photos == ["frame.jpg", "live.jpg"]
  348. assert (await _row(db_session, PrintLogEntry, two_plates.second)).photos == []
  349. assert (await _row(db_session, PrintArchive, two_plates.archive)).photos == [
  350. "frame.jpg",
  351. "live.jpg",
  352. "plate2.jpg",
  353. ]
  354. class TestReprint:
  355. async def test_replaces_the_plates_earlier_videos_only(self, db_session, two_plates):
  356. from backend.app.main import _drop_replaced_plate_videos
  357. for run_id, name in ((two_plates.first, "video_1.mp4"), (two_plates.second, "video_2.mp4")):
  358. (two_plates.files / ARCHIVE_DIR / name).write_bytes(b"v")
  359. await _set(db_session, PrintLogEntry, run_id, timelapse_path=f"{ARCHIVE_DIR}/{name}")
  360. await _drop_replaced_plate_videos(db_session, two_plates.archive, 1, None, logger)
  361. assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
  362. assert not (two_plates.files / ARCHIVE_DIR / "video_1.mp4").exists()
  363. assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
  364. assert (two_plates.files / ARCHIVE_DIR / "video_2.mp4").exists()
  365. async def test_the_archives_video_judged_this_plates_goes_with_its_run(self, db_session, two_plates):
  366. from backend.app.main import _drop_replaced_plate_videos
  367. (two_plates.files / ARCHIVE_DIR / "old.mp4").write_bytes(b"v")
  368. await _set(db_session, PrintLogEntry, two_plates.second, timelapse_path=f"{ARCHIVE_DIR}/old.mp4")
  369. await _drop_replaced_plate_videos(db_session, two_plates.archive, None, f"{ARCHIVE_DIR}/old.mp4", logger)
  370. assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path is None
  371. assert not (two_plates.files / ARCHIVE_DIR / "old.mp4").exists()
  372. async def test_another_plates_video_is_kept_even_if_named(self, db_session, two_plates):
  373. from backend.app.main import _drop_replaced_plate_videos
  374. (two_plates.files / ARCHIVE_DIR / "video_2.mp4").write_bytes(b"v")
  375. await _set(db_session, PrintLogEntry, two_plates.second, timelapse_path=f"{ARCHIVE_DIR}/video_2.mp4")
  376. await _drop_replaced_plate_videos(db_session, two_plates.archive, 1, f"{ARCHIVE_DIR}/video_2.mp4", logger)
  377. assert (two_plates.files / ARCHIVE_DIR / "video_2.mp4").exists()
  378. assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
  379. class TestRoutes:
  380. async def test_deleting_the_video_clears_its_run(self, async_client, db_session, two_plates):
  381. (two_plates.files / ARCHIVE_DIR / "video_2.mp4").write_bytes(b"v")
  382. await _set(db_session, PrintLogEntry, two_plates.second, timelapse_path=f"{ARCHIVE_DIR}/video_2.mp4")
  383. await _set(db_session, PrintArchive, two_plates.archive, timelapse_path=f"{ARCHIVE_DIR}/video_2.mp4")
  384. response = await async_client.delete(f"/api/v1/archives/{two_plates.archive}/timelapse")
  385. assert response.status_code == 200, response.text
  386. assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path is None
  387. async def test_deleting_a_photo_takes_it_off_its_run(self, async_client, db_session, two_plates):
  388. await _set(db_session, PrintLogEntry, two_plates.first, photos=["a.jpg", "b.jpg"])
  389. await _set(db_session, PrintArchive, two_plates.archive, photos=["a.jpg", "b.jpg"])
  390. response = await async_client.delete(f"/api/v1/archives/{two_plates.archive}/photos/a.jpg")
  391. assert response.status_code == 200, response.text
  392. assert (await _row(db_session, PrintLogEntry, two_plates.first)).photos == ["b.jpg"]
  393. async def test_a_run_shows_its_own_video_and_photos(self, async_client, db_session, two_plates):
  394. await _set(db_session, PrintLogEntry, two_plates.first, photos=["one.jpg"], timelapse_path="a/one.mp4")
  395. await _set(
  396. db_session, PrintArchive, two_plates.archive, photos=["one.jpg", "two.jpg"], timelapse_path="a/two.mp4"
  397. )
  398. response = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.first}")
  399. assert response.status_code == 200, response.text
  400. body = response.json()
  401. assert (body["photos"], body["timelapse_path"]) == (["one.jpg"], "a/one.mp4")
  402. none_yet = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.second}")
  403. assert (none_yet.json()["photos"], none_yet.json()["timelapse_path"]) == (None, None)
  404. async def test_a_run_logged_before_keeps_the_archives_photos_with_a_video(
  405. self, async_client, db_session, two_plates
  406. ):
  407. """A video attached by hand to it is its own; photos it never had are the archive's."""
  408. await _set(db_session, PrintLogEntry, two_plates.second, photos=None, timelapse_path="a/picked.mp4")
  409. await _set(db_session, PrintArchive, two_plates.archive, photos=["two.jpg"], timelapse_path="a/picked.mp4")
  410. response = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.second}")
  411. assert (response.json()["photos"], response.json()["timelapse_path"]) == (["two.jpg"], "a/picked.mp4")
  412. async def test_a_run_logged_before_shows_the_archives(self, async_client, db_session, two_plates):
  413. await _set(db_session, PrintLogEntry, two_plates.second, photos=None)
  414. await _set(db_session, PrintArchive, two_plates.archive, photos=["two.jpg"], timelapse_path="a/two.mp4")
  415. response = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.second}")
  416. assert (response.json()["photos"], response.json()["timelapse_path"]) == (["two.jpg"], "a/two.mp4")
  417. async def test_a_run_opened_at_its_start_keeps_its_own_photos(db_session, two_plates):
  418. from backend.app.services.print_log import open_run
  419. archive = await _row(db_session, PrintArchive, two_plates.archive)
  420. archive.started_at = T2 + timedelta(hours=1)
  421. run = await open_run(db_session, archive, printer_id=two_plates.a)
  422. assert run is not None and run.photos == []