test_project_settings_sentinel_sanitiser.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """Unit tests for ``_sanitize_project_settings_sentinels`` (#1201).
  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. Pinning the contract here rather than via the slicer integration tests
  22. because the fix is purely about the bytes we hand to the sidecar — no
  23. slicer mock needed.
  24. """
  25. import io
  26. import json
  27. import zipfile
  28. import pytest
  29. from backend.app.api.routes.library import (
  30. _PROJECT_SETTINGS_SENTINEL_KEYS,
  31. _sanitize_project_settings_sentinels,
  32. )
  33. def _make_3mf(*, settings: dict | None = None, extra_files: dict | None = None) -> bytes:
  34. """Build a tiny in-memory 3MF zip with project_settings.config + a model
  35. payload, plus any caller-supplied extra entries (e.g., model_settings.config)
  36. that should round-trip byte-identical through the sanitiser.
  37. """
  38. buf = io.BytesIO()
  39. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  40. zf.writestr("3D/3dmodel.model", "<model><resources/></model>")
  41. if settings is not None:
  42. zf.writestr("Metadata/project_settings.config", json.dumps(settings))
  43. for name, content in (extra_files or {}).items():
  44. zf.writestr(name, content)
  45. return buf.getvalue()
  46. def _read_settings(zip_bytes: bytes) -> dict:
  47. with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf:
  48. return json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
  49. def _zip_namelist(zip_bytes: bytes) -> list[str]:
  50. with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf:
  51. return zf.namelist()
  52. class TestRemovesSentinelValues:
  53. @pytest.mark.parametrize("key", sorted(_PROJECT_SETTINGS_SENTINEL_KEYS))
  54. def test_removes_each_allowlisted_key_when_value_is_minus_one(self, key):
  55. original = _make_3mf(settings={key: "-1", "layer_height": "0.2"})
  56. sanitised = _sanitize_project_settings_sentinels(original)
  57. cfg = _read_settings(sanitised)
  58. assert key not in cfg, f"Sentinel key {key!r} should have been removed"
  59. # Non-sentinel settings stay untouched so --load-settings can layer
  60. # cleanly on top of what the user actually configured.
  61. assert cfg["layer_height"] == "0.2"
  62. def test_removes_multiple_sentinels_at_once(self):
  63. original = _make_3mf(
  64. settings={
  65. "raft_first_layer_expansion": "-1",
  66. "tree_support_wall_count": "-1",
  67. "prime_tower_brim_width": "-1",
  68. "layer_height": "0.2",
  69. }
  70. )
  71. sanitised = _sanitize_project_settings_sentinels(original)
  72. cfg = _read_settings(sanitised)
  73. assert "raft_first_layer_expansion" not in cfg
  74. assert "tree_support_wall_count" not in cfg
  75. assert "prime_tower_brim_width" not in cfg
  76. assert cfg["layer_height"] == "0.2"
  77. class TestPreservesUnaffectedValues:
  78. def test_preserves_allowlisted_key_with_legitimate_non_sentinel_value(self):
  79. # A user who deliberately configured raft_first_layer_expansion=0 must
  80. # see that 0 forwarded to the slicer — only literal "-1" gets stripped.
  81. original = _make_3mf(settings={"raft_first_layer_expansion": "0"})
  82. sanitised = _sanitize_project_settings_sentinels(original)
  83. assert _read_settings(sanitised)["raft_first_layer_expansion"] == "0"
  84. def test_does_not_touch_non_allowlisted_keys_with_minus_one(self):
  85. # Non-allowlisted keys are left alone even when they hold "-1".
  86. # Some Bambu fields legitimately allow negative values (z_offset,
  87. # translation, etc.) and a blanket "-1" strip would corrupt those.
  88. original = _make_3mf(settings={"z_offset": "-1", "layer_height": "0.2"})
  89. sanitised = _sanitize_project_settings_sentinels(original)
  90. cfg = _read_settings(sanitised)
  91. assert cfg["z_offset"] == "-1"
  92. assert cfg["layer_height"] == "0.2"
  93. def test_returns_original_bytes_when_no_sentinel_present(self):
  94. # If nothing needs sanitising, return the input identity-equal so
  95. # the caller's downstream comparisons / hashes don't churn.
  96. original = _make_3mf(settings={"layer_height": "0.2", "z_offset": "0"})
  97. sanitised = _sanitize_project_settings_sentinels(original)
  98. assert sanitised is original
  99. def test_does_not_strip_array_value_even_if_includes_minus_one(self):
  100. # Bambu sometimes stores per-filament/per-extruder values as JSON
  101. # arrays of strings. v1 of the sanitiser deliberately handles only
  102. # scalar strings — array forms are left alone so a per-filament
  103. # legitimate "-1" inside a list isn't mistaken for the inherit
  104. # sentinel and removed wholesale. If a future report shows the CLI
  105. # rejects array-form sentinels, expand this then.
  106. original = _make_3mf(settings={"raft_first_layer_expansion": ["-1", "0"]})
  107. sanitised = _sanitize_project_settings_sentinels(original)
  108. cfg = _read_settings(sanitised)
  109. assert cfg["raft_first_layer_expansion"] == ["-1", "0"]
  110. class TestZipPreservation:
  111. def test_other_zip_entries_pass_through_unchanged(self):
  112. original = _make_3mf(
  113. settings={"raft_first_layer_expansion": "-1"},
  114. extra_files={
  115. "Metadata/model_settings.config": "<config><object id='1'/></config>",
  116. "Metadata/slice_info.config": "<config><plate/></config>",
  117. "Metadata/_rels/model_settings.rels": "<rels/>",
  118. },
  119. )
  120. sanitised = _sanitize_project_settings_sentinels(original)
  121. assert sanitised is not original
  122. names = _zip_namelist(sanitised)
  123. # Every entry from the original zip must survive — the previous
  124. # full-strip experiment broke StaticPrintConfig by dropping these,
  125. # so the new sanitiser leaves them alone (#1201).
  126. for required in (
  127. "3D/3dmodel.model",
  128. "Metadata/project_settings.config",
  129. "Metadata/model_settings.config",
  130. "Metadata/slice_info.config",
  131. "Metadata/_rels/model_settings.rels",
  132. ):
  133. assert required in names, f"{required} must be preserved in the rebuilt zip"
  134. # Content of unrelated entries is byte-identical.
  135. with zipfile.ZipFile(io.BytesIO(sanitised), "r") as zf:
  136. assert zf.read("Metadata/model_settings.config").decode() == "<config><object id='1'/></config>"
  137. assert zf.read("3D/3dmodel.model").decode() == "<model><resources/></model>"
  138. class TestDefensiveFallbacks:
  139. def test_returns_original_when_input_is_not_a_zip(self):
  140. # An STL or any other non-zip input: pass through. The slicer
  141. # routing decides whether 3MF sanitisation runs anyway, but
  142. # defending here means a misrouted call can't corrupt the bytes.
  143. garbage = b"not a zip file"
  144. assert _sanitize_project_settings_sentinels(garbage) is garbage
  145. def test_returns_original_when_settings_config_absent(self):
  146. # 3MF without an embedded project_settings.config — nothing to do.
  147. original = _make_3mf(settings=None)
  148. assert _sanitize_project_settings_sentinels(original) is original
  149. def test_returns_original_on_malformed_json(self):
  150. # Settings file present but not valid JSON. We don't risk rebuilding
  151. # the zip with synthesised content; the CLI will surface its own
  152. # error and that's better than silent corruption.
  153. buf = io.BytesIO()
  154. with zipfile.ZipFile(buf, "w") as zf:
  155. zf.writestr("3D/3dmodel.model", "<model/>")
  156. zf.writestr("Metadata/project_settings.config", "{not valid json")
  157. original = buf.getvalue()
  158. assert _sanitize_project_settings_sentinels(original) is original
  159. def test_returns_original_when_settings_root_is_not_a_dict(self):
  160. # Real-world configs are objects, but defend against an array root
  161. # (some legacy tooling produced these). Returning unchanged is
  162. # safer than fabricating a dict.
  163. buf = io.BytesIO()
  164. with zipfile.ZipFile(buf, "w") as zf:
  165. zf.writestr("3D/3dmodel.model", "<model/>")
  166. zf.writestr("Metadata/project_settings.config", "[]")
  167. original = buf.getvalue()
  168. assert _sanitize_project_settings_sentinels(original) is original