test_slice_process_support_patch.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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 _patch_process_support_settings
  24. def _make_3mf(project_settings: dict | None) -> bytes:
  25. buf = io.BytesIO()
  26. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  27. zf.writestr("3D/3dmodel.model", "<model/>")
  28. if project_settings is not None:
  29. zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
  30. return buf.getvalue()
  31. class TestPatchProcessSupportSettings:
  32. def test_preserves_source_enable_support_and_interface_slot(self):
  33. # Reporter's exact #1881 config: source has supports on with PVA
  34. # in slot 2 for the interface. Shipped process preset has all four
  35. # fields off. Post-patch, the source wins for the support keys and
  36. # the process preset's own layer_height stays untouched.
  37. source = _make_3mf(
  38. {
  39. "enable_support": "1",
  40. "support_filament": "0",
  41. "support_interface_filament": "2",
  42. "support_type": "normal(manual)",
  43. "filament_type": ["PLA", "PVA"],
  44. }
  45. )
  46. preset = json.dumps(
  47. {
  48. "name": "0.20mm Standard @BBL H2D",
  49. "enable_support": "0",
  50. "support_filament": "0",
  51. "support_interface_filament": "0",
  52. "support_type": "default",
  53. "layer_height": "0.20",
  54. }
  55. )
  56. result = json.loads(_patch_process_support_settings(preset, source))
  57. assert result["enable_support"] == "1"
  58. assert result["support_filament"] == "0"
  59. assert result["support_interface_filament"] == "2"
  60. assert result["support_type"] == "normal(manual)"
  61. # Non-support fields survive.
  62. assert result["layer_height"] == "0.20"
  63. assert result["name"] == "0.20mm Standard @BBL H2D"
  64. def test_preset_supports_on_survives_a_source_with_supports_off(self):
  65. # #2820: the reporter's own process preset turns supports on with
  66. # normal(auto); the MakerWorld source they sliced ships them off
  67. # with tree(auto), like nearly every published 3MF. Carrying the
  68. # off direction handed them a supportless tree(auto) slice, so the
  69. # source is now only allowed to switch supports *on*.
  70. source = _make_3mf(
  71. {
  72. "enable_support": "0",
  73. "support_filament": "0",
  74. "support_interface_filament": "0",
  75. "support_type": "tree(auto)",
  76. }
  77. )
  78. preset = json.dumps(
  79. {
  80. "name": "Pokeball Fast - Buddy",
  81. "enable_support": "1",
  82. "support_filament": "2",
  83. "support_interface_filament": "2",
  84. "support_type": "normal(auto)",
  85. "support_style": "snug",
  86. }
  87. )
  88. result = json.loads(_patch_process_support_settings(preset, source))
  89. assert result["enable_support"] == "1"
  90. assert result["support_filament"] == "2"
  91. assert result["support_interface_filament"] == "2"
  92. assert result["support_type"] == "normal(auto)"
  93. assert result["support_style"] == "snug"
  94. def test_source_without_enable_support_carries_nothing(self):
  95. # A source that never declares enable_support gives us no support
  96. # intent to act on, so its slot assignments stay out of the preset
  97. # — same "supports off" branch, reached via the missing key.
  98. source = _make_3mf({"support_filament": "3", "support_interface_filament": "3"})
  99. preset = json.dumps({"support_filament": "0", "support_interface_filament": "0"})
  100. result = json.loads(_patch_process_support_settings(preset, source))
  101. assert result == {"support_filament": "0", "support_interface_filament": "0"}
  102. def test_non_string_enable_support_still_counts_as_on(self):
  103. # Forks and older BambuStudio builds write real booleans / ints
  104. # instead of "1" — those must still carry (shared truthiness rule
  105. # with extract_support_filament_slots_from_3mf).
  106. for enabled in (True, 1, "1", "true"):
  107. source = _make_3mf({"enable_support": enabled, "support_interface_filament": "2"})
  108. preset = json.dumps({"enable_support": "0", "support_interface_filament": "0"})
  109. result = json.loads(_patch_process_support_settings(preset, source))
  110. assert result["enable_support"] == enabled, f"failed for {enabled!r}"
  111. assert result["support_interface_filament"] == "2"
  112. def test_carry_is_logged_with_the_keys_it_took(self, caplog):
  113. # The slice modal shows the picked preset's values, so a carried
  114. # key silently disagrees with what the user saw. #2820's reporter
  115. # spent the bug report chasing an unrelated sanitiser line because
  116. # this step logged nothing at all.
  117. source = _make_3mf({"enable_support": "1", "support_interface_filament": "2"})
  118. preset = json.dumps({"enable_support": "0", "support_interface_filament": "0"})
  119. with caplog.at_level(logging.INFO, logger="backend.app.api.routes.library"):
  120. _patch_process_support_settings(preset, source)
  121. assert "Carried support settings" in caplog.text
  122. assert "enable_support" in caplog.text
  123. assert "support_interface_filament" in caplog.text
  124. def test_no_log_when_the_source_has_supports_off(self, caplog):
  125. source = _make_3mf({"enable_support": "0", "support_type": "tree(auto)"})
  126. preset = json.dumps({"enable_support": "1"})
  127. with caplog.at_level(logging.INFO, logger="backend.app.api.routes.library"):
  128. _patch_process_support_settings(preset, source)
  129. assert "Carried support settings" not in caplog.text
  130. def test_only_patches_keys_present_in_source(self):
  131. # Source with a partial support config (e.g. legacy 3MFs from an
  132. # older BambuStudio) only overrides the keys it defines. Preset's
  133. # values for the other support keys survive.
  134. source = _make_3mf({"enable_support": "1"})
  135. preset = json.dumps(
  136. {
  137. "enable_support": "0",
  138. "support_filament": "2",
  139. "support_interface_filament": "3",
  140. "support_type": "tree(auto)",
  141. }
  142. )
  143. result = json.loads(_patch_process_support_settings(preset, source))
  144. assert result["enable_support"] == "1"
  145. # Preset's values kept for keys the source didn't define.
  146. assert result["support_filament"] == "2"
  147. assert result["support_interface_filament"] == "3"
  148. assert result["support_type"] == "tree(auto)"
  149. def test_no_project_settings_in_source_returns_preset_unchanged(self):
  150. # STL / STEP / a stripped-down 3MF has no project_settings.config;
  151. # nothing to overlay, preset must pass through untouched.
  152. source = _make_3mf(None)
  153. preset = json.dumps({"enable_support": "0", "layer_height": "0.20"})
  154. result = _patch_process_support_settings(preset, source)
  155. # Same JSON round-trips.
  156. assert json.loads(result) == {"enable_support": "0", "layer_height": "0.20"}
  157. def test_malformed_source_returns_preset_unchanged(self):
  158. # A malformed source 3MF (or a random blob) can't yield support
  159. # info; the slice then runs with the preset's own defaults, which
  160. # is the safe fall-back matching pre-fix behaviour.
  161. preset = json.dumps({"enable_support": "0"})
  162. assert json.loads(_patch_process_support_settings(preset, b"not a zip")) == {"enable_support": "0"}
  163. def test_malformed_project_settings_json_returns_preset_unchanged(self):
  164. buf = io.BytesIO()
  165. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  166. zf.writestr("Metadata/project_settings.config", "{not json")
  167. source = buf.getvalue()
  168. preset = json.dumps({"enable_support": "0"})
  169. assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
  170. def test_source_project_settings_not_dict_returns_preset_unchanged(self):
  171. # Defensive: spec says it's a dict, but a source that ships a
  172. # top-level list (or anything non-dict) shouldn't crash the slice.
  173. buf = io.BytesIO()
  174. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  175. zf.writestr("Metadata/project_settings.config", json.dumps([]))
  176. source = buf.getvalue()
  177. preset = json.dumps({"enable_support": "0"})
  178. assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
  179. def test_malformed_preset_json_returns_input_unchanged(self):
  180. # Symmetric to test_returns_input_unchanged_when_json_is_invalid
  181. # in the bed-type patch's test suite. The slicer would error on
  182. # the preset anyway; the patch is a straight passthrough so
  183. # failure attributes to the original input.
  184. source = _make_3mf({"enable_support": "1"})
  185. bogus = "not a json document"
  186. assert _patch_process_support_settings(bogus, source) is bogus
  187. def test_preset_json_not_a_dict_returns_input_unchanged(self):
  188. source = _make_3mf({"enable_support": "1"})
  189. not_a_dict = json.dumps(["this", "is", "an", "array"])
  190. assert _patch_process_support_settings(not_a_dict, source) is not_a_dict