Ctrl K
ComunidadGuías…

tests/test_printer_camera.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""The printer camera hub (api/printer_camera.py): guest pacing and the pull API.

Regressions this pins, all measured on the live page 2026-09-26:
  - "5fps" guests got 3.3fps: a strict `gap < interval` throttle skipped the
    frame landing at 199ms of a 200ms interval, so every other gap was 300ms.
  - a slow guest fell seconds behind: pushed MJPEG queues in socket buffers the
    hub cannot see. Guests now pull one frame per request (`next_frame`), which
    cannot queue, and share ONE server-side sample however fast they ask.
  - still laggy on an iPhone (Safari and Chrome): guests were capped at 5fps
    against the owner's 10, and one pull at a time capped a phone at
    1/(RTT + transfer). Guests now get the full rate with PULL_DEPTH pulls in
    flight; the live test below runs AS A GUEST at a phone's round trip, and
    skips (not fails) when the camera itself is delivering under the bar.
"""

import asyncio
import os
import sys

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "api"))

import printer_camera  # noqa: E402
from printer_camera import GUEST_FRAME_INTERVAL, PULL_DEPTH, _Camera  # noqa: E402

UPSTREAM_FPS = 10  # what the Centauri Carbon pushes


class _Clock:
    def __init__(self):
        self.t = 1000.0

    def __call__(self):
        return self.t


@pytest.fixture
def clock(monkeypatch):
    c = _Clock()
    monkeypatch.setattr(printer_camera, "monotonic", c)
    return c


@pytest.fixture
def cam():
    c = _Camera()
    c._ensure_running = lambda: None  # never dial a printer from a test
    return c


def _jittered_gaps(n, seed=7):
    """Upstream gaps: 100ms +-5ms, the jitter measured off the real camera."""
    import random
    rng = random.Random(seed)
    return [1 / UPSTREAM_FPS + rng.uniform(-0.005, 0.005) for _ in range(n)]


def _promoted(cam, clock, n=200):
    out = []
    for i, gap in enumerate(_jittered_gaps(n)):
        clock.t += gap
        before = cam._guest_seq
        cam._publish(b"f%d" % i)
        if cam._guest_seq != before:
            out.append(clock.t)
    return out


def test_guests_get_every_upstream_frame(cam, clock):
    """The guest sample is the printer's full rate, not a fraction of it."""
    promoted = _promoted(cam, clock)
    assert len(promoted) == 200


def test_guest_ceiling_under_jitter_skips_no_extra_frame(cam, clock, monkeypatch):
    """Were the ceiling ever to bite (interval 2x the upstream's), it must
    pass every other frame and never leave a 3-frame hole."""
    monkeypatch.setattr(printer_camera, "GUEST_FRAME_INTERVAL", 0.2)
    promoted = _promoted(cam, clock)
    gaps = [b - a for a, b in zip(promoted, promoted[1:])]
    fps = len(gaps) / (promoted[-1] - promoted[0])
    assert 4.75 <= fps <= 5.25
    assert max(gaps) < 0.25


def test_mjpeg_throttle_is_a_true_5fps_under_jitter(cam, clock):
    """The pushed body's own throttle carries the same slack."""
    async def run():
        gen = cam.frames(min_interval=0.2)
        sent = []
        first = asyncio.ensure_future(gen.__anext__())
        await asyncio.sleep(0)
        for i, gap in enumerate(_jittered_gaps(120)):
            clock.t += gap
            cam._publish(b"f%d" % i)
            await asyncio.sleep(0)
            if first is not None and first.done():
                sent.append(clock.t)
                first = asyncio.ensure_future(gen.__anext__())
                await asyncio.sleep(0)
        first.cancel()
        try:
            await first
        except (asyncio.CancelledError, StopAsyncIteration):
            pass
        return sent

    sent = asyncio.run(run())
    gaps = [b - a for a, b in zip(sent, sent[1:])]
    assert max(gaps) < 0.25
    assert len(sent) >= 55  # 12s of upstream at 5fps, less the first frame


def test_first_pull_gets_the_current_frame_at_once(cam, clock):
    cam._publish(b"jpeg-1")
    seq, frame = asyncio.run(cam.next_frame(after=0, wait=0.01))
    assert frame == b"jpeg-1" and seq == 1


def test_pull_waits_for_a_newer_frame(cam, clock):
    async def run():
        cam._publish(b"old")
        waiter = asyncio.ensure_future(cam.next_frame(after=1, wait=5))
        await asyncio.sleep(0)
        assert not waiter.done()  # nothing newer than what the caller holds
        clock.t += GUEST_FRAME_INTERVAL
        cam._publish(b"new")
        return await waiter

    assert asyncio.run(run()) == (2, b"new")


def test_pull_times_out_to_none_not_a_stale_repeat(cam, clock):
    cam._publish(b"only")
    assert asyncio.run(cam.next_frame(after=1, wait=0.01)) is None


