"""A layer timelapse is not lost to a slow stitch (TO-C8). Stitching was killed at a flat 300 s, and the frames were then deleted: a 1500-layer print on a Raspberry Pi 4 takes longer than that to encode, so its timelapse was gone for good. The deadline now grows with the frame count (half a second per frame, at least 5 minutes, at most an hour), and a stitch that fails keeps its frames for a day so the video can still be made. """ import asyncio import os import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from backend.app.services import layer_timelapse from backend.app.services.layer_timelapse import TimelapseSession @pytest.fixture def base_dir(tmp_path, monkeypatch): fake = MagicMock(base_dir=tmp_path) monkeypatch.setattr(layer_timelapse, "settings", fake) layer_timelapse._active_sessions.clear() layer_timelapse._finalizing_sessions.clear() yield tmp_path layer_timelapse._active_sessions.clear() layer_timelapse._finalizing_sessions.clear() def _session_with_frames(printer_id: int, frames: int) -> TimelapseSession: session = TimelapseSession(printer_id=printer_id, archive_id=1, camera_url="http://cam/", camera_type="mjpeg") for n in range(frames): (session.frames_dir / f"layer_{n:05d}.jpg").write_bytes(b"\xff\xd8jpg\xff\xd9") session.frame_count = frames return session @pytest.mark.parametrize(("frames", "seconds"), [(10, 300), (600, 300), (1500, 750), (100_000, 3600)]) def test_the_stitch_deadline_grows_with_the_frames(frames, seconds): assert layer_timelapse._stitch_timeout(frames) == seconds @pytest.mark.asyncio async def test_a_stitch_past_its_deadline_is_killed_and_reaped(base_dir, monkeypatch): session = _session_with_frames(1, 3) proc = MagicMock() async def never(): await asyncio.Event().wait() proc.communicate = never proc.wait = AsyncMock(return_value=-9) monkeypatch.setattr(layer_timelapse, "_stitch_timeout", lambda _frames: 0.05) with ( patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"), patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), ): assert await session.stitch(base_dir / "out.mp4") is False proc.kill.assert_called_once() proc.wait.assert_awaited() @pytest.mark.asyncio async def test_a_failed_stitch_keeps_its_frames(base_dir): session = _session_with_frames(1, 3) layer_timelapse._active_sessions[1] = session async def ffmpeg_failed(self, _output, fps=30): self.ffmpeg_ran = True return False with patch.object(TimelapseSession, "stitch", ffmpeg_failed): assert await layer_timelapse.on_print_complete(1) is None assert len(list(session.frames_dir.glob("layer_*.jpg"))) == 3 marker = session.frames_dir / layer_timelapse.KEPT_FRAMES_MARKER assert marker.exists() and "frames.txt" in marker.read_text() @pytest.mark.asyncio async def test_without_ffmpeg_the_frames_are_not_kept(base_dir): """Nothing a later attempt could do differently: an install without ffmpeg would otherwise keep every print's frames.""" session = _session_with_frames(1, 3) layer_timelapse._active_sessions[1] = session with patch.object(layer_timelapse, "get_ffmpeg_path", return_value=None): assert await layer_timelapse.on_print_complete(1) is None assert not session.frames_dir.exists() @pytest.mark.asyncio async def test_a_stitch_cut_short_by_a_restart_keeps_its_frames(base_dir): """Marked before ffmpeg starts: at startup the janitor sees a kept folder.""" session = _session_with_frames(1, 3) proc = MagicMock() marked_while_running = [] async def interrupted(): marked_while_running.append((session.frames_dir / layer_timelapse.KEPT_FRAMES_MARKER).exists()) raise RuntimeError("process ended") proc.communicate = interrupted with ( patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"), patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), ): assert await session.stitch(base_dir / "out.mp4") is False assert marked_while_running == [True] @pytest.mark.asyncio async def test_a_failed_stitch_leaves_no_partial_video(base_dir, monkeypatch): session = _session_with_frames(1, 3) output = session.frames_dir.parent / "timelapse_x.mp4" proc = MagicMock() proc.returncode = 1 async def partial(): output.write_bytes(b"half a video") return b"", b"encoder error" proc.communicate = partial with ( patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"), patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), ): assert await session.stitch(output) is False assert not output.exists() @pytest.mark.asyncio async def test_two_stitches_of_one_printer_are_both_protected(base_dir): """A short print can finish while a long one still stitches; the janitor now runs while Bambuddy does, so the first must stay protected.""" long_session = _session_with_frames(1, 3) long_session.session_id = "20260101_000000" short_session = _session_with_frames(1, 3) short_session.session_id = "20260101_000500" release = asyncio.Event() async def slow(self, _output, fps=30): if self is long_session: await release.wait() return True with patch.object(TimelapseSession, "stitch", slow): layer_timelapse._active_sessions[1] = long_session first = asyncio.create_task(layer_timelapse.on_print_complete(1)) await asyncio.sleep(0) layer_timelapse._active_sessions[1] = short_session await layer_timelapse.on_print_complete(1) assert layer_timelapse._finalizing_sessions[1] == {"20260101_000000"} release.set() await first assert 1 not in layer_timelapse._finalizing_sessions @pytest.mark.asyncio async def test_a_successful_stitch_still_cleans_up(base_dir): session = _session_with_frames(1, 3) layer_timelapse._active_sessions[1] = session with patch.object(TimelapseSession, "stitch", AsyncMock(return_value=True)): assert await layer_timelapse.on_print_complete(1) is not None assert not session.frames_dir.exists() def test_the_janitor_leaves_kept_frames_for_a_day(base_dir): kept = base_dir / "timelapse_frames" / "1" / "20260101_000000" kept.mkdir(parents=True) (kept / "layer_00000.jpg").write_bytes(b"jpg") (kept / layer_timelapse.KEPT_FRAMES_MARKER).write_text("stitch failed") hour_ago = time.time() - 3600 os.utime(kept, (hour_ago, hour_ago)) os.utime(kept / layer_timelapse.KEPT_FRAMES_MARKER, (hour_ago, hour_ago)) assert layer_timelapse.cleanup_orphaned_timelapse_sessions() == 0 assert kept.exists() old = time.time() - layer_timelapse.KEEP_FAILED_FRAMES_SECONDS - 60 os.utime(kept / layer_timelapse.KEPT_FRAMES_MARKER, (old, old)) assert layer_timelapse.cleanup_orphaned_timelapse_sessions() == 1 assert not kept.exists() def test_the_janitor_still_reaps_ordinary_orphans(base_dir): orphan = base_dir / "timelapse_frames" / "1" / "20260101_000001" orphan.mkdir(parents=True) hour_ago = time.time() - 3600 os.utime(orphan, (hour_ago, hour_ago)) assert layer_timelapse.cleanup_orphaned_timelapse_sessions() == 1 @pytest.mark.asyncio async def test_the_janitor_also_runs_while_bambuddy_does(monkeypatch): """Hourly, so kept frames go after a day rather than at the next restart.""" from backend.app import main sweeps = [] rounds = {"n": 0} async def fake_sleep(_seconds): rounds["n"] += 1 if rounds["n"] >= main.TIMELAPSE_CLEANUP_EVERY * 2: raise asyncio.CancelledError monkeypatch.setattr("backend.app.api.routes.camera.cleanup_orphaned_streams", AsyncMock()) monkeypatch.setattr(layer_timelapse, "cleanup_orphaned_timelapse_sessions", lambda: sweeps.append(1) or 0) monkeypatch.setattr(main.asyncio, "sleep", fake_sleep) with pytest.raises(asyncio.CancelledError): await main._camera_cleanup_loop() assert len(sweeps) == 2 @pytest.mark.asyncio async def test_a_failed_attach_leaves_the_next_prints_session_alone(): """The completed print's session left _active_sessions when its stitch began; the one there now is the next print's.""" import inspect from backend.app import main source = inspect.getsource(main) i = source.index('logger.warning("[LAYER-TL] Failed: %s", e)') assert 'if print_status != "completed":' in source[i : i + 600] @pytest.mark.asyncio async def test_ffmpeg_that_cannot_be_started_does_not_keep_the_frames(base_dir): session = _session_with_frames(1, 3) layer_timelapse._active_sessions[1] = session with ( patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"), patch("asyncio.create_subprocess_exec", AsyncMock(side_effect=PermissionError("not executable"))), ): assert await layer_timelapse.on_print_complete(1) is None assert not session.frames_dir.exists() @pytest.mark.asyncio async def test_a_session_is_protected_before_it_leaves_the_active_list(base_dir, monkeypatch): """The janitor runs in a thread: there must be no moment in which the stitching session is in neither list.""" session = _session_with_frames(1, 3) layer_timelapse._active_sessions[1] = session seen = [] class Watched(dict): def pop(self, key, default=None): seen.append(session.session_id in layer_timelapse._finalizing_sessions.get(key, set())) return super().pop(key, default) monkeypatch.setattr(layer_timelapse, "_active_sessions", Watched(layer_timelapse._active_sessions)) with patch.object(TimelapseSession, "stitch", AsyncMock(return_value=True)): await layer_timelapse.on_print_complete(1) assert seen == [True] assert 1 not in layer_timelapse._finalizing_sessions @pytest.mark.asyncio async def test_a_session_without_frames_is_not_left_protected(base_dir): session = _session_with_frames(1, 0) layer_timelapse._active_sessions[1] = session assert await layer_timelapse.on_print_complete(1) is None assert 1 not in layer_timelapse._finalizing_sessions assert 1 not in layer_timelapse._active_sessions @pytest.mark.asyncio async def test_the_janitor_runs_even_when_the_stream_cleanup_fails(monkeypatch): from backend.app import main sweeps = [] rounds = {"n": 0} async def fake_sleep(_seconds): rounds["n"] += 1 if rounds["n"] >= main.TIMELAPSE_CLEANUP_EVERY: raise asyncio.CancelledError monkeypatch.setattr( "backend.app.api.routes.camera.cleanup_orphaned_streams", AsyncMock(side_effect=RuntimeError("ps failed")) ) monkeypatch.setattr(layer_timelapse, "cleanup_orphaned_timelapse_sessions", lambda: sweeps.append(1) or 0) monkeypatch.setattr(main.asyncio, "sleep", fake_sleep) with pytest.raises(asyncio.CancelledError): await main._camera_cleanup_loop() assert sweeps == [1] @pytest.mark.asyncio async def test_an_ffmpeg_error_keeps_the_frames_end_to_end(base_dir): session = _session_with_frames(1, 3) layer_timelapse._active_sessions[1] = session proc = MagicMock() proc.returncode = 1 proc.communicate = AsyncMock(return_value=(b"", b"encoder error")) with ( patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"), patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), ): assert await layer_timelapse.on_print_complete(1) is None assert len(list(session.frames_dir.glob("layer_*.jpg"))) == 3