test_slice_process_support_patch.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  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. """
  15. import io
  16. import json
  17. import zipfile
  18. from backend.app.api.routes.library import _patch_process_support_settings
  19. def _make_3mf(project_settings: dict | None) -> bytes:
  20. buf = io.BytesIO()
  21. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  22. zf.writestr("3D/3dmodel.model", "<model/>")
  23. if project_settings is not None:
  24. zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
  25. return buf.getvalue()
  26. class TestPatchProcessSupportSettings:
  27. def test_preserves_source_enable_support_and_interface_slot(self):
  28. # Reporter's exact #1881 config: source has supports on with PVA
  29. # in slot 2 for the interface. Shipped process preset has all four
  30. # fields off. Post-patch, the source wins for the support keys and
  31. # the process preset's own layer_height stays untouched.
  32. source = _make_3mf(
  33. {
  34. "enable_support": "1",
  35. "support_filament": "0",
  36. "support_interface_filament": "2",
  37. "support_type": "normal(manual)",
  38. "filament_type": ["PLA", "PVA"],
  39. }
  40. )
  41. preset = json.dumps(
  42. {
  43. "name": "0.20mm Standard @BBL H2D",
  44. "enable_support": "0",
  45. "support_filament": "0",
  46. "support_interface_filament": "0",
  47. "support_type": "default",
  48. "layer_height": "0.20",
  49. }
  50. )
  51. result = json.loads(_patch_process_support_settings(preset, source))
  52. assert result["enable_support"] == "1"
  53. assert result["support_filament"] == "0"
  54. assert result["support_interface_filament"] == "2"
  55. assert result["support_type"] == "normal(manual)"
  56. # Non-support fields survive.
  57. assert result["layer_height"] == "0.20"
  58. assert result["name"] == "0.20mm Standard @BBL H2D"
  59. def test_source_supports_off_beats_preset_supports_on(self):
  60. # Symmetric: a source with supports explicitly disabled must win
  61. # over a process preset that happens to have supports on. Rare in
  62. # practice (Bambu's presets ship off) but the semantic is "source
  63. # wins" regardless of direction — a user who exported without
  64. # supports doesn't want a preset accidentally re-enabling them.
  65. source = _make_3mf(
  66. {
  67. "enable_support": "0",
  68. "support_filament": "0",
  69. "support_interface_filament": "0",
  70. }
  71. )
  72. preset = json.dumps({"enable_support": "1", "support_filament": "2", "support_interface_filament": "2"})
  73. result = json.loads(_patch_process_support_settings(preset, source))
  74. assert result["enable_support"] == "0"
  75. assert result["support_filament"] == "0"
  76. assert result["support_interface_filament"] == "0"
  77. def test_only_patches_keys_present_in_source(self):
  78. # Source with a partial support config (e.g. legacy 3MFs from an
  79. # older BambuStudio) only overrides the keys it defines. Preset's
  80. # values for the other support keys survive.
  81. source = _make_3mf({"enable_support": "1"})
  82. preset = json.dumps(
  83. {
  84. "enable_support": "0",
  85. "support_filament": "2",
  86. "support_interface_filament": "3",
  87. "support_type": "tree(auto)",
  88. }
  89. )
  90. result = json.loads(_patch_process_support_settings(preset, source))
  91. assert result["enable_support"] == "1"
  92. # Preset's values kept for keys the source didn't define.
  93. assert result["support_filament"] == "2"
  94. assert result["support_interface_filament"] == "3"
  95. assert result["support_type"] == "tree(auto)"
  96. def test_no_project_settings_in_source_returns_preset_unchanged(self):
  97. # STL / STEP / a stripped-down 3MF has no project_settings.config;
  98. # nothing to overlay, preset must pass through untouched.
  99. source = _make_3mf(None)
  100. preset = json.dumps({"enable_support": "0", "layer_height": "0.20"})
  101. result = _patch_process_support_settings(preset, source)
  102. # Same JSON round-trips.
  103. assert json.loads(result) == {"enable_support": "0", "layer_height": "0.20"}
  104. def test_malformed_source_returns_preset_unchanged(self):
  105. # A malformed source 3MF (or a random blob) can't yield support
  106. # info; the slice then runs with the preset's own defaults, which
  107. # is the safe fall-back matching pre-fix behaviour.
  108. preset = json.dumps({"enable_support": "0"})
  109. assert json.loads(_patch_process_support_settings(preset, b"not a zip")) == {"enable_support": "0"}
  110. def test_malformed_project_settings_json_returns_preset_unchanged(self):
  111. buf = io.BytesIO()
  112. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  113. zf.writestr("Metadata/project_settings.config", "{not json")
  114. source = buf.getvalue()
  115. preset = json.dumps({"enable_support": "0"})
  116. assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
  117. def test_source_project_settings_not_dict_returns_preset_unchanged(self):
  118. # Defensive: spec says it's a dict, but a source that ships a
  119. # top-level list (or anything non-dict) shouldn't crash the slice.
  120. buf = io.BytesIO()
  121. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  122. zf.writestr("Metadata/project_settings.config", json.dumps([]))
  123. source = buf.getvalue()
  124. preset = json.dumps({"enable_support": "0"})
  125. assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
  126. def test_malformed_preset_json_returns_input_unchanged(self):
  127. # Symmetric to test_returns_input_unchanged_when_json_is_invalid
  128. # in the bed-type patch's test suite. The slicer would error on
  129. # the preset anyway; the patch is a straight passthrough so
  130. # failure attributes to the original input.
  131. source = _make_3mf({"enable_support": "1"})
  132. bogus = "not a json document"
  133. assert _patch_process_support_settings(bogus, source) is bogus
  134. def test_preset_json_not_a_dict_returns_input_unchanged(self):
  135. source = _make_3mf({"enable_support": "1"})
  136. not_a_dict = json.dumps(["this", "is", "an", "array"])
  137. assert _patch_process_support_settings(not_a_dict, source) is not_a_dict