"""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 json
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 (
SlicerApiServerError,
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''
for f in filaments
)
slice_info = (
f'{fil_xml}'
)
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
# ---------------------------------------------------------------------------
# Unparsable custom G-code — a 3MF from a Studio newer than the sidecar.
#
# Studio 2.8 writes `{if timelapse_inline_photo}` into `time_lapse_gcode`
# without exporting a definition for that variable, so an older sidecar dies
# on a placeholder parse error before any slice_info exists. Blanking just
# that template lets the slice finish on the file's own settings.
# ---------------------------------------------------------------------------
# Reproduced verbatim from a Bambu Studio 2.7.1.62 sidecar refusing an H2D
# project saved by Studio 02.08.00.50. Note the slicer says `timelapse_gcode`
# while the 3MF stores the field as `time_lapse_gcode`.
_TIMELAPSE_PARSE_ERROR = (
"Slicer CLI failed (500): Slicing failed with error from slicer: Failed slicing the model.: "
"Slicer process failed (exit code 156)\n"
"stderr: Failed to generate gcode for invalid custom G-code.\n\n"
"timelapse_gcode Parsing error at line 13: Not a variable name\n"
" {if timelapse_inline_photo}\n"
" ^\n"
)
def _make_project_3mf(settings: dict[str, Any], extra: dict[str, bytes] | None = None) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Metadata/project_settings.config", json.dumps(settings))
for name, data in (extra or {}).items():
zf.writestr(name, data)
return buf.getvalue()
def _settings_of(file_bytes: bytes) -> dict[str, Any]:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
return json.loads(zf.read("Metadata/project_settings.config").decode())
class TestUnparsableGcodeOption:
def test_names_the_field_the_slicer_choked_on(self):
assert slice_preview._unparsable_gcode_option(_TIMELAPSE_PARSE_ERROR) == "timelapsegcode"
def test_unrelated_failure_is_not_a_gcode_problem(self):
err = "Slicer CLI failed (500): raft_first_layer_expansion: -1 not in range [0, 340282346638]"
assert slice_preview._unparsable_gcode_option(err) is None
def test_refuses_a_field_that_extrudes(self):
# The whole safety argument for this retry: silencing a template that
# lays a prime line or purges would change the grams the preview
# exists to report. Returning nothing beats returning wrong numbers.
for field in ("machine_start_gcode", "change_filament_gcode", "filament_start_gcode"):
err = f"{field} Parsing error at line 3: Not a variable name\n {{if whatever}}\n"
assert slice_preview._unparsable_gcode_option(err) is None, field
class TestBlankCustomGcode:
def test_blanks_the_matching_field_despite_the_spelling_difference(self):
original = _make_project_3mf(
{
"time_lapse_gcode": "M971 S11 C10\n{if timelapse_inline_photo}\n",
"machine_start_gcode": "G28 ; home",
"filament_colour": ["#FFFFFF", "#000000"],
}
)
out = slice_preview._blank_custom_gcode(original, "timelapsegcode")
assert out is not None
settings = _settings_of(out)
assert settings["time_lapse_gcode"] == ""
# Everything else survives untouched — the preview's accuracy depends
# on the file's own process/support/filament settings being intact.
assert settings["machine_start_gcode"] == "G28 ; home"
assert settings["filament_colour"] == ["#FFFFFF", "#000000"]
def test_keeps_every_other_archive_member(self):
original = _make_project_3mf(
{"time_lapse_gcode": "x"},
extra={"3D/3dmodel.model": b"", "Metadata/plate_1.png": b"\x89PNG"},
)
out = slice_preview._blank_custom_gcode(original, "timelapsegcode")
assert out is not None
with zipfile.ZipFile(io.BytesIO(out)) as zf:
assert zf.read("3D/3dmodel.model") == b""
assert zf.read("Metadata/plate_1.png") == b"\x89PNG"
def test_preserves_a_list_valued_template(self):
original = _make_project_3mf({"layer_change_gcode": ["a", "b", "c"]})
out = slice_preview._blank_custom_gcode(original, "layerchangegcode")
assert out is not None
assert _settings_of(out)["layer_change_gcode"] == ["", "", ""]
def test_no_retry_when_the_field_is_already_empty(self):
# Blanking an empty field would produce a byte-identical request and
# the identical failure, so the caller must be told not to bother.
assert slice_preview._blank_custom_gcode(_make_project_3mf({"time_lapse_gcode": ""}), "timelapsegcode") is None
def test_no_match_no_retry(self):
assert slice_preview._blank_custom_gcode(_make_project_3mf({"other": "x"}), "timelapsegcode") is None
def test_only_gcode_keys_are_eligible(self):
# The normalising fold must not let a same-stem non-template setting
# be silently rewritten.
original = _make_project_3mf({"timelapse_type": "0", "timelapse_gcode_extra": "keep"})
assert slice_preview._blank_custom_gcode(original, "timelapsetype") is None
def test_non_3mf_input_is_not_a_crash(self):
assert slice_preview._blank_custom_gcode(b"not a zip", "timelapsegcode") is None
def test_3mf_without_embedded_settings(self):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("3D/3dmodel.model", "")
assert slice_preview._blank_custom_gcode(buf.getvalue(), "timelapsegcode") is None
class _FailThenSucceedService:
"""Fails the first slice with ``first_error``, then succeeds."""
def __init__(self, first_error: BaseException, response_bytes: bytes) -> None:
self.first_error = first_error
self.response_bytes = response_bytes
self.calls: list[bytes] = []
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def slice_without_profiles(self, **kw):
self.calls.append(kw["model_bytes"])
if len(self.calls) == 1:
raise self.first_error
return SliceResult(
content=self.response_bytes,
print_time_seconds=0,
filament_used_g=0.0,
filament_used_mm=0.0,
)
class TestPreviewRetriesUnparsableGcode:
@pytest.mark.asyncio
async def test_retries_once_with_the_template_blanked(self):
original = _make_project_3mf({"time_lapse_gcode": "{if timelapse_inline_photo}"})
body = _make_sliced_3mf(
plate_id=1,
filaments=[
{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "77.9"},
{"id": "4", "type": "PLA-S", "color": "#0F80FF", "used_g": "11.5"},
],
)
stub = _FailThenSucceedService(SlicerApiServerError(_TIMELAPSE_PARSE_ERROR), body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
result = await get_preview_filaments(
kind="library_file",
source_id=18000,
plate_id=1,
file_bytes=original,
file_name="x.3mf",
api_url="http://sidecar",
)
assert result is not None
# The support slot must survive — losing it is exactly the failure a
# profile-override fallback would have introduced.
assert [f["slot_id"] for f in result] == [1, 4]
assert len(stub.calls) == 2
# The retry sent a modified file, not the original.
assert stub.calls[1] != original
assert _settings_of(stub.calls[1])["time_lapse_gcode"] == ""
@pytest.mark.asyncio
async def test_result_is_cached_under_the_original_file_hash(self):
original = _make_project_3mf({"time_lapse_gcode": "{if timelapse_inline_photo}"})
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _FailThenSucceedService(SlicerApiServerError(_TIMELAPSE_PARSE_ERROR), body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
first = await get_preview_filaments(
kind="library_file",
source_id=1,
plate_id=1,
file_bytes=original,
file_name="x.3mf",
api_url="http://sidecar",
)
second = await get_preview_filaments(
kind="library_file",
source_id=1,
plate_id=1,
file_bytes=original,
file_name="x.3mf",
api_url="http://sidecar",
)
assert second == first
# Two slices for the first call, none for the second.
assert len(stub.calls) == 2
@pytest.mark.asyncio
async def test_no_retry_for_an_unrelated_failure(self):
original = _make_project_3mf({"time_lapse_gcode": "{if timelapse_inline_photo}"})
stub = _FailThenSucceedService(
SlicerApiUnavailableError("Slicer sidecar unreachable"),
_make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}]),
)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
result = await get_preview_filaments(
kind="library_file",
source_id=1,
plate_id=1,
file_bytes=original,
file_name="x.3mf",
api_url="http://sidecar",
)
assert result is None
assert len(stub.calls) == 1
@pytest.mark.asyncio
async def test_a_failing_retry_falls_through_rather_than_raising(self):
original = _make_project_3mf({"time_lapse_gcode": "{if timelapse_inline_photo}"})
class _AlwaysFails(_FailThenSucceedService):
async def slice_without_profiles(self, **kw):
self.calls.append(kw["model_bytes"])
raise self.first_error
stub = _AlwaysFails(SlicerApiServerError(_TIMELAPSE_PARSE_ERROR), b"")
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
result = await get_preview_filaments(
kind="library_file",
source_id=1,
plate_id=1,
file_bytes=original,
file_name="x.3mf",
api_url="http://sidecar",
)
assert result is None
assert len(stub.calls) == 2
# A failed retry must not be cached — the sidecar may be upgraded.
assert not slice_preview._preview_cache