test_usage_tracker.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  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. spool.cost_per_kg = None
  25. spool.material = "PLA"
  26. return spool
  27. def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0, created_at=None):
  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. assignment.created_at = created_at or datetime.now(timezone.utc)
  35. return assignment
  36. def _make_printer_state(ams_data, progress=0, layer_num=0, tray_now=255):
  37. """Create a mock printer state with AMS data."""
  38. state = MagicMock()
  39. state.raw_data = {"ams": ams_data}
  40. state.progress = progress
  41. state.layer_num = layer_num
  42. state.tray_now = tray_now
  43. return state
  44. def _make_printer_manager(state=None):
  45. """Create a mock printer manager."""
  46. pm = MagicMock()
  47. pm.get_status.return_value = state
  48. return pm
  49. class TestOnPrintStart:
  50. """Tests for on_print_start — capturing AMS remain%."""
  51. @pytest.fixture(autouse=True)
  52. def _clear_sessions(self):
  53. _active_sessions.clear()
  54. yield
  55. _active_sessions.clear()
  56. @pytest.mark.asyncio
  57. async def test_creates_session_with_valid_remain(self):
  58. """Session created with remain% data for trays reporting 0-100."""
  59. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
  60. pm = _make_printer_manager(_make_printer_state(ams_data))
  61. await on_print_start(1, {"subtask_name": "test_print"}, pm)
  62. assert 1 in _active_sessions
  63. session = _active_sessions[1]
  64. assert session.print_name == "test_print"
  65. assert session.tray_remain_start == {(0, 0): 80}
  66. @pytest.mark.asyncio
  67. async def test_creates_session_even_without_valid_remain(self):
  68. """Session still created when remain=-1 (for 3MF fallback path)."""
  69. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]
  70. pm = _make_printer_manager(_make_printer_state(ams_data))
  71. await on_print_start(1, {"subtask_name": "test_print"}, pm)
  72. assert 1 in _active_sessions
  73. session = _active_sessions[1]
  74. assert session.tray_remain_start == {} # Empty, no valid remain
  75. @pytest.mark.asyncio
  76. async def test_skips_without_ams_data(self):
  77. """No session created when no AMS data available."""
  78. state = MagicMock()
  79. state.raw_data = {"ams": []}
  80. pm = _make_printer_manager(state)
  81. await on_print_start(1, {"subtask_name": "test"}, pm)
  82. assert 1 not in _active_sessions
  83. class TestOnPrintCompleteAMSDelta:
  84. """Tests for Path 1: AMS remain% delta tracking."""
  85. @pytest.fixture(autouse=True)
  86. def _clear_sessions(self):
  87. _active_sessions.clear()
  88. yield
  89. _active_sessions.clear()
  90. @pytest.fixture(autouse=True)
  91. def _mock_get_setting(self):
  92. with patch(
  93. "backend.app.api.routes.settings.get_setting",
  94. new_callable=AsyncMock,
  95. return_value=None,
  96. ):
  97. yield
  98. @pytest.mark.asyncio
  99. async def test_computes_delta_and_updates_spool(self):
  100. """Spool weight_used updated by remain% delta * label_weight."""
  101. # Set up session with start remain = 80%
  102. _active_sessions[1] = PrintSession(
  103. printer_id=1,
  104. print_name="test",
  105. started_at=datetime.now(timezone.utc),
  106. tray_remain_start={(0, 0): 80},
  107. )
  108. # Current remain = 70% → 10% consumed → 100g on 1000g spool
  109. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
  110. pm = _make_printer_manager(_make_printer_state(ams_data))
  111. spool = _make_spool(label_weight=1000, weight_used=50)
  112. assignment = _make_assignment()
  113. db = AsyncMock()
  114. # First 2 executes → _find_3mf_by_filename (library + archive search, uses scalars().all()),
  115. # then assignment, then spool for the AMS fallback path
  116. db.execute = AsyncMock(
  117. side_effect=[
  118. MagicMock(), # _find_3mf_by_filename: library search
  119. MagicMock(), # _find_3mf_by_filename: archive search
  120. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  121. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  122. ]
  123. )
  124. results = await on_print_complete(1, {"status": "completed"}, pm, db)
  125. assert len(results) == 1
  126. assert results[0]["weight_used"] == 100.0
  127. assert results[0]["percent_used"] == 10
  128. # weight_used should be old (50) + delta (100)
  129. assert spool.weight_used == 150.0
  130. db.commit.assert_called_once()
  131. @pytest.mark.asyncio
  132. async def test_skips_negative_delta(self):
  133. """No tracking when remain increased (spool refilled)."""
  134. _active_sessions[1] = PrintSession(
  135. printer_id=1,
  136. print_name="test",
  137. started_at=datetime.now(timezone.utc),
  138. tray_remain_start={(0, 0): 50},
  139. )
  140. # Remain went UP: 50 → 80 (refilled)
  141. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
  142. pm = _make_printer_manager(_make_printer_state(ams_data))
  143. db = AsyncMock()
  144. results = await on_print_complete(1, {"status": "completed"}, pm, db)
  145. assert results == []
  146. db.commit.assert_not_called()
  147. @pytest.mark.asyncio
  148. async def test_no_session_falls_through_to_3mf(self):
  149. """When no session exists, AMS delta path skipped (3MF may still run)."""
  150. pm = _make_printer_manager()
  151. db = AsyncMock()
  152. results = await on_print_complete(1, {"status": "completed"}, pm, db)
  153. assert results == []
  154. class TestTrackFrom3MF:
  155. """Tests for Path 2: 3MF per-filament fallback tracking."""
  156. @pytest.mark.asyncio
  157. async def test_updates_non_bl_spool_from_3mf(self):
  158. """Non-BL spool gets weight_used from 3MF used_g for completed print."""
  159. spool = _make_spool(id=5, label_weight=1000, weight_used=100)
  160. assignment = _make_assignment(spool_id=5)
  161. archive = MagicMock()
  162. archive.file_path = "archives/test.3mf"
  163. db = AsyncMock()
  164. # archive, queue_item(None), assignment, spool
  165. db.execute = AsyncMock(
  166. side_effect=[
  167. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  168. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  169. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  170. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  171. ]
  172. )
  173. pm = _make_printer_manager(_make_printer_state([], tray_now=0))
  174. filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PLA", "color": "#FF0000"}]
  175. with (
  176. patch("backend.app.core.config.settings") as mock_settings,
  177. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  178. ):
  179. mock_path = MagicMock()
  180. mock_path.exists.return_value = True
  181. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  182. results = await _track_from_3mf(
  183. printer_id=1,
  184. archive_id=10,
  185. status="completed",
  186. print_name="test_print",
  187. handled_trays=set(),
  188. printer_manager=pm,
  189. db=db,
  190. )
  191. assert len(results) == 1
  192. assert results[0]["spool_id"] == 5
  193. assert results[0]["weight_used"] == 25.5
  194. # weight_used = old (100) + 3MF (25.5)
  195. assert spool.weight_used == 125.5
  196. @pytest.mark.asyncio
  197. async def test_scales_by_progress_for_failed_print(self):
  198. """Failed print scales 3MF estimate by progress percentage."""
  199. spool = _make_spool(id=1, label_weight=1000, weight_used=0)
  200. assignment = _make_assignment()
  201. archive = MagicMock()
  202. archive.file_path = "archives/test.3mf"
  203. db = AsyncMock()
  204. # archive, queue_item(None), assignment, spool
  205. db.execute = AsyncMock(
  206. side_effect=[
  207. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  208. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  209. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  210. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  211. ]
  212. )
  213. # Print failed at 50% progress → 50g consumed from 100g estimate
  214. pm = _make_printer_manager(_make_printer_state([], progress=50, tray_now=0))
  215. filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
  216. with (
  217. patch("backend.app.core.config.settings") as mock_settings,
  218. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  219. ):
  220. mock_path = MagicMock()
  221. mock_path.exists.return_value = True
  222. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  223. results = await _track_from_3mf(
  224. printer_id=1,
  225. archive_id=10,
  226. status="failed",
  227. print_name="test",
  228. handled_trays=set(),
  229. printer_manager=pm,
  230. db=db,
  231. )
  232. assert len(results) == 1
  233. assert results[0]["weight_used"] == 50.0
  234. assert spool.weight_used == 50.0
  235. @pytest.mark.asyncio
  236. async def test_tracks_bl_spools_via_3mf(self):
  237. """BL spools (with tag_uid) ARE now tracked via 3MF (unified tracking)."""
  238. spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4")
  239. assignment = _make_assignment()
  240. archive = MagicMock()
  241. archive.file_path = "archives/test.3mf"
  242. db = AsyncMock()
  243. # archive, queue_item(None), assignment, spool
  244. db.execute = AsyncMock(
  245. side_effect=[
  246. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  247. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  248. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  249. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  250. ]
  251. )
  252. pm = _make_printer_manager(_make_printer_state([], tray_now=0))
  253. filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
  254. with (
  255. patch("backend.app.core.config.settings") as mock_settings,
  256. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  257. ):
  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="completed",
  265. print_name="test",
  266. handled_trays=set(),
  267. printer_manager=pm,
  268. db=db,
  269. )
  270. assert len(results) == 1
  271. assert results[0]["spool_id"] == 1
  272. assert results[0]["weight_used"] == 50.0
  273. @pytest.mark.asyncio
  274. async def test_skips_already_handled_trays(self):
  275. """Trays handled by AMS remain% delta are not double-tracked via 3MF."""
  276. archive = MagicMock()
  277. archive.file_path = "archives/test.3mf"
  278. db = AsyncMock()
  279. # archive, queue_item(None)
  280. db.execute = AsyncMock(
  281. side_effect=[
  282. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  283. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  284. ]
  285. )
  286. pm = _make_printer_manager(_make_printer_state([], tray_now=0))
  287. filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
  288. with (
  289. patch("backend.app.core.config.settings") as mock_settings,
  290. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  291. ):
  292. mock_path = MagicMock()
  293. mock_path.exists.return_value = True
  294. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  295. results = await _track_from_3mf(
  296. printer_id=1,
  297. archive_id=10,
  298. status="completed",
  299. print_name="test",
  300. handled_trays={(0, 0)}, # slot_id=1 → ams_id=0, tray_id=0
  301. printer_manager=pm,
  302. db=db,
  303. )
  304. assert results == []
  305. @pytest.mark.asyncio
  306. async def test_slot_to_tray_mapping(self):
  307. """3MF slot_id maps correctly to (ams_id, tray_id) via tray_now."""
  308. # tray_now=4 → ams_id=1, tray_id=0 (single filament uses tray_now)
  309. spool = _make_spool(id=9)
  310. assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)
  311. archive = MagicMock()
  312. archive.file_path = "archives/test.3mf"
  313. db = AsyncMock()
  314. # archive, queue_item(None), assignment, spool
  315. db.execute = AsyncMock(
  316. side_effect=[
  317. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  318. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  319. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  320. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  321. ]
  322. )
  323. pm = _make_printer_manager(_make_printer_state([], tray_now=4))
  324. filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}]
  325. with (
  326. patch("backend.app.core.config.settings") as mock_settings,
  327. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  328. ):
  329. mock_path = MagicMock()
  330. mock_path.exists.return_value = True
  331. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  332. results = await _track_from_3mf(
  333. printer_id=1,
  334. archive_id=10,
  335. status="completed",
  336. print_name="test",
  337. handled_trays=set(),
  338. printer_manager=pm,
  339. db=db,
  340. )
  341. assert len(results) == 1
  342. assert results[0]["ams_id"] == 1
  343. assert results[0]["tray_id"] == 0
  344. class TestSpoolAssignmentSnapshot:
  345. """Tests for spool assignment snapshotting at print start (#459).
  346. When a spool runs empty mid-print, on_ams_change deletes the SpoolAssignment.
  347. The snapshot captured at print start ensures usage is still attributed correctly.
  348. """
  349. @pytest.fixture(autouse=True)
  350. def _clear_sessions(self):
  351. _active_sessions.clear()
  352. yield
  353. _active_sessions.clear()
  354. @pytest.fixture(autouse=True)
  355. def _mock_get_setting(self):
  356. with patch(
  357. "backend.app.api.routes.settings.get_setting",
  358. new_callable=AsyncMock,
  359. return_value=None,
  360. ):
  361. yield
  362. @pytest.mark.asyncio
  363. async def test_on_print_start_snapshots_assignments_with_db(self):
  364. """on_print_start captures spool assignments when db is provided."""
  365. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 60}]}]
  366. pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))
  367. assignment_0 = _make_assignment(spool_id=10, printer_id=1, ams_id=0, tray_id=0)
  368. assignment_1 = _make_assignment(spool_id=20, printer_id=1, ams_id=0, tray_id=1)
  369. db = AsyncMock()
  370. scalars_mock = MagicMock()
  371. scalars_mock.all.return_value = [assignment_0, assignment_1]
  372. result_mock = MagicMock()
  373. result_mock.scalars.return_value = scalars_mock
  374. db.execute = AsyncMock(return_value=result_mock)
  375. await on_print_start(1, {"subtask_name": "Benchy"}, pm, db=db)
  376. session = _active_sessions[1]
  377. assert session.spool_assignments == {(0, 0): 10, (0, 1): 20}
  378. @pytest.mark.asyncio
  379. async def test_on_print_start_empty_snapshot_without_db(self):
  380. """on_print_start creates empty snapshot when no db provided."""
  381. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
  382. pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))
  383. await on_print_start(1, {"subtask_name": "Benchy"}, pm)
  384. session = _active_sessions[1]
  385. assert session.spool_assignments == {}
  386. @pytest.mark.asyncio
  387. async def test_3mf_uses_snapshot_instead_of_live_query(self):
  388. """_track_from_3mf uses snapshot spool_id without querying SpoolAssignment."""
  389. spool = _make_spool(id=42, label_weight=1000)
  390. archive = MagicMock()
  391. archive.file_path = "archives/test.3mf"
  392. # db: archive, queue_item(None), spool — NO assignment query needed
  393. db = AsyncMock()
  394. db.execute = AsyncMock(
  395. side_effect=[
  396. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  397. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  398. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  399. ]
  400. )
  401. pm = _make_printer_manager(_make_printer_state([], tray_now=0))
  402. filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
  403. with (
  404. patch("backend.app.core.config.settings") as mock_settings,
  405. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  406. ):
  407. mock_path = MagicMock()
  408. mock_path.exists.return_value = True
  409. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  410. results = await _track_from_3mf(
  411. printer_id=1,
  412. archive_id=10,
  413. status="completed",
  414. print_name="Test",
  415. handled_trays=set(),
  416. printer_manager=pm,
  417. db=db,
  418. spool_assignments={(0, 0): 42},
  419. )
  420. assert len(results) == 1
  421. assert results[0]["spool_id"] == 42
  422. assert results[0]["weight_used"] == 15.0
  423. @pytest.mark.asyncio
  424. async def test_3mf_falls_back_to_live_query_without_snapshot(self):
  425. """_track_from_3mf queries SpoolAssignment when no snapshot exists."""
  426. spool = _make_spool(id=5, label_weight=1000)
  427. assignment = _make_assignment(spool_id=5)
  428. archive = MagicMock()
  429. archive.file_path = "archives/test.3mf"
  430. # db: archive, queue_item(None), assignment, spool
  431. db = AsyncMock()
  432. db.execute = AsyncMock(
  433. side_effect=[
  434. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  435. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  436. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  437. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  438. ]
  439. )
  440. pm = _make_printer_manager(_make_printer_state([], tray_now=0))
  441. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
  442. with (
  443. patch("backend.app.core.config.settings") as mock_settings,
  444. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  445. ):
  446. mock_path = MagicMock()
  447. mock_path.exists.return_value = True
  448. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  449. results = await _track_from_3mf(
  450. printer_id=1,
  451. archive_id=10,
  452. status="completed",
  453. print_name="Test",
  454. handled_trays=set(),
  455. printer_manager=pm,
  456. db=db,
  457. spool_assignments=None,
  458. )
  459. assert len(results) == 1
  460. assert results[0]["spool_id"] == 5
  461. @pytest.mark.asyncio
  462. async def test_ams_delta_uses_snapshot_over_live_query(self):
  463. """AMS remain% fallback uses snapshot spool_id instead of live query."""
  464. spool = _make_spool(id=77, label_weight=1000)
  465. _active_sessions[1] = PrintSession(
  466. printer_id=1,
  467. print_name="Benchy",
  468. started_at=datetime.now(timezone.utc),
  469. tray_remain_start={(0, 0): 80},
  470. spool_assignments={(0, 0): 77},
  471. )
  472. # Current remain = 70% → 10% delta → 100g
  473. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
  474. pm = _make_printer_manager(_make_printer_state(ams_data))
  475. # First 2 executes → _find_3mf_by_filename (library + archive search),
  476. # then live assignment check (returns None), then spool lookup by snapshot spool_id
  477. db = AsyncMock()
  478. db.execute = AsyncMock(
  479. side_effect=[
  480. MagicMock(), # _find_3mf_by_filename: library search
  481. MagicMock(), # _find_3mf_by_filename: archive search
  482. MagicMock(scalar_one_or_none=MagicMock(return_value=None)), # live assignment
  483. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  484. ]
  485. )
  486. results = await on_print_complete(
  487. printer_id=1,
  488. data={"status": "completed"},
  489. printer_manager=pm,
  490. db=db,
  491. archive_id=None,
  492. )
  493. assert len(results) == 1
  494. assert results[0]["spool_id"] == 77
  495. assert results[0]["weight_used"] == 100.0
  496. @pytest.mark.asyncio
  497. async def test_ams_delta_falls_back_to_live_query_without_snapshot(self):
  498. """AMS remain% fallback queries SpoolAssignment when snapshot is empty."""
  499. spool = _make_spool(id=33, label_weight=1000)
  500. assignment = _make_assignment(spool_id=33)
  501. _active_sessions[1] = PrintSession(
  502. printer_id=1,
  503. print_name="Benchy",
  504. started_at=datetime.now(timezone.utc),
  505. tray_remain_start={(0, 0): 80},
  506. spool_assignments={}, # Empty snapshot (pre-upgrade session)
  507. )
  508. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
  509. pm = _make_printer_manager(_make_printer_state(ams_data))
  510. # First 2 executes → _find_3mf_by_filename (library + archive search),
  511. # then assignment and spool for the AMS fallback path
  512. db = AsyncMock()
  513. db.execute = AsyncMock(
  514. side_effect=[
  515. MagicMock(), # _find_3mf_by_filename: library search
  516. MagicMock(), # _find_3mf_by_filename: archive search
  517. MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
  518. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  519. ]
  520. )
  521. results = await on_print_complete(
  522. printer_id=1,
  523. data={"status": "completed"},
  524. printer_manager=pm,
  525. db=db,
  526. archive_id=None,
  527. )
  528. assert len(results) == 1
  529. assert results[0]["spool_id"] == 33
  530. @pytest.mark.asyncio
  531. async def test_snapshot_survives_mid_print_unlink(self):
  532. """Core bug scenario: snapshot provides spool_id after mid-print unlink.
  533. Simulates the #459 scenario: spool runs empty mid-print, on_ams_change
  534. deletes the SpoolAssignment, but the snapshot from print start still
  535. has the spool_id so usage is correctly attributed at print completion.
  536. """
  537. spool = _make_spool(id=8, label_weight=1000, weight_used=50)
  538. archive = MagicMock()
  539. archive.file_path = "archives/big_print.3mf"
  540. # Session was created at print start WITH snapshot
  541. _active_sessions[1] = PrintSession(
  542. printer_id=1,
  543. print_name="Big Print",
  544. started_at=datetime.now(timezone.utc),
  545. tray_remain_start={(0, 0): 90},
  546. spool_assignments={(0, 0): 8}, # Snapshot from print start
  547. )
  548. pm = _make_printer_manager(
  549. _make_printer_state(
  550. [{"id": 0, "tray": [{"id": 0, "remain": 75}]}],
  551. tray_now=0,
  552. )
  553. )
  554. filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
  555. # db: archive, queue_item(None), live assignment(None), spool,
  556. # then cost aggregation queries
  557. # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
  558. db = AsyncMock()
  559. db.execute = AsyncMock(
  560. side_effect=[
  561. MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
  562. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  563. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  564. MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
  565. # Cost aggregation: sum query (uses .scalar()), archive lookup
  566. MagicMock(scalar=MagicMock(return_value=0)),
  567. MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
  568. ]
  569. )
  570. with (
  571. patch("backend.app.core.config.settings") as mock_settings,
  572. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  573. ):
  574. mock_path = MagicMock()
  575. mock_path.exists.return_value = True
  576. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  577. results = await on_print_complete(
  578. printer_id=1,
  579. data={"status": "completed"},
  580. printer_manager=pm,
  581. db=db,
  582. archive_id=100,
  583. )
  584. # Usage should be tracked despite assignment being deleted mid-print
  585. assert len(results) >= 1
  586. assert results[0]["spool_id"] == 8
  587. assert results[0]["weight_used"] == 14.2
  588. # Spool weight should be updated: 50 + 14.2 = 64.2
  589. assert spool.weight_used == 64.2