test_layer_timelapse_expected_archive.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. """Regression test for #1353: layer timelapse must start for queue/VP-dispatched prints.
  2. Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
  3. print queue (so each print landed in the on_print_start "expected archive"
  4. branch). Frames were never captured, no MP4 was produced, yet the post-print
  5. log line said "Stitching layer timelapse for printer 1" — because
  6. `tl_complete()` ran, found no active session, and silently returned None.
  7. Root cause: only the two new-archive code paths in on_print_start
  8. (`fallback_archive` + `archive_print`) called `layer_timelapse.start_session`.
  9. The expected-archive branch — where reprints and queue dispatch land —
  10. updated the existing archive's status to "printing" but never started a
  11. timelapse session.
  12. Fix: the three start_session call sites in on_print_start were unified
  13. behind `_maybe_start_layer_timelapse(printer, printer_id, archive_id)`,
  14. which gates on the same `external_camera_enabled and external_camera_url`
  15. check. Testing the helper directly (instead of driving the whole
  16. on_print_start flow) keeps this regression locked in without dragging in
  17. unrelated side effects (plate detection, DB queries, MQTT relay, etc.).
  18. """
  19. from types import SimpleNamespace
  20. from unittest.mock import patch
  21. from backend.app.main import _maybe_start_layer_timelapse
  22. def _make_printer(*, external_camera_enabled: bool, external_camera_url: str | None):
  23. """Construct a minimal printer-shaped object with exactly the attributes
  24. the helper reads. SimpleNamespace is used over MagicMock so attribute
  25. access raises AttributeError on anything unexpected — keeps the test
  26. honest about which fields the helper actually depends on.
  27. """
  28. return SimpleNamespace(
  29. external_camera_enabled=external_camera_enabled,
  30. external_camera_url=external_camera_url,
  31. external_camera_type="snapshot",
  32. external_camera_snapshot_url=external_camera_url,
  33. )
  34. def test_starts_timelapse_when_external_camera_enabled():
  35. """Queue/VP-dispatched prints land in the expected-archive branch and must
  36. start the timelapse session there (the #1353 root cause). The helper is
  37. called from all three on_print_start paths (expected-archive promotion,
  38. fallback archive, fresh archive) so testing it once covers all three."""
  39. printer = _make_printer(
  40. external_camera_enabled=True,
  41. external_camera_url="http://camera.local:5000/snapshot.jpg",
  42. )
  43. with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
  44. started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=42)
  45. assert started is True
  46. mock_start_session.assert_called_once_with(
  47. 1,
  48. 42,
  49. "http://camera.local:5000/snapshot.jpg",
  50. "snapshot",
  51. snapshot_url="http://camera.local:5000/snapshot.jpg",
  52. )
  53. def test_skips_timelapse_when_external_camera_disabled():
  54. """The same guard that the new-archive paths use must hold here: no
  55. external camera → no timelapse session. Otherwise we'd try to capture
  56. from a None URL and crash the print-start flow."""
  57. printer = _make_printer(external_camera_enabled=False, external_camera_url=None)
  58. with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
  59. started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=99)
  60. assert started is False
  61. mock_start_session.assert_not_called()
  62. def test_skips_timelapse_when_url_missing_even_if_flag_set():
  63. """If the toggle is on but the URL field is empty (legacy / half-configured
  64. install), the guard must still hold — calling start_session with an empty
  65. URL would crash downstream when the capture thread tries to fetch frames."""
  66. printer = _make_printer(external_camera_enabled=True, external_camera_url=None)
  67. with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
  68. started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=7)
  69. assert started is False
  70. mock_start_session.assert_not_called()
  71. def test_camera_type_defaults_to_mjpeg_when_unset():
  72. """external_camera_type defaults to 'mjpeg' in start_session when the
  73. printer column is None — pre-existing contract preserved by the helper."""
  74. printer = SimpleNamespace(
  75. external_camera_enabled=True,
  76. external_camera_url="http://cam/feed",
  77. external_camera_type=None,
  78. external_camera_snapshot_url=None,
  79. )
  80. with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
  81. _maybe_start_layer_timelapse(printer, printer_id=2, archive_id=11)
  82. assert mock_start_session.called
  83. call_kwargs = mock_start_session.call_args.kwargs
  84. call_args = mock_start_session.call_args.args
  85. assert call_args[3] == "mjpeg"
  86. assert call_kwargs["snapshot_url"] is None