test_ams_mapping_per_printer_2799.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858
  1. """Regression tests for the cross-printer AMS mapping bug (#2799).
  2. One model queued to three P2S at once produced three queue items carrying a
  3. byte-identical ``ams_mapping``. That mapping was correct for the printer it was
  4. computed against and wrong for the other two, whose AMS held the same spools in
  5. different slots — so two of the three printed the lettering in ASA where the 3MF
  6. strictly requires PETG. An explicit ``ams_mapping`` bypasses the firmware's own
  7. type check, so nothing downstream caught it.
  8. The reproduction below is the real incident: the 3MF needs light-blue PETG in
  9. slot 1 and dark-blue PETG in slot 3; P2S-4 holds them in trays 2 and 1, while
  10. P2S-5 holds them in trays 0 and 2 with ASA in tray 1. ``[2, -1, 1]`` is right on
  11. the first and feeds ASA into slot 3 on the second.
  12. """
  13. import json
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from backend.app.services.print_scheduler import PrintScheduler
  17. # 3MF requirements: slot 1 light-blue PETG (body), slot 3 dark-blue PETG (text).
  18. REQUIREMENTS = [
  19. {"slot_id": 1, "type": "PETG", "color": "#76D9F4", "tray_info_idx": "GFG99"},
  20. {"slot_id": 3, "type": "PETG", "color": "#2850E0", "tray_info_idx": "GFG99"},
  21. ]
  22. # AMS layouts as reported over MQTT, same spools in a different slot order.
  23. P2S_4_TRAYS = [
  24. {"global_tray_id": 0, "type": "ASA", "color": "161616FF"},
  25. {"global_tray_id": 1, "type": "PETG", "color": "2850E0FF"},
  26. {"global_tray_id": 2, "type": "PETG", "color": "76D9F4FF"},
  27. {"global_tray_id": 3, "type": "ASA", "color": "898989FF"},
  28. ]
  29. P2S_5_TRAYS = [
  30. {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
  31. {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
  32. {"global_tray_id": 2, "type": "PETG", "color": "2850E0FF"},
  33. {"global_tray_id": 3, "type": "ASA", "color": "898989FF"},
  34. ]
  35. # The mapping the print dialog stamped onto all three items.
  36. SHARED_MAPPING = [2, -1, 1]
  37. def _item(**overrides):
  38. item = MagicMock()
  39. item.id = 276
  40. item.printer_id = 8
  41. item.ams_mapping = json.dumps(SHARED_MAPPING)
  42. item.skip_filament_check = False
  43. item.filament_overrides = None
  44. item.required_filament_types = None
  45. item.manual_start = False
  46. item.filament_short = False
  47. for key, value in overrides.items():
  48. setattr(item, key, value)
  49. return item
  50. def _scheduler(trays, requirements=REQUIREMENTS):
  51. scheduler = PrintScheduler()
  52. scheduler._build_loaded_filaments = MagicMock(return_value=list(trays))
  53. scheduler._get_filament_requirements = AsyncMock(
  54. return_value=[dict(r) for r in requirements] if requirements else None
  55. )
  56. return scheduler
  57. class TestStoredMappingConflict:
  58. """The stored mapping is re-checked against the printer about to run it."""
  59. @pytest.mark.asyncio
  60. async def test_mapping_fits_the_printer_it_was_computed_for(self):
  61. scheduler = _scheduler(P2S_4_TRAYS)
  62. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  63. pm.get_status.return_value = MagicMock()
  64. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), SHARED_MAPPING)
  65. assert conflict is None
  66. @pytest.mark.asyncio
  67. async def test_same_mapping_on_a_differently_loaded_printer_conflicts(self):
  68. """The incident: slot 3 would have printed in ASA."""
  69. scheduler = _scheduler(P2S_5_TRAYS)
  70. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  71. pm.get_status.return_value = MagicMock()
  72. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
  73. assert conflict is not None
  74. assert "slot 3" in conflict
  75. assert "ASA" in conflict
  76. @pytest.mark.asyncio
  77. async def test_tray_not_loaded_conflicts(self):
  78. scheduler = _scheduler(P2S_4_TRAYS[:2]) # trays 2 and 3 removed
  79. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  80. pm.get_status.return_value = MagicMock()
  81. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), SHARED_MAPPING)
  82. assert conflict is not None
  83. assert "does not have loaded" in conflict
  84. @pytest.mark.asyncio
  85. async def test_mapping_too_short_for_the_plate_conflicts(self):
  86. scheduler = _scheduler(P2S_4_TRAYS)
  87. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  88. pm.get_status.return_value = MagicMock()
  89. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [2])
  90. assert conflict is not None
  91. assert "slot 3" in conflict
  92. @pytest.mark.asyncio
  93. async def test_unreported_external_feed_is_not_a_conflict(self):
  94. """An external spool we have not heard about is absence of evidence."""
  95. scheduler = _scheduler(P2S_4_TRAYS)
  96. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  97. pm.get_status.return_value = MagicMock()
  98. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 255])
  99. assert conflict is None
  100. @pytest.mark.asyncio
  101. async def test_external_feed_is_type_checked_like_any_tray(self):
  102. """Two printers with different filament in the external feed is the same
  103. failure this check exists for, so >=254 is not a free pass."""
  104. trays = P2S_4_TRAYS + [{"global_tray_id": 254, "type": "ASA", "color": "161616FF"}]
  105. scheduler = _scheduler(trays)
  106. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  107. pm.get_status.return_value = MagicMock()
  108. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 1])
  109. assert conflict is not None
  110. assert "ASA" in conflict
  111. @pytest.mark.asyncio
  112. async def test_an_external_feed_does_not_excuse_the_rest_of_the_mapping(self):
  113. """An unrecognised >=254 entry is absence of evidence about that slot
  114. only. Bailing out of the whole pass on it let a foreign tray later in
  115. the mapping through unexamined."""
  116. scheduler = _scheduler(P2S_4_TRAYS)
  117. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  118. pm.get_status.return_value = MagicMock()
  119. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 7])
  120. assert conflict is not None
  121. assert "slot 3" in conflict
  122. @pytest.mark.asyncio
  123. async def test_unresolved_required_slot_is_not_a_conflict(self):
  124. """-1 says the matcher had nothing, not that the mapping is foreign.
  125. Recomputing on it would discard the slots the user did resolve by hand;
  126. _block_on_unmatched_filament owns this case instead."""
  127. scheduler = _scheduler(P2S_4_TRAYS)
  128. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  129. pm.get_status.return_value = MagicMock()
  130. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [2, -1, -1])
  131. assert conflict is None
  132. @pytest.mark.asyncio
  133. async def test_foreign_tray_is_caught_without_reading_the_3mf(self):
  134. """The cheap pass runs on live status alone, so a foreign mapping is
  135. rejected without opening the 3MF."""
  136. scheduler = _scheduler(P2S_4_TRAYS)
  137. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  138. pm.get_status.return_value = MagicMock()
  139. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [7, -1, 1])
  140. assert conflict is not None
  141. assert "does not have loaded" in conflict
  142. scheduler._get_filament_requirements.assert_not_awaited()
  143. @pytest.mark.asyncio
  144. async def test_no_status_is_not_a_conflict(self):
  145. """Absence of evidence never triggers a recompute."""
  146. scheduler = _scheduler(P2S_5_TRAYS)
  147. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  148. pm.get_status.return_value = None
  149. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
  150. assert conflict is None
  151. @pytest.mark.asyncio
  152. async def test_no_trays_reported_yet_is_not_a_conflict(self):
  153. scheduler = _scheduler([])
  154. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  155. pm.get_status.return_value = MagicMock()
  156. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
  157. assert conflict is None
  158. @pytest.mark.asyncio
  159. async def test_unreadable_requirements_is_not_a_conflict(self):
  160. scheduler = _scheduler(P2S_5_TRAYS, requirements=None)
  161. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  162. pm.get_status.return_value = MagicMock()
  163. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
  164. assert conflict is None
  165. @pytest.mark.asyncio
  166. async def test_a_mapping_that_is_not_a_list_is_not_a_conflict(self):
  167. """A corrupted row is not evidence about the printer, and the guard that
  168. keeps it from being indexed was the one thing here without a test."""
  169. scheduler = _scheduler(P2S_5_TRAYS)
  170. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  171. pm.get_status.return_value = MagicMock()
  172. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), {"1": 2})
  173. assert conflict is None
  174. @pytest.mark.asyncio
  175. async def test_a_boolean_is_not_read_as_tray_one(self):
  176. """`true` is an int in Python, so it would otherwise be judged against
  177. whatever tray 1 holds — here ASA, i.e. a conflict reported for a slot
  178. nothing is actually known about."""
  179. scheduler = _scheduler(P2S_5_TRAYS)
  180. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  181. pm.get_status.return_value = MagicMock()
  182. conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), [True, -1, 2])
  183. assert conflict is None
  184. class TestEnsureAmsMappingRevalidates:
  185. """``_ensure_ams_mapping`` acts on the conflict verdict."""
  186. @pytest.mark.asyncio
  187. async def test_conflicting_mapping_is_recomputed(self):
  188. scheduler = _scheduler(P2S_5_TRAYS)
  189. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
  190. item = _item()
  191. db = AsyncMock()
  192. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  193. pm.get_status.return_value = MagicMock()
  194. await scheduler._ensure_ams_mapping(db, 8, item)
  195. assert json.loads(item.ams_mapping) == [0, -1, 2]
  196. scheduler._compute_ams_mapping_for_printer.assert_awaited_once()
  197. @pytest.mark.asyncio
  198. async def test_fitting_mapping_is_left_untouched(self):
  199. scheduler = _scheduler(P2S_4_TRAYS)
  200. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
  201. item = _item(printer_id=9)
  202. db = AsyncMock()
  203. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  204. pm.get_status.return_value = MagicMock()
  205. await scheduler._ensure_ams_mapping(db, 9, item)
  206. assert json.loads(item.ams_mapping) == SHARED_MAPPING
  207. scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
  208. @pytest.mark.asyncio
  209. async def test_print_anyway_keeps_the_users_mapping(self):
  210. """skip_filament_check is the user overriding exactly this judgement."""
  211. scheduler = _scheduler(P2S_5_TRAYS)
  212. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
  213. item = _item(skip_filament_check=True)
  214. db = AsyncMock()
  215. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  216. pm.get_status.return_value = MagicMock()
  217. await scheduler._ensure_ams_mapping(db, 8, item)
  218. assert json.loads(item.ams_mapping) == SHARED_MAPPING
  219. scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
  220. @pytest.mark.asyncio
  221. async def test_failed_recompute_does_not_fall_back_to_the_bad_mapping(self):
  222. scheduler = _scheduler(P2S_5_TRAYS)
  223. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=None)
  224. scheduler._unmappable_without_ams_message = AsyncMock(return_value=None)
  225. item = _item()
  226. db = AsyncMock()
  227. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  228. pm.get_status.return_value = MagicMock()
  229. await scheduler._ensure_ams_mapping(db, 8, item)
  230. assert item.ams_mapping is None
  231. class TestBlockOnUnmatchedFilament:
  232. """A slot the plate prints with no tray on this printer holds the job."""
  233. @pytest.mark.asyncio
  234. async def test_unresolved_required_slot_holds(self):
  235. scheduler = _scheduler(P2S_5_TRAYS)
  236. scheduler._get_job_name = AsyncMock(return_value="body1")
  237. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
  238. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  239. db = AsyncMock()
  240. blocked = await scheduler._block_on_unmatched_filament(db, item)
  241. assert blocked is True
  242. assert item.manual_start is True
  243. # The reason names the filament, not a machine token, because it renders
  244. # on the queue row.
  245. assert item.waiting_reason.startswith("Needs ")
  246. assert "PETG" in item.waiting_reason
  247. assert "#2850E0" in item.waiting_reason
  248. @pytest.mark.asyncio
  249. async def test_padding_for_a_slot_this_plate_skips_does_not_hold(self):
  250. """-1 at slot 2, which this plate never prints, is not a missing filament.
  251. This is why the hold intersects with the requirement list rather than
  252. reading the mapping array on its own."""
  253. scheduler = _scheduler(P2S_5_TRAYS)
  254. item = _item(ams_mapping=json.dumps([0, -1, 2]))
  255. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  256. assert blocked is False
  257. assert item.manual_start is False
  258. @pytest.mark.asyncio
  259. async def test_mapping_too_short_for_the_plate_holds(self):
  260. scheduler = _scheduler(P2S_5_TRAYS)
  261. scheduler._get_job_name = AsyncMock(return_value="body1")
  262. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
  263. item = _item(ams_mapping=json.dumps([0]))
  264. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  265. assert blocked is True
  266. @pytest.mark.asyncio
  267. async def test_print_anyway_dispatches(self):
  268. scheduler = _scheduler(P2S_5_TRAYS)
  269. item = _item(ams_mapping=json.dumps([0, -1, -1]), skip_filament_check=True)
  270. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  271. assert blocked is False
  272. @pytest.mark.asyncio
  273. async def test_a_mapping_that_resolved_nothing_at_all_holds(self):
  274. """The complete miss used to fall out of this gate and into the ugly path.
  275. `_ensure_ams_mapping` clears a rejected mapping the recompute could not
  276. replace, and bailing on `not item.ams_mapping` then dispatched the item
  277. with no mapping at all: several megabytes uploaded, 0700_8012 from the
  278. firmware, retries burned. Live status reporting loaded trays while the
  279. matcher resolved none of them is a positive finding, so it holds.
  280. """
  281. scheduler = _scheduler(P2S_5_TRAYS)
  282. scheduler._get_job_name = AsyncMock(return_value="body1")
  283. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
  284. item = _item(ams_mapping=None)
  285. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  286. pm.get_status.return_value = MagicMock()
  287. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  288. assert blocked is True
  289. assert item.manual_start is True
  290. assert item.waiting_reason.startswith("Needs ")
  291. @pytest.mark.asyncio
  292. async def test_a_cleared_mapping_on_a_printer_with_no_trays_reported_dispatches(self):
  293. """#2589 is the same shape and must stay untouched: there the mapping is
  294. bogus because the AMS was not known yet, and an empty loaded list is
  295. exactly that ignorance. Holding on it would hold on absence of
  296. evidence."""
  297. scheduler = _scheduler([])
  298. item = _item(ams_mapping=None)
  299. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  300. pm.get_status.return_value = MagicMock()
  301. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  302. assert blocked is False
  303. assert item.manual_start is False
  304. @pytest.mark.asyncio
  305. async def test_a_cleared_mapping_with_no_status_dispatches(self):
  306. scheduler = _scheduler(P2S_5_TRAYS)
  307. item = _item(ams_mapping=None)
  308. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  309. pm.get_status.return_value = None
  310. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  311. assert blocked is False
  312. assert item.manual_start is False
  313. @pytest.mark.asyncio
  314. async def test_a_boolean_tray_id_holds_rather_than_reading_as_tray_one(self):
  315. """`true` is an int in Python. A hand-written payload carrying it would
  316. otherwise be read as tray 1 and dispatched as whatever sits there."""
  317. scheduler = _scheduler(P2S_5_TRAYS)
  318. scheduler._get_job_name = AsyncMock(return_value="body1")
  319. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
  320. item = _item(ams_mapping=json.dumps([True, -1, 2]))
  321. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  322. assert blocked is True
  323. assert "#76D9F4" in item.waiting_reason
  324. class TestHoldOnTheModelBasedPath:
  325. """A held "any P2S" job must not stay pinned to the P2S that held it.
  326. The model-based path commits `item.printer_id` before the gates run, so a
  327. hold would otherwise leave an unpinned job pinned to the one printer that
  328. could not run it, and the next press of the start button retries the same
  329. dead end. The printer-assigned path keeps its printer: that one was the
  330. user's choice, not the scheduler's.
  331. """
  332. @pytest.mark.asyncio
  333. async def test_a_model_based_hold_releases_the_printer_and_its_mapping(self):
  334. scheduler = _scheduler(P2S_5_TRAYS)
  335. scheduler._get_job_name = AsyncMock(return_value="body1")
  336. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
  337. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  338. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
  339. assert blocked is True
  340. assert item.printer_id is None
  341. # The mapping goes with the assignment: its tray IDs were resolved
  342. # against the printer just released, so leaving it would hold the job
  343. # again on the next printer even when that one has the filament.
  344. assert item.ams_mapping is None
  345. assert item.waiting_reason.startswith("Needs ")
  346. @pytest.mark.asyncio
  347. async def test_the_notification_still_names_the_printer_that_held_it(self):
  348. scheduler = _scheduler(P2S_5_TRAYS)
  349. scheduler._get_job_name = AsyncMock(return_value="body1")
  350. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
  351. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  352. await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
  353. # Looked up before the release, or it would be looked up as None.
  354. assert scheduler._get_printer.await_args.args[1] == 8
  355. @pytest.mark.asyncio
  356. async def test_a_printer_assigned_hold_keeps_both(self):
  357. scheduler = _scheduler(P2S_5_TRAYS)
  358. scheduler._get_job_name = AsyncMock(return_value="body1")
  359. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
  360. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  361. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  362. assert blocked is True
  363. assert item.printer_id == 8
  364. assert json.loads(item.ams_mapping) == [0, -1, -1]
  365. @pytest.mark.asyncio
  366. async def test_unreadable_requirements_dispatches(self):
  367. scheduler = _scheduler(P2S_5_TRAYS, requirements=None)
  368. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  369. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  370. assert blocked is False
  371. @pytest.mark.asyncio
  372. async def test_nozzle_bound_filament_on_the_wrong_side_holds(self):
  373. """The H2D case the type-only scan waved through: PETG is loaded, but on
  374. the other nozzle's AMS, so the nozzle-aware matcher left the slot at -1.
  375. Reading the mapping inherits that restriction for free."""
  376. scheduler = _scheduler(P2S_5_TRAYS)
  377. scheduler._get_job_name = AsyncMock(return_value="body1")
  378. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="H2D"))
  379. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  380. blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
  381. assert blocked is True
  382. class TestModelBasedVirtualPrinterMapping:
  383. """A model-based Virtual Printer stamps a mapping nothing can attribute.
  384. `virtual_printer/manager.py` writes the slicer's own AMS pick onto the queue
  385. item it creates, on the line below `printer_id=self.target_printer_id` —
  386. which is None for an "Any P2S" VP. So the tray IDs the slicer resolved are
  387. carried by an item the scheduler will hand to whichever printer of that
  388. model happens to be free, and the row itself records no printer they could
  389. be checked against. Same failure as the print dialog's, arriving from a
  390. different direction, and reachable whenever the per-VP `save_ams_mapping`
  391. opt-in is on without `queue_force_color_match`.
  392. The item has no `printer_id` until the model-based path assigns one, so
  393. these go through `_ensure_ams_mapping`'s `printer_id` argument rather than
  394. `item.printer_id` — which is how that path calls it.
  395. """
  396. @pytest.mark.asyncio
  397. async def test_slicer_mapping_is_rechecked_against_the_printer_chosen_for_it(self):
  398. scheduler = _scheduler(P2S_5_TRAYS)
  399. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
  400. # Fresh from the VP: no printer_id, mapping resolved by the slicer.
  401. item = _item(printer_id=None)
  402. db = AsyncMock()
  403. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  404. pm.get_status.return_value = MagicMock()
  405. await scheduler._ensure_ams_mapping(db, 10, item)
  406. assert json.loads(item.ams_mapping) == [0, -1, 2]
  407. # Judged against the printer the scheduler picked, not the row's own
  408. # (absent) one.
  409. scheduler._compute_ams_mapping_for_printer.assert_awaited_once()
  410. assert scheduler._compute_ams_mapping_for_printer.await_args.args[1] == 10
  411. @pytest.mark.asyncio
  412. async def test_slicer_mapping_survives_on_a_printer_it_happens_to_fit(self):
  413. """Not every VP mapping is wrong — one that fits the chosen printer is
  414. kept, so the slicer's deliberate slot pick is not thrown away."""
  415. scheduler = _scheduler(P2S_4_TRAYS)
  416. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
  417. item = _item(printer_id=None)
  418. db = AsyncMock()
  419. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  420. pm.get_status.return_value = MagicMock()
  421. await scheduler._ensure_ams_mapping(db, 9, item)
  422. assert json.loads(item.ams_mapping) == SHARED_MAPPING
  423. scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
  424. class TestFilamentRequirementMemo:
  425. """The two gates parse the same 3MF per dispatch attempt (#2799 review).
  426. A one-file fan-out to many idle printers repeats that for every one of them
  427. inside a single pass, each time a DB lookup plus a zip open and XML parse.
  428. The memo is scoped to the pass so a file re-sliced between ticks is re-read.
  429. """
  430. @staticmethod
  431. def _db_returning(archive):
  432. result = MagicMock()
  433. result.scalar_one_or_none.return_value = archive
  434. db = AsyncMock()
  435. db.execute = AsyncMock(return_value=result)
  436. return db
  437. @pytest.fixture
  438. def parsed(self, tmp_path, monkeypatch):
  439. from backend.app.services import print_scheduler as ps
  440. (tmp_path / "plate.3mf").write_bytes(b"")
  441. monkeypatch.setattr(ps.settings, "base_dir", tmp_path)
  442. calls = []
  443. def fake_extract(path, plate_id=None):
  444. calls.append((path, plate_id))
  445. return [{"slot_id": 1, "type": "PETG", "color": "#76D9F4", "tray_info_idx": ""}]
  446. monkeypatch.setattr(
  447. "backend.app.services.filament_requirements.extract_filament_requirements",
  448. fake_extract,
  449. )
  450. return calls
  451. @pytest.mark.asyncio
  452. async def test_the_same_plate_is_parsed_once_per_pass(self, parsed):
  453. scheduler = PrintScheduler()
  454. db = self._db_returning(MagicMock(file_path="plate.3mf"))
  455. item = _item(archive_id=731, library_file_id=None, plate_id=22)
  456. await scheduler._get_filament_requirements(db, item)
  457. await scheduler._get_filament_requirements(db, item)
  458. assert len(parsed) == 1
  459. @pytest.mark.asyncio
  460. async def test_each_caller_gets_its_own_copy(self, parsed):
  461. """`_apply_filament_overrides` rewrites these dicts in place, so handing
  462. out the cached list would leak one item's overrides into the next."""
  463. scheduler = PrintScheduler()
  464. db = self._db_returning(MagicMock(file_path="plate.3mf"))
  465. item = _item(archive_id=731, library_file_id=None, plate_id=22)
  466. first = await scheduler._get_filament_requirements(db, item)
  467. first[0]["type"] = "ASA" # an override applied by one caller
  468. second = await scheduler._get_filament_requirements(db, item)
  469. assert second[0]["type"] == "PETG"
  470. @pytest.mark.asyncio
  471. async def test_a_different_plate_is_parsed_separately(self, parsed):
  472. scheduler = PrintScheduler()
  473. db = self._db_returning(MagicMock(file_path="plate.3mf"))
  474. await scheduler._get_filament_requirements(db, _item(archive_id=731, library_file_id=None, plate_id=22))
  475. await scheduler._get_filament_requirements(db, _item(archive_id=731, library_file_id=None, plate_id=23))
  476. assert len(parsed) == 2
  477. @pytest.mark.asyncio
  478. async def test_the_memo_does_not_outlive_the_pass(self, parsed):
  479. """check_queue clears it at the top of every pass."""
  480. scheduler = PrintScheduler()
  481. db = self._db_returning(MagicMock(file_path="plate.3mf"))
  482. item = _item(archive_id=731, library_file_id=None, plate_id=22)
  483. await scheduler._get_filament_requirements(db, item)
  484. scheduler._filament_req_memo.clear()
  485. await scheduler._get_filament_requirements(db, item)
  486. assert len(parsed) == 2
  487. @pytest.mark.asyncio
  488. async def test_an_unreadable_file_is_remembered_too(self, parsed):
  489. """The miss is worth caching as well — otherwise a missing file costs a
  490. DB lookup per gate per attempt for nothing."""
  491. scheduler = PrintScheduler()
  492. db = self._db_returning(None)
  493. item = _item(archive_id=999, library_file_id=None, plate_id=1)
  494. assert await scheduler._get_filament_requirements(db, item) is None
  495. assert await scheduler._get_filament_requirements(db, item) is None
  496. assert db.execute.await_count == 1
  497. class TestTheHoldReasonSurvivesTheNextPass:
  498. """The reason is the hold's whole explanation, so a later pass must keep it.
  499. Since #3074 the scheduler clears the reason of every staged item on each
  500. pass, because a reason left from before staging would otherwise stand
  501. forever. The unmatched-filament hold writes its reason at the moment it
  502. stages the item, so clearing it would leave a job that waits for a start
  503. press with nothing on the row saying which spool to load.
  504. """
  505. @pytest.fixture
  506. async def session_maker(self):
  507. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  508. import backend.app.models # noqa: F401 - populate Base.metadata
  509. from backend.app.core.database import Base
  510. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  511. async with engine.begin() as conn:
  512. await conn.run_sync(Base.metadata.create_all)
  513. try:
  514. yield async_sessionmaker(engine, expire_on_commit=False)
  515. finally:
  516. await engine.dispose()
  517. async def _staged_item(self, session_maker, reason):
  518. from backend.app.models.print_queue import PrintQueueItem
  519. from backend.app.models.printer import Printer
  520. async with session_maker() as db:
  521. printer = Printer(
  522. name="P2S-5", serial_number="SERIAL", ip_address="10.0.0.1", access_code="code", model="P2S"
  523. )
  524. db.add(printer)
  525. await db.flush()
  526. item = PrintQueueItem(
  527. printer_id=printer.id, status="pending", position=0, manual_start=True, waiting_reason=reason
  528. )
  529. db.add(item)
  530. await db.commit()
  531. return item.id
  532. async def _reason_after_a_pass(self, session_maker, item_id):
  533. from backend.app.models.print_queue import PrintQueueItem
  534. scheduler = PrintScheduler()
  535. with (
  536. patch("backend.app.services.print_scheduler.async_session", session_maker),
  537. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  538. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  539. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
  540. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  541. ):
  542. await scheduler.check_queue()
  543. async with session_maker() as db:
  544. return (await db.get(PrintQueueItem, item_id)).waiting_reason
  545. @pytest.mark.asyncio
  546. async def test_the_hold_reason_stays_on_a_staged_item(self, session_maker):
  547. item_id = await self._staged_item(session_maker, "Needs PETG #2850E0")
  548. assert await self._reason_after_a_pass(session_maker, item_id) == "Needs PETG #2850E0"
  549. @pytest.mark.asyncio
  550. async def test_any_other_reason_on_a_staged_item_is_still_cleared(self, session_maker):
  551. """#3074's rule for everything else is unchanged."""
  552. item_id = await self._staged_item(session_maker, "Busy: P2S-5")
  553. assert await self._reason_after_a_pass(session_maker, item_id) is None
  554. def _matching_scheduler(trays):
  555. """A scheduler whose real matcher runs against ``trays``."""
  556. scheduler = _scheduler(trays)
  557. scheduler._get_bool_setting = AsyncMock(return_value=False)
  558. scheduler._get_job_name = AsyncMock(return_value="body1")
  559. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
  560. return scheduler
  561. class TestUnresolvedSlotsAreMatchedAgain:
  562. """A fitting mapping keeps its resolved slots and has only its gaps matched again.
  563. The hold keeps a pinned item's mapping, and an unresolved slot is not a
  564. conflict, so without this nothing would look at the gap again: loading the
  565. missing spool and pressing Start would hold the job again, every time.
  566. """
  567. @pytest.mark.asyncio
  568. async def test_a_spool_loaded_after_the_hold_is_found(self):
  569. # Held with slot 3 unresolved; dark-blue PETG has since gone into tray 2.
  570. scheduler = _matching_scheduler(P2S_5_TRAYS)
  571. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  572. db = AsyncMock()
  573. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  574. pm.get_status.return_value = MagicMock()
  575. await scheduler._ensure_ams_mapping(db, 8, item)
  576. blocked = await scheduler._block_on_unmatched_filament(db, item)
  577. assert json.loads(item.ams_mapping) == [0, -1, 2]
  578. assert blocked is False
  579. @pytest.mark.asyncio
  580. async def test_a_tray_the_user_picked_is_kept_and_not_handed_out_twice(self):
  581. """The user put slot 1 on tray 2 by hand. Slot 3's best match is that same
  582. tray, but it is taken, so slot 3 gets the other PETG instead of sharing."""
  583. scheduler = _matching_scheduler(P2S_5_TRAYS)
  584. item = _item(ams_mapping=json.dumps([2, -1, -1]))
  585. db = AsyncMock()
  586. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  587. pm.get_status.return_value = MagicMock()
  588. await scheduler._ensure_ams_mapping(db, 8, item)
  589. assert json.loads(item.ams_mapping) == [2, -1, 0]
  590. @pytest.mark.asyncio
  591. async def test_still_missing_leaves_the_mapping_and_holds(self):
  592. trays = [
  593. {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
  594. {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
  595. ]
  596. scheduler = _matching_scheduler(trays)
  597. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  598. db = AsyncMock()
  599. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  600. pm.get_status.return_value = MagicMock()
  601. await scheduler._ensure_ams_mapping(db, 8, item)
  602. blocked = await scheduler._block_on_unmatched_filament(db, item)
  603. assert json.loads(item.ams_mapping) == [0, -1, -1]
  604. assert blocked is True
  605. @pytest.mark.asyncio
  606. async def test_print_anyway_leaves_the_gap_alone(self):
  607. scheduler = _matching_scheduler(P2S_5_TRAYS)
  608. item = _item(ams_mapping=json.dumps([0, -1, -1]), skip_filament_check=True)
  609. db = AsyncMock()
  610. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  611. pm.get_status.return_value = MagicMock()
  612. await scheduler._ensure_ams_mapping(db, 8, item)
  613. assert json.loads(item.ams_mapping) == [0, -1, -1]
  614. @pytest.mark.asyncio
  615. async def test_a_complete_mapping_is_not_matched_again(self):
  616. scheduler = _matching_scheduler(P2S_5_TRAYS)
  617. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
  618. item = _item(ams_mapping=json.dumps([0, -1, 2]))
  619. db = AsyncMock()
  620. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  621. pm.get_status.return_value = MagicMock()
  622. await scheduler._ensure_ams_mapping(db, 8, item)
  623. scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
  624. class TestMissingFilamentForStart:
  625. """The Start button's question: would this item be held again right now?"""
  626. @pytest.mark.asyncio
  627. async def test_names_a_filament_that_is_still_not_loaded(self):
  628. trays = [
  629. {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
  630. {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
  631. ]
  632. scheduler = _matching_scheduler(trays)
  633. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  634. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  635. pm.get_status.return_value = MagicMock()
  636. missing = await scheduler.missing_filament_for_start(AsyncMock(), item)
  637. assert missing is not None and len(missing) == 1
  638. assert "PETG" in missing[0] and "#2850E0" in missing[0]
  639. # Asking is not acting: the stored mapping is the scheduler's to change.
  640. assert json.loads(item.ams_mapping) == [0, -1, -1]
  641. @pytest.mark.asyncio
  642. async def test_nothing_missing_once_the_spool_is_loaded(self):
  643. scheduler = _matching_scheduler(P2S_5_TRAYS)
  644. item = _item(ams_mapping=json.dumps([0, -1, -1]))
  645. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  646. pm.get_status.return_value = MagicMock()
  647. assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
  648. @pytest.mark.asyncio
  649. async def test_a_mapping_from_another_printer_is_judged_by_a_fresh_match(self):
  650. """The #2799 mapping on P2S-5 conflicts, so Start judges a fresh match,
  651. which finds both filaments."""
  652. scheduler = _matching_scheduler(P2S_5_TRAYS)
  653. item = _item()
  654. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  655. pm.get_status.return_value = MagicMock()
  656. assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
  657. @pytest.mark.asyncio
  658. async def test_no_mapping_and_nothing_matching_is_missing(self):
  659. trays = [{"global_tray_id": 0, "type": "ASA", "color": "161616FF"}]
  660. scheduler = _matching_scheduler(trays)
  661. item = _item(ams_mapping=None)
  662. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  663. pm.get_status.return_value = MagicMock()
  664. missing = await scheduler.missing_filament_for_start(AsyncMock(), item)
  665. assert missing is not None and len(missing) == 2
  666. @pytest.mark.asyncio
  667. @pytest.mark.parametrize(
  668. "overrides",
  669. [
  670. {"skip_filament_check": True},
  671. # A model-based item picks its printer at dispatch.
  672. {"printer_id": None},
  673. ],
  674. )
  675. async def test_does_not_judge_what_dispatch_would_not(self, overrides):
  676. trays = [{"global_tray_id": 0, "type": "ASA", "color": "161616FF"}]
  677. scheduler = _matching_scheduler(trays)
  678. item = _item(ams_mapping=None, **overrides)
  679. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  680. pm.get_status.return_value = MagicMock()
  681. assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
  682. @pytest.mark.asyncio
  683. async def test_no_status_is_not_a_finding(self):
  684. scheduler = _matching_scheduler(P2S_5_TRAYS)
  685. item = _item(ams_mapping=None)
  686. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  687. pm.get_status.return_value = None
  688. assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None