test: cover the sample camera pacing, the newest-frame drain and the frame slot
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
CI / lint (pull_request) Failing after 35s
CI / test (3.12) (pull_request) Canceled after 36s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Canceled after 21s
CI / test (3.13) (pull_request) Canceled after 31s
CI / test (3.14) (pull_request) Canceled after 31s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Canceled after 26s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Canceled after 26s
CI / test-with-coverage (pull_request) Canceled after 21s
CI / coverage-analysis (pull_request) Canceled after 0s

CI's diff-coverage gate sat at 29% because the ZMQ fixes live in code no
test reached: the subscriber's socket setup, _emit_image/notify_frame_
displayed, _recv_latest and the run() frame path, plus the main window's
frame slot. The subscriber now runs against a real inproc socket (the
drain and the in-flight cap are the fix, so a mock would prove nothing);
run() is driven with a stubbed _recv_latest that hands over one JPEG and
then stops. The busy overlay gets a check that cooling is the only blue
busy state.

The subscriber fixture takes qapp so no QImage is built before the
QApplication exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 14:56:16 +02:00
co-authored by Claude Fable 5.1
parent dbd5b9078f
commit c3e7b9a2cc
3 changed files with 143 additions and 0 deletions
+19
View File
@@ -50,3 +50,22 @@ def test_auto_centering_flag_overrides_alignment_gate():
auto_centering=True,
)
assert viewing is not None and viewing.text.startswith("Viewing mode")
def test_robot_cooling_is_the_only_blue_busy_state():
from types import SimpleNamespace
from aare.gui.styles import BUSY_BLUE, BUSY_PSI_RED
def _style(activity: str):
tell = SimpleNamespace(activity=SimpleNamespace(value=activity))
style = build_busy_overlay_style(is_busy=True, tell_state=tell) # type: ignore[arg-type]
assert style is not None
return style
assert _style("cooling").text == "ROBOT COOLING"
assert _style("cooling").badge_bg == BUSY_BLUE
# Every other busy state shares the one PSI red.
for activity in ("mounting", "unmounting", "drying", "unknown"):
assert _style(activity).badge_bg == BUSY_PSI_RED
assert _style("unknown").text == "BEAMLINE BUSY"
+31
View File
@@ -606,3 +606,34 @@ def test_nonstaff_beamline_gate_popups(qtbot, mock_ui_state):
with patch("aare.gui.main_window.QToolTip") as tip:
qtbot.mousePress(aux_bar, Qt.MouseButton.LeftButton, pos=aux_bar.tabRect(1).center())
assert tip.showText.called, "aux-puck tab click must explain the lock"
def test_sample_camera_frame_paints_visible_views_and_acks(qtbot, mock_ui_state, monkeypatch):
from PySide6.QtGui import QImage
with (
patch("requests.get"),
patch("aare.gui.main_window.DAQWorker"),
patch("aare.gui.main_window.PredictionSubscriber"),
patch("aare.gui.main_window.VideoThread"),
patch("aare.gui.main_window.JFJochDBusClient"),
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
):
mock_jwt.return_value = {"sub": "testuser", "staff": True, "pgroups": ["p123"], "session": 15}
win = _make_window(qtbot)
views = (win.sample_camera, win.compact_sample_camera, win.portrait_sample_camera)
# Nothing is shown in tests, so pretend every view is on screen to reach
# the paint branch.
monkeypatch.setattr(type(win.sample_camera), "isVisible", lambda self: True)
win.prediction_thread = MagicMock()
win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888))
assert all(v.pixmap_item.pixmap().width() == 4 for v in views)
# The ack is what paces the subscriber; it must fire after every frame.
win.prediction_thread.notify_frame_displayed.assert_called_once()
# No subscriber (GUI started without a sample feed): the slot must not blow up.
win.prediction_thread = None
win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888))
@@ -25,3 +25,96 @@ def test_json_dict_is_parsed():
def test_json_non_dict_is_rejected():
assert _parse(b"[1, 2]") is None
assert _parse(b"") is None
# --- Frame pacing and the multipart-safe conflate -------------------------
# Real socket on an inproc endpoint: the drain and the in-flight cap are the
# fix, so they run against libzmq rather than a mock.
import time
import cv2
import numpy as np
import pytest
import zmq
from PySide6.QtGui import QImage
@pytest.fixture
def sub(qapp):
s = PredictionSubscriber("inproc://samcam-test")
yield s
# run() nulls both on its own cleanup; only tear down what is left.
if s._sock is not None:
s._sock.close(0)
if s._ctx is not None:
s._ctx.term()
def test_emit_image_caps_frames_in_flight(sub):
image = QImage(4, 4, QImage.Format.Format_RGB888)
for _ in range(sub.MAX_FRAMES_IN_FLIGHT + 1):
sub._emit_image(image)
assert sub._frames_in_flight == sub.MAX_FRAMES_IN_FLIGHT
assert sub._dropped == 1
for _ in range(sub.MAX_FRAMES_IN_FLIGHT + 1):
sub.notify_frame_displayed()
assert sub._frames_in_flight == 0 # floors at zero, never negative
def test_recv_latest_keeps_only_the_newest_message(sub):
pub = sub._ctx.socket(zmq.PUB)
pub.bind("inproc://samcam-test")
try:
# Slow joiner: publish probes until the subscription has propagated.
deadline = time.monotonic() + 5
while True:
pub.send_multipart([b"probe"])
try:
sub._sock.recv_multipart(zmq.NOBLOCK)
break
except zmq.Again:
assert time.monotonic() < deadline, "subscription never propagated"
time.sleep(0.01)
while True: # flush any extra probes
try:
sub._sock.recv_multipart(zmq.NOBLOCK)
except zmq.Again:
break
sub._dropped = 0
for payload in (b"old", b"stale", b"newest"):
pub.send_multipart([b"hdr", payload])
assert sub._recv_latest() == [b"hdr", b"newest"]
assert sub._dropped == 2
finally:
pub.close(0)
def test_run_decodes_one_frame_and_reports_the_drops(sub, qtbot):
ok, jpeg = cv2.imencode(".jpg", np.zeros((4, 6, 3), dtype=np.uint8))
assert ok
frame = [b'{"encoding": "jpeg"}', jpeg.tobytes()]
calls = []
def one_frame_then_stop():
calls.append(1)
if len(calls) == 1:
return frame
sub.running = False
raise zmq.Again
sub._recv_latest = one_frame_then_stop # type: ignore[method-assign]
sub._fps_window_start = time.perf_counter() - 10 # force the fps report
sub._dropped = 3
images = []
sub.image.connect(images.append)
sub.run()
assert len(images) == 1 and images[0].width() == 6 and images[0].height() == 4
assert sub._frames_in_flight == 1
assert sub._dropped == 0 # reported alongside the fps, then reset
assert sub._camera_available
assert sub._sock is None and sub._ctx is None # run() cleaned up