test_filament_deficit.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. """Unit tests for the filament-deficit pre-dispatch check (#1496).
  2. The check is the single source of truth that both ``POST /queue/{id}/start``
  3. and the dispatch scheduler call before sending a print to the printer. Pin
  4. the contract for the cases that matter:
  5. * Internal-inventory mode: shortfall + sufficient + no assignment.
  6. * AMS-mapping gating: a missing mapping means "not yet decided, skip".
  7. * Disabled-warnings setting + missing printer (model-based item) + no
  8. source 3MF all short-circuit to "no deficit".
  9. """
  10. from __future__ import annotations
  11. import json
  12. import logging
  13. import zipfile
  14. from pathlib import Path
  15. from unittest.mock import patch
  16. import pytest
  17. from backend.app.models.archive import PrintArchive
  18. from backend.app.models.print_queue import PrintQueueItem
  19. from backend.app.models.settings import Settings
  20. from backend.app.models.spool import Spool
  21. from backend.app.models.spool_assignment import SpoolAssignment
  22. from backend.app.services.filament_deficit import (
  23. FilamentDeficit,
  24. compute_deficit_for_queue_item,
  25. )
  26. def _write_3mf(file_path: Path, filaments: list[dict]) -> None:
  27. """Minimal 3MF that ``extract_filament_requirements`` can parse (flat shape)."""
  28. body = "".join(
  29. f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
  30. f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
  31. for f in filaments
  32. )
  33. config = f'<?xml version="1.0" encoding="utf-8"?><config>{body}</config>'
  34. with zipfile.ZipFile(file_path, "w") as zf:
  35. zf.writestr("Metadata/slice_info.config", config)
  36. async def _setup_archive_3mf(db_session, tmp_path: Path, filaments: list[dict]) -> PrintArchive:
  37. """Create a 3MF on disk and a PrintArchive row pointing at it."""
  38. file_name = "model.3mf"
  39. file_path = tmp_path / file_name
  40. _write_3mf(file_path, filaments)
  41. archive = PrintArchive(
  42. filename=file_name,
  43. print_name="Test",
  44. # The helper resolves via app_settings.base_dir / file_path, but
  45. # storing the absolute path on the model also works because
  46. # ``Path / abs`` collapses to the absolute side.
  47. file_path=str(file_path),
  48. file_size=file_path.stat().st_size,
  49. status="completed",
  50. )
  51. db_session.add(archive)
  52. await db_session.commit()
  53. await db_session.refresh(archive)
  54. return archive
  55. async def _setup_library_3mf(db_session, base_dir: Path, filaments: list[dict], *, absolute: bool = False):
  56. """Create a 3MF under ``base_dir`` and a LibraryFile row pointing at it.
  57. Mirrors production storage: the file lands in
  58. ``<base_dir>/archive/library/files/`` and the row stores the path
  59. *relative* to base_dir, exactly as ``library.py`` writes it (#2779).
  60. """
  61. from backend.app.models.library import LibraryFile
  62. rel_path = Path("archive/library/files/deficit_probe.gcode.3mf")
  63. abs_path = base_dir / rel_path
  64. abs_path.parent.mkdir(parents=True, exist_ok=True)
  65. _write_3mf(abs_path, filaments)
  66. lib_file = LibraryFile(
  67. filename="deficit_probe.gcode.3mf",
  68. file_path=str(abs_path) if absolute else str(rel_path),
  69. file_type="3mf",
  70. file_size=abs_path.stat().st_size,
  71. )
  72. db_session.add(lib_file)
  73. await db_session.commit()
  74. await db_session.refresh(lib_file)
  75. return lib_file
  76. async def _spool(
  77. db_session,
  78. *,
  79. label_weight: int,
  80. weight_used: float,
  81. color: str = "#000000",
  82. slicer_filament: str | None = None,
  83. ) -> Spool:
  84. spool = Spool(
  85. material="PLA",
  86. label_weight=label_weight,
  87. weight_used=weight_used,
  88. rgba=color,
  89. slicer_filament=slicer_filament,
  90. )
  91. db_session.add(spool)
  92. await db_session.commit()
  93. await db_session.refresh(spool)
  94. return spool
  95. async def _assign(db_session, *, printer_id: int, spool_id: int, ams_id: int = 0, tray_id: int = 0) -> None:
  96. db_session.add(
  97. SpoolAssignment(
  98. spool_id=spool_id,
  99. printer_id=printer_id,
  100. ams_id=ams_id,
  101. tray_id=tray_id,
  102. )
  103. )
  104. await db_session.commit()
  105. async def _queue_item(
  106. db_session,
  107. *,
  108. printer_id: int | None,
  109. archive: PrintArchive | None,
  110. ams_mapping: list[int] | None,
  111. plate_id: int | None = None,
  112. library_file=None,
  113. ) -> PrintQueueItem:
  114. item = PrintQueueItem(
  115. printer_id=printer_id,
  116. archive_id=archive.id if archive else None,
  117. library_file_id=library_file.id if library_file else None,
  118. ams_mapping=json.dumps(ams_mapping) if ams_mapping is not None else None,
  119. plate_id=plate_id,
  120. status="pending",
  121. manual_start=True,
  122. )
  123. db_session.add(item)
  124. await db_session.commit()
  125. await db_session.refresh(item, ["archive", "library_file"])
  126. return item
  127. class TestFilamentDeficit:
  128. @pytest.mark.asyncio
  129. async def test_returns_deficit_when_spool_too_light(self, db_session, printer_factory, tmp_path):
  130. """Spool with 30g remaining for a 100g print → one deficit row."""
  131. printer = await printer_factory()
  132. archive = await _setup_archive_3mf(
  133. db_session,
  134. tmp_path,
  135. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  136. )
  137. spool = await _spool(db_session, label_weight=1000, weight_used=970.0) # 30g left
  138. await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
  139. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  140. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  141. deficit = await compute_deficit_for_queue_item(db_session, item)
  142. assert len(deficit) == 1
  143. assert isinstance(deficit[0], FilamentDeficit)
  144. assert deficit[0].slot_id == 1
  145. assert deficit[0].required_grams == 100.0
  146. assert deficit[0].remaining_grams == 30.0
  147. assert deficit[0].filament_type == "PLA"
  148. @pytest.mark.asyncio
  149. async def test_returns_empty_when_spool_has_enough(self, db_session, printer_factory, tmp_path):
  150. printer = await printer_factory()
  151. archive = await _setup_archive_3mf(
  152. db_session,
  153. tmp_path,
  154. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  155. )
  156. spool = await _spool(db_session, label_weight=1000, weight_used=200.0) # 800g left
  157. await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
  158. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  159. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  160. deficit = await compute_deficit_for_queue_item(db_session, item)
  161. assert deficit == []
  162. @pytest.mark.asyncio
  163. async def test_returns_empty_when_ams_mapping_missing(self, db_session, printer_factory, tmp_path):
  164. """No mapping yet = scheduler hasn't decided which slot maps where."""
  165. printer = await printer_factory()
  166. archive = await _setup_archive_3mf(
  167. db_session,
  168. tmp_path,
  169. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  170. )
  171. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=None)
  172. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  173. deficit = await compute_deficit_for_queue_item(db_session, item)
  174. assert deficit == []
  175. @pytest.mark.asyncio
  176. async def test_returns_empty_when_no_printer_assigned(self, db_session, tmp_path):
  177. """Model-based queue items with no resolved printer_id can't be checked."""
  178. archive = await _setup_archive_3mf(
  179. db_session,
  180. tmp_path,
  181. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  182. )
  183. item = await _queue_item(db_session, printer_id=None, archive=archive, ams_mapping=[0])
  184. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  185. deficit = await compute_deficit_for_queue_item(db_session, item)
  186. assert deficit == []
  187. @pytest.mark.asyncio
  188. async def test_returns_empty_when_warnings_disabled(self, db_session, printer_factory, tmp_path):
  189. """Honour the disable_filament_warnings setting (#720 toggle)."""
  190. printer = await printer_factory()
  191. archive = await _setup_archive_3mf(
  192. db_session,
  193. tmp_path,
  194. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  195. )
  196. spool = await _spool(db_session, label_weight=1000, weight_used=970.0)
  197. await _assign(db_session, printer_id=printer.id, spool_id=spool.id)
  198. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  199. db_session.add(Settings(key="disable_filament_warnings", value="true"))
  200. await db_session.commit()
  201. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  202. deficit = await compute_deficit_for_queue_item(db_session, item)
  203. assert deficit == []
  204. @pytest.mark.asyncio
  205. async def test_returns_empty_when_no_assignment(self, db_session, printer_factory, tmp_path):
  206. """Mapping points at a slot with no spool assigned → silent, not blocked."""
  207. printer = await printer_factory()
  208. archive = await _setup_archive_3mf(
  209. db_session,
  210. tmp_path,
  211. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  212. )
  213. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  214. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  215. deficit = await compute_deficit_for_queue_item(db_session, item)
  216. assert deficit == []
  217. @pytest.mark.asyncio
  218. async def test_library_file_with_relative_path_is_checked(self, db_session, printer_factory, tmp_path):
  219. """#2779: a Library-backed item stores its path relative to base_dir.
  220. Resolving it against the process working directory finds nothing, and
  221. "no source" is treated as "nothing to verify" — so the check returned
  222. no deficit and the scheduler dispatched onto a spool that could not
  223. finish the print. Every Slicer Pipeline item and everything queued via
  224. the Library's Add to queue is library-backed, so the guard was absent
  225. for all of them. Numbers are the reporter's: 20.5 g needed, 9 g left.
  226. """
  227. printer = await printer_factory()
  228. lib_file = await _setup_library_3mf(
  229. db_session,
  230. tmp_path,
  231. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "20.5"}],
  232. )
  233. assert not Path(lib_file.file_path).is_absolute() # the shape that broke
  234. spool = await _spool(db_session, label_weight=1000, weight_used=991.0) # 9g left
  235. await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
  236. item = await _queue_item(
  237. db_session, printer_id=printer.id, archive=None, library_file=lib_file, ams_mapping=[0]
  238. )
  239. with patch("backend.app.services.filament_deficit.app_settings.base_dir", tmp_path):
  240. deficit = await compute_deficit_for_queue_item(db_session, item)
  241. assert len(deficit) == 1
  242. assert deficit[0].required_grams == 20.5
  243. assert deficit[0].remaining_grams == 9.0
  244. @pytest.mark.asyncio
  245. async def test_library_file_with_absolute_path_is_checked(self, db_session, printer_factory, tmp_path):
  246. """The other half of the resolver: a row that already holds an absolute
  247. path must not be joined onto base_dir a second time."""
  248. printer = await printer_factory()
  249. lib_file = await _setup_library_3mf(
  250. db_session,
  251. tmp_path,
  252. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  253. absolute=True,
  254. )
  255. spool = await _spool(db_session, label_weight=1000, weight_used=970.0) # 30g left
  256. await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
  257. item = await _queue_item(
  258. db_session, printer_id=printer.id, archive=None, library_file=lib_file, ams_mapping=[0]
  259. )
  260. # A base_dir the file is NOT under — joining it on would break the path.
  261. with patch("backend.app.services.filament_deficit.app_settings.base_dir", tmp_path / "elsewhere"):
  262. deficit = await compute_deficit_for_queue_item(db_session, item)
  263. assert len(deficit) == 1
  264. assert deficit[0].required_grams == 100.0
  265. @pytest.mark.asyncio
  266. async def test_missing_source_is_logged_not_just_skipped(self, db_session, printer_factory, caplog):
  267. """A source that is configured but absent still dispatches — the upload
  268. would fail seconds later anyway, and wedging the queue on a missing
  269. file is the worse trade. But it must not pass silently: skipping the
  270. check without a trace is what let #2779 go unnoticed for every
  271. library-backed item.
  272. """
  273. printer = await printer_factory()
  274. archive = PrintArchive(
  275. filename="ghost.3mf",
  276. file_path="/nonexistent/ghost.3mf",
  277. file_size=0,
  278. status="completed",
  279. )
  280. db_session.add(archive)
  281. await db_session.commit()
  282. await db_session.refresh(archive)
  283. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  284. with caplog.at_level(logging.WARNING, logger="backend.app.services.filament_deficit"):
  285. deficit = await compute_deficit_for_queue_item(db_session, item)
  286. assert deficit == []
  287. assert any("ghost.3mf" in r.getMessage() for r in caplog.records)
  288. @pytest.mark.asyncio
  289. async def test_returns_empty_when_3mf_missing(self, db_session, printer_factory):
  290. printer = await printer_factory()
  291. archive = PrintArchive(
  292. filename="ghost.3mf",
  293. file_path="/nonexistent/ghost.3mf",
  294. file_size=0,
  295. status="completed",
  296. )
  297. db_session.add(archive)
  298. await db_session.commit()
  299. await db_session.refresh(archive)
  300. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  301. deficit = await compute_deficit_for_queue_item(db_session, item)
  302. assert deficit == []
  303. @pytest.mark.asyncio
  304. async def test_multi_slot_only_shorted_slot_returned(self, db_session, printer_factory, tmp_path):
  305. """One slot fine, one short — only the short slot is in the result."""
  306. printer = await printer_factory()
  307. archive = await _setup_archive_3mf(
  308. db_session,
  309. tmp_path,
  310. [
  311. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"},
  312. {"id": "2", "type": "PETG", "color": "#000000", "used_g": "80.0"},
  313. ],
  314. )
  315. plenty = await _spool(db_session, label_weight=1000, weight_used=100.0) # 900g
  316. shorted = await _spool(db_session, label_weight=1000, weight_used=950.0) # 50g
  317. await _assign(db_session, printer_id=printer.id, spool_id=plenty.id, ams_id=0, tray_id=0)
  318. await _assign(db_session, printer_id=printer.id, spool_id=shorted.id, ams_id=0, tray_id=1)
  319. item = await _queue_item(
  320. db_session,
  321. printer_id=printer.id,
  322. archive=archive,
  323. ams_mapping=[0, 1], # slot 1 -> tray 0, slot 2 -> tray 1
  324. )
  325. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  326. deficit = await compute_deficit_for_queue_item(db_session, item)
  327. assert [d.slot_id for d in deficit] == [2]
  328. assert deficit[0].remaining_grams == 50.0
  329. assert deficit[0].required_grams == 80.0
  330. class TestFilamentDeficitBackupAware:
  331. """#1762 — when AMS Filament Backup is ON, pool remaining grams across
  332. same-material spools on the printer (within the same extruder side on
  333. dual-nozzle models) before declaring a slot deficit.
  334. Reporter scenario: PLA Basic in AMS-1 slot 1 with 10 g left, same PLA
  335. Basic in AMS-2 slot 1 with 500 g left. Today's per-slot accounting
  336. blocks the print because slot 1 of AMS-1 is short. With backup ON,
  337. firmware switches mid-print, so the deficit shouldn't fire.
  338. """
  339. @staticmethod
  340. def _patch_status(
  341. *,
  342. printer_id: int,
  343. backup_on: bool,
  344. ams_extruder_map: dict | None = None,
  345. model: str | None = None,
  346. ):
  347. """Patch ``printer_manager.get_status`` + ``get_model`` for the test."""
  348. from types import SimpleNamespace
  349. from unittest.mock import patch as _patch
  350. fake_state = SimpleNamespace(
  351. ams_filament_backup=backup_on if backup_on is not None else None,
  352. ams_extruder_map=ams_extruder_map or {},
  353. )
  354. return [
  355. _patch(
  356. "backend.app.services.printer_manager.printer_manager.get_status",
  357. lambda pid: fake_state if pid == printer_id else None,
  358. ),
  359. _patch(
  360. "backend.app.services.printer_manager.printer_manager.get_model",
  361. lambda pid: model if pid == printer_id else None,
  362. ),
  363. ]
  364. @pytest.mark.asyncio
  365. async def test_backup_on_pool_covers_short_slot(self, db_session, printer_factory, tmp_path):
  366. """The reporter scenario: assigned slot is short, but the same
  367. material on a peer slot covers the print. With backup ON, no deficit."""
  368. printer = await printer_factory(model="X1C")
  369. archive = await _setup_archive_3mf(
  370. db_session,
  371. tmp_path,
  372. [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
  373. )
  374. # Mapped slot: 10 g remaining, same Bambu preset as peer.
  375. short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
  376. # Peer slot on AMS-2: same preset, 500 g remaining.
  377. peer = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
  378. await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
  379. await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
  380. item = await _queue_item(
  381. db_session,
  382. printer_id=printer.id,
  383. archive=archive,
  384. ams_mapping=[0],
  385. )
  386. patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
  387. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  388. for p in patches:
  389. p.start()
  390. try:
  391. deficit = await compute_deficit_for_queue_item(db_session, item)
  392. finally:
  393. for p in patches:
  394. p.stop()
  395. # Pool (10 + 500 = 510 g) covers the 200 g print → no deficit.
  396. assert deficit == []
  397. @pytest.mark.asyncio
  398. async def test_backup_on_pool_insufficient_emits_deficit(self, db_session, printer_factory, tmp_path):
  399. """Backup ON but the same-material pool across all slots is still
  400. too small for the print → deficit emitted (real shortfall)."""
  401. printer = await printer_factory(model="X1C")
  402. archive = await _setup_archive_3mf(
  403. db_session,
  404. tmp_path,
  405. [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "1500.0"}],
  406. )
  407. a = await _spool(db_session, label_weight=1000, weight_used=900.0, slicer_filament="GFA00") # 100g
  408. b = await _spool(db_session, label_weight=1000, weight_used=700.0, slicer_filament="GFA00") # 300g
  409. await _assign(db_session, printer_id=printer.id, spool_id=a.id, ams_id=0, tray_id=0)
  410. await _assign(db_session, printer_id=printer.id, spool_id=b.id, ams_id=1, tray_id=0)
  411. item = await _queue_item(
  412. db_session,
  413. printer_id=printer.id,
  414. archive=archive,
  415. ams_mapping=[0],
  416. )
  417. patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
  418. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  419. for p in patches:
  420. p.start()
  421. try:
  422. deficit = await compute_deficit_for_queue_item(db_session, item)
  423. finally:
  424. for p in patches:
  425. p.stop()
  426. # Pool 400 g < required 1500 g → deficit fires.
  427. assert len(deficit) == 1
  428. assert deficit[0].slot_id == 1
  429. @pytest.mark.asyncio
  430. async def test_backup_on_different_materials_no_pool(self, db_session, printer_factory, tmp_path):
  431. """Backup ON, but the peer slot holds a DIFFERENT material — pool
  432. doesn't include it, deficit fires for the original short slot."""
  433. printer = await printer_factory(model="X1C")
  434. archive = await _setup_archive_3mf(
  435. db_session,
  436. tmp_path,
  437. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "200.0"}],
  438. )
  439. # Assigned slot: PLA White preset GFA01, 10 g.
  440. short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#FFFFFF", slicer_filament="GFA01")
  441. # Peer: PLA Black, different preset (GFA00) — NOT a backup peer under the strict rule.
  442. peer = await _spool(db_session, label_weight=1000, weight_used=500.0, color="#000000", slicer_filament="GFA00")
  443. await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
  444. await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
  445. item = await _queue_item(
  446. db_session,
  447. printer_id=printer.id,
  448. archive=archive,
  449. ams_mapping=[0],
  450. )
  451. patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
  452. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  453. for p in patches:
  454. p.start()
  455. try:
  456. deficit = await compute_deficit_for_queue_item(db_session, item)
  457. finally:
  458. for p in patches:
  459. p.stop()
  460. # Pool for white = 10 g, required = 200 g → deficit.
  461. assert len(deficit) == 1
  462. assert deficit[0].slot_id == 1
  463. assert deficit[0].remaining_grams == 10.0
  464. @pytest.mark.asyncio
  465. async def test_backup_off_falls_back_to_per_slot_accounting(self, db_session, printer_factory, tmp_path):
  466. """When backup is OFF the new code path must be a strict no-op vs.
  467. the pre-#1762 per-slot accounting. Identical inputs to the
  468. ``pool_covers_short_slot`` case but with backup OFF — deficit fires."""
  469. printer = await printer_factory(model="X1C")
  470. archive = await _setup_archive_3mf(
  471. db_session,
  472. tmp_path,
  473. [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
  474. )
  475. short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
  476. peer = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
  477. await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
  478. await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
  479. item = await _queue_item(
  480. db_session,
  481. printer_id=printer.id,
  482. archive=archive,
  483. ams_mapping=[0],
  484. )
  485. patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="X1C")
  486. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  487. for p in patches:
  488. p.start()
  489. try:
  490. deficit = await compute_deficit_for_queue_item(db_session, item)
  491. finally:
  492. for p in patches:
  493. p.stop()
  494. # Backup OFF → per-slot accounting → slot 1 has 10 g, needs 200 g.
  495. assert len(deficit) == 1
  496. assert deficit[0].remaining_grams == 10.0
  497. @pytest.mark.asyncio
  498. async def test_backup_on_dual_extruder_scopes_pool_per_side(self, db_session, printer_factory, tmp_path):
  499. """Dual-extruder printer (H2D): peer slot on the OPPOSITE extruder
  500. does NOT count toward the pool — firmware can't cross. Deficit fires."""
  501. printer = await printer_factory(model="O1D") # H2D internal code
  502. archive = await _setup_archive_3mf(
  503. db_session,
  504. tmp_path,
  505. [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
  506. )
  507. short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
  508. peer_other_side = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
  509. # AMS 0 is on extruder 0 (right). AMS 1 is on extruder 1 (left).
  510. await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
  511. await _assign(db_session, printer_id=printer.id, spool_id=peer_other_side.id, ams_id=1, tray_id=0)
  512. item = await _queue_item(
  513. db_session,
  514. printer_id=printer.id,
  515. archive=archive,
  516. ams_mapping=[0],
  517. )
  518. patches = TestFilamentDeficitBackupAware._patch_status(
  519. printer_id=printer.id,
  520. backup_on=True,
  521. ams_extruder_map={"0": 0, "1": 1},
  522. model="O1D",
  523. )
  524. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  525. for p in patches:
  526. p.start()
  527. try:
  528. deficit = await compute_deficit_for_queue_item(db_session, item)
  529. finally:
  530. for p in patches:
  531. p.stop()
  532. # Pool for extruder 0 = 10 g (peer on extruder 1 is unreachable) <
  533. # required 200 g → deficit.
  534. assert len(deficit) == 1
  535. assert deficit[0].slot_id == 1
  536. @pytest.mark.asyncio
  537. async def test_backup_on_no_preset_never_pairs(self, db_session, printer_factory, tmp_path):
  538. """Strict rule: two user-tagged spools with no slicer_filament preset
  539. must NEVER pair, even when material + colour match. Mirrors Bambu
  540. firmware: the backup decision relies on the Bambu Lab preset ID, so
  541. generic spools without one can't be trusted to switch."""
  542. printer = await printer_factory(model="X1C")
  543. archive = await _setup_archive_3mf(
  544. db_session,
  545. tmp_path,
  546. [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
  547. )
  548. # Both spools: material PLA, colour black, NO preset → unique keys.
  549. short = await _spool(db_session, label_weight=1000, weight_used=990.0)
  550. peer_no_preset = await _spool(db_session, label_weight=1000, weight_used=500.0)
  551. await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
  552. await _assign(db_session, printer_id=printer.id, spool_id=peer_no_preset.id, ams_id=1, tray_id=0)
  553. item = await _queue_item(
  554. db_session,
  555. printer_id=printer.id,
  556. archive=archive,
  557. ams_mapping=[0],
  558. )
  559. patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
  560. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  561. for p in patches:
  562. p.start()
  563. try:
  564. deficit = await compute_deficit_for_queue_item(db_session, item)
  565. finally:
  566. for p in patches:
  567. p.stop()
  568. # No preset means no pool — slot 1's 10 g vs 200 g required → deficit.
  569. assert len(deficit) == 1
  570. assert deficit[0].slot_id == 1
  571. assert deficit[0].remaining_grams == 10.0
  572. @pytest.mark.asyncio
  573. async def test_backup_on_same_preset_different_colors_does_not_pair(self, db_session, printer_factory, tmp_path):
  574. """STRICT colour rule: two spools sharing the same Bambu preset ID
  575. but DIFFERENT colours must NOT pool. Three PETG HF spools in
  576. different colours can't back each other up — the firmware would
  577. switch material correctly but the print would change colour
  578. mid-run. Pool is per-(preset, colour)."""
  579. printer = await printer_factory(model="X1C")
  580. archive = await _setup_archive_3mf(
  581. db_session,
  582. tmp_path,
  583. [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
  584. )
  585. # Assigned slot: PLA Basic + GFA00 + BLACK, only 10 g left.
  586. short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#000000", slicer_filament="GFA00")
  587. # Peer slot: same GFA00 profile but WHITE — must not pool.
  588. peer_diff_color = await _spool(
  589. db_session, label_weight=1000, weight_used=500.0, color="#FFFFFF", slicer_filament="GFA00"
  590. )
  591. await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
  592. await _assign(db_session, printer_id=printer.id, spool_id=peer_diff_color.id, ams_id=1, tray_id=0)
  593. item = await _queue_item(
  594. db_session,
  595. printer_id=printer.id,
  596. archive=archive,
  597. ams_mapping=[0],
  598. )
  599. patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
  600. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  601. for p in patches:
  602. p.start()
  603. try:
  604. deficit = await compute_deficit_for_queue_item(db_session, item)
  605. finally:
  606. for p in patches:
  607. p.stop()
  608. # Pool for (GFA00, black) = 10 g; required = 200 g → deficit.
  609. assert len(deficit) == 1
  610. assert deficit[0].slot_id == 1
  611. assert deficit[0].remaining_grams == 10.0
  612. @pytest.mark.asyncio
  613. async def test_backup_on_color_alpha_normalized(self, db_session, printer_factory, tmp_path):
  614. """Colour normalisation: 6-char hex matches 8-char hex of the same
  615. RGB. ``000000`` and ``000000FF`` should both resolve to BLACK."""
  616. printer = await printer_factory(model="X1C")
  617. archive = await _setup_archive_3mf(
  618. db_session,
  619. tmp_path,
  620. [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
  621. )
  622. short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#000000", slicer_filament="GFA00")
  623. # Same colour but expressed with explicit alpha.
  624. peer = await _spool(
  625. db_session, label_weight=1000, weight_used=500.0, color="#000000FF", slicer_filament="GFA00"
  626. )
  627. await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
  628. await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
  629. item = await _queue_item(
  630. db_session,
  631. printer_id=printer.id,
  632. archive=archive,
  633. ams_mapping=[0],
  634. )
  635. patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
  636. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  637. for p in patches:
  638. p.start()
  639. try:
  640. deficit = await compute_deficit_for_queue_item(db_session, item)
  641. finally:
  642. for p in patches:
  643. p.stop()
  644. # Pool (10 + 500 = 510 g) covers 200 g → no deficit.
  645. assert deficit == []