test_spoolman_no3mf_remain_fallback.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  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.models import active_print_spoolman # noqa: F401 (registers its table)
  13. from backend.app.services.spoolman_tracking import (
  14. _snapshot_tray_remain,
  15. store_print_data,
  16. )
  17. class TestSnapshotTrayRemain:
  18. def test_captures_valid_remain(self):
  19. raw = {
  20. "ams": [
  21. {
  22. "id": 0,
  23. "tray": [
  24. {"id": 0, "tray_uuid": "AAAA", "remain": 75},
  25. {"id": 1, "tray_uuid": "BBBB", "remain": 30},
  26. ],
  27. }
  28. ]
  29. }
  30. snap = _snapshot_tray_remain(raw)
  31. assert snap == {
  32. "0-0": {"remain": 75, "tray_uuid": "AAAA"},
  33. "0-1": {"remain": 30, "tray_uuid": "BBBB"},
  34. }
  35. def test_skips_invalid_remain(self):
  36. """remain=-1 means the AMS hasn't read the spool; a delta would be
  37. meaningless. Skip those slots — usage_tracker does the same
  38. (line 309)."""
  39. raw = {
  40. "ams": [
  41. {
  42. "id": 0,
  43. "tray": [
  44. {"id": 0, "tray_uuid": "AAAA", "remain": 75},
  45. {"id": 1, "tray_uuid": "BBBB", "remain": -1},
  46. {"id": 2, "tray_uuid": "CCCC", "remain": 150},
  47. ],
  48. }
  49. ]
  50. }
  51. snap = _snapshot_tray_remain(raw)
  52. assert set(snap.keys()) == {"0-0"}
  53. def test_captures_vt_tray(self):
  54. """External (VT) spool gets ams_id=255, tray_id=vt_id-254."""
  55. raw = {"ams": [], "vt_tray": [{"id": 254, "tray_uuid": "EEEE", "remain": 50}]}
  56. snap = _snapshot_tray_remain(raw)
  57. assert snap == {"255-0": {"remain": 50, "tray_uuid": "EEEE"}}
  58. def test_empty_when_no_ams_data(self):
  59. assert _snapshot_tray_remain({}) == {}
  60. def test_handles_missing_uuid(self):
  61. raw = {"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]}
  62. snap = _snapshot_tray_remain(raw)
  63. assert snap == {"0-0": {"remain": 80, "tray_uuid": ""}}
  64. class TestStorePrintDataNo3mf:
  65. """store_print_data must create an ActivePrintSpoolman row even when
  66. no 3MF is available, populating tray_remain_start so report_usage can
  67. write a remain-delta at completion (#1820)."""
  68. @pytest.mark.asyncio
  69. async def test_creates_row_with_snapshot_when_no_3mf(self):
  70. db = AsyncMock()
  71. # No queue lookup for the no-3MF branch — only the DELETE.
  72. delete_result = MagicMock()
  73. db.execute = AsyncMock(side_effect=[delete_result])
  74. db.add = MagicMock()
  75. db.commit = AsyncMock()
  76. printer_manager = MagicMock()
  77. printer_manager.get_status.return_value = SimpleNamespace(
  78. raw_data={
  79. "ams": [
  80. {
  81. "id": 0,
  82. "tray": [
  83. {"id": 0, "tray_uuid": "AAAA", "tag_uid": "11", "tray_type": "PLA", "remain": 80},
  84. {"id": 1, "tray_uuid": "BBBB", "tag_uid": "22", "tray_type": "PLA", "remain": 20},
  85. ],
  86. }
  87. ]
  88. }
  89. )
  90. mock_settings = MagicMock()
  91. mock_path = MagicMock()
  92. mock_path.exists.return_value = False # no 3MF — the #1820 case
  93. mock_settings.base_dir.__truediv__.return_value = mock_path
  94. with (
  95. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  96. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  97. ):
  98. await store_print_data(
  99. printer_id=1,
  100. archive_id=42,
  101. file_path="", # fallback-archive file_path
  102. db=db,
  103. printer_manager=printer_manager,
  104. )
  105. db.add.assert_called_once()
  106. tracking = db.add.call_args.args[0]
  107. assert tracking.filament_usage is None
  108. assert tracking.tray_remain_start == {
  109. "0-0": {"remain": 80, "tray_uuid": "AAAA"},
  110. "0-1": {"remain": 20, "tray_uuid": "BBBB"},
  111. }
  112. @pytest.mark.asyncio
  113. async def test_records_the_tray_the_print_started_on(self):
  114. """A print with no 3MF also has no ams_mapping, so the tray in use at
  115. the start is the only evidence of which slot it drew from — and without
  116. that, the completion path would charge every slot whose remain% moved,
  117. including one a spool was merely swapped into (#1269's fault, on this
  118. path)."""
  119. db = AsyncMock()
  120. db.execute = AsyncMock(side_effect=[MagicMock()])
  121. db.add = MagicMock()
  122. db.commit = AsyncMock()
  123. printer_manager = MagicMock()
  124. printer_manager.get_status.return_value = SimpleNamespace(
  125. tray_now=5,
  126. raw_data={"ams": [{"id": 1, "tray": [{"id": 1, "tray_uuid": "AAAA", "remain": 80}]}]},
  127. )
  128. mock_settings = MagicMock()
  129. mock_path = MagicMock()
  130. mock_path.exists.return_value = False
  131. mock_settings.base_dir.__truediv__.return_value = mock_path
  132. with (
  133. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  134. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  135. ):
  136. await store_print_data(
  137. printer_id=1,
  138. archive_id=42,
  139. file_path="",
  140. db=db,
  141. printer_manager=printer_manager,
  142. )
  143. assert db.add.call_args.args[0].tray_now_at_start == 5
  144. @pytest.mark.asyncio
  145. async def test_a_printer_that_reports_no_tray_records_none(self):
  146. """Nullable on purpose: no answer is not the same as slot 0."""
  147. db = AsyncMock()
  148. db.execute = AsyncMock(side_effect=[MagicMock()])
  149. db.add = MagicMock()
  150. db.commit = AsyncMock()
  151. printer_manager = MagicMock()
  152. printer_manager.get_status.return_value = SimpleNamespace(
  153. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 80}]}]},
  154. )
  155. mock_settings = MagicMock()
  156. mock_path = MagicMock()
  157. mock_path.exists.return_value = False
  158. mock_settings.base_dir.__truediv__.return_value = mock_path
  159. with (
  160. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  161. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  162. ):
  163. await store_print_data(
  164. printer_id=1,
  165. archive_id=42,
  166. file_path="",
  167. db=db,
  168. printer_manager=printer_manager,
  169. )
  170. assert db.add.call_args.args[0].tray_now_at_start is None
  171. @pytest.mark.asyncio
  172. async def test_no_row_when_no_3mf_and_no_remain_data(self):
  173. """If the AMS has no slot with valid remain either (e.g. printer
  174. offline at print start), there's nothing to track. Don't create
  175. a row that contributes no value."""
  176. db = AsyncMock()
  177. db.execute = AsyncMock()
  178. db.add = MagicMock()
  179. db.commit = AsyncMock()
  180. printer_manager = MagicMock()
  181. printer_manager.get_status.return_value = SimpleNamespace(
  182. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]}
  183. )
  184. mock_settings = MagicMock()
  185. mock_path = MagicMock()
  186. mock_path.exists.return_value = False
  187. mock_settings.base_dir.__truediv__.return_value = mock_path
  188. with (
  189. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  190. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  191. ):
  192. await store_print_data(
  193. printer_id=1,
  194. archive_id=42,
  195. file_path="",
  196. db=db,
  197. printer_manager=printer_manager,
  198. )
  199. db.add.assert_not_called()
  200. @pytest.mark.asyncio
  201. async def test_3mf_path_also_captures_snapshot(self):
  202. """The remain snapshot is captured ALWAYS, not just for no-3MF.
  203. That lets report_usage fall back per-slot when 3MF coverage is
  204. partial — same shape as usage_tracker.on_print_complete which
  205. runs Path 1 (3MF) and Path 2 (remain delta) for unhandled slots."""
  206. db = AsyncMock()
  207. queue_item = SimpleNamespace(ams_mapping=None, plate_id=None)
  208. queue_result = MagicMock()
  209. queue_result.scalars.return_value.first.return_value = queue_item
  210. delete_result = MagicMock()
  211. db.execute = AsyncMock(side_effect=[queue_result, delete_result])
  212. db.add = MagicMock()
  213. db.commit = AsyncMock()
  214. printer_manager = MagicMock()
  215. printer_manager.get_status.return_value = SimpleNamespace(
  216. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "tray_type": "PLA", "remain": 90}]}]}
  217. )
  218. mock_settings = MagicMock()
  219. mock_path = MagicMock()
  220. mock_path.exists.return_value = True
  221. mock_settings.base_dir.__truediv__.return_value = mock_path
  222. with (
  223. patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
  224. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  225. patch(
  226. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  227. return_value=[{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}],
  228. ),
  229. patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
  230. patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
  231. ):
  232. await store_print_data(
  233. printer_id=1,
  234. archive_id=42,
  235. file_path="archives/test.3mf",
  236. db=db,
  237. printer_manager=printer_manager,
  238. )
  239. db.add.assert_called_once()
  240. tracking = db.add.call_args.args[0]
  241. assert tracking.filament_usage == [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}]
  242. assert tracking.tray_remain_start == {"0-0": {"remain": 90, "tray_uuid": "AAAA"}}
  243. class TestReportUsageRemainDelta:
  244. """report_usage must write a per-slot remain-delta when filament_usage
  245. is missing (no-3MF print), gated on a resolvable Spoolman spool and a
  246. sane current remain%."""
  247. @pytest.mark.asyncio
  248. async def test_remain_delta_writes_to_resolved_spool(self):
  249. """Print started at remain=80% on a 1000g filament, finished at 60%.
  250. Delta = 20% × 1000g = 200g."""
  251. from backend.app.services.spoolman_tracking import report_usage
  252. tracking = SimpleNamespace(
  253. filament_usage=None,
  254. ams_trays={"0": {"tray_uuid": "AAAA", "tag_uid": "11", "tray_type": "PLA"}},
  255. slot_to_tray=None,
  256. tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
  257. )
  258. # Mock db.execute().scalar_one_or_none() -> tracking
  259. db = AsyncMock()
  260. select_result = MagicMock()
  261. select_result.scalar_one_or_none.return_value = tracking
  262. db.execute = AsyncMock(return_value=select_result)
  263. db.delete = AsyncMock()
  264. db.commit = AsyncMock()
  265. client = AsyncMock()
  266. client.get_spool = AsyncMock(return_value={"id": 7, "filament": {"weight": 1000.0, "color_hex": "00FF00"}})
  267. client.use_spool = AsyncMock()
  268. printer_manager = MagicMock()
  269. printer_manager.get_status.return_value = SimpleNamespace(
  270. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 60}]}]}
  271. )
  272. with (
  273. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  274. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  275. patch(
  276. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  277. AsyncMock(return_value=client),
  278. ),
  279. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  280. patch(
  281. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  282. AsyncMock(return_value=7),
  283. ),
  284. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  285. ):
  286. await report_usage(printer_id=1, archive_id=42)
  287. client.use_spool.assert_awaited_once_with(7, 200.0)
  288. @pytest.mark.asyncio
  289. async def test_remain_delta_skips_swapped_spool(self):
  290. """tray_uuid changed between start and completion → user replaced
  291. the spool mid-print. We don't know how much went to each side; skip
  292. rather than mis-charge."""
  293. from backend.app.services.spoolman_tracking import report_usage
  294. tracking = SimpleNamespace(
  295. filament_usage=None,
  296. ams_trays={"0": {"tray_uuid": "AAAA"}},
  297. slot_to_tray=None,
  298. tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
  299. )
  300. db = AsyncMock()
  301. select_result = MagicMock()
  302. select_result.scalar_one_or_none.return_value = tracking
  303. db.execute = AsyncMock(return_value=select_result)
  304. db.delete = AsyncMock()
  305. db.commit = AsyncMock()
  306. client = AsyncMock()
  307. client.use_spool = AsyncMock()
  308. printer_manager = MagicMock()
  309. printer_manager.get_status.return_value = SimpleNamespace(
  310. # tray_uuid changed -> swap detected
  311. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "CCCC", "remain": 50}]}]}
  312. )
  313. with (
  314. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  315. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  316. patch(
  317. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  318. AsyncMock(return_value=client),
  319. ),
  320. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  321. patch(
  322. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  323. AsyncMock(return_value=7),
  324. ),
  325. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  326. ):
  327. await report_usage(printer_id=1, archive_id=42)
  328. client.use_spool.assert_not_called()
  329. @pytest.mark.asyncio
  330. async def test_remain_delta_skips_slot_handled_by_3mf(self):
  331. """Mixed coverage: 3MF carried slot 1 (=global tray 0). Remain
  332. delta on the same physical slot must not double-charge it."""
  333. from backend.app.services.spoolman_tracking import report_usage
  334. tracking = SimpleNamespace(
  335. filament_usage=[{"slot_id": 1, "used_g": 50.0}],
  336. ams_trays={"0": {"tray_uuid": "AAAA", "tray_type": "PLA"}},
  337. slot_to_tray=None,
  338. tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
  339. )
  340. db = AsyncMock()
  341. select_result = MagicMock()
  342. select_result.scalar_one_or_none.return_value = tracking
  343. db.execute = AsyncMock(return_value=select_result)
  344. db.delete = AsyncMock()
  345. db.commit = AsyncMock()
  346. client = AsyncMock()
  347. client.use_spool = AsyncMock()
  348. client.get_spool = AsyncMock()
  349. printer_manager = MagicMock()
  350. printer_manager.get_status.return_value = SimpleNamespace(
  351. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 60}]}]}
  352. )
  353. # Make the 3MF path resolve to a spool too, so it actually writes.
  354. async def fake_report_slots(_client, items, *args, **kwargs):
  355. for _slot_id, grams in items:
  356. if grams > 0:
  357. await _client.use_spool(99, grams)
  358. return 1
  359. with (
  360. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  361. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  362. patch(
  363. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  364. AsyncMock(return_value=client),
  365. ),
  366. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  367. patch(
  368. "backend.app.services.spoolman_tracking._report_spool_usage_for_slots",
  369. AsyncMock(side_effect=fake_report_slots),
  370. ),
  371. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  372. ):
  373. await report_usage(printer_id=1, archive_id=42)
  374. # Only the 3MF path called use_spool. get_spool (remain-delta path)
  375. # was never reached because slot 0 was already in the handled set.
  376. client.use_spool.assert_awaited_once_with(99, 50.0)
  377. client.get_spool.assert_not_called()
  378. class TestPartialUsageRemainDelta:
  379. """cleanup_tracking → _report_partial_usage must also write the
  380. remain-delta for ABORTED no-3MF prints — same shape as the completion
  381. path, otherwise aborts of "Untitled" prints stay silent."""
  382. @pytest.mark.asyncio
  383. async def test_aborted_no_3mf_writes_remain_delta(self):
  384. """Aborted at remain=70% from start of 90% on a 1000g spool.
  385. Delta = 20% × 1000g = 200g — must be written even though no
  386. 3MF / layer data is available."""
  387. from backend.app.services.spoolman_tracking import _report_partial_usage
  388. tracking = SimpleNamespace(
  389. archive_id=99,
  390. filament_usage=None,
  391. layer_usage=None,
  392. filament_properties=None,
  393. ams_trays={"0": {"tray_uuid": "AAAA"}},
  394. slot_to_tray=None,
  395. tray_remain_start={"0-0": {"remain": 90, "tray_uuid": "AAAA"}},
  396. )
  397. client = AsyncMock()
  398. client.get_spool = AsyncMock(return_value={"id": 7, "filament": {"weight": 1000.0}})
  399. client.use_spool = AsyncMock()
  400. printer_manager = MagicMock()
  401. printer_manager.get_status.return_value = SimpleNamespace(
  402. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 70}]}]},
  403. layer_num=42,
  404. total_layers=100,
  405. )
  406. with (
  407. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  408. patch(
  409. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  410. AsyncMock(return_value=client),
  411. ),
  412. patch(
  413. "backend.app.services.spoolman_tracking._get_printer_serial",
  414. AsyncMock(return_value="serial"),
  415. ),
  416. patch(
  417. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  418. AsyncMock(return_value=7),
  419. ),
  420. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  421. ):
  422. await _report_partial_usage(printer_id=1, tracking=tracking)
  423. client.use_spool.assert_awaited_once_with(7, 200.0)
  424. @pytest.mark.asyncio
  425. async def test_remain_delta_uses_the_spools_own_weight(self):
  426. """#3194: the AMS percentage is of this spool, so a 20% drop on a
  427. 250 g spool of a 1000 g filament is 50 g, not 200 g."""
  428. from backend.app.services.spoolman_tracking import _report_partial_usage
  429. tracking = SimpleNamespace(
  430. archive_id=99,
  431. filament_usage=None,
  432. layer_usage=None,
  433. filament_properties=None,
  434. ams_trays={"0": {"tray_uuid": "AAAA"}},
  435. slot_to_tray=None,
  436. tray_remain_start={"0-0": {"remain": 90, "tray_uuid": "AAAA"}},
  437. )
  438. client = AsyncMock()
  439. client.get_spool = AsyncMock(return_value={"id": 7, "initial_weight": 250.0, "filament": {"weight": 1000.0}})
  440. client.use_spool = AsyncMock()
  441. printer_manager = MagicMock()
  442. printer_manager.get_status.return_value = SimpleNamespace(
  443. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 70}]}]},
  444. layer_num=42,
  445. total_layers=100,
  446. )
  447. with (
  448. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  449. patch(
  450. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  451. AsyncMock(return_value=client),
  452. ),
  453. patch(
  454. "backend.app.services.spoolman_tracking._get_printer_serial",
  455. AsyncMock(return_value="serial"),
  456. ),
  457. patch(
  458. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  459. AsyncMock(return_value=7),
  460. ),
  461. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  462. ):
  463. await _report_partial_usage(printer_id=1, tracking=tracking)
  464. client.use_spool.assert_awaited_once_with(7, 50.0)
  465. class TestPartialUsageCost:
  466. """A failed or stopped print's charges are priced as they are made, so the
  467. run is logged with its own cost, as in the built-in inventory."""
  468. @pytest.mark.asyncio
  469. async def test_the_remain_delta_charge_is_priced(self):
  470. from backend.app.services.spoolman_tracking import _PrintCost, _report_partial_usage
  471. tracking = SimpleNamespace(
  472. archive_id=99,
  473. filament_usage=None,
  474. layer_usage=None,
  475. filament_properties=None,
  476. ams_trays={"0": {"tray_uuid": "AAAA"}},
  477. slot_to_tray=None,
  478. tray_remain_start={"0-0": {"remain": 90, "tray_uuid": "AAAA"}},
  479. )
  480. client = AsyncMock()
  481. # 20 for the 1000 g spool: 0.02 a gram.
  482. client.get_spool = AsyncMock(
  483. return_value={"id": 7, "price": 20.0, "initial_weight": 1000.0, "filament": {"weight": 1000.0}}
  484. )
  485. client.use_spool = AsyncMock()
  486. printer_manager = MagicMock()
  487. printer_manager.get_status.return_value = SimpleNamespace(
  488. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 70}]}]},
  489. layer_num=42,
  490. total_layers=100,
  491. )
  492. cost = _PrintCost()
  493. with (
  494. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  495. patch(
  496. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  497. AsyncMock(return_value=client),
  498. ),
  499. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  500. patch(
  501. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  502. AsyncMock(return_value=7),
  503. ),
  504. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  505. ):
  506. await _report_partial_usage(printer_id=1, tracking=tracking, cost_out=cost)
  507. assert round(cost.cost, 2) == 4.0
  508. @pytest.mark.asyncio
  509. async def test_cleanup_returns_the_runs_cost(self, db_session, printer_factory, archive_factory):
  510. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  511. from backend.app.services.spoolman_tracking import cleanup_tracking
  512. printer = await printer_factory()
  513. archive = await archive_factory(printer.id, status="printing", with_run=False)
  514. db_session.add(ActivePrintSpoolman(printer_id=printer.id, archive_id=archive.id, ams_trays={}))
  515. await db_session.commit()
  516. async def _charge(printer_id, tracking, last_layer_num=None, last_progress=None, cost_out=None):
  517. cost_out.add(50.0, {"price": 20.0, "initial_weight": 1000.0}, "Slot 1")
  518. with patch("backend.app.services.spoolman_tracking._report_partial_usage", side_effect=_charge):
  519. run_cost = await cleanup_tracking(printer.id, archive.id, db_session, last_progress=50)
  520. assert run_cost == 1.0
  521. @pytest.mark.asyncio
  522. async def test_a_charged_spool_without_a_price_is_costed_at_the_default(
  523. self, db_session, printer_factory, archive_factory
  524. ):
  525. """20 g at 60/kg plus 30 g off an unpriced spool at the 25/kg default,
  526. as the built-in inventory prices it."""
  527. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  528. from backend.app.services.spoolman_tracking import cleanup_tracking
  529. printer = await printer_factory()
  530. archive = await archive_factory(printer.id, status="printing", with_run=False)
  531. db_session.add(ActivePrintSpoolman(printer_id=printer.id, archive_id=archive.id, ams_trays={}))
  532. await db_session.commit()
  533. async def _charge(printer_id, tracking, last_layer_num=None, last_progress=None, cost_out=None):
  534. cost_out.add(20.0, {"price": 60.0, "initial_weight": 1000.0}, "Slot 1")
  535. cost_out.add(30.0, {"price": None}, "Slot 2")
  536. with patch("backend.app.services.spoolman_tracking._report_partial_usage", side_effect=_charge):
  537. assert await cleanup_tracking(printer.id, archive.id, db_session) == 1.95
  538. @pytest.mark.asyncio
  539. async def test_cleanup_without_a_priced_spool_reports_no_cost(self, db_session, printer_factory, archive_factory):
  540. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  541. from backend.app.services.spoolman_tracking import cleanup_tracking
  542. printer = await printer_factory()
  543. archive = await archive_factory(printer.id, status="printing", with_run=False)
  544. db_session.add(ActivePrintSpoolman(printer_id=printer.id, archive_id=archive.id, ams_trays={}))
  545. await db_session.commit()
  546. with patch("backend.app.services.spoolman_tracking._report_partial_usage", AsyncMock()):
  547. assert await cleanup_tracking(printer.id, archive.id, db_session) is None
  548. class TestPartialUsageTraySplit:
  549. """A single-filament print that ran out and switched to the AMS backup
  550. spool before failing charges each spool its share, as a completed print
  551. and the built-in inventory do. All of it went to the tray that ran out."""
  552. @pytest.mark.asyncio
  553. async def test_the_partial_grams_are_split_at_the_tray_change(self):
  554. from backend.app.services.spoolman_tracking import _report_partial_usage
  555. tracking = SimpleNamespace(
  556. archive_id=99,
  557. filament_usage=[{"slot_id": 1, "used_g": 200.0}],
  558. layer_usage=None,
  559. filament_properties=None,
  560. ams_trays={"0": {"tray_uuid": "A" * 32}, "1": {"tray_uuid": "B" * 32}},
  561. slot_to_tray=[0],
  562. tray_remain_start=None,
  563. )
  564. spools = {"A" * 32: {"id": 7}, "B" * 32: {"id": 8}}
  565. client = AsyncMock()
  566. client.find_spool_by_tag = AsyncMock(side_effect=lambda tag, *a, **k: spools.get(tag))
  567. client.use_spool = AsyncMock()
  568. printer_manager = MagicMock()
  569. printer_manager.get_status.return_value = SimpleNamespace(
  570. raw_data={},
  571. layer_num=100,
  572. total_layers=200,
  573. tray_change_log=[(0, 1), (1, 51)],
  574. )
  575. with (
  576. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  577. patch(
  578. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  579. AsyncMock(return_value=client),
  580. ),
  581. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  582. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  583. ):
  584. await _report_partial_usage(printer_id=1, tracking=tracking)
  585. charged = {call.args[0]: call.args[1] for call in client.use_spool.await_args_list}
  586. # 100 g by layer 100 of 200, the switch at layer 51: half each.
  587. assert charged == {7: 50.0, 8: 50.0}
  588. @pytest.mark.asyncio
  589. async def test_a_stop_layer_below_a_logged_switch_never_charges_more_than_was_used(self):
  590. """The stop layer estimated from progress (60) can trail the logged
  591. switch (80); the split must not exceed the partial grams."""
  592. from backend.app.services.spoolman_tracking import _report_partial_usage
  593. tracking = SimpleNamespace(
  594. archive_id=99,
  595. filament_usage=[{"slot_id": 1, "used_g": 200.0}],
  596. layer_usage=None,
  597. filament_properties=None,
  598. ams_trays={"0": {"tray_uuid": "A" * 32}, "1": {"tray_uuid": "B" * 32}},
  599. slot_to_tray=[0],
  600. tray_remain_start=None,
  601. )
  602. spools = {"A" * 32: {"id": 7}, "B" * 32: {"id": 8}}
  603. client = AsyncMock()
  604. client.find_spool_by_tag = AsyncMock(side_effect=lambda tag, *a, **k: spools.get(tag))
  605. client.use_spool = AsyncMock()
  606. printer_manager = MagicMock()
  607. printer_manager.get_status.return_value = SimpleNamespace(
  608. raw_data={}, layer_num=0, total_layers=200, tray_change_log=[(0, 0), (1, 80)]
  609. )
  610. with (
  611. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  612. patch(
  613. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  614. AsyncMock(return_value=client),
  615. ),
  616. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
  617. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  618. ):
  619. await _report_partial_usage(printer_id=1, tracking=tracking, last_progress=30)
  620. # Layer 60 of 200 from 30 %: 60 g used, never more.
  621. assert round(sum(call.args[1] for call in client.use_spool.await_args_list), 2) <= 60.0
  622. class _AsyncCtx:
  623. """Tiny async-context shim returning a pre-built db mock; mirrors
  624. async_session()'s ``async with`` interface."""
  625. def __init__(self, db):
  626. self._db = db
  627. async def __aenter__(self):
  628. return self._db
  629. async def __aexit__(self, *_):
  630. return False