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