test_spoolman_tray_split.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. """Spoolman-side mid-print tray-split accounting (#1793).
  2. Reporter (@ojimpo) shipped the OP shape:
  3. - H2S, AMS filament backup ON, two same-material spools loaded
  4. - Single-slot print (72.56g on slot 1)
  5. - Origin ran dry at layer 37, AMS auto-switched to backup, print finished
  6. - Pre-fix: whole 72.56g charged to origin (via tag path) + separate 30g to
  7. backup (via remain-delta) — origin exceeded initial_weight, backup double-count
  8. The fix ports usage_tracker's split path to spoolman_tracking so both
  9. inventory backends attribute segments identically. These tests pin the
  10. OP's shape plus the Path 2 (remain-delta) skip guarantee so it can't
  11. double-charge tray IDs the split path already covered.
  12. """
  13. from types import SimpleNamespace
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. class _AsyncCtx:
  17. """async_session() shim — same shape as test_spoolman_no3mf_remain_fallback."""
  18. def __init__(self, db):
  19. self._db = db
  20. async def __aenter__(self):
  21. return self._db
  22. async def __aexit__(self, *_):
  23. return False
  24. def _make_db(tracking):
  25. db = AsyncMock()
  26. select_result = MagicMock()
  27. select_result.scalar_one_or_none.return_value = tracking
  28. db.execute = AsyncMock(return_value=select_result)
  29. db.delete = AsyncMock()
  30. db.commit = AsyncMock()
  31. return db
  32. class TestReportUsageTraySplit:
  33. """report_usage must consult state.tray_change_log and split per-segment."""
  34. @pytest.mark.asyncio
  35. async def test_op_sample_a_seamless_switch_splits_origin_to_backup(self):
  36. """Sample A from the reporter, verbatim: 72.56g single-slot print,
  37. AMS runout switch tray 0 → tray 1 at layer 37 of ~100 total.
  38. No gcode layer_usage is provided → linear-by-layer-ratio fallback:
  39. - seg 0 (tray 0, layers 0-37) = 72.56 * 37/100 = 26.85g → spool 8
  40. - seg 1 (tray 1, layers 37-end) = 72.56 - 26.85 = 45.71g → spool 7
  41. Path 2 (remain-delta) must NOT run against either tray — the split
  42. path already covered them.
  43. """
  44. from backend.app.services.spoolman_tracking import report_usage
  45. tracking = SimpleNamespace(
  46. filament_usage=[{"slot_id": 1, "used_g": 72.56}],
  47. ams_trays={
  48. 0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"},
  49. 1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"},
  50. },
  51. slot_to_tray=[0],
  52. tray_remain_start={
  53. "0-0": {"remain": 3, "tray_uuid": "AAAA"}, # origin near-empty at start-of-completion snapshot
  54. "0-1": {"remain": 73, "tray_uuid": "BBBB"},
  55. },
  56. layer_usage={},
  57. filament_properties={},
  58. )
  59. db = _make_db(tracking)
  60. client = AsyncMock()
  61. async def _find_spool_by_tag(tag):
  62. return (
  63. {"id": 8, "filament": {"color_hex": "000000"}}
  64. if tag == "AAAA"
  65. else {"id": 7, "filament": {"color_hex": "000000"}}
  66. if tag == "BBBB"
  67. else None
  68. )
  69. client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag)
  70. client.use_spool = AsyncMock()
  71. printer_manager = MagicMock()
  72. printer_manager.get_status.return_value = SimpleNamespace(
  73. tray_change_log=[(0, 0), (1, 37)],
  74. total_layers=100,
  75. layer_num=100,
  76. raw_data={
  77. "ams": [
  78. {
  79. "id": 0,
  80. "tray": [
  81. {"id": 0, "tray_uuid": "AAAA", "remain": 0},
  82. {"id": 1, "tray_uuid": "BBBB", "remain": 70},
  83. ],
  84. }
  85. ]
  86. },
  87. )
  88. with (
  89. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  90. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  91. patch(
  92. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  93. AsyncMock(return_value=client),
  94. ),
  95. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")),
  96. patch(
  97. "backend.app.services.spoolman_tracking._apply_spool_colors_to_archive",
  98. AsyncMock(),
  99. ),
  100. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  101. ):
  102. await report_usage(printer_id=1, archive_id=143)
  103. # Exactly two use_spool calls — one per segment. Origin (spool 8)
  104. # gets the layers-0-37 slice, backup (spool 7) gets the remainder.
  105. calls = client.use_spool.await_args_list
  106. assert len(calls) == 2, f"expected 2 use_spool calls, got {len(calls)}: {calls}"
  107. by_spool = {c.args[0]: c.args[1] for c in calls}
  108. assert set(by_spool.keys()) == {8, 7}
  109. # Sum must equal the OP's total — no phantom grams created or lost.
  110. assert round(sum(by_spool.values()), 2) == 72.56
  111. # Origin (spool 8) should carry roughly the layers-0-37 fraction.
  112. # Linear: 72.56 * 37/100 = 26.85g. Allow small rounding wiggle.
  113. assert 26.0 < by_spool[8] < 28.0
  114. # Backup (spool 7) carries the remainder.
  115. assert 44.0 < by_spool[7] < 46.6
  116. @pytest.mark.asyncio
  117. async def test_path_2_remain_delta_skips_tray_handled_by_split(self):
  118. """After the split path attributes segments to tray 0 AND tray 1,
  119. the Path 2 remain-delta iterator must skip BOTH — otherwise backup
  120. would get charged twice (~30g double-count in the OP's Sample A).
  121. """
  122. from backend.app.services.spoolman_tracking import report_usage
  123. tracking = SimpleNamespace(
  124. filament_usage=[{"slot_id": 1, "used_g": 100.0}],
  125. ams_trays={
  126. 0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"},
  127. 1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"},
  128. },
  129. slot_to_tray=[0],
  130. tray_remain_start={
  131. "0-0": {"remain": 20, "tray_uuid": "AAAA"},
  132. "0-1": {"remain": 80, "tray_uuid": "BBBB"},
  133. },
  134. layer_usage={},
  135. filament_properties={},
  136. )
  137. db = _make_db(tracking)
  138. client = AsyncMock()
  139. async def _find_spool_by_tag(tag):
  140. return {"id": 8, "filament": {}} if tag == "AAAA" else {"id": 7, "filament": {}}
  141. client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag)
  142. client.use_spool = AsyncMock()
  143. # If Path 2 ever runs, it needs a filament.weight to compute grams.
  144. # Making it valid means a failure to guard = extra use_spool calls,
  145. # not a silent skip. Combined with a truthy slot-assignment result
  146. # below, this is what actually proves the double-count guard works.
  147. client.get_spool = AsyncMock(return_value={"filament": {"weight": 1000.0}})
  148. printer_manager = MagicMock()
  149. printer_manager.get_status.return_value = SimpleNamespace(
  150. tray_change_log=[(0, 0), (1, 50)],
  151. total_layers=100,
  152. layer_num=100,
  153. raw_data={
  154. "ams": [
  155. {
  156. "id": 0,
  157. "tray": [
  158. {"id": 0, "tray_uuid": "AAAA", "remain": 0},
  159. {"id": 1, "tray_uuid": "BBBB", "remain": 60},
  160. ],
  161. }
  162. ]
  163. },
  164. )
  165. with (
  166. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  167. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  168. patch(
  169. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  170. AsyncMock(return_value=client),
  171. ),
  172. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")),
  173. patch(
  174. # Path 2 uses this to resolve trays. Return valid IDs so
  175. # the ONLY thing stopping Path 2 from double-charging is
  176. # ``handled_global_tray_ids``. If the guard is broken,
  177. # Path 2 would successfully call ``use_spool`` two more
  178. # times and this test would fail with 4 calls, not 2.
  179. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  180. AsyncMock(side_effect=lambda pid, ams, tray: 999 if (ams, tray) == (0, 0) else 888),
  181. ),
  182. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  183. ):
  184. await report_usage(printer_id=1, archive_id=200)
  185. # EXACTLY 2 — one per segment; Path 2 must not add a third.
  186. assert client.use_spool.await_count == 2, (
  187. f"Path 2 leaked past the split — expected 2 use_spool calls, got "
  188. f"{client.use_spool.await_count}: {client.use_spool.await_args_list}"
  189. )
  190. @pytest.mark.asyncio
  191. async def test_multi_slot_print_does_not_activate_split_even_with_tray_changes(self):
  192. """Multi-colour prints normally cycle trays every colour change, so
  193. ``tray_change_log`` has many entries — but splitting each slot's
  194. grams across all of them would attribute slot 1's usage to segments
  195. where slot 2's tray was loaded (and vice versa).
  196. Mirrors ``usage_tracker.py:1002``'s gate: split only when there's
  197. exactly one nonzero slot. Multi-slot prints fall through to the
  198. existing single-tray path with its stable ``slot_to_tray`` mapping.
  199. """
  200. from backend.app.services.spoolman_tracking import report_usage
  201. # Two nonzero slots — regular multi-colour print
  202. tracking = SimpleNamespace(
  203. filament_usage=[
  204. {"slot_id": 1, "used_g": 30.0},
  205. {"slot_id": 2, "used_g": 20.0},
  206. ],
  207. ams_trays={
  208. 0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"},
  209. 1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"},
  210. },
  211. slot_to_tray=[0, 1],
  212. tray_remain_start={
  213. "0-0": {"remain": 90, "tray_uuid": "AAAA"},
  214. "0-1": {"remain": 80, "tray_uuid": "BBBB"},
  215. },
  216. layer_usage={},
  217. filament_properties={},
  218. )
  219. db = _make_db(tracking)
  220. client = AsyncMock()
  221. async def _find_spool_by_tag(tag):
  222. return {"id": 100, "filament": {}} if tag == "AAAA" else {"id": 200, "filament": {}}
  223. client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag)
  224. client.use_spool = AsyncMock()
  225. printer_manager = MagicMock()
  226. # Multi-colour print naturally cycles between trays many times.
  227. # If we don't gate on single-slot, my split would attribute slot 1's
  228. # grams to every segment — including segments where tray 1 was loaded.
  229. printer_manager.get_status.return_value = SimpleNamespace(
  230. tray_change_log=[(0, 0), (1, 10), (0, 20), (1, 30), (0, 40)],
  231. total_layers=50,
  232. layer_num=50,
  233. raw_data={
  234. "ams": [
  235. {
  236. "id": 0,
  237. "tray": [
  238. {"id": 0, "tray_uuid": "AAAA", "remain": 87},
  239. {"id": 1, "tray_uuid": "BBBB", "remain": 78},
  240. ],
  241. }
  242. ]
  243. },
  244. )
  245. with (
  246. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  247. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  248. patch(
  249. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  250. AsyncMock(return_value=client),
  251. ),
  252. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")),
  253. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  254. ):
  255. await report_usage(printer_id=1, archive_id=42)
  256. # Split path must NOT engage. The single-tray path charges each
  257. # slot to its stable slot_to_tray mapping: slot 1 → tray 0 → spool
  258. # 100 (30g), slot 2 → tray 1 → spool 200 (20g). Two calls, exact
  259. # weights from the 3MF (not split).
  260. assert client.use_spool.await_count == 2
  261. by_spool = {c.args[0]: c.args[1] for c in client.use_spool.await_args_list}
  262. assert by_spool == {100: 30.0, 200: 20.0}
  263. @pytest.mark.asyncio
  264. async def test_single_tray_change_entry_uses_normal_path(self):
  265. """Only ONE entry in tray_change_log (start-of-print seed, no
  266. switch) must fall through to the existing single-tray charging
  267. path — not accidentally split when there's nothing to split.
  268. """
  269. from backend.app.services.spoolman_tracking import report_usage
  270. tracking = SimpleNamespace(
  271. filament_usage=[{"slot_id": 1, "used_g": 50.0}],
  272. ams_trays={0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"}},
  273. slot_to_tray=[0],
  274. tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
  275. layer_usage={},
  276. filament_properties={},
  277. )
  278. db = _make_db(tracking)
  279. client = AsyncMock()
  280. client.find_spool_by_tag = AsyncMock(return_value={"id": 8, "filament": {}})
  281. client.use_spool = AsyncMock()
  282. printer_manager = MagicMock()
  283. printer_manager.get_status.return_value = SimpleNamespace(
  284. tray_change_log=[(0, 0)], # just the start-of-print seed
  285. total_layers=100,
  286. layer_num=100,
  287. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 75}]}]},
  288. )
  289. with (
  290. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  291. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  292. patch(
  293. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  294. AsyncMock(return_value=client),
  295. ),
  296. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")),
  297. patch("backend.app.services.printer_manager.printer_manager", printer_manager),
  298. ):
  299. await report_usage(printer_id=1, archive_id=42)
  300. # Single-tray path: exactly one use_spool call, all 50g to spool 8.
  301. client.use_spool.assert_awaited_once_with(8, 50.0)