Files
orisys-app/apps/studio/tests/test_view_layout.py
T
ykcheungab aecde67c73 拆分 C1:面板布局与视图预设移入 ViewLayoutController(app_window 2499→2347 行)
- What: 新增 apps/studio/src/orisys_studio/view_layout.py:把「一个视图是什么」从
  app_window 搬进 ViewLayoutController——面板可见性与最小宽度、splitter 尺寸与 stretch、
  三个预设(live / replay / calibration)的切换、视图菜单三个勾选项的状态、3D 面板的
  开与关(含它看哪个传感器、恢复持久化的显示设置)、设置抽屉的展开状态。
  控制器自己拥有 view_preset / show_cal / show_finger_3d 三个状态(初值直接取自
  preferences,窗口不再留第二份),并直接驱动面板;`LayoutHost` Protocol 写明它向窗口
  借的四件事:_persist_prefs / _update_run_enabled / _refresh_status_rail /
  _current_source_kind(以及 3D 的 _device_geometry_provider)。
  预设切换同时通知 ReplayController(暂停 transport、把曲线图交还实时会话),
  这是 B 之后两个控制器之间唯一的方向性依赖,写在构造参数里而不是隐式查找。
- What: 窗口侧保留过渡 shim(_view_preset / _show_cal / _show_finger_3d 可读写属性,
  _apply_view_preset / _apply_panel_visibility / _shows_3d / _splitter_sizes_for /
  _set_settings_expanded / _sync_settings_action / _sync_3d_source / _restore_3d_settings /
  view_preset()),供 tests 与 tools/ui_check.py 继续驱动窗口;窗口内部调用点
  (brand_bar 导航、mesh3d_panel.enabledChanged、toolbar.settings_toggled、
  _build_menus 里的三个 QAction、_on_sensor_type_changed、_sync_menu_checks)改为直连
  控制器。_sync_menu_checks 只保留主题/语言两项(那是窗口的状态),视图三项交给
  layout_ctl.sync_view_checks()。
- What: 新增 apps/studio/tests/test_view_layout.py:10 个用例,用假面板/假 splitter/
  假 host 直接测布局规则——预设决定面板组合与 rail 角色、未知预设回落 custom、离开回放
  视图会停 transport 并清掉重算标签、3D 面板需要 finger + 开关且隐藏时不建 GL 视图、
  预设不给 3D 留宽度时从最宽面板取且总量不变、标定面板开关即视图开关、3D 开关不自我
  驱动(no-op 不写偏好)、菜单勾选跟随屏幕且 blockSignals、设置抽屉三处同步、
  3D 面板拿到正确的传感器与密钥。
- Why: 「家具」是 app_window 的第三个独立变更驱动。搬完之后「哪个视图显示哪些面板」
  只有一处规则,窗口不再同时持有布局状态与面板。布局与回放两个控制器的依赖是显式的
  单向调用。
- Verification: pytest shared/tests apps/studio/tests(offscreen)621 passed / 0 failed /
  15 skipped(原 611 + 新增 10);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(搬运 + 一层转发)。
- Not in this change: C2 显示偏好(单位/量程/箭头/2D 模式/fps)与标定交互仍未拆分;
  预览(_start_preview/_stop_preview/_on_preview_tick)留在窗口,它既不是会话也不是布局;
  docs 的行数/方法数指标待全部切片落定后统一刷新。
2026-09-15 09:45:05 +08:00

371 lines
12 KiB
Python

