|
|
@@ -1,4 +1,4 @@
|
|
|
-"""Unit tests for ``_sanitize_project_settings_sentinels`` (#1201).
|
|
|
+"""Unit tests for ``sanitize_project_settings_sentinels`` (#1201, #3030).
|
|
|
|
|
|
MakerWorld 3MFs sliced for the P2S (and potentially other Bambu printers)
|
|
|
ship ``Metadata/project_settings.config`` entries with ``"-1"`` values on
|
|
|
@@ -22,6 +22,21 @@ re-zip. The slicer then falls back to the supplied ``--load-settings``
|
|
|
default for the removed keys, while every other entry in the zip stays
|
|
|
byte-identical.
|
|
|
|
|
|
+#3030 added a second sentinel value. ``wall_filament``,
|
|
|
+``sparse_infill_filament`` and ``solid_infill_filament`` are filament indices
|
|
|
+that Bambu Studio writes as ``"0"`` meaning "use the active object/part
|
|
|
+filament". Bambu Studio and OrcaSlicer 2.4.0+ define these ``min 0``, so the
|
|
|
+value is legal there; OrcaSlicer 2.3.x and earlier used the 1-based scheme
|
|
|
+(``min 1``, default ``1``) and answer::
|
|
|
+
|
|
|
+ wall_filament: 0 not in range [1.000000,...]
|
|
|
+
|
|
|
+Sidecar images are version-tagged, so an install can be pinned to one of
|
|
|
+those builds. So the allowlist is a key -> sentinel mapping now rather than
|
|
|
+one global constant, and the two buckets must not bleed into each other: a
|
|
|
+``"-1"`` on a filament index is not a sentinel, and a ``"0"`` on a raft field
|
|
|
+is a value the user chose.
|
|
|
+
|
|
|
Pinning the contract here rather than via the slicer integration tests
|
|
|
because the fix is purely about the bytes we hand to the sidecar — no
|
|
|
slicer mock needed.
|
|
|
@@ -33,9 +48,9 @@ import zipfile
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
-from backend.app.api.routes.library import (
|
|
|
- _PROJECT_SETTINGS_SENTINEL_KEYS,
|
|
|
- _sanitize_project_settings_sentinels,
|
|
|
+from backend.app.utils.threemf_tools import (
|
|
|
+ PROJECT_SETTINGS_SENTINELS,
|
|
|
+ sanitize_project_settings_sentinels,
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -65,10 +80,10 @@ def _zip_namelist(zip_bytes: bytes) -> list[str]:
|
|
|
|
|
|
|
|
|
class TestRemovesSentinelValues:
|
|
|
- @pytest.mark.parametrize("key", sorted(_PROJECT_SETTINGS_SENTINEL_KEYS))
|
|
|
- def test_removes_each_allowlisted_key_when_value_is_minus_one(self, key):
|
|
|
- original = _make_3mf(settings={key: "-1", "layer_height": "0.2"})
|
|
|
- sanitised = _sanitize_project_settings_sentinels(original)
|
|
|
+ @pytest.mark.parametrize(("key", "sentinel"), sorted(PROJECT_SETTINGS_SENTINELS.items()))
|
|
|
+ def test_removes_each_allowlisted_key_at_its_own_sentinel(self, key, sentinel):
|
|
|
+ original = _make_3mf(settings={key: sentinel, "layer_height": "0.2"})
|
|
|
+ sanitised = sanitize_project_settings_sentinels(original)
|
|
|
|
|
|
cfg = _read_settings(sanitised)
|
|
|
assert key not in cfg, f"Sentinel key {key!r} should have been removed"
|
|
|
@@ -85,7 +100,7 @@ class TestRemovesSentinelValues:
|
|
|
"layer_height": "0.2",
|
|
|
}
|
|
|
)
|
|
|
- sanitised = _sanitize_project_settings_sentinels(original)
|
|
|
+ sanitised = sanitize_project_settings_sentinels(original)
|
|
|
|
|
|
cfg = _read_settings(sanitised)
|
|
|
assert "raft_first_layer_expansion" not in cfg
|
|
|
@@ -99,7 +114,7 @@ class TestPreservesUnaffectedValues:
|
|
|
# A user who deliberately configured raft_first_layer_expansion=0 must
|
|
|
# see that 0 forwarded to the slicer — only literal "-1" gets stripped.
|
|
|
original = _make_3mf(settings={"raft_first_layer_expansion": "0"})
|
|
|
- sanitised = _sanitize_project_settings_sentinels(original)
|
|
|
+ sanitised = sanitize_project_settings_sentinels(original)
|
|
|
assert _read_settings(sanitised)["raft_first_layer_expansion"] == "0"
|
|
|
|
|
|
def test_does_not_touch_non_allowlisted_keys_with_minus_one(self):
|
|
|
@@ -107,7 +122,7 @@ class TestPreservesUnaffectedValues:
|
|
|
# Some Bambu fields legitimately allow negative values (z_offset,
|
|
|
# translation, etc.) and a blanket "-1" strip would corrupt those.
|
|
|
original = _make_3mf(settings={"z_offset": "-1", "layer_height": "0.2"})
|
|
|
- sanitised = _sanitize_project_settings_sentinels(original)
|
|
|
+ sanitised = sanitize_project_settings_sentinels(original)
|
|
|
|
|
|
cfg = _read_settings(sanitised)
|
|
|
assert cfg["z_offset"] == "-1"
|
|
|
@@ -117,7 +132,7 @@ class TestPreservesUnaffectedValues:
|
|
|
# If nothing needs sanitising, return the input identity-equal so
|
|
|
# the caller's downstream comparisons / hashes don't churn.
|
|
|
original = _make_3mf(settings={"layer_height": "0.2", "z_offset": "0"})
|
|
|
- sanitised = _sanitize_project_settings_sentinels(original)
|
|
|
+ sanitised = sanitize_project_settings_sentinels(original)
|
|
|
assert sanitised is original
|
|
|
|
|
|
def test_does_not_strip_array_value_even_if_includes_minus_one(self):
|
|
|
@@ -128,7 +143,7 @@ class TestPreservesUnaffectedValues:
|
|
|
# sentinel and removed wholesale. If a future report shows the CLI
|
|
|
# rejects array-form sentinels, expand this then.
|
|
|
original = _make_3mf(settings={"raft_first_layer_expansion": ["-1", "0"]})
|
|
|
- sanitised = _sanitize_project_settings_sentinels(original)
|
|
|
+ sanitised = sanitize_project_settings_sentinels(original)
|
|
|
cfg = _read_settings(sanitised)
|
|
|
assert cfg["raft_first_layer_expansion"] == ["-1", "0"]
|
|
|
|
|
|
@@ -143,7 +158,7 @@ class TestZipPreservation:
|
|
|
"Metadata/_rels/model_settings.rels": "<rels/>",
|
|
|
},
|
|
|
)
|
|
|
- sanitised = _sanitize_project_settings_sentinels(original)
|
|
|
+ sanitised = sanitize_project_settings_sentinels(original)
|
|
|
assert sanitised is not original
|
|
|
|
|
|
names = _zip_namelist(sanitised)
|
|
|
@@ -171,12 +186,12 @@ class TestDefensiveFallbacks:
|
|
|
# routing decides whether 3MF sanitisation runs anyway, but
|
|
|
# defending here means a misrouted call can't corrupt the bytes.
|
|
|
garbage = b"not a zip file"
|
|
|
- assert _sanitize_project_settings_sentinels(garbage) is garbage
|
|
|
+ assert sanitize_project_settings_sentinels(garbage) is garbage
|
|
|
|
|
|
def test_returns_original_when_settings_config_absent(self):
|
|
|
# 3MF without an embedded project_settings.config — nothing to do.
|
|
|
original = _make_3mf(settings=None)
|
|
|
- assert _sanitize_project_settings_sentinels(original) is original
|
|
|
+ assert sanitize_project_settings_sentinels(original) is original
|
|
|
|
|
|
def test_returns_original_on_malformed_json(self):
|
|
|
# Settings file present but not valid JSON. We don't risk rebuilding
|
|
|
@@ -187,7 +202,7 @@ class TestDefensiveFallbacks:
|
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
|
zf.writestr("Metadata/project_settings.config", "{not valid json")
|
|
|
original = buf.getvalue()
|
|
|
- assert _sanitize_project_settings_sentinels(original) is original
|
|
|
+ assert sanitize_project_settings_sentinels(original) is original
|
|
|
|
|
|
def test_returns_original_when_settings_root_is_not_a_dict(self):
|
|
|
# Real-world configs are objects, but defend against an array root
|
|
|
@@ -198,4 +213,69 @@ class TestDefensiveFallbacks:
|
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
|
zf.writestr("Metadata/project_settings.config", "[]")
|
|
|
original = buf.getvalue()
|
|
|
- assert _sanitize_project_settings_sentinels(original) is original
|
|
|
+ assert sanitize_project_settings_sentinels(original) is original
|
|
|
+
|
|
|
+
|
|
|
+class TestTheTwoSentinelBucketsDoNotBleed:
|
|
|
+ """Each key has exactly one sentinel, and the other bucket's value is a
|
|
|
+ real setting on it. Getting this wrong in either direction is worse than
|
|
|
+ the bug: strip a filament index that says ``-1`` and the slice loses a
|
|
|
+ value nothing will put back; strip a raft field that says ``0`` and a
|
|
|
+ user who deliberately turned the raft expansion off gets the preset's
|
|
|
+ default instead."""
|
|
|
+
|
|
|
+ @pytest.mark.parametrize("key", ["wall_filament", "sparse_infill_filament", "solid_infill_filament"])
|
|
|
+ def test_a_filament_index_of_minus_one_is_left_alone(self, key):
|
|
|
+ original = _make_3mf(settings={key: "-1"})
|
|
|
+ assert sanitize_project_settings_sentinels(original) is original
|
|
|
+
|
|
|
+ @pytest.mark.parametrize("key", ["raft_first_layer_expansion", "tree_support_wall_count", "prime_tower_brim_width"])
|
|
|
+ def test_a_zero_on_a_minus_one_key_is_left_alone(self, key):
|
|
|
+ original = _make_3mf(settings={key: "0"})
|
|
|
+ assert sanitize_project_settings_sentinels(original) is original
|
|
|
+
|
|
|
+ def test_a_mixed_config_removes_exactly_the_sentinels(self):
|
|
|
+ original = _make_3mf(
|
|
|
+ settings={
|
|
|
+ # sentinels, both buckets
|
|
|
+ "raft_first_layer_expansion": "-1",
|
|
|
+ "wall_filament": "0",
|
|
|
+ # same keys' non-sentinel values, crossed over
|
|
|
+ "tree_support_wall_count": "0",
|
|
|
+ "sparse_infill_filament": "-1",
|
|
|
+ # an explicit filament pick
|
|
|
+ "solid_infill_filament": "2",
|
|
|
+ "layer_height": "0.2",
|
|
|
+ }
|
|
|
+ )
|
|
|
+ cfg = _read_settings(sanitize_project_settings_sentinels(original))
|
|
|
+ assert "raft_first_layer_expansion" not in cfg
|
|
|
+ assert "wall_filament" not in cfg
|
|
|
+ assert cfg["tree_support_wall_count"] == "0"
|
|
|
+ assert cfg["sparse_infill_filament"] == "-1"
|
|
|
+ assert cfg["solid_infill_filament"] == "2"
|
|
|
+ assert cfg["layer_height"] == "0.2"
|
|
|
+
|
|
|
+
|
|
|
+class TestNumericValuesAreRecognised:
|
|
|
+ """Bambu Studio writes every value as a string, but a 3MF round-tripped
|
|
|
+ through another tool can carry the same field as a JSON number. The
|
|
|
+ slicer's validator reads the deserialised int either way, so the
|
|
|
+ sanitiser has to as well."""
|
|
|
+
|
|
|
+ def test_an_integer_sentinel_is_removed(self):
|
|
|
+ original = _make_3mf(settings={"wall_filament": 0, "layer_height": "0.2"})
|
|
|
+ cfg = _read_settings(sanitize_project_settings_sentinels(original))
|
|
|
+ assert "wall_filament" not in cfg
|
|
|
+ assert cfg["layer_height"] == "0.2"
|
|
|
+
|
|
|
+ def test_an_integer_non_sentinel_survives(self):
|
|
|
+ original = _make_3mf(settings={"wall_filament": 2})
|
|
|
+ assert sanitize_project_settings_sentinels(original) is original
|
|
|
+
|
|
|
+ def test_false_is_not_a_zero_sentinel(self):
|
|
|
+ # bool is an int subclass in Python. A config that stores a flag as
|
|
|
+ # JSON ``false`` under one of these names must not be mistaken for
|
|
|
+ # the numeric 0 sentinel.
|
|
|
+ original = _make_3mf(settings={"wall_filament": False})
|
|
|
+ assert sanitize_project_settings_sentinels(original) is original
|