Files
bambuddy/backend/tests/unit/test_printer_models.py
T
maziggy 4925b4c830 fix(slice): re-slice correctness — model label, honest errors, filament usage, nozzle guard
Five follow-up fixes to cross-printer re-slicing, all surfaced while
  testing archive re-slices.

  1. Re-sliced archive now records the printer it was sliced FOR.
     slice_and_persist_as_archive copied sliced_for_model from the source
     archive, so re-slicing X1C->H2D still showed "X1C sliced". Read it
     from the freshly-sliced 3MF's parsed metadata instead, falling back
     to the source only when absent.

  2. Real slicer rejections are surfaced instead of silently masked.
     _run_slicer_with_fallback retried with the 3MF's embedded settings on
     any sidecar 5xx — including genuine content rejections (object off
     the bed, incompatible filament temps), which "succeeded" only by
     re-slicing for the source's original printer. A new
     _slicer_rejection_message detects the slicer's own error string and
     surfaces it as a 400; the embedded-settings fallback is kept only for
     true CLI crashes.

  3. A failed slice opens an error modal, not a 3s toast. The slicer's
     reason is actionable and a toast hides it before it can be read. New
     AlertModal (acknowledge-only); SliceJobTrackerContext shows it on a
     failed job. New slice.failedTitle key in all 9 locales.

  4. Sliced files no longer report "0 g" filament usage. The sidecar
     doesn't always populate the X-Filament-Used-* headers;
     ThreeMFParser._parse_gcode_header now also reads the slicer's own
     "total filament weight/length" from the G-code header, and both
     slice-persist paths fall back to it when the sidecar reports 0.

  5. Nozzle-class re-slice guard. Re-slicing across the single-nozzle <->
     dual-nozzle boundary (e.g. X1C -> H2D) fails BambuStudio's
     multi-extruder validation; both slice routes now reject it up front
     with a clear 400. The dual-nozzle model classification — previously
     an inline tuple duplicated across start_print and the K-profile
     routes — is centralized into DUAL_NOZZLE_MODELS / is_dual_nozzle_model
     in printer_models.py, consumed by all three sites and the guard.

  Full cross-nozzle-class re-slicing (dual-nozzle project_settings
  reconciliation) remains separately tracked.

  Tests: _slicer_rejection_message, _canonical_printer_model,
  guard_nozzle_class_reslice, is_dual_nozzle_model, the G-code-header
  filament parse, AlertModal, and end-to-end slice-API coverage including
  an X1C-archive-to-H2D 400. Backend ruff + i18n parity clean; frontend
  build clean.
2026-05-22 12:34:08 +02:00

133 lines
4.8 KiB
Python

