test_scheduler_external_spool_nozzle_2771.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. """Regression tests for external-spool nozzle routing on AMS-less printers (#2771).
  2. A fleet of X2Ds with no AMS, printing from external spools, could not be sent a
  3. job with "Any X2D": the print uploaded, then the firmware rejected it with
  4. 0700_8012 "Failed to get AMS mapping table" and the item failed after three
  5. dispatch attempts. Sending the same file to a named printer worked, because that
  6. path carries a mapping the *frontend* resolved and the scheduler's matcher never
  7. runs.
  8. Cause: ``_build_loaded_filaments`` derived dual-nozzle status from
  9. ``ams_extruder_map``, which is built from AMS info bits — a dual-nozzle printer
  10. with zero AMS units reports an empty map. Every external spool then got
  11. ``extruder_id=None``, and the nozzle-aware hard filter in
  12. ``_match_filaments_to_slots`` rejected it because ``None`` equals neither 0 nor
  13. 1. Nothing matched, the mapping came back all -1 and was cleared to None, and the
  14. print command went out as ``use_ams: true`` with no mapping table at all.
  15. This is the backend half of #1257, which fixed the identical logic in
  16. ``useFilamentMapping.ts`` and left this copy behind; the first two tests below
  17. mirror its frontend regression tests.
  18. The second half covers the guard that keeps a genuinely unmappable job from
  19. being uploaded at all, since without an AMS there is no "load another spool and
  20. press Resume" recovery for the firmware error to lead to.
  21. """
  22. import json
  23. from types import SimpleNamespace
  24. from unittest.mock import AsyncMock, MagicMock, patch
  25. import pytest
  26. from backend.app.services.print_scheduler import (
  27. PrintScheduler,
  28. _unmatched_filament_message,
  29. )
  30. # Two external feeds, as an X2D/H2D reports them: 254 is Ext-L (deputy/left,
  31. # extruder 1) and 255 is Ext-R (main/right, extruder 0).
  32. DUAL_EXTERNAL = [
  33. {"id": "254", "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG00"},
  34. {"id": "255", "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
  35. ]
  36. REAL_NOZZLES = [
  37. SimpleNamespace(nozzle_diameter="0.4"),
  38. SimpleNamespace(nozzle_diameter="0.4"),
  39. ]
  40. # The state seeds `nozzles` with two empty NozzleInfo stubs even on single-nozzle
  41. # printers, so the second entry's presence proves nothing — only a diameter does.
  42. STUB_NOZZLES = [
  43. SimpleNamespace(nozzle_diameter="0.4"),
  44. SimpleNamespace(nozzle_diameter=""),
  45. ]
  46. def _status(raw_data, nozzles=None):
  47. return SimpleNamespace(raw_data=raw_data, nozzles=nozzles)
  48. @pytest.fixture
  49. def scheduler():
  50. return PrintScheduler()
  51. class TestExternalSpoolExtruderRouting:
  52. """``_build_loaded_filaments`` must route external spools without an AMS."""
  53. def test_dual_nozzle_without_ams_routes_both_external_feeds(self, scheduler):
  54. """The X2D case from the report: no AMS, so ams_extruder_map is empty."""
  55. loaded = scheduler._build_loaded_filaments(
  56. _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
  57. )
  58. by_tray = {f["global_tray_id"]: f for f in loaded}
  59. assert by_tray[254]["extruder_id"] == 1 # Ext-L -> left
  60. assert by_tray[255]["extruder_id"] == 0 # Ext-R -> right
  61. def test_single_nozzle_stub_does_not_fabricate_an_extruder(self, scheduler):
  62. """A P1S/A1/X1C must keep extruder_id=None, matching pre-fix behaviour.
  63. Sibling regression to the fix: `nozzles` always has two entries, so
  64. inferring dual-nozzle from its length would hand every single-nozzle
  65. printer's external spool a nozzle it does not have.
  66. """
  67. loaded = scheduler._build_loaded_filaments(
  68. _status(
  69. {"ams": [], "ams_extruder_map": {}, "vt_tray": [DUAL_EXTERNAL[0]]},
  70. STUB_NOZZLES,
  71. )
  72. )
  73. assert len(loaded) == 1
  74. assert loaded[0]["extruder_id"] is None
  75. def test_two_external_feeds_alone_imply_dual_nozzle(self, scheduler):
  76. """Fallback signal: only dual-nozzle hardware exposes two external feeds.
  77. Kept for firmware revisions that report the feeds but not the nozzle
  78. diameters — here `nozzles` is absent entirely.
  79. """
  80. loaded = scheduler._build_loaded_filaments(
  81. _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL})
  82. )
  83. assert {f["extruder_id"] for f in loaded} == {0, 1}
  84. def test_populated_ams_extruder_map_still_implies_dual_nozzle(self, scheduler):
  85. """The original signal keeps working when there IS an AMS."""
  86. loaded = scheduler._build_loaded_filaments(
  87. _status(
  88. {"ams": [], "ams_extruder_map": {"0": 1}, "vt_tray": [DUAL_EXTERNAL[0]]},
  89. STUB_NOZZLES,
  90. )
  91. )
  92. assert loaded[0]["extruder_id"] == 1
  93. def test_mapping_resolves_for_the_nozzle_the_spool_feeds(self, scheduler):
  94. """End to end: the matcher now finds the external spool, as it did for
  95. the working named-printer dispatch (which sent ams_mapping [254])."""
  96. loaded = scheduler._build_loaded_filaments(
  97. _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
  98. )
  99. req = {"slot_id": 1, "type": "PETG", "color": "#000000", "tray_info_idx": "GFG00"}
  100. assert scheduler._match_filaments_to_slots([{**req, "nozzle_id": 1}], loaded) == [254]
  101. # Nothing PETG on the right nozzle — still correctly unmatched.
  102. assert scheduler._match_filaments_to_slots([{**req, "nozzle_id": 0}], loaded) == [-1]
  103. class TestUnmatchedFilamentMessage:
  104. """The message has to name the filament and, on dual-nozzle, the nozzle."""
  105. def test_names_type_colour_and_nozzle(self):
  106. message = _unmatched_filament_message(
  107. [{"slot_id": 1, "type": "PETG", "color": "#000000", "nozzle_id": 0}],
  108. [{"type": "PETG", "color": "#000000", "extruder_id": 1}],
  109. )
  110. assert "PETG #000000 (right nozzle)" in message
  111. assert "PETG #000000 (left nozzle)" in message
  112. def test_omits_nozzle_on_single_nozzle_printers(self):
  113. message = _unmatched_filament_message(
  114. [{"slot_id": 1, "type": "ABS", "color": "#FF0000"}],
  115. [{"type": "PLA", "color": "#000000"}],
  116. )
  117. assert "ABS #FF0000" in message
  118. assert "nozzle" not in message
  119. class TestUnmappableWithoutAmsGuard:
  120. """``_ensure_ams_mapping`` reports only a positive, unrecoverable finding."""
  121. def _item(self, ams_mapping=None):
  122. item = MagicMock()
  123. item.id = 22
  124. item.printer_id = 4
  125. item.ams_mapping = ams_mapping
  126. item.filament_overrides = None
  127. return item
  128. async def _ensure(self, scheduler, computed, status):
  129. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=computed)
  130. scheduler._get_filament_requirements = AsyncMock(
  131. return_value=[{"slot_id": 1, "type": "PETG", "color": "#000000", "nozzle_id": 0}]
  132. )
  133. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  134. pm.get_status.return_value = status
  135. return await scheduler._ensure_ams_mapping(AsyncMock(), 4, self._item())
  136. @pytest.mark.asyncio
  137. async def test_reports_when_nothing_matches_and_there_is_no_ams(self, scheduler):
  138. status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
  139. message = await self._ensure(scheduler, [-1], status)
  140. assert message is not None
  141. assert "no AMS" in message
  142. @pytest.mark.asyncio
  143. async def test_silent_when_an_ams_is_attached(self, scheduler):
  144. """With an AMS the user can load a spool and press Resume, so the
  145. firmware's own error is worth reaching — behaviour is unchanged."""
  146. status = _status(
  147. {
  148. "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]}],
  149. "ams_extruder_map": {},
  150. "vt_tray": DUAL_EXTERNAL,
  151. },
  152. REAL_NOZZLES,
  153. )
  154. assert await self._ensure(scheduler, [-1], status) is None
  155. @pytest.mark.asyncio
  156. async def test_silent_when_the_ams_field_has_not_arrived_yet(self, scheduler):
  157. """Absence of an AMS report is not a report of no AMS.
  158. `raw_data["ams"]` appears only once an AMS push has been handled, so a
  159. missing key means a reconnect or a cold start — where a fully loaded
  160. AMS is briefly invisible and everything would look unmappable.
  161. """
  162. status = _status({"ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
  163. assert await self._ensure(scheduler, [-1], status) is None
  164. @pytest.mark.asyncio
  165. async def test_silent_when_the_matcher_never_ran(self, scheduler):
  166. """A None mapping means no requirements parsed or nothing loaded — not
  167. evidence of a mismatch. Fail-safe: dispatch as before."""
  168. status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
  169. assert await self._ensure(scheduler, None, status) is None
  170. @pytest.mark.asyncio
  171. async def test_silent_when_the_mapping_resolves(self, scheduler):
  172. status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
  173. item = self._item()
  174. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[254])
  175. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  176. pm.get_status.return_value = status
  177. assert await scheduler._ensure_ams_mapping(AsyncMock(), 4, item) is None
  178. assert json.loads(item.ams_mapping) == [254]
  179. @pytest.mark.asyncio
  180. async def test_silent_when_the_printer_status_is_gone(self, scheduler):
  181. assert await self._ensure(scheduler, [-1], None) is None
  182. class TestFailUnmappableItem:
  183. """The guard fails the item instead of spending an upload on it."""
  184. @pytest.mark.asyncio
  185. async def test_marks_failed_with_the_message(self, scheduler):
  186. db = AsyncMock()
  187. item = MagicMock()
  188. item.id = 22
  189. item.created_by_id = 1
  190. with (
  191. patch("backend.app.services.print_scheduler.notification_service") as notify,
  192. patch("backend.app.services.print_scheduler.ws_manager"),
  193. ):
  194. notify.on_queue_job_failed = AsyncMock()
  195. scheduler._get_job_name = AsyncMock(return_value="Fidget")
  196. scheduler._get_printer = AsyncMock(return_value=SimpleNamespace(name="X2D-1"))
  197. await scheduler._fail_unmappable_item(db, item, 4, "needs PETG")
  198. assert item.status == "failed"
  199. assert item.error_message == "needs PETG"
  200. assert item.completed_at is not None
  201. db.commit.assert_awaited()
  202. notify.on_queue_job_failed.assert_awaited_once()