test_usage_tracker.py 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091
  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, 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. _track_from_3mf,
  15. on_print_complete,
  16. on_print_start,
  17. )
  18. def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
  19. """Create a mock Spool object."""
  20. spool = MagicMock()
  21. spool.id = spool_id
  22. spool.label_weight = label_weight
  23. spool.weight_used = weight_used
  24. spool.tag_uid = tag_uid
  25. spool.tray_uuid = tray_uuid
  26. spool.last_used = None
  27. return spool
  28. def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):
  29. """Create a mock SpoolAssignment object."""
  30. assignment = MagicMock()
  31. assignment.spool_id = spool_id
  32. assignment.printer_id = printer_id
  33. assignment.ams_id = ams_id
  34. assignment.tray_id = tray_id
  35. return assignment
  36. def _make_archive(archive_id=1, file_path="archives/1/test.3mf", extra_data=None):
  37. """Create a mock PrintArchive object."""
  38. archive = MagicMock()
  39. archive.id = archive_id
  40. archive.file_path = file_path
  41. archive.extra_data = extra_data
  42. return archive
  43. def _make_queue_item(ams_mapping=None, status="printing"):
  44. """Create a mock PrintQueueItem object."""
  45. item = MagicMock()
  46. item.ams_mapping = ams_mapping
  47. item.status = status
  48. return item
  49. def _mock_db_execute(*return_values):
  50. """Create a mock db with execute() that returns values in sequence."""
  51. db = AsyncMock()
  52. results = []
  53. for val in return_values:
  54. result = MagicMock()
  55. result.scalar_one_or_none.return_value = val
  56. results.append(result)
  57. db.execute = AsyncMock(side_effect=results)
  58. return db
  59. def _mock_db_sequential(responses):
  60. """Create mock db that returns responses in order."""
  61. db = AsyncMock()
  62. call_count = [0]
  63. async def mock_execute(*args, **kwargs):
  64. idx = call_count[0]
  65. call_count[0] += 1
  66. result = MagicMock()
  67. if idx < len(responses):
  68. result.scalar_one_or_none.return_value = responses[idx]
  69. else:
  70. result.scalar_one_or_none.return_value = None
  71. return result
  72. db.execute = mock_execute
  73. return db
  74. class TestOnPrintStart:
  75. """Tests for on_print_start()."""
  76. @pytest.fixture(autouse=True)
  77. def _clear_sessions(self):
  78. _active_sessions.clear()
  79. yield
  80. _active_sessions.clear()
  81. @pytest.mark.asyncio
  82. async def test_captures_remain_data(self):
  83. """Captures AMS remain% at print start."""
  84. printer_manager = MagicMock()
  85. printer_manager.get_status.return_value = SimpleNamespace(
  86. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]},
  87. tray_now=5,
  88. )
  89. await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager)
  90. assert 1 in _active_sessions
  91. session = _active_sessions[1]
  92. assert session.print_name == "Benchy"
  93. assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}
  94. @pytest.mark.asyncio
  95. async def test_captures_tray_now_at_start(self):
  96. """Captures tray_now at print start for later use in usage tracking."""
  97. printer_manager = MagicMock()
  98. printer_manager.get_status.return_value = SimpleNamespace(
  99. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
  100. tray_now=9,
  101. )
  102. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  103. assert _active_sessions[1].tray_now_at_start == 9
  104. @pytest.mark.asyncio
  105. async def test_tray_now_at_start_255_when_unloaded(self):
  106. """Captures tray_now=255 when printer has no filament loaded at start."""
  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=255,
  111. )
  112. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  113. assert _active_sessions[1].tray_now_at_start == 255
  114. @pytest.mark.asyncio
  115. async def test_creates_session_without_remain(self):
  116. """Creates session even without valid remain data (for 3MF tracking)."""
  117. printer_manager = MagicMock()
  118. printer_manager.get_status.return_value = SimpleNamespace(
  119. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]},
  120. tray_now=255,
  121. )
  122. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  123. assert 1 in _active_sessions
  124. assert _active_sessions[1].tray_remain_start == {}
  125. class TestOnPrintComplete:
  126. """Tests for on_print_complete() — path ordering and interaction."""
  127. @pytest.fixture(autouse=True)
  128. def _clear_sessions(self):
  129. _active_sessions.clear()
  130. yield
  131. _active_sessions.clear()
  132. @pytest.mark.asyncio
  133. async def test_bl_spool_uses_3mf(self):
  134. """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
  135. spool = _make_spool(spool_id=1, tag_uid="AABB1122", label_weight=1000)
  136. assignment = _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0)
  137. archive = _make_archive(archive_id=10)
  138. # Setup: session with AMS remain data
  139. _active_sessions[1] = PrintSession(
  140. printer_id=1,
  141. print_name="Benchy",
  142. started_at=datetime.now(timezone.utc),
  143. tray_remain_start={(0, 0): 80},
  144. )
  145. # Mock printer state: tray_now=0 (AMS0-T0), single filament
  146. printer_manager = MagicMock()
  147. printer_manager.get_status.return_value = SimpleNamespace(
  148. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  149. progress=100,
  150. layer_num=50,
  151. tray_now=0,
  152. )
  153. # db returns: archive, queue_item(None), assignment, spool
  154. db = _mock_db_sequential([archive, None, assignment, spool])
  155. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  156. with (
  157. patch("backend.app.core.config.settings") as mock_settings,
  158. patch(
  159. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  160. return_value=filament_usage,
  161. ),
  162. ):
  163. mock_settings.base_dir = MagicMock()
  164. mock_path = MagicMock()
  165. mock_path.exists.return_value = True
  166. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  167. results = await on_print_complete(
  168. printer_id=1,
  169. data={"status": "completed"},
  170. printer_manager=printer_manager,
  171. db=db,
  172. archive_id=10,
  173. )
  174. # 3MF path should handle it (BL guard removed)
  175. assert len(results) >= 1
  176. assert results[0]["spool_id"] == 1
  177. assert results[0]["weight_used"] == 15.0
  178. @pytest.mark.asyncio
  179. async def test_ams_delta_fallback_no_archive(self):
  180. """AMS delta tracks consumption when archive_id is None."""
  181. spool = _make_spool(spool_id=2, label_weight=1000)
  182. assignment = _make_assignment(spool_id=2)
  183. _active_sessions[1] = PrintSession(
  184. printer_id=1,
  185. print_name="Test",
  186. started_at=datetime.now(timezone.utc),
  187. tray_remain_start={(0, 0): 80},
  188. )
  189. printer_manager = MagicMock()
  190. printer_manager.get_status.return_value = SimpleNamespace(
  191. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  192. tray_now=0,
  193. last_loaded_tray=-1,
  194. )
  195. # db returns assignment then spool
  196. db = _mock_db_sequential([assignment, spool])
  197. results = await on_print_complete(
  198. printer_id=1,
  199. data={"status": "completed"},
  200. printer_manager=printer_manager,
  201. db=db,
  202. archive_id=None,
  203. )
  204. assert len(results) == 1
  205. assert results[0]["spool_id"] == 2
  206. # 10% of 1000g = 100g
  207. assert results[0]["weight_used"] == 100.0
  208. assert results[0]["percent_used"] == 10
  209. @pytest.mark.asyncio
  210. async def test_no_double_tracking(self):
  211. """When 3MF handles a tray, AMS delta skips it."""
  212. spool = _make_spool(spool_id=1, label_weight=1000)
  213. assignment = _make_assignment(spool_id=1)
  214. archive = _make_archive(archive_id=10)
  215. _active_sessions[1] = PrintSession(
  216. printer_id=1,
  217. print_name="Benchy",
  218. started_at=datetime.now(timezone.utc),
  219. tray_remain_start={(0, 0): 80},
  220. )
  221. # tray_now=0 matches the single filament slot
  222. printer_manager = MagicMock()
  223. printer_manager.get_status.return_value = SimpleNamespace(
  224. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  225. progress=100,
  226. layer_num=50,
  227. tray_now=0,
  228. )
  229. # db returns: archive, queue_item(None), assignment, spool
  230. db = _mock_db_sequential([archive, None, assignment, spool])
  231. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  232. with (
  233. patch("backend.app.core.config.settings") as mock_settings,
  234. patch(
  235. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  236. return_value=filament_usage,
  237. ),
  238. ):
  239. mock_settings.base_dir = MagicMock()
  240. mock_path = MagicMock()
  241. mock_path.exists.return_value = True
  242. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  243. results = await on_print_complete(
  244. printer_id=1,
  245. data={"status": "completed"},
  246. printer_manager=printer_manager,
  247. db=db,
  248. archive_id=10,
  249. )
  250. # Only 1 result (3MF), NOT 2 (3MF + AMS delta)
  251. assert len(results) == 1
  252. assert results[0]["weight_used"] == 15.0
  253. class TestTrackFrom3mf:
  254. """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
  255. @pytest.mark.asyncio
  256. async def test_linear_fallback_for_partial_print(self):
  257. """Falls back to linear scaling when gcode layer data unavailable."""
  258. spool = _make_spool(spool_id=1, label_weight=1000)
  259. assignment = _make_assignment(spool_id=1)
  260. archive = _make_archive(archive_id=10)
  261. # db: archive, queue_item(None), assignment, spool
  262. db = _mock_db_sequential([archive, None, assignment, spool])
  263. printer_manager = MagicMock()
  264. printer_manager.get_status.return_value = SimpleNamespace(
  265. progress=50,
  266. layer_num=25,
  267. tray_now=0,
  268. )
  269. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  270. handled_trays: set[tuple[int, int]] = set()
  271. with (
  272. patch("backend.app.core.config.settings") as mock_settings,
  273. patch(
  274. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  275. return_value=filament_usage,
  276. ),
  277. patch(
  278. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  279. return_value=None, # No layer data available
  280. ),
  281. ):
  282. mock_settings.base_dir = MagicMock()
  283. mock_path = MagicMock()
  284. mock_path.exists.return_value = True
  285. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  286. results = await _track_from_3mf(
  287. printer_id=1,
  288. archive_id=10,
  289. status="failed",
  290. print_name="Benchy",
  291. handled_trays=handled_trays,
  292. printer_manager=printer_manager,
  293. db=db,
  294. )
  295. assert len(results) == 1
  296. # 50% of 20g = 10g
  297. assert results[0]["weight_used"] == 10.0
  298. # Tray should be marked as handled
  299. assert (0, 0) in handled_trays
  300. @pytest.mark.asyncio
  301. async def test_per_layer_partial_print(self):
  302. """Failed print at layer N uses gcode cumulative data."""
  303. spool = _make_spool(spool_id=1, label_weight=1000)
  304. assignment = _make_assignment(spool_id=1)
  305. archive = _make_archive(archive_id=10)
  306. # db: archive, queue_item(None), assignment, spool
  307. db = _mock_db_sequential([archive, None, assignment, spool])
  308. printer_manager = MagicMock()
  309. printer_manager.get_status.return_value = SimpleNamespace(
  310. progress=50,
  311. layer_num=25,
  312. tray_now=0,
  313. )
  314. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  315. # Per-layer data: at layer 25, filament 0 used 5000mm
  316. layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}}
  317. filament_props = {1: {"density": 1.24, "diameter": 1.75}}
  318. handled_trays: set[tuple[int, int]] = set()
  319. with (
  320. patch("backend.app.core.config.settings") as mock_settings,
  321. patch(
  322. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  323. return_value=filament_usage,
  324. ),
  325. patch(
  326. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  327. return_value=layer_data,
  328. ),
  329. patch(
  330. "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
  331. return_value={0: 5000.0},
  332. ),
  333. patch(
  334. "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
  335. return_value=filament_props,
  336. ),
  337. patch(
  338. "backend.app.utils.threemf_tools.mm_to_grams",
  339. return_value=12.0, # 5000mm at 1.75mm/1.24g/cm3
  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=10,
  349. status="failed",
  350. print_name="Benchy",
  351. handled_trays=handled_trays,
  352. printer_manager=printer_manager,
  353. db=db,
  354. )
  355. assert len(results) == 1
  356. # Should use per-layer grams (12.0g), not linear scale (10.0g)
  357. assert results[0]["weight_used"] == 12.0
  358. @pytest.mark.asyncio
  359. async def test_completed_print_uses_full_weight(self):
  360. """Completed print uses full 3MF weight (scale=1.0)."""
  361. spool = _make_spool(spool_id=1, label_weight=1000)
  362. assignment = _make_assignment(spool_id=1)
  363. archive = _make_archive(archive_id=10)
  364. # db: archive, queue_item(None), assignment, spool
  365. db = _mock_db_sequential([archive, None, assignment, spool])
  366. printer_manager = MagicMock()
  367. printer_manager.get_status.return_value = SimpleNamespace(
  368. progress=100,
  369. layer_num=50,
  370. tray_now=0,
  371. )
  372. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  373. handled_trays: set[tuple[int, int]] = set()
  374. with (
  375. patch("backend.app.core.config.settings") as mock_settings,
  376. patch(
  377. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  378. return_value=filament_usage,
  379. ),
  380. ):
  381. mock_settings.base_dir = MagicMock()
  382. mock_path = MagicMock()
  383. mock_path.exists.return_value = True
  384. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  385. results = await _track_from_3mf(
  386. printer_id=1,
  387. archive_id=10,
  388. status="completed",
  389. print_name="Benchy",
  390. handled_trays=handled_trays,
  391. printer_manager=printer_manager,
  392. db=db,
  393. )
  394. assert len(results) == 1
  395. assert results[0]["weight_used"] == 20.0
  396. @pytest.mark.asyncio
  397. async def test_tray_now_override_for_single_filament(self):
  398. """Single-filament non-queue print uses tray_now instead of slot_id mapping."""
  399. # Spool 2 is at AMS1-T3 (global_tray_id=7)
  400. spool = _make_spool(spool_id=2, label_weight=1000)
  401. assignment = _make_assignment(spool_id=2, ams_id=1, tray_id=3)
  402. archive = _make_archive(archive_id=10)
  403. # db: archive, queue_item(None), assignment, spool
  404. db = _mock_db_sequential([archive, None, assignment, spool])
  405. # tray_now=7 = (ams_id=1, tray_id=3), the ACTUAL tray used
  406. printer_manager = MagicMock()
  407. printer_manager.get_status.return_value = SimpleNamespace(
  408. progress=100,
  409. layer_num=50,
  410. tray_now=7,
  411. )
  412. # 3MF has slot_id=12 (would default-map to ams_id=2, tray_id=3 — WRONG)
  413. filament_usage = [{"slot_id": 12, "used_g": 10.6, "type": "PLA", "color": "#FF0000"}]
  414. handled_trays: set[tuple[int, int]] = set()
  415. with (
  416. patch("backend.app.core.config.settings") as mock_settings,
  417. patch(
  418. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  419. return_value=filament_usage,
  420. ),
  421. ):
  422. mock_settings.base_dir = MagicMock()
  423. mock_path = MagicMock()
  424. mock_path.exists.return_value = True
  425. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  426. results = await _track_from_3mf(
  427. printer_id=1,
  428. archive_id=10,
  429. status="completed",
  430. print_name="Test",
  431. handled_trays=handled_trays,
  432. printer_manager=printer_manager,
  433. db=db,
  434. )
  435. assert len(results) == 1
  436. assert results[0]["spool_id"] == 2
  437. assert results[0]["ams_id"] == 1
  438. assert results[0]["tray_id"] == 3
  439. assert results[0]["weight_used"] == 10.6
  440. assert (1, 3) in handled_trays
  441. @pytest.mark.asyncio
  442. async def test_queue_ams_mapping_overrides_default(self):
  443. """Queue item ams_mapping overrides default slot_id mapping."""
  444. # Spool at AMS1-T3 (global_tray_id=7)
  445. spool = _make_spool(spool_id=5, label_weight=1000)
  446. assignment = _make_assignment(spool_id=5, ams_id=1, tray_id=3)
  447. archive = _make_archive(archive_id=20)
  448. # Queue item maps slot 1 → global tray 7 (ams_id=1, tray_id=3)
  449. queue_item = _make_queue_item(ams_mapping="[7, -1, -1, -1]")
  450. # db: archive, queue_item, assignment, spool
  451. db = _mock_db_sequential([archive, queue_item, assignment, spool])
  452. printer_manager = MagicMock()
  453. printer_manager.get_status.return_value = SimpleNamespace(
  454. progress=100,
  455. layer_num=50,
  456. tray_now=7,
  457. )
  458. filament_usage = [{"slot_id": 1, "used_g": 25.0, "type": "PETG", "color": ""}]
  459. handled_trays: set[tuple[int, int]] = set()
  460. with (
  461. patch("backend.app.core.config.settings") as mock_settings,
  462. patch(
  463. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  464. return_value=filament_usage,
  465. ),
  466. ):
  467. mock_settings.base_dir = MagicMock()
  468. mock_path = MagicMock()
  469. mock_path.exists.return_value = True
  470. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  471. results = await _track_from_3mf(
  472. printer_id=1,
  473. archive_id=20,
  474. status="completed",
  475. print_name="Queue Print",
  476. handled_trays=handled_trays,
  477. printer_manager=printer_manager,
  478. db=db,
  479. )
  480. assert len(results) == 1
  481. assert results[0]["spool_id"] == 5
  482. assert results[0]["ams_id"] == 1
  483. assert results[0]["tray_id"] == 3
  484. assert results[0]["weight_used"] == 25.0
  485. @pytest.mark.asyncio
  486. async def test_multi_filament_uses_queue_mapping(self):
  487. """Multi-filament queue prints use ams_mapping for each slot."""
  488. spool_a = _make_spool(spool_id=1, label_weight=1000)
  489. spool_b = _make_spool(spool_id=2, label_weight=1000)
  490. assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  491. assign_b = _make_assignment(spool_id=2, ams_id=1, tray_id=2)
  492. archive = _make_archive(archive_id=30)
  493. # slot 1 → tray 0 (AMS0-T0), slot 2 → tray 6 (AMS1-T2)
  494. queue_item = _make_queue_item(ams_mapping="[0, 6]")
  495. # db: archive, queue_item, assign_a, spool_a, assign_b, spool_b
  496. db = _mock_db_sequential([archive, queue_item, assign_a, spool_a, assign_b, spool_b])
  497. printer_manager = MagicMock()
  498. printer_manager.get_status.return_value = SimpleNamespace(
  499. progress=100,
  500. layer_num=50,
  501. tray_now=6,
  502. )
  503. filament_usage = [
  504. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
  505. {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
  506. ]
  507. handled_trays: set[tuple[int, int]] = set()
  508. with (
  509. patch("backend.app.core.config.settings") as mock_settings,
  510. patch(
  511. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  512. return_value=filament_usage,
  513. ),
  514. ):
  515. mock_settings.base_dir = MagicMock()
  516. mock_path = MagicMock()
  517. mock_path.exists.return_value = True
  518. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  519. results = await _track_from_3mf(
  520. printer_id=1,
  521. archive_id=30,
  522. status="completed",
  523. print_name="Multi",
  524. handled_trays=handled_trays,
  525. printer_manager=printer_manager,
  526. db=db,
  527. )
  528. assert len(results) == 2
  529. assert results[0]["spool_id"] == 1
  530. assert results[0]["ams_id"] == 0
  531. assert results[0]["tray_id"] == 0
  532. assert results[0]["weight_used"] == 10.0
  533. assert results[1]["spool_id"] == 2
  534. assert results[1]["ams_id"] == 1
  535. assert results[1]["tray_id"] == 2
  536. assert results[1]["weight_used"] == 5.0
  537. @pytest.mark.asyncio
  538. async def test_no_tray_now_override_for_multi_filament(self):
  539. """Multi-filament non-queue prints fall back to default mapping, not tray_now."""
  540. spool = _make_spool(spool_id=1, label_weight=1000)
  541. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  542. archive = _make_archive(archive_id=10)
  543. # db: archive, queue_item(None), assignment, spool (2nd slot has no assignment)
  544. db = _mock_db_sequential([archive, None, assignment, spool, None])
  545. printer_manager = MagicMock()
  546. printer_manager.get_status.return_value = SimpleNamespace(
  547. progress=100,
  548. layer_num=50,
  549. tray_now=4, # tray_now won't be used
  550. )
  551. # Two filament slots with usage
  552. filament_usage = [
  553. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
  554. {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
  555. ]
  556. handled_trays: set[tuple[int, int]] = set()
  557. with (
  558. patch("backend.app.core.config.settings") as mock_settings,
  559. patch(
  560. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  561. return_value=filament_usage,
  562. ),
  563. ):
  564. mock_settings.base_dir = MagicMock()
  565. mock_path = MagicMock()
  566. mock_path.exists.return_value = True
  567. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  568. results = await _track_from_3mf(
  569. printer_id=1,
  570. archive_id=10,
  571. status="completed",
  572. print_name="Test",
  573. handled_trays=handled_trays,
  574. printer_manager=printer_manager,
  575. db=db,
  576. )
  577. # Should use default mapping (slot 1 → tray 0, slot 2 → tray 1)
  578. assert len(results) == 1 # Only slot 1 has assignment
  579. assert results[0]["ams_id"] == 0
  580. assert results[0]["tray_id"] == 0
  581. @pytest.mark.asyncio
  582. async def test_stored_ams_mapping_overrides_all(self):
  583. """Stored ams_mapping from print command takes priority over queue and tray_now."""
  584. # Spool at AMS2-T1 (global_tray_id=9)
  585. spool = _make_spool(spool_id=10, label_weight=1000)
  586. assignment = _make_assignment(spool_id=10, ams_id=2, tray_id=1)
  587. archive = _make_archive(archive_id=50)
  588. # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
  589. db = _mock_db_sequential([archive, assignment, spool])
  590. printer_manager = MagicMock()
  591. printer_manager.get_status.return_value = SimpleNamespace(
  592. progress=100,
  593. layer_num=50,
  594. tray_now=0, # Different from mapped tray — should be ignored
  595. last_loaded_tray=0,
  596. )
  597. filament_usage = [{"slot_id": 2, "used_g": 1.57, "type": "PLA", "color": "#FFFFFF"}]
  598. handled_trays: set[tuple[int, int]] = set()
  599. with (
  600. patch("backend.app.core.config.settings") as mock_settings,
  601. patch(
  602. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  603. return_value=filament_usage,
  604. ),
  605. ):
  606. mock_settings.base_dir = MagicMock()
  607. mock_path = MagicMock()
  608. mock_path.exists.return_value = True
  609. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  610. # ams_mapping: slot 2 (index 1) -> tray 9 (AMS2-T1)
  611. results = await _track_from_3mf(
  612. printer_id=1,
  613. archive_id=50,
  614. status="completed",
  615. print_name="Test",
  616. handled_trays=handled_trays,
  617. printer_manager=printer_manager,
  618. db=db,
  619. ams_mapping=[-1, 9],
  620. )
  621. assert len(results) == 1
  622. assert results[0]["spool_id"] == 10
  623. assert results[0]["ams_id"] == 2
  624. assert results[0]["tray_id"] == 1
  625. assert results[0]["weight_used"] == 1.6 # rounded
  626. @pytest.mark.asyncio
  627. async def test_last_loaded_tray_fallback(self):
  628. """Falls back to last_loaded_tray when tray_now_at_start and current tray_now are both 255."""
  629. # Spool at AMS2-T1 (global_tray_id=9)
  630. spool = _make_spool(spool_id=11, label_weight=1000)
  631. assignment = _make_assignment(spool_id=11, ams_id=2, tray_id=1)
  632. archive = _make_archive(archive_id=60)
  633. # db: archive, queue_item(None), assignment, spool
  634. db = _mock_db_sequential([archive, None, assignment, spool])
  635. # H2D scenario: tray_now=255 at completion, but last_loaded_tray=9
  636. printer_manager = MagicMock()
  637. printer_manager.get_status.return_value = SimpleNamespace(
  638. progress=100,
  639. layer_num=50,
  640. tray_now=255,
  641. last_loaded_tray=9,
  642. )
  643. filament_usage = [{"slot_id": 6, "used_g": 1.52, "type": "PLA", "color": "#7CC4D5"}]
  644. handled_trays: set[tuple[int, int]] = set()
  645. with (
  646. patch("backend.app.core.config.settings") as mock_settings,
  647. patch(
  648. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  649. return_value=filament_usage,
  650. ),
  651. ):
  652. mock_settings.base_dir = MagicMock()
  653. mock_path = MagicMock()
  654. mock_path.exists.return_value = True
  655. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  656. results = await _track_from_3mf(
  657. printer_id=1,
  658. archive_id=60,
  659. status="completed",
  660. print_name="Cube",
  661. handled_trays=handled_trays,
  662. printer_manager=printer_manager,
  663. db=db,
  664. tray_now_at_start=255, # H2D: 255 at start too
  665. )
  666. assert len(results) == 1
  667. assert results[0]["spool_id"] == 11
  668. assert results[0]["ams_id"] == 2
  669. assert results[0]["tray_id"] == 1
  670. @pytest.mark.asyncio
  671. async def test_tray_now_at_start_preferred_over_last_loaded(self):
  672. """tray_now_at_start is used before last_loaded_tray fallback."""
  673. spool = _make_spool(spool_id=3, label_weight=1000)
  674. assignment = _make_assignment(spool_id=3, ams_id=1, tray_id=1)
  675. archive = _make_archive(archive_id=70)
  676. db = _mock_db_sequential([archive, None, assignment, spool])
  677. # tray_now_at_start=5 (valid), last_loaded_tray=9 (different) — should use 5
  678. printer_manager = MagicMock()
  679. printer_manager.get_status.return_value = SimpleNamespace(
  680. progress=100,
  681. layer_num=50,
  682. tray_now=255,
  683. last_loaded_tray=9,
  684. )
  685. filament_usage = [{"slot_id": 1, "used_g": 5.0, "type": "PLA", "color": ""}]
  686. handled_trays: set[tuple[int, int]] = set()
  687. with (
  688. patch("backend.app.core.config.settings") as mock_settings,
  689. patch(
  690. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  691. return_value=filament_usage,
  692. ),
  693. ):
  694. mock_settings.base_dir = MagicMock()
  695. mock_path = MagicMock()
  696. mock_path.exists.return_value = True
  697. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  698. results = await _track_from_3mf(
  699. printer_id=1,
  700. archive_id=70,
  701. status="completed",
  702. print_name="Test",
  703. handled_trays=handled_trays,
  704. printer_manager=printer_manager,
  705. db=db,
  706. tray_now_at_start=5, # AMS1-T1
  707. )
  708. assert len(results) == 1
  709. assert results[0]["ams_id"] == 1
  710. assert results[0]["tray_id"] == 1
  711. class TestDecodeMqttMapping:
  712. """Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs."""
  713. def test_none_input(self):
  714. assert _decode_mqtt_mapping(None) is None
  715. def test_empty_list(self):
  716. assert _decode_mqtt_mapping([]) is None
  717. def test_all_unmapped(self):
  718. """All 65535 values → None (no valid mappings)."""
  719. assert _decode_mqtt_mapping([65535, 65535, 65535]) is None
  720. def test_single_ams_slots(self):
  721. """AMS 0 slots: snow values 0-3 → global tray IDs 0-3."""
  722. assert _decode_mqtt_mapping([0, 1, 2, 3]) == [0, 1, 2, 3]
  723. def test_multi_ams_slots(self):
  724. """AMS 1 (hw_id=1): snow 256=AMS1-T0, 257=AMS1-T1 → global 4, 5."""
  725. assert _decode_mqtt_mapping([256, 257]) == [4, 5]
  726. def test_ams_ht_slot(self):
  727. """AMS-HT (hw_id=128): snow 32768 → global 128."""
  728. assert _decode_mqtt_mapping([32768]) == [128]
  729. def test_external_spool(self):
  730. """External spool: ams_hw_id=254, slot=0 → global 254."""
  731. # snow = 254 * 256 + 0 = 65024
  732. assert _decode_mqtt_mapping([65024]) == [254]
  733. def test_mixed_with_unmapped(self):
  734. """Mix of valid and unmapped (65535) values."""
  735. result = _decode_mqtt_mapping([1, 65535, 0])
  736. assert result == [1, -1, 0]
  737. def test_h2c_real_mapping(self):
  738. """Real H2C mapping from MQTT logs: [1, 0, 65535*4, 32768]."""
  739. mapping = [1, 0, 65535, 65535, 65535, 65535, 32768]
  740. result = _decode_mqtt_mapping(mapping)
  741. assert result == [1, 0, -1, -1, -1, -1, 128]
  742. def test_non_int_values_treated_as_unmapped(self):
  743. """Non-integer values in the mapping are treated as unmapped."""
  744. assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
  745. class TestMqttMappingIntegration:
  746. """Integration tests: MQTT mapping field used in _track_from_3mf."""
  747. @pytest.mark.asyncio
  748. async def test_h2c_multi_filament_uses_mqtt_mapping(self):
  749. """H2C: 3 filaments resolved via MQTT mapping field (no ams_mapping, no queue)."""
  750. # AMS0-T1 (White PLA), AMS0-T0 (Black PLA), AMS128-T0 (Red PLA)
  751. spool_white = _make_spool(spool_id=1, label_weight=1000)
  752. spool_black = _make_spool(spool_id=2, label_weight=1000)
  753. spool_red = _make_spool(spool_id=3, label_weight=1000)
  754. assign_white = _make_assignment(spool_id=1, ams_id=0, tray_id=1)
  755. assign_black = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
  756. assign_red = _make_assignment(spool_id=3, ams_id=128, tray_id=0)
  757. archive = _make_archive(archive_id=12)
  758. # db: archive, then 3 pairs of (assignment, spool)
  759. # No queue lookup because MQTT mapping is found first
  760. db = _mock_db_sequential(
  761. [
  762. archive,
  763. assign_white,
  764. spool_white,
  765. assign_black,
  766. spool_black,
  767. assign_red,
  768. spool_red,
  769. ]
  770. )
  771. # MQTT mapping: slot0→AMS0-T1(1), slot1→AMS0-T0(0), slots2-5→unmapped, slot6→AMS128-T0(32768)
  772. printer_manager = MagicMock()
  773. printer_manager.get_status.return_value = SimpleNamespace(
  774. raw_data={"mapping": [1, 0, 65535, 65535, 65535, 65535, 32768]},
  775. progress=100,
  776. layer_num=50,
  777. tray_now=255,
  778. )
  779. # 3MF slots 1, 2, 7 (1-based) → indices 0, 1, 6 in mapping
  780. filament_usage = [
  781. {"slot_id": 1, "used_g": 21.16, "type": "PLA", "color": "#FFFFFF"},
  782. {"slot_id": 2, "used_g": 24.22, "type": "PLA", "color": "#000000"},
  783. {"slot_id": 7, "used_g": 18.47, "type": "PLA", "color": "#F72323"},
  784. ]
  785. handled_trays: set[tuple[int, int]] = set()
  786. with (
  787. patch("backend.app.core.config.settings") as mock_settings,
  788. patch(
  789. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  790. return_value=filament_usage,
  791. ),
  792. ):
  793. mock_settings.base_dir = MagicMock()
  794. mock_path = MagicMock()
  795. mock_path.exists.return_value = True
  796. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  797. results = await _track_from_3mf(
  798. printer_id=1,
  799. archive_id=12,
  800. status="completed",
  801. print_name="Cube + Cube + Cube",
  802. handled_trays=handled_trays,
  803. printer_manager=printer_manager,
  804. db=db,
  805. )
  806. assert len(results) == 3
  807. # slot_id=1 → mapping[0]=1 → AMS0-T1 (White PLA)
  808. assert results[0]["spool_id"] == 1
  809. assert results[0]["ams_id"] == 0
  810. assert results[0]["tray_id"] == 1
  811. assert results[0]["weight_used"] == 21.2
  812. # slot_id=2 → mapping[1]=0 → AMS0-T0 (Black PLA)
  813. assert results[1]["spool_id"] == 2
  814. assert results[1]["ams_id"] == 0
  815. assert results[1]["tray_id"] == 0
  816. assert results[1]["weight_used"] == 24.2
  817. # slot_id=7 → mapping[6]=32768 → AMS128-T0 (Red PLA)
  818. assert results[2]["spool_id"] == 3
  819. assert results[2]["ams_id"] == 128
  820. assert results[2]["tray_id"] == 0
  821. assert results[2]["weight_used"] == 18.5
  822. @pytest.mark.asyncio
  823. async def test_print_cmd_mapping_takes_priority_over_mqtt(self):
  824. """ams_mapping from print command is used even when MQTT mapping exists."""
  825. spool = _make_spool(spool_id=1, label_weight=1000)
  826. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
  827. archive = _make_archive(archive_id=10)
  828. # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
  829. db = _mock_db_sequential([archive, assignment, spool])
  830. printer_manager = MagicMock()
  831. printer_manager.get_status.return_value = SimpleNamespace(
  832. raw_data={"mapping": [0, 65535]}, # MQTT says slot 0 → AMS0-T0
  833. progress=100,
  834. layer_num=50,
  835. tray_now=255,
  836. )
  837. filament_usage = [{"slot_id": 1, "used_g": 10.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. ):
  846. mock_settings.base_dir = MagicMock()
  847. mock_path = MagicMock()
  848. mock_path.exists.return_value = True
  849. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  850. results = await _track_from_3mf(
  851. printer_id=1,
  852. archive_id=10,
  853. status="completed",
  854. print_name="Test",
  855. handled_trays=handled_trays,
  856. printer_manager=printer_manager,
  857. db=db,
  858. ams_mapping=[2], # Print cmd says slot 0 → AMS0-T2 (overrides MQTT)
  859. )
  860. assert len(results) == 1
  861. assert results[0]["ams_id"] == 0
  862. assert results[0]["tray_id"] == 2 # From print_cmd mapping, not MQTT
  863. class TestNotificationVariables:
  864. """Tests for filament_details formatting in notifications."""
  865. def test_filament_details_single_slot(self):
  866. """Single slot produces 'PLA: 15.2g' format."""
  867. slots = [{"type": "PLA", "used_g": 15.2, "slot_id": 1, "color": "#FF0000"}]
  868. parts = []
  869. for slot in slots:
  870. ftype = slot.get("type", "Unknown") or "Unknown"
  871. used = slot.get("used_g", 0)
  872. parts.append(f"{ftype}: {used:.1f}g")
  873. result = " | ".join(parts)
  874. assert result == "PLA: 15.2g"
  875. def test_filament_details_multi_slot(self):
  876. """Multiple slots produce 'PLA: 10.0g | PETG: 5.0g' format."""
  877. slots = [
  878. {"type": "PLA", "used_g": 10.0, "slot_id": 1, "color": ""},
  879. {"type": "PETG", "used_g": 5.0, "slot_id": 2, "color": ""},
  880. ]
  881. parts = []
  882. for slot in slots:
  883. ftype = slot.get("type", "Unknown") or "Unknown"
  884. used = slot.get("used_g", 0)
  885. parts.append(f"{ftype}: {used:.1f}g")
  886. result = " | ".join(parts)
  887. assert result == "PLA: 10.0g | PETG: 5.0g"
  888. def test_filament_details_empty_type(self):
  889. """Empty type defaults to 'Unknown'."""
  890. slots = [{"type": "", "used_g": 5.0, "slot_id": 1, "color": ""}]
  891. parts = []
  892. for slot in slots:
  893. ftype = slot.get("type", "Unknown") or "Unknown"
  894. used = slot.get("used_g", 0)
  895. parts.append(f"{ftype}: {used:.1f}g")
  896. result = " | ".join(parts)
  897. assert result == "Unknown: 5.0g"
  898. def test_filament_grams_scaled_for_partial(self):
  899. """filament_grams is scaled by progress for partial prints."""
  900. filament_used_grams = 20.0
  901. progress = 50
  902. scale = max(0.0, min(progress / 100.0, 1.0))
  903. scaled = round(filament_used_grams * scale, 1)
  904. assert scaled == 10.0
  905. def test_filament_grams_zero_progress(self):
  906. """Progress=0 at cancellation gives 0.0g."""
  907. filament_used_grams = 20.0
  908. progress = 0
  909. scale = max(0.0, min(progress / 100.0, 1.0))
  910. scaled = round(filament_used_grams * scale, 1)
  911. assert scaled == 0.0
  912. def test_slot_scaling_for_partial(self):
  913. """Per-slot usage is scaled linearly for partial prints."""
  914. slots = [
  915. {"type": "PLA", "used_g": 20.0, "slot_id": 1, "color": ""},
  916. {"type": "PETG", "used_g": 10.0, "slot_id": 2, "color": ""},
  917. ]
  918. progress = 30
  919. scale = max(0.0, min(progress / 100.0, 1.0))
  920. scaled_slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
  921. assert scaled_slots[0]["used_g"] == 6.0
  922. assert scaled_slots[1]["used_g"] == 3.0