test_slice_process_support_patch.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """Regression tests for the #1881 support-settings patch on slice requests.
  2. BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
  3. define `enable_support: 0` because supports are a per-print decision, not
  4. a per-quality one. Bambuddy passes the picked process preset via
  5. `--load-settings`, which is authoritative — every field in the loaded
  6. JSON overrides the source 3MF's embedded `project_settings.config`. So
  7. without patching, a user who exported a source 3MF with supports
  8. configured (PLA in slot 1 + PVA in slot 2 for support_interface,
  9. enable_support on) got a single-material output with the PVA slot loaded
  10. but never used.
  11. The patch reads support-related fields from the source's
  12. project_settings.config and overlays them onto the process preset JSON,
  13. so the source's per-project support intent survives `--load-settings`.
  14. The carry is one-way (#2820): a source can switch supports on, never off.
  15. The original rule was symmetric, which meant any 3MF that shipped with
  16. supports disabled -- i.e. nearly every MakerWorld download -- stripped
  17. them back out of a custom process preset that deliberately enabled them.
  18. """
  19. import io
  20. import json
  21. import logging
  22. import zipfile
  23. from backend.app.api.routes.library import _declined_source_keys, _patch_process_support_settings
  24. from backend.app.services.design_settings import DesignOverride
  25. def _make_3mf(project_settings: dict | None) -> bytes:
  26. buf = io.BytesIO()
  27. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  28. zf.writestr("3D/3dmodel.model", "<model/>")
  29. if project_settings is not None:
  30. zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
  31. return buf.getvalue()
  32. class TestPatchProcessSupportSettings:
  33. def test_preserves_source_enable_support_and_interface_slot(self):
  34. # Reporter's exact #1881 config: source has supports on with PVA
  35. # in slot 2 for the interface. Shipped process preset has all four
  36. # fields off. Post-patch, the source wins for the support keys and
  37. # the process preset's own layer_height stays untouched.
  38. source = _make_3mf(
  39. {
  40. "enable_support": "1",
  41. "support_filament": "0",
  42. "support_interface_filament": "2",
  43. "support_type": "normal(manual)",
  44. "filament_type": ["PLA", "PVA"],
  45. }
  46. )
  47. preset = json.dumps(
  48. {
  49. "name": "0.20mm Standard @BBL H2D",
  50. "enable_support": "0",
  51. "support_filament": "0",
  52. "support_interface_filament": "0",
  53. "support_type": "default",
  54. "layer_height": "0.20",
  55. }
  56. )
  57. result = json.loads(_patch_process_support_settings(preset, source))
  58. assert result["enable_support"] == "1"
  59. assert result["support_filament"] == "0"
  60. assert result["support_interface_filament"] == "2"
  61. assert result["support_type"] == "normal(manual)"
  62. # Non-support fields survive.
  63. assert result["layer_height"] == "0.20"
  64. assert result["name"] == "0.20mm Standard @BBL H2D"
  65. def test_preset_supports_on_survives_a_source_with_supports_off(self):
  66. # #2820: the reporter's own process preset turns supports on with
  67. # normal(auto); the MakerWorld source they sliced ships them off
  68. # with tree(auto), like nearly every published 3MF. Carrying the
  69. # off direction handed them a supportless tree(auto) slice, so the
  70. # source is now only allowed to switch supports *on*.
  71. source = _make_3mf(
  72. {
  73. "enable_support": "0",
  74. "support_filament": "0",
  75. "support_interface_filament": "0",
  76. "support_type": "tree(auto)",
  77. }
  78. )
  79. preset = json.dumps(
  80. {
  81. "name": "Pokeball Fast - Buddy",
  82. "enable_support": "1",
  83. "support_filament": "2",
  84. "support_interface_filament": "2",
  85. "support_type": "normal(auto)",
  86. "support_style": "snug",
  87. }
  88. )
  89. result = json.loads(_patch_process_support_settings(preset, source))
  90. assert result["enable_support"] == "1"
  91. assert result["support_filament"] == "2"
  92. assert result["support_interface_filament"] == "2"
  93. assert result["support_type"] == "normal(auto)"
  94. assert result["support_style"] == "snug"
  95. def test_source_without_enable_support_carries_nothing(self):
  96. # A source that never declares enable_support gives us no support
  97. # intent to act on, so its slot assignments stay out of the preset
  98. # — same "supports off" branch, reached via the missing key.
  99. source = _make_3mf({"support_filament": "3", "support_interface_filament": "3"})
  100. preset = json.dumps({"support_filament": "0", "support_interface_filament": "0"})
  101. result = json.loads(_patch_process_support_settings(preset, source))
  102. assert result == {"support_filament": "0", "support_interface_filament": "0"}
  103. def test_non_string_enable_support_still_counts_as_on(self):
  104. # Forks and older BambuStudio builds write real booleans / ints
  105. # instead of "1" — those must still carry (shared truthiness rule
  106. # with extract_support_filament_slots_from_3mf).
  107. for enabled in (True, 1, "1", "true"):
  108. source = _make_3mf({"enable_support": enabled, "support_interface_filament": "2"})
  109. preset = json.dumps({"enable_support": "0", "support_interface_filament": "0"})
  110. result = json.loads(_patch_process_support_settings(preset, source))
  111. assert result["enable_support"] == enabled, f"failed for {enabled!r}"
  112. assert result["support_interface_filament"] == "2"
  113. def test_carry_is_logged_with_the_keys_it_took(self, caplog):
  114. # The slice modal shows the picked preset's values, so a carried
  115. # key silently disagrees with what the user saw. #2820's reporter
  116. # spent the bug report chasing an unrelated sanitiser line because
  117. # this step logged nothing at all.
  118. source = _make_3mf({"enable_support": "1", "support_interface_filament": "2"})
  119. preset = json.dumps({"enable_support": "0", "support_interface_filament": "0"})
  120. with caplog.at_level(logging.INFO, logger="backend.app.api.routes.library"):
  121. _patch_process_support_settings(preset, source)
  122. assert "Carried support settings" in caplog.text
  123. assert "enable_support" in caplog.text
  124. assert "support_interface_filament" in caplog.text
  125. def test_no_log_when_the_source_has_supports_off(self, caplog):
  126. source = _make_3mf({"enable_support": "0", "support_type": "tree(auto)"})
  127. preset = json.dumps({"enable_support": "1"})
  128. with caplog.at_level(logging.INFO, logger="backend.app.api.routes.library"):
  129. _patch_process_support_settings(preset, source)
  130. assert "Carried support settings" not in caplog.text
  131. def test_only_patches_keys_present_in_source(self):
  132. # Source with a partial support config (e.g. legacy 3MFs from an
  133. # older BambuStudio) only overrides the keys it defines. Preset's
  134. # values for the other support keys survive.
  135. source = _make_3mf({"enable_support": "1"})
  136. preset = json.dumps(
  137. {
  138. "enable_support": "0",
  139. "support_filament": "2",
  140. "support_interface_filament": "3",
  141. "support_type": "tree(auto)",
  142. }
  143. )
  144. result = json.loads(_patch_process_support_settings(preset, source))
  145. assert result["enable_support"] == "1"
  146. # Preset's values kept for keys the source didn't define.
  147. assert result["support_filament"] == "2"
  148. assert result["support_interface_filament"] == "3"
  149. assert result["support_type"] == "tree(auto)"
  150. def test_no_project_settings_in_source_returns_preset_unchanged(self):
  151. # STL / STEP / a stripped-down 3MF has no project_settings.config;
  152. # nothing to overlay, preset must pass through untouched.
  153. source = _make_3mf(None)
  154. preset = json.dumps({"enable_support": "0", "layer_height": "0.20"})
  155. result = _patch_process_support_settings(preset, source)
  156. # Same JSON round-trips.
  157. assert json.loads(result) == {"enable_support": "0", "layer_height": "0.20"}
  158. def test_malformed_source_returns_preset_unchanged(self):
  159. # A malformed source 3MF (or a random blob) can't yield support
  160. # info; the slice then runs with the preset's own defaults, which
  161. # is the safe fall-back matching pre-fix behaviour.
  162. preset = json.dumps({"enable_support": "0"})
  163. assert json.loads(_patch_process_support_settings(preset, b"not a zip")) == {"enable_support": "0"}
  164. def test_malformed_project_settings_json_returns_preset_unchanged(self):
  165. buf = io.BytesIO()
  166. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  167. zf.writestr("Metadata/project_settings.config", "{not json")
  168. source = buf.getvalue()
  169. preset = json.dumps({"enable_support": "0"})
  170. assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
  171. def test_source_project_settings_not_dict_returns_preset_unchanged(self):
  172. # Defensive: spec says it's a dict, but a source that ships a
  173. # top-level list (or anything non-dict) shouldn't crash the slice.
  174. buf = io.BytesIO()
  175. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  176. zf.writestr("Metadata/project_settings.config", json.dumps([]))
  177. source = buf.getvalue()
  178. preset = json.dumps({"enable_support": "0"})
  179. assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
  180. def test_malformed_preset_json_returns_input_unchanged(self):
  181. # Symmetric to test_returns_input_unchanged_when_json_is_invalid
  182. # in the bed-type patch's test suite. The slicer would error on
  183. # the preset anyway; the patch is a straight passthrough so
  184. # failure attributes to the original input.
  185. source = _make_3mf({"enable_support": "1"})
  186. bogus = "not a json document"
  187. assert _patch_process_support_settings(bogus, source) is bogus
  188. def test_preset_json_not_a_dict_returns_input_unchanged(self):
  189. source = _make_3mf({"enable_support": "1"})
  190. not_a_dict = json.dumps(["this", "is", "an", "array"])
  191. assert _patch_process_support_settings(not_a_dict, source) is not_a_dict
  192. class TestDeclinedKeysAreNotReinstated:
  193. """What the user unticked stays unticked (#2942).
  194. The slice dialog offers the file's own settings per key and applies only
  195. the ones that are on. This carry ran underneath that, unconditionally, so
  196. four support keys came out of the file whatever the ticks said -- the
  197. reporter's slice took ``enable_support`` and ``support_type`` from a
  198. MakerWorld download onto a process preset they had picked deliberately,
  199. with the dialog's "Use the file's built-in settings" switched off and
  200. nothing on screen able to stop it.
  201. """
  202. def _source(self) -> bytes:
  203. return _make_3mf(
  204. {
  205. "enable_support": "1",
  206. "support_filament": "0",
  207. "support_interface_filament": "0",
  208. "support_type": "normal(auto)",
  209. }
  210. )
  211. def _preset(self) -> str:
  212. return json.dumps(
  213. {
  214. "name": "Pokeball Fast - Buddy",
  215. "enable_support": "0",
  216. "support_type": "tree(auto)",
  217. "layer_height": "0.20",
  218. }
  219. )
  220. def test_declining_everything_leaves_the_preset_alone(self):
  221. result = json.loads(
  222. _patch_process_support_settings(
  223. self._preset(),
  224. self._source(),
  225. declined={"enable_support", "support_filament", "support_interface_filament", "support_type"},
  226. )
  227. )
  228. assert result["enable_support"] == "0"
  229. assert result["support_type"] == "tree(auto)"
  230. assert result["layer_height"] == "0.20"
  231. def test_declining_one_key_still_carries_the_others(self):
  232. # The ticks are per key, so declining the support type is not
  233. # declining supports.
  234. result = json.loads(_patch_process_support_settings(self._preset(), self._source(), declined={"support_type"}))
  235. assert result["enable_support"] == "1"
  236. assert result["support_type"] == "tree(auto)"
  237. def test_declining_nothing_is_the_behaviour_it_always_had(self):
  238. # A source that offers no per-key choice -- an OrcaSlicer export has
  239. # no `different_settings_to_system` to tick -- keeps #1881 whole.
  240. result = json.loads(_patch_process_support_settings(self._preset(), self._source()))
  241. assert result["enable_support"] == "1"
  242. assert result["support_type"] == "normal(auto)"
  243. def test_declining_everything_logs_nothing(self, caplog):
  244. # The log line exists to name the layer the user can't see coming.
  245. # Nothing was carried, so there is nothing to announce.
  246. with caplog.at_level(logging.INFO, logger="backend.app.api.routes.library"):
  247. _patch_process_support_settings(
  248. self._preset(),
  249. self._source(),
  250. declined={"enable_support", "support_filament", "support_interface_filament", "support_type"},
  251. )
  252. assert "Carried support settings" not in caplog.text
  253. def test_declining_a_key_the_source_never_had_changes_nothing(self):
  254. result = json.loads(_patch_process_support_settings(self._preset(), self._source(), declined={"wall_loops"}))
  255. assert result["enable_support"] == "1"
  256. assert result["support_type"] == "normal(auto)"
  257. class TestDeclinedSourceKeys:
  258. """Reading "the user said no" out of a slice request (#2942)."""
  259. @staticmethod
  260. def _offered(*keys: str) -> list[DesignOverride]:
  261. return [DesignOverride(key=key, value="1", printer_coupled=False) for key in keys]
  262. def test_no_list_at_all_declines_nothing(self):
  263. # A client that predates the per-key ticks -- or any API consumer that
  264. # never sends the field -- cannot have turned anything down, so the
  265. # support carry-over stays exactly as it was.
  266. assert _declined_source_keys(self._offered("enable_support"), None) == set()
  267. def test_an_empty_list_declines_everything_on_offer(self):
  268. # Not the same answer as None: the panel was shown, and nothing in it
  269. # was ticked.
  270. assert _declined_source_keys(self._offered("enable_support", "wall_loops"), []) == {
  271. "enable_support",
  272. "wall_loops",
  273. }
  274. def test_a_partial_list_declines_only_the_rest(self):
  275. offered = self._offered("enable_support", "support_type", "wall_loops")
  276. assert _declined_source_keys(offered, ["wall_loops"]) == {"enable_support", "support_type"}
  277. def test_a_key_that_was_never_offered_is_not_a_decline(self):
  278. # Selecting something the file does not list is already ignored when
  279. # the values are applied; it must not turn into a phantom refusal.
  280. assert _declined_source_keys(self._offered("wall_loops"), ["wall_loops", "layer_height"]) == set()
  281. def test_a_file_that_offers_nothing_declines_nothing(self):
  282. assert _declined_source_keys([], []) == set()