test_print_again_reuses_archived_3mf_3307.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. """A "Print again" from the printer's screen is archived with its 3MF (#3307).
  2. On a touchscreen reprint the printer never names the file on the card. It
  3. reports its own history copy under a generic name
  4. (``file:///userdata/project_file.gcode.3mf``, which port 990 cannot serve), the
  5. 3MF's internal title as ``subtask_name`` -- "IKEA Skadis Support Feet", while
  6. the card file is "Support Feet 20mm Standoff_Build Sandbox.gcode.3mf" -- and
  7. ``/data/Metadata/plate_3.gcode``. Every name built from those misses, so the
  8. second run of a job archived fine the first time got a no-3MF fallback.
  9. The first run's archive holds that 3MF, and its ``_print_data`` records the
  10. same subtask name and plate. When the lookup comes back empty, the newest such
  11. archive on the same printer lends its 3MF to the new run. A layer count the
  12. printer has already reported must agree, so a re-slice under the same title is
  13. not mistaken for the same job. Not on a cool-off or a failed transfer: there
  14. the file was reachable and its own retry fetches the real one.
  15. """
  16. import shutil
  17. from contextlib import ExitStack
  18. from datetime import datetime, timedelta, timezone
  19. from types import SimpleNamespace
  20. from unittest.mock import AsyncMock, MagicMock, patch
  21. import pytest
  22. from backend.app.core.config import settings as app_settings
  23. from backend.app.main import (
  24. _active_prints,
  25. _archived_3mf_for_repeat,
  26. _expected_print_creators,
  27. _expected_print_registered_at,
  28. _expected_prints,
  29. _print_ams_mappings,
  30. _timelapse_baselines,
  31. )
  32. from backend.app.models.archive import PrintArchive
  33. from backend.app.models.printer import Printer
  34. from backend.app.services.bambu_ftp import FileNotOnPrinterError
  35. pytestmark = pytest.mark.unit
  36. TITLE = "IKEA Skadis Support Feet"
  37. PLATE_3 = "/data/Metadata/plate_3.gcode"
  38. CARD_FILE = "Support Feet 20mm Standoff_Build Sandbox.gcode.3mf"
  39. @pytest.fixture
  40. def base_dir(tmp_path):
  41. # archive_dir too: it is its own setting, and the copy lands in its temp/.
  42. with (
  43. patch.object(app_settings, "base_dir", tmp_path),
  44. patch.object(app_settings, "archive_dir", tmp_path / "archive"),
  45. ):
  46. yield tmp_path
  47. def _stored_3mf(base_dir, folder: str, content: bytes = b"PK\x03\x04 3mf") -> str:
  48. path = base_dir / "archive" / folder / CARD_FILE
  49. path.parent.mkdir(parents=True, exist_ok=True)
  50. path.write_bytes(content)
  51. return str(path.relative_to(base_dir))
  52. class TestFindingTheEarlierRun:
  53. @pytest.fixture
  54. async def printers(self, db_session):
  55. for n in (1, 2):
  56. db_session.add(
  57. Printer(id=n, name=f"H2C-{n}", serial_number=f"SN{n}", ip_address="192.0.2.1", access_code="1")
  58. )
  59. await db_session.commit()
  60. async def _archive(self, db, base_dir, folder, *, printer_id=1, subtask=TITLE, gcode=PLATE_3, age_min=60, **fields):
  61. archive = PrintArchive(
  62. printer_id=printer_id,
  63. filename=CARD_FILE,
  64. file_path=_stored_3mf(base_dir, folder),
  65. file_size=10,
  66. print_name=f"{TITLE} - Plate 3",
  67. status="completed",
  68. created_at=datetime.now(timezone.utc) - timedelta(minutes=age_min),
  69. extra_data={"_print_data": {"filename": gcode, "subtask_name": subtask}},
  70. **fields,
  71. )
  72. db.add(archive)
  73. await db.commit()
  74. return archive
  75. async def _find(self, db, *, printer_id=1, subtask=TITLE, gcode=PLATE_3, layers=None):
  76. return await _archived_3mf_for_repeat(db, printer_id, subtask, gcode, layers)
  77. @pytest.mark.asyncio
  78. async def test_the_same_job_on_the_same_printer_is_found(self, db_session, printers, base_dir):
  79. first = await self._archive(db_session, base_dir, "14")
  80. assert (await self._find(db_session)).id == first.id
  81. @pytest.mark.asyncio
  82. async def test_the_newest_run_wins(self, db_session, printers, base_dir):
  83. await self._archive(db_session, base_dir, "14", age_min=120)
  84. newer = await self._archive(db_session, base_dir, "15", age_min=10)
  85. assert (await self._find(db_session)).id == newer.id
  86. @pytest.mark.asyncio
  87. async def test_another_plate_is_not(self, db_session, printers, base_dir):
  88. await self._archive(db_session, base_dir, "14", gcode="/data/Metadata/plate_2.gcode")
  89. assert await self._find(db_session) is None
  90. @pytest.mark.asyncio
  91. async def test_another_title_is_not(self, db_session, printers, base_dir):
  92. await self._archive(db_session, base_dir, "14", subtask="IKEA Skadis Hook")
  93. assert await self._find(db_session) is None
  94. @pytest.mark.asyncio
  95. async def test_another_printer_is_not(self, db_session, printers, base_dir):
  96. await self._archive(db_session, base_dir, "14", printer_id=2)
  97. assert await self._find(db_session) is None
  98. @pytest.mark.asyncio
  99. async def test_a_deleted_archive_is_not(self, db_session, printers, base_dir):
  100. await self._archive(db_session, base_dir, "14", deleted_at=datetime.now(timezone.utc))
  101. assert await self._find(db_session) is None
  102. @pytest.mark.asyncio
  103. async def test_a_fallback_archive_without_a_3mf_is_not(self, db_session, printers, base_dir):
  104. archive = await self._archive(db_session, base_dir, "14")
  105. archive.file_path = ""
  106. await db_session.commit()
  107. assert await self._find(db_session) is None
  108. @pytest.mark.asyncio
  109. async def test_an_archive_whose_file_is_gone_is_skipped_for_an_older_one(self, db_session, printers, base_dir):
  110. older = await self._archive(db_session, base_dir, "14", age_min=120)
  111. await self._archive(db_session, base_dir, "15", age_min=10)
  112. shutil.rmtree(base_dir / "archive" / "15")
  113. assert (await self._find(db_session)).id == older.id
  114. @pytest.mark.asyncio
  115. async def test_a_different_layer_count_is_another_slice(self, db_session, printers, base_dir):
  116. await self._archive(db_session, base_dir, "14", total_layers=120)
  117. assert await self._find(db_session, layers=96) is None
  118. assert await self._find(db_session, layers=120) is not None
  119. @pytest.mark.asyncio
  120. async def test_an_unknown_layer_count_on_either_side_does_not_decide(self, db_session, printers, base_dir):
  121. await self._archive(db_session, base_dir, "14")
  122. assert await self._find(db_session, layers=96) is not None
  123. assert await self._find(db_session, layers=None) is not None
  124. @pytest.mark.asyncio
  125. async def test_nothing_to_match_on_finds_nothing(self, db_session, printers, base_dir):
  126. # A run that recorded no plate is no evidence for a print reporting none.
  127. await self._archive(db_session, base_dir, "14", gcode="")
  128. assert await self._find(db_session, gcode="") is None
  129. await self._archive(db_session, base_dir, "15", subtask="")
  130. assert await self._find(db_session, subtask="") is None
  131. @pytest.mark.asyncio
  132. async def test_fallback_archives_do_not_use_up_the_lookback(self, db_session, printers, base_dir):
  133. """Every failed run leaves a no-3MF row; they must not push the run
  134. that has the file out of the rows compared."""
  135. good = await self._archive(db_session, base_dir, "14", age_min=120)
  136. empty = await self._archive(db_session, base_dir, "17", age_min=10)
  137. empty.file_path = ""
  138. await db_session.commit()
  139. with patch("backend.app.main._REPEAT_LOOKBACK", 1):
  140. assert (await self._find(db_session)).id == good.id
  141. @pytest.mark.asyncio
  142. async def test_unreadable_extra_data_is_skipped(self, db_session, printers, base_dir):
  143. archive = await self._archive(db_session, base_dir, "14")
  144. archive.extra_data = {"_print_data": "not a dict"}
  145. await db_session.commit()
  146. assert await self._find(db_session) is None
  147. # --- on_print_start wiring ---------------------------------------------------
  148. @pytest.fixture
  149. def _clear_dicts():
  150. dicts = (
  151. _expected_prints,
  152. _expected_print_registered_at,
  153. _expected_print_creators,
  154. _print_ams_mappings,
  155. _active_prints,
  156. _timelapse_baselines,
  157. )
  158. for d in dicts:
  159. d.clear()
  160. yield
  161. for d in dicts:
  162. d.clear()
  163. def _printer():
  164. printer = MagicMock()
  165. printer.id = 1
  166. printer.auto_archive = True
  167. printer.external_camera_enabled = False
  168. printer.external_camera_url = None
  169. printer.plate_detection_enabled = False
  170. printer.name = "H2C"
  171. printer.model = "H2C"
  172. printer.ip_address = "192.0.2.10"
  173. printer.access_code = "12345678"
  174. return printer
  175. async def _print_again(
  176. base_dir,
  177. *,
  178. earlier,
  179. project_url="file:///userdata/project_file.gcode.3mf",
  180. download=None,
  181. listing=(),
  182. blocked=False,
  183. ):
  184. """Run on_print_start for the reporter's reprint; every lookup misses."""
  185. printer = _printer()
  186. def execute_router(stmt, *args, **kwargs):
  187. sql = str(stmt).lower()
  188. if "from printers" in sql or "from printer " in sql:
  189. return MagicMock(
  190. scalar_one_or_none=MagicMock(return_value=printer),
  191. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
  192. )
  193. return MagicMock(
  194. scalar_one_or_none=MagicMock(return_value=None),
  195. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  196. )
  197. added: list = []
  198. session = AsyncMock()
  199. session.__aenter__ = AsyncMock(return_value=session)
  200. session.__aexit__ = AsyncMock()
  201. session.execute = AsyncMock(side_effect=execute_router)
  202. session.commit = AsyncMock()
  203. session.refresh = AsyncMock()
  204. session.add = MagicMock(side_effect=added.append)
  205. state = MagicMock(current_project_url=project_url, sdcard=True, sdcard_reported=True)
  206. archived = MagicMock(id=99, printer_id=1, status="printing", filename=CARD_FILE)
  207. service = MagicMock()
  208. finder = AsyncMock(return_value=earlier)
  209. copied: dict = {}
  210. async def archive_print(**kwargs):
  211. copied["bytes"] = kwargs["source_file"].read_bytes()
  212. return archived
  213. service.archive_print = AsyncMock(side_effect=archive_print)
  214. with ExitStack() as stack:
  215. session_maker = stack.enter_context(patch("backend.app.main.async_session"))
  216. notif = stack.enter_context(patch("backend.app.main.notification_service"))
  217. plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
  218. ws = stack.enter_context(patch("backend.app.main.ws_manager"))
  219. relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
  220. pm = stack.enter_context(patch("backend.app.main.printer_manager"))
  221. stack.enter_context(
  222. patch(
  223. "backend.app.main.download_file_async",
  224. new=download or AsyncMock(side_effect=FileNotOnPrinterError("550")),
  225. )
  226. )
  227. stack.enter_context(patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)))
  228. stack.enter_context(patch("backend.app.main.get_cached_3mf", return_value=None))
  229. stack.enter_context(patch("backend.app.main.cache_3mf_download"))
  230. stack.enter_context(patch("backend.app.main.peek_plate_index_in_3mf", return_value=3))
  231. stack.enter_context(
  232. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=list(listing)))
  233. )
  234. stack.enter_context(patch("backend.app.main.ftps_handshake_blocked", return_value=blocked))
  235. stack.enter_context(
  236. patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30)))
  237. )
  238. stack.enter_context(patch("backend.app.main._record_energy_start", new_callable=AsyncMock))
  239. stack.enter_context(patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock))
  240. stack.enter_context(patch("backend.app.main._maybe_start_layer_timelapse"))
  241. stack.enter_context(patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock))
  242. stack.enter_context(patch("backend.app.main._schedule_fallback_3mf_retry", new=MagicMock()))
  243. stack.enter_context(
  244. patch("backend.app.main._ask_outcome_for_external_print", new=AsyncMock(return_value=False))
  245. )
  246. stack.enter_context(
  247. patch("backend.app.main._drop_archive_of_deleted_printer", new=AsyncMock(return_value=False))
  248. )
  249. stack.enter_context(patch("backend.app.main._printer_gone", new=AsyncMock(return_value=False)))
  250. stack.enter_context(patch("backend.app.main.ArchiveService", return_value=service))
  251. stack.enter_context(patch("backend.app.main._archived_3mf_for_repeat", new=finder))
  252. session_maker.return_value = session
  253. notif.on_print_start = AsyncMock()
  254. plug.on_print_start = AsyncMock()
  255. ws.send_print_start = AsyncMock()
  256. ws.send_archive_updated = AsyncMock()
  257. ws.send_archive_created = AsyncMock()
  258. relay.on_print_start = AsyncMock()
  259. pm.get_status = MagicMock(return_value=state)
  260. pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3307"))
  261. from backend.app.main import on_print_start
  262. await on_print_start(1, {"filename": PLATE_3, "subtask_name": TITLE, "raw_data": {"total_layer_num": 120}})
  263. fallback = next(
  264. (r for r in added if isinstance(getattr(r, "extra_data", None), dict) and r.extra_data.get("no_3mf_available")),
  265. None,
  266. )
  267. return SimpleNamespace(service=service, finder=finder, fallback=fallback, copied=copied)
  268. def _earlier(base_dir, content=b"PK\x03\x04 the first run's 3MF"):
  269. return SimpleNamespace(id=14, filename=CARD_FILE, file_path=_stored_3mf(base_dir, "14", content))
  270. @pytest.mark.usefixtures("_clear_dicts")
  271. class TestPrintAgain:
  272. @pytest.mark.asyncio
  273. async def test_the_reported_case_is_archived_with_the_earlier_runs_3mf(self, base_dir):
  274. run = await _print_again(base_dir, earlier=_earlier(base_dir))
  275. assert run.fallback is None
  276. assert run.copied["bytes"] == b"PK\x03\x04 the first run's 3MF"
  277. kwargs = run.service.archive_print.await_args.kwargs
  278. # Said where it came from, for whoever reads the archive later.
  279. assert kwargs["print_data"]["_3mf_from_archive"] == 14
  280. # Named after the card file, not after the temp copy.
  281. assert kwargs["original_filename"] == CARD_FILE
  282. @pytest.mark.asyncio
  283. async def test_the_copy_is_per_printer(self, base_dir):
  284. """Two printers reprinting the same job at once must not share one
  285. temp file: one copy would truncate the other mid-archive."""
  286. run = await _print_again(base_dir, earlier=_earlier(base_dir))
  287. assert run.service.archive_print.await_args.kwargs["source_file"].name == f"reprint_1_{CARD_FILE}"
  288. @pytest.mark.asyncio
  289. async def test_a_copy_that_fails_leaves_the_fallback_archive(self, base_dir):
  290. earlier = SimpleNamespace(id=14, filename=CARD_FILE, file_path="archive/gone/" + CARD_FILE)
  291. run = await _print_again(base_dir, earlier=earlier)
  292. assert run.fallback is not None
  293. run.service.archive_print.assert_not_awaited()
  294. @pytest.mark.asyncio
  295. async def test_looked_up_by_title_plate_and_layer_count(self, base_dir):
  296. run = await _print_again(base_dir, earlier=_earlier(base_dir))
  297. assert run.finder.await_args.args[1:] == (1, TITLE, PLATE_3, 120)
  298. @pytest.mark.asyncio
  299. async def test_the_earlier_archive_keeps_its_own_file(self, base_dir):
  300. earlier = _earlier(base_dir)
  301. await _print_again(base_dir, earlier=earlier)
  302. assert (base_dir / earlier.file_path).read_bytes() == b"PK\x03\x04 the first run's 3MF"
  303. @pytest.mark.asyncio
  304. async def test_a_name_sweep_that_missed_too(self, base_dir):
  305. """The reporter's first failure (archive 17): no internal-storage URL,
  306. a full sweep and directory walk, all missing."""
  307. run = await _print_again(base_dir, earlier=_earlier(base_dir), project_url=None)
  308. assert run.fallback is None
  309. assert run.service.archive_print.await_count == 1
  310. @pytest.mark.asyncio
  311. async def test_no_earlier_run_is_a_fallback_archive_as_before(self, base_dir):
  312. run = await _print_again(base_dir, earlier=None)
  313. assert run.fallback is not None
  314. run.service.archive_print.assert_not_awaited()
  315. @pytest.mark.asyncio
  316. async def test_not_when_the_card_listed_a_file_for_it(self, base_dir):
  317. """The directory walk found a file named for the print and only its
  318. transfer failed: that file is on the card, and the retry fetches it --
  319. possibly a newer slice than the earlier run."""
  320. run = await _print_again(
  321. base_dir,
  322. earlier=_earlier(base_dir),
  323. project_url=None,
  324. download=AsyncMock(return_value=False),
  325. listing=[{"name": f"{TITLE}.gcode.3mf", "is_directory": False}],
  326. )
  327. run.finder.assert_not_awaited()
  328. assert run.fallback is not None
  329. @pytest.mark.asyncio
  330. async def test_a_sweep_path_that_timed_out_is_no_such_evidence(self, base_dir):
  331. """The retry would try the same names, which this print's file never
  332. had, so leaving it to the retry left the archive empty for good."""
  333. run = await _print_again(
  334. base_dir, earlier=_earlier(base_dir), project_url=None, download=AsyncMock(return_value=False)
  335. )
  336. assert run.fallback is None
  337. assert run.service.archive_print.await_count == 1
  338. @pytest.mark.asyncio
  339. async def test_nor_is_a_cool_off(self, base_dir):
  340. run = await _print_again(base_dir, earlier=_earlier(base_dir), project_url=None, blocked=True)
  341. assert run.fallback is None
  342. assert run.service.archive_print.await_count == 1