test_layer_timelapse_expected_archive.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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: start_session is now called in the expected-archive branch too, guarded
  13. by the same `external_camera_enabled and external_camera_url` check that
  14. the other two paths use.
  15. """
  16. from unittest.mock import AsyncMock, MagicMock, patch
  17. import pytest
  18. from backend.app.main import (
  19. _active_prints,
  20. _expected_print_creators,
  21. _expected_print_registered_at,
  22. _expected_prints,
  23. _print_ams_mappings,
  24. register_expected_print,
  25. )
  26. @pytest.fixture(autouse=True)
  27. def _clear_dicts():
  28. """Clear module-level tracking dicts before and after each test."""
  29. _expected_prints.clear()
  30. _expected_print_registered_at.clear()
  31. _expected_print_creators.clear()
  32. _print_ams_mappings.clear()
  33. _active_prints.clear()
  34. yield
  35. _expected_prints.clear()
  36. _expected_print_registered_at.clear()
  37. _expected_print_creators.clear()
  38. _print_ams_mappings.clear()
  39. _active_prints.clear()
  40. def _build_mocks(*, external_camera_enabled: bool, external_camera_url: str | None):
  41. """Construct the mock matrix needed to drive on_print_start through the
  42. expected-archive branch. Returns a dict of mock contexts that the test
  43. enters via contextlib.ExitStack.
  44. The session.execute mock returns the printer for the first call (printer
  45. lookup) and the archive row for the second call (expected-archive
  46. re-fetch). The archive row carries a unique filename so the
  47. expected-print key lookup succeeds.
  48. """
  49. mock_printer = MagicMock()
  50. mock_printer.id = 1
  51. mock_printer.auto_archive = True
  52. mock_printer.external_camera_enabled = external_camera_enabled
  53. mock_printer.external_camera_url = external_camera_url
  54. mock_printer.external_camera_type = "snapshot"
  55. mock_printer.external_camera_snapshot_url = external_camera_url
  56. # Disable plate detection in the mock so on_print_start's plate-detection
  57. # block is skipped entirely. Plate detection isn't the subject under test
  58. # and its real code path tries to capture a frame — which fails differently
  59. # in CI (no ffmpeg) vs. local dev (ffmpeg present), and the CI-only path
  60. # somehow prevents the expected-archive branch's start_session from being
  61. # reached. MagicMock's default attribute access returns a truthy object,
  62. # so without this explicit False the production code enters plate detection.
  63. mock_printer.plate_detection_enabled = False
  64. mock_printer.name = "TestA1"
  65. mock_archive = MagicMock()
  66. mock_archive.id = 42
  67. mock_archive.filename = "Universal_Spirit_level_Holder.3mf"
  68. mock_archive.subtask_id = None
  69. mock_archive.print_time_seconds = None
  70. mock_archive.created_by_id = None
  71. mock_archive.printer_id = 1
  72. mock_archive.print_name = "Universal Spirit Level Holder"
  73. mock_archive.status = "pending"
  74. mock_archive.file_path = "/test/archives/fake.3mf"
  75. return mock_printer, mock_archive
  76. @pytest.mark.asyncio
  77. async def test_expected_archive_path_starts_timelapse_when_external_camera_enabled():
  78. """Queue/VP-dispatched prints land in the expected-archive branch and must
  79. start the timelapse session there (the #1353 root cause)."""
  80. mock_printer, mock_archive = _build_mocks(
  81. external_camera_enabled=True, external_camera_url="http://camera.local:5000/snapshot.jpg"
  82. )
  83. # Register the expected print so the dispatch flow finds an archive_id.
  84. register_expected_print(1, "Universal_Spirit_level_Holder.3mf", archive_id=42, ams_mapping=[1])
  85. # on_print_start fires many db.execute() calls (settings lookups,
  86. # usage tracker, plate detection, etc) before reaching the expected-
  87. # archive branch. Route on SQL text so each query gets a sensible
  88. # response regardless of order, rather than queuing N mocks.
  89. def execute_router(stmt, *args, **kwargs):
  90. sql = str(stmt).lower()
  91. if "from printers" in sql or "from printer " in sql:
  92. return MagicMock(
  93. scalar_one_or_none=MagicMock(return_value=mock_printer),
  94. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
  95. )
  96. if "from print_archives" in sql or "from print_archive" in sql:
  97. return MagicMock(
  98. scalar_one_or_none=MagicMock(return_value=mock_archive),
  99. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
  100. )
  101. # Settings, spool assignments, anything else — return empty.
  102. return MagicMock(
  103. scalar_one_or_none=MagicMock(return_value=None),
  104. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  105. )
  106. mock_session = AsyncMock()
  107. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  108. mock_session.__aexit__ = AsyncMock()
  109. mock_session.execute = AsyncMock(side_effect=execute_router)
  110. mock_session.commit = AsyncMock()
  111. with (
  112. patch("backend.app.main.async_session") as mock_session_maker,
  113. patch("backend.app.main.notification_service") as mock_notif,
  114. patch("backend.app.main.smart_plug_manager") as mock_plug,
  115. patch("backend.app.main.ws_manager") as mock_ws,
  116. patch("backend.app.main.printer_manager") as mock_pm,
  117. patch("backend.app.main.mqtt_relay") as mock_relay,
  118. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  119. patch("backend.app.main._load_objects_from_archive"),
  120. patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
  121. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  122. # The actual subject under test: assert start_session is called.
  123. patch("backend.app.services.layer_timelapse.start_session") as mock_start_session,
  124. ):
  125. mock_session_maker.return_value = mock_session
  126. mock_notif.on_print_start = AsyncMock()
  127. mock_plug.on_print_start = AsyncMock()
  128. mock_ws.send_print_start = AsyncMock()
  129. mock_ws.send_archive_updated = AsyncMock()
  130. mock_relay.on_print_start = AsyncMock()
  131. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  132. from backend.app.main import on_print_start
  133. await on_print_start(
  134. 1,
  135. {
  136. "filename": "Universal_Spirit_level_Holder.3mf",
  137. "subtask_name": "Universal_Spirit_level_Holder",
  138. },
  139. )
  140. mock_start_session.assert_called_once()
  141. # Verify it was called with the archive_id from the expected-print
  142. # registration, not a fresh one — that's the contract.
  143. call_args = mock_start_session.call_args
  144. assert call_args.args[0] == 1, "printer_id must match"
  145. assert call_args.args[1] == 42, "archive_id must come from the expected-print registration"
  146. assert call_args.args[2] == "http://camera.local:5000/snapshot.jpg"
  147. assert call_args.args[3] == "snapshot"
  148. @pytest.mark.asyncio
  149. async def test_expected_archive_path_skips_timelapse_when_external_camera_disabled():
  150. """The same guard that the new-archive paths use must hold here: no
  151. external camera → no timelapse session. Otherwise we'd try to capture
  152. from a None URL and crash the print-start flow."""
  153. mock_printer, mock_archive = _build_mocks(external_camera_enabled=False, external_camera_url=None)
  154. mock_archive.filename = "test.3mf"
  155. mock_archive.id = 99
  156. register_expected_print(1, "test.3mf", archive_id=99, ams_mapping=None)
  157. def execute_router(stmt, *args, **kwargs):
  158. sql = str(stmt).lower()
  159. if "from printers" in sql or "from printer " in sql:
  160. return MagicMock(
  161. scalar_one_or_none=MagicMock(return_value=mock_printer),
  162. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
  163. )
  164. if "from print_archives" in sql or "from print_archive" in sql:
  165. return MagicMock(
  166. scalar_one_or_none=MagicMock(return_value=mock_archive),
  167. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
  168. )
  169. return MagicMock(
  170. scalar_one_or_none=MagicMock(return_value=None),
  171. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  172. )
  173. mock_session = AsyncMock()
  174. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  175. mock_session.__aexit__ = AsyncMock()
  176. mock_session.execute = AsyncMock(side_effect=execute_router)
  177. mock_session.commit = AsyncMock()
  178. with (
  179. patch("backend.app.main.async_session") as mock_session_maker,
  180. patch("backend.app.main.notification_service") as mock_notif,
  181. patch("backend.app.main.smart_plug_manager") as mock_plug,
  182. patch("backend.app.main.ws_manager") as mock_ws,
  183. patch("backend.app.main.printer_manager") as mock_pm,
  184. patch("backend.app.main.mqtt_relay") as mock_relay,
  185. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  186. patch("backend.app.main._load_objects_from_archive"),
  187. patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
  188. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  189. patch("backend.app.services.layer_timelapse.start_session") as mock_start_session,
  190. ):
  191. mock_session_maker.return_value = mock_session
  192. mock_notif.on_print_start = AsyncMock()
  193. mock_plug.on_print_start = AsyncMock()
  194. mock_ws.send_print_start = AsyncMock()
  195. mock_ws.send_archive_updated = AsyncMock()
  196. mock_relay.on_print_start = AsyncMock()
  197. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  198. from backend.app.main import on_print_start
  199. await on_print_start(1, {"filename": "test.3mf", "subtask_name": "test"})
  200. mock_start_session.assert_not_called()