"""Acceptance for the panel layout, on its own.
``ViewLayoutController`` owns what a *view* is: which panels are on screen, in what
arrangement, and which preset is active. It drives the panels through their own small
interfaces and borrows a handful of window-level operations through ``LayoutHost``, so
both can be stood in for — which is the point of the split, and what these tests use.
"""
from __future__ import annotations
import pytest
from orisys_studio.view_layout import ViewLayoutController
VIEW_PRESETS = ("custom", "live", "replay", "calibration")
VIEW_SHOWS_3D = ("custom", "live", "replay")
VIEW_SPLITTER_SIZES = {"live": (0, 360, 1080, 0), "replay": (0, 360, 1080, 0), "calibration": (0, 0, 560, 880)}
DEFAULT_SPLITTER_SIZES = (300, 360, 680, 0)
MIN_PANEL_WIDTHS = {"mesh3d": 260, "arrow": 300, "plots": 340, "cal": 300}
class _Action:
def __init__(self, checked: bool = False) -> None:
self.checked = bool(checked)
self.enabled: bool | None = None
self.blocked = 0
self.toggled = _Signal()
def isChecked(self) -> bool: # noqa: N802
return self.checked
def setChecked(self, value: bool) -> None: # noqa: N802
self.checked = bool(value)
def setEnabled(self, value: bool) -> None: # noqa: N802
self.enabled = bool(value)
def blockSignals(self, flag: bool) -> None: # noqa: N802
self.blocked += 1
class _Signal:
def __init__(self) -> None:
self.slots: list = []
def connect(self, slot) -> None:
self.slots.append(slot)
class _Panel:
"""A panel as the layout treats it: visibility, a minimum width, a stretch."""
def __init__(self) -> None:
self.visible: bool | None = None
self.minimum_width: int | None = None
self.views = 0
def setVisible(self, visible: bool) -> None: # noqa: N802
self.visible = bool(visible)
def setMinimumWidth(self, width: int) -> None: # noqa: N802
self.minimum_width = int(width)
def ensure_view(self):
self.views += 1
def set_enabled(self, enabled: bool) -> None:
self.enabled = bool(enabled)
def set_source_kind(self, kind: str) -> None:
self.kind = str(kind)
class _Splitter:
def __init__(self, widgets) -> None:
self._widgets = list(widgets)
self.stretches: list[tuple[int, int]] = []
self.sizes: list = []
def count(self) -> int:
return len(self._widgets)
def widget(self, index: int):
return self._widgets[index]
def setStretchFactor(self, index: int, value: int) -> None: # noqa: N802
self.stretches.append((index, value))
def setSizes(self, sizes) -> None: # noqa: N802
self.sizes = list(sizes)
class _Picker:
def __init__(self, sensor_type: str = "box") -> None:
self._sensor = sensor_type
def sensor_type(self) -> str:
return self._sensor
class _Toolbar:
def __init__(self, *, expanded: bool = False) -> None:
self._expanded = expanded
self.source_views: list[str] = []
self.run_roles: list[str] = []
self.reset_roles: list[str] = []
def set_source_view(self, view: str) -> None:
self.source_views.append(view)
def set_run_role(self, role: str) -> None:
self.run_roles.append(role)
def set_reset_role(self, role: str) -> None:
self.reset_roles.append(role)
def settings_expanded(self) -> bool:
return self._expanded
def set_settings_expanded(self, expanded: bool) -> None:
self._expanded = bool(expanded)
class _BrandBar:
def __init__(self) -> None:
self.active: list = []
def set_active_view(self, preset) -> None:
self.active.append(preset)
class _Replay:
def __init__(self) -> None:
self.paused = 0
self.sources: list[bool] = []
self.recomputed: list[bool] = []
self.refreshed = 0
self.synced = 0
def pause_playback(self) -> None:
self.paused += 1
def apply_source(self, active: bool) -> None:
self.sources.append(bool(active))
def on_recompute_toggled(self, checked: bool) -> None:
self.recomputed.append(bool(checked))
def refresh_state(self) -> None:
self.refreshed += 1
def sync_actions(self) -> None:
self.synced += 1
class _Host:
def __init__(self, prefs: dict) -> None:
self.prefs = dict(prefs)
self.persisted = 0
self.run_updates = 0
self.rail_updates = 0
self.source_kind = "live"
def _persist_prefs(self) -> None:
self.persisted += 1
def _update_run_enabled(self) -> None:
self.run_updates += 1
def _refresh_status_rail(self) -> None:
self.rail_updates += 1
def _current_source_kind(self) -> str:
return self.source_kind
def _device_geometry_provider(self):
return None
def _controller(*, prefs=None, sensor_type="box", settings_expanded=False):
prefs = dict(prefs or {})
host = _Host(prefs)
mesh, image, charts, cal = _Panel(), _Panel(), _Panel(), _Panel()
splitter = _Splitter([mesh, image, charts, cal])
toolbar = _Toolbar(expanded=settings_expanded)
brand = _BrandBar()
replay = _Replay()
controller = ViewLayoutController(
host=host,
splitter=splitter,
mesh_panel=mesh,
image_panel=image,
charts=charts,
cal_panel=cal,
sensor_picker=_Picker(sensor_type),
brand_bar=brand,
toolbar=toolbar,
replay=replay,
presets=VIEW_PRESETS,
shows_3d_in=VIEW_SHOWS_3D,
splitter_sizes=VIEW_SPLITTER_SIZES,
default_splitter_sizes=DEFAULT_SPLITTER_SIZES,
min_panel_widths=MIN_PANEL_WIDTHS,
default_3d_pane_width=340,
)
actions = {
"cal_panel": _Action(),
"finger_3d": _Action(),
"settings": _Action(),
}
controller.bind_actions(**actions)
parts = {
"host": host,
"mesh": mesh,
"image": image,
"charts": charts,
"cal": cal,
"splitter": splitter,
"toolbar": toolbar,
"brand": brand,
"replay": replay,
"actions": actions,
}
return controller, parts
# --------------------------------------------------------------------- tests
def test_a_preset_decides_which_panels_a_view_shows():
controller, parts = _controller(prefs={"view_preset": "live"})
controller.apply_preset("calibration")
assert controller.view_preset == "calibration"
assert parts["cal"].visible is True
assert parts["image"].visible is False, "the 2D panel does not belong to calibration"
assert parts["charts"].visible is True
assert parts["host"].prefs["view_preset"] == "calibration"
assert parts["toolbar"].source_views[-1] == "live"
assert parts["toolbar"].reset_roles[-1] == "reset"
assert parts["host"].persisted == 1
controller.apply_preset("replay")
assert parts["image"].visible is True
assert parts["toolbar"].source_views[-1] == "replay"
assert parts["toolbar"].run_roles[-1] == "playback"
assert parts["toolbar"].reset_roles[-1] == "stop"
# The charts follow the recording in this view, and the replay's own entries are
# re-synced with it. (Calibration did neither of those: it has no replay source.)
assert parts["replay"].sources == [True]
assert parts["replay"].refreshed == 1
assert parts["replay"].synced == 2
assert parts["brand"].active[-1] == "replay"
def test_an_unknown_preset_is_a_custom_layout_not_a_crash():
controller, parts = _controller()
controller.apply_preset("nonsense")
assert controller.view_preset == "custom"
assert parts["host"].prefs["view_preset"] == "custom"
assert parts["brand"].active[-1] is None
def test_leaving_the_replay_view_stops_the_transport_and_drops_the_pass_label():
controller, parts = _controller(prefs={"view_preset": "replay"})
controller.apply_preset("live")
assert parts["replay"].paused == 1, "the transport kept running across a view switch"
assert parts["replay"].sources[-1] is False, "the charts still read the recording"
assert parts["replay"].recomputed == [False], "the pass label survived the view"
def test_the_3d_pane_needs_a_finger_sensor_and_its_own_switch():
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="box")
assert not controller.shows_3d()
# A finger sensor alone is not enough either: the switch has to be on.
controller._picker = _Picker("finger")
controller.show_finger_3d = False
assert not controller.shows_3d()
controller.show_finger_3d = True
assert controller.shows_3d()
# ...and the calibration view never shows it.
controller.apply_preset("calibration")
assert not controller.shows_3d()
assert parts["mesh"].visible is False
assert parts["mesh"].views == 0, "a hidden pane must not build a GL view"
def test_a_preset_that_gives_no_width_to_the_3d_pane_takes_it_from_the_widest():
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger")
controller.show_finger_3d = True
sizes = controller.splitter_sizes_for("live")
assert sizes[0] == 340, sizes
assert sum(sizes) == sum(VIEW_SPLITTER_SIZES["live"]), "the layout changed width"
# Without the pane the table is used as it stands.
controller.show_finger_3d = False
assert controller.splitter_sizes_for("live") == list(VIEW_SPLITTER_SIZES["live"])
def test_the_calibration_panel_toggle_is_the_calibration_view_switch():
controller, parts = _controller(prefs={"view_preset": "live"})
controller.on_cal_panel_toggled(True)
assert controller.view_preset == "calibration"
assert controller.show_cal is True
controller.on_cal_panel_toggled(False)
assert controller.view_preset == "live"
assert controller.show_cal is False
assert parts["host"].prefs["show_cal_panel"] is False
assert parts["brand"].active[-1] == "live"
assert controller.show_cal is False
def test_the_3d_pane_switch_does_not_drive_itself():
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger")
controller.show_finger_3d = True
before = len(parts["mesh"].__dict__)
# The pane is already on: the toggle says so again and nothing is written.
controller.on_3d_pane_toggled(True)
assert "show_finger_3d" not in parts["host"].prefs, (
"a no-op toggle still persisted a preference"
)
controller.on_3d_pane_toggled(False)
assert controller.show_finger_3d is False
assert parts["host"].prefs["show_finger_3d"] is False
assert parts["mesh"].visible is False
assert len(parts["mesh"].__dict__) >= before # sanity: the panel was reused
def test_the_view_menu_entries_follow_what_is_on_screen():
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="box")
controller.sync_view_checks()
assert parts["actions"]["finger_3d"].enabled is False, "a box sensor has no scene"
assert parts["actions"]["finger_3d"].checked is False
assert parts["actions"]["cal_panel"].checked is False
controller.on_cal_panel_toggled(True)
assert parts["actions"]["cal_panel"].checked is True
assert parts["actions"]["cal_panel"].blocked > 0, (
"the check was set without blocking its own signal"
)
def test_the_settings_drawer_keeps_the_menu_entry_in_step():
controller, parts = _controller(prefs={"view_preset": "live"})
controller.set_settings_expanded(True)
assert parts["toolbar"].settings_expanded() is True
assert parts["actions"]["settings"].checked is True
assert parts["host"].prefs["settings_expanded"] is True
assert parts["host"].persisted == 1
controller.set_settings_expanded(False, persist=False)
assert parts["actions"]["settings"].checked is False
assert parts["host"].persisted == 1, "persist=False still wrote the preferences"
def test_the_3d_pane_gets_the_keys_and_the_sensor_it_should_read():
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger")
seen = {}
parts["mesh"].set_keys = lambda keys: seen.setdefault("keys", keys)
parts["mesh"].set_sensor = lambda *a, **k: seen.setdefault("sensor", (a, k))
controller.sync_3d_source()
assert seen["sensor"][0] == ("finger",), seen["sensor"]
assert "keys" in seen