Resolve dispatch plate_id from the archive instead of assuming 1 (#2951)

This commit is contained in:
sgiffhorn
2026-09-28 16:06:41 +02:00
committed by GitHub
parent d70e2b28d8
commit 89dde591f2
6 changed files with 445 additions and 15 deletions
+89 -7
View File
@@ -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,
+23
View File
@@ -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.
@@ -46,13 +46,20 @@ pytestmark = pytest.mark.integration
_PLATE_4_ONE_FILAMENT = '<filament id="7" used_g="12.4" type="PLA" color="#F98C36"/>'
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'<config><plate><metadata key="index" value="{plate_index}"/>{filaments}</plate></config>',
)
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='<filament id="2" used_g="9.0" type="PLA"/>',
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):
@@ -47,7 +47,13 @@ _FILAMENTS = (
_NOZZLES = '<nozzle id="0" extruder_id="1"/><nozzle id="1" extruder_id="2"/><nozzle id="2" extruder_id="2"/>'
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'<config><plate><metadata key="index" value="1"/>{_FILAMENTS}{_NOZZLES}</plate></config>',
slice_info or f'<config><plate><metadata key="index" value="1"/>{_FILAMENTS}{_NOZZLES}</plate></config>',
)
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 = (
"<config>"
'<plate><metadata key="index" value="2"/>'
'<filament id="1" group_id="2" color="#DE4343" nozzle_diameter="0.40" volume_type="High Flow"/>'
'<filament id="2" group_id="0" color="#F4EE2A" nozzle_diameter="0.40" volume_type="High Flow"/>'
'<nozzle id="0" extruder_id="1"/><nozzle id="2" extruder_id="2"/></plate>'
'<plate><metadata key="index" value="3"/>'
'<filament id="3" group_id="1" color="#0078BF" nozzle_diameter="0.40" volume_type="High Flow"/>'
'<nozzle id="1" extruder_id="2"/></plate>'
"</config>"
)
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."""
@@ -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"
@@ -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 == ""