test_usage_tracker.py 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741
  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. _decode_mqtt_mapping,
  14. _match_slots_by_color,
  15. _track_from_3mf,
  16. on_print_complete,
  17. on_print_start,
  18. )
  19. def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
  20. """Create a mock Spool object."""
  21. spool = MagicMock()
  22. spool.id = spool_id
  23. spool.label_weight = label_weight
  24. spool.weight_used = weight_used
  25. spool.tag_uid = tag_uid
  26. spool.tray_uuid = tray_uuid
  27. spool.last_used = None
  28. spool.cost_per_kg = None
  29. spool.material = "PLA"
  30. return spool
  31. def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):
  32. """Create a mock SpoolAssignment object."""
  33. assignment = MagicMock()
  34. assignment.spool_id = spool_id
  35. assignment.printer_id = printer_id
  36. assignment.ams_id = ams_id
  37. assignment.tray_id = tray_id
  38. return assignment
  39. def _make_archive(archive_id=1, file_path="archives/1/test.3mf", extra_data=None):
  40. """Create a mock PrintArchive object."""
  41. archive = MagicMock()
  42. archive.id = archive_id
  43. archive.file_path = file_path
  44. archive.extra_data = extra_data
  45. return archive
  46. def _make_queue_item(ams_mapping=None, status="printing"):
  47. """Create a mock PrintQueueItem object."""
  48. item = MagicMock()
  49. item.ams_mapping = ams_mapping
  50. item.status = status
  51. return item
  52. def _mock_db_execute(*return_values):
  53. """Create a mock db with execute() that returns values in sequence."""
  54. db = AsyncMock()
  55. results = []
  56. for val in return_values:
  57. result = MagicMock()
  58. result.scalar_one_or_none.return_value = val
  59. results.append(result)
  60. db.execute = AsyncMock(side_effect=results)
  61. return db
  62. def _mock_db_sequential(responses):
  63. """Create mock db that returns responses in order."""
  64. db = AsyncMock()
  65. call_count = [0]
  66. async def mock_execute(*args, **kwargs):
  67. idx = call_count[0]
  68. call_count[0] += 1
  69. result = MagicMock()
  70. if idx < len(responses):
  71. result.scalar_one_or_none.return_value = responses[idx]
  72. else:
  73. result.scalar_one_or_none.return_value = None
  74. # For cost aggregation queries that use .scalar() instead of .scalar_one_or_none()
  75. result.scalar.return_value = None
  76. return result
  77. db.execute = mock_execute
  78. return db
  79. class TestOnPrintStart:
  80. """Tests for on_print_start()."""
  81. @pytest.fixture(autouse=True)
  82. def _clear_sessions(self):
  83. _active_sessions.clear()
  84. yield
  85. _active_sessions.clear()
  86. @pytest.mark.asyncio
  87. async def test_captures_remain_data(self):
  88. """Captures AMS remain% at print start."""
  89. printer_manager = MagicMock()
  90. printer_manager.get_status.return_value = SimpleNamespace(
  91. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]},
  92. tray_now=5,
  93. )
  94. await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager)
  95. assert 1 in _active_sessions
  96. session = _active_sessions[1]
  97. assert session.print_name == "Benchy"
  98. assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}
  99. @pytest.mark.asyncio
  100. async def test_captures_tray_now_at_start(self):
  101. """Captures tray_now at print start for later use in usage tracking."""
  102. printer_manager = MagicMock()
  103. printer_manager.get_status.return_value = SimpleNamespace(
  104. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  105. tray_now=9,
  106. )
  107. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  108. assert _active_sessions[1].tray_now_at_start == 9
  109. @pytest.mark.asyncio
  110. async def test_tray_now_at_start_255_when_unloaded(self):
  111. """Captures tray_now=255 when printer has no filament loaded at start."""
  112. printer_manager = MagicMock()
  113. printer_manager.get_status.return_value = SimpleNamespace(
  114. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  115. tray_now=255,
  116. )
  117. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  118. assert _active_sessions[1].tray_now_at_start == 255
  119. @pytest.mark.asyncio
  120. async def test_creates_session_without_remain(self):
  121. """Creates session even without valid remain data (for 3MF tracking)."""
  122. printer_manager = MagicMock()
  123. printer_manager.get_status.return_value = SimpleNamespace(
  124. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]},
  125. tray_now=255,
  126. )
  127. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  128. assert 1 in _active_sessions
  129. assert _active_sessions[1].tray_remain_start == {}
  130. class TestOnPrintComplete:
  131. """Tests for on_print_complete() — path ordering and interaction."""
  132. @pytest.fixture(autouse=True)
  133. def _clear_sessions(self):
  134. _active_sessions.clear()
  135. yield
  136. _active_sessions.clear()
  137. @pytest.fixture(autouse=True)
  138. def _mock_get_setting(self):
  139. with patch(
  140. "backend.app.api.routes.settings.get_setting",
  141. new_callable=AsyncMock,
  142. return_value=None,
  143. ):
  144. yield
  145. @pytest.mark.asyncio
  146. async def test_bl_spool_uses_3mf(self):
  147. """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
  148. spool = _make_spool(spool_id=1, tag_uid="AABB1122", label_weight=1000)
  149. assignment = _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0)
  150. archive = _make_archive(archive_id=10)
  151. # Setup: session with AMS remain data
  152. _active_sessions[1] = PrintSession(
  153. printer_id=1,
  154. print_name="Benchy",
  155. started_at=datetime.now(timezone.utc),
  156. tray_remain_start={(0, 0): 80},
  157. )
  158. # Mock printer state: tray_now=0 (AMS0-T0), single filament
  159. printer_manager = MagicMock()
  160. printer_manager.get_status.return_value = SimpleNamespace(
  161. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  162. progress=100,
  163. layer_num=50,
  164. tray_now=0,
  165. )
  166. # db returns: archive, queue_item(None), assignment, spool
  167. db = _mock_db_sequential([archive, None, assignment, spool])
  168. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  169. with (
  170. patch("backend.app.core.config.settings") as mock_settings,
  171. patch(
  172. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  173. return_value=filament_usage,
  174. ),
  175. ):
  176. mock_settings.base_dir = MagicMock()
  177. mock_path = MagicMock()
  178. mock_path.exists.return_value = True
  179. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  180. results = await on_print_complete(
  181. printer_id=1,
  182. data={"status": "completed"},
  183. printer_manager=printer_manager,
  184. db=db,
  185. archive_id=10,
  186. )
  187. # 3MF path should handle it (BL guard removed)
  188. assert len(results) >= 1
  189. assert results[0]["spool_id"] == 1
  190. assert results[0]["weight_used"] == 15.0
  191. @pytest.mark.asyncio
  192. async def test_ams_delta_fallback_no_archive(self):
  193. """AMS delta tracks consumption when archive_id is None."""
  194. spool = _make_spool(spool_id=2, label_weight=1000)
  195. assignment = _make_assignment(spool_id=2)
  196. _active_sessions[1] = PrintSession(
  197. printer_id=1,
  198. print_name="Test",
  199. started_at=datetime.now(timezone.utc),
  200. tray_remain_start={(0, 0): 80},
  201. )
  202. printer_manager = MagicMock()
  203. printer_manager.get_status.return_value = SimpleNamespace(
  204. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  205. tray_now=0,
  206. last_loaded_tray=-1,
  207. )
  208. # db returns assignment then spool
  209. db = _mock_db_sequential([assignment, spool])
  210. results = await on_print_complete(
  211. printer_id=1,
  212. data={"status": "completed"},
  213. printer_manager=printer_manager,
  214. db=db,
  215. archive_id=None,
  216. )
  217. assert len(results) == 1
  218. assert results[0]["spool_id"] == 2
  219. # 10% of 1000g = 100g
  220. assert results[0]["weight_used"] == 100.0
  221. assert results[0]["percent_used"] == 10
  222. @pytest.mark.asyncio
  223. async def test_no_double_tracking(self):
  224. """When 3MF handles a tray, AMS delta skips it."""
  225. spool = _make_spool(spool_id=1, label_weight=1000)
  226. assignment = _make_assignment(spool_id=1)
  227. archive = _make_archive(archive_id=10)
  228. _active_sessions[1] = PrintSession(
  229. printer_id=1,
  230. print_name="Benchy",
  231. started_at=datetime.now(timezone.utc),
  232. tray_remain_start={(0, 0): 80},
  233. )
  234. # tray_now=0 matches the single filament slot
  235. printer_manager = MagicMock()
  236. printer_manager.get_status.return_value = SimpleNamespace(
  237. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  238. progress=100,
  239. layer_num=50,
  240. tray_now=0,
  241. )
  242. # db returns: archive, queue_item(None), assignment, spool
  243. db = _mock_db_sequential([archive, None, assignment, spool])
  244. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  245. with (
  246. patch("backend.app.core.config.settings") as mock_settings,
  247. patch(
  248. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  249. return_value=filament_usage,
  250. ),
  251. ):
  252. mock_settings.base_dir = MagicMock()
  253. mock_path = MagicMock()
  254. mock_path.exists.return_value = True
  255. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  256. results = await on_print_complete(
  257. printer_id=1,
  258. data={"status": "completed"},
  259. printer_manager=printer_manager,
  260. db=db,
  261. archive_id=10,
  262. )
  263. # Only 1 result (3MF), NOT 2 (3MF + AMS delta)
  264. assert len(results) == 1
  265. assert results[0]["weight_used"] == 15.0
  266. class TestTrackFrom3mf:
  267. """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
  268. @pytest.mark.asyncio
  269. async def test_prefers_live_assignment_when_reassigned_mid_print(self):
  270. """If tray assignment changed during print, track usage on the new spool."""
  271. spool_old = _make_spool(spool_id=1, label_weight=1000)
  272. spool_new = _make_spool(spool_id=2, label_weight=1000)
  273. archive = _make_archive(archive_id=80)
  274. live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
  275. started_at = datetime.now(timezone.utc)
  276. live_assignment.created_at = started_at + timedelta(seconds=5)
  277. # db: archive, queue_item(None), live assignment lookup, spool_new lookup
  278. db = _mock_db_sequential([archive, None, live_assignment, spool_new])
  279. printer_manager = MagicMock()
  280. printer_manager.get_status.return_value = SimpleNamespace(
  281. progress=100,
  282. layer_num=50,
  283. tray_now=0,
  284. )
  285. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
  286. handled_trays: set[tuple[int, int]] = set()
  287. with (
  288. patch("backend.app.core.config.settings") as mock_settings,
  289. patch(
  290. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  291. return_value=filament_usage,
  292. ),
  293. ):
  294. mock_settings.base_dir = MagicMock()
  295. mock_path = MagicMock()
  296. mock_path.exists.return_value = True
  297. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  298. results = await _track_from_3mf(
  299. printer_id=1,
  300. archive_id=80,
  301. status="completed",
  302. print_name="MidPrintReassign",
  303. handled_trays=handled_trays,
  304. printer_manager=printer_manager,
  305. db=db,
  306. spool_assignments={(0, 0): spool_old.id},
  307. print_started_at=started_at,
  308. )
  309. assert len(results) == 1
  310. assert results[0]["spool_id"] == spool_new.id
  311. @pytest.mark.asyncio
  312. async def test_keeps_snapshot_when_live_assignment_predates_print(self):
  313. """If live assignment predates print start, preserve snapshot spool mapping."""
  314. spool_old = _make_spool(spool_id=1, label_weight=1000)
  315. archive = _make_archive(archive_id=81)
  316. live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
  317. started_at = datetime.now(timezone.utc)
  318. live_assignment.created_at = started_at - timedelta(seconds=5)
  319. # db: archive, queue_item(None), live assignment lookup, spool_old lookup
  320. db = _mock_db_sequential([archive, None, live_assignment, spool_old])
  321. printer_manager = MagicMock()
  322. printer_manager.get_status.return_value = SimpleNamespace(
  323. progress=100,
  324. layer_num=50,
  325. tray_now=0,
  326. )
  327. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
  328. handled_trays: set[tuple[int, int]] = set()
  329. with (
  330. patch("backend.app.core.config.settings") as mock_settings,
  331. patch(
  332. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  333. return_value=filament_usage,
  334. ),
  335. ):
  336. mock_settings.base_dir = MagicMock()
  337. mock_path = MagicMock()
  338. mock_path.exists.return_value = True
  339. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  340. results = await _track_from_3mf(
  341. printer_id=1,
  342. archive_id=81,
  343. status="completed",
  344. print_name="SnapshotPreserved",
  345. handled_trays=handled_trays,
  346. printer_manager=printer_manager,
  347. db=db,
  348. spool_assignments={(0, 0): spool_old.id},
  349. print_started_at=started_at,
  350. )
  351. assert len(results) == 1
  352. assert results[0]["spool_id"] == spool_old.id
  353. @pytest.mark.asyncio
  354. async def test_linear_fallback_for_partial_print(self):
  355. """Falls back to linear scaling when gcode layer data unavailable."""
  356. spool = _make_spool(spool_id=1, label_weight=1000)
  357. assignment = _make_assignment(spool_id=1)
  358. archive = _make_archive(archive_id=10)
  359. # db: archive, queue_item(None), assignment, spool
  360. db = _mock_db_sequential([archive, None, assignment, spool])
  361. printer_manager = MagicMock()
  362. printer_manager.get_status.return_value = SimpleNamespace(
  363. progress=50,
  364. layer_num=25,
  365. tray_now=0,
  366. )
  367. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  368. handled_trays: set[tuple[int, int]] = set()
  369. with (
  370. patch("backend.app.core.config.settings") as mock_settings,
  371. patch(
  372. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  373. return_value=filament_usage,
  374. ),
  375. patch(
  376. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  377. return_value=None, # No layer data available
  378. ),
  379. ):
  380. mock_settings.base_dir = MagicMock()
  381. mock_path = MagicMock()
  382. mock_path.exists.return_value = True
  383. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  384. results = await _track_from_3mf(
  385. printer_id=1,
  386. archive_id=10,
  387. status="failed",
  388. print_name="Benchy",
  389. handled_trays=handled_trays,
  390. printer_manager=printer_manager,
  391. db=db,
  392. )
  393. assert len(results) == 1
  394. # 50% of 20g = 10g
  395. assert results[0]["weight_used"] == 10.0
  396. # Tray should be marked as handled
  397. assert (0, 0) in handled_trays
  398. @pytest.mark.asyncio
  399. async def test_per_layer_partial_print(self):
  400. """Failed print at layer N uses gcode cumulative data."""
  401. spool = _make_spool(spool_id=1, label_weight=1000)
  402. assignment = _make_assignment(spool_id=1)
  403. archive = _make_archive(archive_id=10)
  404. # db: archive, queue_item(None), assignment, spool
  405. db = _mock_db_sequential([archive, None, assignment, spool])
  406. printer_manager = MagicMock()
  407. printer_manager.get_status.return_value = SimpleNamespace(
  408. progress=50,
  409. layer_num=25,
  410. tray_now=0,
  411. )
  412. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  413. # Per-layer data: at layer 25, filament 0 used 5000mm
  414. layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}}
  415. filament_props = {1: {"density": 1.24, "diameter": 1.75}}
  416. handled_trays: set[tuple[int, int]] = set()
  417. with (
  418. patch("backend.app.core.config.settings") as mock_settings,
  419. patch(
  420. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  421. return_value=filament_usage,
  422. ),
  423. patch(
  424. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  425. return_value=layer_data,
  426. ),
  427. patch(
  428. "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
  429. return_value={0: 5000.0},
  430. ),
  431. patch(
  432. "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
  433. return_value=filament_props,
  434. ),
  435. patch(
  436. "backend.app.utils.threemf_tools.mm_to_grams",
  437. return_value=12.0, # 5000mm at 1.75mm/1.24g/cm3
  438. ),
  439. ):
  440. mock_settings.base_dir = MagicMock()
  441. mock_path = MagicMock()
  442. mock_path.exists.return_value = True
  443. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  444. results = await _track_from_3mf(
  445. printer_id=1,
  446. archive_id=10,
  447. status="failed",
  448. print_name="Benchy",
  449. handled_trays=handled_trays,
  450. printer_manager=printer_manager,
  451. db=db,
  452. )
  453. assert len(results) == 1
  454. # Should use per-layer grams (12.0g), not linear scale (10.0g)
  455. assert results[0]["weight_used"] == 12.0
  456. @pytest.mark.asyncio
  457. async def test_completed_print_uses_full_weight(self):
  458. """Completed print uses full 3MF weight (scale=1.0)."""
  459. spool = _make_spool(spool_id=1, label_weight=1000)
  460. assignment = _make_assignment(spool_id=1)
  461. archive = _make_archive(archive_id=10)
  462. # db: archive, queue_item(None), assignment, spool
  463. db = _mock_db_sequential([archive, None, assignment, spool])
  464. printer_manager = MagicMock()
  465. printer_manager.get_status.return_value = SimpleNamespace(
  466. progress=100,
  467. layer_num=50,
  468. tray_now=0,
  469. )
  470. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  471. handled_trays: set[tuple[int, int]] = set()
  472. with (
  473. patch("backend.app.core.config.settings") as mock_settings,
  474. patch(
  475. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  476. return_value=filament_usage,
  477. ),
  478. ):
  479. mock_settings.base_dir = MagicMock()
  480. mock_path = MagicMock()
  481. mock_path.exists.return_value = True
  482. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  483. results = await _track_from_3mf(
  484. printer_id=1,
  485. archive_id=10,
  486. status="completed",
  487. print_name="Benchy",
  488. handled_trays=handled_trays,
  489. printer_manager=printer_manager,
  490. db=db,
  491. )
  492. assert len(results) == 1
  493. assert results[0]["weight_used"] == 20.0
  494. @pytest.mark.asyncio
  495. async def test_tray_now_override_for_single_filament(self):
  496. """Single-filament non-queue print uses tray_now instead of slot_id mapping."""
  497. # Spool 2 is at AMS1-T3 (global_tray_id=7)
  498. spool = _make_spool(spool_id=2, label_weight=1000)
  499. assignment = _make_assignment(spool_id=2, ams_id=1, tray_id=3)
  500. archive = _make_archive(archive_id=10)
  501. # db: archive, queue_item(None), assignment, spool
  502. db = _mock_db_sequential([archive, None, assignment, spool])
  503. # tray_now=7 = (ams_id=1, tray_id=3), the ACTUAL tray used
  504. printer_manager = MagicMock()
  505. printer_manager.get_status.return_value = SimpleNamespace(
  506. progress=100,
  507. layer_num=50,
  508. tray_now=7,
  509. )
  510. # 3MF has slot_id=12 (would default-map to ams_id=2, tray_id=3 — WRONG)
  511. filament_usage = [{"slot_id": 12, "used_g": 10.6, "type": "PLA", "color": "#FF0000"}]
  512. handled_trays: set[tuple[int, int]] = set()
  513. with (
  514. patch("backend.app.core.config.settings") as mock_settings,
  515. patch(
  516. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  517. return_value=filament_usage,
  518. ),
  519. ):
  520. mock_settings.base_dir = MagicMock()
  521. mock_path = MagicMock()
  522. mock_path.exists.return_value = True
  523. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  524. results = await _track_from_3mf(
  525. printer_id=1,
  526. archive_id=10,
  527. status="completed",
  528. print_name="Test",
  529. handled_trays=handled_trays,
  530. printer_manager=printer_manager,
  531. db=db,
  532. )
  533. assert len(results) == 1
  534. assert results[0]["spool_id"] == 2
  535. assert results[0]["ams_id"] == 1
  536. assert results[0]["tray_id"] == 3
  537. assert results[0]["weight_used"] == 10.6
  538. assert (1, 3) in handled_trays
  539. @pytest.mark.asyncio
  540. async def test_queue_ams_mapping_overrides_default(self):
  541. """Queue item ams_mapping overrides default slot_id mapping."""
  542. # Spool at AMS1-T3 (global_tray_id=7)
  543. spool = _make_spool(spool_id=5, label_weight=1000)
  544. assignment = _make_assignment(spool_id=5, ams_id=1, tray_id=3)
  545. archive = _make_archive(archive_id=20)
  546. # Queue item maps slot 1 → global tray 7 (ams_id=1, tray_id=3)
  547. queue_item = _make_queue_item(ams_mapping="[7, -1, -1, -1]")
  548. # db: archive, queue_item, assignment, spool
  549. db = _mock_db_sequential([archive, queue_item, assignment, spool])
  550. printer_manager = MagicMock()
  551. printer_manager.get_status.return_value = SimpleNamespace(
  552. progress=100,
  553. layer_num=50,
  554. tray_now=7,
  555. )
  556. filament_usage = [{"slot_id": 1, "used_g": 25.0, "type": "PETG", "color": ""}]
  557. handled_trays: set[tuple[int, int]] = set()
  558. with (
  559. patch("backend.app.core.config.settings") as mock_settings,
  560. patch(
  561. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  562. return_value=filament_usage,
  563. ),
  564. ):
  565. mock_settings.base_dir = MagicMock()
  566. mock_path = MagicMock()
  567. mock_path.exists.return_value = True
  568. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  569. results = await _track_from_3mf(
  570. printer_id=1,
  571. archive_id=20,
  572. status="completed",
  573. print_name="Queue Print",
  574. handled_trays=handled_trays,
  575. printer_manager=printer_manager,
  576. db=db,
  577. )
  578. assert len(results) == 1
  579. assert results[0]["spool_id"] == 5
  580. assert results[0]["ams_id"] == 1
  581. assert results[0]["tray_id"] == 3
  582. assert results[0]["weight_used"] == 25.0
  583. @pytest.mark.asyncio
  584. async def test_multi_filament_uses_queue_mapping(self):
  585. """Multi-filament queue prints use ams_mapping for each slot."""
  586. spool_a = _make_spool(spool_id=1, label_weight=1000)
  587. spool_b = _make_spool(spool_id=2, label_weight=1000)
  588. assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  589. assign_b = _make_assignment(spool_id=2, ams_id=1, tray_id=2)
  590. archive = _make_archive(archive_id=30)
  591. # slot 1 → tray 0 (AMS0-T0), slot 2 → tray 6 (AMS1-T2)
  592. queue_item = _make_queue_item(ams_mapping="[0, 6]")
  593. # db: archive, queue_item, assign_a, spool_a, assign_b, spool_b
  594. db = _mock_db_sequential([archive, queue_item, assign_a, spool_a, assign_b, spool_b])
  595. printer_manager = MagicMock()
  596. printer_manager.get_status.return_value = SimpleNamespace(
  597. progress=100,
  598. layer_num=50,
  599. tray_now=6,
  600. )
  601. filament_usage = [
  602. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
  603. {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
  604. ]
  605. handled_trays: set[tuple[int, int]] = set()
  606. with (
  607. patch("backend.app.core.config.settings") as mock_settings,
  608. patch(
  609. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  610. return_value=filament_usage,
  611. ),
  612. ):
  613. mock_settings.base_dir = MagicMock()
  614. mock_path = MagicMock()
  615. mock_path.exists.return_value = True
  616. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  617. results = await _track_from_3mf(
  618. printer_id=1,
  619. archive_id=30,
  620. status="completed",
  621. print_name="Multi",
  622. handled_trays=handled_trays,
  623. printer_manager=printer_manager,
  624. db=db,
  625. )
  626. assert len(results) == 2
  627. assert results[0]["spool_id"] == 1
  628. assert results[0]["ams_id"] == 0
  629. assert results[0]["tray_id"] == 0
  630. assert results[0]["weight_used"] == 10.0
  631. assert results[1]["spool_id"] == 2
  632. assert results[1]["ams_id"] == 1
  633. assert results[1]["tray_id"] == 2
  634. assert results[1]["weight_used"] == 5.0
  635. @pytest.mark.asyncio
  636. async def test_no_tray_now_override_for_multi_filament(self):
  637. """Multi-filament non-queue prints fall back to default mapping, not tray_now."""
  638. spool = _make_spool(spool_id=1, label_weight=1000)
  639. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  640. archive = _make_archive(archive_id=10)
  641. # db: archive, queue_item(None), assignment, spool (2nd slot has no assignment)
  642. db = _mock_db_sequential([archive, None, assignment, spool, None])
  643. printer_manager = MagicMock()
  644. printer_manager.get_status.return_value = SimpleNamespace(
  645. progress=100,
  646. layer_num=50,
  647. tray_now=4, # tray_now won't be used
  648. )
  649. # Two filament slots with usage
  650. filament_usage = [
  651. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
  652. {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
  653. ]
  654. handled_trays: set[tuple[int, int]] = set()
  655. with (
  656. patch("backend.app.core.config.settings") as mock_settings,
  657. patch(
  658. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  659. return_value=filament_usage,
  660. ),
  661. ):
  662. mock_settings.base_dir = MagicMock()
  663. mock_path = MagicMock()
  664. mock_path.exists.return_value = True
  665. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  666. results = await _track_from_3mf(
  667. printer_id=1,
  668. archive_id=10,
  669. status="completed",
  670. print_name="Test",
  671. handled_trays=handled_trays,
  672. printer_manager=printer_manager,
  673. db=db,
  674. )
  675. # Should use default mapping (slot 1 → tray 0, slot 2 → tray 1)
  676. assert len(results) == 1 # Only slot 1 has assignment
  677. assert results[0]["ams_id"] == 0
  678. assert results[0]["tray_id"] == 0
  679. @pytest.mark.asyncio
  680. async def test_stored_ams_mapping_overrides_all(self):
  681. """Stored ams_mapping from print command takes priority over queue and tray_now."""
  682. # Spool at AMS2-T1 (global_tray_id=9)
  683. spool = _make_spool(spool_id=10, label_weight=1000)
  684. assignment = _make_assignment(spool_id=10, ams_id=2, tray_id=1)
  685. archive = _make_archive(archive_id=50)
  686. # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
  687. db = _mock_db_sequential([archive, assignment, spool])
  688. printer_manager = MagicMock()
  689. printer_manager.get_status.return_value = SimpleNamespace(
  690. progress=100,
  691. layer_num=50,
  692. tray_now=0, # Different from mapped tray — should be ignored
  693. last_loaded_tray=0,
  694. )
  695. filament_usage = [{"slot_id": 2, "used_g": 1.57, "type": "PLA", "color": "#FFFFFF"}]
  696. handled_trays: set[tuple[int, int]] = set()
  697. with (
  698. patch("backend.app.core.config.settings") as mock_settings,
  699. patch(
  700. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  701. return_value=filament_usage,
  702. ),
  703. ):
  704. mock_settings.base_dir = MagicMock()
  705. mock_path = MagicMock()
  706. mock_path.exists.return_value = True
  707. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  708. # ams_mapping: slot 2 (index 1) -> tray 9 (AMS2-T1)
  709. results = await _track_from_3mf(
  710. printer_id=1,
  711. archive_id=50,
  712. status="completed",
  713. print_name="Test",
  714. handled_trays=handled_trays,
  715. printer_manager=printer_manager,
  716. db=db,
  717. ams_mapping=[-1, 9],
  718. )
  719. assert len(results) == 1
  720. assert results[0]["spool_id"] == 10
  721. assert results[0]["ams_id"] == 2
  722. assert results[0]["tray_id"] == 1
  723. assert results[0]["weight_used"] == 1.6 # rounded
  724. @pytest.mark.asyncio
  725. async def test_last_loaded_tray_fallback(self):
  726. """Falls back to last_loaded_tray when tray_now_at_start and current tray_now are both 255."""
  727. # Spool at AMS2-T1 (global_tray_id=9)
  728. spool = _make_spool(spool_id=11, label_weight=1000)
  729. assignment = _make_assignment(spool_id=11, ams_id=2, tray_id=1)
  730. archive = _make_archive(archive_id=60)
  731. # db: archive, queue_item(None), assignment, spool
  732. db = _mock_db_sequential([archive, None, assignment, spool])
  733. # H2D scenario: tray_now=255 at completion, but last_loaded_tray=9
  734. printer_manager = MagicMock()
  735. printer_manager.get_status.return_value = SimpleNamespace(
  736. progress=100,
  737. layer_num=50,
  738. tray_now=255,
  739. last_loaded_tray=9,
  740. )
  741. filament_usage = [{"slot_id": 6, "used_g": 1.52, "type": "PLA", "color": "#7CC4D5"}]
  742. handled_trays: set[tuple[int, int]] = set()
  743. with (
  744. patch("backend.app.core.config.settings") as mock_settings,
  745. patch(
  746. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  747. return_value=filament_usage,
  748. ),
  749. ):
  750. mock_settings.base_dir = MagicMock()
  751. mock_path = MagicMock()
  752. mock_path.exists.return_value = True
  753. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  754. results = await _track_from_3mf(
  755. printer_id=1,
  756. archive_id=60,
  757. status="completed",
  758. print_name="Cube",
  759. handled_trays=handled_trays,
  760. printer_manager=printer_manager,
  761. db=db,
  762. tray_now_at_start=255, # H2D: 255 at start too
  763. )
  764. assert len(results) == 1
  765. assert results[0]["spool_id"] == 11
  766. assert results[0]["ams_id"] == 2
  767. assert results[0]["tray_id"] == 1
  768. @pytest.mark.asyncio
  769. async def test_tray_now_at_start_preferred_over_last_loaded(self):
  770. """tray_now_at_start is used before last_loaded_tray fallback."""
  771. spool = _make_spool(spool_id=3, label_weight=1000)
  772. assignment = _make_assignment(spool_id=3, ams_id=1, tray_id=1)
  773. archive = _make_archive(archive_id=70)
  774. db = _mock_db_sequential([archive, None, assignment, spool])
  775. # tray_now_at_start=5 (valid), last_loaded_tray=9 (different) — should use 5
  776. printer_manager = MagicMock()
  777. printer_manager.get_status.return_value = SimpleNamespace(
  778. progress=100,
  779. layer_num=50,
  780. tray_now=255,
  781. last_loaded_tray=9,
  782. )
  783. filament_usage = [{"slot_id": 1, "used_g": 5.0, "type": "PLA", "color": ""}]
  784. handled_trays: set[tuple[int, int]] = set()
  785. with (
  786. patch("backend.app.core.config.settings") as mock_settings,
  787. patch(
  788. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  789. return_value=filament_usage,
  790. ),
  791. ):
  792. mock_settings.base_dir = MagicMock()
  793. mock_path = MagicMock()
  794. mock_path.exists.return_value = True
  795. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  796. results = await _track_from_3mf(
  797. printer_id=1,
  798. archive_id=70,
  799. status="completed",
  800. print_name="Test",
  801. handled_trays=handled_trays,
  802. printer_manager=printer_manager,
  803. db=db,
  804. tray_now_at_start=5, # AMS1-T1
  805. )
  806. assert len(results) == 1
  807. assert results[0]["ams_id"] == 1
  808. assert results[0]["tray_id"] == 1
  809. class TestTrayChangeSplit:
  810. """Tests for mid-print tray switch weight splitting in _track_from_3mf()."""
  811. @pytest.mark.asyncio
  812. async def test_tray_switch_splits_weight_with_gcode(self):
  813. """Two-tray runout: weight split using per-layer gcode data."""
  814. spool_a = _make_spool(spool_id=10, label_weight=1000)
  815. spool_b = _make_spool(spool_id=20, label_weight=1000)
  816. assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=1)
  817. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=0)
  818. archive = _make_archive(archive_id=100)
  819. # db: archive, queue_item(None), then for each segment: assignment, spool
  820. db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
  821. # Tray change log: started on tray 1, switched to tray 0 at layer 60
  822. printer_manager = MagicMock()
  823. printer_manager.get_status.return_value = SimpleNamespace(
  824. progress=100,
  825. layer_num=100,
  826. tray_now=0,
  827. last_loaded_tray=0,
  828. total_layers=100,
  829. tray_change_log=[(1, 0), (0, 60)],
  830. )
  831. filament_usage = [{"slot_id": 1, "used_g": 30.0, "type": "PLA", "color": ""}]
  832. handled_trays: set[tuple[int, int]] = set()
  833. with (
  834. patch("backend.app.core.config.settings") as mock_settings,
  835. patch(
  836. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  837. return_value=filament_usage,
  838. ),
  839. patch(
  840. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  841. return_value={30: {0: 3000.0}, 60: {0: 6000.0}, 100: {0: 10000.0}},
  842. ),
  843. patch(
  844. "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
  845. side_effect=lambda data, layer: {0: {0: 0.0, 60: 6000.0, 100: 10000.0}.get(layer, 0.0)},
  846. ),
  847. patch(
  848. "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
  849. return_value={1: {"density": 1.24, "diameter": 1.75}},
  850. ),
  851. patch(
  852. "backend.app.utils.threemf_tools.mm_to_grams",
  853. side_effect=lambda mm, d, dens: round(mm * 0.003, 1), # Simple conversion
  854. ),
  855. ):
  856. mock_settings.base_dir = MagicMock()
  857. mock_path = MagicMock()
  858. mock_path.exists.return_value = True
  859. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  860. results = await _track_from_3mf(
  861. printer_id=1,
  862. archive_id=100,
  863. status="completed",
  864. print_name="Runout Test",
  865. handled_trays=handled_trays,
  866. printer_manager=printer_manager,
  867. db=db,
  868. )
  869. # Two results: one per tray segment
  870. assert len(results) == 2
  871. # First segment: tray 1 (AMS0-T1), layers 0→60
  872. assert results[0]["ams_id"] == 0
  873. assert results[0]["tray_id"] == 1
  874. assert results[0]["spool_id"] == 10
  875. assert results[0]["weight_used"] == 18.0 # 6000mm * 0.003
  876. # Second segment: tray 0 (AMS0-T0), layers 60→end = 30.0 - 18.0 = 12.0
  877. assert results[1]["ams_id"] == 0
  878. assert results[1]["tray_id"] == 0
  879. assert results[1]["spool_id"] == 20
  880. assert results[1]["weight_used"] == 12.0
  881. # Both trays handled
  882. assert (0, 1) in handled_trays
  883. assert (0, 0) in handled_trays
  884. @pytest.mark.asyncio
  885. async def test_tray_switch_linear_fallback(self):
  886. """Two-tray runout without per-layer gcode: linear split by layer ratio."""
  887. spool_a = _make_spool(spool_id=10, label_weight=1000)
  888. spool_b = _make_spool(spool_id=20, label_weight=1000)
  889. assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=2)
  890. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)
  891. archive = _make_archive(archive_id=101)
  892. db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
  893. # Tray 2 from layer 0, switched to tray 1 at layer 40 (of 100 total)
  894. printer_manager = MagicMock()
  895. printer_manager.get_status.return_value = SimpleNamespace(
  896. progress=100,
  897. layer_num=100,
  898. tray_now=1,
  899. last_loaded_tray=1,
  900. total_layers=100,
  901. tray_change_log=[(2, 0), (1, 40)],
  902. )
  903. filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
  904. handled_trays: set[tuple[int, int]] = set()
  905. with (
  906. patch("backend.app.core.config.settings") as mock_settings,
  907. patch(
  908. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  909. return_value=filament_usage,
  910. ),
  911. patch(
  912. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  913. return_value=None, # No per-layer gcode available
  914. ),
  915. ):
  916. mock_settings.base_dir = MagicMock()
  917. mock_path = MagicMock()
  918. mock_path.exists.return_value = True
  919. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  920. results = await _track_from_3mf(
  921. printer_id=1,
  922. archive_id=101,
  923. status="completed",
  924. print_name="Linear Fallback",
  925. handled_trays=handled_trays,
  926. printer_manager=printer_manager,
  927. db=db,
  928. )
  929. assert len(results) == 2
  930. # Linear split: tray 2 for 40/100 layers = 20g
  931. assert results[0]["ams_id"] == 0
  932. assert results[0]["tray_id"] == 2
  933. assert results[0]["weight_used"] == 20.0
  934. # Last segment gets remainder: 50 - 20 = 30g
  935. assert results[1]["ams_id"] == 0
  936. assert results[1]["tray_id"] == 1
  937. assert results[1]["weight_used"] == 30.0
  938. @pytest.mark.asyncio
  939. async def test_no_tray_change_uses_normal_path(self):
  940. """Single-entry tray_change_log falls through to normal tray_now_at_start logic."""
  941. spool = _make_spool(spool_id=1, label_weight=1000)
  942. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
  943. archive = _make_archive(archive_id=102)
  944. db = _mock_db_sequential([archive, None, assignment, spool])
  945. # Only one entry = no switch, should use normal path
  946. printer_manager = MagicMock()
  947. printer_manager.get_status.return_value = SimpleNamespace(
  948. progress=100,
  949. layer_num=100,
  950. tray_now=2,
  951. last_loaded_tray=2,
  952. total_layers=100,
  953. tray_change_log=[(2, 0)],
  954. )
  955. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": ""}]
  956. handled_trays: set[tuple[int, int]] = set()
  957. with (
  958. patch("backend.app.core.config.settings") as mock_settings,
  959. patch(
  960. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  961. return_value=filament_usage,
  962. ),
  963. ):
  964. mock_settings.base_dir = MagicMock()
  965. mock_path = MagicMock()
  966. mock_path.exists.return_value = True
  967. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  968. results = await _track_from_3mf(
  969. printer_id=1,
  970. archive_id=102,
  971. status="completed",
  972. print_name="No Switch",
  973. handled_trays=handled_trays,
  974. printer_manager=printer_manager,
  975. db=db,
  976. tray_now_at_start=2,
  977. )
  978. # Normal path: single result, full weight
  979. assert len(results) == 1
  980. assert results[0]["weight_used"] == 15.0
  981. assert results[0]["ams_id"] == 0
  982. assert results[0]["tray_id"] == 2
  983. @pytest.mark.asyncio
  984. async def test_empty_tray_change_log_uses_normal_path(self):
  985. """Empty tray_change_log (e.g. server restart) falls through to existing logic."""
  986. spool = _make_spool(spool_id=1, label_weight=1000)
  987. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  988. archive = _make_archive(archive_id=103)
  989. db = _mock_db_sequential([archive, None, assignment, spool])
  990. # Empty log (server restarted mid-print)
  991. printer_manager = MagicMock()
  992. printer_manager.get_status.return_value = SimpleNamespace(
  993. progress=100,
  994. layer_num=100,
  995. tray_now=0,
  996. last_loaded_tray=0,
  997. total_layers=100,
  998. tray_change_log=[],
  999. )
  1000. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
  1001. handled_trays: set[tuple[int, int]] = set()
  1002. with (
  1003. patch("backend.app.core.config.settings") as mock_settings,
  1004. patch(
  1005. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1006. return_value=filament_usage,
  1007. ),
  1008. ):
  1009. mock_settings.base_dir = MagicMock()
  1010. mock_path = MagicMock()
  1011. mock_path.exists.return_value = True
  1012. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1013. results = await _track_from_3mf(
  1014. printer_id=1,
  1015. archive_id=103,
  1016. status="completed",
  1017. print_name="Restart Recovery",
  1018. handled_trays=handled_trays,
  1019. printer_manager=printer_manager,
  1020. db=db,
  1021. tray_now_at_start=0,
  1022. )
  1023. assert len(results) == 1
  1024. assert results[0]["weight_used"] == 10.0
  1025. @pytest.mark.asyncio
  1026. async def test_tray_switch_segment_no_spool(self):
  1027. """Segment with no spool assignment is skipped; other segments still tracked."""
  1028. spool_b = _make_spool(spool_id=20, label_weight=1000)
  1029. assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=3)
  1030. archive = _make_archive(archive_id=104)
  1031. # db: archive, queue_item(None), 1st segment: no assignment, 2nd segment: assignment, spool
  1032. db = _mock_db_sequential([archive, None, None, assign_b, spool_b])
  1033. # Tray 5 (no spool) from layer 0, switched to tray 3 at layer 50
  1034. printer_manager = MagicMock()
  1035. printer_manager.get_status.return_value = SimpleNamespace(
  1036. progress=100,
  1037. layer_num=100,
  1038. tray_now=3,
  1039. last_loaded_tray=3,
  1040. total_layers=100,
  1041. tray_change_log=[(5, 0), (3, 50)],
  1042. )
  1043. filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": ""}]
  1044. handled_trays: set[tuple[int, int]] = set()
  1045. with (
  1046. patch("backend.app.core.config.settings") as mock_settings,
  1047. patch(
  1048. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1049. return_value=filament_usage,
  1050. ),
  1051. patch(
  1052. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  1053. return_value=None, # No per-layer data
  1054. ),
  1055. ):
  1056. mock_settings.base_dir = MagicMock()
  1057. mock_path = MagicMock()
  1058. mock_path.exists.return_value = True
  1059. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1060. results = await _track_from_3mf(
  1061. printer_id=1,
  1062. archive_id=104,
  1063. status="completed",
  1064. print_name="Missing Spool",
  1065. handled_trays=handled_trays,
  1066. printer_manager=printer_manager,
  1067. db=db,
  1068. )
  1069. # Only the second segment (tray 3) tracked; first segment (tray 5) skipped
  1070. assert len(results) == 1
  1071. assert results[0]["ams_id"] == 0
  1072. assert results[0]["tray_id"] == 3
  1073. assert results[0]["spool_id"] == 20
  1074. @pytest.mark.asyncio
  1075. async def test_tray_switch_three_segments(self):
  1076. """Three-segment switch (rare): A→B→C split by linear fallback."""
  1077. spool_a = _make_spool(spool_id=1, label_weight=1000)
  1078. spool_b = _make_spool(spool_id=2, label_weight=1000)
  1079. spool_c = _make_spool(spool_id=3, label_weight=1000)
  1080. assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  1081. assign_b = _make_assignment(spool_id=2, ams_id=0, tray_id=1)
  1082. assign_c = _make_assignment(spool_id=3, ams_id=0, tray_id=2)
  1083. archive = _make_archive(archive_id=105)
  1084. db = _mock_db_sequential(
  1085. [
  1086. archive,
  1087. None,
  1088. assign_a,
  1089. spool_a,
  1090. assign_b,
  1091. spool_b,
  1092. assign_c,
  1093. spool_c,
  1094. ]
  1095. )
  1096. # 3 segments: tray 0 (0-30), tray 1 (30-70), tray 2 (70-end)
  1097. printer_manager = MagicMock()
  1098. printer_manager.get_status.return_value = SimpleNamespace(
  1099. progress=100,
  1100. layer_num=100,
  1101. tray_now=2,
  1102. last_loaded_tray=2,
  1103. total_layers=100,
  1104. tray_change_log=[(0, 0), (1, 30), (2, 70)],
  1105. )
  1106. filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
  1107. handled_trays: set[tuple[int, int]] = set()
  1108. with (
  1109. patch("backend.app.core.config.settings") as mock_settings,
  1110. patch(
  1111. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1112. return_value=filament_usage,
  1113. ),
  1114. patch(
  1115. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  1116. return_value=None,
  1117. ),
  1118. ):
  1119. mock_settings.base_dir = MagicMock()
  1120. mock_path = MagicMock()
  1121. mock_path.exists.return_value = True
  1122. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1123. results = await _track_from_3mf(
  1124. printer_id=1,
  1125. archive_id=105,
  1126. status="completed",
  1127. print_name="Triple Switch",
  1128. handled_trays=handled_trays,
  1129. printer_manager=printer_manager,
  1130. db=db,
  1131. )
  1132. assert len(results) == 3
  1133. # Tray 0: 30/100 * 100g = 30g
  1134. assert results[0]["weight_used"] == 30.0
  1135. assert results[0]["ams_id"] == 0
  1136. assert results[0]["tray_id"] == 0
  1137. # Tray 1: 40/100 * 100g = 40g
  1138. assert results[1]["weight_used"] == 40.0
  1139. assert results[1]["ams_id"] == 0
  1140. assert results[1]["tray_id"] == 1
  1141. # Tray 2: remainder = 100 - 30 - 40 = 30g
  1142. assert results[2]["weight_used"] == 30.0
  1143. assert results[2]["ams_id"] == 0
  1144. assert results[2]["tray_id"] == 2
  1145. class TestDecodeMqttMapping:
  1146. """Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs."""
  1147. def test_none_input(self):
  1148. assert _decode_mqtt_mapping(None) is None
  1149. def test_empty_list(self):
  1150. assert _decode_mqtt_mapping([]) is None
  1151. def test_all_unmapped(self):
  1152. """All 65535 values → None (no valid mappings)."""
  1153. assert _decode_mqtt_mapping([65535, 65535, 65535]) is None
  1154. def test_single_ams_slots(self):
  1155. """AMS 0 slots: snow values 0-3 → global tray IDs 0-3."""
  1156. assert _decode_mqtt_mapping([0, 1, 2, 3]) == [0, 1, 2, 3]
  1157. def test_multi_ams_slots(self):
  1158. """AMS 1 (hw_id=1): snow 256=AMS1-T0, 257=AMS1-T1 → global 4, 5."""
  1159. assert _decode_mqtt_mapping([256, 257]) == [4, 5]
  1160. def test_ams_ht_slot(self):
  1161. """AMS-HT (hw_id=128): snow 32768 → global 128."""
  1162. assert _decode_mqtt_mapping([32768]) == [128]
  1163. def test_external_spool(self):
  1164. """External spool: ams_hw_id=254, slot=0 → global 254."""
  1165. # snow = 254 * 256 + 0 = 65024
  1166. assert _decode_mqtt_mapping([65024]) == [254]
  1167. def test_mixed_with_unmapped(self):
  1168. """Mix of valid and unmapped (65535) values."""
  1169. result = _decode_mqtt_mapping([1, 65535, 0])
  1170. assert result == [1, -1, 0]
  1171. def test_h2c_real_mapping(self):
  1172. """Real H2C mapping from MQTT logs: [1, 0, 65535*4, 32768]."""
  1173. mapping = [1, 0, 65535, 65535, 65535, 65535, 32768]
  1174. result = _decode_mqtt_mapping(mapping)
  1175. assert result == [1, 0, -1, -1, -1, -1, 128]
  1176. def test_non_int_values_treated_as_unmapped(self):
  1177. """Non-integer values in the mapping are treated as unmapped."""
  1178. assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
  1179. class TestMatchSlotsByColor:
  1180. """Tests for _match_slots_by_color() — color-based filament slot to AMS tray matching."""
  1181. def _ams(self, trays):
  1182. """Build AMS data from list of (ams_id, tray_id, color_hex, tray_type) tuples."""
  1183. units: dict[int, list] = {}
  1184. for ams_id, tray_id, color, tray_type in trays:
  1185. units.setdefault(ams_id, []).append({"id": tray_id, "tray_color": color, "tray_type": tray_type})
  1186. return [{"id": aid, "tray": t} for aid, t in units.items()]
  1187. def _usage(self, slots):
  1188. """Build filament_usage from list of (slot_id, color_hex) tuples."""
  1189. return [{"slot_id": sid, "used_g": 10.0, "type": "PLA", "color": color} for sid, color in slots]
  1190. def test_none_inputs(self):
  1191. assert _match_slots_by_color(None, None) is None
  1192. assert _match_slots_by_color([], None) is None
  1193. assert _match_slots_by_color(None, {"ams": []}) is None
  1194. def test_empty_ams(self):
  1195. usage = self._usage([(1, "#FF0000")])
  1196. assert _match_slots_by_color(usage, {"ams": []}) is None
  1197. def test_single_slot_single_tray(self):
  1198. """One 3MF slot matches one AMS tray by color."""
  1199. ams = self._ams([(0, 0, "FF0000FF", "PLA")])
  1200. usage = self._usage([(1, "#FF0000")])
  1201. assert _match_slots_by_color(usage, {"ams": ams}) == [0]
  1202. def test_a1_mini_three_colors(self):
  1203. """A1 Mini: 3 slots match 3 distinct AMS trays."""
  1204. ams = self._ams(
  1205. [
  1206. (0, 0, "FF0000FF", "PLA"), # Red
  1207. (0, 1, "00FF00FF", "PLA"), # Green
  1208. (0, 2, "0000FFFF", "PLA"), # Blue
  1209. ]
  1210. )
  1211. usage = self._usage([(1, "#FF0000"), (2, "#00FF00"), (3, "#0000FF")])
  1212. assert _match_slots_by_color(usage, {"ams": ams}) == [0, 1, 2]
  1213. def test_dual_ams_p2s_like(self):
  1214. """P2S with dual AMS: slots from second AMS unit."""
  1215. ams = self._ams(
  1216. [
  1217. (0, 0, "AAAAAAFF", "PLA"),
  1218. (0, 1, "BBBBBBFF", "PLA"),
  1219. (1, 0, "CC0000FF", "PETG"), # global_id=4
  1220. (1, 1, "00CC00FF", "PETG"), # global_id=5
  1221. ]
  1222. )
  1223. usage = self._usage([(1, "#CC0000"), (2, "#00CC00")])
  1224. assert _match_slots_by_color(usage, {"ams": ams}) == [4, 5]
  1225. def test_ams_ht_global_id(self):
  1226. """AMS-HT (ams_id >= 128) uses raw ams_id as global tray ID."""
  1227. ams = self._ams(
  1228. [
  1229. (0, 0, "FF0000FF", "PLA"),
  1230. (128, 0, "0000FFFF", "PLA"), # AMS-HT → global_id=128
  1231. ]
  1232. )
  1233. usage = self._usage([(1, "#FF0000"), (2, "#0000FF")])
  1234. assert _match_slots_by_color(usage, {"ams": ams}) == [0, 128]
  1235. def test_ambiguous_same_color_returns_none(self):
  1236. """Two trays with the same color → ambiguous → None."""
  1237. ams = self._ams(
  1238. [
  1239. (0, 0, "FF0000FF", "PLA"),
  1240. (0, 1, "FF0000FF", "PLA"), # Same red
  1241. ]
  1242. )
  1243. usage = self._usage([(1, "#FF0000")])
  1244. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1245. def test_no_matching_color_returns_none(self):
  1246. """3MF slot color not found in any AMS tray → None."""
  1247. ams = self._ams([(0, 0, "00FF00FF", "PLA")])
  1248. usage = self._usage([(1, "#FF0000")]) # Red, but AMS has green
  1249. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1250. def test_color_normalization_strips_alpha(self):
  1251. """AMS colors (RRGGBBAA) and 3MF colors (#RRGGBB) match after normalization."""
  1252. ams = self._ams([(0, 0, "AABBCC80", "PLA")]) # 8-char with alpha
  1253. usage = self._usage([(1, "#AABBCC")]) # 6-char with #
  1254. assert _match_slots_by_color(usage, {"ams": ams}) == [0]
  1255. def test_case_insensitive(self):
  1256. """Color matching is case-insensitive."""
  1257. ams = self._ams([(0, 0, "aaBBccFF", "PLA")])
  1258. usage = self._usage([(1, "#AAbbCC")])
  1259. assert _match_slots_by_color(usage, {"ams": ams}) == [0]
  1260. def test_empty_tray_color_skipped(self):
  1261. """Trays with empty color are skipped (not matched)."""
  1262. ams = self._ams(
  1263. [
  1264. (0, 0, "", "PLA"),
  1265. (0, 1, "FF0000FF", "PLA"),
  1266. ]
  1267. )
  1268. usage = self._usage([(1, "#FF0000")])
  1269. assert _match_slots_by_color(usage, {"ams": ams}) == [1]
  1270. def test_empty_tray_type_skipped(self):
  1271. """Trays with empty tray_type are skipped (unloaded slot)."""
  1272. ams = self._ams(
  1273. [
  1274. (0, 0, "FF0000FF", ""), # Empty slot
  1275. (0, 1, "FF0000FF", "PLA"), # Loaded slot
  1276. ]
  1277. )
  1278. usage = self._usage([(1, "#FF0000")])
  1279. assert _match_slots_by_color(usage, {"ams": ams}) == [1]
  1280. def test_short_slot_color_returns_none(self):
  1281. """3MF slot with color < 6 chars → can't match → None."""
  1282. ams = self._ams([(0, 0, "FF0000FF", "PLA")])
  1283. usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFF"}]
  1284. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1285. def test_slot_id_zero_skipped(self):
  1286. """Slots with slot_id=0 are skipped."""
  1287. ams = self._ams([(0, 0, "FF0000FF", "PLA")])
  1288. usage = [{"slot_id": 0, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
  1289. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1290. def test_ams_data_as_list(self):
  1291. """Handles ams_raw as a plain list (some printer models)."""
  1292. ams_list = [{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}]
  1293. usage = self._usage([(1, "#FF0000")])
  1294. assert _match_slots_by_color(usage, ams_list) == [0]
  1295. def test_same_color_two_trays_disambiguated_by_usage(self):
  1296. """Two trays same color, two slots same color → unique assignment via used_trays tracking."""
  1297. ams = self._ams(
  1298. [
  1299. (0, 0, "FF0000FF", "PLA"),
  1300. (0, 1, "FF0000FF", "PLA"),
  1301. ]
  1302. )
  1303. # Two slots both wanting red — first gets tray 0, second gets tray 1? No.
  1304. # When first slot takes the only available, second has 1 left → should work
  1305. usage = self._usage([(1, "#FF0000"), (2, "#FF0000")])
  1306. # First slot: candidates=[0,1], available=[0,1], len!=1 → None
  1307. assert _match_slots_by_color(usage, {"ams": ams}) is None
  1308. def test_dict_wrapper_with_ams_key(self):
  1309. """Standard dict format with 'ams' key."""
  1310. ams_data = {"ams": [{"id": 0, "tray": [{"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"}]}]}
  1311. usage = self._usage([(1, "#00FF00")])
  1312. assert _match_slots_by_color(usage, ams_data) == [0]
  1313. class TestMqttMappingIntegration:
  1314. """Integration tests: MQTT mapping field used in _track_from_3mf."""
  1315. @pytest.mark.asyncio
  1316. async def test_h2c_multi_filament_uses_mqtt_mapping(self):
  1317. """H2C: 3 filaments resolved via MQTT mapping field (no ams_mapping, no queue)."""
  1318. # AMS0-T1 (White PLA), AMS0-T0 (Black PLA), AMS128-T0 (Red PLA)
  1319. spool_white = _make_spool(spool_id=1, label_weight=1000)
  1320. spool_black = _make_spool(spool_id=2, label_weight=1000)
  1321. spool_red = _make_spool(spool_id=3, label_weight=1000)
  1322. assign_white = _make_assignment(spool_id=1, ams_id=0, tray_id=1)
  1323. assign_black = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
  1324. assign_red = _make_assignment(spool_id=3, ams_id=128, tray_id=0)
  1325. archive = _make_archive(archive_id=12)
  1326. # db: archive, then 3 pairs of (assignment, spool)
  1327. # No queue lookup because MQTT mapping is found first
  1328. db = _mock_db_sequential(
  1329. [
  1330. archive,
  1331. assign_white,
  1332. spool_white,
  1333. assign_black,
  1334. spool_black,
  1335. assign_red,
  1336. spool_red,
  1337. ]
  1338. )
  1339. # MQTT mapping: slot0→AMS0-T1(1), slot1→AMS0-T0(0), slots2-5→unmapped, slot6→AMS128-T0(32768)
  1340. printer_manager = MagicMock()
  1341. printer_manager.get_status.return_value = SimpleNamespace(
  1342. raw_data={"mapping": [1, 0, 65535, 65535, 65535, 65535, 32768]},
  1343. progress=100,
  1344. layer_num=50,
  1345. tray_now=255,
  1346. )
  1347. # 3MF slots 1, 2, 7 (1-based) → indices 0, 1, 6 in mapping
  1348. filament_usage = [
  1349. {"slot_id": 1, "used_g": 21.16, "type": "PLA", "color": "#FFFFFF"},
  1350. {"slot_id": 2, "used_g": 24.22, "type": "PLA", "color": "#000000"},
  1351. {"slot_id": 7, "used_g": 18.47, "type": "PLA", "color": "#F72323"},
  1352. ]
  1353. handled_trays: set[tuple[int, int]] = set()
  1354. with (
  1355. patch("backend.app.core.config.settings") as mock_settings,
  1356. patch(
  1357. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1358. return_value=filament_usage,
  1359. ),
  1360. ):
  1361. mock_settings.base_dir = MagicMock()
  1362. mock_path = MagicMock()
  1363. mock_path.exists.return_value = True
  1364. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1365. results = await _track_from_3mf(
  1366. printer_id=1,
  1367. archive_id=12,
  1368. status="completed",
  1369. print_name="Cube + Cube + Cube",
  1370. handled_trays=handled_trays,
  1371. printer_manager=printer_manager,
  1372. db=db,
  1373. )
  1374. assert len(results) == 3
  1375. # slot_id=1 → mapping[0]=1 → AMS0-T1 (White PLA)
  1376. assert results[0]["spool_id"] == 1
  1377. assert results[0]["ams_id"] == 0
  1378. assert results[0]["tray_id"] == 1
  1379. assert results[0]["weight_used"] == 21.2
  1380. # slot_id=2 → mapping[1]=0 → AMS0-T0 (Black PLA)
  1381. assert results[1]["spool_id"] == 2
  1382. assert results[1]["ams_id"] == 0
  1383. assert results[1]["tray_id"] == 0
  1384. assert results[1]["weight_used"] == 24.2
  1385. # slot_id=7 → mapping[6]=32768 → AMS128-T0 (Red PLA)
  1386. assert results[2]["spool_id"] == 3
  1387. assert results[2]["ams_id"] == 128
  1388. assert results[2]["tray_id"] == 0
  1389. assert results[2]["weight_used"] == 18.5
  1390. @pytest.mark.asyncio
  1391. async def test_print_cmd_mapping_takes_priority_over_mqtt(self):
  1392. """ams_mapping from print command is used even when MQTT mapping exists."""
  1393. spool = _make_spool(spool_id=1, label_weight=1000)
  1394. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
  1395. archive = _make_archive(archive_id=10)
  1396. # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
  1397. db = _mock_db_sequential([archive, assignment, spool])
  1398. printer_manager = MagicMock()
  1399. printer_manager.get_status.return_value = SimpleNamespace(
  1400. raw_data={"mapping": [0, 65535]}, # MQTT says slot 0 → AMS0-T0
  1401. progress=100,
  1402. layer_num=50,
  1403. tray_now=255,
  1404. )
  1405. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
  1406. handled_trays: set[tuple[int, int]] = set()
  1407. with (
  1408. patch("backend.app.core.config.settings") as mock_settings,
  1409. patch(
  1410. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  1411. return_value=filament_usage,
  1412. ),
  1413. ):
  1414. mock_settings.base_dir = MagicMock()
  1415. mock_path = MagicMock()
  1416. mock_path.exists.return_value = True
  1417. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  1418. results = await _track_from_3mf(
  1419. printer_id=1,
  1420. archive_id=10,
  1421. status="completed",
  1422. print_name="Test",
  1423. handled_trays=handled_trays,
  1424. printer_manager=printer_manager,
  1425. db=db,
  1426. ams_mapping=[2], # Print cmd says slot 0 → AMS0-T2 (overrides MQTT)
  1427. )
  1428. assert len(results) == 1
  1429. assert results[0]["ams_id"] == 0
  1430. assert results[0]["tray_id"] == 2 # From print_cmd mapping, not MQTT
  1431. class TestNotificationVariables:
  1432. """Tests for filament_details formatting in notifications."""
  1433. def test_filament_details_single_slot(self):
  1434. """Single slot produces 'PLA: 15.2g' format."""
  1435. slots = [{"type": "PLA", "used_g": 15.2, "slot_id": 1, "color": "#FF0000"}]
  1436. parts = []
  1437. for slot in slots:
  1438. ftype = slot.get("type", "Unknown") or "Unknown"
  1439. used = slot.get("used_g", 0)
  1440. parts.append(f"{ftype}: {used:.1f}g")
  1441. result = " | ".join(parts)
  1442. assert result == "PLA: 15.2g"
  1443. def test_filament_details_multi_slot(self):
  1444. """Multiple slots produce 'PLA: 10.0g | PETG: 5.0g' format."""
  1445. slots = [
  1446. {"type": "PLA", "used_g": 10.0, "slot_id": 1, "color": ""},
  1447. {"type": "PETG", "used_g": 5.0, "slot_id": 2, "color": ""},
  1448. ]
  1449. parts = []
  1450. for slot in slots:
  1451. ftype = slot.get("type", "Unknown") or "Unknown"
  1452. used = slot.get("used_g", 0)
  1453. parts.append(f"{ftype}: {used:.1f}g")
  1454. result = " | ".join(parts)
  1455. assert result == "PLA: 10.0g | PETG: 5.0g"
  1456. def test_filament_details_empty_type(self):
  1457. """Empty type defaults to 'Unknown'."""
  1458. slots = [{"type": "", "used_g": 5.0, "slot_id": 1, "color": ""}]
  1459. parts = []
  1460. for slot in slots:
  1461. ftype = slot.get("type", "Unknown") or "Unknown"
  1462. used = slot.get("used_g", 0)
  1463. parts.append(f"{ftype}: {used:.1f}g")
  1464. result = " | ".join(parts)
  1465. assert result == "Unknown: 5.0g"
  1466. def test_filament_grams_scaled_for_partial(self):
  1467. """filament_grams is scaled by progress for partial prints."""
  1468. filament_used_grams = 20.0
  1469. progress = 50
  1470. scale = max(0.0, min(progress / 100.0, 1.0))
  1471. scaled = round(filament_used_grams * scale, 1)
  1472. assert scaled == 10.0
  1473. def test_filament_grams_zero_progress(self):
  1474. """Progress=0 at cancellation gives 0.0g."""
  1475. filament_used_grams = 20.0
  1476. progress = 0
  1477. scale = max(0.0, min(progress / 100.0, 1.0))
  1478. scaled = round(filament_used_grams * scale, 1)
  1479. assert scaled == 0.0
  1480. def test_slot_scaling_for_partial(self):
  1481. """Per-slot usage is scaled linearly for partial prints."""
  1482. slots = [
  1483. {"type": "PLA", "used_g": 20.0, "slot_id": 1, "color": ""},
  1484. {"type": "PETG", "used_g": 10.0, "slot_id": 2, "color": ""},
  1485. ]
  1486. progress = 30
  1487. scale = max(0.0, min(progress / 100.0, 1.0))
  1488. scaled_slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
  1489. assert scaled_slots[0]["used_g"] == 6.0
  1490. assert scaled_slots[1]["used_g"] == 3.0