test_external_mapping_on_ams_printer_3239.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. """A mapping made for an external spool, sent to a printer whose AMS holds it (#3239).
  2. Two A1 in one location, both with black PETG: A1-018 has no AMS and feeds it
  3. from the external spool (``[254]``), A1-002 holds it in AMS tray 0 and has an
  4. empty external spool. A failed job of A1-018 was queued again and moved to the
  5. location, kept ``[254]``, and was picked by A1-002, which then stopped at once
  6. with "External filament is missing". The #2799 check let it through because an
  7. external tray the printer doesn't report as loaded counted as no evidence.
  8. The printers' trays below are the reporter's, from the support bundle.
  9. """
  10. import json
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from backend.app.services.print_scheduler import PrintScheduler
  15. BLACK_PETG = [{"slot_id": 1, "type": "PETG", "color": "#000000", "tray_info_idx": "GFG99"}]
  16. EMPTY_EXTERNAL = {"id": "254", "tray_type": "", "tray_color": "00000000", "remain": 0, "tray_info_idx": ""}
  17. LOADED_EXTERNAL = {"id": "254", "tray_type": "PETG", "tray_color": "000000FF", "remain": 80, "tray_info_idx": "GFG99"}
  18. A1_002_AMS = [
  19. {
  20. "id": "0",
  21. "tray": [
  22. {"id": "0", "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG99"},
  23. {"id": "1", "tray_type": "PETG", "tray_color": "C52C18FF", "tray_info_idx": "GFG99"},
  24. {"id": "2", "tray_type": "PETG", "tray_color": "AC95D5FF", "tray_info_idx": "GFG99"},
  25. {"id": "3", "tray_type": "PETG", "tray_color": "0085D5FF", "tray_info_idx": "GFG99"},
  26. ],
  27. }
  28. ]
  29. def _status(ams, vt_tray):
  30. return SimpleNamespace(
  31. raw_data={"ams": ams, "vt_tray": [vt_tray], "ams_extruder_map": {}},
  32. nozzles=[],
  33. fila_switch=None,
  34. )
  35. def _item():
  36. item = MagicMock()
  37. item.id = 2453
  38. item.printer_id = None
  39. item.ams_mapping = json.dumps([254])
  40. item.skip_filament_check = False
  41. item.filament_overrides = None
  42. item.required_filament_types = None
  43. item.manual_start = False
  44. item.filament_short = False
  45. return item
  46. def _scheduler():
  47. scheduler = PrintScheduler()
  48. scheduler._get_filament_requirements = AsyncMock(return_value=[dict(r) for r in BLACK_PETG])
  49. scheduler._get_bool_setting = AsyncMock(return_value=False)
  50. return scheduler
  51. async def _ensure(status):
  52. scheduler = _scheduler()
  53. item = _item()
  54. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  55. pm.get_status.return_value = status
  56. await scheduler._ensure_ams_mapping(AsyncMock(), 12, item)
  57. return json.loads(item.ams_mapping) if item.ams_mapping else None
  58. @pytest.mark.asyncio
  59. async def test_the_reporters_job_is_remapped_to_the_ams_tray():
  60. assert await _ensure(_status(A1_002_AMS, EMPTY_EXTERNAL)) == [0]
  61. @pytest.mark.asyncio
  62. async def test_the_printer_it_was_made_for_keeps_the_external_spool():
  63. """A1-018: no AMS, black PETG on the external spool."""
  64. assert await _ensure(_status([], LOADED_EXTERNAL)) == [254]
  65. @pytest.mark.asyncio
  66. async def test_an_external_spool_without_its_filament_set_keeps_printing():
  67. """No AMS, external spool loaded but never given a filament type: there is
  68. nothing else to feed from, so the job goes out as it always did."""
  69. assert await _ensure(_status([], EMPTY_EXTERNAL)) == [254]
  70. @pytest.mark.asyncio
  71. async def test_an_ams_without_the_filament_does_not_take_the_slot():
  72. """An empty external spool alone proves nothing when the AMS can't print the
  73. slot either; the mapping is left for the existing checks."""
  74. other_material = [
  75. {"id": "0", "tray": [{"id": "0", "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "GFL99"}]}
  76. ]
  77. scheduler = _scheduler()
  78. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  79. pm.get_status.return_value = _status(other_material, EMPTY_EXTERNAL)
  80. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 12, _item(), [254])
  81. assert conflict is None
  82. @pytest.mark.asyncio
  83. async def test_another_colour_in_the_ams_does_not_take_the_slot():
  84. """A job for this very printer whose external spool is just empty for now:
  85. the printer asking for the spool beats printing it in white."""
  86. white_only = [
  87. {"id": "0", "tray": [{"id": "0", "tray_type": "PETG", "tray_color": "FFFFFFFF", "tray_info_idx": "GFG99"}]}
  88. ]
  89. scheduler = _scheduler()
  90. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  91. pm.get_status.return_value = _status(white_only, EMPTY_EXTERNAL)
  92. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 12, _item(), [254])
  93. assert conflict is None
  94. @pytest.mark.asyncio
  95. async def test_the_conflict_names_the_empty_external_spool():
  96. scheduler = _scheduler()
  97. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  98. pm.get_status.return_value = _status(A1_002_AMS, EMPTY_EXTERNAL)
  99. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 12, _item(), [254])
  100. assert conflict is not None
  101. assert "external spool" in conflict
  102. assert "tray 0" in conflict
  103. @pytest.mark.asyncio
  104. async def test_print_anyway_still_keeps_the_users_mapping():
  105. scheduler = _scheduler()
  106. item = _item()
  107. item.skip_filament_check = True
  108. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  109. pm.get_status.return_value = _status(A1_002_AMS, EMPTY_EXTERNAL)
  110. await scheduler._ensure_ams_mapping(AsyncMock(), 12, item)
  111. assert json.loads(item.ams_mapping) == [254]
  112. # A job made for A1-003, where black PETG sits in AMS tray 3, moved to the
  113. # location and picked by A1-002, whose tray 3 holds blue PETG. Tray 3 exists
  114. # and holds PETG, so the stored [3] fits by type and would print in blue.
  115. A1_002_BLUE_IN_TRAY_3 = [
  116. {
  117. "id": "0",
  118. "tray": [
  119. {"id": "0", "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG99"},
  120. {"id": "3", "tray_type": "PETG", "tray_color": "0085D5FF", "tray_info_idx": "GFG99"},
  121. ],
  122. }
  123. ]
  124. @pytest.fixture
  125. async def session_maker():
  126. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  127. import backend.app.models # noqa: F401 - populate Base.metadata
  128. from backend.app.core.database import Base
  129. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  130. async with engine.begin() as conn:
  131. await conn.run_sync(Base.metadata.create_all)
  132. try:
  133. yield async_sessionmaker(engine, expire_on_commit=False)
  134. finally:
  135. await engine.dispose()
  136. async def _location_job(session_maker, ams_mapping):
  137. from backend.app.models.library import LibraryFile
  138. from backend.app.models.print_queue import PrintQueueItem
  139. from backend.app.models.printer import Printer
  140. async with session_maker() as db:
  141. db.add(
  142. Printer(
  143. id=2,
  144. name="A1-002",
  145. serial_number="A1002",
  146. ip_address="10.0.0.2",
  147. access_code="x",
  148. model="A1",
  149. location="Farm",
  150. is_active=True,
  151. )
  152. )
  153. lib = LibraryFile(
  154. filename="job.gcode.3mf",
  155. file_path="/library/job.gcode.3mf",
  156. file_size=10,
  157. file_type="gcode.3mf",
  158. file_metadata={"sliced_for_model": "A1"},
  159. )
  160. db.add(lib)
  161. await db.flush()
  162. item = PrintQueueItem(
  163. status="pending",
  164. position=1,
  165. target_model="A1",
  166. target_location="Farm",
  167. library_file_id=lib.id,
  168. ams_mapping=ams_mapping,
  169. )
  170. db.add(item)
  171. await db.commit()
  172. return item.id
  173. async def _mapping_after_a_pass(session_maker, item_id):
  174. from backend.app.models.print_queue import PrintQueueItem
  175. scheduler = _scheduler()
  176. scheduler._get_job_name = AsyncMock(return_value="job")
  177. status = _status(A1_002_BLUE_IN_TRAY_3, EMPTY_EXTERNAL)
  178. with (
  179. patch("backend.app.services.print_scheduler.async_session", session_maker),
  180. patch("backend.app.core.database.async_session", session_maker),
  181. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  182. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
  183. patch("backend.app.services.notification_service.notification_service.on_queue_job_assigned", AsyncMock()),
  184. patch.object(scheduler, "_find_idle_printer_for_model", AsyncMock(return_value=(2, None))),
  185. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  186. patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
  187. patch.object(scheduler, "_launch_uploads", MagicMock()),
  188. ):
  189. await scheduler.check_queue()
  190. async with session_maker() as db:
  191. item = await db.get(PrintQueueItem, item_id)
  192. return item.printer_id, json.loads(item.ams_mapping) if item.ams_mapping else None
  193. @pytest.mark.asyncio
  194. async def test_a_job_moved_to_a_location_is_mapped_for_the_printer_it_gets(session_maker):
  195. item_id = await _location_job(session_maker, json.dumps([3]))
  196. assert await _mapping_after_a_pass(session_maker, item_id) == (2, [0])
  197. @pytest.mark.asyncio
  198. async def test_a_location_job_without_a_mapping_is_matched_the_same(session_maker):
  199. item_id = await _location_job(session_maker, None)
  200. assert await _mapping_after_a_pass(session_maker, item_id) == (2, [0])
  201. async def _ensure_unmapped(status, model="A1", required=None):
  202. """A job with no stored mapping, as a location job now always arrives."""
  203. scheduler = _scheduler()
  204. if required is not None:
  205. scheduler._get_filament_requirements = AsyncMock(return_value=required)
  206. scheduler._get_printer = AsyncMock(return_value=SimpleNamespace(model=model))
  207. item = _item()
  208. item.ams_mapping = None
  209. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  210. pm.get_status.return_value = status
  211. message = await scheduler._ensure_ams_mapping(AsyncMock(), 12, item)
  212. return (json.loads(item.ams_mapping) if item.ams_mapping else None), message
  213. @pytest.mark.asyncio
  214. async def test_a_printer_without_ams_prints_from_its_unset_external_spool():
  215. """A1-018 picks the job from the location: no AMS, and its external spool
  216. holds the black PETG without a filament set. Without a mapping the print
  217. would go out with the AMS on and be rejected with 0700_8012."""
  218. assert await _ensure_unmapped(_status([], EMPTY_EXTERNAL)) == ([254], None)
  219. @pytest.mark.asyncio
  220. async def test_every_printed_filament_goes_to_the_external_spool():
  221. required = [
  222. {"slot_id": 1, "type": "PETG", "color": "#000000"},
  223. {"slot_id": 3, "type": "PETG", "color": "#FFFFFF"},
  224. ]
  225. assert await _ensure_unmapped(_status([], EMPTY_EXTERNAL), required=required) == ([254, -1, 254], None)
  226. @pytest.mark.asyncio
  227. async def test_a_set_external_spool_is_matched_as_before():
  228. assert await _ensure_unmapped(_status([], LOADED_EXTERNAL)) == ([254], None)
  229. @pytest.mark.asyncio
  230. async def test_an_external_spool_set_to_another_material_still_fails_the_job():
  231. """The matcher answered: the only spool holds PLA. That stays a failure
  232. with a message (#2771), not a print in the wrong material."""
  233. pla = {**LOADED_EXTERNAL, "tray_type": "PLA", "tray_info_idx": "GFL99"}
  234. mapping, message = await _ensure_unmapped(_status([], pla))
  235. assert mapping is None
  236. assert message
  237. @pytest.mark.asyncio
  238. async def test_a_printer_with_an_ams_is_not_sent_to_the_external_spool():
  239. empty_ams = [{"id": "0", "tray": [{"id": "0", "tray_type": ""}]}]
  240. assert await _ensure_unmapped(_status(empty_ams, EMPTY_EXTERNAL)) == (None, None)
  241. @pytest.mark.asyncio
  242. async def test_a_printer_that_has_not_reported_its_ams_is_left_alone():
  243. status = SimpleNamespace(raw_data={"vt_tray": [EMPTY_EXTERNAL]}, nozzles=[], fila_switch=None)
  244. assert await _ensure_unmapped(status) == (None, None)
  245. @pytest.mark.asyncio
  246. async def test_a_dual_nozzle_printer_is_left_alone():
  247. """Its two external feeds steer the nozzles; which one is not ours to pick."""
  248. status = _status([], EMPTY_EXTERNAL)
  249. status.raw_data["vt_tray"] = [EMPTY_EXTERNAL, {**EMPTY_EXTERNAL, "id": "255"}]
  250. assert await _ensure_unmapped(status, model="H2D") == (None, None)
  251. @pytest.mark.asyncio
  252. async def test_a_job_that_wants_its_colours_matched_strictly_is_left_alone():
  253. """Force colour match: a spool without a filament set has no colour to check."""
  254. scheduler = _scheduler()
  255. scheduler._get_printer = AsyncMock(return_value=SimpleNamespace(model="A1"))
  256. item = _item()
  257. item.ams_mapping = None
  258. item.filament_overrides = json.dumps(
  259. [{"slot_id": 1, "type": "PETG", "color": "#000000", "force_color_match": True}]
  260. )
  261. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  262. pm.get_status.return_value = _status([], EMPTY_EXTERNAL)
  263. assert await scheduler._external_spool_only_mapping(AsyncMock(), 12, item) is None