test_usage_tracker.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. """Unit tests for the filament usage tracker.
  2. Tests 3MF-primary tracking (Path 1) and AMS remain% delta fallback
  3. (Path 2) for spools not covered by 3MF data.
  4. """
  5. from datetime import datetime, timezone
  6. from unittest.mock import AsyncMock, MagicMock, patch
  7. import pytest
  8. from backend.app.services.usage_tracker import (
  9. PrintSession,
  10. _active_sessions,
  11. _track_from_3mf,
  12. on_print_complete,
  13. on_print_start,
  14. )
  15. def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
  16. """Create a mock Spool object."""
  17. spool = MagicMock()
  18. spool.id = id
  19. spool.label_weight = label_weight
  20. spool.weight_used = weight_used
  21. spool.tag_uid = tag_uid
  22. spool.tray_uuid = tray_uuid
  23. spool.last_used = None
  24. return spool
  25. def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
  26. """Create a mock SpoolAssignment object."""
  27. assignment = MagicMock()
  28. assignment.spool_id = spool_id
  29. assignment.printer_id = printer_id
  30. assignment.ams_id = ams_id
  31. assignment.tray_id = tray_id
  32. return assignment
  33. def _make_printer_state(ams_data, progress=0, layer_num=0, tray_now=255):
  34. """Create a mock printer state with AMS data."""
  35. state = MagicMock()
  36. state.raw_data = {"ams": ams_data}
  37. state.progress = progress
  38. state.layer_num = layer_num
  39. state.tray_now = tray_now
  40. return state
  41. def _make_printer_manager(state=None):
  42. """Create a mock printer manager."""
  43. pm = MagicMock()
  44. pm.get_status.return_value = state
  45. return pm
  46. class TestOnPrintStart:
  47. """Tests for on_print_start — capturing AMS remain%."""
  48. @pytest.fixture(autouse=True)
  49. def _clear_sessions(self):
  50. _active_sessions.clear()
  51. yield
  52. _active_sessions.clear()
  53. @pytest.mark.asyncio
  54. async def test_creates_session_with_valid_remain(self):
  55. """Session created with remain% data for trays reporting 0-100."""
  56. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
  57. pm = _make_printer_manager(_make_printer_state(ams_data))
  58. await on_print_start(1, {"subtask_name": "test_print"}, pm)
  59. assert 1 in _active_sessions
  60. session = _active_sessions[1]
  61. assert session.print_name == "test_print"
  62. assert session.tray_remain_start == {(0, 0): 80}
  63. @pytest.mark.asyncio
  64. async def test_creates_session_even_without_valid_remain(self):
  65. """Session still created when remain=-1 (for 3MF fallback path)."""
  66. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]
  67. pm = _make_printer_manager(_make_printer_state(ams_data))
  68. await on_print_start(1, {"subtask_name": "test_print"}, pm)
  69. assert 1 in _active_sessions
  70. session = _active_sessions[1]
  71. assert session.tray_remain_start == {} # Empty, no valid remain
  72. @pytest.mark.asyncio
  73. async def test_skips_without_ams_data(self):
  74. """No session created when no AMS data available."""
  75. state = MagicMock()
  76. state.raw_data = {"ams": []}
  77. pm = _make_printer_manager(state)
  78. await on_print_start(1, {"subtask_name": "test"}, pm)
  79. assert 1 not in _active_sessions
  80. class TestOnPrintCompleteAMSDelta:
  81. """Tests for Path 1: AMS remain% delta tracking."""
  82. @pytest.fixture(autouse=True)
  83. def _clear_sessions(self):
  84. _active_sessions.clear()
  85. yield
  86. _active_sessions.clear()
  87. @pytest.mark.asyncio
  88. async def test_computes_delta_and_updates_spool(self):
  89. """Spool weight_used updated by remain% delta * label_weight."""
  90. # Set up session with start remain = 80%
  91. _active_sessions[1] = PrintSession(
  92. printer_id=1,
  93. print_name="test",
  94. started_at=datetime.now(timezone.utc),
  95. tray_remain_start={(0, 0): 80},
  96. )
  97. # Current remain = 70% → 10% consumed → 100g on 1000g spool
  98. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
  99. pm = _make_printer_manager(_make_printer_state(ams_data))
  100. spool = _make_spool(label_weight=1000, weight_used=50)
  101. assignment = _make_assignment()
  102. db = AsyncMock()
  103. # First execute → assignment, second → spool
  104. db.execute = AsyncMock(
  105. side_effect=[
  106. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  107. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  108. ]
  109. )
  110. results = await on_print_complete(1, {"status": "completed"}, pm, db)
  111. assert len(results) == 1
  112. assert results[0]["weight_used"] == 100.0
  113. assert results[0]["percent_used"] == 10
  114. # weight_used should be old (50) + delta (100)
  115. assert spool.weight_used == 150.0
  116. db.commit.assert_called_once()
  117. @pytest.mark.asyncio
  118. async def test_skips_negative_delta(self):
  119. """No tracking when remain increased (spool refilled)."""
  120. _active_sessions[1] = PrintSession(
  121. printer_id=1,
  122. print_name="test",
  123. started_at=datetime.now(timezone.utc),
  124. tray_remain_start={(0, 0): 50},
  125. )
  126. # Remain went UP: 50 → 80 (refilled)
  127. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
  128. pm = _make_printer_manager(_make_printer_state(ams_data))
  129. db = AsyncMock()
  130. results = await on_print_complete(1, {"status": "completed"}, pm, db)
  131. assert results == []
  132. db.commit.assert_not_called()
  133. @pytest.mark.asyncio
  134. async def test_no_session_falls_through_to_3mf(self):
  135. """When no session exists, AMS delta path skipped (3MF may still run)."""
  136. pm = _make_printer_manager()
  137. db = AsyncMock()
  138. results = await on_print_complete(1, {"status": "completed"}, pm, db)
  139. assert results == []
  140. class TestTrackFrom3MF:
  141. """Tests for Path 2: 3MF per-filament fallback tracking."""
  142. @pytest.mark.asyncio
  143. async def test_updates_non_bl_spool_from_3mf(self):
  144. """Non-BL spool gets weight_used from 3MF used_g for completed print."""
  145. spool = _make_spool(id=5, label_weight=1000, weight_used=100)
  146. assignment = _make_assignment(spool_id=5)
  147. archive = MagicMock()
  148. archive.file_path = "archives/test.3mf"
  149. db = AsyncMock()
  150. # archive, queue_item(None), assignment, spool
  151. db.execute = AsyncMock(
  152. side_effect=[
  153. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  154. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  155. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  156. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  157. ]
  158. )
  159. pm = _make_printer_manager(_make_printer_state([], tray_now=0))
  160. filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PLA", "color": "#FF0000"}]
  161. with (
  162. patch("backend.app.core.config.settings") as mock_settings,
  163. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  164. ):
  165. mock_path = MagicMock()
  166. mock_path.exists.return_value = True
  167. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  168. results = await _track_from_3mf(
  169. printer_id=1,
  170. archive_id=10,
  171. status="completed",
  172. print_name="test_print",
  173. handled_trays=set(),
  174. printer_manager=pm,
  175. db=db,
  176. )
  177. assert len(results) == 1
  178. assert results[0]["spool_id"] == 5
  179. assert results[0]["weight_used"] == 25.5
  180. # weight_used = old (100) + 3MF (25.5)
  181. assert spool.weight_used == 125.5
  182. @pytest.mark.asyncio
  183. async def test_scales_by_progress_for_failed_print(self):
  184. """Failed print scales 3MF estimate by progress percentage."""
  185. spool = _make_spool(id=1, label_weight=1000, weight_used=0)
  186. assignment = _make_assignment()
  187. archive = MagicMock()
  188. archive.file_path = "archives/test.3mf"
  189. db = AsyncMock()
  190. # archive, queue_item(None), assignment, spool
  191. db.execute = AsyncMock(
  192. side_effect=[
  193. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  194. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  195. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  196. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  197. ]
  198. )
  199. # Print failed at 50% progress → 50g consumed from 100g estimate
  200. pm = _make_printer_manager(_make_printer_state([], progress=50, tray_now=0))
  201. filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
  202. with (
  203. patch("backend.app.core.config.settings") as mock_settings,
  204. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  205. ):
  206. mock_path = MagicMock()
  207. mock_path.exists.return_value = True
  208. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  209. results = await _track_from_3mf(
  210. printer_id=1,
  211. archive_id=10,
  212. status="failed",
  213. print_name="test",
  214. handled_trays=set(),
  215. printer_manager=pm,
  216. db=db,
  217. )
  218. assert len(results) == 1
  219. assert results[0]["weight_used"] == 50.0
  220. assert spool.weight_used == 50.0
  221. @pytest.mark.asyncio
  222. async def test_tracks_bl_spools_via_3mf(self):
  223. """BL spools (with tag_uid) ARE now tracked via 3MF (unified tracking)."""
  224. spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4")
  225. assignment = _make_assignment()
  226. archive = MagicMock()
  227. archive.file_path = "archives/test.3mf"
  228. db = AsyncMock()
  229. # archive, queue_item(None), assignment, spool
  230. db.execute = AsyncMock(
  231. side_effect=[
  232. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  233. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  234. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  235. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  236. ]
  237. )
  238. pm = _make_printer_manager(_make_printer_state([], tray_now=0))
  239. filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
  240. with (
  241. patch("backend.app.core.config.settings") as mock_settings,
  242. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  243. ):
  244. mock_path = MagicMock()
  245. mock_path.exists.return_value = True
  246. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  247. results = await _track_from_3mf(
  248. printer_id=1,
  249. archive_id=10,
  250. status="completed",
  251. print_name="test",
  252. handled_trays=set(),
  253. printer_manager=pm,
  254. db=db,
  255. )
  256. assert len(results) == 1
  257. assert results[0]["spool_id"] == 1
  258. assert results[0]["weight_used"] == 50.0
  259. @pytest.mark.asyncio
  260. async def test_skips_already_handled_trays(self):
  261. """Trays handled by AMS remain% delta are not double-tracked via 3MF."""
  262. archive = MagicMock()
  263. archive.file_path = "archives/test.3mf"
  264. db = AsyncMock()
  265. # archive, queue_item(None)
  266. db.execute = AsyncMock(
  267. side_effect=[
  268. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  269. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  270. ]
  271. )
  272. pm = _make_printer_manager(_make_printer_state([], tray_now=0))
  273. filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
  274. with (
  275. patch("backend.app.core.config.settings") as mock_settings,
  276. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  277. ):
  278. mock_path = MagicMock()
  279. mock_path.exists.return_value = True
  280. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  281. results = await _track_from_3mf(
  282. printer_id=1,
  283. archive_id=10,
  284. status="completed",
  285. print_name="test",
  286. handled_trays={(0, 0)}, # slot_id=1 → ams_id=0, tray_id=0
  287. printer_manager=pm,
  288. db=db,
  289. )
  290. assert results == []
  291. @pytest.mark.asyncio
  292. async def test_slot_to_tray_mapping(self):
  293. """3MF slot_id maps correctly to (ams_id, tray_id) via tray_now."""
  294. # tray_now=4 → ams_id=1, tray_id=0 (single filament uses tray_now)
  295. spool = _make_spool(id=9)
  296. assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)
  297. archive = MagicMock()
  298. archive.file_path = "archives/test.3mf"
  299. db = AsyncMock()
  300. # archive, queue_item(None), assignment, spool
  301. db.execute = AsyncMock(
  302. side_effect=[
  303. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  304. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  305. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  306. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  307. ]
  308. )
  309. pm = _make_printer_manager(_make_printer_state([], tray_now=4))
  310. filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}]
  311. with (
  312. patch("backend.app.core.config.settings") as mock_settings,
  313. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  314. ):
  315. mock_path = MagicMock()
  316. mock_path.exists.return_value = True
  317. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  318. results = await _track_from_3mf(
  319. printer_id=1,
  320. archive_id=10,
  321. status="completed",
  322. print_name="test",
  323. handled_trays=set(),
  324. printer_manager=pm,
  325. db=db,
  326. )
  327. assert len(results) == 1
  328. assert results[0]["ams_id"] == 1
  329. assert results[0]["tray_id"] == 0