test_archive_service.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184
  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 TestModelMetadataSkipsMeshes:
  267. """The metadata scans stream past ``<mesh>`` bodies instead of reading the
  268. whole model entry: a combined plate keeps hundreds of MB of geometry inline
  269. in 3D/3dmodel.model (#3162). Metadata after ``</build>``, where
  270. BambuStudio writes its MakerWorld fields, must still be found."""
  271. MESH = (
  272. '<mesh><vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/>'
  273. '<vertex x="0" y="1" z="0"/></vertices><triangles><triangle v1="0" v2="1" v3="2"/>'
  274. "</triangles></mesh>"
  275. )
  276. def _model(self) -> str:
  277. return (
  278. '<?xml version="1.0" encoding="UTF-8"?>\n'
  279. '<model><metadata name="Title">Before &amp; mesh</metadata><resources>'
  280. f'<object id="1" type="model">{self.MESH}</object>'
  281. f'<object id="2" type="model">{self.MESH}</object>'
  282. '</resources><build><item objectid="1"/></build>'
  283. '<metadata name="Designer">After build</metadata>'
  284. '<metadata name="CopyRight">https://makerworld.com/en/models/130726</metadata>'
  285. "</model>"
  286. )
  287. def _write(self, tmp_path, model: str):
  288. import zipfile
  289. path = tmp_path / "plate.3mf"
  290. with zipfile.ZipFile(path, "w") as zf:
  291. zf.writestr("3D/3dmodel.model", model)
  292. return path
  293. def test_meshes_are_dropped_and_everything_else_kept(self, tmp_path):
  294. import zipfile
  295. from backend.app.services.archive import read_3dmodel_without_meshes
  296. model = self._model()
  297. with zipfile.ZipFile(self._write(tmp_path, model)) as zf:
  298. text = read_3dmodel_without_meshes(zf)
  299. assert "<vertex" not in text and "<triangle" not in text
  300. assert text == model.replace(self.MESH, "")
  301. def test_tags_split_across_chunks(self, tmp_path, monkeypatch):
  302. """Every chunk size from 1 byte up puts ``<mesh`` and ``</mesh>``
  303. across a boundary somewhere; the result must not change."""
  304. import zipfile
  305. from backend.app.services import archive
  306. model = self._model()
  307. path = self._write(tmp_path, model)
  308. expected = model.replace(self.MESH, "")
  309. for chunk in range(1, 40):
  310. monkeypatch.setattr(archive, "_MODEL_READ_CHUNK", chunk)
  311. with zipfile.ZipFile(path) as zf:
  312. assert archive.read_3dmodel_without_meshes(zf) == expected, chunk
  313. def test_parsers_still_find_metadata_after_the_build(self, tmp_path):
  314. from backend.app.services.archive import ProjectPageParser, ThreeMFParser
  315. path = self._write(tmp_path, self._model())
  316. parsed = ThreeMFParser(str(path)).parse()
  317. assert parsed.get("print_name") == "Before & mesh"
  318. assert parsed.get("designer") == "After build"
  319. assert parsed.get("makerworld_model_id") == "130726"
  320. page = ProjectPageParser(str(path)).parse(archive_id=1)
  321. assert page["title"] == "Before & mesh"
  322. assert page["designer"] == "After build"
  323. class TestPrintableObjectsExtraction:
  324. """Tests for extracting printable objects count from 3MF files."""
  325. def test_extract_printable_objects_from_slice_info(self):
  326. """Test parsing printable objects from slice_info.config XML."""
  327. from defusedxml import ElementTree as ET
  328. # Example slice_info.config content with 4 objects
  329. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  330. <config>
  331. <plate plate_idx="1">
  332. <metadata key="prediction" value="3600" />
  333. <metadata key="weight" value="50.5" />
  334. <object identify_id="1" name="Part_A" skipped="false" />
  335. <object identify_id="2" name="Part_B" skipped="false" />
  336. <object identify_id="3" name="Part_C" skipped="false" />
  337. <object identify_id="4" name="Part_D" skipped="true" />
  338. </plate>
  339. </config>
  340. """
  341. root = ET.fromstring(slice_info_xml)
  342. plate = root.find(".//plate")
  343. # Count non-skipped objects (should be 3, not 4)
  344. count = 0
  345. for obj in plate.findall("object"):
  346. skipped = obj.get("skipped", "false")
  347. if skipped.lower() != "true":
  348. count += 1
  349. assert count == 3 # 3 objects (Part_D is skipped)
  350. def test_extract_printable_objects_empty_plate(self):
  351. """Test handling plate with no objects."""
  352. from defusedxml import ElementTree as ET
  353. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  354. <config>
  355. <plate plate_idx="1">
  356. <metadata key="prediction" value="0" />
  357. </plate>
  358. </config>
  359. """
  360. root = ET.fromstring(slice_info_xml)
  361. plate = root.find(".//plate")
  362. count = 0
  363. for obj in plate.findall("object"):
  364. skipped = obj.get("skipped", "false")
  365. if skipped.lower() != "true":
  366. count += 1
  367. assert count == 0
  368. def test_extract_printable_objects_all_skipped(self):
  369. """Test handling plate where all objects are skipped."""
  370. from defusedxml import ElementTree as ET
  371. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  372. <config>
  373. <plate plate_idx="1">
  374. <object identify_id="1" name="Part_A" skipped="true" />
  375. <object identify_id="2" name="Part_B" skipped="true" />
  376. </plate>
  377. </config>
  378. """
  379. root = ET.fromstring(slice_info_xml)
  380. plate = root.find(".//plate")
  381. count = 0
  382. for obj in plate.findall("object"):
  383. skipped = obj.get("skipped", "false")
  384. if skipped.lower() != "true":
  385. count += 1
  386. assert count == 0 # All objects skipped
  387. class TestThreeMFPlateIndexExtraction:
  388. """Tests for extracting plate index from multi-plate 3MF exports (Issue #92)."""
  389. def test_extract_plate_index_from_slice_info(self):
  390. """Test parsing plate index from slice_info.config metadata."""
  391. from defusedxml import ElementTree as ET
  392. # Single-plate export from plate 5 of a multi-plate project
  393. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  394. <config>
  395. <plate>
  396. <metadata key="index" value="5" />
  397. <metadata key="prediction" value="3600" />
  398. <metadata key="weight" value="50.5" />
  399. <object identify_id="1" name="Part_A" skipped="false" />
  400. </plate>
  401. </config>
  402. """
  403. root = ET.fromstring(slice_info_xml)
  404. plate = root.find(".//plate")
  405. plate_index = None
  406. for meta in plate.findall("metadata"):
  407. if meta.get("key") == "index":
  408. plate_index = int(meta.get("value"))
  409. break
  410. assert plate_index == 5
  411. def test_extract_plate_index_plate_1(self):
  412. """Test parsing plate index when it's plate 1."""
  413. from defusedxml import ElementTree as ET
  414. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  415. <config>
  416. <plate>
  417. <metadata key="index" value="1" />
  418. <metadata key="prediction" value="1800" />
  419. </plate>
  420. </config>
  421. """
  422. root = ET.fromstring(slice_info_xml)
  423. plate = root.find(".//plate")
  424. plate_index = None
  425. for meta in plate.findall("metadata"):
  426. if meta.get("key") == "index":
  427. plate_index = int(meta.get("value"))
  428. break
  429. assert plate_index == 1
  430. def test_thumbnail_path_uses_plate_number(self):
  431. """Test that thumbnail path correctly uses the extracted plate number."""
  432. plate_number = 5
  433. thumbnail_paths = []
  434. if plate_number:
  435. thumbnail_paths.append(f"Metadata/plate_{plate_number}.png")
  436. thumbnail_paths.extend(
  437. [
  438. "Metadata/plate_1.png",
  439. "Metadata/thumbnail.png",
  440. ]
  441. )
  442. # First priority should be plate_5.png
  443. assert thumbnail_paths[0] == "Metadata/plate_5.png"
  444. @staticmethod
  445. def _enhance_print_name(print_name: str, plate_index: int) -> str:
  446. """Apply plate name enhancement logic from archive.py."""
  447. if plate_index and plate_index > 1:
  448. if print_name and f"Plate {plate_index}" not in print_name:
  449. print_name = f"{print_name} - Plate {plate_index}"
  450. return print_name
  451. def test_print_name_enhanced_for_plate_greater_than_1(self):
  452. """Test that print_name is enhanced with plate info for plate > 1."""
  453. assert self._enhance_print_name("Benchy", 5) == "Benchy - Plate 5"
  454. def test_print_name_not_enhanced_for_plate_1(self):
  455. """Test that print_name is NOT enhanced for plate 1."""
  456. assert self._enhance_print_name("Benchy", 1) == "Benchy"
  457. def test_print_name_not_duplicated(self):
  458. """Test that plate info is not added if already present in print_name."""
  459. assert self._enhance_print_name("Benchy - Plate 5", 5) == "Benchy - Plate 5"
  460. def test_high_plate_number_extraction(self):
  461. """Test extracting high plate numbers (e.g., plate 28)."""
  462. from defusedxml import ElementTree as ET
  463. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  464. <config>
  465. <plate>
  466. <metadata key="index" value="28" />
  467. <metadata key="prediction" value="7200" />
  468. </plate>
  469. </config>
  470. """
  471. root = ET.fromstring(slice_info_xml)
  472. plate = root.find(".//plate")
  473. plate_index = None
  474. for meta in plate.findall("metadata"):
  475. if meta.get("key") == "index":
  476. plate_index = int(meta.get("value"))
  477. break
  478. assert plate_index == 28
  479. # Verify thumbnail would use correct plate
  480. thumbnail_path = f"Metadata/plate_{plate_index}.png"
  481. assert thumbnail_path == "Metadata/plate_28.png"
  482. class TestMultiPlate3MFParsing:
  483. """Tests for parsing multi-plate 3MF files (Issue #93)."""
  484. def test_parse_multiple_plates_from_slice_info(self):
  485. """Test parsing multiple plates from slice_info.config."""
  486. from defusedxml import ElementTree as ET
  487. # Multi-plate 3MF with 3 plates
  488. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  489. <config>
  490. <plate>
  491. <metadata key="index" value="1" />
  492. <metadata key="prediction" value="3600" />
  493. <metadata key="weight" value="50.0" />
  494. <filament id="1" type="PLA" color="#FF0000" used_g="25.0" used_m="8.5" />
  495. <object identify_id="1" name="Part_A" skipped="false" />
  496. </plate>
  497. <plate>
  498. <metadata key="index" value="2" />
  499. <metadata key="prediction" value="7200" />
  500. <metadata key="weight" value="100.0" />
  501. <filament id="2" type="PETG" color="#00FF00" used_g="50.0" used_m="17.0" />
  502. <object identify_id="2" name="Part_B" skipped="false" />
  503. </plate>
  504. <plate>
  505. <metadata key="index" value="3" />
  506. <metadata key="prediction" value="1800" />
  507. <metadata key="weight" value="25.0" />
  508. <filament id="1" type="PLA" color="#FF0000" used_g="12.5" used_m="4.2" />
  509. <filament id="3" type="TPU" color="#0000FF" used_g="12.5" used_m="4.2" />
  510. <object identify_id="3" name="Part_C" skipped="false" />
  511. </plate>
  512. </config>
  513. """
  514. root = ET.fromstring(slice_info_xml)
  515. plates = root.findall(".//plate")
  516. assert len(plates) == 3
  517. # Parse each plate
  518. plate_data = []
  519. for plate_elem in plates:
  520. plate_info = {"index": None, "filaments": []}
  521. for meta in plate_elem.findall("metadata"):
  522. if meta.get("key") == "index":
  523. plate_info["index"] = int(meta.get("value"))
  524. for filament_elem in plate_elem.findall("filament"):
  525. used_g = float(filament_elem.get("used_g", "0"))
  526. if used_g > 0:
  527. plate_info["filaments"].append(
  528. {
  529. "slot_id": int(filament_elem.get("id")),
  530. "type": filament_elem.get("type"),
  531. "color": filament_elem.get("color"),
  532. "used_grams": used_g,
  533. }
  534. )
  535. plate_data.append(plate_info)
  536. # Verify plate 1
  537. assert plate_data[0]["index"] == 1
  538. assert len(plate_data[0]["filaments"]) == 1
  539. assert plate_data[0]["filaments"][0]["slot_id"] == 1
  540. assert plate_data[0]["filaments"][0]["type"] == "PLA"
  541. # Verify plate 2
  542. assert plate_data[1]["index"] == 2
  543. assert len(plate_data[1]["filaments"]) == 1
  544. assert plate_data[1]["filaments"][0]["slot_id"] == 2
  545. assert plate_data[1]["filaments"][0]["type"] == "PETG"
  546. # Verify plate 3 (has 2 filaments)
  547. assert plate_data[2]["index"] == 3
  548. assert len(plate_data[2]["filaments"]) == 2
  549. filament_types = {f["type"] for f in plate_data[2]["filaments"]}
  550. assert filament_types == {"PLA", "TPU"}
  551. def test_filter_filaments_by_plate_id(self):
  552. """Test filtering filaments for a specific plate."""
  553. from defusedxml import ElementTree as ET
  554. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  555. <config>
  556. <plate>
  557. <metadata key="index" value="1" />
  558. <filament id="1" type="PLA" color="#FF0000" used_g="25.0" />
  559. </plate>
  560. <plate>
  561. <metadata key="index" value="2" />
  562. <filament id="2" type="PETG" color="#00FF00" used_g="50.0" />
  563. </plate>
  564. </config>
  565. """
  566. root = ET.fromstring(slice_info_xml)
  567. # Filter for plate 2 only
  568. target_plate_id = 2
  569. filaments = []
  570. for plate_elem in root.findall(".//plate"):
  571. plate_index = None
  572. for meta in plate_elem.findall("metadata"):
  573. if meta.get("key") == "index":
  574. plate_index = int(meta.get("value", "0"))
  575. break
  576. if plate_index == target_plate_id:
  577. for filament_elem in plate_elem.findall("filament"):
  578. used_g = float(filament_elem.get("used_g", "0"))
  579. if used_g > 0:
  580. filaments.append(
  581. {
  582. "slot_id": int(filament_elem.get("id")),
  583. "type": filament_elem.get("type"),
  584. }
  585. )
  586. break
  587. # Should only have plate 2's filament
  588. assert len(filaments) == 1
  589. assert filaments[0]["slot_id"] == 2
  590. assert filaments[0]["type"] == "PETG"
  591. def test_detect_multi_plate_from_gcode_files(self):
  592. """Test detecting multiple plates from gcode file presence."""
  593. # Simulate namelist from a multi-plate 3MF
  594. namelist = [
  595. "Metadata/plate_1.gcode",
  596. "Metadata/plate_2.gcode",
  597. "Metadata/plate_3.gcode",
  598. "Metadata/plate_1.png",
  599. "Metadata/plate_2.png",
  600. "Metadata/plate_3.png",
  601. "Metadata/slice_info.config",
  602. "3D/3dmodel.model",
  603. ]
  604. # Extract plate indices from gcode files
  605. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  606. plate_indices = []
  607. for gf in gcode_files:
  608. plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
  609. plate_indices.append(int(plate_str))
  610. plate_indices.sort()
  611. assert len(plate_indices) == 3
  612. assert plate_indices == [1, 2, 3]
  613. # Verify it's a multi-plate file
  614. is_multi_plate = len(plate_indices) > 1
  615. assert is_multi_plate is True
  616. def test_single_plate_export_not_multi_plate(self):
  617. """Test that single-plate exports are not detected as multi-plate."""
  618. # Simulate namelist from a single-plate export (plate 5 only)
  619. namelist = [
  620. "Metadata/plate_5.gcode",
  621. "Metadata/plate_1.png",
  622. "Metadata/plate_2.png",
  623. "Metadata/plate_3.png",
  624. "Metadata/plate_4.png",
  625. "Metadata/plate_5.png", # All thumbnails present
  626. "Metadata/slice_info.config",
  627. "3D/3dmodel.model",
  628. ]
  629. # Extract plate indices from gcode files (not thumbnails!)
  630. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  631. plate_indices = []
  632. for gf in gcode_files:
  633. plate_str = gf[15:-6]
  634. plate_indices.append(int(plate_str))
  635. # Only one gcode file = single plate export
  636. assert len(plate_indices) == 1
  637. assert plate_indices[0] == 5
  638. is_multi_plate = len(plate_indices) > 1
  639. assert is_multi_plate is False
  640. class TestReprintCostCalculation:
  641. """Tests for reprint cost calculation."""
  642. def test_cost_addition_logic(self):
  643. """Test that reprint costs are added correctly."""
  644. # Simulate the cost addition logic
  645. existing_cost = 5.25 # Original print cost
  646. filament_grams = 100.0
  647. cost_per_kg = 25.0 # Default cost
  648. # Calculate additional cost for reprint
  649. additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  650. assert additional_cost == 2.50
  651. # Add to existing cost
  652. new_total = round(existing_cost + additional_cost, 2)
  653. assert new_total == 7.75
  654. def test_cost_addition_with_none_existing(self):
  655. """Test cost addition when existing cost is None."""
  656. existing_cost = None
  657. filament_grams = 200.0
  658. cost_per_kg = 15.0
  659. additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  660. assert additional_cost == 3.0
  661. # When existing is None, just use additional
  662. new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2)
  663. assert new_total == 3.0
  664. def test_cost_with_custom_filament_price(self):
  665. """Test cost calculation with custom filament price."""
  666. filament_grams = 150.0
  667. custom_cost_per_kg = 35.0 # More expensive filament
  668. cost = round((filament_grams / 1000) * custom_cost_per_kg, 2)
  669. assert cost == 5.25
  670. def test_multiple_reprints_accumulate(self):
  671. """Test that multiple reprints accumulate costs correctly."""
  672. filament_grams = 100.0
  673. cost_per_kg = 20.0
  674. single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  675. assert single_print_cost == 2.0
  676. # After 3 prints (1 original + 2 reprints)
  677. total_after_3_prints = round(single_print_cost * 3, 2)
  678. assert total_after_3_prints == 6.0
  679. class TestGcodeHeaderFilamentUsage:
  680. """ThreeMFParser pulls total filament usage from the produced 3MF's G-code
  681. header. Some slicer-sidecar builds leave the X-Filament-Used-* response
  682. headers unset, so the slice would otherwise report "0 g" for a real
  683. multi-hour print."""
  684. @staticmethod
  685. def _make_3mf(gcode_header: str) -> str:
  686. import tempfile
  687. import zipfile
  688. fd, path = tempfile.mkstemp(suffix=".3mf")
  689. import os
  690. os.close(fd)
  691. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  692. zf.writestr("3D/3dmodel.model", "<model/>")
  693. zf.writestr("Metadata/plate_1.gcode", gcode_header + "\nG1 X0 Y0\n")
  694. return path
  695. def test_extracts_filament_weight_and_length_from_header(self):
  696. from backend.app.services.archive import ThreeMFParser
  697. header = (
  698. "; HEADER_BLOCK_START\n"
  699. "; BambuStudio 02.06.00.51\n"
  700. "; total layer number: 503\n"
  701. "; total filament length [mm] : 41661.40\n"
  702. "; total filament volume [cm^3] : 100207.42\n"
  703. "; total filament weight [g] : 126.26\n"
  704. )
  705. meta = ThreeMFParser(self._make_3mf(header)).parse()
  706. assert meta.get("filament_used_grams") == 126.26
  707. assert meta.get("filament_used_mm") == 41661.40
  708. assert meta.get("total_layers") == 503
  709. def test_no_filament_keys_when_header_lacks_them(self):
  710. from backend.app.services.archive import ThreeMFParser
  711. meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
  712. assert "filament_used_grams" not in meta
  713. assert "filament_used_mm" not in meta
  714. class TestMultiPlateSliceInfoSum:
  715. """Multi-plate ``.gcode.3mf`` exports must produce file-level totals that
  716. are the SUM of every plate's prediction + weight, not plate-1 only.
  717. Pre-fix the parser used ``root.find(".//plate")`` and only read the
  718. first plate's metadata, so the archive card and project rollup
  719. under-reported by roughly the number of plates (#1593).
  720. """
  721. @staticmethod
  722. def _make_3mf_with_slice_info(slice_info_xml: str) -> str:
  723. """Write a minimal .3mf with the given slice_info.config payload.
  724. Bambu Studio's slice_info.config is the file the parser reads for
  725. file-level `prediction` / `weight`; the rest of the 3MF members
  726. aren't required for this test.
  727. """
  728. import os
  729. import tempfile
  730. import zipfile
  731. fd, path = tempfile.mkstemp(suffix=".3mf")
  732. os.close(fd)
  733. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  734. zf.writestr("3D/3dmodel.model", "<model/>")
  735. zf.writestr("Metadata/slice_info.config", slice_info_xml)
  736. return path
  737. def test_three_plate_file_sums_prediction_and_weight(self):
  738. """The reporter's case: three plates with distinct prediction +
  739. weight values must yield file-level totals that are the sum.
  740. """
  741. from backend.app.services.archive import ThreeMFParser
  742. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  743. <config>
  744. <plate>
  745. <metadata key="index" value="1" />
  746. <metadata key="prediction" value="7140" />
  747. <metadata key="weight" value="19.2" />
  748. </plate>
  749. <plate>
  750. <metadata key="index" value="2" />
  751. <metadata key="prediction" value="6000" />
  752. <metadata key="weight" value="20.0" />
  753. </plate>
  754. <plate>
  755. <metadata key="index" value="3" />
  756. <metadata key="prediction" value="6300" />
  757. <metadata key="weight" value="18.8" />
  758. </plate>
  759. </config>
  760. """
  761. parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
  762. meta = parser.parse()
  763. assert meta["print_time_seconds"] == 7140 + 6000 + 6300 # 19440
  764. assert meta["filament_used_grams"] == round(19.2 + 20.0 + 18.8, 2) # 58.0
  765. # Multi-plate file: no single plate index should be claimed at the
  766. # file level — the archive represents all plates, not a specific one.
  767. assert parser.plate_number is None
  768. def test_single_plate_file_preserves_plate_index_and_objects(self):
  769. """The single-plate path must still set ``_plate_index`` and pick
  770. up printable objects — these only make sense when the archive
  771. represents exactly one plate.
  772. """
  773. from backend.app.services.archive import ThreeMFParser
  774. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  775. <config>
  776. <plate>
  777. <metadata key="index" value="2" />
  778. <metadata key="prediction" value="3600" />
  779. <metadata key="weight" value="50.5" />
  780. <metadata key="curr_bed_type" value="textured_pei" />
  781. <object identify_id="1" name="Part_A" skipped="false" />
  782. <object identify_id="2" name="Part_B" skipped="true" />
  783. </plate>
  784. </config>
  785. """
  786. parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
  787. meta = parser.parse()
  788. assert meta["print_time_seconds"] == 3600
  789. assert meta["filament_used_grams"] == 50.5
  790. # Single-plate exports surface the plate index via ``plate_number``
  791. # (``_plate_index`` is an internal key cleared at the end of parse).
  792. assert parser.plate_number == 2
  793. assert meta["bed_type"] == "textured_pei"
  794. assert meta["printable_objects"] == {1: "Part_A"}
  795. def test_multi_plate_ignores_per_plate_objects(self):
  796. """Multi-plate exports must NOT carry a single plate's objects at
  797. the file level — the ``/plates`` endpoint surfaces them per-plate.
  798. Conflating them would attach plate-1's parts to the whole archive.
  799. """
  800. from backend.app.services.archive import ThreeMFParser
  801. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  802. <config>
  803. <plate>
  804. <metadata key="index" value="1" />
  805. <metadata key="prediction" value="1000" />
  806. <metadata key="weight" value="10.0" />
  807. <object identify_id="1" name="Part_A" skipped="false" />
  808. </plate>
  809. <plate>
  810. <metadata key="index" value="2" />
  811. <metadata key="prediction" value="1500" />
  812. <metadata key="weight" value="15.0" />
  813. <object identify_id="2" name="Part_B" skipped="false" />
  814. </plate>
  815. </config>
  816. """
  817. parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
  818. meta = parser.parse()
  819. assert meta["print_time_seconds"] == 2500
  820. assert meta["filament_used_grams"] == 25.0
  821. # No archive-level object list when there's more than one plate.
  822. assert "printable_objects" not in meta
  823. assert parser.plate_number is None
  824. def test_missing_or_malformed_values_are_skipped(self):
  825. """A plate with a malformed prediction/weight string must skip
  826. that field, not poison the sum or raise — defensive parsing was
  827. already present per-field; the sum loop must preserve it.
  828. """
  829. from backend.app.services.archive import ThreeMFParser
  830. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  831. <config>
  832. <plate>
  833. <metadata key="prediction" value="100" />
  834. <metadata key="weight" value="not-a-number" />
  835. </plate>
  836. <plate>
  837. <metadata key="prediction" value="200" />
  838. <metadata key="weight" value="5.0" />
  839. </plate>
  840. </config>
  841. """
  842. meta = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)).parse()
  843. assert meta["print_time_seconds"] == 300
  844. # Only the second plate's weight contributed.
  845. assert meta["filament_used_grams"] == 5.0
  846. class TestThreeMFParserSupportMaterial:
  847. """#1881: `_extract_filament_info` used to filter out support materials
  848. (any slot where `filament_is_support == "1"`). That hid PVA / BVOH from
  849. the archive card of unsliced source 3MFs — a PLA-model + PVA-support
  850. project looked single-material until the print completed. This class
  851. covers the follow-up: support materials must be included in
  852. `filament_type` / `filament_color`.
  853. """
  854. @staticmethod
  855. def _make_3mf_with_project_settings(project_settings: dict) -> str:
  856. import json
  857. import os
  858. import tempfile
  859. import zipfile
  860. fd, path = tempfile.mkstemp(suffix=".3mf")
  861. os.close(fd)
  862. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  863. zf.writestr("3D/3dmodel.model", "<model/>")
  864. zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
  865. return path
  866. def test_support_filament_included_on_source_3mf(self):
  867. # Reporter's exact config: PLA model + PVA support. Both must show
  868. # on the archive card badge, in slot order.
  869. from backend.app.services.archive import ThreeMFParser
  870. path = self._make_3mf_with_project_settings(
  871. {
  872. "filament_type": ["PLA", "PVA"],
  873. "filament_colour": ["#FFFFFF", "#00AA00"],
  874. "filament_is_support": ["0", "1"],
  875. }
  876. )
  877. meta = ThreeMFParser(path).parse()
  878. assert meta["filament_type"] == "PLA, PVA"
  879. assert meta["filament_color"] == "#FFFFFF,#00AA00"
  880. def test_single_support_material_still_populated(self):
  881. # Degenerate case: only material configured happens to be marked
  882. # support. Old fallback picked the first entry; new logic keeps
  883. # the same shape.
  884. from backend.app.services.archive import ThreeMFParser
  885. path = self._make_3mf_with_project_settings(
  886. {
  887. "filament_type": ["PVA"],
  888. "filament_colour": ["#FFFFFF"],
  889. "filament_is_support": ["1"],
  890. }
  891. )
  892. meta = ThreeMFParser(path).parse()
  893. assert meta["filament_type"] == "PVA"
  894. assert meta["filament_color"] == "#FFFFFF"
  895. def test_duplicate_material_types_deduped(self):
  896. # Two AMS slots both PLA of different colours: type list dedupes
  897. # but colour list keeps both (multi-colour print).
  898. from backend.app.services.archive import ThreeMFParser
  899. path = self._make_3mf_with_project_settings(
  900. {
  901. "filament_type": ["PLA", "PLA"],
  902. "filament_colour": ["#FFFFFF", "#000000"],
  903. "filament_is_support": ["0", "0"],
  904. }
  905. )
  906. meta = ThreeMFParser(path).parse()
  907. assert meta["filament_type"] == "PLA"
  908. assert meta["filament_color"] == "#FFFFFF,#000000"
  909. class TestThreeMFLayerHeightFromPlateGcode:
  910. """The plate's G-code outranks project_settings.config on layer height.
  911. Reported against a print sliced at 0.08 that archived as 0.2: the card
  912. reads ``project_settings.config``, which is the *project's* record and can
  913. still describe another plate or an earlier process, while the plate G-code
  914. is the file the printer actually runs. The value sits in the CONFIG_BLOCK
  915. 14-25KB into the G-code, past the 4KB header window this parser used to
  916. read, so the disagreement had no way to surface.
  917. """
  918. @staticmethod
  919. def _plate_gcode(layer_height: str, total_layers: int) -> str:
  920. # Mirrors a real Bambu plate: header block first, then the alphabetical
  921. # config block, which is what pushes layer_height past 4KB.
  922. filler = "".join(f"; config_filler_{i} = {i}\n" for i in range(1200))
  923. return (
  924. "; HEADER_BLOCK_START\n"
  925. f"; total layer number: {total_layers}\n"
  926. "; HEADER_BLOCK_END\n"
  927. "; CONFIG_BLOCK_START\n"
  928. f"{filler}"
  929. "; independent_support_layer_height = 0\n"
  930. f"; layer_height = {layer_height}\n"
  931. "; CONFIG_BLOCK_END\n"
  932. "G1 X0 Y0\n"
  933. )
  934. def _write(self, path, *, config_layer_height, plates):
  935. import json
  936. import zipfile
  937. with zipfile.ZipFile(path, "w") as zf:
  938. zf.writestr("3D/3dmodel.model", "<model/>")
  939. zf.writestr(
  940. "Metadata/project_settings.config",
  941. json.dumps({"layer_height": config_layer_height}),
  942. )
  943. for index, (layer_height, total_layers) in plates.items():
  944. zf.writestr(
  945. f"Metadata/plate_{index}.gcode",
  946. self._plate_gcode(layer_height, total_layers),
  947. )
  948. return path
  949. def test_gcode_wins_over_project_settings(self, tmp_path):
  950. from backend.app.services.archive import ThreeMFParser
  951. path = self._write(tmp_path / "sliced.3mf", config_layer_height="0.2", plates={1: ("0.08", 59)})
  952. parsed = ThreeMFParser(path).parse()
  953. assert parsed["layer_height"] == 0.08
  954. assert parsed["total_layers"] == 59
  955. def test_reads_the_plate_that_was_printed(self, tmp_path):
  956. from backend.app.services.archive import ThreeMFParser
  957. path = self._write(
  958. tmp_path / "multi.3mf",
  959. config_layer_height="0.2",
  960. plates={1: ("0.2", 30), 2: ("0.08", 148)},
  961. )
  962. parsed = ThreeMFParser(path, plate_number=2).parse()
  963. assert parsed["layer_height"] == 0.08
  964. assert parsed["total_layers"] == 148
  965. def test_falls_back_to_the_lowest_plate_when_none_was_named(self, tmp_path):
  966. from backend.app.services.archive import ThreeMFParser
  967. path = self._write(
  968. tmp_path / "unnamed.3mf",
  969. config_layer_height="0.2",
  970. plates={2: ("0.08", 148), 1: ("0.2", 30)},
  971. )
  972. parsed = ThreeMFParser(path).parse()
  973. assert parsed["layer_height"] == 0.2
  974. assert parsed["total_layers"] == 30
  975. def test_source_3mf_without_gcode_keeps_the_project_value(self, tmp_path):
  976. import json
  977. import zipfile
  978. from backend.app.services.archive import ThreeMFParser
  979. path = tmp_path / "source.3mf"
  980. with zipfile.ZipFile(path, "w") as zf:
  981. zf.writestr("3D/3dmodel.model", "<model/>")
  982. zf.writestr("Metadata/project_settings.config", json.dumps({"layer_height": "0.28"}))
  983. assert ThreeMFParser(path).parse()["layer_height"] == 0.28