Files
bambuddy/backend/tests/unit/test_slice_request_schema.py
T
maziggy 988c00554e feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
  silently truncated multi-color 3MFs to a single profile, producing wrong
  colours on every multi-filament print. End-to-end fix across sidecar,
  backend, and frontend.

  Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
    - /slice accepts up to 16 repeated filamentProfile parts; slicing
      service materializes each and joins paths with `;` for
      --load-filaments.
    - /profiles/bundled emits filament_type and filament_colour per leaf
      so the bundled tier carries metadata into the modal.

  Bambuddy backend:
    - SliceRequest gains filament_presets: list[PresetRef]. Validator
      accepts three shapes (multi-color array, source-aware singular,
      legacy bare-int id) and lands them all on a populated array before
      the route handler runs — fully backwards-compatible.
    - SlicerApiService.slice_with_profiles takes filament_profile_jsons:
      list[str] and sends one filamentProfile multipart part per profile
      (in submission order) so the sidecar receives N profiles cleanly.
    - New service slice_preview runs the sidecar's slice_without_profiles
      against an unsliced project file's embedded settings, parses the
      result's slice_info.config, and returns the canonical per-plate
      filament list. Cached by (kind, source_id, plate_id, content_hash)
      with LRU eviction at 256 entries, per-key asyncio.Lock prevents
      thundering-herd; transient sidecar failures are NOT cached so they
      retry naturally; parse failures ARE cached (deterministic property
      of the input, no point re-running).
    - /filament-requirements endpoint chain: slice_info.config (existing,
      sliced files) → preview-slice (new, unsliced project files) →
      project_settings.config + painted-face heuristic with 5% noise
      threshold (sidecar-down fallback).
    - threemf_tools gains extract_project_filaments_from_3mf and
      extract_plate_extruder_set_from_3mf — the latter unions object
      top-level extruder, per-part overrides, and painted-face quadtree
      leaves (1-E nibbles in paint_color attrs of <triangle> elements
      inside per-object .model files).
    - Cloud preset listing no longer fetches per-preset detail (Bambu's
      rate limit at ~10/sec returns 429 on every request for users with
      50+ presets). Unified-listing dedup pass instead backfills metadata
      cross-tier so a cloud entry that wins dedup over a same-named local
      entry inherits the local's filament_type / filament_colour.

  Frontend:
    - SliceModal multi-step: plate-picker first when the source is a
      multi-plate 3MF, then preset dropdowns. One filament dropdown per
      AMS slot the plate actually uses, each pre-picked by metadata
      match against user's local + standard presets via existing
      colorsAreSimilar / normalizeColorForCompare utils.
    - SliceModal-only tier priority is now local → cloud → standard
      (was cloud → local → standard). Other consumers of /slicer/presets
      keep the existing cloud-first order.
    - Submits filament_presets array; backfills the legacy singular
      filament_preset from the array's first entry for stale-tab
      compatibility.
    - i18n keys added across all 8 locales: slice.filamentSlot,
      slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
      expired,unreachable}, slice.noPresetsForSlot,
      slice.allPresetsRequired (en + de fully translated; six others
      seeded with English copies pending native translation, matching
      the project's existing flow).

  Permissions: no new endpoint paths added. Preview-slice runs inside
  /filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
  dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
  widened.

  Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
  precedence; 9 unit tests for slice_preview cache behaviour (LRU
  eviction with lock cleanup, content-hash invalidation, concurrent
  thundering-herd guard, no-cache-poison on transient sidecar failure);
  15 unit tests for the two new threemf_tools helpers (5 + 10 cases
  including the 60/40 painted-threshold regression pin); a multi-filament
  wire-format test pinning the multipart part count + order; 22 frontend
  SliceModal tests covering plate picker, multi-color render,
  metadata-aware pre-pick, manual override, and the new tier order.
2026-04-28 12:20:59 +02:00

145 lines
6.0 KiB
Python

"""Tests for `SliceRequest` validator — covers both the legacy bare-int
shape and the new source-aware shape, plus the backwards-compat
normalisation that lets the route handler ignore the difference.
"""
import pytest
from pydantic import ValidationError
from backend.app.schemas.slicer import PresetRef, SliceRequest
class TestLegacyBareIntegerShape:
"""Existing clients (and stale browser tabs after upgrade) keep
sending bare integer ids. They must continue working unchanged."""
def test_bare_int_ids_normalise_to_local_preset_ref(self):
req = SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3)
assert req.printer_preset == PresetRef(source="local", id="1")
assert req.process_preset == PresetRef(source="local", id="2")
assert req.filament_preset == PresetRef(source="local", id="3")
def test_legacy_ids_unchanged_in_payload(self):
"""The legacy fields stay populated — no behaviour change for
clients that read them back from the model."""
req = SliceRequest(printer_preset_id=10, process_preset_id=20, filament_preset_id=30)
assert req.printer_preset_id == 10
assert req.process_preset_id == 20
assert req.filament_preset_id == 30
class TestNewSourceAwareShape:
"""The new modal sends source-aware refs explicitly."""
def test_cloud_refs_pass_through(self):
req = SliceRequest(
printer_preset=PresetRef(source="cloud", id="PFUprinter"),
process_preset=PresetRef(source="cloud", id="PFUprocess"),
filament_preset=PresetRef(source="cloud", id="PFUfilament"),
)
assert req.printer_preset.source == "cloud"
assert req.printer_preset.id == "PFUprinter"
def test_mixed_sources_per_slot(self):
"""A user may pick cloud for printer, local for process, standard
for filament — the modal is per-slot."""
req = SliceRequest(
printer_preset=PresetRef(source="cloud", id="PFU123"),
process_preset=PresetRef(source="local", id="42"),
filament_preset=PresetRef(source="standard", id="Bambu PLA Basic"),
)
assert req.printer_preset.source == "cloud"
assert req.process_preset.source == "local"
assert req.filament_preset.source == "standard"
class TestValidationErrors:
def test_missing_printer_slot_raises(self):
with pytest.raises(ValidationError) as exc:
SliceRequest(process_preset_id=2, filament_preset_id=3)
assert "printer" in str(exc.value)
def test_invalid_source_rejected(self):
with pytest.raises(ValidationError):
SliceRequest(
printer_preset={"source": "made_up", "id": "x"},
process_preset_id=2,
filament_preset_id=3,
)
class TestPriorityWhenBothSet:
"""If a client sends BOTH the legacy id AND the new ref for the same
slot (unlikely in practice, but ambiguous), the new ref wins. Tests
pin the resolution order so a future schema change can't silently
flip it."""
def test_explicit_ref_wins_over_legacy_id(self):
req = SliceRequest(
printer_preset_id=999, # would resolve to local:999
printer_preset=PresetRef(source="cloud", id="PFU"),
process_preset_id=2,
filament_preset_id=3,
)
# Validator only fills the ref when it's None — the explicit cloud
# ref stays untouched.
assert req.printer_preset == PresetRef(source="cloud", id="PFU")
class TestFilamentPresetsList:
"""Multi-color: the new array shape carries one filament profile per
plate slot in plate order. Backwards-compat: legacy clients still
submit a singular `filament_preset` and the validator promotes it into
a one-element list so the route handler only deals with one shape."""
def test_explicit_list_passes_through(self):
refs = [
PresetRef(source="cloud", id="A"),
PresetRef(source="local", id="2"),
PresetRef(source="standard", id="Bambu PLA Basic"),
]
req = SliceRequest(
printer_preset_id=1,
process_preset_id=2,
filament_preset_id=99, # explicit legacy id — should be ignored
filament_presets=refs,
)
assert req.filament_presets == refs
# Precedence pin: when caller sends both shapes, the array wins and
# the singular gets backfilled from the array's first entry — NOT
# from the legacy id 99. Documents the migration ordering for a
# future change that might quietly mix them.
assert req.filament_preset == refs[0]
def test_empty_list_is_backfilled_from_singular(self):
req = SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3)
# Legacy single-color path: validator promotes the singular into a
# one-element list so route handlers can iterate uniformly.
assert req.filament_presets == [PresetRef(source="local", id="3")]
def test_explicit_empty_list_with_singular_set_uses_singular(self):
# User of the new schema can leave `filament_presets` as the empty
# default and rely on the legacy `filament_preset_id` — same path
# as `test_empty_list_is_backfilled_from_singular`.
req = SliceRequest(
printer_preset_id=1,
process_preset_id=2,
filament_preset=PresetRef(source="cloud", id="PFU"),
filament_presets=[],
)
assert req.filament_presets == [PresetRef(source="cloud", id="PFU")]
def test_list_preserves_order(self):
refs = [
PresetRef(source="cloud", id="slot1"),
PresetRef(source="cloud", id="slot2"),
PresetRef(source="cloud", id="slot3"),
]
req = SliceRequest(
printer_preset_id=1,
process_preset_id=2,
filament_preset_id=3,
filament_presets=refs,
)
assert [r.id for r in req.filament_presets] == ["slot1", "slot2", "slot3"]