Files
bambuddy/backend/tests/unit/test_layer_timelapse_expected_archive.py
T
maziggy 6569d5d1a7 refactor(timelapse): extract _maybe_start_layer_timelapse + rewrite test
The CI-only failure on test_layer_timelapse_expected_archive came from
  the test driving the entire on_print_start flow through ~12 patches and
  a MagicMock printer, which behaved differently between Python 3.11 (CI)
  and 3.13 (local) — execution stopped silently somewhere in the
  expected-archive path under CI's pytest-xdist parallelism but completed
  locally.

  Fix root-shape instead of fix the symptom:

  1. Extract the three identical start_session call sites in on_print_start
     (expected-archive promotion at 2030, fallback archive at 2554, fresh
     archive at 2644) into one helper _maybe_start_layer_timelapse() with
     the same external_camera_enabled / external_camera_url guard. The
     three inline blocks had already started drifting (#1353 originally
     only fixed one of them on the first pass) — the helper keeps them
     locked together going forward.

  2. Rewrite the test to call the helper directly. Uses SimpleNamespace
     (strict attribute access) instead of MagicMock (default-truthy), no
     DB mocking, no event loop, no parallel-state surface. Four small
     cases instead of two integration-style ones: enabled→starts,
     disabled→skips, URL-missing→skips, camera_type default 'mjpeg'.
2026-05-16 14:35:47 +02:00

110 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",
)
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