test_threemf_tools.py 47 KB

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