Files
bambuddy/backend/tests/unit/services/test_slice_preview.py
maziggy 1c42a9f1fd remove(slicer): drop bundle import; fix cloud preset type/from for CLI (#1712)
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
  strips system processes/filaments, so importing a bundle left users without
  process presets and slicing fell back to embedded settings on STL. Bundle
  mode also hid the standard tier behind a constrained dropdown, the actual
  trap reported here.

  Removed end-to-end:
  - backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
    SliceBundleSpec, dispatch fork in library.py, bundle-context params on
    the filament-requirements endpoints, bundle-fingerprint cache key in
    slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
    slice_with_bundle, BundleSummary / BundleNotFoundError.
  - frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
    branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
    SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
    loses its bundle path; presetCompatibility keeps compatible_printers
    plus the @BBL fallback.
  - SlicerBundlesPanel turns into a permanent static notice explaining the
    removal, alternative import paths, and the new slice-time lookup order
    (Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
  - i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
    translated across all 11 locales; slice.bundle*, slicerBundles.* keys
    removed.

  Fixed (surfaced by removing bundle mode):
  - _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
    from: "system" on the payload before json.dumps. Bambu Cloud ships
    type as "printer"/"print" and routinely empty `from`; the BS CLI's
    --load-settings parser rejects both with return -5 / "input preset
    file invalid". Standard tier already did this; cloud paths now match.
2026-06-12 10:14:06 +02:00

257 lines
9.6 KiB
Python

"""Unit tests for the preview-slice cache.
The preview-slice runs the sidecar's `slice_without_profiles` on an unsliced
project file to extract the per-plate filament list. Results are cached by
``(kind, source_id, plate_id, content_hash)`` with LRU eviction so repeat
modal opens on the same plate are instant.
"""
from __future__ import annotations
import asyncio
import io
import zipfile
from typing import Any
from unittest.mock import patch
import pytest
from backend.app.services import slice_preview
from backend.app.services.slice_preview import (
_PREVIEW_CACHE_MAX,
_parse_filaments_from_sliced_3mf,
get_preview_filaments,
)
from backend.app.services.slicer_api import (
SlicerApiUnavailableError,
SliceResult,
)
def _make_sliced_3mf(plate_id: int, filaments: list[dict[str, str]]) -> bytes:
"""Build a fake sliced-3MF zip whose Metadata/slice_info.config has one
plate matching ``plate_id`` with the given filament rows."""
fil_xml = "".join(
f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}"'
f' used_g="{f.get("used_g", "0")}" used_m="{f.get("used_m", "0")}"'
f' tray_info_idx="{f.get("tray_info_idx", "")}"/>'
for f in filaments
)
slice_info = (
f'<?xml version="1.0"?><config><plate><metadata key="index" value="{plate_id}"/>{fil_xml}</plate></config>'
)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Metadata/slice_info.config", slice_info)
return buf.getvalue()
@pytest.fixture(autouse=True)
def _reset_cache():
"""Each test gets an empty cache + lock dict to keep them independent."""
slice_preview._preview_cache.clear()
slice_preview._preview_locks.clear()
yield
slice_preview._preview_cache.clear()
slice_preview._preview_locks.clear()
class _StubService:
"""Mimics SlicerApiService just enough for these tests. Records every
`slice_without_profiles` call so we can assert call counts."""
def __init__(self, response_bytes: bytes | None = None, raise_exc: BaseException | None = None) -> None:
self.response_bytes = response_bytes
self.raise_exc = raise_exc
self.calls: list[dict[str, Any]] = []
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def slice_without_profiles(self, **kw):
self.calls.append({"method": "slice_without_profiles", **kw})
if self.raise_exc is not None:
raise self.raise_exc
return SliceResult(
content=self.response_bytes or b"",
print_time_seconds=0,
filament_used_g=0.0,
filament_used_mm=0.0,
)
# ---------------------------------------------------------------------------
# _parse_filaments_from_sliced_3mf — pure-function parsing tests.
# ---------------------------------------------------------------------------
class TestParseFilamentsFromSliced3mf:
def test_happy_path(self):
body = _make_sliced_3mf(
plate_id=22,
filaments=[
{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "33.9"},
{"id": "6", "type": "PLA", "color": "#FF0000", "used_g": "37.7"},
],
)
result = _parse_filaments_from_sliced_3mf(body, 22)
assert result is not None
assert [(f["slot_id"], f["color"]) for f in result] == [(1, "#FFFFFF"), (6, "#FF0000")]
assert result[0]["used_grams"] == 33.9
def test_missing_slice_info_returns_none(self):
empty_zip = io.BytesIO()
with zipfile.ZipFile(empty_zip, "w") as zf:
zf.writestr("placeholder.txt", "x")
assert _parse_filaments_from_sliced_3mf(empty_zip.getvalue(), 1) is None
def test_plate_not_in_slice_info_returns_none(self):
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
assert _parse_filaments_from_sliced_3mf(body, plate_id=99) is None
def test_corrupt_zip_returns_none(self):
assert _parse_filaments_from_sliced_3mf(b"not a zip file", 1) is None
# ---------------------------------------------------------------------------
# get_preview_filaments — cache + concurrency behaviour.
# ---------------------------------------------------------------------------
class TestGetPreviewFilaments:
@pytest.mark.asyncio
async def test_happy_path_caches_result(self):
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
first = await get_preview_filaments(
kind="archive",
source_id=1,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
)
second = await get_preview_filaments(
kind="archive",
source_id=1,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
)
assert first is not None
assert first[0]["slot_id"] == 1
assert second == first
# Cache hit — only one slice was actually run.
assert len(stub.calls) == 1
@pytest.mark.asyncio
async def test_different_content_hash_misses_cache(self):
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
await get_preview_filaments(
kind="archive",
source_id=1,
plate_id=1,
file_bytes=b"v1",
file_name="x.3mf",
api_url="http://sidecar",
)
await get_preview_filaments(
kind="archive",
source_id=1,
plate_id=1,
file_bytes=b"v2", # Same archive, but content changed
file_name="x.3mf",
api_url="http://sidecar",
)
# Hash differs → cache miss → fresh slice.
assert len(stub.calls) == 2
@pytest.mark.asyncio
async def test_sidecar_unavailable_returns_none_no_cache(self):
# Transient sidecar failure must NOT poison the cache — the next
# request retries cleanly.
stub = _StubService(raise_exc=SlicerApiUnavailableError("boom"))
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
first = await get_preview_filaments(
kind="archive",
source_id=1,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
)
assert first is None
# Second call hits the sidecar again (no cached failure).
await get_preview_filaments(
kind="archive",
source_id=1,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
)
assert len(stub.calls) == 2
@pytest.mark.asyncio
async def test_concurrent_calls_share_one_slice(self):
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
# Slow stub so we can observe N coroutines piling up on the lock.
class _SlowStub(_StubService):
async def slice_without_profiles(self, **kw):
self.calls.append(kw)
await asyncio.sleep(0.05)
return SliceResult(
content=self.response_bytes or b"",
print_time_seconds=0,
filament_used_g=0.0,
filament_used_mm=0.0,
)
stub = _SlowStub(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
results = await asyncio.gather(
*(
get_preview_filaments(
kind="archive",
source_id=1,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
)
for _ in range(8)
),
)
# All 8 callers got the same result, but only ONE slice ran.
assert all(r == results[0] for r in results)
assert len(stub.calls) == 1
@pytest.mark.asyncio
async def test_lru_eviction_drops_lock(self):
# Fill cache past the bound; oldest should evict, including its lock.
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
# Each call has a unique source_id → unique cache key.
for i in range(_PREVIEW_CACHE_MAX + 5):
await get_preview_filaments(
kind="archive",
source_id=i,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
)
# Cache is bounded — older entries fell off.
assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX
# Lock dict is also pruned (no leak): same size as cache.
assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX