Files
bambuddy/backend/tests/unit/test_scheduler_backup_gate.py
T
maziggy b1cb26f6ee feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
  status/control surface, and the #1766 fix that depends on it.

  Added -- AMS Filament Backup status + control
  - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
    on every push_status. Verified against OrcaSlicer source
    (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
    (None = A1 family / pre-cfg push) preserves today's behaviour.
  - Hold-timer guard (3 s) prevents stale frames from flickering the badge
    back to the printer's old cfg after a user-initiated toggle.
  - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
    method calling _set_print_option("auto_switch_filament", enabled).
  - GET /printers/{id}/inventory-remain endpoint exposes the same map the
    dispatcher uses (internal and Spoolman modes both work uniformly).
  - Small icon badge in the printer card's "Filaments" section header
    (placement reads as printer-wide because the cfg bit is printer-wide,
    not per-AMS). Click to toggle, success toast.
  - 5 i18n keys x 11 locales for the badge UI.

  Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
  - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
    to False when status.ams_filament_backup is False; log the skip.
  - New effectivePreferLowest(setting, backup) helper applied at every
    frontend sort entry point: single-printer PrintModal, multi-printer
    hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
    standalone editor (the last had NO preferLowest awareness at all
    before this change).
  - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
    key exactly, including the banding tie-break (regular AMS < AMS-HT <
    external) so the client-side pre-compute matches the dispatch-time pick.
    An earlier draft used a flat `amsId * 4 + trayId` priority which gave
    external slots (ams_id = -1) a NEGATIVE priority -- caught in code
    review before commit.
  - Settings -> Filament -> "Prefer lowest remaining filament" gets an
    explanatory note about the printer-side AMS Backup dependency, with
    i18n key in all 11 locales.
2026-06-20 12:07:20 +02:00

97 lines
3.9 KiB
Python

"""Tests for the AMS Filament Backup gate on prefer_lowest sort (#1766).
The reporter set ``prefer_lowest_filament=True`` but the printer kept picking
the first matching spool. Root cause: without the printer's AMS Filament
Backup enabled, switching to the second spool is impossible — so sorting
toward the lowest leaves the print at risk. The gate coerces prefer_lowest
to False whenever the printer reports backup OFF, with None (unknown / A1
family) preserving today's behaviour.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.print_scheduler import PrintScheduler
@pytest.fixture
def scheduler():
return PrintScheduler()
def _patch_status(backup):
"""Patch ``printer_manager.get_status`` to return a stub PrinterState whose
``ams_filament_backup`` is the requested tri-state value."""
return patch(
"backend.app.services.print_scheduler.printer_manager.get_status",
return_value=SimpleNamespace(ams_filament_backup=backup, raw_data={}),
)
async def _run_with_backup(scheduler, backup_state, prefer_lowest_setting):
"""Drive ``_compute_ams_mapping_for_printer`` past the gate point and
return whatever ``prefer_lowest`` value gets handed to the matcher."""
db = MagicMock()
item = SimpleNamespace(filament_overrides=None)
filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
loaded = [
{
"ams_id": 0,
"tray_id": 0,
"global_tray_id": 0,
"is_external": False,
"type": "PLA",
"color": "#000000",
"tray_info_idx": "",
},
]
captured: dict = {}
def _capture_match(reqs, loaded_, prefer, overrides):
captured["prefer_lowest"] = prefer
return [0]
with (
_patch_status(backup_state),
patch.object(scheduler, "_get_filament_requirements", new=AsyncMock(return_value=filament_reqs)),
patch.object(scheduler, "_build_loaded_filaments", return_value=loaded),
patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=prefer_lowest_setting)),
patch.object(scheduler, "_build_inventory_remain_overrides", new=AsyncMock(return_value={})),
patch.object(scheduler, "_match_filaments_to_slots", side_effect=_capture_match),
):
await scheduler._compute_ams_mapping_for_printer(db, printer_id=1, item=item)
return captured.get("prefer_lowest")
class TestPreferLowestBackupGate:
@pytest.mark.asyncio
async def test_backup_off_disables_prefer_lowest(self, scheduler):
# User setting ON but printer reports backup OFF — gate must coerce.
# This is the #1766 fix: previously the sort applied and picked the
# near-empty spool, leaving the print to fail mid-job.
out = await _run_with_backup(scheduler, backup_state=False, prefer_lowest_setting=True)
assert out is False
@pytest.mark.asyncio
async def test_backup_on_preserves_prefer_lowest(self, scheduler):
# Backup ON — sort applies as the user intended.
out = await _run_with_backup(scheduler, backup_state=True, prefer_lowest_setting=True)
assert out is True
@pytest.mark.asyncio
async def test_backup_unknown_preserves_prefer_lowest(self, scheduler):
# None = unknown / unsupported (A1 family). Must NOT be treated as OFF
# — that would regress A1 users who currently get the sort applied.
out = await _run_with_backup(scheduler, backup_state=None, prefer_lowest_setting=True)
assert out is True
@pytest.mark.asyncio
async def test_user_setting_off_short_circuits(self, scheduler):
# User setting OFF — backup state is irrelevant; sort never applies.
out = await _run_with_backup(scheduler, backup_state=True, prefer_lowest_setting=False)
assert out is False