| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858 |
- """Regression tests for the cross-printer AMS mapping bug (#2799).
- One model queued to three P2S at once produced three queue items carrying a
- byte-identical ``ams_mapping``. That mapping was correct for the printer it was
- computed against and wrong for the other two, whose AMS held the same spools in
- different slots — so two of the three printed the lettering in ASA where the 3MF
- strictly requires PETG. An explicit ``ams_mapping`` bypasses the firmware's own
- type check, so nothing downstream caught it.
- The reproduction below is the real incident: the 3MF needs light-blue PETG in
- slot 1 and dark-blue PETG in slot 3; P2S-4 holds them in trays 2 and 1, while
- P2S-5 holds them in trays 0 and 2 with ASA in tray 1. ``[2, -1, 1]`` is right on
- the first and feeds ASA into slot 3 on the second.
- """
- import json
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services.print_scheduler import PrintScheduler
- # 3MF requirements: slot 1 light-blue PETG (body), slot 3 dark-blue PETG (text).
- REQUIREMENTS = [
- {"slot_id": 1, "type": "PETG", "color": "#76D9F4", "tray_info_idx": "GFG99"},
- {"slot_id": 3, "type": "PETG", "color": "#2850E0", "tray_info_idx": "GFG99"},
- ]
- # AMS layouts as reported over MQTT, same spools in a different slot order.
- P2S_4_TRAYS = [
- {"global_tray_id": 0, "type": "ASA", "color": "161616FF"},
- {"global_tray_id": 1, "type": "PETG", "color": "2850E0FF"},
- {"global_tray_id": 2, "type": "PETG", "color": "76D9F4FF"},
- {"global_tray_id": 3, "type": "ASA", "color": "898989FF"},
- ]
- P2S_5_TRAYS = [
- {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
- {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
- {"global_tray_id": 2, "type": "PETG", "color": "2850E0FF"},
- {"global_tray_id": 3, "type": "ASA", "color": "898989FF"},
- ]
- # The mapping the print dialog stamped onto all three items.
- SHARED_MAPPING = [2, -1, 1]
- def _item(**overrides):
- item = MagicMock()
- item.id = 276
- item.printer_id = 8
- item.ams_mapping = json.dumps(SHARED_MAPPING)
- item.skip_filament_check = False
- item.filament_overrides = None
- item.required_filament_types = None
- item.manual_start = False
- item.filament_short = False
- for key, value in overrides.items():
- setattr(item, key, value)
- return item
- def _scheduler(trays, requirements=REQUIREMENTS):
- scheduler = PrintScheduler()
- scheduler._build_loaded_filaments = MagicMock(return_value=list(trays))
- scheduler._get_filament_requirements = AsyncMock(
- return_value=[dict(r) for r in requirements] if requirements else None
- )
- return scheduler
- class TestStoredMappingConflict:
- """The stored mapping is re-checked against the printer about to run it."""
- @pytest.mark.asyncio
- async def test_mapping_fits_the_printer_it_was_computed_for(self):
- scheduler = _scheduler(P2S_4_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), SHARED_MAPPING)
- assert conflict is None
- @pytest.mark.asyncio
- async def test_same_mapping_on_a_differently_loaded_printer_conflicts(self):
- """The incident: slot 3 would have printed in ASA."""
- scheduler = _scheduler(P2S_5_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
- assert conflict is not None
- assert "slot 3" in conflict
- assert "ASA" in conflict
- @pytest.mark.asyncio
- async def test_tray_not_loaded_conflicts(self):
- scheduler = _scheduler(P2S_4_TRAYS[:2]) # trays 2 and 3 removed
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), SHARED_MAPPING)
- assert conflict is not None
- assert "does not have loaded" in conflict
- @pytest.mark.asyncio
- async def test_mapping_too_short_for_the_plate_conflicts(self):
- scheduler = _scheduler(P2S_4_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [2])
- assert conflict is not None
- assert "slot 3" in conflict
- @pytest.mark.asyncio
- async def test_unreported_external_feed_is_not_a_conflict(self):
- """An external spool we have not heard about is absence of evidence."""
- scheduler = _scheduler(P2S_4_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 255])
- assert conflict is None
- @pytest.mark.asyncio
- async def test_external_feed_is_type_checked_like_any_tray(self):
- """Two printers with different filament in the external feed is the same
- failure this check exists for, so >=254 is not a free pass."""
- trays = P2S_4_TRAYS + [{"global_tray_id": 254, "type": "ASA", "color": "161616FF"}]
- scheduler = _scheduler(trays)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 1])
- assert conflict is not None
- assert "ASA" in conflict
- @pytest.mark.asyncio
- async def test_an_external_feed_does_not_excuse_the_rest_of_the_mapping(self):
- """An unrecognised >=254 entry is absence of evidence about that slot
- only. Bailing out of the whole pass on it let a foreign tray later in
- the mapping through unexamined."""
- scheduler = _scheduler(P2S_4_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 7])
- assert conflict is not None
- assert "slot 3" in conflict
- @pytest.mark.asyncio
- async def test_unresolved_required_slot_is_not_a_conflict(self):
- """-1 says the matcher had nothing, not that the mapping is foreign.
- Recomputing on it would discard the slots the user did resolve by hand;
- _block_on_unmatched_filament owns this case instead."""
- scheduler = _scheduler(P2S_4_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [2, -1, -1])
- assert conflict is None
- @pytest.mark.asyncio
- async def test_foreign_tray_is_caught_without_reading_the_3mf(self):
- """The cheap pass runs on live status alone, so a foreign mapping is
- rejected without opening the 3MF."""
- scheduler = _scheduler(P2S_4_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [7, -1, 1])
- assert conflict is not None
- assert "does not have loaded" in conflict
- scheduler._get_filament_requirements.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_no_status_is_not_a_conflict(self):
- """Absence of evidence never triggers a recompute."""
- scheduler = _scheduler(P2S_5_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = None
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
- assert conflict is None
- @pytest.mark.asyncio
- async def test_no_trays_reported_yet_is_not_a_conflict(self):
- scheduler = _scheduler([])
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
- assert conflict is None
- @pytest.mark.asyncio
- async def test_unreadable_requirements_is_not_a_conflict(self):
- scheduler = _scheduler(P2S_5_TRAYS, requirements=None)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
- assert conflict is None
- @pytest.mark.asyncio
- async def test_a_mapping_that_is_not_a_list_is_not_a_conflict(self):
- """A corrupted row is not evidence about the printer, and the guard that
- keeps it from being indexed was the one thing here without a test."""
- scheduler = _scheduler(P2S_5_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), {"1": 2})
- assert conflict is None
- @pytest.mark.asyncio
- async def test_a_boolean_is_not_read_as_tray_one(self):
- """`true` is an int in Python, so it would otherwise be judged against
- whatever tray 1 holds — here ASA, i.e. a conflict reported for a slot
- nothing is actually known about."""
- scheduler = _scheduler(P2S_5_TRAYS)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), [True, -1, 2])
- assert conflict is None
- class TestEnsureAmsMappingRevalidates:
- """``_ensure_ams_mapping`` acts on the conflict verdict."""
- @pytest.mark.asyncio
- async def test_conflicting_mapping_is_recomputed(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
- item = _item()
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 8, item)
- assert json.loads(item.ams_mapping) == [0, -1, 2]
- scheduler._compute_ams_mapping_for_printer.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_fitting_mapping_is_left_untouched(self):
- scheduler = _scheduler(P2S_4_TRAYS)
- scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
- item = _item(printer_id=9)
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 9, item)
- assert json.loads(item.ams_mapping) == SHARED_MAPPING
- scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_print_anyway_keeps_the_users_mapping(self):
- """skip_filament_check is the user overriding exactly this judgement."""
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
- item = _item(skip_filament_check=True)
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 8, item)
- assert json.loads(item.ams_mapping) == SHARED_MAPPING
- scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_failed_recompute_does_not_fall_back_to_the_bad_mapping(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=None)
- scheduler._unmappable_without_ams_message = AsyncMock(return_value=None)
- item = _item()
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 8, item)
- assert item.ams_mapping is None
- class TestBlockOnUnmatchedFilament:
- """A slot the plate prints with no tray on this printer holds the job."""
- @pytest.mark.asyncio
- async def test_unresolved_required_slot_holds(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- db = AsyncMock()
- blocked = await scheduler._block_on_unmatched_filament(db, item)
- assert blocked is True
- assert item.manual_start is True
- # The reason names the filament, not a machine token, because it renders
- # on the queue row.
- assert item.waiting_reason.startswith("Needs ")
- assert "PETG" in item.waiting_reason
- assert "#2850E0" in item.waiting_reason
- @pytest.mark.asyncio
- async def test_padding_for_a_slot_this_plate_skips_does_not_hold(self):
- """-1 at slot 2, which this plate never prints, is not a missing filament.
- This is why the hold intersects with the requirement list rather than
- reading the mapping array on its own."""
- scheduler = _scheduler(P2S_5_TRAYS)
- item = _item(ams_mapping=json.dumps([0, -1, 2]))
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is False
- assert item.manual_start is False
- @pytest.mark.asyncio
- async def test_mapping_too_short_for_the_plate_holds(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
- item = _item(ams_mapping=json.dumps([0]))
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is True
- @pytest.mark.asyncio
- async def test_print_anyway_dispatches(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- item = _item(ams_mapping=json.dumps([0, -1, -1]), skip_filament_check=True)
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is False
- @pytest.mark.asyncio
- async def test_a_mapping_that_resolved_nothing_at_all_holds(self):
- """The complete miss used to fall out of this gate and into the ugly path.
- `_ensure_ams_mapping` clears a rejected mapping the recompute could not
- replace, and bailing on `not item.ams_mapping` then dispatched the item
- with no mapping at all: several megabytes uploaded, 0700_8012 from the
- firmware, retries burned. Live status reporting loaded trays while the
- matcher resolved none of them is a positive finding, so it holds.
- """
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
- item = _item(ams_mapping=None)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is True
- assert item.manual_start is True
- assert item.waiting_reason.startswith("Needs ")
- @pytest.mark.asyncio
- async def test_a_cleared_mapping_on_a_printer_with_no_trays_reported_dispatches(self):
- """#2589 is the same shape and must stay untouched: there the mapping is
- bogus because the AMS was not known yet, and an empty loaded list is
- exactly that ignorance. Holding on it would hold on absence of
- evidence."""
- scheduler = _scheduler([])
- item = _item(ams_mapping=None)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is False
- assert item.manual_start is False
- @pytest.mark.asyncio
- async def test_a_cleared_mapping_with_no_status_dispatches(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- item = _item(ams_mapping=None)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = None
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is False
- assert item.manual_start is False
- @pytest.mark.asyncio
- async def test_a_boolean_tray_id_holds_rather_than_reading_as_tray_one(self):
- """`true` is an int in Python. A hand-written payload carrying it would
- otherwise be read as tray 1 and dispatched as whatever sits there."""
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
- item = _item(ams_mapping=json.dumps([True, -1, 2]))
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is True
- assert "#76D9F4" in item.waiting_reason
- class TestHoldOnTheModelBasedPath:
- """A held "any P2S" job must not stay pinned to the P2S that held it.
- The model-based path commits `item.printer_id` before the gates run, so a
- hold would otherwise leave an unpinned job pinned to the one printer that
- could not run it, and the next press of the start button retries the same
- dead end. The printer-assigned path keeps its printer: that one was the
- user's choice, not the scheduler's.
- """
- @pytest.mark.asyncio
- async def test_a_model_based_hold_releases_the_printer_and_its_mapping(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
- assert blocked is True
- assert item.printer_id is None
- # The mapping goes with the assignment: its tray IDs were resolved
- # against the printer just released, so leaving it would hold the job
- # again on the next printer even when that one has the filament.
- assert item.ams_mapping is None
- assert item.waiting_reason.startswith("Needs ")
- @pytest.mark.asyncio
- async def test_the_notification_still_names_the_printer_that_held_it(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
- # Looked up before the release, or it would be looked up as None.
- assert scheduler._get_printer.await_args.args[1] == 8
- @pytest.mark.asyncio
- async def test_a_printer_assigned_hold_keeps_both(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is True
- assert item.printer_id == 8
- assert json.loads(item.ams_mapping) == [0, -1, -1]
- @pytest.mark.asyncio
- async def test_unreadable_requirements_dispatches(self):
- scheduler = _scheduler(P2S_5_TRAYS, requirements=None)
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is False
- @pytest.mark.asyncio
- async def test_nozzle_bound_filament_on_the_wrong_side_holds(self):
- """The H2D case the type-only scan waved through: PETG is loaded, but on
- the other nozzle's AMS, so the nozzle-aware matcher left the slot at -1.
- Reading the mapping inherits that restriction for free."""
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="H2D"))
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
- assert blocked is True
- class TestModelBasedVirtualPrinterMapping:
- """A model-based Virtual Printer stamps a mapping nothing can attribute.
- `virtual_printer/manager.py` writes the slicer's own AMS pick onto the queue
- item it creates, on the line below `printer_id=self.target_printer_id` —
- which is None for an "Any P2S" VP. So the tray IDs the slicer resolved are
- carried by an item the scheduler will hand to whichever printer of that
- model happens to be free, and the row itself records no printer they could
- be checked against. Same failure as the print dialog's, arriving from a
- different direction, and reachable whenever the per-VP `save_ams_mapping`
- opt-in is on without `queue_force_color_match`.
- The item has no `printer_id` until the model-based path assigns one, so
- these go through `_ensure_ams_mapping`'s `printer_id` argument rather than
- `item.printer_id` — which is how that path calls it.
- """
- @pytest.mark.asyncio
- async def test_slicer_mapping_is_rechecked_against_the_printer_chosen_for_it(self):
- scheduler = _scheduler(P2S_5_TRAYS)
- scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
- # Fresh from the VP: no printer_id, mapping resolved by the slicer.
- item = _item(printer_id=None)
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 10, item)
- assert json.loads(item.ams_mapping) == [0, -1, 2]
- # Judged against the printer the scheduler picked, not the row's own
- # (absent) one.
- scheduler._compute_ams_mapping_for_printer.assert_awaited_once()
- assert scheduler._compute_ams_mapping_for_printer.await_args.args[1] == 10
- @pytest.mark.asyncio
- async def test_slicer_mapping_survives_on_a_printer_it_happens_to_fit(self):
- """Not every VP mapping is wrong — one that fits the chosen printer is
- kept, so the slicer's deliberate slot pick is not thrown away."""
- scheduler = _scheduler(P2S_4_TRAYS)
- scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
- item = _item(printer_id=None)
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 9, item)
- assert json.loads(item.ams_mapping) == SHARED_MAPPING
- scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
- class TestFilamentRequirementMemo:
- """The two gates parse the same 3MF per dispatch attempt (#2799 review).
- A one-file fan-out to many idle printers repeats that for every one of them
- inside a single pass, each time a DB lookup plus a zip open and XML parse.
- The memo is scoped to the pass so a file re-sliced between ticks is re-read.
- """
- @staticmethod
- def _db_returning(archive):
- result = MagicMock()
- result.scalar_one_or_none.return_value = archive
- db = AsyncMock()
- db.execute = AsyncMock(return_value=result)
- return db
- @pytest.fixture
- def parsed(self, tmp_path, monkeypatch):
- from backend.app.services import print_scheduler as ps
- (tmp_path / "plate.3mf").write_bytes(b"")
- monkeypatch.setattr(ps.settings, "base_dir", tmp_path)
- calls = []
- def fake_extract(path, plate_id=None):
- calls.append((path, plate_id))
- return [{"slot_id": 1, "type": "PETG", "color": "#76D9F4", "tray_info_idx": ""}]
- monkeypatch.setattr(
- "backend.app.services.filament_requirements.extract_filament_requirements",
- fake_extract,
- )
- return calls
- @pytest.mark.asyncio
- async def test_the_same_plate_is_parsed_once_per_pass(self, parsed):
- scheduler = PrintScheduler()
- db = self._db_returning(MagicMock(file_path="plate.3mf"))
- item = _item(archive_id=731, library_file_id=None, plate_id=22)
- await scheduler._get_filament_requirements(db, item)
- await scheduler._get_filament_requirements(db, item)
- assert len(parsed) == 1
- @pytest.mark.asyncio
- async def test_each_caller_gets_its_own_copy(self, parsed):
- """`_apply_filament_overrides` rewrites these dicts in place, so handing
- out the cached list would leak one item's overrides into the next."""
- scheduler = PrintScheduler()
- db = self._db_returning(MagicMock(file_path="plate.3mf"))
- item = _item(archive_id=731, library_file_id=None, plate_id=22)
- first = await scheduler._get_filament_requirements(db, item)
- first[0]["type"] = "ASA" # an override applied by one caller
- second = await scheduler._get_filament_requirements(db, item)
- assert second[0]["type"] == "PETG"
- @pytest.mark.asyncio
- async def test_a_different_plate_is_parsed_separately(self, parsed):
- scheduler = PrintScheduler()
- db = self._db_returning(MagicMock(file_path="plate.3mf"))
- await scheduler._get_filament_requirements(db, _item(archive_id=731, library_file_id=None, plate_id=22))
- await scheduler._get_filament_requirements(db, _item(archive_id=731, library_file_id=None, plate_id=23))
- assert len(parsed) == 2
- @pytest.mark.asyncio
- async def test_the_memo_does_not_outlive_the_pass(self, parsed):
- """check_queue clears it at the top of every pass."""
- scheduler = PrintScheduler()
- db = self._db_returning(MagicMock(file_path="plate.3mf"))
- item = _item(archive_id=731, library_file_id=None, plate_id=22)
- await scheduler._get_filament_requirements(db, item)
- scheduler._filament_req_memo.clear()
- await scheduler._get_filament_requirements(db, item)
- assert len(parsed) == 2
- @pytest.mark.asyncio
- async def test_an_unreadable_file_is_remembered_too(self, parsed):
- """The miss is worth caching as well — otherwise a missing file costs a
- DB lookup per gate per attempt for nothing."""
- scheduler = PrintScheduler()
- db = self._db_returning(None)
- item = _item(archive_id=999, library_file_id=None, plate_id=1)
- assert await scheduler._get_filament_requirements(db, item) is None
- assert await scheduler._get_filament_requirements(db, item) is None
- assert db.execute.await_count == 1
- class TestTheHoldReasonSurvivesTheNextPass:
- """The reason is the hold's whole explanation, so a later pass must keep it.
- Since #3074 the scheduler clears the reason of every staged item on each
- pass, because a reason left from before staging would otherwise stand
- forever. The unmatched-filament hold writes its reason at the moment it
- stages the item, so clearing it would leave a job that waits for a start
- press with nothing on the row saying which spool to load.
- """
- @pytest.fixture
- async def session_maker(self):
- 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
- engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- try:
- yield async_sessionmaker(engine, expire_on_commit=False)
- finally:
- await engine.dispose()
- async def _staged_item(self, session_maker, reason):
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.printer import Printer
- async with session_maker() as db:
- printer = Printer(
- name="P2S-5", serial_number="SERIAL", ip_address="10.0.0.1", access_code="code", model="P2S"
- )
- db.add(printer)
- await db.flush()
- item = PrintQueueItem(
- printer_id=printer.id, status="pending", position=0, manual_start=True, waiting_reason=reason
- )
- db.add(item)
- await db.commit()
- return item.id
- async def _reason_after_a_pass(self, session_maker, item_id):
- from backend.app.models.print_queue import PrintQueueItem
- scheduler = PrintScheduler()
- with (
- patch("backend.app.services.print_scheduler.async_session", 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.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
- patch.object(scheduler, "_check_auto_drying", AsyncMock()),
- ):
- await scheduler.check_queue()
- async with session_maker() as db:
- return (await db.get(PrintQueueItem, item_id)).waiting_reason
- @pytest.mark.asyncio
- async def test_the_hold_reason_stays_on_a_staged_item(self, session_maker):
- item_id = await self._staged_item(session_maker, "Needs PETG #2850E0")
- assert await self._reason_after_a_pass(session_maker, item_id) == "Needs PETG #2850E0"
- @pytest.mark.asyncio
- async def test_any_other_reason_on_a_staged_item_is_still_cleared(self, session_maker):
- """#3074's rule for everything else is unchanged."""
- item_id = await self._staged_item(session_maker, "Busy: P2S-5")
- assert await self._reason_after_a_pass(session_maker, item_id) is None
- def _matching_scheduler(trays):
- """A scheduler whose real matcher runs against ``trays``."""
- scheduler = _scheduler(trays)
- scheduler._get_bool_setting = AsyncMock(return_value=False)
- scheduler._get_job_name = AsyncMock(return_value="body1")
- scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
- return scheduler
- class TestUnresolvedSlotsAreMatchedAgain:
- """A fitting mapping keeps its resolved slots and has only its gaps matched again.
- The hold keeps a pinned item's mapping, and an unresolved slot is not a
- conflict, so without this nothing would look at the gap again: loading the
- missing spool and pressing Start would hold the job again, every time.
- """
- @pytest.mark.asyncio
- async def test_a_spool_loaded_after_the_hold_is_found(self):
- # Held with slot 3 unresolved; dark-blue PETG has since gone into tray 2.
- scheduler = _matching_scheduler(P2S_5_TRAYS)
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 8, item)
- blocked = await scheduler._block_on_unmatched_filament(db, item)
- assert json.loads(item.ams_mapping) == [0, -1, 2]
- assert blocked is False
- @pytest.mark.asyncio
- async def test_a_tray_the_user_picked_is_kept_and_not_handed_out_twice(self):
- """The user put slot 1 on tray 2 by hand. Slot 3's best match is that same
- tray, but it is taken, so slot 3 gets the other PETG instead of sharing."""
- scheduler = _matching_scheduler(P2S_5_TRAYS)
- item = _item(ams_mapping=json.dumps([2, -1, -1]))
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 8, item)
- assert json.loads(item.ams_mapping) == [2, -1, 0]
- @pytest.mark.asyncio
- async def test_still_missing_leaves_the_mapping_and_holds(self):
- trays = [
- {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
- {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
- ]
- scheduler = _matching_scheduler(trays)
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 8, item)
- blocked = await scheduler._block_on_unmatched_filament(db, item)
- assert json.loads(item.ams_mapping) == [0, -1, -1]
- assert blocked is True
- @pytest.mark.asyncio
- async def test_print_anyway_leaves_the_gap_alone(self):
- scheduler = _matching_scheduler(P2S_5_TRAYS)
- item = _item(ams_mapping=json.dumps([0, -1, -1]), skip_filament_check=True)
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 8, item)
- assert json.loads(item.ams_mapping) == [0, -1, -1]
- @pytest.mark.asyncio
- async def test_a_complete_mapping_is_not_matched_again(self):
- scheduler = _matching_scheduler(P2S_5_TRAYS)
- scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
- item = _item(ams_mapping=json.dumps([0, -1, 2]))
- db = AsyncMock()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- await scheduler._ensure_ams_mapping(db, 8, item)
- scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
- class TestMissingFilamentForStart:
- """The Start button's question: would this item be held again right now?"""
- @pytest.mark.asyncio
- async def test_names_a_filament_that_is_still_not_loaded(self):
- trays = [
- {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
- {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
- ]
- scheduler = _matching_scheduler(trays)
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- missing = await scheduler.missing_filament_for_start(AsyncMock(), item)
- assert missing is not None and len(missing) == 1
- assert "PETG" in missing[0] and "#2850E0" in missing[0]
- # Asking is not acting: the stored mapping is the scheduler's to change.
- assert json.loads(item.ams_mapping) == [0, -1, -1]
- @pytest.mark.asyncio
- async def test_nothing_missing_once_the_spool_is_loaded(self):
- scheduler = _matching_scheduler(P2S_5_TRAYS)
- item = _item(ams_mapping=json.dumps([0, -1, -1]))
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
- @pytest.mark.asyncio
- async def test_a_mapping_from_another_printer_is_judged_by_a_fresh_match(self):
- """The #2799 mapping on P2S-5 conflicts, so Start judges a fresh match,
- which finds both filaments."""
- scheduler = _matching_scheduler(P2S_5_TRAYS)
- item = _item()
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
- @pytest.mark.asyncio
- async def test_no_mapping_and_nothing_matching_is_missing(self):
- trays = [{"global_tray_id": 0, "type": "ASA", "color": "161616FF"}]
- scheduler = _matching_scheduler(trays)
- item = _item(ams_mapping=None)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- missing = await scheduler.missing_filament_for_start(AsyncMock(), item)
- assert missing is not None and len(missing) == 2
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "overrides",
- [
- {"skip_filament_check": True},
- # A model-based item picks its printer at dispatch.
- {"printer_id": None},
- ],
- )
- async def test_does_not_judge_what_dispatch_would_not(self, overrides):
- trays = [{"global_tray_id": 0, "type": "ASA", "color": "161616FF"}]
- scheduler = _matching_scheduler(trays)
- item = _item(ams_mapping=None, **overrides)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = MagicMock()
- assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
- @pytest.mark.asyncio
- async def test_no_status_is_not_a_finding(self):
- scheduler = _matching_scheduler(P2S_5_TRAYS)
- item = _item(ams_mapping=None)
- with patch("backend.app.services.print_scheduler.printer_manager") as pm:
- pm.get_status.return_value = None
- assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
|