| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- """Chamber light for the camera (#1655).
- The light goes on for a camera use when the light mode covers the printer, stays on
- while anything still uses the camera, and goes off after a grace only if
- Bambuddy turned it on.
- """
- import asyncio
- import time
- from types import SimpleNamespace
- from unittest.mock import MagicMock
- import pytest
- from backend.app.services import camera_light as svc
- from backend.app.services.camera_light import camera_light
- pytestmark = pytest.mark.unit
- # The autouse fixture stubs the settings read; this is the real one.
- _real_read_settings = svc._read_settings
- GRACE = 0.05
- class LightSettings:
- """Stands in for reading the two app settings; counts the reads."""
- def __init__(self):
- self.mode = "all"
- self.delay = 0.0
- self.reads = 0
- async def __call__(self):
- self.reads += 1
- return self.mode, self.delay
- @pytest.fixture(autouse=True)
- def settings(monkeypatch):
- monkeypatch.setattr(svc, "_holders", {})
- monkeypatch.setattr(svc, "_lit_at", {})
- off_tasks: dict = {}
- monkeypatch.setattr(svc, "_off_tasks", off_tasks)
- monkeypatch.setattr(svc, "OFF_GRACE_SECONDS", GRACE)
- light = LightSettings()
- monkeypatch.setattr(svc, "_read_settings", light)
- monkeypatch.setattr(svc, "_settings_cache", None)
- yield light
- for task in off_tasks.values():
- task.cancel()
- class FakeClient:
- """A printer whose light state follows its commands, like a real report would."""
- def __init__(self, light_on: bool = False, reports: bool = True):
- self.state = SimpleNamespace(chamber_light=light_on)
- self.calls: list[bool] = []
- self._reports = reports
- def set_chamber_light(self, on: bool) -> bool:
- self.calls.append(on)
- if self._reports:
- self.state.chamber_light = on
- return True
- def _printer(selected=False, printer_id=1):
- return SimpleNamespace(id=printer_id, camera_light_auto=selected)
- @pytest.fixture
- def client(monkeypatch):
- fake = FakeClient()
- monkeypatch.setattr(svc, "_get_client", lambda _pid: fake)
- return fake
- async def _past_grace():
- await asyncio.sleep(GRACE * 3)
- class TestWhichPrinters:
- @pytest.mark.asyncio
- async def test_mode_off_never_switches(self, client, settings):
- settings.mode = "off"
- async with camera_light(_printer(selected=True)):
- pass
- await _past_grace()
- assert client.calls == []
- @pytest.mark.asyncio
- async def test_mode_all_switches_every_printer(self, client, settings):
- settings.mode = "all"
- async with camera_light(_printer(selected=False)):
- pass
- assert client.calls == [True]
- @pytest.mark.asyncio
- async def test_mode_selected_switches_only_picked_printers(self, client, settings):
- settings.mode = "selected"
- async with camera_light(_printer(selected=False)):
- pass
- assert client.calls == []
- async with camera_light(_printer(selected=True)):
- pass
- assert client.calls == [True]
- @pytest.mark.asyncio
- async def test_no_printer_is_a_no_op(self, client):
- async with camera_light(None):
- pass
- assert client.calls == []
- class TestTurnsOnAndOff:
- @pytest.mark.asyncio
- async def test_a_dark_printer_is_lit_for_the_picture_and_turned_off_after(self, client):
- async with camera_light(_printer()):
- assert client.calls == [True]
- assert client.calls == [True] # not yet: the grace runs first
- await _past_grace()
- assert client.calls == [True, False]
- @pytest.mark.asyncio
- async def test_a_light_that_was_already_on_is_left_alone(self, client, settings):
- client.state.chamber_light = True
- settings.delay = 5
- async with camera_light(_printer()):
- pass
- await _past_grace()
- assert client.calls == []
- @pytest.mark.asyncio
- async def test_the_light_stays_on_until_the_last_user_lets_go(self, client):
- printer = _printer()
- viewer = camera_light(printer, wait=False)
- await viewer.__aenter__()
- async with camera_light(printer):
- pass
- await _past_grace()
- assert client.calls == [True] # the viewer still watches
- await viewer.__aexit__(None, None, None)
- await _past_grace()
- assert client.calls == [True, False]
- @pytest.mark.asyncio
- async def test_coming_back_within_the_grace_keeps_the_light_on(self, client):
- # The camera wall polls each tile every 8 seconds; it must not flash.
- printer = _printer()
- async with camera_light(printer):
- pass
- async with camera_light(printer):
- pass
- assert client.calls == [True]
- await _past_grace()
- assert client.calls == [True, False]
- class TestTheDelay:
- @pytest.mark.asyncio
- async def test_a_snapshot_waits_after_turning_the_light_on(self, client, settings):
- settings.delay = 0.2
- start = time.monotonic()
- async with camera_light(_printer()):
- waited = time.monotonic() - start
- assert waited >= 0.2
- @pytest.mark.asyncio
- async def test_a_live_view_does_not_wait_either_way(self, client, settings):
- settings.delay = 5
- start = time.monotonic()
- async with camera_light(_printer(), wait=False):
- waited = time.monotonic() - start
- assert waited < 1
- @pytest.mark.asyncio
- @pytest.mark.asyncio
- async def test_a_second_snapshot_waits_only_for_what_is_left_and_sends_nothing(self, monkeypatch, settings):
- # The printer hasn't reported the light yet: the second picture trusts
- # our own command instead of sending another, and still waits for it.
- slow = FakeClient(reports=False)
- monkeypatch.setattr(svc, "_get_client", lambda _pid: slow)
- settings.delay = 0.3
- printer = _printer()
- first = asyncio.create_task(_hold(printer))
- await asyncio.sleep(0.1)
- start = time.monotonic()
- async with camera_light(printer):
- waited = time.monotonic() - start
- await first
- assert slow.calls == [True]
- assert 0.1 <= waited < 0.3
- async def _hold(printer):
- async with camera_light(printer):
- pass
- class TestTheUserDecides:
- @pytest.mark.asyncio
- async def test_switching_the_light_by_hand_keeps_it_on(self, client):
- printer = _printer()
- async with camera_light(printer):
- svc.hand_back(printer.id)
- await _past_grace()
- assert client.calls == [True]
- @pytest.mark.asyncio
- async def test_a_light_switched_off_at_the_printer_comes_back_for_the_next_picture(self, client, monkeypatch):
- monkeypatch.setattr(svc, "_REPORT_LAG_SECONDS", 0.05)
- printer = _printer()
- viewer = camera_light(printer, wait=False)
- await viewer.__aenter__()
- client.state.chamber_light = False # switched off on the printer's screen
- await asyncio.sleep(0.1)
- async with camera_light(printer):
- pass
- await viewer.__aexit__(None, None, None)
- await _past_grace()
- assert client.calls == [True, True, False]
- class TestFailuresNeverCostThePicture:
- @pytest.mark.asyncio
- async def test_a_disconnected_printer_still_gets_its_capture(self, monkeypatch, settings):
- monkeypatch.setattr(svc, "_get_client", lambda _pid: None)
- settings.delay = 5
- ran = False
- async with camera_light(_printer()):
- ran = True
- assert ran
- assert svc._holders == {}
- @pytest.mark.asyncio
- async def test_a_failing_light_command_still_gets_its_capture(self, monkeypatch, settings):
- broken = MagicMock()
- broken.state = SimpleNamespace(chamber_light=False)
- broken.set_chamber_light.side_effect = RuntimeError("mqtt down")
- monkeypatch.setattr(svc, "_get_client", lambda _pid: broken)
- settings.delay = 5
- ran = False
- async with camera_light(_printer()):
- ran = True
- assert ran
- assert svc._holders == {}
- @pytest.mark.asyncio
- async def test_a_capture_that_raises_still_lets_go_of_the_light(self, client):
- with pytest.raises(ValueError):
- async with camera_light(_printer()):
- raise ValueError("capture failed")
- await _past_grace()
- assert client.calls == [True, False]
- assert svc._holders == {}
- class TestTheSettings:
- @pytest.mark.asyncio
- async def test_settings_are_read_once_then_cached_until_saved(self, client, settings):
- async with camera_light(_printer()):
- pass
- async with camera_light(_printer()):
- pass
- assert settings.reads == 1
- svc.invalidate_settings()
- async with camera_light(_printer()):
- pass
- assert settings.reads == 2
- @pytest.mark.asyncio
- async def test_unreadable_settings_switch_nothing(self, client, monkeypatch):
- async def _broken():
- raise RuntimeError("database gone")
- monkeypatch.setattr(svc, "_read_settings", _broken)
- async with camera_light(_printer(selected=True)):
- pass
- assert client.calls == []
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("stored_mode", "stored_delay", "expected"),
- [
- ("all", "1.5", ("all", 1.5)),
- ("selected", None, ("selected", 2.0)),
- (None, None, ("off", 2.0)), # never saved
- ("sometimes", "x", ("off", 2.0)), # garbage reads as off
- ("all", "30", ("all", 5.0)), # never past the finish photo's budget
- ("all", "-3", ("all", 0.0)),
- ],
- )
- async def test_stored_values_are_parsed_safely(self, monkeypatch, stored_mode, stored_delay, expected):
- from contextlib import asynccontextmanager
- stored = {"camera_light_mode": stored_mode, "camera_light_delay": stored_delay}
- async def _get_setting(_db, key):
- return stored[key]
- @asynccontextmanager
- async def _session():
- yield object()
- monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
- monkeypatch.setattr("backend.app.core.database.async_session", _session)
- assert await _real_read_settings() == expected
|