test_archive_service.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  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. class TestPrintableObjectsExtraction:
  184. """Tests for extracting printable objects count from 3MF files."""
  185. def test_extract_printable_objects_from_slice_info(self):
  186. """Test parsing printable objects from slice_info.config XML."""
  187. from defusedxml import ElementTree as ET
  188. # Example slice_info.config content with 4 objects
  189. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  190. <config>
  191. <plate plate_idx="1">
  192. <metadata key="prediction" value="3600" />
  193. <metadata key="weight" value="50.5" />
  194. <object identify_id="1" name="Part_A" skipped="false" />
  195. <object identify_id="2" name="Part_B" skipped="false" />
  196. <object identify_id="3" name="Part_C" skipped="false" />
  197. <object identify_id="4" name="Part_D" skipped="true" />
  198. </plate>
  199. </config>
  200. """
  201. root = ET.fromstring(slice_info_xml)
  202. plate = root.find(".//plate")
  203. # Count non-skipped objects (should be 3, not 4)
  204. count = 0
  205. for obj in plate.findall("object"):
  206. skipped = obj.get("skipped", "false")
  207. if skipped.lower() != "true":
  208. count += 1
  209. assert count == 3 # 3 objects (Part_D is skipped)
  210. def test_extract_printable_objects_empty_plate(self):
  211. """Test handling plate with no objects."""
  212. from defusedxml import ElementTree as ET
  213. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  214. <config>
  215. <plate plate_idx="1">
  216. <metadata key="prediction" value="0" />
  217. </plate>
  218. </config>
  219. """
  220. root = ET.fromstring(slice_info_xml)
  221. plate = root.find(".//plate")
  222. count = 0
  223. for obj in plate.findall("object"):
  224. skipped = obj.get("skipped", "false")
  225. if skipped.lower() != "true":
  226. count += 1
  227. assert count == 0
  228. def test_extract_printable_objects_all_skipped(self):
  229. """Test handling plate where all objects are skipped."""
  230. from defusedxml import ElementTree as ET
  231. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  232. <config>
  233. <plate plate_idx="1">
  234. <object identify_id="1" name="Part_A" skipped="true" />
  235. <object identify_id="2" name="Part_B" skipped="true" />
  236. </plate>
  237. </config>
  238. """
  239. root = ET.fromstring(slice_info_xml)
  240. plate = root.find(".//plate")
  241. count = 0
  242. for obj in plate.findall("object"):
  243. skipped = obj.get("skipped", "false")
  244. if skipped.lower() != "true":
  245. count += 1
  246. assert count == 0 # All objects skipped
  247. class TestThreeMFPlateIndexExtraction:
  248. """Tests for extracting plate index from multi-plate 3MF exports (Issue #92)."""
  249. def test_extract_plate_index_from_slice_info(self):
  250. """Test parsing plate index from slice_info.config metadata."""
  251. from defusedxml import ElementTree as ET
  252. # Single-plate export from plate 5 of a multi-plate project
  253. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  254. <config>
  255. <plate>
  256. <metadata key="index" value="5" />
  257. <metadata key="prediction" value="3600" />
  258. <metadata key="weight" value="50.5" />
  259. <object identify_id="1" name="Part_A" skipped="false" />
  260. </plate>
  261. </config>
  262. """
  263. root = ET.fromstring(slice_info_xml)
  264. plate = root.find(".//plate")
  265. plate_index = None
  266. for meta in plate.findall("metadata"):
  267. if meta.get("key") == "index":
  268. plate_index = int(meta.get("value"))
  269. break
  270. assert plate_index == 5
  271. def test_extract_plate_index_plate_1(self):
  272. """Test parsing plate index when it's plate 1."""
  273. from defusedxml import ElementTree as ET
  274. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  275. <config>
  276. <plate>
  277. <metadata key="index" value="1" />
  278. <metadata key="prediction" value="1800" />
  279. </plate>
  280. </config>
  281. """
  282. root = ET.fromstring(slice_info_xml)
  283. plate = root.find(".//plate")
  284. plate_index = None
  285. for meta in plate.findall("metadata"):
  286. if meta.get("key") == "index":
  287. plate_index = int(meta.get("value"))
  288. break
  289. assert plate_index == 1
  290. def test_thumbnail_path_uses_plate_number(self):
  291. """Test that thumbnail path correctly uses the extracted plate number."""
  292. plate_number = 5
  293. thumbnail_paths = []
  294. if plate_number:
  295. thumbnail_paths.append(f"Metadata/plate_{plate_number}.png")
  296. thumbnail_paths.extend(
  297. [
  298. "Metadata/plate_1.png",
  299. "Metadata/thumbnail.png",
  300. ]
  301. )
  302. # First priority should be plate_5.png
  303. assert thumbnail_paths[0] == "Metadata/plate_5.png"
  304. @staticmethod
  305. def _enhance_print_name(print_name: str, plate_index: int) -> str:
  306. """Apply plate name enhancement logic from archive.py."""
  307. if plate_index and plate_index > 1:
  308. if print_name and f"Plate {plate_index}" not in print_name:
  309. print_name = f"{print_name} - Plate {plate_index}"
  310. return print_name
  311. def test_print_name_enhanced_for_plate_greater_than_1(self):
  312. """Test that print_name is enhanced with plate info for plate > 1."""
  313. assert self._enhance_print_name("Benchy", 5) == "Benchy - Plate 5"
  314. def test_print_name_not_enhanced_for_plate_1(self):
  315. """Test that print_name is NOT enhanced for plate 1."""
  316. assert self._enhance_print_name("Benchy", 1) == "Benchy"
  317. def test_print_name_not_duplicated(self):
  318. """Test that plate info is not added if already present in print_name."""
  319. assert self._enhance_print_name("Benchy - Plate 5", 5) == "Benchy - Plate 5"
  320. def test_high_plate_number_extraction(self):
  321. """Test extracting high plate numbers (e.g., plate 28)."""
  322. from defusedxml import ElementTree as ET
  323. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  324. <config>
  325. <plate>
  326. <metadata key="index" value="28" />
  327. <metadata key="prediction" value="7200" />
  328. </plate>
  329. </config>
  330. """
  331. root = ET.fromstring(slice_info_xml)
  332. plate = root.find(".//plate")
  333. plate_index = None
  334. for meta in plate.findall("metadata"):
  335. if meta.get("key") == "index":
  336. plate_index = int(meta.get("value"))
  337. break
  338. assert plate_index == 28
  339. # Verify thumbnail would use correct plate
  340. thumbnail_path = f"Metadata/plate_{plate_index}.png"
  341. assert thumbnail_path == "Metadata/plate_28.png"
  342. class TestMultiPlate3MFParsing:
  343. """Tests for parsing multi-plate 3MF files (Issue #93)."""
  344. def test_parse_multiple_plates_from_slice_info(self):
  345. """Test parsing multiple plates from slice_info.config."""
  346. from defusedxml import ElementTree as ET
  347. # Multi-plate 3MF with 3 plates
  348. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  349. <config>
  350. <plate>
  351. <metadata key="index" value="1" />
  352. <metadata key="prediction" value="3600" />
  353. <metadata key="weight" value="50.0" />
  354. <filament id="1" type="PLA" color="#FF0000" used_g="25.0" used_m="8.5" />
  355. <object identify_id="1" name="Part_A" skipped="false" />
  356. </plate>
  357. <plate>
  358. <metadata key="index" value="2" />
  359. <metadata key="prediction" value="7200" />
  360. <metadata key="weight" value="100.0" />
  361. <filament id="2" type="PETG" color="#00FF00" used_g="50.0" used_m="17.0" />
  362. <object identify_id="2" name="Part_B" skipped="false" />
  363. </plate>
  364. <plate>
  365. <metadata key="index" value="3" />
  366. <metadata key="prediction" value="1800" />
  367. <metadata key="weight" value="25.0" />
  368. <filament id="1" type="PLA" color="#FF0000" used_g="12.5" used_m="4.2" />
  369. <filament id="3" type="TPU" color="#0000FF" used_g="12.5" used_m="4.2" />
  370. <object identify_id="3" name="Part_C" skipped="false" />
  371. </plate>
  372. </config>
  373. """
  374. root = ET.fromstring(slice_info_xml)
  375. plates = root.findall(".//plate")
  376. assert len(plates) == 3
  377. # Parse each plate
  378. plate_data = []
  379. for plate_elem in plates:
  380. plate_info = {"index": None, "filaments": []}
  381. for meta in plate_elem.findall("metadata"):
  382. if meta.get("key") == "index":
  383. plate_info["index"] = int(meta.get("value"))
  384. for filament_elem in plate_elem.findall("filament"):
  385. used_g = float(filament_elem.get("used_g", "0"))
  386. if used_g > 0:
  387. plate_info["filaments"].append(
  388. {
  389. "slot_id": int(filament_elem.get("id")),
  390. "type": filament_elem.get("type"),
  391. "color": filament_elem.get("color"),
  392. "used_grams": used_g,
  393. }
  394. )
  395. plate_data.append(plate_info)
  396. # Verify plate 1
  397. assert plate_data[0]["index"] == 1
  398. assert len(plate_data[0]["filaments"]) == 1
  399. assert plate_data[0]["filaments"][0]["slot_id"] == 1
  400. assert plate_data[0]["filaments"][0]["type"] == "PLA"
  401. # Verify plate 2
  402. assert plate_data[1]["index"] == 2
  403. assert len(plate_data[1]["filaments"]) == 1
  404. assert plate_data[1]["filaments"][0]["slot_id"] == 2
  405. assert plate_data[1]["filaments"][0]["type"] == "PETG"
  406. # Verify plate 3 (has 2 filaments)
  407. assert plate_data[2]["index"] == 3
  408. assert len(plate_data[2]["filaments"]) == 2
  409. filament_types = {f["type"] for f in plate_data[2]["filaments"]}
  410. assert filament_types == {"PLA", "TPU"}
  411. def test_filter_filaments_by_plate_id(self):
  412. """Test filtering filaments for a specific plate."""
  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. <filament id="1" type="PLA" color="#FF0000" used_g="25.0" />
  419. </plate>
  420. <plate>
  421. <metadata key="index" value="2" />
  422. <filament id="2" type="PETG" color="#00FF00" used_g="50.0" />
  423. </plate>
  424. </config>
  425. """
  426. root = ET.fromstring(slice_info_xml)
  427. # Filter for plate 2 only
  428. target_plate_id = 2
  429. filaments = []
  430. for plate_elem in root.findall(".//plate"):
  431. plate_index = None
  432. for meta in plate_elem.findall("metadata"):
  433. if meta.get("key") == "index":
  434. plate_index = int(meta.get("value", "0"))
  435. break
  436. if plate_index == target_plate_id:
  437. for filament_elem in plate_elem.findall("filament"):
  438. used_g = float(filament_elem.get("used_g", "0"))
  439. if used_g > 0:
  440. filaments.append(
  441. {
  442. "slot_id": int(filament_elem.get("id")),
  443. "type": filament_elem.get("type"),
  444. }
  445. )
  446. break
  447. # Should only have plate 2's filament
  448. assert len(filaments) == 1
  449. assert filaments[0]["slot_id"] == 2
  450. assert filaments[0]["type"] == "PETG"
  451. def test_detect_multi_plate_from_gcode_files(self):
  452. """Test detecting multiple plates from gcode file presence."""
  453. # Simulate namelist from a multi-plate 3MF
  454. namelist = [
  455. "Metadata/plate_1.gcode",
  456. "Metadata/plate_2.gcode",
  457. "Metadata/plate_3.gcode",
  458. "Metadata/plate_1.png",
  459. "Metadata/plate_2.png",
  460. "Metadata/plate_3.png",
  461. "Metadata/slice_info.config",
  462. "3D/3dmodel.model",
  463. ]
  464. # Extract plate indices from gcode files
  465. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  466. plate_indices = []
  467. for gf in gcode_files:
  468. plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
  469. plate_indices.append(int(plate_str))
  470. plate_indices.sort()
  471. assert len(plate_indices) == 3
  472. assert plate_indices == [1, 2, 3]
  473. # Verify it's a multi-plate file
  474. is_multi_plate = len(plate_indices) > 1
  475. assert is_multi_plate is True
  476. def test_single_plate_export_not_multi_plate(self):
  477. """Test that single-plate exports are not detected as multi-plate."""
  478. # Simulate namelist from a single-plate export (plate 5 only)
  479. namelist = [
  480. "Metadata/plate_5.gcode",
  481. "Metadata/plate_1.png",
  482. "Metadata/plate_2.png",
  483. "Metadata/plate_3.png",
  484. "Metadata/plate_4.png",
  485. "Metadata/plate_5.png", # All thumbnails present
  486. "Metadata/slice_info.config",
  487. "3D/3dmodel.model",
  488. ]
  489. # Extract plate indices from gcode files (not thumbnails!)
  490. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  491. plate_indices = []
  492. for gf in gcode_files:
  493. plate_str = gf[15:-6]
  494. plate_indices.append(int(plate_str))
  495. # Only one gcode file = single plate export
  496. assert len(plate_indices) == 1
  497. assert plate_indices[0] == 5
  498. is_multi_plate = len(plate_indices) > 1
  499. assert is_multi_plate is False
  500. class TestReprintCostCalculation:
  501. """Tests for reprint cost calculation."""
  502. def test_cost_addition_logic(self):
  503. """Test that reprint costs are added correctly."""
  504. # Simulate the cost addition logic
  505. existing_cost = 5.25 # Original print cost
  506. filament_grams = 100.0
  507. cost_per_kg = 25.0 # Default cost
  508. # Calculate additional cost for reprint
  509. additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  510. assert additional_cost == 2.50
  511. # Add to existing cost
  512. new_total = round(existing_cost + additional_cost, 2)
  513. assert new_total == 7.75
  514. def test_cost_addition_with_none_existing(self):
  515. """Test cost addition when existing cost is None."""
  516. existing_cost = None
  517. filament_grams = 200.0
  518. cost_per_kg = 15.0
  519. additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  520. assert additional_cost == 3.0
  521. # When existing is None, just use additional
  522. new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2)
  523. assert new_total == 3.0
  524. def test_cost_with_custom_filament_price(self):
  525. """Test cost calculation with custom filament price."""
  526. filament_grams = 150.0
  527. custom_cost_per_kg = 35.0 # More expensive filament
  528. cost = round((filament_grams / 1000) * custom_cost_per_kg, 2)
  529. assert cost == 5.25
  530. def test_multiple_reprints_accumulate(self):
  531. """Test that multiple reprints accumulate costs correctly."""
  532. filament_grams = 100.0
  533. cost_per_kg = 20.0
  534. single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  535. assert single_print_cost == 2.0
  536. # After 3 prints (1 original + 2 reprints)
  537. total_after_3_prints = round(single_print_cost * 3, 2)
  538. assert total_after_3_prints == 6.0
  539. class TestGcodeHeaderFilamentUsage:
  540. """ThreeMFParser pulls total filament usage from the produced 3MF's G-code
  541. header. Some slicer-sidecar builds leave the X-Filament-Used-* response
  542. headers unset, so the slice would otherwise report "0 g" for a real
  543. multi-hour print."""
  544. @staticmethod
  545. def _make_3mf(gcode_header: str) -> str:
  546. import tempfile
  547. import zipfile
  548. fd, path = tempfile.mkstemp(suffix=".3mf")
  549. import os
  550. os.close(fd)
  551. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  552. zf.writestr("3D/3dmodel.model", "<model/>")
  553. zf.writestr("Metadata/plate_1.gcode", gcode_header + "\nG1 X0 Y0\n")
  554. return path
  555. def test_extracts_filament_weight_and_length_from_header(self):
  556. from backend.app.services.archive import ThreeMFParser
  557. header = (
  558. "; HEADER_BLOCK_START\n"
  559. "; BambuStudio 02.06.00.51\n"
  560. "; total layer number: 503\n"
  561. "; total filament length [mm] : 41661.40\n"
  562. "; total filament volume [cm^3] : 100207.42\n"
  563. "; total filament weight [g] : 126.26\n"
  564. )
  565. meta = ThreeMFParser(self._make_3mf(header)).parse()
  566. assert meta.get("filament_used_grams") == 126.26
  567. assert meta.get("filament_used_mm") == 41661.40
  568. assert meta.get("total_layers") == 503
  569. def test_no_filament_keys_when_header_lacks_them(self):
  570. from backend.app.services.archive import ThreeMFParser
  571. meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
  572. assert "filament_used_grams" not in meta
  573. assert "filament_used_mm" not in meta