"""The motion gate must save CPU without ever losing a face. Cheapness is easy; the property that makes it acceptable is that every way it could miss somebody is closed. These tests are that argument, written down. """ import numpy as np import pytest from behavision.config import Config from behavision.engine import CameraWorker class _Worker: """CameraWorker's gate, without a camera, a model or a thread. Built with object.__new__ and the two attributes the gate touches - the pattern the other engine tests already use, so no test needs a 166 MB model file or a live RTSP stream. """ def __new__(cls, **app): w = object.__new__(CameraWorker) w.cfg = Config() for k, v in app.items(): setattr(w.cfg.app, k, v) w._motion_prev = None w._motion_skipped = 0 return w def frame(value: int, size=(90, 160)) -> np.ndarray: return np.full((*size, 3), value, dtype=np.uint8) def test_the_first_frame_is_always_searched(): """Nothing to compare against is not evidence that nothing moved.""" w = _Worker() assert w._nothing_moved(frame(40)) is False def test_a_still_room_is_skipped(): w = _Worker() w._nothing_moved(frame(40)) # prime assert w._nothing_moved(frame(40)) is True def test_movement_is_never_skipped(): w = _Worker() w._nothing_moved(frame(40)) # a person is an enormous change next to a 1.0 threshold assert w._nothing_moved(frame(120)) is False def test_it_gives_up_and_looks_anyway(): """A change too small or too gradual for a thumbnail must still be found. The invariant is about the longest RUN of skips, not the total: what matters is the worst case a person could fall into, which is how long the camera can go without actually looking. Counting the total instead would pass a gate that skipped forty frames and then looked forty times. """ w = _Worker(motion_max_skip=5) w._nothing_moved(frame(40)) run = longest = 0 for _ in range(40): if w._nothing_moved(frame(40)): run += 1 longest = max(longest, run) else: run = 0 assert longest <= 5, f"went {longest} frames without looking, cap is 5" assert longest == 5, f"longest run was {longest} - the gate is not saving what it could" def test_a_slow_drift_cannot_creep_past_the_threshold(): """Each frame below the threshold, but the total far above it. Compared against the last frame we SEARCHED rather than the last frame we saw, so a gradual change accumulates and eventually trips the gate instead of sliding under it one frame at a time. Without that, someone easing into view slowly enough is invisible forever. """ w = _Worker(motion_max_skip=10_000) # the safety net must not rescue this w._nothing_moved(frame(40)) tripped = None for i in range(1, 30): if w._nothing_moved(frame(40 + i)) is False: tripped = i break assert tripped is not None, "a slow drift was never noticed" assert tripped <= 5, f"took {tripped} frames of drift to notice" @pytest.mark.parametrize("gate", [True, False]) def test_the_gate_is_switchable(gate): w = _Worker(motion_gate=gate) assert w.cfg.app.motion_gate is gate