| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- """An "any model" job goes to a printer that has the filament for it (#3137).
- The reporter's farm queues jobs for "any H2D Pro". The matcher took the first
- idle H2D Pro with the right filament *type*, and only then did the deficit gate
- look at the amount. It held the job on that printer, pinned there, while
- another idle H2D Pro had plenty. The job only ran once the user moved it there
- by hand.
- Now the matcher passes over a printer that would be short while another
- eligible one is not. When every idle printer is short the job is held on the
- first, as before, so "Print Anyway" still has a printer to print on; and once
- another printer of its model can run it, the hold is released and the job
- goes there.
- """
- from contextlib import ExitStack
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
- import backend.app.models # noqa: F401 - populate Base.metadata
- from backend.app.core.database import Base
- from backend.app.models.library import LibraryFile
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.printer import Printer
- from backend.app.services.filament_deficit import FilamentDeficit
- from backend.app.services.print_scheduler import PrintScheduler
- _DEFICIT = [
- FilamentDeficit(
- slot_id=1,
- ams_id=0,
- tray_id=0,
- filament_type="PLA",
- required_grams=120.0,
- remaining_grams=40.0,
- )
- ]
- @pytest.fixture
- async def queue_db():
- """Three X1Cs, in id order, so "the first idle printer" is well defined."""
- engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- session_maker = async_sessionmaker(engine, expire_on_commit=False)
- async with session_maker() as db:
- db.add_all(
- [
- Printer(
- id=pid,
- name=f"X1C-{pid}",
- serial_number=f"X1C000{pid}",
- ip_address=f"10.0.0.{pid}",
- access_code="x",
- model="X1C",
- is_active=True,
- )
- for pid in (1, 2, 3)
- ]
- )
- await db.commit()
- try:
- yield SimpleNamespace(session_maker=session_maker)
- finally:
- await engine.dispose()
- async def _add_item(ctx, **fields):
- async with ctx.session_maker() as db:
- lib = LibraryFile(
- filename="job.gcode.3mf",
- file_path="/library/job.gcode.3mf",
- file_size=10,
- file_type="gcode.3mf",
- file_metadata={"sliced_for_model": "X1C"},
- )
- db.add(lib)
- await db.flush()
- values = {"status": "pending", "position": 1, "target_model": "X1C", "library_file_id": lib.id}
- values.update(fields)
- item = PrintQueueItem(**values)
- db.add(item)
- await db.commit()
- return item.id
- async def _get_item(ctx, item_id):
- async with ctx.session_maker() as db:
- return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
- def _short_on(short: set[int], unknown: frozenset[int] = frozenset()) -> AsyncMock:
- """``_filament_short_on`` stand-in: the printers in ``short`` are too light,
- and those in ``unknown`` have spools whose amount is not on record."""
- async def check(db, item, printer_id, *, require_known=False):
- return printer_id in short or (require_known and printer_id in unknown)
- return AsyncMock(side_effect=check)
- async def _run(
- ctx, scheduler, *, short: set[int], launched, idle=lambda pid: True, short_check=None, unknown=frozenset()
- ):
- """One queue pass. The deficit gate is real; only the deficit it reads is faked."""
- async def deficit(db, item, *, printer_id=None, ams_mapping=None):
- return _DEFICIT if (printer_id or item.printer_id) in short else []
- patches = [
- patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
- patch("backend.app.core.database.async_session", ctx.session_maker),
- patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
- patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
- patch(
- "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
- AsyncMock(return_value={}),
- ),
- patch(
- "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
- AsyncMock(),
- ),
- patch(
- "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
- AsyncMock(),
- ),
- patch("backend.app.services.print_scheduler.compute_deficit_for_queue_item", AsyncMock(side_effect=deficit)),
- patch.object(scheduler, "_filament_short_on", short_check or _short_on(short, unknown)),
- patch.object(scheduler, "_is_printer_idle", MagicMock(side_effect=lambda pid, *a, **k: idle(pid))),
- patch.object(scheduler, "_check_auto_drying", AsyncMock()),
- patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
- patch.object(scheduler, "_block_on_unmatched_filament", AsyncMock(return_value=False)),
- patch.object(scheduler, "_launch_uploads", launched),
- ]
- with ExitStack() as stack:
- for p in patches:
- stack.enter_context(p)
- return await scheduler.check_queue()
- class TestMatcher:
- """``_find_idle_printer_for_model`` with and without the shortfall check."""
- async def _find(self, ctx, scheduler, *, short=None, **kwargs):
- async def is_short(pid):
- return pid in short
- 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(), is_short=is_short if short is not None else None, **kwargs
- )
- @pytest.mark.asyncio
- async def test_passes_over_a_short_printer(self, queue_db):
- assert await self._find(queue_db, PrintScheduler(), short={1}) == (2, None)
- @pytest.mark.asyncio
- async def test_every_printer_short_returns_the_first(self, queue_db):
- """The deficit gate then holds the job there, where Print Anyway can start it."""
- assert await self._find(queue_db, PrintScheduler(), short={1, 2, 3}) == (1, None)
- @pytest.mark.asyncio
- async def test_without_the_check_the_first_idle_printer_wins(self, queue_db):
- assert await self._find(queue_db, PrintScheduler()) == (1, None)
- @pytest.mark.asyncio
- async def test_force_colour_jobs_pass_over_a_short_printer(self, queue_db):
- scheduler = PrintScheduler()
- overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True}]
- with patch.object(scheduler, "_get_missing_force_color_slots", MagicMock(return_value=[])):
- result = await self._find(queue_db, scheduler, short={1}, filament_overrides=overrides)
- assert result == (2, None)
- @pytest.mark.asyncio
- async def test_colour_ranking_takes_the_best_printer_that_is_not_short(self, queue_db):
- scheduler = PrintScheduler()
- overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
- matches = {1: 1, 2: 2, 3: 1}
- with patch.object(
- scheduler, "_count_override_color_matches", MagicMock(side_effect=lambda pid, _o: matches[pid])
- ):
- ranked_first = await self._find(queue_db, scheduler, short=set(), filament_overrides=overrides)
- best_is_short = await self._find(queue_db, scheduler, short={2}, filament_overrides=overrides)
- all_short = await self._find(queue_db, scheduler, short={1, 2, 3}, filament_overrides=overrides)
- assert ranked_first == (2, None)
- assert best_is_short == (1, None)
- assert all_short == (2, None)
- class TestAssignment:
- @pytest.mark.asyncio
- async def test_goes_to_the_printer_with_enough_filament(self, queue_db):
- """The reporter's case: printer 1 is idle but light, printer 2 idle and full."""
- item_id = await _add_item(queue_db)
- launched = MagicMock()
- await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
- launched.assert_called_once()
- assert launched.call_args[0][0] == [item_id]
- item = await _get_item(queue_db, item_id)
- assert item.printer_id == 2
- assert item.manual_start is False
- assert item.filament_short is False
- @pytest.mark.asyncio
- async def test_held_on_the_first_printer_when_every_idle_printer_is_short(self, queue_db):
- item_id = await _add_item(queue_db)
- launched = MagicMock()
- await _run(queue_db, PrintScheduler(), short={1, 2, 3}, launched=launched)
- launched.assert_not_called()
- item = await _get_item(queue_db, item_id)
- assert item.printer_id == 1
- assert item.manual_start is True
- assert item.filament_short is True
- @pytest.mark.asyncio
- async def test_print_anyway_is_not_second_guessed(self, queue_db):
- """With Print Anyway set, no printer is passed over for being short."""
- item_id = await _add_item(queue_db, skip_filament_check=True)
- launched = MagicMock()
- scheduler = PrintScheduler()
- with patch(
- "backend.app.services.print_scheduler.PrintScheduler._compute_ams_mapping_for_printer",
- AsyncMock(return_value=[0]),
- ):
- await _run(
- queue_db,
- scheduler,
- short={1},
- launched=launched,
- short_check=AsyncMock(side_effect=scheduler._filament_short_on),
- )
- launched.assert_called_once()
- assert (await _get_item(queue_db, item_id)).printer_id == 1
- class TestHeldJob:
- @pytest.mark.asyncio
- async def test_moves_to_another_printer_that_has_the_filament(self, queue_db):
- item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
- launched = MagicMock()
- await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
- launched.assert_called_once()
- assert launched.call_args[0][0] == [item_id]
- item = await _get_item(queue_db, item_id)
- assert item.printer_id == 2
- assert item.manual_start is False
- assert item.filament_short is False
- @pytest.mark.asyncio
- async def test_stays_held_while_every_other_printer_is_short(self, queue_db):
- item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
- launched = MagicMock()
- await _run(queue_db, PrintScheduler(), short={1, 2, 3}, launched=launched)
- launched.assert_not_called()
- item = await _get_item(queue_db, item_id)
- assert item.printer_id == 1
- assert item.manual_start is True
- assert item.filament_short is True
- @pytest.mark.asyncio
- async def test_stays_held_while_the_other_printers_are_busy(self, queue_db):
- """Item 2080 in the reporter's log: the printer it waits on was the only idle one."""
- item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
- launched = MagicMock()
- short_check = _short_on({1})
- await _run(
- queue_db, PrintScheduler(), short={1}, launched=launched, idle=lambda pid: pid == 1, short_check=short_check
- )
- launched.assert_not_called()
- assert (await _get_item(queue_db, item_id)).printer_id == 1
- # The printer it is held on is not checked again: a spool loaded there
- # is started with Start, as before.
- short_check.assert_not_called()
- @pytest.mark.asyncio
- async def test_a_job_the_user_put_on_a_printer_is_not_moved(self, queue_db):
- item_id = await _add_item(queue_db, target_model=None, printer_id=1, manual_start=True, filament_short=True)
- launched = MagicMock()
- await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
- launched.assert_not_called()
- item = await _get_item(queue_db, item_id)
- assert item.printer_id == 1
- assert item.manual_start is True
- @pytest.mark.asyncio
- async def test_a_job_held_for_another_reason_is_not_moved(self, queue_db):
- """Only the deficit gate sets filament_short; a plain manual start waits for the user."""
- item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=False)
- launched = MagicMock()
- short_check = _short_on(set())
- await _run(queue_db, PrintScheduler(), short=set(), launched=launched, short_check=short_check)
- launched.assert_not_called()
- assert (await _get_item(queue_db, item_id)).manual_start is True
- short_check.assert_not_called()
- @pytest.mark.asyncio
- async def test_not_moved_to_a_printer_whose_spools_are_not_tracked(self, queue_db):
- """Moving starts the job with nobody asked, so "nothing against it" is not enough."""
- item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
- launched = MagicMock()
- await _run(queue_db, PrintScheduler(), short={1}, unknown={2, 3}, launched=launched)
- launched.assert_not_called()
- item = await _get_item(queue_db, item_id)
- assert item.printer_id == 1
- assert item.manual_start is True
- @pytest.mark.asyncio
- async def test_goes_to_the_printer_that_was_checked(self, queue_db):
- """Printer 2 comes first and has nothing against it, but only printer 3
- has the amount on record: the job goes to 3, not to whichever printer a
- fresh pick would find first."""
- item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
- launched = MagicMock()
- await _run(queue_db, PrintScheduler(), short={1}, unknown={2}, launched=launched)
- launched.assert_called_once()
- item = await _get_item(queue_db, item_id)
- assert item.printer_id == 3
- assert item.target_model == "X1C"
- assert item.manual_start is False
- assert item.filament_short is False
- assert item.waiting_reason is None
- @pytest.mark.asyncio
- async def test_a_failing_check_keeps_the_hold_and_the_pass_going(self, queue_db):
- held_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
- other_id = await _add_item(queue_db, position=2, target_model=None, printer_id=3)
- launched = MagicMock()
- scheduler = PrintScheduler()
- with patch.object(scheduler, "_find_idle_printer_for_model", AsyncMock(side_effect=RuntimeError("boom"))):
- await _run(queue_db, scheduler, short={1}, launched=launched)
- held = await _get_item(queue_db, held_id)
- assert held.printer_id == 1
- assert held.manual_start is True
- launched.assert_called_once()
- assert launched.call_args[0][0] == [other_id]
- @pytest.mark.asyncio
- async def test_looks_again_only_after_the_recheck_interval(self, queue_db):
- item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
- scheduler = PrintScheduler()
- short_check = _short_on({1, 2, 3})
- await _run(queue_db, scheduler, short={1, 2, 3}, launched=MagicMock(), short_check=short_check)
- first_pass_calls = short_check.await_count
- await _run(queue_db, scheduler, short={1, 2, 3}, launched=MagicMock(), short_check=short_check)
- assert short_check.await_count == first_pass_calls
- scheduler._short_hold_checked_at[item_id] -= 121
- launched = MagicMock()
- await _run(queue_db, scheduler, short={1}, launched=launched)
- launched.assert_called_once()
- assert (await _get_item(queue_db, item_id)).printer_id == 2
- class TestFilamentShortOn:
- """``_filament_short_on``'s answer where it cannot see the amounts."""
- @staticmethod
- async def _ask(*, mapping=None, deficit=None, raises=None, skip=False, require_known=False):
- scheduler = PrintScheduler()
- item = PrintQueueItem(id=7, skip_filament_check=skip)
- compute = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=mapping)
- with (
- patch.object(scheduler, "_compute_ams_mapping_for_printer", compute),
- patch(
- "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
- AsyncMock(return_value=deficit or []),
- ),
- ):
- return await scheduler._filament_short_on(MagicMock(), item, 1, require_known=require_known)
- @pytest.mark.asyncio
- async def test_a_deficit_is_short(self):
- assert await self._ask(mapping=[0], deficit=_DEFICIT) is True
- assert await self._ask(mapping=[0], deficit=_DEFICIT, require_known=True) is True
- @pytest.mark.asyncio
- async def test_enough_is_not_short(self):
- assert await self._ask(mapping=[0]) is False
- assert await self._ask(mapping=[0], require_known=True) is False
- @pytest.mark.asyncio
- async def test_no_mapping_counts_against_the_printer_only_when_required_known(self):
- assert await self._ask(mapping=None) is False
- assert await self._ask(mapping=None, require_known=True) is True
- @pytest.mark.asyncio
- async def test_a_failing_check_counts_against_the_printer_only_when_required_known(self):
- assert await self._ask(raises=RuntimeError("boom")) is False
- assert await self._ask(raises=RuntimeError("boom"), require_known=True) is True
- @pytest.mark.asyncio
- async def test_print_anyway_is_never_short(self):
- assert await self._ask(mapping=[0], deficit=_DEFICIT, skip=True) is False
- assert await self._ask(mapping=[0], deficit=_DEFICIT, skip=True, require_known=True) is False
|