test_send_all_timelapse_per_plate_3275.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. """#3275: starting plate 2 of a Send All deleted plate 1's only timelapse.
  2. Every plate of a Send All shares one archive. Print start cleared the archive's
  3. timelapse and unlinked the file so a reprint would not reuse the previous run's
  4. video (#1707) -- but for the next plate that file is the only copy of another
  5. plate's run, the printer's having been deleted once it attached. Print start
  6. now unlinks only when the same plate prints again, and attaching never writes
  7. over a video the archive no longer points at.
  8. """
  9. from __future__ import annotations
  10. from pathlib import Path
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from backend.app.core.config import settings as app_settings
  14. from backend.app.main import register_expected_print
  15. from backend.app.services.archive import ArchiveService
  16. from backend.tests.unit.test_reprint_clears_stale_timelapse import _build_mocks, _clear_dicts, _patches # noqa: F401
  17. RELPATH = "archives/42/video_plate1.mp4"
  18. def _archive(timelapse_plate_id):
  19. archive = MagicMock()
  20. archive.id = 42
  21. archive.filename = "Plates.3mf"
  22. archive.subtask_id = None
  23. archive.print_time_seconds = None
  24. archive.created_by_id = None
  25. archive.printer_id = 1
  26. archive.print_name = "Plates"
  27. archive.status = "completed"
  28. archive.file_path = "archives/42/Plates.3mf"
  29. archive.energy_start_kwh = None
  30. archive.timelapse_path = RELPATH
  31. archive.timelapse_plate_id = timelapse_plate_id
  32. return archive
  33. async def _start(tmp_path: Path, archive, plate_id: int) -> Path:
  34. """Start ``plate_id`` of the shared archive; returns the old video's path."""
  35. video = tmp_path / RELPATH
  36. video.parent.mkdir(parents=True, exist_ok=True)
  37. video.write_bytes(b"plate 1 video")
  38. printer = MagicMock(id=1, auto_archive=True, external_camera_enabled=False, external_camera_url=None)
  39. register_expected_print(1, "Plates.3mf", archive_id=42, ams_mapping=None, plate_id=plate_id)
  40. session = _build_mocks(printer, archive)
  41. (session_p, notif_p, plug_p, ws_p, pm_p, relay_p, *rest) = _patches()
  42. with (
  43. session_p as session_maker,
  44. notif_p as notif,
  45. plug_p as plug,
  46. ws_p as ws,
  47. pm_p as pm,
  48. relay_p as relay,
  49. rest[0],
  50. rest[1],
  51. rest[2],
  52. rest[3],
  53. rest[4],
  54. patch.object(app_settings, "base_dir", tmp_path),
  55. ):
  56. session_maker.return_value = session
  57. notif.on_print_start = AsyncMock()
  58. plug.on_print_start = AsyncMock()
  59. ws.send_print_start = AsyncMock()
  60. ws.send_archive_updated = AsyncMock()
  61. relay.on_print_start = AsyncMock()
  62. pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  63. from backend.app.main import on_print_start
  64. await on_print_start(1, {"filename": "Plates.3mf", "subtask_name": "Plates"})
  65. return video
  66. class TestPrintStart:
  67. @pytest.mark.asyncio
  68. async def test_the_next_plate_keeps_the_previous_plates_video(self, tmp_path):
  69. archive = _archive(timelapse_plate_id=1)
  70. video = await _start(tmp_path, archive, plate_id=2)
  71. assert video.read_bytes() == b"plate 1 video"
  72. # The card still starts fresh, so plate 2's own video gets attached.
  73. assert archive.timelapse_path is None
  74. assert archive.timelapse_plate_id is None
  75. @pytest.mark.asyncio
  76. async def test_the_same_plate_again_still_replaces_it(self, tmp_path):
  77. archive = _archive(timelapse_plate_id=2)
  78. video = await _start(tmp_path, archive, plate_id=2)
  79. assert not video.exists()
  80. assert archive.timelapse_path is None
  81. @pytest.mark.asyncio
  82. async def test_a_video_of_unknown_plate_is_kept(self, tmp_path):
  83. """Attached by hand or before the plate was recorded: it cannot be told
  84. apart from another plate's, so it is not deleted."""
  85. archive = _archive(timelapse_plate_id=None)
  86. video = await _start(tmp_path, archive, plate_id=1)
  87. assert video.exists()
  88. assert archive.timelapse_path is None
  89. def _service(tmp_path: Path, monkeypatch, current: str | None):
  90. archive_dir = tmp_path / "archive" / "1" / "20260101_plates"
  91. archive_dir.mkdir(parents=True)
  92. fake_settings = MagicMock(base_dir=tmp_path, archive_dir=tmp_path / "archive")
  93. monkeypatch.setattr("backend.app.services.archive.settings", fake_settings)
  94. monkeypatch.setattr("backend.app.utils.archive_paths.settings", fake_settings)
  95. # The .avi case hands over the MP4 conversion; close it unrun.
  96. monkeypatch.setattr("backend.app.services.archive.spawn_background_task", lambda coro, **_: coro.close())
  97. db = MagicMock()
  98. db.commit = AsyncMock()
  99. service = ArchiveService(db)
  100. archive = MagicMock()
  101. archive.file_path = "archive/1/20260101_plates/Plates.3mf"
  102. archive.timelapse_path = current
  103. archive.timelapse_plate_id = None
  104. service.get_archive = AsyncMock(return_value=archive)
  105. return service, archive, archive_dir
  106. class TestAttach:
  107. @pytest.mark.asyncio
  108. async def test_records_the_plate(self, tmp_path, monkeypatch):
  109. service, archive, _ = _service(tmp_path, monkeypatch, current=None)
  110. assert await service.attach_timelapse(1, b"v", "video_a.mp4", plate_id=2)
  111. assert archive.timelapse_plate_id == 2
  112. @pytest.mark.asyncio
  113. async def test_a_manual_attach_records_no_plate(self, tmp_path, monkeypatch):
  114. service, archive, _ = _service(tmp_path, monkeypatch, current=None)
  115. archive.timelapse_plate_id = 1
  116. assert await service.attach_timelapse(1, b"v", "video_a.mp4")
  117. assert archive.timelapse_plate_id is None
  118. @pytest.mark.asyncio
  119. async def test_does_not_write_over_a_kept_video(self, tmp_path, monkeypatch):
  120. """The external-camera stitch always names its output
  121. layer_timelapse.mp4, so plate 2's would land on plate 1's."""
  122. service, archive, archive_dir = _service(tmp_path, monkeypatch, current=None)
  123. kept = archive_dir / "layer_timelapse.mp4"
  124. kept.write_bytes(b"plate 1")
  125. assert await service.attach_timelapse(1, b"plate 2", "layer_timelapse.mp4", plate_id=2)
  126. assert kept.read_bytes() == b"plate 1"
  127. assert (archive_dir / "layer_timelapse_2.mp4").read_bytes() == b"plate 2"
  128. assert archive.timelapse_path == "archive/1/20260101_plates/layer_timelapse_2.mp4"
  129. @pytest.mark.asyncio
  130. async def test_an_avi_does_not_convert_over_a_kept_mp4(self, tmp_path, monkeypatch):
  131. service, archive, archive_dir = _service(tmp_path, monkeypatch, current=None)
  132. (archive_dir / "video_a.mp4").write_bytes(b"plate 1")
  133. assert await service.attach_timelapse(1, b"plate 2", "video_a.avi", plate_id=2)
  134. assert archive.timelapse_path == "archive/1/20260101_plates/video_a_2.avi"
  135. @pytest.mark.asyncio
  136. async def test_re_attaching_the_current_video_still_replaces_it(self, tmp_path, monkeypatch):
  137. service, archive, archive_dir = _service(
  138. tmp_path, monkeypatch, current="archive/1/20260101_plates/layer_timelapse.mp4"
  139. )
  140. (archive_dir / "layer_timelapse.mp4").write_bytes(b"old")
  141. assert await service.attach_timelapse(1, b"new", "layer_timelapse.mp4")
  142. assert (archive_dir / "layer_timelapse.mp4").read_bytes() == b"new"
  143. assert not (archive_dir / "layer_timelapse_2.mp4").exists()
  144. class TestScan:
  145. @pytest.mark.asyncio
  146. async def test_the_completed_runs_plate_reaches_the_attach(self):
  147. from backend.app.main import _attach_first_unclaimed_timelapse
  148. from backend.tests.unit.test_timelapse_scan_2704 import _printer, _session, _video, logger
  149. service = MagicMock()
  150. service.attach_timelapse = AsyncMock(return_value=True)
  151. with (
  152. patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=b"x" * 1000)),
  153. patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
  154. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock(return_value=True)),
  155. patch("backend.app.main.async_session", return_value=_session()),
  156. patch("backend.app.main.ArchiveService", return_value=service),
  157. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
  158. ):
  159. assert await _attach_first_unclaimed_timelapse(
  160. 42, _printer(), [_video("video_b.avi")], set(), set(), 1, logger, plate_id=2
  161. )
  162. assert service.attach_timelapse.await_args.kwargs["plate_id"] == 2
  163. class TestEviction:
  164. """Print start leaves the "<base>.gcode" registration behind. Its eviction
  165. two hours after dispatch used to drop a running print's plate, so a long
  166. print's video was attached with no plate on record."""
  167. def _register_and_start(self):
  168. import time
  169. import backend.app.main as m
  170. register_expected_print(1, "Plates.3mf", archive_id=42, ams_mapping=[0], plate_id=2)
  171. # What print start pops for filename "Plates.3mf", subtask "Plates".
  172. for key in [(1, "Plates"), (1, "Plates.3mf"), (1, "Plates.gcode.3mf")]:
  173. m._expected_prints.pop(key, None)
  174. m._expected_print_registered_at.pop(key, None)
  175. assert (1, "Plates.gcode") in m._expected_prints
  176. for key in list(m._expected_print_registered_at):
  177. m._expected_print_registered_at[key] = time.monotonic() - m._EXPECTED_PRINT_TTL_SECONDS - 1
  178. return m
  179. def test_a_running_print_keeps_its_plate(self):
  180. m = self._register_and_start()
  181. m._active_prints[(1, "Plates.3mf")] = 42
  182. m._evict_stale_expected_prints()
  183. assert (1, "Plates.gcode") not in m._expected_prints
  184. assert m._print_plate_ids[42] == 2
  185. assert m._print_ams_mappings[42] == [0]
  186. def test_a_print_that_never_started_still_releases_it(self):
  187. m = self._register_and_start()
  188. m._evict_stale_expected_prints()
  189. assert 42 not in m._print_plate_ids
  190. assert 42 not in m._print_ams_mappings