test_print_start_subtask_with_extension_3009.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. """A print started from the printer's own screen names its file in full (#3009).
  2. A job Bambuddy dispatches carries a bare subtask name, and the 3MF lookup
  3. appends the extensions to it. A job started on the printer's touchscreen
  4. reports the file's own name, extension included, so the lookup asked for
  5. ``X.3mf.gcode.3mf`` and ``X.3mf.3mf`` first -- six connections, about six
  6. seconds, that could not succeed -- and the existing-archive check compared
  7. against ``X.3mf.3mf`` and could never reattach such a print after a restart.
  8. The name is from a reporter's A1 log.
  9. """
  10. import re
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from backend.app.main import (
  14. _active_prints,
  15. _expected_print_creators,
  16. _expected_print_registered_at,
  17. _expected_prints,
  18. _print_ams_mappings,
  19. _timelapse_baselines,
  20. )
  21. pytestmark = pytest.mark.unit
  22. NAME = "A1-Siraya_Tech_TPU-64D-18JAN26"
  23. @pytest.fixture(autouse=True)
  24. def _clear_dicts():
  25. dicts = (
  26. _expected_prints,
  27. _expected_print_registered_at,
  28. _expected_print_creators,
  29. _print_ams_mappings,
  30. _active_prints,
  31. _timelapse_baselines,
  32. )
  33. for d in dicts:
  34. d.clear()
  35. yield
  36. for d in dicts:
  37. d.clear()
  38. def _printer():
  39. printer = MagicMock()
  40. printer.id = 1
  41. printer.auto_archive = True
  42. printer.external_camera_enabled = False
  43. printer.external_camera_url = None
  44. printer.plate_detection_enabled = False
  45. printer.name = "A1"
  46. printer.model = "A1"
  47. printer.ip_address = "192.168.1.117"
  48. printer.access_code = "12345678"
  49. return printer
  50. async def _run_print_start(subtask_name, filename):
  51. """Drive on_print_start with every FTP path answering 550, and return the
  52. remote paths it tried, in order, and the SQL it ran."""
  53. printer = _printer()
  54. statements = []
  55. def execute_router(stmt, *args, **kwargs):
  56. statements.append(stmt)
  57. sql = str(stmt).lower()
  58. if "from printers" in sql or "from printer " in sql:
  59. return MagicMock(
  60. scalar_one_or_none=MagicMock(return_value=printer),
  61. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
  62. )
  63. return MagicMock(
  64. scalar_one_or_none=MagicMock(return_value=None),
  65. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  66. )
  67. session = AsyncMock()
  68. session.__aenter__ = AsyncMock(return_value=session)
  69. session.__aexit__ = AsyncMock()
  70. session.execute = AsyncMock(side_effect=execute_router)
  71. session.commit = AsyncMock()
  72. session.refresh = AsyncMock()
  73. session.add = MagicMock()
  74. from backend.app.services.bambu_ftp import FileNotOnPrinterError
  75. download = AsyncMock(side_effect=FileNotOnPrinterError("550"))
  76. state = MagicMock(current_project_url=f"ftp://{filename}", sdcard=True, sdcard_reported=True)
  77. with (
  78. patch("backend.app.main.async_session") as session_maker,
  79. patch("backend.app.main.notification_service") as notif,
  80. patch("backend.app.main.smart_plug_manager") as plug,
  81. patch("backend.app.main.ws_manager") as ws,
  82. patch("backend.app.main.mqtt_relay") as relay,
  83. patch("backend.app.main.printer_manager") as pm,
  84. patch("backend.app.main.download_file_async", new=download),
  85. patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)),
  86. patch("backend.app.main.with_ftp_retry", new=AsyncMock(return_value=False)),
  87. patch("backend.app.main.get_cached_3mf", return_value=None),
  88. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
  89. patch("backend.app.main.ftps_handshake_blocked", return_value=False),
  90. patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
  91. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  92. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  93. patch("backend.app.main._maybe_start_layer_timelapse"),
  94. patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
  95. patch("backend.app.main._schedule_fallback_3mf_retry", new=MagicMock()),
  96. ):
  97. session_maker.return_value = session
  98. notif.on_print_start = AsyncMock()
  99. plug.on_print_start = AsyncMock()
  100. ws.send_print_start = AsyncMock()
  101. ws.send_archive_updated = AsyncMock()
  102. ws.send_archive_created = AsyncMock()
  103. relay.on_print_start = AsyncMock()
  104. pm.get_status = MagicMock(return_value=state)
  105. pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3009"))
  106. from backend.app.main import on_print_start
  107. await on_print_start(1, {"filename": filename, "subtask_name": subtask_name})
  108. tried = [call.args[2] for call in download.await_args_list]
  109. return tried, statements
  110. def _existing_archive_lookup(statements):
  111. """The literal SQL of the name-based check for a printing archive to reattach."""
  112. for stmt in statements:
  113. try:
  114. sql = str(stmt.compile(compile_kwargs={"literal_binds": True}))
  115. except Exception:
  116. continue
  117. if "print_archives" in sql and "print_name" in sql and "printing" in sql:
  118. return sql
  119. return None
  120. @pytest.mark.asyncio
  121. async def test_a_subtask_with_its_extension_is_tried_as_the_file_itself_first():
  122. tried, _ = await _run_print_start(f"{NAME}.3mf", f"{NAME}.3mf")
  123. assert tried[0] == f"/{NAME}.3mf"
  124. @pytest.mark.asyncio
  125. async def test_no_second_extension_is_appended_to_it():
  126. tried, _ = await _run_print_start(f"{NAME}.3mf", f"{NAME}.3mf")
  127. assert not [p for p in tried if ".3mf.gcode.3mf" in p or ".3mf.3mf" in p]
  128. @pytest.mark.asyncio
  129. async def test_only_the_reported_file_is_tried():
  130. """The printer named the file, so no other name is guessed at -- the list
  131. is the old one minus the two that could not exist."""
  132. tried, _ = await _run_print_start(f"{NAME}.3mf", f"{NAME}.3mf")
  133. assert {p.rsplit("/", 1)[-1] for p in tried} == {f"{NAME}.3mf"}
  134. def _archive_filenames(sql):
  135. """The file names the existing-archive check accepts."""
  136. match = re.search(r"filename IN \(([^)]*)\)", sql)
  137. assert match, sql
  138. return {name.strip().strip("'") for name in match.group(1).split(",")}
  139. @pytest.mark.asyncio
  140. async def test_the_existing_archive_check_also_accepts_the_file_itself():
  141. _, statements = await _run_print_start(f"{NAME}.3mf", f"{NAME}.3mf")
  142. assert f"{NAME}.3mf" in _archive_filenames(_existing_archive_lookup(statements))
  143. @pytest.mark.asyncio
  144. async def test_the_existing_archive_check_gets_nothing_looser():
  145. """A leftover 'printing' archive of another file with the same base name
  146. must not swallow this print: a match with progress is taken as the same
  147. print and no new archive is made."""
  148. _, statements = await _run_print_start(f"{NAME}.3mf", f"{NAME}.3mf")
  149. sql = _existing_archive_lookup(statements)
  150. assert f"{NAME}.gcode.3mf" not in _archive_filenames(sql)
  151. assert f"print_archives.print_name = '{NAME}.3mf'" in sql
  152. @pytest.mark.asyncio
  153. async def test_a_bare_subtask_is_looked_up_exactly_as_before():
  154. """The dispatched case, which is every queue and slicer print."""
  155. tried, _ = await _run_print_start(NAME, f"{NAME}.gcode.3mf")
  156. assert tried[0] == f"/{NAME}.gcode.3mf"
  157. assert f"/{NAME}.3mf" in tried
  158. @pytest.mark.asyncio
  159. async def test_a_dot_inside_the_model_name_is_kept():
  160. tried, _ = await _run_print_start("My.Model", "My.Model.gcode.3mf")
  161. assert tried[0] == "/My.Model.gcode.3mf"
  162. @pytest.mark.asyncio
  163. async def test_a_dispatched_model_named_with_3mf_keeps_the_old_order():
  164. """A model can be named "Foo.3mf"; its file is then Foo.3mf.gcode.3mf. The
  165. printer reports a different file than the subtask, so nothing about the
  166. name says the extension is the file's own, and a stale Foo.3mf on the card
  167. must not be tried first."""
  168. tried, statements = await _run_print_start("Foo.3mf", "/data/Metadata/plate_1.gcode")
  169. assert tried[0] == "/Foo.3mf.gcode.3mf"
  170. assert "/Foo.gcode.3mf" not in tried
  171. assert _archive_filenames(_existing_archive_lookup(statements)) == {"Foo.3mf.3mf", "Foo.3mf.gcode.3mf"}
  172. class TestSubtaskIsTheFile:
  173. @pytest.mark.parametrize(
  174. "subtask,filename",
  175. [
  176. (f"{NAME}.3mf", f"{NAME}.3mf"),
  177. (f"{NAME}.gcode.3mf", f"{NAME}.gcode.3mf"),
  178. ("Part.3mf", "/sdcard/Part.3mf"),
  179. ("Part.3mf", "ftp://Part.3mf"),
  180. ],
  181. )
  182. def test_the_same_name_as_the_file(self, subtask, filename):
  183. from backend.app.main import _subtask_is_the_file
  184. assert _subtask_is_the_file(subtask, filename)
  185. @pytest.mark.parametrize(
  186. "subtask,filename",
  187. [
  188. (NAME, f"{NAME}.gcode.3mf"),
  189. ("Foo.3mf", "/data/Metadata/plate_1.gcode"),
  190. ("Foo.3mf", "Bar.3mf"),
  191. ("Part.gcode", "Part.gcode"),
  192. ("", ""),
  193. ("Part.3mf", None),
  194. ],
  195. )
  196. def test_anything_else(self, subtask, filename):
  197. from backend.app.main import _subtask_is_the_file
  198. assert not _subtask_is_the_file(subtask, filename)