409 lines
14 KiB
Python
409 lines
14 KiB
Python
"""Smoke tests for Sensor Bench SDK acquisition and UI construction."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import numpy as np
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "src"))
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
|
|
def test_load_fixed_mode() -> None:
|
|
from sensor_bench.acquisition import load_fixed_mode
|
|
|
|
mode = load_fixed_mode(ROOT / "tests" / "fixtures" / "ddjx01_min.json")
|
|
assert mode.width == 1920
|
|
assert mode.height == 1080
|
|
assert mode.fps == 30.0
|
|
assert mode.stitch_width == 365
|
|
assert "1920x1080" in mode.label()
|
|
|
|
|
|
def test_translator_chinese_default() -> None:
|
|
from sensor_bench.i18n import DEFAULT_LOCALE, Translator
|
|
from sensor_bench.paths import default_locales_dir
|
|
|
|
assert DEFAULT_LOCALE == "zh_CN"
|
|
tr = Translator(default_locales_dir())
|
|
assert tr.locale == "zh_CN"
|
|
assert "传感器" in tr.t("app.title") or "展示" in tr.t("exhibition.title")
|
|
tr.set_locale("en_US")
|
|
assert tr.t("app.title") == "Sensor Bench"
|
|
assert "Exhibition" in tr.t("exhibition.title")
|
|
# Public locales must not ship internal inspection workflow copy.
|
|
assert "workflow" not in (tr._catalogs.get("en_US") or {})
|
|
assert tr._lookup("en_US", "workflow.title") is None
|
|
|
|
|
|
def test_read_forces_from_sensor() -> None:
|
|
from sensor_bench.acquisition import read_forces_from_sensor, sensor_force_calibrated
|
|
|
|
class RawSensor:
|
|
info = SimpleNamespace(
|
|
FNORMAL=1,
|
|
FSHEARX=2,
|
|
FSHEARY=3,
|
|
FNORMAL_RAW=4,
|
|
FSHEARX_RAW=5,
|
|
FSHEARY_RAW=6,
|
|
VRAW=7,
|
|
)
|
|
_force_cal_sensor_name = "box"
|
|
|
|
def read_info(self, *args):
|
|
return 1.1, 2.2, 3.3, 4.4, 5.5, 6.6, None
|
|
|
|
forces = read_forces_from_sensor(RawSensor())
|
|
assert sensor_force_calibrated(RawSensor())
|
|
assert forces.force_calibrated is True
|
|
assert forces.force_unit == "N"
|
|
assert forces.normal_force == 1.1
|
|
assert forces.normal_force_raw == 4.4
|
|
|
|
class LegacySensor:
|
|
info = SimpleNamespace(FNORMAL=1, FSHEARX=2, FSHEARY=3, VRAW=4)
|
|
|
|
def read_info(self, *args):
|
|
if len(args) == 4:
|
|
return 10.0, 20.0, 30.0, None
|
|
return 10.0, 20.0, 30.0
|
|
|
|
legacy = read_forces_from_sensor(LegacySensor())
|
|
assert legacy.force_calibrated is False
|
|
assert legacy.force_unit == "raw"
|
|
assert legacy.normal_force_raw == 10.0
|
|
|
|
|
|
def test_sdk_worker_packet_mapping() -> None:
|
|
from sensor_bench.acquisition import SdkAcquisitionWorker
|
|
|
|
raw = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
|
processed = np.full((365, 365, 3), 40, dtype=np.uint8)
|
|
|
|
class FakeSensor:
|
|
def __init__(self, *args, **kwargs):
|
|
self.frame = raw.copy()
|
|
self.info = SimpleNamespace(FNORMAL=1, FSHEARX=2, FSHEARY=3)
|
|
|
|
def get_img(self):
|
|
self.frame = raw.copy()
|
|
return processed.copy()
|
|
|
|
def compute_deformation(self, **kwargs):
|
|
return True
|
|
|
|
def compute_contact(self):
|
|
return True
|
|
|
|
def read_info(self, *args):
|
|
return 1.0, 2.0, 3.0
|
|
|
|
def configure(self, **kwargs):
|
|
return self
|
|
|
|
def disconnect(self):
|
|
pass
|
|
|
|
fake_orisys = MagicMock()
|
|
fake_orisys.Sensor = FakeSensor
|
|
fake_orisys.__version__ = "test"
|
|
|
|
worker = SdkAcquisitionWorker()
|
|
with patch.dict(sys.modules, {"orisys": fake_orisys}):
|
|
with patch("sensor_bench.acquisition.orisys_importable", return_value=(True, "test")):
|
|
mode = worker.start(
|
|
0,
|
|
config_path=ROOT / "tests" / "fixtures" / "ddjx01_min.json",
|
|
backend="cpu",
|
|
)
|
|
assert mode.width == 1920
|
|
deadline = time.time() + 2.0
|
|
packet = None
|
|
while time.time() < deadline:
|
|
packet = worker.latest()
|
|
if packet is not None:
|
|
break
|
|
time.sleep(0.02)
|
|
worker.stop()
|
|
|
|
assert packet is not None
|
|
assert packet.width == 1920
|
|
assert packet.height == 1080
|
|
assert packet.processed_width == 365
|
|
assert packet.processed_height == 365
|
|
assert packet.normal_force == 1.0
|
|
assert packet.shear_x == 2.0
|
|
assert packet.shear_y == 3.0
|
|
assert packet.normal_force_raw == 1.0
|
|
assert packet.force_unit == "raw"
|
|
assert packet.force_calibrated is False
|
|
assert packet.raw_frame.shape[:2] == (1080, 1920)
|
|
assert packet.processed_frame.shape[:2] == (365, 365)
|
|
|
|
|
|
def test_force_calibration_and_plot_min_span() -> None:
|
|
from sensor_bench.force_calibration import (
|
|
NORMAL_FORCE_CAL_OFFSET,
|
|
Y_AXIS_MIN_SPAN,
|
|
calibrate_normal_force,
|
|
)
|
|
from sensor_bench.plot_history import MetricHistory
|
|
|
|
assert abs(calibrate_normal_force(0.0) - NORMAL_FORCE_CAL_OFFSET) < 1e-12
|
|
hist = MetricHistory(
|
|
npoints=8,
|
|
channels=3,
|
|
window_sec=1.0,
|
|
sample_rate_hz=30.0,
|
|
lowpass_cutoff_hz=0.0,
|
|
y_axis_min_span=Y_AXIS_MIN_SPAN,
|
|
y_range_alpha=0.5,
|
|
)
|
|
for i in range(5):
|
|
hist.push(float(i) / 30.0, 100.0, 100.0, 100.0)
|
|
state = hist.state()
|
|
assert state is not None
|
|
for ymin, ymax in state.y_ranges:
|
|
assert (ymax - ymin) >= Y_AXIS_MIN_SPAN * 0.99
|
|
|
|
|
|
def test_list_orisys_configs_excludes_options() -> None:
|
|
from sensor_bench.paths import (
|
|
config_display_name,
|
|
list_local_sensor_bundle_names,
|
|
list_local_sensor_config_files,
|
|
list_orisys_configs,
|
|
local_sensor_configs_dir,
|
|
)
|
|
|
|
entries = list_orisys_configs()
|
|
labels = [config_display_name(e).lower() for e in entries]
|
|
assert "options" not in labels
|
|
assert all(Path(e).stem.lower() != "options" for e in entries if Path(e).suffix.lower() == ".json")
|
|
|
|
bundles = list_local_sensor_bundle_names()
|
|
if bundles:
|
|
assert labels[0] in bundles
|
|
|
|
local_ddjx = local_sensor_configs_dir() / "ddjx01.json"
|
|
if local_ddjx.exists():
|
|
local_files = list_local_sensor_config_files()
|
|
assert any(p.name == "ddjx01.json" for p in local_files)
|
|
if "box" not in bundles:
|
|
assert any(config_display_name(e).lower() == "ddjx01" for e in entries)
|
|
else:
|
|
assert not any(config_display_name(e).lower() == "ddjx01" for e in entries)
|
|
|
|
|
|
def test_default_config_prefers_box_bundle() -> None:
|
|
from sensor_bench.paths import (
|
|
config_display_name,
|
|
default_config_name,
|
|
default_ddjx01_config,
|
|
list_local_sensor_bundle_names,
|
|
)
|
|
|
|
bundles = list_local_sensor_bundle_names()
|
|
if not bundles:
|
|
return
|
|
|
|
assert default_config_name("orisys") == "box"
|
|
default = default_ddjx01_config()
|
|
assert config_display_name(default).lower() == "box"
|
|
|
|
|
|
def test_legacy_config_force_calibration_fallback() -> None:
|
|
from sensor_bench.acquisition import sensor_force_calibrated
|
|
from sensor_bench.paths import (
|
|
bundle_has_force_calibration,
|
|
local_sensor_configs_dir,
|
|
resolve_force_bundle_from_config,
|
|
)
|
|
|
|
if not bundle_has_force_calibration("box"):
|
|
return
|
|
|
|
class LegacySensor:
|
|
pass
|
|
|
|
legacy_path = local_sensor_configs_dir() / "ddjx01.json"
|
|
if legacy_path.exists():
|
|
assert resolve_force_bundle_from_config(legacy_path) == "box"
|
|
assert sensor_force_calibrated(LegacySensor(), config_ref=legacy_path) is True
|
|
|
|
assert resolve_force_bundle_from_config("ddjx01") == "box"
|
|
assert resolve_force_bundle_from_config("prototype") == "box"
|
|
assert sensor_force_calibrated(LegacySensor(), config_ref="ddjx01") is True
|
|
|
|
|
|
def test_normalize_sensor_config_ref_prefers_bundle() -> None:
|
|
from sensor_bench.paths import (
|
|
bundle_has_force_calibration,
|
|
local_sensor_bundle_sensor_json,
|
|
normalize_sensor_config_ref,
|
|
)
|
|
|
|
if not bundle_has_force_calibration("box"):
|
|
return
|
|
|
|
local_json = local_sensor_bundle_sensor_json("box")
|
|
assert local_json is not None
|
|
normalized = normalize_sensor_config_ref("ddjx01")
|
|
assert Path(normalized).resolve() == local_json.resolve()
|
|
assert normalize_sensor_config_ref("box") == str(local_json.resolve())
|
|
|
|
|
|
def test_prefer_local_sensor_bundles_for_force_calibration() -> None:
|
|
import orisys
|
|
|
|
from sensor_bench.paths import app_root, prefer_local_sensor_bundles
|
|
|
|
root = app_root()
|
|
local_box = (root / "sensors" / "box").resolve()
|
|
local_cal = local_box / "force_calibration.json"
|
|
if not local_box.is_dir() or not local_cal.is_file():
|
|
return
|
|
|
|
preferred = prefer_local_sensor_bundles(root)
|
|
assert preferred == (root / "sensors").resolve()
|
|
|
|
# Second call must stay idempotent (no stacked wrappers).
|
|
assert prefer_local_sensor_bundles(root) == preferred
|
|
roots = orisys.configs._candidate_sensor_roots()
|
|
assert Path(roots[0]).resolve() == preferred
|
|
assert sum(1 for r in roots if Path(r).resolve() == preferred) == 1
|
|
|
|
bundle = orisys.configs.resolve_sensor_bundle("box")
|
|
assert bundle is not None
|
|
assert bundle.root == local_box
|
|
assert bundle.force_calibration_json is not None
|
|
assert bundle.force_calibration_json.resolve() == local_cal.resolve()
|
|
|
|
|
|
def test_roi_geometry_helpers() -> None:
|
|
from sensor_bench.roi_geometry import build_roi_warp, order_quad_points, warp_roi_to_rectangle
|
|
import numpy as np
|
|
|
|
points = np.array([[10, 10], [100, 12], [98, 90], [12, 88]], dtype=np.float32)
|
|
ordered = order_quad_points(points)
|
|
assert ordered.shape == (4, 2)
|
|
matrix, src, size = build_roi_warp(points, (120, 100), (120, 100), (64, 64))
|
|
assert size == (64, 64)
|
|
assert src.shape == (4, 2)
|
|
frame = np.zeros((100, 120, 3), dtype=np.uint8)
|
|
out = warp_roi_to_rectangle(frame, matrix, size)
|
|
assert out.shape == (64, 64, 3)
|
|
|
|
|
|
def test_exhibition_window_offscreen() -> None:
|
|
from PyQt5.QtWidgets import QApplication
|
|
from sensor_bench.exhibition_app import ExhibitionWindow
|
|
from sensor_bench.i18n import DEFAULT_LOCALE
|
|
|
|
app = QApplication.instance() or QApplication([])
|
|
win = ExhibitionWindow()
|
|
assert win.tr.locale == DEFAULT_LOCALE == "zh_CN"
|
|
assert "展示" in win.windowTitle() or "Sensor Bench" in win.windowTitle()
|
|
assert win.btn_start is not None
|
|
assert win.btn_stop is not None
|
|
assert win.btn_reset is not None
|
|
assert win.btn_record is not None
|
|
assert win.btn_search is not None
|
|
assert win.btn_camera is not None
|
|
assert not hasattr(win, "btn_cal")
|
|
assert not hasattr(win, "cal_edit")
|
|
assert win.btn_reset.text() == "重置"
|
|
assert win.image_label is not None
|
|
assert win.log_widget is not None
|
|
assert not win.show_log_check.isChecked()
|
|
assert win.log_widget.isHidden()
|
|
assert win.show_log_check.text() == "显示日志面板"
|
|
win.show_log_check.setChecked(True)
|
|
assert win._log_panel_visible
|
|
assert not win.log_widget.isHidden()
|
|
win.show_log_check.setChecked(False)
|
|
assert win.log_widget.isHidden()
|
|
assert not win._log_panel_visible
|
|
assert len(win.plots) == 3
|
|
assert len(win.curves) == 3
|
|
assert win.btn_start.text() == "启动"
|
|
assert not win.btn_stop.isEnabled()
|
|
assert win._view_mode == "pure_color"
|
|
assert win.view_combo.currentData() == "pure_color"
|
|
assert win.about_action is not None
|
|
assert win.help_menu is not None
|
|
assert win.sensor_combo.count() == 2
|
|
assert win.config_combo is not None
|
|
assert win.config_combo.count() >= 1
|
|
from sensor_bench.paths import config_display_name, default_ddjx01_config, list_orisys_configs
|
|
|
|
installed = list_orisys_configs()
|
|
default = default_ddjx01_config()
|
|
if config_display_name(default).lower() == "box":
|
|
assert config_display_name(win._selected_config()).lower() == "box"
|
|
elif "ddjx01" in installed:
|
|
assert win.config_combo.currentData() == "ddjx01" or win._selected_config() == "ddjx01"
|
|
|
|
win.language_combo.setCurrentIndex(1)
|
|
assert win.tr.locale == "en_US"
|
|
assert "Exhibition" in win.windowTitle()
|
|
assert win.btn_start.text() == "Start"
|
|
assert win.btn_reset.text() == "Reset"
|
|
assert win.show_log_check.text() == "Show log panel"
|
|
assert "About" in win.about_action.text()
|
|
title0 = win.plots[0].titleLabel.text
|
|
assert "FN" in title0
|
|
assert "raw" in title0
|
|
|
|
from sensor_bench.force_calibration import (
|
|
CALIBRATED_NORMAL_FORCE_Y_MAX,
|
|
CALIBRATED_NORMAL_FORCE_Y_MIN,
|
|
CALIBRATED_SHEAR_FORCE_Y_MAX,
|
|
CALIBRATED_SHEAR_FORCE_Y_MIN,
|
|
)
|
|
|
|
win._force_calibrated = True
|
|
win.force_history.push(time.time(), 1.0, -2.0, 3.0)
|
|
win._update_plots()
|
|
fn_ymin, fn_ymax = win.plots[0].viewRange()[1]
|
|
assert abs(fn_ymin - CALIBRATED_NORMAL_FORCE_Y_MIN) < 1e-6
|
|
assert abs(fn_ymax - CALIBRATED_NORMAL_FORCE_Y_MAX) < 1e-6
|
|
fx_ymin, fx_ymax = win.plots[1].viewRange()[1]
|
|
assert abs(fx_ymin - CALIBRATED_SHEAR_FORCE_Y_MIN) < 1e-6
|
|
assert abs(fx_ymax - CALIBRATED_SHEAR_FORCE_Y_MAX) < 1e-6
|
|
axis = win.plots[0].getAxis("left")
|
|
assert "20" in axis.tickStrings([20.0], 1.0, 20.0)
|
|
|
|
win.view_combo.setCurrentIndex(1)
|
|
assert win._view_mode == "captured"
|
|
win.sensor_combo.setCurrentIndex(1)
|
|
assert win._sensor_type == "dd06"
|
|
selected = str(win._selected_config()).lower()
|
|
assert "dd06" in selected or selected.endswith(".json")
|
|
win.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_load_fixed_mode()
|
|
test_translator_chinese_default()
|
|
test_read_forces_from_sensor()
|
|
test_sdk_worker_packet_mapping()
|
|
test_force_calibration_and_plot_min_span()
|
|
test_list_orisys_configs_excludes_options()
|
|
test_default_config_prefers_box_bundle()
|
|
test_legacy_config_force_calibration_fallback()
|
|
test_normalize_sensor_config_ref_prefers_bundle()
|
|
test_prefer_local_sensor_bundles_for_force_calibration()
|
|
test_roi_geometry_helpers()
|
|
test_exhibition_window_offscreen()
|
|
print("ok")
|