test_notification_plate_scope.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. """Unit tests for `_scope_notification_archive_data_to_plate` (#1785).
  2. The 3MF parser at services/archive.py:200-264 sums per-plate `prediction` and
  3. `weight` into archive-level totals (#1593) — correct for the archive card's
  4. "whole project" headline, wrong for the completion notification of a single
  5. plate. The helper under test mirrors what the queue UI does at
  6. print_queue.py:272-285: re-read the 3MF and substitute the plate's actual
  7. values for filament grams, time estimate, and per-slot breakdown.
  8. """
  9. import io
  10. import zipfile
  11. from backend.app.main import _scope_notification_archive_data_to_plate
  12. def _write_multi_plate_3mf(tmp_path, name="multi.3mf") -> "tuple":
  13. """Create a 3-plate 3MF with distinct prediction + weight per plate.
  14. Plate 1: 30 min, 50g PLA
  15. Plate 2: 60 min, 120g PETG
  16. Plate 3: 90 min, 200g PLA
  17. """
  18. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  19. <config>
  20. <plate>
  21. <metadata key="index" value="1"/>
  22. <metadata key="prediction" value="1800"/>
  23. <metadata key="weight" value="50"/>
  24. <filament id="1" used_g="50.0" type="PLA" color="#FF0000"/>
  25. </plate>
  26. <plate>
  27. <metadata key="index" value="2"/>
  28. <metadata key="prediction" value="3600"/>
  29. <metadata key="weight" value="120"/>
  30. <filament id="1" used_g="80.0" type="PETG" color="#00FF00"/>
  31. <filament id="2" used_g="40.0" type="PETG" color="#0000FF"/>
  32. </plate>
  33. <plate>
  34. <metadata key="index" value="3"/>
  35. <metadata key="prediction" value="5400"/>
  36. <metadata key="weight" value="200"/>
  37. <filament id="1" used_g="200.0" type="PLA" color="#FF0000"/>
  38. </plate>
  39. </config>
  40. """
  41. buffer = io.BytesIO()
  42. with zipfile.ZipFile(buffer, "w") as zf:
  43. zf.writestr("Metadata/slice_info.config", xml_content)
  44. buffer.seek(0)
  45. file_path = tmp_path / name
  46. file_path.write_bytes(buffer.read())
  47. return file_path, "multi.3mf"
  48. def _project_totals_archive_data() -> dict:
  49. """Pre-fix archive_data as `_background_notifications` constructs it: the
  50. summed-across-plates totals from PrintArchive's columns and extra_data."""
  51. return {
  52. # Summed: 30 + 60 + 90 min = 180 min = 10800s
  53. "print_time_seconds": 10800,
  54. "actual_time_seconds": None,
  55. # Summed: 50 + 120 + 200 = 370g
  56. "actual_filament_grams": 370.0,
  57. # Summed across all 3 plates' filament rows
  58. "filament_slots": [
  59. {"slot_id": 1, "used_g": 330.0, "type": "PLA", "color": "#FF0000"},
  60. {"slot_id": 2, "used_g": 40.0, "type": "PETG", "color": "#0000FF"},
  61. ],
  62. }
  63. class TestScopeNotificationArchiveDataToPlate:
  64. def test_completed_plate_replaces_summed_totals(self, tmp_path):
  65. # The bug: notification shows project totals (370g, 3h) when only
  66. # plate 2 was printed. Expected after fix: plate 2's 120g and 60 min.
  67. file_path, rel = _write_multi_plate_3mf(tmp_path)
  68. archive_data = _project_totals_archive_data()
  69. result = _scope_notification_archive_data_to_plate(
  70. archive_data,
  71. rel,
  72. plate_id=2,
  73. print_status="completed",
  74. progress=100,
  75. base_dir=tmp_path,
  76. )
  77. assert result["actual_filament_grams"] == 120.0
  78. assert result["print_time_seconds"] == 3600
  79. assert result["filament_slots"] == [
  80. {"slot_id": 1, "used_g": 80.0, "type": "PETG", "color": "#00FF00"},
  81. {"slot_id": 2, "used_g": 40.0, "type": "PETG", "color": "#0000FF"},
  82. ]
  83. def test_plate_1_scoping_works(self, tmp_path):
  84. file_path, rel = _write_multi_plate_3mf(tmp_path)
  85. archive_data = _project_totals_archive_data()
  86. result = _scope_notification_archive_data_to_plate(
  87. archive_data,
  88. rel,
  89. plate_id=1,
  90. print_status="completed",
  91. progress=100,
  92. base_dir=tmp_path,
  93. )
  94. assert result["actual_filament_grams"] == 50.0
  95. assert result["print_time_seconds"] == 1800
  96. def test_plate_3_scoping_works(self, tmp_path):
  97. file_path, rel = _write_multi_plate_3mf(tmp_path)
  98. archive_data = _project_totals_archive_data()
  99. result = _scope_notification_archive_data_to_plate(
  100. archive_data,
  101. rel,
  102. plate_id=3,
  103. print_status="completed",
  104. progress=100,
  105. base_dir=tmp_path,
  106. )
  107. assert result["actual_filament_grams"] == 200.0
  108. assert result["print_time_seconds"] == 5400
  109. def test_partial_print_scales_plate_values(self, tmp_path):
  110. # Plate 2 cancelled at 50%: expect half the plate's grams + per-slot
  111. # values scaled, but full slicer estimate kept (callers display this
  112. # alongside the partial actual_filament_grams).
  113. file_path, rel = _write_multi_plate_3mf(tmp_path)
  114. archive_data = _project_totals_archive_data()
  115. result = _scope_notification_archive_data_to_plate(
  116. archive_data,
  117. rel,
  118. plate_id=2,
  119. print_status="cancelled",
  120. progress=50,
  121. base_dir=tmp_path,
  122. )
  123. assert result["actual_filament_grams"] == 60.0
  124. assert result["print_time_seconds"] == 3600
  125. assert result["filament_slots"] == [
  126. {"slot_id": 1, "used_g": 40.0, "type": "PETG", "color": "#00FF00"},
  127. {"slot_id": 2, "used_g": 20.0, "type": "PETG", "color": "#0000FF"},
  128. ]
  129. def test_no_plate_id_returns_unchanged(self, tmp_path):
  130. # Single-plate prints or non-plate-scoped completions take the
  131. # project-level archive values as-is.
  132. file_path, rel = _write_multi_plate_3mf(tmp_path)
  133. archive_data = _project_totals_archive_data()
  134. before = {**archive_data, "filament_slots": list(archive_data["filament_slots"])}
  135. result = _scope_notification_archive_data_to_plate(
  136. archive_data,
  137. rel,
  138. plate_id=None,
  139. print_status="completed",
  140. progress=100,
  141. base_dir=tmp_path,
  142. )
  143. assert result["actual_filament_grams"] == before["actual_filament_grams"]
  144. assert result["print_time_seconds"] == before["print_time_seconds"]
  145. assert result["filament_slots"] == before["filament_slots"]
  146. def test_no_file_path_returns_unchanged(self, tmp_path):
  147. archive_data = _project_totals_archive_data()
  148. before = {**archive_data}
  149. result = _scope_notification_archive_data_to_plate(
  150. archive_data,
  151. None,
  152. plate_id=2,
  153. print_status="completed",
  154. progress=100,
  155. base_dir=tmp_path,
  156. )
  157. assert result["actual_filament_grams"] == before["actual_filament_grams"]
  158. assert result["print_time_seconds"] == before["print_time_seconds"]
  159. def test_missing_3mf_returns_unchanged(self, tmp_path):
  160. # Archive's file may have been deleted (manual cleanup) between print
  161. # completion and the notification firing — must not blow up the
  162. # notification, just send the project-level numbers we already have.
  163. archive_data = _project_totals_archive_data()
  164. before = {**archive_data}
  165. result = _scope_notification_archive_data_to_plate(
  166. archive_data,
  167. "missing.3mf",
  168. plate_id=2,
  169. print_status="completed",
  170. progress=100,
  171. base_dir=tmp_path,
  172. )
  173. assert result["actual_filament_grams"] == before["actual_filament_grams"]
  174. assert result["print_time_seconds"] == before["print_time_seconds"]
  175. def test_corrupt_3mf_returns_unchanged(self, tmp_path):
  176. # Invalid file at the right path: helper falls back gracefully.
  177. bad_path = tmp_path / "bad.3mf"
  178. bad_path.write_text("not a zip file")
  179. archive_data = _project_totals_archive_data()
  180. before = {**archive_data}
  181. result = _scope_notification_archive_data_to_plate(
  182. archive_data,
  183. "bad.3mf",
  184. plate_id=2,
  185. print_status="completed",
  186. progress=100,
  187. base_dir=tmp_path,
  188. )
  189. assert result["actual_filament_grams"] == before["actual_filament_grams"]
  190. assert result["print_time_seconds"] == before["print_time_seconds"]
  191. def test_plate_id_outside_range_returns_unchanged(self, tmp_path):
  192. # Defensive: if plate_id doesn't match any plate in the 3MF, leave the
  193. # project-level numbers alone rather than emitting zeros.
  194. file_path, rel = _write_multi_plate_3mf(tmp_path)
  195. archive_data = _project_totals_archive_data()
  196. before = {**archive_data}
  197. result = _scope_notification_archive_data_to_plate(
  198. archive_data,
  199. rel,
  200. plate_id=99,
  201. print_status="completed",
  202. progress=100,
  203. base_dir=tmp_path,
  204. )
  205. assert result["actual_filament_grams"] == before["actual_filament_grams"]
  206. assert result["print_time_seconds"] == before["print_time_seconds"]
  207. def test_zero_grams_plate_keeps_project_level_breakdown(self, tmp_path):
  208. # Defensive: a 3MF that emits per-plate filament rows summing to zero
  209. # (slicer bug / re-slice without estimate) must NOT clobber the
  210. # project-level grams + per-slot breakdown the archive columns already
  211. # provide — otherwise the notification would headline "370 g" next to
  212. # an all-zero per-slot breakdown.
  213. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  214. <config>
  215. <plate>
  216. <metadata key="index" value="1"/>
  217. <metadata key="prediction" value="1800"/>
  218. <metadata key="weight" value="0"/>
  219. <filament id="1" used_g="0" type="PLA" color="#FF0000"/>
  220. </plate>
  221. </config>
  222. """
  223. buffer = io.BytesIO()
  224. with zipfile.ZipFile(buffer, "w") as zf:
  225. zf.writestr("Metadata/slice_info.config", xml_content)
  226. buffer.seek(0)
  227. file_path = tmp_path / "zero.3mf"
  228. file_path.write_bytes(buffer.read())
  229. archive_data = _project_totals_archive_data()
  230. before_slots = list(archive_data["filament_slots"])
  231. before_grams = archive_data["actual_filament_grams"]
  232. result = _scope_notification_archive_data_to_plate(
  233. archive_data,
  234. "zero.3mf",
  235. plate_id=1,
  236. print_status="completed",
  237. progress=100,
  238. base_dir=tmp_path,
  239. )
  240. # Time still scopes (prediction parsed cleanly).
  241. assert result["print_time_seconds"] == 1800
  242. # Grams + per-slot breakdown stay on project-level so the notification
  243. # doesn't ship an inconsistent headline.
  244. assert result["actual_filament_grams"] == before_grams
  245. assert result["filament_slots"] == before_slots
  246. def test_single_plate_file_with_plate_id_1(self, tmp_path):
  247. # Single-plate 3MF where queue still has plate_id=1 set: the parser's
  248. # "sum across plates" already collapses to plate 1's values, so the
  249. # helper just confirms (no double-scaling, no field clobber).
  250. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  251. <config>
  252. <plate>
  253. <metadata key="index" value="1"/>
  254. <metadata key="prediction" value="2400"/>
  255. <metadata key="weight" value="75"/>
  256. <filament id="1" used_g="75.0" type="PLA" color="#0000FF"/>
  257. </plate>
  258. </config>
  259. """
  260. buffer = io.BytesIO()
  261. with zipfile.ZipFile(buffer, "w") as zf:
  262. zf.writestr("Metadata/slice_info.config", xml_content)
  263. buffer.seek(0)
  264. file_path = tmp_path / "single.3mf"
  265. file_path.write_bytes(buffer.read())
  266. archive_data = {
  267. "print_time_seconds": 2400,
  268. "actual_time_seconds": None,
  269. "actual_filament_grams": 75.0,
  270. }
  271. result = _scope_notification_archive_data_to_plate(
  272. archive_data,
  273. "single.3mf",
  274. plate_id=1,
  275. print_status="completed",
  276. progress=100,
  277. base_dir=tmp_path,
  278. )
  279. assert result["actual_filament_grams"] == 75.0
  280. assert result["print_time_seconds"] == 2400