فهرست منبع

Keep other plates' timelapses on a shared archive and keep a long print's plate until it completes (#3275)

maziggy 1 هفته پیش
والد
کامیت
219d8a4a8c

+ 2 - 0
CHANGELOG.md

@@ -161,6 +161,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Starting the next plate of a Send All deleted the previous plate's timelapse (#3275, reported by @dovmesiz)** — All plates sent together with **Send All** share one archive. When a print starts on an archive that already has a timelapse, Bambuddy clears it so the new run gets its own video and completion photo. It also deleted the old video file, so starting plate 2 deleted plate 1's. With **delete from printer after archiving** on, that was the only copy. The video is now deleted only when the same plate prints again. Another plate's video, or one attached by hand or before this release, stays in the archive folder and is removed with the archive. The card still shows the latest plate's video. An external-camera timelapse no longer overwrites a kept video of the same name.
+  - **A queued print still running 2 hours after it was sent lost what Bambuddy stored for it at dispatch:** its plate, AMS mapping and cost centre, which the completion uses (for example for the per-plate totals in the notification). A clean-up of unused print registrations removed them mid-print. They are now kept until the print completes.
 - **A PP spool without a slicer preset was sent to the AMS slot with no filament ID (#3273, reported by @sgiffhorn)** — When a spool has no preset of its own, Bambuddy fills in Bambu's generic preset for its material. The table used for spool assignment had no entry for PP, PE, PCTG, PLA Silk or PLA High Speed, so those slots got an empty filament ID, while **Configure AMS Slot** used a separate, longer table. The backend now has one table with all five, matching the one the frontend already used. A slot already set to Generic PLA Silk or PLA High Speed still keeps it when a PLA spool without a preset is assigned, as before.
   - **PVA, HIPS and Bambu support filaments got the wrong setting ID:** their filament IDs start with `GFS` (Generic PVA is `GFS99`), which Bambuddy took for a setting ID. It sent `GFS99` instead of `GFSS99` and read it back as `GF99`.
 - **Every start filled the PostgreSQL log with about 320 errors** — On PostgreSQL, the startup migrations re-ran every column, index and constraint they had already added, and the database logged each of those as an `ERROR` with its statement, about 640 lines per start, before Bambuddy quietly skipped it. Nothing was wrong, but the noise buried real errors and alarmed anyone reading the log, on the appliance as much as on Docker with PostgreSQL. On PostgreSQL these statements now ask the database to skip what is already there (`IF NOT EXISTS`, or a check of the catalog for constraints and renames), so a normal start logs no errors. Columns, renames and constraints that are actually missing are still added, and SQLite is unchanged. PostgreSQL 13 or newer is required, and the wiki now says so: Bambuddy has never started on PostgreSQL 12 or older, whose startup migrations fail on a column type those versions reject.

+ 1 - 0
backend/app/api/routes/archives.py

@@ -2823,6 +2823,7 @@ async def delete_timelapse(
 
     # Clear the path in database
     archive.timelapse_path = None
+    archive.timelapse_plate_id = None
     await db.commit()
 
     return {"status": "deleted"}

+ 5 - 0
backend/app/core/database.py

@@ -5406,6 +5406,11 @@ async def _run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_dried_temp INTEGER")
     await _safe_execute(conn, f"ALTER TABLE spool ADD COLUMN last_dried_hours {float_type}")
 
+    # Migration: which plate's run the attached timelapse came from, so starting
+    # another plate of a shared Send All archive keeps it (#3275). Nullable, no
+    # default -- identical DDL on SQLite and Postgres.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_plate_id INTEGER")
+
 
 async def _backfill_snapshot_prices(conn) -> None:
     """Give the energy snapshots taken before #1251 the price set at upgrade.

+ 38 - 6
backend/app/main.py

@@ -4444,10 +4444,32 @@ async def on_print_start(printer_id: int, data: dict):
                 # it describes the printer before the previous run. The capture
                 # below overwrites it, but clear it here too so an early failure
                 # can't leave the scan diffing against the wrong snapshot.
+                #
+                # Unlinked only when it is the same plate printing again. Every
+                # plate of a Send All shares this archive, and the video on it
+                # can be the only copy of another plate's run -- the printer's
+                # was deleted once it attached (#3275). That one is left in the
+                # archive directory, which goes with the archive. A video whose
+                # plate is not on record (attached by hand, or before the
+                # column existed) is kept too: nothing tells it apart from
+                # another plate's, and keeping costs one file where deleting
+                # can cost the only copy.
                 archive.timelapse_baseline = None
                 stale_timelapse_relpath = archive.timelapse_path
+                stale_timelapse_plate_id = archive.timelapse_plate_id
+                starting_plate_id = _print_plate_ids.get(expected_archive_id)
                 if stale_timelapse_relpath:
                     archive.timelapse_path = None
+                    archive.timelapse_plate_id = None
+                if stale_timelapse_relpath and stale_timelapse_plate_id != starting_plate_id:
+                    logger.info(
+                        "Kept timelapse %s of plate %s on archive %s: plate %s is starting",
+                        stale_timelapse_relpath,
+                        stale_timelapse_plate_id,
+                        expected_archive_id,
+                        starting_plate_id,
+                    )
+                elif stale_timelapse_relpath:
                     try:
                         stale_path = app_settings.base_dir / stale_timelapse_relpath
                         if stale_path.is_file():
@@ -5726,7 +5748,9 @@ async def _capture_timelapse_baseline_at_start(
         logger.warning("[TIMELAPSE] Failed to persist baseline for archive %s: %s", archive_id, e)
 
 
-async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):
+async def _scan_for_timelapse_with_retries(
+    archive_id: int, baseline_names: set[str] | None = None, *, plate_id: int | None = None
+):
     """Poll the printer for this print's timelapse and attach it.
 
     Snapshot diff, not timestamp matching: a printer in LAN-only mode cannot
@@ -5884,6 +5908,7 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                     logger,
                     quiet=not changed,
                     require_unambiguous=not baseline_trusted,
+                    plate_id=plate_id,
                 )
                 if attached:
                     return
@@ -5918,6 +5943,7 @@ async def _attach_first_unclaimed_timelapse(
     *,
     quiet: bool = False,
     require_unambiguous: bool = False,
+    plate_id: int | None = None,
 ) -> bool:
     """Download and attach the one video that belongs to this print.
 
@@ -6022,7 +6048,7 @@ async def _attach_first_unclaimed_timelapse(
 
     # Write phase: attach in a fresh short-lived session.
     async with async_session() as db:
-        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, file_name)
+        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, file_name, plate_id=plate_id)
     if not success:
         logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
         return False
@@ -8774,7 +8800,9 @@ async def on_print_complete(printer_id: int, data: dict):
                     async with async_session() as db:
                         service = ArchiveService(db)
                         timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes)
-                        await service.attach_timelapse(archive_id, timelapse_data, "layer_timelapse.mp4")
+                        await service.attach_timelapse(
+                            archive_id, timelapse_data, "layer_timelapse.mp4", plate_id=notify_plate_id
+                        )
                         # Clean up the temp file
                         await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
                         logger.info("[LAYER-TL] Layer timelapse attached successfully")
@@ -8806,7 +8834,7 @@ async def on_print_complete(printer_id: int, data: dict):
         # The printer needs time to encode the video after print completion
         baseline = _timelapse_baselines.pop(printer_id, None)
         spawn_background_task(
-            _scan_for_timelapse_with_retries(archive_id, baseline),
+            _scan_for_timelapse_with_retries(archive_id, baseline, plate_id=notify_plate_id),
             name=f"scan-timelapse-{archive_id}",
         )
         log_timing("Timelapse scan scheduled")
@@ -9756,8 +9784,12 @@ def _evict_stale_expected_prints() -> None:
 
     # Also clean up _print_ams_mappings and _print_plate_ids for archive_ids
     # that have no remaining live keys in _expected_prints (all variants
-    # were just evicted).
-    live_archive_ids = set(_expected_prints.values())
+    # were just evicted) and are not printing right now. Print start pops
+    # only the names the printer reported, so the "<base>.gcode" variant
+    # outlives it; without the printing check its eviction two hours after
+    # dispatch threw away a running print's plate, AMS mapping and cost
+    # centre, which completion still reads (#3275).
+    live_archive_ids = set(_expected_prints.values()) | set(_active_prints.values())
     for archive_id in evicted_archive_ids:
         if archive_id not in live_archive_ids:
             _print_ams_mappings.pop(archive_id, None)

+ 6 - 0
backend/app/models/archive.py

@@ -29,6 +29,12 @@ class PrintArchive(Base):
     content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     timelapse_path: Mapped[str | None] = mapped_column(String(500))
+    # The plate whose run produced ``timelapse_path`` (#3275). Every plate of a
+    # Send All shares this archive, so a print start may delete the previous
+    # video only when it is the same plate printing again; another plate's
+    # video is the only copy of that plate's run. NULL when unknown -- a
+    # manually attached video, or one attached before this column existed.
+    timelapse_plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
     # True when Bambuddy forced timelapse recording on for this print so the
     # finish-photo extractor (#1397) could pull the post-park-pre-drop frame.
     # The cleanup path uses this to know the timelapse should be deleted

+ 41 - 0
backend/app/services/archive.py

@@ -1835,11 +1835,17 @@ class ArchiveService:
         archive_id: int,
         timelapse_data: bytes,
         filename: str = "timelapse.mp4",
+        *,
+        plate_id: int | None = None,
     ) -> bool:
         """Attach a timelapse video to an archive.
 
         Non-MP4 videos (e.g. AVI from P1S) are saved as-is and a background
         task converts them to MP4 for browser compatibility.
+
+        ``plate_id`` is the plate whose run produced the video, when the caller
+        knows it; print start uses it to tell a reprint from the next plate of
+        a shared archive (#3275).
         """
         import asyncio
 
@@ -1881,10 +1887,20 @@ class ArchiveService:
         # leaves nothing behind. A no-3MF archive has never had a directory of
         # its own, and the timelapse can be the first thing to want one.
         await asyncio.to_thread(lambda: timelapse_file.parent.mkdir(parents=True, exist_ok=True))
+
+        # A file of this name that the archive no longer points at is an earlier
+        # run's video, kept because another plate of a shared archive started
+        # (#3275). Writing over it would destroy the copy that was kept: the
+        # external-camera stitch always names its output layer_timelapse.mp4.
+        # Re-attaching the file the archive does point at still replaces it.
+        timelapse_file = await asyncio.to_thread(
+            _free_timelapse_name, timelapse_file, archive.timelapse_path, settings.base_dir
+        )
         await asyncio.to_thread(timelapse_file.write_bytes, timelapse_data)
 
         # Update archive record
         archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
+        archive.timelapse_plate_id = plate_id
         await self.db.commit()
 
         # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion
@@ -1897,6 +1913,31 @@ class ArchiveService:
         return True
 
 
+def _free_timelapse_name(target: Path, current_relpath: str | None, base_dir: Path) -> Path:
+    """``target``, or ``<stem>_<n><suffix>`` beside it when ``target`` would
+    replace a video the archive does not point at (see ``attach_timelapse``).
+
+    A non-MP4 is converted to ``<stem>.mp4`` afterwards, so that name counts as
+    taken too.
+    """
+
+    def outputs(path: Path) -> set[Path]:
+        return {path.resolve(), path.with_suffix(".mp4").resolve()}
+
+    # Only compared against ``target``, which attach_timelapse has already
+    # safe-joined; nothing is read or written through this path.
+    current = (base_dir / current_relpath).resolve() if current_relpath else None  # SEC-PATH-OK: comparison only
+    taken = {p for p in outputs(target) if p.exists()}
+    if not taken or current in taken:
+        return target
+    n = 2
+    while True:
+        candidate = target.with_name(f"{target.stem}_{n}{target.suffix}")
+        if not any(p.exists() for p in outputs(candidate)):
+            return candidate
+        n += 1
+
+
 async def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:
     """Background task: convert non-MP4 timelapse (e.g. AVI from P1S) to MP4.
 

+ 6 - 1
backend/tests/unit/test_reprint_clears_stale_timelapse.py

@@ -24,6 +24,7 @@ from backend.app.main import (
     _expected_print_registered_at,
     _expected_prints,
     _print_ams_mappings,
+    _print_plate_ids,
     _timelapse_baselines,
     register_expected_print,
 )
@@ -35,6 +36,7 @@ def _clear_dicts():
     _expected_print_registered_at.clear()
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
+    _print_plate_ids.clear()
     _active_prints.clear()
     _timelapse_baselines.clear()
     yield
@@ -42,6 +44,7 @@ def _clear_dicts():
     _expected_print_registered_at.clear()
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
+    _print_plate_ids.clear()
     _active_prints.clear()
     _timelapse_baselines.clear()
 
@@ -124,8 +127,10 @@ async def test_reprint_clears_timelapse_path_and_unlinks_stale_file(tmp_path):
     mock_archive.file_path = "archives/42/MyModel.3mf"
     mock_archive.energy_start_kwh = None
     mock_archive.timelapse_path = relpath  # stale from the original run
+    mock_archive.timelapse_plate_id = 1  # ...which printed plate 1
 
-    register_expected_print(1, "MyModel.3mf", archive_id=42, ams_mapping=None)
+    # The queue always dispatches a plate; the same one is printing again.
+    register_expected_print(1, "MyModel.3mf", archive_id=42, ams_mapping=None, plate_id=1)
 
     mock_session = _build_mocks(mock_printer, mock_archive)
 

+ 242 - 0
backend/tests/unit/test_send_all_timelapse_per_plate_3275.py

@@ -0,0 +1,242 @@
+"""#3275: starting plate 2 of a Send All deleted plate 1's only timelapse.
+
+Every plate of a Send All shares one archive. Print start cleared the archive's
+timelapse and unlinked the file so a reprint would not reuse the previous run's
+video (#1707) -- but for the next plate that file is the only copy of another
+plate's run, the printer's having been deleted once it attached. Print start
+now unlinks only when the same plate prints again, and attaching never writes
+over a video the archive no longer points at.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+from backend.app.main import register_expected_print
+from backend.app.services.archive import ArchiveService
+from backend.tests.unit.test_reprint_clears_stale_timelapse import _build_mocks, _clear_dicts, _patches  # noqa: F401
+
+RELPATH = "archives/42/video_plate1.mp4"
+
+
+def _archive(timelapse_plate_id):
+    archive = MagicMock()
+    archive.id = 42
+    archive.filename = "Plates.3mf"
+    archive.subtask_id = None
+    archive.print_time_seconds = None
+    archive.created_by_id = None
+    archive.printer_id = 1
+    archive.print_name = "Plates"
+    archive.status = "completed"
+    archive.file_path = "archives/42/Plates.3mf"
+    archive.energy_start_kwh = None
+    archive.timelapse_path = RELPATH
+    archive.timelapse_plate_id = timelapse_plate_id
+    return archive
+
+
+async def _start(tmp_path: Path, archive, plate_id: int) -> Path:
+    """Start ``plate_id`` of the shared archive; returns the old video's path."""
+    video = tmp_path / RELPATH
+    video.parent.mkdir(parents=True, exist_ok=True)
+    video.write_bytes(b"plate 1 video")
+
+    printer = MagicMock(id=1, auto_archive=True, external_camera_enabled=False, external_camera_url=None)
+    register_expected_print(1, "Plates.3mf", archive_id=42, ams_mapping=None, plate_id=plate_id)
+    session = _build_mocks(printer, archive)
+    (session_p, notif_p, plug_p, ws_p, pm_p, relay_p, *rest) = _patches()
+    with (
+        session_p as session_maker,
+        notif_p as notif,
+        plug_p as plug,
+        ws_p as ws,
+        pm_p as pm,
+        relay_p as relay,
+        rest[0],
+        rest[1],
+        rest[2],
+        rest[3],
+        rest[4],
+        patch.object(app_settings, "base_dir", tmp_path),
+    ):
+        session_maker.return_value = session
+        notif.on_print_start = AsyncMock()
+        plug.on_print_start = AsyncMock()
+        ws.send_print_start = AsyncMock()
+        ws.send_archive_updated = AsyncMock()
+        relay.on_print_start = AsyncMock()
+        pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(1, {"filename": "Plates.3mf", "subtask_name": "Plates"})
+    return video
+
+
+class TestPrintStart:
+    @pytest.mark.asyncio
+    async def test_the_next_plate_keeps_the_previous_plates_video(self, tmp_path):
+        archive = _archive(timelapse_plate_id=1)
+        video = await _start(tmp_path, archive, plate_id=2)
+
+        assert video.read_bytes() == b"plate 1 video"
+        # The card still starts fresh, so plate 2's own video gets attached.
+        assert archive.timelapse_path is None
+        assert archive.timelapse_plate_id is None
+
+    @pytest.mark.asyncio
+    async def test_the_same_plate_again_still_replaces_it(self, tmp_path):
+        archive = _archive(timelapse_plate_id=2)
+        video = await _start(tmp_path, archive, plate_id=2)
+
+        assert not video.exists()
+        assert archive.timelapse_path is None
+
+    @pytest.mark.asyncio
+    async def test_a_video_of_unknown_plate_is_kept(self, tmp_path):
+        """Attached by hand or before the plate was recorded: it cannot be told
+        apart from another plate's, so it is not deleted."""
+        archive = _archive(timelapse_plate_id=None)
+        video = await _start(tmp_path, archive, plate_id=1)
+
+        assert video.exists()
+        assert archive.timelapse_path is None
+
+
+def _service(tmp_path: Path, monkeypatch, current: str | None):
+    archive_dir = tmp_path / "archive" / "1" / "20260101_plates"
+    archive_dir.mkdir(parents=True)
+    fake_settings = MagicMock(base_dir=tmp_path, archive_dir=tmp_path / "archive")
+    monkeypatch.setattr("backend.app.services.archive.settings", fake_settings)
+    monkeypatch.setattr("backend.app.utils.archive_paths.settings", fake_settings)
+    # The .avi case hands over the MP4 conversion; close it unrun.
+    monkeypatch.setattr("backend.app.services.archive.spawn_background_task", lambda coro, **_: coro.close())
+
+    db = MagicMock()
+    db.commit = AsyncMock()
+    service = ArchiveService(db)
+    archive = MagicMock()
+    archive.file_path = "archive/1/20260101_plates/Plates.3mf"
+    archive.timelapse_path = current
+    archive.timelapse_plate_id = None
+    service.get_archive = AsyncMock(return_value=archive)
+    return service, archive, archive_dir
+
+
+class TestAttach:
+    @pytest.mark.asyncio
+    async def test_records_the_plate(self, tmp_path, monkeypatch):
+        service, archive, _ = _service(tmp_path, monkeypatch, current=None)
+
+        assert await service.attach_timelapse(1, b"v", "video_a.mp4", plate_id=2)
+        assert archive.timelapse_plate_id == 2
+
+    @pytest.mark.asyncio
+    async def test_a_manual_attach_records_no_plate(self, tmp_path, monkeypatch):
+        service, archive, _ = _service(tmp_path, monkeypatch, current=None)
+        archive.timelapse_plate_id = 1
+
+        assert await service.attach_timelapse(1, b"v", "video_a.mp4")
+        assert archive.timelapse_plate_id is None
+
+    @pytest.mark.asyncio
+    async def test_does_not_write_over_a_kept_video(self, tmp_path, monkeypatch):
+        """The external-camera stitch always names its output
+        layer_timelapse.mp4, so plate 2's would land on plate 1's."""
+        service, archive, archive_dir = _service(tmp_path, monkeypatch, current=None)
+        kept = archive_dir / "layer_timelapse.mp4"
+        kept.write_bytes(b"plate 1")
+
+        assert await service.attach_timelapse(1, b"plate 2", "layer_timelapse.mp4", plate_id=2)
+
+        assert kept.read_bytes() == b"plate 1"
+        assert (archive_dir / "layer_timelapse_2.mp4").read_bytes() == b"plate 2"
+        assert archive.timelapse_path == "archive/1/20260101_plates/layer_timelapse_2.mp4"
+
+    @pytest.mark.asyncio
+    async def test_an_avi_does_not_convert_over_a_kept_mp4(self, tmp_path, monkeypatch):
+        service, archive, archive_dir = _service(tmp_path, monkeypatch, current=None)
+        (archive_dir / "video_a.mp4").write_bytes(b"plate 1")
+
+        assert await service.attach_timelapse(1, b"plate 2", "video_a.avi", plate_id=2)
+
+        assert archive.timelapse_path == "archive/1/20260101_plates/video_a_2.avi"
+
+    @pytest.mark.asyncio
+    async def test_re_attaching_the_current_video_still_replaces_it(self, tmp_path, monkeypatch):
+        service, archive, archive_dir = _service(
+            tmp_path, monkeypatch, current="archive/1/20260101_plates/layer_timelapse.mp4"
+        )
+        (archive_dir / "layer_timelapse.mp4").write_bytes(b"old")
+
+        assert await service.attach_timelapse(1, b"new", "layer_timelapse.mp4")
+
+        assert (archive_dir / "layer_timelapse.mp4").read_bytes() == b"new"
+        assert not (archive_dir / "layer_timelapse_2.mp4").exists()
+
+
+class TestScan:
+    @pytest.mark.asyncio
+    async def test_the_completed_runs_plate_reaches_the_attach(self):
+        from backend.app.main import _attach_first_unclaimed_timelapse
+        from backend.tests.unit.test_timelapse_scan_2704 import _printer, _session, _video, logger
+
+        service = MagicMock()
+        service.attach_timelapse = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=b"x" * 1000)),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock(return_value=True)),
+            patch("backend.app.main.async_session", return_value=_session()),
+            patch("backend.app.main.ArchiveService", return_value=service),
+            patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
+        ):
+            assert await _attach_first_unclaimed_timelapse(
+                42, _printer(), [_video("video_b.avi")], set(), set(), 1, logger, plate_id=2
+            )
+
+        assert service.attach_timelapse.await_args.kwargs["plate_id"] == 2
+
+
+class TestEviction:
+    """Print start leaves the "<base>.gcode" registration behind. Its eviction
+    two hours after dispatch used to drop a running print's plate, so a long
+    print's video was attached with no plate on record."""
+
+    def _register_and_start(self):
+        import time
+
+        import backend.app.main as m
+
+        register_expected_print(1, "Plates.3mf", archive_id=42, ams_mapping=[0], plate_id=2)
+        # What print start pops for filename "Plates.3mf", subtask "Plates".
+        for key in [(1, "Plates"), (1, "Plates.3mf"), (1, "Plates.gcode.3mf")]:
+            m._expected_prints.pop(key, None)
+            m._expected_print_registered_at.pop(key, None)
+        assert (1, "Plates.gcode") in m._expected_prints
+        for key in list(m._expected_print_registered_at):
+            m._expected_print_registered_at[key] = time.monotonic() - m._EXPECTED_PRINT_TTL_SECONDS - 1
+        return m
+
+    def test_a_running_print_keeps_its_plate(self):
+        m = self._register_and_start()
+        m._active_prints[(1, "Plates.3mf")] = 42
+
+        m._evict_stale_expected_prints()
+
+        assert (1, "Plates.gcode") not in m._expected_prints
+        assert m._print_plate_ids[42] == 2
+        assert m._print_ams_mappings[42] == [0]
+
+    def test_a_print_that_never_started_still_releases_it(self):
+        m = self._register_and_start()
+
+        m._evict_stale_expected_prints()
+
+        assert 42 not in m._print_plate_ids
+        assert 42 not in m._print_ams_mappings