bambuddy/backend/tests/unit/test_slice_process_support_patch.py
maziggy bdac27ebee
Some checks failed
Security Audit / Frontend Security Audit (push) Failing after 14m29s
Security Audit / Backend Security Audit (push) Failing after 14m39s
Security Audit / Container Security Scan (Trivy) (push) Failing after 14m47s
Security Audit / Python Security Analysis (Bandit) (push) Failing after 14m55s
fix(slicer): preserve PVA-for-support intent across re-slice of source 3MF (#1881)
Three bugs on the same PLA-model + PVA-support flow, discovered in
sequence:

(A) substitute_unused_plate_filaments inspected only object geometry
    (per-object extruder metadata + paint_color triangles) so a support-
    only slot was silently treated as "unused" and the user's PVA profile
    got overwritten with slot 1's PLA.

(B) _extract_filament_info stripped filament_is_support==1 entries,
    hiding PVA from unsliced source archive cards even when the project
    explicitly configured it.

(C) --load-settings is authoritative over the source's project_settings.
    config, and Bambu's shipped process presets ship enable_support=0
    (supports are a per-print decision, not per-quality). So even with
    (A) fixed, the sliced output had supports disabled and the PVA slot
    loaded but never consumed. Inverts BambuStudio GUI's semantics where
    the project overrides the preset.

Fixes:
- New extract_support_filament_slots_from_3mf reads enable_support +
  support_filament + support_interface_filament from project_settings.
  config; substitute_unused_plate_filaments unions it into the geometry-
  derived set.
- _extract_filament_info returns all configured filament types + colours.
- New _patch_process_support_settings overlays four fields (enable_
  support, support_filament, support_interface_filament, support_type)
  from the source 3MF onto the picked process preset JSON before
  --load-settings sees it. Deliberately targeted to what fixes #1881
  without widening to a full project-over-preset merge.
2026-07-02 11:32:22 +02:00

153 lines
7.2 KiB
Python

"""Regression tests for the #1881 support-settings patch on slice requests.
BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
define `enable_support: 0` because supports are a per-print decision, not
a per-quality one. Bambuddy passes the picked process preset via
`--load-settings`, which is authoritative — every field in the loaded
JSON overrides the source 3MF's embedded `project_settings.config`. So
without patching, a user who exported a source 3MF with supports
configured (PLA in slot 1 + PVA in slot 2 for support_interface,
enable_support on) got a single-material output with the PVA slot loaded
but never used.
The patch reads support-related fields from the source's
project_settings.config and overlays them onto the process preset JSON,
so the source's per-project support intent survives `--load-settings`.
"""
import io
import json
import zipfile
from backend.app.api.routes.library import _patch_process_support_settings
def _make_3mf(project_settings: dict | None) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
if project_settings is not None:
zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
return buf.getvalue()
class TestPatchProcessSupportSettings:
def test_preserves_source_enable_support_and_interface_slot(self):
# Reporter's exact #1881 config: source has supports on with PVA
# in slot 2 for the interface. Shipped process preset has all four
# fields off. Post-patch, the source wins for the support keys and
# the process preset's own layer_height stays untouched.
source = _make_3mf(
{
"enable_support": "1",
"support_filament": "0",
"support_interface_filament": "2",
"support_type": "normal(manual)",
"filament_type": ["PLA", "PVA"],
}
)
preset = json.dumps(
{
"name": "0.20mm Standard @BBL H2D",
"enable_support": "0",
"support_filament": "0",
"support_interface_filament": "0",
"support_type": "default",
"layer_height": "0.20",
}
)
result = json.loads(_patch_process_support_settings(preset, source))
assert result["enable_support"] == "1"
assert result["support_filament"] == "0"
assert result["support_interface_filament"] == "2"
assert result["support_type"] == "normal(manual)"
# Non-support fields survive.
assert result["layer_height"] == "0.20"
assert result["name"] == "0.20mm Standard @BBL H2D"
def test_source_supports_off_beats_preset_supports_on(self):
# Symmetric: a source with supports explicitly disabled must win
# over a process preset that happens to have supports on. Rare in
# practice (Bambu's presets ship off) but the semantic is "source
# wins" regardless of direction — a user who exported without
# supports doesn't want a preset accidentally re-enabling them.
source = _make_3mf(
{
"enable_support": "0",
"support_filament": "0",
"support_interface_filament": "0",
}
)
preset = json.dumps({"enable_support": "1", "support_filament": "2", "support_interface_filament": "2"})
result = json.loads(_patch_process_support_settings(preset, source))
assert result["enable_support"] == "0"
assert result["support_filament"] == "0"
assert result["support_interface_filament"] == "0"
def test_only_patches_keys_present_in_source(self):
# Source with a partial support config (e.g. legacy 3MFs from an
# older BambuStudio) only overrides the keys it defines. Preset's
# values for the other support keys survive.
source = _make_3mf({"enable_support": "1"})
preset = json.dumps(
{
"enable_support": "0",
"support_filament": "2",
"support_interface_filament": "3",
"support_type": "tree(auto)",
}
)
result = json.loads(_patch_process_support_settings(preset, source))
assert result["enable_support"] == "1"
# Preset's values kept for keys the source didn't define.
assert result["support_filament"] == "2"
assert result["support_interface_filament"] == "3"
assert result["support_type"] == "tree(auto)"
def test_no_project_settings_in_source_returns_preset_unchanged(self):
# STL / STEP / a stripped-down 3MF has no project_settings.config;
# nothing to overlay, preset must pass through untouched.
source = _make_3mf(None)
preset = json.dumps({"enable_support": "0", "layer_height": "0.20"})
result = _patch_process_support_settings(preset, source)
# Same JSON round-trips.
assert json.loads(result) == {"enable_support": "0", "layer_height": "0.20"}
def test_malformed_source_returns_preset_unchanged(self):
# A malformed source 3MF (or a random blob) can't yield support
# info; the slice then runs with the preset's own defaults, which
# is the safe fall-back matching pre-fix behaviour.
preset = json.dumps({"enable_support": "0"})
assert json.loads(_patch_process_support_settings(preset, b"not a zip")) == {"enable_support": "0"}
def test_malformed_project_settings_json_returns_preset_unchanged(self):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("Metadata/project_settings.config", "{not json")
source = buf.getvalue()
preset = json.dumps({"enable_support": "0"})
assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
def test_source_project_settings_not_dict_returns_preset_unchanged(self):
# Defensive: spec says it's a dict, but a source that ships a
# top-level list (or anything non-dict) shouldn't crash the slice.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("Metadata/project_settings.config", json.dumps([]))
source = buf.getvalue()
preset = json.dumps({"enable_support": "0"})
assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
def test_malformed_preset_json_returns_input_unchanged(self):
# Symmetric to test_returns_input_unchanged_when_json_is_invalid
# in the bed-type patch's test suite. The slicer would error on
# the preset anyway; the patch is a straight passthrough so
# failure attributes to the original input.
source = _make_3mf({"enable_support": "1"})
bogus = "not a json document"
assert _patch_process_support_settings(bogus, source) is bogus
def test_preset_json_not_a_dict_returns_input_unchanged(self):
source = _make_3mf({"enable_support": "1"})
not_a_dict = json.dumps(["this", "is", "an", "array"])
assert _patch_process_support_settings(not_a_dict, source) is not_a_dict