test_camera_light.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. """Chamber light for the camera (#1655).
  2. The light goes on for a camera use when the light mode covers the printer, stays on
  3. while anything still uses the camera, and goes off after a grace only if
  4. Bambuddy turned it on.
  5. """
  6. import asyncio
  7. import time
  8. from types import SimpleNamespace
  9. from unittest.mock import MagicMock
  10. import pytest
  11. from backend.app.services import camera_light as svc
  12. from backend.app.services.camera_light import camera_light
  13. pytestmark = pytest.mark.unit
  14. # The autouse fixture stubs the settings read; this is the real one.
  15. _real_read_settings = svc._read_settings
  16. GRACE = 0.05
  17. class LightSettings:
  18. """Stands in for reading the two app settings; counts the reads."""
  19. def __init__(self):
  20. self.mode = "all"
  21. self.delay = 0.0
  22. self.reads = 0
  23. async def __call__(self):
  24. self.reads += 1
  25. return self.mode, self.delay
  26. @pytest.fixture(autouse=True)
  27. def settings(monkeypatch):
  28. monkeypatch.setattr(svc, "_holders", {})
  29. monkeypatch.setattr(svc, "_lit_at", {})
  30. off_tasks: dict = {}
  31. monkeypatch.setattr(svc, "_off_tasks", off_tasks)
  32. monkeypatch.setattr(svc, "OFF_GRACE_SECONDS", GRACE)
  33. light = LightSettings()
  34. monkeypatch.setattr(svc, "_read_settings", light)
  35. monkeypatch.setattr(svc, "_settings_cache", None)
  36. yield light
  37. for task in off_tasks.values():
  38. task.cancel()
  39. class FakeClient:
  40. """A printer whose light state follows its commands, like a real report would."""
  41. def __init__(self, light_on: bool = False, reports: bool = True):
  42. self.state = SimpleNamespace(chamber_light=light_on)
  43. self.calls: list[bool] = []
  44. self._reports = reports
  45. def set_chamber_light(self, on: bool) -> bool:
  46. self.calls.append(on)
  47. if self._reports:
  48. self.state.chamber_light = on
  49. return True
  50. def _printer(selected=False, printer_id=1):
  51. return SimpleNamespace(id=printer_id, camera_light_auto=selected)
  52. @pytest.fixture
  53. def client(monkeypatch):
  54. fake = FakeClient()
  55. monkeypatch.setattr(svc, "_get_client", lambda _pid: fake)
  56. return fake
  57. async def _past_grace():
  58. await asyncio.sleep(GRACE * 3)
  59. class TestWhichPrinters:
  60. @pytest.mark.asyncio
  61. async def test_mode_off_never_switches(self, client, settings):
  62. settings.mode = "off"
  63. async with camera_light(_printer(selected=True)):
  64. pass
  65. await _past_grace()
  66. assert client.calls == []
  67. @pytest.mark.asyncio
  68. async def test_mode_all_switches_every_printer(self, client, settings):
  69. settings.mode = "all"
  70. async with camera_light(_printer(selected=False)):
  71. pass
  72. assert client.calls == [True]
  73. @pytest.mark.asyncio
  74. async def test_mode_selected_switches_only_picked_printers(self, client, settings):
  75. settings.mode = "selected"
  76. async with camera_light(_printer(selected=False)):
  77. pass
  78. assert client.calls == []
  79. async with camera_light(_printer(selected=True)):
  80. pass
  81. assert client.calls == [True]
  82. @pytest.mark.asyncio
  83. async def test_no_printer_is_a_no_op(self, client):
  84. async with camera_light(None):
  85. pass
  86. assert client.calls == []
  87. class TestTurnsOnAndOff:
  88. @pytest.mark.asyncio
  89. async def test_a_dark_printer_is_lit_for_the_picture_and_turned_off_after(self, client):
  90. async with camera_light(_printer()):
  91. assert client.calls == [True]
  92. assert client.calls == [True] # not yet: the grace runs first
  93. await _past_grace()
  94. assert client.calls == [True, False]
  95. @pytest.mark.asyncio
  96. async def test_a_light_that_was_already_on_is_left_alone(self, client, settings):
  97. client.state.chamber_light = True
  98. settings.delay = 5
  99. async with camera_light(_printer()):
  100. pass
  101. await _past_grace()
  102. assert client.calls == []
  103. @pytest.mark.asyncio
  104. async def test_the_light_stays_on_until_the_last_user_lets_go(self, client):
  105. printer = _printer()
  106. viewer = camera_light(printer, wait=False)
  107. await viewer.__aenter__()
  108. async with camera_light(printer):
  109. pass
  110. await _past_grace()
  111. assert client.calls == [True] # the viewer still watches
  112. await viewer.__aexit__(None, None, None)
  113. await _past_grace()
  114. assert client.calls == [True, False]
  115. @pytest.mark.asyncio
  116. async def test_coming_back_within_the_grace_keeps_the_light_on(self, client):
  117. # The camera wall polls each tile every 8 seconds; it must not flash.
  118. printer = _printer()
  119. async with camera_light(printer):
  120. pass
  121. async with camera_light(printer):
  122. pass
  123. assert client.calls == [True]
  124. await _past_grace()
  125. assert client.calls == [True, False]
  126. class TestTheDelay:
  127. @pytest.mark.asyncio
  128. async def test_a_snapshot_waits_after_turning_the_light_on(self, client, settings):
  129. settings.delay = 0.2
  130. start = time.monotonic()
  131. async with camera_light(_printer()):
  132. waited = time.monotonic() - start
  133. assert waited >= 0.2
  134. @pytest.mark.asyncio
  135. async def test_a_live_view_does_not_wait_either_way(self, client, settings):
  136. settings.delay = 5
  137. start = time.monotonic()
  138. async with camera_light(_printer(), wait=False):
  139. waited = time.monotonic() - start
  140. assert waited < 1
  141. @pytest.mark.asyncio
  142. @pytest.mark.asyncio
  143. async def test_a_second_snapshot_waits_only_for_what_is_left_and_sends_nothing(self, monkeypatch, settings):
  144. # The printer hasn't reported the light yet: the second picture trusts
  145. # our own command instead of sending another, and still waits for it.
  146. slow = FakeClient(reports=False)
  147. monkeypatch.setattr(svc, "_get_client", lambda _pid: slow)
  148. settings.delay = 0.3
  149. printer = _printer()
  150. first = asyncio.create_task(_hold(printer))
  151. await asyncio.sleep(0.1)
  152. start = time.monotonic()
  153. async with camera_light(printer):
  154. waited = time.monotonic() - start
  155. await first
  156. assert slow.calls == [True]
  157. assert 0.1 <= waited < 0.3
  158. async def _hold(printer):
  159. async with camera_light(printer):
  160. pass
  161. class TestTheUserDecides:
  162. @pytest.mark.asyncio
  163. async def test_switching_the_light_by_hand_keeps_it_on(self, client):
  164. printer = _printer()
  165. async with camera_light(printer):
  166. svc.hand_back(printer.id)
  167. await _past_grace()
  168. assert client.calls == [True]
  169. @pytest.mark.asyncio
  170. async def test_a_light_switched_off_at_the_printer_comes_back_for_the_next_picture(self, client, monkeypatch):
  171. monkeypatch.setattr(svc, "_REPORT_LAG_SECONDS", 0.05)
  172. printer = _printer()
  173. viewer = camera_light(printer, wait=False)
  174. await viewer.__aenter__()
  175. client.state.chamber_light = False # switched off on the printer's screen
  176. await asyncio.sleep(0.1)
  177. async with camera_light(printer):
  178. pass
  179. await viewer.__aexit__(None, None, None)
  180. await _past_grace()
  181. assert client.calls == [True, True, False]
  182. class TestFailuresNeverCostThePicture:
  183. @pytest.mark.asyncio
  184. async def test_a_disconnected_printer_still_gets_its_capture(self, monkeypatch, settings):
  185. monkeypatch.setattr(svc, "_get_client", lambda _pid: None)
  186. settings.delay = 5
  187. ran = False
  188. async with camera_light(_printer()):
  189. ran = True
  190. assert ran
  191. assert svc._holders == {}
  192. @pytest.mark.asyncio
  193. async def test_a_failing_light_command_still_gets_its_capture(self, monkeypatch, settings):
  194. broken = MagicMock()
  195. broken.state = SimpleNamespace(chamber_light=False)
  196. broken.set_chamber_light.side_effect = RuntimeError("mqtt down")
  197. monkeypatch.setattr(svc, "_get_client", lambda _pid: broken)
  198. settings.delay = 5
  199. ran = False
  200. async with camera_light(_printer()):
  201. ran = True
  202. assert ran
  203. assert svc._holders == {}
  204. @pytest.mark.asyncio
  205. async def test_a_capture_that_raises_still_lets_go_of_the_light(self, client):
  206. with pytest.raises(ValueError):
  207. async with camera_light(_printer()):
  208. raise ValueError("capture failed")
  209. await _past_grace()
  210. assert client.calls == [True, False]
  211. assert svc._holders == {}
  212. class TestTheSettings:
  213. @pytest.mark.asyncio
  214. async def test_settings_are_read_once_then_cached_until_saved(self, client, settings):
  215. async with camera_light(_printer()):
  216. pass
  217. async with camera_light(_printer()):
  218. pass
  219. assert settings.reads == 1
  220. svc.invalidate_settings()
  221. async with camera_light(_printer()):
  222. pass
  223. assert settings.reads == 2
  224. @pytest.mark.asyncio
  225. async def test_unreadable_settings_switch_nothing(self, client, monkeypatch):
  226. async def _broken():
  227. raise RuntimeError("database gone")
  228. monkeypatch.setattr(svc, "_read_settings", _broken)
  229. async with camera_light(_printer(selected=True)):
  230. pass
  231. assert client.calls == []
  232. @pytest.mark.asyncio
  233. @pytest.mark.parametrize(
  234. ("stored_mode", "stored_delay", "expected"),
  235. [
  236. ("all", "1.5", ("all", 1.5)),
  237. ("selected", None, ("selected", 2.0)),
  238. (None, None, ("off", 2.0)), # never saved
  239. ("sometimes", "x", ("off", 2.0)), # garbage reads as off
  240. ("all", "30", ("all", 5.0)), # never past the finish photo's budget
  241. ("all", "-3", ("all", 0.0)),
  242. ],
  243. )
  244. async def test_stored_values_are_parsed_safely(self, monkeypatch, stored_mode, stored_delay, expected):
  245. from contextlib import asynccontextmanager
  246. stored = {"camera_light_mode": stored_mode, "camera_light_delay": stored_delay}
  247. async def _get_setting(_db, key):
  248. return stored[key]
  249. @asynccontextmanager
  250. async def _session():
  251. yield object()
  252. monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
  253. monkeypatch.setattr("backend.app.core.database.async_session", _session)
  254. assert await _real_read_settings() == expected