def test_polling_fast_cannot_beat_the_guest_rate(cam, clock, monkeypatch):
    """A guest asking as fast as it likes still sees only the shared sample."""
    monkeypatch.setattr(printer_camera, "GUEST_FRAME_INTERVAL", 0.2)

    async def run():
        seen, seq = [], 0
        for i in range(40):
            clock.t += 1 / UPSTREAM_FPS
            cam._publish(b"f%d" % i)
            got = await cam.next_frame(after=seq, wait=0.001)
            if got:
                seq = got[0]
                seen.append(got[1])
        return seen

    assert len(asyncio.run(run())) == 20


def test_a_pipelined_guest_counts_once_against_the_cap(cam, clock, monkeypatch):
    monkeypatch.setattr(printer_camera, "MAX_VIEWERS", 2)

    async def run():
        one_guest = [asyncio.ensure_future(cam.next_frame(after=0, wait=5))
                     for _ in range(PULL_DEPTH)]
        await asyncio.sleep(0)
        room_for_second = cam.can_admit()
        second = asyncio.ensure_future(cam.next_frame(after=0, wait=5))
        await asyncio.sleep(0)
        full = not cam.can_admit()
        for f in one_guest + [second]:
            f.cancel()
        await asyncio.sleep(0)
        return room_for_second, full

    assert asyncio.run(run()) == (True, True)
    assert cam.can_admit()  # cancelled pulls release their slots


def test_upstream_stays_wanted_between_pulls_then_lets_go(cam, clock):
    cam._publish(b"x")
    asyncio.run(cam.next_frame(after=0, wait=0.01))
    clock.t += 1.0
    assert cam._wanted()  # the gap between two pulls must not hang up
    clock.t += printer_camera._IDLE_STOP_S
    assert not cam._wanted()


# ── the route, against the live container (conftest `client`) ─────────────
def test_frame_route_refuses_anonymous(client):
    assert client.get("/printer/frame").status_code == 401


def _printer_up(client, guest_cookie):
    r = client.get("/api/printer/health", headers=guest_cookie)
    if r.status_code != 200:
        pytest.skip("printer / home tunnel offline — no camera to measure")


def test_frame_route_serves_a_guest_one_jpeg_with_its_seq(client, guest_cookie):
    """Also proves /printer/frame is not shadowed by the owner-only
    /printer/{path} catch-all, which would bounce a guest to the admin login."""
    _printer_up(client, guest_cookie)
    r = client.get("/printer/frame?after=0", headers=guest_cookie)
    assert r.status_code == 200, r.text[:200]
    assert r.headers["content-type"] == "image/jpeg"
    assert r.content[:2] == b"\xff\xd8"
    assert int(r.headers["x-frame-seq"]) > 0
    assert r.headers["cache-control"] == "no-store"
    assert "content-encoding" not in r.headers  # a JPEG is never re-gzipped


def test_guest_on_a_phone_round_trip_gets_the_full_rate(guest_cookie):
    """What web/printer.js does, AS A GUEST, over the public edge, with a
    phone's round trip added (150ms, split either side of each request):
    PULL_DEPTH pulls in flight, each asking for the frame after the last one
    asked for. Must hold near the camera's ~10fps with no visible hole, and
    every frame must arrive fresh."""
    import threading
    import time

    import httpx
    from conftest import BASE_URL
    RTT = 0.15
    with httpx.Client(base_url=BASE_URL, timeout=15.0) as c:
        if c.get("/api/printer/health", headers=guest_cookie).status_code != 200:
            pytest.skip("printer / home tunnel offline — no camera to measure")
        lock = threading.Lock()
        state = {"asked": 0, "shown": 0}
        got, t0 = [], time.monotonic()

        def worker():
            while time.monotonic() - t0 < 5.0:
                with lock:
                    after = state["asked"]
                    if after:
                        state["asked"] += 1
                time.sleep(RTT / 2)
                r = c.get(f"/printer/frame?after={after}", headers=guest_cookie)
                time.sleep(RTT / 2)
                with lock:
                    if r.status_code != 200:
                        state["asked"] = state["shown"]
                        continue
                    seq = int(r.headers["x-frame-seq"])
                    state["asked"] = max(state["asked"], seq)
                    if seq > state["shown"]:
                        state["shown"] = seq
                        got.append((time.monotonic(), seq))

        threads = [threading.Thread(target=worker) for _ in range(PULL_DEPTH)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
    got = got[3:]  # the first pulls both take the frame already there
    span = got[-1][0] - got[0][0]
    # The guest seq advances once per frame the hub PROMOTED, so its span over
    # the run is the rate the camera actually delivered. A slow home uplink or
    # camera (measured 6.6fps for minutes on 2026-10-07) is not a regression
    # here and must not block unrelated commits: skip, don't fail.
    hub_fps = (got[-1][1] - got[0][1]) / span
    if hub_fps < 1 / GUEST_FRAME_INTERVAL * 0.8:
        pytest.skip(f"camera upstream slow ({hub_fps:.2f}fps) — nothing to measure the pull against")
    times = [t for t, _ in got]
    gaps = [b - a for a, b in zip(times, times[1:])]
    fps = len(gaps) / span
    assert fps >= 1 / GUEST_FRAME_INTERVAL * 0.8, f"{fps:.2f}fps"
    assert max(gaps) < 0.3, f"max gap {max(gaps) * 1000:.0f}ms"