test_usage_tracker.py 98 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533
  1. """Unit tests for usage_tracker.py — 3MF-primary filament tracking.
  2. Tests the unified tracking logic: 3MF slicer estimates as primary path,
  3. AMS remain% delta as fallback, per-layer gcode for partial prints,
  4. slot-to-tray mapping resolution, and notification variable formatting.
  5. """
  6. from datetime import datetime, timedelta, timezone
  7. from types import SimpleNamespace
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from backend.app.services.usage_tracker import (
  11. PrintSession,
  12. _active_sessions,
  13. _archive_types_from_spools,
  14. _decode_mqtt_mapping,
  15. _find_3mf_by_filename,
  16. _match_slots_by_color,
  17. _track_from_3mf,
  18. on_print_complete,
  19. on_print_start,
  20. )
  21. def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
  22. """Create a mock Spool object."""
  23. spool = MagicMock()
  24. spool.id = spool_id
  25. spool.label_weight = label_weight
  26. spool.weight_used = weight_used
  27. spool.tag_uid = tag_uid
  28. spool.tray_uuid = tray_uuid
  29. spool.last_used = None
  30. spool.cost_per_kg = None
  31. spool.material = "PLA"
  32. return spool
  33. def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):
  34. """Create a mock SpoolAssignment object."""
  35. assignment = MagicMock()
  36. assignment.spool_id = spool_id
  37. assignment.printer_id = printer_id
  38. assignment.ams_id = ams_id
  39. assignment.tray_id = tray_id
  40. return assignment
  41. def _make_archive(archive_id=1, file_path="archives/1/test.3mf", extra_data=None):
  42. """Create a mock PrintArchive object."""
  43. archive = MagicMock()
  44. archive.id = archive_id
  45. archive.file_path = file_path
  46. archive.extra_data = extra_data
  47. return archive
  48. def _make_queue_item(ams_mapping=None, status="printing", plate_id=None):
  49. """Create a mock PrintQueueItem object."""
  50. item = MagicMock()
  51. item.ams_mapping = ams_mapping
  52. item.status = status
  53. item.plate_id = plate_id
  54. return item
  55. def _mock_db_execute(*return_values):
  56. """Create a mock db with execute() that returns values in sequence."""
  57. db = AsyncMock()
  58. results = []
  59. for val in return_values:
  60. result = MagicMock()
  61. result.scalar_one_or_none.return_value = val
  62. results.append(result)
  63. db.execute = AsyncMock(side_effect=results)
  64. return db
  65. def _mock_db_sequential(responses):
  66. """Create mock db that returns responses in order."""
  67. db = AsyncMock()
  68. call_count = [0]
  69. async def mock_execute(*args, **kwargs):
  70. idx = call_count[0]
  71. call_count[0] += 1
  72. result = MagicMock()
  73. value = responses[idx] if idx < len(responses) else None
  74. result.scalar_one_or_none.return_value = value
  75. # Lookups that may legitimately match more than one row (an archive
  76. # dispatched as several queue items in a batch) use scalars().first()
  77. # instead of scalar_one_or_none() — same response sequence either way.
  78. result.scalars.return_value.first.return_value = value
  79. # For cost aggregation queries that use .scalar() instead of .scalar_one_or_none()
  80. result.scalar.return_value = None
  81. return result
  82. db.execute = mock_execute
  83. return db
  84. class TestOnPrintStart:
  85. """Tests for on_print_start()."""
  86. @pytest.fixture(autouse=True)
  87. def _clear_sessions(self):
  88. _active_sessions.clear()
  89. yield
  90. _active_sessions.clear()
  91. @pytest.mark.asyncio
  92. async def test_captures_remain_data(self):
  93. """Captures AMS remain% at print start."""
  94. printer_manager = MagicMock()
  95. printer_manager.get_status.return_value = SimpleNamespace(
  96. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]},
  97. tray_now=5,
  98. )
  99. await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager)
  100. assert 1 in _active_sessions
  101. session = _active_sessions[1]
  102. assert session.print_name == "Benchy"
  103. assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}
  104. @pytest.mark.asyncio
  105. async def test_captures_tray_now_at_start(self):
  106. """Captures tray_now at print start for later use in usage tracking."""
  107. printer_manager = MagicMock()
  108. printer_manager.get_status.return_value = SimpleNamespace(
  109. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  110. tray_now=9,
  111. )
  112. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  113. assert _active_sessions[1].tray_now_at_start == 9
  114. @pytest.mark.asyncio
  115. async def test_tray_now_at_start_255_when_unloaded(self):
  116. """Captures tray_now=255 when printer has no filament loaded at start."""
  117. printer_manager = MagicMock()
  118. printer_manager.get_status.return_value = SimpleNamespace(
  119. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  120. tray_now=255,
  121. )
  122. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  123. assert _active_sessions[1].tray_now_at_start == 255
  124. @pytest.mark.asyncio
  125. async def test_creates_session_without_remain(self):
  126. """Creates session even without valid remain data (for 3MF tracking)."""
  127. printer_manager = MagicMock()
  128. printer_manager.get_status.return_value = SimpleNamespace(
  129. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]},
  130. tray_now=255,
  131. )
  132. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  133. assert 1 in _active_sessions
  134. assert _active_sessions[1].tray_remain_start == {}
  135. class TestOnPrintComplete:
  136. """Tests for on_print_complete() — path ordering and interaction."""
  137. @pytest.fixture(autouse=True)
  138. def _clear_sessions(self):
  139. _active_sessions.clear()
  140. yield
  141. _active_sessions.clear()
  142. @pytest.fixture(autouse=True)
  143. def _mock_get_setting(self):
  144. with patch(
  145. "backend.app.api.routes.settings.get_setting",
  146. new_callable=AsyncMock,
  147. return_value=None,
  148. ):
  149. yield
  150. @pytest.mark.asyncio
  151. async def test_bl_spool_uses_3mf(self):
  152. """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
  153. spool = _make_spool(spool_id=1, tag_uid="AABB1122", label_weight=1000)
  154. assignment = _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0)
  155. archive = _make_archive(archive_id=10)
  156. # Setup: session with AMS remain data
  157. _active_sessions[1] = PrintSession(
  158. printer_id=1,
  159. print_name="Benchy",
  160. started_at=datetime.now(timezone.utc),
  161. tray_remain_start={(0, 0): 80},
  162. )
  163. # Mock printer state: tray_now=0 (AMS0-T0), single filament
  164. printer_manager = MagicMock()
  165. printer_manager.get_status.return_value = SimpleNamespace(
  166. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  167. progress=100,
  168. layer_num=50,
  169. tray_now=0,
  170. )
  171. # db returns: archive, queue_item(None), assignment, spool
  172. db = _mock_db_sequential([archive, None, assignment, spool])
  173. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  174. with (
  175. patch("backend.app.core.config.settings") as mock_settings,
  176. patch(
  177. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  178. return_value=filament_usage,
  179. ),
  180. ):
  181. mock_settings.base_dir = MagicMock()
  182. mock_path = MagicMock()
  183. mock_path.exists.return_value = True
  184. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  185. results = await on_print_complete(
  186. printer_id=1,
  187. data={"status": "completed"},
  188. printer_manager=printer_manager,
  189. db=db,
  190. archive_id=10,
  191. )
  192. # 3MF path should handle it (BL guard removed)
  193. assert len(results) >= 1
  194. assert results[0]["spool_id"] == 1
  195. assert results[0]["weight_used"] == 15.0
  196. @pytest.mark.asyncio
  197. async def test_ams_delta_fallback_no_archive(self):
  198. """AMS delta tracks consumption when archive_id is None."""
  199. spool = _make_spool(spool_id=2, label_weight=1000)
  200. assignment = _make_assignment(spool_id=2)
  201. _active_sessions[1] = PrintSession(
  202. printer_id=1,
  203. print_name="Test",
  204. started_at=datetime.now(timezone.utc),
  205. tray_remain_start={(0, 0): 80},
  206. )
  207. printer_manager = MagicMock()
  208. printer_manager.get_status.return_value = SimpleNamespace(
  209. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  210. tray_now=0,
  211. last_loaded_tray=-1,
  212. )
  213. # Pad 2 Nones for _find_3mf_by_filename DB queries (library + archive search),
  214. # then assignment and spool for the AMS fallback path
  215. db = _mock_db_sequential([None, None, assignment, spool])
  216. results = await on_print_complete(
  217. printer_id=1,
  218. data={"status": "completed"},
  219. printer_manager=printer_manager,
  220. db=db,
  221. archive_id=None,
  222. )
  223. assert len(results) == 1
  224. assert results[0]["spool_id"] == 2
  225. # 10% of 1000g = 100g
  226. assert results[0]["weight_used"] == 100.0
  227. assert results[0]["percent_used"] == 10
  228. @pytest.mark.asyncio
  229. async def test_no_double_tracking(self):
  230. """When 3MF handles a tray, AMS delta skips it."""
  231. spool = _make_spool(spool_id=1, label_weight=1000)
  232. assignment = _make_assignment(spool_id=1)
  233. archive = _make_archive(archive_id=10)
  234. _active_sessions[1] = PrintSession(
  235. printer_id=1,
  236. print_name="Benchy",
  237. started_at=datetime.now(timezone.utc),
  238. tray_remain_start={(0, 0): 80},
  239. )
  240. # tray_now=0 matches the single filament slot
  241. printer_manager = MagicMock()
  242. printer_manager.get_status.return_value = SimpleNamespace(
  243. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  244. progress=100,
  245. layer_num=50,
  246. tray_now=0,
  247. )
  248. # db returns: archive, queue_item(None), assignment, spool
  249. db = _mock_db_sequential([archive, None, assignment, spool])
  250. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  251. with (
  252. patch("backend.app.core.config.settings") as mock_settings,
  253. patch(
  254. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  255. return_value=filament_usage,
  256. ),
  257. ):
  258. mock_settings.base_dir = MagicMock()
  259. mock_path = MagicMock()
  260. mock_path.exists.return_value = True
  261. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  262. results = await on_print_complete(
  263. printer_id=1,
  264. data={"status": "completed"},
  265. printer_manager=printer_manager,
  266. db=db,
  267. archive_id=10,
  268. )
  269. # Only 1 result (3MF), NOT 2 (3MF + AMS delta)
  270. assert len(results) == 1
  271. assert results[0]["weight_used"] == 15.0
  272. class TestTrackFrom3mf:
  273. """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
  274. @pytest.mark.asyncio
  275. async def test_prefers_live_assignment_when_reassigned_mid_print(self):
  276. """If tray assignment changed during print, track usage on the new spool."""
  277. spool_old = _make_spool(spool_id=1, label_weight=1000)
  278. spool_new = _make_spool(spool_id=2, label_weight=1000)
  279. archive = _make_archive(archive_id=80)
  280. live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
  281. started_at = datetime.now(timezone.utc)
  282. live_assignment.created_at = started_at + timedelta(seconds=5)
  283. # db: archive, queue_item(None), live assignment lookup, spool_new lookup
  284. db = _mock_db_sequential([archive, None, live_assignment, spool_new])
  285. printer_manager = MagicMock()
  286. printer_manager.get_status.return_value = SimpleNamespace(
  287. progress=100,
  288. layer_num=50,
  289. tray_now=0,
  290. )
  291. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
  292. handled_trays: set[tuple[int, int]] = set()
  293. with (
  294. patch("backend.app.core.config.settings") as mock_settings,
  295. patch(
  296. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  297. return_value=filament_usage,
  298. ),
  299. ):
  300. mock_settings.base_dir = MagicMock()
  301. mock_path = MagicMock()
  302. mock_path.exists.return_value = True
  303. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  304. results = await _track_from_3mf(
  305. printer_id=1,
  306. archive_id=80,
  307. status="completed",
  308. print_name="MidPrintReassign",
  309. handled_trays=handled_trays,
  310. printer_manager=printer_manager,
  311. db=db,
  312. spool_assignments={(0, 0): spool_old.id},
  313. print_started_at=started_at,
  314. )
  315. assert len(results) == 1
  316. assert results[0]["spool_id"] == spool_new.id
  317. @pytest.mark.asyncio
  318. async def test_keeps_snapshot_when_live_assignment_predates_print(self):
  319. """If live assignment predates print start, preserve snapshot spool mapping."""
  320. spool_old = _make_spool(spool_id=1, label_weight=1000)
  321. archive = _make_archive(archive_id=81)
  322. live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
  323. started_at = datetime.now(timezone.utc)
  324. live_assignment.created_at = started_at - timedelta(seconds=5)
  325. # db: archive, queue_item(None), live assignment lookup, spool_old lookup
  326. db = _mock_db_sequential([archive, None, live_assignment, spool_old])
  327. printer_manager = MagicMock()
  328. printer_manager.get_status.return_value = SimpleNamespace(
  329. progress=100,
  330. layer_num=50,
  331. tray_now=0,
  332. )
  333. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
  334. handled_trays: set[tuple[int, int]] = set()
  335. with (
  336. patch("backend.app.core.config.settings") as mock_settings,
  337. patch(
  338. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  339. return_value=filament_usage,
  340. ),
  341. ):
  342. mock_settings.base_dir = MagicMock()
  343. mock_path = MagicMock()
  344. mock_path.exists.return_value = True
  345. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  346. results = await _track_from_3mf(
  347. printer_id=1,
  348. archive_id=81,
  349. status="completed",
  350. print_name="SnapshotPreserved",
  351. handled_trays=handled_trays,
  352. printer_manager=printer_manager,
  353. db=db,
  354. spool_assignments={(0, 0): spool_old.id},
  355. print_started_at=started_at,
  356. )
  357. assert len(results) == 1
  358. assert results[0]["spool_id"] == spool_old.id
  359. @pytest.mark.asyncio
  360. async def test_linear_fallback_for_partial_print(self):
  361. """Falls back to linear scaling when gcode layer data unavailable."""
  362. spool = _make_spool(spool_id=1, label_weight=1000)
  363. assignment = _make_assignment(spool_id=1)
  364. archive = _make_archive(archive_id=10)
  365. # db: archive, queue_item(None), assignment, spool
  366. db = _mock_db_sequential([archive, None, assignment, spool])
  367. printer_manager = MagicMock()
  368. printer_manager.get_status.return_value = SimpleNamespace(
  369. progress=50,
  370. layer_num=25,
  371. tray_now=0,
  372. )
  373. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  374. handled_trays: set[tuple[int, int]] = set()
  375. with (
  376. patch("backend.app.core.config.settings") as mock_settings,
  377. patch(
  378. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  379. return_value=filament_usage,
  380. ),
  381. patch(
  382. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  383. return_value=None, # No layer data available
  384. ),
  385. ):
  386. mock_settings.base_dir = MagicMock()
  387. mock_path = MagicMock()
  388. mock_path.exists.return_value = True
  389. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  390. results = await _track_from_3mf(
  391. printer_id=1,
  392. archive_id=10,
  393. status="failed",
  394. print_name="Benchy",
  395. handled_trays=handled_trays,
  396. printer_manager=printer_manager,
  397. db=db,
  398. )
  399. assert len(results) == 1
  400. # 50% of 20g = 10g
  401. assert results[0]["weight_used"] == 10.0
  402. # Tray should be marked as handled
  403. assert (0, 0) in handled_trays
  404. @pytest.mark.asyncio
  405. async def test_per_layer_partial_print(self):
  406. """Failed print at layer N uses gcode cumulative data."""
  407. spool = _make_spool(spool_id=1, label_weight=1000)
  408. assignment = _make_assignment(spool_id=1)
  409. archive = _make_archive(archive_id=10)
  410. # db: archive, queue_item(None), assignment, spool
  411. db = _mock_db_sequential([archive, None, assignment, spool])
  412. printer_manager = MagicMock()
  413. printer_manager.get_status.return_value = SimpleNamespace(
  414. progress=50,
  415. layer_num=25,
  416. tray_now=0,
  417. )
  418. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  419. # Per-layer data: at layer 25, filament 0 used 5000mm
  420. layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}}
  421. filament_props = {1: {"density": 1.24, "diameter": 1.75}}
  422. handled_trays: set[tuple[int, int]] = set()
  423. with (
  424. patch("backend.app.core.config.settings") as mock_settings,
  425. patch(
  426. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  427. return_value=filament_usage,
  428. ),
  429. patch(
  430. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  431. return_value=layer_data,
  432. ),
  433. patch(
  434. "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
  435. return_value={0: 5000.0},
  436. ),
  437. patch(
  438. "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
  439. return_value=filament_props,
  440. ),
  441. patch(
  442. "backend.app.utils.threemf_tools.mm_to_grams",
  443. return_value=12.0, # 5000mm at 1.75mm/1.24g/cm3
  444. ),
  445. ):
  446. mock_settings.base_dir = MagicMock()
  447. mock_path = MagicMock()
  448. mock_path.exists.return_value = True
  449. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  450. results = await _track_from_3mf(
  451. printer_id=1,
  452. archive_id=10,
  453. status="failed",
  454. print_name="Benchy",
  455. handled_trays=handled_trays,
  456. printer_manager=printer_manager,
  457. db=db,
  458. )
  459. assert len(results) == 1
  460. # Should use per-layer grams (12.0g), not linear scale (10.0g)
  461. assert results[0]["weight_used"] == 12.0
  462. @pytest.mark.asyncio
  463. async def test_completed_print_uses_full_weight(self):
  464. """Completed print uses full 3MF weight (scale=1.0)."""
  465. spool = _make_spool(spool_id=1, label_weight=1000)
  466. assignment = _make_assignment(spool_id=1)
  467. archive = _make_archive(archive_id=10)
  468. # db: archive, queue_item(None), assignment, spool
  469. db = _mock_db_sequential([archive, None, assignment, spool])
  470. printer_manager = MagicMock()
  471. printer_manager.get_status.return_value = SimpleNamespace(
  472. progress=100,
  473. layer_num=50,
  474. tray_now=0,
  475. )
  476. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  477. handled_trays: set[tuple[int, int]] = set()
  478. with (
  479. patch("backend.app.core.config.settings") as mock_settings,
  480. patch(
  481. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  482. return_value=filament_usage,
  483. ),
  484. ):
  485. mock_settings.base_dir = MagicMock()
  486. mock_path = MagicMock()
  487. mock_path.exists.return_value = True
  488. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  489. results = await _track_from_3mf(
  490. printer_id=1,
  491. archive_id=10,
  492. status="completed",
  493. print_name="Benchy",
  494. handled_trays=handled_trays,
  495. printer_manager=printer_manager,
  496. db=db,
  497. )
  498. assert len(results) == 1
  499. assert results[0]["weight_used"] == 20.0
  500. @pytest.mark.asyncio
  501. async def test_tray_now_override_for_single_filament(self):
  502. """Single-filament non-queue print uses tray_now instead of slot_id mapping."""
  503. # Spool 2 is at AMS1-T3 (global_tray_id=7)
  504. spool = _make_spool(spool_id=2, label_weight=1000)
  505. assignment = _make_assignment(spool_id=2, ams_id=1, tray_id=3)
  506. archive = _make_archive(archive_id=10)
  507. # db: archive, queue_item(None), assignment, spool
  508. db = _mock_db_sequential([archive, None, assignment, spool])
  509. # tray_now=7 = (ams_id=1, tray_id=3), the ACTUAL tray used
  510. printer_manager = MagicMock()
  511. printer_manager.get_status.return_value = SimpleNamespace(
  512. progress=100,
  513. layer_num=50,
  514. tray_now=7,
  515. )
  516. # 3MF has slot_id=12 (would default-map to ams_id=2, tray_id=3 — WRONG)
  517. filament_usage = [{"slot_id": 12, "used_g": 10.6, "type": "PLA", "color": "#FF0000"}]
  518. handled_trays: set[tuple[int, int]] = set()
  519. with (
  520. patch("backend.app.core.config.settings") as mock_settings,
  521. patch(
  522. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  523. return_value=filament_usage,
  524. ),
  525. ):
  526. mock_settings.base_dir = MagicMock()
  527. mock_path = MagicMock()
  528. mock_path.exists.return_value = True
  529. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  530. results = await _track_from_3mf(
  531. printer_id=1,
  532. archive_id=10,
  533. status="completed",
  534. print_name="Test",
  535. handled_trays=handled_trays,
  536. printer_manager=printer_manager,
  537. db=db,
  538. )
  539. assert len(results) == 1
  540. assert results[0]["spool_id"] == 2
  541. assert results[0]["ams_id"] == 1
  542. assert results[0]["tray_id"] == 3
  543. assert results[0]["weight_used"] == 10.6
  544. assert (1, 3) in handled_trays
  545. @pytest.mark.asyncio
  546. async def test_queue_ams_mapping_overrides_default(self):
  547. """Queue item ams_mapping overrides default slot_id mapping."""
  548. # Spool at AMS1-T3 (global_tray_id=7)
  549. spool = _make_spool(spool_id=5, label_weight=1000)
  550. assignment = _make_assignment(spool_id=5, ams_id=1, tray_id=3)
  551. archive = _make_archive(archive_id=20)
  552. # Queue item maps slot 1 → global tray 7 (ams_id=1, tray_id=3)
  553. queue_item = _make_queue_item(ams_mapping="[7, -1, -1, -1]")
  554. # db: archive, queue_item, assignment, spool
  555. db = _mock_db_sequential([archive, queue_item, assignment, spool])
  556. printer_manager = MagicMock()
  557. printer_manager.get_status.return_value = SimpleNamespace(
  558. progress=100,
  559. layer_num=50,
  560. tray_now=7,
  561. )
  562. filament_usage = [{"slot_id": 1, "used_g": 25.0, "type": "PETG", "color": ""}]
  563. handled_trays: set[tuple[int, int]] = set()
  564. with (
  565. patch("backend.app.core.config.settings") as mock_settings,
  566. patch(
  567. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  568. return_value=filament_usage,
  569. ),
  570. ):
  571. mock_settings.base_dir = MagicMock()
  572. mock_path = MagicMock()
  573. mock_path.exists.return_value = True
  574. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  575. results = await _track_from_3mf(
  576. printer_id=1,
  577. archive_id=20,
  578. status="completed",
  579. print_name="Queue Print",
  580. handled_trays=handled_trays,
  581. printer_manager=printer_manager,
  582. db=db,
  583. )
  584. assert len(results) == 1
  585. assert results[0]["spool_id"] == 5
  586. assert results[0]["ams_id"] == 1
  587. assert results[0]["tray_id"] == 3
  588. assert results[0]["weight_used"] == 25.0
  589. @pytest.mark.asyncio
  590. async def test_multi_filament_uses_queue_mapping(self):
  591. """Multi-filament queue prints use ams_mapping for each slot."""
  592. spool_a = _make_spool(spool_id=1, label_weight=1000)
  593. spool_b = _make_spool(spool_id=2, label_weight=1000)
  594. assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  595. assign_b = _make_assignment(spool_id=2, ams_id=1, tray_id=2)
  596. archive = _make_archive(archive_id=30)
  597. # slot 1 → tray 0 (AMS0-T0), slot 2 → tray 6 (AMS1-T2)
  598. queue_item = _make_queue_item(ams_mapping="[0, 6]")
  599. # db: archive, queue_item, assign_a, spool_a, assign_b, spool_b
  600. db = _mock_db_sequential([archive, queue_item, assign_a, spool_a, assign_b, spool_b])
  601. printer_manager = MagicMock()
  602. printer_manager.get_status.return_value = SimpleNamespace(
  603. progress=100,
  604. layer_num=50,
  605. tray_now=6,
  606. )
  607. filament_usage = [
  608. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
  609. {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
  610. ]
  611. handled_trays: set[tuple[int, int]] = set()
  612. with (
  613. patch("backend.app.core.config.settings") as mock_settings,
  614. patch(
  615. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  616. return_value=filament_usage,
  617. ),
  618. ):
  619. mock_settings.base_dir = MagicMock()
  620. mock_path = MagicMock()
  621. mock_path.exists.return_value = True
  622. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  623. results = await _track_from_3mf(
  624. printer_id=1,
  625. archive_id=30,
  626. status="completed",
  627. print_name="Multi",
  628. handled_trays=handled_trays,
  629. printer_manager=printer_manager,
  630. db=db,
  631. )
  632. assert len(results) == 2
  633. assert results[0]["spool_id"] == 1
  634. assert results[0]["ams_id"] == 0
  635. assert results[0]["tray_id"] == 0
  636. assert results[0]["weight_used"] == 10.0
  637. assert results[1]["spool_id"] == 2
  638. assert results[1]["ams_id"] == 1
  639. assert results[1]["tray_id"] == 2
  640. assert results[1]["weight_used"] == 5.0
  641. @pytest.mark.asyncio
  642. async def test_no_tray_now_override_for_multi_filament(self):
  643. """Multi-filament non-queue prints fall back to default mapping, not tray_now."""
  644. spool = _make_spool(spool_id=1, label_weight=1000)
  645. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  646. archive = _make_archive(archive_id=10)
  647. # db: archive, queue_item(None), assignment, spool (2nd slot has no assignment)
  648. db = _mock_db_sequential([archive, None, assignment, spool, None])
  649. printer_manager = MagicMock()
  650. printer_manager.get_status.return_value = SimpleNamespace(
  651. progress=100,
  652. layer_num=50,
  653. tray_now=4, # tray_now won't be used
  654. )
  655. # Two filament slots with usage
  656. filament_usage = [
  657. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
  658. {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
  659. ]
  660. handled_trays: set[tuple[int, int]] = set()
  661. with (
  662. patch("backend.app.core.config.settings") as mock_settings,
  663. patch(
  664. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  665. return_value=filament_usage,
  666. ),
  667. ):
  668. mock_settings.base_dir = MagicMock()
  669. mock_path = MagicMock()
  670. mock_path.exists.return_value = True
  671. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  672. results = await _track_from_3mf(
  673. printer_id=1,
  674. archive_id=10,
  675. status="completed",
  676. print_name="Test",
  677. handled_trays=handled_trays,
  678. printer_manager=printer_manager,
  679. db=db,
  680. )
  681. # Should use default mapping (slot 1 → tray 0, slot 2 → tray 1)
  682. assert len(results) == 1 # Only slot 1 has assignment
  683. assert results[0]["ams_id"] == 0
  684. assert results[0]["tray_id"] == 0
  685. @pytest.mark.asyncio
  686. async def test_stored_ams_mapping_overrides_all(self):
  687. """Stored ams_mapping from print command takes priority over queue and tray_now."""
  688. # Spool at AMS2-T1 (global_tray_id=9)
  689. spool = _make_spool(spool_id=10, label_weight=1000)
  690. assignment = _make_assignment(spool_id=10, ams_id=2, tray_id=1)
  691. archive = _make_archive(archive_id=50)
  692. # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
  693. db = _mock_db_sequential([archive, assignment, spool])
  694. printer_manager = MagicMock()
  695. printer_manager.get_status.return_value = SimpleNamespace(
  696. progress=100,
  697. layer_num=50,
  698. tray_now=0, # Different from mapped tray — should be ignored
  699. last_loaded_tray=0,
  700. )
  701. filament_usage = [{"slot_id": 2, "used_g": 1.57, "type": "PLA", "color": "#FFFFFF"}]
  702. handled_trays: set[tuple[int, int]] = set()
  703. with (
  704. patch("backend.app.core.config.settings") as mock_settings,
  705. patch(
  706. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  707. return_value=filament_usage,
  708. ),
  709. ):
  710. mock_settings.base_dir = MagicMock()
  711. mock_path = MagicMock()
  712. mock_path.exists.return_value = True
  713. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  714. # ams_mapping: slot 2 (index 1) -> tray 9 (AMS2-T1)
  715. results = await _track_from_3mf(
  716. printer_id=1,
  717. archive_id=50,
  718. status="completed",
  719. print_name="Test",
  720. handled_trays=handled_trays,
  721. printer_manager=printer_manager,
  722. db=db,
  723. ams_mapping=[-1, 9],
  724. )
  725. assert len(results) == 1
  726. assert results[0]["spool_id"] == 10
  727. assert results[0]["ams_id"] == 2
  728. assert results[0]["tray_id"] == 1
  729. assert results[0]["weight_used"] == 1.6 # rounded
  730. @pytest.mark.asyncio
  731. async def test_last_loaded_tray_fallback(self):
  732. """Falls back to last_loaded_tray when tray_now_at_start and current tray_now are both 255."""
  733. # Spool at AMS2-T1 (global_tray_id=9)
  734. spool = _make_spool(spool_id=11, label_weight=1000)
  735. assignment = _make_assignment(spool_id=11, ams_id=2, tray_id=1)
  736. archive = _make_archive(archive_id=60)
  737. # db: archive, queue_item(None), assignment, spool
  738. db = _mock_db_sequential([archive, None, assignment, spool])
  739. # H2D scenario: tray_now=255 at completion, but last_loaded_tray=9
  740. printer_manager = MagicMock()
  741. printer_manager.get_status.return_value = SimpleNamespace(
  742. progress=100,
  743. layer_num=50,
  744. tray_now=255,
  745. last_loaded_tray=9,
  746. )
  747. filament_usage = [{"slot_id": 6, "used_g": 1.52, "type": "PLA", "color": "#7CC4D5"}]
  748. handled_trays: set[tuple[int, int]] = set()
  749. with (
  750. patch("backend.app.core.config.settings") as mock_settings,
  751. patch(
  752. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  753. return_value=filament_usage,
  754. ),
  755. ):
  756. mock_settings.base_dir = MagicMock()
  757. mock_path = MagicMock()
  758. mock_path.exists.return_value = True
  759. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  760. results = await _track_from_3mf(
  761. printer_id=1,
  762. archive_id=60,
  763. status="completed",
  764. print_name="Cube",
  765. handled_trays=handled_trays,
  766. printer_manager=printer_manager,
  767. db=db,
  768. tray_now_at_start=255, # H2D: 255 at start too
  769. )
  770. assert len(results) == 1
  771. assert results[0]["spool_id"] == 11
  772. assert results[0]["ams_id"] == 2
  773. assert results[0]["tray_id"] == 1
  774. @pytest.mark.asyncio
  775. async def test_tray_now_at_start_preferred_over_last_loaded(self):
  776. """tray_now_at_start is used before last_loaded_tray fallback."""
  777. spool = _make_spool(spool_id=3, label_weight=1000)
  778. assignment = _make_assignment(spool_id=3, ams_id=1, tray_id=1)
  779. archive = _make_archive(archive_id=70)
  780. db = _mock_db_sequential([archive, None, assignment, spool])
  781. # tray_now_at_start=5 (valid), last_loaded_tray=9 (different) — should use 5
  782. printer_manager = MagicMock()
  783. printer_manager.get_status.return_value = SimpleNamespace(
  784. progress=100,
  785. layer_num=50,
  786. tray_now=255,
  787. last_loaded_tray=9,
  788. )
  789. filament_usage = [{"slot_id": 1, "used_g": 5.0, "type": "PLA", "color": ""}]
  790. handled_trays: set[tuple[int, int]] = set()
  791. with (
  792. patch("backend.app.core.config.settings") as mock_settings,
  793. patch(
  794. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  795. return_value=filament_usage,
  796. ),
  797. ):
  798. mock_settings.base_dir = MagicMock()
  799. mock_path = MagicMock()
  800. mock_path.exists.return_value = True
  801. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  802. results = await _track_from_3mf(
  803. printer_id=1,
  804. archive_id=70,
  805. status="completed",
  806. print_name="Test",
  807. handled_trays=handled_trays,
  808. printer_manager=printer_manager,
  809. db=db,
  810. tray_now_at_start=5, # AMS1-T1
  811. )
  812. assert len(results) == 1
  813. assert results[0]["ams_id"] == 1
  814. assert results[0]["tray_id"] == 1
  815. class TestTrayChangeSplit:
  816. """Tests for mid-print tray switch weight splitting in _track_from_3mf()."""
  817. @pytest.mark.asyncio
  818. async def test_tray_switch_splits_weight_with_gcode(self):
  819. """Two-tray runout: weight split using per-layer gcode data."""
  820. spool_a = _make_spool(spool_id=10, label_weight=1000)
  821. spool_b = _make_spool(spool_id=20, label_weight=1000)
  822. assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=1)
  823. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=0)
  824. archive = _make_archive(archive_id=100)
  825. # db: archive, queue_item(None), then for each segment: assignment, spool
  826. db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
  827. # Tray change log: started on tray 1, switched to tray 0 at layer 60
  828. printer_manager = MagicMock()
  829. printer_manager.get_status.return_value = SimpleNamespace(
  830. progress=100,
  831. layer_num=100,
  832. tray_now=0,
  833. last_loaded_tray=0,
  834. total_layers=100,
  835. tray_change_log=[(1, 0), (0, 60)],
  836. )
  837. filament_usage = [{"slot_id": 1, "used_g": 30.0, "type": "PLA", "color": ""}]
  838. handled_trays: set[tuple[int, int]] = set()
  839. with (
  840. patch("backend.app.core.config.settings") as mock_settings,
  841. patch(
  842. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  843. return_value=filament_usage,
  844. ),
  845. patch(
  846. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  847. return_value={30: {0: 3000.0}, 60: {0: 6000.0}, 100: {0: 10000.0}},
  848. ),
  849. patch(
  850. "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
  851. side_effect=lambda data, layer: {0: {0: 0.0, 60: 6000.0, 100: 10000.0}.get(layer, 0.0)},
  852. ),
  853. patch(
  854. "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
  855. return_value={1: {"density": 1.24, "diameter": 1.75}},
  856. ),
  857. patch(
  858. "backend.app.utils.threemf_tools.mm_to_grams",
  859. side_effect=lambda mm, d, dens: round(mm * 0.003, 1), # Simple conversion
  860. ),
  861. ):
  862. mock_settings.base_dir = MagicMock()
  863. mock_path = MagicMock()
  864. mock_path.exists.return_value = True
  865. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  866. results = await _track_from_3mf(
  867. printer_id=1,
  868. archive_id=100,
  869. status="completed",
  870. print_name="Runout Test",
  871. handled_trays=handled_trays,
  872. printer_manager=printer_manager,
  873. db=db,
  874. )
  875. # Two results: one per tray segment
  876. assert len(results) == 2
  877. # First segment: tray 1 (AMS0-T1), layers 0→60
  878. assert results[0]["ams_id"] == 0
  879. assert results[0]["tray_id"] == 1
  880. assert results[0]["spool_id"] == 10
  881. assert results[0]["weight_used"] == 18.0 # 6000mm * 0.003
  882. # Second segment: tray 0 (AMS0-T0), layers 60→end = 30.0 - 18.0 = 12.0
  883. assert results[1]["ams_id"] == 0
  884. assert results[1]["tray_id"] == 0
  885. assert results[1]["spool_id"] == 20
  886. assert results[1]["weight_used"] == 12.0
  887. # Both trays handled
  888. assert (0, 1) in handled_trays
  889. assert (0, 0) in handled_trays
  890. @pytest.mark.asyncio
  891. async def test_tray_switch_linear_fallback(self):
  892. """Two-tray runout without per-layer gcode: linear split by layer ratio."""
  893. spool_a = _make_spool(spool_id=10, label_weight=1000)
  894. spool_b = _make_spool(spool_id=20, label_weight=1000)
  895. assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=2)
  896. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)
  897. archive = _make_archive(archive_id=101)
  898. db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
  899. # Tray 2 from layer 0, switched to tray 1 at layer 40 (of 100 total)
  900. printer_manager = MagicMock()
  901. printer_manager.get_status.return_value = SimpleNamespace(
  902. progress=100,
  903. layer_num=100,
  904. tray_now=1,
  905. last_loaded_tray=1,
  906. total_layers=100,
  907. tray_change_log=[(2, 0), (1, 40)],
  908. )
  909. filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
  910. handled_trays: set[tuple[int, int]] = set()
  911. with (
  912. patch("backend.app.core.config.settings") as mock_settings,
  913. patch(
  914. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  915. return_value=filament_usage,
  916. ),
  917. patch(
  918. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  919. return_value=None, # No per-layer gcode available
  920. ),
  921. ):
  922. mock_settings.base_dir = MagicMock()
  923. mock_path = MagicMock()
  924. mock_path.exists.return_value = True
  925. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  926. results = await _track_from_3mf(
  927. printer_id=1,
  928. archive_id=101,
  929. status="completed",
  930. print_name="Linear Fallback",
  931. handled_trays=handled_trays,
  932. printer_manager=printer_manager,
  933. db=db,
  934. )
  935. assert len(results) == 2
  936. # Linear split: tray 2 for 40/100 layers = 20g
  937. assert results[0]["ams_id"] == 0
  938. assert results[0]["tray_id"] == 2
  939. assert results[0]["weight_used"] == 20.0
  940. # Last segment gets remainder: 50 - 20 = 30g
  941. assert results[1]["ams_id"] == 0
  942. assert results[1]["tray_id"] == 1
  943. assert results[1]["weight_used"] == 30.0
  944. @pytest.mark.asyncio
  945. async def test_tray_switch_overrides_print_cmd_mapping(self):
  946. """tray_change_log evidence overrides slot_to_tray captured at print start.
  947. Regression for #957: when AMS auto-falls-back from an empty spool to a
  948. same-material sibling, the print_cmd's mapping (which named the
  949. original tray) is stale by the time the print finishes. Before this
  950. fix, the splitting branch was gated on ``not slot_to_tray`` so the
  951. slicer mapping was preferred even when the printer actually fed from
  952. a different tray — Path 1 credited the original tray with the full
  953. 3MF estimate and Path 2 layered the AMS-fallback delta on top, so
  954. spool consumption double-counted (e.g. 78 g print credited as 78 g
  955. + 60 g = 138 g). This test pins the new behavior: when
  956. tray_change_log has > 1 entries, splitting takes over regardless of
  957. whether ams_mapping was provided.
  958. """
  959. spool_a = _make_spool(spool_id=10, label_weight=1000)
  960. spool_b = _make_spool(spool_id=20, label_weight=1000)
  961. assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=0)
  962. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)
  963. archive = _make_archive(archive_id=200)
  964. # No queue_item placeholder: passing ams_mapping bypasses the queue lookup
  965. # at usage_tracker.py:816 (`if not slot_to_tray and archive_id`).
  966. db = _mock_db_sequential([archive, assign_a, spool_a, assign_b, spool_b])
  967. # Slicer mapping says slot 1 -> tray 0; printer actually swapped to tray 1 at layer 30
  968. printer_manager = MagicMock()
  969. printer_manager.get_status.return_value = SimpleNamespace(
  970. progress=100,
  971. layer_num=100,
  972. tray_now=1,
  973. last_loaded_tray=1,
  974. total_layers=100,
  975. tray_change_log=[(0, 0), (1, 30)],
  976. )
  977. filament_usage = [{"slot_id": 1, "used_g": 78.0, "type": "PLA", "color": ""}]
  978. handled_trays: set[tuple[int, int]] = set()
  979. with (
  980. patch("backend.app.core.config.settings") as mock_settings,
  981. patch(
  982. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  983. return_value=filament_usage,
  984. ),
  985. patch(
  986. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  987. return_value=None, # No per-layer data — exercises linear fallback
  988. ),
  989. ):
  990. mock_settings.base_dir = MagicMock()
  991. mock_path = MagicMock()
  992. mock_path.exists.return_value = True
  993. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  994. results = await _track_from_3mf(
  995. printer_id=1,
  996. archive_id=200,
  997. status="completed",
  998. print_name="Runout w/ slicer mapping",
  999. handled_trays=handled_trays,
  1000. printer_manager=printer_manager,
  1001. db=db,
  1002. # ams_mapping captured at print start — slicer told us slot 0 -> tray 0
  1003. # (1-based slot_id=1 -> 0-based slot index 0).
  1004. ams_mapping=[0],
  1005. )
  1006. # Splitting branch ran despite ams_mapping being set: two segments,
  1007. # one per tray, total weight matches the 3MF estimate (no double-count).
  1008. assert len(results) == 2
  1009. total = sum(r["weight_used"] for r in results)
  1010. assert total == pytest.approx(78.0, abs=0.1)
  1011. # Both trays now in handled_trays so Path 2 (remain%-delta) skips them.
  1012. assert (0, 0) in handled_trays
  1013. assert (0, 1) in handled_trays
  1014. @pytest.mark.asyncio
  1015. async def test_no_tray_change_uses_normal_path(self):
  1016. """Single-entry tray_change_log falls through to normal tray_now_at_start logic."""
  1017. spool = _make_spool(spool_id=1, label_weight=1000)
  1018. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
  1019. archive = _make_archive(archive_id=102)
  1020. db = _mock_db_sequential([archive, None, assignment, spool])
  1021. # Only one entry = no switch, should use normal path
  1022. printer_manager = MagicMock()
  1023. printer_manager.get_status.return_value = SimpleNamespace(
  1024. progress=100,
  1025. layer_num=100,
  1026. tray_now=2,
  1027. last_loaded_tray=2,
  1028. total_layers=100,
  1029. tray_change_log=[(2, 0)],
  1030. )
  1031. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": ""}]
  1032. handled_trays: set[tuple[int, int]] = set()
  1033. with (
  1034. patch("backend.app.core.config.settings") as mock_settings,
  1035. patch(
  1036. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1037. return_value=filament_usage,
  1038. ),
  1039. ):
  1040. mock_settings.base_dir = MagicMock()
  1041. mock_path = MagicMock()
  1042. mock_path.exists.return_value = True
  1043. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1044. results = await _track_from_3mf(
  1045. printer_id=1,
  1046. archive_id=102,
  1047. status="completed",
  1048. print_name="No Switch",
  1049. handled_trays=handled_trays,
  1050. printer_manager=printer_manager,
  1051. db=db,
  1052. tray_now_at_start=2,
  1053. )
  1054. # Normal path: single result, full weight
  1055. assert len(results) == 1
  1056. assert results[0]["weight_used"] == 15.0
  1057. assert results[0]["ams_id"] == 0
  1058. assert results[0]["tray_id"] == 2
  1059. @pytest.mark.asyncio
  1060. async def test_empty_tray_change_log_uses_normal_path(self):
  1061. """Empty tray_change_log (e.g. server restart) falls through to existing logic."""
  1062. spool = _make_spool(spool_id=1, label_weight=1000)
  1063. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  1064. archive = _make_archive(archive_id=103)
  1065. db = _mock_db_sequential([archive, None, assignment, spool])
  1066. # Empty log (server restarted mid-print)
  1067. printer_manager = MagicMock()
  1068. printer_manager.get_status.return_value = SimpleNamespace(
  1069. progress=100,
  1070. layer_num=100,
  1071. tray_now=0,
  1072. last_loaded_tray=0,
  1073. total_layers=100,
  1074. tray_change_log=[],
  1075. )
  1076. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
  1077. handled_trays: set[tuple[int, int]] = set()
  1078. with (
  1079. patch("backend.app.core.config.settings") as mock_settings,
  1080. patch(
  1081. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1082. return_value=filament_usage,
  1083. ),
  1084. ):
  1085. mock_settings.base_dir = MagicMock()
  1086. mock_path = MagicMock()
  1087. mock_path.exists.return_value = True
  1088. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1089. results = await _track_from_3mf(
  1090. printer_id=1,
  1091. archive_id=103,
  1092. status="completed",
  1093. print_name="Restart Recovery",
  1094. handled_trays=handled_trays,
  1095. printer_manager=printer_manager,
  1096. db=db,
  1097. tray_now_at_start=0,
  1098. )
  1099. assert len(results) == 1
  1100. assert results[0]["weight_used"] == 10.0
  1101. @pytest.mark.asyncio
  1102. async def test_tray_switch_segment_no_spool(self):
  1103. """Segment with no spool assignment is skipped; other segments still tracked."""
  1104. spool_b = _make_spool(spool_id=20, label_weight=1000)
  1105. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=3)
  1106. archive = _make_archive(archive_id=104)
  1107. # db: archive, queue_item(None), 1st segment: no assignment, 2nd segment: assignment, spool
  1108. db = _mock_db_sequential([archive, None, None, assign_b, spool_b])
  1109. # Tray 5 (no spool) from layer 0, switched to tray 3 at layer 50
  1110. printer_manager = MagicMock()
  1111. printer_manager.get_status.return_value = SimpleNamespace(
  1112. progress=100,
  1113. layer_num=100,
  1114. tray_now=3,
  1115. last_loaded_tray=3,
  1116. total_layers=100,
  1117. tray_change_log=[(5, 0), (3, 50)],
  1118. )
  1119. filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": ""}]
  1120. handled_trays: set[tuple[int, int]] = set()
  1121. with (
  1122. patch("backend.app.core.config.settings") as mock_settings,
  1123. patch(
  1124. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1125. return_value=filament_usage,
  1126. ),
  1127. patch(
  1128. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  1129. return_value=None, # No per-layer data
  1130. ),
  1131. ):
  1132. mock_settings.base_dir = MagicMock()
  1133. mock_path = MagicMock()
  1134. mock_path.exists.return_value = True
  1135. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1136. results = await _track_from_3mf(
  1137. printer_id=1,
  1138. archive_id=104,
  1139. status="completed",
  1140. print_name="Missing Spool",
  1141. handled_trays=handled_trays,
  1142. printer_manager=printer_manager,
  1143. db=db,
  1144. )
  1145. # Only the second segment (tray 3) tracked; first segment (tray 5) skipped
  1146. assert len(results) == 1
  1147. assert results[0]["ams_id"] == 0
  1148. assert results[0]["tray_id"] == 3
  1149. assert results[0]["spool_id"] == 20
  1150. @pytest.mark.asyncio
  1151. async def test_tray_switch_three_segments(self):
  1152. """Three-segment switch (rare): A→B→C split by linear fallback."""
  1153. spool_a = _make_spool(spool_id=1, label_weight=1000)
  1154. spool_b = _make_spool(spool_id=2, label_weight=1000)
  1155. spool_c = _make_spool(spool_id=3, label_weight=1000)
  1156. assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  1157. assign_b = _make_assignment(spool_id=2, ams_id=0, tray_id=1)
  1158. assign_c = _make_assignment(spool_id=3, ams_id=0, tray_id=2)
  1159. archive = _make_archive(archive_id=105)
  1160. db = _mock_db_sequential(
  1161. [
  1162. archive,
  1163. None,
  1164. assign_a,
  1165. spool_a,
  1166. assign_b,
  1167. spool_b,
  1168. assign_c,
  1169. spool_c,
  1170. ]
  1171. )
  1172. # 3 segments: tray 0 (0-30), tray 1 (30-70), tray 2 (70-end)
  1173. printer_manager = MagicMock()
  1174. printer_manager.get_status.return_value = SimpleNamespace(
  1175. progress=100,
  1176. layer_num=100,
  1177. tray_now=2,
  1178. last_loaded_tray=2,
  1179. total_layers=100,
  1180. tray_change_log=[(0, 0), (1, 30), (2, 70)],
  1181. )
  1182. filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
  1183. handled_trays: set[tuple[int, int]] = set()
  1184. with (
  1185. patch("backend.app.core.config.settings") as mock_settings,
  1186. patch(
  1187. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1188. return_value=filament_usage,
  1189. ),
  1190. patch(
  1191. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  1192. return_value=None,
  1193. ),
  1194. ):
  1195. mock_settings.base_dir = MagicMock()
  1196. mock_path = MagicMock()
  1197. mock_path.exists.return_value = True
  1198. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1199. results = await _track_from_3mf(
  1200. printer_id=1,
  1201. archive_id=105,
  1202. status="completed",
  1203. print_name="Triple Switch",
  1204. handled_trays=handled_trays,
  1205. printer_manager=printer_manager,
  1206. db=db,
  1207. )
  1208. assert len(results) == 3
  1209. # Tray 0: 30/100 * 100g = 30g
  1210. assert results[0]["weight_used"] == 30.0
  1211. assert results[0]["ams_id"] == 0
  1212. assert results[0]["tray_id"] == 0
  1213. # Tray 1: 40/100 * 100g = 40g
  1214. assert results[1]["weight_used"] == 40.0
  1215. assert results[1]["ams_id"] == 0
  1216. assert results[1]["tray_id"] == 1
  1217. # Tray 2: remainder = 100 - 30 - 40 = 30g
  1218. assert results[2]["weight_used"] == 30.0
  1219. assert results[2]["ams_id"] == 0
  1220. assert results[2]["tray_id"] == 2
  1221. @pytest.mark.asyncio
  1222. async def test_tray_switch_uses_last_layer_num_when_total_layers_reset(self):
  1223. """#1771 regression: P1S firmware resets `total_layer_num` to 0 at print
  1224. end; without the cascade the linear fallback collapsed to `0.0` per
  1225. non-last segment and dumped the whole print onto the last spool. With
  1226. the fix, `last_layer_num` (the print's last-valid layer captured before
  1227. the firmware reset) is the substitute denominator.
  1228. Reporter's exact shape: print needed ~260 g, started on a 180 g spool,
  1229. AMS Backup switched at ~70% through, second spool finished the print.
  1230. Before fix: spool 1 → 0 g, spool 2 → 260 g (the bug).
  1231. After fix: spool 1 → 180 g, spool 2 → 80 g (correct).
  1232. """
  1233. spool_a = _make_spool(spool_id=10, label_weight=1000)
  1234. spool_b = _make_spool(spool_id=20, label_weight=1000)
  1235. assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=0)
  1236. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)
  1237. archive = _make_archive(archive_id=171)
  1238. db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
  1239. # Firmware reset: state.total_layers is 0 by the time usage_tracker runs.
  1240. # last_layer_num threaded in from on_print_complete is the survival value.
  1241. printer_manager = MagicMock()
  1242. printer_manager.get_status.return_value = SimpleNamespace(
  1243. progress=100,
  1244. layer_num=0, # also reset
  1245. tray_now=1,
  1246. last_loaded_tray=1,
  1247. total_layers=0, # the bug trigger
  1248. tray_change_log=[(0, 0), (1, 180)], # switched at layer 180 of 260
  1249. )
  1250. filament_usage = [{"slot_id": 1, "used_g": 260.0, "type": "PLA", "color": ""}]
  1251. handled_trays: set[tuple[int, int]] = set()
  1252. with (
  1253. patch("backend.app.core.config.settings") as mock_settings,
  1254. patch(
  1255. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1256. return_value=filament_usage,
  1257. ),
  1258. patch(
  1259. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  1260. return_value=None, # No per-layer 3MF data — force linear fallback path
  1261. ),
  1262. ):
  1263. mock_settings.base_dir = MagicMock()
  1264. mock_path = MagicMock()
  1265. mock_path.exists.return_value = True
  1266. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1267. results = await _track_from_3mf(
  1268. printer_id=1,
  1269. archive_id=171,
  1270. status="completed",
  1271. print_name="#1771 repro",
  1272. handled_trays=handled_trays,
  1273. printer_manager=printer_manager,
  1274. db=db,
  1275. last_layer_num=260, # survives the firmware reset of total_layer_num
  1276. )
  1277. # Both segments must be attributed correctly.
  1278. assert len(results) == 2
  1279. # Segment 1: tray 0, layers 0-180 of 260 → 260 * 180/260 = 180.0 g
  1280. assert results[0]["ams_id"] == 0
  1281. assert results[0]["tray_id"] == 0
  1282. assert results[0]["weight_used"] == 180.0
  1283. # Segment 2: tray 1, remainder = 260 - 180 = 80.0 g
  1284. assert results[1]["ams_id"] == 0
  1285. assert results[1]["tray_id"] == 1
  1286. assert results[1]["weight_used"] == 80.0
  1287. @pytest.mark.asyncio
  1288. async def test_tray_switch_equal_split_when_no_layer_info_at_all(self):
  1289. """Defensive fence: when neither `state.total_layers` nor `last_layer_num`
  1290. survives (older firmware / edge case), equal-split across segments is the
  1291. last-resort fallback. Still wrong but BOUNDED — the original bug dumped
  1292. the whole print weight onto the last segment, which was strictly worse.
  1293. """
  1294. spool_a = _make_spool(spool_id=10, label_weight=1000)
  1295. spool_b = _make_spool(spool_id=20, label_weight=1000)
  1296. assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=0)
  1297. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)
  1298. archive = _make_archive(archive_id=172)
  1299. db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
  1300. printer_manager = MagicMock()
  1301. printer_manager.get_status.return_value = SimpleNamespace(
  1302. progress=100,
  1303. layer_num=0,
  1304. tray_now=1,
  1305. last_loaded_tray=1,
  1306. total_layers=0, # neither source available
  1307. tray_change_log=[(0, 0), (1, 50)],
  1308. )
  1309. filament_usage = [{"slot_id": 1, "used_g": 60.0, "type": "PLA", "color": ""}]
  1310. handled_trays: set[tuple[int, int]] = set()
  1311. with (
  1312. patch("backend.app.core.config.settings") as mock_settings,
  1313. patch(
  1314. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1315. return_value=filament_usage,
  1316. ),
  1317. patch(
  1318. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  1319. return_value=None,
  1320. ),
  1321. ):
  1322. mock_settings.base_dir = MagicMock()
  1323. mock_path = MagicMock()
  1324. mock_path.exists.return_value = True
  1325. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1326. results = await _track_from_3mf(
  1327. printer_id=1,
  1328. archive_id=172,
  1329. status="completed",
  1330. print_name="no layer info",
  1331. handled_trays=handled_trays,
  1332. printer_manager=printer_manager,
  1333. db=db,
  1334. last_layer_num=0, # also unavailable
  1335. )
  1336. # 2 segments, equal split: 60g / 2 = 30g each. Last segment uses the
  1337. # `is_last` remainder branch so it stays at 30.0 too.
  1338. assert len(results) == 2
  1339. assert results[0]["weight_used"] == 30.0
  1340. assert results[1]["weight_used"] == 30.0
  1341. class TestDecodeMqttMapping:
  1342. """Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs."""
  1343. def test_none_input(self):
  1344. assert _decode_mqtt_mapping(None) is None
  1345. def test_empty_list(self):
  1346. assert _decode_mqtt_mapping([]) is None
  1347. def test_all_unmapped(self):
  1348. """All 65535 values → None (no valid mappings)."""
  1349. assert _decode_mqtt_mapping([65535, 65535, 65535]) is None
  1350. def test_single_ams_slots(self):
  1351. """AMS 0 slots: snow values 0-3 → global tray IDs 0-3."""
  1352. assert _decode_mqtt_mapping([0, 1, 2, 3]) == [0, 1, 2, 3]
  1353. def test_multi_ams_slots(self):
  1354. """AMS 1 (hw_id=1): snow 256=AMS1-T0, 257=AMS1-T1 → global 4, 5."""
  1355. assert _decode_mqtt_mapping([256, 257]) == [4, 5]
  1356. def test_ams_ht_slot(self):
  1357. """AMS-HT (hw_id=128): snow 32768 → global 128."""
  1358. assert _decode_mqtt_mapping([32768]) == [128]
  1359. def test_external_spool(self):
  1360. """External spool: ams_hw_id=254, slot=0 → global 254."""
  1361. # snow = 254 * 256 + 0 = 65024
  1362. assert _decode_mqtt_mapping([65024]) == [254]
  1363. def test_mixed_with_unmapped(self):
  1364. """Mix of valid and unmapped (65535) values."""
  1365. result = _decode_mqtt_mapping([1, 65535, 0])
  1366. assert result == [1, -1, 0]
  1367. def test_h2c_real_mapping(self):
  1368. """Real H2C mapping from MQTT logs: [1, 0, 65535*4, 32768]."""
  1369. mapping = [1, 0, 65535, 65535, 65535, 65535, 32768]
  1370. result = _decode_mqtt_mapping(mapping)
  1371. assert result == [1, 0, -1, -1, -1, -1, 128]
  1372. def test_non_int_values_treated_as_unmapped(self):
  1373. """Non-integer values in the mapping are treated as unmapped."""
  1374. assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
  1375. class TestMatchSlotsByColor:
  1376. """Tests for _match_slots_by_color() — color-based filament slot to AMS tray matching."""
  1377. def _ams(self, trays):
  1378. """Build AMS data from list of (ams_id, tray_id, color_hex, tray_type) tuples."""
  1379. units: dict[int, list] = {}
  1380. for ams_id, tray_id, color, tray_type in trays:
  1381. units.setdefault(ams_id, []).append({"id": tray_id, "tray_color": color, "tray_type": tray_type})
  1382. return [{"id": aid, "tray": t} for aid, t in units.items()]
  1383. def _usage(self, slots):
  1384. """Build filament_usage from list of (slot_id, color_hex) tuples."""
  1385. return [{"slot_id": sid, "used_g": 10.0, "type": "PLA", "color": color} for sid, color in slots]
  1386. def test_none_inputs(self):
  1387. assert _match_slots_by_color(None, None) is None
  1388. assert _match_slots_by_color([], None) is None
  1389. assert _match_slots_by_color(None, {"ams": []}) is None
  1390. def test_empty_ams(self):
  1391. usage = self._usage([(1, "#FF0000")])
  1392. assert _match_slots_by_color(usage, {"ams": []}) is None
  1393. def test_single_slot_single_tray(self):
  1394. """One 3MF slot matches one AMS tray by color."""
  1395. ams = self._ams([(0, 0, "FF0000FF", "PLA")])
  1396. usage = self._usage([(1, "#FF0000")])
  1397. assert _match_slots_by_color(usage, {"ams": ams}) == [0]
  1398. def test_a1_mini_three_colors(self):
  1399. """A1 Mini: 3 slots match 3 distinct AMS trays."""
  1400. ams = self._ams(
  1401. [
  1402. (0, 0, "FF0000FF", "PLA"), # Red
  1403. (0, 1, "00FF00FF", "PLA"), # Green
  1404. (0, 2, "0000FFFF", "PLA"), # Blue
  1405. ]
  1406. )
  1407. usage = self._usage([(1, "#FF0000"), (2, "#00FF00"), (3, "#0000FF")])
  1408. assert _match_slots_by_color(usage, {"ams": ams}) == [0, 1, 2]
  1409. def test_dual_ams_p2s_like(self):
  1410. """P2S with dual AMS: slots from second AMS unit."""
  1411. ams = self._ams(
  1412. [
  1413. (0, 0, "AAAAAAFF", "PLA"),
  1414. (0, 1, "BBBBBBFF", "PLA"),
  1415. (1, 0, "CC0000FF", "PETG"), # global_id=4
  1416. (1, 1, "00CC00FF", "PETG"), # global_id=5
  1417. ]
  1418. )
  1419. usage = self._usage([(1, "#CC0000"), (2, "#00CC00")])
  1420. assert _match_slots_by_color(usage, {"ams": ams}) == [4, 5]
  1421. def test_ams_ht_global_id(self):
  1422. """AMS-HT (ams_id >= 128) uses raw ams_id as global tray ID."""
  1423. ams = self._ams(
  1424. [
  1425. (0, 0, "FF0000FF", "PLA"),
  1426. (128, 0, "0000FFFF", "PLA"), # AMS-HT → global_id=128
  1427. ]
  1428. )
  1429. usage = self._usage([(1, "#FF0000"), (2, "#0000FF")])
  1430. assert _match_slots_by_color(usage, {"ams": ams}) == [0, 128]
  1431. def test_ambiguous_same_color_returns_none(self):
  1432. """Two trays with the same color → ambiguous → None."""
  1433. ams = self._ams(
  1434. [
  1435. (0, 0, "FF0000FF", "PLA"),
  1436. (0, 1, "FF0000FF", "PLA"), # Same red
  1437. ]
  1438. )
  1439. usage = self._usage([(1, "#FF0000")])
  1440. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1441. def test_no_matching_color_returns_none(self):
  1442. """3MF slot color not found in any AMS tray → None."""
  1443. ams = self._ams([(0, 0, "00FF00FF", "PLA")])
  1444. usage = self._usage([(1, "#FF0000")]) # Red, but AMS has green
  1445. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1446. def test_color_normalization_strips_alpha(self):
  1447. """AMS colors (RRGGBBAA) and 3MF colors (#RRGGBB) match after normalization."""
  1448. ams = self._ams([(0, 0, "AABBCC80", "PLA")]) # 8-char with alpha
  1449. usage = self._usage([(1, "#AABBCC")]) # 6-char with #
  1450. assert _match_slots_by_color(usage, {"ams": ams}) == [0]
  1451. def test_case_insensitive(self):
  1452. """Color matching is case-insensitive."""
  1453. ams = self._ams([(0, 0, "aaBBccFF", "PLA")])
  1454. usage = self._usage([(1, "#AAbbCC")])
  1455. assert _match_slots_by_color(usage, {"ams": ams}) == [0]
  1456. def test_empty_tray_color_skipped(self):
  1457. """Trays with empty color are skipped (not matched)."""
  1458. ams = self._ams(
  1459. [
  1460. (0, 0, "", "PLA"),
  1461. (0, 1, "FF0000FF", "PLA"),
  1462. ]
  1463. )
  1464. usage = self._usage([(1, "#FF0000")])
  1465. assert _match_slots_by_color(usage, {"ams": ams}) == [1]
  1466. def test_empty_tray_type_skipped(self):
  1467. """Trays with empty tray_type are skipped (unloaded slot)."""
  1468. ams = self._ams(
  1469. [
  1470. (0, 0, "FF0000FF", ""), # Empty slot
  1471. (0, 1, "FF0000FF", "PLA"), # Loaded slot
  1472. ]
  1473. )
  1474. usage = self._usage([(1, "#FF0000")])
  1475. assert _match_slots_by_color(usage, {"ams": ams}) == [1]
  1476. def test_short_slot_color_returns_none(self):
  1477. """3MF slot with color < 6 chars → can't match → None."""
  1478. ams = self._ams([(0, 0, "FF0000FF", "PLA")])
  1479. usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFF"}]
  1480. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1481. def test_slot_id_zero_skipped(self):
  1482. """Slots with slot_id=0 are skipped."""
  1483. ams = self._ams([(0, 0, "FF0000FF", "PLA")])
  1484. usage = [{"slot_id": 0, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
  1485. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1486. def test_ams_data_as_list(self):
  1487. """Handles ams_raw as a plain list (some printer models)."""
  1488. ams_list = [{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}]
  1489. usage = self._usage([(1, "#FF0000")])
  1490. assert _match_slots_by_color(usage, ams_list) == [0]
  1491. def test_same_color_two_trays_disambiguated_by_usage(self):
  1492. """Two trays same color, two slots same color → unique assignment via used_trays tracking."""
  1493. ams = self._ams(
  1494. [
  1495. (0, 0, "FF0000FF", "PLA"),
  1496. (0, 1, "FF0000FF", "PLA"),
  1497. ]
  1498. )
  1499. # Two slots both wanting red — first gets tray 0, second gets tray 1? No.
  1500. # When first slot takes the only available, second has 1 left → should work
  1501. usage = self._usage([(1, "#FF0000"), (2, "#FF0000")])
  1502. # First slot: candidates=[0,1], available=[0,1], len!=1 → None
  1503. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1504. def test_dict_wrapper_with_ams_key(self):
  1505. """Standard dict format with 'ams' key."""
  1506. ams_data = {"ams": [{"id": 0, "tray": [{"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"}]}]}
  1507. usage = self._usage([(1, "#00FF00")])
  1508. assert _match_slots_by_color(usage, ams_data) == [0]
  1509. class TestMqttMappingIntegration:
  1510. """Integration tests: MQTT mapping field used in _track_from_3mf."""
  1511. @pytest.mark.asyncio
  1512. async def test_h2c_multi_filament_uses_mqtt_mapping(self):
  1513. """H2C: 3 filaments resolved via MQTT mapping field (no ams_mapping, no queue)."""
  1514. # AMS0-T1 (White PLA), AMS0-T0 (Black PLA), AMS128-T0 (Red PLA)
  1515. spool_white = _make_spool(spool_id=1, label_weight=1000)
  1516. spool_black = _make_spool(spool_id=2, label_weight=1000)
  1517. spool_red = _make_spool(spool_id=3, label_weight=1000)
  1518. assign_white = _make_assignment(spool_id=1, ams_id=0, tray_id=1)
  1519. assign_black = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
  1520. assign_red = _make_assignment(spool_id=3, ams_id=128, tray_id=0)
  1521. archive = _make_archive(archive_id=12)
  1522. # db: archive, queue lookup (no queue item — this is a direct print),
  1523. # then 3 pairs of (assignment, spool). The queue mapping is consulted
  1524. # before the MQTT field because AMS backup rewrites the live one.
  1525. db = _mock_db_sequential(
  1526. [
  1527. archive,
  1528. None,
  1529. assign_white,
  1530. spool_white,
  1531. assign_black,
  1532. spool_black,
  1533. assign_red,
  1534. spool_red,
  1535. ]
  1536. )
  1537. # MQTT mapping: slot0→AMS0-T1(1), slot1→AMS0-T0(0), slots2-5→unmapped, slot6→AMS128-T0(32768)
  1538. printer_manager = MagicMock()
  1539. printer_manager.get_status.return_value = SimpleNamespace(
  1540. raw_data={"mapping": [1, 0, 65535, 65535, 65535, 65535, 32768]},
  1541. progress=100,
  1542. layer_num=50,
  1543. tray_now=255,
  1544. )
  1545. # 3MF slots 1, 2, 7 (1-based) → indices 0, 1, 6 in mapping
  1546. filament_usage = [
  1547. {"slot_id": 1, "used_g": 21.16, "type": "PLA", "color": "#FFFFFF"},
  1548. {"slot_id": 2, "used_g": 24.22, "type": "PLA", "color": "#000000"},
  1549. {"slot_id": 7, "used_g": 18.47, "type": "PLA", "color": "#F72323"},
  1550. ]
  1551. handled_trays: set[tuple[int, int]] = set()
  1552. with (
  1553. patch("backend.app.core.config.settings") as mock_settings,
  1554. patch(
  1555. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1556. return_value=filament_usage,
  1557. ),
  1558. ):
  1559. mock_settings.base_dir = MagicMock()
  1560. mock_path = MagicMock()
  1561. mock_path.exists.return_value = True
  1562. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1563. results = await _track_from_3mf(
  1564. printer_id=1,
  1565. archive_id=12,
  1566. status="completed",
  1567. print_name="Cube + Cube + Cube",
  1568. handled_trays=handled_trays,
  1569. printer_manager=printer_manager,
  1570. db=db,
  1571. )
  1572. assert len(results) == 3
  1573. # slot_id=1 → mapping[0]=1 → AMS0-T1 (White PLA)
  1574. assert results[0]["spool_id"] == 1
  1575. assert results[0]["ams_id"] == 0
  1576. assert results[0]["tray_id"] == 1
  1577. assert results[0]["weight_used"] == 21.2
  1578. # slot_id=2 → mapping[1]=0 → AMS0-T0 (Black PLA)
  1579. assert results[1]["spool_id"] == 2
  1580. assert results[1]["ams_id"] == 0
  1581. assert results[1]["tray_id"] == 0
  1582. assert results[1]["weight_used"] == 24.2
  1583. # slot_id=7 → mapping[6]=32768 → AMS128-T0 (Red PLA)
  1584. assert results[2]["spool_id"] == 3
  1585. assert results[2]["ams_id"] == 128
  1586. assert results[2]["tray_id"] == 0
  1587. assert results[2]["weight_used"] == 18.5
  1588. @pytest.mark.asyncio
  1589. async def test_print_cmd_mapping_takes_priority_over_mqtt(self):
  1590. """ams_mapping from print command is used even when MQTT mapping exists."""
  1591. spool = _make_spool(spool_id=1, label_weight=1000)
  1592. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
  1593. archive = _make_archive(archive_id=10)
  1594. # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
  1595. db = _mock_db_sequential([archive, assignment, spool])
  1596. printer_manager = MagicMock()
  1597. printer_manager.get_status.return_value = SimpleNamespace(
  1598. raw_data={"mapping": [0, 65535]}, # MQTT says slot 0 → AMS0-T0
  1599. progress=100,
  1600. layer_num=50,
  1601. tray_now=255,
  1602. )
  1603. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
  1604. handled_trays: set[tuple[int, int]] = set()
  1605. with (
  1606. patch("backend.app.core.config.settings") as mock_settings,
  1607. patch(
  1608. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1609. return_value=filament_usage,
  1610. ),
  1611. ):
  1612. mock_settings.base_dir = MagicMock()
  1613. mock_path = MagicMock()
  1614. mock_path.exists.return_value = True
  1615. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1616. results = await _track_from_3mf(
  1617. printer_id=1,
  1618. archive_id=10,
  1619. status="completed",
  1620. print_name="Test",
  1621. handled_trays=handled_trays,
  1622. printer_manager=printer_manager,
  1623. db=db,
  1624. ams_mapping=[2], # Print cmd says slot 0 → AMS0-T2 (overrides MQTT)
  1625. )
  1626. assert len(results) == 1
  1627. assert results[0]["ams_id"] == 0
  1628. assert results[0]["tray_id"] == 2 # From print_cmd mapping, not MQTT
  1629. class TestPositionBasedFallbackEmptyAmsSlot:
  1630. """Position-based mapping fallback (#1607): when no explicit mapping is
  1631. available, the slicer's Nth filament must map to the Nth *loaded* AMS tray
  1632. (skipping empty slots), not the Nth physical slot position. BambuStudio /
  1633. OrcaSlicer compact their filament-assignment UI by hiding unloaded AMS
  1634. slots, so the 3MF slot list is dense even when the AMS itself has gaps."""
  1635. @pytest.mark.asyncio
  1636. async def test_external_routed_correctly_when_ams_has_empty_middle_slot(self):
  1637. """Reporter's scenario: AMS trays 0-2 loaded, tray 3 empty, external
  1638. loaded. Slicer emits 4 filaments — slot 4 = external. Without the fix
  1639. the position-based fallback maps slot 4 to the empty AMS tray 3
  1640. (since `available_trays = [0, 1, 2, 3, 254]`) and external usage is
  1641. silently dropped because no spool is assigned to AMS0-T3.
  1642. After the fix, empty AMS slots are filtered (tray_type is empty) so
  1643. `available_trays = [0, 1, 2, 254]` and slot 4 correctly resolves to
  1644. the external (global tray 254 → AMS255-T0)."""
  1645. # Spool fed via external (vt_tray 254 → AMS255-T0)
  1646. spool = _make_spool(spool_id=42, label_weight=1000)
  1647. assignment = _make_assignment(spool_id=42, ams_id=255, tray_id=0)
  1648. archive = _make_archive(archive_id=70)
  1649. # db: archive, queue_item(None), assignment, spool
  1650. db = _mock_db_sequential([archive, None, assignment, spool])
  1651. # AMS reports 4 physical tray slots but slot 3 has no spool (empty
  1652. # tray_type); external spool is loaded in vt_tray.
  1653. # No `mapping` field on the state — forces fallback through path 5.
  1654. printer_manager = MagicMock()
  1655. printer_manager.get_status.return_value = SimpleNamespace(
  1656. raw_data={
  1657. "ams": [
  1658. {
  1659. "id": 0,
  1660. "tray": [
  1661. {"id": 0, "tray_type": "PLA"},
  1662. {"id": 1, "tray_type": "PETG"},
  1663. {"id": 2, "tray_type": "ABS"},
  1664. {"id": 3, "tray_type": ""}, # empty slot
  1665. ],
  1666. }
  1667. ],
  1668. "vt_tray": [{"id": 254, "tray_type": "PLA"}],
  1669. },
  1670. progress=100,
  1671. layer_num=50,
  1672. tray_now=254,
  1673. tray_change_log=[],
  1674. )
  1675. # 3MF has 4 dense filament slots — slot 4 is the external. Only slot 4
  1676. # has weight (other slots came from AMS spools handled separately).
  1677. filament_usage = [{"slot_id": 4, "used_g": 12.3, "type": "PLA", "color": "#00AABB"}]
  1678. handled_trays: set[tuple[int, int]] = set()
  1679. with (
  1680. patch("backend.app.core.config.settings") as mock_settings,
  1681. patch(
  1682. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1683. return_value=filament_usage,
  1684. ),
  1685. ):
  1686. mock_settings.base_dir = MagicMock()
  1687. mock_path = MagicMock()
  1688. mock_path.exists.return_value = True
  1689. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1690. results = await _track_from_3mf(
  1691. printer_id=1,
  1692. archive_id=70,
  1693. status="completed",
  1694. print_name="External + AMS print",
  1695. handled_trays=handled_trays,
  1696. printer_manager=printer_manager,
  1697. db=db,
  1698. )
  1699. assert len(results) == 1
  1700. # The external spool was charged, NOT the empty AMS slot.
  1701. assert results[0]["spool_id"] == 42
  1702. assert results[0]["ams_id"] == 255
  1703. assert results[0]["tray_id"] == 0
  1704. assert results[0]["weight_used"] == 12.3
  1705. assert (255, 0) in handled_trays
  1706. # Critical assertion: AMS0-T3 (the empty slot) was NOT charged.
  1707. assert (0, 3) not in handled_trays
  1708. @pytest.mark.asyncio
  1709. async def test_dense_ams_unchanged_no_empty_slots(self):
  1710. """Sanity check: when every AMS slot is loaded, the position-based
  1711. fallback still works for the slicer's external = last slot case."""
  1712. spool = _make_spool(spool_id=99, label_weight=1000)
  1713. assignment = _make_assignment(spool_id=99, ams_id=255, tray_id=0)
  1714. archive = _make_archive(archive_id=71)
  1715. db = _mock_db_sequential([archive, None, assignment, spool])
  1716. printer_manager = MagicMock()
  1717. printer_manager.get_status.return_value = SimpleNamespace(
  1718. raw_data={
  1719. "ams": [
  1720. {
  1721. "id": 0,
  1722. "tray": [
  1723. {"id": 0, "tray_type": "PLA"},
  1724. {"id": 1, "tray_type": "PETG"},
  1725. {"id": 2, "tray_type": "ABS"},
  1726. {"id": 3, "tray_type": "TPU"},
  1727. ],
  1728. }
  1729. ],
  1730. "vt_tray": [{"id": 254, "tray_type": "PLA"}],
  1731. },
  1732. progress=100,
  1733. layer_num=50,
  1734. tray_now=254,
  1735. tray_change_log=[],
  1736. )
  1737. # 5 filaments, slot 5 = external. available_trays = [0,1,2,3,254] →
  1738. # slot_id=5 → available_trays[4] = 254.
  1739. filament_usage = [{"slot_id": 5, "used_g": 7.5, "type": "PLA", "color": ""}]
  1740. handled_trays: set[tuple[int, int]] = set()
  1741. with (
  1742. patch("backend.app.core.config.settings") as mock_settings,
  1743. patch(
  1744. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1745. return_value=filament_usage,
  1746. ),
  1747. ):
  1748. mock_settings.base_dir = MagicMock()
  1749. mock_path = MagicMock()
  1750. mock_path.exists.return_value = True
  1751. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1752. results = await _track_from_3mf(
  1753. printer_id=1,
  1754. archive_id=71,
  1755. status="completed",
  1756. print_name="Dense AMS + external",
  1757. handled_trays=handled_trays,
  1758. printer_manager=printer_manager,
  1759. db=db,
  1760. )
  1761. assert len(results) == 1
  1762. assert results[0]["spool_id"] == 99
  1763. assert results[0]["ams_id"] == 255
  1764. assert results[0]["tray_id"] == 0
  1765. class TestNotificationVariables:
  1766. """Tests for filament_details formatting in notifications."""
  1767. def test_filament_details_single_slot(self):
  1768. """Single slot produces 'PLA: 15.2g' format."""
  1769. slots = [{"type": "PLA", "used_g": 15.2, "slot_id": 1, "color": "#FF0000"}]
  1770. parts = []
  1771. for slot in slots:
  1772. ftype = slot.get("type", "Unknown") or "Unknown"
  1773. used = slot.get("used_g", 0)
  1774. parts.append(f"{ftype}: {used:.1f}g")
  1775. result = " | ".join(parts)
  1776. assert result == "PLA: 15.2g"
  1777. def test_filament_details_multi_slot(self):
  1778. """Multiple slots produce 'PLA: 10.0g | PETG: 5.0g' format."""
  1779. slots = [
  1780. {"type": "PLA", "used_g": 10.0, "slot_id": 1, "color": ""},
  1781. {"type": "PETG", "used_g": 5.0, "slot_id": 2, "color": ""},
  1782. ]
  1783. parts = []
  1784. for slot in slots:
  1785. ftype = slot.get("type", "Unknown") or "Unknown"
  1786. used = slot.get("used_g", 0)
  1787. parts.append(f"{ftype}: {used:.1f}g")
  1788. result = " | ".join(parts)
  1789. assert result == "PLA: 10.0g | PETG: 5.0g"
  1790. def test_filament_details_empty_type(self):
  1791. """Empty type defaults to 'Unknown'."""
  1792. slots = [{"type": "", "used_g": 5.0, "slot_id": 1, "color": ""}]
  1793. parts = []
  1794. for slot in slots:
  1795. ftype = slot.get("type", "Unknown") or "Unknown"
  1796. used = slot.get("used_g", 0)
  1797. parts.append(f"{ftype}: {used:.1f}g")
  1798. result = " | ".join(parts)
  1799. assert result == "Unknown: 5.0g"
  1800. def test_filament_grams_scaled_for_partial(self):
  1801. """filament_grams is scaled by progress for partial prints."""
  1802. filament_used_grams = 20.0
  1803. progress = 50
  1804. scale = max(0.0, min(progress / 100.0, 1.0))
  1805. scaled = round(filament_used_grams * scale, 1)
  1806. assert scaled == 10.0
  1807. def test_filament_grams_zero_progress(self):
  1808. """Progress=0 at cancellation gives 0.0g."""
  1809. filament_used_grams = 20.0
  1810. progress = 0
  1811. scale = max(0.0, min(progress / 100.0, 1.0))
  1812. scaled = round(filament_used_grams * scale, 1)
  1813. assert scaled == 0.0
  1814. def test_slot_scaling_for_partial(self):
  1815. """Per-slot usage is scaled linearly for partial prints."""
  1816. slots = [
  1817. {"type": "PLA", "used_g": 20.0, "slot_id": 1, "color": ""},
  1818. {"type": "PETG", "used_g": 10.0, "slot_id": 2, "color": ""},
  1819. ]
  1820. progress = 30
  1821. scale = max(0.0, min(progress / 100.0, 1.0))
  1822. scaled_slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
  1823. assert scaled_slots[0]["used_g"] == 6.0
  1824. assert scaled_slots[1]["used_g"] == 3.0
  1825. class TestOnPrintStartAmsMapping:
  1826. """Tests for ams_mapping capture in on_print_start()."""
  1827. @pytest.fixture(autouse=True)
  1828. def _clear_sessions(self):
  1829. _active_sessions.clear()
  1830. yield
  1831. _active_sessions.clear()
  1832. @pytest.mark.asyncio
  1833. async def test_captures_ams_mapping_from_data(self):
  1834. """on_print_start captures ams_mapping from the data dict into the session."""
  1835. printer_manager = MagicMock()
  1836. printer_manager.get_status.return_value = SimpleNamespace(
  1837. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  1838. tray_now=0,
  1839. )
  1840. await on_print_start(1, {"subtask_name": "Test", "ams_mapping": [3, -1, -1, 2]}, printer_manager)
  1841. assert _active_sessions[1].ams_mapping == [3, -1, -1, 2]
  1842. @pytest.mark.asyncio
  1843. async def test_ams_mapping_none_when_not_in_data(self):
  1844. """Session ams_mapping is None when data dict has no ams_mapping."""
  1845. printer_manager = MagicMock()
  1846. printer_manager.get_status.return_value = SimpleNamespace(
  1847. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  1848. tray_now=0,
  1849. )
  1850. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  1851. assert _active_sessions[1].ams_mapping is None
  1852. @pytest.mark.asyncio
  1853. async def test_captures_queue_plate_id(self):
  1854. """on_print_start records the queue item's plate_id onto the session (#1697)."""
  1855. printer_manager = MagicMock()
  1856. printer_manager.get_status.return_value = SimpleNamespace(
  1857. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  1858. tray_now=0,
  1859. )
  1860. queue_item = _make_queue_item(plate_id=2)
  1861. # on_print_start now executes: SpoolAssignment lookup, then PrintQueueItem lookup.
  1862. db = AsyncMock()
  1863. assignment_result = MagicMock()
  1864. assignment_result.scalars.return_value.all.return_value = []
  1865. queue_result = MagicMock()
  1866. queue_result.scalars.return_value.first.return_value = queue_item
  1867. db.execute = AsyncMock(side_effect=[assignment_result, queue_result])
  1868. await on_print_start(1, {"subtask_name": "Test"}, printer_manager, db=db)
  1869. assert _active_sessions[1].plate_id == 2
  1870. @pytest.mark.asyncio
  1871. async def test_plate_id_none_when_no_queue_item(self):
  1872. """Direct/library prints with no queue item leave session.plate_id = None."""
  1873. printer_manager = MagicMock()
  1874. printer_manager.get_status.return_value = SimpleNamespace(
  1875. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  1876. tray_now=0,
  1877. )
  1878. db = AsyncMock()
  1879. assignment_result = MagicMock()
  1880. assignment_result.scalars.return_value.all.return_value = []
  1881. queue_result = MagicMock()
  1882. queue_result.scalars.return_value.first.return_value = None
  1883. db.execute = AsyncMock(side_effect=[assignment_result, queue_result])
  1884. await on_print_start(1, {"subtask_name": "Test"}, printer_manager, db=db)
  1885. assert _active_sessions[1].plate_id is None
  1886. class TestFindThreemfByFilename:
  1887. """Tests for _find_3mf_by_filename() — library/archive search without archive_id."""
  1888. @pytest.mark.asyncio
  1889. async def test_finds_library_file(self):
  1890. """Finds a 3MF from library files matching filename."""
  1891. from pathlib import Path
  1892. from unittest.mock import MagicMock
  1893. lib_file = MagicMock()
  1894. lib_file.file_path = "library/BMCU-BADGE.3mf"
  1895. mock_result = MagicMock()
  1896. mock_result.scalars.return_value.all.return_value = [lib_file]
  1897. db = AsyncMock()
  1898. db.execute = AsyncMock(return_value=mock_result)
  1899. base_dir = MagicMock(spec=Path)
  1900. candidate = MagicMock(spec=Path)
  1901. candidate.exists.return_value = True
  1902. candidate.suffix = ".3mf"
  1903. base_dir.__truediv__ = MagicMock(return_value=candidate)
  1904. result = await _find_3mf_by_filename(1, "BMCU-BADGE.3mf", db, base_dir)
  1905. assert result == candidate
  1906. @pytest.mark.asyncio
  1907. async def test_returns_none_for_empty_filename(self):
  1908. """Returns None when filename is empty or just extensions."""
  1909. db = AsyncMock()
  1910. base_dir = MagicMock()
  1911. result = await _find_3mf_by_filename(1, ".3mf", db, base_dir)
  1912. assert result is None
  1913. result = await _find_3mf_by_filename(1, "", db, base_dir)
  1914. assert result is None
  1915. @pytest.mark.asyncio
  1916. async def test_falls_through_to_archive_search(self):
  1917. """Falls back to previous archives when library search returns no results."""
  1918. from pathlib import Path
  1919. # Library returns nothing
  1920. empty_result = MagicMock()
  1921. empty_result.scalars.return_value.all.return_value = []
  1922. # Archive returns a match
  1923. archive = MagicMock()
  1924. archive.id = 35
  1925. archive.file_path = "archives/35/BMCU-BADGE.3mf"
  1926. archive_result = MagicMock()
  1927. archive_result.scalars.return_value.all.return_value = [archive]
  1928. db = AsyncMock()
  1929. db.execute = AsyncMock(side_effect=[empty_result, archive_result])
  1930. base_dir = MagicMock(spec=Path)
  1931. candidate = MagicMock(spec=Path)
  1932. candidate.exists.return_value = True
  1933. candidate.suffix = ".3mf"
  1934. base_dir.__truediv__ = MagicMock(return_value=candidate)
  1935. result = await _find_3mf_by_filename(1, "BMCU-BADGE.3mf", db, base_dir)
  1936. assert result == candidate
  1937. assert db.execute.call_count == 2
  1938. @pytest.mark.asyncio
  1939. async def test_returns_none_when_nothing_found(self):
  1940. """Returns None when neither library nor archives have a matching 3MF."""
  1941. empty_result = MagicMock()
  1942. empty_result.scalars.return_value.all.return_value = []
  1943. db = AsyncMock()
  1944. db.execute = AsyncMock(return_value=empty_result)
  1945. base_dir = MagicMock()
  1946. result = await _find_3mf_by_filename(1, "nonexistent.3mf", db, base_dir)
  1947. assert result is None
  1948. @pytest.mark.asyncio
  1949. async def test_strips_path_and_extensions(self):
  1950. """Correctly strips path components and extensions for search."""
  1951. empty_result = MagicMock()
  1952. empty_result.scalars.return_value.all.return_value = []
  1953. db = AsyncMock()
  1954. db.execute = AsyncMock(return_value=empty_result)
  1955. base_dir = MagicMock()
  1956. # Should search for "BMCU-BADGE" base name even with path and .gcode.3mf
  1957. await _find_3mf_by_filename(1, "/sdcard/BMCU-BADGE.gcode.3mf", db, base_dir)
  1958. # Verify the execute was called (search was attempted with stripped name)
  1959. assert db.execute.call_count == 2 # library + archive search
  1960. class TestTrackFrom3mfWithPreresolvedPath:
  1961. """Tests for _track_from_3mf() with threemf_path (no archive needed)."""
  1962. @pytest.mark.asyncio
  1963. async def test_uses_preresolved_path_without_archive(self):
  1964. """When threemf_path is provided with archive_id=None, uses the path directly."""
  1965. spool = _make_spool(spool_id=1, label_weight=1000)
  1966. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=3)
  1967. # DB: 1st call = assignment lookup (live), 2nd = spool lookup
  1968. db = _mock_db_sequential([assignment, spool])
  1969. printer_manager = MagicMock()
  1970. printer_manager.get_status.return_value = SimpleNamespace(
  1971. raw_data={"ams": [{"id": 0, "tray": []}]},
  1972. tray_now=255,
  1973. last_loaded_tray=3,
  1974. tray_change_log=[],
  1975. )
  1976. filament_usage = [{"slot_id": 1, "used_g": 5.0, "type": "PETG", "color": "#FFFFFF"}]
  1977. with (
  1978. patch(
  1979. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1980. return_value=filament_usage,
  1981. ),
  1982. patch("backend.app.core.config.settings") as mock_settings,
  1983. ):
  1984. mock_settings.base_dir = MagicMock()
  1985. mock_path = MagicMock()
  1986. mock_path.exists.return_value = True
  1987. results = await _track_from_3mf(
  1988. printer_id=1,
  1989. archive_id=None,
  1990. status="completed",
  1991. print_name="BMCU-BADGE",
  1992. handled_trays=set(),
  1993. printer_manager=printer_manager,
  1994. db=db,
  1995. ams_mapping=[3, -1, -1, -1],
  1996. threemf_path=mock_path,
  1997. )
  1998. assert len(results) == 1
  1999. assert results[0]["spool_id"] == 1
  2000. assert results[0]["weight_used"] == 5.0
  2001. @pytest.mark.asyncio
  2002. async def test_skips_queue_lookup_without_archive_id(self):
  2003. """When archive_id is None, queue item lookup is skipped."""
  2004. spool = _make_spool(spool_id=1, label_weight=1000)
  2005. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  2006. db = _mock_db_sequential([assignment, spool])
  2007. printer_manager = MagicMock()
  2008. printer_manager.get_status.return_value = SimpleNamespace(
  2009. raw_data={"ams": [{"id": 0, "tray": []}]},
  2010. tray_now=0,
  2011. last_loaded_tray=0,
  2012. tray_change_log=[],
  2013. )
  2014. filament_usage = [{"slot_id": 1, "used_g": 2.0, "type": "PLA", "color": "#FF0000"}]
  2015. with (
  2016. patch(
  2017. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  2018. return_value=filament_usage,
  2019. ),
  2020. patch("backend.app.core.config.settings") as mock_settings,
  2021. ):
  2022. mock_settings.base_dir = MagicMock()
  2023. mock_path = MagicMock()
  2024. mock_path.exists.return_value = True
  2025. # Should NOT fail even though there's no archive_id for queue lookup
  2026. results = await _track_from_3mf(
  2027. printer_id=1,
  2028. archive_id=None,
  2029. status="completed",
  2030. print_name="Test",
  2031. handled_trays=set(),
  2032. printer_manager=printer_manager,
  2033. db=db,
  2034. tray_now_at_start=0,
  2035. threemf_path=mock_path,
  2036. )
  2037. assert len(results) == 1
  2038. assert results[0]["weight_used"] == 2.0
  2039. class TestTrackFrom3mfPlateId:
  2040. """plate_id must propagate from PrintSession through _track_from_3mf to the
  2041. 3MF parser, so multi-plate files dispatched for one plate only count that
  2042. plate's filament (#1697)."""
  2043. @pytest.mark.asyncio
  2044. async def test_passes_plate_id_to_3mf_extract(self):
  2045. spool = _make_spool(spool_id=1, label_weight=1000)
  2046. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  2047. db = _mock_db_sequential([assignment, spool])
  2048. printer_manager = MagicMock()
  2049. printer_manager.get_status.return_value = SimpleNamespace(
  2050. raw_data={"ams": [{"id": 0, "tray": []}]},
  2051. tray_now=0,
  2052. last_loaded_tray=0,
  2053. tray_change_log=[],
  2054. )
  2055. extract_mock = MagicMock(return_value=[{"slot_id": 1, "used_g": 190.0, "type": "PETG", "color": "#888888"}])
  2056. with (
  2057. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", extract_mock),
  2058. patch("backend.app.core.config.settings") as mock_settings,
  2059. ):
  2060. mock_settings.base_dir = MagicMock()
  2061. mock_path = MagicMock()
  2062. mock_path.exists.return_value = True
  2063. await _track_from_3mf(
  2064. printer_id=1,
  2065. archive_id=None,
  2066. status="completed",
  2067. print_name="GridfinityLid",
  2068. handled_trays=set(),
  2069. printer_manager=printer_manager,
  2070. db=db,
  2071. tray_now_at_start=0,
  2072. threemf_path=mock_path,
  2073. plate_id=2,
  2074. )
  2075. # plate_id=2 passed positionally as second arg
  2076. assert extract_mock.call_count == 1
  2077. assert extract_mock.call_args.args[1] == 2
  2078. @pytest.mark.asyncio
  2079. async def test_plate_id_none_for_non_queue_print(self):
  2080. spool = _make_spool(spool_id=1, label_weight=1000)
  2081. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  2082. db = _mock_db_sequential([assignment, spool])
  2083. printer_manager = MagicMock()
  2084. printer_manager.get_status.return_value = SimpleNamespace(
  2085. raw_data={"ams": [{"id": 0, "tray": []}]},
  2086. tray_now=0,
  2087. last_loaded_tray=0,
  2088. tray_change_log=[],
  2089. )
  2090. extract_mock = MagicMock(return_value=[{"slot_id": 1, "used_g": 5.0, "type": "PLA", "color": "#FF0000"}])
  2091. with (
  2092. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", extract_mock),
  2093. patch("backend.app.core.config.settings") as mock_settings,
  2094. ):
  2095. mock_settings.base_dir = MagicMock()
  2096. mock_path = MagicMock()
  2097. mock_path.exists.return_value = True
  2098. # No plate_id kwarg — direct/library Print flow.
  2099. await _track_from_3mf(
  2100. printer_id=1,
  2101. archive_id=None,
  2102. status="completed",
  2103. print_name="DirectPrint",
  2104. handled_trays=set(),
  2105. printer_manager=printer_manager,
  2106. db=db,
  2107. tray_now_at_start=0,
  2108. threemf_path=mock_path,
  2109. )
  2110. assert extract_mock.call_args.args[1] is None
  2111. class TestArchiveTypesFromSpools:
  2112. """Tests for _archive_types_from_spools() — adopting the mapped inventory
  2113. spool's material as the archive/print-log/stats filament type (#2563)."""
  2114. @staticmethod
  2115. def _usage(slots):
  2116. """slots: list of (slot_id, used_g)."""
  2117. return [{"slot_id": sid, "used_g": g} for sid, g in slots]
  2118. @staticmethod
  2119. def _results(entries):
  2120. """entries: list of (slot_id, material)."""
  2121. return [{"slot_id": sid, "material": m} for sid, m in entries]
  2122. def test_mapped_material_overrides_sliced_type(self):
  2123. """The reporter's case: PLA-sliced slot mapped to a PETG spool → PETG."""
  2124. usage = self._usage([(1, 2.9)])
  2125. results = self._results([(1, "PETG")])
  2126. assert _archive_types_from_spools(usage, results) == ["PETG"]
  2127. def test_multi_slot_slot_ordered_and_deduped(self):
  2128. """Distinct materials keep slot order; a repeated material collapses."""
  2129. usage = self._usage([(1, 5.0), (2, 3.0), (3, 4.0)])
  2130. results = self._results([(2, "PETG"), (1, "PLA"), (3, "PLA")])
  2131. assert _archive_types_from_spools(usage, results) == ["PLA", "PETG"]
  2132. def test_partial_match_returns_none(self):
  2133. """All-or-nothing: an unmatched used slot means no rewrite at all."""
  2134. usage = self._usage([(1, 5.0), (2, 3.0)])
  2135. results = self._results([(1, "PETG")]) # slot 2 never resolved
  2136. assert _archive_types_from_spools(usage, results) is None
  2137. def test_zero_usage_slot_ignored(self):
  2138. """A slot that consumed nothing doesn't need a spool material."""
  2139. usage = self._usage([(1, 5.0), (2, 0.0)])
  2140. results = self._results([(1, "PETG")])
  2141. assert _archive_types_from_spools(usage, results) == ["PETG"]
  2142. def test_no_used_slots_returns_none(self):
  2143. assert _archive_types_from_spools([], []) is None
  2144. assert _archive_types_from_spools(self._usage([(1, 0.0)]), []) is None
  2145. def test_fallback_results_without_slot_id_ignored(self):
  2146. """AMS remain%-delta results carry slot_id=None and must not count."""
  2147. usage = self._usage([(1, 5.0)])
  2148. results = [{"slot_id": None, "material": "PETG"}]
  2149. assert _archive_types_from_spools(usage, results) is None
  2150. def test_blank_material_is_not_a_match(self):
  2151. """An empty/whitespace material doesn't satisfy the all-or-nothing gate."""
  2152. usage = self._usage([(1, 5.0)])
  2153. assert _archive_types_from_spools(usage, self._results([(1, " ")])) is None
  2154. assert _archive_types_from_spools(usage, self._results([(1, " PETG ")])) == ["PETG"]