"""Runtime camera add/remove on a live Engine — stubbed so no models load.""" import threading import time import pytest from behavision.cameras import CameraStore from behavision.config import CameraConfig, Config from behavision.engine import Engine class FakeWorker: def __init__(self, cam_cfg, *a, **kw): self.cam_cfg = cam_cfg self.started = self.stopped = self.joined = False self._alive = False # Faithful to threading.Thread: stop() only signals - the thread stays # alive until its loop exits, and join() on a never-started Thread raises. def start(self): self.started = self._alive = True def stop(self): self.stopped = True def is_alive(self): return self._alive def join(self, timeout=None): if not self.started: raise RuntimeError("cannot join thread before it is started") self.joined = True self._alive = False def stats(self): return {"camera_id": self.cam_cfg.id} @pytest.fixture def engine(tmp_path, monkeypatch): """Engine without __init__ — no ONNX, no camera, no gallery.""" monkeypatch.setattr("behavision.engine.CameraWorker", FakeWorker) monkeypatch.setattr("behavision.engine.FaceDetector", lambda *a, **kw: object()) eng = object.__new__(Engine) eng.cfg = Config() eng.cfg.app.data_dir = tmp_path eng.encoder = eng.gallery = eng.bus = eng.attributes = None eng._lock = threading.RLock() eng.workers, eng.detectors = {}, {} eng.started_at, eng._running = None, True eng.camera_store = CameraStore(tmp_path / "cameras.json") return eng def cam(cid): return CameraConfig(id=cid, host="10.0.0.1") def test_add_camera_starts_it_on_a_running_engine(engine): w = engine.add_camera(cam("a")) assert w.started and engine.workers["a"] is w assert "a" in engine.detectors def test_each_camera_gets_its_own_detector(engine): """Sharing one cv2.FaceDetectorYN across workers races on input size.""" engine.add_camera(cam("a")) engine.add_camera(cam("b")) assert engine.detectors["a"] is not engine.detectors["b"] def test_duplicate_camera_id_rejected(engine): engine.add_camera(cam("a")) with pytest.raises(ValueError, match="already running"): engine.add_camera(cam("a")) def test_remove_camera_stops_and_joins(engine): w = engine.add_camera(cam("a")) assert engine.remove_camera("a") assert w.stopped and w.joined assert "a" not in engine.workers and "a" not in engine.detectors assert not engine.remove_camera("a") def test_restart_builds_a_new_worker(engine): """CameraWorker is a Thread; a stopped Thread cannot be restarted, so an edited URL must produce a fresh object.""" first = engine.add_camera(cam("a")) second = engine.restart_camera(CameraConfig(id="a", host="10.0.0.99")) assert second is not first assert first.stopped and second.started assert engine.workers["a"].cam_cfg.host == "10.0.0.99" def test_camera_added_before_start_is_not_started_yet(engine): engine._running = False w = engine.add_camera(cam("a")) assert not w.started def test_removing_a_never_started_camera_does_not_raise(engine): """join() on an unstarted Thread raises RuntimeError - the is_alive() guard in remove_camera is what prevents it.""" engine._running = False w = engine.add_camera(cam("a")) assert engine.remove_camera("a") assert w.stopped and not w.joined def test_stats_tolerates_a_concurrent_remove(engine): """snapshot_workers() exists so stats() cannot trip over a dict being mutated by an API thread mid-iteration.""" for i in range(40): engine.add_camera(cam(f"c{i}")) errors = [] def churn(): try: for i in range(40): engine.remove_camera(f"c{i}") time.sleep(0.001) except Exception as exc: # noqa: BLE001 errors.append(exc) def read(): try: for _ in range(200): [w.stats() for w in engine.snapshot_workers()] except Exception as exc: # noqa: BLE001 errors.append(exc) ts = [threading.Thread(target=churn), threading.Thread(target=read)] for t in ts: t.start() for t in ts: t.join() assert not errors, errors # ---------------------------------------------------------------- real threads # Every test above stubs the worker, which is why a real threading bug lived # here undetected: `join()` on a started CameraWorker raised # "'Event' object is not callable", so removing or editing a camera at runtime # answered 500 - the exact path head office uses to push a camera to a shop PC. # These two use the real classes and the real Thread machinery. def test_a_started_capture_thread_can_be_stopped_and_joined(): from behavision.capture import VideoSource src = VideoSource("cam1", "rtsp://127.0.0.1:1/none", "rtsp://127.0.0.1:1/none", 1280) src.start() time.sleep(0.05) src.stop() src.join(timeout=5) assert not src.is_alive() def test_a_started_camera_worker_can_be_stopped_and_joined(tmp_path): from behavision.engine import CameraWorker cfg = Config() cfg.app.data_dir = tmp_path cam = CameraConfig(id="cam1", host="127.0.0.1", port=1, path="/none") # The collaborators are never reached: the source cannot connect, so the # loop only ever sees `frame is None`. w = CameraWorker(cam, cfg, detector=None, encoder=None, gallery=None, bus=None, attrs=None) w.start() time.sleep(0.05) w.stop() w.join(timeout=5) assert not w.is_alive()