"""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, fts_installed=False): 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