test_plate_check_chamber_light.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  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 as soon as the camera is done with it -- before the
  4. broadcast and the provider sends -- and also when anything on the way raises,
  5. or the printer is left lit for the rest of the print.
  6. """
  7. from types import SimpleNamespace
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from backend.app.main import (
  11. _active_prints,
  12. _expected_print_creators,
  13. _expected_print_registered_at,
  14. _expected_prints,
  15. _print_ams_mappings,
  16. _timelapse_baselines,
  17. )
  18. @pytest.fixture(autouse=True)
  19. def _clear_dicts():
  20. dicts = (
  21. _expected_prints,
  22. _expected_print_registered_at,
  23. _expected_print_creators,
  24. _print_ams_mappings,
  25. _active_prints,
  26. _timelapse_baselines,
  27. )
  28. for d in dicts:
  29. d.clear()
  30. BROADCASTS.clear()
  31. yield
  32. for d in dicts:
  33. d.clear()
  34. BROADCASTS: list[dict] = []
  35. def _printer():
  36. printer = MagicMock()
  37. printer.id = 1
  38. printer.name = "TestP1S"
  39. printer.plate_detection_enabled = True
  40. printer.plate_detection_roi_x = None
  41. printer.plate_detection_roi_y = None
  42. printer.plate_detection_roi_w = None
  43. printer.plate_detection_roi_h = None
  44. printer.auto_archive = False
  45. printer.external_camera_enabled = False
  46. printer.external_camera_url = None
  47. return printer
  48. async def _run_print_start(events: list[str], *, check_plate_empty, pause_side_effect=None):
  49. printer = _printer()
  50. client = MagicMock()
  51. client.state = SimpleNamespace(chamber_light=False)
  52. client.set_chamber_light = MagicMock(side_effect=lambda on: events.append(f"light:{'on' if on else 'off'}"))
  53. client.pause_print = MagicMock(side_effect=pause_side_effect or (lambda: events.append("pause")))
  54. def execute_router(stmt, *args, **kwargs):
  55. sql = str(stmt).lower()
  56. found = printer if "from printers" in sql else None
  57. return MagicMock(
  58. scalar_one_or_none=MagicMock(return_value=found),
  59. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[found] if found else []))),
  60. )
  61. session = AsyncMock()
  62. session.__aenter__ = AsyncMock(return_value=session)
  63. session.__aexit__ = AsyncMock()
  64. session.execute = AsyncMock(side_effect=execute_router)
  65. async def broadcast(message):
  66. events.append("broadcast")
  67. BROADCASTS.append(message)
  68. async def notify(**_kwargs):
  69. events.append("notify")
  70. with (
  71. patch("backend.app.main.async_session", return_value=session),
  72. patch("backend.app.main.notification_service") as mock_notif,
  73. patch("backend.app.main.smart_plug_manager") as mock_plug,
  74. patch("backend.app.main.ws_manager") as mock_ws,
  75. patch("backend.app.main.printer_manager") as mock_pm,
  76. patch("backend.app.main.mqtt_relay") as mock_relay,
  77. patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
  78. patch("backend.app.main._capture_snapshot_for_notification", new_callable=AsyncMock, return_value=b"jpeg"),
  79. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  80. patch("backend.app.main._load_objects_from_archive"),
  81. patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
  82. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  83. patch("backend.app.services.plate_detection.check_plate_empty", check_plate_empty),
  84. ):
  85. mock_notif.on_plate_not_empty = AsyncMock(side_effect=notify)
  86. mock_notif.on_print_start = AsyncMock()
  87. mock_plug.on_print_start = AsyncMock()
  88. mock_ws.broadcast = AsyncMock(side_effect=broadcast)
  89. mock_ws.send_print_start = AsyncMock()
  90. mock_ws.send_archive_updated = AsyncMock()
  91. mock_relay.on_print_start = AsyncMock()
  92. mock_pm.get_client = MagicMock(return_value=client)
  93. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  94. from backend.app.main import on_print_start
  95. await on_print_start(1, {"filename": "Benchy.3mf", "subtask_name": "Benchy"})
  96. def _objects_on_plate():
  97. return AsyncMock(
  98. return_value=SimpleNamespace(needs_calibration=False, is_empty=False, confidence=0.9, difference_percent=12.0)
  99. )
  100. @pytest.mark.asyncio
  101. async def test_light_goes_off_before_the_notifications_are_sent():
  102. events: list[str] = []
  103. await _run_print_start(events, check_plate_empty=_objects_on_plate())
  104. assert events.index("light:on") < events.index("light:off")
  105. assert events.index("light:off") < events.index("broadcast")
  106. assert events.index("light:off") < events.index("notify")
  107. @pytest.mark.asyncio
  108. async def test_light_goes_off_when_detection_raises():
  109. events: list[str] = []
  110. await _run_print_start(events, check_plate_empty=AsyncMock(side_effect=RuntimeError("camera gone")))
  111. assert events == ["light:on", "light:off", "broadcast"]
  112. @pytest.mark.asyncio
  113. async def test_light_goes_off_when_pausing_raises():
  114. events: list[str] = []
  115. await _run_print_start(
  116. events, check_plate_empty=_objects_on_plate(), pause_side_effect=RuntimeError("mqtt disconnected")
  117. )
  118. assert "light:off" in events
  119. assert "notify" not in events
  120. @pytest.mark.asyncio
  121. @pytest.mark.parametrize(
  122. ("check", "reason"),
  123. [
  124. (
  125. AsyncMock(
  126. return_value=SimpleNamespace(
  127. needs_calibration=False,
  128. is_empty=True,
  129. confidence=0.0,
  130. difference_percent=0.0,
  131. failure_reason="no_camera_frame",
  132. message="Failed to capture camera frame from any source",
  133. )
  134. ),
  135. "no_camera_frame",
  136. ),
  137. (AsyncMock(side_effect=RuntimeError("camera gone")), "error"),
  138. (
  139. AsyncMock(
  140. return_value=SimpleNamespace(
  141. needs_calibration=True,
  142. is_empty=True,
  143. confidence=0.0,
  144. difference_percent=0.0,
  145. failure_reason=None,
  146. message="No calibration - please calibrate with empty plate first",
  147. )
  148. ),
  149. "not_calibrated",
  150. ),
  151. ],
  152. )
  153. async def test_a_check_that_could_not_run_says_so(check, reason, caplog):
  154. """It still does not block the print, but it is no longer reported as an
  155. empty plate."""
  156. import logging
  157. events: list[str] = []
  158. with caplog.at_level(logging.WARNING):
  159. await _run_print_start(events, check_plate_empty=check)
  160. assert "pause" not in events
  161. assert [m for m in BROADCASTS if m["type"] == "plate_check_failed"] == [
  162. {"type": "plate_check_failed", "printer_id": 1, "printer_name": "TestP1S", "reason": reason}
  163. ]
  164. assert any("could not run" in r.message for r in caplog.records if r.levelno == logging.WARNING)
  165. assert not any("Plate is empty" in r.message for r in caplog.records)