test_spoolman_no3mf_remain_fallback.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. """AMS remain%-delta fallback for the no-3MF Spoolman path (#1820).
  2. When a Bambu print starts without leaving a retrievable .gcode.3mf on the
  3. printer (subtask_name='名称未設定'/'Untitled'), Bambuddy creates a
  4. fallback archive with no 3MF on disk. Before this fix the Spoolman
  5. tracking row was never created, so the print silently didn't decrement
  6. the spool weight. This is the Spoolman mirror of usage_tracker's Path 2
  7. fallback (already in place for the internal-inventory side).
  8. """
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.services.spoolman_tracking import (
  13. _snapshot_tray_remain,
  14. store_print_data,
  15. )
  16. class TestSnapshotTrayRemain:
  17. def test_captures_valid_remain(self):
  18. raw = {
  19. "ams": [
  20. {
  21. "id": 0,
  22. "tray": [
  23. {"id": 0, "tray_uuid": "AAAA", "remain": 75},
  24. {"id": 1, "tray_uuid": "BBBB", "remain": 30},
  25. ],
  26. }
  27. ]
  28. }
  29. snap = _snapshot_tray_remain(raw)
  30. assert snap == {
  31. "0-0": {"remain": 75, "tray_uuid": "AAAA"},
  32. "0-1": {"remain": 30, "tray_uuid": "BBBB"},
  33. }
  34. def test_skips_invalid_remain(self):
  35. """remain=-1 means the AMS hasn't read the spool; a delta would be
  36. meaningless. Skip those slots — usage_tracker does the same
  37. (line 309)."""
  38. raw = {
  39. "ams": [
  40. {
  41. "id": 0,
  42. "tray": [
  43. {"id": 0, "tray_uuid": "AAAA", "remain": 75},
  44. {"id": 1, "tray_uuid": "BBBB", "remain": -1},
  45. {"id": 2, "tray_uuid": "CCCC", "remain": 150},
  46. ],
  47. }
  48. ]
  49. }
  50. snap = _snapshot_tray_remain(raw)
  51. assert set(snap.keys()) == {"0-0"}
  52. def test_captures_vt_tray(self):
  53. """External (VT) spool gets ams_id=255, tray_id=vt_id-254."""
  54. raw = {"ams": [], "vt_tray": [{"id": 254, "tray_uuid": "EEEE", "remain": 50}]}
  55. snap = _snapshot_tray_remain(raw)
  56. assert snap == {"255-0": {"remain": 50, "tray_uuid": "EEEE"}}
  57. def test_empty_when_no_ams_data(self):
  58. assert _snapshot_tray_remain({}) == {}
  59. def test_handles_missing_uuid(self):
  60. raw = {"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]}
  61. snap = _snapshot_tray_remain(raw)
  62. assert snap == {"0-0": {"remain": 80, "tray_uuid": ""}}
  63. class TestStorePrintDataNo3mf:
  64. """store_print_data must create an ActivePrintSpoolman row even when
  65. no 3MF is available, populating tray_remain_start so report_usage can
  66. write a remain-delta at completion (#1820)."""
  67. @pytest.mark.asyncio
  68. async def test_creates_row_with_snapshot_when_no_3mf(self):
  69. db = AsyncMock()
  70. # No queue lookup for the no-3MF branch — only the DELETE.
  71. delete_result = MagicMock()
  72. db.execute = AsyncMock(side_effect=[delete_result])
  73. db.add = MagicMock()
  74. db.commit = AsyncMock()
  75. printer_manager = MagicMock()
  76. printer_manager.get_status.return_value = SimpleNamespace(
  77. raw_data={
  78. "ams": [
  79. {
  80. "id": 0,
  81. "tray": [
  82. {"id": 0, "tray_uuid": "AAAA", "tag_uid": "11", "tray_type": "PLA", "remain": 80},
  83. {"id": 1, "tray_uuid": "BBBB", "tag_uid": "22", "tray_type": "PLA", "remain": 20},
  84. ],
  85. }
  86. ]
  87. }
  88. )
  89. mock_settings = MagicMock()
  90. mock_path = MagicMock()
  91. mock_path.exists.return_value = False # no 3MF — the #1820 case
  92. mock_settings.base_dir.__truediv__.return_value = mock_path
  93. with (
  94. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  95. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  96. ):
  97. await store_print_data(
  98. printer_id=1,
  99. archive_id=42,
  100. file_path="", # fallback-archive file_path
  101. db=db,
  102. printer_manager=printer_manager,
  103. )
  104. db.add.assert_called_once()
  105. tracking = db.add.call_args.args[0]
  106. assert tracking.filament_usage is None
  107. assert tracking.tray_remain_start == {
  108. "0-0": {"remain": 80, "tray_uuid": "AAAA"},
  109. "0-1": {"remain": 20, "tray_uuid": "BBBB"},
  110. }
  111. @pytest.mark.asyncio
  112. async def test_records_the_tray_the_print_started_on(self):
  113. """A print with no 3MF also has no ams_mapping, so the tray in use at
  114. the start is the only evidence of which slot it drew from — and without
  115. that, the completion path would charge every slot whose remain% moved,
  116. including one a spool was merely swapped into (#1269's fault, on this
  117. path)."""
  118. db = AsyncMock()
  119. db.execute = AsyncMock(side_effect=[MagicMock()])
  120. db.add = MagicMock()
  121. db.commit = AsyncMock()
  122. printer_manager = MagicMock()
  123. printer_manager.get_status.return_value = SimpleNamespace(
  124. tray_now=5,
  125. raw_data={"ams": [{"id": 1, "tray": [{"id": 1, "tray_uuid": "AAAA", "remain": 80}]}]},
  126. )
  127. mock_settings = MagicMock()
  128. mock_path = MagicMock()
  129. mock_path.exists.return_value = False
  130. mock_settings.base_dir.__truediv__.return_value = mock_path
  131. with (
  132. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  133. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  134. ):
  135. await store_print_data(
  136. printer_id=1,
  137. archive_id=42,
  138. file_path="",
  139. db=db,
  140. printer_manager=printer_manager,
  141. )
  142. assert db.add.call_args.args[0].tray_now_at_start == 5
  143. @pytest.mark.asyncio
  144. async def test_a_printer_that_reports_no_tray_records_none(self):
  145. """Nullable on purpose: no answer is not the same as slot 0."""
  146. db = AsyncMock()
  147. db.execute = AsyncMock(side_effect=[MagicMock()])
  148. db.add = MagicMock()
  149. db.commit = AsyncMock()
  150. printer_manager = MagicMock()
  151. printer_manager.get_status.return_value = SimpleNamespace(
  152. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 80}]}]},
  153. )
  154. mock_settings = MagicMock()
  155. mock_path = MagicMock()
  156. mock_path.exists.return_value = False
  157. mock_settings.base_dir.__truediv__.return_value = mock_path
  158. with (
  159. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  160. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  161. ):
  162. await store_print_data(
  163. printer_id=1,
  164. archive_id=42,
  165. file_path="",
  166. db=db,
  167. printer_manager=printer_manager,
  168. )
  169. assert db.add.call_args.args[0].tray_now_at_start is None
  170. @pytest.mark.asyncio
  171. async def test_no_row_when_no_3mf_and_no_remain_data(self):
  172. """If the AMS has no slot with valid remain either (e.g. printer
  173. offline at print start), there's nothing to track. Don't create
  174. a row that contributes no value."""
  175. db = AsyncMock()
  176. db.execute = AsyncMock()
  177. db.add = MagicMock()
  178. db.commit = AsyncMock()
  179. printer_manager = MagicMock()
  180. printer_manager.get_status.return_value = SimpleNamespace(
  181. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]}
  182. )
  183. mock_settings = MagicMock()
  184. mock_path = MagicMock()
  185. mock_path.exists.return_value = False
  186. mock_settings.base_dir.__truediv__.return_value = mock_path
  187. with (
  188. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  189. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  190. ):
  191. await store_print_data(
  192. printer_id=1,
  193. archive_id=42,
  194. file_path="",
  195. db=db,
  196. printer_manager=printer_manager,
  197. )
  198. db.add.assert_not_called()
  199. @pytest.mark.asyncio
  200. async def test_3mf_path_also_captures_snapshot(self):
  201. """The remain snapshot is captured ALWAYS, not just for no-3MF.
  202. That lets report_usage fall back per-slot when 3MF coverage is
  203. partial — same shape as usage_tracker.on_print_complete which
  204. runs Path 1 (3MF) and Path 2 (remain delta) for unhandled slots."""
  205. db = AsyncMock()
  206. queue_item = SimpleNamespace(ams_mapping=None, plate_id=None)
  207. queue_result = MagicMock()
  208. queue_result.scalar_one_or_none.return_value = queue_item
  209. delete_result = MagicMock()
  210. db.execute = AsyncMock(side_effect=[queue_result, delete_result])
  211. db.add = MagicMock()
  212. db.commit = AsyncMock()
  213. printer_manager = MagicMock()
  214. printer_manager.get_status.return_value = SimpleNamespace(
  215. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "tray_type": "PLA", "remain": 90}]}]}
  216. )
  217. mock_settings = MagicMock()
  218. mock_path = MagicMock()
  219. mock_path.exists.return_value = True
  220. mock_settings.base_dir.__truediv__.return_value = mock_path
  221. with (
  222. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  223. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  224. patch(
  225. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  226. return_value=[{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}],
  227. ),
  228. patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
  229. patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
  230. ):
  231. await store_print_data(
  232. printer_id=1,
  233. archive_id=42,
  234. file_path="archives/test.3mf",
  235. db=db,
  236. printer_manager=printer_manager,
  237. )
  238. db.add.assert_called_once()
  239. tracking = db.add.call_args.args[0]
  240. assert tracking.filament_usage == [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}]
  241. assert tracking.tray_remain_start == {"0-0": {"remain": 90, "tray_uuid": "AAAA"}}
  242. class TestReportUsageRemainDelta:
  243. """report_usage must write a per-slot remain-delta when filament_usage
  244. is missing (no-3MF print), gated on a resolvable Spoolman spool and a
  245. sane current remain%."""
  246. @pytest.mark.asyncio
  247. async def test_remain_delta_writes_to_resolved_spool(self):
  248. """Print started at remain=80% on a 1000g filament, finished at 60%.
  249. Delta = 20% × 1000g = 200g."""
  250. from backend.app.services.spoolman_tracking import report_usage
  251. tracking = SimpleNamespace(
  252. filament_usage=None,
  253. ams_trays={"0": {"tray_uuid": "AAAA", "tag_uid": "11", "tray_type": "PLA"}},
  254. slot_to_tray=None,
  255. tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
  256. )
  257. # Mock db.execute().scalar_one_or_none() -> tracking
  258. db = AsyncMock()
  259. select_result = MagicMock()
  260. select_result.scalar_one_or_none.return_value = tracking
  261. db.execute = AsyncMock(return_value=select_result)
  262. db.delete = AsyncMock()
  263. db.commit = AsyncMock()
  264. client = AsyncMock()
  265. client.get_spool = AsyncMock(return_value={"id": 7, "filament": {"weight": 1000.0, "color_hex": "00FF00"}})
  266. client.use_spool = AsyncMock()
  267. printer_manager = MagicMock()
  268. printer_manager.get_status.return_value = SimpleNamespace(
  269. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 60}]}]}
  270. )
  271. with (
  272. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  273. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  274. patch(
  275. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  276. AsyncMock(return_value=client),
  277. ),
  278. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  279. patch(
  280. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  281. AsyncMock(return_value=7),
  282. ),
  283. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  284. ):
  285. await report_usage(printer_id=1, archive_id=42)
  286. client.use_spool.assert_awaited_once_with(7, 200.0)
  287. @pytest.mark.asyncio
  288. async def test_remain_delta_skips_swapped_spool(self):
  289. """tray_uuid changed between start and completion → user replaced
  290. the spool mid-print. We don't know how much went to each side; skip
  291. rather than mis-charge."""
  292. from backend.app.services.spoolman_tracking import report_usage
  293. tracking = SimpleNamespace(
  294. filament_usage=None,
  295. ams_trays={"0": {"tray_uuid": "AAAA"}},
  296. slot_to_tray=None,
  297. tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
  298. )
  299. db = AsyncMock()
  300. select_result = MagicMock()
  301. select_result.scalar_one_or_none.return_value = tracking
  302. db.execute = AsyncMock(return_value=select_result)
  303. db.delete = AsyncMock()
  304. db.commit = AsyncMock()
  305. client = AsyncMock()
  306. client.use_spool = AsyncMock()
  307. printer_manager = MagicMock()
  308. printer_manager.get_status.return_value = SimpleNamespace(
  309. # tray_uuid changed -> swap detected
  310. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "CCCC", "remain": 50}]}]}
  311. )
  312. with (
  313. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  314. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  315. patch(
  316. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  317. AsyncMock(return_value=client),
  318. ),
  319. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  320. patch(
  321. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  322. AsyncMock(return_value=7),
  323. ),
  324. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  325. ):
  326. await report_usage(printer_id=1, archive_id=42)
  327. client.use_spool.assert_not_called()
  328. @pytest.mark.asyncio
  329. async def test_remain_delta_skips_slot_handled_by_3mf(self):
  330. """Mixed coverage: 3MF carried slot 1 (=global tray 0). Remain
  331. delta on the same physical slot must not double-charge it."""
  332. from backend.app.services.spoolman_tracking import report_usage
  333. tracking = SimpleNamespace(
  334. filament_usage=[{"slot_id": 1, "used_g": 50.0}],
  335. ams_trays={"0": {"tray_uuid": "AAAA", "tray_type": "PLA"}},
  336. slot_to_tray=None,
  337. tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
  338. )
  339. db = AsyncMock()
  340. select_result = MagicMock()
  341. select_result.scalar_one_or_none.return_value = tracking
  342. db.execute = AsyncMock(return_value=select_result)
  343. db.delete = AsyncMock()
  344. db.commit = AsyncMock()
  345. client = AsyncMock()
  346. client.use_spool = AsyncMock()
  347. client.get_spool = AsyncMock()
  348. printer_manager = MagicMock()
  349. printer_manager.get_status.return_value = SimpleNamespace(
  350. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 60}]}]}
  351. )
  352. # Make the 3MF path resolve to a spool too, so it actually writes.
  353. async def fake_report_slots(_client, items, *args, **kwargs):
  354. for _slot_id, grams in items:
  355. if grams > 0:
  356. await _client.use_spool(99, grams)
  357. return 1
  358. with (
  359. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  360. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  361. patch(
  362. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  363. AsyncMock(return_value=client),
  364. ),
  365. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  366. patch(
  367. "backend.app.services.spoolman_tracking._report_spool_usage_for_slots",
  368. AsyncMock(side_effect=fake_report_slots),
  369. ),
  370. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  371. ):
  372. await report_usage(printer_id=1, archive_id=42)
  373. # Only the 3MF path called use_spool. get_spool (remain-delta path)
  374. # was never reached because slot 0 was already in the handled set.
  375. client.use_spool.assert_awaited_once_with(99, 50.0)
  376. client.get_spool.assert_not_called()
  377. class TestPartialUsageRemainDelta:
  378. """cleanup_tracking → _report_partial_usage must also write the
  379. remain-delta for ABORTED no-3MF prints — same shape as the completion
  380. path, otherwise aborts of "Untitled" prints stay silent."""
  381. @pytest.mark.asyncio
  382. async def test_aborted_no_3mf_writes_remain_delta(self):
  383. """Aborted at remain=70% from start of 90% on a 1000g spool.
  384. Delta = 20% × 1000g = 200g — must be written even though no
  385. 3MF / layer data is available."""
  386. from backend.app.services.spoolman_tracking import _report_partial_usage
  387. tracking = SimpleNamespace(
  388. archive_id=99,
  389. filament_usage=None,
  390. layer_usage=None,
  391. filament_properties=None,
  392. ams_trays={"0": {"tray_uuid": "AAAA"}},
  393. slot_to_tray=None,
  394. tray_remain_start={"0-0": {"remain": 90, "tray_uuid": "AAAA"}},
  395. )
  396. client = AsyncMock()
  397. client.get_spool = AsyncMock(return_value={"id": 7, "filament": {"weight": 1000.0}})
  398. client.use_spool = AsyncMock()
  399. printer_manager = MagicMock()
  400. printer_manager.get_status.return_value = SimpleNamespace(
  401. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 70}]}]},
  402. layer_num=42,
  403. total_layers=100,
  404. )
  405. with (
  406. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  407. patch(
  408. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  409. AsyncMock(return_value=client),
  410. ),
  411. patch(
  412. "backend.app.services.spoolman_tracking._get_printer_serial",
  413. AsyncMock(return_value="serial"),
  414. ),
  415. patch(
  416. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  417. AsyncMock(return_value=7),
  418. ),
  419. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  420. ):
  421. await _report_partial_usage(printer_id=1, tracking=tracking)
  422. client.use_spool.assert_awaited_once_with(7, 200.0)
  423. class _AsyncCtx:
  424. """Tiny async-context shim returning a pre-built db mock; mirrors
  425. async_session()'s ``async with`` interface."""
  426. def __init__(self, db):
  427. self._db = db
  428. async def __aenter__(self):
  429. return self._db
  430. async def __aexit__(self, *_):
  431. return False