test_scheduler_nozzle_rack_dispatch_1784.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  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) -> None:
  40. path.parent.mkdir(parents=True, exist_ok=True)
  41. with zipfile.ZipFile(path, "w") as zf:
  42. zf.writestr(
  43. "Metadata/project_settings.config",
  44. json.dumps(
  45. {
  46. "physical_extruder_map": ["1", "0"],
  47. "extruder_max_nozzle_count": ["1", "6"],
  48. "extruder_nozzle_stats": ["High Flow#1", "High Flow#6"],
  49. }
  50. ),
  51. )
  52. zf.writestr(
  53. "Metadata/slice_info.config",
  54. f'<config><plate><metadata key="index" value="1"/>{_FILAMENTS}{_NOZZLES}</plate></config>',
  55. )
  56. def _rack(present=(1, 2, 3, 4, 5, 6)):
  57. """Live rack telemetry, plus the always-reported fixed carriage."""
  58. return [{"id": 15 + p, "diameter": "0.4", "type": "HH01", "filament_color": ""} for p in present] + [
  59. {"id": 1, "diameter": "0.4", "type": "HH01", "filament_color": ""}
  60. ]
  61. @pytest.fixture
  62. async def rack_case(tmp_path):
  63. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  64. async with engine.begin() as conn:
  65. await conn.run_sync(Base.metadata.create_all)
  66. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  67. base_dir = tmp_path / "rack-dispatch"
  68. archive_rel = Path("archives") / "benchy.gcode.3mf"
  69. _write_3mf(base_dir / archive_rel)
  70. async def _build(model: str, choice: dict | None):
  71. async with session_maker() as db:
  72. printer = Printer(
  73. name="H2C-1",
  74. serial_number="RACK-SERIAL",
  75. ip_address="127.0.0.1",
  76. access_code="access-code",
  77. model=model,
  78. )
  79. db.add(printer)
  80. await db.flush()
  81. archive = PrintArchive(
  82. printer_id=printer.id,
  83. filename="benchy.gcode.3mf",
  84. file_path=str(archive_rel),
  85. file_size=(base_dir / archive_rel).stat().st_size,
  86. status="completed",
  87. )
  88. db.add(archive)
  89. await db.flush()
  90. item = PrintQueueItem(
  91. printer_id=printer.id,
  92. archive_id=archive.id,
  93. plate_id=1,
  94. status="pending",
  95. nozzle_rack_choice=json.dumps(choice) if choice else None,
  96. )
  97. db.add(item)
  98. await db.commit()
  99. return SimpleNamespace(item_id=item.id, printer_id=printer.id)
  100. try:
  101. yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, build=_build)
  102. finally:
  103. await engine.dispose()
  104. async def _dispatch(ctx, ids, rack_slots):
  105. """Run one dispatch, returning the mocked ``start_print`` and the delete."""
  106. scheduler = PrintScheduler()
  107. start_print = MagicMock(return_value=True)
  108. delete_file = AsyncMock(return_value=True)
  109. status = SimpleNamespace(state="IDLE", nozzle_rack=rack_slots)
  110. with ExitStack() as stack:
  111. for patcher in (
  112. patch.object(scheduler_module, "async_session", ctx.session_maker),
  113. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  114. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  115. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
  116. patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
  117. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  118. patch("backend.app.services.print_scheduler.delete_file_async", delete_file),
  119. patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
  120. # Reads settings through its own session on the real app database,
  121. # which the in-memory engine here does not have.
  122. patch(
  123. "backend.app.services.print_scheduler.get_ftp_retry_settings",
  124. AsyncMock(return_value=(False, 3, 2.0, 30.0)),
  125. ),
  126. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  127. discarding_spawn_patch(),
  128. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  129. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  130. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  131. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  132. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  133. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  134. ):
  135. stack.enter_context(patcher)
  136. await scheduler._dispatch_one(ids.item_id)
  137. async with ctx.session_maker() as db:
  138. item = await db.get(PrintQueueItem, ids.item_id)
  139. return start_print, delete_file, item
  140. def _sent_mapping(start_print):
  141. assert start_print.call_count == 1, "the print command was never sent"
  142. return json.loads(start_print.call_args.kwargs["nozzle_mapping"])
  143. class TestAPickThatStillFits:
  144. async def test_the_chosen_positions_reach_the_printer(self, rack_case):
  145. """Picking R1 for group 2 and R2 for group 1 is BambuStudio's own
  146. dispatch of this plate on 2026-08-14: nozzle_mapping [16, 1, 17].
  147. """
  148. ids = await rack_case.build("H2C", {"2": 1, "1": 2})
  149. start_print, _, item = await _dispatch(rack_case, ids, _rack())
  150. assert _sent_mapping(start_print)[:3] == [16, 1, 17]
  151. assert item.status == "printing"
  152. async def test_a_different_pick_of_the_same_plate_sends_a_different_mapping(self, rack_case):
  153. """The 2026-08-13 dispatch of the identical file: [16, 1, 18]."""
  154. ids = await rack_case.build("H2C", {"2": 1, "1": 3})
  155. start_print, _, _ = await _dispatch(rack_case, ids, _rack())
  156. assert _sent_mapping(start_print)[:3] == [16, 1, 18]
  157. class TestNoPickAtAll:
  158. async def test_positions_are_assigned_rather_than_left_to_the_firmware(self, rack_case):
  159. """The plate that used to dispatch with no mapping now gets one."""
  160. ids = await rack_case.build("H2C", None)
  161. start_print, _, item = await _dispatch(rack_case, ids, _rack())
  162. assert _sent_mapping(start_print)[:3] == [17, 1, 16]
  163. assert item.status == "printing"
  164. async def test_an_unassignable_plate_falls_back_instead_of_failing(self, rack_case):
  165. """Nothing was promised, so nothing is broken by letting the firmware
  166. pick -- exactly what happened before this feature existed.
  167. """
  168. ids = await rack_case.build("H2C", None)
  169. start_print, _, item = await _dispatch(rack_case, ids, _rack(present=()))
  170. assert start_print.call_count == 1
  171. assert start_print.call_args.kwargs["nozzle_mapping"] is None
  172. assert item.status == "printing"
  173. class TestAPickThatNoLongerFits:
  174. """Someone re-loaded the rack between queueing and dispatch."""
  175. async def test_the_print_is_refused_rather_than_sent_to_another_hotend(self, rack_case):
  176. ids = await rack_case.build("H2C", {"2": 1, "1": 3})
  177. start_print, _, item = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
  178. start_print.assert_not_called()
  179. assert item.status == "failed"
  180. async def test_the_error_names_the_position_and_says_how_to_fix_it(self, rack_case):
  181. ids = await rack_case.build("H2C", {"2": 1, "1": 3})
  182. _, _, item = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
  183. assert "rack position 3" in item.error_message
  184. assert "Edit the item" in item.error_message
  185. async def test_the_uploaded_file_is_removed_from_the_sd_card(self, rack_case):
  186. """It is already uploaded by this point, and a 3MF left there is a
  187. phantom print waiting to be started from the touchscreen.
  188. """
  189. ids = await rack_case.build("H2C", {"2": 1, "1": 3})
  190. _, delete_file, _ = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
  191. delete_file.assert_awaited()
  192. class TestOtherModels:
  193. async def test_a_non_rack_printer_is_left_entirely_alone(self, rack_case):
  194. """No rack means no resolution, no refusal, and no mapping invented."""
  195. ids = await rack_case.build("X1C", None)
  196. start_print, _, item = await _dispatch(rack_case, ids, [])
  197. assert start_print.call_count == 1
  198. assert start_print.call_args.kwargs["nozzle_mapping"] is None
  199. assert item.status == "printing"
  200. async def test_a_stale_pick_on_a_non_rack_printer_does_not_stop_the_print(self, rack_case):
  201. """The column can survive a reassignment to another model; it must not
  202. then block a printer the pick never applied to.
  203. """
  204. ids = await rack_case.build("X1C", {"2": 1, "1": 3})
  205. start_print, _, item = await _dispatch(rack_case, ids, [])
  206. assert start_print.call_count == 1
  207. assert item.status == "printing"