Files
bambuddy/backend/tests/unit/test_slice_request_schema.py
maziggy 7e1105dcb6 feat(slicer): bundle dispatch path for library slice route
When SliceRequest.bundle is set, the dispatch picks the per-category
  JSON triplet from a sidecar-stored .bbscfg by name instead of
  resolving cloud/local/standard PresetRefs. Mirrors the bundle-aware
  preview slice (committed earlier) so live slices match the same
  profile triplet the modal previewed against.

  Schema:
  - SliceBundleSpec: bundle_id + printer_name + process_name +
    filament_names (min-length-1 list, plate-slot order)
  - SliceRequest.bundle: optional, validator skips preset-required
    check when set so bundle-only requests validate

  Dispatch:
  - _run_slicer_with_fallback branches on request.bundle
  - Skips resolve_preset_ref, calls slice_with_bundle
  - 3MF + bundle CLI 5xx still falls back to embedded-settings slice
    (used_embedded_settings=True surfaces in the response)
  - Sidecar 404 (unknown bundle / preset name) maps to 400
2026-05-06 09:42:15 +02:00

223 lines
9.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, SliceBundleSpec, 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"]
class TestBundleDispatchShape:
"""When SliceRequest.bundle is set, the dispatcher picks the JSON
triplet from a sidecar-side bundle by name and PresetRef resolution
is skipped entirely. Validator must accept "bundle alone" without
flagging missing presets."""
def test_bundle_alone_validates(self):
req = SliceRequest(
bundle=SliceBundleSpec(
bundle_id="abc123def456abcd",
printer_name="# Bambu Lab H2D 0.4 nozzle",
process_name="# 0.20mm Standard @BBL H2D",
filament_names=["# Bambu PLA Basic @BBL H2D"],
),
)
# PresetRef fields are absent; that's fine in bundle mode.
assert req.bundle is not None
assert req.printer_preset is None
assert req.process_preset is None
assert req.filament_presets == []
def test_bundle_with_filament_list_preserves_order(self):
req = SliceRequest(
bundle=SliceBundleSpec(
bundle_id="abc",
printer_name="P",
process_name="Q",
filament_names=["red", "blue", "green"],
),
)
assert req.bundle.filament_names == ["red", "blue", "green"]
def test_bundle_rejects_empty_filament_list(self):
with pytest.raises(ValidationError):
SliceBundleSpec(
bundle_id="abc",
printer_name="P",
process_name="Q",
filament_names=[],
)
def test_bundle_rejects_empty_id(self):
with pytest.raises(ValidationError):
SliceBundleSpec(
bundle_id="",
printer_name="P",
process_name="Q",
filament_names=["F"],
)
def test_no_bundle_no_presets_still_rejected(self):
# Dropping the bundle escape-hatch must not bypass the existing
# presets-required check.
with pytest.raises(ValidationError):
SliceRequest()
def test_bundle_with_presets_keeps_both_fields(self):
# Sending both is allowed (validator accepts the bundle and skips
# preset normalisation) — the dispatch picks bundle on the route
# side. Confirms the validator doesn't reject overlapping intent
# so a future client that wants to record the legacy presets
# alongside doesn't fail validation.
req = SliceRequest(
printer_preset=PresetRef(source="standard", id="X1C"),
process_preset=PresetRef(source="standard", id="0.20"),
filament_presets=[PresetRef(source="standard", id="PLA")],
bundle=SliceBundleSpec(
bundle_id="abc",
printer_name="P",
process_name="Q",
filament_names=["F"],
),
)
assert req.bundle is not None
# Presets stay populated; dispatch ignores them when bundle is set.
assert req.printer_preset is not None