test_threemf_tools.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. """Unit tests for 3MF parsing utilities (threemf_tools.py).
  2. Tests G-code parsing, filament length-to-weight conversion,
  3. and cumulative layer usage lookup.
  4. """
  5. import io
  6. import math
  7. import zipfile
  8. from backend.app.utils.threemf_tools import (
  9. extract_filament_usage_from_3mf,
  10. get_cumulative_usage_at_layer,
  11. mm_to_grams,
  12. parse_gcode_layer_filament_usage,
  13. )
  14. def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
  15. """Create a mock 3MF file (ZIP) with slice_info.config content."""
  16. buffer = io.BytesIO()
  17. with zipfile.ZipFile(buffer, "w") as zf:
  18. zf.writestr("Metadata/slice_info.config", slice_info_content)
  19. buffer.seek(0)
  20. return buffer
  21. class TestParseGcodeLayerFilamentUsage:
  22. """Tests for parse_gcode_layer_filament_usage()."""
  23. def test_single_filament_single_layer(self):
  24. """Single filament extruding on one layer."""
  25. gcode = """
  26. M620 S0
  27. G1 X10 Y10 E5.0
  28. G1 X20 Y20 E3.0
  29. """
  30. result = parse_gcode_layer_filament_usage(gcode)
  31. assert result == {0: {0: 8.0}}
  32. def test_multi_layer_single_filament(self):
  33. """Single filament across multiple layers."""
  34. gcode = """
  35. M620 S0
  36. G1 X10 Y10 E10.0
  37. M73 L1
  38. G1 X20 Y20 E5.0
  39. M73 L2
  40. G1 X30 Y30 E7.0
  41. """
  42. result = parse_gcode_layer_filament_usage(gcode)
  43. assert result[0] == {0: 10.0}
  44. assert result[1] == {0: 15.0}
  45. assert result[2] == {0: 22.0}
  46. def test_multi_material(self):
  47. """Multiple filaments switching via M620."""
  48. gcode = """
  49. M620 S0
  50. G1 E10.0
  51. M73 L1
  52. M620 S1
  53. G1 E5.0
  54. M620 S0
  55. G1 E3.0
  56. M73 L2
  57. G1 E2.0
  58. """
  59. result = parse_gcode_layer_filament_usage(gcode)
  60. # Layer 0: filament 0 = 10mm
  61. assert result[0] == {0: 10.0}
  62. # Layer 1: filament 0 = 13mm (10+3), filament 1 = 5mm
  63. assert result[1] == {0: 13.0, 1: 5.0}
  64. # Layer 2: filament 0 = 15mm (13+2)
  65. assert result[2] == {0: 15.0, 1: 5.0}
  66. def test_retractions_ignored(self):
  67. """Negative E values (retractions) should be ignored."""
  68. gcode = """
  69. M620 S0
  70. G1 E10.0
  71. G1 E-2.0
  72. G1 E5.0
  73. """
  74. result = parse_gcode_layer_filament_usage(gcode)
  75. assert result == {0: {0: 15.0}}
  76. def test_m620_s255_unloads(self):
  77. """M620 S255 means unload - extrusion after should be ignored."""
  78. gcode = """
  79. M620 S0
  80. G1 E10.0
  81. M620 S255
  82. G1 E5.0
  83. """
  84. result = parse_gcode_layer_filament_usage(gcode)
  85. assert result == {0: {0: 10.0}}
  86. def test_m620_with_suffix(self):
  87. """M620 S0A format (filament ID with suffix letter)."""
  88. gcode = """
  89. M620 S0A
  90. G1 E10.0
  91. M620 S1A
  92. G1 E5.0
  93. """
  94. result = parse_gcode_layer_filament_usage(gcode)
  95. assert result == {0: {0: 10.0, 1: 5.0}}
  96. def test_comments_ignored(self):
  97. """Comment lines and inline comments are ignored."""
  98. gcode = """
  99. ; This is a comment
  100. M620 S0
  101. G1 X10 E5.0 ; inline comment with E value
  102. G1 E3.0
  103. """
  104. result = parse_gcode_layer_filament_usage(gcode)
  105. assert result == {0: {0: 8.0}}
  106. def test_empty_gcode(self):
  107. """Empty G-code returns empty dict."""
  108. assert parse_gcode_layer_filament_usage("") == {}
  109. assert parse_gcode_layer_filament_usage("\n\n\n") == {}
  110. def test_no_extrusion(self):
  111. """G-code with moves but no extrusion."""
  112. gcode = """
  113. G1 X10 Y10
  114. G1 X20 Y20
  115. """
  116. assert parse_gcode_layer_filament_usage(gcode) == {}
  117. def test_no_active_filament_extrusion_ignored(self):
  118. """Extrusion before any M620 is ignored (no active filament)."""
  119. gcode = """
  120. G1 E10.0
  121. M620 S0
  122. G1 E5.0
  123. """
  124. result = parse_gcode_layer_filament_usage(gcode)
  125. assert result == {0: {0: 5.0}}
  126. def test_g0_g2_g3_extrusion(self):
  127. """G0, G2, G3 with E parameter are also tracked."""
  128. gcode = """
  129. M620 S0
  130. G0 E1.0
  131. G1 E2.0
  132. G2 E3.0
  133. G3 E4.0
  134. """
  135. result = parse_gcode_layer_filament_usage(gcode)
  136. assert result == {0: {0: 10.0}}
  137. def test_cumulative_across_layers(self):
  138. """Values are cumulative, not per-layer."""
  139. gcode = """
  140. M620 S0
  141. G1 E100.0
  142. M73 L1
  143. G1 E100.0
  144. M73 L2
  145. G1 E100.0
  146. """
  147. result = parse_gcode_layer_filament_usage(gcode)
  148. assert result[0] == {0: 100.0}
  149. assert result[1] == {0: 200.0}
  150. assert result[2] == {0: 300.0}
  151. class TestMmToGrams:
  152. """Tests for mm_to_grams()."""
  153. def test_default_pla_175(self):
  154. """Default PLA 1.75mm conversion."""
  155. # 1000mm of 1.75mm PLA at 1.24 g/cm³
  156. # Volume = π × (0.0875cm)² × 100cm = 2.405cm³
  157. # Weight = 2.405 × 1.24 = 2.982g
  158. result = mm_to_grams(1000.0)
  159. expected = math.pi * (0.0875**2) * 100 * 1.24
  160. assert abs(result - expected) < 0.001
  161. def test_zero_length(self):
  162. """Zero length returns zero weight."""
  163. assert mm_to_grams(0.0) == 0.0
  164. def test_custom_diameter(self):
  165. """Custom diameter (2.85mm) changes result."""
  166. result_175 = mm_to_grams(1000.0, diameter_mm=1.75)
  167. result_285 = mm_to_grams(1000.0, diameter_mm=2.85)
  168. # 2.85mm filament has more volume per mm
  169. assert result_285 > result_175
  170. ratio = (2.85 / 1.75) ** 2 # Volume scales with diameter²
  171. assert abs(result_285 / result_175 - ratio) < 0.001
  172. def test_custom_density(self):
  173. """Different density (ABS vs PLA)."""
  174. pla = mm_to_grams(1000.0, density_g_cm3=1.24)
  175. abs_ = mm_to_grams(1000.0, density_g_cm3=1.04)
  176. assert pla > abs_
  177. assert abs(pla / abs_ - 1.24 / 1.04) < 0.001
  178. def test_known_value(self):
  179. """Verify against a known calculation.
  180. 1m (1000mm) of 1.75mm PLA at 1.24 g/cm³:
  181. r = 0.0875 cm, L = 100 cm
  182. V = π × 0.0875² × 100 = 2.4053 cm³
  183. m = 2.4053 × 1.24 = 2.9826 g
  184. """
  185. result = mm_to_grams(1000.0, 1.75, 1.24)
  186. assert abs(result - 2.9826) < 0.01
  187. class TestGetCumulativeUsageAtLayer:
  188. """Tests for get_cumulative_usage_at_layer()."""
  189. def test_exact_layer_match(self):
  190. """Target layer exists exactly in the data."""
  191. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  192. assert get_cumulative_usage_at_layer(data, 5) == {0: 500.0}
  193. def test_between_layers(self):
  194. """Target is between recorded layers - uses the closest lower one."""
  195. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  196. # Layer 7 is between 5 and 10, should return layer 5's data
  197. assert get_cumulative_usage_at_layer(data, 7) == {0: 500.0}
  198. def test_beyond_last_layer(self):
  199. """Target is beyond the last recorded layer."""
  200. data = {0: {0: 100.0}, 5: {0: 500.0}}
  201. assert get_cumulative_usage_at_layer(data, 100) == {0: 500.0}
  202. def test_before_first_layer(self):
  203. """Target is before any recorded data."""
  204. data = {5: {0: 500.0}, 10: {0: 1000.0}}
  205. assert get_cumulative_usage_at_layer(data, 3) == {}
  206. def test_empty_data(self):
  207. """Empty layer_usage returns empty dict."""
  208. assert get_cumulative_usage_at_layer({}, 5) == {}
  209. def test_none_data(self):
  210. """None layer_usage returns empty dict."""
  211. assert get_cumulative_usage_at_layer(None, 5) == {}
  212. def test_multi_filament(self):
  213. """Multi-filament data at target layer."""
  214. data = {
  215. 0: {0: 50.0},
  216. 5: {0: 200.0, 1: 100.0},
  217. 10: {0: 400.0, 1: 250.0, 2: 50.0},
  218. }
  219. result = get_cumulative_usage_at_layer(data, 8)
  220. assert result == {0: 200.0, 1: 100.0}
  221. def test_layer_zero(self):
  222. """Target layer 0."""
  223. data = {0: {0: 10.0}, 1: {0: 20.0}}
  224. assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
  225. class TestExtractFilamentUsageFrom3mf:
  226. """Tests for extract_filament_usage_from_3mf function."""
  227. def test_extract_single_filament(self, tmp_path):
  228. """Test extracting a single filament."""
  229. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  230. <config>
  231. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  232. </config>
  233. """
  234. mock_3mf = create_mock_3mf(xml_content)
  235. file_path = tmp_path / "test.3mf"
  236. file_path.write_bytes(mock_3mf.read())
  237. result = extract_filament_usage_from_3mf(file_path)
  238. assert len(result) == 1
  239. assert result[0]["slot_id"] == 1
  240. assert result[0]["used_g"] == 50.5
  241. assert result[0]["type"] == "PLA"
  242. assert result[0]["color"] == "#FF0000"
  243. def test_extract_multiple_filaments(self, tmp_path):
  244. """Test extracting multiple filaments."""
  245. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  246. <config>
  247. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  248. <filament id="2" used_g="30.2" type="PETG" color="#00FF00"/>
  249. <filament id="3" used_g="10.0" type="ABS" color="#0000FF"/>
  250. </config>
  251. """
  252. mock_3mf = create_mock_3mf(xml_content)
  253. file_path = tmp_path / "test.3mf"
  254. file_path.write_bytes(mock_3mf.read())
  255. result = extract_filament_usage_from_3mf(file_path)
  256. assert len(result) == 3
  257. assert result[0]["slot_id"] == 1
  258. assert result[1]["slot_id"] == 2
  259. assert result[2]["slot_id"] == 3
  260. def test_extract_filament_with_plate_id(self, tmp_path):
  261. """Test extracting filament for a specific plate."""
  262. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  263. <config>
  264. <plate>
  265. <metadata key="index" value="1"/>
  266. <filament id="1" used_g="25.0" type="PLA" color="#FF0000"/>
  267. </plate>
  268. <plate>
  269. <metadata key="index" value="2"/>
  270. <filament id="1" used_g="75.0" type="PETG" color="#00FF00"/>
  271. </plate>
  272. </config>
  273. """
  274. mock_3mf = create_mock_3mf(xml_content)
  275. file_path = tmp_path / "test.3mf"
  276. file_path.write_bytes(mock_3mf.read())
  277. result = extract_filament_usage_from_3mf(file_path, plate_id=2)
  278. assert len(result) == 1
  279. assert result[0]["used_g"] == 75.0
  280. assert result[0]["type"] == "PETG"
  281. def test_missing_slice_info_returns_empty(self, tmp_path):
  282. """Test that missing slice_info.config returns empty list."""
  283. buffer = io.BytesIO()
  284. with zipfile.ZipFile(buffer, "w") as zf:
  285. zf.writestr("other_file.txt", "content")
  286. buffer.seek(0)
  287. file_path = tmp_path / "test.3mf"
  288. file_path.write_bytes(buffer.read())
  289. result = extract_filament_usage_from_3mf(file_path)
  290. assert result == []
  291. def test_invalid_file_returns_empty(self, tmp_path):
  292. """Test that invalid file returns empty list."""
  293. file_path = tmp_path / "invalid.3mf"
  294. file_path.write_text("not a zip file")
  295. result = extract_filament_usage_from_3mf(file_path)
  296. assert result == []
  297. def test_nonexistent_file_returns_empty(self, tmp_path):
  298. """Test that nonexistent file returns empty list."""
  299. file_path = tmp_path / "nonexistent.3mf"
  300. result = extract_filament_usage_from_3mf(file_path)
  301. assert result == []
  302. def test_filament_without_id_is_skipped(self, tmp_path):
  303. """Test that filament without id is skipped."""
  304. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  305. <config>
  306. <filament used_g="50.5" type="PLA" color="#FF0000"/>
  307. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  308. </config>
  309. """
  310. mock_3mf = create_mock_3mf(xml_content)
  311. file_path = tmp_path / "test.3mf"
  312. file_path.write_bytes(mock_3mf.read())
  313. result = extract_filament_usage_from_3mf(file_path)
  314. assert len(result) == 1
  315. assert result[0]["slot_id"] == 2
  316. def test_invalid_used_g_is_skipped(self, tmp_path):
  317. """Test that filament with invalid used_g is skipped."""
  318. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  319. <config>
  320. <filament id="1" used_g="invalid" type="PLA" color="#FF0000"/>
  321. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  322. </config>
  323. """
  324. mock_3mf = create_mock_3mf(xml_content)
  325. file_path = tmp_path / "test.3mf"
  326. file_path.write_bytes(mock_3mf.read())
  327. result = extract_filament_usage_from_3mf(file_path)
  328. assert len(result) == 1
  329. assert result[0]["slot_id"] == 2
  330. def test_missing_optional_fields(self, tmp_path):
  331. """Test that missing type and color default to empty string."""
  332. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  333. <config>
  334. <filament id="1" used_g="50.5"/>
  335. </config>
  336. """
  337. mock_3mf = create_mock_3mf(xml_content)
  338. file_path = tmp_path / "test.3mf"
  339. file_path.write_bytes(mock_3mf.read())
  340. result = extract_filament_usage_from_3mf(file_path)
  341. assert len(result) == 1
  342. assert result[0]["type"] == ""
  343. assert result[0]["color"] == ""