"""Unit tests for printer model utilities."""
import pytest
from backend.app.services.camera import get_camera_port, supports_rtsp
from backend.app.utils.printer_models import (
CARBON_ROD_MODELS,
STEEL_ROD_MODELS,
get_rod_type,
has_ethernet,
is_dual_nozzle_model,
normalize_printer_model,
normalize_printer_model_id,
)
class TestGetRodType:
"""Tests for get_rod_type() rod/rail classification."""
@pytest.mark.parametrize("model", ["X1C", "X1", "X1E", "P1P", "P1S"])
def test_carbon_rod_models(self, model: str):
assert get_rod_type(model) == "carbon"
@pytest.mark.parametrize("model", ["C11", "C12", "C13"])
def test_carbon_rod_internal_codes(self, model: str):
assert get_rod_type(model) == "carbon"
def test_p2s_is_steel_rod(self):
"""P2S uses hardened steel rods, not carbon rods (#640)."""
assert get_rod_type("P2S") == "steel_rod"
def test_p2s_internal_code_is_steel_rod(self):
"""N7 (P2S internal code) uses steel rods."""
assert get_rod_type("N7") == "steel_rod"
@pytest.mark.parametrize("model", ["A1", "A1 Mini", "H2D", "H2D Pro", "H2C", "H2S"])
def test_linear_rail_models(self, model: str):
assert get_rod_type(model) == "linear_rail"
@pytest.mark.parametrize("model", ["N1", "N2S", "A11", "A12", "O1D", "O1E", "O2D", "O1C", "O1C2", "O1S"])
def test_linear_rail_internal_codes(self, model: str):
assert get_rod_type(model) == "linear_rail"
def test_unknown_model_returns_none(self):
assert get_rod_type("UNKNOWN") is None
def test_none_returns_none(self):
assert get_rod_type(None) is None
def test_case_insensitive(self):
assert get_rod_type("p2s") == "steel_rod"
assert get_rod_type("x1c") == "carbon"
assert get_rod_type("a1") == "linear_rail"
def test_strips_whitespace_and_dashes(self):
assert get_rod_type(" P2S ") == "steel_rod"
assert get_rod_type("A1-Mini") == "linear_rail"
class TestX2DModel:
"""X2D printer support (issue #988).
The X2D is a dual-nozzle enclosed printer launched April 2026. It shares
the hardened steel rod hardware with P2S (NOT carbon rods) and uses
RTSP on port 322 like other X/H series printers. Internal SSDP/MQTT
model code is "N6"; serial numbers begin with "20P9".
"""
def test_x2d_is_steel_rod_display_name(self):
assert get_rod_type("X2D") == "steel_rod"
def test_x2d_is_steel_rod_internal_code(self):
assert get_rod_type("N6") == "steel_rod"
def test_x2d_model_id_map(self):
assert normalize_printer_model_id("N6") == "X2D"
def test_x2d_model_map(self):
assert normalize_printer_model("Bambu Lab X2D") == "X2D"
def test_x2d_has_ethernet_display_name(self):
assert has_ethernet("X2D") is True
def test_x2d_has_ethernet_internal_code(self):
assert has_ethernet("N6") is True
def test_x2d_supports_rtsp_display_name(self):
assert supports_rtsp("X2D") is True
def test_x2d_supports_rtsp_internal_code(self):
assert supports_rtsp("N6") is True
def test_x2d_camera_port_is_rtsp(self):
assert get_camera_port("N6") == 322
assert get_camera_port("X2D") == 322
def test_x2d_not_in_carbon_rod_set(self):
"""Regression guard: X2D has hardened steel rods, not carbon (#988).
A prior PR classified X2D as carbon; the reporter confirmed it uses
the same stainless steel rod gantry as P2S. This assertion pins the
classification so a future change that reverts it will fail loudly.
"""
assert "X2D" not in CARBON_ROD_MODELS
assert "N6" not in CARBON_ROD_MODELS
assert "X2D" in STEEL_ROD_MODELS
assert "N6" in STEEL_ROD_MODELS
class TestDualNozzleModel:
"""is_dual_nozzle_model — the single source of truth for nozzle class,
consumed by start_print, the K-profile routes, and the re-slice guard."""
def test_h2d_and_pro_are_dual(self):
# Takes a normalized model code (like has_ethernet) — "H2D Pro" with a
# space is accepted; full "Bambu Lab …" names are normalized by callers.
assert is_dual_nozzle_model("H2D") is True
assert is_dual_nozzle_model("H2D Pro") is True
assert is_dual_nozzle_model("H2DPRO") is True
def test_internal_codes_are_dual(self):
assert is_dual_nozzle_model("O1D") is True # H2D
assert is_dual_nozzle_model("O1E") is True # H2D Pro
def test_single_nozzle_models_are_not_dual(self):
# H2S is in the H2 family but single-nozzle (#1386) — must be False.
for model in ("X1C", "X1E", "P1S", "P1P", "A1", "A1 Mini", "P2S", "H2S"):
assert is_dual_nozzle_model(model) is False, model
def test_none_and_empty_are_not_dual(self):
assert is_dual_nozzle_model(None) is False
assert is_dual_nozzle_model("") is False