pompom / tests /test_runtime.py
sy
Create PomPom standalone Reachy Mini focus companion
3d6e922
Raw History Blame Contribute Delete
8.49 kB
import threading
from concurrent.futures import Future
import pytest
from support import Clock, snapshot
from pompom.api import ApiError
from pompom.config import Settings, SettingsStore
from pompom.runtime import Runtime
class Hardware:
def __init__(self):
self.captures = self.notifications = self.cancels = 0
def capabilities(self):
return {"camera": True, "actions": ["reminder", "antenna_wiggle"]}
def capture(self):
self.captures += 1
return b"jpeg"
def notify(self, settings):
self.notifications += 1
return [{"action": "reminder", "status": "started"}]
def celebrate(self, settings):
return [{"action": "cheerful1", "status": "started"}]
def cancel(self):
self.cancels += 1
def step(self):
pass
class Api:
def __init__(self):
self.controls = []
self.counter = 0
self.frames = []
def start(self, settings):
self.counter += 1
return snapshot("session-" + str(self.counter), next_capture_after_ms=0)
def control(self, settings, sid, action):
self.controls.append((sid, action))
return snapshot(
sid,
state={"pause": "paused", "resume": "observing", "end": "ended"}[action],
)
def frame(self, settings, sid, frame_id, jpeg):
self.frames.append(frame_id)
return snapshot(sid)
class DancingHardware(Hardware):
def __init__(self):
super().__init__()
self.dancing = True
self.dance_targets = 0
def is_dancing(self):
return self.dancing
def notify(self, settings):
self.dancing = False
return super().notify(settings)
def cancel(self):
self.dancing = False
super().cancel()
def set_focus_progress(self, seconds):
if seconds < 10:
self.dancing = False
def step(self):
self.dance_targets += int(self.dancing)
@pytest.fixture
def runtime(tmp_path):
store = SettingsStore(tmp_path / "settings.json")
store.save(Settings(api_key="test-secret"))
r = Runtime(Hardware(), Api(), store, Clock())
yield r
r.close()
def test_controls_require_consent_and_token(runtime):
with pytest.raises(ValueError):
runtime.control("start")
runtime.configure({"api_key": ""})
with pytest.raises(ValueError):
runtime.control("start", consent=True)
def test_phone_release_executes_happy_once(runtime):
r = runtime
r.control("start", consent=True)
for phone in (1, 0, 0):
f = Future()
f.set_result(
snapshot(
phone_probability=phone,
observable_probability=1,
reason="phone_use_suspected" if phone else "phone_use_not_detected",
)
)
r.pending, r.pending_generation = f, r.session.generation
r.step()
events = [e for e in r.session.events if e["event"] == "phone_put_down"]
assert len(events) == 1
assert events[0]["actions"] == [{"action": "cheerful1", "status": "started"}]
@pytest.mark.parametrize(
"interruption", ["phone", "unknown", "failure", "paused", "ended"]
)
def test_first_phone_result_interrupts_dance_before_next_motion_target(
runtime, interruption
):
r = runtime
r.control("start", consent=True)
r.hardware = DancingHardware()
f = Future()
r.pending, r.pending_generation = f, r.session.generation
result = snapshot(
state="suspected",
reason="phone_use_suspected",
phone_probability=0.9,
observable_probability=1,
notify=False,
continuous_hold_ms=0,
)
if interruption in ("paused", "ended"):
r.control("pause" if interruption == "paused" else "end")
if interruption == "failure":
f.set_exception(ApiError("timeout"))
else:
if interruption == "unknown":
result |= {
"state": "unknown",
"reason": "insufficient_visibility",
"observable_probability": 0.1,
}
f.set_result(result)
r.step()
assert not r.hardware.dancing
assert r.hardware.dance_targets == 0
assert r.hardware.notifications == (1 if interruption == "phone" else 0)
assert r.session.notification_count == 0 # We did not wait for a server notify.
@pytest.mark.parametrize(
"change", ["pause", "end", "settings", "new_session", "deadline"]
)
def test_old_frame_never_notifies(runtime, change):
r = runtime
r.control("start", consent=True)
sid = r.session.session_id
f = Future()
r.pending, r.pending_generation = f, r.session.generation
if change == "settings":
r.configure({"motion_enabled": True})
elif change == "new_session":
r.control("end")
r.control("start", consent=True)
elif change == "deadline":
r.session.clock.now += 1500
else:
r.control(change)
f.set_result(snapshot(sid, notify=True, notification_id="notice-1"))
r.step()
assert r.hardware.notifications == 0
def test_server_delay_is_used_without_extra_local_interval(runtime):
r = runtime
r.control("start", consent=True)
f = Future()
f.set_result(snapshot(next_capture_after_ms=250))
r.pending, r.pending_generation = f, r.session.generation
r.step()
assert r.next_capture == r.session.clock() + 0.25
assert r.hardware.captures == 0
def test_only_one_inflight_and_stop_stays_responsive(runtime):
r = runtime
entered, release = threading.Event(), threading.Event()
def frame(settings, sid, fid, jpeg):
entered.set()
assert release.wait(3)
return snapshot(sid, notify=True, notification_id="late")
r.api.frame = frame
r.control("start", consent=True)
r.step()
assert entered.wait(2)
for _ in range(10):
r.session.clock.now += 1
r.step()
assert r.hardware.captures == 1
r.control("pause")
release.set()
r.pending.result(timeout=2)
r.step()
assert r.hardware.notifications == 0 and r.hardware.captures == 1
def test_failed_pause_still_stops_capture(runtime):
r = runtime
r.control("start", consent=True)
def fail(*args):
raise ApiError("connection")
r.api.control = fail
with pytest.raises(ValueError):
r.control("pause")
r.step()
assert r.session.state == "paused" and r.hardware.captures == 0
def test_end_during_slow_start_closes_remote_session(runtime):
r = runtime
entered, release = threading.Event(), threading.Event()
def slow(settings):
entered.set()
assert release.wait(3)
return snapshot()
r.api.start = slow
thread = threading.Thread(target=lambda: r.control("start", consent=True))
thread.start()
assert entered.wait(2)
# No session id exists yet; stop locally without waiting for the pending start.
# control/end can wait for serialized commands, so execute it on another thread.
ended = threading.Event()
locally_stopped = threading.Event()
original_cancel = r.hardware.cancel
def cancelled():
original_cancel()
locally_stopped.set()
r.hardware.cancel = cancelled
def end():
r.control("end")
ended.set()
t = threading.Thread(target=end)
t.start()
assert locally_stopped.wait(2)
with r.lock:
assert r.session.state == "ended"
release.set()
thread.join(3)
t.join(3)
r.cleanup_pool.submit(lambda: None).result(timeout=3)
assert ended.is_set() and ("session-1", "end") in r.api.controls
assert r.hardware.captures == 0
def test_settings_owner_only_no_token_echo_and_preview_local(runtime):
assert runtime.preview() == b"jpeg"
runtime.configure({"motion_enabled": True})
assert runtime.store.path.stat().st_mode & 0o777 == 0o600
assert "test-secret" not in str(runtime.snapshot())
assert "test-secret" not in repr(runtime.session.settings)
runtime.control("start", consent=True)
with pytest.raises(ValueError):
runtime.preview()
def test_queued_capture_cancelled_and_local_timer_runs_without_frames(runtime):
r = runtime
r.control("start", consent=True)
g = r.session.generation
r.session.clock.now += 1500
r.step()
assert r.session.state == "completed"
assert r._observe(g, r.session.settings, "session-1") is None
assert r.hardware.captures == r.hardware.notifications == 0