Files
bambuddy/backend/tests/unit/test_scheduler_filament_override.py
T
maziggy 38af5df050 Add filament override for model-based queue assignment (#486)
When scheduling a print to "any printer" (model-based assignment),
users can now override the 3MF's original filament choices per slot.
The override dropdown shows compatible filaments loaded across all
printers of the selected model, filtered by type and nozzle (H2D).
The scheduler matches against overridden type/color instead of the
original 3MF values, preferring printers with exact color matches.

Backend:
- New GET /printers/available-filaments endpoint (aggregates loaded
  filaments across printers of a model, includes extruder_id for
  dual-nozzle nozzle-aware filtering)
- New filament_overrides JSON column on print_queue table
- Scheduler applies overrides before AMS mapping, clears tray_info_idx
  on overridden slots so color matching takes priority
- Printer selection prefers printers with most override color matches

Frontend:
- New FilamentOverride component with per-slot override dropdowns
- Type-filtered options (PLA slots only show PLA)
- Nozzle-aware filtering (H2D: only shows filaments on correct extruder)
- Integrated into PrintModal for model-based queue mode

Tests:
- 11 backend unit tests (color match counting, override application)
- 12 frontend tests (rendering, type/nozzle filtering, interactions)

i18n: All 6 locales (en, de, fr, it, ja, pt-BR)
2026-02-22 16:43:16 +01:00

216 lines
9.9 KiB
Python

"""Tests for the filament override feature in the print scheduler."""
from unittest.mock import MagicMock, patch
import pytest
from backend.app.services.print_scheduler import PrintScheduler
class TestCountOverrideColorMatches:
"""Test the _count_override_color_matches method."""
@pytest.fixture
def scheduler(self):
return PrintScheduler()
@patch("backend.app.services.print_scheduler.printer_manager")
def test_no_status_returns_zero(self, mock_pm, scheduler):
"""When printer_manager.get_status() returns None, should return 0."""
mock_pm.get_status.return_value = None
result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
assert result == 0
@patch("backend.app.services.print_scheduler.printer_manager")
def test_exact_match(self, mock_pm, scheduler):
"""Override with matching type+color on printer returns 1."""
mock_pm.get_status.return_value = MagicMock(
raw_data={
"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
}
)
result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
assert result == 1
@patch("backend.app.services.print_scheduler.printer_manager")
def test_no_match(self, mock_pm, scheduler):
"""Override with type+color not on printer returns 0."""
mock_pm.get_status.return_value = MagicMock(
raw_data={
"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
}
)
result = scheduler._count_override_color_matches(1, [{"type": "PETG", "color": "#00FF00"}])
assert result == 0
@patch("backend.app.services.print_scheduler.printer_manager")
def test_multiple_overrides_partial_match(self, mock_pm, scheduler):
"""2 overrides, only 1 matching = returns 1."""
mock_pm.get_status.return_value = MagicMock(
raw_data={
"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
}
)
overrides = [
{"type": "PLA", "color": "#FF0000"}, # Matches
{"type": "PETG", "color": "#00FF00"}, # Does not match
]
result = scheduler._count_override_color_matches(1, overrides)
assert result == 1
@patch("backend.app.services.print_scheduler.printer_manager")
def test_color_normalization(self, mock_pm, scheduler):
"""Override color '#FF0000' matches printer tray_color 'FF0000FF' (with alpha)."""
mock_pm.get_status.return_value = MagicMock(
raw_data={
"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
}
)
# Override uses #-prefixed color; printer uses 8-char RGBA without hash
result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
assert result == 1
@patch("backend.app.services.print_scheduler.printer_manager")
def test_external_spool_match(self, mock_pm, scheduler):
"""Override matches filament in vt_tray."""
mock_pm.get_status.return_value = MagicMock(
raw_data={
"ams": [],
"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FFFF"}],
}
)
result = scheduler._count_override_color_matches(1, [{"type": "TPU", "color": "#0000FF"}])
assert result == 1
class TestFilamentOverrideInMatching:
"""Test that when overrides are applied to filament requirements, the matching uses overridden values."""
@pytest.fixture
def scheduler(self):
return PrintScheduler()
def _apply_overrides(self, filament_reqs, overrides):
"""Simulate override application as done in _compute_ams_mapping_for_printer."""
override_map = {o["slot_id"]: o for o in overrides}
for req in filament_reqs:
if req["slot_id"] in override_map:
override = override_map[req["slot_id"]]
req["type"] = override["type"]
req["color"] = override["color"]
req["tray_info_idx"] = "" # Clear for override
return filament_reqs
def test_override_changes_color_match(self, scheduler):
"""Original req has color A, loaded has color B. Override to color B gives exact match."""
filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
loaded = [
{"type": "PLA", "color": "#FF0000", "global_tray_id": 0},
]
# Without override: type-only match (colors differ)
result_without = scheduler._match_filaments_to_slots(filament_reqs, loaded)
assert result_without == [0] # Matches by type only
# Now apply override changing color to match loaded
overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
self._apply_overrides(filament_reqs_overridden, overrides)
result_with = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
assert result_with == [0] # Exact color match now
# Verify the override actually changed the color in the requirement
assert filament_reqs_overridden[0]["color"] == "#FF0000"
def test_override_clears_tray_info_idx(self, scheduler):
"""When tray_info_idx is cleared, matching falls to color-based instead of tray_info_idx-based."""
loaded = [
{"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "tray_info_idx": "GFA00"},
{"type": "PLA", "color": "#00FF00", "global_tray_id": 1, "tray_info_idx": "GFB00"},
]
# Without override: tray_info_idx "GFA00" matches tray 0 (red)
filament_reqs_original = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA00"}]
result_original = scheduler._match_filaments_to_slots(filament_reqs_original, loaded)
assert result_original == [0] # Matched by tray_info_idx
# With override: tray_info_idx is cleared, color changed to green -> matches tray 1
filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA00"}]
overrides = [{"slot_id": 1, "type": "PLA", "color": "#00FF00"}]
self._apply_overrides(filament_reqs_overridden, overrides)
assert filament_reqs_overridden[0]["tray_info_idx"] == "" # Cleared
result_overridden = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
assert result_overridden == [1] # Now matches tray 1 by color
def test_override_type_change(self, scheduler):
"""Override changes type from PLA to PETG, loaded has PETG -> matches."""
loaded = [
{"type": "PETG", "color": "#FF0000", "global_tray_id": 0},
]
# Without override: PLA requirement, PETG loaded -> no match
filament_reqs_original = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
result_original = scheduler._match_filaments_to_slots(filament_reqs_original, loaded)
assert result_original == [-1] # Type mismatch
# With override: type changed to PETG -> matches
filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
overrides = [{"slot_id": 1, "type": "PETG", "color": "#FF0000"}]
self._apply_overrides(filament_reqs_overridden, overrides)
result_overridden = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
assert result_overridden == [0] # Exact match now
def test_partial_override(self, scheduler):
"""2 slots, only slot 1 overridden. Slot 1 uses override, slot 2 uses original."""
loaded = [
{"type": "PLA", "color": "#FF0000", "global_tray_id": 0},
{"type": "PETG", "color": "#00FF00", "global_tray_id": 1},
]
filament_reqs = [
{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"},
{"slot_id": 2, "type": "PETG", "color": "#00FF00", "tray_info_idx": "GFG02"},
]
# Override only slot 1: change color to red
overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
self._apply_overrides(filament_reqs, overrides)
# Slot 1: overridden to PLA/#FF0000, tray_info_idx cleared -> matches tray 0 by exact color
assert filament_reqs[0]["color"] == "#FF0000"
assert filament_reqs[0]["tray_info_idx"] == ""
# Slot 2: NOT overridden, retains original tray_info_idx
assert filament_reqs[1]["color"] == "#00FF00"
assert filament_reqs[1]["tray_info_idx"] == "GFG02"
result = scheduler._match_filaments_to_slots(filament_reqs, loaded)
assert result == [0, 1] # Slot 1 -> tray 0 (red PLA), slot 2 -> tray 1 (green PETG)
def test_nozzle_filtering_with_override(self, scheduler):
"""Override to a type only available on the wrong nozzle returns -1."""
loaded = [
# PETG on RIGHT nozzle (extruder 0) only
{"type": "PETG", "color": "#FF0000", "global_tray_id": 0, "extruder_id": 0},
# PLA on LEFT nozzle (extruder 1) only
{"type": "PLA", "color": "#00FF00", "global_tray_id": 4, "extruder_id": 1},
]
# Override to PETG on LEFT nozzle — but PETG is only on RIGHT
filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00", "nozzle_id": 1}]
overrides = [{"slot_id": 1, "type": "PETG", "color": "#FF0000"}]
self._apply_overrides(filament_reqs, overrides)
result = scheduler._match_filaments_to_slots(filament_reqs, loaded)
# Nozzle filter limits to extruder 1 (LEFT) which only has PLA.
# Override changed type to PETG, so no type match on LEFT nozzle -> -1
assert result == [-1]