Files
orisys-app/apps/studio/tests/test_display_prefs.py
T
ykcheungab 0a42a452dd 拆分 C2:显示偏好移入 DisplayPrefsController(app_window 2347→2340 行 / 净减 110 行代码)
- What: 新增 apps/studio/src/orisys_studio/display_prefs.py:把「同一个测量值可以怎么
  显示」搬进 DisplayPrefsController——单位(newton / raw,含工具栏、曲线图、重算引擎、
  数值卡四处同步)、量程与时间窗(含默认值、应用到面板、供对话框读取)、箭头显示
  (按传感器决定旋转是否可用、图例倍率、旋转 90°)、2D 视图模式切换后的重绘、以及
  rail 上的帧率读数开关。控制器自己拥有 show_fps;其余偏好仍写在 preferences 里。
  `DisplayHost` Protocol 写明它向窗口借的五件事:_persist_prefs / _refresh_status_rail /
  _latest_fps(算滤波设计用的采样率)/ _drop_render_marker + _repaint_live_frame
  (2D 视图模式变了要重建那一帧)。
  _on_plot_settings *不搬*:它要开 QDialog、还要把滤波系数交给采集 worker,那是会话与
  窗口的事,不是显示偏好;它现在只向控制器取对话框要显示的值、把结果交回去应用。
- What: 窗口侧保留过渡 shim(_set_display_unit / _on_units_changed / _on_chart_unit_toggle /
  _on_image_view_mode_changed / _on_show_fps_toggled / _apply_plot_prefs_to_panel /
  _device_sample_rate / _apply_arrow_rotation_for_sensor / _apply_arrow_legend /
  _draw_style / _on_rotate_arrows,以及 _show_fps 可读写属性),供 tests 与
  tools/ui_check.py 继续驱动窗口(其中 window._on_plot_settings 仍可被测试整体替换)。
  窗口内部连线(toolbar.units_changed、arrow_panel.view_mode_changed、
  force_plots.unit_toggle_requested、act_show_fps.toggled、act_rotate_arrows.triggered)
  改为直连控制器;_sync_menu_checks 里的箭头部分也走控制器。
- What: 新增 apps/studio/tests/test_display_prefs.py:11 个用例——单位四处同步、
  工具栏改单位时不回写工具栏(防回声)、图表单位芯片翻转、对话框值取默认值、
  应用量程写偏好与面板、采样率优先用实测值再回落存储值与默认值、不可旋转的传感器
  不提供旋转且不写偏好、可旋转的重启后记住角度、图例倍率随 draw style 变化、
  切换 2D 模式只重绘一次、帧率开关同时更新偏好与 rail。
- Why: 「数字怎么显示」是 app_window 的第四个独立变更驱动:改一个显示偏好不该碰会话、
  回放或布局。搬完之后这条规则的唯一所有者在 display_prefs 里,且可以脱离窗口单测。
- Verification: pytest shared/tests apps/studio/tests(offscreen)632 passed / 0 failed /
  15 skipped(原 621 + 新增 11);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(搬运 + 一层转发)。
- Not in this change: 标定交互(_on_calibration_apply / 身份与不匹配检查 / 导出与事件)
  仍未拆分;预览(_start_preview/_stop_preview/_on_preview_tick)留在窗口;
  _depth_range/_depth_scale_text 与 _show_image_frame 同属 2D 渲染,留在窗口;
  docs 的行数/方法数指标待全部切片落定后统一刷新。
2026-09-15 09:50:25 +08:00

281 lines
8.8 KiB
Python

