Files
bambuddy/backend/tests/unit/test_cleanup_forced_timelapse.py
T
maziggy 12d17bfbe7 fix(photo): source finish photo from forced timelapse + cleanup (#1397)
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
  live-camera grab captured the bed already dropped, ruining the photo
  framing. Source the photo from a brief Bambu timelapse instead —
  firmware stops timelapse recording AFTER toolhead parks but BEFORE
  bed-drop runs, so the last frame frames the finished print correctly.

  When capture_finish_photo is on AND the user did not opt in to
  timelapse for this print, force timelapse=True at dispatch + mark the
  new PrintArchive.bambuddy_forced_timelapse column. After extraction
  (success or failure), cleanup deletes the locally-attached file,
  clears archive.timelapse_path, and walks the four scanner directories
  (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
  against the original filename. User-opted-in timelapses pass through
  unchanged.

  Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
  (module-level so the print queue can reuse it). Both dispatch paths
  wired: background_dispatch.py (Print Now / Reprint) AND
  print_scheduler.py:_start_print (the queue). Field testing caught the
  scheduler gap on the first round — AST regression test now asserts
  start_print(timelapse=...) references effective_timelapse, not the raw
  item.timelapse, so a future refactor can't silently drop it.

  Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
  frames overwrite the same output file, so the file left on disk is the
  literal last frame regardless of duration. Bambu records one frame per
  layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
  original -sseof -1.0 approach seeked before the start of the file and
  returned frame 0 (empty bed). Decoding every frame is fine; Bambu
  timelapses are short by construction even on hours-long prints.

  Migration adds bambuddy_forced_timelapse branched on is_sqlite()
  (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
  Verified live on postgres:16-alpine.

  Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
  so the notification carries the bed-up photo instead of falling back
  to the live-cam grab on slow links.

  Scope limit, documented in the camera wiki: prints started directly
  on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
  both dispatch paths, so the override doesn't fire there. Future
  option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.

  Setting description rewritten in all 11 locales to drop the "only
  works when timelapse enabled" caveat (Bambuddy now forces it) and
  explain the kept-or-deleted behaviour.
2026-06-05 13:53:26 +02:00

206 lines
7.8 KiB
Python

"""Tests for _cleanup_forced_timelapse (#1397).
When Bambuddy forced timelapse on for the finish-photo path, this helper
runs after the extractor (success OR failure — we never leave debris).
It deletes:
- the locally-attached file (clears archive.timelapse_path)
- the printer-side file via FTP DELE, walking the four scanner dirs
These tests pin the four branches:
1. archive doesn't exist → no-op
2. archive exists but bambuddy_forced_timelapse=False → no-op (user wanted
the timelapse)
3. archive exists, forced=True, local file present → delete local + DB
update + FTP DELE on the first directory that succeeds
4. archive exists, forced=True, but FTP DELE fails on every dir → local
side still cleaned up; warn log emitted (best-effort)
"""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app import main as main_module
from backend.app.main import _cleanup_forced_timelapse
def _fake_session_factory(rows: dict):
"""Return an async_session() replacement that yields the given rows.
`rows` is a mapping of model -> object that the test wants returned
from `db.execute(select(...)).scalar_one_or_none()`. The select
target is detected by walking the column descriptions — for these
tests we just look at the model class name.
"""
from contextlib import asynccontextmanager
@asynccontextmanager
async def fake_session():
async def execute(stmt):
# The select(...) statement carries the target entity in
# `stmt.column_descriptions[0]["entity"]`. Match by class name.
target_name = stmt.column_descriptions[0]["entity"].__name__
row = rows.get(target_name)
return SimpleNamespace(scalar_one_or_none=lambda: row)
commits: list[None] = []
async def commit():
commits.append(None)
yield SimpleNamespace(execute=execute, commit=commit, _commits=commits)
return fake_session
@pytest.fixture(autouse=True)
def patch_app_settings(monkeypatch, tmp_path):
"""Point base_dir at a tmp_path so the helper can resolve relative
timelapse paths against a real fs we control."""
monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
return tmp_path
@pytest.mark.asyncio
async def test_no_archive_is_noop(monkeypatch):
"""Archive deleted between print start and cleanup? Don't crash."""
monkeypatch.setattr(main_module, "async_session", _fake_session_factory({"PrintArchive": None, "Printer": None}))
delete_mock = AsyncMock()
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
delete_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_not_forced_is_noop(monkeypatch, tmp_path):
"""User wanted a timelapse → don't delete anything."""
archive = SimpleNamespace(
bambuddy_forced_timelapse=False,
timelapse_path="archive/1/timelapse.mp4",
)
monkeypatch.setattr(
main_module,
"async_session",
_fake_session_factory({"PrintArchive": archive, "Printer": None}),
)
# Lay down a real file so we'd detect a stray delete.
video_path = tmp_path / archive.timelapse_path
video_path.parent.mkdir(parents=True, exist_ok=True)
video_path.write_bytes(b"x" * 100)
delete_mock = AsyncMock(return_value=True)
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
delete_mock.assert_not_awaited()
assert video_path.exists()
# archive.timelapse_path is untouched — we still have the user's video
# tracked correctly.
assert archive.timelapse_path == "archive/1/timelapse.mp4"
@pytest.mark.asyncio
async def test_forced_deletes_local_and_remote(monkeypatch, tmp_path):
"""Happy path: forced=True → local file unlinked, DB row cleared, FTP
DELE called against /timelapse/<filename> (the first dir to succeed)."""
archive = SimpleNamespace(
bambuddy_forced_timelapse=True,
timelapse_path="archive/1/myprint.mp4",
)
printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
monkeypatch.setattr(
main_module,
"async_session",
_fake_session_factory({"PrintArchive": archive, "Printer": printer}),
)
video_path = tmp_path / archive.timelapse_path
video_path.parent.mkdir(parents=True, exist_ok=True)
video_path.write_bytes(b"x" * 100)
# FTP DELE succeeds on the first directory we try.
delete_mock = AsyncMock(return_value=True)
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
# Local side: file gone, DB cleared.
assert not video_path.exists()
assert archive.timelapse_path is None
# Remote side: DELE'd against /timelapse/myprint.mp4 — that's the
# first dir the cleanup tries.
delete_mock.assert_awaited()
call = delete_mock.await_args
assert call.args[0] == "10.0.0.5"
assert call.args[1] == "12345678"
assert call.args[2] == "/timelapse/myprint.mp4"
@pytest.mark.asyncio
async def test_forced_walks_alternate_dirs_when_first_fails(monkeypatch, tmp_path):
"""If /timelapse/ DELE returns False (file not there), try the other
scanner dirs in order."""
archive = SimpleNamespace(
bambuddy_forced_timelapse=True,
timelapse_path="archive/1/myprint.mp4",
)
printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
monkeypatch.setattr(
main_module,
"async_session",
_fake_session_factory({"PrintArchive": archive, "Printer": printer}),
)
video_path = tmp_path / archive.timelapse_path
video_path.parent.mkdir(parents=True, exist_ok=True)
video_path.write_bytes(b"x" * 100)
# First two attempts fail (False), third succeeds (True). Cleanup
# should stop after the third.
delete_mock = AsyncMock(side_effect=[False, False, True])
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
assert delete_mock.await_count == 3
paths_tried = [call.args[2] for call in delete_mock.await_args_list]
assert paths_tried == [
"/timelapse/myprint.mp4",
"/timelapse/video/myprint.mp4",
"/record/myprint.mp4",
]
@pytest.mark.asyncio
async def test_forced_local_cleanup_runs_even_if_ftp_unreachable(monkeypatch, tmp_path):
"""FTP completely failing must not block local cleanup — the user's
archive UI should reflect that the timelapse is gone immediately,
even if the printer-side file lingers."""
archive = SimpleNamespace(
bambuddy_forced_timelapse=True,
timelapse_path="archive/1/myprint.mp4",
)
printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
monkeypatch.setattr(
main_module,
"async_session",
_fake_session_factory({"PrintArchive": archive, "Printer": printer}),
)
video_path = tmp_path / archive.timelapse_path
video_path.parent.mkdir(parents=True, exist_ok=True)
video_path.write_bytes(b"x" * 100)
# Every FTP attempt throws.
delete_mock = AsyncMock(side_effect=OSError("connection refused"))
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
# Local side cleaned up even though all FTP attempts threw.
assert not video_path.exists()
assert archive.timelapse_path is None
# All four dirs were attempted before giving up.
assert delete_mock.await_count == 4