test_usage_tracker_restart_recovery.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  1. """Filament attribution has to survive a restart mid-print.
  2. A 14-hour print that spans a Bambuddy restart used to lose everything the
  3. completion path needs: the plate (so the 3MF parser summed every plate of a
  4. multi-plate file), the dispatched slot-to-tray mapping (so it fell back to the
  5. live MQTT ``mapping`` field, which AMS filament backup rewrites to the
  6. substitute tray), the spool-assignment snapshot, and the tray-change log that
  7. splits weight across a runout. The whole print was then charged to whichever
  8. spool happened to finish it, while the spool that actually ran dry was charged
  9. nothing.
  10. These tests cover the durable ``active_print_sessions`` row that fixes that,
  11. plus the plate and mapping fallbacks the completion path now applies.
  12. """
  13. from datetime import datetime, timezone
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from sqlalchemy import select
  18. from backend.app.models.active_print_session import ActivePrintSession
  19. from backend.app.models.printer import Printer
  20. from backend.app.services.usage_tracker import (
  21. PrintSession,
  22. _active_sessions,
  23. _track_from_3mf,
  24. clear_persisted_session,
  25. get_persisted_print_name,
  26. on_print_complete,
  27. persist_session,
  28. record_tray_change,
  29. restore_session,
  30. )
  31. def _make_spool(spool_id=1, label_weight=1000, weight_used=0):
  32. spool = MagicMock()
  33. spool.id = spool_id
  34. spool.label_weight = label_weight
  35. spool.weight_used = weight_used
  36. spool.tag_uid = None
  37. spool.tray_uuid = None
  38. spool.last_used = None
  39. spool.cost_per_kg = None
  40. spool.material = "ABS"
  41. spool.rgba = "616777FF"
  42. return spool
  43. def _make_assignment(spool_id=1, ams_id=0, tray_id=0):
  44. assignment = MagicMock()
  45. assignment.spool_id = spool_id
  46. assignment.printer_id = 1
  47. assignment.ams_id = ams_id
  48. assignment.tray_id = tray_id
  49. assignment.created_at = None
  50. return assignment
  51. def _make_archive(archive_id=1, plate_id=None, file_path="archives/1/multi_plate.3mf"):
  52. archive = MagicMock()
  53. archive.id = archive_id
  54. archive.file_path = file_path
  55. archive.plate_id = plate_id
  56. archive.extra_data = None
  57. return archive
  58. def _make_queue_item(item_id=629, ams_mapping=None, plate_id=None):
  59. item = MagicMock()
  60. item.id = item_id
  61. item.ams_mapping = ams_mapping
  62. item.plate_id = plate_id
  63. item.status = "printing"
  64. return item
  65. def _mock_db_sequential(responses):
  66. """Mock db whose execute() yields the given rows in order."""
  67. db = AsyncMock()
  68. call_count = [0]
  69. async def mock_execute(*args, **kwargs):
  70. idx = call_count[0]
  71. call_count[0] += 1
  72. result = MagicMock()
  73. value = responses[idx] if idx < len(responses) else None
  74. result.scalar_one_or_none.return_value = value
  75. result.scalars.return_value.first.return_value = value
  76. result.scalar.return_value = None
  77. return result
  78. db.execute = mock_execute
  79. return db
  80. def _patched_3mf(filament_usage, capture=None):
  81. """Patch the 3MF extract, optionally recording the plate_id it was given."""
  82. def _extract(path, plate_id=None):
  83. if capture is not None:
  84. capture.append(plate_id)
  85. return filament_usage
  86. return patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", side_effect=_extract)
  87. def _settings_patch():
  88. mock_settings = patch("backend.app.core.config.settings")
  89. return mock_settings
  90. class TestPersistedSessionRoundTrip:
  91. """The row is the only thing that outlives the process."""
  92. @pytest.fixture(autouse=True)
  93. def _clear_sessions(self):
  94. _active_sessions.clear()
  95. yield
  96. _active_sessions.clear()
  97. @pytest.fixture
  98. async def printer(self, db_session):
  99. row = Printer(name="H2D-1", ip_address="192.168.0.10", access_code="1234", serial_number="TESTSERIAL")
  100. db_session.add(row)
  101. await db_session.commit()
  102. return row
  103. def _session(self, printer_id):
  104. return PrintSession(
  105. printer_id=printer_id,
  106. print_name="AMS_Rack",
  107. started_at=datetime(2026, 8, 11, 9, 25, 6, tzinfo=timezone.utc),
  108. tray_remain_start={(0, 2): 84, (0, 3): 100},
  109. tray_now_at_start=2,
  110. spool_assignments={(0, 2): 69, (0, 3): 68},
  111. ams_mapping=[2],
  112. plate_id=1,
  113. )
  114. @pytest.mark.asyncio
  115. async def test_restore_rebuilds_the_session_and_returns_the_tray_log(self, db_session, printer):
  116. await persist_session(db_session, self._session(printer.id), [(2, 0)])
  117. _active_sessions.clear()
  118. log = await restore_session(db_session, printer.id)
  119. assert log == [[2, 0]]
  120. restored = _active_sessions[printer.id]
  121. assert restored.plate_id == 1
  122. assert restored.ams_mapping == [2]
  123. assert restored.tray_now_at_start == 2
  124. # Tuple keys survive the JSON round trip — the completion path indexes
  125. # the snapshot by (ams_id, tray_id).
  126. assert restored.spool_assignments == {(0, 2): 69, (0, 3): 68}
  127. assert restored.tray_remain_start == {(0, 2): 84, (0, 3): 100}
  128. assert restored.started_at.tzinfo is not None
  129. @pytest.mark.asyncio
  130. async def test_tray_changes_accumulate_in_order(self, db_session, printer):
  131. await persist_session(db_session, self._session(printer.id), [(2, 0)])
  132. # The runout sequence from the reported print: A3 empties, the AMS
  133. # parks, then filament backup brings A4 in.
  134. await record_tray_change(db_session, printer.id, 254, 670)
  135. await record_tray_change(db_session, printer.id, 3, 675)
  136. assert await restore_session(db_session, printer.id) == [[2, 0], [254, 670], [3, 675]]
  137. @pytest.mark.asyncio
  138. async def test_tray_change_without_a_session_is_a_noop(self, db_session, printer):
  139. await record_tray_change(db_session, printer.id, 3, 675)
  140. row = await db_session.get(ActivePrintSession, printer.id)
  141. assert row is None
  142. @pytest.mark.asyncio
  143. async def test_print_start_overwrites_a_row_left_by_a_missed_completion(self, db_session, printer):
  144. await persist_session(db_session, self._session(printer.id), [(2, 0), (3, 675)])
  145. second = self._session(printer.id)
  146. second.print_name = "Cover"
  147. second.plate_id = 2
  148. second.ams_mapping = [5]
  149. second.spool_assignments = {(1, 0): 60}
  150. await persist_session(db_session, second, [(5, 0)])
  151. rows = (await db_session.execute(select(ActivePrintSession))).scalars().all()
  152. assert len(rows) == 1
  153. log = await restore_session(db_session, printer.id)
  154. assert log == [[5, 0]]
  155. assert _active_sessions[printer.id].plate_id == 2
  156. assert _active_sessions[printer.id].spool_assignments == {(1, 0): 60}
  157. @pytest.mark.asyncio
  158. async def test_clear_removes_the_row(self, db_session, printer):
  159. await persist_session(db_session, self._session(printer.id), [(2, 0)])
  160. await clear_persisted_session(db_session, printer.id)
  161. assert await restore_session(db_session, printer.id) is None
  162. assert await get_persisted_print_name(db_session, printer.id) is None
  163. @pytest.mark.asyncio
  164. async def test_clear_is_safe_without_a_row(self, db_session, printer):
  165. await clear_persisted_session(db_session, printer.id)
  166. @pytest.mark.asyncio
  167. async def test_print_name_is_readable_for_the_identity_check(self, db_session, printer):
  168. await persist_session(db_session, self._session(printer.id), None)
  169. assert await get_persisted_print_name(db_session, printer.id) == "AMS_Rack"
  170. @pytest.mark.asyncio
  171. async def test_completion_falls_back_to_the_persisted_row(self, db_session, printer):
  172. """No in-memory session (the restart case): the plate, the mapping and
  173. the assignment snapshot must still reach the 3MF path."""
  174. await persist_session(db_session, self._session(printer.id), [(2, 0), (3, 675)])
  175. _active_sessions.clear()
  176. captured = {}
  177. async def _fake_track(*args, **kwargs):
  178. captured.update(kwargs)
  179. return []
  180. with (
  181. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  182. patch("backend.app.services.usage_tracker._track_from_3mf", side_effect=_fake_track),
  183. ):
  184. await on_print_complete(
  185. printer.id,
  186. {"status": "completed", "subtask_name": "AMS_Rack"},
  187. MagicMock(),
  188. db_session,
  189. archive_id=312,
  190. )
  191. assert captured["plate_id"] == 1
  192. assert captured["ams_mapping"] == [2]
  193. assert captured["tray_now_at_start"] == 2
  194. assert captured["spool_assignments"] == {(0, 2): 69, (0, 3): 68}
  195. class TestPlateIdRecovery:
  196. """Without the plate, the 3MF parser sums every plate in the file and the
  197. whole multi-plate total lands on one spool."""
  198. @pytest.mark.asyncio
  199. async def test_archive_plate_id_is_used_when_the_session_is_gone(self):
  200. archive = _make_archive(archive_id=312, plate_id=1)
  201. spool = _make_spool(spool_id=68)
  202. assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
  203. db = _mock_db_sequential([archive, None, assignment, spool])
  204. seen_plate_ids: list = []
  205. printer_manager = MagicMock()
  206. printer_manager.get_status.return_value = SimpleNamespace(
  207. raw_data={"mapping": [3]},
  208. progress=100,
  209. layer_num=809,
  210. tray_now=255,
  211. tray_change_log=[],
  212. total_layers=809,
  213. )
  214. with (
  215. _settings_patch() as mock_settings,
  216. _patched_3mf([{"slot_id": 1, "used_g": 1122.44, "type": "ABS", "color": "#808080"}], seen_plate_ids),
  217. ):
  218. mock_settings.base_dir = MagicMock()
  219. mock_path = MagicMock()
  220. mock_path.exists.return_value = True
  221. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  222. await _track_from_3mf(
  223. printer_id=1,
  224. archive_id=312,
  225. status="completed",
  226. print_name="AMS_Rack",
  227. handled_trays=set(),
  228. printer_manager=printer_manager,
  229. db=db,
  230. plate_id=None,
  231. )
  232. assert seen_plate_ids == [1]
  233. @pytest.mark.asyncio
  234. async def test_queue_item_plate_id_is_used_when_the_archive_has_none(self):
  235. archive = _make_archive(archive_id=312, plate_id=None)
  236. queue_item = _make_queue_item(plate_id=2)
  237. spool = _make_spool(spool_id=68)
  238. assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
  239. # db: archive, the single queue lookup (plate + mapping share it),
  240. # then assignment and spool
  241. db = _mock_db_sequential([archive, queue_item, assignment, spool])
  242. seen_plate_ids: list = []
  243. printer_manager = MagicMock()
  244. printer_manager.get_status.return_value = SimpleNamespace(
  245. raw_data={"mapping": [3]},
  246. progress=100,
  247. layer_num=361,
  248. tray_now=255,
  249. tray_change_log=[],
  250. total_layers=361,
  251. )
  252. with (
  253. _settings_patch() as mock_settings,
  254. _patched_3mf([{"slot_id": 1, "used_g": 318.82, "type": "ABS", "color": "#808080"}], seen_plate_ids),
  255. ):
  256. mock_settings.base_dir = MagicMock()
  257. mock_path = MagicMock()
  258. mock_path.exists.return_value = True
  259. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  260. await _track_from_3mf(
  261. printer_id=1,
  262. archive_id=312,
  263. status="completed",
  264. print_name="AMS_Rack",
  265. handled_trays=set(),
  266. printer_manager=printer_manager,
  267. db=db,
  268. plate_id=None,
  269. )
  270. assert seen_plate_ids == [2]
  271. @pytest.mark.asyncio
  272. async def test_caller_plate_id_wins_over_the_database(self):
  273. archive = _make_archive(archive_id=312, plate_id=1)
  274. spool = _make_spool(spool_id=68)
  275. assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
  276. db = _mock_db_sequential([archive, None, assignment, spool])
  277. seen_plate_ids: list = []
  278. printer_manager = MagicMock()
  279. printer_manager.get_status.return_value = SimpleNamespace(
  280. raw_data={"mapping": [3]},
  281. progress=100,
  282. layer_num=361,
  283. tray_now=255,
  284. tray_change_log=[],
  285. total_layers=361,
  286. )
  287. with (
  288. _settings_patch() as mock_settings,
  289. _patched_3mf([{"slot_id": 1, "used_g": 318.82, "type": "ABS", "color": "#808080"}], seen_plate_ids),
  290. ):
  291. mock_settings.base_dir = MagicMock()
  292. mock_path = MagicMock()
  293. mock_path.exists.return_value = True
  294. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  295. await _track_from_3mf(
  296. printer_id=1,
  297. archive_id=312,
  298. status="completed",
  299. print_name="AMS_Rack",
  300. handled_trays=set(),
  301. printer_manager=printer_manager,
  302. db=db,
  303. plate_id=2,
  304. )
  305. assert seen_plate_ids == [2]
  306. @pytest.mark.asyncio
  307. async def test_queue_item_plate_wins_over_the_archive_plate(self):
  308. """The archive keeps the plate of its first print; a reprint of plate 3
  309. is recorded only on its queue item."""
  310. archive = _make_archive(archive_id=312, plate_id=1)
  311. queue_item = _make_queue_item(plate_id=3)
  312. spool = _make_spool(spool_id=68)
  313. assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
  314. db = _mock_db_sequential([archive, queue_item, assignment, spool])
  315. seen_plate_ids: list = []
  316. printer_manager = MagicMock()
  317. printer_manager.get_status.return_value = SimpleNamespace(
  318. raw_data={"mapping": [3]},
  319. progress=100,
  320. layer_num=361,
  321. tray_now=255,
  322. tray_change_log=[],
  323. total_layers=361,
  324. )
  325. with (
  326. _settings_patch() as mock_settings,
  327. _patched_3mf([{"slot_id": 1, "used_g": 318.82, "type": "ABS", "color": "#808080"}], seen_plate_ids),
  328. ):
  329. mock_settings.base_dir = MagicMock()
  330. mock_path = MagicMock()
  331. mock_path.exists.return_value = True
  332. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  333. await _track_from_3mf(
  334. printer_id=1,
  335. archive_id=312,
  336. status="completed",
  337. print_name="AMS_Rack",
  338. handled_trays=set(),
  339. printer_manager=printer_manager,
  340. db=db,
  341. plate_id=None,
  342. )
  343. assert seen_plate_ids == [3]
  344. @staticmethod
  345. async def _archive_on_new_printer(db_session, serial="TESTSERIAL"):
  346. from backend.app.models.archive import PrintArchive
  347. printer = Printer(name=serial, ip_address="192.168.0.10", access_code="1234", serial_number=serial)
  348. db_session.add(printer)
  349. await db_session.flush()
  350. archive = PrintArchive(
  351. printer_id=printer.id,
  352. filename="multi_plate.3mf",
  353. file_path="archives/1/multi_plate.3mf",
  354. file_size=1,
  355. content_hash="x",
  356. plate_id=1,
  357. status="completed",
  358. )
  359. db_session.add(archive)
  360. await db_session.flush()
  361. return printer.id, archive.id
  362. @staticmethod
  363. async def _plates_parsed(db_session, printer_id, archive_id) -> list:
  364. seen_plate_ids: list = []
  365. printer_manager = MagicMock()
  366. printer_manager.get_status.return_value = SimpleNamespace(
  367. raw_data={},
  368. progress=100,
  369. layer_num=10,
  370. tray_now=255,
  371. last_loaded_tray=255,
  372. tray_change_log=[],
  373. total_layers=10,
  374. )
  375. with (
  376. _settings_patch() as mock_settings,
  377. _patched_3mf([{"slot_id": 1, "used_g": 10.0, "type": "ABS", "color": "#808080"}], seen_plate_ids),
  378. ):
  379. mock_settings.base_dir = MagicMock()
  380. mock_path = MagicMock()
  381. mock_path.exists.return_value = True
  382. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  383. await _track_from_3mf(
  384. printer_id=printer_id,
  385. archive_id=archive_id,
  386. status="completed",
  387. print_name="multi_plate",
  388. handled_trays=set(),
  389. printer_manager=printer_manager,
  390. db=db_session,
  391. plate_id=None,
  392. )
  393. return seen_plate_ids
  394. @pytest.mark.asyncio
  395. async def test_the_newest_queue_item_of_the_archive_is_read(self, db_session):
  396. """An archive printed twice from the queue has two finished items; the
  397. current run is the newer one."""
  398. from backend.app.models.print_queue import PrintQueueItem
  399. printer_id, archive_id = await self._archive_on_new_printer(db_session)
  400. db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=2, status="completed"))
  401. db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=3, status="printing"))
  402. await db_session.commit()
  403. assert await self._plates_parsed(db_session, printer_id, archive_id) == [3]
  404. @pytest.mark.asyncio
  405. async def test_another_printers_queue_item_is_not_read(self, db_session):
  406. """Plates of one archive can run on several printers at once."""
  407. from backend.app.models.print_queue import PrintQueueItem
  408. printer_id, archive_id = await self._archive_on_new_printer(db_session)
  409. other = Printer(name="other", ip_address="192.168.0.12", access_code="1234", serial_number="OTHER")
  410. db_session.add(other)
  411. await db_session.flush()
  412. db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=2, status="completed"))
  413. db_session.add(PrintQueueItem(printer_id=other.id, archive_id=archive_id, plate_id=3, status="printing"))
  414. await db_session.commit()
  415. assert await self._plates_parsed(db_session, printer_id, archive_id) == [2]
  416. @pytest.mark.asyncio
  417. async def test_a_cancelled_run_reads_its_own_queue_item(self, db_session):
  418. """Completion marks the item cancelled before usage is tracked."""
  419. from backend.app.models.print_queue import PrintQueueItem
  420. printer_id, archive_id = await self._archive_on_new_printer(db_session)
  421. db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=2, status="completed"))
  422. db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=3, status="cancelled"))
  423. await db_session.commit()
  424. assert await self._plates_parsed(db_session, printer_id, archive_id) == [3]
  425. class TestMappingPriority:
  426. """AMS filament backup rewrites the printer's live ``mapping`` field to the
  427. substitute tray. Read at completion it names the tray that finished the
  428. print, not the one the slicer assigned."""
  429. @pytest.mark.asyncio
  430. async def test_queue_mapping_beats_the_live_mqtt_mapping(self):
  431. archive = _make_archive(archive_id=312, plate_id=1)
  432. # Dispatched against AMS0-T2 (global tray 2); the printer now reports
  433. # tray 3 because backup swapped in the neighbouring spool.
  434. queue_item = _make_queue_item(ams_mapping="[2]")
  435. spool_69 = _make_spool(spool_id=69)
  436. assign_69 = _make_assignment(spool_id=69, ams_id=0, tray_id=2)
  437. db = _mock_db_sequential([archive, queue_item, assign_69, spool_69])
  438. printer_manager = MagicMock()
  439. printer_manager.get_status.return_value = SimpleNamespace(
  440. raw_data={"mapping": [3]},
  441. progress=100,
  442. layer_num=809,
  443. tray_now=255,
  444. tray_change_log=[],
  445. total_layers=809,
  446. )
  447. with (
  448. _settings_patch() as mock_settings,
  449. _patched_3mf([{"slot_id": 1, "used_g": 1122.44, "type": "ABS", "color": "#808080"}]),
  450. ):
  451. mock_settings.base_dir = MagicMock()
  452. mock_path = MagicMock()
  453. mock_path.exists.return_value = True
  454. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  455. results = await _track_from_3mf(
  456. printer_id=1,
  457. archive_id=312,
  458. status="completed",
  459. print_name="AMS_Rack",
  460. handled_trays=set(),
  461. printer_manager=printer_manager,
  462. db=db,
  463. plate_id=1,
  464. )
  465. assert len(results) == 1
  466. assert results[0]["spool_id"] == 69
  467. assert (results[0]["ams_id"], results[0]["tray_id"]) == (0, 2)
  468. @pytest.mark.asyncio
  469. async def test_mqtt_mapping_still_used_for_a_direct_print(self):
  470. """No queue item — the live field is the only mapping there is."""
  471. archive = _make_archive(archive_id=400, plate_id=1)
  472. spool = _make_spool(spool_id=68)
  473. assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
  474. db = _mock_db_sequential([archive, None, assignment, spool])
  475. printer_manager = MagicMock()
  476. printer_manager.get_status.return_value = SimpleNamespace(
  477. raw_data={"mapping": [3]},
  478. progress=100,
  479. layer_num=100,
  480. tray_now=255,
  481. tray_change_log=[],
  482. total_layers=100,
  483. )
  484. with (
  485. _settings_patch() as mock_settings,
  486. _patched_3mf([{"slot_id": 1, "used_g": 50.0, "type": "ABS", "color": "#808080"}]),
  487. ):
  488. mock_settings.base_dir = MagicMock()
  489. mock_path = MagicMock()
  490. mock_path.exists.return_value = True
  491. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  492. results = await _track_from_3mf(
  493. printer_id=1,
  494. archive_id=400,
  495. status="completed",
  496. print_name="Cover",
  497. handled_trays=set(),
  498. printer_manager=printer_manager,
  499. db=db,
  500. plate_id=1,
  501. )
  502. assert len(results) == 1
  503. assert (results[0]["ams_id"], results[0]["tray_id"]) == (0, 3)
  504. class TestRestoreOnRestartRecovery:
  505. """``on_print_running_observed`` is the only hook that fires when Bambuddy
  506. comes up mid-print — the #1304 guard suppresses ``on_print_start``."""
  507. @pytest.fixture(autouse=True)
  508. def _clear_sessions(self):
  509. _active_sessions.clear()
  510. yield
  511. _active_sessions.clear()
  512. @pytest.fixture
  513. async def printer(self, db_session):
  514. row = Printer(name="H2D-1", ip_address="192.168.0.10", access_code="1234", serial_number="TESTSERIAL")
  515. db_session.add(row)
  516. await db_session.commit()
  517. return row
  518. def _state(self, **overrides):
  519. state = SimpleNamespace(
  520. subtask_name="AMS_Rack",
  521. tray_change_log=[],
  522. tray_now=3,
  523. layer_num=700,
  524. last_loaded_tray=-1,
  525. print_ams_mapping=None,
  526. )
  527. for key, value in overrides.items():
  528. setattr(state, key, value)
  529. return state
  530. def _session(self, printer_id, print_name="AMS_Rack"):
  531. return PrintSession(
  532. printer_id=printer_id,
  533. print_name=print_name,
  534. started_at=datetime(2026, 8, 11, 9, 25, 6, tzinfo=timezone.utc),
  535. tray_now_at_start=2,
  536. spool_assignments={(0, 2): 69},
  537. ams_mapping=[2],
  538. plate_id=1,
  539. )
  540. @pytest.mark.asyncio
  541. async def test_persisted_log_comes_back_onto_the_printer_state(self, db_session, printer):
  542. from backend.app.main import _restore_usage_tracking_session
  543. await persist_session(db_session, self._session(printer.id), [(2, 0)])
  544. _active_sessions.clear()
  545. state = self._state()
  546. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  547. assert state.tray_change_log == [(2, 0)]
  548. assert _active_sessions[printer.id].plate_id == 1
  549. @pytest.mark.asyncio
  550. @pytest.mark.parametrize("spoolman", [False, True])
  551. async def test_the_print_s_slot_mapping_comes_back_onto_the_printer_state(self, db_session, printer, spoolman):
  552. """The slot pills (#1762) read the mapping captured from the print
  553. command, which dies with the process; with Spoolman too, which gets no
  554. in-memory session."""
  555. from backend.app.main import _restore_usage_tracking_session
  556. from backend.app.models.settings import Settings
  557. if spoolman:
  558. db_session.add(Settings(key="spoolman_enabled", value="true"))
  559. await db_session.commit()
  560. await persist_session(db_session, self._session(printer.id), [(2, 0)])
  561. state = self._state()
  562. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  563. assert state.print_ams_mapping == [2]
  564. @pytest.mark.asyncio
  565. async def test_a_mapping_this_process_captured_is_kept(self, db_session, printer):
  566. from backend.app.main import _restore_usage_tracking_session
  567. await persist_session(db_session, self._session(printer.id), [(2, 0)])
  568. state = self._state(print_ams_mapping=[5, 10])
  569. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  570. assert state.print_ams_mapping == [5, 10]
  571. @pytest.mark.asyncio
  572. async def test_entries_seen_by_this_process_are_kept_after_the_persisted_ones(self, db_session, printer):
  573. from backend.app.main import _restore_usage_tracking_session
  574. await persist_session(db_session, self._session(printer.id), [(2, 0)])
  575. state = self._state(tray_change_log=[(3, 675)])
  576. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  577. assert state.tray_change_log == [(2, 0), (3, 675)]
  578. @pytest.mark.asyncio
  579. async def test_no_persisted_row_seeds_from_the_tray_feeding_now(self, db_session, printer):
  580. """A print that started before this build still gets its remaining
  581. segment attributed to the right spool."""
  582. from backend.app.main import _restore_usage_tracking_session
  583. state = self._state()
  584. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  585. assert state.tray_change_log == [(3, 700)]
  586. assert state.last_loaded_tray == 3
  587. # And no slot pills: nothing says how the print's filaments were mapped.
  588. assert state.print_ams_mapping is None
  589. @pytest.mark.asyncio
  590. async def test_unloaded_tray_seeds_nothing(self, db_session, printer):
  591. from backend.app.main import _restore_usage_tracking_session
  592. state = self._state(tray_now=255)
  593. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  594. assert state.tray_change_log == []
  595. @pytest.mark.asyncio
  596. async def test_a_row_from_a_different_print_is_discarded(self, db_session, printer):
  597. """A completion Bambuddy never saw leaves a row behind; it must not
  598. attach itself to whatever is running now."""
  599. from backend.app.main import _restore_usage_tracking_session
  600. await persist_session(db_session, self._session(printer.id, print_name="Old_Print"), [(2, 0)])
  601. _active_sessions.clear()
  602. state = self._state(subtask_name="AMS_Rack")
  603. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  604. assert printer.id not in _active_sessions
  605. assert await restore_session(db_session, printer.id) is None
  606. # Still seeded, so the rest of the running print stays attributable.
  607. assert state.tray_change_log == [(3, 700)]
  608. # Nor do its slot pills show on the running print.
  609. assert state.print_ams_mapping is None
  610. @pytest.mark.asyncio
  611. async def test_an_unloaded_tray_does_not_clobber_last_loaded_tray(self, db_session, printer):
  612. """``last_loaded_tray`` is the fallback that survives the end-of-print
  613. retract to 255; writing 255 into it would defeat its whole purpose."""
  614. from backend.app.main import _restore_usage_tracking_session
  615. state = self._state(tray_now=255, last_loaded_tray=2)
  616. await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
  617. assert state.last_loaded_tray == 2
  618. @pytest.mark.asyncio
  619. async def test_a_failure_is_swallowed_so_the_caller_keeps_going(self, db_session, printer):
  620. """The caller still has to capture its timelapse baseline before the
  621. printer uploads the in-flight recording — there is no second chance."""
  622. from backend.app.main import _restore_usage_tracking_session
  623. broken = SimpleNamespace() # no subtask_name, no tray fields at all
  624. await _restore_usage_tracking_session(printer.id, broken, db_session, MagicMock())
  625. class TestPlateNotInTheFile:
  626. """A recovered plate has to be treated as a hint, not a filter that can
  627. silently zero out a print's usage."""
  628. @pytest.mark.asyncio
  629. async def test_falls_back_to_the_whole_file_when_the_plate_is_absent(self):
  630. """The archive's own 3MF can be gone, with a same-named library file
  631. substituted that was sliced with different plates."""
  632. archive = _make_archive(archive_id=312, plate_id=7)
  633. spool = _make_spool(spool_id=68)
  634. assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
  635. db = _mock_db_sequential([archive, None, assignment, spool])
  636. calls: list = []
  637. def _extract(path, plate_id=None):
  638. calls.append(plate_id)
  639. return [] if plate_id is not None else [{"slot_id": 1, "used_g": 12.0, "type": "ABS", "color": ""}]
  640. printer_manager = MagicMock()
  641. printer_manager.get_status.return_value = SimpleNamespace(
  642. raw_data={"mapping": [3]},
  643. progress=100,
  644. layer_num=10,
  645. tray_now=255,
  646. tray_change_log=[],
  647. total_layers=10,
  648. )
  649. with (
  650. _settings_patch() as mock_settings,
  651. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", side_effect=_extract),
  652. ):
  653. mock_settings.base_dir = MagicMock()
  654. mock_path = MagicMock()
  655. mock_path.exists.return_value = True
  656. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  657. results = await _track_from_3mf(
  658. printer_id=1,
  659. archive_id=312,
  660. status="completed",
  661. print_name="AMS_Rack",
  662. handled_trays=set(),
  663. printer_manager=printer_manager,
  664. db=db,
  665. plate_id=None,
  666. )
  667. assert calls == [7, None]
  668. assert len(results) == 1
  669. assert results[0]["weight_used"] == 12.0
  670. @pytest.mark.asyncio
  671. async def test_a_file_with_no_usage_at_all_still_records_nothing(self):
  672. archive = _make_archive(archive_id=312, plate_id=1)
  673. db = _mock_db_sequential([archive, None])
  674. printer_manager = MagicMock()
  675. printer_manager.get_status.return_value = SimpleNamespace(
  676. raw_data={"mapping": [3]},
  677. progress=100,
  678. layer_num=10,
  679. tray_now=255,
  680. tray_change_log=[],
  681. total_layers=10,
  682. )
  683. with (
  684. _settings_patch() as mock_settings,
  685. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=[]),
  686. ):
  687. mock_settings.base_dir = MagicMock()
  688. mock_path = MagicMock()
  689. mock_path.exists.return_value = True
  690. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  691. results = await _track_from_3mf(
  692. printer_id=1,
  693. archive_id=312,
  694. status="completed",
  695. print_name="AMS_Rack",
  696. handled_trays=set(),
  697. printer_manager=printer_manager,
  698. db=db,
  699. plate_id=1,
  700. )
  701. assert results == []
  702. class TestSpoolmanParity:
  703. """Both inventory backends need the same restart protection.
  704. Spoolman's own durable row (#1820) already carries its plate-scoped 3MF
  705. figures and the mapping it was dispatched with, but not the tray-change
  706. log — and that log is the only record of which spool fed which layers when
  707. AMS Filament Backup swaps trays. Capturing it for one backend only would
  708. leave Spoolman users with the bug this fixes for everyone else.
  709. """
  710. @pytest.fixture(autouse=True)
  711. def _clear_sessions(self):
  712. _active_sessions.clear()
  713. yield
  714. _active_sessions.clear()
  715. @pytest.fixture
  716. async def printer(self, db_session):
  717. row = Printer(name="H2D-1", ip_address="192.168.0.10", access_code="1234", serial_number="TESTSERIAL")
  718. db_session.add(row)
  719. await db_session.commit()
  720. return row
  721. def _printer_manager(self):
  722. pm = MagicMock()
  723. pm.get_status.return_value = SimpleNamespace(
  724. raw_data={
  725. "ams": {"ams": [{"id": 0, "tray": [{"id": 2, "remain": 84, "tray_type": "ABS"}]}]},
  726. "vt_tray": [],
  727. "mapping": [2],
  728. },
  729. tray_now=2,
  730. last_loaded_tray=2,
  731. tray_change_log=[(2, 0)],
  732. )
  733. return pm
  734. @pytest.mark.asyncio
  735. async def test_the_row_is_written_with_spoolman_enabled(self, db_session, printer):
  736. from backend.app.services.usage_tracker import on_print_start
  737. await on_print_start(
  738. printer.id,
  739. {"subtask_name": "AMS_Rack", "ams_mapping": [2]},
  740. self._printer_manager(),
  741. db=db_session,
  742. spoolman_owns_usage=True,
  743. )
  744. row = await db_session.get(ActivePrintSession, printer.id)
  745. assert row is not None
  746. assert row.print_name == "AMS_Rack"
  747. assert row.tray_change_log == [[2, 0]]
  748. @pytest.mark.asyncio
  749. async def test_spoolman_does_not_get_an_in_memory_session(self, db_session, printer):
  750. """``_active_sessions`` doubles as on_ams_change's "skip the remain%
  751. weight sync, the internal tracker will deduct precisely" flag (#880).
  752. A session the internal tracker will never complete would suppress a
  753. sync Spoolman users still need."""
  754. from backend.app.services.usage_tracker import on_print_start
  755. await on_print_start(
  756. printer.id,
  757. {"subtask_name": "AMS_Rack", "ams_mapping": [2]},
  758. self._printer_manager(),
  759. db=db_session,
  760. spoolman_owns_usage=True,
  761. )
  762. assert printer.id not in _active_sessions
  763. @pytest.mark.asyncio
  764. async def test_the_internal_tracker_still_gets_one(self, db_session, printer):
  765. from backend.app.services.usage_tracker import on_print_start
  766. await on_print_start(
  767. printer.id,
  768. {"subtask_name": "AMS_Rack", "ams_mapping": [2]},
  769. self._printer_manager(),
  770. db=db_session,
  771. spoolman_owns_usage=False,
  772. )
  773. assert _active_sessions[printer.id].ams_mapping == [2]
  774. assert await db_session.get(ActivePrintSession, printer.id) is not None
  775. @pytest.mark.asyncio
  776. async def test_restore_can_return_the_log_without_publishing_a_session(self, db_session, printer):
  777. await persist_session(
  778. db_session,
  779. PrintSession(
  780. printer_id=printer.id,
  781. print_name="AMS_Rack",
  782. started_at=datetime(2026, 8, 11, 9, 25, 6, tzinfo=timezone.utc),
  783. ),
  784. [(2, 0), (3, 675)],
  785. )
  786. _active_sessions.clear()
  787. log = await restore_session(db_session, printer.id, register_active=False)
  788. assert log == [[2, 0], [3, 675]]
  789. assert printer.id not in _active_sessions
  790. @pytest.mark.asyncio
  791. async def test_discard_clears_both_halves(self, db_session, printer):
  792. from backend.app.services.usage_tracker import discard_session
  793. session = PrintSession(
  794. printer_id=printer.id,
  795. print_name="AMS_Rack",
  796. started_at=datetime(2026, 8, 11, 9, 25, 6, tzinfo=timezone.utc),
  797. )
  798. _active_sessions[printer.id] = session
  799. await persist_session(db_session, session, [(2, 0)])
  800. await discard_session(db_session, printer.id)
  801. assert printer.id not in _active_sessions
  802. assert await db_session.get(ActivePrintSession, printer.id) is None