test_timelapse_unread_baseline_3412.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. """A print-start baseline taken off a card that could not be read (#3412).
  2. The capture at print start knew the card was unreadable -- it logged so -- and
  3. then recorded the empty listing exactly like a card that held nothing, both in
  4. memory and on the archive row. The completion scan believed either copy, so
  5. with two old videos still on the printer it attached the first of them to this
  6. print, deleted it off the printer and took the finish photo from it. The
  7. ``require_unambiguous`` guard from #2957 only covered a baseline the scan took
  8. itself at completion.
  9. Now the baseline carries whether the card was read; an unread one keeps the
  10. scan from choosing between several videos, and is read again while the print
  11. runs, since the printer writes the video only once the print has ended.
  12. """
  13. from __future__ import annotations
  14. import asyncio
  15. import logging
  16. import time
  17. from datetime import datetime
  18. from types import SimpleNamespace
  19. from unittest.mock import AsyncMock, patch
  20. import pytest
  21. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  22. from backend.app.models.archive import PrintArchive
  23. from backend.app.models.printer import Printer
  24. from backend.app.services import bambu_ftp
  25. from backend.app.services.bambu_ftp import FileListResult
  26. pytestmark = pytest.mark.asyncio
  27. PRINTER_IP = "172.25.12.150"
  28. # Two videos of earlier prints that nothing claimed, as on the reporter's X2D.
  29. OLD_A = "video_2026-08-22_20-19-02_4.mp4"
  30. OLD_B = "video_2026-08-23_09-00-00.mp4"
  31. NEW_VIDEO = "video_2026-10-24_14-00-00.mp4"
  32. STARTED = datetime(2026, 10, 24, 11, 5, 1)
  33. def _entry(name: str) -> dict:
  34. return {"name": name, "size": 1024, "is_directory": False, "path": f"/timelapse/{name}"}
  35. @pytest.fixture(autouse=True)
  36. def _forget_baselines():
  37. from backend.app import main
  38. yield
  39. main._timelapse_baselines.clear()
  40. main._timelapse_baselines_unread.clear()
  41. async def _seed(engine, *, baseline: list[str] | None = None, unread: bool = False):
  42. maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  43. async with maker() as db:
  44. printer = Printer(
  45. name="X2D", serial_number="20P9AJ5A0700123", ip_address=PRINTER_IP, access_code="12345678", model="X2D"
  46. )
  47. db.add(printer)
  48. await db.commit()
  49. await db.refresh(printer)
  50. archive = PrintArchive(
  51. printer_id=printer.id,
  52. filename="Bracket.gcode.3mf",
  53. file_path="",
  54. file_size=0,
  55. print_name="Bracket",
  56. status="printing",
  57. started_at=STARTED,
  58. timelapse_baseline=baseline,
  59. timelapse_baseline_unread=unread,
  60. )
  61. db.add(archive)
  62. await db.commit()
  63. await db.refresh(archive)
  64. return maker, printer, archive.id
  65. def _ftp(main_module, maker, monkeypatch, tmp_path, listing, *, state="RUNNING", others=None):
  66. """Stand in for the FTP layer and the printer's live status.
  67. ``listing()`` is what ``/timelapse`` holds at that moment, or None while
  68. the card cannot be read -- what both listing calls then answer. The other
  69. candidate directories answer "no such directory" unless ``others`` gives
  70. them a FileListResult of their own.
  71. """
  72. from backend.app.core.config import settings as app_config
  73. from backend.app.services import bambu_ftp
  74. monkeypatch.setattr(app_config, "base_dir", tmp_path)
  75. monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
  76. monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_FIRST_DELAY_SECONDS", 0.01)
  77. monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS", 0.01)
  78. monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_TIMEOUT_SECONDS", 0.3)
  79. monkeypatch.setattr(main_module, "_TIMELAPSE_BASELINE_REREAD_INTERVAL_SECONDS", 0.01)
  80. monkeypatch.setattr(main_module, "_TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS", 20)
  81. async def _list(ip, code, path, printer_model=None):
  82. return (listing() or []) if path == "/timelapse" else []
  83. async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
  84. if path != "/timelapse":
  85. return (others or {}).get(path, FileListResult(files=[], available=False, missing=True))
  86. files = listing()
  87. return FileListResult(files=files or [], available=files is not None)
  88. status = SimpleNamespace(connected=True, state=state, sdcard=True, sdcard_reported=True)
  89. monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
  90. monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
  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. monkeypatch.setattr(main_module.printer_manager, "get_status", lambda _pid: status)
  95. return status, patch.object(main_module, "async_session", maker)
  96. async def _complete(main, archive_id, printer_id):
  97. """What on_print_complete does: schedule the scan, here run inline."""
  98. spawned = []
  99. with patch.object(main, "spawn_background_task", lambda coro, name=None: spawned.append(coro)):
  100. main._schedule_timelapse_scan(archive_id, printer_id, plate_id=None)
  101. assert len(spawned) == 1
  102. await spawned[0]
  103. async def _attached(maker, archive_id) -> str | None:
  104. async with maker() as db:
  105. return (await db.get(PrintArchive, archive_id)).timelapse_path
  106. class TestTheScanDoesNotGuess:
  107. """The reporter's chain, from the stored baseline on: [] at print start, and
  108. by completion the card readable again with this print's video beside two
  109. old ones."""
  110. async def test_a_stored_unread_baseline_leaves_several_videos_alone(self, test_engine, tmp_path, monkeypatch):
  111. from backend.app import main
  112. maker, _, archive_id = await _seed(test_engine, baseline=[], unread=True)
  113. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)])
  114. with session:
  115. await main._scan_for_timelapse_with_retries(archive_id, None)
  116. assert await _attached(maker, archive_id) is None, "an unread baseline must not pick between three videos"
  117. async def test_an_unread_baseline_handed_over_in_memory_leaves_them_alone(self, test_engine, tmp_path, monkeypatch):
  118. """The in-process path: on_print_complete hands the in-memory copy over,
  119. and the archive row is never consulted."""
  120. from backend.app import main
  121. maker, printer, archive_id = await _seed(test_engine)
  122. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)])
  123. main._timelapse_baselines[printer.id] = set()
  124. main._timelapse_baselines_unread.add(printer.id)
  125. with session:
  126. await _complete(main, archive_id, printer.id)
  127. assert printer.id not in main._timelapse_baselines
  128. assert printer.id not in main._timelapse_baselines_unread
  129. assert await _attached(maker, archive_id) is None
  130. async def test_a_stored_read_baseline_beats_an_unread_one_taken_after_a_restart(
  131. self, test_engine, tmp_path, monkeypatch
  132. ):
  133. """Read at print start; after a restart the running-observed capture
  134. could not read the card. The stored one is this run's."""
  135. from backend.app import main
  136. maker, printer, archive_id = await _seed(test_engine, baseline=[OLD_A, OLD_B], unread=False)
  137. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)])
  138. main._timelapse_baselines[printer.id] = set()
  139. main._timelapse_baselines_unread.add(printer.id)
  140. with session:
  141. await _complete(main, archive_id, printer.id)
  142. attached = await _attached(maker, archive_id)
  143. assert attached is not None and NEW_VIDEO in attached
  144. async def test_a_lone_video_still_resolves_off_an_unread_baseline(self, test_engine, tmp_path, monkeypatch):
  145. """The usual card: Bambuddy deletes each video once attached, so only
  146. this print's is there, and one candidate needs no baseline."""
  147. from backend.app import main
  148. maker, _, archive_id = await _seed(test_engine, baseline=[], unread=True)
  149. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(NEW_VIDEO)])
  150. with session:
  151. await main._scan_for_timelapse_with_retries(archive_id, None)
  152. attached = await _attached(maker, archive_id)
  153. assert attached is not None and NEW_VIDEO in attached
  154. async def test_a_read_baseline_still_picks_the_new_video(self, test_engine, tmp_path, monkeypatch):
  155. from backend.app import main
  156. maker, _, archive_id = await _seed(test_engine, baseline=[OLD_A, OLD_B], unread=False)
  157. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)])
  158. with session:
  159. await main._scan_for_timelapse_with_retries(archive_id, None)
  160. attached = await _attached(maker, archive_id)
  161. assert attached is not None and NEW_VIDEO in attached
  162. async def test_a_baseline_the_scan_takes_itself_off_a_failed_listing(self, test_engine, tmp_path, monkeypatch):
  163. """No cool-off running, the listing simply failed (a timeout, a 421 on
  164. this very connection): the old check only knew about the cool-off."""
  165. from backend.app import main
  166. maker, _, archive_id = await _seed(test_engine)
  167. rounds = iter([None, None]) # both baseline listings fail, the poll's succeed
  168. _, session = _ftp(
  169. main,
  170. maker,
  171. monkeypatch,
  172. tmp_path,
  173. lambda: next(rounds, [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)]),
  174. )
  175. with session:
  176. await main._scan_for_timelapse_with_retries(archive_id, None)
  177. assert await _attached(maker, archive_id) is None
  178. class TestTheCaptureAtPrintStart:
  179. async def _capture(self, main, printer, archive_id):
  180. spawned = []
  181. def _spawn(coro, name=None):
  182. spawned.append(name)
  183. coro.close()
  184. with patch.object(main, "spawn_background_task", _spawn):
  185. await main._capture_timelapse_baseline_at_start(
  186. printer, printer.id, logging.getLogger(__name__), archive_id=archive_id
  187. )
  188. return spawned
  189. async def test_an_unreadable_card_is_recorded_as_unread(self, test_engine, tmp_path, monkeypatch):
  190. from backend.app import main
  191. maker, printer, archive_id = await _seed(test_engine)
  192. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: None)
  193. with session:
  194. spawned = await self._capture(main, printer, archive_id)
  195. async with maker() as db:
  196. archive = await db.get(PrintArchive, archive_id)
  197. assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([], True)
  198. assert main._timelapse_baselines[printer.id] == set()
  199. assert printer.id in main._timelapse_baselines_unread
  200. assert spawned == [f"reread-timelapse-baseline-{printer.id}"]
  201. async def test_a_card_in_cooloff_is_recorded_as_unread(self, test_engine, tmp_path, monkeypatch):
  202. """The reporter's log line: the cool-off was running at print start."""
  203. from backend.app import main
  204. from backend.app.services.bambu_ftp import BambuFTPClient
  205. maker, printer, archive_id = await _seed(test_engine)
  206. # A listing would find videos -- it must not be asked while blocked.
  207. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [])
  208. BambuFTPClient._handshake_blocked_until[PRINTER_IP] = time.monotonic() + 60
  209. try:
  210. with session:
  211. await self._capture(main, printer, archive_id)
  212. finally:
  213. BambuFTPClient._handshake_blocked_until.pop(PRINTER_IP, None)
  214. async with maker() as db:
  215. assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is True
  216. async def test_an_empty_card_that_answered_is_read(self, test_engine, tmp_path, monkeypatch):
  217. from backend.app import main
  218. maker, printer, archive_id = await _seed(test_engine)
  219. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [])
  220. with session:
  221. spawned = await self._capture(main, printer, archive_id)
  222. async with maker() as db:
  223. archive = await db.get(PrintArchive, archive_id)
  224. assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([], False)
  225. assert printer.id not in main._timelapse_baselines_unread
  226. assert spawned == []
  227. async def test_a_failed_directory_is_not_outvoted_by_one_that_answered(self, test_engine, tmp_path, monkeypatch):
  228. """/timelapse answers empty, /timelapse/video -- where this card keeps
  229. its videos -- fails: the card was not read."""
  230. from backend.app import main
  231. maker, printer, archive_id = await _seed(test_engine)
  232. failed = {"/timelapse/video": FileListResult(files=[], available=False)}
  233. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [], others=failed)
  234. with session:
  235. await self._capture(main, printer, archive_id)
  236. async with maker() as db:
  237. assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is True
  238. async def test_a_card_without_any_timelapse_directory_is_read(self, test_engine, tmp_path, monkeypatch):
  239. """A fresh card: every directory answers 550. That is an answer, and a
  240. re-read could only get the same one."""
  241. from backend.app import main
  242. maker, printer, archive_id = await _seed(test_engine)
  243. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [])
  244. monkeypatch.setattr(
  245. bambu_ftp,
  246. "list_files_result_async",
  247. AsyncMock(return_value=FileListResult(files=[], available=False, missing=True)),
  248. )
  249. with session:
  250. spawned = await self._capture(main, printer, archive_id)
  251. async with maker() as db:
  252. assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is False
  253. assert spawned == []
  254. async def test_an_empty_first_listing_is_replaced_by_the_one_that_answered(
  255. self, test_engine, tmp_path, monkeypatch
  256. ):
  257. """The first listing fails, the second reads the card: its names are the
  258. baseline, not the empty set the first one returned."""
  259. from backend.app import main
  260. maker, printer, archive_id = await _seed(test_engine)
  261. rounds = iter([None])
  262. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: next(rounds, [_entry(OLD_A), _entry(OLD_B)]))
  263. with session:
  264. await self._capture(main, printer, archive_id)
  265. async with maker() as db:
  266. archive = await db.get(PrintArchive, archive_id)
  267. assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([OLD_A, OLD_B], False)
  268. class TestTheCardIsReadAgainDuringThePrint:
  269. async def _start(self, main, printer):
  270. names: set[str] = set()
  271. main._timelapse_baselines[printer.id] = names
  272. main._timelapse_baselines_unread.add(printer.id)
  273. return names
  274. async def test_a_later_reading_becomes_the_baseline(self, test_engine, tmp_path, monkeypatch):
  275. """And with it the scan picks this print's video out of three."""
  276. from backend.app import main
  277. maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
  278. card: list[list[dict] | None] = [None]
  279. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: card[0])
  280. names = await self._start(main, printer)
  281. with session:
  282. task = asyncio.create_task(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED))
  283. await asyncio.sleep(0.05)
  284. assert printer.id in main._timelapse_baselines_unread, "nothing to record while the card is unreadable"
  285. card[0] = [_entry(OLD_A), _entry(OLD_B)] # the cool-off ends
  286. await asyncio.wait_for(task, 2)
  287. assert main._timelapse_baselines[printer.id] is names
  288. assert names == {OLD_A, OLD_B}
  289. assert printer.id not in main._timelapse_baselines_unread
  290. async with maker() as db:
  291. archive = await db.get(PrintArchive, archive_id)
  292. assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([OLD_A, OLD_B], False)
  293. card[0] = [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)] # the print ends
  294. await _complete(main, archive_id, printer.id)
  295. attached = await _attached(maker, archive_id)
  296. assert attached is not None and NEW_VIDEO in attached
  297. async def test_a_disconnect_skips_a_round_and_does_not_end_the_rereading(self, test_engine, tmp_path, monkeypatch):
  298. from backend.app import main
  299. maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
  300. status, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A)])
  301. status.connected = False
  302. names = await self._start(main, printer)
  303. with session:
  304. task = asyncio.create_task(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED))
  305. await asyncio.sleep(0.05)
  306. assert not task.done(), "a dropped connection says nothing about the print"
  307. assert names == set()
  308. status.connected = True
  309. await asyncio.wait_for(task, 2)
  310. assert names == {OLD_A}
  311. async def test_a_reading_that_returns_to_a_disconnected_printer_is_not_kept(
  312. self, test_engine, tmp_path, monkeypatch
  313. ):
  314. """The printer dropped off while the listing was out, so nothing says
  315. the print had not ended meanwhile."""
  316. from backend.app import main
  317. maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
  318. names = await self._start(main, printer)
  319. status = None
  320. card = [[_entry(OLD_A), _entry(NEW_VIDEO)]]
  321. def listing():
  322. if status.connected:
  323. status.connected = False # drops while this listing is out
  324. return card[0]
  325. return None
  326. status, session = _ftp(main, maker, monkeypatch, tmp_path, listing)
  327. with session:
  328. task = asyncio.create_task(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED))
  329. await asyncio.sleep(0.05)
  330. task.cancel()
  331. assert names == set()
  332. assert printer.id in main._timelapse_baselines_unread
  333. async def test_nothing_is_read_once_the_print_has_ended(self, test_engine, tmp_path, monkeypatch):
  334. """After the end the printer may already be writing this print's video,
  335. which a reading would put into the baseline."""
  336. from backend.app import main
  337. maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
  338. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(NEW_VIDEO)], state="FINISH")
  339. names = await self._start(main, printer)
  340. with session:
  341. await asyncio.wait_for(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED), 2)
  342. assert names == set()
  343. async with maker() as db:
  344. assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is True
  345. async def test_a_reading_that_returns_after_completion_is_dropped(self, test_engine, tmp_path, monkeypatch):
  346. """The print ends while the listing is out: completion has taken the
  347. baseline by the time it comes back."""
  348. from backend.app import main
  349. maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
  350. names = await self._start(main, printer)
  351. status = None
  352. def listing():
  353. status.state = "FINISH"
  354. main._timelapse_baselines.pop(printer.id)
  355. main._timelapse_baselines_unread.discard(printer.id)
  356. return [_entry(OLD_A), _entry(NEW_VIDEO)]
  357. status, session = _ftp(main, maker, monkeypatch, tmp_path, listing)
  358. with session:
  359. await asyncio.wait_for(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED), 2)
  360. assert names == set()
  361. async with maker() as db:
  362. assert (await db.get(PrintArchive, archive_id)).timelapse_baseline == []
  363. async def test_a_reading_still_in_the_print_but_with_the_baseline_gone_is_dropped(
  364. self, test_engine, tmp_path, monkeypatch
  365. ):
  366. """State alone is not the run: the next print can already be running."""
  367. from backend.app import main
  368. maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
  369. names = await self._start(main, printer)
  370. def listing():
  371. main._timelapse_baselines[printer.id] = {"the next print's"}
  372. return [_entry(OLD_A)]
  373. _, session = _ftp(main, maker, monkeypatch, tmp_path, listing)
  374. with session:
  375. await asyncio.wait_for(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED), 2)
  376. assert names == set()
  377. async with maker() as db:
  378. assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is True
  379. async def test_a_reprinted_row_keeps_its_own_baseline(self, test_engine, tmp_path, monkeypatch):
  380. """The row passed to a newer run meanwhile: the reading belongs to the
  381. old run, so memory takes it and the row does not."""
  382. from backend.app import main
  383. maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
  384. _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A)])
  385. names = await self._start(main, printer)
  386. with session:
  387. await asyncio.wait_for(
  388. main._reread_unread_timelapse_baseline(printer.id, names, archive_id, datetime(2026, 10, 1)), 2
  389. )
  390. assert names == {OLD_A}
  391. async with maker() as db:
  392. archive = await db.get(PrintArchive, archive_id)
  393. assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([], True)