test_threemf_tools.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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 math
  6. import pytest
  7. from backend.app.utils.threemf_tools import (
  8. get_cumulative_usage_at_layer,
  9. mm_to_grams,
  10. parse_gcode_layer_filament_usage,
  11. )
  12. class TestParseGcodeLayerFilamentUsage:
  13. """Tests for parse_gcode_layer_filament_usage()."""
  14. def test_single_filament_single_layer(self):
  15. """Single filament extruding on one layer."""
  16. gcode = """
  17. M620 S0
  18. G1 X10 Y10 E5.0
  19. G1 X20 Y20 E3.0
  20. """
  21. result = parse_gcode_layer_filament_usage(gcode)
  22. assert result == {0: {0: 8.0}}
  23. def test_multi_layer_single_filament(self):
  24. """Single filament across multiple layers."""
  25. gcode = """
  26. M620 S0
  27. G1 X10 Y10 E10.0
  28. M73 L1
  29. G1 X20 Y20 E5.0
  30. M73 L2
  31. G1 X30 Y30 E7.0
  32. """
  33. result = parse_gcode_layer_filament_usage(gcode)
  34. assert result[0] == {0: 10.0}
  35. assert result[1] == {0: 15.0}
  36. assert result[2] == {0: 22.0}
  37. def test_multi_material(self):
  38. """Multiple filaments switching via M620."""
  39. gcode = """
  40. M620 S0
  41. G1 E10.0
  42. M73 L1
  43. M620 S1
  44. G1 E5.0
  45. M620 S0
  46. G1 E3.0
  47. M73 L2
  48. G1 E2.0
  49. """
  50. result = parse_gcode_layer_filament_usage(gcode)
  51. # Layer 0: filament 0 = 10mm
  52. assert result[0] == {0: 10.0}
  53. # Layer 1: filament 0 = 13mm (10+3), filament 1 = 5mm
  54. assert result[1] == {0: 13.0, 1: 5.0}
  55. # Layer 2: filament 0 = 15mm (13+2)
  56. assert result[2] == {0: 15.0, 1: 5.0}
  57. def test_retractions_ignored(self):
  58. """Negative E values (retractions) should be ignored."""
  59. gcode = """
  60. M620 S0
  61. G1 E10.0
  62. G1 E-2.0
  63. G1 E5.0
  64. """
  65. result = parse_gcode_layer_filament_usage(gcode)
  66. assert result == {0: {0: 15.0}}
  67. def test_m620_s255_unloads(self):
  68. """M620 S255 means unload - extrusion after should be ignored."""
  69. gcode = """
  70. M620 S0
  71. G1 E10.0
  72. M620 S255
  73. G1 E5.0
  74. """
  75. result = parse_gcode_layer_filament_usage(gcode)
  76. assert result == {0: {0: 10.0}}
  77. def test_m620_with_suffix(self):
  78. """M620 S0A format (filament ID with suffix letter)."""
  79. gcode = """
  80. M620 S0A
  81. G1 E10.0
  82. M620 S1A
  83. G1 E5.0
  84. """
  85. result = parse_gcode_layer_filament_usage(gcode)
  86. assert result == {0: {0: 10.0, 1: 5.0}}
  87. def test_comments_ignored(self):
  88. """Comment lines and inline comments are ignored."""
  89. gcode = """
  90. ; This is a comment
  91. M620 S0
  92. G1 X10 E5.0 ; inline comment with E value
  93. G1 E3.0
  94. """
  95. result = parse_gcode_layer_filament_usage(gcode)
  96. assert result == {0: {0: 8.0}}
  97. def test_empty_gcode(self):
  98. """Empty G-code returns empty dict."""
  99. assert parse_gcode_layer_filament_usage("") == {}
  100. assert parse_gcode_layer_filament_usage("\n\n\n") == {}
  101. def test_no_extrusion(self):
  102. """G-code with moves but no extrusion."""
  103. gcode = """
  104. G1 X10 Y10
  105. G1 X20 Y20
  106. """
  107. assert parse_gcode_layer_filament_usage(gcode) == {}
  108. def test_no_active_filament_extrusion_ignored(self):
  109. """Extrusion before any M620 is ignored (no active filament)."""
  110. gcode = """
  111. G1 E10.0
  112. M620 S0
  113. G1 E5.0
  114. """
  115. result = parse_gcode_layer_filament_usage(gcode)
  116. assert result == {0: {0: 5.0}}
  117. def test_g0_g2_g3_extrusion(self):
  118. """G0, G2, G3 with E parameter are also tracked."""
  119. gcode = """
  120. M620 S0
  121. G0 E1.0
  122. G1 E2.0
  123. G2 E3.0
  124. G3 E4.0
  125. """
  126. result = parse_gcode_layer_filament_usage(gcode)
  127. assert result == {0: {0: 10.0}}
  128. def test_cumulative_across_layers(self):
  129. """Values are cumulative, not per-layer."""
  130. gcode = """
  131. M620 S0
  132. G1 E100.0
  133. M73 L1
  134. G1 E100.0
  135. M73 L2
  136. G1 E100.0
  137. """
  138. result = parse_gcode_layer_filament_usage(gcode)
  139. assert result[0] == {0: 100.0}
  140. assert result[1] == {0: 200.0}
  141. assert result[2] == {0: 300.0}
  142. class TestMmToGrams:
  143. """Tests for mm_to_grams()."""
  144. def test_default_pla_175(self):
  145. """Default PLA 1.75mm conversion."""
  146. # 1000mm of 1.75mm PLA at 1.24 g/cm³
  147. # Volume = π × (0.0875cm)² × 100cm = 2.405cm³
  148. # Weight = 2.405 × 1.24 = 2.982g
  149. result = mm_to_grams(1000.0)
  150. expected = math.pi * (0.0875**2) * 100 * 1.24
  151. assert abs(result - expected) < 0.001
  152. def test_zero_length(self):
  153. """Zero length returns zero weight."""
  154. assert mm_to_grams(0.0) == 0.0
  155. def test_custom_diameter(self):
  156. """Custom diameter (2.85mm) changes result."""
  157. result_175 = mm_to_grams(1000.0, diameter_mm=1.75)
  158. result_285 = mm_to_grams(1000.0, diameter_mm=2.85)
  159. # 2.85mm filament has more volume per mm
  160. assert result_285 > result_175
  161. ratio = (2.85 / 1.75) ** 2 # Volume scales with diameter²
  162. assert abs(result_285 / result_175 - ratio) < 0.001
  163. def test_custom_density(self):
  164. """Different density (ABS vs PLA)."""
  165. pla = mm_to_grams(1000.0, density_g_cm3=1.24)
  166. abs_ = mm_to_grams(1000.0, density_g_cm3=1.04)
  167. assert pla > abs_
  168. assert abs(pla / abs_ - 1.24 / 1.04) < 0.001
  169. def test_known_value(self):
  170. """Verify against a known calculation.
  171. 1m (1000mm) of 1.75mm PLA at 1.24 g/cm³:
  172. r = 0.0875 cm, L = 100 cm
  173. V = π × 0.0875² × 100 = 2.4053 cm³
  174. m = 2.4053 × 1.24 = 2.9826 g
  175. """
  176. result = mm_to_grams(1000.0, 1.75, 1.24)
  177. assert abs(result - 2.9826) < 0.01
  178. class TestGetCumulativeUsageAtLayer:
  179. """Tests for get_cumulative_usage_at_layer()."""
  180. def test_exact_layer_match(self):
  181. """Target layer exists exactly in the data."""
  182. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  183. assert get_cumulative_usage_at_layer(data, 5) == {0: 500.0}
  184. def test_between_layers(self):
  185. """Target is between recorded layers - uses the closest lower one."""
  186. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  187. # Layer 7 is between 5 and 10, should return layer 5's data
  188. assert get_cumulative_usage_at_layer(data, 7) == {0: 500.0}
  189. def test_beyond_last_layer(self):
  190. """Target is beyond the last recorded layer."""
  191. data = {0: {0: 100.0}, 5: {0: 500.0}}
  192. assert get_cumulative_usage_at_layer(data, 100) == {0: 500.0}
  193. def test_before_first_layer(self):
  194. """Target is before any recorded data."""
  195. data = {5: {0: 500.0}, 10: {0: 1000.0}}
  196. assert get_cumulative_usage_at_layer(data, 3) == {}
  197. def test_empty_data(self):
  198. """Empty layer_usage returns empty dict."""
  199. assert get_cumulative_usage_at_layer({}, 5) == {}
  200. def test_none_data(self):
  201. """None layer_usage returns empty dict."""
  202. assert get_cumulative_usage_at_layer(None, 5) == {}
  203. def test_multi_filament(self):
  204. """Multi-filament data at target layer."""
  205. data = {
  206. 0: {0: 50.0},
  207. 5: {0: 200.0, 1: 100.0},
  208. 10: {0: 400.0, 1: 250.0, 2: 50.0},
  209. }
  210. result = get_cumulative_usage_at_layer(data, 8)
  211. assert result == {0: 200.0, 1: 100.0}
  212. def test_layer_zero(self):
  213. """Target layer 0."""
  214. data = {0: {0: 10.0}, 1: {0: 20.0}}
  215. assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}