test_fallback_name_keeps_project_3126.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. """Rejecting the wrong plate must not cost the archive its name (#3126).
  2. The reporter's X2D was sent a print from Bambu Studio, which filed it on
  3. internal eMMC (``"url": "brtc://emmc/..."``). FTPS cannot serve that, but the
  4. bounded probe found a *same-named* file at the card's root -- an earlier slice
  5. of the same project, plate 4, while the running print was plate 1. #1204's
  6. guard caught the contradiction and refused the file, which is right: archiving
  7. plate 4's thumbnail, filament and cost against this print is the swap #2957
  8. removed.
  9. What it then did with the name was not. The guard asks ``swap_plate_suffix``
  10. for a corrected name and blanked ``subtask_name`` whenever it came back None --
  11. but None covers two unrelated cases, and only one of them is a name that could
  12. mislead. ``防雨防虫_模块化_排气口(50_75_80_100管可用)`` carries no
  13. ``- Plate N`` suffix at all, so it holds no stale plate number to be wrong
  14. about; blanking it dropped the project name too and the row fell through to the
  15. gcode_file path, titled ``plate_1``. #1204's own premise is consecutive plates
  16. *of the same model*, so the project part of a lagging name is right either way.
  17. Fixing that is only half of it, and the other half is why the title lives in its
  18. own variable. The name is kept for *display*; every lookup still disowns it,
  19. ``_active_prints`` included. Key the row under a name the guard just watched
  20. fetch the wrong plate and the cover endpoint -- which downloads that very name
  21. for the running print's thumbnail -- hands the bytes to the recovery path, which
  22. checks a candidate is a readable 3MF and never which plate it holds. The row
  23. would be filled in with the file this branch had just deleted.
  24. Pinned here: a name without a plate suffix survives the rejection intact as the
  25. title, a name with a stale one still gets its number corrected, and the rejected
  26. name keys nothing.
  27. """
  28. from unittest.mock import AsyncMock, MagicMock, patch
  29. import pytest
  30. from backend.app.main import (
  31. _active_prints,
  32. _expected_print_creators,
  33. _expected_print_registered_at,
  34. _expected_prints,
  35. _print_ams_mappings,
  36. _timelapse_baselines,
  37. )
  38. pytestmark = pytest.mark.unit
  39. DISPATCH = "/data/Metadata/plate_1.gcode"
  40. # The reporter's own subtask_name. Non-ASCII and parenthesised, with no plate
  41. # suffix anywhere in it -- exactly the shape that used to be thrown away.
  42. PROJECT = "防雨防虫_模块化_排气口(50_75_80_100管可用)"
  43. @pytest.fixture(autouse=True)
  44. def _clear_dicts():
  45. dicts = (
  46. _expected_prints,
  47. _expected_print_registered_at,
  48. _expected_print_creators,
  49. _print_ams_mappings,
  50. _active_prints,
  51. _timelapse_baselines,
  52. )
  53. for d in dicts:
  54. d.clear()
  55. yield
  56. for d in dicts:
  57. d.clear()
  58. def _printer():
  59. printer = MagicMock()
  60. printer.id = 1
  61. printer.auto_archive = True
  62. printer.external_camera_enabled = False
  63. printer.external_camera_url = None
  64. # Every unset MagicMock attribute is truthy, and leaving this one implicit
  65. # runs the plate-detection camera grab against a printer that is not there.
  66. printer.plate_detection_enabled = False
  67. printer.name = "X2D"
  68. printer.model = "X2D"
  69. printer.ip_address = "172.25.12.149"
  70. printer.access_code = "12345678"
  71. return printer
  72. async def _run_print_start(subtask: str):
  73. """Drive on_print_start to the wrong-plate rejection and return the row.
  74. Every download succeeds and every 3MF peeks as plate 4, while the dispatch
  75. says plate 1 -- so the initial fetch is rejected and no re-download can
  76. satisfy the guard either, which is the reporter's sequence.
  77. """
  78. printer = _printer()
  79. def execute_router(stmt, *args, **kwargs):
  80. sql = str(stmt).lower()
  81. if "from printers" in sql or "from printer " in sql:
  82. return MagicMock(
  83. scalar_one_or_none=MagicMock(return_value=printer),
  84. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
  85. )
  86. return MagicMock(
  87. scalar_one_or_none=MagicMock(return_value=None),
  88. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  89. )
  90. added: list = []
  91. session = AsyncMock()
  92. session.__aenter__ = AsyncMock(return_value=session)
  93. session.__aexit__ = AsyncMock()
  94. session.execute = AsyncMock(side_effect=execute_router)
  95. session.commit = AsyncMock()
  96. session.refresh = AsyncMock()
  97. session.add = MagicMock(side_effect=added.append)
  98. state = MagicMock(
  99. current_project_url=f"ftp://{subtask}.gcode.3mf",
  100. sdcard=True,
  101. sdcard_reported=True,
  102. )
  103. with (
  104. patch("backend.app.main.async_session") as session_maker,
  105. patch("backend.app.main.notification_service") as notif,
  106. patch("backend.app.main.smart_plug_manager") as plug,
  107. patch("backend.app.main.ws_manager") as ws,
  108. patch("backend.app.main.mqtt_relay") as relay,
  109. patch("backend.app.main.printer_manager") as pm,
  110. patch("backend.app.main.download_file_async", new=AsyncMock(return_value=True)),
  111. patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)),
  112. patch("backend.app.main.get_cached_3mf", return_value=None),
  113. patch("backend.app.main.cache_3mf_download"),
  114. # Plate 4 on the card, plate 1 on the printer -- the mismatch itself.
  115. patch("backend.app.main.peek_plate_index_in_3mf", return_value=4),
  116. # Imported inside the function, so patching it anywhere else lets the
  117. # directory walk open real sockets and the test hangs on connect.
  118. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
  119. patch("backend.app.main.ftps_handshake_blocked", return_value=False),
  120. patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
  121. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  122. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  123. patch("backend.app.main._maybe_start_layer_timelapse"),
  124. patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
  125. # Real, it would spawn a task that outlives the test by a minute.
  126. patch("backend.app.main._schedule_fallback_3mf_retry", new=MagicMock()),
  127. ):
  128. session_maker.return_value = session
  129. notif.on_print_start = AsyncMock()
  130. plug.on_print_start = AsyncMock()
  131. ws.send_print_start = AsyncMock()
  132. ws.send_archive_updated = AsyncMock()
  133. # Awaited between creating the fallback row and the rest of the
  134. # handler: a plain MagicMock raises, and the handler swallows it.
  135. ws.send_archive_created = AsyncMock()
  136. relay.on_print_start = AsyncMock()
  137. pm.get_status = MagicMock(return_value=state)
  138. pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3126"))
  139. from backend.app.main import on_print_start
  140. await on_print_start(1, {"filename": DISPATCH, "subtask_name": subtask})
  141. # Snapshot before the autouse fixture clears it in teardown.
  142. keys = {name for (_pid, name) in _active_prints}
  143. for row in added:
  144. extra = getattr(row, "extra_data", None)
  145. if isinstance(extra, dict) and extra.get("no_3mf_available"):
  146. return row, keys
  147. return None, keys
  148. class TestANameWithNoPlateSuffixSurvives:
  149. @pytest.mark.asyncio
  150. async def test_the_reported_case_keeps_the_project_name(self):
  151. """The regression. This row used to be titled ``plate_1``."""
  152. row, _keys = await _run_print_start(PROJECT)
  153. assert row is not None
  154. assert row.print_name == PROJECT
  155. @pytest.mark.asyncio
  156. async def test_the_original_subtask_is_still_recorded(self):
  157. """Nothing reads this field today -- it is there for support, and a
  158. row that records the dispatch path under both its name and its
  159. subtask tells whoever reads the bundle nothing about the print."""
  160. row, _keys = await _run_print_start(PROJECT)
  161. assert row.extra_data["original_subtask"] == PROJECT
  162. @pytest.mark.asyncio
  163. async def test_an_ascii_name_too(self):
  164. """Nothing here is about the encoding -- any single-plate project name
  165. reaches the same branch."""
  166. row, _keys = await _run_print_start("Fan_Shroud")
  167. assert row.print_name == "Fan_Shroud"
  168. class TestAStalePlateSuffixIsStillCorrected:
  169. """#1204's actual fix, which the change above must not undo."""
  170. @pytest.mark.asyncio
  171. async def test_the_spaced_form_gets_the_running_plate(self):
  172. row, _keys = await _run_print_start("Fan_Shroud - Plate 4")
  173. assert row.print_name == "Fan_Shroud - Plate 1"
  174. @pytest.mark.asyncio
  175. async def test_the_underscored_form_too(self):
  176. row, _keys = await _run_print_start("Fan_Shroud_plate_4")
  177. assert row.print_name == "Fan_Shroud_plate_1"
  178. class TestTheDisownedNameStillFindsNoFiles:
  179. """The other half, and the reason the name is kept in its own variable.
  180. A name the guard just watched fetch another plate's 3MF must not key
  181. ``_active_prints``. The cover endpoint downloads that same name for the
  182. running print's thumbnail and offers the bytes to
  183. ``try_recover_fallback_archive``, which matches on those keys and hands
  184. whatever it gets to ``_recover_fallback_archive`` -- and that checks a
  185. candidate is a readable 3MF, never which plate it holds. Key the row under
  186. the rejected name and the cover endpoint fills it in with the exact file
  187. this branch just deleted, which is #2957's swap coming back in through a
  188. different door.
  189. """
  190. @pytest.mark.asyncio
  191. async def test_the_rejected_name_is_not_registered(self):
  192. row, keys = await _run_print_start(PROJECT)
  193. assert row.print_name == PROJECT, "the title is the whole point of the fix"
  194. assert PROJECT not in keys
  195. assert f"{PROJECT}.3mf" not in keys
  196. @pytest.mark.asyncio
  197. async def test_the_dispatch_path_still_is(self):
  198. """Disowning the subtask name must not leave the archive unfindable at
  199. print completion -- the gcode_file key is what matches it there."""
  200. _row, keys = await _run_print_start(PROJECT)
  201. assert DISPATCH in keys
  202. @pytest.mark.asyncio
  203. async def test_a_corrected_name_is_registered(self):
  204. """#1204's case is different: the swapped name points at the plate that
  205. really is running, so a file found under it is the right file."""
  206. _row, keys = await _run_print_start("Fan_Shroud - Plate 4")
  207. assert "Fan_Shroud - Plate 1" in keys
  208. assert "Fan_Shroud - Plate 4" not in keys