| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- """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
|