diff --git a/assert b/assert new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 047301f2b..cee78bbd8 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -2,6 +2,7 @@ import asyncio import logging import re import zipfile +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response @@ -1083,6 +1084,41 @@ def clear_cover_cache(printer_id: int) -> None: _cover_404_cache.pop(printer_id, None) +async def _running_print_archive_file(printer_id: int, state) -> Path | None: + """Path to the 3MF of the print this printer is running, if we have it. + + Bambuddy archives the sliced file when the print starts, so the copy the + printer is executing is usually already on disk. Anchored on ``subtask_id``, + which the firmware mints per print: a leftover ``status="printing"`` row from + a completion that was never seen must not lend its file to another job. + + Opens its own short-lived session, like the caller does, so the pooled + connection is not held across the FTP work that follows. + """ + subtask_id = str(getattr(state, "subtask_id", "") or "").strip() + if subtask_id in ("", "0"): + return None + + from backend.app.models.archive import PrintArchive + + async with database.async_session() as db: + archive = await db.scalar( + select(PrintArchive) + .where( + PrintArchive.printer_id == printer_id, + PrintArchive.status == "printing", + PrintArchive.subtask_id == subtask_id, + ) + .order_by(PrintArchive.created_at.desc()) + .limit(1) + ) + if archive is None or not archive.file_path: + return None + + path = settings.base_dir / archive.file_path + return path if path.is_file() and str(path).endswith(".3mf") else None + + @router.get("/{printer_id}/cover") async def get_printer_cover( printer_id: int, @@ -1175,7 +1211,16 @@ async def get_printer_cover( fut: asyncio.Future = asyncio.get_event_loop().create_future() _cover_inflight[inflight_key] = fut try: - image_data = await _produce_cover_image(printer, printer_id, subtask_name, view, view_key, plate_num, cache_key) + image_data = await _produce_cover_image( + printer, + printer_id, + subtask_name, + view, + view_key, + plate_num, + cache_key, + archive_path=await _running_print_archive_file(printer_id, state), + ) return Response(content=image_data, media_type="image/png") finally: if not fut.done(): @@ -1191,6 +1236,7 @@ async def _produce_cover_image( view_key: str, plate_num: int | None, cache_key: tuple[str, str], + archive_path: Path | None = None, ) -> bytes: """Download the active-print 3MF and extract its cover thumbnail (#2572). @@ -1198,7 +1244,9 @@ async def _produce_cover_image( can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes on success (also filling ``_cover_cache``) and raises ``HTTPException`` on failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB - work — the caller already released the pooled connection before this runs. + work — the caller already released the pooled connection before this runs, + which is also why ``archive_path`` arrives resolved rather than looked up + here. """ # Build possible 3MF filenames from subtask_name # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio) @@ -1260,6 +1308,20 @@ async def _produce_cover_image( using_cached = True break + if not downloaded: + # Same idea, one step further back: that in-memory cache dies with the + # process, but the archive of the print that is still running holds the + # very 3MF on disk. Without this, reopening a card or the skip-objects + # plate after a restart pulls the whole file back off a printer that is + # mid-print — measured at three concurrent fan-outs, thirteen seconds + # and a 0-byte read on the maintainer's H2C, which is exactly the + # single-socket contention #972 was about. + if archive_path is not None: + logger.info("Cover using the running print's archived 3MF at %s (no FTP)", archive_path) + temp_path = archive_path + downloaded = True + using_cached = True + if not downloaded: # The cover lives inside the 3MF, so it is only reachable if the 3MF is. # When the printer kept the print on internal storage there is nothing @@ -3663,6 +3725,47 @@ async def get_printable_objects( # Reload objects from 3MF if requested or no objects loaded if reload or not client.state.printable_objects: + # The archive of a running print normally holds the very file the + # printer is executing, so ask the disk before asking the printer: + # the fan-out below pulls the whole 3MF over FTPS from a machine that + # is mid-print — 15 MB on the print this was written for — and on a + # printer that kept the file on internal storage it cannot succeed at + # all. skipped_objects is deliberately left alone: a reload is + # not a new print, and the list of what the user already skipped only + # lives here. + from backend.app.models.archive import PrintArchive + from backend.app.services.archive import extract_printable_objects_from_archive + + subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip() + if subtask_id not in ("", "0"): + archive = await db.scalar( + select(PrintArchive) + .where( + PrintArchive.printer_id == printer_id, + PrintArchive.status == "printing", + PrintArchive.subtask_id == subtask_id, + ) + .order_by(PrintArchive.created_at.desc()) + .limit(1) + ) + if archive is not None: + objects, bbox_all = extract_printable_objects_from_archive( + settings.base_dir / archive.file_path, + plate_number=resolve_plate_id(client.state), + ) + if objects: + client.state.printable_objects = objects + client.state.printable_objects_bbox_all = bbox_all + logger.info( + "Reloaded %s objects for printer %s from archive %s", + len(objects), + printer_id, + archive.id, + ) + + # Only when the disk could not answer: a `reload=true` that the archive + # satisfied has already refreshed from the file the printer is running. + if not client.state.printable_objects: subtask_name = client.state.subtask_name if subtask_name: from backend.app.services.archive import extract_printable_objects_from_3mf diff --git a/backend/app/main.py b/backend/app/main.py index 0f552c88f..c1ee84899 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2860,34 +2860,73 @@ async def _dispatch_user_print_email( def _load_objects_from_archive(archive, printer_id: int, logger) -> None: """Extract printable objects from an archive's 3MF file and store in printer state.""" try: - from backend.app.services.archive import extract_printable_objects_from_3mf + from backend.app.services.archive import extract_printable_objects_from_archive client = printer_manager.get_client(printer_id) if not client: return - file_path = app_settings.base_dir / archive.file_path - if file_path.is_file() and str(file_path).endswith(".3mf"): - with open(file_path, "rb") as f: - threemf_data = f.read() - # Extract with positions for UI overlay, scoped to the plate that - # is printing — resolve_plate_id is the same resolver /cover uses, - # so the object list can't disagree with the thumbnail it is drawn - # over (#2522). - printable_objects, bbox_all = extract_printable_objects_from_3mf( - threemf_data, - plate_number=resolve_plate_id(client.state), - include_positions=True, - ) - if printable_objects: - client.state.printable_objects = printable_objects - client.state.printable_objects_bbox_all = bbox_all - client.state.skipped_objects = [] - logger.info("Loaded %s printable objects for printer %s", len(printable_objects), printer_id) + # Extract with positions for UI overlay, scoped to the plate that + # is printing — resolve_plate_id is the same resolver /cover uses, + # so the object list can't disagree with the thumbnail it is drawn + # over (#2522). + printable_objects, bbox_all = extract_printable_objects_from_archive( + app_settings.base_dir / archive.file_path, + plate_number=resolve_plate_id(client.state), + ) + if printable_objects: + client.state.printable_objects = printable_objects + client.state.printable_objects_bbox_all = bbox_all + client.state.skipped_objects = [] + logger.info("Loaded %s printable objects for printer %s", len(printable_objects), printer_id) except Exception as e: logger.debug("Failed to extract printable objects from archive: %s", e) +async def _restore_printable_objects(printer_id: int, state, db, logger) -> None: + """Put the skip-objects list back after a restart mid-print. + + ``PrinterState.printable_objects`` is in-memory only, and the only thing + that fills it is ``_load_objects_from_archive`` on the print-start paths — + which the #1304 guard suppresses on the first RUNNING push after startup. + Everything else this hook restores (the archive, the usage-tracking session, + the timelapse baseline) was already handled; the object list was not, so a + restart mid-print took skip-objects away for the rest of that print. + + Nothing recovered it either: the printer card gates its Skip button on the + object count, and the one endpoint that can rebuild the list is reachable + only from the modal that button opens. + + Anchored on ``subtask_id``, which the firmware mints per print, so a + leftover ``status="printing"`` row from a completion we never saw cannot + hand this print someone else's objects. Without one, nothing is loaded + rather than guessed — the reload path on ``GET /print/objects`` covers that + case on demand. + """ + client = printer_manager.get_client(printer_id) + if client is None or client.state.printable_objects: + return + + subtask_id = str(getattr(state, "subtask_id", "") or "").strip() + if subtask_id in ("", "0"): + return + + from backend.app.models.archive import PrintArchive + + archive = await db.scalar( + select(PrintArchive) + .where( + PrintArchive.printer_id == printer_id, + PrintArchive.status == "printing", + PrintArchive.subtask_id == subtask_id, + ) + .order_by(PrintArchive.created_at.desc()) + .limit(1) + ) + if archive is not None: + _load_objects_from_archive(archive, printer_id, logger) + + async def on_print_start(printer_id: int, data: dict): """Handle print start - archive the 3MF file immediately.""" logger = logging.getLogger(__name__) @@ -4798,6 +4837,7 @@ async def on_print_running_observed(printer_id: int, data: dict): logger.info("[RESTART] Restored active Bambuddy print for printer %s", printer_id) await _restore_usage_tracking_session(printer_id, state, db, logger) + await _restore_printable_objects(printer_id, state, db, logger) result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 4e8f3785d..7aad8b6da 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -603,6 +603,26 @@ class ThreeMFParser: break +def extract_printable_objects_from_archive( + file_path: Path, plate_number: int | None = None +) -> tuple[dict[int, dict], list | None]: + """Objects and plate bbox for an archived print, read off local disk. + + The archive of a running print usually holds the very 3MF the printer is + executing, so the object list can be rebuilt without asking the printer for + a file we already have -- 15 MB over FTPS from a machine that is mid-print, + in the case this was written for. Returns empty when the archive has + no readable 3MF, which is the caller's signal to fall back to the printer. + """ + if not file_path.is_file() or not str(file_path).endswith(".3mf"): + return {}, None + try: + data = file_path.read_bytes() + except OSError: + return {}, None + return extract_printable_objects_from_3mf(data, plate_number=plate_number, include_positions=True) + + def extract_printable_objects_from_3mf( data: bytes, plate_number: int | None = None, include_positions: bool = False ) -> dict[int, str] | dict[int, dict] | tuple[dict[int, dict], list | None]: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 144fd6a03..311a7ca51 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -167,6 +167,31 @@ def reset_auth_enabled_cache(): invalidate_auth_enabled_cache() +@pytest.fixture(autouse=True) +def disconnect_printers_registered_during_a_test(): + """Hand the ``printer_manager`` singleton back the way the test found it. + + ``POST /api/v1/printers`` really calls ``connect_printer``, so a test that + creates a printer through the API parks a live client in the singleton -- + and the singleton outlives the per-test in-memory database. The next test + on the same xdist worker gets a fresh database whose first printer is handed + the same primary key, and reads that leftover client as its own live status. + ``test_scheduled_drying_routes`` saw exactly that: an "online" printer with + no firmware version, so scheduling a dry came back 400 instead of 200. + + Only ids this test added are dropped, so a client registered by a wider + fixture stays registered. ``disconnect_printer`` is what clears the model + and printer-info caches too, and it stops the paho thread the leaked client + would otherwise keep retrying on for the rest of the run. + """ + from backend.app.services.printer_manager import printer_manager + + before = set(printer_manager._clients) + yield + for printer_id in set(printer_manager._clients) - before: + printer_manager.disconnect_printer(printer_id) + + @pytest.fixture(scope="session") def event_loop(): """Create an instance of the default event loop for each test session.""" diff --git a/backend/tests/integration/test_printers_api.py b/backend/tests/integration/test_printers_api.py index 81b1f616c..87e541160 100644 --- a/backend/tests/integration/test_printers_api.py +++ b/backend/tests/integration/test_printers_api.py @@ -4494,3 +4494,186 @@ class TestClearPlateOnAPoweredDownPrinter: body = response.json() assert body["connected"] is False assert body["awaiting_plate_clear"] is True + + +class TestPrintableObjectsReload: + """Rebuilding the object list must ask the disk before the printer. + + The archive of a running print normally holds the very 3MF the printer is + executing. Pulling it back over FTPS costs a full transfer from a machine + that is mid-print — 15 MB on the print this was written for — and on a + printer that kept the file on internal storage it cannot succeed at all. + """ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_the_archive_on_disk_is_used_before_any_ftp( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + printer = await printer_factory(name="H2C-1") + await archive_factory(printer.id, status="printing", subtask_id="330151809") + + client = MagicMock() + client.state = MagicMock( + printable_objects={}, + skipped_objects=[], + subtask_id="330151809", + subtask_name="HULA", + state="RUNNING", + gcode_file="/data/Metadata/plate_1.gcode", + printable_objects_bbox_all=None, + ) + ftp = AsyncMock(return_value=None) + + with ( + patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client), + patch( + "backend.app.services.archive.extract_printable_objects_from_archive", + return_value=({101: {"name": "left"}, 102: {"name": "right"}}, None), + ) as from_disk, + patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp), + ): + response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects") + + assert response.status_code == 200, response.text + assert response.json()["total"] == 2 + from_disk.assert_called_once() + ftp.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_reload_that_the_disk_cannot_answer_still_asks_the_printer( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """The fan-out is the fallback, not dead code: a print whose archive has + no 3MF (or none at all) still has the printer to ask.""" + printer = await printer_factory(name="H2C-1") + await archive_factory(printer.id, status="printing", subtask_id="330151809") + + client = MagicMock() + client.state = MagicMock( + printable_objects={}, + skipped_objects=[], + subtask_id="330151809", + subtask_name="HULA", + state="RUNNING", + gcode_file="/data/Metadata/plate_1.gcode", + printable_objects_bbox_all=None, + ) + ftp = AsyncMock(return_value=None) + + with ( + patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client), + patch( + "backend.app.services.archive.extract_printable_objects_from_archive", + return_value=({}, None), + ), + patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp), + ): + response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects") + + assert response.status_code == 200, response.text + ftp.assert_awaited() + + +class TestCoverUsesTheRunningPrintsArchive: + """The cover must not re-fetch a 3MF Bambuddy already has on disk. + + The in-memory download cache dies with the process, so after a restart + mid-print every cover, top view and skip-objects plate mask went back to the + printer for the whole file — measured on the maintainer's H2C as three + concurrent fan-outs, thirteen seconds and a 0-byte read, on a machine that + was busy printing. The archive of the running print holds that exact file. + """ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_the_archived_3mf_is_preferred_over_ftp( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session, tmp_path + ): + import zipfile + + from backend.app.core.config import settings as app_settings + + printer = await printer_factory(name="H2C-1") + # A 3MF that is a real zip but carries no cover, so the endpoint reaches + # its "no cover in this file" answer without any FTP work. + threemf = tmp_path / "job.3mf" + with zipfile.ZipFile(threemf, "w") as zf: + zf.writestr("Metadata/slice_info.config", "") + await archive_factory( + printer.id, + status="printing", + subtask_id="330151809", + file_path=str(threemf.relative_to(tmp_path)), + ) + + state = MagicMock( + subtask_name="job", + subtask_id="330151809", + state="RUNNING", + gcode_file="/data/Metadata/plate_1.gcode", + current_project_url="ftp://job.3mf", + sdcard=True, + sdcard_reported=True, + ) + ftp = AsyncMock(return_value=None) + + with ( + patch.object(app_settings, "base_dir", tmp_path), + patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state), + patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1), + patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None), + patch("backend.app.api.routes.printers.download_file_try_paths_async", new=ftp), + ): + response = await async_client.get(f"/api/v1/printers/{printer.id}/cover") + + # No cover inside this 3MF, so a 404 — but reached from the file on disk. + assert response.status_code == 404, response.text + ftp.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_the_archived_file_is_never_deleted_by_the_cover_flow( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session, tmp_path + ): + """The cover flow unlinks the file it downloaded. Handed the archive's + own copy, that would delete the print's 3MF.""" + import zipfile + + from backend.app.core.config import settings as app_settings + + printer = await printer_factory(name="H2C-1") + threemf = tmp_path / "job.3mf" + with zipfile.ZipFile(threemf, "w") as zf: + zf.writestr("Metadata/slice_info.config", "") + await archive_factory( + printer.id, + status="printing", + subtask_id="330151809", + file_path=str(threemf.relative_to(tmp_path)), + ) + + state = MagicMock( + subtask_name="job", + subtask_id="330151809", + state="RUNNING", + gcode_file="/data/Metadata/plate_1.gcode", + current_project_url="ftp://job.3mf", + sdcard=True, + sdcard_reported=True, + ) + + with ( + patch.object(app_settings, "base_dir", tmp_path), + patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state), + patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1), + patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None), + patch( + "backend.app.api.routes.printers.download_file_try_paths_async", + new=AsyncMock(return_value=None), + ), + ): + await async_client.get(f"/api/v1/printers/{printer.id}/cover") + + assert threemf.is_file(), "the cover flow deleted the running print's archived 3MF" diff --git a/backend/tests/unit/test_cover_coalescing_2572.py b/backend/tests/unit/test_cover_coalescing_2572.py index d53ad45d0..d08ab1435 100644 --- a/backend/tests/unit/test_cover_coalescing_2572.py +++ b/backend/tests/unit/test_cover_coalescing_2572.py @@ -50,7 +50,9 @@ async def test_concurrent_cover_requests_download_once(): produce_calls = {"n": 0} - async def slow_produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key): + async def slow_produce( + printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key, archive_path=None + ): produce_calls["n"] += 1 await asyncio.sleep(0.1) # hold leadership long enough for followers to attach printers_mod._cover_cache.setdefault(printer_id, {})[cache_key] = b"PNGDATA" @@ -76,7 +78,7 @@ async def test_second_request_serves_from_positive_cache(): produce_calls = {"n": 0} - async def produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key): + async def produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key, archive_path=None): produce_calls["n"] += 1 printers_mod._cover_cache.setdefault(printer_id, {})[cache_key] = b"PNGDATA" return b"PNGDATA" diff --git a/backend/tests/unit/test_skip_objects_survive_restart.py b/backend/tests/unit/test_skip_objects_survive_restart.py new file mode 100644 index 000000000..582decf93 --- /dev/null +++ b/backend/tests/unit/test_skip_objects_survive_restart.py @@ -0,0 +1,166 @@ +"""The skip-objects list has to survive a restart mid-print. + +``PrinterState.printable_objects`` is in-memory and is filled by the print-start +path, which the #1304 guard suppresses on the first RUNNING push after Bambuddy +comes back up. So a restart during a print left the list empty for the rest of +that print: the printer card gates its Skip button on the object count, and the +one endpoint that can rebuild the list is reachable only from the modal that +button opens. Measured on the maintainer's H2C — 8 objects loaded at 09:02, +restart at 09:17, Skip dead for the remaining hour. + +The recovery hook now reloads the objects from the archive of the print that is +still running, anchored on the subtask_id the firmware mints per print. +""" + +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _client(objects=None): + client = MagicMock() + client.state = MagicMock(printable_objects=objects if objects is not None else {}) + return client + + +def _db(archive): + db = AsyncMock() + db.scalar = AsyncMock(return_value=archive) + return db + + +class TestRestartRecovery: + @pytest.mark.asyncio + async def test_objects_are_reloaded_from_the_running_print_s_archive(self): + from backend.app.main import _restore_printable_objects + + archive = MagicMock(id=360, file_path="archive/7/job/job.3mf") + state = MagicMock(subtask_id="330151809") + + with ( + patch("backend.app.main.printer_manager") as pm, + patch("backend.app.main._load_objects_from_archive") as load, + ): + pm.get_client.return_value = _client() + await _restore_printable_objects(7, state, _db(archive), logging.getLogger(__name__)) + + load.assert_called_once() + assert load.call_args.args[0] is archive + assert load.call_args.args[1] == 7 + + @pytest.mark.asyncio + async def test_a_list_that_is_already_loaded_is_left_alone(self): + """The hook also fires on a reconnect during a print Bambuddy saw start. + Reloading there would discard what the user has already skipped, which + only lives alongside the object list.""" + from backend.app.main import _restore_printable_objects + + state = MagicMock(subtask_id="330151809") + + with ( + patch("backend.app.main.printer_manager") as pm, + patch("backend.app.main._load_objects_from_archive") as load, + ): + pm.get_client.return_value = _client({1: "cube"}) + await _restore_printable_objects(7, state, _db(MagicMock()), logging.getLogger(__name__)) + + load.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize("subtask_id", ["", "0", None]) + async def test_nothing_is_loaded_without_a_subtask_to_anchor_on(self, subtask_id): + """A leftover status="printing" row from a completion we never saw must + not hand this print someone else's objects. The endpoint's reload covers + the case on demand instead.""" + from backend.app.main import _restore_printable_objects + + state = MagicMock(subtask_id=subtask_id) + db = _db(MagicMock()) + + with ( + patch("backend.app.main.printer_manager") as pm, + patch("backend.app.main._load_objects_from_archive") as load, + ): + pm.get_client.return_value = _client() + await _restore_printable_objects(7, state, db, logging.getLogger(__name__)) + + load.assert_not_called() + db.scalar.assert_not_awaited() + + @pytest.mark.asyncio + async def test_no_matching_archive_is_not_an_error(self): + from backend.app.main import _restore_printable_objects + + state = MagicMock(subtask_id="330151809") + + with ( + patch("backend.app.main.printer_manager") as pm, + patch("backend.app.main._load_objects_from_archive") as load, + ): + pm.get_client.return_value = _client() + await _restore_printable_objects(7, state, _db(None), logging.getLogger(__name__)) + + load.assert_not_called() + + @pytest.mark.asyncio + async def test_the_recovery_hook_calls_it(self): + """Wiring guard: the restore is only useful if on_print_running_observed + runs it alongside the archive and usage-tracking restores.""" + import backend.app.main as main + + state = MagicMock(subtask_id="330151809", subtask_name="job") + session = AsyncMock() + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock() + session.execute = AsyncMock( + return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)), + ) + + with ( + patch("backend.app.main.async_session", return_value=session), + patch("backend.app.main.printer_manager") as pm, + patch("backend.app.main._is_bambuddy_authorized_print", new=AsyncMock(return_value=True)), + patch("backend.app.main._restore_usage_tracking_session", new=AsyncMock()), + patch("backend.app.main._restore_printable_objects", new=AsyncMock()) as restore, + ): + pm.get_status.return_value = state + await main.on_print_running_observed(7, {}) + + restore.assert_awaited_once() + assert restore.await_args.args[0] == 7 + + +class TestExtractFromArchiveFile: + def test_a_missing_file_is_empty_rather_than_an_error(self, tmp_path): + from backend.app.services.archive import extract_printable_objects_from_archive + + assert extract_printable_objects_from_archive(tmp_path / "gone.3mf") == ({}, None) + + def test_a_fallback_archive_with_no_3mf_is_empty(self, tmp_path): + """An archive created without its 3MF has an empty file_path, which + resolves to the data directory itself.""" + from backend.app.services.archive import extract_printable_objects_from_archive + + assert extract_printable_objects_from_archive(tmp_path) == ({}, None) + + def test_objects_come_back_with_their_positions(self, tmp_path): + import zipfile + + from backend.app.services.archive import extract_printable_objects_from_archive + + slice_info = """ + + + + + + + """ + path = tmp_path / "job.3mf" + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("Metadata/slice_info.config", slice_info) + + objects, _bbox = extract_printable_objects_from_archive(path, plate_number=1) + + assert sorted(objects) == [101, 102] diff --git a/frontend/src/__tests__/pages/SkipObjectsButtonGate.test.tsx b/frontend/src/__tests__/pages/SkipObjectsButtonGate.test.tsx new file mode 100644 index 000000000..491c60137 --- /dev/null +++ b/frontend/src/__tests__/pages/SkipObjectsButtonGate.test.tsx @@ -0,0 +1,106 @@ +/** + * The Skip button must stay reachable when the object list is merely unknown. + * + * `printable_objects_count` is derived from an in-memory list that a Bambuddy + * restart wipes, so mid-print it can drop to 0 while the print carries on. The + * card read 0 as "nothing to skip" and disabled the button — and the button is + * what opens the modal whose fetch rebuilds the list, so the print never got it + * back. A running print always has at least one object, so 0 means unknown; + * exactly 1 is the real nothing-to-skip case. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { server } from '../mocks/server'; +import { PrintersPage } from '../../pages/PrintersPage'; + +const printer = { + id: 1, + name: 'H2C-1', + ip_address: '192.168.1.100', + serial_number: '00M09A350100001', + access_code: '12345678', + model: 'H2C', + enabled: true, + is_active: true, + nozzle_diameter: 0.4, + location: 'Workshop', + auto_archive: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +const printing = { + connected: true, + state: 'RUNNING', + // The card treats a print as active only when it can name it, so the fixture + // has to carry one for the Skip button to be in its printing state at all. + current_print: 'HULA_H2D_air_pad.gcode.3mf', + subtask_name: 'HULA_H2D_air_pad', + gcode_file: '/data/Metadata/plate_1.gcode', + progress: 42, + layer_num: 30, + total_layers: 184, + awaiting_plate_clear: false, + temperatures: { nozzle: 220, bed: 60, chamber: 30 }, + remaining_time: 3600, + wifi_signal: -50, + vt_tray: [], +}; + +function serveStatus(status: Record) { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([printer])), + http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)), + http.get('/api/v1/printers/:id/print/objects', () => + HttpResponse.json({ objects: [], total: 0, skipped_count: 0, is_printing: true, bbox_all: null }), + ), + ); +} + +// The whole live-status block waits on the status query, so find rather than get. +const skipButton = () => screen.findByRole('button', { name: /skip objects/i }); + +describe('skip objects button gate', () => { + beforeEach(() => { + localStorage.removeItem('printerCardSize'); + }); + + it('stays enabled while printing when the object list is not loaded', async () => { + serveStatus({ ...printing, printable_objects_count: 0 }); + + render(); + + expect(await skipButton()).toBeEnabled(); + }); + + it('is disabled for a single-object print, where there is nothing to skip', async () => { + serveStatus({ ...printing, printable_objects_count: 1 }); + + render(); + + const button = await skipButton(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'Skip objects (requires 2+ objects)'); + }); + + it('is enabled for the ordinary multi-object print', async () => { + serveStatus({ ...printing, printable_objects_count: 8 }); + + render(); + + expect(await skipButton()).toBeEnabled(); + }); + + it('is disabled when the printer is not printing', async () => { + serveStatus({ ...printing, state: 'IDLE', printable_objects_count: 0 }); + + render(); + + const button = await skipButton(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'Skip objects (only while printing)'); + }); +}); diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 6b846383a..da5b962ca 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -4258,13 +4258,23 @@ function PrinterCard({ const coverUrl = isActivePrint ? status.cover_url : showRetainedPrint ? retainedPrintJob.coverUrl : null; const progress = isActivePrint ? (status.progress || 0) : showRetainedPrint ? 100 : 0; + // A running print always has at least one object, so a count of + // zero means "not loaded", not "nothing to skip" — after a + // restart mid-print the list is empty until something rebuilds + // it. Treating that as nothing-to-skip disabled the only + // control that opens the modal, and the modal's own fetch is + // what rebuilds the list, so the print could never get it back. + // Exactly one object is the real nothing-to-skip case. + const objectCount = status.printable_objects_count ?? 0; + const canSkipObjects = isActivePrint && objectCount !== 1 && hasPermission('printers:control'); + return (