test_scheduler_nozzle_rack_dispatch_1784.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. """What the dispatcher does with a rack-position pick (#1784).
  2. The resolution itself is covered in
  3. ``backend/tests/unit/test_nozzle_rack_positions_1784.py``. This covers the glue
  4. around it, where the two failure modes deliberately differ:
  5. - an **explicit** pick that no longer fits the rack stops the print, because the
  6. operator named a hotend and printing from a different one is how a plate gets
  7. levelled on one nozzle and drawn with another, millimetres above the bed;
  8. - an **assignment** that cannot be made falls through to the pre-existing #2800
  9. path, which is strictly not worse than the behaviour before any of this.
  10. And a non-rack printer must be untouched by all of it.
  11. """
  12. from __future__ import annotations
  13. import json
  14. import zipfile
  15. from contextlib import ExitStack
  16. from pathlib import Path
  17. from types import SimpleNamespace
  18. from unittest.mock import AsyncMock, MagicMock, patch
  19. import pytest
  20. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  21. import backend.app.models # noqa: F401 - populate Base.metadata
  22. import backend.app.services.print_scheduler as scheduler_module
  23. from backend.app.core.database import Base
  24. from backend.app.models.archive import PrintArchive
  25. from backend.app.models.print_queue import PrintQueueItem
  26. from backend.app.models.printer import Printer
  27. from backend.app.models.settings import Settings # noqa: F401 - registers the table
  28. from backend.app.services.print_scheduler import PrintScheduler
  29. from backend.tests._fixtures.background_tasks import discarding_spawn_patch
  30. pytestmark = pytest.mark.integration
  31. # Three filaments in groups 2/0/1, groups 1 and 2 both on the rack carriage --
  32. # the maintainer's own plate, the one that printed in mid-air.
  33. _FILAMENTS = (
  34. '<filament id="1" group_id="2" color="#DE4343" nozzle_diameter="0.40" volume_type="High Flow"/>'
  35. '<filament id="2" group_id="0" color="#F4EE2A" nozzle_diameter="0.40" volume_type="High Flow"/>'
  36. '<filament id="3" group_id="1" color="#0078BF" nozzle_diameter="0.40" volume_type="High Flow"/>'
  37. )
  38. _NOZZLES = '<nozzle id="0" extruder_id="1"/><nozzle id="1" extruder_id="2"/><nozzle id="2" extruder_id="2"/>'
  39. def _write_3mf(path: Path, gcode_members: tuple[str, ...] = (), slice_info: str | None = None) -> None:
  40. """The rack cases need no G-code member and carry none, which is why
  41. ``gcode_members`` defaults to empty. #2947 does need one: the plate a
  42. dispatch resolves to when the queue item names none is read out of the
  43. archive's G-code member names, and with none present the resolution has
  44. nothing to answer from and falls back to 1 regardless.
  45. """
  46. path.parent.mkdir(parents=True, exist_ok=True)
  47. with zipfile.ZipFile(path, "w") as zf:
  48. zf.writestr(
  49. "Metadata/project_settings.config",
  50. json.dumps(
  51. {
  52. "physical_extruder_map": ["1", "0"],
  53. "extruder_max_nozzle_count": ["1", "6"],
  54. "extruder_nozzle_stats": ["High Flow#1", "High Flow#6"],
  55. }
  56. ),
  57. )
  58. zf.writestr(
  59. "Metadata/slice_info.config",
  60. slice_info or f'<config><plate><metadata key="index" value="1"/>{_FILAMENTS}{_NOZZLES}</plate></config>',
  61. )
  62. for name in gcode_members:
  63. zf.writestr(name, "")
  64. def _rack(present=(1, 2, 3, 4, 5, 6)):
  65. """Live rack telemetry, plus the always-reported fixed carriage."""
  66. return [{"id": 15 + p, "diameter": "0.4", "type": "HH01", "filament_color": ""} for p in present] + [
  67. {"id": 1, "diameter": "0.4", "type": "HH01", "filament_color": ""}
  68. ]
  69. @pytest.fixture
  70. async def rack_case(tmp_path):
  71. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  72. async with engine.begin() as conn:
  73. await conn.run_sync(Base.metadata.create_all)
  74. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  75. base_dir = tmp_path / "rack-dispatch"
  76. archive_rel = Path("archives") / "benchy.gcode.3mf"
  77. _write_3mf(base_dir / archive_rel)
  78. async def _build(
  79. model: str,
  80. choice: dict | None,
  81. *,
  82. plate_id: int | None = 1,
  83. gcode_members: tuple[str, ...] = (),
  84. slice_info: str | None = None,
  85. ):
  86. if gcode_members or slice_info:
  87. _write_3mf(base_dir / archive_rel, gcode_members, slice_info)
  88. async with session_maker() as db:
  89. printer = Printer(
  90. name="H2C-1",
  91. serial_number="RACK-SERIAL",
  92. ip_address="127.0.0.1",
  93. access_code="access-code",
  94. model=model,
  95. )
  96. db.add(printer)
  97. await db.flush()
  98. archive = PrintArchive(
  99. printer_id=printer.id,
  100. filename="benchy.gcode.3mf",
  101. file_path=str(archive_rel),
  102. file_size=(base_dir / archive_rel).stat().st_size,
  103. status="completed",
  104. )
  105. db.add(archive)
  106. await db.flush()
  107. item = PrintQueueItem(
  108. printer_id=printer.id,
  109. archive_id=archive.id,
  110. plate_id=plate_id,
  111. status="pending",
  112. nozzle_rack_choice=json.dumps(choice) if choice else None,
  113. )
  114. db.add(item)
  115. await db.commit()
  116. return SimpleNamespace(item_id=item.id, printer_id=printer.id)
  117. try:
  118. yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, build=_build)
  119. finally:
  120. await engine.dispose()
  121. async def _dispatch(ctx, ids, rack_slots):
  122. """Run one dispatch, returning the mocked ``start_print`` and the delete."""
  123. scheduler = PrintScheduler()
  124. start_print = MagicMock(return_value=True)
  125. delete_file = AsyncMock(return_value=True)
  126. status = SimpleNamespace(state="IDLE", nozzle_rack=rack_slots)
  127. with ExitStack() as stack:
  128. for patcher in (
  129. patch.object(scheduler_module, "async_session", ctx.session_maker),
  130. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  131. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  132. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
  133. patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
  134. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  135. patch("backend.app.services.print_scheduler.delete_file_async", delete_file),
  136. patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
  137. # Reads settings through its own session on the real app database,
  138. # which the in-memory engine here does not have.
  139. patch(
  140. "backend.app.services.print_scheduler.get_ftp_retry_settings",
  141. AsyncMock(return_value=(False, 3, 2.0, 30.0)),
  142. ),
  143. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  144. discarding_spawn_patch(),
  145. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  146. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  147. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  148. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  149. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  150. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  151. ):
  152. stack.enter_context(patcher)
  153. await scheduler._dispatch_one(ids.item_id)
  154. async with ctx.session_maker() as db:
  155. item = await db.get(PrintQueueItem, ids.item_id)
  156. return start_print, delete_file, item
  157. def _sent_mapping(start_print):
  158. assert start_print.call_count == 1, "the print command was never sent"
  159. return json.loads(start_print.call_args.kwargs["nozzle_mapping"])
  160. class TestAPickThatStillFits:
  161. async def test_the_chosen_positions_reach_the_printer(self, rack_case):
  162. """Picking R1 for group 2 and R2 for group 1 is BambuStudio's own
  163. dispatch of this plate on 2026-08-14: nozzle_mapping [16, 1, 17].
  164. """
  165. ids = await rack_case.build("H2C", {"2": 1, "1": 2})
  166. start_print, _, item = await _dispatch(rack_case, ids, _rack())
  167. assert _sent_mapping(start_print)[:3] == [16, 1, 17]
  168. assert item.status == "printing"
  169. async def test_a_different_pick_of_the_same_plate_sends_a_different_mapping(self, rack_case):
  170. """The 2026-08-13 dispatch of the identical file: [16, 1, 18]."""
  171. ids = await rack_case.build("H2C", {"2": 1, "1": 3})
  172. start_print, _, _ = await _dispatch(rack_case, ids, _rack())
  173. assert _sent_mapping(start_print)[:3] == [16, 1, 18]
  174. class TestNoPickAtAll:
  175. async def test_positions_are_assigned_rather_than_left_to_the_firmware(self, rack_case):
  176. """The plate that used to dispatch with no mapping now gets one."""
  177. ids = await rack_case.build("H2C", None)
  178. start_print, _, item = await _dispatch(rack_case, ids, _rack())
  179. assert _sent_mapping(start_print)[:3] == [17, 1, 16]
  180. assert item.status == "printing"
  181. async def test_an_unassignable_plate_falls_back_instead_of_failing(self, rack_case):
  182. """Nothing was promised, so nothing is broken by letting the firmware
  183. pick -- exactly what happened before this feature existed.
  184. """
  185. ids = await rack_case.build("H2C", None)
  186. start_print, _, item = await _dispatch(rack_case, ids, _rack(present=()))
  187. assert start_print.call_count == 1
  188. assert start_print.call_args.kwargs["nozzle_mapping"] is None
  189. assert item.status == "printing"
  190. class TestAPickThatNoLongerFits:
  191. """Someone re-loaded the rack between queueing and dispatch."""
  192. async def test_the_print_is_refused_rather_than_sent_to_another_hotend(self, rack_case):
  193. ids = await rack_case.build("H2C", {"2": 1, "1": 3})
  194. start_print, _, item = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
  195. start_print.assert_not_called()
  196. assert item.status == "failed"
  197. async def test_the_error_names_the_position_and_says_how_to_fix_it(self, rack_case):
  198. ids = await rack_case.build("H2C", {"2": 1, "1": 3})
  199. _, _, item = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
  200. assert "rack position 3" in item.error_message
  201. assert "Edit the item" in item.error_message
  202. async def test_the_uploaded_file_is_removed_from_the_sd_card(self, rack_case):
  203. """It is already uploaded by this point, and a 3MF left there is a
  204. phantom print waiting to be started from the touchscreen.
  205. """
  206. ids = await rack_case.build("H2C", {"2": 1, "1": 3})
  207. _, delete_file, _ = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
  208. delete_file.assert_awaited()
  209. class TestThePlateThatGetsDispatched:
  210. """#2947: a queue item with no plate of its own used to dispatch a bare 1.
  211. Driven on an X1C so the rack machinery is out of the way entirely (see
  212. ``test_a_non_rack_printer_is_left_entirely_alone``): the only thing under
  213. test here is which plate number leaves ``_start_print``.
  214. The archive is the shape that wedged a real printer: a single-plate export
  215. cut out of a two-plate project, so its one G-code member is
  216. ``plate_2.gcode`` and there is no ``plate_1.gcode`` for the firmware to
  217. find.
  218. """
  219. async def test_the_print_command_names_the_plate_the_archive_holds(self, rack_case):
  220. ids = await rack_case.build("X1C", None, plate_id=None, gcode_members=("Metadata/plate_2.gcode",))
  221. start_print, _, item = await _dispatch(rack_case, ids, [])
  222. assert start_print.call_count == 1
  223. assert start_print.call_args.kwargs["plate_id"] == 2
  224. assert item.status == "printing"
  225. async def test_usage_tracking_is_registered_for_that_same_plate(self, rack_case):
  226. """Imported inside ``_start_print``, so it patches on its own module.
  227. Without this the archive says plate 2 printed while the usage tracker
  228. was told nothing, and a None there books every filament in the file
  229. instead of the plate that ran.
  230. """
  231. ids = await rack_case.build("X1C", None, plate_id=None, gcode_members=("Metadata/plate_2.gcode",))
  232. with patch("backend.app.main.register_expected_print") as register:
  233. start_print, _, _ = await _dispatch(rack_case, ids, [])
  234. assert start_print.call_count == 1
  235. assert register.call_args.kwargs["plate_id"] == 2
  236. # Plates 2 and 3 cut out of a larger project, so there is no plate 1. Plate 2
  237. # prints filaments 1 and 2 (one rack group, one fixed); plate 3 prints filament
  238. # 3 on a second rack group. The rack lookups read every plate when asked for one
  239. # the file does not describe, and across both plates this becomes the
  240. # two-rack-groups case that neither lookup can answer the same way.
  241. _PLATES_2_AND_3 = (
  242. "<config>"
  243. '<plate><metadata key="index" value="2"/>'
  244. '<filament id="1" group_id="2" color="#DE4343" nozzle_diameter="0.40" volume_type="High Flow"/>'
  245. '<filament id="2" group_id="0" color="#F4EE2A" nozzle_diameter="0.40" volume_type="High Flow"/>'
  246. '<nozzle id="0" extruder_id="1"/><nozzle id="2" extruder_id="2"/></plate>'
  247. '<plate><metadata key="index" value="3"/>'
  248. '<filament id="3" group_id="1" color="#0078BF" nozzle_diameter="0.40" volume_type="High Flow"/>'
  249. '<nozzle id="1" extruder_id="2"/></plate>'
  250. "</config>"
  251. )
  252. class TestTheRackLookupsReadThePlateThatGetsDispatched:
  253. """#2947 on an H2C: the rack plan and the slot extruders are read for the
  254. same plate the print command names, not for a plate 1 the file lacks.
  255. """
  256. async def _build(self, rack_case):
  257. return await rack_case.build(
  258. "H2C",
  259. None,
  260. plate_id=None,
  261. gcode_members=("Metadata/plate_2.gcode", "Metadata/plate_3.gcode"),
  262. slice_info=_PLATES_2_AND_3,
  263. )
  264. async def test_the_rack_is_resolved_for_plate_two_only(self, rack_case):
  265. ids = await self._build(rack_case)
  266. start_print, _, _ = await _dispatch(rack_case, ids, _rack())
  267. assert start_print.call_args.kwargs["plate_id"] == 2
  268. # Filament 3 belongs to plate 3; plate 2 has no third slot to map.
  269. assert _sent_mapping(start_print)[:3] == [16, 1, -1]
  270. async def test_the_slot_extruders_are_read_for_plate_two_only(self, rack_case):
  271. """With no rack to assign from, the dispatch falls back to the #2800
  272. slot extruders, which plate 2 alone can state and the pair cannot."""
  273. ids = await self._build(rack_case)
  274. start_print, _, _ = await _dispatch(rack_case, ids, _rack(present=()))
  275. assert start_print.call_args.kwargs["nozzle_mapping"] is None
  276. assert json.loads(start_print.call_args.kwargs["nozzle_slot_extruders"]) == [0, 1]
  277. class TestOtherModels:
  278. async def test_a_non_rack_printer_is_left_entirely_alone(self, rack_case):
  279. """No rack means no resolution, no refusal, and no mapping invented."""
  280. ids = await rack_case.build("X1C", None)
  281. start_print, _, item = await _dispatch(rack_case, ids, [])
  282. assert start_print.call_count == 1
  283. assert start_print.call_args.kwargs["nozzle_mapping"] is None
  284. assert item.status == "printing"
  285. async def test_a_stale_pick_on_a_non_rack_printer_does_not_stop_the_print(self, rack_case):
  286. """The column can survive a reassignment to another model; it must not
  287. then block a printer the pick never applied to.
  288. """
  289. ids = await rack_case.build("X1C", {"2": 1, "1": 3})
  290. start_print, _, item = await _dispatch(rack_case, ids, [])
  291. assert start_print.call_count == 1
  292. assert item.status == "printing"