test_mid_print_spool_swap_3325.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. """A spool swapped by hand on one tray mid-print shares the print (#3325).
  2. The reporter's P2S has no AMS: the external spool ran out, they loaded a new
  3. roll and assigned it in Bambuddy while the print was paused, and the whole
  4. print was charged to the new spool. The tray never changed (254 before and
  5. after), so the tray-change split had nothing to work with; the assignment was
  6. the only trace of the swap.
  7. """
  8. from datetime import datetime, timezone
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.models.active_print_session import ActivePrintSession
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.spool import Spool
  15. from backend.app.services import spool_swaps
  16. from backend.app.services.usage_tracker import _track_from_3mf
  17. from backend.app.utils.threemf_tools import mm_to_grams
  18. pytestmark = pytest.mark.unit
  19. INV = spool_swaps.INVENTORY
  20. EXT = (255, 0) # the external spool, as assignments key it
  21. def _log(*swaps, start=4, backend=INV, key=EXT):
  22. """A print that started with spool ``start`` on ``key`` and the given (spool, layer) swaps."""
  23. log = [spool_swaps.entry(backend, *key, start, 0, start=True)]
  24. log += [spool_swaps.entry(backend, *key, spool, layer) for spool, layer in swaps]
  25. return log
  26. # 100 layers of filament 0, evenly: the whole print is 50 g of PLA, as the
  27. # slicer says, so the G-code and the slicer agree.
  28. LAYER_MM = 50.0 / mm_to_grams(1.0, 1.75, 1.24) / 100
  29. LAYER_USAGE = {layer: {0: layer * LAYER_MM} for layer in range(1, 101)}
  30. class TestSegments:
  31. def test_a_swap_after_the_start(self):
  32. assert spool_swaps.segments(_log((9, 60)), INV, *EXT) == [(4, 0), (9, 60)]
  33. def test_no_swap_is_no_split(self):
  34. assert spool_swaps.segments(_log(), INV, *EXT) == []
  35. def test_reassigning_the_same_spool_is_no_swap(self):
  36. assert spool_swaps.segments(_log((4, 30)), INV, *EXT) == []
  37. def test_a_tray_empty_at_the_start_is_not_split(self):
  38. # Assigning a spool mid-print to a tray that had none is catching up on
  39. # a forgotten assignment: the whole print goes to it, as before.
  40. log = [spool_swaps.entry(INV, *EXT, 9, 40)]
  41. assert spool_swaps.segments(log, INV, *EXT) == []
  42. def test_a_caught_up_assignment_covers_the_start_of_a_later_swap(self):
  43. # Spool 9 assigned late at layer 40, then swapped for 12 at 70: 9 fed
  44. # the print from its start, not from layer 40.
  45. log = [spool_swaps.entry(INV, *EXT, 9, 40), spool_swaps.entry(INV, *EXT, 12, 70)]
  46. assert spool_swaps.segments(log, INV, *EXT) == [(9, 0), (12, 70)]
  47. def test_other_trays_and_backends_are_ignored(self):
  48. log = _log((9, 60), key=(0, 1)) + _log((7, 20), backend=spool_swaps.SPOOLMAN)
  49. assert spool_swaps.segments(log, INV, *EXT) == []
  50. def test_swapping_back_is_a_segment_of_its_own(self):
  51. assert spool_swaps.segments(_log((9, 30), (4, 70)), INV, *EXT) == [(4, 0), (9, 30), (4, 70)]
  52. class TestSplit:
  53. def test_by_gcode_extrusion(self):
  54. shares = spool_swaps.split([(4, 0), (9, 60)], 50.0, 0, LAYER_USAGE, 100)
  55. assert [(s, round(g, 6)) for s, g in shares] == [(4, 30.0), (9, 20.0)]
  56. def test_by_layer_count_without_gcode(self):
  57. shares = spool_swaps.split([(4, 0), (9, 25)], 40.0, 0, None, 100)
  58. assert [(s, round(g, 6)) for s, g in shares] == [(4, 10.0), (9, 30.0)]
  59. def test_shares_always_add_up_and_never_starve_the_last_spool(self):
  60. # Front-loaded G-code: nearly all filament before the swap. The new
  61. # spool still gets its proportional share, and the total holds.
  62. usage = {1: {0: 9000.0}, 50: {0: 9900.0}, 100: {0: 10000.0}}
  63. shares = dict(spool_swaps.split([(4, 0), (9, 50)], 12.0, 0, usage, 100))
  64. assert shares[9] > 0
  65. assert sum(shares.values()) == pytest.approx(12.0)
  66. def test_a_swap_back_adds_up_per_spool(self):
  67. shares = dict(spool_swaps.split([(4, 0), (9, 25), (4, 75)], 100.0, 0, None, 100))
  68. assert shares == pytest.approx({4: 50.0, 9: 50.0})
  69. def test_within_a_range(self):
  70. # An AMS-backup segment from layer 40 to 80, the spool swapped at 60.
  71. shares = spool_swaps.split([(4, 0), (9, 60)], 8.0, 0, LAYER_USAGE, 80, from_layer=40)
  72. assert [(s, round(g, 6)) for s, g in shares] == [(4, 4.0), (9, 4.0)]
  73. def test_a_swap_after_the_print_stopped_goes_nowhere(self):
  74. # A failed print stopped at layer 30; the spool was swapped at 60.
  75. assert spool_swaps.split([(4, 0), (9, 60)], 5.0, 0, LAYER_USAGE, 30) == [(4, 5.0)]
  76. def test_nothing_to_weigh_by_goes_to_the_last_spool(self):
  77. assert spool_swaps.split([(4, 0), (9, 0)], 5.0, 0, None, 0) == [(9, 5.0)]
  78. class TestRecordAssignment:
  79. async def _record(self, db_session, printer_id, state, spool_id=9):
  80. with patch("backend.app.services.printer_manager.printer_manager.get_status", return_value=state):
  81. await spool_swaps.record_assignment(db_session, printer_id, *EXT, spool_id, INV)
  82. await db_session.commit()
  83. @pytest.mark.asyncio
  84. async def test_a_swap_while_printing_is_logged_at_the_current_layer(self, db_session, printer_factory):
  85. printer = await printer_factory()
  86. db_session.add(ActivePrintSession(printer_id=printer.id, started_at=datetime.now(), spool_change_log=_log()))
  87. await db_session.commit()
  88. state = SimpleNamespace(layer_num=57, spool_change_log=_log())
  89. await self._record(db_session, printer.id, state)
  90. assert spool_swaps.segments(state.spool_change_log, INV, *EXT) == [(4, 0), (9, 57)]
  91. row = await db_session.get(ActivePrintSession, printer.id)
  92. await db_session.refresh(row)
  93. assert row.spool_change_log == state.spool_change_log
  94. @pytest.mark.asyncio
  95. async def test_nothing_is_logged_without_a_print(self, db_session, printer_factory):
  96. printer = await printer_factory()
  97. state = SimpleNamespace(layer_num=0, spool_change_log=[])
  98. await self._record(db_session, printer.id, state)
  99. assert state.spool_change_log == []
  100. @pytest.mark.asyncio
  101. async def test_an_assignment_that_rolls_back_is_not_logged(self, db_session, printer_factory):
  102. printer = await printer_factory()
  103. db_session.add(ActivePrintSession(printer_id=printer.id, started_at=datetime.now(), spool_change_log=_log()))
  104. await db_session.commit()
  105. state = SimpleNamespace(layer_num=57, spool_change_log=_log())
  106. with patch("backend.app.services.printer_manager.printer_manager.get_status", return_value=state):
  107. await spool_swaps.record_assignment(db_session, printer.id, *EXT, 9, INV)
  108. await db_session.rollback()
  109. assert state.spool_change_log == _log()
  110. @pytest.mark.asyncio
  111. async def test_after_a_restart_the_start_spool_is_kept(self, db_session, printer_factory):
  112. # Bambuddy restarted during the runout pause: the state's log is empty
  113. # until the printer reports RUNNING, the row still has the start spool.
  114. printer = await printer_factory()
  115. db_session.add(ActivePrintSession(printer_id=printer.id, started_at=datetime.now(), spool_change_log=_log()))
  116. await db_session.commit()
  117. state = SimpleNamespace(layer_num=57, spool_change_log=[])
  118. await self._record(db_session, printer.id, state)
  119. assert spool_swaps.segments(state.spool_change_log, INV, *EXT) == [(4, 0), (9, 57)]
  120. row = await db_session.get(ActivePrintSession, printer.id)
  121. await db_session.refresh(row)
  122. assert row.spool_change_log == state.spool_change_log
  123. @pytest.mark.asyncio
  124. async def test_the_same_spool_again_is_not_logged(self, db_session, printer_factory):
  125. printer = await printer_factory()
  126. db_session.add(ActivePrintSession(printer_id=printer.id, started_at=datetime.now()))
  127. await db_session.commit()
  128. state = SimpleNamespace(layer_num=20, spool_change_log=_log())
  129. await self._record(db_session, printer.id, state, spool_id=4)
  130. assert state.spool_change_log == _log()
  131. # --- The built-in inventory -------------------------------------------------
  132. def _spool(spool_id: int):
  133. spool = MagicMock()
  134. spool.id = spool_id
  135. spool.label_weight = 1000
  136. spool.weight_used = 0
  137. spool.cost_per_kg = None
  138. spool.material = "PLA"
  139. spool.rgba = None
  140. return spool
  141. def _db(spools: dict[int, MagicMock]):
  142. archive = MagicMock()
  143. archive.id = 7
  144. archive.file_path = "archives/7/test.3mf"
  145. archive.extra_data = None
  146. archive.plate_id = None
  147. async def execute(stmt, *args, **kwargs):
  148. entity = stmt.column_descriptions[0].get("entity")
  149. result = MagicMock()
  150. value = None
  151. if entity is PrintArchive:
  152. value = archive
  153. elif entity is Spool:
  154. value = spools.get(stmt.whereclause.right.value)
  155. result.scalar_one_or_none.return_value = value
  156. result.scalars.return_value.first.return_value = None
  157. result.scalar.return_value = None
  158. return result
  159. db = AsyncMock()
  160. db.execute = execute
  161. db.add = MagicMock()
  162. return db
  163. async def _track(
  164. spool_change_log, status="completed", layer_num=100, tray_change_log=None, last_layer_num=0, total_layers=100
  165. ):
  166. printer_manager = MagicMock()
  167. printer_manager.get_status.return_value = SimpleNamespace(
  168. progress=100 if status == "completed" else 40,
  169. layer_num=layer_num,
  170. total_layers=total_layers,
  171. tray_now=254,
  172. last_loaded_tray=254,
  173. tray_change_log=tray_change_log or [],
  174. spool_change_log=spool_change_log,
  175. raw_data={"ams": [], "vt_tray": [{"id": "254", "tray_type": "PLA"}]},
  176. )
  177. async def resolve(printer_id, ams_id, tray_id, **kwargs):
  178. # AMS slot 0 holds spool 20; the external spool's live assignment is
  179. # spool 9, the one loaded at the end.
  180. return 20 if (ams_id, tray_id) == (0, 0) else 9
  181. charged: list[tuple[int, float]] = []
  182. async def charge(db, spool, grams):
  183. charged.append((spool.id, round(grams, 2)))
  184. return True
  185. with (
  186. patch("backend.app.core.config.settings") as mock_settings,
  187. patch(
  188. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  189. return_value=[{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": "#000000"}],
  190. ),
  191. patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=LAYER_USAGE),
  192. patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
  193. patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
  194. patch("backend.app.services.usage_tracker._charge_spool", side_effect=charge),
  195. ):
  196. mock_path = MagicMock()
  197. mock_path.exists.return_value = True
  198. mock_settings.base_dir = MagicMock()
  199. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  200. results = await _track_from_3mf(
  201. printer_id=1,
  202. archive_id=7,
  203. status=status,
  204. print_name="swap",
  205. handled_trays=set(),
  206. printer_manager=printer_manager,
  207. db=_db({4: _spool(4), 9: _spool(9), 20: _spool(20)}),
  208. ams_mapping=[254],
  209. last_layer_num=last_layer_num,
  210. print_started_at=datetime.now(timezone.utc),
  211. )
  212. return results, charged
  213. class TestBuiltInInventory:
  214. @pytest.mark.asyncio
  215. async def test_the_print_is_split_between_the_two_spools(self):
  216. results, charged = await _track(_log((9, 60)))
  217. assert charged == [(4, 30.0), (9, 20.0)]
  218. assert [r["spool_id"] for r in results] == [4, 9]
  219. @pytest.mark.asyncio
  220. async def test_a_printer_that_forgot_its_layer_count_still_splits(self):
  221. # P1S firmware zeroes total_layer_num at print end; the G-code's own
  222. # last layer bounds the split instead.
  223. _, charged = await _track(_log((9, 60)), layer_num=0, total_layers=0)
  224. assert charged == [(4, 30.0), (9, 20.0)]
  225. @pytest.mark.asyncio
  226. async def test_without_a_swap_the_live_spool_takes_it_all(self):
  227. _, charged = await _track(_log())
  228. assert charged == [(9, 50.0)]
  229. @pytest.mark.asyncio
  230. async def test_a_failed_print_splits_up_to_where_it_stopped(self):
  231. # Stopped at layer 80 of 100: 40 g of the slot's 50 g by the G-code,
  232. # the swap at 60 splits it 30 / 10.
  233. _, charged = await _track(_log((9, 60)), status="failed", layer_num=0, last_layer_num=80)
  234. assert charged == [(4, 30.0), (9, 10.0)]
  235. @pytest.mark.asyncio
  236. async def test_an_ams_backup_segment_is_split_too(self):
  237. # AMS backup moved the print from AMS slot 0 to the external spool at
  238. # layer 40, and that spool was swapped by hand at 60.
  239. _, charged = await _track(_log((9, 60)), tray_change_log=[(0, 0), (254, 40)])
  240. assert charged == [(20, 20.0), (4, 10.0), (9, 20.0)]
  241. # --- Spoolman ---------------------------------------------------------------
  242. def _client():
  243. client = MagicMock()
  244. client.find_spool_by_tag = AsyncMock(return_value=None)
  245. client.use_spool = AsyncMock()
  246. client.get_spool = AsyncMock(side_effect=lambda spool_id: {"id": spool_id, "price": 20.0, "initial_weight": 1000})
  247. return client
  248. def _splitter(log, end_layer=100, completed=True):
  249. return spool_swaps.Splitter(log, spool_swaps.SPOOLMAN, LAYER_USAGE, end_layer, completed=completed)
  250. class TestSpoolman:
  251. @pytest.mark.asyncio
  252. async def test_the_print_is_split_between_the_two_spools(self):
  253. from backend.app.services.spoolman_tracking import _PrintCost, _report_spool_usage_for_slots
  254. client = _client()
  255. cost = _PrintCost()
  256. with patch(
  257. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", AsyncMock(return_value=9)
  258. ):
  259. updated = await _report_spool_usage_for_slots(
  260. client,
  261. [(1, 50.0)],
  262. {254: {"tray_type": "PLA"}},
  263. [254],
  264. "Archive 7",
  265. "SERIAL",
  266. printer_id=1,
  267. cost_out=cost,
  268. swaps=_splitter(_log((9, 60), backend=spool_swaps.SPOOLMAN)),
  269. )
  270. assert updated == 2
  271. assert [c.args for c in client.use_spool.await_args_list] == [(4, 30.0), (9, 20.0)]
  272. assert cost.priced_grams == pytest.approx(50.0)
  273. @pytest.mark.asyncio
  274. async def test_without_a_swap_nothing_changes(self):
  275. from backend.app.services.spoolman_tracking import _report_spool_usage_for_slots
  276. client = _client()
  277. with patch(
  278. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", AsyncMock(return_value=9)
  279. ):
  280. await _report_spool_usage_for_slots(
  281. client,
  282. [(1, 50.0)],
  283. {254: {"tray_type": "PLA"}},
  284. [254],
  285. "Archive 7",
  286. "SERIAL",
  287. printer_id=1,
  288. swaps=_splitter(_log(backend=spool_swaps.SPOOLMAN)),
  289. )
  290. assert [c.args for c in client.use_spool.await_args_list] == [(9, 50.0)]
  291. def test_a_multi_colour_print_without_gcode_layers_splits_by_layer_count(self):
  292. # report_usage reads the printer's layers for single-slot prints only.
  293. from backend.app.services.spoolman_tracking import _swap_splitter
  294. log = _log((9, 25), backend=spool_swaps.SPOOLMAN)
  295. state = SimpleNamespace(total_layers=100, layer_num=100, spool_change_log=log)
  296. with patch("backend.app.services.printer_manager.printer_manager.get_status", return_value=state):
  297. swaps = _swap_splitter(1, None, None, 0, completed=True)
  298. assert swaps.shares(*EXT, 1, 40.0) == pytest.approx([(4, 10.0), (9, 30.0)])
  299. @pytest.mark.asyncio
  300. async def test_a_tray_change_segment_is_split_too(self):
  301. from backend.app.services.spoolman_tracking import _report_spool_usage_split_by_tray_changes
  302. client = _client()
  303. async def slot(printer_id, ams_id, tray_id):
  304. return 9 if ams_id == 255 else 20
  305. with patch("backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", side_effect=slot):
  306. await _report_spool_usage_split_by_tray_changes(
  307. client,
  308. [{"slot_id": 1, "used_g": 50.0}],
  309. [(0, 0), (254, 40)],
  310. {0: {"tray_type": "PLA"}, 254: {"tray_type": "PLA"}},
  311. LAYER_USAGE,
  312. {},
  313. 100,
  314. 100,
  315. "Archive 7",
  316. "SERIAL",
  317. printer_id=1,
  318. swaps=_splitter(_log((9, 60), backend=spool_swaps.SPOOLMAN)),
  319. )
  320. charged = [c.args for c in client.use_spool.await_args_list]
  321. assert charged[0][0] == 20
  322. assert charged[1:] == [(4, 10.0), (9, 20.0)]
  323. # --- Print start and restart --------------------------------------------------
  324. class TestPrintStartAndRestart:
  325. def _state(self):
  326. state = MagicMock()
  327. state.raw_data = {"ams": [], "vt_tray": [{"id": "254", "tray_type": "PLA"}]}
  328. state.tray_now = 254
  329. state.tray_change_log = [(254, 0)]
  330. state.spool_change_log = []
  331. return state
  332. @pytest.mark.asyncio
  333. @pytest.mark.parametrize("spoolman", [False, True])
  334. async def test_print_start_logs_the_spool_each_tray_starts_with(self, db_session, printer_factory, spoolman):
  335. from backend.app.models.spool_assignment import SpoolAssignment
  336. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  337. from backend.app.services.usage_tracker import _active_sessions, on_print_start
  338. printer = await printer_factory()
  339. spool = Spool(material="PLA", label_weight=1000, weight_used=0)
  340. db_session.add(spool)
  341. await db_session.commit()
  342. db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=255, tray_id=0))
  343. db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=255, tray_id=0, spoolman_spool_id=77))
  344. await db_session.commit()
  345. state = self._state()
  346. printer_manager = MagicMock()
  347. printer_manager.get_status.return_value = state
  348. await on_print_start(
  349. printer.id, {"subtask_name": "swap"}, printer_manager, db=db_session, spoolman_owns_usage=spoolman
  350. )
  351. _active_sessions.pop(printer.id, None)
  352. backend, spool_id = (spool_swaps.SPOOLMAN, 77) if spoolman else (INV, spool.id)
  353. assert state.spool_change_log == [spool_swaps.entry(backend, 255, 0, spool_id, 0, start=True)]
  354. row = await db_session.get(ActivePrintSession, printer.id)
  355. assert row.spool_change_log == state.spool_change_log
  356. @pytest.mark.asyncio
  357. async def test_a_restart_puts_the_log_back(self, db_session, printer_factory):
  358. from backend.app.main import _restore_usage_tracking_session
  359. printer = await printer_factory()
  360. before_restart = _log((9, 40))
  361. db_session.add(
  362. ActivePrintSession(
  363. printer_id=printer.id,
  364. print_name="swap",
  365. started_at=datetime.now(),
  366. spool_change_log=before_restart,
  367. )
  368. )
  369. await db_session.commit()
  370. since = spool_swaps.entry(INV, *EXT, 12, 70)
  371. state = SimpleNamespace(
  372. subtask_name="swap",
  373. tray_change_log=[],
  374. tray_now=254,
  375. layer_num=75,
  376. last_loaded_tray=254,
  377. print_ams_mapping=None,
  378. spool_change_log=[since],
  379. )
  380. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  381. assert state.spool_change_log == before_restart + [since]
  382. assert spool_swaps.segments(state.spool_change_log, INV, *EXT) == [(4, 0), (9, 40), (12, 70)]
  383. class TestBuiltInRemainDelta:
  384. """Without a 3MF the built-in tracker charges a tray's remain% drop. Across a
  385. swap the start and end readings belong to two different spools."""
  386. @pytest.fixture(autouse=True)
  387. def _clean(self):
  388. from backend.app.services.usage_tracker import _active_sessions
  389. _active_sessions.clear()
  390. with patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None):
  391. yield
  392. _active_sessions.clear()
  393. async def _complete(self, spool_change_log):
  394. from backend.app.services.usage_tracker import PrintSession, _active_sessions, on_print_complete
  395. _active_sessions[1] = PrintSession(
  396. printer_id=1,
  397. print_name="Test",
  398. started_at=datetime.now(timezone.utc),
  399. tray_remain_start={(0, 0): 80},
  400. )
  401. printer_manager = MagicMock()
  402. printer_manager.get_status.return_value = SimpleNamespace(
  403. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  404. tray_now=0,
  405. last_loaded_tray=-1,
  406. spool_change_log=spool_change_log,
  407. )
  408. assignment = MagicMock(spool_id=2, printer_id=1, ams_id=0, tray_id=0)
  409. spool = _spool(2)
  410. responses = iter([None, None, assignment, spool])
  411. async def execute(*args, **kwargs):
  412. result = MagicMock()
  413. value = next(responses, None)
  414. result.scalar_one_or_none.return_value = value
  415. result.scalars.return_value.first.return_value = value
  416. result.scalar.return_value = None
  417. return result
  418. db = AsyncMock()
  419. db.execute = execute
  420. db.add = MagicMock()
  421. return await on_print_complete(
  422. printer_id=1, data={"status": "completed"}, printer_manager=printer_manager, db=db, archive_id=None
  423. )
  424. @pytest.mark.asyncio
  425. async def test_a_swapped_tray_is_not_charged_its_remain_drop(self):
  426. assert await self._complete(_log((2, 40), start=5, key=(0, 0))) == []
  427. @pytest.mark.asyncio
  428. async def test_an_assignment_corrected_before_the_first_layer_is_still_charged(self):
  429. results = await self._complete(_log((2, 0), start=5, key=(0, 0)))
  430. assert [(r["spool_id"], r["weight_used"]) for r in results] == [(2, 100.0)]
  431. @pytest.mark.asyncio
  432. async def test_a_tray_that_kept_its_spool_still_is(self):
  433. results = await self._complete(_log(start=2, key=(0, 0)))
  434. assert [(r["spool_id"], r["weight_used"]) for r in results] == [(2, 100.0)]