test_usage_tracker.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  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. _track_from_3mf,
  14. on_print_complete,
  15. on_print_start,
  16. )
  17. def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
  18. """Create a mock Spool object."""
  19. spool = MagicMock()
  20. spool.id = spool_id
  21. spool.label_weight = label_weight
  22. spool.weight_used = weight_used
  23. spool.tag_uid = tag_uid
  24. spool.tray_uuid = tray_uuid
  25. spool.last_used = None
  26. return spool
  27. def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):
  28. """Create a mock SpoolAssignment object."""
  29. assignment = MagicMock()
  30. assignment.spool_id = spool_id
  31. assignment.printer_id = printer_id
  32. assignment.ams_id = ams_id
  33. assignment.tray_id = tray_id
  34. return assignment
  35. def _make_archive(archive_id=1, file_path="archives/1/test.3mf", extra_data=None):
  36. """Create a mock PrintArchive object."""
  37. archive = MagicMock()
  38. archive.id = archive_id
  39. archive.file_path = file_path
  40. archive.extra_data = extra_data
  41. return archive
  42. def _make_queue_item(ams_mapping=None, status="printing"):
  43. """Create a mock PrintQueueItem object."""
  44. item = MagicMock()
  45. item.ams_mapping = ams_mapping
  46. item.status = status
  47. return item
  48. def _mock_db_execute(*return_values):
  49. """Create a mock db with execute() that returns values in sequence."""
  50. db = AsyncMock()
  51. results = []
  52. for val in return_values:
  53. result = MagicMock()
  54. result.scalar_one_or_none.return_value = val
  55. results.append(result)
  56. db.execute = AsyncMock(side_effect=results)
  57. return db
  58. def _mock_db_sequential(responses):
  59. """Create mock db that returns responses in order."""
  60. db = AsyncMock()
  61. call_count = [0]
  62. async def mock_execute(*args, **kwargs):
  63. idx = call_count[0]
  64. call_count[0] += 1
  65. result = MagicMock()
  66. if idx < len(responses):
  67. result.scalar_one_or_none.return_value = responses[idx]
  68. else:
  69. result.scalar_one_or_none.return_value = None
  70. return result
  71. db.execute = mock_execute
  72. return db
  73. class TestOnPrintStart:
  74. """Tests for on_print_start()."""
  75. @pytest.fixture(autouse=True)
  76. def _clear_sessions(self):
  77. _active_sessions.clear()
  78. yield
  79. _active_sessions.clear()
  80. @pytest.mark.asyncio
  81. async def test_captures_remain_data(self):
  82. """Captures AMS remain% at print start."""
  83. printer_manager = MagicMock()
  84. printer_manager.get_status.return_value = SimpleNamespace(
  85. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]}
  86. )
  87. await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager)
  88. assert 1 in _active_sessions
  89. session = _active_sessions[1]
  90. assert session.print_name == "Benchy"
  91. assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}
  92. @pytest.mark.asyncio
  93. async def test_creates_session_without_remain(self):
  94. """Creates session even without valid remain data (for 3MF tracking)."""
  95. printer_manager = MagicMock()
  96. printer_manager.get_status.return_value = SimpleNamespace(
  97. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]}
  98. )
  99. await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
  100. assert 1 in _active_sessions
  101. assert _active_sessions[1].tray_remain_start == {}
  102. class TestOnPrintComplete:
  103. """Tests for on_print_complete() — path ordering and interaction."""
  104. @pytest.fixture(autouse=True)
  105. def _clear_sessions(self):
  106. _active_sessions.clear()
  107. yield
  108. _active_sessions.clear()
  109. @pytest.mark.asyncio
  110. async def test_bl_spool_uses_3mf(self):
  111. """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
  112. spool = _make_spool(spool_id=1, tag_uid="AABB1122", label_weight=1000)
  113. assignment = _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0)
  114. archive = _make_archive(archive_id=10)
  115. # Setup: session with AMS remain data
  116. _active_sessions[1] = PrintSession(
  117. printer_id=1,
  118. print_name="Benchy",
  119. started_at=datetime.now(timezone.utc),
  120. tray_remain_start={(0, 0): 80},
  121. )
  122. # Mock printer state: tray_now=0 (AMS0-T0), single filament
  123. printer_manager = MagicMock()
  124. printer_manager.get_status.return_value = SimpleNamespace(
  125. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  126. progress=100,
  127. layer_num=50,
  128. tray_now=0,
  129. )
  130. # db returns: archive, queue_item(None), assignment, spool
  131. db = _mock_db_sequential([archive, None, assignment, spool])
  132. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  133. with (
  134. patch("backend.app.core.config.settings") as mock_settings,
  135. patch(
  136. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  137. return_value=filament_usage,
  138. ),
  139. ):
  140. mock_settings.base_dir = MagicMock()
  141. mock_path = MagicMock()
  142. mock_path.exists.return_value = True
  143. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  144. results = await on_print_complete(
  145. printer_id=1,
  146. data={"status": "completed"},
  147. printer_manager=printer_manager,
  148. db=db,
  149. archive_id=10,
  150. )
  151. # 3MF path should handle it (BL guard removed)
  152. assert len(results) >= 1
  153. assert results[0]["spool_id"] == 1
  154. assert results[0]["weight_used"] == 15.0
  155. @pytest.mark.asyncio
  156. async def test_ams_delta_fallback_no_archive(self):
  157. """AMS delta tracks consumption when archive_id is None."""
  158. spool = _make_spool(spool_id=2, label_weight=1000)
  159. assignment = _make_assignment(spool_id=2)
  160. _active_sessions[1] = PrintSession(
  161. printer_id=1,
  162. print_name="Test",
  163. started_at=datetime.now(timezone.utc),
  164. tray_remain_start={(0, 0): 80},
  165. )
  166. printer_manager = MagicMock()
  167. printer_manager.get_status.return_value = SimpleNamespace(
  168. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  169. )
  170. # db returns assignment then spool
  171. db = _mock_db_sequential([assignment, spool])
  172. results = await on_print_complete(
  173. printer_id=1,
  174. data={"status": "completed"},
  175. printer_manager=printer_manager,
  176. db=db,
  177. archive_id=None,
  178. )
  179. assert len(results) == 1
  180. assert results[0]["spool_id"] == 2
  181. # 10% of 1000g = 100g
  182. assert results[0]["weight_used"] == 100.0
  183. assert results[0]["percent_used"] == 10
  184. @pytest.mark.asyncio
  185. async def test_no_double_tracking(self):
  186. """When 3MF handles a tray, AMS delta skips it."""
  187. spool = _make_spool(spool_id=1, label_weight=1000)
  188. assignment = _make_assignment(spool_id=1)
  189. archive = _make_archive(archive_id=10)
  190. _active_sessions[1] = PrintSession(
  191. printer_id=1,
  192. print_name="Benchy",
  193. started_at=datetime.now(timezone.utc),
  194. tray_remain_start={(0, 0): 80},
  195. )
  196. # tray_now=0 matches the single filament slot
  197. printer_manager = MagicMock()
  198. printer_manager.get_status.return_value = SimpleNamespace(
  199. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  200. progress=100,
  201. layer_num=50,
  202. tray_now=0,
  203. )
  204. # db returns: archive, queue_item(None), assignment, spool
  205. db = _mock_db_sequential([archive, None, assignment, spool])
  206. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  207. with (
  208. patch("backend.app.core.config.settings") as mock_settings,
  209. patch(
  210. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  211. return_value=filament_usage,
  212. ),
  213. ):
  214. mock_settings.base_dir = MagicMock()
  215. mock_path = MagicMock()
  216. mock_path.exists.return_value = True
  217. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  218. results = await on_print_complete(
  219. printer_id=1,
  220. data={"status": "completed"},
  221. printer_manager=printer_manager,
  222. db=db,
  223. archive_id=10,
  224. )
  225. # Only 1 result (3MF), NOT 2 (3MF + AMS delta)
  226. assert len(results) == 1
  227. assert results[0]["weight_used"] == 15.0
  228. class TestTrackFrom3mf:
  229. """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
  230. @pytest.mark.asyncio
  231. async def test_linear_fallback_for_partial_print(self):
  232. """Falls back to linear scaling when gcode layer data unavailable."""
  233. spool = _make_spool(spool_id=1, label_weight=1000)
  234. assignment = _make_assignment(spool_id=1)
  235. archive = _make_archive(archive_id=10)
  236. # db: archive, queue_item(None), assignment, spool
  237. db = _mock_db_sequential([archive, None, assignment, spool])
  238. printer_manager = MagicMock()
  239. printer_manager.get_status.return_value = SimpleNamespace(
  240. progress=50,
  241. layer_num=25,
  242. tray_now=0,
  243. )
  244. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  245. handled_trays: set[tuple[int, int]] = set()
  246. with (
  247. patch("backend.app.core.config.settings") as mock_settings,
  248. patch(
  249. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  250. return_value=filament_usage,
  251. ),
  252. patch(
  253. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  254. return_value=None, # No layer data available
  255. ),
  256. ):
  257. mock_settings.base_dir = MagicMock()
  258. mock_path = MagicMock()
  259. mock_path.exists.return_value = True
  260. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  261. results = await _track_from_3mf(
  262. printer_id=1,
  263. archive_id=10,
  264. status="failed",
  265. print_name="Benchy",
  266. handled_trays=handled_trays,
  267. printer_manager=printer_manager,
  268. db=db,
  269. )
  270. assert len(results) == 1
  271. # 50% of 20g = 10g
  272. assert results[0]["weight_used"] == 10.0
  273. # Tray should be marked as handled
  274. assert (0, 0) in handled_trays
  275. @pytest.mark.asyncio
  276. async def test_per_layer_partial_print(self):
  277. """Failed print at layer N uses gcode cumulative data."""
  278. spool = _make_spool(spool_id=1, label_weight=1000)
  279. assignment = _make_assignment(spool_id=1)
  280. archive = _make_archive(archive_id=10)
  281. # db: archive, queue_item(None), assignment, spool
  282. db = _mock_db_sequential([archive, None, assignment, spool])
  283. printer_manager = MagicMock()
  284. printer_manager.get_status.return_value = SimpleNamespace(
  285. progress=50,
  286. layer_num=25,
  287. tray_now=0,
  288. )
  289. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  290. # Per-layer data: at layer 25, filament 0 used 5000mm
  291. layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}}
  292. filament_props = {1: {"density": 1.24, "diameter": 1.75}}
  293. handled_trays: set[tuple[int, int]] = set()
  294. with (
  295. patch("backend.app.core.config.settings") as mock_settings,
  296. patch(
  297. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  298. return_value=filament_usage,
  299. ),
  300. patch(
  301. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  302. return_value=layer_data,
  303. ),
  304. patch(
  305. "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
  306. return_value={0: 5000.0},
  307. ),
  308. patch(
  309. "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
  310. return_value=filament_props,
  311. ),
  312. patch(
  313. "backend.app.utils.threemf_tools.mm_to_grams",
  314. return_value=12.0, # 5000mm at 1.75mm/1.24g/cm3
  315. ),
  316. ):
  317. mock_settings.base_dir = MagicMock()
  318. mock_path = MagicMock()
  319. mock_path.exists.return_value = True
  320. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  321. results = await _track_from_3mf(
  322. printer_id=1,
  323. archive_id=10,
  324. status="failed",
  325. print_name="Benchy",
  326. handled_trays=handled_trays,
  327. printer_manager=printer_manager,
  328. db=db,
  329. )
  330. assert len(results) == 1
  331. # Should use per-layer grams (12.0g), not linear scale (10.0g)
  332. assert results[0]["weight_used"] == 12.0
  333. @pytest.mark.asyncio
  334. async def test_completed_print_uses_full_weight(self):
  335. """Completed print uses full 3MF weight (scale=1.0)."""
  336. spool = _make_spool(spool_id=1, label_weight=1000)
  337. assignment = _make_assignment(spool_id=1)
  338. archive = _make_archive(archive_id=10)
  339. # db: archive, queue_item(None), assignment, spool
  340. db = _mock_db_sequential([archive, None, assignment, spool])
  341. printer_manager = MagicMock()
  342. printer_manager.get_status.return_value = SimpleNamespace(
  343. progress=100,
  344. layer_num=50,
  345. tray_now=0,
  346. )
  347. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
  348. handled_trays: set[tuple[int, int]] = set()
  349. with (
  350. patch("backend.app.core.config.settings") as mock_settings,
  351. patch(
  352. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  353. return_value=filament_usage,
  354. ),
  355. ):
  356. mock_settings.base_dir = MagicMock()
  357. mock_path = MagicMock()
  358. mock_path.exists.return_value = True
  359. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  360. results = await _track_from_3mf(
  361. printer_id=1,
  362. archive_id=10,
  363. status="completed",
  364. print_name="Benchy",
  365. handled_trays=handled_trays,
  366. printer_manager=printer_manager,
  367. db=db,
  368. )
  369. assert len(results) == 1
  370. assert results[0]["weight_used"] == 20.0
  371. @pytest.mark.asyncio
  372. async def test_tray_now_override_for_single_filament(self):
  373. """Single-filament non-queue print uses tray_now instead of slot_id mapping."""
  374. # Spool 2 is at AMS1-T3 (global_tray_id=7)
  375. spool = _make_spool(spool_id=2, label_weight=1000)
  376. assignment = _make_assignment(spool_id=2, ams_id=1, tray_id=3)
  377. archive = _make_archive(archive_id=10)
  378. # db: archive, queue_item(None), assignment, spool
  379. db = _mock_db_sequential([archive, None, assignment, spool])
  380. # tray_now=7 = (ams_id=1, tray_id=3), the ACTUAL tray used
  381. printer_manager = MagicMock()
  382. printer_manager.get_status.return_value = SimpleNamespace(
  383. progress=100,
  384. layer_num=50,
  385. tray_now=7,
  386. )
  387. # 3MF has slot_id=12 (would default-map to ams_id=2, tray_id=3 — WRONG)
  388. filament_usage = [{"slot_id": 12, "used_g": 10.6, "type": "PLA", "color": "#FF0000"}]
  389. handled_trays: set[tuple[int, int]] = set()
  390. with (
  391. patch("backend.app.core.config.settings") as mock_settings,
  392. patch(
  393. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  394. return_value=filament_usage,
  395. ),
  396. ):
  397. mock_settings.base_dir = MagicMock()
  398. mock_path = MagicMock()
  399. mock_path.exists.return_value = True
  400. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  401. results = await _track_from_3mf(
  402. printer_id=1,
  403. archive_id=10,
  404. status="completed",
  405. print_name="Test",
  406. handled_trays=handled_trays,
  407. printer_manager=printer_manager,
  408. db=db,
  409. )
  410. assert len(results) == 1
  411. assert results[0]["spool_id"] == 2
  412. assert results[0]["ams_id"] == 1
  413. assert results[0]["tray_id"] == 3
  414. assert results[0]["weight_used"] == 10.6
  415. assert (1, 3) in handled_trays
  416. @pytest.mark.asyncio
  417. async def test_queue_ams_mapping_overrides_default(self):
  418. """Queue item ams_mapping overrides default slot_id mapping."""
  419. # Spool at AMS1-T3 (global_tray_id=7)
  420. spool = _make_spool(spool_id=5, label_weight=1000)
  421. assignment = _make_assignment(spool_id=5, ams_id=1, tray_id=3)
  422. archive = _make_archive(archive_id=20)
  423. # Queue item maps slot 1 → global tray 7 (ams_id=1, tray_id=3)
  424. queue_item = _make_queue_item(ams_mapping="[7, -1, -1, -1]")
  425. # db: archive, queue_item, assignment, spool
  426. db = _mock_db_sequential([archive, queue_item, assignment, spool])
  427. printer_manager = MagicMock()
  428. printer_manager.get_status.return_value = SimpleNamespace(
  429. progress=100,
  430. layer_num=50,
  431. tray_now=7,
  432. )
  433. filament_usage = [{"slot_id": 1, "used_g": 25.0, "type": "PETG", "color": ""}]
  434. handled_trays: set[tuple[int, int]] = set()
  435. with (
  436. patch("backend.app.core.config.settings") as mock_settings,
  437. patch(
  438. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  439. return_value=filament_usage,
  440. ),
  441. ):
  442. mock_settings.base_dir = MagicMock()
  443. mock_path = MagicMock()
  444. mock_path.exists.return_value = True
  445. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  446. results = await _track_from_3mf(
  447. printer_id=1,
  448. archive_id=20,
  449. status="completed",
  450. print_name="Queue Print",
  451. handled_trays=handled_trays,
  452. printer_manager=printer_manager,
  453. db=db,
  454. )
  455. assert len(results) == 1
  456. assert results[0]["spool_id"] == 5
  457. assert results[0]["ams_id"] == 1
  458. assert results[0]["tray_id"] == 3
  459. assert results[0]["weight_used"] == 25.0
  460. @pytest.mark.asyncio
  461. async def test_multi_filament_uses_queue_mapping(self):
  462. """Multi-filament queue prints use ams_mapping for each slot."""
  463. spool_a = _make_spool(spool_id=1, label_weight=1000)
  464. spool_b = _make_spool(spool_id=2, label_weight=1000)
  465. assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  466. assign_b = _make_assignment(spool_id=2, ams_id=1, tray_id=2)
  467. archive = _make_archive(archive_id=30)
  468. # slot 1 → tray 0 (AMS0-T0), slot 2 → tray 6 (AMS1-T2)
  469. queue_item = _make_queue_item(ams_mapping="[0, 6]")
  470. # db: archive, queue_item, assign_a, spool_a, assign_b, spool_b
  471. db = _mock_db_sequential([archive, queue_item, assign_a, spool_a, assign_b, spool_b])
  472. printer_manager = MagicMock()
  473. printer_manager.get_status.return_value = SimpleNamespace(
  474. progress=100,
  475. layer_num=50,
  476. tray_now=6,
  477. )
  478. filament_usage = [
  479. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
  480. {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
  481. ]
  482. handled_trays: set[tuple[int, int]] = set()
  483. with (
  484. patch("backend.app.core.config.settings") as mock_settings,
  485. patch(
  486. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  487. return_value=filament_usage,
  488. ),
  489. ):
  490. mock_settings.base_dir = MagicMock()
  491. mock_path = MagicMock()
  492. mock_path.exists.return_value = True
  493. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  494. results = await _track_from_3mf(
  495. printer_id=1,
  496. archive_id=30,
  497. status="completed",
  498. print_name="Multi",
  499. handled_trays=handled_trays,
  500. printer_manager=printer_manager,
  501. db=db,
  502. )
  503. assert len(results) == 2
  504. assert results[0]["spool_id"] == 1
  505. assert results[0]["ams_id"] == 0
  506. assert results[0]["tray_id"] == 0
  507. assert results[0]["weight_used"] == 10.0
  508. assert results[1]["spool_id"] == 2
  509. assert results[1]["ams_id"] == 1
  510. assert results[1]["tray_id"] == 2
  511. assert results[1]["weight_used"] == 5.0
  512. @pytest.mark.asyncio
  513. async def test_no_tray_now_override_for_multi_filament(self):
  514. """Multi-filament non-queue prints fall back to default mapping, not tray_now."""
  515. spool = _make_spool(spool_id=1, label_weight=1000)
  516. assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  517. archive = _make_archive(archive_id=10)
  518. # db: archive, queue_item(None), assignment, spool (2nd slot has no assignment)
  519. db = _mock_db_sequential([archive, None, assignment, spool, None])
  520. printer_manager = MagicMock()
  521. printer_manager.get_status.return_value = SimpleNamespace(
  522. progress=100,
  523. layer_num=50,
  524. tray_now=4, # tray_now won't be used
  525. )
  526. # Two filament slots with usage
  527. filament_usage = [
  528. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
  529. {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
  530. ]
  531. handled_trays: set[tuple[int, int]] = set()
  532. with (
  533. patch("backend.app.core.config.settings") as mock_settings,
  534. patch(
  535. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  536. return_value=filament_usage,
  537. ),
  538. ):
  539. mock_settings.base_dir = MagicMock()
  540. mock_path = MagicMock()
  541. mock_path.exists.return_value = True
  542. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  543. results = await _track_from_3mf(
  544. printer_id=1,
  545. archive_id=10,
  546. status="completed",
  547. print_name="Test",
  548. handled_trays=handled_trays,
  549. printer_manager=printer_manager,
  550. db=db,
  551. )
  552. # Should use default mapping (slot 1 → tray 0, slot 2 → tray 1)
  553. assert len(results) == 1 # Only slot 1 has assignment
  554. assert results[0]["ams_id"] == 0
  555. assert results[0]["tray_id"] == 0
  556. class TestNotificationVariables:
  557. """Tests for filament_details formatting in notifications."""
  558. def test_filament_details_single_slot(self):
  559. """Single slot produces 'PLA: 15.2g' format."""
  560. slots = [{"type": "PLA", "used_g": 15.2, "slot_id": 1, "color": "#FF0000"}]
  561. parts = []
  562. for slot in slots:
  563. ftype = slot.get("type", "Unknown") or "Unknown"
  564. used = slot.get("used_g", 0)
  565. parts.append(f"{ftype}: {used:.1f}g")
  566. result = " | ".join(parts)
  567. assert result == "PLA: 15.2g"
  568. def test_filament_details_multi_slot(self):
  569. """Multiple slots produce 'PLA: 10.0g | PETG: 5.0g' format."""
  570. slots = [
  571. {"type": "PLA", "used_g": 10.0, "slot_id": 1, "color": ""},
  572. {"type": "PETG", "used_g": 5.0, "slot_id": 2, "color": ""},
  573. ]
  574. parts = []
  575. for slot in slots:
  576. ftype = slot.get("type", "Unknown") or "Unknown"
  577. used = slot.get("used_g", 0)
  578. parts.append(f"{ftype}: {used:.1f}g")
  579. result = " | ".join(parts)
  580. assert result == "PLA: 10.0g | PETG: 5.0g"
  581. def test_filament_details_empty_type(self):
  582. """Empty type defaults to 'Unknown'."""
  583. slots = [{"type": "", "used_g": 5.0, "slot_id": 1, "color": ""}]
  584. parts = []
  585. for slot in slots:
  586. ftype = slot.get("type", "Unknown") or "Unknown"
  587. used = slot.get("used_g", 0)
  588. parts.append(f"{ftype}: {used:.1f}g")
  589. result = " | ".join(parts)
  590. assert result == "Unknown: 5.0g"
  591. def test_filament_grams_scaled_for_partial(self):
  592. """filament_grams is scaled by progress for partial prints."""
  593. filament_used_grams = 20.0
  594. progress = 50
  595. scale = max(0.0, min(progress / 100.0, 1.0))
  596. scaled = round(filament_used_grams * scale, 1)
  597. assert scaled == 10.0
  598. def test_filament_grams_zero_progress(self):
  599. """Progress=0 at cancellation gives 0.0g."""
  600. filament_used_grams = 20.0
  601. progress = 0
  602. scale = max(0.0, min(progress / 100.0, 1.0))
  603. scaled = round(filament_used_grams * scale, 1)
  604. assert scaled == 0.0
  605. def test_slot_scaling_for_partial(self):
  606. """Per-slot usage is scaled linearly for partial prints."""
  607. slots = [
  608. {"type": "PLA", "used_g": 20.0, "slot_id": 1, "color": ""},
  609. {"type": "PETG", "used_g": 10.0, "slot_id": 2, "color": ""},
  610. ]
  611. progress = 30
  612. scale = max(0.0, min(progress / 100.0, 1.0))
  613. scaled_slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
  614. assert scaled_slots[0]["used_g"] == 6.0
  615. assert scaled_slots[1]["used_g"] == 3.0