|
|
@@ -0,0 +1,287 @@
|
|
|
+"""An "any model" job is only given a printer the slot matcher can map (#3280).
|
|
|
+
|
|
|
+Choosing the printer asked cheaper questions than dispatch does: is each
|
|
|
+filament type loaded somewhere, is each forced colour loaded somewhere. On a
|
|
|
+dual-nozzle printer the matcher only uses a spool on the slot's own nozzle, so
|
|
|
+a printer with the filament on the other side passed the choice, the matcher
|
|
|
+left the slot unmatched, and the unmatched-filament hold (#2799) staged the job
|
|
|
+and gave the printer back. Start then found no printer to check, the next pass
|
|
|
+chose the same printer, and the job was held again, with no Print Anyway
|
|
|
+offered in between.
|
|
|
+
|
|
|
+Now the choice asks the matcher itself and passes over a printer it cannot
|
|
|
+map. When no idle printer can, the job stays pending with the filament it
|
|
|
+needs in its reason and is picked up once one is loaded.
|
|
|
+"""
|
|
|
+
|
|
|
+from types import SimpleNamespace
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.services.print_scheduler import PrintScheduler
|
|
|
+from backend.tests.unit.test_scheduler_model_filament_short_3137 import (
|
|
|
+ _add_item,
|
|
|
+ _get_item,
|
|
|
+ _run,
|
|
|
+ queue_db, # noqa: F401 - fixture
|
|
|
+)
|
|
|
+
|
|
|
+NEEDS = ["PLA #FFFFFF (left nozzle)"]
|
|
|
+
|
|
|
+
|
|
|
+def _unmatched_on(unmatched: set[int]) -> AsyncMock:
|
|
|
+ """``_unmatched_on`` stand-in: the printers in ``unmatched`` cannot map the job."""
|
|
|
+
|
|
|
+ async def check(db, item, printer_id):
|
|
|
+ return list(NEEDS) if printer_id in unmatched else []
|
|
|
+
|
|
|
+ return AsyncMock(side_effect=check)
|
|
|
+
|
|
|
+
|
|
|
+class TestChoosingAPrinter:
|
|
|
+ async def _find(self, ctx, unmatched: set[int], **kwargs):
|
|
|
+ scheduler = PrintScheduler()
|
|
|
+
|
|
|
+ async def unmatched_on(pid):
|
|
|
+ return list(NEEDS) if pid in unmatched else []
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
|
|
|
+ patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
|
|
|
+ ):
|
|
|
+ async with ctx.session_maker() as db:
|
|
|
+ return await scheduler._find_idle_printer_for_model(
|
|
|
+ db, "X1C", set(), unmatched_on=unmatched_on, **kwargs
|
|
|
+ )
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_passes_over_a_printer_the_matcher_cannot_map(self, queue_db):
|
|
|
+ assert await self._find(queue_db, {1}) == (2, None)
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_no_printer_when_none_can_map_it(self, queue_db):
|
|
|
+ printer_id, reason = await self._find(queue_db, {1, 2, 3})
|
|
|
+ assert printer_id is None
|
|
|
+ assert "PLA #FFFFFF (left nozzle)" in reason
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_colour_ranking_skips_a_printer_the_matcher_cannot_map(self, queue_db):
|
|
|
+ """Preference overrides rank printers by colour; the best one still has
|
|
|
+ to be mappable."""
|
|
|
+ overrides = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF"}]
|
|
|
+ scheduler = PrintScheduler()
|
|
|
+
|
|
|
+ async def unmatched_on(pid):
|
|
|
+ return list(NEEDS) if pid == 2 else []
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
|
|
|
+ patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
|
|
|
+ patch.object(
|
|
|
+ scheduler, "_count_override_color_matches", MagicMock(side_effect=lambda pid, o: 2 if pid == 2 else 1)
|
|
|
+ ),
|
|
|
+ ):
|
|
|
+ async with queue_db.session_maker() as db:
|
|
|
+ found = await scheduler._find_idle_printer_for_model(
|
|
|
+ db, "X1C", set(), filament_overrides=overrides, unmatched_on=unmatched_on
|
|
|
+ )
|
|
|
+
|
|
|
+ assert found == (1, None)
|
|
|
+
|
|
|
+
|
|
|
+class TestQueuePass:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_goes_to_a_printer_that_can_map_it(self, queue_db):
|
|
|
+ item_id = await _add_item(queue_db)
|
|
|
+ launched = MagicMock()
|
|
|
+ scheduler = PrintScheduler()
|
|
|
+
|
|
|
+ with patch.object(scheduler, "_unmatched_on", _unmatched_on({1})):
|
|
|
+ await _run(queue_db, scheduler, short=set(), launched=launched)
|
|
|
+
|
|
|
+ launched.assert_called_once()
|
|
|
+ item = await _get_item(queue_db, item_id)
|
|
|
+ assert item.printer_id == 2
|
|
|
+ assert item.manual_start is False
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_waits_unassigned_while_no_printer_can_map_it(self, queue_db):
|
|
|
+ item_id = await _add_item(queue_db)
|
|
|
+ launched = MagicMock()
|
|
|
+ scheduler = PrintScheduler()
|
|
|
+
|
|
|
+ with patch.object(scheduler, "_unmatched_on", _unmatched_on({1, 2, 3})):
|
|
|
+ await _run(queue_db, scheduler, short=set(), launched=launched)
|
|
|
+
|
|
|
+ launched.assert_not_called()
|
|
|
+ item = await _get_item(queue_db, item_id)
|
|
|
+ assert item.printer_id is None
|
|
|
+ assert item.manual_start is False
|
|
|
+ assert "PLA #FFFFFF (left nozzle)" in item.waiting_reason
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_job_held_for_low_filament_is_not_moved_to_a_printer_that_cannot_map_it(self, queue_db):
|
|
|
+ item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
|
|
|
+ launched = MagicMock()
|
|
|
+ scheduler = PrintScheduler()
|
|
|
+
|
|
|
+ with patch.object(scheduler, "_unmatched_on", _unmatched_on({2})):
|
|
|
+ await _run(queue_db, scheduler, short={1}, launched=launched)
|
|
|
+
|
|
|
+ assert (await _get_item(queue_db, item_id)).printer_id == 3
|
|
|
+
|
|
|
+
|
|
|
+class TestUnmatchedOn:
|
|
|
+ """The check itself answers what the unmatched-filament hold would."""
|
|
|
+
|
|
|
+ REQ = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 1}]
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _item(**fields):
|
|
|
+ values = {
|
|
|
+ "id": 7,
|
|
|
+ "skip_filament_check": False,
|
|
|
+ "filament_overrides": None,
|
|
|
+ "archive_id": None,
|
|
|
+ "library_file_id": 3,
|
|
|
+ "plate_id": 1,
|
|
|
+ }
|
|
|
+ values.update(fields)
|
|
|
+ return SimpleNamespace(**values)
|
|
|
+
|
|
|
+ def _scheduler(self, mapping, requirements=REQ, loaded=True):
|
|
|
+ scheduler = PrintScheduler()
|
|
|
+ if isinstance(mapping, Exception):
|
|
|
+ scheduler._compute_ams_mapping_for_printer = AsyncMock(side_effect=mapping)
|
|
|
+ else:
|
|
|
+ scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=mapping)
|
|
|
+ scheduler._get_filament_requirements = AsyncMock(
|
|
|
+ return_value=[dict(r) for r in requirements] if requirements else None
|
|
|
+ )
|
|
|
+ scheduler._build_loaded_filaments = MagicMock(
|
|
|
+ return_value=[{"global_tray_id": 0, "type": "PLA", "color": "#FFFFFF"}] if loaded else []
|
|
|
+ )
|
|
|
+ return scheduler
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_an_unmatched_slot_is_named(self):
|
|
|
+ scheduler = self._scheduler([-1])
|
|
|
+ with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
|
|
|
+ assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == NEEDS
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_mapped_slot_is_fine(self):
|
|
|
+ scheduler = self._scheduler([0])
|
|
|
+ with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
|
|
|
+ assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == []
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_print_anyway_is_never_unmatched(self):
|
|
|
+ scheduler = self._scheduler([-1])
|
|
|
+ assert await scheduler._unmatched_on(AsyncMock(), self._item(skip_filament_check=True), 4) == []
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_no_mapping_counts_only_when_the_printer_reported_trays(self):
|
|
|
+ """The hold's own rule (#2589): an empty tray list is not knowing yet."""
|
|
|
+ with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
|
|
|
+ assert await self._scheduler(None)._unmatched_on(AsyncMock(), self._item(), 4) == NEEDS
|
|
|
+ assert await self._scheduler(None, loaded=False)._unmatched_on(AsyncMock(), self._item(), 4) == []
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_failing_match_does_not_pass_the_printer_over(self):
|
|
|
+ scheduler = self._scheduler(RuntimeError("boom"))
|
|
|
+ with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
|
|
|
+ assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == []
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_an_unreadable_file_is_judged_on_its_forced_colours(self):
|
|
|
+ forced = '[{"slot_id": 1, "type": "PLA", "color": "#A4AAAC", "force_color_match": true}]'
|
|
|
+ scheduler = self._scheduler([-1], requirements=None)
|
|
|
+ with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
|
|
|
+ assert await scheduler._unmatched_on(AsyncMock(), self._item(filament_overrides=forced), 4) == [
|
|
|
+ "PLA #A4AAAC"
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+class TestDualNozzleEndToEnd:
|
|
|
+ """The reported shape with the real matcher: white PLA on the right nozzle,
|
|
|
+ a slot that prints on the left. Type checks pass; the matcher refuses."""
|
|
|
+
|
|
|
+ REQ = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 1, "tray_info_idx": ""}]
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _trays(extruder_id):
|
|
|
+ return [
|
|
|
+ {"global_tray_id": 0, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "extruder_id": extruder_id},
|
|
|
+ ]
|
|
|
+
|
|
|
+ def _scheduler(self, extruder_id):
|
|
|
+ scheduler = PrintScheduler()
|
|
|
+ scheduler._build_loaded_filaments = MagicMock(return_value=self._trays(extruder_id))
|
|
|
+ scheduler._get_filament_requirements = AsyncMock(side_effect=lambda db, item: [dict(r) for r in self.REQ])
|
|
|
+ scheduler._get_bool_setting = AsyncMock(return_value=False)
|
|
|
+ scheduler._get_job_name = AsyncMock(return_value="bracket")
|
|
|
+ scheduler._get_printer = AsyncMock(return_value=MagicMock(model="H2D"))
|
|
|
+ return scheduler
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _item():
|
|
|
+ item = MagicMock()
|
|
|
+ item.id = 9
|
|
|
+ item.printer_id = 4
|
|
|
+ item.archive_id = None
|
|
|
+ item.library_file_id = 3
|
|
|
+ item.plate_id = 1
|
|
|
+ item.ams_mapping = None
|
|
|
+ item.skip_filament_check = False
|
|
|
+ item.filament_overrides = None
|
|
|
+ item.manual_start = False
|
|
|
+ item.waiting_reason = None
|
|
|
+ return item
|
|
|
+
|
|
|
+ @pytest.mark.parametrize(("extruder_id", "held"), [(0, True), (1, False)])
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_the_choice_and_the_hold_agree(self, extruder_id, held):
|
|
|
+ scheduler = self._scheduler(extruder_id)
|
|
|
+ item = self._item()
|
|
|
+ db = AsyncMock()
|
|
|
+
|
|
|
+ with patch("backend.app.services.print_scheduler.printer_manager") as pm:
|
|
|
+ pm.get_status.return_value = MagicMock(fila_switch=None, ams_filament_backup=None)
|
|
|
+ unmatched = await scheduler._unmatched_on(db, item, 4)
|
|
|
+ await scheduler._ensure_ams_mapping(db, 4, item)
|
|
|
+ blocked = await scheduler._block_on_unmatched_filament(db, item)
|
|
|
+
|
|
|
+ assert bool(unmatched) is held
|
|
|
+ assert blocked is held
|
|
|
+ if held:
|
|
|
+ assert unmatched == ["PLA #FFFFFF (left nozzle)"]
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_asked_once_per_pass_for_the_same_job_and_printer(self):
|
|
|
+ scheduler = self._scheduler(0)
|
|
|
+ item = self._item()
|
|
|
+ scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[-1])
|
|
|
+
|
|
|
+ with patch("backend.app.services.print_scheduler.printer_manager") as pm:
|
|
|
+ pm.get_status.return_value = MagicMock(fila_switch=None, ams_filament_backup=None)
|
|
|
+ first = await scheduler._unmatched_on(AsyncMock(), item, 4)
|
|
|
+ second = await scheduler._unmatched_on(AsyncMock(), item, 4)
|
|
|
+ scheduler._unmatched_memo.clear()
|
|
|
+ await scheduler._unmatched_on(AsyncMock(), item, 4)
|
|
|
+
|
|
|
+ assert first == second == ["PLA #FFFFFF (left nozzle)"]
|
|
|
+ assert scheduler._compute_ams_mapping_for_printer.await_count == 2
|
|
|
+
|
|
|
+
|
|
|
+class TestTheMemoLastsOnePass:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_new_pass_asks_again(self, queue_db):
|
|
|
+ """Spools change between passes; an answer from the last one is stale."""
|
|
|
+ scheduler = PrintScheduler()
|
|
|
+ scheduler._unmatched_memo[("stale",)] = list(NEEDS)
|
|
|
+
|
|
|
+ await _run(queue_db, scheduler, short=set(), launched=MagicMock())
|
|
|
+
|
|
|
+ assert ("stale",) not in scheduler._unmatched_memo
|