| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- """#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
|