| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767 |
- """Timelapse scan reliability (#2704).
- A Bambu printer in LAN-only mode never reaches Bambu's NTP server, so the clock
- behind both the timelapse filename and the FTP mtime drifts freely — the P1S in
- the report was six and a half days out. That is why the automatic scan works by
- diffing the printer's ``/timelapse`` listing against a snapshot taken when the
- print started, and why nothing in that path may fall back to comparing times.
- These tests pin the parts that make the diff dependable:
- * the candidate is chosen by exclusion, never by ordering (ordering could only
- be done on the printer's clock);
- * a download that comes up short never attaches and never triggers a delete —
- deleting the printer's copy is only safe because the transfer was verified;
- * the baseline persisted at print start is what the manual Scan button uses,
- instead of the clock-based strategies that cannot work on a drifted printer.
- """
- import logging
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- logger = logging.getLogger(__name__)
- def _printer():
- p = MagicMock()
- p.id = 1
- p.name = "TestP1S"
- p.ip_address = "192.168.1.100"
- p.access_code = "12345678"
- p.model = "P1S"
- return p
- def _video(name: str, size: int = 1000):
- return {"name": name, "is_directory": False, "path": f"/timelapse/{name}", "size": size}
- def _session(archive=None):
- session = AsyncMock()
- session.__aenter__ = AsyncMock(return_value=session)
- session.__aexit__ = AsyncMock()
- if archive is not None:
- session.get = AsyncMock(return_value=archive)
- return session
- class TestCandidateSelection:
- """Which of the printer's videos belongs to this print."""
- @pytest.fixture
- def attach(self):
- from backend.app.main import _attach_first_unclaimed_timelapse
- return _attach_first_unclaimed_timelapse
- @pytest.mark.asyncio
- async def test_nothing_new_since_baseline_is_not_an_attach(self, attach):
- result = await attach(
- 42,
- _printer(),
- [_video("video_2026-07-21_09-17-37.avi")],
- {"video_2026-07-21_09-17-37.avi"},
- set(),
- 1,
- logger,
- )
- assert result is False
- @pytest.mark.asyncio
- async def test_attaches_the_one_new_file_and_deletes_it_from_the_printer(self, attach):
- download = AsyncMock(return_value=b"x" * 1000)
- delete = AsyncMock(return_value=True)
- service = MagicMock()
- service.attach_timelapse = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
- patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
- patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
- 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())),
- ):
- result = await attach(
- 42,
- _printer(),
- [_video("old.avi"), _video("video_2026-07-22_06-18-39.avi")],
- {"old.avi"},
- set(),
- 1,
- logger,
- )
- assert result is True
- service.attach_timelapse.assert_awaited_once()
- assert service.attach_timelapse.await_args.args[2] == "video_2026-07-22_06-18-39.avi"
- delete.assert_awaited_once()
- assert delete.await_args.args[2] == "/timelapse/video_2026-07-22_06-18-39.avi"
- @pytest.mark.asyncio
- async def test_skips_a_previous_prints_late_landing_video(self, attach):
- """Two files are new since the baseline because the previous print's
- video only landed after this print started. It is already attached to
- another archive, so it is excluded by name — no timestamps involved."""
- download = AsyncMock(return_value=b"y" * 1000)
- service = MagicMock()
- service.attach_timelapse = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
- patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
- patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
- 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())),
- ):
- result = await attach(
- 42,
- _printer(),
- # Listing order puts the previous print's video first, so a
- # naive "take the first new one" would grab the wrong video.
- [_video("previous_print.avi"), _video("this_print.avi")],
- set(),
- {"previous_print"},
- 1,
- logger,
- )
- assert result is True
- assert service.attach_timelapse.await_args.args[2] == "this_print.avi"
- @pytest.mark.asyncio
- async def test_claimed_match_survives_the_mp4_conversion(self, attach):
- """Attached AVIs are converted to MP4 afterwards, which keeps the stem
- but changes the extension — so exclusion has to compare stems."""
- result = await attach(
- 42,
- _printer(),
- [_video("video_2026-07-22_06-18-39.avi")],
- set(),
- {"video_2026-07-22_06-18-39"}, # stored as .mp4 on the archive
- 1,
- logger,
- )
- assert result is False
- @pytest.mark.asyncio
- async def test_all_new_files_claimed_keeps_polling(self, attach):
- result = await attach(42, _printer(), [_video("a.avi"), _video("b.avi")], set(), {"a", "b"}, 1, logger)
- assert result is False
- class TestDownloadVerificationGatesTheDelete:
- """The printer's copy is the only other copy — it goes only after the
- transfer is verified against the size the listing reported."""
- @pytest.fixture
- def attach(self):
- from backend.app.main import _attach_first_unclaimed_timelapse
- return _attach_first_unclaimed_timelapse
- @pytest.mark.asyncio
- async def test_passes_the_listed_size_to_the_downloader(self, attach):
- download = AsyncMock(return_value=b"z" * 4096)
- service = MagicMock()
- service.attach_timelapse = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
- patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
- patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
- 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())),
- ):
- await attach(42, _printer(), [_video("new.avi", size=4096)], set(), set(), 1, logger)
- assert download.await_args.kwargs["expected_size"] == 4096
- @pytest.mark.asyncio
- async def test_short_download_does_not_attach_or_delete(self, attach):
- """download_file_bytes_async returns None on a size mismatch. The
- printer must keep its copy so the next poll round can retry."""
- delete = AsyncMock()
- service = MagicMock()
- service.attach_timelapse = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=None)),
- patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
- patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
- patch("backend.app.main.async_session", return_value=_session()),
- patch("backend.app.main.ArchiveService", return_value=service),
- ):
- result = await attach(42, _printer(), [_video("new.avi")], set(), set(), 1, logger)
- assert result is False
- service.attach_timelapse.assert_not_awaited()
- delete.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_failed_attach_does_not_delete(self, attach):
- delete = AsyncMock()
- service = MagicMock()
- service.attach_timelapse = AsyncMock(return_value=False)
- 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", delete),
- patch("backend.app.main.async_session", return_value=_session()),
- patch("backend.app.main.ArchiveService", return_value=service),
- ):
- result = await attach(42, _printer(), [_video("new.avi")], set(), set(), 1, logger)
- assert result is False
- delete.assert_not_awaited()
- class TestFtpDownloadSizeCheck:
- """`download_file` is where a truncated FTPS transfer used to pass for a
- complete one — a partial buffer is non-empty, so every caller downstream
- treated it as a good file."""
- def _client(self, payload: bytes):
- from backend.app.services.bambu_ftp import BambuFTPClient
- client = BambuFTPClient("192.168.1.100", "12345678")
- ftp = MagicMock()
- ftp.retrbinary = MagicMock(side_effect=lambda cmd, cb: cb(payload))
- client._ftp = ftp
- return client
- def test_exact_size_passes(self):
- assert self._client(b"a" * 500).download_file("/timelapse/v.avi", expected_size=500) == b"a" * 500
- def test_short_read_is_a_failure(self):
- assert self._client(b"a" * 499).download_file("/timelapse/v.avi", expected_size=500) is None
- def test_long_read_is_a_failure(self):
- """Not expected in practice, but a mismatch either way means we don't
- know what we have, and we're about to delete the original."""
- assert self._client(b"a" * 501).download_file("/timelapse/v.avi", expected_size=500) is None
- def test_zero_bytes_is_a_failure_even_without_an_expected_size(self):
- assert self._client(b"").download_file("/cache/whatever.3mf") is None
- def test_unverified_download_still_works_for_callers_that_do_not_pass_a_size(self):
- assert self._client(b"abc").download_file("/cache/whatever.3mf") == b"abc"
- class TestDeleteIsBestEffort:
- """A printer that refuses the delete must not break the flow — the video
- is already in the archive, and the diff excludes it by name from then on."""
- @pytest.mark.asyncio
- async def test_reports_success_on_delete(self):
- from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
- with patch(
- "backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.DELETED)
- ) as d:
- assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is True
- assert d.await_count == 1
- @pytest.mark.asyncio
- async def test_not_found_is_success_and_is_not_retried(self):
- """550 means the printer already cleaned up; waiting cannot change it."""
- from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
- with patch(
- "backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.NOT_FOUND)
- ) as d:
- assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is True
- assert d.await_count == 1
- @pytest.mark.asyncio
- async def test_failure_retries_then_gives_up_without_raising(self):
- from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
- with (
- patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.FAILED)),
- patch("backend.app.services.bambu_ftp.asyncio.sleep", AsyncMock()),
- ):
- assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is False
- @pytest.mark.asyncio
- async def test_raising_transport_does_not_propagate(self):
- from backend.app.services.bambu_ftp import delete_archived_timelapse
- with (
- patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(side_effect=OSError("boom"))),
- patch("backend.app.services.bambu_ftp.asyncio.sleep", AsyncMock()),
- ):
- assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is False
- class TestBaselineIsPersisted:
- """The baseline has to outlive the process: a restart mid-print used to
- lose it, and the manual scan never had access to it at all."""
- @pytest.mark.asyncio
- async def test_written_to_the_archive_row_at_print_start(self):
- from backend.app.main import _capture_timelapse_baseline_at_start
- archive = MagicMock()
- archive.timelapse_baseline = None
- session = _session(archive)
- with (
- patch("backend.app.main.async_session", return_value=session),
- patch(
- "backend.app.main._list_timelapse_videos",
- new=AsyncMock(return_value=([_video("a.avi"), _video("b.avi")], "/timelapse")),
- ),
- ):
- await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
- assert archive.timelapse_baseline == ["a.avi", "b.avi"]
- session.commit.assert_awaited()
- @pytest.mark.asyncio
- async def test_no_archive_id_keeps_it_in_memory_only(self):
- from backend.app.main import _capture_timelapse_baseline_at_start, _timelapse_baselines
- _timelapse_baselines.pop(1, None)
- session = _session(MagicMock())
- with (
- patch("backend.app.main.async_session", return_value=session),
- patch(
- "backend.app.main._list_timelapse_videos",
- new=AsyncMock(return_value=([_video("a.avi")], "/timelapse")),
- ),
- ):
- await _capture_timelapse_baseline_at_start(_printer(), 1, logger)
- assert _timelapse_baselines[1] == {"a.avi"}
- session.commit.assert_not_awaited()
- _timelapse_baselines.pop(1, None)
- @pytest.mark.asyncio
- async def test_listing_failure_stores_null_not_an_empty_baseline(self):
- """An empty list would make every video on the printer look new; NULL
- correctly means "no baseline" and falls back to a fresh snapshot."""
- from backend.app.main import _capture_timelapse_baseline_at_start
- archive = MagicMock()
- session = _session(archive)
- with (
- patch("backend.app.main.async_session", return_value=session),
- patch("backend.app.main._list_timelapse_videos", new=AsyncMock(side_effect=OSError("ftp down"))),
- ):
- await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
- assert archive.timelapse_baseline is None
- class TestManualScanUsesTheBaseline:
- """The reporter's second symptom: pressing "Scan for Timelapse" found
- nothing. Every strategy the endpoint had was clock-based, and their
- printer's clock was days out, so it could not match on any of them."""
- def _archive(self, baseline):
- from datetime import datetime, timezone
- a = MagicMock()
- a.id = 64
- a.printer_id = 1
- a.filename = "mops.3mf"
- a.timelapse_path = None
- a.timelapse_baseline = baseline
- a.started_at = datetime(2026, 7, 28, 20, 30, tzinfo=timezone.utc)
- a.completed_at = datetime(2026, 7, 28, 21, 19, tzinfo=timezone.utc)
- a.created_at = a.completed_at
- return a
- async def _scan(self, archive, listing, download=None, delete=None):
- from backend.app.api.routes import archives as archives_mod
- service = MagicMock()
- service.get_archive = AsyncMock(return_value=archive)
- service.attach_timelapse = AsyncMock(return_value=True)
- session = AsyncMock()
- session.__aenter__ = AsyncMock(return_value=session)
- session.__aexit__ = AsyncMock()
- session.execute = AsyncMock(
- return_value=MagicMock(
- scalar_one_or_none=MagicMock(return_value=_printer()),
- scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
- )
- )
- with (
- patch("backend.app.core.database.async_session", return_value=session),
- patch("backend.app.api.routes.archives.ArchiveService", return_value=service),
- patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
- patch(
- "backend.app.services.bambu_ftp.get_ftp_retry_settings",
- AsyncMock(return_value=(False, 3, 2, 30)),
- ),
- patch(
- "backend.app.services.bambu_ftp.download_file_bytes_async",
- download or AsyncMock(return_value=b"x" * 1000),
- ),
- patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete or AsyncMock()),
- ):
- return await archives_mod.scan_timelapse(archive.id, None)
- @pytest.mark.asyncio
- async def test_attaches_the_single_unclaimed_new_file(self):
- """The printer's clock is six days out here — exactly the reporter's
- case. Nothing in this path looks at a timestamp."""
- archive = self._archive(["video_2026-07-21_22-49-47.avi"])
- listing = [
- _video("video_2026-07-21_22-49-47.avi"),
- _video("video_2026-07-22_06-18-39.avi"),
- ]
- result = await self._scan(archive, listing)
- assert result["status"] == "attached"
- assert result["filename"] == "video_2026-07-22_06-18-39.avi"
- @pytest.mark.asyncio
- async def test_deletes_from_the_printer_after_attaching(self):
- delete = AsyncMock()
- archive = self._archive(["old.avi"])
- await self._scan(archive, [_video("old.avi"), _video("new.avi")], delete=delete)
- delete.assert_awaited_once()
- assert delete.await_args.args[2] == "/timelapse/new.avi"
- @pytest.mark.asyncio
- async def test_baseline_showing_nothing_new_does_not_guess(self):
- """With a baseline saying no new video exists, the clock strategies
- must not run — otherwise a coincidental timestamp match attaches
- someone else's video and calls it this print's."""
- archive = self._archive(["video_2026-07-28_20-30-00.avi"])
- # This file's embedded time is minutes from started_at, so the old
- # timestamp strategy would have matched it confidently.
- listing = [_video("video_2026-07-28_20-30-00.avi")]
- result = await self._scan(archive, listing)
- assert result["status"] == "not_found"
- @pytest.mark.asyncio
- async def test_ambiguous_baseline_offers_only_the_plausible_files(self):
- archive = self._archive(["old.avi"])
- listing = [_video("old.avi"), _video("candidate_a.avi"), _video("candidate_b.avi")]
- result = await self._scan(archive, listing)
- assert result["status"] == "not_found"
- assert {f["name"] for f in result["available_files"]} == {"candidate_a.avi", "candidate_b.avi"}
- @pytest.mark.asyncio
- async def test_archives_without_a_baseline_keep_the_old_strategies(self):
- """Rows predating the persisted baseline still get the best guess the
- endpoint can make, rather than nothing at all."""
- archive = self._archive(None)
- listing = [_video("mops_something.avi")] # matches by print name
- result = await self._scan(archive, listing)
- assert result["status"] == "attached"
- assert result["filename"] == "mops_something.avi"
- class TestPollBounds:
- """The poll is bounded twice on purpose."""
- def test_round_cap_tracks_the_wall_clock_budget(self):
- from backend.app.main import (
- _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS,
- _TIMELAPSE_SCAN_TIMEOUT_SECONDS,
- _timelapse_scan_max_attempts,
- )
- assert (
- _timelapse_scan_max_attempts()
- == int(_TIMELAPSE_SCAN_TIMEOUT_SECONDS // _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS) + 1
- )
- def test_zero_interval_does_not_divide_by_zero(self, monkeypatch):
- """The deadline alone can't bound the loop once sleeps are shortened to
- nothing, which is exactly what a test or a future tweak would do."""
- import backend.app.main as main_mod
- monkeypatch.setattr(main_mod, "_TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS", 0)
- assert main_mod._timelapse_scan_max_attempts() > 1
- def test_budget_is_much_longer_than_the_ladder_it_replaced(self):
- """The old [5, 10, 20, 30] ladder gave up after ~65 seconds, while the
- support bundles showed videos still arriving at the cutoff."""
- from backend.app.main import _TIMELAPSE_SCAN_TIMEOUT_SECONDS
- assert _TIMELAPSE_SCAN_TIMEOUT_SECONDS >= 300
- class TestFinishPhotoUpgrade:
- """The print-complete notification waits ~60s for the timelapse, because
- holding it for minutes is worse than sending a live grab. On a P1S the
- video routinely lands later than that (p90 167s, worst observed 546s), so
- the archive kept the live grab — taken after the end G-code dropped the
- bed, which is the worse of the two photos. The upgrade runs afterwards."""
- @pytest.mark.asyncio
- async def test_puts_the_timelapse_frame_first_and_keeps_the_live_grab(self):
- """First, because the gallery opens at index 0. Kept, because the
- notification that already went out links to that exact file."""
- from backend.app.main import _upgrade_finish_photo_from_timelapse
- archive = MagicMock()
- archive.photos = ["finish_live_grab.jpg"]
- session = _session(archive)
- with (
- patch(
- "backend.app.main._capture_finish_photo_from_timelapse",
- AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
- ),
- patch("backend.app.main.async_session", return_value=session),
- patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())) as ws,
- ):
- await _upgrade_finish_photo_from_timelapse(7, MagicMock())
- assert archive.photos == ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
- session.commit.assert_awaited()
- ws.send_archive_updated.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_waits_far_longer_than_the_notification_can(self):
- from backend.app.main import (
- _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS,
- _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS,
- _upgrade_finish_photo_from_timelapse,
- )
- capture = AsyncMock(return_value=(None, True))
- with patch("backend.app.main._capture_finish_photo_from_timelapse", capture):
- await _upgrade_finish_photo_from_timelapse(7, MagicMock())
- assert capture.await_args.kwargs["timeout"] == _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS
- assert _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS > _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS
- # Covers the 546s worst case seen in the support bundles.
- assert _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS >= 600
- @pytest.mark.asyncio
- async def test_video_never_arrives_leaves_the_archive_alone(self):
- from backend.app.main import _upgrade_finish_photo_from_timelapse
- session = _session(MagicMock())
- with (
- patch("backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=(None, True))),
- patch("backend.app.main.async_session", return_value=session),
- ):
- await _upgrade_finish_photo_from_timelapse(7, MagicMock())
- session.commit.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_is_idempotent(self):
- """A second run must not list the same photo twice."""
- from backend.app.main import _upgrade_finish_photo_from_timelapse
- archive = MagicMock()
- archive.photos = ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
- session = _session(archive)
- with (
- patch(
- "backend.app.main._capture_finish_photo_from_timelapse",
- AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
- ),
- patch("backend.app.main.async_session", return_value=session),
- ):
- await _upgrade_finish_photo_from_timelapse(7, MagicMock())
- assert archive.photos == ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
- session.commit.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_missing_archive_does_not_raise(self):
- from backend.app.main import _upgrade_finish_photo_from_timelapse
- session = AsyncMock()
- session.__aenter__ = AsyncMock(return_value=session)
- session.__aexit__ = AsyncMock()
- session.get = AsyncMock(return_value=None)
- with (
- patch("backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=("f.jpg", False))),
- patch("backend.app.main.async_session", return_value=session),
- ):
- await _upgrade_finish_photo_from_timelapse(7, MagicMock())
- session.commit.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_refuses_to_delete_an_unverified_download(self):
- """The safety rule lives with the destructive call, not at the call
- sites — an unverified transfer may be a truncated file, and deleting
- the source would destroy the only complete copy."""
- from backend.app.services.bambu_ftp import delete_archived_timelapse
- with patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock()) as d:
- assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=False) is False
- d.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_verified_is_required_not_defaulted(self):
- """A future call site must not be able to silently skip the check."""
- import inspect
- from backend.app.services.bambu_ftp import delete_archived_timelapse
- param = inspect.signature(delete_archived_timelapse).parameters["verified"]
- assert param.default is inspect.Parameter.empty
- assert param.kind is inspect.Parameter.KEYWORD_ONLY
- class TestStaleBaselineCannotSurvive:
- """A reprint reuses the archive row, so a baseline left over from the
- previous run would have the scan diff this print against the printer's
- state before the *last* one — and unlike NULL, a stale list reads as
- authoritative and suppresses the fresh-snapshot fallback."""
- @pytest.mark.asyncio
- async def test_failed_capture_clears_rather_than_leaves_the_old_value(self):
- from backend.app.main import _capture_timelapse_baseline_at_start
- archive = MagicMock()
- archive.timelapse_baseline = ["from_the_previous_run.avi"]
- session = _session(archive)
- with (
- patch("backend.app.main.async_session", return_value=session),
- patch("backend.app.main._list_timelapse_videos", new=AsyncMock(side_effect=OSError("ftp down"))),
- ):
- await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
- assert archive.timelapse_baseline is None
- session.commit.assert_awaited()
- @pytest.mark.asyncio
- async def test_successful_capture_overwrites_the_old_value(self):
- from backend.app.main import _capture_timelapse_baseline_at_start
- archive = MagicMock()
- archive.timelapse_baseline = ["from_the_previous_run.avi"]
- session = _session(archive)
- with (
- patch("backend.app.main.async_session", return_value=session),
- patch(
- "backend.app.main._list_timelapse_videos",
- new=AsyncMock(return_value=([_video("now_on_the_printer.avi")], "/timelapse")),
- ),
- ):
- await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
- assert archive.timelapse_baseline == ["now_on_the_printer.avi"]
- class TestFileMustHaveStoppedGrowing:
- """Matching the listing's size proves we received what it said, not that
- the printer had finished writing. The scan's first look lands seconds after
- the print ends — exactly when the video is being written — so a growing
- file can be listed short, served short, and pass the length check. That was
- survivable while the printer kept its copy; it isn't now that a successful
- attach deletes it."""
- @pytest.mark.asyncio
- async def test_same_size_afterwards_is_settled(self):
- from backend.app.services.bambu_ftp import remote_file_settled
- with patch(
- "backend.app.services.bambu_ftp.list_files_async",
- AsyncMock(return_value=[_video("v.avi", size=4096)]),
- ):
- assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is True
- @pytest.mark.asyncio
- async def test_grown_since_download_is_not_settled(self):
- """We hold a prefix of the video, not the video."""
- from backend.app.services.bambu_ftp import remote_file_settled
- with patch(
- "backend.app.services.bambu_ftp.list_files_async",
- AsyncMock(return_value=[_video("v.avi", size=9000)]),
- ):
- assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is False
- @pytest.mark.asyncio
- async def test_vanished_counts_as_settled(self):
- """Nothing left that can grow, and nothing left to delete either."""
- from backend.app.services.bambu_ftp import remote_file_settled
- with patch(
- "backend.app.services.bambu_ftp.list_files_async",
- AsyncMock(return_value=[_video("something_else.avi")]),
- ):
- assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is True
- @pytest.mark.asyncio
- async def test_listing_failure_is_not_settled(self):
- """ "Could not check" must not read as "safe to delete"."""
- from backend.app.services.bambu_ftp import remote_file_settled
- with patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])):
- assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is False
- @pytest.mark.asyncio
- async def test_scan_discards_a_still_growing_video_without_deleting(self):
- from backend.app.main import _attach_first_unclaimed_timelapse
- delete = AsyncMock()
- 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=False)),
- patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
- patch("backend.app.main.async_session", return_value=_session()),
- patch("backend.app.main.ArchiveService", return_value=service),
- ):
- result = await _attach_first_unclaimed_timelapse(
- 42, _printer(), [_video("new.avi", size=1000)], set(), set(), 1, logger
- )
- assert result is False
- service.attach_timelapse.assert_not_awaited()
- delete.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_scan_attaches_once_the_video_has_settled(self):
- from backend.app.main import _attach_first_unclaimed_timelapse
- settled = AsyncMock(return_value=True)
- 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", settled),
- patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
- 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())),
- ):
- result = await _attach_first_unclaimed_timelapse(
- 42, _printer(), [_video("new.avi", size=1000)], set(), set(), 1, logger
- )
- assert result is True
- # Checked against what we actually received, not against the listing.
- assert settled.await_args.args[3] == 1000
|