test_threemf_tools.py 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306
  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 json
  7. import math
  8. import zipfile
  9. from backend.app.utils.threemf_tools import (
  10. expand_to_project_slots,
  11. extract_bed_type_from_3mf,
  12. extract_embedded_presets_from_3mf,
  13. extract_filament_usage_from_3mf,
  14. extract_plate_extruder_set_from_3mf,
  15. extract_print_time_from_3mf,
  16. extract_project_filaments_from_3mf,
  17. extract_support_filament_slots_from_3mf,
  18. get_cumulative_usage_at_layer,
  19. mm_to_grams,
  20. parse_gcode_layer_filament_usage,
  21. )
  22. def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
  23. """Create a mock 3MF file (ZIP) with slice_info.config content."""
  24. buffer = io.BytesIO()
  25. with zipfile.ZipFile(buffer, "w") as zf:
  26. zf.writestr("Metadata/slice_info.config", slice_info_content)
  27. buffer.seek(0)
  28. return buffer
  29. class TestParseGcodeLayerFilamentUsage:
  30. """Tests for parse_gcode_layer_filament_usage()."""
  31. def test_single_filament_single_layer(self):
  32. """Single filament extruding on one layer."""
  33. gcode = """
  34. M620 S0
  35. G1 X10 Y10 E5.0
  36. G1 X20 Y20 E3.0
  37. """
  38. result = parse_gcode_layer_filament_usage(gcode)
  39. assert result == {0: {0: 8.0}}
  40. def test_multi_layer_single_filament(self):
  41. """Single filament across multiple layers."""
  42. gcode = """
  43. M620 S0
  44. G1 X10 Y10 E10.0
  45. M73 L1
  46. G1 X20 Y20 E5.0
  47. M73 L2
  48. G1 X30 Y30 E7.0
  49. """
  50. result = parse_gcode_layer_filament_usage(gcode)
  51. assert result[0] == {0: 10.0}
  52. assert result[1] == {0: 15.0}
  53. assert result[2] == {0: 22.0}
  54. def test_multi_material(self):
  55. """Multiple filaments switching via M620."""
  56. gcode = """
  57. M620 S0
  58. G1 E10.0
  59. M73 L1
  60. M620 S1
  61. G1 E5.0
  62. M620 S0
  63. G1 E3.0
  64. M73 L2
  65. G1 E2.0
  66. """
  67. result = parse_gcode_layer_filament_usage(gcode)
  68. # Layer 0: filament 0 = 10mm
  69. assert result[0] == {0: 10.0}
  70. # Layer 1: filament 0 = 13mm (10+3), filament 1 = 5mm
  71. assert result[1] == {0: 13.0, 1: 5.0}
  72. # Layer 2: filament 0 = 15mm (13+2)
  73. assert result[2] == {0: 15.0, 1: 5.0}
  74. def test_retractions_ignored(self):
  75. """Negative E values (retractions) should be ignored."""
  76. gcode = """
  77. M620 S0
  78. G1 E10.0
  79. G1 E-2.0
  80. G1 E5.0
  81. """
  82. result = parse_gcode_layer_filament_usage(gcode)
  83. assert result == {0: {0: 15.0}}
  84. def test_m620_s255_unloads(self):
  85. """M620 S255 means unload - extrusion after should be ignored."""
  86. gcode = """
  87. M620 S0
  88. G1 E10.0
  89. M620 S255
  90. G1 E5.0
  91. """
  92. result = parse_gcode_layer_filament_usage(gcode)
  93. assert result == {0: {0: 10.0}}
  94. def test_m620_with_suffix(self):
  95. """M620 S0A format (filament ID with suffix letter)."""
  96. gcode = """
  97. M620 S0A
  98. G1 E10.0
  99. M620 S1A
  100. G1 E5.0
  101. """
  102. result = parse_gcode_layer_filament_usage(gcode)
  103. assert result == {0: {0: 10.0, 1: 5.0}}
  104. def test_comments_ignored(self):
  105. """Comment lines and inline comments are ignored."""
  106. gcode = """
  107. ; This is a comment
  108. M620 S0
  109. G1 X10 E5.0 ; inline comment with E value
  110. G1 E3.0
  111. """
  112. result = parse_gcode_layer_filament_usage(gcode)
  113. assert result == {0: {0: 8.0}}
  114. def test_empty_gcode(self):
  115. """Empty G-code returns empty dict."""
  116. assert parse_gcode_layer_filament_usage("") == {}
  117. assert parse_gcode_layer_filament_usage("\n\n\n") == {}
  118. def test_no_extrusion(self):
  119. """G-code with moves but no extrusion."""
  120. gcode = """
  121. G1 X10 Y10
  122. G1 X20 Y20
  123. """
  124. assert parse_gcode_layer_filament_usage(gcode) == {}
  125. def test_no_active_filament_extrusion_ignored(self):
  126. """Extrusion before any M620 is ignored (no active filament)."""
  127. gcode = """
  128. G1 E10.0
  129. M620 S0
  130. G1 E5.0
  131. """
  132. result = parse_gcode_layer_filament_usage(gcode)
  133. assert result == {0: {0: 5.0}}
  134. def test_g0_g2_g3_extrusion(self):
  135. """G0, G2, G3 with E parameter are also tracked."""
  136. gcode = """
  137. M620 S0
  138. G0 E1.0
  139. G1 E2.0
  140. G2 E3.0
  141. G3 E4.0
  142. """
  143. result = parse_gcode_layer_filament_usage(gcode)
  144. assert result == {0: {0: 10.0}}
  145. def test_cumulative_across_layers(self):
  146. """Values are cumulative, not per-layer."""
  147. gcode = """
  148. M620 S0
  149. G1 E100.0
  150. M73 L1
  151. G1 E100.0
  152. M73 L2
  153. G1 E100.0
  154. """
  155. result = parse_gcode_layer_filament_usage(gcode)
  156. assert result[0] == {0: 100.0}
  157. assert result[1] == {0: 200.0}
  158. assert result[2] == {0: 300.0}
  159. class TestMmToGrams:
  160. """Tests for mm_to_grams()."""
  161. def test_default_pla_175(self):
  162. """Default PLA 1.75mm conversion."""
  163. # 1000mm of 1.75mm PLA at 1.24 g/cm³
  164. # Volume = π × (0.0875cm)² × 100cm = 2.405cm³
  165. # Weight = 2.405 × 1.24 = 2.982g
  166. result = mm_to_grams(1000.0)
  167. expected = math.pi * (0.0875**2) * 100 * 1.24
  168. assert abs(result - expected) < 0.001
  169. def test_zero_length(self):
  170. """Zero length returns zero weight."""
  171. assert mm_to_grams(0.0) == 0.0
  172. def test_custom_diameter(self):
  173. """Custom diameter (2.85mm) changes result."""
  174. result_175 = mm_to_grams(1000.0, diameter_mm=1.75)
  175. result_285 = mm_to_grams(1000.0, diameter_mm=2.85)
  176. # 2.85mm filament has more volume per mm
  177. assert result_285 > result_175
  178. ratio = (2.85 / 1.75) ** 2 # Volume scales with diameter²
  179. assert abs(result_285 / result_175 - ratio) < 0.001
  180. def test_custom_density(self):
  181. """Different density (ABS vs PLA)."""
  182. pla = mm_to_grams(1000.0, density_g_cm3=1.24)
  183. abs_ = mm_to_grams(1000.0, density_g_cm3=1.04)
  184. assert pla > abs_
  185. assert abs(pla / abs_ - 1.24 / 1.04) < 0.001
  186. def test_known_value(self):
  187. """Verify against a known calculation.
  188. 1m (1000mm) of 1.75mm PLA at 1.24 g/cm³:
  189. r = 0.0875 cm, L = 100 cm
  190. V = π × 0.0875² × 100 = 2.4053 cm³
  191. m = 2.4053 × 1.24 = 2.9826 g
  192. """
  193. result = mm_to_grams(1000.0, 1.75, 1.24)
  194. assert abs(result - 2.9826) < 0.01
  195. class TestGetCumulativeUsageAtLayer:
  196. """Tests for get_cumulative_usage_at_layer()."""
  197. def test_exact_layer_match(self):
  198. """Target layer exists exactly in the data."""
  199. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  200. assert get_cumulative_usage_at_layer(data, 5) == {0: 500.0}
  201. def test_between_layers(self):
  202. """Target is between recorded layers - uses the closest lower one."""
  203. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  204. # Layer 7 is between 5 and 10, should return layer 5's data
  205. assert get_cumulative_usage_at_layer(data, 7) == {0: 500.0}
  206. def test_beyond_last_layer(self):
  207. """Target is beyond the last recorded layer."""
  208. data = {0: {0: 100.0}, 5: {0: 500.0}}
  209. assert get_cumulative_usage_at_layer(data, 100) == {0: 500.0}
  210. def test_before_first_layer(self):
  211. """Target is before any recorded data."""
  212. data = {5: {0: 500.0}, 10: {0: 1000.0}}
  213. assert get_cumulative_usage_at_layer(data, 3) == {}
  214. def test_empty_data(self):
  215. """Empty layer_usage returns empty dict."""
  216. assert get_cumulative_usage_at_layer({}, 5) == {}
  217. def test_none_data(self):
  218. """None layer_usage returns empty dict."""
  219. assert get_cumulative_usage_at_layer(None, 5) == {}
  220. def test_multi_filament(self):
  221. """Multi-filament data at target layer."""
  222. data = {
  223. 0: {0: 50.0},
  224. 5: {0: 200.0, 1: 100.0},
  225. 10: {0: 400.0, 1: 250.0, 2: 50.0},
  226. }
  227. result = get_cumulative_usage_at_layer(data, 8)
  228. assert result == {0: 200.0, 1: 100.0}
  229. def test_layer_zero(self):
  230. """Target layer 0."""
  231. data = {0: {0: 10.0}, 1: {0: 20.0}}
  232. assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
  233. class TestExtractFilamentUsageFrom3mf:
  234. """Tests for extract_filament_usage_from_3mf function."""
  235. def test_extract_single_filament(self, tmp_path):
  236. """Test extracting a single filament."""
  237. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  238. <config>
  239. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  240. </config>
  241. """
  242. mock_3mf = create_mock_3mf(xml_content)
  243. file_path = tmp_path / "test.3mf"
  244. file_path.write_bytes(mock_3mf.read())
  245. result = extract_filament_usage_from_3mf(file_path)
  246. assert len(result) == 1
  247. assert result[0]["slot_id"] == 1
  248. assert result[0]["used_g"] == 50.5
  249. assert result[0]["type"] == "PLA"
  250. assert result[0]["color"] == "#FF0000"
  251. def test_extract_multiple_filaments(self, tmp_path):
  252. """Test extracting multiple filaments."""
  253. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  254. <config>
  255. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  256. <filament id="2" used_g="30.2" type="PETG" color="#00FF00"/>
  257. <filament id="3" used_g="10.0" type="ABS" color="#0000FF"/>
  258. </config>
  259. """
  260. mock_3mf = create_mock_3mf(xml_content)
  261. file_path = tmp_path / "test.3mf"
  262. file_path.write_bytes(mock_3mf.read())
  263. result = extract_filament_usage_from_3mf(file_path)
  264. assert len(result) == 3
  265. assert result[0]["slot_id"] == 1
  266. assert result[1]["slot_id"] == 2
  267. assert result[2]["slot_id"] == 3
  268. def test_extract_filament_with_plate_id(self, tmp_path):
  269. """Test extracting filament for a specific plate."""
  270. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  271. <config>
  272. <plate>
  273. <metadata key="index" value="1"/>
  274. <filament id="1" used_g="25.0" type="PLA" color="#FF0000"/>
  275. </plate>
  276. <plate>
  277. <metadata key="index" value="2"/>
  278. <filament id="1" used_g="75.0" type="PETG" color="#00FF00"/>
  279. </plate>
  280. </config>
  281. """
  282. mock_3mf = create_mock_3mf(xml_content)
  283. file_path = tmp_path / "test.3mf"
  284. file_path.write_bytes(mock_3mf.read())
  285. result = extract_filament_usage_from_3mf(file_path, plate_id=2)
  286. assert len(result) == 1
  287. assert result[0]["used_g"] == 75.0
  288. assert result[0]["type"] == "PETG"
  289. def test_missing_slice_info_returns_empty(self, tmp_path):
  290. """Test that missing slice_info.config returns empty list."""
  291. buffer = io.BytesIO()
  292. with zipfile.ZipFile(buffer, "w") as zf:
  293. zf.writestr("other_file.txt", "content")
  294. buffer.seek(0)
  295. file_path = tmp_path / "test.3mf"
  296. file_path.write_bytes(buffer.read())
  297. result = extract_filament_usage_from_3mf(file_path)
  298. assert result == []
  299. def test_invalid_file_returns_empty(self, tmp_path):
  300. """Test that invalid file returns empty list."""
  301. file_path = tmp_path / "invalid.3mf"
  302. file_path.write_text("not a zip file")
  303. result = extract_filament_usage_from_3mf(file_path)
  304. assert result == []
  305. def test_nonexistent_file_returns_empty(self, tmp_path):
  306. """Test that nonexistent file returns empty list."""
  307. file_path = tmp_path / "nonexistent.3mf"
  308. result = extract_filament_usage_from_3mf(file_path)
  309. assert result == []
  310. def test_filament_without_id_is_skipped(self, tmp_path):
  311. """Test that filament without id is skipped."""
  312. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  313. <config>
  314. <filament used_g="50.5" type="PLA" color="#FF0000"/>
  315. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  316. </config>
  317. """
  318. mock_3mf = create_mock_3mf(xml_content)
  319. file_path = tmp_path / "test.3mf"
  320. file_path.write_bytes(mock_3mf.read())
  321. result = extract_filament_usage_from_3mf(file_path)
  322. assert len(result) == 1
  323. assert result[0]["slot_id"] == 2
  324. def test_invalid_used_g_is_skipped(self, tmp_path):
  325. """Test that filament with invalid used_g is skipped."""
  326. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  327. <config>
  328. <filament id="1" used_g="invalid" type="PLA" color="#FF0000"/>
  329. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  330. </config>
  331. """
  332. mock_3mf = create_mock_3mf(xml_content)
  333. file_path = tmp_path / "test.3mf"
  334. file_path.write_bytes(mock_3mf.read())
  335. result = extract_filament_usage_from_3mf(file_path)
  336. assert len(result) == 1
  337. assert result[0]["slot_id"] == 2
  338. def test_missing_optional_fields(self, tmp_path):
  339. """Test that missing type and color default to empty string."""
  340. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  341. <config>
  342. <filament id="1" used_g="50.5"/>
  343. </config>
  344. """
  345. mock_3mf = create_mock_3mf(xml_content)
  346. file_path = tmp_path / "test.3mf"
  347. file_path.write_bytes(mock_3mf.read())
  348. result = extract_filament_usage_from_3mf(file_path)
  349. assert len(result) == 1
  350. assert result[0]["type"] == ""
  351. assert result[0]["color"] == ""
  352. # ---------------------------------------------------------------------------
  353. # Tests for extract_project_filaments_from_3mf — used by the slice modal as
  354. # fallback when the sidecar can't run a preview slice.
  355. # ---------------------------------------------------------------------------
  356. def _make_3mf_with(files: dict[str, bytes | str]) -> zipfile.ZipFile:
  357. buf = io.BytesIO()
  358. with zipfile.ZipFile(buf, "w") as zf:
  359. for name, content in files.items():
  360. zf.writestr(name, content if isinstance(content, (bytes, str)) else str(content))
  361. buf.seek(0)
  362. return zipfile.ZipFile(buf, "r")
  363. class TestExtractProjectFilamentsFrom3mf:
  364. """The helper backfills the slice modal when slice_info.config is empty
  365. (raw project files) and the sidecar is unreachable."""
  366. def test_returns_empty_when_project_settings_missing(self):
  367. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  368. assert extract_project_filaments_from_3mf(zf) == []
  369. def test_happy_path_returns_one_entry_per_slot(self):
  370. proj = {
  371. "filament_type": ["PLA", "PETG"],
  372. "filament_colour": ["#000000", "#FFFFFF"],
  373. }
  374. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  375. out = extract_project_filaments_from_3mf(zf)
  376. assert [(f["slot_id"], f["type"], f["color"]) for f in out] == [
  377. (1, "PLA", "#000000"),
  378. (2, "PETG", "#FFFFFF"),
  379. ]
  380. def test_mismatched_array_lengths_use_max_with_blanks(self):
  381. proj = {
  382. "filament_type": ["PLA", "PETG", "ABS"],
  383. "filament_colour": ["#000000"],
  384. }
  385. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  386. out = extract_project_filaments_from_3mf(zf)
  387. assert len(out) == 3
  388. assert out[0]["color"] == "#000000"
  389. assert out[1]["color"] == ""
  390. assert out[2]["color"] == ""
  391. def test_corrupt_json_returns_empty_no_exception(self):
  392. with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
  393. assert extract_project_filaments_from_3mf(zf) == []
  394. def test_root_is_list_returns_empty(self):
  395. # Defensive: spec says it's a dict, but a file shipping a top-level
  396. # list (or anything non-dict) shouldn't crash the modal.
  397. with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
  398. assert extract_project_filaments_from_3mf(zf) == []
  399. def test_empty_arrays_returns_empty(self):
  400. proj = {"filament_type": [], "filament_colour": []}
  401. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  402. assert extract_project_filaments_from_3mf(zf) == []
  403. # ---------------------------------------------------------------------------
  404. # Tests for expand_to_project_slots — #2712
  405. # ---------------------------------------------------------------------------
  406. class TestExpandToProjectSlots:
  407. """The slice modal's filament list is positional: index 0 is slot 1, all
  408. the way through to the ``filament_N.json`` parts handed to the slicer.
  409. A MakerWorld source that carries slice_info but paints with slot 4 alone
  410. used to yield a one-row list, so the user's single pick was bound to slot
  411. 1 and slot 4 — the slot that actually prints — kept the source's embedded
  412. default. Picking PETG produced a PLA print.
  413. """
  414. PROJECT = json.dumps(
  415. {
  416. "filament_type": ["PLA", "PLA", "PLA", "PLA"],
  417. "filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
  418. }
  419. )
  420. def test_a_single_used_slot_still_produces_a_full_positional_list(self):
  421. """The reported file: four project slots, only slot 4 printed."""
  422. used = [
  423. {
  424. "slot_id": 4,
  425. "type": "PLA",
  426. "color": "#898989",
  427. "used_grams": 105.9,
  428. "used_meters": 35.51,
  429. "tray_info_idx": "GFL99",
  430. "used_in_plate": True,
  431. }
  432. ]
  433. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  434. out = expand_to_project_slots(zf, used)
  435. assert [f["slot_id"] for f in out] == [1, 2, 3, 4]
  436. assert [f["used_in_plate"] for f in out] == [False, False, False, True]
  437. def test_the_used_row_keeps_its_usage_figures(self):
  438. """The modal shows the real weight and colour, and the print path
  439. downstream reads ``tray_info_idx`` — none of it may be flattened into
  440. a zeroed project row."""
  441. used = [
  442. {
  443. "slot_id": 4,
  444. "type": "PETG",
  445. "color": "#FF0000",
  446. "used_grams": 105.9,
  447. "used_meters": 35.51,
  448. "tray_info_idx": "GFL99",
  449. "used_in_plate": True,
  450. }
  451. ]
  452. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  453. out = expand_to_project_slots(zf, used)
  454. slot4 = out[3]
  455. assert slot4["used_grams"] == 105.9
  456. assert slot4["tray_info_idx"] == "GFL99"
  457. # Resolved from the slice, not the project's stale PLA/#898989.
  458. assert (slot4["type"], slot4["color"]) == ("PETG", "#FF0000")
  459. def test_padding_rows_carry_the_project_type_and_colour(self):
  460. """They drive the modal's pre-pick for the disabled rows."""
  461. used = [{"slot_id": 4, "type": "PLA", "color": "#898989", "used_grams": 1.0, "used_meters": 1.0}]
  462. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  463. out = expand_to_project_slots(zf, used)
  464. assert (out[0]["type"], out[0]["color"]) == ("PLA", "#38CC0A")
  465. assert out[0]["used_grams"] == 0
  466. def test_every_slot_used_is_a_shape_change_only(self):
  467. used = [
  468. {"slot_id": i, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0, "used_in_plate": True}
  469. for i in (1, 2, 3, 4)
  470. ]
  471. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  472. out = expand_to_project_slots(zf, used)
  473. assert len(out) == 4
  474. assert all(f["used_in_plate"] for f in out)
  475. assert all(f["used_grams"] == 5.0 for f in out)
  476. def test_a_used_slot_beyond_the_project_list_is_kept(self):
  477. """Dropping it would recreate the original bug on a file whose
  478. project settings and slice_info disagree — the one slot that prints
  479. would vanish from the list entirely."""
  480. used = [{"slot_id": 9, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
  481. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  482. out = expand_to_project_slots(zf, used)
  483. assert [f["slot_id"] for f in out] == [1, 2, 3, 4, 9]
  484. assert out[-1]["used_in_plate"] is True
  485. def test_returns_the_input_unchanged_without_project_settings(self):
  486. """Nothing to widen against — a narrow list still prints correctly,
  487. an invented one might not."""
  488. used = [{"slot_id": 4, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
  489. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  490. assert expand_to_project_slots(zf, used) == used
  491. # ---------------------------------------------------------------------------
  492. # Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
  493. # object top-level extruder, per-part extruder, painted-face quadtree leaves.
  494. # ---------------------------------------------------------------------------
  495. def _model_settings(plate_id: int, objects: list[dict]) -> str:
  496. """Build a minimal model_settings.config XML for tests. Each object dict
  497. can have: id, extruder (top-level), parts (list of {extruder}).
  498. The plate references all object ids."""
  499. parts_xml = []
  500. for obj in objects:
  501. oid = obj["id"]
  502. ext = obj.get("extruder")
  503. parts = obj.get("parts", [])
  504. ext_meta = f'<metadata key="extruder" value="{ext}"/>' if ext is not None else ""
  505. part_blocks = "".join(
  506. f'<part id="{i}" subtype="normal_part"><metadata key="extruder" value="{p["extruder"]}"/></part>'
  507. for i, p in enumerate(parts)
  508. if p.get("extruder") is not None
  509. )
  510. parts_xml.append(f'<object id="{oid}"><metadata key="name" value="o{oid}"/>{ext_meta}{part_blocks}</object>')
  511. instances = "".join(
  512. f'<model_instance><metadata key="object_id" value="{o["id"]}"/></model_instance>' for o in objects
  513. )
  514. plate = f'<plate><metadata key="plater_id" value="{plate_id}"/>{instances}</plate>'
  515. return f'<?xml version="1.0"?><config>{"".join(parts_xml)}{plate}</config>'
  516. class TestExtractPlateExtruderSetFrom3mf:
  517. def test_returns_empty_set_when_model_settings_missing(self):
  518. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  519. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  520. def test_object_top_level_extruder_only(self):
  521. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
  522. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  523. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
  524. def test_per_part_extruder_unions_with_top_level(self):
  525. # Object's default is 1; one of its parts overrides to 3 (multi-color
  526. # via a sub-mesh). Union both — the slicer needs profiles for both.
  527. xml = _model_settings(
  528. plate_id=1,
  529. objects=[{"id": "10", "extruder": 1, "parts": [{"extruder": 3}]}],
  530. )
  531. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  532. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 3}
  533. def test_unknown_plate_id_returns_empty_set(self):
  534. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
  535. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  536. assert extract_plate_extruder_set_from_3mf(zf, plate_id=99) == set()
  537. def test_corrupt_xml_returns_empty_set_no_exception(self):
  538. with _make_3mf_with({"Metadata/model_settings.config": "<not valid xml"}) as zf:
  539. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  540. def test_zero_extruder_value_ignored(self):
  541. # Bambu's 0 means "use object default" — not a real slot.
  542. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 0}])
  543. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  544. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  545. def test_painted_face_above_threshold_kept(self):
  546. # 60/40 split: 60 triangles painted with extruder 1, 40 with ext 2.
  547. # Threshold is 5%; both above. The dominant ones are real colours.
  548. triangles = []
  549. for _ in range(60):
  550. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
  551. for _ in range(40):
  552. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="2"/>')
  553. per_obj = (
  554. '<?xml version="1.0"?>'
  555. '<model><resources><object id="100" type="model"><mesh>'
  556. "<triangles>" + "".join(triangles) + "</triangles>"
  557. "</mesh></object></resources><build/></model>"
  558. )
  559. ms = (
  560. '<?xml version="1.0"?><config>'
  561. '<object id="10"><metadata key="name" value="o"/></object>'
  562. '<plate><metadata key="plater_id" value="1"/>'
  563. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  564. "</plate></config>"
  565. )
  566. threed = (
  567. '<?xml version="1.0"?>'
  568. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  569. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  570. "<resources>"
  571. '<object id="10" type="model"><components>'
  572. '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
  573. "</components></object>"
  574. "</resources><build/></model>"
  575. )
  576. with _make_3mf_with(
  577. {
  578. "Metadata/model_settings.config": ms,
  579. "3D/3dmodel.model": threed,
  580. "3D/Objects/o100.model": per_obj,
  581. }
  582. ) as zf:
  583. result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
  584. # Both real colours kept (60/40 well above 5% threshold); the dropped
  585. # threshold case is the regression that motivates this test.
  586. assert result == {1, 2}
  587. def test_painted_face_below_threshold_dropped_as_noise(self):
  588. # 99 triangles at ext 1, 1 triangle at ext 9 (1% — below 5%
  589. # threshold). The 1% leaf is a single-leaf accident.
  590. triangles = []
  591. for _ in range(99):
  592. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
  593. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="9"/>')
  594. per_obj = (
  595. '<?xml version="1.0"?>'
  596. '<model><resources><object id="100" type="model"><mesh>'
  597. "<triangles>" + "".join(triangles) + "</triangles>"
  598. "</mesh></object></resources><build/></model>"
  599. )
  600. ms = (
  601. '<?xml version="1.0"?><config>'
  602. '<object id="10"><metadata key="name" value="o"/></object>'
  603. '<plate><metadata key="plater_id" value="1"/>'
  604. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  605. "</plate></config>"
  606. )
  607. threed = (
  608. '<?xml version="1.0"?>'
  609. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  610. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  611. '<resources><object id="10" type="model"><components>'
  612. '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
  613. "</components></object></resources><build/></model>"
  614. )
  615. with _make_3mf_with(
  616. {
  617. "Metadata/model_settings.config": ms,
  618. "3D/3dmodel.model": threed,
  619. "3D/Objects/o100.model": per_obj,
  620. }
  621. ) as zf:
  622. result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
  623. # Single-leaf accident at 1% filtered as noise; only the dominant
  624. # extruder survives.
  625. assert result == {1}
  626. def test_missing_per_object_model_file_silently_skipped(self):
  627. ms = (
  628. '<?xml version="1.0"?><config>'
  629. '<object id="10"><metadata key="extruder" value="2"/></object>'
  630. '<plate><metadata key="plater_id" value="1"/>'
  631. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  632. "</plate></config>"
  633. )
  634. threed = (
  635. '<?xml version="1.0"?>'
  636. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  637. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  638. '<resources><object id="10" type="model"><components>'
  639. '<component p:path="/3D/Objects/missing.model" objectid="999"/>'
  640. "</components></object></resources><build/></model>"
  641. )
  642. with _make_3mf_with(
  643. {"Metadata/model_settings.config": ms, "3D/3dmodel.model": threed},
  644. ) as zf:
  645. # Top-level metadata still works; missing component model file
  646. # is silently skipped without crashing.
  647. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
  648. class TestExtractEmbeddedPresetsFrom3mf:
  649. """Printer / process preset names read from project_settings.config so the
  650. SliceModal can default its dropdowns to the file's own config (#1325)."""
  651. def test_extracts_printer_and_process(self):
  652. config = json.dumps(
  653. {
  654. "printer_settings_id": "Bambu Lab X1 Carbon 0.4 nozzle",
  655. "print_settings_id": "0.20mm Standard @BBL X1C",
  656. "filament_settings_id": ["Bambu PLA Basic @BBL X1C"],
  657. }
  658. )
  659. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  660. assert extract_embedded_presets_from_3mf(zf) == {
  661. "printer": "Bambu Lab X1 Carbon 0.4 nozzle",
  662. "process": "0.20mm Standard @BBL X1C",
  663. }
  664. def test_settings_id_as_list_takes_first(self):
  665. # Some exports write *_settings_id as a per-extruder list.
  666. config = json.dumps(
  667. {
  668. "printer_settings_id": ["Bambu Lab A1 0.4 nozzle"],
  669. "print_settings_id": ["0.16mm Optimal @BBL A1", "0.20mm @BBL A1"],
  670. }
  671. )
  672. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  673. result = extract_embedded_presets_from_3mf(zf)
  674. assert result["printer"] == "Bambu Lab A1 0.4 nozzle"
  675. assert result["process"] == "0.16mm Optimal @BBL A1"
  676. def test_missing_config_returns_none_values(self):
  677. with _make_3mf_with({"3D/3dmodel.model": "<model/>"}) as zf:
  678. assert extract_embedded_presets_from_3mf(zf) == {
  679. "printer": None,
  680. "process": None,
  681. }
  682. def test_malformed_json_returns_none_values(self):
  683. with _make_3mf_with({"Metadata/project_settings.config": "not json"}) as zf:
  684. assert extract_embedded_presets_from_3mf(zf) == {
  685. "printer": None,
  686. "process": None,
  687. }
  688. def test_blank_and_absent_keys_yield_none(self):
  689. config = json.dumps({"printer_settings_id": " ", "other": "x"})
  690. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  691. assert extract_embedded_presets_from_3mf(zf) == {
  692. "printer": None,
  693. "process": None,
  694. }
  695. class TestExtractBedTypeFrom3mf:
  696. """extract_bed_type_from_3mf reads per-plate `curr_bed_type` from
  697. slice_info.config so the queue / print modal can show the right plate
  698. even on multi-plate 3MFs where different plates target different beds
  699. (#1281). archive.bed_type is one-value-per-archive (first plate's
  700. curr_bed_type — see services/archive.py:235), so for accurate
  701. per-plate surfacing we have to re-read the 3MF."""
  702. def test_single_plate_returns_bed_type(self, tmp_path):
  703. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  704. <config>
  705. <plate>
  706. <metadata key="index" value="1"/>
  707. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  708. </plate>
  709. </config>
  710. """
  711. file_path = tmp_path / "test.3mf"
  712. file_path.write_bytes(create_mock_3mf(xml_content).read())
  713. assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
  714. def test_multi_plate_returns_per_plate_value(self, tmp_path):
  715. # Reporter's case: a 3MF mixing PEI + Engineering across plates.
  716. # Looking up by plate_id must return THAT plate's value, not the
  717. # first plate's value the archive happens to cache.
  718. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  719. <config>
  720. <plate>
  721. <metadata key="index" value="1"/>
  722. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  723. </plate>
  724. <plate>
  725. <metadata key="index" value="2"/>
  726. <metadata key="curr_bed_type" value="Engineering Plate"/>
  727. </plate>
  728. <plate>
  729. <metadata key="index" value="3"/>
  730. <metadata key="curr_bed_type" value="Cool Plate"/>
  731. </plate>
  732. </config>
  733. """
  734. file_path = tmp_path / "test.3mf"
  735. file_path.write_bytes(create_mock_3mf(xml_content).read())
  736. assert extract_bed_type_from_3mf(file_path, plate_id=1) == "Textured PEI Plate"
  737. assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
  738. assert extract_bed_type_from_3mf(file_path, plate_id=3) == "Cool Plate"
  739. def test_no_plate_id_returns_first_plate(self, tmp_path):
  740. # The plate_id=None branch must match the archive-level capture
  741. # convention (first plate wins) so callers that don't care about
  742. # plate selection see the same value the archive table holds.
  743. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  744. <config>
  745. <plate>
  746. <metadata key="index" value="1"/>
  747. <metadata key="curr_bed_type" value="Cool Plate SuperTack"/>
  748. </plate>
  749. <plate>
  750. <metadata key="index" value="2"/>
  751. <metadata key="curr_bed_type" value="Engineering Plate"/>
  752. </plate>
  753. </config>
  754. """
  755. file_path = tmp_path / "test.3mf"
  756. file_path.write_bytes(create_mock_3mf(xml_content).read())
  757. assert extract_bed_type_from_3mf(file_path) == "Cool Plate SuperTack"
  758. def test_unknown_plate_id_returns_none(self, tmp_path):
  759. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  760. <config>
  761. <plate>
  762. <metadata key="index" value="1"/>
  763. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  764. </plate>
  765. </config>
  766. """
  767. file_path = tmp_path / "test.3mf"
  768. file_path.write_bytes(create_mock_3mf(xml_content).read())
  769. assert extract_bed_type_from_3mf(file_path, plate_id=99) is None
  770. def test_plate_without_bed_type_returns_none(self, tmp_path):
  771. # Older slicers may export a plate without curr_bed_type. The
  772. # helper must return None rather than falling through to another
  773. # plate's value (which would silently lie).
  774. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  775. <config>
  776. <plate>
  777. <metadata key="index" value="1"/>
  778. </plate>
  779. <plate>
  780. <metadata key="index" value="2"/>
  781. <metadata key="curr_bed_type" value="Engineering Plate"/>
  782. </plate>
  783. </config>
  784. """
  785. file_path = tmp_path / "test.3mf"
  786. file_path.write_bytes(create_mock_3mf(xml_content).read())
  787. assert extract_bed_type_from_3mf(file_path, plate_id=1) is None
  788. assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
  789. def test_missing_slice_info_returns_none(self, tmp_path):
  790. buffer = io.BytesIO()
  791. with zipfile.ZipFile(buffer, "w") as zf:
  792. zf.writestr("other_file.txt", "content")
  793. buffer.seek(0)
  794. file_path = tmp_path / "test.3mf"
  795. file_path.write_bytes(buffer.read())
  796. assert extract_bed_type_from_3mf(file_path) is None
  797. def test_invalid_file_returns_none(self, tmp_path):
  798. file_path = tmp_path / "invalid.3mf"
  799. file_path.write_text("not a zip file")
  800. assert extract_bed_type_from_3mf(file_path) is None
  801. def test_whitespace_trimmed(self, tmp_path):
  802. # 3MF values sometimes carry surrounding whitespace from manual
  803. # template tweaks; getBedTypeInfo() on the frontend is also
  804. # whitespace-tolerant, but the wire shape should be clean.
  805. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  806. <config>
  807. <plate>
  808. <metadata key="index" value="1"/>
  809. <metadata key="curr_bed_type" value=" Textured PEI Plate "/>
  810. </plate>
  811. </config>
  812. """
  813. file_path = tmp_path / "test.3mf"
  814. file_path.write_bytes(create_mock_3mf(xml_content).read())
  815. assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
  816. class TestExtractPrintTimeFrom3mf:
  817. """Tests for extract_print_time_from_3mf — the per-plate `prediction` reader
  818. used by the completion notification path to scope the archive-level (summed)
  819. total down to the actually-printed plate (#1785)."""
  820. def test_returns_plate_prediction_when_plate_id_matches(self, tmp_path):
  821. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  822. <config>
  823. <plate>
  824. <metadata key="index" value="1"/>
  825. <metadata key="prediction" value="3600"/>
  826. </plate>
  827. <plate>
  828. <metadata key="index" value="2"/>
  829. <metadata key="prediction" value="7200"/>
  830. </plate>
  831. <plate>
  832. <metadata key="index" value="3"/>
  833. <metadata key="prediction" value="10800"/>
  834. </plate>
  835. </config>
  836. """
  837. file_path = tmp_path / "test.3mf"
  838. file_path.write_bytes(create_mock_3mf(xml_content).read())
  839. assert extract_print_time_from_3mf(file_path, plate_id=2) == 7200
  840. def test_returns_first_plate_when_no_plate_id(self, tmp_path):
  841. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  842. <config>
  843. <plate>
  844. <metadata key="index" value="1"/>
  845. <metadata key="prediction" value="900"/>
  846. </plate>
  847. <plate>
  848. <metadata key="index" value="2"/>
  849. <metadata key="prediction" value="1800"/>
  850. </plate>
  851. </config>
  852. """
  853. file_path = tmp_path / "test.3mf"
  854. file_path.write_bytes(create_mock_3mf(xml_content).read())
  855. assert extract_print_time_from_3mf(file_path) == 900
  856. def test_returns_none_when_plate_id_missing(self, tmp_path):
  857. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  858. <config>
  859. <plate>
  860. <metadata key="index" value="1"/>
  861. <metadata key="prediction" value="3600"/>
  862. </plate>
  863. </config>
  864. """
  865. file_path = tmp_path / "test.3mf"
  866. file_path.write_bytes(create_mock_3mf(xml_content).read())
  867. assert extract_print_time_from_3mf(file_path, plate_id=5) is None
  868. def test_returns_none_when_prediction_unparseable(self, tmp_path):
  869. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  870. <config>
  871. <plate>
  872. <metadata key="index" value="1"/>
  873. <metadata key="prediction" value="not-a-number"/>
  874. </plate>
  875. </config>
  876. """
  877. file_path = tmp_path / "test.3mf"
  878. file_path.write_bytes(create_mock_3mf(xml_content).read())
  879. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  880. def test_returns_none_when_slice_info_missing(self, tmp_path):
  881. buffer = io.BytesIO()
  882. with zipfile.ZipFile(buffer, "w") as zf:
  883. zf.writestr("other_file.txt", "content")
  884. buffer.seek(0)
  885. file_path = tmp_path / "test.3mf"
  886. file_path.write_bytes(buffer.read())
  887. assert extract_print_time_from_3mf(file_path) is None
  888. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  889. def test_returns_none_when_file_invalid(self, tmp_path):
  890. file_path = tmp_path / "invalid.3mf"
  891. file_path.write_text("not a zip file")
  892. assert extract_print_time_from_3mf(file_path) is None
  893. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  894. def test_returns_none_when_file_missing(self, tmp_path):
  895. file_path = tmp_path / "nonexistent.3mf"
  896. assert extract_print_time_from_3mf(file_path) is None
  897. assert extract_print_time_from_3mf(file_path, plate_id=2) is None
  898. # ---------------------------------------------------------------------------
  899. # Tests for extract_support_filament_slots_from_3mf — #1881: a plate that uses
  900. # PVA (or any material) exclusively for supports doesn't reference the support
  901. # slot from object geometry, so without this helper substitute_unused_plate_
  902. # filaments overwrites the user's support-material profile with slot 1's.
  903. # ---------------------------------------------------------------------------
  904. class TestExtractSupportFilamentSlotsFrom3mf:
  905. def test_pla_object_plus_pva_support_returns_support_slot(self):
  906. # The reporter's exact scenario (#1881): slot 1 = PLA (model),
  907. # slot 2 = PVA (support). enable_support on. Without this the
  908. # substitute logic replaces slot 2's PVA profile with PLA and
  909. # the printed supports come out in PLA.
  910. cfg = json.dumps(
  911. {
  912. "enable_support": "1",
  913. "support_filament": "2",
  914. "support_interface_filament": "2",
  915. "filament_type": ["PLA", "PVA"],
  916. }
  917. )
  918. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  919. assert extract_support_filament_slots_from_3mf(zf) == {2}
  920. def test_distinct_support_body_and_interface_slots(self):
  921. cfg = json.dumps(
  922. {
  923. "enable_support": "1",
  924. "support_filament": "2",
  925. "support_interface_filament": "3",
  926. }
  927. )
  928. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  929. assert extract_support_filament_slots_from_3mf(zf) == {2, 3}
  930. def test_supports_disabled_returns_empty(self):
  931. # enable_support off — supports won't be printed even if a slot is
  932. # configured. Don't force it into the "used" set; substitution
  933. # should still homogenise the loaded-filament array.
  934. cfg = json.dumps(
  935. {
  936. "enable_support": "0",
  937. "support_filament": "2",
  938. "support_interface_filament": "2",
  939. }
  940. )
  941. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  942. assert extract_support_filament_slots_from_3mf(zf) == set()
  943. def test_slot_zero_treated_as_same_as_model(self):
  944. # BambuStudio's `0` for support_filament means "same as model" —
  945. # no dedicated slot to preserve.
  946. cfg = json.dumps(
  947. {
  948. "enable_support": "1",
  949. "support_filament": "0",
  950. "support_interface_filament": "0",
  951. }
  952. )
  953. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  954. assert extract_support_filament_slots_from_3mf(zf) == set()
  955. def test_boolean_enable_support_accepted(self):
  956. # Some forks / older versions write a real JSON bool instead of "1"/"0".
  957. cfg = json.dumps({"enable_support": True, "support_filament": "2"})
  958. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  959. assert extract_support_filament_slots_from_3mf(zf) == {2}
  960. def test_integer_slot_value_accepted(self):
  961. cfg = json.dumps({"enable_support": "1", "support_filament": 3})
  962. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  963. assert extract_support_filament_slots_from_3mf(zf) == {3}
  964. def test_missing_project_settings_returns_empty(self):
  965. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  966. assert extract_support_filament_slots_from_3mf(zf) == set()
  967. def test_malformed_json_returns_empty(self):
  968. with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
  969. assert extract_support_filament_slots_from_3mf(zf) == set()
  970. def test_root_is_list_returns_empty(self):
  971. with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
  972. assert extract_support_filament_slots_from_3mf(zf) == set()
  973. def test_non_numeric_slot_value_skipped(self):
  974. cfg = json.dumps({"enable_support": "1", "support_filament": "not-a-number"})
  975. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  976. assert extract_support_filament_slots_from_3mf(zf) == set()
  977. class TestExtractPlateMetadataFrom3mf:
  978. """The combined per-plate helper parses slice_info.config once and caches
  979. the result by file revision so queue polling doesn't re-open the same 3MF
  980. three times per row on every poll (#2573)."""
  981. _MULTI_PLATE = """<?xml version="1.0" encoding="UTF-8"?>
  982. <config>
  983. <plate>
  984. <metadata key="index" value="1"/>
  985. <metadata key="prediction" value="3600"/>
  986. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  987. <filament id="1" used_g="50.0" type="PLA" color="#FF0000"/>
  988. </plate>
  989. <plate>
  990. <metadata key="index" value="2"/>
  991. <metadata key="prediction" value="7200"/>
  992. <metadata key="curr_bed_type" value="Engineering Plate"/>
  993. <filament id="1" used_g="12.5" type="ABS" color="#00FF00"/>
  994. <filament id="2" used_g="7.5" type="ABS" color="#0000FF"/>
  995. </plate>
  996. </config>
  997. """
  998. def _write(self, tmp_path, xml, name="test.3mf"):
  999. from backend.app.utils.threemf_tools import clear_plate_metadata_cache
  1000. clear_plate_metadata_cache()
  1001. file_path = tmp_path / name
  1002. file_path.write_bytes(create_mock_3mf(xml).read())
  1003. return file_path
  1004. def test_combines_all_three_fields_for_plate(self, tmp_path):
  1005. from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
  1006. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1007. meta = extract_plate_metadata_from_3mf(file_path, plate_id=2)
  1008. assert meta.print_time_seconds == 7200
  1009. assert meta.bed_type == "Engineering Plate"
  1010. assert meta.filament_used_grams == 20.0
  1011. assert {f["slot_id"] for f in meta.filament_usage} == {1, 2}
  1012. def test_plate_id_none_matches_legacy_behaviour(self, tmp_path):
  1013. # Legacy None behaviour: time+bed from the first plate, but usage
  1014. # collects EVERY filament in the file (not just plate 1).
  1015. from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
  1016. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1017. meta = extract_plate_metadata_from_3mf(file_path, plate_id=None)
  1018. assert meta.print_time_seconds == 3600
  1019. assert meta.bed_type == "Textured PEI Plate"
  1020. assert len(meta.filament_usage) == 3 # 1 from plate 1 + 2 from plate 2
  1021. def test_second_call_hits_cache_without_reparsing(self, tmp_path):
  1022. from unittest.mock import patch
  1023. import backend.app.utils.threemf_tools as tools
  1024. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1025. with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
  1026. first = tools.extract_plate_metadata_from_3mf(file_path, plate_id=2)
  1027. second = tools.extract_plate_metadata_from_3mf(file_path, plate_id=2)
  1028. assert spy.call_count == 1 # parsed once, served from cache the second time
  1029. assert first is second
  1030. assert second.print_time_seconds == 7200
  1031. def test_changed_file_reparses(self, tmp_path):
  1032. from unittest.mock import patch
  1033. import backend.app.utils.threemf_tools as tools
  1034. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1035. with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
  1036. tools.extract_plate_metadata_from_3mf(file_path, plate_id=1)
  1037. # Replace the file with different content (and a different size, so the
  1038. # revision key changes even if mtime resolution is coarse).
  1039. new_xml = """<?xml version="1.0" encoding="UTF-8"?>
  1040. <config>
  1041. <plate>
  1042. <metadata key="index" value="1"/>
  1043. <metadata key="prediction" value="999"/>
  1044. <metadata key="curr_bed_type" value="Cool Plate"/>
  1045. <filament id="1" used_g="1.0" type="PLA" color="#FFFFFF"/>
  1046. </plate>
  1047. </config>
  1048. """
  1049. file_path.write_bytes(create_mock_3mf(new_xml).read())
  1050. fresh = tools.extract_plate_metadata_from_3mf(file_path, plate_id=1)
  1051. assert spy.call_count == 2 # revision changed -> re-parsed
  1052. assert fresh.print_time_seconds == 999
  1053. assert fresh.bed_type == "Cool Plate"
  1054. def test_wrapper_returns_mutable_copy(self, tmp_path):
  1055. # extract_filament_usage_from_3mf callers mutate the list; that must not
  1056. # corrupt the shared cached PlateMetadata.
  1057. from backend.app.utils.threemf_tools import (
  1058. extract_filament_usage_from_3mf,
  1059. extract_plate_metadata_from_3mf,
  1060. )
  1061. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1062. usage = extract_filament_usage_from_3mf(file_path, plate_id=2)
  1063. usage.append({"slot_id": 99, "used_g": 0.0, "type": "", "color": ""})
  1064. usage[0]["used_g"] = -1.0
  1065. cached = extract_plate_metadata_from_3mf(file_path, plate_id=2)
  1066. assert len(cached.filament_usage) == 2
  1067. assert all(f["used_g"] > 0 for f in cached.filament_usage)
  1068. def test_non_numeric_filament_id_is_skipped_not_raised(self, tmp_path):
  1069. # A garbage filament id (or used_g) must be silently skipped, exactly as
  1070. # the legacy helpers did — a raise here would 500 the queue listing that
  1071. # calls this per row. Guards both the plate-specific and plate_id=None paths.
  1072. from backend.app.utils.threemf_tools import (
  1073. extract_filament_usage_from_3mf,
  1074. extract_plate_metadata_from_3mf,
  1075. )
  1076. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  1077. <config>
  1078. <plate>
  1079. <metadata key="index" value="1"/>
  1080. <metadata key="prediction" value="3600"/>
  1081. <filament id="abc" used_g="5.0" type="PLA" color="#FFFFFF"/>
  1082. <filament id="1" used_g="10.0" type="PLA" color="#FF0000"/>
  1083. <filament id="2" used_g="bad" type="PLA" color="#00FF00"/>
  1084. </plate>
  1085. </config>
  1086. """
  1087. file_path = self._write(tmp_path, xml_content)
  1088. meta = extract_plate_metadata_from_3mf(file_path, plate_id=1)
  1089. assert [f["slot_id"] for f in meta.filament_usage] == [1]
  1090. assert meta.filament_used_grams == 10.0
  1091. assert meta.print_time_seconds == 3600
  1092. # plate_id=None path (collects all filaments in the file) must skip too.
  1093. none_result = extract_filament_usage_from_3mf(file_path, plate_id=None)
  1094. assert [f["slot_id"] for f in none_result] == [1]
  1095. def test_missing_file_returns_empty_and_is_not_cached(self, tmp_path):
  1096. from unittest.mock import patch
  1097. import backend.app.utils.threemf_tools as tools
  1098. tools.clear_plate_metadata_cache()
  1099. missing = tmp_path / "nope.3mf"
  1100. with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
  1101. meta = tools.extract_plate_metadata_from_3mf(missing, plate_id=1)
  1102. tools.extract_plate_metadata_from_3mf(missing, plate_id=1)
  1103. assert meta.print_time_seconds is None
  1104. assert meta.filament_usage == []
  1105. # Missing file must not create a sticky cache entry (it may appear later).
  1106. assert spy.call_count == 2