"""Acceptance for the display preferences, on their own.
``DisplayPrefsController`` decides how a measurement is *presented*: newtons or raw
counts, which chart range, which arrow style, whether the rail shows the frame rate. It
drives the panels through their own interfaces and borrows a few window-level operations
through ``DisplayHost``, so both can be stood in for — which these tests do.
"""
from __future__ import annotations
from orisys_studio.display_prefs import DisplayPrefsController
PLOT_DEFAULTS = {
"window_sec": 10.0,
"fn_max": 5.0,
"shear_max": 5.0,
"fn_raw_max": 1e5,
"shear_raw_max": 1e5,
}
class _Action:
def __init__(self) -> None:
self.enabled: bool | None = None
def setEnabled(self, value: bool) -> None: # noqa: N802
self.enabled = bool(value)
class _Toolbar:
def __init__(self, units: str = "newton") -> None:
self._units = units
self.set_units_calls: list[str] = []
def units(self) -> str:
return self._units
def set_units(self, units: str) -> None:
self.set_units_calls.append(str(units))
self._units = str(units)
class _Charts:
def __init__(self, unit: str = "N") -> None:
self._unit = unit
self.fixed_newton: list[bool] = []
self.units: list[str] = []
self.window_sec: list[float] = []
self.limits: list[tuple] = []
def unit(self) -> str:
return self._unit
def set_fixed_newton(self, value: bool) -> None: # noqa: N802
self.fixed_newton.append(bool(value))
def set_units(self, unit: str) -> None:
self.units.append(str(unit))
self._unit = str(unit)
def set_window_sec(self, value: float) -> None: # noqa: N802
self.window_sec.append(float(value))
def set_axis_limits(self, *values) -> None: # noqa: N802
self.limits.append(tuple(float(v) for v in values))
class _ArrowPanel:
def __init__(self) -> None:
self.rotation_enabled: list[bool] = []
self.scales: list[float] = []
self.rotations = 0
def set_rotation_enabled(self, enabled: bool) -> None:
self.rotation_enabled.append(bool(enabled))
def set_arrow_scale(self, scale: float) -> None:
self.scales.append(float(scale))
def rotate_90_ccw(self) -> int:
self.rotations += 1
return 180
class _Engine:
def __init__(self, loaded: bool = False) -> None:
self._loaded = loaded
self.units: list[str] = []
def is_loaded(self) -> bool:
return self._loaded
def set_unit(self, unit: str) -> None:
self.units.append(str(unit))
class _Picker:
def __init__(self, sensor_type: str = "box") -> None:
self._sensor = sensor_type
def sensor_type(self) -> str:
return self._sensor
class _Host:
def __init__(self, prefs: dict, latest_fps=None) -> None:
self.prefs = dict(prefs)
self.persisted = 0
self.rail_updates = 0
self.markers = 0
self.repaints = 0
self._latest_fps = latest_fps
def _persist_prefs(self) -> None:
self.persisted += 1
def _refresh_status_rail(self) -> None:
self.rail_updates += 1
def _drop_render_marker(self) -> None:
self.markers += 1
def _repaint_live_frame(self) -> None:
self.repaints += 1
def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False, latest_fps=None):
host = _Host(prefs or {}, latest_fps=latest_fps)
toolbar = _Toolbar(units)
charts = _Charts("N" if units == "newton" else "raw")
arrows = _ArrowPanel()
engine = _Engine(loaded)
action = _Action()
controller = DisplayPrefsController(
host=host,
toolbar=toolbar,
charts=charts,
arrow_panel=arrows,
engine=engine,
sensor_picker=_Picker(sensor_type),
rotate_arrows_action=action,
plot_defaults=PLOT_DEFAULTS,
default_sample_rate_hz=30.0,
arrow_rotate_types=frozenset({"box", "mini"}),
)
return controller, {
"host": host,
"toolbar": toolbar,
"charts": charts,
"arrows": arrows,
"engine": engine,
"action": action,
}
# --------------------------------------------------------------------- tests
def test_the_unit_reaches_the_toolbar_the_charts_and_the_replay():
controller, parts = _controller(prefs={}, units="newton", loaded=True)
controller.set_display_unit("raw")
assert parts["toolbar"].set_units_calls == ["raw"]
assert parts["engine"].units == ["raw"], "an open recording was not told"
assert parts["charts"].fixed_newton == [False]
assert parts["charts"].units == ["raw"]
assert parts["host"].prefs["units"] == "raw"
assert parts["host"].persisted == 1
def test_a_unit_change_from_the_toolbar_does_not_talk_back_to_it():
"""The combo already *is* on that unit; writing it again is how a loop starts."""
controller, parts = _controller(prefs={}, units="raw")
controller.on_units_changed("newton")
assert parts["toolbar"].set_units_calls == [], parts["toolbar"].set_units_calls
assert parts["charts"].units == ["N"]
assert parts["charts"].fixed_newton == [True]
def test_the_chart_chip_flips_which_unit_the_charts_use():
controller, parts = _controller(prefs={}, units="newton")
controller.on_chart_unit_toggle()
assert parts["charts"].units == ["raw"]
controller.on_chart_unit_toggle()
assert parts["charts"].units == ["raw", "N"]
def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
controller, parts = _controller(prefs={"plot_window_sec": 3.0})
settings = controller.plot_settings()
assert settings["window_sec"] == 3.0
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
assert settings["units"] == "newton"
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
controller, parts = _controller(prefs={})
controller.apply_plot_settings(
{
"window_sec": 20.0,
"fn_max": 8.0,
"shear_max": 9.0,
"fn_raw_max": 2e5,
"shear_raw_max": 3e5,
}
)
assert parts["host"].prefs["plot_window_sec"] == 20.0
assert parts["host"].prefs["plot_shear_raw_max"] == 3e5
assert parts["charts"].window_sec == [20.0]
assert parts["charts"].limits == [(8.0, 9.0, 2e5, 3e5)]
assert parts["host"].persisted == 1
def test_the_sample_rate_prefers_the_measured_rate_over_the_stored_one():
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
assert controller.device_sample_rate() == 48.5
parts["host"]._latest_fps = None
assert controller.device_sample_rate() == 12.0
parts["host"].prefs.pop("measured_fps")
assert controller.device_sample_rate() == 30.0, "the default did not apply"
def test_rotation_is_offered_only_where_the_sensor_can_be_rotated():
controller, parts = _controller(sensor_type="finger")
controller.apply_arrow_rotation_for_sensor()
assert parts["arrows"].rotation_enabled[-1] is False
assert parts["action"].enabled is False
parts["arrows"].rotations = 0
controller.on_rotate_arrows()
assert parts["arrows"].rotations == 0, "a finger sensor was rotated anyway"
assert "arrow_rotation_deg" not in parts["host"].prefs
def test_rotating_remembers_the_angle_and_a_box_sensor_may_rotate():
controller, parts = _controller(sensor_type="box")
controller.apply_arrow_rotation_for_sensor()
assert parts["arrows"].rotation_enabled[-1] is True
assert parts["action"].enabled is True
controller.on_rotate_arrows()
assert parts["host"].prefs["arrow_rotation_deg"] == 180
assert parts["host"].persisted == 1
def test_the_arrow_legend_describes_the_configured_sensor():
controller, parts = _controller(sensor_type="finger")
controller.apply_arrow_legend()
finger_scale = parts["arrows"].scales[-1]
controller, parts = _controller(sensor_type="box")
controller.apply_arrow_legend()
box_scale = parts["arrows"].scales[-1]
assert finger_scale != box_scale, "the two draw styles share one magnification"
def test_a_new_2d_view_mode_repaints_the_panel_once():
controller, parts = _controller(prefs={})
controller.on_image_view_mode_changed("depth")
assert parts["host"].prefs["image_view_mode"] == "depth"
assert parts["host"].markers == 1, "the stale frame was kept"
assert parts["host"].repaints == 1
assert parts["host"].persisted == 1
def test_the_fps_readout_is_a_preference_like_any_other():
controller, parts = _controller(prefs={})
assert controller.show_fps is False
controller.on_show_fps_toggled(True)
assert controller.show_fps is True
assert parts["host"].prefs["show_fps"] is True
assert parts["host"].rail_updates == 1, "the rail was not told to show it"
assert parts["host"].persisted == 1