test_usage_tracker.py 14 KB

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