| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570 |
- """Integration tests for inventory spool assignment — tray_info_idx resolution.
- Tests that the spool's own slicer_filament (including PFUS* cloud-synced
- custom presets) takes priority, with slot reuse and generic fallback as
- lower-priority fallbacks.
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.models.spool import Spool
- @pytest.fixture
- async def spool_factory(db_session: AsyncSession):
- """Factory to create test spools."""
- _counter = [0]
- async def _create_spool(**kwargs):
- _counter[0] += 1
- defaults = {
- "material": "PLA",
- "subtype": "Basic",
- "brand": "Devil Design",
- "color_name": "Red",
- "rgba": "FF0000FF",
- "label_weight": 1000,
- "weight_used": 0,
- "slicer_filament": "PFUS9ac902733670a9",
- }
- defaults.update(kwargs)
- spool = Spool(**defaults)
- db_session.add(spool)
- await db_session.commit()
- await db_session.refresh(spool)
- return spool
- return _create_spool
- def _make_mock_status(ams_data=None, vt_tray=None, nozzles=None, ams_extruder_map=None):
- """Build a mock printer status with optional AMS/nozzle data."""
- status = MagicMock()
- raw = {}
- if ams_data is not None:
- raw["ams"] = {"ams": ams_data}
- if vt_tray is not None:
- raw["vt_tray"] = vt_tray
- status.raw_data = raw
- status.nozzles = nozzles or [MagicMock(nozzle_diameter="0.4")]
- status.ams_extruder_map = ams_extruder_map
- return status
- class TestAssignSpoolTrayInfoIdx:
- """Tests for tray_info_idx resolution during spool assignment."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_pfus_slicer_filament_falls_back_to_generic(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """PFUS* cloud setting_ids are rejected by the slicer as tray_info_idx, so the
- no-kp path falls back to the generic material id (PLA → GFL99). The K-profile
- realignment path translates PFUS → P-prefix when a stored kp exists; that's
- covered separately."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_pfus_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
- """When the spool's PFUS gets discarded as slicer-invalid, the slot's existing
- valid P-prefix preset is reused if it matches the spool's material — preserves
- the printer's calibration context rather than resetting to generic."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # Slot already configured by slicer with cloud-synced preset
- status = _make_mock_status(
- ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spool_preset_used_even_if_different_material_on_slot(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Spool's material drives the fallback generic id. Slot's existing PLA preset
- is overridden because the spool is PETG → GFG99."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # Slot currently has PLA but spool is PETG
- status = _make_mock_status(
- ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):
- """Standard GF* IDs from spool.slicer_filament are used directly."""
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
- """Spool with no slicer_filament gets a generic ID from material type."""
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament=None, material="ABS")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "ABS"}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spool_pfus_falls_back_to_generic_over_slot_pfus(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Both spool and slot have PFUS values — both rejected as tray_info_idx —
- falls back to generic material id (PLA → GFL99)."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # Slot has a PFUS* ID from some previous config
- status = _make_mock_status(
- ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "PFUS2222222222", "tray_type": "PLA"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_generic_on_slot_falls_back_to_material_generic(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """When spool's PFUS is discarded and slot only has a generic ID, the result
- comes from the spool's material (ABS → GFB99) — not from the slot. Important
- because the generic-id check (`not in _generic_id_values`) prevents stale
- generic reuse and routes the decision through the material fallback."""
- printer = await printer_factory(name="P2S")
- spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # Slot stuck on generic ABS from a previous assignment
- status = _make_mock_status(
- ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_preset_with_generic_on_slot_still_uses_generic(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Spool without preset + generic on slot → generic fallback (not slot reuse)."""
- printer = await printer_factory(name="P2S")
- spool = await spool_factory(slicer_filament=None, material="ABS")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # Slot has generic ABS
- status = _make_mock_status(
- ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- # Still gets generic, but via fallback — not via sticky reuse
- assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_preset_reuses_specific_slot_preset(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Spool without preset + specific preset on slot → reuse slot's preset."""
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament=None, material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # Slot has a specific Bambu PLA preset (not generic)
- status = _make_mock_status(
- ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "GFA05", "tray_type": "PLA"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- # Slot's specific preset is reused when spool has no own preset
- assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"
- @pytest.mark.asyncio
- @pytest.mark.integration
- @pytest.mark.parametrize("slot_generic", ["GFL96", "GFL95"])
- async def test_no_preset_keeps_a_silk_or_high_speed_generic_on_the_slot(
- self, async_client: AsyncClient, printer_factory, spool_factory, slot_generic
- ):
- """PLA Silk and PLA High Speed joined the generic table (#3273), but the
- reuse check kept its old set: a slot set to one of them still keeps it
- for a preset-less PLA spool rather than dropping to Generic PLA."""
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament=None, material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(
- ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": slot_generic, "tray_type": "PLA"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == slot_generic
- class TestSlotReuseRespectsTheSubtype:
- """A preset-less spool reuses the slot's Bambu filament id when the bare
- type matches, to keep a calibrated preset. A Silk spool must not inherit a
- slot that the printer describes as Matte, though."""
- async def _sent_id(self, async_client, printer_factory, spool_factory, subtype, slot_sub_brands):
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament=None, material="PLA", subtype=subtype)
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- tray = {"id": 0, "tray_info_idx": "GFA01", "tray_type": "PLA", "tray_sub_brands": slot_sub_brands}
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- return mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_silk_spool_does_not_take_a_matte_slot(self, async_client, printer_factory, spool_factory):
- sent = await self._sent_id(async_client, printer_factory, spool_factory, "Silk", "PLA Matte")
- assert sent == "GFL96" # Generic PLA Silk, the spool's own subtype
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_silk_plus_spool_gets_generic_pla_silk(self, async_client, printer_factory, spool_factory):
- assert await self._sent_id(async_client, printer_factory, spool_factory, "Silk+", "PLA Matte") == "GFL96"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_finish_in_the_material_column_counts(self, async_client, printer_factory, spool_factory):
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament=None, material="PLA Silk", subtype=None)
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- tray = {"id": 0, "tray_info_idx": "GFA01", "tray_type": "PLA", "tray_sub_brands": "PLA Matte"}
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL96"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_same_subtype_still_reuses_the_slot(self, async_client, printer_factory, spool_factory):
- assert await self._sent_id(async_client, printer_factory, spool_factory, "Matte", "PLA Matte") == "GFA01"
- @pytest.mark.asyncio
- @pytest.mark.integration
- @pytest.mark.parametrize(("subtype", "slot_sub_brands"), [(None, "PLA Matte"), ("Silk", ""), ("Silk", "Sunlu PLA")])
- async def test_without_two_known_subtypes_it_still_reuses(
- self, async_client, printer_factory, spool_factory, subtype, slot_sub_brands
- ):
- sent = await self._sent_id(async_client, printer_factory, spool_factory, subtype, slot_sub_brands)
- assert sent == "GFA01"
- class TestAssignSpoolPresetMapping:
- """Tests that assign_spool saves the slot preset mapping for correct UI display."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_preset_mapping_saved_with_slicer_filament_name(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Slot preset mapping uses slicer_filament_name (not material+subtype)."""
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(
- slicer_filament="GFA05",
- slicer_filament_name="Bambu PLA Silk",
- material="PLA",
- subtype="Silk",
- brand="Bambu",
- )
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 1, "tray_type": "PLA"}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
- )
- assert response.status_code == 200
- # Verify via the slot presets API
- presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
- assert presets_resp.status_code == 200
- presets = presets_resp.json()
- # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → "1"
- assert "1" in presets
- # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
- assert presets["1"]["preset_name"] == "Bambu PLA Silk"
- assert presets["1"]["preset_id"] == "GFSA05"
- # The filament id the slot was given goes with it, so the slot card can
- # tell a later re-configuration from elsewhere (#3216).
- assert presets["1"]["tray_info_idx"] == "GFA05"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_preset_mapping_overwrites_old_mapping(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """Assigning a new spool overwrites the old slot preset mapping."""
- from backend.app.models.slot_preset import SlotPresetMapping
- printer = await printer_factory(name="X1C")
- # Pre-existing mapping (e.g. from previous manual configuration)
- old_mapping = SlotPresetMapping(
- printer_id=printer.id,
- ams_id=0,
- tray_id=2,
- preset_id="GFSA01",
- preset_name="Bambu PLA Matte",
- preset_source="cloud",
- tray_info_idx="GFA01",
- )
- db_session.add(old_mapping)
- await db_session.commit()
- # Assign a "Generic PLA Silk" spool to same slot
- spool = await spool_factory(
- slicer_filament="GFL96",
- slicer_filament_name="Generic PLA Silk",
- material="PLA",
- subtype="Silk",
- )
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 2, "tray_type": "PLA"}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
- )
- assert response.status_code == 200
- # Verify via the slot presets API to avoid stale session cache
- presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
- assert presets_resp.status_code == 200
- presets = presets_resp.json()
- # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → "2"
- assert "2" in presets
- # Old "Bambu PLA Matte" must be overwritten
- assert presets["2"]["preset_name"] == "Generic PLA Silk"
- assert presets["2"]["preset_id"] == "GFSL96"
- # ...and so must the filament id recorded with it (#3216).
- assert presets["2"]["tray_info_idx"] == "GFL96"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_preset_mapping_fallback_to_tray_sub_brands(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """When slicer_filament_name is null, falls back to tray_sub_brands."""
- from backend.app.models.slot_preset import SlotPresetMapping
- printer = await printer_factory(name="A1M")
- spool = await spool_factory(
- slicer_filament="GFL05",
- slicer_filament_name=None,
- material="PLA",
- subtype="Matte",
- brand="Overture",
- )
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- # Verify via the slot presets API
- presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
- assert presets_resp.status_code == 200
- presets = presets_resp.json()
- # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → "0"
- assert "0" in presets
- # Falls back to tray_sub_brands ("Overture PLA Matte")
- assert presets["0"]["preset_name"] == "Overture PLA Matte"
- class TestAssignSpoolLiveCaliIdx:
- """assign_spool always resets the slot to Default K when the spool has no stored K-profile."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_kprofile_resets_to_default_k(self, async_client: AsyncClient, printer_factory, spool_factory):
- """When no KProfile row exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
- printer = await printer_factory()
- spool = await spool_factory()
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # Live cali_idx=42 belongs to whatever filament was previously calibrated
- # in this slot. Applying it to a different spool would use the wrong K
- # value, so the assign flow must override it with Default K (-1).
- tray_data = {
- "id": 1,
- "cali_idx": 42,
- "tray_color": "FF0000FF",
- "tray_type": "PLA",
- "tray_sub_brands": "PLA Basic",
- "tray_id_name": "GFL99",
- }
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
- )
- assert response.status_code == 200
- mock_client.extrusion_cali_sel.assert_called_once()
- assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_kprofile_no_live_cali_idx_sends_default(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """When tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
- printer = await printer_factory()
- spool = await spool_factory()
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- tray_data = {
- "id": 0,
- "cali_idx": None,
- "tray_color": "FF0000FF",
- "tray_type": "PLA",
- "tray_sub_brands": "PLA Basic",
- "tray_id_name": "GFL99",
- }
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- mock_client.extrusion_cali_sel.assert_called_once()
- assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_negative_live_cali_idx_sends_default(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """A negative live cali_idx (-1) falls through and is sent as Default (cali_idx=-1)."""
- printer = await printer_factory()
- spool = await spool_factory()
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- tray_data = {
- "id": 0,
- "cali_idx": -1,
- "tray_color": "FF0000FF",
- "tray_type": "PLA",
- "tray_sub_brands": "PLA Basic",
- "tray_id_name": "GFL99",
- }
- status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- mock_client.extrusion_cali_sel.assert_called_once()
- assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
- class TestAssignSpoolEmptySlotPreConfig:
- """Assign path under ambiguous / explicit-empty AMS state.
- Updated for the #1322 follow-up: only the firmware's *explicit* empty
- signal (state ∈ {9, 10}) skips MQTT. Anything else — including the
- SpoolBuddy weigh-then-assign-before-insert case where state/tray_type
- can't tell us whether a spool is loaded — attempts MQTT. The deferred-
- config workflow still works because on_ams_change at main.py:1031-1054
- re-fires when an AMS push eventually reports the loaded slot.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_empty_tray_type_without_state_still_fires_mqtt(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """tray_type='' with no state field: AMS can't tell us whether a
- spool is loaded. Trust the user's Assign click and fire MQTT —
- firmware accepts it when a spool is physically there, drops it
- silently otherwise (no harm)."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_called_once()
- body = response.json()
- assert body["pending_config"] is False
- assert body["configured"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_ams_data_with_no_client_marks_pending(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """No AMS data + no MQTT client (printer offline, no telemetry):
- publish can't happen, so configured=False and pending_config=True so
- on_ams_change replay picks it up when the printer comes online."""
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- # No AMS data — fingerprint_type stays None.
- status = _make_mock_status(ams_data=[])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = None # Printer offline, no MQTT client.
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- body = response.json()
- assert body["pending_config"] is True
- assert body["configured"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_loaded_slot_publishes_mqtt_immediately(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Loaded slot (tray_type non-empty) → MQTT fires + pending_config=False."""
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(
- ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_info_idx": "GFL05"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
- )
- assert response.status_code == 200
- body = response.json()
- assert body["pending_config"] is False
- assert body["configured"] is True
- mock_client.ams_set_filament_setting.assert_called_once()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_on_ams_change_fires_config_when_pre_assigned_slot_loads(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """Pre-config replay: SpoolAssignment with empty fingerprint + slot now loaded → MQTT fires."""
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- # Pre-existing assignment with empty fingerprint (the SpoolBuddy state)
- pre_assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=2,
- tray_id=3,
- fingerprint_color=None,
- fingerprint_type=None,
- )
- db_session.add(pre_assignment)
- await db_session.commit()
- # Filament has now been physically inserted into the slot.
- # state=11 ("filament fed to extruder") is the load signal we trigger on.
- ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=ams_data)
- printer_info = MagicMock(name="H2D", serial_number="0948BB540200427")
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = printer_info
- mock_pm_main.get_status.return_value = status
- mock_pm_main.get_client.return_value = mock_client
- mock_pm_main.get_model.return_value = "H2D"
- mock_pm_inv.get_client.return_value = mock_client
- mock_pm_inv.get_status.return_value = status
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, ams_data)
- # Full filament setting was published when the slot transitioned to loaded
- mock_client.ams_set_filament_setting.assert_called_once()
- call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
- assert call_kwargs["ams_id"] == 2
- assert call_kwargs["tray_id"] == 3
- assert call_kwargs["tray_info_idx"] == "GFL05"
- # Fingerprint was updated so the next push doesn't re-fire
- await db_session.refresh(pre_assignment)
- assert pre_assignment.fingerprint_type == "PLA"
- assert pre_assignment.fingerprint_color == "FF0000FF"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_replay_passes_the_slots_finish(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """A preset-less Silk spool loaded into a slot reporting Bambu Matte."""
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament=None, material="PLA", subtype="Silk")
- db_session.add(
- SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=2,
- tray_id=3,
- fingerprint_color=None,
- fingerprint_type=None,
- )
- )
- await db_session.commit()
- tray = {
- "id": 3,
- "tray_type": "PLA",
- "tray_color": "FF0000FF",
- "state": 11,
- "tray_info_idx": "GFA01",
- "tray_sub_brands": "PLA Matte",
- }
- ams_data = [{"id": 2, "tray": [tray]}]
- mock_client = MagicMock()
- status = _make_mock_status(ams_data=ams_data)
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
- mock_pm_main.get_status.return_value = status
- mock_pm_main.get_client.return_value = mock_client
- mock_pm_main.get_model.return_value = "H2D"
- mock_pm_inv.get_client.return_value = mock_client
- mock_pm_inv.get_status.return_value = status
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, ams_data)
- assert mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL96"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_on_ams_change_does_not_refire_for_already_configured_slot(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """Once fingerprint_type is set, subsequent AMS pushes must not re-fire MQTT."""
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- # Assignment already configured (fingerprint stamped)
- configured_assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=0,
- tray_id=0,
- fingerprint_color="FF0000FF",
- fingerprint_type="PLA",
- )
- db_session.add(configured_assignment)
- await db_session.commit()
- ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=ams_data)
- printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = printer_info
- mock_pm_main.get_status.return_value = status
- mock_pm_main.get_client.return_value = mock_client
- mock_pm_main.get_model.return_value = "X1C"
- mock_pm_inv.get_client.return_value = mock_client
- mock_pm_inv.get_status.return_value = status
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, ams_data)
- # Fingerprint was already set — re-fire path skipped
- mock_client.ams_set_filament_setting.assert_not_called()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay
- (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then
- configured the slot manually in Bambu Studio so tray_type went from ''
- to 'PLA' — but state stays at 3 because these firmwares never set it
- to 11. With state-only detection the replay never fired."""
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="A1 mini")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- pre_assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=0,
- tray_id=3,
- fingerprint_color=None,
- fingerprint_type=None,
- )
- db_session.add(pre_assignment)
- await db_session.commit()
- # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type
- # is now configured — the replay must fire on this transition too.
- ams_data = [
- {
- "id": 0,
- "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}],
- }
- ]
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=ams_data)
- printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999")
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = printer_info
- mock_pm_main.get_status.return_value = status
- mock_pm_main.get_client.return_value = mock_client
- mock_pm_main.get_model.return_value = "A1 mini"
- mock_pm_inv.get_client.return_value = mock_client
- mock_pm_inv.get_status.return_value = status
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, ams_data)
- # Replay fired despite state never being 11 — the disjunction picked
- # up tray_type going non-empty.
- mock_client.ams_set_filament_setting.assert_called_once()
- await db_session.refresh(pre_assignment)
- assert pre_assignment.fingerprint_type == "PLA"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_auto_unlink_broadcasts_assignment_change(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """#2575 follow-up: when on_ams_change auto-unlinks a stale external-spool
- assignment, it must broadcast spool_assignment_changed. Only the manual
- REST endpoints did, so open browsers kept rendering the unlinked spool —
- the reporter read that as "the fix didn't work" when the DB was correct."""
- from unittest.mock import AsyncMock
- from sqlalchemy import select
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament="GFU01", material="TPU")
- # TPU inventory spool assigned to the external slot (ams_id=255, tray 0)
- assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=255,
- tray_id=0,
- fingerprint_color="000000FF",
- fingerprint_type="TPU",
- )
- db_session.add(assignment)
- await db_session.commit()
- # The printer's external spool now reports ABS — the assignment is stale.
- vt_tray = [
- {
- "id": "254",
- "tray_type": "ABS",
- "tray_color": "000000FF",
- "tag_uid": "0000000000000000",
- "tray_uuid": "00000000000000000000000000000000",
- }
- ]
- status = _make_mock_status(ams_data=[], vt_tray=vt_tray)
- printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = printer_info
- mock_pm_main.get_status.return_value = status
- mock_pm_main.get_client.return_value = MagicMock()
- mock_pm_main.get_model.return_value = "X1C"
- mock_pm_inv.get_client.return_value = MagicMock()
- mock_pm_inv.get_status.return_value = status
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, [])
- # The stale TPU assignment on the now-ABS external slot was unlinked...
- gone = await db_session.execute(
- select(SpoolAssignment).where(
- SpoolAssignment.printer_id == printer.id,
- SpoolAssignment.ams_id == 255,
- SpoolAssignment.tray_id == 0,
- )
- )
- assert gone.scalar_one_or_none() is None
- # ...and the frontend was told about it.
- change_events = [
- c.args[0]
- for c in mock_ws.broadcast.await_args_list
- if c.args and isinstance(c.args[0], dict) and c.args[0].get("type") == "spool_assignment_changed"
- ]
- assert change_events, "auto-unlink must broadcast spool_assignment_changed"
- assert change_events[0]["printer_id"] == printer.id
- assert change_events[0]["ams_id"] == 255
- assert change_events[0]["tray_id"] == 0
- class TestAssignSpoolEmptyDetection:
- """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present
- but filament not in feeder. The assign route must prefer that signal over
- tray_type for the empty-vs-loaded check, because a manual "Reset slot"
- clears tray_type to "" while leaving filament physically loaded — the
- legacy heuristic would route to the pending-config path and skip MQTT
- forever, since on_ams_change replay only fires on an empty→loaded
- transition that never comes when the slot is already loaded.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_state_loaded_with_empty_tray_type_fires_mqtt(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Post-reset case: state=11 (loaded) but tray_type='' — MQTT must fire."""
- printer = await printer_factory()
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # Simulates the "reset slot" aftermath: filament physically loaded
- # (state=11) but tray_type/tray_color/tray_info_idx have been cleared.
- tray_data = {"id": 3, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- # MQTT must have fired — the bug was that legacy detection saw the
- # empty tray_type and skipped this entirely.
- mock_client.ams_set_filament_setting.assert_called_once()
- # Response must report configured=True, pending_config=False — the
- # slot is loaded, just had stale metadata cleared.
- body = response.json()
- assert body["pending_config"] is False
- assert body["configured"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_state_empty_skips_mqtt_and_marks_pending(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Genuinely empty slot: state=9 — MQTT skipped, pending_config=True."""
- printer = await printer_factory()
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- tray_data = {"id": 3, "state": 9, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- # SpoolBuddy weigh-then-assign workflow: firmware drops MQTT for
- # unloaded slots, so we don't bother sending it.
- mock_client.ams_set_filament_setting.assert_not_called()
- body = response.json()
- assert body["pending_config"] is True
- assert body["configured"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_state_missing_falls_back_to_tray_type_loaded(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Older firmware without state field: tray_type='PLA' → treated as loaded."""
- printer = await printer_factory()
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- # No 'state' key at all — older firmware behaviour.
- tray_data = {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF"}
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- # Legacy fallback: tray_type non-empty → treated as loaded → MQTT fires.
- mock_client.ams_set_filament_setting.assert_called_once()
- body = response.json()
- assert body["pending_config"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_state_missing_with_empty_tray_type_still_fires_mqtt(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """Older firmware without state field + empty tray_type still fires MQTT.
- The AMS doesn't tell us whether a spool is physically loaded in this
- case (no state, no tray_type), so the assign click is the user's
- assertion that a spool is there. Firmware silently drops the push on
- a truly empty slot — no harm done, and on_ams_change replay handles
- the deferred-config case (#1322 follow-up).
- """
- printer = await printer_factory()
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- tray_data = {"id": 3, "tray_type": "", "tray_color": ""}
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_called_once()
- body = response.json()
- assert body["pending_config"] is False
- assert body["configured"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always
- report tray.state=3, never 11 — even for fully-loaded configured slots.
- A state-only check classified those as empty and skipped MQTT (#1322).
- With the disjunctive check, tray_type='PLA' alone is enough to fire."""
- printer = await printer_factory()
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- # state=3, tray_type non-empty — A1 Mini / P1S configured slot.
- tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"}
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_called_once()
- body = response.json()
- assert body["pending_config"] is False
- assert body["configured"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_post_reset_slot_with_state_3_still_fires_mqtt(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """A1 Mini BMCU / P1S Standard AMS post-"Reset Slot" with spool still
- inserted: state=3, tray_type="". The AMS gives us no signal to tell
- this apart from a truly-empty slot. We trust the user's Assign click
- and fire MQTT — firmware accepts the push because a spool is
- physically there (#1322 follow-up by @RosdasHH).
- Replaces the previous "marks_pending" assertion which was the bug:
- that gate created a deadlock because the AMS would never report a
- state change (nothing physically changed), so on_ams_change replay
- never re-fired the deferred config either.
- """
- printer = await printer_factory()
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""}
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_called_once()
- body = response.json()
- assert body["pending_config"] is False
- assert body["configured"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_external_slot_state_loaded_with_empty_tray_type_fires_mqtt(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """External (vt_tray) slot post-reset: same fix applies for ams_id=255."""
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- # External slot tray_id=0 → vt_tray id=254. state=11 (loaded), tray_type
- # cleared by reset.
- vt_data = [{"id": 254, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}]
- status = _make_mock_status(ams_data=[], vt_tray=vt_data)
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 255, "tray_id": 0},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_called_once()
- body = response.json()
- assert body["pending_config"] is False
- assert body["configured"] is True
- class TestAssignSpoolPresenceBit:
- """#3084: the slot the firmware says is full, and the cache says is empty.
- ``apply_tray_exist_bits`` stamps ``state = 9`` on every slot whose
- ``tray_exist_bits`` bit is 0 and annotates ``exists`` on every slot it
- looks at — but when the bit comes back it only refreshes ``exists`` and
- leaves the 9 where it was. Swap a Bambu spool for a non-RFID one and the
- slot sits at ``exists=True, state=9`` until something configures it.
- Reported on an H2D/H2C AMS-HT: remove the Bambu spool (bits ``f``), insert
- a third-party one 9 seconds later (bits ``1000f``), then Assign Spool 28
- seconds after that — and no ``ams_filament_setting`` was published at all.
- Configure worked, because it publishes unconditionally.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_bit_overrules_a_stale_empty_state(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """exists=True with a leftover state=9 — MQTT must fire."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- tray_data = {"id": 0, "state": 9, "exists": True, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
- status = _make_mock_status(ams_data=[{"id": 128, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 128, "tray_id": 0},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_called_once()
- body = response.json()
- assert body["configured"] is True
- assert body["pending_config"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_empty_bit_does_not_start_suppressing_pushes(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """The bit overrules the 9 and nothing else.
- Reading it the other way too would be tidier — skip the push firmware
- is going to drop — but it also means a slot that silently stops
- configuring on whichever AMS variant we compute the bit position
- wrong for. The saving is one MQTT message; the failure is the bug
- this commit is fixing, inverted. So a state that does not say "empty"
- still publishes, exactly as it did before.
- """
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- tray_data = {"id": 3, "state": 11, "exists": False, "tray_type": "", "tray_color": ""}
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_called_once()
- assert response.json()["pending_config"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_pre_assign_workflow_still_skips_a_genuinely_empty_slot(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """SpoolBuddy weighs a spool and assigns it before it goes in. Bit
- clear and state 9 agree that the slot is empty, so the push is still
- deferred to the replay — unchanged."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- tray_data = {"id": 3, "state": 9, "exists": False, "tray_type": "", "tray_color": ""}
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_not_called()
- body = response.json()
- assert body["configured"] is False
- assert body["pending_config"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_unannotated_tray_still_reads_the_state(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """No presence bit in the payload → the 9/10 heuristic still decides.
- The external spool's ``vt_tray`` has no bit in the mask, and neither do
- the hand-built payloads every other test in this file uses.
- """
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "state": 9, "tray_type": ""}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- mock_client.ams_set_filament_setting.assert_not_called()
- assert response.json()["pending_config"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deferred_config_fires_for_a_spool_the_ams_cannot_name(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """The pre-assign workflow's half of the same bug.
- A non-RFID spool inserted into a pre-assigned slot brings no
- ``tray_type`` with it, and the stale 9 kept the replay's "loaded" test
- false, so the deferred configuration never fired for it either. The
- presence bit is the only thing in the payload that changed.
- """
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="GFL05", material="PLA")
- pre_assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=2,
- tray_id=3,
- fingerprint_color=None,
- fingerprint_type=None,
- )
- db_session.add(pre_assignment)
- await db_session.commit()
- ams_data = [{"id": 2, "tray": [{"id": 3, "state": 9, "exists": True, "tray_type": "", "tray_color": ""}]}]
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=ams_data)
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
- mock_pm_main.get_status.return_value = status
- mock_pm_main.get_client.return_value = mock_client
- mock_pm_main.get_model.return_value = "H2D"
- mock_pm_inv.get_client.return_value = mock_client
- mock_pm_inv.get_status.return_value = status
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, ams_data)
- mock_client.ams_set_filament_setting.assert_called_once()
- call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
- assert call_kwargs["ams_id"] == 2
- assert call_kwargs["tray_id"] == 3
- assert call_kwargs["tray_info_idx"] == "GFL05"
- # The assignment is still there — the pass that fires the config is the
- # same pass that deletes stale ones (#3100).
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, pre_assignment.id) is not None
- class TestAssignSpoolPfcnCloudPreset:
- """Assign path for PFCN-prefix cloud presets (#1648).
- PFCN is a third Bambu cloud preset shape alongside PFUS (cloud user-created)
- and GFS (Bambu official) — used for cloud-shared / partner-uploaded
- presets like Polymaker's "(Custom)" Bambu Lab H2D variants. Before #1648
- the assign path skipped the cloud-detail lookup and left the raw PFCN
- string in tray_info_idx, which the printer's calibration table can't
- resolve. ConfigureAmsSlotModal rescued each assignment by doing the lookup
- itself, making "Configure" feel like a mandatory follow-up step.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_pfcn_falls_back_to_generic_when_cloud_unavailable(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """When cloud auth isn't available (e.g. user not logged into Bambu Cloud),
- the raw PFCN must be discarded as slicer-invalid and the slot configures
- with the spool's generic material id (PLA → GFL99). Pre-fix behaviour
- was to leak the raw PFCN, which the slicer can't resolve."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- # PFCN never leaks into tray_info_idx — must resolve to the
- # generic-material fallback when cloud lookup couldn't.
- assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
- assert not call_kwargs.kwargs["tray_info_idx"].startswith("PFCN")
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_pfcn_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
- """Symmetry with the PFUS case: when the spool's PFCN is discarded as
- slicer-invalid, the slot's existing valid P-prefix preset is reused
- if material matches — preserves calibration context instead of
- resetting to generic."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(
- ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
- )
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_pfcn_resolves_to_filament_id_via_cloud_lookup(
- self, async_client: AsyncClient, printer_factory, spool_factory
- ):
- """When the user is authenticated against Bambu Cloud, the PFCN setting_id
- triggers the same cloud-detail lookup as PFUS / GFS — extracts the real
- filament_id from `detail["filament_id"]` and ships that as
- tray_info_idx. This is the happy path the Configure modal already had
- but the assign path didn't, #1648."""
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
- mock_client = MagicMock()
- mock_client.ams_set_filament_setting.return_value = True
- mock_client.extrusion_cali_sel.return_value = True
- status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
- # Cloud responds with a real filament_id for the PFCN preset — exactly
- # what the Configure modal already exploits.
- mock_cloud = MagicMock()
- mock_cloud.is_authenticated = True
- async def fake_get_detail(setting_id):
- assert setting_id == "PFCN80e80c1f79db85"
- return {"filament_id": "GFL05", "name": "Polymaker PLA Matte"}
- async def fake_close():
- return None
- mock_cloud.get_setting_detail = fake_get_detail
- mock_cloud.close = fake_close
- async def fake_build_cloud(_db, _user):
- return mock_cloud
- with (
- patch("backend.app.services.printer_manager.printer_manager") as mock_pm,
- patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=fake_build_cloud),
- ):
- mock_pm.get_client.return_value = mock_client
- mock_pm.get_status.return_value = status
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
- )
- assert response.status_code == 200
- call_kwargs = mock_client.ams_set_filament_setting.call_args
- # tray_info_idx is the resolved cloud filament_id; setting_id is the
- # original PFCN (which the slicer needs separately).
- assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
- assert call_kwargs.kwargs["setting_id"] == "PFCN80e80c1f79db85"
- def _make_printing_status(ams_data, state="RUNNING"):
- """Printer status carrying an explicit gcode state for the runout guard."""
- status = _make_mock_status(ams_data=ams_data)
- status.state = state
- return status
- class TestAutoUnlinkDuringRunout:
- """A slot that reports empty mid-print is a filament runout, not a spool
- swap — the spool is still in the AMS, just consumed.
- Unlinking there erased the only record of which spool fed the print, so the
- completion path had nothing to charge the runout segment to. With AMS
- filament backup that is the normal course of events, not an edge case.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_cleared_tray_data_keeps_the_assignment_while_printing(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(material="ABS", rgba="616777FF")
- assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=0,
- tray_id=2,
- fingerprint_color="616777FF",
- fingerprint_type="ABS",
- )
- db_session.add(assignment)
- await db_session.commit()
- # The firmware clears colour and type when it unloads a spool it just
- # emptied (state 26 = "not loaded").
- ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
- mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
- mock_pm_main.get_model.return_value = "H2D"
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, ams_data)
- # on_ams_change committed through its own session — drop this one's
- # identity map so the assertion reads the database, not a cached row.
- db_session.expunge_all()
- remaining = await db_session.get(SpoolAssignment, assignment.id)
- assert remaining is not None, "runout must not unlink the spool that fed the print"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_cleared_tray_data_still_unlinks_when_idle(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """Off the print, an emptied slot really does mean the spool is gone --
- once it has stayed empty for the grace period (#3186)."""
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.services import slot_unlink_grace
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(material="ABS", rgba="616777FF")
- assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=0,
- tray_id=2,
- fingerprint_color="616777FF",
- fingerprint_type="ABS",
- )
- db_session.add(assignment)
- await db_session.commit()
- assignment_id = assignment.id
- ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
- mock_pm_main.get_status.return_value = _make_printing_status(ams_data, state="IDLE")
- mock_pm_main.get_model.return_value = "H2D"
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- clock = [1000.0]
- with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
- await on_ams_change(printer.id, ams_data)
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is not None
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await on_ams_change(printer.id, ams_data)
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_genuinely_different_filament_still_unlinks_while_printing(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """The guard is for blank tray data only — a real swap must still
- reconcile, or the wrong spool gets charged."""
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(material="ABS", rgba="616777FF")
- assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=0,
- tray_id=2,
- fingerprint_color="616777FF",
- fingerprint_type="ABS",
- )
- db_session.add(assignment)
- await db_session.commit()
- assignment_id = assignment.id
- ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "PETG", "tray_color": "6EE53CFF", "state": 11}]}]
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
- mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
- mock_pm_main.get_model.return_value = "H2D"
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, ams_data)
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_slot_missing_from_ams_data_keeps_the_assignment_while_printing(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="H2D")
- spool = await spool_factory(material="ABS", rgba="616777FF")
- assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=0,
- tray_id=2,
- fingerprint_color="616777FF",
- fingerprint_type="ABS",
- )
- db_session.add(assignment)
- await db_session.commit()
- assignment_id = assignment.id
- # Tray 2 dropped out of the payload entirely.
- ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "ABS", "tray_color": "FFFFFFFF", "state": 11}]}]
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
- mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
- mock_pm_main.get_model.return_value = "H2D"
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer.id, ams_data)
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is not None
- class TestAutoUnlinkOccupiedSlot:
- """#3100: six saved assignments deleted across three X1 Carbons.
- Each one had an explicit earlier assignment and then an ``Auto-unlink ...
- fingerprint mismatch``, and the reporter recovered the mappings from logs
- because they were gone from the inventory API, not merely hidden. Two
- shapes, one cause: a slot the presence bit calls occupied while the tray
- reports nothing about what is in it.
- ``cur=/ fp=BCBCBCFF/PLA spool=8A8F92FF/PLA`` — an established assignment,
- a blank idle report, and the row deleted. The spool never went anywhere;
- the AMS simply had nothing to say about a filament it cannot read.
- """
- @staticmethod
- async def _run(printer_id, ams_data, status):
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_printer.return_value = MagicMock(name="X1C", serial_number="0948BB540200427")
- mock_pm_main.get_status.return_value = status
- mock_pm_main.get_model.return_value = "X1C"
- mock_pm_main.get_client.return_value = None
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer_id, ams_data)
- @staticmethod
- async def _assignment(db_session, printer, spool):
- from backend.app.models.spool_assignment import SpoolAssignment
- assignment = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=1,
- tray_id=1,
- fingerprint_color="BCBCBCFF",
- fingerprint_type="PLA",
- )
- db_session.add(assignment)
- await db_session.commit()
- return assignment.id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_blank_report_from_an_occupied_slot_keeps_the_assignment(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="8A8F92FF")
- assignment_id = await self._assignment(db_session, printer, spool)
- ams_data = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
- await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is not None, (
- "a spool the AMS cannot identify is not a spool that was removed"
- )
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_blank_report_from_an_empty_slot_still_unlinks(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """The guard reads the bit, not the blankness — take the spool out and
- the assignment still goes, which is what makes the test above a
- distinction rather than a blanket reprieve."""
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.services import slot_unlink_grace
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="8A8F92FF")
- assignment_id = await self._assignment(db_session, printer, spool)
- ams_data = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
- clock = [1000.0]
- with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
- await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_genuinely_different_filament_in_an_occupied_slot_still_unlinks(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """The guard is for a blank report only. A slot that names a filament
- which is not the assigned spool is a swap, bit set or not."""
- from backend.app.models.spool_assignment import SpoolAssignment
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="8A8F92FF")
- assignment_id = await self._assignment(db_session, printer, spool)
- ams_data = [
- {"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "PETG", "tray_color": "00FF00FF", "state": 11}]}
- ]
- await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is None
- class TestAutoUnlinkHeldForGrace:
- """#3186: one push is not proof a spool was taken out.
- An idle X1 Carbon on X1Plus sent a push that cleared a whole AMS unit --
- presence bits off, colour and type blank -- and four saved assignments were
- deleted in the same instant (``Auto-unlinked 4 stale spool assignments``).
- The spools never moved. A removal is now held for the grace period and
- unlinked only if the slot is still empty then.
- """
- @staticmethod
- async def _assign_unit(db_session, printer, spool):
- from backend.app.models.spool_assignment import SpoolAssignment
- ids = []
- for tray_id in range(4):
- a = SpoolAssignment(
- spool_id=spool.id,
- printer_id=printer.id,
- ams_id=1,
- tray_id=tray_id,
- fingerprint_color="858585FF",
- fingerprint_type="PLA",
- )
- db_session.add(a)
- await db_session.commit()
- ids.append(a.id)
- return ids
- @staticmethod
- def _unit(blank: bool):
- return [
- {
- "id": 1,
- "tray": [
- {"id": t, "exists": False, "tray_type": "", "tray_color": "", "state": 9}
- if blank
- else {"id": t, "exists": True, "tray_type": "PLA", "tray_color": "858585FF", "state": 11}
- for t in range(4)
- ],
- }
- ]
- @staticmethod
- async def _surviving(db_session, ids):
- from backend.app.models.spool_assignment import SpoolAssignment
- db_session.expunge_all()
- return [i for i in ids if await db_session.get(SpoolAssignment, i) is not None]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_whole_unit_blanking_for_one_push_keeps_every_assignment(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- from backend.app.services import slot_unlink_grace
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="000000FF")
- ids = await self._assign_unit(db_session, printer, spool)
- clock = [1000.0]
- with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
- blank, back = self._unit(blank=True), self._unit(blank=False)
- await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
- assert await self._surviving(db_session, ids) == ids
- clock[0] += 30
- await TestAutoUnlinkOccupiedSlot._run(printer.id, back, _make_printing_status(back, state="IDLE"))
- # The recovery dropped the holds, so a blank long after the first
- # one starts a fresh clock instead of confirming the old one.
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
- assert await self._surviving(db_session, ids) == ids
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_unit_missing_from_one_push_keeps_its_assignments(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="000000FF")
- ids = await self._assign_unit(db_session, printer, spool)
- # AMS 1 dropped out of the payload entirely.
- other_unit = [{"id": 0, "tray": [{"id": 0, "exists": True, "tray_type": "PLA", "tray_color": "FFFFFFFF"}]}]
- await TestAutoUnlinkOccupiedSlot._run(printer.id, other_unit, _make_printing_status(other_unit, state="IDLE"))
- assert await self._surviving(db_session, ids) == ids
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_slot_that_stays_empty_is_unlinked_by_the_recheck(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """No second push has to arrive: a spool taken out and left out never
- changes the AMS hash again, so the held removal re-checks itself."""
- from backend.app.main import _recheck_held_unlinks
- from backend.app.services import slot_unlink_grace
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="000000FF")
- ids = await self._assign_unit(db_session, printer, spool)
- blank = self._unit(blank=True)
- clock = [1000.0]
- with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
- await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
- assert printer.id in slot_unlink_grace._recheck_tasks, "a held removal must schedule its re-check"
- assert await self._surviving(db_session, ids) == ids
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_status.return_value = _make_printing_status(blank, state="IDLE")
- mock_ws.broadcast = AsyncMock()
- await _recheck_held_unlinks(printer.id)
- assert await self._surviving(db_session, ids) == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_recheck_leaves_a_slot_that_came_back(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- from backend.app.main import _recheck_held_unlinks
- from backend.app.services import slot_unlink_grace
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="000000FF")
- ids = await self._assign_unit(db_session, printer, spool)
- blank, back = self._unit(blank=True), self._unit(blank=False)
- clock = [1000.0]
- with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
- await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_pm_main.get_status.return_value = _make_printing_status(back, state="IDLE")
- mock_ws.broadcast = AsyncMock()
- await _recheck_held_unlinks(printer.id)
- assert await self._surviving(db_session, ids) == ids
- class TestAutoUnlinkSwapDuringGrace:
- """A non-RFID spool swapped out and another put in within the grace period.
- The new spool reports occupied-but-blank, which on its own keeps an
- assignment (#3100). After an *empty* report it means the spool changed,
- so the held removal has to keep running rather than be cancelled -- or the
- old spool would stay assigned to the new one indefinitely.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_swap_still_unlinks_once_the_grace_runs_out(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.services import slot_unlink_grace
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="8A8F92FF")
- assignment_id = await TestAutoUnlinkOccupiedSlot._assignment(db_session, printer, spool)
- taken_out = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
- new_spool = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
- clock = [1000.0]
- with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
- await TestAutoUnlinkOccupiedSlot._run(printer.id, taken_out, _make_printing_status(taken_out, state="IDLE"))
- clock[0] += 20
- await TestAutoUnlinkOccupiedSlot._run(printer.id, new_spool, _make_printing_status(new_spool, state="IDLE"))
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is not None
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await TestAutoUnlinkOccupiedSlot._run(printer.id, new_spool, _make_printing_status(new_spool, state="IDLE"))
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, assignment_id) is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_new_assignment_to_the_slot_starts_clean(
- self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
- ):
- """Assigning a spool to the slot after the swap -- even the same spool
- again -- makes a new row, and the old clock does not carry over to it."""
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.services import slot_unlink_grace
- printer = await printer_factory(name="X1C")
- spool = await spool_factory(material="PLA", rgba="8A8F92FF")
- old_id = await TestAutoUnlinkOccupiedSlot._assignment(db_session, printer, spool)
- taken_out = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
- back_in = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
- clock = [1000.0]
- with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
- await TestAutoUnlinkOccupiedSlot._run(printer.id, taken_out, _make_printing_status(taken_out, state="IDLE"))
- # The user puts the spool back and assigns it again through the
- # endpoint, which replaces the row -- on SQLite quite possibly under
- # the same id, so only dropping the hold keeps the old clock off it.
- hold_key = ("inventory", 1, 1, spool.id)
- assert slot_unlink_grace.is_held(printer.id, hold_key)
- with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
- mock_pm.get_client.return_value = None
- mock_pm.get_status.return_value = _make_mock_status(ams_data=back_in)
- response = await async_client.post(
- "/api/v1/inventory/assignments",
- json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 1, "tray_id": 1},
- )
- assert response.status_code == 200
- assert not slot_unlink_grace.is_held(printer.id, hold_key), "an assignment must drop the old hold"
- new_id = response.json()["id"]
- assert await db_session.get(SpoolAssignment, old_id) is None or new_id == old_id
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await TestAutoUnlinkOccupiedSlot._run(printer.id, back_in, _make_printing_status(back_in, state="IDLE"))
- db_session.expunge_all()
- assert await db_session.get(SpoolAssignment, new_id) is not None
- class TestSpoolmanSlotUnlinkHeldForGrace:
- """Spoolman mode's half of #3186: the slot row waits out the same grace."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_recheck_deletes_a_row_only_once_the_slot_stayed_empty(
- self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
- ):
- from backend.app.main import _expire_spoolman_empty_slots
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services import slot_unlink_grace
- await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
- printer = await printer_factory(name="H2D")
- row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
- db_session.add(row)
- await db_session.commit()
- row_id = row.id
- blank = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
- clock = [1000.0]
- with (
- patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_ws.broadcast = AsyncMock()
- await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
- mock_ws.broadcast.assert_awaited()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_swap_to_an_unreadable_spool_still_expires_the_row(
- self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
- ):
- from backend.app.main import _expire_spoolman_empty_slots
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services import slot_unlink_grace
- await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
- printer = await printer_factory(name="H2D")
- row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
- db_session.add(row)
- await db_session.commit()
- row_id = row.id
- taken_out = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
- new_spool = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
- clock = [1000.0]
- with (
- patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_ws.broadcast = AsyncMock()
- await _expire_spoolman_empty_slots(printer.id, taken_out, printing_now=False)
- clock[0] += 20
- await _expire_spoolman_empty_slots(printer.id, new_spool, printing_now=False)
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await _expire_spoolman_empty_slots(printer.id, new_spool, printing_now=False)
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_occupied_unreadable_slot_with_no_hold_keeps_its_row(
- self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
- ):
- """#3100 still holds for the re-check: never reported empty, never unlinked."""
- from backend.app.main import _expire_spoolman_empty_slots
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services import slot_unlink_grace
- await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
- printer = await printer_factory(name="H2D")
- row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
- db_session.add(row)
- await db_session.commit()
- row_id = row.id
- unreadable = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
- clock = [1000.0]
- with (
- patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_ws.broadcast = AsyncMock()
- for _ in range(3):
- await _expire_spoolman_empty_slots(printer.id, unreadable, printing_now=False)
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_recheck_keeps_a_row_whose_slot_reads_again(
- self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
- ):
- from backend.app.main import _expire_spoolman_empty_slots
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services import slot_unlink_grace
- await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
- printer = await printer_factory(name="H2D")
- row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
- db_session.add(row)
- await db_session.commit()
- row_id = row.id
- blank = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
- back = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "PLA", "tray_color": "858585FF"}]}]
- clock = [1000.0]
- with (
- patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
- patch("backend.app.main.ws_manager") as mock_ws,
- ):
- mock_ws.broadcast = AsyncMock()
- await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
- clock[0] += 30
- await _expire_spoolman_empty_slots(printer.id, back, printing_now=False)
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
- class TestSpoolmanSlotAssignmentDuringRunout:
- """`spoolman_slot_assignments` is how a tag-less spool assigned through the
- Bambuddy UI is resolved at completion (#1459). Deleting the row when a slot
- empties mid-print loses the runout segment's usage — the same failure the
- internal inventory's auto-unlink had, so it needs the same guard."""
- async def _enable_spoolman(self, db_session):
- from backend.app.models.settings import Settings
- for key, value in (
- ("spoolman_enabled", "true"),
- ("spoolman_sync_mode", "auto"),
- ("spoolman_url", "http://spoolman.test"),
- ):
- db_session.add(Settings(key=key, value=value))
- await db_session.commit()
- async def _run(self, printer_id, state, tray=None):
- from unittest.mock import AsyncMock
- from backend.app.main import on_ams_change
- # A tray the firmware has cleared: parse_ams_tray returns None, which
- # is what marks the slot empty for the cleanup pass.
- ams_data = [{"id": 0, "tray": [tray or {"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
- spoolman_client = MagicMock()
- spoolman_client.health_check = AsyncMock(return_value=True)
- spoolman_client.get_spools = AsyncMock(return_value=[])
- spoolman_client.sync_ams_tray = AsyncMock(return_value=None)
- # None is what marks the slot empty for the cleanup pass.
- spoolman_client.parse_ams_tray.return_value = None
- with (
- patch("backend.app.main.printer_manager") as mock_pm_main,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.ws_manager") as mock_ws,
- patch("backend.app.main.get_spoolman_client", new=AsyncMock(return_value=spoolman_client)),
- ):
- mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
- mock_pm_main.get_status.return_value = state
- mock_pm_main.get_model.return_value = "H2D"
- mock_relay.on_ams_change = AsyncMock()
- mock_ws.send_printer_status = AsyncMock()
- mock_ws.broadcast = AsyncMock()
- await on_ams_change(printer_id, ams_data)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_slot_row_survives_a_runout(
- self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
- ):
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- await self._enable_spoolman(db_session)
- printer = await printer_factory(name="H2D")
- row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
- db_session.add(row)
- await db_session.commit()
- row_id = row.id
- await self._run(printer.id, _make_printing_status(None))
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_occupied_slot_the_ams_cannot_read_keeps_its_row(
- self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
- ):
- """Spoolman mode's half of #3100.
- parse_ams_tray calls a tray with no type empty, and a tag-less spool
- has none until something configures it — so the row assigned through
- the UI was deleted by the first idle push after the spool went in.
- Firmware's presence bit is the same answer the built-in inventory
- uses, so the two modes stay in step.
- """
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- await self._enable_spoolman(db_session)
- printer = await printer_factory(name="H2D")
- row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
- db_session.add(row)
- await db_session.commit()
- row_id = row.id
- await self._run(
- printer.id,
- _make_printing_status(None, state="IDLE"),
- tray={"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9},
- )
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_slot_row_is_still_cleaned_up_when_idle(
- self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
- ):
- """Proves the guard is what saved the row above, not an unreachable
- code path -- once the slot has stayed empty for the grace period."""
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services import slot_unlink_grace
- await self._enable_spoolman(db_session)
- printer = await printer_factory(name="H2D")
- row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
- db_session.add(row)
- await db_session.commit()
- row_id = row.id
- clock = [1000.0]
- with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
- await self._run(printer.id, _make_printing_status(None, state="IDLE"))
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
- clock[0] += slot_unlink_grace.GRACE_SECONDS
- await self._run(printer.id, _make_printing_status(None, state="IDLE"))
- db_session.expunge_all()
- assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
|