test_archive_service.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940
  1. """Unit tests for the archive service."""
  2. from datetime import datetime
  3. class TestArchiveServiceHelpers:
  4. """Tests for archive service helper functions."""
  5. def test_parse_print_time_seconds(self):
  6. """Test parsing print time to seconds."""
  7. # Import the actual function if available, otherwise test the logic
  8. # 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds
  9. _time_str = "2h 30m 15s" # Example format
  10. # Parse hours
  11. hours = 2
  12. minutes = 30
  13. seconds = 15
  14. total = hours * 3600 + minutes * 60 + seconds
  15. assert total == 9015
  16. def test_parse_filament_grams(self):
  17. """Test parsing filament usage to grams."""
  18. # Example: "150.5g" -> 150.5
  19. filament_str = "150.5g"
  20. grams = float(filament_str.replace("g", ""))
  21. assert grams == 150.5
  22. def test_format_duration(self):
  23. """Test formatting seconds to human readable duration."""
  24. # 3661 seconds = 1h 1m 1s
  25. seconds = 3661
  26. hours = seconds // 3600
  27. minutes = (seconds % 3600) // 60
  28. secs = seconds % 60
  29. assert hours == 1
  30. assert minutes == 1
  31. assert secs == 1
  32. class TestArchiveDataParsing:
  33. """Tests for parsing archive data from MQTT messages."""
  34. def test_parse_gcode_state(self):
  35. """Test parsing gcode state."""
  36. states = {
  37. "RUNNING": "printing",
  38. "FINISH": "completed",
  39. "FAILED": "failed",
  40. "IDLE": "idle",
  41. "PAUSE": "paused",
  42. }
  43. for gcode_state, expected in states.items():
  44. # Simple state mapping
  45. mapped = gcode_state.lower()
  46. if gcode_state == "RUNNING":
  47. mapped = "printing"
  48. elif gcode_state == "FINISH":
  49. mapped = "completed"
  50. elif gcode_state == "FAILED":
  51. mapped = "failed"
  52. elif gcode_state == "IDLE":
  53. mapped = "idle"
  54. elif gcode_state == "PAUSE":
  55. mapped = "paused"
  56. assert mapped == expected
  57. def test_parse_progress(self):
  58. """Test parsing print progress."""
  59. # mc_percent is the progress field in MQTT messages
  60. data = {"mc_percent": 75}
  61. progress = data.get("mc_percent", 0)
  62. assert progress == 75
  63. assert 0 <= progress <= 100
  64. def test_parse_layer_info(self):
  65. """Test parsing layer information."""
  66. data = {
  67. "layer_num": 50,
  68. "total_layers": 200,
  69. }
  70. current_layer = data.get("layer_num", 0)
  71. total_layers = data.get("total_layers", 0)
  72. assert current_layer == 50
  73. assert total_layers == 200
  74. if total_layers > 0:
  75. layer_percent = (current_layer / total_layers) * 100
  76. assert layer_percent == 25.0
  77. class TestArchiveFilePaths:
  78. """Tests for archive file path handling."""
  79. def test_generate_archive_path(self):
  80. """Test generating archive file paths."""
  81. printer_name = "X1C_01"
  82. _print_name = "benchy" # Example print name
  83. timestamp = datetime(2024, 1, 15, 14, 30, 0)
  84. # Expected pattern: archives/{printer}/{year}/{month}/{filename}
  85. year = timestamp.year
  86. month = f"{timestamp.month:02d}"
  87. expected_dir = f"archives/{printer_name}/{year}/{month}"
  88. assert "archives" in expected_dir
  89. assert printer_name in expected_dir
  90. assert str(year) in expected_dir
  91. def test_sanitize_filename(self):
  92. """Test filename sanitization."""
  93. # Characters to remove: / \ : * ? " < > |
  94. dirty_name = "test:file<name>.3mf"
  95. # Simple sanitization
  96. safe_chars = []
  97. for c in dirty_name:
  98. if c not in '\\/:*?"<>|':
  99. safe_chars.append(c)
  100. clean_name = "".join(safe_chars)
  101. assert ":" not in clean_name
  102. assert "<" not in clean_name
  103. assert ">" not in clean_name
  104. def test_thumbnail_path(self):
  105. """Test thumbnail path generation."""
  106. archive_path = "archives/X1C_01/2024/01/benchy.3mf"
  107. # Thumbnail typically has same path with _thumb.png suffix
  108. base_path = archive_path.rsplit(".", 1)[0]
  109. thumbnail_path = f"{base_path}_thumb.png"
  110. assert thumbnail_path.endswith("_thumb.png")
  111. assert "benchy" in thumbnail_path
  112. class TestArchiveStatus:
  113. """Tests for archive status handling."""
  114. def test_valid_status_values(self):
  115. """Test valid archive status values."""
  116. valid_statuses = ["completed", "failed", "cancelled", "stopped"]
  117. for status in valid_statuses:
  118. assert status in valid_statuses
  119. def test_status_from_gcode_state(self):
  120. """Test mapping gcode state to archive status."""
  121. state_mapping = {
  122. "FINISH": "completed",
  123. "FAILED": "failed",
  124. "CANCEL": "cancelled",
  125. }
  126. for gcode_state, expected_status in state_mapping.items():
  127. assert state_mapping[gcode_state] == expected_status
  128. class TestArchiveFilamentData:
  129. """Tests for filament data parsing."""
  130. def test_parse_ams_filament(self):
  131. """Test parsing AMS filament information."""
  132. ams_data = {
  133. "ams": {
  134. "ams": [
  135. {
  136. "tray": [
  137. {"tray_type": "PLA", "tray_color": "FF0000"},
  138. {"tray_type": "PETG", "tray_color": "00FF00"},
  139. ]
  140. }
  141. ]
  142. }
  143. }
  144. trays = ams_data["ams"]["ams"][0]["tray"]
  145. assert trays[0]["tray_type"] == "PLA"
  146. assert trays[1]["tray_type"] == "PETG"
  147. def test_parse_filament_color_hex(self):
  148. """Test parsing filament color from hex."""
  149. color_hex = "FF5500"
  150. # Should be valid hex
  151. assert len(color_hex) == 6
  152. r = int(color_hex[0:2], 16)
  153. g = int(color_hex[2:4], 16)
  154. b = int(color_hex[4:6], 16)
  155. assert r == 255
  156. assert g == 85
  157. assert b == 0
  158. def test_calculate_filament_cost(self):
  159. """Test calculating filament cost."""
  160. grams_used = 150.0
  161. cost_per_kg = 25.0 # $25 per kg
  162. cost = (grams_used / 1000) * cost_per_kg
  163. assert cost == 3.75
  164. class TestArchiveThumbnails:
  165. """Tests for archive thumbnail handling."""
  166. def test_thumbnail_file_types(self):
  167. """Test supported thumbnail file types."""
  168. supported_types = [".png", ".jpg", ".jpeg"]
  169. for ext in supported_types:
  170. assert ext.startswith(".")
  171. assert ext.lower() in [".png", ".jpg", ".jpeg"]
  172. def test_extract_thumbnail_from_3mf(self):
  173. """Test thumbnail extraction concept from 3MF."""
  174. # 3MF files are ZIP archives containing:
  175. # - Metadata/thumbnail.png
  176. # - 3D/3dmodel.model
  177. expected_thumbnail_paths = [
  178. "Metadata/thumbnail.png",
  179. "Metadata/plate_1.png",
  180. ]
  181. for path in expected_thumbnail_paths:
  182. assert "png" in path.lower()
  183. def test_extract_thumbnail_falls_back_to_auxiliaries(self, tmp_path):
  184. """#1493 follow-up: when BambuStudio's CLI runs with --arrange it
  185. rearranges objects but doesn't always emit a fresh
  186. ``Metadata/plate_N.png`` for the rearranged plate. The project-wide
  187. thumbnail under ``Auxiliaries/.thumbnails/`` survives though, and
  188. we use it as a cover-image fallback so re-sliced archive cards
  189. still render with a thumbnail."""
  190. import zipfile
  191. from backend.app.services.archive import ThreeMFParser
  192. threemf_path = tmp_path / "sliced.3mf"
  193. with zipfile.ZipFile(threemf_path, "w") as zf:
  194. zf.writestr("3D/3dmodel.model", "<model/>")
  195. # No Metadata/plate_1.png / thumbnail.png — only the
  196. # Auxiliaries project-wide thumbnail (what arranged slices
  197. # carry in practice).
  198. zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"PNGMIDDLE")
  199. parser = ThreeMFParser(str(threemf_path), plate_number=1)
  200. parsed = parser.parse()
  201. assert parsed.get("_thumbnail_data") == b"PNGMIDDLE"
  202. assert parsed.get("_thumbnail_ext") == ".png"
  203. def test_per_plate_png_wins_over_auxiliaries_fallback(self, tmp_path):
  204. """Order matters: when BOTH the per-plate preview and the
  205. Auxiliaries fallback are present, the per-plate one wins because
  206. it reflects the actual sliced layout."""
  207. import zipfile
  208. from backend.app.services.archive import ThreeMFParser
  209. threemf_path = tmp_path / "sliced.3mf"
  210. with zipfile.ZipFile(threemf_path, "w") as zf:
  211. zf.writestr("3D/3dmodel.model", "<model/>")
  212. zf.writestr("Metadata/plate_1.png", b"PLATE1")
  213. zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"PROJECT_WIDE")
  214. parser = ThreeMFParser(str(threemf_path), plate_number=1)
  215. parsed = parser.parse()
  216. assert parsed.get("_thumbnail_data") == b"PLATE1"
  217. class TestThreeMFMetadataHTMLUnescape:
  218. """3MF `<metadata name="Title">…</metadata>` values are XML-encoded.
  219. BambuStudio sometimes writes triple-encoded payloads (the
  220. ProjectPageParser comment documents this). Without an unescape loop,
  221. a Title like ``Foo & Bar`` lands in the DB as raw ``Foo &amp; Bar`` and
  222. React then escapes the `&` on render to ``Foo &amp;amp; Bar`` — the
  223. user-visible symptom reported on #1658."""
  224. def test_title_with_ampersand_is_unescaped(self, tmp_path):
  225. import zipfile
  226. from backend.app.services.archive import ThreeMFParser
  227. threemf_path = tmp_path / "ampersand.3mf"
  228. with zipfile.ZipFile(threemf_path, "w") as zf:
  229. zf.writestr(
  230. "3D/3dmodel.model",
  231. '<?xml version="1.0" encoding="UTF-8"?>\n'
  232. '<model><metadata name="Title">PCB Vise &amp; Solder Station</metadata>'
  233. '<metadata name="Designer">Chefkoch</metadata></model>',
  234. )
  235. parsed = ThreeMFParser(str(threemf_path)).parse()
  236. assert parsed.get("print_name") == "PCB Vise & Solder Station"
  237. assert parsed.get("designer") == "Chefkoch"
  238. def test_title_with_triple_encoded_ampersand_is_fully_unescaped(self, tmp_path):
  239. """BambuStudio has been observed writing triple-encoded payloads
  240. (`&amp;amp;amp;`). The decoder loops until the string stops changing
  241. so all layers get peeled in one pass."""
  242. import zipfile
  243. from backend.app.services.archive import ThreeMFParser
  244. threemf_path = tmp_path / "triple.3mf"
  245. with zipfile.ZipFile(threemf_path, "w") as zf:
  246. zf.writestr(
  247. "3D/3dmodel.model",
  248. '<?xml version="1.0" encoding="UTF-8"?>\n'
  249. '<model><metadata name="Title">Foo &amp;amp;amp; Bar</metadata></model>',
  250. )
  251. parsed = ThreeMFParser(str(threemf_path)).parse()
  252. assert parsed.get("print_name") == "Foo & Bar"
  253. def test_title_without_entities_passes_through_unchanged(self, tmp_path):
  254. """The unescape loop must be a no-op when there's nothing to unescape —
  255. regression guard against accidentally munging plain ASCII titles."""
  256. import zipfile
  257. from backend.app.services.archive import ThreeMFParser
  258. threemf_path = tmp_path / "plain.3mf"
  259. with zipfile.ZipFile(threemf_path, "w") as zf:
  260. zf.writestr(
  261. "3D/3dmodel.model",
  262. '<?xml version="1.0" encoding="UTF-8"?>\n<model><metadata name="Title">Benchy</metadata></model>',
  263. )
  264. parsed = ThreeMFParser(str(threemf_path)).parse()
  265. assert parsed.get("print_name") == "Benchy"
  266. class TestPrintableObjectsExtraction:
  267. """Tests for extracting printable objects count from 3MF files."""
  268. def test_extract_printable_objects_from_slice_info(self):
  269. """Test parsing printable objects from slice_info.config XML."""
  270. from defusedxml import ElementTree as ET
  271. # Example slice_info.config content with 4 objects
  272. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  273. <config>
  274. <plate plate_idx="1">
  275. <metadata key="prediction" value="3600" />
  276. <metadata key="weight" value="50.5" />
  277. <object identify_id="1" name="Part_A" skipped="false" />
  278. <object identify_id="2" name="Part_B" skipped="false" />
  279. <object identify_id="3" name="Part_C" skipped="false" />
  280. <object identify_id="4" name="Part_D" skipped="true" />
  281. </plate>
  282. </config>
  283. """
  284. root = ET.fromstring(slice_info_xml)
  285. plate = root.find(".//plate")
  286. # Count non-skipped objects (should be 3, not 4)
  287. count = 0
  288. for obj in plate.findall("object"):
  289. skipped = obj.get("skipped", "false")
  290. if skipped.lower() != "true":
  291. count += 1
  292. assert count == 3 # 3 objects (Part_D is skipped)
  293. def test_extract_printable_objects_empty_plate(self):
  294. """Test handling plate with no objects."""
  295. from defusedxml import ElementTree as ET
  296. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  297. <config>
  298. <plate plate_idx="1">
  299. <metadata key="prediction" value="0" />
  300. </plate>
  301. </config>
  302. """
  303. root = ET.fromstring(slice_info_xml)
  304. plate = root.find(".//plate")
  305. count = 0
  306. for obj in plate.findall("object"):
  307. skipped = obj.get("skipped", "false")
  308. if skipped.lower() != "true":
  309. count += 1
  310. assert count == 0
  311. def test_extract_printable_objects_all_skipped(self):
  312. """Test handling plate where all objects are skipped."""
  313. from defusedxml import ElementTree as ET
  314. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  315. <config>
  316. <plate plate_idx="1">
  317. <object identify_id="1" name="Part_A" skipped="true" />
  318. <object identify_id="2" name="Part_B" skipped="true" />
  319. </plate>
  320. </config>
  321. """
  322. root = ET.fromstring(slice_info_xml)
  323. plate = root.find(".//plate")
  324. count = 0
  325. for obj in plate.findall("object"):
  326. skipped = obj.get("skipped", "false")
  327. if skipped.lower() != "true":
  328. count += 1
  329. assert count == 0 # All objects skipped
  330. class TestThreeMFPlateIndexExtraction:
  331. """Tests for extracting plate index from multi-plate 3MF exports (Issue #92)."""
  332. def test_extract_plate_index_from_slice_info(self):
  333. """Test parsing plate index from slice_info.config metadata."""
  334. from defusedxml import ElementTree as ET
  335. # Single-plate export from plate 5 of a multi-plate project
  336. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  337. <config>
  338. <plate>
  339. <metadata key="index" value="5" />
  340. <metadata key="prediction" value="3600" />
  341. <metadata key="weight" value="50.5" />
  342. <object identify_id="1" name="Part_A" skipped="false" />
  343. </plate>
  344. </config>
  345. """
  346. root = ET.fromstring(slice_info_xml)
  347. plate = root.find(".//plate")
  348. plate_index = None
  349. for meta in plate.findall("metadata"):
  350. if meta.get("key") == "index":
  351. plate_index = int(meta.get("value"))
  352. break
  353. assert plate_index == 5
  354. def test_extract_plate_index_plate_1(self):
  355. """Test parsing plate index when it's plate 1."""
  356. from defusedxml import ElementTree as ET
  357. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  358. <config>
  359. <plate>
  360. <metadata key="index" value="1" />
  361. <metadata key="prediction" value="1800" />
  362. </plate>
  363. </config>
  364. """
  365. root = ET.fromstring(slice_info_xml)
  366. plate = root.find(".//plate")
  367. plate_index = None
  368. for meta in plate.findall("metadata"):
  369. if meta.get("key") == "index":
  370. plate_index = int(meta.get("value"))
  371. break
  372. assert plate_index == 1
  373. def test_thumbnail_path_uses_plate_number(self):
  374. """Test that thumbnail path correctly uses the extracted plate number."""
  375. plate_number = 5
  376. thumbnail_paths = []
  377. if plate_number:
  378. thumbnail_paths.append(f"Metadata/plate_{plate_number}.png")
  379. thumbnail_paths.extend(
  380. [
  381. "Metadata/plate_1.png",
  382. "Metadata/thumbnail.png",
  383. ]
  384. )
  385. # First priority should be plate_5.png
  386. assert thumbnail_paths[0] == "Metadata/plate_5.png"
  387. @staticmethod
  388. def _enhance_print_name(print_name: str, plate_index: int) -> str:
  389. """Apply plate name enhancement logic from archive.py."""
  390. if plate_index and plate_index > 1:
  391. if print_name and f"Plate {plate_index}" not in print_name:
  392. print_name = f"{print_name} - Plate {plate_index}"
  393. return print_name
  394. def test_print_name_enhanced_for_plate_greater_than_1(self):
  395. """Test that print_name is enhanced with plate info for plate > 1."""
  396. assert self._enhance_print_name("Benchy", 5) == "Benchy - Plate 5"
  397. def test_print_name_not_enhanced_for_plate_1(self):
  398. """Test that print_name is NOT enhanced for plate 1."""
  399. assert self._enhance_print_name("Benchy", 1) == "Benchy"
  400. def test_print_name_not_duplicated(self):
  401. """Test that plate info is not added if already present in print_name."""
  402. assert self._enhance_print_name("Benchy - Plate 5", 5) == "Benchy - Plate 5"
  403. def test_high_plate_number_extraction(self):
  404. """Test extracting high plate numbers (e.g., plate 28)."""
  405. from defusedxml import ElementTree as ET
  406. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  407. <config>
  408. <plate>
  409. <metadata key="index" value="28" />
  410. <metadata key="prediction" value="7200" />
  411. </plate>
  412. </config>
  413. """
  414. root = ET.fromstring(slice_info_xml)
  415. plate = root.find(".//plate")
  416. plate_index = None
  417. for meta in plate.findall("metadata"):
  418. if meta.get("key") == "index":
  419. plate_index = int(meta.get("value"))
  420. break
  421. assert plate_index == 28
  422. # Verify thumbnail would use correct plate
  423. thumbnail_path = f"Metadata/plate_{plate_index}.png"
  424. assert thumbnail_path == "Metadata/plate_28.png"
  425. class TestMultiPlate3MFParsing:
  426. """Tests for parsing multi-plate 3MF files (Issue #93)."""
  427. def test_parse_multiple_plates_from_slice_info(self):
  428. """Test parsing multiple plates from slice_info.config."""
  429. from defusedxml import ElementTree as ET
  430. # Multi-plate 3MF with 3 plates
  431. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  432. <config>
  433. <plate>
  434. <metadata key="index" value="1" />
  435. <metadata key="prediction" value="3600" />
  436. <metadata key="weight" value="50.0" />
  437. <filament id="1" type="PLA" color="#FF0000" used_g="25.0" used_m="8.5" />
  438. <object identify_id="1" name="Part_A" skipped="false" />
  439. </plate>
  440. <plate>
  441. <metadata key="index" value="2" />
  442. <metadata key="prediction" value="7200" />
  443. <metadata key="weight" value="100.0" />
  444. <filament id="2" type="PETG" color="#00FF00" used_g="50.0" used_m="17.0" />
  445. <object identify_id="2" name="Part_B" skipped="false" />
  446. </plate>
  447. <plate>
  448. <metadata key="index" value="3" />
  449. <metadata key="prediction" value="1800" />
  450. <metadata key="weight" value="25.0" />
  451. <filament id="1" type="PLA" color="#FF0000" used_g="12.5" used_m="4.2" />
  452. <filament id="3" type="TPU" color="#0000FF" used_g="12.5" used_m="4.2" />
  453. <object identify_id="3" name="Part_C" skipped="false" />
  454. </plate>
  455. </config>
  456. """
  457. root = ET.fromstring(slice_info_xml)
  458. plates = root.findall(".//plate")
  459. assert len(plates) == 3
  460. # Parse each plate
  461. plate_data = []
  462. for plate_elem in plates:
  463. plate_info = {"index": None, "filaments": []}
  464. for meta in plate_elem.findall("metadata"):
  465. if meta.get("key") == "index":
  466. plate_info["index"] = int(meta.get("value"))
  467. for filament_elem in plate_elem.findall("filament"):
  468. used_g = float(filament_elem.get("used_g", "0"))
  469. if used_g > 0:
  470. plate_info["filaments"].append(
  471. {
  472. "slot_id": int(filament_elem.get("id")),
  473. "type": filament_elem.get("type"),
  474. "color": filament_elem.get("color"),
  475. "used_grams": used_g,
  476. }
  477. )
  478. plate_data.append(plate_info)
  479. # Verify plate 1
  480. assert plate_data[0]["index"] == 1
  481. assert len(plate_data[0]["filaments"]) == 1
  482. assert plate_data[0]["filaments"][0]["slot_id"] == 1
  483. assert plate_data[0]["filaments"][0]["type"] == "PLA"
  484. # Verify plate 2
  485. assert plate_data[1]["index"] == 2
  486. assert len(plate_data[1]["filaments"]) == 1
  487. assert plate_data[1]["filaments"][0]["slot_id"] == 2
  488. assert plate_data[1]["filaments"][0]["type"] == "PETG"
  489. # Verify plate 3 (has 2 filaments)
  490. assert plate_data[2]["index"] == 3
  491. assert len(plate_data[2]["filaments"]) == 2
  492. filament_types = {f["type"] for f in plate_data[2]["filaments"]}
  493. assert filament_types == {"PLA", "TPU"}
  494. def test_filter_filaments_by_plate_id(self):
  495. """Test filtering filaments for a specific plate."""
  496. from defusedxml import ElementTree as ET
  497. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  498. <config>
  499. <plate>
  500. <metadata key="index" value="1" />
  501. <filament id="1" type="PLA" color="#FF0000" used_g="25.0" />
  502. </plate>
  503. <plate>
  504. <metadata key="index" value="2" />
  505. <filament id="2" type="PETG" color="#00FF00" used_g="50.0" />
  506. </plate>
  507. </config>
  508. """
  509. root = ET.fromstring(slice_info_xml)
  510. # Filter for plate 2 only
  511. target_plate_id = 2
  512. filaments = []
  513. for plate_elem in root.findall(".//plate"):
  514. plate_index = None
  515. for meta in plate_elem.findall("metadata"):
  516. if meta.get("key") == "index":
  517. plate_index = int(meta.get("value", "0"))
  518. break
  519. if plate_index == target_plate_id:
  520. for filament_elem in plate_elem.findall("filament"):
  521. used_g = float(filament_elem.get("used_g", "0"))
  522. if used_g > 0:
  523. filaments.append(
  524. {
  525. "slot_id": int(filament_elem.get("id")),
  526. "type": filament_elem.get("type"),
  527. }
  528. )
  529. break
  530. # Should only have plate 2's filament
  531. assert len(filaments) == 1
  532. assert filaments[0]["slot_id"] == 2
  533. assert filaments[0]["type"] == "PETG"
  534. def test_detect_multi_plate_from_gcode_files(self):
  535. """Test detecting multiple plates from gcode file presence."""
  536. # Simulate namelist from a multi-plate 3MF
  537. namelist = [
  538. "Metadata/plate_1.gcode",
  539. "Metadata/plate_2.gcode",
  540. "Metadata/plate_3.gcode",
  541. "Metadata/plate_1.png",
  542. "Metadata/plate_2.png",
  543. "Metadata/plate_3.png",
  544. "Metadata/slice_info.config",
  545. "3D/3dmodel.model",
  546. ]
  547. # Extract plate indices from gcode files
  548. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  549. plate_indices = []
  550. for gf in gcode_files:
  551. plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
  552. plate_indices.append(int(plate_str))
  553. plate_indices.sort()
  554. assert len(plate_indices) == 3
  555. assert plate_indices == [1, 2, 3]
  556. # Verify it's a multi-plate file
  557. is_multi_plate = len(plate_indices) > 1
  558. assert is_multi_plate is True
  559. def test_single_plate_export_not_multi_plate(self):
  560. """Test that single-plate exports are not detected as multi-plate."""
  561. # Simulate namelist from a single-plate export (plate 5 only)
  562. namelist = [
  563. "Metadata/plate_5.gcode",
  564. "Metadata/plate_1.png",
  565. "Metadata/plate_2.png",
  566. "Metadata/plate_3.png",
  567. "Metadata/plate_4.png",
  568. "Metadata/plate_5.png", # All thumbnails present
  569. "Metadata/slice_info.config",
  570. "3D/3dmodel.model",
  571. ]
  572. # Extract plate indices from gcode files (not thumbnails!)
  573. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  574. plate_indices = []
  575. for gf in gcode_files:
  576. plate_str = gf[15:-6]
  577. plate_indices.append(int(plate_str))
  578. # Only one gcode file = single plate export
  579. assert len(plate_indices) == 1
  580. assert plate_indices[0] == 5
  581. is_multi_plate = len(plate_indices) > 1
  582. assert is_multi_plate is False
  583. class TestReprintCostCalculation:
  584. """Tests for reprint cost calculation."""
  585. def test_cost_addition_logic(self):
  586. """Test that reprint costs are added correctly."""
  587. # Simulate the cost addition logic
  588. existing_cost = 5.25 # Original print cost
  589. filament_grams = 100.0
  590. cost_per_kg = 25.0 # Default cost
  591. # Calculate additional cost for reprint
  592. additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  593. assert additional_cost == 2.50
  594. # Add to existing cost
  595. new_total = round(existing_cost + additional_cost, 2)
  596. assert new_total == 7.75
  597. def test_cost_addition_with_none_existing(self):
  598. """Test cost addition when existing cost is None."""
  599. existing_cost = None
  600. filament_grams = 200.0
  601. cost_per_kg = 15.0
  602. additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  603. assert additional_cost == 3.0
  604. # When existing is None, just use additional
  605. new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2)
  606. assert new_total == 3.0
  607. def test_cost_with_custom_filament_price(self):
  608. """Test cost calculation with custom filament price."""
  609. filament_grams = 150.0
  610. custom_cost_per_kg = 35.0 # More expensive filament
  611. cost = round((filament_grams / 1000) * custom_cost_per_kg, 2)
  612. assert cost == 5.25
  613. def test_multiple_reprints_accumulate(self):
  614. """Test that multiple reprints accumulate costs correctly."""
  615. filament_grams = 100.0
  616. cost_per_kg = 20.0
  617. single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  618. assert single_print_cost == 2.0
  619. # After 3 prints (1 original + 2 reprints)
  620. total_after_3_prints = round(single_print_cost * 3, 2)
  621. assert total_after_3_prints == 6.0
  622. class TestGcodeHeaderFilamentUsage:
  623. """ThreeMFParser pulls total filament usage from the produced 3MF's G-code
  624. header. Some slicer-sidecar builds leave the X-Filament-Used-* response
  625. headers unset, so the slice would otherwise report "0 g" for a real
  626. multi-hour print."""
  627. @staticmethod
  628. def _make_3mf(gcode_header: str) -> str:
  629. import tempfile
  630. import zipfile
  631. fd, path = tempfile.mkstemp(suffix=".3mf")
  632. import os
  633. os.close(fd)
  634. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  635. zf.writestr("3D/3dmodel.model", "<model/>")
  636. zf.writestr("Metadata/plate_1.gcode", gcode_header + "\nG1 X0 Y0\n")
  637. return path
  638. def test_extracts_filament_weight_and_length_from_header(self):
  639. from backend.app.services.archive import ThreeMFParser
  640. header = (
  641. "; HEADER_BLOCK_START\n"
  642. "; BambuStudio 02.06.00.51\n"
  643. "; total layer number: 503\n"
  644. "; total filament length [mm] : 41661.40\n"
  645. "; total filament volume [cm^3] : 100207.42\n"
  646. "; total filament weight [g] : 126.26\n"
  647. )
  648. meta = ThreeMFParser(self._make_3mf(header)).parse()
  649. assert meta.get("filament_used_grams") == 126.26
  650. assert meta.get("filament_used_mm") == 41661.40
  651. assert meta.get("total_layers") == 503
  652. def test_no_filament_keys_when_header_lacks_them(self):
  653. from backend.app.services.archive import ThreeMFParser
  654. meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
  655. assert "filament_used_grams" not in meta
  656. assert "filament_used_mm" not in meta
  657. class TestMultiPlateSliceInfoSum:
  658. """Multi-plate ``.gcode.3mf`` exports must produce file-level totals that
  659. are the SUM of every plate's prediction + weight, not plate-1 only.
  660. Pre-fix the parser used ``root.find(".//plate")`` and only read the
  661. first plate's metadata, so the archive card and project rollup
  662. under-reported by roughly the number of plates (#1593).
  663. """
  664. @staticmethod
  665. def _make_3mf_with_slice_info(slice_info_xml: str) -> str:
  666. """Write a minimal .3mf with the given slice_info.config payload.
  667. Bambu Studio's slice_info.config is the file the parser reads for
  668. file-level `prediction` / `weight`; the rest of the 3MF members
  669. aren't required for this test.
  670. """
  671. import os
  672. import tempfile
  673. import zipfile
  674. fd, path = tempfile.mkstemp(suffix=".3mf")
  675. os.close(fd)
  676. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  677. zf.writestr("3D/3dmodel.model", "<model/>")
  678. zf.writestr("Metadata/slice_info.config", slice_info_xml)
  679. return path
  680. def test_three_plate_file_sums_prediction_and_weight(self):
  681. """The reporter's case: three plates with distinct prediction +
  682. weight values must yield file-level totals that are the sum.
  683. """
  684. from backend.app.services.archive import ThreeMFParser
  685. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  686. <config>
  687. <plate>
  688. <metadata key="index" value="1" />
  689. <metadata key="prediction" value="7140" />
  690. <metadata key="weight" value="19.2" />
  691. </plate>
  692. <plate>
  693. <metadata key="index" value="2" />
  694. <metadata key="prediction" value="6000" />
  695. <metadata key="weight" value="20.0" />
  696. </plate>
  697. <plate>
  698. <metadata key="index" value="3" />
  699. <metadata key="prediction" value="6300" />
  700. <metadata key="weight" value="18.8" />
  701. </plate>
  702. </config>
  703. """
  704. parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
  705. meta = parser.parse()
  706. assert meta["print_time_seconds"] == 7140 + 6000 + 6300 # 19440
  707. assert meta["filament_used_grams"] == round(19.2 + 20.0 + 18.8, 2) # 58.0
  708. # Multi-plate file: no single plate index should be claimed at the
  709. # file level — the archive represents all plates, not a specific one.
  710. assert parser.plate_number is None
  711. def test_single_plate_file_preserves_plate_index_and_objects(self):
  712. """The single-plate path must still set ``_plate_index`` and pick
  713. up printable objects — these only make sense when the archive
  714. represents exactly one plate.
  715. """
  716. from backend.app.services.archive import ThreeMFParser
  717. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  718. <config>
  719. <plate>
  720. <metadata key="index" value="2" />
  721. <metadata key="prediction" value="3600" />
  722. <metadata key="weight" value="50.5" />
  723. <metadata key="curr_bed_type" value="textured_pei" />
  724. <object identify_id="1" name="Part_A" skipped="false" />
  725. <object identify_id="2" name="Part_B" skipped="true" />
  726. </plate>
  727. </config>
  728. """
  729. parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
  730. meta = parser.parse()
  731. assert meta["print_time_seconds"] == 3600
  732. assert meta["filament_used_grams"] == 50.5
  733. # Single-plate exports surface the plate index via ``plate_number``
  734. # (``_plate_index`` is an internal key cleared at the end of parse).
  735. assert parser.plate_number == 2
  736. assert meta["bed_type"] == "textured_pei"
  737. assert meta["printable_objects"] == {1: "Part_A"}
  738. def test_multi_plate_ignores_per_plate_objects(self):
  739. """Multi-plate exports must NOT carry a single plate's objects at
  740. the file level — the ``/plates`` endpoint surfaces them per-plate.
  741. Conflating them would attach plate-1's parts to the whole archive.
  742. """
  743. from backend.app.services.archive import ThreeMFParser
  744. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  745. <config>
  746. <plate>
  747. <metadata key="index" value="1" />
  748. <metadata key="prediction" value="1000" />
  749. <metadata key="weight" value="10.0" />
  750. <object identify_id="1" name="Part_A" skipped="false" />
  751. </plate>
  752. <plate>
  753. <metadata key="index" value="2" />
  754. <metadata key="prediction" value="1500" />
  755. <metadata key="weight" value="15.0" />
  756. <object identify_id="2" name="Part_B" skipped="false" />
  757. </plate>
  758. </config>
  759. """
  760. parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
  761. meta = parser.parse()
  762. assert meta["print_time_seconds"] == 2500
  763. assert meta["filament_used_grams"] == 25.0
  764. # No archive-level object list when there's more than one plate.
  765. assert "printable_objects" not in meta
  766. assert parser.plate_number is None
  767. def test_missing_or_malformed_values_are_skipped(self):
  768. """A plate with a malformed prediction/weight string must skip
  769. that field, not poison the sum or raise — defensive parsing was
  770. already present per-field; the sum loop must preserve it.
  771. """
  772. from backend.app.services.archive import ThreeMFParser
  773. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  774. <config>
  775. <plate>
  776. <metadata key="prediction" value="100" />
  777. <metadata key="weight" value="not-a-number" />
  778. </plate>
  779. <plate>
  780. <metadata key="prediction" value="200" />
  781. <metadata key="weight" value="5.0" />
  782. </plate>
  783. </config>
  784. """
  785. meta = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)).parse()
  786. assert meta["print_time_seconds"] == 300
  787. # Only the second plate's weight contributed.
  788. assert meta["filament_used_grams"] == 5.0