test_print_start_expected_promotion.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. """Tests for expected print promotion when auto_archive is disabled (#839).
  2. When auto_archive=False but a print was dispatched by BamBuddy (queue/reprint),
  3. the on_print_start callback must still promote the expected print to _active_prints
  4. so that at print completion the archive_id and ams_mapping are available for
  5. filament usage tracking.
  6. These are pure unit tests that verify the module-level dict manipulation logic
  7. directly, NOT by calling the full on_print_start callback.
  8. """
  9. import time
  10. import pytest
  11. from backend.app.main import (
  12. _active_prints,
  13. _expected_print_creators,
  14. _expected_print_registered_at,
  15. _expected_prints,
  16. _get_start_plate_id,
  17. _print_ams_mappings,
  18. _print_plate_ids,
  19. register_expected_print,
  20. )
  21. @pytest.fixture(autouse=True)
  22. def _clear_dicts():
  23. """Clear module-level tracking dicts before and after each test."""
  24. _expected_prints.clear()
  25. _expected_print_registered_at.clear()
  26. _expected_print_creators.clear()
  27. _print_ams_mappings.clear()
  28. _print_plate_ids.clear()
  29. _active_prints.clear()
  30. yield
  31. _expected_prints.clear()
  32. _expected_print_registered_at.clear()
  33. _expected_print_creators.clear()
  34. _print_ams_mappings.clear()
  35. _print_plate_ids.clear()
  36. _active_prints.clear()
  37. class TestRegisterExpectedPrint:
  38. """Verify register_expected_print populates all tracking dicts."""
  39. def test_registers_filename_and_variants(self):
  40. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  41. assert _expected_prints[(1, "Box.3mf")] == 54
  42. assert _expected_prints[(1, "Box")] == 54
  43. assert _expected_prints[(1, "Box.gcode")] == 54
  44. def test_stores_ams_mapping(self):
  45. register_expected_print(1, "test.3mf", archive_id=10, ams_mapping=[2, -1, 3])
  46. assert _print_ams_mappings[10] == [2, -1, 3]
  47. def test_no_ams_mapping_when_none(self):
  48. register_expected_print(1, "test.3mf", archive_id=10, ams_mapping=None)
  49. assert 10 not in _print_ams_mappings
  50. def test_stores_creator(self):
  51. register_expected_print(1, "test.3mf", archive_id=10, created_by_id=5)
  52. assert _expected_print_creators[(1, "test.3mf")] == 5
  53. def test_stores_registered_at(self):
  54. before = time.monotonic()
  55. register_expected_print(1, "test.3mf", archive_id=10)
  56. after = time.monotonic()
  57. ts = _expected_print_registered_at[(1, "test.3mf")]
  58. assert before <= ts <= after
  59. def test_stores_plate_id(self):
  60. """plate_id is registered so usage tracking can scope multi-plate 3MFs (#1697)."""
  61. register_expected_print(1, "test.3mf", archive_id=10, plate_id=2)
  62. assert _print_plate_ids[10] == 2
  63. def test_no_plate_id_when_none(self):
  64. """Direct-Print of a single-plate file passes plate_id=None; nothing stored."""
  65. register_expected_print(1, "test.3mf", archive_id=10, plate_id=None)
  66. assert 10 not in _print_plate_ids
  67. def test_get_start_plate_id_reads_back(self):
  68. register_expected_print(1, "test.3mf", archive_id=10, plate_id=3)
  69. assert _get_start_plate_id(10) == 3
  70. def test_get_start_plate_id_returns_none_for_unregistered(self):
  71. assert _get_start_plate_id(10) is None
  72. assert _get_start_plate_id(None) is None
  73. class TestExpectedPrintDetection:
  74. """Verify the expected-print detection logic used in on_print_start.
  75. Reproduces the key-building and lookup logic from the auto_archive=False
  76. block in on_print_start to verify that expected prints are correctly
  77. detected across all filename variations.
  78. """
  79. @staticmethod
  80. def _build_check_keys(printer_id: int, filename: str, subtask_name: str):
  81. """Reproduce the key-building logic from on_print_start."""
  82. check_keys = []
  83. if subtask_name:
  84. check_keys += [
  85. (printer_id, subtask_name),
  86. (printer_id, f"{subtask_name}.3mf"),
  87. (printer_id, f"{subtask_name}.gcode.3mf"),
  88. ]
  89. if filename:
  90. base_fn = filename.split("/")[-1] if "/" in filename else filename
  91. check_keys.append((printer_id, base_fn))
  92. no_archive_base = base_fn.replace(".gcode", "").replace(".3mf", "")
  93. check_keys += [
  94. (printer_id, no_archive_base),
  95. (printer_id, f"{no_archive_base}.3mf"),
  96. ]
  97. return check_keys
  98. def test_detects_expected_print_by_subtask(self):
  99. """Expected print is found when subtask_name matches."""
  100. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  101. keys = self._build_check_keys(1, filename="", subtask_name="Box")
  102. assert any(k in _expected_prints for k in keys)
  103. def test_detects_expected_print_by_filename(self):
  104. """Expected print is found when filename matches."""
  105. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  106. keys = self._build_check_keys(1, filename="Box.3mf", subtask_name="")
  107. assert any(k in _expected_prints for k in keys)
  108. def test_detects_expected_print_by_gcode_filename(self):
  109. """Expected print is found when MQTT reports .gcode filename."""
  110. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  111. # MQTT sometimes reports gcode filename
  112. keys = self._build_check_keys(1, filename="Box.gcode", subtask_name="Box")
  113. assert any(k in _expected_prints for k in keys)
  114. def test_no_false_positive_for_different_file(self):
  115. """Expected print NOT found for a different filename."""
  116. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  117. keys = self._build_check_keys(1, filename="Benchy.3mf", subtask_name="Benchy")
  118. assert not any(k in _expected_prints for k in keys)
  119. def test_no_false_positive_for_different_printer(self):
  120. """Expected print NOT found when printer_id doesn't match."""
  121. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  122. keys = self._build_check_keys(2, filename="Box.3mf", subtask_name="Box")
  123. assert not any(k in _expected_prints for k in keys)
  124. def test_empty_expected_prints_returns_false(self):
  125. """No detection when _expected_prints is empty."""
  126. keys = self._build_check_keys(1, filename="test.3mf", subtask_name="test")
  127. assert not any(k in _expected_prints for k in keys)
  128. def test_filename_with_spaces_and_parens(self):
  129. """Handles filenames with spaces and parentheses (e.g. 'Box3.0_(2)_plate_5.3mf')."""
  130. register_expected_print(1, "Box3.0_(2)_plate_5.3mf", archive_id=54, ams_mapping=[1])
  131. keys = self._build_check_keys(
  132. 1,
  133. filename="Box3.0_(2)_plate_5.gcode",
  134. subtask_name="Box3.0_(2)_plate_5",
  135. )
  136. assert any(k in _expected_prints for k in keys)
  137. class TestExpectedPrintPromotion:
  138. """Verify that expected prints are correctly promoted to _active_prints.
  139. Reproduces the expected-print pop + promotion logic from on_print_start
  140. (lines 1468-1496) to verify that _active_prints is populated and
  141. _expected_prints is cleaned up.
  142. """
  143. @staticmethod
  144. def _simulate_expected_print_promotion(printer_id: int, subtask_name: str, filename: str, archive_filename: str):
  145. """Simulate the expected-print lookup and promotion from on_print_start."""
  146. expected_keys = []
  147. if subtask_name:
  148. expected_keys.append((printer_id, subtask_name))
  149. expected_keys.append((printer_id, f"{subtask_name}.3mf"))
  150. expected_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
  151. if filename:
  152. fname = filename.split("/")[-1] if "/" in filename else filename
  153. expected_keys.append((printer_id, fname))
  154. base = fname.replace(".gcode", "").replace(".3mf", "")
  155. expected_keys.append((printer_id, base))
  156. expected_keys.append((printer_id, f"{base}.3mf"))
  157. expected_archive_id = None
  158. for key in expected_keys:
  159. expected_archive_id = _expected_prints.pop(key, None)
  160. _expected_print_registered_at.pop(key, None)
  161. if expected_archive_id:
  162. for other_key in expected_keys:
  163. _expected_prints.pop(other_key, None)
  164. _expected_print_registered_at.pop(other_key, None)
  165. break
  166. if expected_archive_id:
  167. _active_prints[(printer_id, archive_filename)] = expected_archive_id
  168. if subtask_name:
  169. _active_prints[(printer_id, f"{subtask_name}.3mf")] = expected_archive_id
  170. return expected_archive_id
  171. def test_promotion_populates_active_prints(self):
  172. """After promotion, archive is in _active_prints."""
  173. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  174. archive_id = self._simulate_expected_print_promotion(
  175. printer_id=1,
  176. subtask_name="Box",
  177. filename="Box.gcode",
  178. archive_filename="Box.3mf",
  179. )
  180. assert archive_id == 54
  181. assert _active_prints[(1, "Box.3mf")] == 54
  182. def test_promotion_cleans_up_expected_prints(self):
  183. """After promotion, _expected_prints is empty for this print."""
  184. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  185. self._simulate_expected_print_promotion(
  186. printer_id=1,
  187. subtask_name="Box",
  188. filename="Box.gcode",
  189. archive_filename="Box.3mf",
  190. )
  191. # All variants should be cleaned up
  192. assert (1, "Box.3mf") not in _expected_prints
  193. assert (1, "Box") not in _expected_prints
  194. assert (1, "Box.gcode") not in _expected_prints
  195. def test_ams_mapping_survives_promotion(self):
  196. """_print_ams_mappings is NOT consumed during promotion — it's needed at completion."""
  197. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  198. self._simulate_expected_print_promotion(
  199. printer_id=1,
  200. subtask_name="Box",
  201. filename="Box.gcode",
  202. archive_filename="Box.3mf",
  203. )
  204. # ams_mapping should still be available for on_print_complete
  205. assert _print_ams_mappings[54] == [1]
  206. def test_completion_lookup_finds_promoted_archive(self):
  207. """Simulate on_print_complete finding the archive in _active_prints."""
  208. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  209. self._simulate_expected_print_promotion(
  210. printer_id=1,
  211. subtask_name="Box",
  212. filename="Box.gcode",
  213. archive_filename="Box.3mf",
  214. )
  215. # Simulate on_print_complete key building
  216. completion_keys = [
  217. (1, "Box.3mf"),
  218. (1, "Box.gcode.3mf"),
  219. (1, "Box"),
  220. ]
  221. found_id = None
  222. for key in completion_keys:
  223. found_id = _active_prints.pop(key, None)
  224. if found_id:
  225. break
  226. assert found_id == 54
  227. # And ams_mapping is retrievable
  228. assert _print_ams_mappings.pop(54, None) == [1]
  229. def test_no_promotion_for_external_print(self):
  230. """When no expected print exists, nothing is promoted."""
  231. archive_id = self._simulate_expected_print_promotion(
  232. printer_id=1,
  233. subtask_name="Benchy",
  234. filename="Benchy.gcode",
  235. archive_filename="Benchy.3mf",
  236. )
  237. assert archive_id is None
  238. assert len(_active_prints) == 0
  239. class TestAMSMappingInjection:
  240. """Verify ams_mapping injection into usage tracker session."""
  241. def test_injection_into_session(self):
  242. """ams_mapping from _print_ams_mappings is injectable into a session."""
  243. from datetime import datetime, timezone
  244. from backend.app.services.usage_tracker import PrintSession, _active_sessions
  245. _active_sessions.clear()
  246. # Create a session without ams_mapping (simulates MQTT not providing it)
  247. session = PrintSession(
  248. printer_id=1,
  249. print_name="Box",
  250. started_at=datetime.now(timezone.utc),
  251. tray_remain_start={},
  252. tray_now_at_start=-1,
  253. spool_assignments={},
  254. ams_mapping=None,
  255. )
  256. _active_sessions[1] = session
  257. # Register expected print with ams_mapping
  258. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  259. # Simulate the injection logic from on_print_start
  260. _stored_map = _print_ams_mappings.get(54)
  261. assert _stored_map == [1]
  262. ut_session = _active_sessions.get(1)
  263. assert ut_session is not None
  264. assert ut_session.ams_mapping is None # before injection
  265. ut_session.ams_mapping = _stored_map # injection
  266. assert ut_session.ams_mapping == [1]
  267. _active_sessions.clear()
  268. def test_no_injection_when_session_already_has_mapping(self):
  269. """Don't overwrite existing ams_mapping in session."""
  270. from datetime import datetime, timezone
  271. from backend.app.services.usage_tracker import PrintSession, _active_sessions
  272. _active_sessions.clear()
  273. session = PrintSession(
  274. printer_id=1,
  275. print_name="Box",
  276. started_at=datetime.now(timezone.utc),
  277. tray_remain_start={},
  278. tray_now_at_start=-1,
  279. spool_assignments={},
  280. ams_mapping=[5, 6], # already has mapping from MQTT
  281. )
  282. _active_sessions[1] = session
  283. register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
  284. _stored_map = _print_ams_mappings.get(54)
  285. ut_session = _active_sessions.get(1)
  286. # Guard: don't overwrite if session already has a mapping
  287. if ut_session and not ut_session.ams_mapping:
  288. ut_session.ams_mapping = _stored_map
  289. assert ut_session.ams_mapping == [5, 6] # unchanged
  290. _active_sessions.clear()
  291. class TestPlateIdInjection:
  292. """Verify plate_id injection into usage tracker session for direct-Print of
  293. a non-first plate from a multi-plate 3MF (#1697)."""
  294. def test_injection_into_session(self):
  295. """plate_id from _print_plate_ids gets injected when session has none."""
  296. from datetime import datetime, timezone
  297. from backend.app.services.usage_tracker import PrintSession, _active_sessions
  298. _active_sessions.clear()
  299. # Session created by on_print_start before expected-print promotion;
  300. # plate_id is None because no queue item was found (direct-Print path).
  301. session = PrintSession(
  302. printer_id=1,
  303. print_name="Box",
  304. started_at=datetime.now(timezone.utc),
  305. tray_remain_start={},
  306. tray_now_at_start=-1,
  307. spool_assignments={},
  308. ams_mapping=None,
  309. plate_id=None,
  310. )
  311. _active_sessions[1] = session
  312. register_expected_print(1, "Box.3mf", archive_id=54, plate_id=2)
  313. # Mirror the injection branch from main.py.
  314. _stored_plate_id = _print_plate_ids.get(54)
  315. assert _stored_plate_id == 2
  316. ut_session = _active_sessions.get(1)
  317. assert ut_session is not None
  318. assert ut_session.plate_id is None # before injection
  319. ut_session.plate_id = _stored_plate_id # injection
  320. assert ut_session.plate_id == 2
  321. _active_sessions.clear()
  322. def test_no_injection_when_session_already_has_plate_id(self):
  323. """Queue path: on_print_start already captured plate_id from queue_item;
  324. don't overwrite with the dict value."""
  325. from datetime import datetime, timezone
  326. from backend.app.services.usage_tracker import PrintSession, _active_sessions
  327. _active_sessions.clear()
  328. session = PrintSession(
  329. printer_id=1,
  330. print_name="Box",
  331. started_at=datetime.now(timezone.utc),
  332. tray_remain_start={},
  333. tray_now_at_start=-1,
  334. spool_assignments={},
  335. ams_mapping=None,
  336. plate_id=3, # captured from queue_item by on_print_start
  337. )
  338. _active_sessions[1] = session
  339. register_expected_print(1, "Box.3mf", archive_id=54, plate_id=2)
  340. _stored_plate_id = _print_plate_ids.get(54)
  341. ut_session = _active_sessions.get(1)
  342. # Guard: don't overwrite if session already has a plate_id
  343. if ut_session and ut_session.plate_id is None:
  344. ut_session.plate_id = _stored_plate_id
  345. assert ut_session.plate_id == 3 # queue value preserved
  346. _active_sessions.clear()