test_project_settings_sentinel_sanitiser.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """Unit tests for ``sanitize_project_settings_sentinels`` (#1201, #3030).
  2. MakerWorld 3MFs sliced for the P2S (and potentially other Bambu printers)
  3. ship ``Metadata/project_settings.config`` entries with ``"-1"`` values on
  4. fields that BambuStudio's GUI internally interprets as "inherit from the
  5. parent process preset" — but the headless slicer CLI's
  6. ``StaticPrintConfig`` validator runs *before* ``--load-settings`` overrides
  7. apply, so the sentinel trips the field's lower-bound range check and the
  8. CLI exits non-zero. The user sees::
  9. Param values in 3mf/config error:
  10. raft_first_layer_expansion: -1 not in range [0.0, 3.4e+38]
  11. tree_support_wall_count: -1 not in range [0.0, 2.0]
  12. Earlier the codebase tried to fix this by stripping
  13. ``Metadata/project_settings.config`` (and its sibling configs) entirely.
  14. That broke ``StaticPrintConfig`` initialisation — see the comment block
  15. inside ``_run_slicer_with_fallback`` — so the strip-everything path was
  16. reverted. The current fix is surgical: open the embedded config, drop
  17. *only* the allowlisted keys when their value is exactly ``"-1"``, and
  18. re-zip. The slicer then falls back to the supplied ``--load-settings``
  19. default for the removed keys, while every other entry in the zip stays
  20. byte-identical.
  21. #3030 added a second sentinel value. ``wall_filament``,
  22. ``sparse_infill_filament`` and ``solid_infill_filament`` are filament indices
  23. that Bambu Studio writes as ``"0"`` meaning "use the active object/part
  24. filament". Bambu Studio and OrcaSlicer 2.4.0+ define these ``min 0``, so the
  25. value is legal there; OrcaSlicer 2.3.x and earlier used the 1-based scheme
  26. (``min 1``, default ``1``) and answer::
  27. wall_filament: 0 not in range [1.000000,...]
  28. Sidecar images are version-tagged, so an install can be pinned to one of
  29. those builds. So the allowlist is a key -> sentinel mapping now rather than
  30. one global constant, and the two buckets must not bleed into each other: a
  31. ``"-1"`` on a filament index is not a sentinel, and a ``"0"`` on a raft field
  32. is a value the user chose.
  33. Pinning the contract here rather than via the slicer integration tests
  34. because the fix is purely about the bytes we hand to the sidecar — no
  35. slicer mock needed.
  36. """
  37. import io
  38. import json
  39. import zipfile
  40. import pytest
  41. from backend.app.utils.threemf_tools import (
  42. PROJECT_SETTINGS_SENTINELS,
  43. sanitize_project_settings_sentinels,
  44. )
  45. def _make_3mf(*, settings: dict | None = None, extra_files: dict | None = None) -> bytes:
  46. """Build a tiny in-memory 3MF zip with project_settings.config + a model
  47. payload, plus any caller-supplied extra entries (e.g., model_settings.config)
  48. that should round-trip byte-identical through the sanitiser.
  49. """
  50. buf = io.BytesIO()
  51. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  52. zf.writestr("3D/3dmodel.model", "<model><resources/></model>")
  53. if settings is not None:
  54. zf.writestr("Metadata/project_settings.config", json.dumps(settings))
  55. for name, content in (extra_files or {}).items():
  56. zf.writestr(name, content)
  57. return buf.getvalue()
  58. def _read_settings(zip_bytes: bytes) -> dict:
  59. with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf:
  60. return json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
  61. def _zip_namelist(zip_bytes: bytes) -> list[str]:
  62. with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf:
  63. return zf.namelist()
  64. class TestRemovesSentinelValues:
  65. @pytest.mark.parametrize(("key", "sentinel"), sorted(PROJECT_SETTINGS_SENTINELS.items()))
  66. def test_removes_each_allowlisted_key_at_its_own_sentinel(self, key, sentinel):
  67. original = _make_3mf(settings={key: sentinel, "layer_height": "0.2"})
  68. sanitised = sanitize_project_settings_sentinels(original)
  69. cfg = _read_settings(sanitised)
  70. assert key not in cfg, f"Sentinel key {key!r} should have been removed"
  71. # Non-sentinel settings stay untouched so --load-settings can layer
  72. # cleanly on top of what the user actually configured.
  73. assert cfg["layer_height"] == "0.2"
  74. def test_removes_multiple_sentinels_at_once(self):
  75. original = _make_3mf(
  76. settings={
  77. "raft_first_layer_expansion": "-1",
  78. "tree_support_wall_count": "-1",
  79. "prime_tower_brim_width": "-1",
  80. "layer_height": "0.2",
  81. }
  82. )
  83. sanitised = sanitize_project_settings_sentinels(original)
  84. cfg = _read_settings(sanitised)
  85. assert "raft_first_layer_expansion" not in cfg
  86. assert "tree_support_wall_count" not in cfg
  87. assert "prime_tower_brim_width" not in cfg
  88. assert cfg["layer_height"] == "0.2"
  89. class TestPreservesUnaffectedValues:
  90. def test_preserves_allowlisted_key_with_legitimate_non_sentinel_value(self):
  91. # A user who deliberately configured raft_first_layer_expansion=0 must
  92. # see that 0 forwarded to the slicer — only literal "-1" gets stripped.
  93. original = _make_3mf(settings={"raft_first_layer_expansion": "0"})
  94. sanitised = sanitize_project_settings_sentinels(original)
  95. assert _read_settings(sanitised)["raft_first_layer_expansion"] == "0"
  96. def test_does_not_touch_non_allowlisted_keys_with_minus_one(self):
  97. # Non-allowlisted keys are left alone even when they hold "-1".
  98. # Some Bambu fields legitimately allow negative values (z_offset,
  99. # translation, etc.) and a blanket "-1" strip would corrupt those.
  100. original = _make_3mf(settings={"z_offset": "-1", "layer_height": "0.2"})
  101. sanitised = sanitize_project_settings_sentinels(original)
  102. cfg = _read_settings(sanitised)
  103. assert cfg["z_offset"] == "-1"
  104. assert cfg["layer_height"] == "0.2"
  105. def test_returns_original_bytes_when_no_sentinel_present(self):
  106. # If nothing needs sanitising, return the input identity-equal so
  107. # the caller's downstream comparisons / hashes don't churn.
  108. original = _make_3mf(settings={"layer_height": "0.2", "z_offset": "0"})
  109. sanitised = sanitize_project_settings_sentinels(original)
  110. assert sanitised is original
  111. def test_does_not_strip_array_value_even_if_includes_minus_one(self):
  112. # Bambu sometimes stores per-filament/per-extruder values as JSON
  113. # arrays of strings. v1 of the sanitiser deliberately handles only
  114. # scalar strings — array forms are left alone so a per-filament
  115. # legitimate "-1" inside a list isn't mistaken for the inherit
  116. # sentinel and removed wholesale. If a future report shows the CLI
  117. # rejects array-form sentinels, expand this then.
  118. original = _make_3mf(settings={"raft_first_layer_expansion": ["-1", "0"]})
  119. sanitised = sanitize_project_settings_sentinels(original)
  120. cfg = _read_settings(sanitised)
  121. assert cfg["raft_first_layer_expansion"] == ["-1", "0"]
  122. class TestZipPreservation:
  123. def test_other_zip_entries_pass_through_unchanged(self):
  124. original = _make_3mf(
  125. settings={"raft_first_layer_expansion": "-1"},
  126. extra_files={
  127. "Metadata/model_settings.config": "<config><object id='1'/></config>",
  128. "Metadata/slice_info.config": "<config><plate/></config>",
  129. "Metadata/_rels/model_settings.rels": "<rels/>",
  130. },
  131. )
  132. sanitised = sanitize_project_settings_sentinels(original)
  133. assert sanitised is not original
  134. names = _zip_namelist(sanitised)
  135. # Every entry from the original zip must survive — the previous
  136. # full-strip experiment broke StaticPrintConfig by dropping these,
  137. # so the new sanitiser leaves them alone (#1201).
  138. for required in (
  139. "3D/3dmodel.model",
  140. "Metadata/project_settings.config",
  141. "Metadata/model_settings.config",
  142. "Metadata/slice_info.config",
  143. "Metadata/_rels/model_settings.rels",
  144. ):
  145. assert required in names, f"{required} must be preserved in the rebuilt zip"
  146. # Content of unrelated entries is byte-identical.
  147. with zipfile.ZipFile(io.BytesIO(sanitised), "r") as zf:
  148. assert zf.read("Metadata/model_settings.config").decode() == "<config><object id='1'/></config>"
  149. assert zf.read("3D/3dmodel.model").decode() == "<model><resources/></model>"
  150. class TestDefensiveFallbacks:
  151. def test_returns_original_when_input_is_not_a_zip(self):
  152. # An STL or any other non-zip input: pass through. The slicer
  153. # routing decides whether 3MF sanitisation runs anyway, but
  154. # defending here means a misrouted call can't corrupt the bytes.
  155. garbage = b"not a zip file"
  156. assert sanitize_project_settings_sentinels(garbage) is garbage
  157. def test_returns_original_when_settings_config_absent(self):
  158. # 3MF without an embedded project_settings.config — nothing to do.
  159. original = _make_3mf(settings=None)
  160. assert sanitize_project_settings_sentinels(original) is original
  161. def test_returns_original_on_malformed_json(self):
  162. # Settings file present but not valid JSON. We don't risk rebuilding
  163. # the zip with synthesised content; the CLI will surface its own
  164. # error and that's better than silent corruption.
  165. buf = io.BytesIO()
  166. with zipfile.ZipFile(buf, "w") as zf:
  167. zf.writestr("3D/3dmodel.model", "<model/>")
  168. zf.writestr("Metadata/project_settings.config", "{not valid json")
  169. original = buf.getvalue()
  170. assert sanitize_project_settings_sentinels(original) is original
  171. def test_returns_original_when_settings_root_is_not_a_dict(self):
  172. # Real-world configs are objects, but defend against an array root
  173. # (some legacy tooling produced these). Returning unchanged is
  174. # safer than fabricating a dict.
  175. buf = io.BytesIO()
  176. with zipfile.ZipFile(buf, "w") as zf:
  177. zf.writestr("3D/3dmodel.model", "<model/>")
  178. zf.writestr("Metadata/project_settings.config", "[]")
  179. original = buf.getvalue()
  180. assert sanitize_project_settings_sentinels(original) is original
  181. class TestTheTwoSentinelBucketsDoNotBleed:
  182. """Each key has exactly one sentinel, and the other bucket's value is a
  183. real setting on it. Getting this wrong in either direction is worse than
  184. the bug: strip a filament index that says ``-1`` and the slice loses a
  185. value nothing will put back; strip a raft field that says ``0`` and a
  186. user who deliberately turned the raft expansion off gets the preset's
  187. default instead."""
  188. @pytest.mark.parametrize("key", ["wall_filament", "sparse_infill_filament", "solid_infill_filament"])
  189. def test_a_filament_index_of_minus_one_is_left_alone(self, key):
  190. original = _make_3mf(settings={key: "-1"})
  191. assert sanitize_project_settings_sentinels(original) is original
  192. @pytest.mark.parametrize("key", ["raft_first_layer_expansion", "tree_support_wall_count", "prime_tower_brim_width"])
  193. def test_a_zero_on_a_minus_one_key_is_left_alone(self, key):
  194. original = _make_3mf(settings={key: "0"})
  195. assert sanitize_project_settings_sentinels(original) is original
  196. def test_a_mixed_config_removes_exactly_the_sentinels(self):
  197. original = _make_3mf(
  198. settings={
  199. # sentinels, both buckets
  200. "raft_first_layer_expansion": "-1",
  201. "wall_filament": "0",
  202. # same keys' non-sentinel values, crossed over
  203. "tree_support_wall_count": "0",
  204. "sparse_infill_filament": "-1",
  205. # an explicit filament pick
  206. "solid_infill_filament": "2",
  207. "layer_height": "0.2",
  208. }
  209. )
  210. cfg = _read_settings(sanitize_project_settings_sentinels(original))
  211. assert "raft_first_layer_expansion" not in cfg
  212. assert "wall_filament" not in cfg
  213. assert cfg["tree_support_wall_count"] == "0"
  214. assert cfg["sparse_infill_filament"] == "-1"
  215. assert cfg["solid_infill_filament"] == "2"
  216. assert cfg["layer_height"] == "0.2"
  217. class TestNumericValuesAreRecognised:
  218. """Bambu Studio writes every value as a string, but a 3MF round-tripped
  219. through another tool can carry the same field as a JSON number. The
  220. slicer's validator reads the deserialised int either way, so the
  221. sanitiser has to as well."""
  222. def test_an_integer_sentinel_is_removed(self):
  223. original = _make_3mf(settings={"wall_filament": 0, "layer_height": "0.2"})
  224. cfg = _read_settings(sanitize_project_settings_sentinels(original))
  225. assert "wall_filament" not in cfg
  226. assert cfg["layer_height"] == "0.2"
  227. def test_an_integer_non_sentinel_survives(self):
  228. original = _make_3mf(settings={"wall_filament": 2})
  229. assert sanitize_project_settings_sentinels(original) is original
  230. def test_false_is_not_a_zero_sentinel(self):
  231. # bool is an int subclass in Python. A config that stores a flag as
  232. # JSON ``false`` under one of these names must not be mistaken for
  233. # the numeric 0 sentinel.
  234. original = _make_3mf(settings={"wall_filament": False})
  235. assert sanitize_project_settings_sentinels(original) is original