test_ams_slot_material_2902.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. """What reaches an AMS slot when a spool's material is a product line (#2902).
  2. The reporter assigned an eSUN PLA+ spool and the slot came out unusable: any
  3. plate sliced with a PLA profile refused it. Four routes configure a slot and
  4. all four wrote the spool's material straight into ``tray_type``, where "PLA+"
  5. matches nothing -- not the slicer, and not Bambuddy's own dispatch matcher,
  6. which compares the printer's reported ``tray_type`` to the 3MF's declared type
  7. as plain equality.
  8. Each test below asserts the whole slot, not just the type: an unrecognised
  9. material also missed the generic-filament-id lookup, so the slot went out with
  10. an empty ``tray_info_idx`` -- the half-configured state #2604 documents the
  11. printer as reverting from -- and took the 200/240 catch-all temperatures
  12. instead of PLA's.
  13. """
  14. import json
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from httpx import AsyncClient
  18. from sqlalchemy.ext.asyncio import AsyncSession
  19. from backend.app.models.spool import Spool
  20. def _mqtt_mock():
  21. client = MagicMock()
  22. client.ams_set_filament_setting.return_value = True
  23. client.extrusion_cali_sel.return_value = True
  24. return client
  25. def _status(ams_data=None):
  26. status = MagicMock()
  27. status.raw_data = {"ams": {"ams": ams_data if ams_data is not None else []}}
  28. status.nozzles = [MagicMock(nozzle_diameter="0.4")]
  29. status.ams_extruder_map = None
  30. status.kprofiles = []
  31. return status
  32. def _spoolman_spool(material, spool_id=11, slicer_filament=None):
  33. extra = {}
  34. if slicer_filament is not None:
  35. extra["bambu_slicer_filament"] = json.dumps(str(slicer_filament))
  36. return {
  37. "id": spool_id,
  38. "filament": {
  39. "id": 1,
  40. "name": "Cool White",
  41. "material": material,
  42. "color_hex": "E1E9E9",
  43. "weight": 1000,
  44. "vendor": {"id": 1, "name": "eSUN"},
  45. },
  46. "remaining_weight": 800.0,
  47. "used_weight": 200.0,
  48. "archived": False,
  49. "extra": extra,
  50. }
  51. def _spoolman_client(spool):
  52. client = MagicMock()
  53. client.base_url = "http://localhost:7912"
  54. client.health_check = AsyncMock(return_value=True)
  55. client.get_spool = AsyncMock(return_value=spool)
  56. client.get_spools = AsyncMock(return_value=[spool])
  57. client.merge_spool_extra = AsyncMock(return_value=spool)
  58. return client
  59. class TestInternalInventoryAssign:
  60. async def _assign(self, async_client, db_session, material, **spool_kwargs):
  61. from backend.app.models.printer import Printer
  62. printer = Printer(
  63. name="P1S",
  64. serial_number=f"MAT2902{material[:4]}",
  65. ip_address="192.168.1.77",
  66. access_code="12345678",
  67. )
  68. db_session.add(printer)
  69. spool = Spool(
  70. material=material,
  71. brand="eSUN",
  72. color_name="Cool White",
  73. rgba="E1E9E9FF",
  74. label_weight=1000,
  75. weight_used=0,
  76. **spool_kwargs,
  77. )
  78. db_session.add(spool)
  79. await db_session.commit()
  80. await db_session.refresh(printer)
  81. await db_session.refresh(spool)
  82. client = _mqtt_mock()
  83. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  84. pm.get_client.return_value = client
  85. pm.get_status.return_value = _status()
  86. response = await async_client.post(
  87. "/api/v1/inventory/assignments",
  88. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  89. )
  90. assert response.status_code == 200
  91. client.ams_set_filament_setting.assert_called_once()
  92. return client.ams_set_filament_setting.call_args.kwargs
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. async def test_a_pla_plus_spool_configures_the_slot_as_pla(
  96. self, async_client: AsyncClient, db_session: AsyncSession
  97. ):
  98. sent = await self._assign(async_client, db_session, "PLA+")
  99. assert sent["tray_type"] == "PLA"
  100. # Not just the label: the id and its setting_id are what stop the
  101. # printer treating the slot as half configured, and the temperatures
  102. # are PLA's rather than the catch-all.
  103. assert sent["tray_info_idx"] == "GFL99"
  104. assert sent["setting_id"] == "GFSL99"
  105. assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_the_product_name_is_not_lost_it_moves_to_the_sub_brand(
  109. self, async_client: AsyncClient, db_session: AsyncSession
  110. ):
  111. """Which is where Bambu itself puts it -- their own catalogue has a
  112. preset named "eSUN PLA+" (GFL03) whose type is PLA."""
  113. sent = await self._assign(async_client, db_session, "PLA+")
  114. assert "PLA+" in sent["tray_sub_brands"]
  115. @pytest.mark.asyncio
  116. @pytest.mark.integration
  117. async def test_a_material_that_already_resolved_keeps_its_own_preset(
  118. self, async_client: AsyncClient, db_session: AsyncSession
  119. ):
  120. """ "PETG HF" has a generic preset of its own (GFG96, "Generic PETG HF").
  121. Reducing the material before the id lookup rather than after it would
  122. trade that away for plain PETG's GFG99 -- a quiet downgrade of slots
  123. that work today."""
  124. sent = await self._assign(async_client, db_session, "PETG HF")
  125. assert sent["tray_info_idx"] == "GFG96"
  126. assert sent["tray_type"] == "PETG"
  127. @pytest.mark.asyncio
  128. @pytest.mark.integration
  129. async def test_it_can_now_reuse_the_calibrated_preset_already_in_the_slot(
  130. self, async_client: AsyncClient, db_session: AsyncSession
  131. ):
  132. """A slot already holding a specific preset keeps it when the incoming
  133. spool is the same material -- that is how a printer's calibration
  134. context survives an assignment. The comparison is against the slot's
  135. reported type, so a PLA+ spool could never match a PLA slot and the
  136. reuse branch was dead for every spool this issue is about."""
  137. from backend.app.models.printer import Printer
  138. printer = Printer(
  139. name="Reuse P1S",
  140. serial_number="MAT2902RU",
  141. ip_address="192.168.1.81",
  142. access_code="12345678",
  143. )
  144. db_session.add(printer)
  145. spool = Spool(material="PLA+", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
  146. db_session.add(spool)
  147. await db_session.commit()
  148. await db_session.refresh(printer)
  149. await db_session.refresh(spool)
  150. client = _mqtt_mock()
  151. live_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  152. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  153. pm.get_client.return_value = client
  154. pm.get_status.return_value = _status(live_slot)
  155. response = await async_client.post(
  156. "/api/v1/inventory/assignments",
  157. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  158. )
  159. assert response.status_code == 200
  160. sent = client.ams_set_filament_setting.call_args.kwargs
  161. assert sent["tray_info_idx"] == "P4d64437"
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_but_it_does_not_reuse_a_product_name_a_previous_version_left_there(
  165. self, async_client: AsyncClient, db_session: AsyncSession
  166. ):
  167. """A spool whose slicer_filament was free text could send that text to
  168. the printer as the slot's filament id, and the printer reports it
  169. straight back -- so an upgraded install can be looking at a slot that
  170. says type PLA, id "PLA+". Reuse has always refused a bare material name
  171. in that field; refusing a product line too is what stops the bad id
  172. being carried forward on every assignment instead of replaced."""
  173. from backend.app.models.printer import Printer
  174. printer = Printer(
  175. name="Stale P1S",
  176. serial_number="MAT2902ST",
  177. ip_address="192.168.1.82",
  178. access_code="12345678",
  179. )
  180. db_session.add(printer)
  181. spool = Spool(material="PLA", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
  182. db_session.add(spool)
  183. await db_session.commit()
  184. await db_session.refresh(printer)
  185. await db_session.refresh(spool)
  186. client = _mqtt_mock()
  187. stale_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "PLA+", "tray_type": "PLA"}]}]
  188. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  189. pm.get_client.return_value = client
  190. pm.get_status.return_value = _status(stale_slot)
  191. response = await async_client.post(
  192. "/api/v1/inventory/assignments",
  193. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  194. )
  195. assert response.status_code == 200
  196. sent = client.ams_set_filament_setting.call_args.kwargs
  197. assert sent["tray_info_idx"] == "GFL99"
  198. assert sent["tray_type"] == "PLA"
  199. @pytest.mark.asyncio
  200. @pytest.mark.integration
  201. async def test_a_material_nothing_can_be_made_of_is_sent_unchanged(
  202. self, async_client: AsyncClient, db_session: AsyncSession
  203. ):
  204. """The catalogue ships a few names with no filament type in them at all.
  205. Guessing at those would be worse than leaving them: this route behaved
  206. exactly this way before #2902, and still does."""
  207. sent = await self._assign(async_client, db_session, "CPE HG100")
  208. assert sent["tray_type"] == "CPE HG100"
  209. assert sent["tray_info_idx"] == ""
  210. @pytest.mark.asyncio
  211. @pytest.mark.integration
  212. async def test_a_free_text_slicer_filament_naming_a_product_is_not_a_filament_id(
  213. self, async_client: AsyncClient, db_session: AsyncSession
  214. ):
  215. """slicer_filament is free text on older spools, so "PLA+" can be sitting
  216. in it. It is as unusable a tray_info_idx as the bare "PLA" the resolver
  217. already discarded, and letting it through would put a product name in
  218. the field the printer keys its calibration table by."""
  219. sent = await self._assign(async_client, db_session, "PLA+", slicer_filament="PLA+")
  220. assert sent["tray_info_idx"] == "GFL99"
  221. class TestSpoolmanInventoryAssign:
  222. @pytest.fixture
  223. async def settings(self, db_session):
  224. from backend.app.models.settings import Settings
  225. db_session.add(Settings(key="spoolman_enabled", value="true"))
  226. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  227. await db_session.commit()
  228. @pytest.fixture
  229. async def printer(self, db_session):
  230. from backend.app.models.printer import Printer
  231. p = Printer(
  232. name="Spoolman P1S",
  233. serial_number="MAT2902SM",
  234. ip_address="192.168.1.78",
  235. access_code="12345678",
  236. )
  237. db_session.add(p)
  238. await db_session.commit()
  239. await db_session.refresh(p)
  240. return p
  241. async def _assign(self, async_client, printer, material, slicer_filament=None):
  242. mqtt = _mqtt_mock()
  243. spool = _spoolman_spool(material, slicer_filament=slicer_filament)
  244. with (
  245. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
  246. patch(
  247. "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
  248. AsyncMock(return_value=_spoolman_client(spool)),
  249. ),
  250. ):
  251. pm.get_client.return_value = mqtt
  252. pm.get_status.return_value = _status()
  253. response = await async_client.post(
  254. "/api/v1/spoolman/inventory/slot-assignments",
  255. json={"spoolman_spool_id": 11, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
  256. )
  257. assert response.status_code == 200
  258. return mqtt.ams_set_filament_setting.call_args.kwargs
  259. @pytest.mark.asyncio
  260. @pytest.mark.integration
  261. async def test_spoolmans_free_text_material_is_reduced_the_same_way(
  262. self, async_client: AsyncClient, settings, printer
  263. ):
  264. """Spoolman's material field is free text too, so the same product names
  265. arrive by this route -- and it is the route the reporter used."""
  266. sent = await self._assign(async_client, printer, "PLA+")
  267. assert sent["tray_type"] == "PLA"
  268. assert sent["tray_info_idx"] == "GFL99"
  269. # Exactly, not just "contains PLA+": the filament's own name is in this
  270. # string too, so a substring check would pass even if the material had
  271. # been reduced before it was built.
  272. assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
  273. @pytest.mark.asyncio
  274. @pytest.mark.integration
  275. async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
  276. self, async_client: AsyncClient, settings, printer
  277. ):
  278. sent = await self._assign(async_client, printer, "PETG HF")
  279. assert sent["tray_info_idx"] == "GFG96"
  280. assert sent["tray_type"] == "PETG"
  281. @pytest.mark.asyncio
  282. @pytest.mark.integration
  283. async def test_the_resolver_is_handed_the_spools_own_wording_not_the_type(
  284. self, async_client: AsyncClient, settings, printer, db_session
  285. ):
  286. """This route also passes the material down to the slicer-filament
  287. resolver. Handing that the reduced type instead would look harmless and
  288. quietly downgrade GFG96 to GFG99 whenever the spool points at a local
  289. preset with no filament_id of its own."""
  290. from backend.app.models.local_preset import LocalPreset
  291. lp = LocalPreset(name="Generic PETG HF", preset_type="filament", source="orcaslicer", setting="{}")
  292. db_session.add(lp)
  293. await db_session.commit()
  294. await db_session.refresh(lp)
  295. sent = await self._assign(async_client, printer, "PETG HF", slicer_filament=lp.id)
  296. assert sent["tray_info_idx"] == "GFG96"
  297. class TestConfigureSlotModal:
  298. @pytest.mark.asyncio
  299. @pytest.mark.integration
  300. async def test_a_product_line_typed_into_the_modal_is_reduced_too(self, async_client: AsyncClient, printer_factory):
  301. """The Configure Slot modal derives tray_type from a preset name or the
  302. spool's material, so it can hand the backend a product line as readily
  303. as the assignment routes can."""
  304. printer = await printer_factory(model="P1S")
  305. client = _mqtt_mock()
  306. with patch("backend.app.api.routes.printers.printer_manager") as pm:
  307. pm.get_client.return_value = client
  308. pm.get_status.return_value = _status()
  309. response = await async_client.post(
  310. f"/api/v1/printers/{printer.id}/slots/0/1/configure",
  311. params={
  312. "tray_info_idx": "",
  313. "tray_type": "PLA+",
  314. "tray_sub_brands": "eSUN PLA+",
  315. "tray_color": "E1E9E9FF",
  316. "nozzle_temp_min": 190,
  317. "nozzle_temp_max": 230,
  318. },
  319. )
  320. assert response.status_code == 200
  321. sent = client.ams_set_filament_setting.call_args.kwargs
  322. assert sent["tray_type"] == "PLA"
  323. # The empty tray_info_idx the modal sent for a generic material is what
  324. # the reduced type now rescues.
  325. assert sent["tray_info_idx"] == "GFL99"
  326. assert sent["tray_sub_brands"] == "eSUN PLA+"
  327. class TestSpoolmanLink:
  328. """The fourth route that configures a slot: linking a Spoolman spool to a
  329. slot's tag auto-configures it too, from the same free-text material."""
  330. @pytest.fixture
  331. async def settings(self, db_session):
  332. from backend.app.models.settings import Settings
  333. db_session.add(Settings(key="spoolman_enabled", value="true"))
  334. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  335. await db_session.commit()
  336. @pytest.fixture
  337. async def printer(self, db_session):
  338. from backend.app.models.printer import Printer
  339. p = Printer(
  340. name="Link P1S",
  341. serial_number="MAT2902LK",
  342. ip_address="192.168.1.79",
  343. access_code="12345678",
  344. )
  345. db_session.add(p)
  346. await db_session.commit()
  347. await db_session.refresh(p)
  348. return p
  349. async def _link(self, async_client, printer, material):
  350. client = _spoolman_client(_spoolman_spool(material, spool_id=12))
  351. mqtt = _mqtt_mock()
  352. with (
  353. patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
  354. patch("backend.app.api.routes.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
  355. patch("backend.app.api.routes.spoolman.printer_manager") as pm,
  356. ):
  357. pm.get_client.return_value = mqtt
  358. pm.get_status.return_value = _status()
  359. response = await async_client.post(
  360. "/api/v1/spoolman/spools/12/link",
  361. json={
  362. "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  363. "printer_id": printer.id,
  364. "ams_id": 0,
  365. "tray_id": 3,
  366. },
  367. )
  368. assert response.status_code == 200
  369. return mqtt.ams_set_filament_setting.call_args.kwargs
  370. @pytest.mark.asyncio
  371. @pytest.mark.integration
  372. async def test_linking_a_pla_plus_spool_configures_the_slot_as_pla(
  373. self, async_client: AsyncClient, settings, printer
  374. ):
  375. sent = await self._link(async_client, printer, "PLA+")
  376. assert sent["tray_type"] == "PLA"
  377. assert sent["tray_info_idx"] == "GFL99"
  378. assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
  379. assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
  380. @pytest.mark.asyncio
  381. @pytest.mark.integration
  382. async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
  383. self, async_client: AsyncClient, settings, printer
  384. ):
  385. sent = await self._link(async_client, printer, "PETG HF")
  386. assert sent["tray_info_idx"] == "GFG96"
  387. assert sent["tray_type"] == "PETG"
  388. class TestALocalPresetThatNamesNoFilamentId:
  389. """The one path where the material reaches the slicer-filament resolver
  390. rather than the route's own fallback: a spool pointing at an imported local
  391. preset whose setting JSON carries no filament_id. The resolver then has only
  392. the material to go on, so it has to read it the same way -- and be handed
  393. the spool's own wording, not the reduced type."""
  394. @pytest.fixture
  395. async def preset(self, db_session):
  396. from backend.app.models.local_preset import LocalPreset
  397. lp = LocalPreset(
  398. name="eSUN PLA+ @BBL P1S",
  399. preset_type="filament",
  400. source="orcaslicer",
  401. filament_type=None,
  402. setting="{}",
  403. )
  404. db_session.add(lp)
  405. await db_session.commit()
  406. await db_session.refresh(lp)
  407. return lp
  408. @pytest.fixture
  409. async def printer(self, db_session):
  410. from backend.app.models.printer import Printer
  411. p = Printer(
  412. name="LP P1S",
  413. serial_number="MAT2902LP",
  414. ip_address="192.168.1.80",
  415. access_code="12345678",
  416. )
  417. db_session.add(p)
  418. await db_session.commit()
  419. await db_session.refresh(p)
  420. return p
  421. async def _assign(self, async_client, db_session, printer, preset, material):
  422. spool = Spool(
  423. material=material,
  424. brand="eSUN",
  425. rgba="E1E9E9FF",
  426. label_weight=1000,
  427. weight_used=0,
  428. slicer_filament=str(preset.id),
  429. )
  430. db_session.add(spool)
  431. await db_session.commit()
  432. await db_session.refresh(spool)
  433. client = _mqtt_mock()
  434. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  435. pm.get_client.return_value = client
  436. pm.get_status.return_value = _status()
  437. response = await async_client.post(
  438. "/api/v1/inventory/assignments",
  439. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  440. )
  441. assert response.status_code == 200
  442. return client.ams_set_filament_setting.call_args.kwargs
  443. @pytest.mark.asyncio
  444. @pytest.mark.integration
  445. async def test_the_resolver_places_a_product_line_too(
  446. self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
  447. ):
  448. sent = await self._assign(async_client, db_session, printer, preset, "PLA+")
  449. assert sent["tray_info_idx"] == "GFL99"
  450. @pytest.mark.asyncio
  451. @pytest.mark.integration
  452. async def test_and_still_prefers_a_material_that_has_its_own_preset(
  453. self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
  454. ):
  455. sent = await self._assign(async_client, db_session, printer, preset, "PETG HF")
  456. assert sent["tray_info_idx"] == "GFG96"
  457. class TestTheAssignmentSurvivesTheSlotItJustConfigured:
  458. """The other side of the same coin, and the one that bites hardest.
  459. on_ams_change auto-unlinks an assignment whose slot no longer looks like it
  460. did when the spool was assigned. The fingerprint is snapshotted *before* the
  461. MQTT config goes out, so the very next AMS push after an assignment is a
  462. mismatch by construction -- and what saves the assignment is a second check:
  463. does the tray match the assigned spool now? That check read the spool's raw
  464. material, which the slot no longer carries, so every spool this issue is
  465. about would have been silently unlinked from the slot it had just been
  466. assigned to. Correct in isolation, ruinous together.
  467. """
  468. async def _push(self, db_session, printer_factory, spool_material, reported_type, fingerprint_type="PETG"):
  469. from unittest.mock import AsyncMock
  470. from backend.app.main import on_ams_change
  471. from backend.app.models.spool_assignment import SpoolAssignment
  472. printer = await printer_factory(name="H2D")
  473. spool = Spool(material=spool_material, brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
  474. db_session.add(spool)
  475. await db_session.commit()
  476. await db_session.refresh(spool)
  477. assignment = SpoolAssignment(
  478. spool_id=spool.id,
  479. printer_id=printer.id,
  480. ams_id=0,
  481. tray_id=2,
  482. fingerprint_color="E1E9E9FF",
  483. fingerprint_type=fingerprint_type,
  484. )
  485. db_session.add(assignment)
  486. await db_session.commit()
  487. assignment_id = assignment.id
  488. ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": reported_type, "tray_color": "E1E9E9FF", "state": 11}]}]
  489. status = _status(ams_data)
  490. status.state = "IDLE"
  491. with (
  492. patch("backend.app.main.printer_manager") as pm,
  493. patch("backend.app.main.mqtt_relay") as relay,
  494. patch("backend.app.main.ws_manager") as ws,
  495. ):
  496. pm.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  497. pm.get_status.return_value = status
  498. pm.get_model.return_value = "H2D"
  499. relay.on_ams_change = AsyncMock()
  500. ws.send_printer_status = AsyncMock()
  501. ws.broadcast = AsyncMock()
  502. await on_ams_change(printer.id, ams_data)
  503. # on_ams_change commits through its own session.
  504. db_session.expunge_all()
  505. return await db_session.get(SpoolAssignment, assignment_id)
  506. @pytest.mark.asyncio
  507. @pytest.mark.integration
  508. async def test_a_pla_plus_spool_is_not_unlinked_from_the_slot_now_reporting_pla(
  509. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  510. ):
  511. surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA")
  512. assert surviving is not None, "the slot reports what we wrote to it -- that is a match, not a swap"
  513. @pytest.mark.asyncio
  514. @pytest.mark.integration
  515. async def test_nor_is_one_in_a_slot_an_older_version_configured(
  516. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  517. ):
  518. """An install upgrading into this fix has slots still reporting "PLA+"
  519. until something reconfigures them. Reducing only the spool's side would
  520. break those the moment they were left alone."""
  521. surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA+")
  522. assert surviving is not None
  523. @pytest.mark.asyncio
  524. @pytest.mark.integration
  525. async def test_a_genuinely_different_filament_still_unlinks(
  526. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  527. ):
  528. """The check still has to do its job: someone swapping PLA for ABS in
  529. the slot must lose the assignment, or usage gets charged to the wrong
  530. spool."""
  531. surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="ABS")
  532. assert surviving is None