test_fallback_timelapse_baseline_2957.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. """Does a no-3MF fallback archive ever get its timelapse? (#2957 follow-up)
  2. The reporter of #2957 confirmed the archive recovery works and then noticed the
  3. timelapse is not recovered with it, "even though it's there".
  4. ``_capture_timelapse_baseline_at_start`` says in its own docstring that it must
  5. be called from every ``on_print_start`` path that proceeds to a real print, and
  6. what breaks when it is not: the completion scan falls back to snapshotting the
  7. card *after* the video has landed, so the new file ends up inside the baseline
  8. and no diff can ever match. ``on_print_start`` has three such paths. The
  9. new-archive and expected-archive branches call it. The fallback-archive branch
  10. does not, and nothing else covers it -- ``on_print_running_observed`` is
  11. restart-recovery only and is suppressed whenever ``on_print_start`` fires.
  12. So a fallback archive reaches completion with no baseline in memory and none on
  13. the row. These tests pin what happens then. Both drive the scan the way
  14. ``on_print_complete`` does for such an archive: ``_timelapse_baselines.pop``
  15. misses, so ``baseline_names`` is None.
  16. Two cases, split by whether the five-minute FTPS cool-off that caused the
  17. fallback has expired by the time the print ends:
  18. * Longer than the cool-off -- the card is readable at completion, and the
  19. self-taken baseline swallows the new video.
  20. * Shorter than the cool-off -- the card is *not* readable, so the baseline is
  21. empty rather than merely late, and every video on the card reads as new once
  22. the cool-off clears inside the poll window.
  23. The fix is two parts. The fallback branch now takes the same baseline as the
  24. other two whenever the card is readable, and an *empty* listing taken while the
  25. card is unreadable is no longer believed -- ``list_files_async`` answers [] when
  26. its connect fails rather than raising, so it looks exactly like an empty card.
  27. """
  28. from __future__ import annotations
  29. import time
  30. from unittest.mock import AsyncMock, patch
  31. import pytest
  32. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  33. from backend.app.models.archive import PrintArchive
  34. from backend.app.models.printer import Printer
  35. pytestmark = pytest.mark.asyncio
  36. PRINTER_IP = "172.25.12.149"
  37. OLD_VIDEO = "video_2019-01-01_00-00-00.mp4"
  38. NEW_VIDEO = "video_2026-08-27_01-30-00.mp4"
  39. def _entry(name: str) -> dict:
  40. return {"name": name, "size": 1024, "is_directory": False, "path": f"/timelapse/{name}"}
  41. async def _seed(engine) -> tuple[async_sessionmaker, int, int]:
  42. """A printer plus the empty fallback archive, exactly as the cool-off
  43. branch of ``on_print_start`` writes it -- note ``timelapse_baseline`` is
  44. never set there, which is the whole point."""
  45. maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  46. async with maker() as db:
  47. printer = Printer(
  48. name="P1S",
  49. serial_number="01P00A3B1200579",
  50. ip_address=PRINTER_IP,
  51. access_code="12345678",
  52. model="P1S",
  53. )
  54. db.add(printer)
  55. await db.commit()
  56. await db.refresh(printer)
  57. archive = PrintArchive(
  58. printer_id=printer.id,
  59. filename="Desktop_Goose.gcode.3mf",
  60. file_path="",
  61. file_size=0,
  62. print_name="Desktop_Goose",
  63. status="completed",
  64. extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"},
  65. )
  66. db.add(archive)
  67. await db.commit()
  68. await db.refresh(archive)
  69. assert archive.timelapse_baseline is None, "the fallback branch never captures one"
  70. return maker, printer.id, archive.id
  71. def _patches(main_module, maker, monkeypatch, tmp_path, listing):
  72. """Shrink the poll to test speed and stand in for the FTP layer.
  73. ``listing`` is called per request and returns what ``/timelapse`` holds at
  74. that moment, so a test can let the cool-off expire mid-poll.
  75. """
  76. from backend.app.core.config import settings as app_config
  77. from backend.app.services import bambu_ftp
  78. # archive_dir is its own setting rather than derived, so both have to move
  79. # or attach_timelapse writes under the real one and then fails its
  80. # relative_to(base_dir).
  81. monkeypatch.setattr(app_config, "base_dir", tmp_path)
  82. monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
  83. monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_FIRST_DELAY_SECONDS", 0.05)
  84. monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS", 0.05)
  85. monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_TIMEOUT_SECONDS", 3.0)
  86. async def _list(ip, code, path, printer_model=None):
  87. if path != "/timelapse":
  88. return []
  89. return listing()
  90. monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
  91. monkeypatch.setattr(bambu_ftp, "download_file_bytes_async", AsyncMock(return_value=b"x" * 1024))
  92. monkeypatch.setattr(bambu_ftp, "remote_file_settled", AsyncMock(return_value=True))
  93. monkeypatch.setattr(bambu_ftp, "delete_archived_timelapse", AsyncMock(return_value=True))
  94. return patch.object(main_module, "async_session", maker)
  95. class TestTheCooloffOutlastsThePrint:
  96. """Print shorter than the five-minute cool-off, so the card is still
  97. unreadable when the scan takes its baseline."""
  98. async def test_an_unclaimed_old_video_is_attached_to_this_print(self, test_engine, tmp_path, monkeypatch):
  99. from backend.app import main as main_module
  100. from backend.app.services.bambu_ftp import BambuFTPClient as BambuFTP
  101. maker, printer_id, archive_id = await _seed(test_engine)
  102. # The real cool-off, armed the way a failed TLS handshake arms it.
  103. # Short enough to expire inside the poll window, as it does on a print
  104. # that ends before the five minutes are up.
  105. BambuFTP._handshake_blocked_until[PRINTER_IP] = time.monotonic() + 0.25
  106. def listing():
  107. if BambuFTP.handshake_blocked(PRINTER_IP):
  108. # What the real path yields while blocked: list_files_async
  109. # returns [] when its connect fails rather than raising, so this
  110. # is indistinguishable from a card holding no videos.
  111. return []
  112. # The printer wrote this print's video at completion; the older one
  113. # was already there and belongs to no archive.
  114. return [_entry(OLD_VIDEO), _entry(NEW_VIDEO)]
  115. try:
  116. with _patches(main_module, maker, monkeypatch, tmp_path, listing):
  117. await main_module._scan_for_timelapse_with_retries(archive_id, None)
  118. finally:
  119. BambuFTP._handshake_blocked_until.pop(PRINTER_IP, None)
  120. async with maker() as db:
  121. archive = await db.get(PrintArchive, archive_id)
  122. attached = archive.timelapse_path
  123. # Before the fix the empty baseline licensed both videos as "new", the
  124. # first in listing order won, and the stale video was attached to this
  125. # print and then deleted off the printer. With no baseline to tell them
  126. # apart, both now stay on the printer for manual selection.
  127. assert attached is None, f"expected no attach, got {attached!r}"
  128. async def test_a_lone_video_still_resolves(self, test_engine, tmp_path, monkeypatch):
  129. """The steady state, and why the scan must not simply abort here:
  130. Bambuddy deletes each video from the printer once it is attached, so the
  131. usual card holds exactly this print's video and nothing else. One
  132. unclaimed candidate is unambiguous with or without a readable baseline,
  133. and aborting would lose the common case to protect the rare one."""
  134. from backend.app import main as main_module
  135. from backend.app.services.bambu_ftp import BambuFTPClient as BambuFTP
  136. maker, printer_id, archive_id = await _seed(test_engine)
  137. BambuFTP._handshake_blocked_until[PRINTER_IP] = time.monotonic() + 0.25
  138. def listing():
  139. if BambuFTP.handshake_blocked(PRINTER_IP):
  140. return []
  141. return [_entry(NEW_VIDEO)]
  142. try:
  143. with _patches(main_module, maker, monkeypatch, tmp_path, listing):
  144. await main_module._scan_for_timelapse_with_retries(archive_id, None)
  145. finally:
  146. BambuFTP._handshake_blocked_until.pop(PRINTER_IP, None)
  147. async with maker() as db:
  148. attached = (await db.get(PrintArchive, archive_id)).timelapse_path
  149. assert attached is not None, "one unclaimed video needs no baseline to disambiguate"
  150. assert NEW_VIDEO in attached, f"expected this print's video, got {attached!r}"
  151. class TestTheCardWasReadableAtPrintStart:
  152. """The reporter's case, and every fallback archive on an H2/P2S: FTPS is
  153. healthy, so the fallback branch now takes a baseline like the other two."""
  154. async def test_the_persisted_baseline_picks_this_prints_video(self, test_engine, tmp_path, monkeypatch):
  155. from backend.app import main as main_module
  156. maker, printer_id, archive_id = await _seed(test_engine)
  157. # What the fallback branch now writes at print start: the card as it was
  158. # before this print, holding only the older video.
  159. async with maker() as db:
  160. archive = await db.get(PrintArchive, archive_id)
  161. archive.timelapse_baseline = [OLD_VIDEO]
  162. await db.commit()
  163. # By completion the printer has written this print's video alongside it.
  164. def listing():
  165. return [_entry(OLD_VIDEO), _entry(NEW_VIDEO)]
  166. with _patches(main_module, maker, monkeypatch, tmp_path, listing):
  167. await main_module._scan_for_timelapse_with_retries(archive_id, None)
  168. async with maker() as db:
  169. attached = (await db.get(PrintArchive, archive_id)).timelapse_path
  170. assert attached is not None, "the baseline makes this print's video the only new one"
  171. assert NEW_VIDEO in attached, f"expected this print's video, got {attached!r}"
  172. class TestTheBaselineCaptureItself:
  173. """Part one, at the source: what the fallback branch calls."""
  174. async def test_an_unreadable_card_still_records_the_empty_baseline(self, test_engine, tmp_path, monkeypatch):
  175. """Deliberately ``[]`` rather than NULL. NULL sends completion off to
  176. take its own snapshot, by which point this print's video is on the card
  177. and gets swallowed by the very baseline meant to exclude it. The
  178. ambiguity an unread card creates is handled at the attach step."""
  179. from backend.app import main as main_module
  180. from backend.app.services.bambu_ftp import BambuFTPClient as BambuFTP
  181. maker, printer_id, archive_id = await _seed(test_engine)
  182. BambuFTP._handshake_blocked_until[PRINTER_IP] = time.monotonic() + 60
  183. async with maker() as db:
  184. printer = await db.get(Printer, printer_id)
  185. try:
  186. with _patches(main_module, maker, monkeypatch, tmp_path, lambda: []):
  187. await main_module._capture_timelapse_baseline_at_start(
  188. printer, printer_id, main_module.logging.getLogger(__name__), archive_id=archive_id
  189. )
  190. finally:
  191. BambuFTP._handshake_blocked_until.pop(PRINTER_IP, None)
  192. async with maker() as db:
  193. assert (await db.get(PrintArchive, archive_id)).timelapse_baseline == []
  194. assert main_module._timelapse_baselines[printer_id] == set()
  195. main_module._timelapse_baselines.pop(printer_id, None)
  196. async def test_a_readable_card_is_captured_and_persisted(self, test_engine, tmp_path, monkeypatch):
  197. from backend.app import main as main_module
  198. maker, printer_id, archive_id = await _seed(test_engine)
  199. async with maker() as db:
  200. printer = await db.get(Printer, printer_id)
  201. try:
  202. with _patches(main_module, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_VIDEO)]):
  203. await main_module._capture_timelapse_baseline_at_start(
  204. printer, printer_id, main_module.logging.getLogger(__name__), archive_id=archive_id
  205. )
  206. async with maker() as db:
  207. assert (await db.get(PrintArchive, archive_id)).timelapse_baseline == [OLD_VIDEO]
  208. assert main_module._timelapse_baselines[printer_id] == {OLD_VIDEO}
  209. finally:
  210. main_module._timelapse_baselines.pop(printer_id, None)