"""Tests for Qt viewer backend force history and aspect-aware display helpers.""" from __future__ import annotations import sys import types from pathlib import Path from unittest.mock import MagicMock import numpy as np import pytest _REPO_ROOT = Path(__file__).resolve().parents[1] _EXAMPLES_DIR = _REPO_ROOT / "examples" if str(_EXAMPLES_DIR) not in sys.path: sys.path.insert(0, str(_EXAMPLES_DIR)) from gui.viewer2d.backend import ( # noqa: E402 QtViewerBackend, image_shape_meta, panel_width_for_aspect, scale_bgr_to_fit, select_display_image, ) from gui.sensors import ( # noqa: E402 FrameSnapshot, _snapshot_from_sensor, calibrate_normal_force, ) class FakeForceAdapter: """Minimal adapter that emits changing FN/FX/FY without a real sensor.""" def __init__(self): self._i = 0 def read(self): i = self._i self._i += 1 return FrameSnapshot( image=np.zeros((100, 200, 3), dtype=np.uint8), normal_force=1000.0 + 50.0 * i, shear_x=-200.0 + 10.0 * i, shear_y=300.0 - 5.0 * i, normal_force_calibrated=calibrate_normal_force(1000.0 + 50.0 * i), fps=30.0, timestamp=float(i) / 30.0, flow_overlay=np.zeros((100, 200, 3), dtype=np.uint8), record_frame=np.zeros((100, 200, 3), dtype=np.uint8), ) def test_backend_force_history_tracks_all_channels(): adapter = FakeForceAdapter() backend = QtViewerBackend(npoints=32, lowpass_cutoff_hz=0.0) for _ in range(8): snap = adapter.read() backend.ingest(snap, now=snap.timestamp) state = backend.plot_state() assert state is not None assert state.sample_count == 8 assert state.values.shape == (8, 3) # All three channels must change across frames (not stay flat). fn_span = float(state.values[:, 0].max() - state.values[:, 0].min()) fx_span = float(state.values[:, 1].max() - state.values[:, 1].min()) fy_span = float(state.values[:, 2].max() - state.values[:, 2].min()) assert fn_span > 100.0 assert fx_span > 20.0 assert fy_span > 10.0 # Raw FN is used for curves, not the tiny calibrated Newton scale. assert float(state.values[-1, 0]) == pytest.approx(1000.0 + 50.0 * 7) @pytest.mark.parametrize( "shape,expected_aspect", [ ((100, 200, 3), 2.0), # wide ((120, 120, 3), 1.0), # square ((200, 100, 3), 0.5), # portrait ], ) def test_backend_image_aspect_metadata(shape, expected_aspect): img = np.zeros(shape, dtype=np.uint8) h, w, aspect = image_shape_meta(img) assert (h, w) == (shape[0], shape[1]) assert aspect == pytest.approx(expected_aspect) snap = FrameSnapshot(flow_overlay=img, normal_force=1.0, shear_x=2.0, shear_y=3.0) backend = QtViewerBackend(lowpass_cutoff_hz=0.0) frame = backend.ingest(snap, now=0.0) assert frame.image_height == shape[0] assert frame.image_width == shape[1] assert frame.aspect_ratio == pytest.approx(expected_aspect) fitted = scale_bgr_to_fit(img, max_w=80, max_h=80) fh, fw = fitted.shape[:2] assert fw <= 80 and fh <= 80 assert abs((fw / fh) - expected_aspect) < 0.05 panel_w = panel_width_for_aspect(200, expected_aspect, min_size=160) assert panel_w >= 160 assert panel_w == pytest.approx(max(160, int(round(200 * expected_aspect)))) def test_select_display_image_prefers_flow_overlay(): overlay = np.full((10, 20, 3), 7, dtype=np.uint8) processed = np.full((10, 20, 3), 3, dtype=np.uint8) snap = FrameSnapshot(flow_overlay=overlay, processed_image=processed, image=processed) out = select_display_image(snap) assert np.array_equal(out, overlay) def test_snapshot_requests_hhd_every_frame_without_motion_gate(): """Regression: force refresh must not use check_motion=True gating.""" sensor = MagicMock() sensor.img = np.zeros((32, 32), dtype=np.uint8) sensor.frame = np.zeros((32, 32), dtype=np.uint8) sensor.info = types.SimpleNamespace( FPS="FPS", VRAW="VRAW", VNORMAL="VNORMAL", IMG="IMG", CONTOUR="CONTOUR", CENTROID="CENTROID", DEPTH="DEPTH", FNORMAL="FNORMAL", FSHEARX="FSHEARX", FSHEARY="FSHEARY", ) sensor.read_info.return_value = ( 30.0, np.zeros((32, 32, 2), dtype=np.float32), np.zeros((32, 32), dtype=np.float32), sensor.img, None, None, np.zeros((32, 32), dtype=np.float32), 12345.0, -100.0, 50.0, ) # Avoid depending on orisys.util.draw_arrows implementation details. import gui.sensors.adapter as sa original_util = sa.orisys.util sa.orisys.util = types.SimpleNamespace( draw_arrows=lambda *args, **kwargs: np.zeros((32, 32, 3), dtype=np.uint8) ) try: snap = _snapshot_from_sensor(sensor, raw_image=sensor.frame, processed_image=sensor.img) finally: sa.orisys.util = original_util sensor.compute_deformation.assert_called_once_with(decompose=True, check_motion=False) assert snap is not None assert snap.normal_force == pytest.approx(12345.0) assert snap.shear_x == pytest.approx(-100.0) assert snap.shear_y == pytest.approx(50.0) assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0))