diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 15e214a71..0b929f818 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -5,6 +5,7 @@ import json import logging import time import uuid +import zipfile from collections import deque from collections.abc import Mapping from dataclasses import dataclass @@ -74,8 +75,10 @@ from backend.app.utils.printer_models import ( normalize_printer_model, ) from backend.app.utils.threemf_tools import ( + default_plate_number, extract_rack_plan_from_3mf, extract_slot_extruders_from_3mf, + select_plate_gcode_name, ) logger = logging.getLogger(__name__) @@ -853,6 +856,77 @@ def _unmatched_filament_message(required: list[dict], loaded: list[dict]) -> str ) +def _effective_plate_id(explicit_plate_id: int | None, file_path: Path) -> int: + """The plate to dispatch, resolved once in ``_start_print`` and reused at + every call site below it: G-code injection, usage registration, rack-plan + lookup, slot-extruder lookup, the external-spool check, and the actual + print command. + + A positive explicit ``plate_id`` on the queue item always wins. A + non-positive one is treated as "not set" and resolved from the archive, + the way the rest of the queue code already reads it (``if item.plate_id:`` + in ``api/routes/print_queue.py``): the schemas put no lower bound on the + field, and passing a 0 straight through would build a print command for + ``Metadata/plate_0.gcode``, which is the same wedge this function exists + to prevent. The ``item.plate_id or 1`` this replaced mapped 0 to 1. + + Falling back to a bare ``1`` instead of reading the archive assumes a + single-plate file's one G-code is numbered 1, which only holds for a + plate exported on its own: one cut out of a larger project keeps its + ORIGINAL plate number, so a printer asked to print "plate 1" of a file + whose only G-code is ``plate_2.gcode`` accepts the command, can't find + the file, throws an HMS error, and sits wedged in IDLE until + power-cycled (#2947). + + The call sites agreed on a fallback only by accident before this: + with G-code injection on, ``inject_gcode_into_3mf`` already falls back to + the archive's own default plate internally whenever the plate id it's + handed isn't in the file, so it could silently inject into a different + plate than the one the print command itself asked for. + + Falls back to 1 when the archive can't be read, holds no G-code member at + all, or its default member doesn't follow the ``plate_N`` naming + convention (a slicer that doesn't use it has no number to dispatch). + + An explicit plate the archive doesn't hold is logged and then sent + anyway. It wedges the printer exactly like #2947 did, but redirecting it + to a plate that is in the file would print a model nobody asked for, + which is the worse of the two. + """ + try: + with zipfile.ZipFile(file_path, "r") as zf: + names = zf.namelist() + except (OSError, zipfile.BadZipFile) as exc: + logger.warning( + "Dispatch plate: cannot read %s (%s), so the archive's own plate numbering " + "is unavailable; dispatching plate %s", + file_path, + exc, + explicit_plate_id if explicit_plate_id is not None and explicit_plate_id > 0 else 1, + ) + names = None + + if explicit_plate_id is not None and explicit_plate_id > 0: + if ( + names is not None + and default_plate_number(names) is not None + and select_plate_gcode_name(names, explicit_plate_id) is None + ): + logger.warning( + "Dispatch plate: %s was queued for plate %s but holds no G-code for it " + "(it has %s). Sending plate %s as asked; expect the printer to reject the " + "file, since printing a different plate would print the wrong model (#2947)", + file_path, + explicit_plate_id, + ", ".join(sorted(n for n in names if n.endswith(".gcode"))), + explicit_plate_id, + ) + return explicit_plate_id + + resolved = default_plate_number(names) if names is not None else None + return resolved if resolved is not None else 1 + + class PrintScheduler: """Background scheduler that processes the print queue.""" @@ -6833,6 +6907,10 @@ class PrintScheduler: logger.info("Queue item %s: dispatch abandoned — cancelled during preheat", item.id) return + # See `_effective_plate_id` for why this is resolved once here rather + # than each site below repeating `item.plate_id or 1`. + effective_plate_id = _effective_plate_id(item.plate_id, file_path) + # G-code injection for auto-print systems (#422) injected_path = None # #2547: tracked separately from `injected_path`, which is also set when @@ -6851,7 +6929,7 @@ class PrintScheduler: from backend.app.utils.threemf_tools import inject_gcode_into_3mf injected_path = inject_gcode_into_3mf( - file_path, item.plate_id or 1, start_gc or None, end_gc or None + file_path, effective_plate_id, start_gc or None, end_gc or None ) if injected_path: file_path = injected_path @@ -7052,7 +7130,11 @@ class PrintScheduler: ams_mapping=ams_mapping, created_by_id=item.created_by_id, cost_center_id=item.cost_center_id, - plate_id=item.plate_id, + # The plate actually dispatched, not the queue item's raw + # column: on None, `register_expected_print` stores nothing, + # and `extract_filament_usage_from_3mf` then books every + # filament in the file rather than the one plate that printed. + plate_id=effective_plate_id, ) # Registration happens before the print command by necessity (the # printer can report the print before the send returns), so record @@ -7209,7 +7291,7 @@ class PrintScheduler: # rack as it stands right now, after the upload, not at queue time. resolved_nozzle_mapping = None if not item.nozzle_mapping and file_path is not None and is_nozzle_rack_model(printer.model): - rack_plan = extract_rack_plan_from_3mf(file_path, plate_id=item.plate_id or 1) + rack_plan = extract_rack_plan_from_3mf(file_path, plate_id=effective_plate_id) if rack_plan is not None: try: stored_choice = json.loads(item.nozzle_rack_choice) if item.nozzle_rack_choice else {} @@ -7308,7 +7390,7 @@ class PrintScheduler: and file_path is not None and is_nozzle_rack_model(printer.model) ): - slot_extruders = extract_slot_extruders_from_3mf(file_path, plate_id=item.plate_id or 1) + slot_extruders = extract_slot_extruders_from_3mf(file_path, plate_id=effective_plate_id) if slot_extruders: nozzle_slot_extruders = json.dumps(slot_extruders) @@ -7348,7 +7430,7 @@ class PrintScheduler: from backend.app.services.filament_requirements import extract_filament_requirements consumed = _consumed_mapping_entries( - ams_mapping, extract_filament_requirements(file_path, plate_id=item.plate_id or 1) + ams_mapping, extract_filament_requirements(file_path, plate_id=effective_plate_id) ) if consumed and all(_is_external_tray(t) for t in consumed): effective_use_ams = False @@ -7356,7 +7438,7 @@ class PrintScheduler: "Queue item %s: every filament plate %s prints is on the external spool " "(mapping %s) — dispatching with use_ams=False (#3087)", item.id, - item.plate_id or 1, + effective_plate_id, ams_mapping, ) @@ -7369,7 +7451,7 @@ class PrintScheduler: started = printer_manager.start_print( item.printer_id, remote_filename, - plate_id=item.plate_id or 1, + plate_id=effective_plate_id, ams_mapping=ams_mapping, bed_levelling=item.bed_levelling, flow_cali=item.flow_cali, diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index f2c1fe12b..71794ce17 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -1243,6 +1243,29 @@ def carries_gcode(file_path: Path | str) -> bool: return False +def default_plate_number(names: list[str]) -> int | None: + """The plate index to dispatch when the caller didn't ask for one. + + Composes :func:`default_plate_gcode_name` with the numeric index its + chosen member encodes, so a caller that needs the integer ``plate_id`` a + print command takes gets the same "lowest plate, not first zip member" + answer the plate viewer already relies on. + + None when the archive holds no G-code at all, or its default member + doesn't follow the ``plate_N`` naming convention ``default_plate_gcode_name`` + falls back to a bare first member for — callers dispatching a real print + command should fall back to plate 1 themselves in that case rather than + treat None as a plate number. + + A single-plate export cut out of a larger project keeps its ORIGINAL + plate number, so a caller that instead hardcodes a plate-1 fallback sends + a print command for a plate the archive may not contain. A real printer + accepts that command, can't find the G-code, and wedges (#2947). + """ + selected = default_plate_gcode_name(names) + return _plate_number_of(selected) if selected is not None else None + + # The header block sits at the very top of the plate G-code. Read only that # much: a sliced plate is routinely tens of megabytes and `ZipFile.read()` # would inflate all of it to reach ~40 lines. diff --git a/backend/tests/integration/test_external_spool_use_ams_3087.py b/backend/tests/integration/test_external_spool_use_ams_3087.py index b89edec66..9743a577a 100644 --- a/backend/tests/integration/test_external_spool_use_ams_3087.py +++ b/backend/tests/integration/test_external_spool_use_ams_3087.py @@ -46,13 +46,20 @@ pytestmark = pytest.mark.integration _PLATE_4_ONE_FILAMENT = '' -def _write_3mf(path: Path, plate_index: int = 4, filaments: str = _PLATE_4_ONE_FILAMENT) -> None: +def _write_3mf( + path: Path, + plate_index: int = 4, + filaments: str = _PLATE_4_ONE_FILAMENT, + gcode_members: tuple[str, ...] = (), +) -> None: path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(path, "w") as zf: zf.writestr( "Metadata/slice_info.config", f'{filaments}', ) + for name in gcode_members: + zf.writestr(name, "") def _write_3mf_without_slice_info(path: Path) -> None: @@ -70,11 +77,24 @@ async def dispatch_case(tmp_path): base_dir = tmp_path / "external-spool" async def _build( - mapping, *, use_ams=True, plate_id=4, filaments=_PLATE_4_ONE_FILAMENT, slice_info=True, model="P1S" + mapping, + *, + use_ams=True, + plate_id=4, + filaments=_PLATE_4_ONE_FILAMENT, + slice_info=True, + model="P1S", + plate_index=None, + gcode_members=(), ): - archive_rel = Path("archives") / f"plate-{plate_id}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf" + # The file's own plate number follows the queue item's unless a case + # needs them apart: an item with no plate still prints a numbered one. + plate_index = plate_id if plate_index is None else plate_index + archive_rel = Path("archives") / f"plate-{plate_index}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf" if slice_info: - _write_3mf(base_dir / archive_rel, plate_index=plate_id, filaments=filaments) + _write_3mf( + base_dir / archive_rel, plate_index=plate_index, filaments=filaments, gcode_members=gcode_members + ) else: _write_3mf_without_slice_info(base_dir / archive_rel) @@ -177,6 +197,23 @@ class TestThePlateThatOnlyPrintsFromTheSpoolHolder: assert call.kwargs["use_ams"] is False + async def test_an_item_with_no_plate_is_judged_on_the_plate_that_prints(self, dispatch_case): + """#2947's file: plate 2 cut out of a two-plate project, queued with no + plate. The check read plate 1 then, which this file does not describe, + so it found no filaments and the print went out with the AMS. + """ + ids = await dispatch_case.build( + [-1, 254], + filaments='', + plate_id=None, + plate_index=2, + gcode_members=("Metadata/plate_2.gcode",), + ) + call = await _dispatch(dispatch_case, ids) + + assert call.kwargs["plate_id"] == 2 + assert call.kwargs["use_ams"] is False + class TestWhatMustNotChange: async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case): diff --git a/backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py b/backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py index 3902eb3b4..b8b784c58 100644 --- a/backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py +++ b/backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py @@ -47,7 +47,13 @@ _FILAMENTS = ( _NOZZLES = '' -def _write_3mf(path: Path) -> None: +def _write_3mf(path: Path, gcode_members: tuple[str, ...] = (), slice_info: str | None = None) -> None: + """The rack cases need no G-code member and carry none, which is why + ``gcode_members`` defaults to empty. #2947 does need one: the plate a + dispatch resolves to when the queue item names none is read out of the + archive's G-code member names, and with none present the resolution has + nothing to answer from and falls back to 1 regardless. + """ path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(path, "w") as zf: zf.writestr( @@ -62,8 +68,10 @@ def _write_3mf(path: Path) -> None: ) zf.writestr( "Metadata/slice_info.config", - f'{_FILAMENTS}{_NOZZLES}', + slice_info or f'{_FILAMENTS}{_NOZZLES}', ) + for name in gcode_members: + zf.writestr(name, "") def _rack(present=(1, 2, 3, 4, 5, 6)): @@ -84,7 +92,16 @@ async def rack_case(tmp_path): archive_rel = Path("archives") / "benchy.gcode.3mf" _write_3mf(base_dir / archive_rel) - async def _build(model: str, choice: dict | None): + async def _build( + model: str, + choice: dict | None, + *, + plate_id: int | None = 1, + gcode_members: tuple[str, ...] = (), + slice_info: str | None = None, + ): + if gcode_members or slice_info: + _write_3mf(base_dir / archive_rel, gcode_members, slice_info) async with session_maker() as db: printer = Printer( name="H2C-1", @@ -107,7 +124,7 @@ async def rack_case(tmp_path): item = PrintQueueItem( printer_id=printer.id, archive_id=archive.id, - plate_id=1, + plate_id=plate_id, status="pending", nozzle_rack_choice=json.dumps(choice) if choice else None, ) @@ -233,6 +250,92 @@ class TestAPickThatNoLongerFits: delete_file.assert_awaited() +class TestThePlateThatGetsDispatched: + """#2947: a queue item with no plate of its own used to dispatch a bare 1. + + Driven on an X1C so the rack machinery is out of the way entirely (see + ``test_a_non_rack_printer_is_left_entirely_alone``): the only thing under + test here is which plate number leaves ``_start_print``. + + The archive is the shape that wedged a real printer: a single-plate export + cut out of a two-plate project, so its one G-code member is + ``plate_2.gcode`` and there is no ``plate_1.gcode`` for the firmware to + find. + """ + + async def test_the_print_command_names_the_plate_the_archive_holds(self, rack_case): + ids = await rack_case.build("X1C", None, plate_id=None, gcode_members=("Metadata/plate_2.gcode",)) + start_print, _, item = await _dispatch(rack_case, ids, []) + + assert start_print.call_count == 1 + assert start_print.call_args.kwargs["plate_id"] == 2 + assert item.status == "printing" + + async def test_usage_tracking_is_registered_for_that_same_plate(self, rack_case): + """Imported inside ``_start_print``, so it patches on its own module. + + Without this the archive says plate 2 printed while the usage tracker + was told nothing, and a None there books every filament in the file + instead of the plate that ran. + """ + ids = await rack_case.build("X1C", None, plate_id=None, gcode_members=("Metadata/plate_2.gcode",)) + with patch("backend.app.main.register_expected_print") as register: + start_print, _, _ = await _dispatch(rack_case, ids, []) + + assert start_print.call_count == 1 + assert register.call_args.kwargs["plate_id"] == 2 + + +# Plates 2 and 3 cut out of a larger project, so there is no plate 1. Plate 2 +# prints filaments 1 and 2 (one rack group, one fixed); plate 3 prints filament +# 3 on a second rack group. The rack lookups read every plate when asked for one +# the file does not describe, and across both plates this becomes the +# two-rack-groups case that neither lookup can answer the same way. +_PLATES_2_AND_3 = ( + "" + '' + '' + '' + '' + '' + '' + '' + "" +) + + +class TestTheRackLookupsReadThePlateThatGetsDispatched: + """#2947 on an H2C: the rack plan and the slot extruders are read for the + same plate the print command names, not for a plate 1 the file lacks. + """ + + async def _build(self, rack_case): + return await rack_case.build( + "H2C", + None, + plate_id=None, + gcode_members=("Metadata/plate_2.gcode", "Metadata/plate_3.gcode"), + slice_info=_PLATES_2_AND_3, + ) + + async def test_the_rack_is_resolved_for_plate_two_only(self, rack_case): + ids = await self._build(rack_case) + start_print, _, _ = await _dispatch(rack_case, ids, _rack()) + + assert start_print.call_args.kwargs["plate_id"] == 2 + # Filament 3 belongs to plate 3; plate 2 has no third slot to map. + assert _sent_mapping(start_print)[:3] == [16, 1, -1] + + async def test_the_slot_extruders_are_read_for_plate_two_only(self, rack_case): + """With no rack to assign from, the dispatch falls back to the #2800 + slot extruders, which plate 2 alone can state and the pair cannot.""" + ids = await self._build(rack_case) + start_print, _, _ = await _dispatch(rack_case, ids, _rack(present=())) + + assert start_print.call_args.kwargs["nozzle_mapping"] is None + assert json.loads(start_print.call_args.kwargs["nozzle_slot_extruders"]) == [0, 1] + + class TestOtherModels: async def test_a_non_rack_printer_is_left_entirely_alone(self, rack_case): """No rack means no resolution, no refusal, and no mapping invented.""" diff --git a/backend/tests/unit/test_plate_gcode_selection.py b/backend/tests/unit/test_plate_gcode_selection.py index b94da1bbe..742b042e4 100644 --- a/backend/tests/unit/test_plate_gcode_selection.py +++ b/backend/tests/unit/test_plate_gcode_selection.py @@ -9,6 +9,7 @@ opening plate 2 on a file whose first plate is plate 1. from backend.app.utils.threemf_tools import ( default_plate_gcode_name, + default_plate_number, select_plate_gcode_name, ) @@ -42,6 +43,40 @@ class TestDefaultPlateGcodeName: assert default_plate_gcode_name(["3D/3dmodel.model"]) is None +class TestDefaultPlateNumber: + """The integer a dispatch caller needs when no ``plate_id`` was asked for + (#2947). Composes ``default_plate_gcode_name`` with the numeric index its + chosen member encodes. + """ + + def test_picks_the_lowest_plate_not_the_first_member(self): + assert default_plate_number(REVERSED_ORDER) == 1 + + def test_single_plate_file_numbered_two_resolves_to_two(self): + # The exact shape of a plate cut out of a multi-plate project: one + # G-code member, keeping its ORIGINAL (non-1) plate number. Dispatch + # hardcoding a plate-1 fallback here is #2947. + assert default_plate_number(["Metadata/plate_2.gcode"]) == 2 + + def test_a_gcode_md5_sidecar_is_not_mistaken_for_the_toolpath(self): + names = ["Metadata/plate_1.gcode.md5", "Metadata/plate_2.gcode", "Metadata/plate_1.gcode"] + assert default_plate_number(names) == 1 + + def test_double_digit_plates_sort_numerically_not_lexically(self): + names = ["Metadata/plate_10.gcode", "Metadata/plate_2.gcode"] + assert default_plate_number(names) == 2 + + def test_returns_none_for_an_unsliced_file(self): + assert default_plate_number(["3D/3dmodel.model"]) is None + + def test_returns_none_when_the_default_member_has_no_plate_number(self): + # default_plate_gcode_name falls back to the first .gcode member for + # a slicer that doesn't use the plate_N convention; that member + # carries no parseable number, and this must not invent one — the + # dispatch caller is the one that falls back to plate 1. + assert default_plate_number(["out.gcode", "other.gcode"]) is None + + class TestSelectPlateGcodeName: def test_selects_the_named_plate_regardless_of_zip_order(self): assert select_plate_gcode_name(REVERSED_ORDER, 1) == "Metadata/plate_1.gcode" diff --git a/backend/tests/unit/test_scheduler_effective_plate_id.py b/backend/tests/unit/test_scheduler_effective_plate_id.py new file mode 100644 index 000000000..816f43e1d --- /dev/null +++ b/backend/tests/unit/test_scheduler_effective_plate_id.py @@ -0,0 +1,150 @@ +"""``_effective_plate_id``, the plate resolved once in ``_start_print`` and +reused at every dispatch call site: G-code injection, usage registration, +rack-plan lookup, slot-extruder lookup, the external-spool check, and the +actual print command (#2947). + +A single-plate 3MF exported out of a multi-plate project keeps its ORIGINAL +plate number: cutting the right-side plate out of a two-plate project leaves +a file whose only G-code member is ``Metadata/plate_2.gcode``, never +``plate_1.gcode``. A caller that hardcodes ``item.plate_id or 1`` sends a +print command for a plate the archive doesn't hold. The printer accepts the +command, can't find the G-code, and wedges until power-cycled. + +The call sites agreed on this fallback only by accident before this fix +— see the function's own docstring for how a plate mismatch could sneak past +G-code injection specifically. +""" + +import logging +import zipfile + +from backend.app.services.print_scheduler import _effective_plate_id + + +def _write_gcode_members(path, names): + with zipfile.ZipFile(path, "w") as zf: + for name in names: + zf.writestr(name, "") + return path + + +class TestEffectivePlateId: + def test_explicit_plate_id_wins_even_when_the_file_cannot_be_read(self, tmp_path): + # A queue item's own choice always wins, and an archive that can't be + # opened must not stop it from being returned. + assert _effective_plate_id(3, tmp_path / "does-not-exist.3mf") == 3 + + def test_plate_id_zero_is_treated_as_unset_and_resolved_from_the_archive(self, tmp_path): + # Nothing validates the field as positive (no ge= on any plate_id in + # schemas/print_queue.py) and the rest of the queue code reads it + # truthily, so a 0 means "not set" here too. Returning it would + # dispatch Metadata/plate_0.gcode, the exact wedge this fixes. + path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"]) + assert _effective_plate_id(0, path) == 2 + + def test_negative_plate_id_is_treated_as_unset_too(self, tmp_path): + path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"]) + assert _effective_plate_id(-1, path) == 2 + + def test_single_plate_file_numbered_two_resolves_to_two(self, tmp_path): + # The exact shape of the wedged printer in #2947: one G-code member, + # keeping its original (non-1) plate number. + path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"]) + assert _effective_plate_id(None, path) == 2 + + def test_single_plate_file_numbered_one_resolves_to_one(self, tmp_path): + path = _write_gcode_members(tmp_path / "left.gcode.3mf", ["Metadata/plate_1.gcode"]) + assert _effective_plate_id(None, path) == 1 + + def test_reversed_zip_order_picks_the_lowest_plate_not_first_member(self, tmp_path): + # Bambu Studio does not write plates in zip order: a real two-plate + # export stores plate_2.gcode ahead of plate_1.gcode. + path = _write_gcode_members( + tmp_path / "reversed.gcode.3mf", + ["Metadata/plate_2.gcode", "Metadata/plate_1.gcode"], + ) + assert _effective_plate_id(None, path) == 1 + + def test_gcode_md5_sidecar_is_not_mistaken_for_the_toolpath(self, tmp_path): + path = _write_gcode_members( + tmp_path / "sidecar.gcode.3mf", + ["Metadata/plate_1.gcode.md5", "Metadata/plate_2.gcode", "Metadata/plate_1.gcode"], + ) + assert _effective_plate_id(None, path) == 1 + + def test_unsliced_file_falls_back_to_plate_one(self, tmp_path): + path = _write_gcode_members(tmp_path / "unsliced.3mf", ["3D/3dmodel.model"]) + assert _effective_plate_id(None, path) == 1 + + def test_gcode_member_without_plate_naming_falls_back_to_plate_one(self, tmp_path): + # default_plate_number returns None here (no number to dispatch); + # this is the one place that turns that None into the actual + # fallback a print command needs. + path = _write_gcode_members(tmp_path / "custom.gcode.3mf", ["Metadata/print.gcode"]) + assert _effective_plate_id(None, path) == 1 + + def test_unreadable_file_falls_back_to_plate_one(self, tmp_path): + path = tmp_path / "broken.3mf" + path.write_bytes(b"not a zip") + assert _effective_plate_id(None, path) == 1 + + def test_missing_file_falls_back_to_plate_one(self, tmp_path): + assert _effective_plate_id(None, tmp_path / "does-not-exist.3mf") == 1 + + +class TestWhatGetsLogged: + """The fallbacks are silent recoveries from something that is wrong with + the file, so each one has to leave a trace naming the archive. None of + them may raise: this runs on a dispatch that is otherwise fine. + """ + + def test_an_unreadable_archive_is_logged_with_the_path_and_the_reason(self, tmp_path, caplog): + path = tmp_path / "broken.3mf" + path.write_bytes(b"not a zip") + + with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"): + assert _effective_plate_id(None, path) == 1 + + assert "broken.3mf" in caplog.text + assert "BadZipFile" in caplog.text or "not a zip file" in caplog.text.lower() + + def test_an_explicit_plate_the_archive_does_not_hold_is_logged_not_redirected(self, tmp_path, caplog): + # The wedge of #2947 seen from the other side: the operator named + # plate 1, the file only has plate 2. Redirecting to 2 would print a + # model nobody asked for, so the command goes out as asked. + path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"]) + + with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"): + assert _effective_plate_id(1, path) == 1 + + assert "right.gcode.3mf" in caplog.text + assert "Metadata/plate_2.gcode" in caplog.text + + def test_a_plate_the_archive_does_hold_logs_nothing(self, tmp_path, caplog): + path = _write_gcode_members( + tmp_path / "two.gcode.3mf", + ["Metadata/plate_1.gcode", "Metadata/plate_2.gcode"], + ) + + with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"): + assert _effective_plate_id(2, path) == 2 + + assert caplog.text == "" + + def test_a_slicer_that_does_not_number_its_plates_is_not_warned_about(self, tmp_path, caplog): + # There is no plate numbering to contradict here, so an explicit plate + # is not evidence of a mismatch and must not be reported as one. + path = _write_gcode_members(tmp_path / "custom.gcode.3mf", ["Metadata/print.gcode"]) + + with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"): + assert _effective_plate_id(1, path) == 1 + + assert caplog.text == "" + + def test_an_unsliced_archive_is_not_warned_about_either(self, tmp_path, caplog): + path = _write_gcode_members(tmp_path / "unsliced.3mf", ["3D/3dmodel.model"]) + + with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"): + assert _effective_plate_id(1, path) == 1 + + assert caplog.text == ""