test_archive_service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. """Unit tests for the archive service."""
  2. from datetime import datetime
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import pytest
  5. class TestArchiveServiceHelpers:
  6. """Tests for archive service helper functions."""
  7. def test_parse_print_time_seconds(self):
  8. """Test parsing print time to seconds."""
  9. # Import the actual function if available, otherwise test the logic
  10. # 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds
  11. _time_str = "2h 30m 15s" # Example format
  12. # Parse hours
  13. hours = 2
  14. minutes = 30
  15. seconds = 15
  16. total = hours * 3600 + minutes * 60 + seconds
  17. assert total == 9015
  18. def test_parse_filament_grams(self):
  19. """Test parsing filament usage to grams."""
  20. # Example: "150.5g" -> 150.5
  21. filament_str = "150.5g"
  22. grams = float(filament_str.replace("g", ""))
  23. assert grams == 150.5
  24. def test_format_duration(self):
  25. """Test formatting seconds to human readable duration."""
  26. # 3661 seconds = 1h 1m 1s
  27. seconds = 3661
  28. hours = seconds // 3600
  29. minutes = (seconds % 3600) // 60
  30. secs = seconds % 60
  31. assert hours == 1
  32. assert minutes == 1
  33. assert secs == 1
  34. class TestArchiveDataParsing:
  35. """Tests for parsing archive data from MQTT messages."""
  36. def test_parse_gcode_state(self):
  37. """Test parsing gcode state."""
  38. states = {
  39. "RUNNING": "printing",
  40. "FINISH": "completed",
  41. "FAILED": "failed",
  42. "IDLE": "idle",
  43. "PAUSE": "paused",
  44. }
  45. for gcode_state, expected in states.items():
  46. # Simple state mapping
  47. mapped = gcode_state.lower()
  48. if gcode_state == "RUNNING":
  49. mapped = "printing"
  50. elif gcode_state == "FINISH":
  51. mapped = "completed"
  52. elif gcode_state == "FAILED":
  53. mapped = "failed"
  54. elif gcode_state == "IDLE":
  55. mapped = "idle"
  56. elif gcode_state == "PAUSE":
  57. mapped = "paused"
  58. assert mapped == expected
  59. def test_parse_progress(self):
  60. """Test parsing print progress."""
  61. # mc_percent is the progress field in MQTT messages
  62. data = {"mc_percent": 75}
  63. progress = data.get("mc_percent", 0)
  64. assert progress == 75
  65. assert 0 <= progress <= 100
  66. def test_parse_layer_info(self):
  67. """Test parsing layer information."""
  68. data = {
  69. "layer_num": 50,
  70. "total_layers": 200,
  71. }
  72. current_layer = data.get("layer_num", 0)
  73. total_layers = data.get("total_layers", 0)
  74. assert current_layer == 50
  75. assert total_layers == 200
  76. if total_layers > 0:
  77. layer_percent = (current_layer / total_layers) * 100
  78. assert layer_percent == 25.0
  79. class TestArchiveFilePaths:
  80. """Tests for archive file path handling."""
  81. def test_generate_archive_path(self):
  82. """Test generating archive file paths."""
  83. printer_name = "X1C_01"
  84. _print_name = "benchy" # Example print name
  85. timestamp = datetime(2024, 1, 15, 14, 30, 0)
  86. # Expected pattern: archives/{printer}/{year}/{month}/{filename}
  87. year = timestamp.year
  88. month = f"{timestamp.month:02d}"
  89. expected_dir = f"archives/{printer_name}/{year}/{month}"
  90. assert "archives" in expected_dir
  91. assert printer_name in expected_dir
  92. assert str(year) in expected_dir
  93. def test_sanitize_filename(self):
  94. """Test filename sanitization."""
  95. # Characters to remove: / \ : * ? " < > |
  96. dirty_name = "test:file<name>.3mf"
  97. # Simple sanitization
  98. safe_chars = []
  99. for c in dirty_name:
  100. if c not in '\\/:*?"<>|':
  101. safe_chars.append(c)
  102. clean_name = "".join(safe_chars)
  103. assert ":" not in clean_name
  104. assert "<" not in clean_name
  105. assert ">" not in clean_name
  106. def test_thumbnail_path(self):
  107. """Test thumbnail path generation."""
  108. archive_path = "archives/X1C_01/2024/01/benchy.3mf"
  109. # Thumbnail typically has same path with _thumb.png suffix
  110. base_path = archive_path.rsplit(".", 1)[0]
  111. thumbnail_path = f"{base_path}_thumb.png"
  112. assert thumbnail_path.endswith("_thumb.png")
  113. assert "benchy" in thumbnail_path
  114. class TestArchiveStatus:
  115. """Tests for archive status handling."""
  116. def test_valid_status_values(self):
  117. """Test valid archive status values."""
  118. valid_statuses = ["completed", "failed", "cancelled", "stopped"]
  119. for status in valid_statuses:
  120. assert status in valid_statuses
  121. def test_status_from_gcode_state(self):
  122. """Test mapping gcode state to archive status."""
  123. state_mapping = {
  124. "FINISH": "completed",
  125. "FAILED": "failed",
  126. "CANCEL": "cancelled",
  127. }
  128. for gcode_state, expected_status in state_mapping.items():
  129. assert state_mapping[gcode_state] == expected_status
  130. class TestArchiveFilamentData:
  131. """Tests for filament data parsing."""
  132. def test_parse_ams_filament(self):
  133. """Test parsing AMS filament information."""
  134. ams_data = {
  135. "ams": {
  136. "ams": [
  137. {
  138. "tray": [
  139. {"tray_type": "PLA", "tray_color": "FF0000"},
  140. {"tray_type": "PETG", "tray_color": "00FF00"},
  141. ]
  142. }
  143. ]
  144. }
  145. }
  146. trays = ams_data["ams"]["ams"][0]["tray"]
  147. assert trays[0]["tray_type"] == "PLA"
  148. assert trays[1]["tray_type"] == "PETG"
  149. def test_parse_filament_color_hex(self):
  150. """Test parsing filament color from hex."""
  151. color_hex = "FF5500"
  152. # Should be valid hex
  153. assert len(color_hex) == 6
  154. r = int(color_hex[0:2], 16)
  155. g = int(color_hex[2:4], 16)
  156. b = int(color_hex[4:6], 16)
  157. assert r == 255
  158. assert g == 85
  159. assert b == 0
  160. def test_calculate_filament_cost(self):
  161. """Test calculating filament cost."""
  162. grams_used = 150.0
  163. cost_per_kg = 25.0 # $25 per kg
  164. cost = (grams_used / 1000) * cost_per_kg
  165. assert cost == 3.75
  166. class TestArchiveThumbnails:
  167. """Tests for archive thumbnail handling."""
  168. def test_thumbnail_file_types(self):
  169. """Test supported thumbnail file types."""
  170. supported_types = [".png", ".jpg", ".jpeg"]
  171. for ext in supported_types:
  172. assert ext.startswith(".")
  173. assert ext.lower() in [".png", ".jpg", ".jpeg"]
  174. def test_extract_thumbnail_from_3mf(self):
  175. """Test thumbnail extraction concept from 3MF."""
  176. # 3MF files are ZIP archives containing:
  177. # - Metadata/thumbnail.png
  178. # - 3D/3dmodel.model
  179. expected_thumbnail_paths = [
  180. "Metadata/thumbnail.png",
  181. "Metadata/plate_1.png",
  182. ]
  183. for path in expected_thumbnail_paths:
  184. assert "png" in path.lower()
  185. class TestPrintableObjectsExtraction:
  186. """Tests for extracting printable objects count from 3MF files."""
  187. def test_extract_printable_objects_from_slice_info(self):
  188. """Test parsing printable objects from slice_info.config XML."""
  189. from defusedxml import ElementTree as ET
  190. # Example slice_info.config content with 4 objects
  191. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  192. <config>
  193. <plate plate_idx="1">
  194. <metadata key="prediction" value="3600" />
  195. <metadata key="weight" value="50.5" />
  196. <object identify_id="1" name="Part_A" skipped="false" />
  197. <object identify_id="2" name="Part_B" skipped="false" />
  198. <object identify_id="3" name="Part_C" skipped="false" />
  199. <object identify_id="4" name="Part_D" skipped="true" />
  200. </plate>
  201. </config>
  202. """
  203. root = ET.fromstring(slice_info_xml)
  204. plate = root.find(".//plate")
  205. # Count non-skipped objects (should be 3, not 4)
  206. count = 0
  207. for obj in plate.findall("object"):
  208. skipped = obj.get("skipped", "false")
  209. if skipped.lower() != "true":
  210. count += 1
  211. assert count == 3 # 3 objects (Part_D is skipped)
  212. def test_extract_printable_objects_empty_plate(self):
  213. """Test handling plate with no objects."""
  214. from defusedxml import ElementTree as ET
  215. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  216. <config>
  217. <plate plate_idx="1">
  218. <metadata key="prediction" value="0" />
  219. </plate>
  220. </config>
  221. """
  222. root = ET.fromstring(slice_info_xml)
  223. plate = root.find(".//plate")
  224. count = 0
  225. for obj in plate.findall("object"):
  226. skipped = obj.get("skipped", "false")
  227. if skipped.lower() != "true":
  228. count += 1
  229. assert count == 0
  230. def test_extract_printable_objects_all_skipped(self):
  231. """Test handling plate where all objects are skipped."""
  232. from defusedxml import ElementTree as ET
  233. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  234. <config>
  235. <plate plate_idx="1">
  236. <object identify_id="1" name="Part_A" skipped="true" />
  237. <object identify_id="2" name="Part_B" skipped="true" />
  238. </plate>
  239. </config>
  240. """
  241. root = ET.fromstring(slice_info_xml)
  242. plate = root.find(".//plate")
  243. count = 0
  244. for obj in plate.findall("object"):
  245. skipped = obj.get("skipped", "false")
  246. if skipped.lower() != "true":
  247. count += 1
  248. assert count == 0 # All objects skipped
  249. class TestThreeMFPlateIndexExtraction:
  250. """Tests for extracting plate index from multi-plate 3MF exports (Issue #92)."""
  251. def test_extract_plate_index_from_slice_info(self):
  252. """Test parsing plate index from slice_info.config metadata."""
  253. from defusedxml import ElementTree as ET
  254. # Single-plate export from plate 5 of a multi-plate project
  255. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  256. <config>
  257. <plate>
  258. <metadata key="index" value="5" />
  259. <metadata key="prediction" value="3600" />
  260. <metadata key="weight" value="50.5" />
  261. <object identify_id="1" name="Part_A" skipped="false" />
  262. </plate>
  263. </config>
  264. """
  265. root = ET.fromstring(slice_info_xml)
  266. plate = root.find(".//plate")
  267. plate_index = None
  268. for meta in plate.findall("metadata"):
  269. if meta.get("key") == "index":
  270. plate_index = int(meta.get("value"))
  271. break
  272. assert plate_index == 5
  273. def test_extract_plate_index_plate_1(self):
  274. """Test parsing plate index when it's plate 1."""
  275. from defusedxml import ElementTree as ET
  276. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  277. <config>
  278. <plate>
  279. <metadata key="index" value="1" />
  280. <metadata key="prediction" value="1800" />
  281. </plate>
  282. </config>
  283. """
  284. root = ET.fromstring(slice_info_xml)
  285. plate = root.find(".//plate")
  286. plate_index = None
  287. for meta in plate.findall("metadata"):
  288. if meta.get("key") == "index":
  289. plate_index = int(meta.get("value"))
  290. break
  291. assert plate_index == 1
  292. def test_thumbnail_path_uses_plate_number(self):
  293. """Test that thumbnail path correctly uses the extracted plate number."""
  294. plate_number = 5
  295. thumbnail_paths = []
  296. if plate_number:
  297. thumbnail_paths.append(f"Metadata/plate_{plate_number}.png")
  298. thumbnail_paths.extend(
  299. [
  300. "Metadata/plate_1.png",
  301. "Metadata/thumbnail.png",
  302. ]
  303. )
  304. # First priority should be plate_5.png
  305. assert thumbnail_paths[0] == "Metadata/plate_5.png"
  306. def test_print_name_enhanced_for_plate_greater_than_1(self):
  307. """Test that print_name is enhanced with plate info for plate > 1."""
  308. plate_index = 5
  309. print_name = "Benchy"
  310. # Logic from archive.py
  311. if plate_index and plate_index > 1:
  312. if print_name and f"Plate {plate_index}" not in print_name:
  313. print_name = f"{print_name} - Plate {plate_index}"
  314. assert print_name == "Benchy - Plate 5"
  315. def test_print_name_not_enhanced_for_plate_1(self):
  316. """Test that print_name is NOT enhanced for plate 1."""
  317. plate_index = 1
  318. print_name = "Benchy"
  319. # Logic from archive.py
  320. if plate_index and plate_index > 1:
  321. if print_name and f"Plate {plate_index}" not in print_name:
  322. print_name = f"{print_name} - Plate {plate_index}"
  323. assert print_name == "Benchy" # Unchanged for plate 1
  324. def test_print_name_not_duplicated(self):
  325. """Test that plate info is not added if already present in print_name."""
  326. plate_index = 5
  327. print_name = "Benchy - Plate 5"
  328. # Logic from archive.py
  329. if plate_index and plate_index > 1:
  330. if print_name and f"Plate {plate_index}" not in print_name:
  331. print_name = f"{print_name} - Plate {plate_index}"
  332. assert print_name == "Benchy - Plate 5" # Not duplicated
  333. def test_high_plate_number_extraction(self):
  334. """Test extracting high plate numbers (e.g., plate 28)."""
  335. from defusedxml import ElementTree as ET
  336. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  337. <config>
  338. <plate>
  339. <metadata key="index" value="28" />
  340. <metadata key="prediction" value="7200" />
  341. </plate>
  342. </config>
  343. """
  344. root = ET.fromstring(slice_info_xml)
  345. plate = root.find(".//plate")
  346. plate_index = None
  347. for meta in plate.findall("metadata"):
  348. if meta.get("key") == "index":
  349. plate_index = int(meta.get("value"))
  350. break
  351. assert plate_index == 28
  352. # Verify thumbnail would use correct plate
  353. thumbnail_path = f"Metadata/plate_{plate_index}.png"
  354. assert thumbnail_path == "Metadata/plate_28.png"
  355. class TestMultiPlate3MFParsing:
  356. """Tests for parsing multi-plate 3MF files (Issue #93)."""
  357. def test_parse_multiple_plates_from_slice_info(self):
  358. """Test parsing multiple plates from slice_info.config."""
  359. from defusedxml import ElementTree as ET
  360. # Multi-plate 3MF with 3 plates
  361. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  362. <config>
  363. <plate>
  364. <metadata key="index" value="1" />
  365. <metadata key="prediction" value="3600" />
  366. <metadata key="weight" value="50.0" />
  367. <filament id="1" type="PLA" color="#FF0000" used_g="25.0" used_m="8.5" />
  368. <object identify_id="1" name="Part_A" skipped="false" />
  369. </plate>
  370. <plate>
  371. <metadata key="index" value="2" />
  372. <metadata key="prediction" value="7200" />
  373. <metadata key="weight" value="100.0" />
  374. <filament id="2" type="PETG" color="#00FF00" used_g="50.0" used_m="17.0" />
  375. <object identify_id="2" name="Part_B" skipped="false" />
  376. </plate>
  377. <plate>
  378. <metadata key="index" value="3" />
  379. <metadata key="prediction" value="1800" />
  380. <metadata key="weight" value="25.0" />
  381. <filament id="1" type="PLA" color="#FF0000" used_g="12.5" used_m="4.2" />
  382. <filament id="3" type="TPU" color="#0000FF" used_g="12.5" used_m="4.2" />
  383. <object identify_id="3" name="Part_C" skipped="false" />
  384. </plate>
  385. </config>
  386. """
  387. root = ET.fromstring(slice_info_xml)
  388. plates = root.findall(".//plate")
  389. assert len(plates) == 3
  390. # Parse each plate
  391. plate_data = []
  392. for plate_elem in plates:
  393. plate_info = {"index": None, "filaments": []}
  394. for meta in plate_elem.findall("metadata"):
  395. if meta.get("key") == "index":
  396. plate_info["index"] = int(meta.get("value"))
  397. for filament_elem in plate_elem.findall("filament"):
  398. used_g = float(filament_elem.get("used_g", "0"))
  399. if used_g > 0:
  400. plate_info["filaments"].append(
  401. {
  402. "slot_id": int(filament_elem.get("id")),
  403. "type": filament_elem.get("type"),
  404. "color": filament_elem.get("color"),
  405. "used_grams": used_g,
  406. }
  407. )
  408. plate_data.append(plate_info)
  409. # Verify plate 1
  410. assert plate_data[0]["index"] == 1
  411. assert len(plate_data[0]["filaments"]) == 1
  412. assert plate_data[0]["filaments"][0]["slot_id"] == 1
  413. assert plate_data[0]["filaments"][0]["type"] == "PLA"
  414. # Verify plate 2
  415. assert plate_data[1]["index"] == 2
  416. assert len(plate_data[1]["filaments"]) == 1
  417. assert plate_data[1]["filaments"][0]["slot_id"] == 2
  418. assert plate_data[1]["filaments"][0]["type"] == "PETG"
  419. # Verify plate 3 (has 2 filaments)
  420. assert plate_data[2]["index"] == 3
  421. assert len(plate_data[2]["filaments"]) == 2
  422. filament_types = {f["type"] for f in plate_data[2]["filaments"]}
  423. assert filament_types == {"PLA", "TPU"}
  424. def test_filter_filaments_by_plate_id(self):
  425. """Test filtering filaments for a specific plate."""
  426. from defusedxml import ElementTree as ET
  427. slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
  428. <config>
  429. <plate>
  430. <metadata key="index" value="1" />
  431. <filament id="1" type="PLA" color="#FF0000" used_g="25.0" />
  432. </plate>
  433. <plate>
  434. <metadata key="index" value="2" />
  435. <filament id="2" type="PETG" color="#00FF00" used_g="50.0" />
  436. </plate>
  437. </config>
  438. """
  439. root = ET.fromstring(slice_info_xml)
  440. # Filter for plate 2 only
  441. target_plate_id = 2
  442. filaments = []
  443. for plate_elem in root.findall(".//plate"):
  444. plate_index = None
  445. for meta in plate_elem.findall("metadata"):
  446. if meta.get("key") == "index":
  447. plate_index = int(meta.get("value", "0"))
  448. break
  449. if plate_index == target_plate_id:
  450. for filament_elem in plate_elem.findall("filament"):
  451. used_g = float(filament_elem.get("used_g", "0"))
  452. if used_g > 0:
  453. filaments.append(
  454. {
  455. "slot_id": int(filament_elem.get("id")),
  456. "type": filament_elem.get("type"),
  457. }
  458. )
  459. break
  460. # Should only have plate 2's filament
  461. assert len(filaments) == 1
  462. assert filaments[0]["slot_id"] == 2
  463. assert filaments[0]["type"] == "PETG"
  464. def test_detect_multi_plate_from_gcode_files(self):
  465. """Test detecting multiple plates from gcode file presence."""
  466. # Simulate namelist from a multi-plate 3MF
  467. namelist = [
  468. "Metadata/plate_1.gcode",
  469. "Metadata/plate_2.gcode",
  470. "Metadata/plate_3.gcode",
  471. "Metadata/plate_1.png",
  472. "Metadata/plate_2.png",
  473. "Metadata/plate_3.png",
  474. "Metadata/slice_info.config",
  475. "3D/3dmodel.model",
  476. ]
  477. # Extract plate indices from gcode files
  478. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  479. plate_indices = []
  480. for gf in gcode_files:
  481. plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
  482. plate_indices.append(int(plate_str))
  483. plate_indices.sort()
  484. assert len(plate_indices) == 3
  485. assert plate_indices == [1, 2, 3]
  486. # Verify it's a multi-plate file
  487. is_multi_plate = len(plate_indices) > 1
  488. assert is_multi_plate is True
  489. def test_single_plate_export_not_multi_plate(self):
  490. """Test that single-plate exports are not detected as multi-plate."""
  491. # Simulate namelist from a single-plate export (plate 5 only)
  492. namelist = [
  493. "Metadata/plate_5.gcode",
  494. "Metadata/plate_1.png",
  495. "Metadata/plate_2.png",
  496. "Metadata/plate_3.png",
  497. "Metadata/plate_4.png",
  498. "Metadata/plate_5.png", # All thumbnails present
  499. "Metadata/slice_info.config",
  500. "3D/3dmodel.model",
  501. ]
  502. # Extract plate indices from gcode files (not thumbnails!)
  503. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  504. plate_indices = []
  505. for gf in gcode_files:
  506. plate_str = gf[15:-6]
  507. plate_indices.append(int(plate_str))
  508. # Only one gcode file = single plate export
  509. assert len(plate_indices) == 1
  510. assert plate_indices[0] == 5
  511. is_multi_plate = len(plate_indices) > 1
  512. assert is_multi_plate is False
  513. class TestReprintCostCalculation:
  514. """Tests for reprint cost calculation."""
  515. def test_cost_addition_logic(self):
  516. """Test that reprint costs are added correctly."""
  517. # Simulate the cost addition logic
  518. existing_cost = 5.25 # Original print cost
  519. filament_grams = 100.0
  520. cost_per_kg = 25.0 # Default cost
  521. # Calculate additional cost for reprint
  522. additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  523. assert additional_cost == 2.50
  524. # Add to existing cost
  525. new_total = round(existing_cost + additional_cost, 2)
  526. assert new_total == 7.75
  527. def test_cost_addition_with_none_existing(self):
  528. """Test cost addition when existing cost is None."""
  529. existing_cost = None
  530. filament_grams = 200.0
  531. cost_per_kg = 15.0
  532. additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  533. assert additional_cost == 3.0
  534. # When existing is None, just use additional
  535. new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2)
  536. assert new_total == 3.0
  537. def test_cost_with_custom_filament_price(self):
  538. """Test cost calculation with custom filament price."""
  539. filament_grams = 150.0
  540. custom_cost_per_kg = 35.0 # More expensive filament
  541. cost = round((filament_grams / 1000) * custom_cost_per_kg, 2)
  542. assert cost == 5.25
  543. def test_multiple_reprints_accumulate(self):
  544. """Test that multiple reprints accumulate costs correctly."""
  545. filament_grams = 100.0
  546. cost_per_kg = 20.0
  547. single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2)
  548. assert single_print_cost == 2.0
  549. # After 3 prints (1 original + 2 reprints)
  550. total_after_3_prints = round(single_print_cost * 3, 2)
  551. assert total_after_3_prints == 6.0