test_plate_check_chamber_light.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. """Plate check: the chamber light it switched on is always switched back off.
  2. on_print_start turns the chamber light on for plate detection when it was
  3. off. It must go back off once the camera is done with it -- also when
  4. anything on the way raises, or the printer is left lit for the rest of the
  5. print. It holds the light through the camera-light count rather than
  6. switching it itself (SP-C11), so it goes off after that count's grace, and
  7. not at all while a live view opened meanwhile still holds it.
  8. """
  9. import asyncio
  10. from types import SimpleNamespace
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from backend.app.main import (
  14. _active_prints,
  15. _expected_print_creators,
  16. _expected_print_registered_at,
  17. _expected_prints,
  18. _print_ams_mappings,
  19. _timelapse_baselines,
  20. )
  21. @pytest.fixture(autouse=True)
  22. def _light_count(monkeypatch):
  23. from backend.app.services import camera_light as svc
  24. monkeypatch.setattr(svc, "_holders", {})
  25. monkeypatch.setattr(svc, "_lit_at", {})
  26. monkeypatch.setattr(svc, "_off_tasks", {})
  27. monkeypatch.setattr(svc, "OFF_GRACE_SECONDS", 0)
  28. # The camera-light mode is off: the plate check lights the chamber anyway.
  29. monkeypatch.setattr(svc, "_light_settings", AsyncMock(return_value=("off", 2.0)))
  30. @pytest.fixture(autouse=True)
  31. def _clear_dicts():
  32. dicts = (
  33. _expected_prints,
  34. _expected_print_registered_at,
  35. _expected_print_creators,
  36. _print_ams_mappings,
  37. _active_prints,
  38. _timelapse_baselines,
  39. )
  40. for d in dicts:
  41. d.clear()
  42. BROADCASTS.clear()
  43. yield
  44. for d in dicts:
  45. d.clear()
  46. BROADCASTS: list[dict] = []
  47. def _printer():
  48. printer = MagicMock()
  49. printer.id = 1
  50. printer.name = "TestP1S"
  51. printer.plate_detection_enabled = True
  52. printer.plate_detection_roi_x = None
  53. printer.plate_detection_roi_y = None
  54. printer.plate_detection_roi_w = None
  55. printer.plate_detection_roi_h = None
  56. printer.auto_archive = False
  57. printer.external_camera_enabled = False
  58. printer.external_camera_url = None
  59. return printer
  60. async def _run_print_start(events: list[str], *, check_plate_empty, pause_side_effect=None):
  61. printer = _printer()
  62. async def snapshot(*_args):
  63. events.append("snapshot")
  64. return b"jpeg"
  65. client = MagicMock()
  66. client.state = SimpleNamespace(chamber_light=False)
  67. def set_light(on: bool) -> bool:
  68. events.append(f"light:{'on' if on else 'off'}")
  69. return True
  70. client.set_chamber_light = MagicMock(side_effect=set_light)
  71. client.pause_print = MagicMock(side_effect=pause_side_effect or (lambda: events.append("pause")))
  72. def execute_router(stmt, *args, **kwargs):
  73. sql = str(stmt).lower()
  74. found = printer if "from printers" in sql else None
  75. return MagicMock(
  76. scalar_one_or_none=MagicMock(return_value=found),
  77. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[found] if found else []))),
  78. )
  79. session = AsyncMock()
  80. session.__aenter__ = AsyncMock(return_value=session)
  81. session.__aexit__ = AsyncMock()
  82. session.execute = AsyncMock(side_effect=execute_router)
  83. async def broadcast(message):
  84. events.append("broadcast")
  85. BROADCASTS.append(message)
  86. async def notify(**_kwargs):
  87. events.append("notify")
  88. with (
  89. patch("backend.app.main.async_session", return_value=session),
  90. patch("backend.app.main.notification_service") as mock_notif,
  91. patch("backend.app.main.smart_plug_manager") as mock_plug,
  92. patch("backend.app.main.ws_manager") as mock_ws,
  93. patch("backend.app.main.printer_manager") as mock_pm,
  94. patch("backend.app.main.mqtt_relay") as mock_relay,
  95. patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
  96. patch("backend.app.main._capture_snapshot_for_notification", side_effect=snapshot),
  97. patch("backend.app.services.camera_light._get_client", return_value=client),
  98. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  99. patch("backend.app.main._load_objects_from_archive"),
  100. patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
  101. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  102. patch("backend.app.services.plate_detection.check_plate_empty", check_plate_empty),
  103. ):
  104. mock_notif.on_plate_not_empty = AsyncMock(side_effect=notify)
  105. mock_notif.on_print_start = AsyncMock()
  106. mock_plug.on_print_start = AsyncMock()
  107. mock_ws.broadcast = AsyncMock(side_effect=broadcast)
  108. mock_ws.send_print_start = AsyncMock()
  109. mock_ws.send_archive_updated = AsyncMock()
  110. mock_relay.on_print_start = AsyncMock()
  111. mock_pm.get_client = MagicMock(return_value=client)
  112. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  113. from backend.app.main import on_print_start
  114. await on_print_start(1, {"filename": "Benchy.3mf", "subtask_name": "Benchy"})
  115. # The light goes off after the camera-light grace (0 here).
  116. from backend.app.services import camera_light as svc
  117. await asyncio.gather(*svc._off_tasks.values())
  118. def _objects_on_plate():
  119. return AsyncMock(
  120. return_value=SimpleNamespace(needs_calibration=False, is_empty=False, confidence=0.9, difference_percent=12.0)
  121. )
  122. @pytest.mark.asyncio
  123. async def test_light_is_on_for_the_photo_and_goes_off_after():
  124. events: list[str] = []
  125. await _run_print_start(events, check_plate_empty=_objects_on_plate())
  126. assert events.index("light:on") < events.index("snapshot") < events.index("light:off")
  127. assert events.count("light:off") == 1
  128. @pytest.mark.asyncio
  129. async def test_light_goes_off_when_detection_raises():
  130. events: list[str] = []
  131. await _run_print_start(events, check_plate_empty=AsyncMock(side_effect=RuntimeError("camera gone")))
  132. assert events.count("light:on") == events.count("light:off") == 1
  133. assert events.index("light:on") < events.index("light:off")
  134. @pytest.mark.asyncio
  135. async def test_a_live_view_opened_during_the_check_keeps_the_light():
  136. """SP-C11: the viewer found the light already on and so never took it;
  137. the check then switched it off under them."""
  138. from backend.app.services.camera_light import camera_light
  139. events: list[str] = []
  140. viewer = camera_light(_printer(), wait=False)
  141. async def check_while_a_viewer_arrives(**_kwargs):
  142. await viewer.__aenter__()
  143. return SimpleNamespace(needs_calibration=False, is_empty=True, confidence=0.9, difference_percent=0.0)
  144. with patch("backend.app.services.camera_light._light_settings", AsyncMock(return_value=("all", 0.0))):
  145. await _run_print_start(events, check_plate_empty=AsyncMock(side_effect=check_while_a_viewer_arrives))
  146. assert "light:on" in events
  147. assert "light:off" not in events
  148. await viewer.__aexit__(None, None, None)
  149. @pytest.mark.asyncio
  150. async def test_light_goes_off_when_pausing_raises():
  151. events: list[str] = []
  152. await _run_print_start(
  153. events, check_plate_empty=_objects_on_plate(), pause_side_effect=RuntimeError("mqtt disconnected")
  154. )
  155. assert "light:off" in events
  156. assert "notify" not in events
  157. @pytest.mark.asyncio
  158. @pytest.mark.parametrize(
  159. ("check", "reason"),
  160. [
  161. (
  162. AsyncMock(
  163. return_value=SimpleNamespace(
  164. needs_calibration=False,
  165. is_empty=True,
  166. confidence=0.0,
  167. difference_percent=0.0,
  168. failure_reason="no_camera_frame",
  169. message="Failed to capture camera frame from any source",
  170. )
  171. ),
  172. "no_camera_frame",
  173. ),
  174. (AsyncMock(side_effect=RuntimeError("camera gone")), "error"),
  175. (
  176. AsyncMock(
  177. return_value=SimpleNamespace(
  178. needs_calibration=True,
  179. is_empty=True,
  180. confidence=0.0,
  181. difference_percent=0.0,
  182. failure_reason=None,
  183. message="No calibration - please calibrate with empty plate first",
  184. )
  185. ),
  186. "not_calibrated",
  187. ),
  188. ],
  189. )
  190. async def test_a_check_that_could_not_run_says_so(check, reason, caplog):
  191. """It still does not block the print, but it is no longer reported as an
  192. empty plate."""
  193. import logging
  194. events: list[str] = []
  195. with caplog.at_level(logging.WARNING):
  196. await _run_print_start(events, check_plate_empty=check)
  197. assert "pause" not in events
  198. assert [m for m in BROADCASTS if m["type"] == "plate_check_failed"] == [
  199. {"type": "plate_check_failed", "printer_id": 1, "printer_name": "TestP1S", "reason": reason}
  200. ]
  201. assert any("could not run" in r.message for r in caplog.records if r.levelno == logging.WARNING)
  202. assert not any("Plate is empty" in r.message for r in caplog.records)