Files
bambuddy/backend/tests/unit/test_layer_timelapse_expected_archive.py
T
CarlandClaude Sonnet 5 5db4c75ca0 fix(camera): apply camera_rotation to layer-timelapse frames too
Same gap as the finish-photo fix: camera_rotation was only ever wired
into the notification-snapshot path, so a layer-timelapse video came
out upside-down whenever a rotation was configured - every frame
(fresh or reused from the live view's buffer) was written to disk raw.

Extracts the rotation logic out of main.py into a shared
apply_camera_rotation(image_data, rotation, logger) in services/camera.py
(taking the rotation value directly rather than a printer object, so
both main.py's printer-shaped callers and layer_timelapse's plain int
field can use it). main.py's _apply_camera_rotation becomes a thin
compatibility wrapper so its existing call sites are unchanged.

Threads a `rotation` field through TimelapseSession/start_session,
set from printer.camera_rotation in _maybe_start_layer_timelapse, and
applies it (via asyncio.to_thread, since PIL rotation is CPU-bound) in
capture_layer before each frame is written - after #2707's
live_frame_for_capture() resolves the frame, regardless of whether it
came fresh or from the live view's buffer.

Rebased onto #2707's landed implementation: capture_layer now calls
live_frame_for_capture() instead of the older is_stream_active/
try_get_active_buffered_frame pair this was originally written
against, so the layer-timelapse tests are updated to match, and the
now-redundant TestCaptureLayerAvoidsCompetingWithLiveViewer class
(superseded by #2707's own test_external_camera_live_frame_reuse.py)
is dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:34:13 +01:00

111 lines
4.8 KiB
Python

"""Regression test for #1353: layer timelapse must start for queue/VP-dispatched prints.
Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
print queue (so each print landed in the on_print_start "expected archive"
branch). Frames were never captured, no MP4 was produced, yet the post-print
log line said "Stitching layer timelapse for printer 1" — because
`tl_complete()` ran, found no active session, and silently returned None.
Root cause: only the two new-archive code paths in on_print_start
(`fallback_archive` + `archive_print`) called `layer_timelapse.start_session`.
The expected-archive branch — where reprints and queue dispatch land —
updated the existing archive's status to "printing" but never started a
timelapse session.
Fix: the three start_session call sites in on_print_start were unified
behind `_maybe_start_layer_timelapse(printer, printer_id, archive_id)`,
which gates on the same `external_camera_enabled and external_camera_url`
check. Testing the helper directly (instead of driving the whole
on_print_start flow) keeps this regression locked in without dragging in
unrelated side effects (plate detection, DB queries, MQTT relay, etc.).
"""
from types import SimpleNamespace
from unittest.mock import patch
from backend.app.main import _maybe_start_layer_timelapse
def _make_printer(*, external_camera_enabled: bool, external_camera_url: str | None):
"""Construct a minimal printer-shaped object with exactly the attributes
the helper reads. SimpleNamespace is used over MagicMock so attribute
access raises AttributeError on anything unexpected — keeps the test
honest about which fields the helper actually depends on.
"""
return SimpleNamespace(
external_camera_enabled=external_camera_enabled,
external_camera_url=external_camera_url,
external_camera_type="snapshot",
external_camera_snapshot_url=external_camera_url,
)
def test_starts_timelapse_when_external_camera_enabled():
"""Queue/VP-dispatched prints land in the expected-archive branch and must
start the timelapse session there (the #1353 root cause). The helper is
called from all three on_print_start paths (expected-archive promotion,
fallback archive, fresh archive) so testing it once covers all three."""
printer = _make_printer(
external_camera_enabled=True,
external_camera_url="http://camera.local:5000/snapshot.jpg",
)
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=42)
assert started is True
mock_start_session.assert_called_once_with(
1,
42,
"http://camera.local:5000/snapshot.jpg",
"snapshot",
snapshot_url="http://camera.local:5000/snapshot.jpg",
rotation=0,
)
def test_skips_timelapse_when_external_camera_disabled():
"""The same guard that the new-archive paths use must hold here: no
external camera → no timelapse session. Otherwise we'd try to capture
from a None URL and crash the print-start flow."""
printer = _make_printer(external_camera_enabled=False, external_camera_url=None)
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=99)
assert started is False
mock_start_session.assert_not_called()
def test_skips_timelapse_when_url_missing_even_if_flag_set():
"""If the toggle is on but the URL field is empty (legacy / half-configured
install), the guard must still hold — calling start_session with an empty
URL would crash downstream when the capture thread tries to fetch frames."""
printer = _make_printer(external_camera_enabled=True, external_camera_url=None)
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=7)
assert started is False
mock_start_session.assert_not_called()
def test_camera_type_defaults_to_mjpeg_when_unset():
"""external_camera_type defaults to 'mjpeg' in start_session when the
printer column is None — pre-existing contract preserved by the helper."""
printer = SimpleNamespace(
external_camera_enabled=True,
external_camera_url="http://cam/feed",
external_camera_type=None,
external_camera_snapshot_url=None,
)
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
_maybe_start_layer_timelapse(printer, printer_id=2, archive_id=11)
assert mock_start_session.called
call_kwargs = mock_start_session.call_args.kwargs
call_args = mock_start_session.call_args.args
assert call_args[3] == "mjpeg"
assert call_kwargs["snapshot_url"] is None