test_filament_deficit.py 27 KB

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